From ebd44c49804d24132da9d8e4f32cdf553eef5d88 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 13:21:57 +0800 Subject: [PATCH] feat(chat): add private message unlock flow --- env-example/.env.local.example | 4 +- src/app/chat/chat-screen.tsx | 40 ++++-- src/app/chat/components/chat-area.tsx | 29 +++- src/app/chat/components/index.ts | 1 + src/app/chat/components/message-bubble.tsx | 32 ++++- src/app/chat/components/message-content.tsx | 36 ++++- .../private-message-card.module.css | 57 ++++++++ .../chat/components/private-message-card.tsx | 38 ++++++ src/data/dto/chat/ui_message.ts | 4 + src/data/repositories/chat_repository.ts | 39 ++++++ .../interfaces/ichat_repository.ts | 6 + .../schemas/chat/unlock_private_response.ts | 8 +- src/data/storage/chat/local_chat_db.ts | 3 + src/data/storage/chat/local_message.ts | 12 ++ src/stores/chat/chat-context.tsx | 2 + src/stores/chat/chat-events.ts | 1 + src/stores/chat/chat-machine.actors.ts | 35 +++++ src/stores/chat/chat-machine.helpers.ts | 9 ++ src/stores/chat/chat-machine.ts | 124 ++++++++++++++++++ src/stores/chat/chat-state.ts | 4 +- 20 files changed, 455 insertions(+), 29 deletions(-) create mode 100644 src/app/chat/components/private-message-card.module.css create mode 100644 src/app/chat/components/private-message-card.tsx diff --git a/env-example/.env.local.example b/env-example/.env.local.example index 869256ad..9a7f6e48 100644 --- a/env-example/.env.local.example +++ b/env-example/.env.local.example @@ -3,8 +3,8 @@ NEXT_PUBLIC_APP_ENV=test # NextAuth v4 —— OAuth callback **公**网 base URL(**必**须与**公**网域名**一**致) NEXTAUTH_URL=https://frontend-test.banlv-ai.com NEXTAUTH_URL_INTERNAL=http://localhost:3000 -NEXT_PUBLIC_API_BASE_URL=https://api.cozsweet.com -NEXT_PUBLIC_WS_BASE_URL=wss://api.cozsweet.com/ws +NEXT_PUBLIC_API_BASE_URL=https://testapi.banlv-ai.com +NEXT_PUBLIC_WS_BASE_URL=wss://testapi.banlv-ai.com/ws NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index a1064c40..703a31bd 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -8,7 +8,7 @@ import type { LoginStatus } from "@/data/dto/auth"; import { AppStorage } from "@/data/storage/app/app_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { UserStorage } from "@/data/storage/user/user_storage"; -import { useChatState } from "@/stores/chat/chat-context"; +import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { AppEnvUtil, BrowserDetector, todayString, UrlLauncherUtil } from "@/utils"; import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; @@ -35,6 +35,7 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean { export function ChatScreen() { const state = useChatState(); + const chatDispatch = useChatDispatch(); const authState = useAuthState(); const [showExternalBrowserDialog, setShowExternalBrowserDialog] = useState(false); @@ -48,8 +49,16 @@ export function ChatScreen() { state.paywallReason === "daily_limit"; const showPhotoPaywallBanner = state.paywallTriggered && state.paywallReason === "photo_paywall"; - const showExhaustedBanner = - showDailyLimitBanner || showPhotoPaywallBanner; + const showPrivatePaywallBanner = + state.paywallTriggered && state.paywallReason === "private_paywall"; + const showInlineUpgradeBanner = + showPhotoPaywallBanner || showPrivatePaywallBanner; + const inlineUpgradeTitle = showPrivatePaywallBanner + ? "Unlock VIP to view private messages" + : "Unlock VIP to view Elio's photos"; + const inlineUpgradeCta = showPrivatePaywallBanner + ? "Activate VIP to view private messages" + : "Activate VIP to view photos"; const externalBrowserPromptShownRef = useRef(false); @@ -122,6 +131,10 @@ export function ChatScreen() { UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash); } + function handleUnlockPrivateMessage(messageId: string): void { + chatDispatch({ type: "ChatUnlockPrivateMessage", messageId }); + } + return (
@@ -144,20 +157,19 @@ export function ChatScreen() { messages={state.messages} isReplyingAI={state.isReplyingAI} isGuest={isGuest} + unlockingPrivateMessageId={state.unlockingPrivateMessageId} + onUnlockPrivateMessage={handleUnlockPrivateMessage} /> - {showExhaustedBanner ? ( + {showInlineUpgradeBanner ? ( + + ) : null} + + {showDailyLimitBanner ? ( ) : ( diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 9d8a9f51..417a3671 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -28,9 +28,16 @@ export interface ChatAreaProps { messages: readonly UiMessage[]; isReplyingAI: boolean; isGuest: boolean; + unlockingPrivateMessageId?: string | null; + onUnlockPrivateMessage?: (messageId: string) => void; } -export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) { +export function ChatArea({ + messages, + isReplyingAI, + unlockingPrivateMessageId, + onUnlockPrivateMessage, +}: ChatAreaProps) { const scrollRef = useRef(null); const prevLengthRef = useRef(messages.length); @@ -49,7 +56,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) {
- {renderMessagesWithDateHeaders(messages)} + {renderMessagesWithDateHeaders( + messages, + unlockingPrivateMessageId, + onUnlockPrivateMessage, + )} {isReplyingAI && }
@@ -57,7 +68,11 @@ export function ChatArea({ messages, isReplyingAI }: ChatAreaProps) { } /** 渲染消息列表(按日期分组插入分隔条) */ -function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) { +function renderMessagesWithDateHeaders( + messages: readonly UiMessage[], + unlockingPrivateMessageId?: string | null, + onUnlockPrivateMessage?: (messageId: string) => void, +) { const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; messages.forEach((m, i) => { @@ -73,9 +88,17 @@ function renderMessagesWithDateHeaders(messages: readonly UiMessage[]) { ) : ( ), ); diff --git a/src/app/chat/components/index.ts b/src/app/chat/components/index.ts index c522a0b5..56e09e75 100644 --- a/src/app/chat/components/index.ts +++ b/src/app/chat/components/index.ts @@ -22,5 +22,6 @@ export * from "./message-bubble"; export * from "./message-content"; export * from "./pwa-install-dialog"; export * from "./pwa-install-overlay"; +export * from "./private-message-card"; export * from "./text-bubble"; export * from "./user-message-avatar"; diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 77297ce1..3c6c10e7 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -15,12 +15,26 @@ import { MessageContent } from "./message-content"; import styles from "./chat-area.module.css"; export interface MessageBubbleProps { + id?: string; content: string; imageUrl?: string | null; isFromAI: boolean; + privateLocked?: boolean | null; + privateHint?: string | null; + isUnlockingPrivate?: boolean; + onUnlockPrivateMessage?: (messageId: string) => void; } -export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProps) { +export function MessageBubble({ + id, + content, + imageUrl, + isFromAI, + privateLocked, + privateHint, + isUnlockingPrivate, + onUnlockPrivateMessage, +}: MessageBubbleProps) { const { avatarUrl } = useUserState(); if (isFromAI) { @@ -29,9 +43,14 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp
{/* 占位:保持与头像宽度一致 */}
@@ -41,7 +60,16 @@ export function MessageBubble({ content, imageUrl, isFromAI }: MessageBubbleProp return (
{/* 占位 */} - +
diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index e55671b6..76064fb8 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -10,19 +10,35 @@ * - 两者都有:图片在上,文字在下 */ import { ImageBubble } from "./image-bubble"; +import { PrivateMessageCard } from "./private-message-card"; import { TextBubble } from "./text-bubble"; export interface MessageContentProps { + messageId?: string; content: string; imageUrl?: string | null; isFromAI: boolean; + privateLocked?: boolean | null; + privateHint?: string | null; + isUnlockingPrivate?: boolean; + onUnlockPrivateMessage?: (messageId: string) => void; } const IMAGE_PLACEHOLDER = "[图片]"; -export function MessageContent({ content, imageUrl, isFromAI }: MessageContentProps) { +export function MessageContent({ + messageId, + content, + imageUrl, + isFromAI, + privateLocked, + privateHint, + isUnlockingPrivate = false, + onUnlockPrivateMessage, +}: MessageContentProps) { const hasImage = imageUrl != null && imageUrl.length > 0; const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER; + const isLockedPrivateMessage = privateLocked === true; return (
- {hasImage && imageUrl && } - {hasText && } + {isLockedPrivateMessage ? ( + onUnlockPrivateMessage?.(messageId) + : undefined + } + /> + ) : ( + <> + {hasImage && imageUrl && } + {hasText && } + + )}
); } diff --git a/src/app/chat/components/private-message-card.module.css b/src/app/chat/components/private-message-card.module.css new file mode 100644 index 00000000..66821132 --- /dev/null +++ b/src/app/chat/components/private-message-card.module.css @@ -0,0 +1,57 @@ +.card { + max-width: min(280px, 100%); + padding: 14px; + border: 1px solid rgba(246, 87, 160, 0.2); + border-radius: 18px; + background: + linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)), + #ffffff; + box-shadow: 0 4px 12px rgba(246, 87, 160, 0.12); + color: #3c3b3b; +} + +.iconWrap { + width: 42px; + height: 42px; + border-radius: 50%; + background: linear-gradient(135deg, #ff8fc7 0%, #f657a0 100%); + color: #ffffff; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 10px; +} + +.icon { + display: block; +} + +.hint { + margin: 0; + color: #3c3b3b; + font-size: 14px; + line-height: 1.4; +} + +.button { + width: 100%; + margin-top: 12px; + padding: 10px 12px; + border: 0; + border-radius: 999px; + background: linear-gradient(90deg, #ff67e0, #ff52a2); + color: #ffffff; + cursor: pointer; + font-size: 14px; + font-weight: 700; +} + +.button:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.button:focus-visible { + outline: 2px solid #f657a0; + outline-offset: 3px; +} diff --git a/src/app/chat/components/private-message-card.tsx b/src/app/chat/components/private-message-card.tsx new file mode 100644 index 00000000..7c7deda1 --- /dev/null +++ b/src/app/chat/components/private-message-card.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { LockKeyhole } from "lucide-react"; + +import styles from "./private-message-card.module.css"; + +export interface PrivateMessageCardProps { + hint?: string | null; + isUnlocking?: boolean; + onUnlock?: () => void; +} + +export function PrivateMessageCard({ + hint, + isUnlocking = false, + onUnlock, +}: PrivateMessageCardProps) { + return ( +
+ +

+ {hint && hint.length > 0 + ? hint + : "Elio has a private message for you."} +

+ +
+ ); +} diff --git a/src/data/dto/chat/ui_message.ts b/src/data/dto/chat/ui_message.ts index fa154322..a0eac429 100644 --- a/src/data/dto/chat/ui_message.ts +++ b/src/data/dto/chat/ui_message.ts @@ -8,6 +8,7 @@ import { z } from "zod"; export const UiMessageSchema = z.object({ + id: z.string().optional(), content: z.string(), isFromAI: z.boolean(), /** 日期分隔条使用的本地日期(YYYY-MM-DD) */ @@ -16,6 +17,9 @@ export const UiMessageSchema = z.object({ imageUrl: z.string().optional(), /** 语音 URL */ voiceUrl: z.string().optional(), + isPrivate: z.boolean().nullable().optional(), + privateLocked: z.boolean().nullable().optional(), + privateHint: z.string().nullable().optional(), }); export type UiMessage = z.infer; diff --git a/src/data/repositories/chat_repository.ts b/src/data/repositories/chat_repository.ts index 8b42c8be..76fe0f5e 100644 --- a/src/data/repositories/chat_repository.ts +++ b/src/data/repositories/chat_repository.ts @@ -68,6 +68,39 @@ export class ChatRepository implements IChatRepository { ); } + /** 把本地缓存中的私密消息标记为已解锁。 */ + async markPrivateMessageUnlockedInLocal( + messageId: string, + content: string, + ): Promise> { + return Result.wrap(async () => { + const localResult = await this.getLocalMessages(); + if (Result.isErr(localResult)) { + throw localResult.error; + } + + let changed = false; + const updatedMessages = localResult.data.map((message) => { + if (message.id !== messageId) return message; + changed = true; + return ChatMessage.from({ + ...message.toJson(), + content, + isPrivate: message.isPrivate ?? true, + privateLocked: false, + privateHint: null, + }); + }); + + if (!changed) return; + + const saveResult = await this.saveMessagesToLocal(updatedMessages); + if (Result.isErr(saveResult)) { + throw saveResult.error; + } + }); + } + // ============ 本地(Dexie)操作 ============ /** 把一条消息写入本地存储。 */ @@ -125,6 +158,9 @@ export class ChatRepository implements IChatRepository { content: message.content, createdAt: message.createdAt, imageUrl: message.imageUrl, + isPrivate: message.isPrivate, + privateLocked: message.privateLocked, + privateHint: message.privateHint, sessionId: "", }); } @@ -137,6 +173,9 @@ export class ChatRepository implements IChatRepository { content: local.content, createdAt: local.createdAt, imageUrl: local.imageUrl, + isPrivate: local.isPrivate, + privateLocked: local.privateLocked, + privateHint: local.privateHint, }); } diff --git a/src/data/repositories/interfaces/ichat_repository.ts b/src/data/repositories/interfaces/ichat_repository.ts index 92b5dd43..f19ba0ec 100644 --- a/src/data/repositories/interfaces/ichat_repository.ts +++ b/src/data/repositories/interfaces/ichat_repository.ts @@ -32,6 +32,12 @@ export interface IChatRepository { /** 解锁私密消息。 */ unlockPrivateMessage(messageId: string): Promise>; + /** 把本地缓存中的私密消息标记为已解锁。 */ + markPrivateMessageUnlockedInLocal( + messageId: string, + content: string, + ): Promise>; + /** 把一条消息写入本地存储。 */ saveMessageToLocal(message: ChatMessage): Promise>; diff --git a/src/data/schemas/chat/unlock_private_response.ts b/src/data/schemas/chat/unlock_private_response.ts index 378d7936..e9f8e63b 100644 --- a/src/data/schemas/chat/unlock_private_response.ts +++ b/src/data/schemas/chat/unlock_private_response.ts @@ -13,10 +13,10 @@ export const UnlockPrivateReasonSchema = z.enum([ export const UnlockPrivateResponseSchema = z.object({ unlocked: z.boolean(), content: z.string().nullable(), - showUpgrade: z.boolean(), - paywallTriggered: z.boolean(), - privateFreeLimit: z.number(), - privateUsedToday: z.number(), + showUpgrade: z.boolean().default(false), + paywallTriggered: z.boolean().default(false), + privateFreeLimit: z.number().default(0), + privateUsedToday: z.number().default(0), reason: UnlockPrivateReasonSchema, }); diff --git a/src/data/storage/chat/local_chat_db.ts b/src/data/storage/chat/local_chat_db.ts index 3b78ced5..f4a245af 100644 --- a/src/data/storage/chat/local_chat_db.ts +++ b/src/data/storage/chat/local_chat_db.ts @@ -22,6 +22,9 @@ export interface LocalMessageRow { content: string; createdAt: string; imageUrl?: string | null; + isPrivate?: boolean | null; + privateLocked?: boolean | null; + privateHint?: string | null; sessionId: string; } diff --git a/src/data/storage/chat/local_message.ts b/src/data/storage/chat/local_message.ts index d5b0dc04..de18ea4a 100644 --- a/src/data/storage/chat/local_message.ts +++ b/src/data/storage/chat/local_message.ts @@ -23,6 +23,9 @@ export const LocalMessageSchema = z.object({ content: z.string(), createdAt: z.string().default(""), imageUrl: z.string().nullable().default(null), + isPrivate: z.boolean().nullable().default(null), + privateLocked: z.boolean().nullable().default(null), + privateHint: z.string().nullable().default(null), sessionId: z.string().default(""), }); @@ -35,6 +38,9 @@ export class LocalMessage { declare readonly content: string; declare readonly createdAt: string; declare readonly imageUrl: string | null; + declare readonly isPrivate: boolean | null; + declare readonly privateLocked: boolean | null; + declare readonly privateHint: string | null; declare readonly sessionId: string; private constructor(input: LocalMessageInput) { @@ -63,6 +69,9 @@ export class LocalMessage { content: this.content, createdAt: this.createdAt, imageUrl: this.imageUrl, + isPrivate: this.isPrivate, + privateLocked: this.privateLocked, + privateHint: this.privateHint, sessionId: this.sessionId, }; } @@ -75,6 +84,9 @@ export class LocalMessage { content: row.content, createdAt: row.createdAt, imageUrl: row.imageUrl ?? null, + isPrivate: row.isPrivate ?? null, + privateLocked: row.privateLocked ?? null, + privateHint: row.privateHint ?? null, sessionId: row.sessionId, }); } diff --git a/src/stores/chat/chat-context.tsx b/src/stores/chat/chat-context.tsx index 0d8aa4e8..a7c4ac75 100644 --- a/src/stores/chat/chat-context.tsx +++ b/src/stores/chat/chat-context.tsx @@ -24,6 +24,7 @@ interface ChatState { paywallTriggered: boolean; paywallReason: MachineContext["paywallReason"]; paywallDetail: MachineContext["paywallDetail"]; + unlockingPrivateMessageId: MachineContext["unlockingPrivateMessageId"]; isLoadingMore: boolean; hasMore: boolean; historyOffset: number; @@ -49,6 +50,7 @@ export function ChatProvider({ children }: ChatProviderProps) { paywallTriggered: state.context.paywallTriggered, paywallReason: state.context.paywallReason, paywallDetail: state.context.paywallDetail, + unlockingPrivateMessageId: state.context.unlockingPrivateMessageId, isLoadingMore: state.context.isLoadingMore, hasMore: state.context.hasMore, historyOffset: state.context.historyOffset, diff --git a/src/stores/chat/chat-events.ts b/src/stores/chat/chat-events.ts index 6977abd3..ec877663 100644 --- a/src/stores/chat/chat-events.ts +++ b/src/stores/chat/chat-events.ts @@ -25,6 +25,7 @@ export type ChatEvent = // 业务事件 | { type: "ChatSendMessage"; content: string } | { type: "ChatSendImage"; imageBase64: string } + | { type: "ChatUnlockPrivateMessage"; messageId: string } | { type: "ChatLoadMoreHistory" } | { type: "ChatImageReceived"; imageUrl: string } | { diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index 4676a14c..2d9a20d8 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -105,6 +105,41 @@ export const loadMoreHistoryActor = fromPromise< }; }); +export const unlockPrivateMessageActor = fromPromise< + { + messageId: string; + response: import("@/data/dto/chat").UnlockPrivateResponse; + }, + { messageId: string } +>(async ({ input }) => { + const result = await chatRepo.unlockPrivateMessage(input.messageId); + if (Result.isErr(result)) { + log.error("[chat-machine] unlockPrivateMessageActor failed", { + messageId: input.messageId, + error: result.error, + }); + throw result.error; + } + + if (result.data.unlocked && result.data.content != null) { + const localResult = await chatRepo.markPrivateMessageUnlockedInLocal( + input.messageId, + result.data.content, + ); + if (Result.isErr(localResult)) { + log.error("[chat-machine] unlockPrivateMessageActor local sync failed", { + messageId: input.messageId, + error: localResult.error, + }); + } + } + + return { + messageId: input.messageId, + response: result.data, + }; +}); + // ============================================================ // WebSocket: long-lived callback // ============================================================ diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 1a73dfca..1f31b054 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -49,17 +49,25 @@ export const chatRepo: IChatRepository = chatRepository; */ export function localMessagesToUi( records: ReadonlyArray<{ + id?: string; content: string; role: string; createdAt: string; imageUrl?: string | null; + isPrivate?: boolean | null; + privateLocked?: boolean | null; + privateHint?: string | null; }>, ): UiMessage[] { return records.map((m) => ({ + ...(m.id ? { id: m.id } : {}), content: m.content, isFromAI: m.role === "assistant", date: messageDateFromCreatedAt(m.createdAt), ...(m.imageUrl ? { imageUrl: m.imageUrl } : {}), + ...(m.isPrivate != null ? { isPrivate: m.isPrivate } : {}), + ...(m.privateLocked != null ? { privateLocked: m.privateLocked } : {}), + ...(m.privateHint != null ? { privateHint: m.privateHint } : {}), })); } @@ -76,6 +84,7 @@ function messageDateFromCreatedAt(createdAt: string): string { */ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { return { + ...(response.messageId ? { id: response.messageId } : {}), content: response.reply, isFromAI: true, date: todayString(new Date(response.timestamp)), diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index a95f9964..bbdc67c2 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -47,6 +47,7 @@ import { sendMessageWsActor, loadMoreHistoryActor, chatWebSocketActor, + unlockPrivateMessageActor, } from "./chat-machine.actors"; const log = new Logger("StoresChatChatMachine"); @@ -70,6 +71,7 @@ export const chatMachine = setup({ sendMessageWs: sendMessageWsActor, loadMoreHistory: loadMoreHistoryActor, chatWebSocket: chatWebSocketActor, + unlockPrivateMessage: unlockPrivateMessageActor, }, actions: { startGuestSession: assign(() => ({ @@ -276,6 +278,65 @@ export const chatMachine = setup({ }; }), + setUnlockingPrivateMessage: assign(({ event }) => { + if (event.type !== "ChatUnlockPrivateMessage") return {}; + return { + unlockingPrivateMessageId: event.messageId, + paywallTriggered: false, + paywallReason: null, + paywallDetail: null, + }; + }), + + applyUnlockPrivateOutput: assign(({ context, event }) => { + if (!("output" in event)) return {}; + const output = event.output as { + messageId: string; + response: import("@/data/dto/chat").UnlockPrivateResponse; + }; + const { messageId, response } = output; + + if (response.unlocked && response.content != null) { + return { + messages: context.messages.map((message) => + message.id === messageId + ? { + ...message, + content: response.content ?? message.content, + privateLocked: false, + privateHint: null, + isPrivate: message.isPrivate ?? true, + } + : message, + ), + unlockingPrivateMessageId: null, + paywallTriggered: false, + paywallReason: null, + paywallDetail: null, + }; + } + + if (response.showUpgrade) { + return { + unlockingPrivateMessageId: null, + paywallTriggered: true, + paywallReason: "private_paywall", + paywallDetail: { + usedToday: response.privateUsedToday, + limit: response.privateFreeLimit, + }, + }; + } + + return { + unlockingPrivateMessageId: null, + }; + }), + + clearUnlockingPrivateMessage: assign({ + unlockingPrivateMessageId: null, + }), + setWsConnected: assign({ wsConnected: true }), clearWsConnected: assign({ wsConnected: false }), }, @@ -356,6 +417,10 @@ export const chatMachine = setup({ actions: "appendGuestUserImage", target: "sending", }, + ChatUnlockPrivateMessage: { + actions: "setUnlockingPrivateMessage", + target: "unlockingPrivate", + }, // 删除 ChatLoadMoreHistory / WS handlers —— 游客无服务端 history,也不连接 WS }, }, @@ -377,6 +442,23 @@ export const chatMachine = setup({ }, }, }, + unlockingPrivate: { + invoke: { + src: "unlockPrivateMessage", + input: ({ event }) => ({ + messageId: + event.type === "ChatUnlockPrivateMessage" ? event.messageId : "", + }), + onDone: { + target: "ready", + actions: "applyUnlockPrivateOutput", + }, + onError: { + target: "ready", + actions: "clearUnlockingPrivateMessage", + }, + }, + }, }, }, @@ -437,6 +519,10 @@ export const chatMachine = setup({ ChatLoadMoreHistory: { target: "loadingMore", }, + ChatUnlockPrivateMessage: { + actions: "setUnlockingPrivateMessage", + target: "unlockingPrivate", + }, }, }, sendingViaHttp: { @@ -476,6 +562,23 @@ export const chatMachine = setup({ }, }, }, + unlockingPrivate: { + invoke: { + src: "unlockPrivateMessage", + input: ({ event }) => ({ + messageId: + event.type === "ChatUnlockPrivateMessage" ? event.messageId : "", + }), + onDone: { + target: "ready", + actions: "applyUnlockPrivateOutput", + }, + onError: { + target: "ready", + actions: "clearUnlockingPrivateMessage", + }, + }, + }, }, }, @@ -577,6 +680,10 @@ export const chatMachine = setup({ ChatLoadMoreHistory: { target: "loadingMore", }, + ChatUnlockPrivateMessage: { + actions: "setUnlockingPrivateMessage", + target: "unlockingPrivate", + }, // 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected // 已上提到 vipUserSession.on,子级不再声明 }, @@ -651,6 +758,23 @@ export const chatMachine = setup({ }, }, }, + unlockingPrivate: { + invoke: { + src: "unlockPrivateMessage", + input: ({ event }) => ({ + messageId: + event.type === "ChatUnlockPrivateMessage" ? event.messageId : "", + }), + onDone: { + target: "ready", + actions: "applyUnlockPrivateOutput", + }, + onError: { + target: "ready", + actions: "clearUnlockingPrivateMessage", + }, + }, + }, }, }, }, diff --git a/src/stores/chat/chat-state.ts b/src/stores/chat/chat-state.ts index b579ac8f..12e2df89 100644 --- a/src/stores/chat/chat-state.ts +++ b/src/stores/chat/chat-state.ts @@ -19,8 +19,9 @@ export interface ChatState { */ wsConnected: boolean; paywallTriggered: boolean; - paywallReason: "daily_limit" | "photo_paywall" | null; + paywallReason: "daily_limit" | "photo_paywall" | "private_paywall" | null; paywallDetail: { usedToday: number; limit: number } | null; + unlockingPrivateMessageId: string | null; isLoadingMore: boolean; hasMore: boolean; historyOffset: number; @@ -38,6 +39,7 @@ export const initialState: ChatState = { paywallTriggered: false, paywallReason: null, paywallDetail: null, + unlockingPrivateMessageId: null, isLoadingMore: false, hasMore: true, historyOffset: 0,