Merge branch 'dev' into test
This commit is contained in:
@@ -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",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -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;
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
/**
|
/**
|
||||||
* ChatArea 消息列表区
|
* ChatArea 消息列表区
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/chat/widgets/chat_area.dart(153 行)
|
|
||||||
*
|
|
||||||
* 组成(自上而下):
|
* 组成(自上而下):
|
||||||
* - 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);
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
/**
|
/**
|
||||||
* ImageBubble 图片气泡
|
* ImageBubble 图片气泡
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/chat/widgets/image_bubble.dart(63 行)
|
|
||||||
*
|
|
||||||
* 支持:
|
* 支持:
|
||||||
* - 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} />
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
/**
|
/**
|
||||||
* MessageContent 消息内容容器
|
* MessageContent 消息内容容器
|
||||||
*
|
*
|
||||||
* 原始 Dart: lib/ui/chat/widgets/message_content.dart(43 行)
|
|
||||||
*
|
|
||||||
* 决定渲染 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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" 粉色胶囊按钮
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export const UiMessageSchema = z.object({
|
|||||||
date: z.string(),
|
date: z.string(),
|
||||||
/** 图片 URL(base64 data URL 或 http URL) */
|
/** 图片 URL(base64 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),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,返回最终结果)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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 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 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
// Init 任务 2:history 3 步流(local → network → save)—— `loadHistoryActor` 调
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user