diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 59feb36d..fc9d1f7b 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -96,6 +96,8 @@ function renderMessagesWithDateHeaders( imagePaywalled={item.message.imagePaywalled} audioUrl={item.message.audioUrl} isFromAI={item.message.isFromAI} + locked={item.message.locked} + lockReason={item.message.lockReason} lockedPrivate={item.message.lockedPrivate} privateMessageHint={item.message.privateMessageHint} isUnlockingPrivate={ diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 554e34a5..1f3d29fe 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -21,6 +21,8 @@ export interface MessageBubbleProps { imagePaywalled?: boolean; audioUrl?: string | null; isFromAI: boolean; + locked?: boolean | null; + lockReason?: string | null; lockedPrivate?: boolean | null; privateMessageHint?: string | null; isUnlockingPrivate?: boolean; @@ -35,6 +37,8 @@ export function MessageBubble({ imagePaywalled, audioUrl, isFromAI, + locked, + lockReason, lockedPrivate, privateMessageHint, isUnlockingPrivate, @@ -55,6 +59,8 @@ export function MessageBubble({ imagePaywalled={imagePaywalled} audioUrl={audioUrl} isFromAI={true} + locked={locked} + lockReason={lockReason} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} isUnlockingPrivate={isUnlockingPrivate} @@ -76,6 +82,8 @@ export function MessageBubble({ imagePaywalled={imagePaywalled} audioUrl={audioUrl} isFromAI={false} + locked={locked} + lockReason={lockReason} lockedPrivate={lockedPrivate} privateMessageHint={privateMessageHint} isUnlockingPrivate={isUnlockingPrivate} diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index f4778ecf..d3fecb49 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -1,12 +1,4 @@ "use client"; -/** - * MessageContent 消息内容容器 - * - * 决定渲染 ImageBubble 还是 TextBubble: - * - 有 imageUrl:渲染 ImageBubble(图片) - * - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字) - * - 两者都有:图片在上,文字在下 - */ import { ImageBubble } from "./image-bubble"; import { PrivateMessageCard } from "./private-message-card"; import { TextBubble } from "./text-bubble"; @@ -19,6 +11,8 @@ export interface MessageContentProps { imagePaywalled?: boolean; audioUrl?: string | null; isFromAI: boolean; + locked?: boolean | null; + lockReason?: string | null; lockedPrivate?: boolean | null; privateMessageHint?: string | null; isUnlockingPrivate?: boolean; @@ -35,6 +29,8 @@ export function MessageContent({ imagePaywalled, audioUrl, isFromAI, + locked, + lockReason, lockedPrivate, privateMessageHint, isUnlockingPrivate = false, @@ -44,7 +40,10 @@ export function MessageContent({ const hasImage = imageUrl != null && imageUrl.length > 0; const hasAudio = audioUrl != null && audioUrl.length > 0; const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER; - const isLockedPrivateMessage = lockedPrivate === true; + const isLockedPrivateMessage = + lockedPrivate === true || + (locked === true && lockReason === "private_message"); + const isLockedVoiceMessage = locked === true && lockReason === "voice_message"; return (
onUnlockPrivateMessage?.(messageId) + : undefined + } /> ) : null} {hasText && !hasAudio ? ( diff --git a/src/app/chat/components/voice-bubble.module.css b/src/app/chat/components/voice-bubble.module.css index 590c8061..c2415cb2 100644 --- a/src/app/chat/components/voice-bubble.module.css +++ b/src/app/chat/components/voice-bubble.module.css @@ -24,6 +24,14 @@ color: #ffffff; } +.locked { + border: 1px solid rgba(246, 87, 160, 0.2); + background: + linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)), + #ffffff; + color: #3c3b3b; +} + .playButton { display: inline-flex; flex: 0 0 auto; @@ -43,6 +51,11 @@ color: #ffffff; } +.locked .playButton { + background: linear-gradient(135deg, #ff8fc7 0%, #f657a0 100%); + color: #ffffff; +} + .playButton:disabled { cursor: not-allowed; opacity: 0.6; @@ -106,3 +119,33 @@ .bubbleUser .error { color: #ffffff; } + +.hint { + margin: 0; + color: #3c3b3b; + font-size: 14px; + line-height: 1.4; +} + +.unlockButton { + align-self: flex-start; + margin-top: 2px; + padding: 9px 14px; + border: 0; + border-radius: 999px; + background: linear-gradient(90deg, #ff67e0, #ff52a2); + color: #ffffff; + cursor: pointer; + font-size: 14px; + font-weight: 700; +} + +.unlockButton:disabled { + cursor: not-allowed; + opacity: 0.65; +} + +.unlockButton:focus-visible { + outline: 2px solid #f657a0; + outline-offset: 3px; +} diff --git a/src/app/chat/components/voice-bubble.tsx b/src/app/chat/components/voice-bubble.tsx index 0b4133f3..fa71a0d0 100644 --- a/src/app/chat/components/voice-bubble.tsx +++ b/src/app/chat/components/voice-bubble.tsx @@ -1,6 +1,6 @@ "use client"; -import { Pause, Play } from "lucide-react"; +import { LockKeyhole, Pause, Play } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import styles from "./voice-bubble.module.css"; @@ -8,11 +8,19 @@ import styles from "./voice-bubble.module.css"; export interface VoiceBubbleProps { audioUrl: string; isFromAI: boolean; + locked?: boolean; + hint?: string | null; + isUnlocking?: boolean; + onUnlock?: () => void; } export function VoiceBubble({ audioUrl, isFromAI, + locked = false, + hint, + isUnlocking = false, + onUnlock, }: VoiceBubbleProps) { const audioRef = useRef(null); const [isPlaying, setIsPlaying] = useState(false); @@ -43,6 +51,8 @@ export function VoiceBubble({ }, []); const handleToggle = () => { + if (locked) return; + const audio = audioRef.current; if (!audio || hasError) return; @@ -61,16 +71,22 @@ export function VoiceBubble({
-
); } diff --git a/src/data/dto/chat/ui_message.ts b/src/data/dto/chat/ui_message.ts index 9820ba11..e9670ff5 100644 --- a/src/data/dto/chat/ui_message.ts +++ b/src/data/dto/chat/ui_message.ts @@ -19,6 +19,8 @@ export const UiMessageSchema = z.object({ imagePaywalled: z.boolean().optional(), /** 语音 URL */ audioUrl: z.string().optional(), + locked: z.boolean().nullable().optional(), + lockReason: z.string().nullable().optional(), isPrivate: z.boolean().nullable().optional(), lockedPrivate: z.boolean().nullable().optional(), privateMessageHint: z.string().nullable().optional(), diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 2ec44609..4250e532 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -114,17 +114,18 @@ function deriveUiLockFields( | undefined, imageUrl?: string | null, ): Partial { - const isPrivateMessage = - lockDetail?.reason === "private_message" || - lockDetail?.reason === "voice_message"; + const lockReason = lockDetail?.reason ?? null; + const isPrivateMessage = lockReason === "private_message"; + const isVoiceMessage = lockReason === "voice_message"; return { ...(imageUrl ? { imageUrl } : {}), ...(imageUrl && lockDetail?.showUpgrade ? { imagePaywalled: true } : {}), + ...(lockDetail ? { locked: lockDetail.locked, lockReason } : {}), ...(isPrivateMessage ? { isPrivate: true } : {}), ...(isPrivateMessage && lockDetail?.locked ? { lockedPrivate: true } : {}), - ...(isPrivateMessage && lockDetail?.hint + ...((isPrivateMessage || isVoiceMessage) && lockDetail?.hint ? { privateMessageHint: lockDetail.hint } : {}), }; diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 0467dbd0..a9a08a23 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -323,6 +323,7 @@ export const chatMachine = setup({ ? { ...message, content: response.content ?? message.content, + locked: false, lockedPrivate: false, privateMessageHint: null, isPrivate: message.isPrivate ?? true,