refactor(app): tighten chat and sync boundaries

This commit is contained in:
2026-07-01 14:03:25 +08:00
parent f6e7adbe96
commit 760e4fe343
19 changed files with 215 additions and 677 deletions
+12 -79
View File
@@ -7,18 +7,12 @@ import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import { ROUTES } from "@/router/routes";
import {
openChatInExternalBrowser,
recordExternalBrowserPromptShown,
resolveExternalBrowserPromptEligibility,
} from "@/lib/chat/chat_external_browser";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
savePendingChatUnlock,
type PendingChatUnlockKind,
} from "@/lib/navigation/chat_unlock_session";
import { MobileShell } from "@/app/_components/core";
@@ -27,7 +21,7 @@ import {
ChatArea,
ChatHeader,
ChatInputBar,
ChatQuotaExhaustedBanner,
ChatInsufficientCreditsBanner,
ExternalBrowserDialog,
HistoryUnlockDialog,
InsufficientCreditsDialog,
@@ -40,6 +34,7 @@ import {
isChatDevelopmentEnvironment,
shouldStartExternalBrowserPrompt,
} from "./chat-screen.helpers";
import { useChatUnlockNavigationFlow } from "./hooks/use-chat-unlock-navigation-flow";
import styles from "./components/chat-screen.module.css";
export function ChatScreen() {
@@ -50,13 +45,15 @@ export function ChatScreen() {
const router = useRouter();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false);
const unlockPaywallRequest = state.unlockPaywallRequest;
const {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({ returnUrl: ROUTES.chat });
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus);
const isAuthenticatedUser =
authState.loginStatus !== "notLoggedIn" &&
authState.loginStatus !== "guest";
// 发送能力由后端统一返回,当前只处理积分不足引导。
const showMessageLimitBanner =
@@ -108,75 +105,11 @@ export function ChatScreen() {
authState.loginStatus,
]);
useEffect(() => {
if (!state.historyLoaded || !isAuthenticatedUser) return;
const pending = peekPendingChatUnlock();
if (!pending || pending.returnUrl !== ROUTES.chat) return;
const consumed = consumePendingChatUnlock();
if (!consumed) return;
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
kind: consumed.kind,
});
}, [
chatDispatch,
isAuthenticatedUser,
state.historyLoaded,
]);
function handleCloseInsufficientCreditsDialog(): void {
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
}
function handleConfirmInsufficientCreditsDialog(): void {
if (!unlockPaywallRequest) return;
savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl: ROUTES.chat,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" },
),
);
}
async function handleOpenExternalBrowser(): Promise<void> {
setShowExternalBrowserDialog(false);
await openChatInExternalBrowser();
}
function requestMessageUnlock(
messageId: string,
kind: PendingChatUnlockKind,
): void {
if (!isAuthenticatedUser) {
savePendingChatUnlock({
messageId,
kind,
returnUrl: ROUTES.chat,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(ROUTES.chat));
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
kind,
});
}
function handleUnlockPrivateMessage(messageId: string): void {
requestMessageUnlock(messageId, "private");
}
@@ -222,7 +155,7 @@ export function ChatScreen() {
/>
{showMessageLimitBanner ? (
<ChatQuotaExhaustedBanner
<ChatInsufficientCreditsBanner
title={messageLimitTitle}
ctaLabel={messageLimitCtaLabel}
onUnlock={handleMessageLimitUnlock}
@@ -258,8 +191,8 @@ export function ChatScreen() {
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={handleCloseInsufficientCreditsDialog}
onConfirm={handleConfirmInsufficientCreditsDialog}
onClose={closeInsufficientCreditsDialog}
onConfirm={confirmInsufficientCreditsDialog}
/>
</div>
</MobileShell>
@@ -1,50 +0,0 @@
.panel {
min-height: 170px;
padding: 24px 16px 20px;
border-top: 1px solid rgba(246, 87, 160, 0.14);
background: #fff1f4;
}
.voiceCard {
width: 142px;
min-height: 153px;
padding: 12px 12px 14px;
border: 1px solid rgba(30, 30, 30, 0.08);
border-radius: 18px;
background: #ffffff;
box-shadow: 0 1px 2px rgba(30, 30, 30, 0.03);
color: #1e1e1e;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
}
.voiceCard:focus-visible {
outline: 2px solid var(--color-accent, #f84d96);
outline-offset: 3px;
}
.voiceIcon {
display: block;
width: 78px;
height: 78px;
object-fit: contain;
}
.voiceTitle,
.voiceMinutes {
font-family: var(--font-athelas), Athelas, serif;
font-size: 14px;
line-height: 1.08;
text-align: center;
}
.voiceTitle {
margin-top: 8px;
}
.voiceMinutes {
color: var(--color-accent, #f84d96);
}
@@ -1,37 +0,0 @@
"use client";
import Image from "next/image";
import styles from "./chat-input-action-panel.module.css";
export interface ChatInputActionPanelProps {
voiceMinutesRemaining: number;
onVoiceMessageClick?: () => void;
}
export function ChatInputActionPanel({
voiceMinutesRemaining,
onVoiceMessageClick,
}: ChatInputActionPanelProps) {
return (
<div className={styles.panel}>
<button
type="button"
className={styles.voiceCard}
onClick={onVoiceMessageClick}
aria-label={`Voice message, ${voiceMinutesRemaining} minutes remaining`}
>
<Image
src="/images/chat/ic_voicemessage_act.png"
alt=""
width={78}
height={78}
className={styles.voiceIcon}
aria-hidden="true"
/>
<span className={styles.voiceTitle}>voice message</span>
<span className={styles.voiceMinutes}>({voiceMinutesRemaining}min)</span>
</button>
</div>
);
}
@@ -1,4 +1,4 @@
/* ChatQuotaExhaustedBanner 游客配额耗尽横幅
/* ChatInsufficientCreditsBanner 积分不足横幅
* 视觉规格与设计稿对齐
* - 品红渐变背景 splash gradient 一致
* - 居中文案 + 渐变 pill 按钮
@@ -1,6 +1,6 @@
"use client";
/**
* ChatQuotaExhaustedBanner
* ChatInsufficientCreditsBanner
*
*
* - cannotSendReason="insufficient_credits"
@@ -8,38 +8,36 @@
* 稿
* - splash
* - /
* - pill /subscription
* - pill /subscription?type=topup
*
*
* - `src/app/sidebar/components/vip-benefits-card.tsx` "Activate VIP Membership"
* - VIP
* - top up
*
*
* - state machinechat UI
* - total<=0 "耗尽"
*/
import { useRouter } from "next/navigation";
import { ROUTES } from "@/router/routes";
import { ROUTE_BUILDERS } from "@/router/routes";
import styles from "./chat-quota-exhausted-banner.module.css";
import styles from "./chat-insufficient-credits-banner.module.css";
export interface ChatQuotaExhaustedBannerProps {
export interface ChatInsufficientCreditsBannerProps {
title?: string;
ctaLabel?: string;
/**
* router.push(ROUTES.subscription)
* router.push(topup subscription)
* - mock
* - nested overlay
*/
onUnlock?: () => void;
}
export function ChatQuotaExhaustedBanner({
export function ChatInsufficientCreditsBanner({
title = "Insufficient credits\nTop up to continue chatting",
ctaLabel = "Top up credits to continue",
onUnlock,
}: ChatQuotaExhaustedBannerProps) {
}: ChatInsufficientCreditsBannerProps) {
const router = useRouter();
const titleLines = title.split("\n");
@@ -48,7 +46,7 @@ export function ChatQuotaExhaustedBanner({
onUnlock();
return;
}
router.push(`${ROUTES.subscription}?type=vip`);
router.push(ROUTE_BUILDERS.subscription("topup"));
};
return (
@@ -56,7 +54,7 @@ export function ChatQuotaExhaustedBanner({
className={styles.banner}
role="status"
aria-live="polite"
data-testid="chat-quota-exhausted-banner"
data-testid="chat-insufficient-credits-banner"
>
<p className={styles.text}>
{titleLines.map((line, index) => (
+1 -2
View File
@@ -6,10 +6,9 @@ export * from "./ai-disclosure-banner";
export * from "./browser-hint-overlay";
export * from "./chat-area";
export * from "./chat-header";
export * from "./chat-input-action-panel";
export * from "./chat-insufficient-credits-banner";
export * from "./chat-input-bar";
export * from "./chat-input-text-field";
export * from "./chat-quota-exhausted-banner";
export * from "./chat-send-button";
export * from "./date-header";
export * from "./external-browser-dialog";
@@ -0,0 +1,164 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { ROUTE_BUILDERS } from "@/router/routes";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import type { ChatUnlockPaywallRequest } from "@/stores/chat/chat-state";
import { useUserState } from "@/stores/user/user-context";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
savePendingChatUnlock,
type PendingChatUnlock,
type PendingChatUnlockKind,
} from "@/lib/navigation/chat_unlock_session";
import { getInsufficientCreditsSubscriptionType } from "../chat-screen.helpers";
export interface UseChatUnlockNavigationFlowInput {
returnUrl: string;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
}
export interface UseChatUnlockNavigationFlowOutput {
unlockPaywallRequest: ChatUnlockPaywallRequest | null;
requestMessageUnlock: (
messageId: string,
kind: PendingChatUnlockKind,
) => void;
closeInsufficientCreditsDialog: () => void;
confirmInsufficientCreditsDialog: () => void;
}
export function useChatUnlockNavigationFlow({
returnUrl,
expectedKind,
expectedMessageId,
}: UseChatUnlockNavigationFlowInput): UseChatUnlockNavigationFlowOutput {
const router = useRouter();
const authState = useAuthState();
const userState = useUserState();
const chatState = useChatState();
const chatDispatch = useChatDispatch();
const isAuthenticatedUser =
authState.loginStatus !== "notLoggedIn" &&
authState.loginStatus !== "guest";
const unlockPaywallRequest = getScopedUnlockPaywallRequest({
request: chatState.unlockPaywallRequest,
expectedKind,
expectedMessageId,
});
useEffect(() => {
if (!chatState.historyLoaded || !isAuthenticatedUser) return;
const pending = peekPendingChatUnlock();
if (
!pending ||
pending.returnUrl !== returnUrl ||
!matchesPendingUnlockScope({
pending,
expectedKind,
expectedMessageId,
})
) {
return;
}
const consumed = consumePendingChatUnlock();
if (!consumed) return;
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
kind: consumed.kind,
});
}, [
chatDispatch,
chatState.historyLoaded,
expectedKind,
expectedMessageId,
isAuthenticatedUser,
returnUrl,
]);
function requestMessageUnlock(
messageId: string,
kind: PendingChatUnlockKind,
): void {
if (!isAuthenticatedUser) {
savePendingChatUnlock({
messageId,
kind,
returnUrl,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
kind,
});
}
function closeInsufficientCreditsDialog(): void {
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
}
function confirmInsufficientCreditsDialog(): void {
if (!unlockPaywallRequest) return;
savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" },
),
);
}
return {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
};
}
function getScopedUnlockPaywallRequest(input: {
request: ChatUnlockPaywallRequest | null;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
}): ChatUnlockPaywallRequest | null {
const { request, expectedKind, expectedMessageId } = input;
if (!request) return null;
if (expectedKind && request.kind !== expectedKind) return null;
if (expectedMessageId && request.messageId !== expectedMessageId) return null;
return request;
}
function matchesPendingUnlockScope(input: {
pending: PendingChatUnlock;
expectedKind?: PendingChatUnlockKind;
expectedMessageId?: string;
}): boolean {
const { pending, expectedKind, expectedMessageId } = input;
if (expectedKind && pending.kind !== expectedKind) return false;
if (expectedMessageId && pending.messageId !== expectedMessageId) {
return false;
}
return true;
}
@@ -1,25 +1,17 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { useUserState } from "@/stores/user/user-context";
import {
consumePendingChatUnlock,
peekPendingChatUnlock,
savePendingChatUnlock,
} from "@/lib/navigation/chat_unlock_session";
import { getInsufficientCreditsSubscriptionType } from "../../chat-screen.helpers";
import {
FullscreenImageViewer,
HistoryUnlockDialog,
InsufficientCreditsDialog,
} from "../../components";
import { useChatUnlockNavigationFlow } from "../../hooks/use-chat-unlock-navigation-flow";
import styles from "./chat-image-viewer-screen.module.css";
export interface ChatImageViewerScreenProps {
@@ -30,95 +22,29 @@ export function ChatImageViewerScreen({
messageId,
}: ChatImageViewerScreenProps) {
const router = useRouter();
const authState = useAuthState();
const userState = useUserState();
const chatState = useChatState();
const chatDispatch = useChatDispatch();
const returnUrl = ROUTE_BUILDERS.chatImage(messageId);
const unlockPaywallRequest =
chatState.unlockPaywallRequest?.kind === "image" &&
chatState.unlockPaywallRequest.messageId === messageId
? chatState.unlockPaywallRequest
: null;
const isAuthenticatedUser =
authState.loginStatus !== "notLoggedIn" &&
authState.loginStatus !== "guest";
const {
unlockPaywallRequest,
requestMessageUnlock,
closeInsufficientCreditsDialog,
confirmInsufficientCreditsDialog,
} = useChatUnlockNavigationFlow({
returnUrl,
expectedKind: "image",
expectedMessageId: messageId,
});
const message = chatState.messages.find(
(item) => item.id === messageId && item.imageUrl,
);
useEffect(() => {
if (!chatState.historyLoaded || !isAuthenticatedUser) return;
const pending = peekPendingChatUnlock();
if (
!pending ||
pending.kind !== "image" ||
pending.messageId !== messageId ||
pending.returnUrl !== returnUrl
) {
return;
}
const consumed = consumePendingChatUnlock();
if (!consumed) return;
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId: consumed.messageId,
kind: consumed.kind,
});
}, [
chatDispatch,
chatState.historyLoaded,
isAuthenticatedUser,
messageId,
returnUrl,
]);
const handleCloseInsufficientCreditsDialog = () => {
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
};
const handleConfirmInsufficientCreditsDialog = () => {
if (!unlockPaywallRequest) return;
savePendingChatUnlock({
messageId: unlockPaywallRequest.messageId,
kind: unlockPaywallRequest.kind,
returnUrl,
stage: "payment",
});
chatDispatch({ type: "ChatUnlockPaywallNavigationConsumed" });
router.push(
ROUTE_BUILDERS.subscription(
getInsufficientCreditsSubscriptionType(userState.isVip),
{ returnTo: "chat" },
),
);
};
const handleClose = () => {
router.push(ROUTES.chat);
};
const handleUnlockImagePaywall = () => {
if (!isAuthenticatedUser) {
savePendingChatUnlock({
messageId,
kind: "image",
returnUrl,
stage: "auth",
});
router.push(ROUTE_BUILDERS.authWithRedirect(returnUrl));
return;
}
chatDispatch({
type: "ChatUnlockMessageRequested",
messageId,
kind: "image",
});
requestMessageUnlock(messageId, "image");
};
const historyUnlockDialog = (
@@ -136,8 +62,8 @@ export function ChatImageViewerScreen({
creditBalance={unlockPaywallRequest?.creditBalance ?? 0}
requiredCredits={unlockPaywallRequest?.requiredCredits ?? 0}
shortfallCredits={unlockPaywallRequest?.shortfallCredits ?? 0}
onClose={handleCloseInsufficientCreditsDialog}
onConfirm={handleConfirmInsufficientCreditsDialog}
onClose={closeInsufficientCreditsDialog}
onConfirm={confirmInsufficientCreditsDialog}
/>
</>
);