Merge branch 'dev' into test

This commit is contained in:
2026-06-23 15:48:37 +08:00
11 changed files with 104 additions and 35 deletions
+2 -2
View File
@@ -3,8 +3,8 @@ NEXT_PUBLIC_APP_ENV=test
# NextAuth v4 —— OAuth callback **公**网 base URL**必**须与**公**网域名**一**致) # NextAuth v4 —— OAuth callback **公**网 base URL**必**须与**公**网域名**一**致)
NEXTAUTH_URL=https://frontend-test.banlv-ai.com NEXTAUTH_URL=https://frontend-test.banlv-ai.com
NEXTAUTH_URL_INTERNAL=http://localhost:3000 NEXTAUTH_URL_INTERNAL=http://localhost:3000
NEXT_PUBLIC_API_BASE_URL=https://testapi.banlv-ai.com NEXT_PUBLIC_API_BASE_URL=https://proapi.banlv-ai.com
NEXT_PUBLIC_WS_BASE_URL=wss://testapi.banlv-ai.com/ws NEXT_PUBLIC_WS_BASE_URL=wss://proapi.banlv-ai.com/ws
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51Te8ybEJDtUGxQHo0UMySJ2hW0vVryynzllmIUI3UQdQCFgxNLci0Jmm2lQkRsTaIP7C7IQlFzfFyBJ5rOhr1ML800G8P8DF5R
NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000 NEXT_PUBLIC_API_CONNECT_TIMEOUT=30000
+10 -4
View File
@@ -43,10 +43,15 @@ export function ChatScreen() {
// isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段) // isGuest 派生(chat-screen 是唯一知道这个值的地方 —— chat 机器无 isGuest 字段)
const isGuest = deriveIsGuest(authState.loginStatus); const isGuest = deriveIsGuest(authState.loginStatus);
// 消息数量限制由后端统一返回 blocked/daily_limit,前端不再处理本地额度。 // 消息数量限制由后端统一返回 blocked,前端不再处理本地额度。
const showDailyLimitBanner = const showMessageLimitBanner =
state.paywallTriggered && state.paywallTriggered &&
state.paywallReason === "daily_limit"; (state.paywallReason === "daily_limit" ||
state.paywallReason === "total_limit");
const messageLimitTitle =
state.paywallReason === "total_limit"
? "The limit for free chat times\nhas been reached"
: undefined;
const showPhotoPaywallBanner = const showPhotoPaywallBanner =
state.paywallTriggered && state.paywallReason === "photo_paywall"; state.paywallTriggered && state.paywallReason === "photo_paywall";
const showPrivatePaywallBanner = const showPrivatePaywallBanner =
@@ -168,8 +173,9 @@ export function ChatScreen() {
/> />
) : null} ) : null}
{showDailyLimitBanner ? ( {showMessageLimitBanner ? (
<ChatQuotaExhaustedBanner <ChatQuotaExhaustedBanner
title={messageLimitTitle}
/> />
) : ( ) : (
<ChatInputBar disabled={state.isReplyingAI} /> <ChatInputBar disabled={state.isReplyingAI} />
@@ -3,10 +3,11 @@
* 订阅页返回箭头 * 订阅页返回箭头
* *
* 视觉规格(与设计稿对齐): * 视觉规格(与设计稿对齐):
* - 24×24 SVG chevron-left * - 24×24 chevron-left 图标
* - 黑色,2px 描边 * - 黑色,2px 描边
* - 无 "Back" 文本 * - 无 "Back" 文本
*/ */
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";
@@ -24,22 +25,12 @@ export function SubscriptionBackLink({ className }: SubscriptionBackLinkProps) {
aria-label="Back" aria-label="Back"
className={[styles.backLink, className].filter(Boolean).join(" ")} className={[styles.backLink, className].filter(Boolean).join(" ")}
> >
<svg <ChevronLeft
width="24" size={24}
height="24" strokeWidth={2}
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>
</Link> </Link>
); );
} }
@@ -3,14 +3,13 @@
flex-direction: column; flex-direction: column;
min-height: 100dvh; min-height: 100dvh;
background: var(--color-page-background); background: var(--color-page-background);
padding: 30px 30px 10px; padding: 18 30px 10px;
} }
.header { .header {
display: flex; display: flex;
align-items: center; align-items: center;
height: 40px; height: 40px;
padding-top: var(--spacing-sm);
} }
.backSlot { .backSlot {
@@ -73,7 +72,7 @@
} }
.agreementLabel { .agreementLabel {
font-size: 17px; font-size: 12px;
} }
.agreementLink { .agreementLink {
+12 -5
View File
@@ -78,7 +78,8 @@ function currencySymbol(currency: string): string {
return `${currency} `; return `${currency} `;
} }
function formatAmount(amountCents: number): string { function formatAmount(amountCents: number | null): string | null {
if (amountCents === null) return null;
return (amountCents / 100).toFixed(2).replace(/\.00$/, ""); return (amountCents / 100).toFixed(2).replace(/\.00$/, "");
} }
@@ -91,9 +92,15 @@ function planCaption(
? "Voice package" ? "Voice package"
: `${plan.dolAmount} voice messages`; : `${plan.dolAmount} voice messages`;
} }
if (plan.dailyPriceCents !== null) {
const dailyPrice = formatAmount(plan.dailyPriceCents);
return dailyPrice === null
? "Lifetime"
: `${currencySymbol(plan.currency)}${dailyPrice}/day`;
}
if (plan.vipDays === null) return "Lifetime"; if (plan.vipDays === null) return "Lifetime";
const perDay = plan.amountCents / 100 / Math.max(plan.vipDays, 1); const fallbackDailyPrice = plan.amountCents / Math.max(plan.vipDays, 1);
return `${currencySymbol(plan.currency)}${perDay.toFixed(2)}/day`; return `${currencySymbol(plan.currency)}${formatAmount(fallbackDailyPrice)}/day`;
} }
function toPlanView( function toPlanView(
@@ -103,8 +110,8 @@ function toPlanView(
return { return {
id: plan.planId, id: plan.planId,
name: plan.planName, name: plan.planName,
price: formatAmount(plan.amountCents), price: formatAmount(plan.amountCents) ?? "",
originalPrice: null, originalPrice: formatAmount(plan.originalAmountCents),
perDay: planCaption(plan, subscriptionType), perDay: planCaption(plan, subscriptionType),
currencySymbol: currencySymbol(plan.currency), currencySymbol: currencySymbol(plan.currency),
}; };
+2
View File
@@ -12,6 +12,8 @@ export class PaymentPlan {
declare readonly planName: string; declare readonly planName: string;
declare readonly orderType: string; declare readonly orderType: string;
declare readonly amountCents: number; declare readonly amountCents: number;
declare readonly originalAmountCents: number | null;
declare readonly dailyPriceCents: number | null;
declare readonly currency: string; declare readonly currency: string;
declare readonly vipDays: number | null; declare readonly vipDays: number | null;
declare readonly dolAmount: number | null; declare readonly dolAmount: number | null;
@@ -0,0 +1,30 @@
{
"code": 200,
"message": "success",
"success": true,
"data": {
"mode": "http",
"type": "text",
"reply": "",
"voiceUrl": "",
"audioUrl": "",
"intimacyChange": 0,
"newIntimacy": 0,
"relationshipStage": "密友",
"currentMood": "happy",
"messageId": "",
"isGuest": true,
"timestamp": 1782197944984,
"blocked": true,
"blockReason": "total_limit",
"blockDetail": {
"type": "guest_total_msg_limit",
"usedTotal": 12,
"limit": 5
},
"paywallTriggered": false,
"showUpgrade": false,
"imageType": null,
"imageUrl": null
}
}
+2 -1
View File
@@ -6,7 +6,8 @@ import { z } from "zod";
export const ChatBlockDetailSchema = z.object({ export const ChatBlockDetailSchema = z.object({
type: z.string(), type: z.string(),
usedToday: z.number(), usedToday: z.number().optional(),
usedTotal: z.number().optional(),
limit: z.number(), limit: z.number(),
}); });
+6
View File
@@ -8,6 +8,8 @@ const PaymentPlanWireSchema = z.object({
plan_name: z.string(), plan_name: z.string(),
order_type: z.string(), order_type: z.string(),
amount_cents: z.number(), amount_cents: z.number(),
original_amount_cents: z.number().nullable().default(null),
daily_price_cents: z.number().nullable().default(null),
currency: z.string(), currency: z.string(),
vip_days: z.number().nullable(), vip_days: z.number().nullable(),
dol_amount: z.number().nullable(), dol_amount: z.number().nullable(),
@@ -23,6 +25,8 @@ export const PaymentPlanSchema = z
plan_name: data.planName, plan_name: data.planName,
order_type: data.orderType, order_type: data.orderType,
amount_cents: data.amountCents, amount_cents: data.amountCents,
original_amount_cents: data.originalAmountCents,
daily_price_cents: data.dailyPriceCents,
currency: data.currency, currency: data.currency,
vip_days: data.vipDays, vip_days: data.vipDays,
dol_amount: data.dolAmount, dol_amount: data.dolAmount,
@@ -35,6 +39,8 @@ export const PaymentPlanSchema = z
planName: data.plan_name, planName: data.plan_name,
orderType: data.order_type, orderType: data.order_type,
amountCents: data.amount_cents, amountCents: data.amount_cents,
originalAmountCents: data.original_amount_cents,
dailyPriceCents: data.daily_price_cents,
currency: data.currency, currency: data.currency,
vipDays: data.vip_days, vipDays: data.vip_days,
dolAmount: data.dol_amount, dolAmount: data.dol_amount,
+18 -3
View File
@@ -103,7 +103,12 @@ export function applyHttpSendOutput(
): Partial<ChatState> { ): Partial<ChatState> {
const { response, reply } = output; const { response, reply } = output;
if (response.blocked === true && response.blockReason === "daily_limit") { const isMessageLimitBlocked =
response.blocked === true &&
(response.blockReason === "daily_limit" ||
response.blockReason === "total_limit");
if (isMessageLimitBlocked) {
const detail = response.blockDetail; const detail = response.blockDetail;
const lastMessage = context.messages[context.messages.length - 1]; const lastMessage = context.messages[context.messages.length - 1];
const messages = const messages =
@@ -115,9 +120,19 @@ export function applyHttpSendOutput(
messages, messages,
isReplyingAI: false, isReplyingAI: false,
paywallTriggered: true, paywallTriggered: true,
paywallReason: "daily_limit", paywallReason:
response.blockReason === "total_limit" ? "total_limit" : "daily_limit",
paywallDetail: detail paywallDetail: detail
? { usedToday: detail.usedToday, limit: detail.limit } ? {
type: detail.type,
...(detail.usedToday != null
? { usedToday: detail.usedToday }
: {}),
...(detail.usedTotal != null
? { usedTotal: detail.usedTotal }
: {}),
limit: detail.limit,
}
: null, : null,
}; };
} }
+14 -2
View File
@@ -19,8 +19,20 @@ export interface ChatState {
*/ */
wsConnected: boolean; wsConnected: boolean;
paywallTriggered: boolean; paywallTriggered: boolean;
paywallReason: "daily_limit" | "photo_paywall" | "private_paywall" | null; paywallReason:
paywallDetail: { usedToday: number; limit: number } | null; | "daily_limit"
| "total_limit"
| "photo_paywall"
| "private_paywall"
| null;
paywallDetail:
| {
type?: string;
usedToday?: number;
usedTotal?: number;
limit: number;
}
| null;
unlockingPrivateMessageId: string | null; unlockingPrivateMessageId: string | null;
isLoadingMore: boolean; isLoadingMore: boolean;
hasMore: boolean; hasMore: boolean;