Merge branch 'dev' into test

This commit is contained in:
2026-06-24 11:36:54 +08:00
36 changed files with 515 additions and 217 deletions
+4
View File
@@ -32,6 +32,10 @@ const nextConfig: NextConfig = {
hostname: "**.supabase.co", hostname: "**.supabase.co",
pathname: "/storage/v1/object/public/**", pathname: "/storage/v1/object/public/**",
}, },
{
protocol: "https",
hostname: "free.picui.cn",
},
], ],
}, },
}; };
@@ -6,8 +6,8 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: var(--auth-back-button-size); width: var(--back-button-size);
height: var(--auth-back-button-size); height: var(--back-button-size);
padding: 0 2px 0 0; padding: 0 2px 0 0;
color: var(--color-auth-text-primary); color: var(--color-auth-text-primary);
cursor: pointer; cursor: pointer;
+28
View File
@@ -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 (
<button
type="button"
className={[styles.backButton, className].filter(Boolean).join(" ")}
onClick={onClick}
aria-label={ariaLabel}
>
<ChevronLeft size={20} strokeWidth={2.5} aria-hidden="true" />
</button>
);
}
@@ -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;
}
@@ -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<HTMLButtonElement>) => void;
className?: string;
ariaLabel?: string;
}
export function AuthBackButton({
href,
onClick,
className,
ariaLabel = "Back",
}: AuthBackButtonProps) {
const router = useRouter();
return (
<button
type="button"
aria-label={ariaLabel}
className={[styles.button, className].filter(Boolean).join(" ")}
onClick={(e) => {
if (onClick) {
onClick(e);
return;
}
if (href) router.push(href);
else router.back();
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
>
<path
d="M15 18l-6-6 6-6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
);
}
-1
View File
@@ -2,7 +2,6 @@
* @file Automatically generated by barrelsby. * @file Automatically generated by barrelsby.
*/ */
export * from "./auth-back-button";
export * from "./bottom-sheet"; export * from "./bottom-sheet";
export * from "./checkbox"; export * from "./checkbox";
export * from "./dialog"; export * from "./dialog";
+5
View File
@@ -0,0 +1,5 @@
/**
* @file Shared app component barrel.
*/
export * from "./back-button";
@@ -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 (
<button
type="button"
className={styles.backButton}
onClick={onClick}
aria-label="Back"
>
<ChevronLeft size={20} strokeWidth={2.5} aria-hidden="true" />
</button>
);
}
+2 -2
View File
@@ -4,9 +4,9 @@
*/ */
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { BackButton } from "@/app/_components";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { AuthBackButton } from "./auth-back-button";
import { AuthEmailPanel } from "./auth-email-panel"; import { AuthEmailPanel } from "./auth-email-panel";
import { AuthFacebookPanel } from "./auth-facebook-panel"; import { AuthFacebookPanel } from "./auth-facebook-panel";
import styles from "./auth-panel.module.css"; import styles from "./auth-panel.module.css";
@@ -31,7 +31,7 @@ export function AuthPanel() {
return ( return (
<div className={styles.panelShell}> <div className={styles.panelShell}>
<AuthBackButton onClick={handleBack} /> <BackButton onClick={handleBack} />
{state.authPanelMode === "facebook" ? ( {state.authPanelMode === "facebook" ? (
<AuthFacebookPanel onSwitchToEmail={switchToEmail} /> <AuthFacebookPanel onSwitchToEmail={switchToEmail} />
-1
View File
@@ -3,7 +3,6 @@
*/ */
export * from "./auth-background"; export * from "./auth-background";
export * from "./auth-back-button";
export * from "./auth-divider"; export * from "./auth-divider";
export * from "./auth-email-panel"; export * from "./auth-email-panel";
export * from "./auth-error-message"; export * from "./auth-error-message";
+10 -11
View File
@@ -55,18 +55,12 @@ export function ChatScreen() {
? "The limit for free chat times\nhas been reached" ? "The limit for free chat times\nhas been reached"
: undefined; : undefined;
const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined; const messageLimitCta = isGuest ? "Log in to continue chatting" : undefined;
const showPhotoPaywallBanner =
state.paywallTriggered && state.paywallReason === "photo_paywall";
const showPrivatePaywallBanner = const showPrivatePaywallBanner =
state.paywallTriggered && state.paywallReason === "private_paywall"; state.paywallTriggered && state.paywallReason === "private_paywall";
const showInlineUpgradeBanner =
showPhotoPaywallBanner || showPrivatePaywallBanner; const inlineUpgradeTitle = "Unlock VIP to view private messages";
const inlineUpgradeTitle = showPrivatePaywallBanner const inlineUpgradeCta = "Activate VIP to view private messages";
? "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); const externalBrowserPromptShownRef = useRef(false);
@@ -143,6 +137,10 @@ export function ChatScreen() {
chatDispatch({ type: "ChatUnlockPrivateMessage", messageId }); chatDispatch({ type: "ChatUnlockPrivateMessage", messageId });
} }
function handleUnlockImagePaywall(): void {
router.push(`${ROUTES.subscription}?type=vip`);
}
function handleMessageLimitUnlock(): void { function handleMessageLimitUnlock(): void {
if (isGuest) { if (isGuest) {
router.push(ROUTES.auth); router.push(ROUTES.auth);
@@ -175,9 +173,10 @@ export function ChatScreen() {
isGuest={isGuest} isGuest={isGuest}
unlockingPrivateMessageId={state.unlockingPrivateMessageId} unlockingPrivateMessageId={state.unlockingPrivateMessageId}
onUnlockPrivateMessage={handleUnlockPrivateMessage} onUnlockPrivateMessage={handleUnlockPrivateMessage}
onUnlockImagePaywall={handleUnlockImagePaywall}
/> />
{showInlineUpgradeBanner ? ( {showPrivatePaywallBanner ? (
<ChatQuotaExhaustedBanner <ChatQuotaExhaustedBanner
title={inlineUpgradeTitle} title={inlineUpgradeTitle}
ctaLabel={inlineUpgradeCta} ctaLabel={inlineUpgradeCta}
+6 -2
View File
@@ -2,8 +2,6 @@
/** /**
* ChatArea 消息列表区 * ChatArea 消息列表区
* *
* 原始 Dart: lib/ui/chat/widgets/chat_area.dart153 行)
*
* 组成(自上而下): * 组成(自上而下):
* - AI 披露横幅 * - AI 披露横幅
* - 日期分隔条 * - 日期分隔条
@@ -30,6 +28,7 @@ export interface ChatAreaProps {
isGuest: boolean; isGuest: boolean;
unlockingPrivateMessageId?: string | null; unlockingPrivateMessageId?: string | null;
onUnlockPrivateMessage?: (messageId: string) => void; onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockImagePaywall?: () => void;
} }
export function ChatArea({ export function ChatArea({
@@ -37,6 +36,7 @@ export function ChatArea({
isReplyingAI, isReplyingAI,
unlockingPrivateMessageId, unlockingPrivateMessageId,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockImagePaywall,
}: ChatAreaProps) { }: ChatAreaProps) {
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const prevLengthRef = useRef(messages.length); const prevLengthRef = useRef(messages.length);
@@ -60,6 +60,7 @@ export function ChatArea({
messages, messages,
unlockingPrivateMessageId, unlockingPrivateMessageId,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockImagePaywall,
)} )}
{isReplyingAI && <LottieMessageBubble />} {isReplyingAI && <LottieMessageBubble />}
@@ -72,6 +73,7 @@ function renderMessagesWithDateHeaders(
messages: readonly UiMessage[], messages: readonly UiMessage[],
unlockingPrivateMessageId?: string | null, unlockingPrivateMessageId?: string | null,
onUnlockPrivateMessage?: (messageId: string) => void, onUnlockPrivateMessage?: (messageId: string) => void,
onUnlockImagePaywall?: () => void,
) { ) {
const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = []; const items: Array<{ type: "date"; date: string; key: string } | { type: "msg"; message: UiMessage; key: string }> = [];
@@ -91,6 +93,7 @@ function renderMessagesWithDateHeaders(
id={item.message.id} id={item.message.id}
content={item.message.content} content={item.message.content}
imageUrl={item.message.imageUrl} imageUrl={item.message.imageUrl}
imagePaywalled={item.message.imagePaywalled}
isFromAI={item.message.isFromAI} isFromAI={item.message.isFromAI}
privateLocked={item.message.privateLocked} privateLocked={item.message.privateLocked}
privateHint={item.message.privateHint} privateHint={item.message.privateHint}
@@ -99,6 +102,7 @@ function renderMessagesWithDateHeaders(
item.message.id === unlockingPrivateMessageId item.message.id === unlockingPrivateMessageId
} }
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
/> />
), ),
); );
@@ -11,6 +11,11 @@
cursor: pointer; cursor: pointer;
} }
.paywallViewer {
cursor: default;
overflow: hidden;
}
.viewer img { .viewer img {
max-width: 100%; max-width: 100%;
max-height: 100%; max-height: 100%;
@@ -19,6 +24,66 @@
-webkit-user-drag: none; -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 { .loading {
width: 32px; width: 32px;
height: 32px; height: 32px;
@@ -9,6 +9,7 @@
* - 点击空白 / 下滑手势 → 关闭 * - 点击空白 / 下滑手势 → 关闭
* - 双指缩放(CSS `touch-action: pinch-zoom` * - 双指缩放(CSS `touch-action: pinch-zoom`
*/ */
import { ChevronLeft } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useEffect } from "react"; import { useEffect } from "react";
@@ -16,10 +17,17 @@ import styles from "./fullscreen-image-viewer.module.css";
export interface FullscreenImageViewerProps { export interface FullscreenImageViewerProps {
imageUrl: string; imageUrl: string;
imagePaywalled?: boolean;
onUnlockImagePaywall?: () => void;
onClose: () => void; onClose: () => void;
} }
export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageViewerProps) { export function FullscreenImageViewer({
imageUrl,
imagePaywalled = false,
onUnlockImagePaywall,
onClose,
}: FullscreenImageViewerProps) {
useEffect(() => { useEffect(() => {
const handleKey = (e: KeyboardEvent) => { const handleKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") onClose();
@@ -28,6 +36,44 @@ export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageView
return () => document.removeEventListener("keydown", handleKey); return () => document.removeEventListener("keydown", handleKey);
}, [onClose]); }, [onClose]);
const src = imageUrl.startsWith("data:") || imageUrl.startsWith("http")
? imageUrl
: `data:image/png;base64,${imageUrl}`;
if (imagePaywalled) {
return (
<div
className={`${styles.viewer} ${styles.paywallViewer}`}
role="dialog"
aria-label="Locked fullscreen image"
>
<Image
src={src}
alt=""
fill
sizes="100vw"
className={styles.paywallImage}
/>
<div className={styles.paywallScrim} aria-hidden="true" />
<button
type="button"
className={styles.backButton}
onClick={onClose}
aria-label="Back"
>
<ChevronLeft size={34} strokeWidth={2.5} aria-hidden="true" />
</button>
<button
type="button"
className={styles.unlockButton}
onClick={onUnlockImagePaywall}
>
Unlock high-definition large image
</button>
</div>
);
}
return ( return (
<div <div
className={styles.viewer} className={styles.viewer}
@@ -36,7 +82,7 @@ export function FullscreenImageViewer({ imageUrl, onClose }: FullscreenImageView
aria-label="Fullscreen image" aria-label="Fullscreen image"
> >
<Image <Image
src={imageUrl.startsWith("data:") || imageUrl.startsWith("http") ? imageUrl : `data:image/png;base64,${imageUrl}`} src={src}
alt="" alt=""
width={800} width={800}
height={800} height={800}
@@ -4,6 +4,7 @@
max-width: 220px; max-width: 220px;
max-height: 220px; max-height: 220px;
border-radius: var(--radius-lg, 12px); border-radius: var(--radius-lg, 12px);
border-top-left-radius: 0;
overflow: hidden; overflow: hidden;
cursor: pointer; cursor: pointer;
background: var(--color-bubble-background, #fff); background: var(--color-bubble-background, #fff);
+15 -4
View File
@@ -2,8 +2,6 @@
/** /**
* ImageBubble 图片气泡 * ImageBubble 图片气泡
* *
* 原始 Dart: lib/ui/chat/widgets/image_bubble.dart63 行)
*
* 支持: * 支持:
* - base64 data URI 解码(`data:image/png;base64,...` * - base64 data URI 解码(`data:image/png;base64,...`
* - 点击 → 打开全屏查看器 * - 点击 → 打开全屏查看器
@@ -17,9 +15,15 @@ import styles from "./image-bubble.module.css";
export interface ImageBubbleProps { export interface ImageBubbleProps {
imageUrl: string; // base64 data URI 或 URL imageUrl: string; // base64 data URI 或 URL
imagePaywalled?: boolean;
onUnlockImagePaywall?: () => void;
} }
export function ImageBubble({ imageUrl }: ImageBubbleProps) { export function ImageBubble({
imageUrl,
imagePaywalled = false,
onUnlockImagePaywall,
}: ImageBubbleProps) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [error, setError] = useState(false); const [error, setError] = useState(false);
@@ -53,7 +57,14 @@ export function ImageBubble({ imageUrl }: ImageBubbleProps) {
/> />
)} )}
</div> </div>
{open && <FullscreenImageViewer imageUrl={imageUrl} onClose={() => setOpen(false)} />} {open && (
<FullscreenImageViewer
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
onUnlockImagePaywall={onUnlockImagePaywall}
onClose={() => setOpen(false)}
/>
)}
</> </>
); );
} }
@@ -18,22 +18,26 @@ export interface MessageBubbleProps {
id?: string; id?: string;
content: string; content: string;
imageUrl?: string | null; imageUrl?: string | null;
imagePaywalled?: boolean;
isFromAI: boolean; isFromAI: boolean;
privateLocked?: boolean | null; privateLocked?: boolean | null;
privateHint?: string | null; privateHint?: string | null;
isUnlockingPrivate?: boolean; isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void; onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockImagePaywall?: () => void;
} }
export function MessageBubble({ export function MessageBubble({
id, id,
content, content,
imageUrl, imageUrl,
imagePaywalled,
isFromAI, isFromAI,
privateLocked, privateLocked,
privateHint, privateHint,
isUnlockingPrivate, isUnlockingPrivate,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockImagePaywall,
}: MessageBubbleProps) { }: MessageBubbleProps) {
const { avatarUrl } = useUserState(); const { avatarUrl } = useUserState();
@@ -46,11 +50,13 @@ export function MessageBubble({
messageId={id} messageId={id}
content={content} content={content}
imageUrl={imageUrl} imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
isFromAI={true} isFromAI={true}
privateLocked={privateLocked} privateLocked={privateLocked}
privateHint={privateHint} privateHint={privateHint}
isUnlockingPrivate={isUnlockingPrivate} isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
/> />
<div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */} <div style={{ width: 43 }} /> {/* 占位:保持与头像宽度一致 */}
</div> </div>
@@ -64,11 +70,13 @@ export function MessageBubble({
messageId={id} messageId={id}
content={content} content={content}
imageUrl={imageUrl} imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
isFromAI={false} isFromAI={false}
privateLocked={privateLocked} privateLocked={privateLocked}
privateHint={privateHint} privateHint={privateHint}
isUnlockingPrivate={isUnlockingPrivate} isUnlockingPrivate={isUnlockingPrivate}
onUnlockPrivateMessage={onUnlockPrivateMessage} onUnlockPrivateMessage={onUnlockPrivateMessage}
onUnlockImagePaywall={onUnlockImagePaywall}
/> />
<div style={{ width: 8 }} /> <div style={{ width: 8 }} />
<MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} /> <MessageAvatar isFromAI={false} userAvatarUrl={avatarUrl} />
+11 -3
View File
@@ -2,8 +2,6 @@
/** /**
* MessageContent 消息内容容器 * MessageContent 消息内容容器
* *
* 原始 Dart: lib/ui/chat/widgets/message_content.dart43 行)
*
* 决定渲染 ImageBubble 还是 TextBubble * 决定渲染 ImageBubble 还是 TextBubble
* - 有 imageUrl:渲染 ImageBubble(图片) * - 有 imageUrl:渲染 ImageBubble(图片)
* - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字) * - 有 content 且非 "[图片]" 占位符:渲染 TextBubble(文字)
@@ -17,11 +15,13 @@ export interface MessageContentProps {
messageId?: string; messageId?: string;
content: string; content: string;
imageUrl?: string | null; imageUrl?: string | null;
imagePaywalled?: boolean;
isFromAI: boolean; isFromAI: boolean;
privateLocked?: boolean | null; privateLocked?: boolean | null;
privateHint?: string | null; privateHint?: string | null;
isUnlockingPrivate?: boolean; isUnlockingPrivate?: boolean;
onUnlockPrivateMessage?: (messageId: string) => void; onUnlockPrivateMessage?: (messageId: string) => void;
onUnlockImagePaywall?: () => void;
} }
const IMAGE_PLACEHOLDER = "[图片]"; const IMAGE_PLACEHOLDER = "[图片]";
@@ -30,11 +30,13 @@ export function MessageContent({
messageId, messageId,
content, content,
imageUrl, imageUrl,
imagePaywalled,
isFromAI, isFromAI,
privateLocked, privateLocked,
privateHint, privateHint,
isUnlockingPrivate = false, isUnlockingPrivate = false,
onUnlockPrivateMessage, onUnlockPrivateMessage,
onUnlockImagePaywall,
}: MessageContentProps) { }: MessageContentProps) {
const hasImage = imageUrl != null && imageUrl.length > 0; const hasImage = imageUrl != null && imageUrl.length > 0;
const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER; const hasText = content.length > 0 && content !== IMAGE_PLACEHOLDER;
@@ -65,7 +67,13 @@ export function MessageContent({
/> />
) : ( ) : (
<> <>
{hasImage && imageUrl && <ImageBubble imageUrl={imageUrl} />} {hasImage && imageUrl && (
<ImageBubble
imageUrl={imageUrl}
imagePaywalled={imagePaywalled}
onUnlockImagePaywall={onUnlockImagePaywall}
/>
)}
{hasText && <TextBubble content={content} isFromAI={isFromAI} />} {hasText && <TextBubble content={content} isFromAI={isFromAI} />}
</> </>
)} )}
@@ -3,6 +3,7 @@
padding: 14px; padding: 14px;
border: 1px solid rgba(246, 87, 160, 0.2); border: 1px solid rgba(246, 87, 160, 0.2);
border-radius: 18px; border-radius: 18px;
border-top-left-radius: 0;
background: background:
linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)), linear-gradient(180deg, rgba(255, 244, 248, 0.95), rgba(255, 255, 255, 0.95)),
#ffffff; #ffffff;
@@ -7,11 +7,11 @@
.link { .link {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--spacing-xs, 4px); justify-content: center;
width: 32px;
height: 32px;
color: #000000; color: #000000;
text-decoration: none; text-decoration: none;
font-size: var(--font-size-lg, 16px);
font-weight: 400;
background: none; background: none;
border: 0; border: 0;
padding: 0; padding: 0;
@@ -26,9 +26,3 @@
color: currentColor; color: currentColor;
flex-shrink: 0; flex-shrink: 0;
} }
.text {
color: currentColor;
font-size: var(--font-size-lg, 16px);
font-weight: 400;
}
+6 -16
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import { ChevronLeft } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
@@ -7,7 +8,7 @@ import { ROUTES } from "@/router/routes";
import styles from "./back-bar.module.css"; import styles from "./back-bar.module.css";
/** /**
* 顶部返回栏:chevron + "Back" 文字,链接至聊天页。 * 顶部返回栏:图标按钮,链接至聊天页。
* *
* 视觉规格(与设计稿对齐): * 视觉规格(与设计稿对齐):
* - 黑色文本(浅色主题下) * - 黑色文本(浅色主题下)
@@ -21,23 +22,12 @@ export function BackBar() {
className={styles.link} className={styles.link}
aria-label="Back" aria-label="Back"
> >
<svg <ChevronLeft
width="20" size={24}
height="20" strokeWidth={2.5}
viewBox="0 0 24 24"
fill="none"
aria-hidden="true" aria-hidden="true"
className={styles.icon} className={styles.icon}
> />
<path
d="M15 6l-6 6 6 6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<span className={styles.text}>Back</span>
</Link> </Link>
</header> </header>
); );
@@ -88,22 +88,26 @@
} }
.activateBtn { .activateBtn {
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px); padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px); border-radius: 22px;
background: linear-gradient( background-image:
90deg, linear-gradient(
var(--color-button-gradient-start, #ff67e0), 135deg,
var(--color-button-gradient-end, #ff52a2) #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); color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-md, 14px); font-size: var(--font-size-md, 14px);
font-weight: 600; font-weight: 600;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -74,22 +74,20 @@
} }
.buyBtn { .buyBtn {
display: flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--spacing-md, 12px) var(--spacing-lg, 16px); padding: var(--spacing-md, 12px) var(--spacing-lg, 16px);
border-radius: var(--radius-full, 999px); border-radius: 22px;
background: linear-gradient( background-image:
90deg, linear-gradient(91deg, #f67382 0%, #f66690 44%, #f657a0 100%),
var(--color-button-gradient-start, #ff67e0), linear-gradient(#f69757, #f69757);
var(--color-button-gradient-end, #ff52a2) background-blend-mode: normal, normal;
);
color: var(--color-text-primary, #ffffff); color: var(--color-text-primary, #ffffff);
font-size: var(--font-size-md, 14px); font-size: var(--font-size-md, 14px);
font-weight: 600; font-weight: 600;
border: 0; border: 0;
cursor: pointer; cursor: pointer;
width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -19,7 +19,6 @@ export interface VoicePackageCardProps {
* 视觉规格(与设计稿对齐): * 视觉规格(与设计稿对齐):
* - 白色卡片 + 粉色→橙色渐变描边 * - 白色卡片 + 粉色→橙色渐变描边
* - 标题 "Voice Message Package"(粉色加粗) * - 标题 "Voice Message Package"(粉色加粗)
* - 右上角大圆形渐变麦克风图标
* - "0min/0min used/remaining" 文案(前缀粉色加粗) * - "0min/0min used/remaining" 文案(前缀粉色加粗)
* - 底部 "Buy Package" 粉色胶囊按钮 * - 底部 "Buy Package" 粉色胶囊按钮
*/ */
+1
View File
@@ -8,6 +8,7 @@ export * from "./subscription-benefits-card";
export * from "./subscription-checkout-button"; export * from "./subscription-checkout-button";
export * from "./subscription-cta-button"; export * from "./subscription-cta-button";
export * from "./subscription-payment-method"; export * from "./subscription-payment-method";
export * from "./subscription-payment-success-dialog";
export * from "./subscription-plan-card"; export * from "./subscription-plan-card";
export * from "./subscription-user-row"; export * from "./subscription-user-row";
export * from "./stripe-payment-dialog"; export * from "./stripe-payment-dialog";
@@ -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);
}
@@ -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 (
<div
className={styles.overlay}
role="alertdialog"
aria-modal="true"
aria-labelledby="subscription-payment-success-title"
>
<div className={styles.dialog}>
<div className={styles.badge} aria-hidden="true">
</div>
<h2 id="subscription-payment-success-title" className={styles.title}>
Payment successful
</h2>
<p className={styles.content}>{content}</p>
<button type="button" className={styles.button} onClick={onClose}>
OK
</button>
</div>
</div>
);
}
+19 -1
View File
@@ -14,7 +14,7 @@
* 7. 主操作按钮 * 7. 主操作按钮
* 8. 协议复选框 * 8. 协议复选框
*/ */
import { useEffect, useMemo, useRef } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Checkbox, MobileShell } from "@/app/_components/core"; import { Checkbox, MobileShell } from "@/app/_components/core";
import { AppConstants } from "@/core/app_constants"; import { AppConstants } from "@/core/app_constants";
@@ -32,6 +32,7 @@ import {
SubscriptionBenefitsCard, SubscriptionBenefitsCard,
SubscriptionCheckoutButton, SubscriptionCheckoutButton,
SubscriptionPaymentMethod, SubscriptionPaymentMethod,
SubscriptionPaymentSuccessDialog,
SubscriptionPlanCard, SubscriptionPlanCard,
SubscriptionUserRow, SubscriptionUserRow,
} from "./components"; } from "./components";
@@ -61,6 +62,9 @@ export function SubscriptionScreen({
const paymentDispatch = usePaymentDispatch(); const paymentDispatch = usePaymentDispatch();
const refreshedPaidOrderRef = useRef<string | null>(null); const refreshedPaidOrderRef = useRef<string | null>(null);
const resumedPendingOrderRef = useRef<string | null>(null); const resumedPendingOrderRef = useRef<string | null>(null);
const successDialogShownOrderRef = useRef<string | null>(null);
const [showPaymentSuccessDialog, setShowPaymentSuccessDialog] =
useState(false);
useEffect(() => { useEffect(() => {
if (payment.status === "idle") { if (payment.status === "idle") {
@@ -75,6 +79,14 @@ export function SubscriptionScreen({
userDispatch({ type: "UserFetch" }); userDispatch({ type: "UserFetch" });
}, [payment.currentOrderId, payment.isPaid, userDispatch]); }, [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(() => { useEffect(() => {
const canInspectPendingOrder = const canInspectPendingOrder =
payment.status === "ready" || payment.status === "ready" ||
@@ -296,6 +308,12 @@ export function SubscriptionScreen({
} }
/> />
</div> </div>
<SubscriptionPaymentSuccessDialog
open={showPaymentSuccessDialog}
subscriptionType={subscriptionType}
onClose={() => setShowPaymentSuccessDialog(false)}
/>
</div> </div>
</MobileShell> </MobileShell>
); );
+105 -13
View File
@@ -1,10 +1,6 @@
/** /**
* 聊天 WebSocket 处理器 * 聊天 WebSocket 处理器
* *
* 原始 Dart: lib/core/net/websocket_handler.dart
*
* 浏览器方案:使用原生 `WebSocket` API,封装连接管理 + 重连 + 事件回调。
*
* 事件: * 事件:
* - onConnected(userId) * - onConnected(userId)
* - onTyping(isTyping) * - onTyping(isTyping)
@@ -15,6 +11,10 @@
* - onError(errorMessage) * - onError(errorMessage)
*/ */
import { getApiConfig } from "@/core/net/config/api_config"; import { getApiConfig } from "@/core/net/config/api_config";
import { AppEnvUtil, Logger } from "@/utils";
const log = new Logger("ChatWebSocket");
const RECONNECT_DELAY_MS = 3000;
export interface SentencePayload { export interface SentencePayload {
index: number; index: number;
@@ -39,6 +39,7 @@ export interface PaywallStatusPayload {
export class ChatWebSocket { export class ChatWebSocket {
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null; private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private connectionUrl: string | null = null;
private disposed = false; private disposed = false;
onConnected: ((userId: string) => void) | null = null; onConnected: ((userId: string) => void) | null = null;
@@ -64,26 +65,54 @@ export class ChatWebSocket {
if (this.disposed) return; if (this.disposed) return;
if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return; if (this.ws && this.ws.readyState !== WebSocket.CLOSED) return;
try { try {
const url = `${this.serverUrl}?token=${encodeURIComponent(this.token)}`; const url = buildChatWebSocketUrl(this.serverUrl, this.token);
this.connectionUrl = url;
logWebSocketDebug(`↔ WS CONNECT ${redactWebSocketUrl(url)}`);
this.ws = new WebSocket(url); this.ws = new WebSocket(url);
this.ws.onopen = () => { this.ws.onopen = () => {
logWebSocketDebug(`↔ WS OPEN ${redactWebSocketUrl(url)}`);
// 连接成功;userId 由后端在第一条消息中下发 // 连接成功;userId 由后端在第一条消息中下发
}; };
this.ws.onmessage = (e) => this.handleMessage(e.data); this.ws.onmessage = (e) => {
this.ws.onerror = () => this.onError?.("WebSocket error"); logWebSocketFrame("← WS MESSAGE", url, e.data);
this.ws.onclose = () => { 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) { if (!this.disposed) {
this.reconnectTimer = setTimeout(() => this.connect(), 3000); this.reconnectTimer = setTimeout(
() => this.connect(),
RECONNECT_DELAY_MS,
);
} }
}; };
} catch (e) { } catch (e) {
logWebSocketError("✕ WS CONNECT FAILED", e);
this.onError?.(e instanceof Error ? e.message : String(e)); this.onError?.(e instanceof Error ? e.message : String(e));
} }
} }
sendMessage(content: string): boolean { sendMessage(content: string): boolean {
if (!this.isConnected) return false; const payload = { type: "message", content };
this.ws?.send(JSON.stringify({ 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; return true;
} }
@@ -95,10 +124,14 @@ export class ChatWebSocket {
} }
this.ws?.close(); this.ws?.close();
this.ws = null; this.ws = null;
this.connectionUrl = null;
} }
private handleMessage(data: unknown): void { 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: { let payload: {
type?: string; type?: string;
userId?: string; userId?: string;
@@ -122,7 +155,8 @@ export class ChatWebSocket {
}; };
try { try {
payload = JSON.parse(data); payload = JSON.parse(data);
} catch { } catch (e) {
logWebSocketError("← WS MESSAGE PARSE FAILED", e);
return; return;
} }
switch (payload.type) { switch (payload.type) {
@@ -173,6 +207,64 @@ export class ChatWebSocket {
} }
} }
function buildChatWebSocketUrl(serverUrl: string, token: string): string {
if (AppEnvUtil.isDevelopment()) return serverUrl;
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", "<redacted>");
}
return parsed.toString();
} catch {
return url.replace(/([?&]token=)[^&]+/i, "$1<redacted>");
}
}
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。 */ /** 默认创建实例:从 env 读取 WS URL。 */
export function createChatWebSocket(token: string): ChatWebSocket { export function createChatWebSocket(token: string): ChatWebSocket {
const base = getApiConfig().wsUrl; const base = getApiConfig().wsUrl;
+3
View File
@@ -26,6 +26,9 @@ export class ChatSendResponse {
declare readonly paywallTriggered: boolean; declare readonly paywallTriggered: boolean;
declare readonly showUpgrade: boolean; declare readonly showUpgrade: boolean;
declare readonly imageType: string | null; 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; declare readonly imageUrl: string | null;
private constructor(input: ChatSendResponseInput) { private constructor(input: ChatSendResponseInput) {
+2
View File
@@ -15,6 +15,8 @@ export const UiMessageSchema = z.object({
date: z.string(), date: z.string(),
/** 图片 URLbase64 data URL 或 http URL */ /** 图片 URLbase64 data URL 或 http URL */
imageUrl: z.string().optional(), imageUrl: z.string().optional(),
/** true = 图片可在列表展示,但全屏查看时需要会员解锁 */
imagePaywalled: z.boolean().optional(),
/** 语音 URL */ /** 语音 URL */
voiceUrl: z.string().optional(), voiceUrl: z.string().optional(),
isPrivate: z.boolean().nullable().optional(), isPrivate: z.boolean().nullable().optional(),
@@ -30,6 +30,9 @@ export const ChatSendResponseSchema = z.object({
paywallTriggered: z.boolean().default(false), paywallTriggered: z.boolean().default(false),
showUpgrade: z.boolean().default(false), showUpgrade: z.boolean().default(false),
imageType: z.string().nullable().default(null), 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), imageUrl: z.string().nullable().default(null),
}); });
-2
View File
@@ -41,8 +41,6 @@ export function getActiveChatWebSocket(): ChatWebSocket | null {
return activeChatWebSocket; return activeChatWebSocket;
} }
// 本地游客消息额度 actor 已停用:消息数量限制统一交由后端 blocked/daily_limit 响应处理。
// ============================================================ // ============================================================
// Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果) // Init 任务 2:拉 history 3 步流(fromPromise,返回最终结果)
// ============================================================ // ============================================================
+30 -26
View File
@@ -1,22 +1,3 @@
/**
* Chat 状态机:纯函数 + 数据加载
*
* 从 `chat-machine.ts` 抽出的"helpers"段:
* - 翻页大小常量
* - 仓库注入(接口类型 → 单例)
* - DTO ↔ UiMessage 映射(纯函数 —— 可单测)
* - `readAndSyncHistory()` —— local → network → save network to local 3 步
*
* 设计目标:
* - helpers 无 XState 依赖(可独立 import / 测试)
* - actors 依赖 helpersimport
* - machine 依赖 actors,并复用 helpers 里的纯状态转换函数
*
* 历史:
* - `readInitData` + `InitResult` 已删(被 2 个独立 helper 替换)
* - `AuthStorage` import 已删(只 `readInitData` 用过,移到 `chatInit` actor 上层调用)
*/
import type { UiMessage } from "@/data/dto/chat"; import type { UiMessage } from "@/data/dto/chat";
import { chatRepository } from "@/data/repositories/chat_repository"; import { chatRepository } from "@/data/repositories/chat_repository";
import type { IChatRepository } from "@/data/repositories/interfaces"; import type { IChatRepository } from "@/data/repositories/interfaces";
@@ -61,7 +42,11 @@ export function localMessagesToUi(
): UiMessage[] { ): UiMessage[] {
return records.map((m) => ({ return records.map((m) => ({
...(m.id ? { id: m.id } : {}), ...(m.id ? { id: m.id } : {}),
content: m.content, content: getAiMessageDisplayContent({
content: m.content,
isFromAI: m.role === "assistant",
imageUrl: m.imageUrl,
}),
isFromAI: m.role === "assistant", isFromAI: m.role === "assistant",
date: messageDateFromCreatedAt(m.createdAt), date: messageDateFromCreatedAt(m.createdAt),
...(m.imageUrl ? { imageUrl: m.imageUrl } : {}), ...(m.imageUrl ? { imageUrl: m.imageUrl } : {}),
@@ -77,6 +62,14 @@ function messageDateFromCreatedAt(createdAt: string): string {
return todayString(parsed); return todayString(parsed);
} }
function getAiMessageDisplayContent(input: {
content: string;
isFromAI: boolean;
imageUrl?: string | null;
}): string {
return input.isFromAI && input.imageUrl ? "" : input.content;
}
/** /**
* ChatSendResponse → UiMessage(纯函数) * ChatSendResponse → UiMessage(纯函数)
* - 业务事实:后端响应就是 AI 的回复 * - 业务事实:后端响应就是 AI 的回复
@@ -85,10 +78,24 @@ function messageDateFromCreatedAt(createdAt: string): string {
export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage { export function sendResponseToUiMessage(response: ChatSendResponse): UiMessage {
return { return {
...(response.messageId ? { id: response.messageId } : {}), ...(response.messageId ? { id: response.messageId } : {}),
content: response.reply, content: getAiMessageDisplayContent({
content: response.reply,
isFromAI: true,
imageUrl: response.imageUrl,
}),
isFromAI: true, isFromAI: true,
date: todayString(new Date(response.timestamp)), date: todayString(new Date(response.timestamp)),
...(response.imageUrl ? { imageUrl: response.imageUrl } : {}), ...(response.imageUrl ? { imageUrl: response.imageUrl } : {}),
...(response.showUpgrade && response.imageUrl
? { imagePaywalled: true }
: {}),
...(response.isPrivate != null ? { isPrivate: response.isPrivate } : {}),
...(response.privateLocked != null
? { privateLocked: response.privateLocked }
: {}),
...(response.privateHint != null
? { privateHint: response.privateHint }
: {}),
}; };
} }
@@ -147,8 +154,8 @@ export function applyHttpSendOutput(
return { return {
messages: [...context.messages, reply], messages: [...context.messages, reply],
isReplyingAI: false, isReplyingAI: false,
paywallTriggered: true, paywallTriggered: false,
paywallReason: "photo_paywall", paywallReason: null,
paywallDetail: null, paywallDetail: null,
}; };
} }
@@ -162,9 +169,6 @@ export function applyHttpSendOutput(
}; };
} }
// 本地游客消息额度读取 / 映射逻辑已停用:
// 消息数量限制统一由后端接口返回 blocked/daily_limit,前端不再读取 ChatStorage quota。
// ============================================================ // ============================================================
// Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调 // Init 任务 2history 3 步流(local → network → save)—— `loadHistoryActor` 调
// ============================================================ // ============================================================
+1
View File
@@ -243,6 +243,7 @@ export const chatMachine = setup({
if (last?.isFromAI && !last.imageUrl) { if (last?.isFromAI && !last.imageUrl) {
messages[messages.length - 1] = { messages[messages.length - 1] = {
...last, ...last,
content: "",
imageUrl: event.imageUrl, imageUrl: event.imageUrl,
}; };
} else { } else {
+1 -1
View File
@@ -51,7 +51,7 @@
--auth-legal-checkbox-inner-size: 10px; --auth-legal-checkbox-inner-size: 10px;
/* 悬浮返回按钮(40×40 圆形) */ /* 悬浮返回按钮(40×40 圆形) */
--auth-back-button-size: 40px; --back-button-size: 40px;
/* 底部弹层圆角(28px = Dart AppRadius.radius28 */ /* 底部弹层圆角(28px = Dart AppRadius.radius28 */
--auth-bottom-sheet-radius: 28px; --auth-bottom-sheet-radius: 28px;