refactor(app): tighten feature boundaries

This commit is contained in:
2026-06-30 14:50:31 +08:00
parent 1fed21fa2f
commit ba6ce57e62
17 changed files with 126 additions and 52 deletions
+1
View File
@@ -3,3 +3,4 @@
*/ */
export * from "./back-button"; export * from "./back-button";
export * from "./user-message-avatar";
@@ -0,0 +1,19 @@
.avatar {
flex: 0 0 auto;
width: 43px;
height: 43px;
border-radius: 9999px;
overflow: hidden;
border: 2px solid var(--color-avatar-border, #fbf3f5);
box-shadow: var(--shadow-input-box, 0 1px 2px rgba(0, 0, 0, 0.1));
background: var(--color-avatar-border, #fbf3f5);
display: flex;
align-items: center;
justify-content: center;
}
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
@@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import styles from "./message-avatar.module.css"; import styles from "./user-message-avatar.module.css";
export interface UserMessageAvatarProps { export interface UserMessageAvatarProps {
avatarUrl?: string | null; avatarUrl?: string | null;
-1
View File
@@ -25,6 +25,5 @@ export * from "./private-message-card";
export * from "./pwa-install-dialog"; export * from "./pwa-install-dialog";
export * from "./pwa-install-overlay"; export * from "./pwa-install-overlay";
export * from "./text-bubble"; export * from "./text-bubble";
export * from "./user-message-avatar";
export * from "./voice-bubble"; export * from "./voice-bubble";
export * from "./voice-unlock-options-dialog"; export * from "./voice-unlock-options-dialog";
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { UserMessageAvatar } from "./user-message-avatar"; import { UserMessageAvatar } from "@/app/_components";
import styles from "./message-avatar.module.css"; import styles from "./message-avatar.module.css";
export interface MessageAvatarProps { export interface MessageAvatarProps {
@@ -5,16 +5,14 @@
* *
* 设计: * 设计:
* - 不走 Server `redirect()`:避免把 fbid 暴露在 URL 里。 * - 不走 Server `redirect()`:避免把 fbid 暴露在 URL 里。
* - deviceId / fbid 都走 `AuthStorage.getInstance().setXxx`(与 `token_interceptor.ts` 同源) * - deviceId / fbid / avatarUrl 统一交给 `lib/chat` 落地。
* - avatarUrl 写入 UserStorage 的头像 slot,外部浏览器首次恢复时可带给后端。
* - 写入完成后 `router.replace('/chat')`URL 不留深链痕迹。 * - 写入完成后 `router.replace('/chat')`URL 不留深链痕迹。
*/ */
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { persistExternalBrowserChatDeepLink } from "@/lib/chat/chat_external_browser";
import { UserStorage } from "@/data/storage/user/user_storage";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { Logger } from "@/utils"; import { Logger } from "@/utils";
@@ -37,13 +35,11 @@ export default function DeepLinkPersist({
useEffect(() => { useEffect(() => {
void (async () => { void (async () => {
try { try {
await AuthStorage.getInstance().setDeviceId(deviceId); await persistExternalBrowserChatDeepLink({
if (fbid && fbid.length > 0) { deviceId,
await AuthStorage.getInstance().setFacebookId(fbid); facebookId: fbid,
} avatarUrl,
if (avatarUrl && avatarUrl.length > 0) { });
await UserStorage.getInstance().setAvatarUrl(avatarUrl);
}
} catch (err) { } catch (err) {
// 写不进 localStorage 时(例如隐私模式)也不阻塞跳转;记录到 console 即可。 // 写不进 localStorage 时(例如隐私模式)也不阻塞跳转;记录到 console 即可。
log.warn("[DeepLinkPersist] failed to persist", err); log.warn("[DeepLinkPersist] failed to persist", err);
@@ -6,9 +6,9 @@ import { MobileShell } from "@/app/_components/core";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useChatDispatch, useChatState } from "@/stores/chat/chat-context";
import { savePendingChatImageReturn } from "@/lib/navigation/chat_image_return_session";
import { getChatPaywallNavigationUrl } from "../../chat-screen.helpers"; import { getChatPaywallNavigationUrl } from "../../chat-screen.helpers";
import { savePendingChatImageReturn } from "../../chat-image-return-session";
import { FullscreenImageViewer, HistoryUnlockDialog } from "../../components"; import { FullscreenImageViewer, HistoryUnlockDialog } from "../../components";
import styles from "./chat-image-viewer-screen.module.css"; import styles from "./chat-image-viewer-screen.module.css";
+1 -1
View File
@@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar"; import { UserMessageAvatar } from "@/app/_components";
import styles from "./user-header.module.css"; import styles from "./user-header.module.css";
+2 -4
View File
@@ -4,8 +4,8 @@ import { useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { hasBusinessLoginToken } from "@/lib/auth/auth_session";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { pwaUtil, Logger } from "@/utils"; import { pwaUtil, Logger } from "@/utils";
@@ -48,9 +48,7 @@ export function SplashScreen() {
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
// 用 getLoginToken() 拿 string | null(不是 hasLoginToken() 拿 boolean if (!cancelled && (await hasBusinessLoginToken())) {
const r = await AuthStorage.getInstance().getLoginToken();
if (!cancelled && r.success && r.data) {
router.replace(ROUTES.chat); router.replace(ROUTES.chat);
} }
})(); })();
@@ -6,7 +6,7 @@
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { PendingPaymentOrderStorage } from "@/data/storage"; import { savePendingEzpayOrder } from "@/lib/payment/pending_payment_order";
import { import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
@@ -280,12 +280,10 @@ async function launchEzpayRedirect({
return; return;
} }
const saveResult = await PendingPaymentOrderStorage.setPendingOrder({ const saveResult = await savePendingEzpayOrder({
orderId, orderId,
payChannel: "ezpay",
subscriptionType, subscriptionType,
...(returnTo ? { returnTo } : {}), ...(returnTo ? { returnTo } : {}),
createdAt: Date.now(),
}); });
if (Result.isErr(saveResult)) { if (Result.isErr(saveResult)) {
const errorMessage = const errorMessage =
@@ -7,7 +7,7 @@
* - 头像右侧两行文本:用户名(16px/600)、副标题(12px/secondary * - 头像右侧两行文本:用户名(16px/600)、副标题(12px/secondary
* - 无头像时显示默认用户图标 * - 无头像时显示默认用户图标
*/ */
import { UserMessageAvatar } from "@/app/chat/components/user-message-avatar"; import { UserMessageAvatar } from "@/app/_components";
import styles from "./subscription-user-row.module.css"; import styles from "./subscription-user-row.module.css";
+6 -20
View File
@@ -4,23 +4,14 @@ import { useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { PendingPaymentOrderStorage } from "@/data/storage"; import {
buildPendingPaymentSubscriptionUrl,
getPendingPaymentOrder,
} from "@/lib/payment/pending_payment_order";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
type ReturnStatus = "loading" | "missing"; type ReturnStatus = "loading" | "missing";
function buildSubscriptionUrl(
subscriptionType: "vip" | "topup",
returnTo?: "chat",
): string {
const params = new URLSearchParams({
type: subscriptionType,
paymentReturn: "1",
});
if (returnTo) params.set("returnTo", returnTo);
return `${ROUTES.subscription}?${params.toString()}`;
}
export default function SubscriptionReturnPage() { export default function SubscriptionReturnPage() {
const router = useRouter(); const router = useRouter();
const [status, setStatus] = useState<ReturnStatus>("loading"); const [status, setStatus] = useState<ReturnStatus>("loading");
@@ -29,16 +20,11 @@ export default function SubscriptionReturnPage() {
let cancelled = false; let cancelled = false;
const redirectToSubscription = async () => { const redirectToSubscription = async () => {
const result = await PendingPaymentOrderStorage.getPendingOrder(); const result = await getPendingPaymentOrder();
if (cancelled) return; if (cancelled) return;
if (result.success && result.data !== null) { if (result.success && result.data !== null) {
router.replace( router.replace(buildPendingPaymentSubscriptionUrl(result.data));
buildSubscriptionUrl(
result.data.subscriptionType,
result.data.returnTo,
),
);
return; return;
} }
+8 -7
View File
@@ -3,10 +3,13 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { BackButton } from "@/app/_components"; import { BackButton } from "@/app/_components";
import { consumePendingChatImageReturn } from "@/app/chat/chat-image-return-session";
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";
import { PendingPaymentOrderStorage } from "@/data/storage"; import { consumePendingChatImageReturn } from "@/lib/navigation/chat_image_return_session";
import {
clearPendingPaymentOrder,
getPendingPaymentOrderForType,
} from "@/lib/payment/pending_payment_order";
import { import {
usePaymentDispatch, usePaymentDispatch,
usePaymentState, usePaymentState,
@@ -85,13 +88,11 @@ export function SubscriptionScreen({
let cancelled = false; let cancelled = false;
const handlePendingOrder = async () => { const handlePendingOrder = async () => {
const result = await PendingPaymentOrderStorage.getPendingOrderForType( const result = await getPendingPaymentOrderForType(subscriptionType);
subscriptionType,
);
if (cancelled || !result.success || result.data === null) return; if (cancelled || !result.success || result.data === null) return;
if (!shouldResumePendingOrder) { if (!shouldResumePendingOrder) {
await PendingPaymentOrderStorage.clearPendingOrder(); await clearPendingPaymentOrder();
if ( if (
payment.currentOrderId === result.data.orderId && payment.currentOrderId === result.data.orderId &&
(payment.isPollingOrder || payment.isPaid || payment.status === "failed") (payment.isPollingOrder || payment.isPaid || payment.status === "failed")
@@ -130,7 +131,7 @@ export function SubscriptionScreen({
if (!payment.currentOrderId) return; if (!payment.currentOrderId) return;
if (!payment.isPaid && payment.status !== "failed") return; if (!payment.isPaid && payment.status !== "failed") return;
void PendingPaymentOrderStorage.clearPendingOrder(); void clearPendingPaymentOrder();
}, [payment.currentOrderId, payment.isPaid, payment.status]); }, [payment.currentOrderId, payment.isPaid, payment.status]);
const vipOfferPlans = useMemo( const vipOfferPlans = useMemo(
+8
View File
@@ -0,0 +1,8 @@
"use client";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
export async function hasBusinessLoginToken(): Promise<boolean> {
const result = await AuthStorage.getInstance().getLoginToken();
return result.success && Boolean(result.data);
}
+14
View File
@@ -52,3 +52,17 @@ export async function openChatInExternalBrowser(): Promise<void> {
UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash); UrlLauncherUtil.openUrlWithExternalBrowser(ROUTES.splash);
} }
export async function persistExternalBrowserChatDeepLink(input: {
deviceId: string;
facebookId?: string | null;
avatarUrl?: string | null;
}): Promise<void> {
await AuthStorage.getInstance().setDeviceId(input.deviceId);
if (input.facebookId && input.facebookId.length > 0) {
await AuthStorage.getInstance().setFacebookId(input.facebookId);
}
if (input.avatarUrl && input.avatarUrl.length > 0) {
await UserStorage.getInstance().setAvatarUrl(input.avatarUrl);
}
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import {
PendingPaymentOrderStorage,
type PendingPaymentOrder,
} from "@/data/storage";
import { ROUTES } from "@/router/routes";
import type { Result } from "@/utils";
export type PendingPaymentSubscriptionType =
PendingPaymentOrder["subscriptionType"];
export type PendingPaymentReturnTo = PendingPaymentOrder["returnTo"];
export function savePendingEzpayOrder(input: {
orderId: string;
subscriptionType: PendingPaymentSubscriptionType;
returnTo?: PendingPaymentReturnTo;
createdAt?: number;
}): Promise<Result<void>> {
return PendingPaymentOrderStorage.setPendingOrder({
orderId: input.orderId,
payChannel: "ezpay",
subscriptionType: input.subscriptionType,
...(input.returnTo ? { returnTo: input.returnTo } : {}),
createdAt: input.createdAt ?? Date.now(),
});
}
export function getPendingPaymentOrder(): Promise<
Result<PendingPaymentOrder | null>
> {
return PendingPaymentOrderStorage.getPendingOrder();
}
export function getPendingPaymentOrderForType(
subscriptionType: PendingPaymentSubscriptionType,
): Promise<Result<PendingPaymentOrder | null>> {
return PendingPaymentOrderStorage.getPendingOrderForType(subscriptionType);
}
export function clearPendingPaymentOrder(): Promise<Result<void>> {
return PendingPaymentOrderStorage.clearPendingOrder();
}
export function buildPendingPaymentSubscriptionUrl(
order: Pick<PendingPaymentOrder, "subscriptionType" | "returnTo">,
): string {
const params = new URLSearchParams({
type: order.subscriptionType,
paymentReturn: "1",
});
if (order.returnTo) params.set("returnTo", order.returnTo);
return `${ROUTES.subscription}?${params.toString()}`;
}