Merge branch 'dev' into test

This commit is contained in:
2026-06-18 12:58:42 +08:00
42 changed files with 163 additions and 179 deletions
+1 -3
View File
@@ -30,9 +30,7 @@
"./src/app/subscription/components", "./src/app/subscription/components",
"./src/hooks", "./src/hooks",
"./src/integrations", "./src/integrations",
"./src/models/auth", "./src/utils",
"./src/models/chat",
"./src/models/user",
"./src/stores/auth", "./src/stores/auth",
"./src/stores/chat", "./src/stores/chat",
"./src/stores/sidebar", "./src/stores/sidebar",
+2 -11
View File
@@ -9,7 +9,6 @@ import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { AuthBackground, AuthPanel } from "./components"; import { AuthBackground, AuthPanel } from "./components";
@@ -18,10 +17,9 @@ import styles from "./components/auth-screen.module.css";
export function AuthScreen() { export function AuthScreen() {
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const userDispatch = useUserDispatch();
const router = useRouter(); const router = useRouter();
// 邮箱登录成功 → 通知 User + 跳转 // 邮箱登录成功 → 跳转User hydrate 由根级 <UserAuthSync /> 监听 loginStatus 变化处理。
// 用 pendingRedirect flag(不是 useRef transition detection)—— // 用 pendingRedirect flag(不是 useRef transition detection)——
// re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。 // re-mount 时 flag 在 auth context 里持久,区分"刚按了"和"re-visit"准确无误。
useEffect(() => { useEffect(() => {
@@ -37,16 +35,9 @@ export function AuthScreen() {
"[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]", "[auth-screen] useEffect → router.replace(/chat) [via pendingRedirect flag]",
); );
authDispatch({ type: "AuthClearPendingRedirect" }); authDispatch({ type: "AuthClearPendingRedirect" });
userDispatch({ type: "UserInit" });
router.replace(ROUTES.chat); router.replace(ROUTES.chat);
} }
}, [ }, [state.loginStatus, state.pendingRedirect, authDispatch, router]);
state.loginStatus,
state.pendingRedirect,
authDispatch,
userDispatch,
router,
]);
return ( return (
<MobileShell> <MobileShell>
+5 -71
View File
@@ -10,25 +10,19 @@
* - BrowserHintOverlay:浏览器提示 * - BrowserHintOverlay:浏览器提示
* *
* 鉴权解耦(事件驱动): * 鉴权解耦(事件驱动):
* - chat 机器不感知鉴权 / 不管 WebSocket —— 由 chat-screen 派生 loginStatus * - chat 机器不感知鉴权 / 不管 WebSocket
* - chat-screen 只派 3 个事件: * - auth → chat 的生命周期同步由根级 <ChatAuthSync /> 负责
* - `ChatGuestLogin` → 游客会话(断 WS = 不连) * - ChatScreen 只派生 UI 展示所需的 isGuest
* - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
* - `ChatLogout` → 登出(机器自动 cleanup WS actor
* - chat 机器内部用 fromCallback actor 完整管理 WS 生命周期
*/ */
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Image from "next/image"; import Image from "next/image";
import { useRouter } from "next/navigation";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import type { LoginStatus } from "@/data/dto/auth"; import type { LoginStatus } from "@/data/dto/auth";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage"; import { UserStorage } from "@/data/storage/user/user_storage";
import { useChatDispatch, useChatState } from "@/stores/chat/chat-context"; import { useChatState } from "@/stores/chat/chat-context";
import { AppEnvUtil } from "@/utils/app-env"; import { AppEnvUtil, BrowserDetector, UrlLauncherUtil } from "@/utils";
import { BrowserDetector } from "@/utils/browser-detect";
import { UrlLauncherUtil } from "@/utils/url-launcher-util";
import { ROUTE_BUILDERS, ROUTES } from "@/router/routes"; import { ROUTE_BUILDERS, ROUTES } from "@/router/routes";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
@@ -52,39 +46,9 @@ function deriveIsGuest(loginStatus: LoginStatus): boolean {
return loginStatus === "guest"; return loginStatus === "guest";
} }
/**
* 按 loginStatus 派鉴权生命周期事件(chat 机器不感知 loginStatus —— 这层转换在此)
*
* 设计:不直接管理 WS / 不读 AuthStorage(除取 token
*/
function dispatchAuthLifecycle(
loginStatus: LoginStatus,
chatDispatch: ReturnType<typeof useChatDispatch>,
): void {
if (loginStatus === "guest") {
// 游客:派 ChatGuestLoginchat 机器不连 WS —— 业务事实"断 WS"
chatDispatch({ type: "ChatGuestLogin" });
return;
}
if (loginStatus === "notLoggedIn") {
// 登出:派 ChatLogoutchat 机器自动 cleanup WS actor + 清消息)
chatDispatch({ type: "ChatLogout" });
return;
}
// 非游客:拿 loginToken → 派 ChatNonGuestLogin { token }
void (async () => {
const tokenR = await AuthStorage.getInstance().getLoginToken();
if (tokenR.success && tokenR.data) {
chatDispatch({ type: "ChatNonGuestLogin", token: tokenR.data });
}
})();
}
export function ChatScreen() { export function ChatScreen() {
const router = useRouter();
const state = useChatState(); const state = useChatState();
const authState = useAuthState(); const authState = useAuthState();
const chatDispatch = useChatDispatch();
const [showExternalBrowserDialog, setShowExternalBrowserDialog] = const [showExternalBrowserDialog, setShowExternalBrowserDialog] =
useState(false); useState(false);
@@ -100,37 +64,7 @@ export function ChatScreen() {
const showExhaustedBanner = const showExhaustedBanner =
isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0; isGuest && state.quotaLoaded && state.quotaExceededTrigger > 0;
// ─────────────────────────────────────────────────────────────
// loginStatus 变化 → 派对应鉴权生命周期事件
//
// 规则:
// - AuthInit 未完成 → 等,避免初始 notLoggedIn 把 /chat 变成死状态
// - AuthInit 完成后仍 "notLoggedIn" → 清 chat 并回 splash
// - 登录态变化(guest→email 等)→ 派 ChatGuestLogin 或 ChatNonGuestLogin
// ─────────────────────────────────────────────────────────────
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
const externalBrowserPromptShownRef = useRef(false); const externalBrowserPromptShownRef = useRef(false);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
const prev = prevLoginStatusRef.current;
if (prev === authState.loginStatus) return; // 登录态没变
prevLoginStatusRef.current = authState.loginStatus;
if (authState.loginStatus === "notLoggedIn") {
chatDispatch({ type: "ChatLogout" });
router.replace(ROUTES.splash);
return;
}
dispatchAuthLifecycle(authState.loginStatus, chatDispatch);
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
chatDispatch,
router,
]);
useEffect(() => { useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return; if (!authState.hasInitialized || authState.isLoading) return;
@@ -14,7 +14,7 @@
*/ */
import { useState } from "react"; import { useState } from "react";
import { BrowserDetector } from "@/utils/browser-detect"; import { BrowserDetector } from "@/utils";
import styles from "./browser-hint-overlay.module.css"; import styles from "./browser-hint-overlay.module.css";
@@ -14,10 +14,7 @@
*/ */
import { useEffect } from "react"; import { useEffect } from "react";
import { BrowserDetector } from "@/utils/browser-detect"; import { BrowserDetector, AppEnvUtil, SpAsyncUtil, pwaUtil } from "@/utils";
import { AppEnvUtil } from "@/utils/app-env";
import { SpAsyncUtil } from "@/utils/storage";
import { pwaUtil } from "@/utils/pwa";
import { PwaInstallDialog } from "./pwa-install-dialog"; import { PwaInstallDialog } from "./pwa-install-dialog";
import styles from "./pwa-install-overlay.module.css"; import styles from "./pwa-install-overlay.module.css";
-17
View File
@@ -1,11 +1,8 @@
"use client"; "use client";
import { useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import { signOut } from "next-auth/react"; import { signOut } from "next-auth/react";
import { MobileShell, SettingsSection } from "@/app/_components/core"; import { MobileShell, SettingsSection } from "@/app/_components/core";
import { ROUTES } from "@/router/routes";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch, useUserState } from "@/stores/user/user-context"; import { useUserDispatch, useUserState } from "@/stores/user/user-context";
@@ -36,22 +33,8 @@ export function SidebarScreen() {
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const auth = useAuthState(); const auth = useAuthState();
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const router = useRouter();
const isLogoutRedirectPendingRef = useRef(false);
useEffect(() => {
userDispatch({ type: "UserInit" });
}, [userDispatch]);
useEffect(() => {
if (!isLogoutRedirectPendingRef.current) return;
if (auth.isLoading || auth.loginStatus !== "notLoggedIn") return;
isLogoutRedirectPendingRef.current = false;
router.replace(ROUTES.splash);
}, [auth.isLoading, auth.loginStatus, router]);
const handleLogoutClick = () => { const handleLogoutClick = () => {
isLogoutRedirectPendingRef.current = true;
userDispatch({ type: "UserClearLocal" }); userDispatch({ type: "UserClearLocal" });
authDispatch({ type: "AuthLogoutSubmitted" }); authDispatch({ type: "AuthLogoutSubmitted" });
void signOut({ redirect: false }); void signOut({ redirect: false });
+2 -11
View File
@@ -5,10 +5,9 @@ import { useRouter } from "next/navigation";
import { MobileShell } from "@/app/_components/core"; import { MobileShell } from "@/app/_components/core";
import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context"; import { useAuthDispatch, useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch } from "@/stores/user/user-context";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { ROUTES } from "@/router/routes"; import { ROUTES } from "@/router/routes";
import { pwaUtil } from "@/utils/pwa"; import { pwaUtil } from "@/utils";
import { import {
SplashBackground, SplashBackground,
@@ -27,7 +26,6 @@ export function SplashScreen() {
const router = useRouter(); const router = useRouter();
const state = useAuthState(); const state = useAuthState();
const authDispatch = useAuthDispatch(); const authDispatch = useAuthDispatch();
const userDispatch = useUserDispatch();
const handleSkip = () => { const handleSkip = () => {
authDispatch({ type: "AuthGuestLoginSubmitted" }); authDispatch({ type: "AuthGuestLoginSubmitted" });
@@ -81,16 +79,9 @@ export function SplashScreen() {
"[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]", "[splash] useEffect ② → router.replace(/chat) [via pendingRedirect flag]",
); );
authDispatch({ type: "AuthClearPendingRedirect" }); authDispatch({ type: "AuthClearPendingRedirect" });
userDispatch({ type: "UserInit" });
router.replace(ROUTES.chat); router.replace(ROUTES.chat);
} }
}, [ }, [state.loginStatus, state.pendingRedirect, authDispatch, router]);
state.loginStatus,
state.pendingRedirect,
authDispatch,
userDispatch,
router,
]);
return ( return (
<MobileShell background="var(--color-sidebar-background)"> <MobileShell background="var(--color-sidebar-background)">
+1 -1
View File
@@ -4,7 +4,7 @@
* 通过环境变量配置后端服务地址与超时时间。 * 通过环境变量配置后端服务地址与超时时间。
* 原始 Dart: lib/core/net/service_manager.dart * 原始 Dart: lib/core/net/service_manager.dart
*/ */
import { AppEnvUtil, type AppEnv } from "@/utils/app-env"; import { AppEnvUtil, type AppEnv } from "@/utils";
export type { AppEnv }; export type { AppEnv };
@@ -8,8 +8,7 @@ import type { FetchHook } from "ofetch";
import { ApiPath } from "../../../data/services/api/api_path"; import { ApiPath } from "../../../data/services/api/api_path";
import { ApiError, ErrorCode } from "../../../data/services/api/api_result"; import { ApiError, ErrorCode } from "../../../data/services/api/api_result";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { deviceIdentifier } from "@/utils/device_identifier"; import { deviceIdentifier, Result } from "@/utils";
import { Result } from "@/utils/result";
/** /**
* 不需要 auth token 刷新的路径 * 不需要 auth token 刷新的路径
+1 -1
View File
@@ -11,9 +11,9 @@ export * from "./fb_id_login_request";
export * from "./google_login_request"; export * from "./google_login_request";
export * from "./guest_login_request"; export * from "./guest_login_request";
export * from "./guest_login_response"; export * from "./guest_login_response";
export * from "./login_status";
export * from "./login_request"; export * from "./login_request";
export * from "./login_response"; export * from "./login_response";
export * from "./login_status";
export * from "./logout_response"; export * from "./logout_response";
export * from "./refresh_token_request"; export * from "./refresh_token_request";
export * from "./refresh_token_response"; export * from "./refresh_token_response";
+1 -1
View File
@@ -6,7 +6,7 @@ import {
type GuestChatQuotaInput, type GuestChatQuotaInput,
type GuestChatQuotaData, type GuestChatQuotaData,
} from "@/data/schemas/chat/guest_chat_quota"; } from "@/data/schemas/chat/guest_chat_quota";
import { AppEnvUtil } from "@/utils/app-env"; import { AppEnvUtil } from "@/utils";
export class GuestChatQuota { export class GuestChatQuota {
// 静态常量 // 静态常量
+1 -1
View File
@@ -28,7 +28,7 @@ import {
SendCodeRequest, SendCodeRequest,
} from "@/data/dto/auth"; } from "@/data/dto/auth";
import { User } from "@/data/dto/user"; import { User } from "@/data/dto/user";
import { Result } from "@/utils/result"; import { Result } from "@/utils";
import type { IAuthRepository } from "@/data/repositories/interfaces"; import type { IAuthRepository } from "@/data/repositories/interfaces";
import { AuthStorage, type IAuthStorage } from "@/data/storage/auth"; import { AuthStorage, type IAuthStorage } from "@/data/storage/auth";
import { UserStorage, type IUserStorage } from "@/data/storage/user"; import { UserStorage, type IUserStorage } from "@/data/storage/user";
+1 -1
View File
@@ -22,7 +22,7 @@ import {
ChatSendResponse, ChatSendResponse,
SendMessageRequest, SendMessageRequest,
} from "@/data/dto/chat"; } from "@/data/dto/chat";
import { Result } from "@/utils/result"; import { Result } from "@/utils";
import type { IChatRepository } from "@/data/repositories/interfaces"; import type { IChatRepository } from "@/data/repositories/interfaces";
import { LocalChatStorage, LocalMessage } from "@/data/storage/chat"; import { LocalChatStorage, LocalMessage } from "@/data/storage/chat";
@@ -13,7 +13,7 @@
* 原始 Dart: lib/data/repositories/auth_repository_impl.dart * 原始 Dart: lib/data/repositories/auth_repository_impl.dart
*/ */
import type { Result } from "@/utils/result"; import type { Result } from "@/utils";
import type { import type {
GuestLoginResponse, GuestLoginResponse,
LoginResponse, LoginResponse,
@@ -11,7 +11,7 @@
* 原始 Dart: lib/data/repositories/chat_repository_impl.dart * 原始 Dart: lib/data/repositories/chat_repository_impl.dart
*/ */
import type { Result } from "@/utils/result"; import type { Result } from "@/utils";
import type { import type {
ChatHistoryResponse, ChatHistoryResponse,
ChatMessage, ChatMessage,
@@ -9,7 +9,7 @@
* 原始 Dart: lib/data/repositories/metrics_repository_impl.dart * 原始 Dart: lib/data/repositories/metrics_repository_impl.dart
*/ */
import type { Result } from "@/utils/result"; import type { Result } from "@/utils";
export interface IMetricsRepository { export interface IMetricsRepository {
/** 上报 PWA 事件。自动注入当前秒级时间戳。 */ /** 上报 PWA 事件。自动注入当前秒级时间戳。 */
@@ -9,7 +9,7 @@
* 原始 Dart: lib/data/repositories/user_repository_impl.dart * 原始 Dart: lib/data/repositories/user_repository_impl.dart
*/ */
import type { Result } from "@/utils/result"; import type { Result } from "@/utils";
import type { import type {
CreditsData, CreditsData,
CreditsHistoryData, CreditsHistoryData,
+1 -1
View File
@@ -9,7 +9,7 @@
*/ */
import { MetricsApi, metricsApi } from "@/data/services/api"; import { MetricsApi, metricsApi } from "@/data/services/api";
import { AppEvent, PwaEvent } from "@/data/dto/metrics"; import { AppEvent, PwaEvent } from "@/data/dto/metrics";
import { Result } from "@/utils/result"; import { Result } from "@/utils";
import type { IMetricsRepository } from "@/data/repositories/interfaces"; import type { IMetricsRepository } from "@/data/repositories/interfaces";
export class MetricsRepository implements IMetricsRepository { export class MetricsRepository implements IMetricsRepository {
+1 -1
View File
@@ -16,7 +16,7 @@ import {
User, User,
UserStatsResponse, UserStatsResponse,
} from "@/data/dto/user"; } from "@/data/dto/user";
import { Result } from "@/utils/result"; import { Result } from "@/utils";
import type { IUserRepository } from "@/data/repositories/interfaces"; import type { IUserRepository } from "@/data/repositories/interfaces";
export class UserRepository implements IUserRepository { export class UserRepository implements IUserRepository {
+1 -2
View File
@@ -16,8 +16,7 @@
* - `recordXxx(today)` 写入 todayString,后续 `canXxx` 在同一天返回 `false`。 * - `recordXxx(today)` 写入 todayString,后续 `canXxx` 在同一天返回 `false`。
*/ */
import { Result, type Result as ResultT } from "@/utils/result"; import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils";
import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys"; import { StorageKeys } from "../storage_keys";
export class AppStorage { export class AppStorage {
+1 -2
View File
@@ -14,8 +14,7 @@
* - `clearAuthData` 顺序清理:loginToken → guestToken → refreshToken,任一失败立即返回 * - `clearAuthData` 顺序清理:loginToken → guestToken → refreshToken,任一失败立即返回
*/ */
import { Result, type Result as ResultT } from "@/utils/result"; import { Result, type Result as ResultT, SpAsyncUtil } from "@/utils";
import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys"; import { StorageKeys } from "../storage_keys";
import type { IAuthStorage } from "./iauth_storage"; import type { IAuthStorage } from "./iauth_storage";
+1 -1
View File
@@ -9,7 +9,7 @@
* - 所有方法返回 `Promise<Result<T>>`,与 Dart `Future<Result<T>>` 对齐 * - 所有方法返回 `Promise<Result<T>>`,与 Dart `Future<Result<T>>` 对齐
*/ */
import type { Result } from "@/utils/result"; import type { Result } from "@/utils";
export interface IAuthStorage { export interface IAuthStorage {
getLoginToken(): Promise<Result<string | null>>; getLoginToken(): Promise<Result<string | null>>;
@@ -4,8 +4,7 @@ import memoryDriver from "unstorage/drivers/memory";
import { GuestChatQuota } from "@/data/dto/chat/guest_chat_quota"; import { GuestChatQuota } from "@/data/dto/chat/guest_chat_quota";
import { StorageKeys } from "@/data/storage/storage_keys"; import { StorageKeys } from "@/data/storage/storage_keys";
import { todayString } from "@/utils/date"; import { todayString, SpAsyncUtil } from "@/utils";
import { SpAsyncUtil } from "@/utils/storage";
import { ChatStorage } from "../chat_storage"; import { ChatStorage } from "../chat_storage";
+1 -3
View File
@@ -7,9 +7,7 @@ import {
import { GuestChatQuota as GuestChatQuotaDto } from "@/data/dto/chat/guest_chat_quota"; import { GuestChatQuota as GuestChatQuotaDto } from "@/data/dto/chat/guest_chat_quota";
import { z } from "zod"; import { z } from "zod";
import { type Result as ResultT, Result } from "@/utils/result"; import { type Result as ResultT, Result, todayString, SpAsyncUtil } from "@/utils";
import { todayString } from "@/utils/date";
import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys"; import { StorageKeys } from "../storage_keys";
import type { IChatStorage } from "./ichat_storage"; import type { IChatStorage } from "./ichat_storage";
+1 -1
View File
@@ -11,7 +11,7 @@
* 跨天判断逻辑(`needsReset`)保留在 `GuestChatQuota` 类上。 * 跨天判断逻辑(`needsReset`)保留在 `GuestChatQuota` 类上。
*/ */
import type { Result } from "@/utils/result"; import type { Result } from "@/utils";
import type { GuestChatQuotaData } from "@/data/schemas/chat/guest_chat_quota"; import type { GuestChatQuotaData } from "@/data/schemas/chat/guest_chat_quota";
export interface IChatStorage { export interface IChatStorage {
+1 -1
View File
@@ -15,7 +15,7 @@
* 数据量大时考虑升级 schema 加索引。 * 数据量大时考虑升级 schema 加索引。
*/ */
import { Result, type Result as ResultT } from "@/utils/result"; import { Result, type Result as ResultT } from "@/utils";
import { LocalChatDB } from "./local_chat_db"; import { LocalChatDB } from "./local_chat_db";
import { LocalMessage } from "./local_message"; import { LocalMessage } from "./local_message";
+1 -1
View File
@@ -11,7 +11,7 @@
* 具体序列化由实现层通过 Zod schema 校验后转换为 `User` 类实例。 * 具体序列化由实现层通过 Zod schema 校验后转换为 `User` 类实例。
*/ */
import type { Result } from "@/utils/result"; import type { Result } from "@/utils";
import type { UserData } from "@/data/schemas/user/user"; import type { UserData } from "@/data/schemas/user/user";
export interface IUserStorage { export interface IUserStorage {
+1 -2
View File
@@ -16,8 +16,7 @@
*/ */
import { UserSchema, type UserData } from "@/data/schemas/user/user"; import { UserSchema, type UserData } from "@/data/schemas/user/user";
import { type Result as ResultT } from "@/utils/result"; import { type Result as ResultT, SpAsyncUtil } from "@/utils";
import { SpAsyncUtil } from "@/utils/storage";
import { StorageKeys } from "../storage_keys"; import { StorageKeys } from "../storage_keys";
import type { IUserStorage } from "./iuser_storage"; import type { IUserStorage } from "./iuser_storage";
+1 -1
View File
@@ -9,7 +9,7 @@
* *
* 注:Web Speech API 在 Chrome / Edge 完整支持;Safari / Firefox 仅录音。 * 注:Web Speech API 在 Chrome / Edge 完整支持;Safari / Firefox 仅录音。
*/ */
import { Result } from "@/utils/result"; import { Result } from "@/utils";
export interface SpeechRecognitionResultEvent { export interface SpeechRecognitionResultEvent {
transcript: string; transcript: string;
+3
View File
@@ -8,6 +8,7 @@
* + AuthStatusChecker(启动时一次:派发 AuthInit * + AuthStatusChecker(启动时一次:派发 AuthInit
* + OAuthSessionSync (持续监听 NextAuth session → auth machine * + OAuthSessionSync (持续监听 NextAuth session → auth machine
* + UserAuthSync auth 登录态恢复后 hydrate user machine * + UserAuthSync auth 登录态恢复后 hydrate user machine
* + ChatAuthSync (持续监听 auth loginStatus → chat machine
* *
* 它们始终挂载,保证各页面直接 `use*State()` / `use*Dispatch()` 即可, * 它们始终挂载,保证各页面直接 `use*State()` / `use*Dispatch()` 即可,
* 无需在每个页面单独包裹。 * 无需在每个页面单独包裹。
@@ -18,6 +19,7 @@ import type { ReactNode } from "react";
import { AuthProvider } from "@/stores/auth/auth-context"; import { AuthProvider } from "@/stores/auth/auth-context";
import { AuthStatusChecker } from "@/stores/auth/auth-status-checker"; import { AuthStatusChecker } from "@/stores/auth/auth-status-checker";
import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync"; import { OAuthSessionSync } from "@/stores/auth/oauth-session-sync";
import { ChatAuthSync } from "@/stores/chat/chat-auth-sync";
import { ChatProvider } from "@/stores/chat/chat-context"; import { ChatProvider } from "@/stores/chat/chat-context";
import { SidebarProvider } from "@/stores/sidebar/sidebar-context"; import { SidebarProvider } from "@/stores/sidebar/sidebar-context";
import { UserAuthSync } from "@/stores/user/user-auth-sync"; import { UserAuthSync } from "@/stores/user/user-auth-sync";
@@ -39,6 +41,7 @@ export function RootProviders({ children }: RootProvidersProps) {
<UserAuthSync /> <UserAuthSync />
<SidebarProvider> <SidebarProvider>
<ChatProvider> <ChatProvider>
<ChatAuthSync />
{children} {children}
<div id="toast-portal" /> <div id="toast-portal" />
</ChatProvider> </ChatProvider>
+1 -4
View File
@@ -9,10 +9,7 @@ import { authRepository } from "@/data/repositories/auth_repository";
import type { IAuthRepository } from "@/data/repositories/interfaces"; import type { IAuthRepository } from "@/data/repositories/interfaces";
import { AuthStorage } from "@/data/storage/auth/auth_storage"; import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { UserStorage } from "@/data/storage/user/user_storage"; import { UserStorage } from "@/data/storage/user/user_storage";
import { deviceIdentifier } from "@/utils/device_identifier"; import { deviceIdentifier, fetchFacebookUserData, Logger, Result } from "@/utils";
import { fetchFacebookUserData } from "@/utils/facebook-graph";
import { Logger } from "@/utils/logger";
import { Result } from "@/utils/result";
import { readGuestId } from "./auth-helpers"; import { readGuestId } from "./auth-helpers";
+1 -1
View File
@@ -13,7 +13,7 @@ export interface AuthState {
username: string; username: string;
confirmPassword: string; confirmPassword: string;
errorMessage: string | null; errorMessage: string | null;
/** 当前登录状态(未登录 / 游客 / OAuth / 邮箱) */ /** 当前登录状态(未登录 / 游客 / OAuth / 邮箱)—— 下游同步器监听此字段的双向变化 */
loginStatus: LoginStatus; loginStatus: LoginStatus;
/** 启动时的本地登录态恢复是否已经完成 */ /** 启动时的本地登录态恢复是否已经完成 */
hasInitialized: boolean; hasInitialized: boolean;
+72
View File
@@ -0,0 +1,72 @@
"use client";
/**
* Auth → Chat 同步器
*
* Chat machine 不直接感知 Auth machine;这里作为根级桥接器,持续监听
* loginStatus 变化并派发 chat 鉴权生命周期事件。这样页面组件只负责 UI,
* 不再承担全局状态同步职责。
*/
import { useEffect, useRef } from "react";
import { usePathname, useRouter } from "next/navigation";
import type { LoginStatus } from "@/data/dto/auth";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { useAuthState } from "@/stores/auth/auth-context";
import { PROTECTED_ROUTES, ROUTES, type StaticRoute } from "@/router/routes";
import { useChatDispatch } from "./chat-context";
function isProtectedPath(pathname: string): boolean {
return PROTECTED_ROUTES.some((route: StaticRoute) => pathname === route);
}
export function ChatAuthSync() {
const authState = useAuthState();
const chatDispatch = useChatDispatch();
const pathname = usePathname();
const router = useRouter();
const prevLoginStatusRef = useRef<LoginStatus | null>(null);
useEffect(() => {
if (!authState.hasInitialized || authState.isLoading) return;
const prev = prevLoginStatusRef.current;
if (prev === authState.loginStatus) return;
prevLoginStatusRef.current = authState.loginStatus;
if (authState.loginStatus === "notLoggedIn") {
chatDispatch({ type: "ChatLogout" });
if (isProtectedPath(pathname)) {
router.replace(ROUTES.splash);
}
return;
}
if (authState.loginStatus === "guest") {
chatDispatch({ type: "ChatGuestLogin" });
return;
}
let cancelled = false;
void (async () => {
const tokenR = await AuthStorage.getInstance().getLoginToken();
if (!cancelled && tokenR.success && tokenR.data) {
chatDispatch({ type: "ChatNonGuestLogin", token: tokenR.data });
}
})();
return () => {
cancelled = true;
};
}, [
authState.hasInitialized,
authState.isLoading,
authState.loginStatus,
chatDispatch,
pathname,
router,
]);
return null;
}
+2 -2
View File
@@ -4,7 +4,7 @@
* 事件名与原 Dart ChatEvent 对齐。 * 事件名与原 Dart ChatEvent 对齐。
* *
* 历史:原本有 `ChatAuthStatusChanged`chat 机器刷新 isGuest)—— 已删。 * 历史:原本有 `ChatAuthStatusChanged`chat 机器刷新 isGuest)—— 已删。
* 鉴权状态变化(loginStatus)由 chat-screen 通过 `useAuthState()` 订阅, * 鉴权状态变化(loginStatus)由 <ChatAuthSync /> 通过 `useAuthState()` 订阅,
* chat 机器不感知鉴权。 * chat 机器不感知鉴权。
* *
* 鉴权生命周期事件(本轮新增): * 鉴权生命周期事件(本轮新增):
@@ -16,7 +16,7 @@
* chat-screen 不直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。 * chat-screen 不直接创建 ChatWebSocket / 不读 AuthStorage(除 token 取值)。
*/ */
export type ChatEvent = export type ChatEvent =
// 鉴权生命周期(用户显式触发登录后由 chat-screen 派) // 鉴权生命周期(登录态变化后由 ChatAuthSync 派)
| { type: "ChatGuestLogin" } | { type: "ChatGuestLogin" }
| { type: "ChatNonGuestLogin"; token: string } | { type: "ChatNonGuestLogin"; token: string }
| { type: "ChatLogout" } | { type: "ChatLogout" }
+1 -1
View File
@@ -5,7 +5,7 @@
import { fromPromise, fromCallback } from "xstate"; import { fromPromise, fromCallback } from "xstate";
import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket"; import { createChatWebSocket, ChatWebSocket } from "@/core/net/chat-websocket";
import { Result } from "@/utils/result"; import { Result } from "@/utils";
import { import {
PAGE_SIZE, PAGE_SIZE,
+1 -2
View File
@@ -25,8 +25,7 @@ import { chatRepository } from "@/data/repositories/chat_repository";
import type { IChatRepository } from "@/data/repositories/interfaces"; import type { IChatRepository } from "@/data/repositories/interfaces";
import { ChatStorage } from "@/data/storage/chat/chat_storage"; import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { ChatSendResponse } from "@/data/dto/chat/chat_send_response"; import { ChatSendResponse } from "@/data/dto/chat/chat_send_response";
import { formatDate } from "@/utils/date"; import { formatDate, Result } from "@/utils";
import { Result } from "@/utils/result";
// ============================================================ // ============================================================
// Constants // Constants
+13 -7
View File
@@ -2,8 +2,8 @@
* Chat 状态机(XState v5 * Chat 状态机(XState v5
* *
* 鉴权解耦(事件驱动): * 鉴权解耦(事件驱动):
* chat 机器不感知鉴权 / 不管 WebSocket —— 由 chat-screen 派生 loginStatus * chat 机器不感知鉴权 / 不管 WebSocket —— 由 <ChatAuthSync /> 派生 loginStatus
* chat-screen 只派 3 个事件: * ChatAuthSync 只派 3 个事件:
* - `ChatGuestLogin` → 游客会话(断 WS = 不连) * - `ChatGuestLogin` → 游客会话(断 WS = 不连)
* - `ChatNonGuestLogin { token }` → 非游客会话(连 WS) * - `ChatNonGuestLogin { token }` → 非游客会话(连 WS)
* - `ChatLogout` → 登出(机器自动 cleanup WS actor * - `ChatLogout` → 登出(机器自动 cleanup WS actor
@@ -33,7 +33,7 @@
import { setup, assign } from "xstate"; import { setup, assign } from "xstate";
import { ChatStorage } from "@/data/storage/chat/chat_storage"; import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { formatDate, todayString } from "@/utils/date"; import { formatDate, todayString } from "@/utils";
import { ChatState, initialState } from "./chat-state"; import { ChatState, initialState } from "./chat-state";
@@ -221,6 +221,10 @@ export const chatMachine = setup({
// guestSession(游客会话 —— 不连 WS,2 个 init 任务并行) // guestSession(游客会话 —— 不连 WS,2 个 init 任务并行)
// ======================================================================== // ========================================================================
guestSession: { guestSession: {
on: {
ChatLogout: "#chat.idle",
ChatNonGuestLogin: "#chat.userSession",
},
initial: "initializing", initial: "initializing",
states: { states: {
initializing: { initializing: {
@@ -311,8 +315,6 @@ export const chatMachine = setup({
}, },
], ],
// 删除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页 // 删除 ChatLoadMoreHistory handler —— 游客无服务端 history,不支持翻页
ChatLogout: "#chat.idle",
ChatNonGuestLogin: "#chat.userSession",
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" }, ChatWebSocketConnected: { actions: "setWsConnected" },
@@ -355,6 +357,12 @@ export const chatMachine = setup({
// - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action // - sendingViaWs 重新声明 `ChatWebSocketError` 加 target: "ready"(带 action
// —— XState v5: child handler 替换 parent;复制 action // —— XState v5: child handler 替换 parent;复制 action
on: { on: {
ChatLogout: "#chat.idle",
ChatGuestLogin: "#chat.guestSession",
ChatNonGuestLogin: {
target: "#chat.userSession",
reenter: true,
},
ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" }, ChatAISentenceReceived: { actions: "appendOrUpdateAISentence" },
ChatWebSocketError: { actions: "appendSocketErrorMessage" }, ChatWebSocketError: { actions: "appendSocketErrorMessage" },
ChatWebSocketConnected: { actions: "setWsConnected" }, ChatWebSocketConnected: { actions: "setWsConnected" },
@@ -424,8 +432,6 @@ export const chatMachine = setup({
ChatLoadMoreHistory: { ChatLoadMoreHistory: {
target: "loadingMore", target: "loadingMore",
}, },
ChatLogout: "#chat.idle",
ChatGuestLogin: "#chat.guestSession",
ChatQuotaExceeded: { actions: "incrementQuotaExceeded" }, ChatQuotaExceeded: { actions: "incrementQuotaExceeded" },
// 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected // 注:ChatAISentenceReceived / ChatWebSocketError / ChatWebSocketConnected
// 已上提到 userSession.on,子级不再声明 // 已上提到 userSession.on,子级不再声明
+1
View File
@@ -2,6 +2,7 @@
* @file Automatically generated by barrelsby. * @file Automatically generated by barrelsby.
*/ */
export * from "./chat-auth-sync";
export * from "./chat-context"; export * from "./chat-context";
export * from "./chat-events"; export * from "./chat-events";
export * from "./chat-machine.actors"; export * from "./chat-machine.actors";
+17 -13
View File
@@ -1,35 +1,39 @@
"use client"; "use client";
/** /**
* Auth → User 启动同步器 * Auth → User 同步器
* *
* AuthStatusChecker 只负责恢复 loginStatus头像等登录用户资料由 * AuthStatusChecker 只负责恢复 loginStatus;用户资料由 user machine
* UserStorage 持久化在 user machine 这一侧。冷启动直达 /chat 时, * 管理。这里持续监听登录态变化:
* 没有经过 splash/auth 的跳转副作用,因此需要在登录态恢复后主动 * - notLoggedIn → 清空本地 user context
* hydrate user store * - guest / email / OAuth → 重新 hydrate user store
*/ */
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import type { LoginStatus } from "@/data/dto/auth";
import { useAuthState } from "@/stores/auth/auth-context"; import { useAuthState } from "@/stores/auth/auth-context";
import { useUserDispatch } from "./user-context"; import { useUserDispatch } from "./user-context";
export function UserAuthSync() { export function UserAuthSync() {
const { loginStatus } = useAuthState(); const { hasInitialized, isLoading, loginStatus } = useAuthState();
const userDispatch = useUserDispatch(); const userDispatch = useUserDispatch();
const lastInitializedStatusRef = useRef<string | null>(null); const prevLoginStatusRef = useRef<LoginStatus | null>(null);
//TODO 在测位栏界面监听变化,监听登录状态的变化若发生变化,则需要分发 user init 事件
useEffect(() => { useEffect(() => {
if (loginStatus === "notLoggedIn" || loginStatus === "guest") { if (!hasInitialized || isLoading) return;
lastInitializedStatusRef.current = null;
const prev = prevLoginStatusRef.current;
if (prev === loginStatus) return;
prevLoginStatusRef.current = loginStatus;
if (loginStatus === "notLoggedIn") {
userDispatch({ type: "UserClearLocal" });
return; return;
} }
if (lastInitializedStatusRef.current === loginStatus) return;
lastInitializedStatusRef.current = loginStatus;
userDispatch({ type: "UserInit" }); userDispatch({ type: "UserInit" });
}, [loginStatus, userDispatch]); }, [hasInitialized, isLoading, loginStatus, userDispatch]);
return null; return null;
} }
+1 -1
View File
@@ -21,7 +21,7 @@ import type {
IAuthRepository, IAuthRepository,
IUserRepository, IUserRepository,
} from "@/data/repositories/interfaces"; } from "@/data/repositories/interfaces";
import { Result } from "@/utils/result"; import { Result } from "@/utils";
import { type InitData, readInitData, toView, userStorage } from "./user-machine.helpers"; import { type InitData, readInitData, toView, userStorage } from "./user-machine.helpers";
+1 -1
View File
@@ -15,7 +15,7 @@
import type { UserView } from "@/data/dto/user"; import type { UserView } from "@/data/dto/user";
import { UserStorage } from "@/data/storage/user/user_storage"; import { UserStorage } from "@/data/storage/user/user_storage";
import { Result } from "@/utils/result"; import { Result } from "@/utils";
// ============================================================ // ============================================================
// Storage singleton // Storage singleton
+15
View File
@@ -0,0 +1,15 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./app-env";
export * from "./browser-detect";
export * from "./date";
export * from "./device_identifier";
export * from "./facebook-graph";
export * from "./logger";
export * from "./platform-detect";
export * from "./pwa";
export * from "./result";
export * from "./storage";
export * from "./url-launcher-util";