From 25b786bcad14159e7d78b1ee764d29e7634df3e2 Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 18:35:15 +0800 Subject: [PATCH 1/7] fix(chat): omit websocket token in development --- src/core/net/chat-websocket.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 30a591b0..81ced9b0 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -15,6 +15,7 @@ * - onError(errorMessage) */ import { getApiConfig } from "@/core/net/config/api_config"; +import { AppEnvUtil } from "@/utils"; export interface SentencePayload { index: number; @@ -64,7 +65,7 @@ export class ChatWebSocket { if (this.disposed) return; if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return; try { - const url = `${this.serverUrl}?token=${encodeURIComponent(this.token)}`; + const url = buildChatWebSocketUrl(this.serverUrl, this.token); this.ws = new WebSocket(url); this.ws.onopen = () => { // 连接成功;userId 由后端在第一条消息中下发 @@ -173,6 +174,11 @@ export class ChatWebSocket { } } +function buildChatWebSocketUrl(serverUrl: string, token: string): string { + if (AppEnvUtil.isDevelopment()) return serverUrl; + return `${serverUrl}?token=${encodeURIComponent(token)}`; +} + /** 默认创建实例:从 env 读取 WS URL。 */ export function createChatWebSocket(token: string): ChatWebSocket { const base = getApiConfig().wsUrl; From 6c25a24440ea13b9333d9794e04932fb7551569e Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 23 Jun 2026 18:58:40 +0800 Subject: [PATCH 2/7] feat(config): add new image hosting configuration for picui --- next.config.ts | 4 ++++ src/core/net/chat-websocket.ts | 6 +----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/next.config.ts b/next.config.ts index c4ede2aa..2863d1fd 100644 --- a/next.config.ts +++ b/next.config.ts @@ -32,6 +32,10 @@ const nextConfig: NextConfig = { hostname: "**.supabase.co", pathname: "/storage/v1/object/public/**", }, + { + protocol: "https", + hostname: "free.picui.cn", + }, ], }, }; diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 81ced9b0..5c78af72 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -1,10 +1,6 @@ /** * 聊天 WebSocket 处理器 - * - * 原始 Dart: lib/core/net/websocket_handler.dart - * - * 浏览器方案:使用原生 `WebSocket` API,封装连接管理 + 重连 + 事件回调。 - * + * * 事件: * - onConnected(userId) * - onTyping(isTyping) From bfbf7ee91ab81a6200d5d7eddae1bf232a38ceb4 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 24 Jun 2026 10:02:22 +0800 Subject: [PATCH 3/7] feat(app): improve payment and paywall UX --- .../back-button.module.css} | 4 +- src/app/_components/back-button.tsx | 28 ++++++++ .../core/auth-back-button.module.css | 22 ------ src/app/_components/core/auth-back-button.tsx | 59 --------------- src/app/_components/core/index.ts | 1 - src/app/_components/index.ts | 5 ++ src/app/auth/components/auth-back-button.tsx | 22 ------ src/app/auth/components/auth-panel.tsx | 4 +- src/app/auth/components/index.ts | 1 - src/app/chat/chat-screen.tsx | 21 +++--- src/app/chat/components/chat-area.tsx | 6 ++ .../fullscreen-image-viewer.module.css | 65 +++++++++++++++++ .../components/fullscreen-image-viewer.tsx | 50 ++++++++++++- src/app/chat/components/image-bubble.tsx | 17 ++++- src/app/chat/components/message-bubble.tsx | 8 +++ src/app/chat/components/message-content.tsx | 12 +++- src/app/subscription/components/index.ts | 1 + ...cription-payment-success-dialog.module.css | 72 +++++++++++++++++++ .../subscription-payment-success-dialog.tsx | 44 ++++++++++++ src/app/subscription/subscription-screen.tsx | 20 +++++- src/data/dto/chat/ui_message.ts | 2 + src/stores/chat/chat-machine.helpers.ts | 7 +- src/tokens/dimensions.css | 2 +- 23 files changed, 344 insertions(+), 129 deletions(-) rename src/app/{auth/components/auth-back-button.module.css => _components/back-button.module.css} (87%) create mode 100644 src/app/_components/back-button.tsx delete mode 100644 src/app/_components/core/auth-back-button.module.css delete mode 100644 src/app/_components/core/auth-back-button.tsx create mode 100644 src/app/_components/index.ts delete mode 100644 src/app/auth/components/auth-back-button.tsx create mode 100644 src/app/subscription/components/subscription-payment-success-dialog.module.css create mode 100644 src/app/subscription/components/subscription-payment-success-dialog.tsx diff --git a/src/app/auth/components/auth-back-button.module.css b/src/app/_components/back-button.module.css similarity index 87% rename from src/app/auth/components/auth-back-button.module.css rename to src/app/_components/back-button.module.css index f29ef8c0..839eea03 100644 --- a/src/app/auth/components/auth-back-button.module.css +++ b/src/app/_components/back-button.module.css @@ -6,8 +6,8 @@ display: inline-flex; align-items: center; justify-content: center; - width: var(--auth-back-button-size); - height: var(--auth-back-button-size); + width: var(--back-button-size); + height: var(--back-button-size); padding: 0 2px 0 0; color: var(--color-auth-text-primary); cursor: pointer; diff --git a/src/app/_components/back-button.tsx b/src/app/_components/back-button.tsx new file mode 100644 index 00000000..1870a416 --- /dev/null +++ b/src/app/_components/back-button.tsx @@ -0,0 +1,28 @@ +"use client"; + +import { ChevronLeft } from "lucide-react"; + +import styles from "./back-button.module.css"; + +export interface BackButtonProps { + onClick: () => void; + className?: string; + ariaLabel?: string; +} + +export function BackButton({ + onClick, + className, + ariaLabel = "Back", +}: BackButtonProps) { + return ( + + ); +} diff --git a/src/app/_components/core/auth-back-button.module.css b/src/app/_components/core/auth-back-button.module.css deleted file mode 100644 index 6fa6c265..00000000 --- a/src/app/_components/core/auth-back-button.module.css +++ /dev/null @@ -1,22 +0,0 @@ -.button { - display: inline-flex; - align-items: center; - justify-content: center; - width: 40px; - height: 40px; - border: none; - border-radius: var(--radius-full); - background: transparent; - color: var(--color-text-primary); - cursor: pointer; - transition: background 0.15s ease; -} - -.button:hover { - background: rgba(255, 255, 255, 0.08); -} - -.button:focus-visible { - outline: var(--border-medium) solid var(--color-accent); - outline-offset: 2px; -} diff --git a/src/app/_components/core/auth-back-button.tsx b/src/app/_components/core/auth-back-button.tsx deleted file mode 100644 index e2d8d1de..00000000 --- a/src/app/_components/core/auth-back-button.tsx +++ /dev/null @@ -1,59 +0,0 @@ -"use client"; -/** - * 通用返回按钮 - * - * 原始 Dart: lib/ui/auth/widgets/back_button.dart(被 auth/chat/sidebar 共用)。 - */ -import { useRouter } from "next/navigation"; -import { type MouseEvent } from "react"; - -import styles from "./auth-back-button.module.css"; - -export interface AuthBackButtonProps { - /** 自定义跳转目标(默认 history.back)。 */ - href?: string; - /** 自定义点击处理(覆盖默认跳转)。 */ - onClick?: (e: MouseEvent) => void; - className?: string; - ariaLabel?: string; -} - -export function AuthBackButton({ - href, - onClick, - className, - ariaLabel = "Back", -}: AuthBackButtonProps) { - const router = useRouter(); - return ( - - ); -} diff --git a/src/app/_components/core/index.ts b/src/app/_components/core/index.ts index ed7c62e1..edcd15ad 100644 --- a/src/app/_components/core/index.ts +++ b/src/app/_components/core/index.ts @@ -2,7 +2,6 @@ * @file Automatically generated by barrelsby. */ -export * from "./auth-back-button"; export * from "./bottom-sheet"; export * from "./checkbox"; export * from "./dialog"; diff --git a/src/app/_components/index.ts b/src/app/_components/index.ts new file mode 100644 index 00000000..b1560313 --- /dev/null +++ b/src/app/_components/index.ts @@ -0,0 +1,5 @@ +/** + * @file Shared app component barrel. + */ + +export * from "./back-button"; diff --git a/src/app/auth/components/auth-back-button.tsx b/src/app/auth/components/auth-back-button.tsx deleted file mode 100644 index 5203cec9..00000000 --- a/src/app/auth/components/auth-back-button.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { ChevronLeft } from "lucide-react"; - -import styles from "./auth-back-button.module.css"; - -export interface AuthBackButtonProps { - onClick: () => void; -} - -export function AuthBackButton({ onClick }: AuthBackButtonProps) { - return ( - - ); -} diff --git a/src/app/auth/components/auth-panel.tsx b/src/app/auth/components/auth-panel.tsx index 5a21cb4b..86cc391e 100644 --- a/src/app/auth/components/auth-panel.tsx +++ b/src/app/auth/components/auth-panel.tsx @@ -4,9 +4,9 @@ */ import { useRouter } from "next/navigation"; +import { BackButton } from "@/app/_components"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; -import { AuthBackButton } from "./auth-back-button"; import { AuthEmailPanel } from "./auth-email-panel"; import { AuthFacebookPanel } from "./auth-facebook-panel"; import styles from "./auth-panel.module.css"; @@ -31,7 +31,7 @@ export function AuthPanel() { return (
- + {state.authPanelMode === "facebook" ? ( diff --git a/src/app/auth/components/index.ts b/src/app/auth/components/index.ts index fc76f775..45497f29 100644 --- a/src/app/auth/components/index.ts +++ b/src/app/auth/components/index.ts @@ -3,7 +3,6 @@ */ export * from "./auth-background"; -export * from "./auth-back-button"; export * from "./auth-divider"; export * from "./auth-email-panel"; export * from "./auth-error-message"; diff --git a/src/app/chat/chat-screen.tsx b/src/app/chat/chat-screen.tsx index 3c32dabf..eee3b8d8 100644 --- a/src/app/chat/chat-screen.tsx +++ b/src/app/chat/chat-screen.tsx @@ -55,18 +55,12 @@ export function ChatScreen() { ? "The limit for free chat times\nhas been reached" : undefined; const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined; - const showPhotoPaywallBanner = - state.paywallTriggered && state.paywallReason === "photo_paywall"; + 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 inlineUpgradeTitle = "Unlock VIP to view private messages"; + const inlineUpgradeCta = "Activate VIP to view private messages"; const externalBrowserPromptShownRef = useRef(false); @@ -143,6 +137,10 @@ export function ChatScreen() { chatDispatch({ type: "ChatUnlockPrivateMessage", messageId }); } + function handleUnlockImagePaywall(): void { + router.push(`${ROUTES.subscription}?type=vip`); + } + function handleMessageLimitUnlock(): void { if (isGuest) { router.push(ROUTES.auth); @@ -175,9 +173,10 @@ export function ChatScreen() { isGuest={isGuest} unlockingPrivateMessageId={state.unlockingPrivateMessageId} onUnlockPrivateMessage={handleUnlockPrivateMessage} + onUnlockImagePaywall={handleUnlockImagePaywall} /> - {showInlineUpgradeBanner ? ( + {showPrivatePaywallBanner ? ( void; + onUnlockImagePaywall?: () => void; } export function ChatArea({ @@ -37,6 +38,7 @@ export function ChatArea({ isReplyingAI, unlockingPrivateMessageId, onUnlockPrivateMessage, + onUnlockImagePaywall, }: ChatAreaProps) { const scrollRef = useRef(null); const prevLengthRef = useRef(messages.length); @@ -60,6 +62,7 @@ export function ChatArea({ messages, unlockingPrivateMessageId, onUnlockPrivateMessage, + onUnlockImagePaywall, )} {isReplyingAI && } @@ -72,6 +75,7 @@ function renderMessagesWithDateHeaders( messages: readonly UiMessage[], unlockingPrivateMessageId?: string | null, onUnlockPrivateMessage?: (messageId: string) => void, + onUnlockImagePaywall?: () => void, ) { const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; @@ -91,6 +95,7 @@ function renderMessagesWithDateHeaders( id={item.message.id} content={item.message.content} imageUrl={item.message.imageUrl} + imagePaywalled={item.message.imagePaywalled} isFromAI={item.message.isFromAI} privateLocked={item.message.privateLocked} privateHint={item.message.privateHint} @@ -99,6 +104,7 @@ function renderMessagesWithDateHeaders( item.message.id === unlockingPrivateMessageId } onUnlockPrivateMessage={onUnlockPrivateMessage} + onUnlockImagePaywall={onUnlockImagePaywall} /> ), ); diff --git a/src/app/chat/components/fullscreen-image-viewer.module.css b/src/app/chat/components/fullscreen-image-viewer.module.css index 9219270f..9bd2beaf 100644 --- a/src/app/chat/components/fullscreen-image-viewer.module.css +++ b/src/app/chat/components/fullscreen-image-viewer.module.css @@ -11,6 +11,11 @@ cursor: pointer; } +.paywallViewer { + cursor: default; + overflow: hidden; +} + .viewer img { max-width: 100%; max-height: 100%; @@ -19,6 +24,66 @@ -webkit-user-drag: none; } +.paywallImage { + filter: blur(18px); + object-fit: cover; + transform: scale(1.08); +} + +.paywallScrim { + position: absolute; + inset: 0; + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.04) 0%, + rgba(255, 255, 255, 0.02) 48%, + rgba(255, 255, 255, 0.18) 100% + ); + pointer-events: none; +} + +.backButton { + position: absolute; + top: 22px; + left: 18px; + z-index: 1; + display: inline-flex; + align-items: center; + justify-content: center; + width: 54px; + height: 54px; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.94); + color: #6d6d6d; + box-shadow: 0 6px 14px rgba(0, 0, 0, 0.1); + cursor: pointer; +} + +.unlockButton { + position: absolute; + right: 18px; + bottom: max(24px, env(safe-area-inset-bottom)); + left: 18px; + z-index: 1; + min-height: 64px; + border: 0; + border-radius: 999px; + background: linear-gradient(90deg, #e05ad7 0%, #ef66b0 100%); + box-shadow: 0 8px 16px rgba(130, 60, 90, 0.28); + color: #ffffff; + cursor: pointer; + font-family: var(--font-athelas), Athelas, serif; + font-size: 22px; + font-weight: 700; + letter-spacing: 0.01em; +} + +.unlockButton:active { + transform: translateY(1px); +} + .loading { width: 32px; height: 32px; diff --git a/src/app/chat/components/fullscreen-image-viewer.tsx b/src/app/chat/components/fullscreen-image-viewer.tsx index 1de1a77c..1a6232fd 100644 --- a/src/app/chat/components/fullscreen-image-viewer.tsx +++ b/src/app/chat/components/fullscreen-image-viewer.tsx @@ -9,6 +9,7 @@ * - 点击空白 / 下滑手势 → 关闭 * - 双指缩放(CSS `touch-action: pinch-zoom`) */ +import { ChevronLeft } from "lucide-react"; import Image from "next/image"; import { useEffect } from "react"; @@ -16,10 +17,17 @@ import styles from "./fullscreen-image-viewer.module.css"; export interface FullscreenImageViewerProps { imageUrl: string; + imagePaywalled?: boolean; + onUnlockImagePaywall?: () => void; onClose: () => void; } -export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageViewerProps) { +export function FullscreenImageViewer({ + imageUrl, + imagePaywalled = false, + onUnlockImagePaywall, + onClose, +}: FullscreenImageViewerProps) { useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -28,6 +36,44 @@ export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageView return () => document.removeEventListener("keydown", handleKey); }, [onClose]); + const src = imageUrl.startsWith("data:") || imageUrl.startsWith("http") + ? imageUrl + : `data:image/png;base64,${imageUrl}`; + + if (imagePaywalled) { + return ( +
+ + + ); + } + return (
void; } -export function ImageBubble({ imageUrl }: ImageBubbleProps) { +export function ImageBubble({ + imageUrl, + imagePaywalled = false, + onUnlockImagePaywall, +}: ImageBubbleProps) { const [open, setOpen] = useState(false); const [error, setError] = useState(false); @@ -53,7 +59,14 @@ export function ImageBubble({ imageUrl }: ImageBubbleProps) { /> )}
- {open && setOpen(false)} />} + {open && ( + setOpen(false)} + /> + )} ); } diff --git a/src/app/chat/components/message-bubble.tsx b/src/app/chat/components/message-bubble.tsx index 3c6c10e7..7940543b 100644 --- a/src/app/chat/components/message-bubble.tsx +++ b/src/app/chat/components/message-bubble.tsx @@ -18,22 +18,26 @@ export interface MessageBubbleProps { id?: string; content: string; imageUrl?: string | null; + imagePaywalled?: boolean; isFromAI: boolean; privateLocked?: boolean | null; privateHint?: string | null; isUnlockingPrivate?: boolean; onUnlockPrivateMessage?: (messageId: string) => void; + onUnlockImagePaywall?: () => void; } export function MessageBubble({ id, content, imageUrl, + imagePaywalled, isFromAI, privateLocked, privateHint, isUnlockingPrivate, onUnlockPrivateMessage, + onUnlockImagePaywall, }: MessageBubbleProps) { const { avatarUrl } = useUserState(); @@ -46,11 +50,13 @@ export function MessageBubble({ messageId={id} content={content} imageUrl={imageUrl} + imagePaywalled={imagePaywalled} isFromAI={true} privateLocked={privateLocked} privateHint={privateHint} isUnlockingPrivate={isUnlockingPrivate} onUnlockPrivateMessage={onUnlockPrivateMessage} + onUnlockImagePaywall={onUnlockImagePaywall} />
{/* 占位:保持与头像宽度一致 */}
@@ -64,11 +70,13 @@ export function MessageBubble({ messageId={id} content={content} imageUrl={imageUrl} + imagePaywalled={imagePaywalled} isFromAI={false} privateLocked={privateLocked} privateHint={privateHint} isUnlockingPrivate={isUnlockingPrivate} onUnlockPrivateMessage={onUnlockPrivateMessage} + onUnlockImagePaywall={onUnlockImagePaywall} />
diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index 76064fb8..6adddfe3 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -17,11 +17,13 @@ export interface MessageContentProps { messageId?: string; content: string; imageUrl?: string | null; + imagePaywalled?: boolean; isFromAI: boolean; privateLocked?: boolean | null; privateHint?: string | null; isUnlockingPrivate?: boolean; onUnlockPrivateMessage?: (messageId: string) => void; + onUnlockImagePaywall?: () => void; } const IMAGE_PLACEHOLDER = "[图片]"; @@ -30,11 +32,13 @@ export function MessageContent({ messageId, content, imageUrl, + imagePaywalled, isFromAI, privateLocked, privateHint, isUnlockingPrivate = false, onUnlockPrivateMessage, + onUnlockImagePaywall, }: MessageContentProps) { const hasImage = imageUrl != null && imageUrl.length > 0; const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER; @@ -65,7 +69,13 @@ export function MessageContent({ /> ) : ( <> - {hasImage && imageUrl && } + {hasImage && imageUrl && ( + + )} {hasText && } )} diff --git a/src/app/subscription/components/index.ts b/src/app/subscription/components/index.ts index 19cb0abb..bc1f58c9 100644 --- a/src/app/subscription/components/index.ts +++ b/src/app/subscription/components/index.ts @@ -8,6 +8,7 @@ export * from "./subscription-benefits-card"; export * from "./subscription-checkout-button"; export * from "./subscription-cta-button"; export * from "./subscription-payment-method"; +export * from "./subscription-payment-success-dialog"; export * from "./subscription-plan-card"; export * from "./subscription-user-row"; export * from "./stripe-payment-dialog"; diff --git a/src/app/subscription/components/subscription-payment-success-dialog.module.css b/src/app/subscription/components/subscription-payment-success-dialog.module.css new file mode 100644 index 00000000..f2f30caf --- /dev/null +++ b/src/app/subscription/components/subscription-payment-success-dialog.module.css @@ -0,0 +1,72 @@ +.overlay { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.45); +} + +.dialog { + width: 100%; + max-width: 380px; + border-radius: 32px; + background: var(--color-page-background, #ffffff); + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16); + padding: 28px 20px 20px; + text-align: center; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 58px; + height: 58px; + margin-bottom: 16px; + border-radius: 999px; + background: linear-gradient(135deg, #ff67e0 0%, #f657a0 100%); + color: #ffffff; + font-size: 32px; + font-weight: 700; + line-height: 1; + box-shadow: 0 8px 18px rgba(247, 89, 168, 0.28); +} + +.title { + margin: 0 0 8px; + color: var(--color-text-foreground, #171717); + font-size: var(--font-size-22, 22px); + font-weight: 700; + line-height: 1.2; +} + +.content { + margin: 0; + color: #393939; + font-size: var(--font-size-md, 14px); + line-height: 1.5; +} + +.button { + width: 100%; + min-height: 48px; + margin-top: 22px; + border: 0; + border-radius: 999px; + background: + linear-gradient(269deg, #ff67e0 0%, rgba(254, 104, 224, 0.5) 20%, rgba(252, 105, 223, 0.79) 66%, #f96ade 100%), + linear-gradient(#f657a0, #f657a0); + background-blend-mode: normal, normal; + box-shadow: 0 5px 7px rgba(247, 89, 168, 0.31); + color: #ffffff; + cursor: pointer; + font-size: var(--font-size-lg, 16px); + font-weight: 700; +} + +.button:active { + transform: translateY(1px); +} diff --git a/src/app/subscription/components/subscription-payment-success-dialog.tsx b/src/app/subscription/components/subscription-payment-success-dialog.tsx new file mode 100644 index 00000000..8e827f20 --- /dev/null +++ b/src/app/subscription/components/subscription-payment-success-dialog.tsx @@ -0,0 +1,44 @@ +"use client"; + +import styles from "./subscription-payment-success-dialog.module.css"; + +export interface SubscriptionPaymentSuccessDialogProps { + open: boolean; + subscriptionType: "vip" | "voice"; + onClose: () => void; +} + +export function SubscriptionPaymentSuccessDialog({ + open, + subscriptionType, + onClose, +}: SubscriptionPaymentSuccessDialogProps) { + if (!open) return null; + + const content = + subscriptionType === "voice" + ? "Your voice package purchase was completed successfully." + : "Your VIP membership has been activated successfully."; + + return ( +
+
+ +

+ Payment successful +

+

{content}

+ +
+
+ ); +} diff --git a/src/app/subscription/subscription-screen.tsx b/src/app/subscription/subscription-screen.tsx index 532713ee..15ce67b9 100644 --- a/src/app/subscription/subscription-screen.tsx +++ b/src/app/subscription/subscription-screen.tsx @@ -14,7 +14,7 @@ * 7. 主操作按钮 * 8. 协议复选框 */ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Checkbox, MobileShell } from "@/app/_components/core"; import { AppConstants } from "@/core/app_constants"; @@ -32,6 +32,7 @@ import { SubscriptionBenefitsCard, SubscriptionCheckoutButton, SubscriptionPaymentMethod, + SubscriptionPaymentSuccessDialog, SubscriptionPlanCard, SubscriptionUserRow, } from "./components"; @@ -61,6 +62,9 @@ export function SubscriptionScreen({ const paymentDispatch = usePaymentDispatch(); const refreshedPaidOrderRef = useRef(null); const resumedPendingOrderRef = useRef(null); + const successDialogShownOrderRef = useRef(null); + const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] = + useState(false); useEffect(() => { if (payment.status === "idle") { @@ -75,6 +79,14 @@ export function SubscriptionScreen({ userDispatch({ type: "UserFetch" }); }, [payment.currentOrderId, payment.isPaid, userDispatch]); + useEffect(() => { + if (!payment.isPaid || !payment.currentOrderId) return; + if (successDialogShownOrderRef.current === payment.currentOrderId) return; + + successDialogShownOrderRef.current = payment.currentOrderId; + setShowPaymentSuccessDialog(true); + }, [payment.currentOrderId, payment.isPaid]); + useEffect(() => { const canInspectPendingOrder = payment.status === "ready" || @@ -296,6 +308,12 @@ export function SubscriptionScreen({ } />
+ + setShowPaymentSuccessDialog(false)} + />
); diff --git a/src/data/dto/chat/ui_message.ts b/src/data/dto/chat/ui_message.ts index a0eac429..1695e4a8 100644 --- a/src/data/dto/chat/ui_message.ts +++ b/src/data/dto/chat/ui_message.ts @@ -15,6 +15,8 @@ export const UiMessageSchema = z.object({ date: z.string(), /** 图片 URL(base64 data URL 或 http URL) */ imageUrl: z.string().optional(), + /** true = 图片可在列表展示,但全屏查看时需要会员解锁 */ + imagePaywalled: z.boolean().optional(), /** 语音 URL */ voiceUrl: z.string().optional(), isPrivate: z.boolean().nullable().optional(), diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index e0fa41a2..e0734857 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -89,6 +89,9 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { isFromAI: true, date: todayString(new Date(response.timestamp)), ...(response.imageUrl ? { imageUrl: response.imageUrl } : {}), + ...(response.showUpgrade && response.imageUrl + ? { imagePaywalled: true } + : {}), }; } @@ -147,8 +150,8 @@ export function applyHttpSendOutput( return { messages: [...context.messages, reply], isReplyingAI: false, - paywallTriggered: true, - paywallReason: "photo_paywall", + paywallTriggered: false, + paywallReason: null, paywallDetail: null, }; } diff --git a/src/tokens/dimensions.css b/src/tokens/dimensions.css index 341a1763..baf2529f 100644 --- a/src/tokens/dimensions.css +++ b/src/tokens/dimensions.css @@ -51,7 +51,7 @@ --auth-legal-checkbox-inner-size: 10px; /* 悬浮返回按钮(40×40 圆形) */ - --auth-back-button-size: 40px; + --back-button-size: 40px; /* 底部弹层圆角(28px = Dart AppRadius.radius28) */ --auth-bottom-sheet-radius: 28px; From a571867620a5545224156b47a8fb3978cc01326a Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 24 Jun 2026 10:19:30 +0800 Subject: [PATCH 4/7] fix(chat): hide text for ai image messages --- src/stores/chat/chat-machine.helpers.ts | 39 ++++++++++++------------- src/stores/chat/chat-machine.ts | 1 + 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index e0734857..bab25ee1 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -1,22 +1,3 @@ -/** - * Chat 状态机:纯函数 + 数据加载 - * - * 从 `chat-machine.ts` 抽出的"helpers"段: - * - 翻页大小常量 - * - 仓库注入(接口类型 → 单例) - * - DTO ↔ UiMessage 映射(纯函数 —— 可单测) - * - `readAndSyncHistory()` —— local → network → save network to local 3 步 - * - * 设计目标: - * - helpers 无 XState 依赖(可独立 import / 测试) - * - actors 依赖 helpers(import) - * - machine 依赖 actors,并复用 helpers 里的纯状态转换函数 - * - * 历史: - * - `readInitData` + `InitResult` 已删(被 2 个独立 helper 替换) - * - `AuthStorage` import 已删(只 `readInitData` 用过,移到 `chatInit` actor 上层调用) - */ - import type { UiMessage } from "@/data/dto/chat"; import { chatRepository } from "@/data/repositories/chat_repository"; import type { IChatRepository } from "@/data/repositories/interfaces"; @@ -61,7 +42,11 @@ export function localMessagesToUi( ): UiMessage[] { return records.map((m) => ({ ...(m.id ? { id: m.id } : {}), - content: m.content, + content: getAiMessageDisplayContent({ + content: m.content, + isFromAI: m.role === "assistant", + imageUrl: m.imageUrl, + }), isFromAI: m.role === "assistant", date: messageDateFromCreatedAt(m.createdAt), ...(m.imageUrl ? { imageUrl: m.imageUrl } : {}), @@ -77,6 +62,14 @@ function messageDateFromCreatedAt(createdAt: string): string { return todayString(parsed); } +function getAiMessageDisplayContent(input: { + content: string; + isFromAI: boolean; + imageUrl?: string | null; +}): string { + return input.isFromAI && input.imageUrl ? "" : input.content; +} + /** * ChatSendResponse → UiMessage(纯函数) * - 业务事实:后端响应就是 AI 的回复 @@ -85,7 +78,11 @@ function messageDateFromCreatedAt(createdAt: string): string { export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { return { ...(response.messageId ? { id: response.messageId } : {}), - content: response.reply, + content: getAiMessageDisplayContent({ + content: response.reply, + isFromAI: true, + imageUrl: response.imageUrl, + }), isFromAI: true, date: todayString(new Date(response.timestamp)), ...(response.imageUrl ? { imageUrl: response.imageUrl } : {}), diff --git a/src/stores/chat/chat-machine.ts b/src/stores/chat/chat-machine.ts index 9926fc3c..3304f6ab 100644 --- a/src/stores/chat/chat-machine.ts +++ b/src/stores/chat/chat-machine.ts @@ -243,6 +243,7 @@ export const chatMachine = setup({ if (last?.isFromAI && !last.imageUrl) { messages[messages.length - 1] = { ...last, + content: "", imageUrl: event.imageUrl, }; } else { From f80b5215a99e74d6a3e77b33088dd24b2d4d5e47 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 24 Jun 2026 10:48:36 +0800 Subject: [PATCH 5/7] feat(chat): log websocket traffic --- .../chat/components/image-bubble.module.css | 1 + src/app/chat/components/image-bubble.tsx | 2 - src/app/chat/components/message-content.tsx | 2 - .../private-message-card.module.css | 1 + src/core/net/chat-websocket.ts | 108 ++++++++++++++++-- src/stores/chat/chat-machine.helpers.ts | 3 - 6 files changed, 101 insertions(+), 16 deletions(-) diff --git a/src/app/chat/components/image-bubble.module.css b/src/app/chat/components/image-bubble.module.css index c5235cd5..170c833a 100644 --- a/src/app/chat/components/image-bubble.module.css +++ b/src/app/chat/components/image-bubble.module.css @@ -4,6 +4,7 @@ max-width: 220px; max-height: 220px; border-radius: var(--radius-lg, 12px); + border-top-left-radius: 0; overflow: hidden; cursor: pointer; background: var(--color-bubble-background, #fff); diff --git a/src/app/chat/components/image-bubble.tsx b/src/app/chat/components/image-bubble.tsx index e9559b41..d49dbc44 100644 --- a/src/app/chat/components/image-bubble.tsx +++ b/src/app/chat/components/image-bubble.tsx @@ -2,8 +2,6 @@ /** * ImageBubble 图片气泡 * - * 原始 Dart: lib/ui/chat/widgets/image_bubble.dart(63 行) - * * 支持: * - base64 data URI 解码(`data:image/png;base64,...`) * - 点击 → 打开全屏查看器 diff --git a/src/app/chat/components/message-content.tsx b/src/app/chat/components/message-content.tsx index 6adddfe3..453a6093 100644 --- a/src/app/chat/components/message-content.tsx +++ b/src/app/chat/components/message-content.tsx @@ -2,8 +2,6 @@ /** * MessageContent 消息内容容器 * - * 原始 Dart: lib/ui/chat/widgets/message_content.dart(43 行) - * * 决定渲染 ImageBubble 还是 TextBubble: * - 有 imageUrl:渲染 ImageBubble(图片) * - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字) diff --git a/src/app/chat/components/private-message-card.module.css b/src/app/chat/components/private-message-card.module.css index 66821132..97f8f1d8 100644 --- a/src/app/chat/components/private-message-card.module.css +++ b/src/app/chat/components/private-message-card.module.css @@ -3,6 +3,7 @@ padding: 14px; border: 1px solid rgba(246, 87, 160, 0.2); border-radius: 18px; + border-top-left-radius: 0; background: linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)), #ffffff; diff --git a/src/core/net/chat-websocket.ts b/src/core/net/chat-websocket.ts index 5c78af72..404ec459 100644 --- a/src/core/net/chat-websocket.ts +++ b/src/core/net/chat-websocket.ts @@ -11,7 +11,10 @@ * - onError(errorMessage) */ import { getApiConfig } from "@/core/net/config/api_config"; -import { AppEnvUtil } from "@/utils"; +import { AppEnvUtil, Logger } from "@/utils"; + +const log = new Logger("ChatWebSocket"); +const RECONNECT_DELAY_MS = 3000; export interface SentencePayload { index: number; @@ -36,6 +39,7 @@ export interface PaywallStatusPayload { export class ChatWebSocket { private ws: WebSocket | null = null; private reconnectTimer: ReturnType | null = null; + private connectionUrl: string | null = null; private disposed = false; onConnected: ((userId: string) => void) | null = null; @@ -62,25 +66,53 @@ export class ChatWebSocket { if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return; try { const url = buildChatWebSocketUrl(this.serverUrl, this.token); + this.connectionUrl = url; + logWebSocketDebug(`↔ WS CONNECT ${redactWebSocketUrl(url)}`); this.ws = new WebSocket(url); this.ws.onopen = () => { + logWebSocketDebug(`↔ WS OPEN ${redactWebSocketUrl(url)}`); // 连接成功;userId 由后端在第一条消息中下发 }; - this.ws.onmessage = (e) => this.handleMessage(e.data); - this.ws.onerror = () => this.onError?.("WebSocket error"); - this.ws.onclose = () => { + this.ws.onmessage = (e) => { + logWebSocketFrame("← WS MESSAGE", url, e.data); + this.handleMessage(e.data); + }; + this.ws.onerror = () => { + logWebSocketError(`✕ WS ERROR ${redactWebSocketUrl(url)}`); + this.onError?.("WebSocket error"); + }; + this.ws.onclose = (event) => { + logWebSocketDebug(`↔ WS CLOSE ${redactWebSocketUrl(url)}`, { + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + reconnectInMs: this.disposed ? null : RECONNECT_DELAY_MS, + }); if (!this.disposed) { - this.reconnectTimer = setTimeout(() => this.connect(), 3000); + this.reconnectTimer = setTimeout( + () => this.connect(), + RECONNECT_DELAY_MS, + ); } }; } catch (e) { + logWebSocketError("✕ WS CONNECT FAILED", e); this.onError?.(e instanceof Error ? e.message : String(e)); } } sendMessage(content: string): boolean { - if (!this.isConnected) return false; - this.ws?.send(JSON.stringify({ type: "message", content })); + const payload = { type: "message", content }; + if (!this.isConnected) { + logWebSocketWarn("→ WS SEND SKIPPED: socket is not open", payload); + return false; + } + logWebSocketFrame( + "→ WS SEND", + this.connectionUrl ?? this.serverUrl, + payload, + ); + this.ws?.send(JSON.stringify(payload)); return true; } @@ -92,10 +124,14 @@ export class ChatWebSocket { } this.ws?.close(); this.ws = null; + this.connectionUrl = null; } private handleMessage(data: unknown): void { - if (typeof data !== "string") return; + if (typeof data !== "string") { + logWebSocketWarn("← WS MESSAGE IGNORED: non-string payload", data); + return; + } let payload: { type?: string; userId?: string; @@ -119,7 +155,8 @@ export class ChatWebSocket { }; try { payload = JSON.parse(data); - } catch { + } catch (e) { + logWebSocketError("← WS MESSAGE PARSE FAILED", e); return; } switch (payload.type) { @@ -175,6 +212,59 @@ function buildChatWebSocketUrl(serverUrl: string, token: string): string { return `${serverUrl}?token=${encodeURIComponent(token)}`; } +function redactWebSocketUrl(url: string): string { + try { + const parsed = new URL(url); + if (parsed.searchParams.has("token")) { + parsed.searchParams.set("token", ""); + } + return parsed.toString(); + } catch { + return url.replace(/([?&]token=)[^&]+/i, "$1"); + } +} + +function logWebSocketFrame( + label: string, + url: string, + payload: unknown, +): void { + if (AppEnvUtil.isProduction()) return; + const body = formatWebSocketLogValue(payload); + const bodyLabel = label.startsWith("→") ? "request body" : "response body"; + log.debug( + `${label} ${redactWebSocketUrl(url)}${body ? `\n${bodyLabel}:\n${body}` : ""}`, + ); +} + +function logWebSocketDebug(message: string, payload?: unknown): void { + if (AppEnvUtil.isProduction()) return; + const body = formatWebSocketLogValue(payload); + log.debug(`${message}${body ? `\nbody:\n${body}` : ""}`); +} + +function logWebSocketWarn(message: string, payload?: unknown): void { + if (AppEnvUtil.isProduction()) return; + const body = formatWebSocketLogValue(payload); + log.warn(`${message}${body ? `\nbody:\n${body}` : ""}`); +} + +function logWebSocketError(message: string, payload?: unknown): void { + if (AppEnvUtil.isProduction()) return; + const body = formatWebSocketLogValue(payload); + log.error(`${message}${body ? `\nbody:\n${body}` : ""}`); +} + +function formatWebSocketLogValue(payload: unknown): string { + if (payload instanceof Error) { + return Logger.formatValue({ + message: payload.message, + stack: payload.stack, + }); + } + return Logger.formatValue(payload); +} + /** 默认创建实例:从 env 读取 WS URL。 */ export function createChatWebSocket(token: string): ChatWebSocket { const base = getApiConfig().wsUrl; diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index bab25ee1..39f6b46a 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -162,9 +162,6 @@ export function applyHttpSendOutput( }; } -// 本地游客消息额度读取 / 映射逻辑已停用: -// 消息数量限制统一由后端接口返回 blocked/daily_limit,前端不再读取 ChatStorage quota。 - // ============================================================ // Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调 // ============================================================ From 3e766a0ecd6cf925534d99388c31f6066a405bb1 Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 24 Jun 2026 10:57:42 +0800 Subject: [PATCH 6/7] fix(chat): preserve private send response fields --- src/app/chat/components/chat-area.tsx | 2 -- src/data/dto/chat/chat_send_response.ts | 3 +++ src/data/schemas/chat/chat_send_response.ts | 3 +++ src/stores/chat/chat-machine.actors.ts | 2 -- src/stores/chat/chat-machine.helpers.ts | 7 +++++++ 5 files changed, 13 insertions(+), 4 deletions(-) diff --git a/src/app/chat/components/chat-area.tsx b/src/app/chat/components/chat-area.tsx index 4af9a568..0c2e8f22 100644 --- a/src/app/chat/components/chat-area.tsx +++ b/src/app/chat/components/chat-area.tsx @@ -2,8 +2,6 @@ /** * ChatArea 消息列表区 * - * 原始 Dart: lib/ui/chat/widgets/chat_area.dart(153 行) - * * 组成(自上而下): * - AI 披露横幅 * - 日期分隔条 diff --git a/src/data/dto/chat/chat_send_response.ts b/src/data/dto/chat/chat_send_response.ts index 592b97d1..cf8a7479 100644 --- a/src/data/dto/chat/chat_send_response.ts +++ b/src/data/dto/chat/chat_send_response.ts @@ -26,6 +26,9 @@ export class ChatSendResponse { declare readonly paywallTriggered: boolean; declare readonly showUpgrade: boolean; declare readonly imageType: string | null; + declare readonly isPrivate: boolean | null; + declare readonly privateLocked: boolean | null; + declare readonly privateHint: string | null; declare readonly imageUrl: string | null; private constructor(input: ChatSendResponseInput) { diff --git a/src/data/schemas/chat/chat_send_response.ts b/src/data/schemas/chat/chat_send_response.ts index add4757b..962c975a 100644 --- a/src/data/schemas/chat/chat_send_response.ts +++ b/src/data/schemas/chat/chat_send_response.ts @@ -30,6 +30,9 @@ export const ChatSendResponseSchema = z.object({ paywallTriggered: z.boolean().default(false), showUpgrade: z.boolean().default(false), imageType: z.string().nullable().default(null), + isPrivate: z.boolean().nullable().default(null), + privateLocked: z.boolean().nullable().default(null), + privateHint: z.string().nullable().default(null), imageUrl: z.string().nullable().default(null), }); diff --git a/src/stores/chat/chat-machine.actors.ts b/src/stores/chat/chat-machine.actors.ts index 2d9a20d8..f2ce9e9b 100644 --- a/src/stores/chat/chat-machine.actors.ts +++ b/src/stores/chat/chat-machine.actors.ts @@ -41,8 +41,6 @@ export function getActiveChatWebSocket(): ChatWebSocket | null { return activeChatWebSocket; } -// 本地游客消息额度 actor 已停用:消息数量限制统一交由后端 blocked/daily_limit 响应处理。 - // ============================================================ // Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果) // ============================================================ diff --git a/src/stores/chat/chat-machine.helpers.ts b/src/stores/chat/chat-machine.helpers.ts index 39f6b46a..2397df91 100644 --- a/src/stores/chat/chat-machine.helpers.ts +++ b/src/stores/chat/chat-machine.helpers.ts @@ -89,6 +89,13 @@ export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { ...(response.showUpgrade && response.imageUrl ? { imagePaywalled: true } : {}), + ...(response.isPrivate != null ? { isPrivate: response.isPrivate } : {}), + ...(response.privateLocked != null + ? { privateLocked: response.privateLocked } + : {}), + ...(response.privateHint != null + ? { privateHint: response.privateHint } + : {}), }; } From 57e4add7a4fd63b47ff85e335daad5b8c988b55b Mon Sep 17 00:00:00 2001 From: chenhang Date: Wed, 24 Jun 2026 11:35:15 +0800 Subject: [PATCH 7/7] style(sidebar): refine action buttons --- .../sidebar/components/back-bar.module.css | 12 +++------- src/app/sidebar/components/back-bar.tsx | 22 +++++-------------- .../components/vip-benefits-card.module.css | 20 ++++++++++------- .../components/voice-package-card.module.css | 14 +++++------- .../sidebar/components/voice-package-card.tsx | 1 - 5 files changed, 27 insertions(+), 42 deletions(-) diff --git a/src/app/sidebar/components/back-bar.module.css b/src/app/sidebar/components/back-bar.module.css index 2482ca69..e27325e2 100644 --- a/src/app/sidebar/components/back-bar.module.css +++ b/src/app/sidebar/components/back-bar.module.css @@ -7,11 +7,11 @@ .link { display: inline-flex; align-items: center; - gap: var(--spacing-xs, 4px); + justify-content: center; + width: 32px; + height: 32px; color: #000000; text-decoration: none; - font-size: var(--font-size-lg, 16px); - font-weight: 400; background: none; border: 0; padding: 0; @@ -26,9 +26,3 @@ color: currentColor; flex-shrink: 0; } - -.text { - color: currentColor; - font-size: var(--font-size-lg, 16px); - font-weight: 400; -} diff --git a/src/app/sidebar/components/back-bar.tsx b/src/app/sidebar/components/back-bar.tsx index 4221f765..e6590b46 100644 --- a/src/app/sidebar/components/back-bar.tsx +++ b/src/app/sidebar/components/back-bar.tsx @@ -1,5 +1,6 @@ "use client"; +import { ChevronLeft } from "lucide-react"; import Link from "next/link"; import { ROUTES } from "@/router/routes"; @@ -7,7 +8,7 @@ import { ROUTES } from "@/router/routes"; import styles from "./back-bar.module.css"; /** - * 顶部返回栏:chevron + "Back" 文字,链接至聊天页。 + * 顶部返回栏:图标按钮,链接至聊天页。 * * 视觉规格(与设计稿对齐): * - 黑色文本(浅色主题下) @@ -21,23 +22,12 @@ export function BackBar() { className={styles.link} aria-label="Back" > - - Back + /> ); diff --git a/src/app/sidebar/components/vip-benefits-card.module.css b/src/app/sidebar/components/vip-benefits-card.module.css index f96be910..9c17e5e5 100644 --- a/src/app/sidebar/components/vip-benefits-card.module.css +++ b/src/app/sidebar/components/vip-benefits-card.module.css @@ -88,22 +88,26 @@ } .activateBtn { - display: flex; + display: inline-flex; align-items: center; justify-content: center; padding: var(--spacing-md, 12px) var(--spacing-lg, 16px); - border-radius: var(--radius-full, 999px); - background: linear-gradient( - 90deg, - var(--color-button-gradient-start, #ff67e0), - var(--color-button-gradient-end, #ff52a2) - ); + border-radius: 22px; + background-image: + linear-gradient( + 135deg, + #ff67e0 0%, + rgba(255, 109, 225, 0.29) 20%, + rgba(254, 122, 228, 0.47) 66%, + rgba(252, 140, 231, 0.59) 100% + ), + linear-gradient(#fb5e9d, #fb5e9d); + background-blend-mode: normal, normal; color: var(--color-text-primary, #ffffff); font-size: var(--font-size-md, 14px); font-weight: 600; border: 0; cursor: pointer; - width: 100%; box-sizing: border-box; } diff --git a/src/app/sidebar/components/voice-package-card.module.css b/src/app/sidebar/components/voice-package-card.module.css index 51191482..6cac511d 100644 --- a/src/app/sidebar/components/voice-package-card.module.css +++ b/src/app/sidebar/components/voice-package-card.module.css @@ -74,22 +74,20 @@ } .buyBtn { - display: flex; + display: inline-flex; align-items: center; justify-content: center; padding: var(--spacing-md, 12px) var(--spacing-lg, 16px); - border-radius: var(--radius-full, 999px); - background: linear-gradient( - 90deg, - var(--color-button-gradient-start, #ff67e0), - var(--color-button-gradient-end, #ff52a2) - ); + border-radius: 22px; + background-image: + linear-gradient(91deg, #f67382 0%, #f66690 44%, #f657a0 100%), + linear-gradient(#f69757, #f69757); + background-blend-mode: normal, normal; color: var(--color-text-primary, #ffffff); font-size: var(--font-size-md, 14px); font-weight: 600; border: 0; cursor: pointer; - width: 100%; box-sizing: border-box; } diff --git a/src/app/sidebar/components/voice-package-card.tsx b/src/app/sidebar/components/voice-package-card.tsx index b82d49b3..e442e97d 100644 --- a/src/app/sidebar/components/voice-package-card.tsx +++ b/src/app/sidebar/components/voice-package-card.tsx @@ -19,7 +19,6 @@ export interface VoicePackageCardProps { * 视觉规格(与设计稿对齐): * - 白色卡片 + 粉色→橙色渐变描边 * - 标题 "Voice Message Package"(粉色加粗) - * - 右上角大圆形渐变麦克风图标 * - "0min/0min used/remaining" 文案(前缀粉色加粗) * - 底部 "Buy Package" 粉色胶囊按钮 */