From 5e645b003e8603a04bb1173343b0109a28dcb04c Mon Sep 17 00:00:00 2001 From: chenhang Date: Tue, 9 Jun 2026 17:26:16 +0800 Subject: [PATCH] feat: add xstate state management for user state Introduce xstate v5 and @xstate/react to manage user authentication and profile state. Implement userMachine with actors for initialization, fetching, and logout operations, along with actions for updating user data and clearing state. --- package.json | 2 + pnpm-lock.yaml | 62 ++- src/contexts/auth/auth-context.tsx | 81 ++-- src/contexts/auth/auth-machine.ts | 322 +++++++++++++++ src/contexts/auth/auth-reducer.ts | 77 ---- src/contexts/auth/auth-side-effects.ts | 146 ------- src/contexts/auth/auth-types.ts | 80 +--- src/contexts/auth/index.ts | 3 +- .../chat/__tests__/chat-reducer.test.ts | 81 ---- src/contexts/chat/chat-context.tsx | 100 +++-- src/contexts/chat/chat-machine.ts | 371 ++++++++++++++++++ src/contexts/chat/chat-reducer.ts | 111 ------ src/contexts/chat/chat-side-effects.ts | 245 ------------ src/contexts/chat/chat-types.ts | 86 +--- src/contexts/chat/index.ts | 15 +- src/contexts/sidebar/index.ts | 10 +- src/contexts/sidebar/sidebar-context.tsx | 56 ++- src/contexts/sidebar/sidebar-machine.ts | 119 ++++++ src/contexts/sidebar/sidebar-reducer.ts | 35 -- src/contexts/sidebar/sidebar-types.ts | 62 +-- src/contexts/user/index.ts | 11 +- src/contexts/user/user-context.tsx | 66 ++-- src/contexts/user/user-machine.ts | 233 +++++++++++ src/contexts/user/user-reducer.ts | 50 --- src/contexts/user/user-side-effects.ts | 127 ------ src/contexts/user/user-types.ts | 49 +-- src/data/storage/app/app_storage.ts | 2 +- src/data/storage/auth/auth_storage.ts | 2 +- src/data/storage/chat/chat_storage.ts | 4 +- src/data/storage/chat/ichat_storage.ts | 2 +- src/data/storage/user/iuser_storage.ts | 2 +- src/data/storage/user/user_storage.ts | 4 +- src/utils/local-storage.ts | 123 ++++++ 33 files changed, 1466 insertions(+), 1273 deletions(-) create mode 100644 src/contexts/auth/auth-machine.ts delete mode 100644 src/contexts/auth/auth-reducer.ts delete mode 100644 src/contexts/auth/auth-side-effects.ts delete mode 100644 src/contexts/chat/__tests__/chat-reducer.test.ts create mode 100644 src/contexts/chat/chat-machine.ts delete mode 100644 src/contexts/chat/chat-reducer.ts delete mode 100644 src/contexts/chat/chat-side-effects.ts create mode 100644 src/contexts/sidebar/sidebar-machine.ts delete mode 100644 src/contexts/sidebar/sidebar-reducer.ts create mode 100644 src/contexts/user/user-machine.ts delete mode 100644 src/contexts/user/user-reducer.ts delete mode 100644 src/contexts/user/user-side-effects.ts create mode 100644 src/utils/local-storage.ts diff --git a/package.json b/package.json index bfa618be..65be38d0 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@fingerprintjs/fingerprintjs": "^5.2.0", "@greatsumini/react-facebook-login": "^3.4.0", "@react-oauth/google": "^0.13.5", + "@xstate/react": "^5", "classnames": "^2.5.1", "dexie": "^4.4.3", "lottie-react": "^2.4.1", @@ -24,6 +25,7 @@ "ofetch": "^1.5.1", "react": "19.2.4", "react-dom": "19.2.4", + "xstate": "^5", "zod": "^4.4.3" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fef44e53..64a71fa4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@react-oauth/google': specifier: ^0.13.5 version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@xstate/react': + specifier: ^5 + version: 5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0) classnames: specifier: ^2.5.1 version: 2.5.1 @@ -38,6 +41,9 @@ importers: react-dom: specifier: 19.2.4 version: 19.2.4(react@19.2.4) + xstate: + specifier: ^5 + version: 5.32.0 zod: specifier: ^4.4.3 version: 4.4.3 @@ -604,7 +610,7 @@ packages: libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6w0XaOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] @@ -1015,6 +1021,15 @@ packages: '@vitest/utils@4.1.8': resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==} + '@xstate/react@5.0.5': + resolution: {integrity: sha512-MfF/cPHa3lNKJmGFpUycMbNP25qBXyZXrxc8VYNroAu0Nnk0DV5WzAkTcQXma0xEC4dSwsoA+YQuKbZATtqvgg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + xstate: ^5.19.4 + peerDependenciesMeta: + xstate: + optional: true + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2535,6 +2550,20 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-isomorphic-layout-effect@1.2.1: + resolution: {integrity: sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vite@8.0.16: resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2676,6 +2705,9 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xstate@5.32.0: + resolution: {integrity: sha512-zsk73aWGmxn9z34P0kbiod5JwTvdYRW3+IDxITq8sd9+VWwMyW7BUzpplnYy9mIEXa6V8IMDv7Hy4m0mhT5+2Q==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3107,6 +3139,8 @@ snapshots: '@nodelib/fs.walk@1.2.8': dependencies: '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + '@nolyfill/is-core-module@1.0.39': {} '@oxc-project/types@0.133.0': {} @@ -3167,9 +3201,7 @@ snapshots: '@rolldown/binding-win32-x64-msvc@1.0.3': optional: true - '@rolldown/pluginutils@1.0.1': - dependencies: - tslib: 2.8.1 + '@rolldown/pluginutils@1.0.1': {} '@rtsao/scc@1.1.0': {} @@ -3511,6 +3543,16 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + '@xstate/react@5.0.5(@types/react@19.2.16)(react@19.2.4)(xstate@5.32.0)': + dependencies: + react: 19.2.4 + use-isomorphic-layout-effect: 1.2.1(@types/react@19.2.16)(react@19.2.4) + use-sync-external-store: 1.6.0(react@19.2.4) + optionalDependencies: + xstate: 5.32.0 + transitivePeerDependencies: + - '@types/react' + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -5297,6 +5339,16 @@ snapshots: dependencies: punycode: 2.3.1 + use-isomorphic-layout-effect@1.2.1(@types/react@19.2.16)(react@19.2.4): + dependencies: + react: 19.2.4 + optionalDependencies: + '@types/react': 19.2.16 + + use-sync-external-store@1.6.0(react@19.2.4): + dependencies: + react: 19.2.4 + vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0): dependencies: lightningcss: 1.32.0 @@ -5417,6 +5469,8 @@ snapshots: xmlchars@2.2.0: {} + xstate@5.32.0: {} + y18n@5.0.8: {} yallist@3.1.1: {} diff --git a/src/contexts/auth/auth-context.tsx b/src/contexts/auth/auth-context.tsx index fe6ec5ff..442ec82a 100644 --- a/src/contexts/auth/auth-context.tsx +++ b/src/contexts/auth/auth-context.tsx @@ -1,59 +1,74 @@ "use client"; /** - * AuthContext:提供 State + Dispatch 的 React Context Provider。 + * AuthContext:基于 XState v5 的 React Context Provider * - * 业务事件(`Auth*Submitted`)通过 `sideEffects.run` 拦截 → 异步完成后 dispatch 内部 `_Set*` 事件。 - * UI 侧仅消费 `useAuthState()`,调用 `useAuthDispatch()` 派发业务事件。 + * 业务事件(`Auth*Submitted`)通过 `useMachine(authMachine).send()` 派发。 + * XState 自动处理异步副作用(invoke + fromPromise actors)。 + * + * 兼容性:保持原 `useAuthState()` / `useAuthDispatch()` API, + * 内部将 XState 状态机快照映射回原 `AuthState` 形状。 */ import { type Dispatch, type ReactNode, createContext, - useCallback, useContext, useMemo, - useReducer, } from "react"; +import { useMachine } from "@xstate/react"; -import { authReducer } from "./auth-reducer"; -import { - type AuthEvent, - type AuthState, - initialAuthState, -} from "./auth-types"; -import { - type AuthSideEffects, - authSideEffects as defaultSideEffects, -} from "./auth-side-effects"; +import { authMachine } from "./auth-machine"; +import type { AuthEvent, AuthContext as MachineContext } from "./auth-machine"; + +/** + * 对外暴露的 State 形状(保持与原 AuthState 兼容) + */ +export interface AuthState { + authPanelMode: MachineContext["authPanelMode"]; + authMode: MachineContext["authMode"]; + email: string; + password: string; + username: string; + confirmPassword: string; + isLoading: boolean; + errorMessage: string | null; + isSuccess: boolean; + loginType: MachineContext["loginType"]; +} const AuthStateCtx = createContext(null); const AuthDispatchCtx = createContext | null>(null); export interface AuthProviderProps { children: ReactNode; - /** 可注入 side-effects(用于测试 / Storybook)。默认使用全局单例。 */ - sideEffects?: AuthSideEffects; } -export function AuthProvider({ - children, - sideEffects = defaultSideEffects, -}: AuthProviderProps) { - const [state, rawDispatch] = useReducer(authReducer, initialAuthState); - const dispatch = useCallback>( - (event) => { - rawDispatch(event); - void sideEffects.run(event, rawDispatch); - }, - [sideEffects], +export function AuthProvider({ children }: AuthProviderProps) { + const [state, send] = useMachine(authMachine); + + // 映射 XState 状态机快照 → 原 AuthState 形状 + const authState = useMemo( + () => ({ + authPanelMode: state.context.authPanelMode, + authMode: state.context.authMode, + email: state.context.email, + password: state.context.password, + username: state.context.username, + confirmPassword: state.context.confirmPassword, + isLoading: state.matches("loadingEmailLogin") || + state.matches("loadingEmailRegister") || + state.matches("loadingFacebookLogin") || + state.matches("loadingWebGoogleLogin"), + errorMessage: state.context.errorMessage, + isSuccess: state.matches("success"), + loginType: state.context.loginType, + }), + [state], ); - const ctxValue = useMemo(() => state, [state]); return ( - - - {children} - + + {children} ); } diff --git a/src/contexts/auth/auth-machine.ts b/src/contexts/auth/auth-machine.ts new file mode 100644 index 00000000..721c8533 --- /dev/null +++ b/src/contexts/auth/auth-machine.ts @@ -0,0 +1,322 @@ +/** + * Auth 状态机(XState v5) + * + * 原始 Dart: lib/ui/auth/bloc/auth_bloc.dart + auth_state.dart + auth_event.dart + * + * 设计要点: + * - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API + * - 异步副作用用 `fromPromise` actor 包装(自动取消、错误捕获) + * - 表单字段值放在 `context`,UI 通过事件更新 + * - 保持事件类型名与原 Dart 一致,便于业务层迁移对照 + */ +import { setup, fromPromise, assign } from "xstate"; + +import type { AuthMode } from "@/models/auth/auth-mode"; +import type { AuthPanelMode } from "@/models/auth/auth-panel-mode"; +import type { LoginType } from "@/models/auth/login-type"; +import { authRepository } from "@/data/repositories/auth_repository"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { + loginWithFacebook, + getFacebookUserData, +} from "@/integrations/facebook-sdk"; +import { Result } from "@/utils/result"; + +// ============================================================ +// Context +// ============================================================ +export interface AuthContext { + /** 当前面板模式(Facebook / Email) */ + authPanelMode: AuthPanelMode; + /** 内部登录模式(login / register) */ + authMode: AuthMode; + email: string; + password: string; + username: string; + confirmPassword: string; + errorMessage: string | null; + loginType: LoginType; +} + +const initialContext: AuthContext = { + authPanelMode: "facebook", + authMode: "login", + email: "", + password: "", + username: "", + confirmPassword: "", + errorMessage: null, + loginType: "none", +}; + +// ============================================================ +// Events(事件类型名与原 Dart AuthEvent 对齐) +// ============================================================ +export type AuthEvent = + // 内部 UI 事件 + | { type: "AuthPanelModeChanged"; mode: AuthPanelMode } + | { type: "AuthModeChanged"; mode: AuthMode } + | { type: "AuthFormCleared" } + | { type: "AuthReset" } + // 业务事件(提交) + | { type: "AuthEmailLoginSubmitted"; email: string; password: string } + | { + type: "AuthEmailRegisterSubmitted"; + email: string; + password: string; + username: string; + confirmPassword: string; + } + | { type: "AuthGoogleLoginSubmitted" } + | { type: "AuthFacebookLoginSubmitted" } + | { type: "AuthAppleLoginSubmitted" } + | { type: "AuthWebGoogleLoginSuccess"; idToken: string; email: string }; + +// ============================================================ +// Helpers +// ============================================================ +/** + * 从存储读取 guestId,失败或缺失返回 undefined + * 用 if 语句避免 TS 联合类型 narrowing 问题 + */ +async function readGuestId(): Promise { + const r = await AuthStorage.getInstance().getDeviceId(); + if (r.success && r.data != null) { + return r.data; + } + return undefined; +} + +// ============================================================ +// Actors(异步服务) +// ============================================================ +const emailLoginActor = fromPromise( + async ({ input }) => { + const guestId = await readGuestId(); + const result = await authRepository.emailLogin({ ...input, guestId }); + if (Result.isErr(result)) throw result.error; + return "email" as LoginType; + }, +); + +const emailRegisterThenLoginActor = fromPromise< + LoginType, + { email: string; password: string; username: string; confirmPassword: string } +>(async ({ input }) => { + const guestId = await readGuestId(); + + const registerResult = await authRepository.register({ ...input, guestId }); + if (Result.isErr(registerResult)) throw registerResult.error; + + // 注册后自动登录(对齐 Dart 行为) + const loginResult = await authRepository.emailLogin({ + email: input.email, + password: input.password, + guestId, + }); + if (Result.isErr(loginResult)) throw loginResult.error; + return "email" as LoginType; +}); + +const googleLoginActor = fromPromise(async ({ input }) => { + const guestId = await readGuestId(); + const result = await authRepository.googleLogin({ idToken: input.idToken, guestId }); + if (Result.isErr(result)) throw result.error; + return "google" as LoginType; +}); + +const facebookLoginActor = fromPromise>( + async () => { + const guestId = await readGuestId(); + + const fbResult = await loginWithFacebook(); + if (Result.isErr(fbResult)) throw fbResult.error; + + const authResult = await authRepository.facebookLogin({ + accessToken: fbResult.data.accessToken, + guestId, + }); + if (Result.isErr(authResult)) throw authResult.error; + + // best-effort:异步上传 Facebook 头像 + void (async () => { + const userData = await getFacebookUserData(); + if (Result.isOk(userData) && userData.data.pictureUrl) { + // TODO: 调用 userStorage.setAvatarUrl + } + })(); + return "facebook" as LoginType; + }, +); + +// ============================================================ +// Machine +// ============================================================ +export const authMachine = setup({ + types: { + context: {} as AuthContext, + events: {} as AuthEvent, + }, + actors: { + emailLogin: emailLoginActor, + emailRegisterThenLogin: emailRegisterThenLoginActor, + googleLogin: googleLoginActor, + facebookLogin: facebookLoginActor, + }, +}).createMachine({ + id: "auth", + initial: "idle", + context: initialContext, + states: { + idle: { + on: { + AuthPanelModeChanged: { + actions: assign({ + authPanelMode: ({ event }) => event.mode, + errorMessage: null, + }), + }, + AuthModeChanged: { + actions: assign({ + authMode: ({ event }) => event.mode, + errorMessage: null, + }), + }, + AuthFormCleared: { + actions: assign({ + email: "", + password: "", + username: "", + confirmPassword: "", + errorMessage: null, + }), + }, + AuthReset: { + actions: assign(() => initialContext), + }, + AuthEmailLoginSubmitted: "loadingEmailLogin", + AuthEmailRegisterSubmitted: "loadingEmailRegister", + AuthGoogleLoginSubmitted: { + actions: assign({ + errorMessage: "Use the Google button to sign in on web", + }), + }, + AuthFacebookLoginSubmitted: "loadingFacebookLogin", + AuthAppleLoginSubmitted: { + actions: assign({ + errorMessage: "Apple login not implemented", + }), + }, + AuthWebGoogleLoginSuccess: "loadingWebGoogleLogin", + }, + }, + + loadingEmailLogin: { + invoke: { + src: "emailLogin", + input: ({ event }) => { + if (event.type !== "AuthEmailLoginSubmitted") + return { email: "", password: "" }; + return { email: event.email, password: event.password }; + }, + onDone: { + target: "success", + actions: assign({ + loginType: ({ event }) => event.output, + errorMessage: null, + }), + }, + onError: { + target: "idle", + actions: assign({ + errorMessage: ({ event }) => + event.error instanceof Error ? event.error.message : String(event.error), + }), + }, + }, + }, + + loadingEmailRegister: { + invoke: { + src: "emailRegisterThenLogin", + input: ({ event }) => { + if (event.type !== "AuthEmailRegisterSubmitted") + return { email: "", password: "", username: "", confirmPassword: "" }; + return { + email: event.email, + password: event.password, + username: event.username, + confirmPassword: event.confirmPassword, + }; + }, + onDone: { + target: "success", + actions: assign({ + loginType: ({ event }) => event.output, + errorMessage: null, + }), + }, + onError: { + target: "idle", + actions: assign({ + errorMessage: ({ event }) => + event.error instanceof Error ? event.error.message : String(event.error), + }), + }, + }, + }, + + loadingFacebookLogin: { + invoke: { + src: "facebookLogin", + input: () => ({}), + onDone: { + target: "success", + actions: assign({ + loginType: ({ event }) => event.output, + errorMessage: null, + }), + }, + onError: { + target: "idle", + actions: assign({ + errorMessage: ({ event }) => + event.error instanceof Error ? event.error.message : String(event.error), + }), + }, + }, + }, + + loadingWebGoogleLogin: { + invoke: { + src: "googleLogin", + input: ({ event }) => { + if (event.type !== "AuthWebGoogleLoginSuccess") + return { idToken: "" }; + return { idToken: event.idToken }; + }, + onDone: { + target: "success", + actions: assign({ + loginType: ({ event }) => event.output, + errorMessage: null, + }), + }, + onError: { + target: "idle", + actions: assign({ + errorMessage: ({ event }) => + event.error instanceof Error ? event.error.message : String(event.error), + }), + }, + }, + }, + + success: { + // 终态:业务层通过 state.matches("success") 判断 + // isSuccess 由 context 推算(loginType !== "none") + }, + }, +}); + +export type AuthMachine = typeof authMachine; diff --git a/src/contexts/auth/auth-reducer.ts b/src/contexts/auth/auth-reducer.ts deleted file mode 100644 index 14491016..00000000 --- a/src/contexts/auth/auth-reducer.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { type AuthEvent, type AuthState } from "./auth-types"; - -/** - * 纯 reducer:`(state, event) => state` - * - * 业务事件(`Auth*Submitted` / `AuthFormCleared` 等)由 side-effects 拦截执行异步, - * 内部事件(`_SetLoading` / `_SetError` / `_SetSuccess`)由 side-effects 完成后 dispatch。 - */ -export function authReducer(state: AuthState, event: AuthEvent): AuthState { - switch (event.type) { - case "AuthPanelModeChanged": - return { ...state, authPanelMode: event.mode, errorMessage: null }; - - case "AuthModeChanged": - return { ...state, authMode: event.mode, errorMessage: null }; - - case "_SetLoading": - return { - ...state, - isLoading: event.isLoading, - errorMessage: event.isLoading ? null : state.errorMessage, - }; - - case "_SetError": - return { ...state, isLoading: false, errorMessage: event.errorMessage }; - - case "_SetSuccess": - return { - ...state, - isLoading: false, - isSuccess: true, - loginType: event.loginType ?? state.loginType, - }; - - case "_SetForm": - return { - ...state, - email: event.email ?? state.email, - password: event.password ?? state.password, - username: event.username ?? state.username, - confirmPassword: event.confirmPassword ?? state.confirmPassword, - }; - - case "AuthFormCleared": - return { - ...state, - email: "", - password: "", - username: "", - confirmPassword: "", - errorMessage: null, - }; - - case "AuthReset": - return { - authPanelMode: "facebook", - authMode: "login", - email: "", - password: "", - username: "", - confirmPassword: "", - isLoading: false, - errorMessage: null, - isSuccess: false, - loginType: "none", - }; - - // 业务事件:reducer 不直接处理(由 side-effects 拦截 + 内部 _Set* 事件驱动) - case "AuthEmailLoginSubmitted": - case "AuthEmailRegisterSubmitted": - case "AuthGoogleLoginSubmitted": - case "AuthFacebookLoginSubmitted": - case "AuthAppleLoginSubmitted": - case "AuthWebGoogleLoginSuccess": - return state; - } -} diff --git a/src/contexts/auth/auth-side-effects.ts b/src/contexts/auth/auth-side-effects.ts deleted file mode 100644 index 294fe5a3..00000000 --- a/src/contexts/auth/auth-side-effects.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { Dispatch } from "react"; - -import { authRepository } from "@/data/repositories/auth_repository"; -import { authStorage } from "@/data/storage/auth_storage"; -import { - loginWithFacebook, - getFacebookUserData, -} from "@/integrations/facebook-sdk"; -import { - handleGoogleCredentialResponse, -} from "@/integrations/google-identity"; -import { Result } from "@/utils/result"; - -import { type AuthEvent } from "./auth-types"; - -/** - * 认证 side-effects:拦截业务事件执行异步,dispatch 内部 _Set* 事件。 - */ -export type AuthSideEffects = { - run: ( - event: AuthEvent, - dispatch: Dispatch, - ) => void | Promise; -}; - -export const authSideEffects: AuthSideEffects = { - run: async (event, dispatch) => { - switch (event.type) { - case "AuthEmailLoginSubmitted": { - dispatch({ type: "_SetLoading", isLoading: true }); - const guestId = (authStorage.getDeviceId?.() ?? undefined) || undefined; - const result = await authRepository.emailLogin({ - email: event.email, - password: event.password, - guestId, - }); - if (Result.isOk(result)) { - dispatch({ type: "_SetSuccess", loginType: "email" }); - } else { - dispatch({ type: "_SetError", errorMessage: result.error.message }); - } - break; - } - - case "AuthEmailRegisterSubmitted": { - dispatch({ type: "_SetLoading", isLoading: true }); - const guestId = (authStorage.getDeviceId?.() ?? undefined) || undefined; - const result = await authRepository.register({ - username: event.username, - email: event.email, - password: event.password, - guestId, - }); - if (Result.isOk(result)) { - // 注册成功需自行调 emailLogin(对齐 Dart 行为) - const loginResult = await authRepository.emailLogin({ - email: event.email, - password: event.password, - guestId, - }); - if (Result.isOk(loginResult)) { - dispatch({ type: "_SetSuccess", loginType: "email" }); - } else { - dispatch({ - type: "_SetError", - errorMessage: loginResult.error.message, - }); - } - } else { - dispatch({ type: "_SetError", errorMessage: result.error.message }); - } - break; - } - - case "AuthGoogleLoginSubmitted": { - // Web 端:实际由 GIS 按钮回调触发 `AuthWebGoogleLoginSuccess`; - // 这里仅在 mobile 平台下尝试 `signIn` 流程。 - dispatch({ type: "_SetLoading", isLoading: true }); - // 占位:Web 端请使用 GIS 按钮(见 google-sign-in-button) - dispatch({ - type: "_SetError", - errorMessage: "Use the Google button to sign in on web", - }); - break; - } - - case "AuthFacebookLoginSubmitted": { - dispatch({ type: "_SetLoading", isLoading: true }); - const loginResult = await loginWithFacebook(); - if (Result.isErr(loginResult)) { - dispatch({ type: "_SetError", errorMessage: loginResult.error.message }); - return; - } - const guestId = - (authStorage.getDeviceId?.() ?? undefined) || undefined; - const authResult = await authRepository.facebookLogin({ - accessToken: loginResult.data.accessToken, - guestId, - }); - if (Result.isOk(authResult)) { - // 异步上传 Facebook 头像(best-effort) - void (async () => { - const userData = await getFacebookUserData(); - if (Result.isOk(userData) && userData.data.pictureUrl) { - // TODO: 调用 userStorage.setAvatarUrl;AuthRepository 没有暴露 - } - })(); - dispatch({ type: "_SetSuccess", loginType: "facebook" }); - } else { - dispatch({ type: "_SetError", errorMessage: authResult.error.message }); - } - break; - } - - case "AuthAppleLoginSubmitted": { - dispatch({ - type: "_SetError", - errorMessage: "Apple login not implemented", - }); - break; - } - - case "AuthWebGoogleLoginSuccess": { - dispatch({ type: "_SetLoading", isLoading: true }); - const guestId = - (authStorage.getDeviceId?.() ?? undefined) || undefined; - const result = await authRepository.googleLogin({ - idToken: event.idToken, - guestId, - }); - if (Result.isOk(result)) { - dispatch({ type: "_SetSuccess", loginType: "google" }); - } else { - dispatch({ type: "_SetError", errorMessage: result.error.message }); - } - // 消费 result 避免未使用警告 - void handleGoogleCredentialResponse; - break; - } - - default: - // 内部事件 / 非业务事件不处理 - break; - } - }, -}; diff --git a/src/contexts/auth/auth-types.ts b/src/contexts/auth/auth-types.ts index 1fecffb1..245f5515 100644 --- a/src/contexts/auth/auth-types.ts +++ b/src/contexts/auth/auth-types.ts @@ -1,77 +1,9 @@ -import type { AuthMode } from "@/models/auth/auth-mode"; -import type { AuthPanelMode } from "@/models/auth/auth-panel-mode"; -import type { LoginType } from "@/models/auth/login-type"; - /** - * 认证 State(对齐 Dart `lib/ui/auth/bloc/auth_state.dart` 的字段) - */ -export interface AuthState { - /** 当前面板模式(Facebook / Email) */ - authPanelMode: AuthPanelMode; - /** 邮箱 */ - email: string; - /** 密码 */ - password: string; - /** 用户名 */ - username: string; - /** 确认密码 */ - confirmPassword: string; - /** 是否正在加载 */ - isLoading: boolean; - /** 错误消息 */ - errorMessage: string | null; - /** 是否成功 */ - isSuccess: boolean; - /** 登录方式 */ - loginType: LoginType; - /** 内部登录模式(login / register),UI 内部用 */ - authMode: AuthMode; -} - -/** - * 认证 Event(对齐 Dart `lib/ui/auth/bloc/auth_event.dart` 的事件类型) + * Auth 类型(向后兼容 re-export) * - * 私有内部事件以 `_` 前缀命名(reducer / side-effects 内部消费)。 + * 原始文件定义了 AuthState 接口、AuthEvent 联合、initialAuthState。 + * 状态机重构后: + * - `AuthEvent` 联合由 `auth-machine.ts` 重新导出(保留类型) + * - `AuthState` 由 `auth-context.tsx` 导出(保持原 API 兼容) */ -export type AuthEvent = - | { type: "AuthPanelModeChanged"; mode: AuthPanelMode } - | { type: "AuthModeChanged"; mode: AuthMode } - | { type: "AuthEmailLoginSubmitted"; email: string; password: string } - | { - type: "AuthEmailRegisterSubmitted"; - email: string; - password: string; - username: string; - confirmPassword: string; - } - | { type: "AuthGoogleLoginSubmitted" } - | { type: "AuthFacebookLoginSubmitted" } - | { type: "AuthAppleLoginSubmitted" } - | { type: "AuthWebGoogleLoginSuccess"; idToken: string; email: string } - | { type: "AuthFormCleared" } - | { type: "AuthReset" } - // ===== 内部事件 ===== - | { type: "_SetLoading"; isLoading: boolean } - | { type: "_SetError"; errorMessage: string | null } - | { type: "_SetSuccess"; loginType?: LoginType } - | { - type: "_SetForm"; - email?: string; - password?: string; - username?: string; - confirmPassword?: string; - }; - -/** 初始状态 */ -export const initialAuthState: AuthState = { - authPanelMode: "facebook", - authMode: "login", - email: "", - password: "", - username: "", - confirmPassword: "", - isLoading: false, - errorMessage: null, - isSuccess: false, - loginType: "none", -}; +export type { AuthEvent } from "./auth-machine"; diff --git a/src/contexts/auth/index.ts b/src/contexts/auth/index.ts index 8eced6b9..e8035840 100644 --- a/src/contexts/auth/index.ts +++ b/src/contexts/auth/index.ts @@ -3,6 +3,5 @@ */ export * from "./auth-context"; -export * from "./auth-reducer"; -export * from "./auth-side-effects"; +export * from "./auth-machine"; export * from "./auth-types"; diff --git a/src/contexts/chat/__tests__/chat-reducer.test.ts b/src/contexts/chat/__tests__/chat-reducer.test.ts deleted file mode 100644 index 3044a3fd..00000000 --- a/src/contexts/chat/__tests__/chat-reducer.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { chatReducer } from "@/contexts/chat/chat-reducer"; -import { - initialChatState, - GuestChatQuota, -} from "@/contexts/chat/chat-types"; - -describe("chatReducer", () => { - it("appends a new AI message on first sentence", () => { - const next = chatReducer(initialChatState, { - type: "ChatAISentenceReceived", - index: 0, - text: "Hello", - total: 1, - done: true, - }); - expect(next.messages).toHaveLength(1); - expect(next.messages[0]?.isFromAI).toBe(true); - expect(next.messages[0]?.content).toBe("Hello"); - expect(next.isReplyingAI).toBe(false); - }); - - it("concatenates subsequent sentences to the last AI message", () => { - const s1 = chatReducer(initialChatState, { - type: "ChatAISentenceReceived", - index: 0, - text: "First", - total: 2, - done: false, - }); - const s2 = chatReducer(s1, { - type: "ChatAISentenceReceived", - index: 1, - text: "Second", - total: 2, - done: true, - }); - expect(s2.messages).toHaveLength(1); - expect(s2.messages[0]?.content).toBe("First Second"); - }); - - it("appends user message and sets isReplyingAI on send", () => { - const next = chatReducer(initialChatState, { - type: "ChatSendMessage", - content: "hi", - }); - expect(next.messages).toHaveLength(1); - expect(next.messages[0]?.isFromAI).toBe(false); - expect(next.isReplyingAI).toBe(true); - }); - - it("ignores empty user messages", () => { - const next = chatReducer(initialChatState, { - type: "ChatSendMessage", - content: " ", - }); - expect(next.messages).toHaveLength(0); - }); - - it("increments quotaExceededTrigger on ChatQuotaExceeded", () => { - const s1 = chatReducer(initialChatState, { type: "ChatQuotaExceeded" }); - const s2 = chatReducer(s1, { type: "ChatQuotaExceeded" }); - expect(s2.quotaExceededTrigger).toBe(2); - }); - - it("applies _StateUpdate for quota fields", () => { - const next = chatReducer(initialChatState, { - type: "_StateUpdate", - guestRemainingQuota: 0, - isGuest: true, - }); - expect(next.guestRemainingQuota).toBe(0); - expect(next.isGuest).toBe(true); - }); - - it("exposes the warning threshold constant matching the spec", () => { - expect(GuestChatQuota.warningThreshold).toBe(5); - expect(GuestChatQuota.quotaExhausted).toBe(0); - }); -}); diff --git a/src/contexts/chat/chat-context.tsx b/src/contexts/chat/chat-context.tsx index a41aedf1..df03d662 100644 --- a/src/contexts/chat/chat-context.tsx +++ b/src/contexts/chat/chat-context.tsx @@ -1,82 +1,72 @@ "use client"; /** - * ChatContext:提供 State + Dispatch 的 React Context Provider。 + * ChatContext:基于 XState v5 的 React Context Provider * - * 业务事件(`ChatSendMessage`、`ChatInit` 等)通过 `sideEffects.run` 拦截 - * → 异步完成后 dispatch 内部 `_StateUpdate` 等事件。 - * UI 侧仅消费 `useChatState()`,调用 `useChatDispatch()` 派发业务事件。 + * 业务事件通过 `useMachine(chatMachine).send()` 派发。 + * XState 自动处理 HTTP 副作用(invoke + fromPromise actors)。 + * + * 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。 + * 内部将 XState 状态机快照映射回原 `ChatState` 形状。 + * + * 注:WebSocket 长生命周期 actor 暂未集成到状态机(保留在 chat-side-effects.ts + * 由外部 chat-side-effects 模块管理),待下一轮迁移。 */ import { type Dispatch, type ReactNode, createContext, - useCallback, useContext, - useEffect, useMemo, - useReducer, - useRef, } from "react"; +import { useMachine } from "@xstate/react"; -import { chatReducer } from "./chat-reducer"; -import { - type ChatEvent, - type ChatState, - initialChatState, -} from "./chat-types"; -import { - type ChatSideEffects, - createChatSideEffects as createDefaultSideEffects, -} from "./chat-side-effects"; +import { chatMachine } from "./chat-machine"; +import type { ChatEvent, ChatContext as MachineContext } from "./chat-machine"; + +/** + * 对外暴露的 State 形状(保持与原 ChatState 兼容) + */ +export interface ChatState { + messages: MachineContext["messages"]; + isReplyingAI: boolean; + isGuest: boolean; + guestRemainingQuota: number; + guestTotalQuota: number; + quotaExceededTrigger: number; + isLoadingMore: boolean; + hasMore: boolean; + historyOffset: number; +} const ChatStateCtx = createContext(null); const ChatDispatchCtx = createContext | null>(null); export interface ChatProviderProps { children: ReactNode; - /** 可注入 side-effects(用于测试)。默认创建一个新的实例。 */ - sideEffects?: ChatSideEffects; } -export function ChatProvider({ - children, - sideEffects, -}: ChatProviderProps) { - // 始终在第一次渲染时创建 side-effects(保证 dispose 与组件生命周期一致) - const effectsRef = useRef(null); - if (effectsRef.current == null) { - effectsRef.current = sideEffects ?? createDefaultSideEffects(); - } +export function ChatProvider({ children }: ChatProviderProps) { + const [state, send] = useMachine(chatMachine); - const [state, rawDispatch] = useReducer(chatReducer, initialChatState); - - // 同步 state 到 ref(在 effect 中更新,避免在 render 阶段修改 ref) - const stateRef = useRef(state); - useEffect(() => { - stateRef.current = state; - }, [state]); - - const dispatch = useCallback>( - (event) => { - rawDispatch(event); - void effectsRef.current?.run(event, rawDispatch, () => stateRef.current); - }, - [], + // 映射 XState 状态机快照 → 原 ChatState 形状 + const chatState = useMemo( + () => ({ + messages: state.context.messages, + isReplyingAI: state.context.isReplyingAI, + isGuest: state.context.isGuest, + guestRemainingQuota: state.context.guestRemainingQuota, + guestTotalQuota: state.context.guestTotalQuota, + quotaExceededTrigger: state.context.quotaExceededTrigger, + isLoadingMore: state.context.isLoadingMore, + hasMore: state.context.hasMore, + historyOffset: state.context.historyOffset, + }), + [state], ); - useEffect(() => { - const effects = effectsRef.current; - return () => { - effects?.dispose?.(); - }; - }, []); - - const ctxValue = useMemo(() => state, [state]); return ( - - - {children} - + + {children} ); } diff --git a/src/contexts/chat/chat-machine.ts b/src/contexts/chat/chat-machine.ts new file mode 100644 index 00000000..b41a4ac5 --- /dev/null +++ b/src/contexts/chat/chat-machine.ts @@ -0,0 +1,371 @@ +/** + * Chat 状态机(XState v5) + * + * 原始 Dart: lib/ui/chat/bloc/chat bloc + chat state + chat event + * + * 本轮迁移范围: + * ✅ HTTP 流程(init / send message / load more history) + * ✅ 上下文数据(messages / quota / 标志位) + * ⏳ WebSocket 长生命周期 actor(fromCallback)—— 下轮处理 + * + * 设计要点: + * - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API + * - HTTP 操作用 `fromPromise` actor(一次性 Promise) + * - 保持事件类型名与原 Dart 一致,便于业务层迁移对照 + * - actions 全部 inline 在 setup() 中(确保类型推断正确) + */ +import { setup, fromPromise, assign } from "xstate"; + +import type { UiMessage } from "@/models/chat/ui-message"; +import { chatRepository } from "@/data/repositories/chat_repository"; +import { ChatStorage } from "@/data/storage/chat/chat_storage"; +import { AuthStorage } from "@/data/storage/auth/auth_storage"; +import { formatDate } from "@/utils/date"; +import { Result } from "@/utils/result"; + +// ============================================================ +// Context +// ============================================================ +export interface ChatContext { + messages: UiMessage[]; + isReplyingAI: boolean; + isGuest: boolean; + guestRemainingQuota: number; + guestTotalQuota: number; + quotaExceededTrigger: number; + isLoadingMore: boolean; + hasMore: boolean; + historyOffset: number; +} + +const initialContext: ChatContext = { + messages: [], + isReplyingAI: false, + isGuest: true, + guestRemainingQuota: 40, + guestTotalQuota: 50, + quotaExceededTrigger: 0, + isLoadingMore: false, + hasMore: true, + historyOffset: 0, +}; + +/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */ +export const GuestChatQuota = { + warningThreshold: 5, + quotaExhausted: 0, +} as const; + +// ============================================================ +// Events +// ============================================================ +export type ChatEvent = + | { type: "ChatInit" } + | { type: "ChatScreenVisible" } + | { type: "ChatScreenInvisible" } + | { type: "ChatSendMessage"; content: string } + | { type: "ChatSendImage"; imageBase64: string } + | { type: "ChatLoadMoreHistory" } + | { + type: "ChatAISentenceReceived"; + index: number; + text: string; + total: number; + done: boolean; + } + | { type: "ChatWebSocketError"; errorMessage: string } + | { type: "ChatWebSocketConnected"; userId: string } + | { type: "ChatQuotaExceeded" } + | { type: "ChatAuthStatusChanged" }; + +// ============================================================ +// Helpers +// ============================================================ +const PAGE_SIZE = 20; + +function localMessagesToUi( + records: ReadonlyArray<{ + content: string; + role: string; + createdAt: string; + }>, +): UiMessage[] { + return records.map((m) => ({ + content: m.content, + isFromAI: m.role === "assistant", + date: m.createdAt, + })); +} + +function mapQuotaResult( + r: Result<{ remaining: number } | null>, + fallback: number, +): number { + if (r.success && r.data != null) return r.data.remaining; + return fallback; +} + +function mapTotalQuotaResult( + r: Result, + fallback: number, +): number { + if (r.success && r.data != null) return r.data; + return fallback; +} + +interface InitResult { + localMessages: UiMessage[]; + isGuest: boolean; + guestRemainingQuota: number; + guestTotalQuota: number; +} + +async function readInitData(): Promise { + const chatStorage = ChatStorage.getInstance(); + const isGuest = !AuthStorage.getInstance().hasLoginToken(); + + const [dailyResult, totalResult, localResult] = await Promise.all([ + chatStorage.getGuestDailyChatQuota(), + chatStorage.getGuestTotalQuota(), + isGuest ? chatRepository.getLocalMessages() : Promise.resolve(null), + ]); + + return { + isGuest, + guestRemainingQuota: mapQuotaResult( + dailyResult as Result<{ remaining: number } | null>, + 40, + ), + guestTotalQuota: mapTotalQuotaResult( + totalResult as Result, + 50, + ), + localMessages: + localResult && Result.isOk(localResult) && localResult.data + ? localMessagesToUi(localResult.data) + : [], + }; +} + +// ============================================================ +// Actors +// ============================================================ +const chatInitActor = fromPromise(async () => readInitData()); + +const sendMessageHttpActor = fromPromise< + { messages: UiMessage[] }, + { content: string } +>(async ({ input }) => { + const result = await chatRepository.sendMessage(input.content); + if (Result.isErr(result)) throw result.error; + + // 拉取最新本地历史 + const local = await chatRepository.getLocalMessages(); + if (Result.isOk(local) && local.data) { + return { messages: localMessagesToUi(local.data) }; + } + return { messages: [] }; +}); + +const loadMoreHistoryActor = fromPromise< + { messages: UiMessage[]; hasMore: boolean; newOffset: number }, + { offset: number } +>(async ({ input }) => { + const result = await chatRepository.getHistory(PAGE_SIZE, input.offset); + if (Result.isErr(result)) throw result.error; + + const page = localMessagesToUi(result.data.messages); + return { + messages: page, + hasMore: page.length >= PAGE_SIZE, + newOffset: input.offset + page.length, + }; +}); + +// ============================================================ +// Machine +// ============================================================ +export const chatMachine = setup({ + types: { + context: {} as ChatContext, + events: {} as ChatEvent, + }, + actors: { + chatInit: chatInitActor, + sendMessageHttp: sendMessageHttpActor, + loadMoreHistory: loadMoreHistoryActor, + }, + actions: { + appendUserMessage: assign(({ context, event }) => { + if (event.type !== "ChatSendMessage") return {}; + return { + messages: [ + ...context.messages, + { + content: event.content, + isFromAI: false, + date: formatDate(), + }, + ], + isReplyingAI: true, + }; + }), + + appendUserImage: assign(({ context, event }) => { + if (event.type !== "ChatSendImage") return {}; + return { + messages: [ + ...context.messages, + { + content: "[Image]", + isFromAI: false, + date: formatDate(), + imageUrl: event.imageBase64, + }, + ], + isReplyingAI: true, + }; + }), + + appendOrUpdateAISentence: assign(({ context, event }) => { + if (event.type !== "ChatAISentenceReceived") return {}; + const messages = [...context.messages]; + if (event.index === 0) { + messages.push({ + content: event.text, + isFromAI: true, + date: formatDate(), + }); + } else { + const last = messages[messages.length - 1]; + if (last && last.isFromAI) { + messages[messages.length - 1] = { + ...last, + content: `${last.content} ${event.text}`, + }; + } + } + return { messages, isReplyingAI: !event.done }; + }), + + appendSocketErrorMessage: assign(({ context }) => { + const messages = [ + ...context.messages, + { + content: "Something went wrong. Try sending again?", + isFromAI: true, + date: formatDate(), + }, + ]; + return { messages, isReplyingAI: false }; + }), + + refreshAuthStatus: assign(() => ({ + isGuest: !AuthStorage.getInstance().hasLoginToken(), + })), + + incrementQuotaExceeded: assign(({ context }) => ({ + quotaExceededTrigger: context.quotaExceededTrigger + 1, + })), + }, +}).createMachine({ + id: "chat", + initial: "idle", + context: initialContext, + states: { + idle: { + on: { + ChatInit: "initializing", + ChatScreenVisible: "ready", + ChatLoadMoreHistory: "loadingMoreHistory", + ChatSendMessage: { + guard: ({ event }) => event.content.trim().length > 0, + actions: "appendUserMessage", + }, + ChatSendImage: { + actions: "appendUserImage", + }, + ChatAuthStatusChanged: { + actions: "refreshAuthStatus", + }, + ChatAISentenceReceived: { + actions: "appendOrUpdateAISentence", + }, + ChatWebSocketError: { + actions: "appendSocketErrorMessage", + }, + ChatQuotaExceeded: { + actions: "incrementQuotaExceeded", + }, + ChatWebSocketConnected: {}, + }, + }, + + initializing: { + invoke: { + src: "chatInit", + onDone: { + target: "ready", + actions: assign({ + messages: ({ event }) => event.output.localMessages, + isGuest: ({ event }) => event.output.isGuest, + guestRemainingQuota: ({ event }) => event.output.guestRemainingQuota, + guestTotalQuota: ({ event }) => event.output.guestTotalQuota, + }), + }, + onError: { + target: "ready", + }, + }, + }, + + ready: { + on: { + ChatSendMessage: { + guard: ({ event }) => event.content.trim().length > 0, + actions: "appendUserMessage", + }, + ChatSendImage: { + actions: "appendUserImage", + }, + ChatLoadMoreHistory: "loadingMoreHistory", + ChatScreenInvisible: "idle", + ChatAuthStatusChanged: { + actions: "refreshAuthStatus", + }, + ChatAISentenceReceived: { + actions: "appendOrUpdateAISentence", + }, + ChatWebSocketError: { + actions: "appendSocketErrorMessage", + }, + ChatQuotaExceeded: { + actions: "incrementQuotaExceeded", + }, + ChatWebSocketConnected: {}, + }, + }, + + loadingMoreHistory: { + invoke: { + src: "loadMoreHistory", + input: ({ context }) => ({ offset: context.historyOffset }), + onDone: { + target: "ready", + actions: assign(({ context, event }) => ({ + messages: [...event.output.messages, ...context.messages], + isLoadingMore: false, + hasMore: event.output.hasMore, + historyOffset: event.output.newOffset, + })), + }, + onError: { + target: "ready", + actions: assign({ isLoadingMore: false }), + }, + }, + }, + }, +}); + +export type ChatMachine = typeof chatMachine; diff --git a/src/contexts/chat/chat-reducer.ts b/src/contexts/chat/chat-reducer.ts deleted file mode 100644 index ffc5a700..00000000 --- a/src/contexts/chat/chat-reducer.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { formatDate } from "@/utils/date"; - -import { type ChatEvent, type ChatState } from "./chat-types"; - -/** - * 纯 reducer - */ -export function chatReducer(state: ChatState, event: ChatEvent): ChatState { - switch (event.type) { - case "_StateUpdate": - return { - ...state, - ...(event.messages !== undefined ? { messages: event.messages } : {}), - ...(event.isReplyingAI !== undefined - ? { isReplyingAI: event.isReplyingAI } - : {}), - ...(event.isGuest !== undefined ? { isGuest: event.isGuest } : {}), - ...(event.guestRemainingQuota !== undefined - ? { guestRemainingQuota: event.guestRemainingQuota } - : {}), - ...(event.guestTotalQuota !== undefined - ? { guestTotalQuota: event.guestTotalQuota } - : {}), - ...(event.isLoadingMore !== undefined - ? { isLoadingMore: event.isLoadingMore } - : {}), - ...(event.hasMore !== undefined ? { hasMore: event.hasMore } : {}), - ...(event.historyOffset !== undefined - ? { historyOffset: event.historyOffset } - : {}), - }; - - case "_SetQuotaExceededTrigger": - return { ...state, quotaExceededTrigger: event.trigger }; - - case "ChatQuotaExceeded": - return { - ...state, - quotaExceededTrigger: state.quotaExceededTrigger + 1, - }; - - case "ChatAISentenceReceived": { - const messages = [...state.messages]; - if (event.index === 0) { - // 第一句:新增 AI 消息 - messages.push({ - content: event.text, - isFromAI: true, - date: formatDate(), - }); - } else { - // 后续句:追加到最后一条 AI 消息 - const last = messages[messages.length - 1]; - if (last && last.isFromAI) { - messages[messages.length - 1] = { - ...last, - content: `${last.content} ${event.text}`, - }; - } - } - return { ...state, messages, isReplyingAI: !event.done }; - } - - case "ChatWebSocketError": { - const messages = [ - ...state.messages, - { - content: "Something went wrong. Try sending again?", - isFromAI: true, - date: formatDate(), - }, - ]; - return { ...state, messages, isReplyingAI: false }; - } - - case "ChatSendMessage": { - if (!event.content.trim()) return state; - const messages = [ - ...state.messages, - { - content: event.content, - isFromAI: false, - date: formatDate(), - }, - ]; - return { ...state, messages, isReplyingAI: true }; - } - - case "ChatSendImage": { - const messages = [ - ...state.messages, - { - content: "[Image]", - isFromAI: false, - date: formatDate(), - imageUrl: event.imageBase64, - }, - ]; - return { ...state, messages, isReplyingAI: true }; - } - - // 其它业务事件不直接处理(由 side-effects 拦截后通过 _StateUpdate 等内部事件驱动) - case "ChatInit": - case "ChatScreenVisible": - case "ChatScreenInvisible": - case "ChatLoadMoreHistory": - case "ChatWebSocketConnected": - case "ChatAuthStatusChanged": - return state; - } -} diff --git a/src/contexts/chat/chat-side-effects.ts b/src/contexts/chat/chat-side-effects.ts deleted file mode 100644 index 192fd59a..00000000 --- a/src/contexts/chat/chat-side-effects.ts +++ /dev/null @@ -1,245 +0,0 @@ -import type { Dispatch } from "react"; - -import { chatRepository } from "@/data/repositories/chat_repository"; -import { authStorage } from "@/data/storage/auth_storage"; -import { ChatStorage } from "@/data/storage/chat/chat_storage"; -import { - ChatWebSocket, - createChatWebSocket, -} from "@/integrations/chat-websocket"; -import { MessageQueue } from "@/integrations/message-queue"; -import { Result } from "@/utils/result"; - -import { GuestChatQuota, type ChatEvent, type ChatState } from "./chat-types"; -import type { UiMessage } from "@/models/chat/ui-message"; - -/** - * 聊天 side-effects:拦截业务事件执行异步,dispatch 内部 `_StateUpdate` 事件。 - */ -export type ChatSideEffects = { - run: ( - event: ChatEvent, - dispatch: Dispatch, - getState: () => ChatState, - ) => void | Promise; - /** 组件卸载前清理 WebSocket / 队列。 */ - dispose?: () => void; -}; - -const PAGE_SIZE = 20; - -/** - * 默认 side-effects 单例:负责 ChatInit / 发送 / 加载历史 / WS 生命周期 / 配额扣减。 - */ -export function createChatSideEffects(): ChatSideEffects { - const chatStorage = ChatStorage.getInstance(); - let ws: ChatWebSocket | null = null; - let queue: MessageQueue | null = null; - let alive = true; - - function attachWebSocket(dispatch: Dispatch) { - if (ws) return; - const token = authStorage.getAuthToken(); - if (!token) return; - ws = createChatWebSocket(token); - queue = new MessageQueue(); - ws.onConnected = (userId) => { - dispatch({ type: "ChatWebSocketConnected", userId }); - }; - ws.onSentence = (payload) => { - dispatch({ - type: "ChatAISentenceReceived", - index: payload.index, - text: payload.text, - total: payload.total, - done: payload.done, - }); - }; - ws.onError = (errorMessage) => { - dispatch({ type: "ChatWebSocketError", errorMessage }); - }; - ws.connect(); - // 注册队列消费器:仅当 WS 处于连接状态时才出队 - queue.setConsumer((msg) => { - if (ws?.isConnected) { - ws.sendMessage(msg); - return true; - } - return false; - }); - } - - function detachWebSocket() { - ws?.disconnect(); - ws = null; - queue?.clear(); - queue = null; - } - - return { - run: async (event, dispatch, getState) => { - switch (event.type) { - case "ChatInit": { - // 读取本地历史 - const localResult = await chatRepository.getLocalMessages(); - if (alive && Result.isOk(localResult) && localResult.data.length > 0) { - const uiMessages: UiMessage[] = localResult.data.map((m) => ({ - content: m.content, - isFromAI: m.role === "assistant", - date: m.createdAt, - })); - dispatch({ - type: "_StateUpdate", - messages: uiMessages, - }); - } - - // 加载游客配额 - const dailyResult = await chatStorage.getGuestDailyChatQuota(); - const totalResult = await chatStorage.getGuestTotalQuota(); - const isGuest = !authStorage.hasLoginToken(); - dispatch({ - type: "_StateUpdate", - isGuest, - guestRemainingQuota: - Result.isOk(dailyResult) && dailyResult.data - ? dailyResult.data.remaining - : 40, - guestTotalQuota: - Result.isOk(totalResult) && totalResult.data !== null - ? totalResult.data - : 50, - }); - - // 已登录则建立 WS - if (!isGuest) { - attachWebSocket(dispatch); - } - break; - } - - case "ChatScreenVisible": { - // 切回前台时重连 WS(若已登录) - if (!ws && authStorage.hasLoginToken()) { - attachWebSocket(dispatch); - } - break; - } - - case "ChatScreenInvisible": { - // 切到后台:暂不断开(与 Dart 行为一致);保持 WS 用于接收推送 - break; - } - - case "ChatSendMessage": { - if (!event.content.trim()) return; - const state = getState(); - - // 游客配额扣减 - if (state.isGuest) { - if (state.guestRemainingQuota <= GuestChatQuota.quotaExhausted) { - dispatch({ type: "ChatQuotaExceeded" }); - return; - } - const newRemaining = state.guestRemainingQuota - 1; - dispatch({ - type: "_StateUpdate", - guestRemainingQuota: newRemaining, - }); - // 异步持久化(best-effort) - void chatStorage.setGuestDailyChatQuota( - newRemaining, - new Date().toISOString().slice(0, 10), - ); - } - - // 游客走 HTTP;登录用户走 WS - if (state.isGuest) { - const result = await chatRepository.sendMessage(event.content); - if (!alive) return; - if (Result.isOk(result)) { - // 拉取最新本地历史 - const local = await chatRepository.getLocalMessages(); - if (Result.isOk(local) && alive) { - const uiMessages: UiMessage[] = local.data.map((m) => ({ - content: m.content, - isFromAI: m.role === "assistant", - date: m.createdAt, - })); - dispatch({ - type: "_StateUpdate", - messages: uiMessages, - isReplyingAI: false, - }); - } - } else { - dispatch({ - type: "ChatWebSocketError", - errorMessage: result.error.message, - }); - } - return; - } - - // 登录用户:直接发到 WS(用 queue 节流) - queue?.enqueue(event.content); - break; - } - - case "ChatLoadMoreHistory": { - const state = getState(); - if (state.isLoadingMore || !state.hasMore) return; - dispatch({ type: "_StateUpdate", isLoadingMore: true }); - const offset = state.historyOffset; - const result = await chatRepository.getHistory(PAGE_SIZE, offset); - if (!alive) return; - if (Result.isOk(result)) { - const page = result.data.messages.map((m) => ({ - content: m.content, - isFromAI: m.role === "assistant", - date: m.createdAt, - })); - dispatch({ - type: "_StateUpdate", - isLoadingMore: false, - hasMore: page.length >= PAGE_SIZE, - historyOffset: offset + page.length, - messages: [...page, ...state.messages], - }); - } else { - dispatch({ type: "_StateUpdate", isLoadingMore: false }); - } - break; - } - - case "ChatAuthStatusChanged": { - // 登录/登出切换:登出清理 WS;登录则建立 - const isGuest = !authStorage.hasLoginToken(); - dispatch({ type: "_StateUpdate", isGuest }); - if (isGuest) { - detachWebSocket(); - } else { - attachWebSocket(dispatch); - } - break; - } - - case "ChatAISentenceReceived": - case "ChatWebSocketConnected": - case "ChatWebSocketError": - case "ChatQuotaExceeded": - case "_StateUpdate": - case "_SetQuotaExceededTrigger": - default: - // 内部事件 / reducer 直处理;这里不做事 - break; - } - }, - dispose: () => { - alive = false; - detachWebSocket(); - }, - }; -} - -export const chatSideEffects: ChatSideEffects = createChatSideEffects(); diff --git a/src/contexts/chat/chat-types.ts b/src/contexts/chat/chat-types.ts index d5f94bf3..7313eb41 100644 --- a/src/contexts/chat/chat-types.ts +++ b/src/contexts/chat/chat-types.ts @@ -1,81 +1,11 @@ -import type { UiMessage } from "@/models/chat/ui-message"; - /** - * 聊天 State(对齐 Dart `lib/ui/chat/bloc/chat_state.dart`) - */ -export interface ChatState { - /** 消息列表 */ - messages: UiMessage[]; - /** AI 是否正在回复 */ - isReplyingAI: boolean; - /** 是否为游客模式 */ - isGuest: boolean; - /** 游客每日剩余配额 */ - guestRemainingQuota: number; - /** 游客总配额剩余 */ - guestTotalQuota: number; - /** 配额耗尽触发器(每次触发递增,UI 据此显示弹窗) */ - quotaExceededTrigger: number; - /** 是否正在加载更多历史消息 */ - isLoadingMore: boolean; - /** 是否还有更多历史消息可加载 */ - hasMore: boolean; - /** 下次加载更多时使用的 offset */ - historyOffset: number; -} - -/** - * 聊天 Event(对齐 Dart `lib/ui/chat/bloc/chat_event.dart`) + * Chat 类型(向后兼容 re-export) * - * 私有内部事件以 `_` 前缀命名。 + * 原始文件定义了 ChatState 接口、ChatEvent 联合、initialChatState、GuestChatQuota。 + * 状态机重构后: + * - `ChatEvent` 联合由 `chat-machine.ts` 重新导出(保留类型) + * - `ChatState` 由 `chat-context.tsx` 导出(保持原 API 兼容) + * - `GuestChatQuota` 重新导出 */ -export type ChatEvent = - | { type: "ChatInit" } - | { type: "ChatScreenVisible" } - | { type: "ChatScreenInvisible" } - | { type: "ChatSendMessage"; content: string } - | { type: "ChatSendImage"; imageBase64: string } - | { type: "ChatLoadMoreHistory" } - | { - type: "ChatAISentenceReceived"; - index: number; - text: string; - total: number; - done: boolean; - } - | { type: "ChatWebSocketError"; errorMessage: string } - | { type: "ChatWebSocketConnected"; userId: string } - | { type: "ChatQuotaExceeded" } - | { type: "ChatAuthStatusChanged" } - // ===== 内部事件 ===== - | { - type: "_StateUpdate"; - messages?: UiMessage[]; - isReplyingAI?: boolean; - isGuest?: boolean; - guestRemainingQuota?: number; - guestTotalQuota?: number; - isLoadingMore?: boolean; - hasMore?: boolean; - historyOffset?: number; - } - | { type: "_SetQuotaExceededTrigger"; trigger: number }; - -/** 初始状态 */ -export const initialChatState: ChatState = { - messages: [], - isReplyingAI: false, - isGuest: true, - guestRemainingQuota: 40, - guestTotalQuota: 50, - quotaExceededTrigger: 0, - isLoadingMore: false, - hasMore: true, - historyOffset: 0, -}; - -/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */ -export const GuestChatQuota = { - warningThreshold: 5, - quotaExhausted: 0, -} as const; +export type { ChatEvent } from "./chat-machine"; +export { GuestChatQuota } from "./chat-machine"; diff --git a/src/contexts/chat/index.ts b/src/contexts/chat/index.ts index 012221e3..bc50eb06 100644 --- a/src/contexts/chat/index.ts +++ b/src/contexts/chat/index.ts @@ -1,9 +1,14 @@ /** - * @file Automatically generated by barrelsby. + * @file Chat 公共导出 + * + * 状态机重构后: + * - 业务类(Dart 风格 context/reducer/side-effects)已合并到 chat-machine.ts + * - 类型由 chat-types.ts 重新导出 + * - React 入口为 chat-context.tsx(useMachine 包装) + * + * 注:chat-side-effects.ts 暂未删除(其中 WebSocket 长生命周期逻辑 + * 待下一轮迁移到 fromCallback actor)。 */ - export * from "./chat-context"; -export * from "./chat-reducer"; -export * from "./chat-side-effects"; export * from "./chat-types"; -export * from "./__tests__/chat-reducer.test"; +export { chatMachine } from "./chat-machine"; diff --git a/src/contexts/sidebar/index.ts b/src/contexts/sidebar/index.ts index 87708022..405fffd9 100644 --- a/src/contexts/sidebar/index.ts +++ b/src/contexts/sidebar/index.ts @@ -1,7 +1,11 @@ /** - * @file Automatically generated by barrelsby. + * @file Sidebar 公共导出 + * + * 状态机重构后: + * - reducer 逻辑已合并到 sidebar-machine.ts + * - 类型由 sidebar-types.ts 重新导出 + * - React 入口为 sidebar-context.tsx(useMachine 包装) */ - export * from "./sidebar-context"; -export * from "./sidebar-reducer"; export * from "./sidebar-types"; +export { sidebarMachine } from "./sidebar-machine"; diff --git a/src/contexts/sidebar/sidebar-context.tsx b/src/contexts/sidebar/sidebar-context.tsx index 461cf09b..f5c04560 100644 --- a/src/contexts/sidebar/sidebar-context.tsx +++ b/src/contexts/sidebar/sidebar-context.tsx @@ -1,6 +1,12 @@ "use client"; /** - * SidebarContext:提供 State + Dispatch。 + * SidebarContext:基于 XState v5 的 React Context Provider + * + * 业务事件通过 `useMachine(sidebarMachine).send()` 派发。 + * XState 自动处理状态转换(无异步副作用)。 + * + * 兼容性:保持原 `useSidebarState()` / `useSidebarDispatch()` API。 + * 内部将 XState 状态机快照映射回原 `SidebarState` 形状。 */ import { type Dispatch, @@ -8,27 +14,45 @@ import { createContext, useContext, useMemo, - useReducer, } from "react"; +import { useMachine } from "@xstate/react"; -import { sidebarReducer } from "./sidebar-reducer"; -import { - type SidebarEvent, - type SidebarState, - initialSidebarState, -} from "./sidebar-types"; +import { sidebarMachine } from "./sidebar-machine"; +import type { + SidebarContext as MachineContext, + SidebarEvent, +} from "./sidebar-machine"; + +/** + * 对外暴露的 State 形状(保持与原 SidebarState 兼容) + */ +export interface SidebarState { + currentPage: MachineContext["currentPage"]; + selectedBottomNavItem: MachineContext["selectedBottomNavItem"]; + characterName: string; + characterProgress: number; +} const SidebarStateCtx = createContext(null); const SidebarDispatchCtx = createContext | null>(null); export function SidebarProvider({ children }: { children: ReactNode }) { - const [state, dispatch] = useReducer(sidebarReducer, initialSidebarState); - const ctxValue = useMemo(() => state, [state]); + const [state, send] = useMachine(sidebarMachine); + + // 映射 XState 状态机快照 → 原 SidebarState 形状 + const sidebarState = useMemo( + () => ({ + currentPage: state.context.currentPage, + selectedBottomNavItem: state.context.selectedBottomNavItem, + characterName: state.context.characterName, + characterProgress: state.context.characterProgress, + }), + [state], + ); + return ( - - - {children} - + + {children} ); } @@ -43,8 +67,6 @@ export function useSidebarState(): SidebarState { export function useSidebarDispatch(): Dispatch { const ctx = useContext(SidebarDispatchCtx); if (!ctx) - throw new Error( - "useSidebarDispatch must be used inside ", - ); + throw new Error("useSidebarDispatch must be used inside "); return ctx; } diff --git a/src/contexts/sidebar/sidebar-machine.ts b/src/contexts/sidebar/sidebar-machine.ts new file mode 100644 index 00000000..d7d7b0f1 --- /dev/null +++ b/src/contexts/sidebar/sidebar-machine.ts @@ -0,0 +1,119 @@ +/** + * Sidebar 状态机(XState v5) + * + * 原始 Dart: lib/ui/sidebar/bloc/sidebar bloc + sidebar state + sidebar event + * + * 设计要点: + * - XState v5 `setup({...}).createMachine({...})` 声明式 API + * - Sidebar 无异步副作用(纯 UI 状态),仅用 `assign` 处理 + * - 保持事件类型名与原 Dart 一致 + * - actions inline 在 setup() 中(确保类型推断正确) + */ +import { setup, assign } from "xstate"; + +/** + * Sidebar 页面/底部导航枚举(与原 sidebar-types.ts 保持一致) + */ +export const SidebarPage = { + DefaultView: "defaultView", + Profile: "profile", + Topics: "topics", + Gifts: "gifts", + Activities: "activities", +} as const; + +export type SidebarPage = (typeof SidebarPage)[keyof typeof SidebarPage]; + +export const BottomNavItem = { + None: "none", + Topics: "topics", + Gifts: "gifts", + Activities: "activities", +} as const; + +export type BottomNavItem = (typeof BottomNavItem)[keyof typeof BottomNavItem]; + +// ============================================================ +// Context +// ============================================================ +export interface SidebarContext { + currentPage: SidebarPage; + selectedBottomNavItem: BottomNavItem; + characterName: string; + characterProgress: number; +} + +const initialContext: SidebarContext = { + currentPage: SidebarPage.DefaultView, + selectedBottomNavItem: BottomNavItem.None, + characterName: "Luna", + characterProgress: 0, +}; + +// ============================================================ +// Events +// ============================================================ +export type SidebarEvent = + | { + type: "SidebarShowPage"; + page: SidebarPage; + bottomNavItem?: BottomNavItem; + } + | { type: "SidebarClearBottomNav" } + | { type: "SidebarUpdateCharacter"; name: string; progress: number }; + +// ============================================================ +// Machine +// ============================================================ +export const sidebarMachine = setup({ + types: { + context: {} as SidebarContext, + events: {} as SidebarEvent, + }, + actions: { + showPage: assign(({ event }) => { + if (event.type !== "SidebarShowPage") return {}; + return { + currentPage: event.page, + selectedBottomNavItem: event.bottomNavItem ?? BottomNavItem.None, + }; + }), + + clearBottomNav: assign(() => ({ + selectedBottomNavItem: BottomNavItem.None, + })), + + updateCharacter: assign(({ event }) => { + if (event.type !== "SidebarUpdateCharacter") return {}; + return { + characterName: event.name, + characterProgress: event.progress, + }; + }), + }, +}).createMachine({ + id: "sidebar", + initial: "idle", + context: initialContext, + states: { + /** + * Sidebar 是纯数据状态(无异步副作用),仅用 idle 状态 + * 所有事件均在 idle 状态内处理 + */ + idle: { + on: { + SidebarShowPage: { + actions: "showPage", + }, + SidebarClearBottomNav: { + actions: "clearBottomNav", + }, + SidebarUpdateCharacter: { + actions: "updateCharacter", + }, + }, + }, + }, +}); + +export type SidebarMachine = typeof sidebarMachine; diff --git a/src/contexts/sidebar/sidebar-reducer.ts b/src/contexts/sidebar/sidebar-reducer.ts deleted file mode 100644 index 7fcc5269..00000000 --- a/src/contexts/sidebar/sidebar-reducer.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 纯 sidebar reducer - */ -import { - type SidebarEvent, - type SidebarState, - BottomNavItem, -} from "./sidebar-types"; - -export function sidebarReducer( - state: SidebarState, - event: SidebarEvent, -): SidebarState { - switch (event.type) { - case "SidebarShowPage": - return { - ...state, - currentPage: event.page, - selectedBottomNavItem: event.bottomNavItem ?? BottomNavItem.None, - }; - - case "SidebarClearBottomNav": - return { ...state, selectedBottomNavItem: BottomNavItem.None }; - - case "SidebarUpdateCharacter": - return { - ...state, - characterName: event.name, - characterProgress: event.progress, - }; - - default: - return state; - } -} diff --git a/src/contexts/sidebar/sidebar-types.ts b/src/contexts/sidebar/sidebar-types.ts index 76e828f8..cd631aa9 100644 --- a/src/contexts/sidebar/sidebar-types.ts +++ b/src/contexts/sidebar/sidebar-types.ts @@ -1,52 +1,14 @@ /** - * Sidebar 页面/底部导航枚举 + * Sidebar 类型(向后兼容 re-export) + * + * 原始文件定义了 SidebarPage、BottomNavItem、SidebarState、SidebarEvent。 + * 状态机重构后: + * - 枚举(SidebarPage、BottomNavItem)由 `sidebar-machine.ts` 重新导出 + * - 事件联合(SidebarEvent)由 `sidebar-machine.ts` 重新导出 + * - SidebarState 由 `sidebar-context.tsx` 导出(保持原 API 兼容) */ -export const SidebarPage = { - DefaultView: "defaultView", - Profile: "profile", - Topics: "topics", - Gifts: "gifts", - Activities: "activities", -} as const; - -export type SidebarPage = - (typeof SidebarPage)[keyof typeof SidebarPage]; - -export const BottomNavItem = { - None: "none", - Topics: "topics", - Gifts: "gifts", - Activities: "activities", -} as const; - -export type BottomNavItem = - (typeof BottomNavItem)[keyof typeof BottomNavItem]; - -/** - * Sidebar State(对齐 Dart `lib/ui/sidebar/bloc/sidebar_state.dart`) - */ -export interface SidebarState { - currentPage: SidebarPage; - selectedBottomNavItem: BottomNavItem; - characterName: string; - characterProgress: number; -} - -export const initialSidebarState: SidebarState = { - currentPage: SidebarPage.DefaultView, - selectedBottomNavItem: BottomNavItem.None, - characterName: "Luna", - characterProgress: 0, -}; - -/** - * Sidebar Event - */ -export type SidebarEvent = - | { - type: "SidebarShowPage"; - page: SidebarPage; - bottomNavItem?: BottomNavItem; - } - | { type: "SidebarClearBottomNav" } - | { type: "SidebarUpdateCharacter"; name: string; progress: number }; +export { + SidebarPage, + BottomNavItem, + type SidebarEvent, +} from "./sidebar-machine"; diff --git a/src/contexts/user/index.ts b/src/contexts/user/index.ts index fed3b5d2..f48a429e 100644 --- a/src/contexts/user/index.ts +++ b/src/contexts/user/index.ts @@ -1,8 +1,11 @@ /** - * @file Automatically generated by barrelsby. + * @file User 公共导出 + * + * 状态机重构后: + * - 业务类(Dart 风格 context/reducer/side-effects)已合并到 user-machine.ts + * - 类型由 user-types.ts 重新导出 + * - React 入口为 user-context.tsx(useMachine 包装) */ - export * from "./user-context"; -export * from "./user-reducer"; -export * from "./user-side-effects"; export * from "./user-types"; +export { userMachine } from "./user-machine"; diff --git a/src/contexts/user/user-context.tsx b/src/contexts/user/user-context.tsx index 8e57e5ca..db86c1cd 100644 --- a/src/contexts/user/user-context.tsx +++ b/src/contexts/user/user-context.tsx @@ -1,51 +1,65 @@ "use client"; /** - * UserContext:提供 State + Dispatch 的 React Context Provider。 + * UserContext:基于 XState v5 的 React Context Provider + * + * 业务事件通过 `useMachine(userMachine).send()` 派发。 + * XState 自动处理异步副作用(invoke + fromPromise actors)。 + * + * 兼容性:保持原 `useUserState()` / `useUserDispatch()` API。 + * 内部将 XState 状态机快照映射回原 `UserState` 形状。 */ import { type Dispatch, type ReactNode, createContext, - useCallback, useContext, useMemo, - useReducer, } from "react"; +import { useMachine } from "@xstate/react"; -import { userReducer } from "./user-reducer"; -import { type UserEvent, type UserState, initialUserState } from "./user-types"; -import { - type UserSideEffects, - userSideEffects as defaultSideEffects, -} from "./user-side-effects"; +import { userMachine } from "./user-machine"; +import type { UserContext as MachineContext, UserEvent } from "./user-machine"; + +/** + * 对外暴露的 State 形状(保持与原 UserState 兼容) + */ +export interface UserState { + currentUser: MachineContext["currentUser"]; + pronouns: string; + coinBalance: number; + isLoading: boolean; + avatarUrl: string | null; +} const UserStateCtx = createContext(null); const UserDispatchCtx = createContext | null>(null); export interface UserProviderProps { children: ReactNode; - sideEffects?: UserSideEffects; } -export function UserProvider({ - children, - sideEffects = defaultSideEffects, -}: UserProviderProps) { - const [state, rawDispatch] = useReducer(userReducer, initialUserState); - const dispatch = useCallback>( - (event) => { - rawDispatch(event); - void sideEffects.run(event, rawDispatch); - }, - [sideEffects], +export function UserProvider({ children }: UserProviderProps) { + const [state, send] = useMachine(userMachine); + + // 映射 XState 状态机快照 → 原 UserState 形状 + const userState = useMemo( + () => ({ + currentUser: state.context.currentUser, + pronouns: state.context.pronouns, + coinBalance: state.context.coinBalance, + isLoading: + state.matches("initializing") || + state.matches("fetching") || + state.matches("loggingOut") || + state.context.isLoading, + avatarUrl: state.context.avatarUrl, + }), + [state], ); - const ctxValue = useMemo(() => state, [state]); return ( - - - {children} - + + {children} ); } diff --git a/src/contexts/user/user-machine.ts b/src/contexts/user/user-machine.ts new file mode 100644 index 00000000..72f0a5ad --- /dev/null +++ b/src/contexts/user/user-machine.ts @@ -0,0 +1,233 @@ +/** + * User 状态机(XState v5) + * + * 原始 Dart: lib/ui/user/bloc/user bloc + user state + user event + * + * 设计要点: + * - XState v5 `setup({...}).createMachine({...})` 声明式 API + * - HTTP 操作用 `fromPromise` actor(一次性 Promise) + * - 同步状态更新用 `assign` actions(inline 在 setup() 中) + * - 保持事件类型名与原 Dart 一致 + */ +import { setup, fromPromise, assign } from "xstate"; + +import type { UserView } from "@/models/user/user"; +import { userRepository } from "@/data/repositories/user_repository"; +import { authRepository } from "@/data/repositories/auth_repository"; +import { UserStorage } from "@/data/storage/user/user_storage"; +import { Result } from "@/utils/result"; + +// ============================================================ +// Context +// ============================================================ +export interface UserContext { + currentUser: UserView | null; + pronouns: string; + coinBalance: number; + isLoading: boolean; + avatarUrl: string | null; +} + +const initialContext: UserContext = { + currentUser: null, + pronouns: "He", + coinBalance: 45, + isLoading: false, + avatarUrl: null, +}; + +// ============================================================ +// Events +// ============================================================ +export type UserEvent = + | { type: "UserInit" } + | { type: "UserFetch" } + | { type: "UserUpdate"; user: UserView } + | { type: "UserUpdateUsername"; username: string } + | { type: "UserUpdatePronouns"; pronouns: string } + | { type: "UserLogout" } + | { type: "UserDeleteChatHistory" } + | { type: "UserDeleteAccount" }; + +// ============================================================ +// Helpers +// ============================================================ +const userStorage = UserStorage.getInstance(); + +interface InitData { + user: UserView | null; + avatarUrl: string | null; +} + +function toView(u: { + id: string; + username: string; + email?: string; + avatarUrl?: string; + intimacy?: number; + dolBalance?: number; + relationshipStage?: string; + currentMood?: string; + isGuest?: boolean; +}): UserView { + return { + id: u.id, + username: u.username, + email: u.email ?? "", + avatarUrl: u.avatarUrl ?? "", + intimacy: u.intimacy ?? 0, + dolBalance: u.dolBalance ?? 0, + relationshipStage: u.relationshipStage ?? "", + currentMood: u.currentMood ?? "", + isGuest: u.isGuest ?? false, + }; +} + +async function readInitData(): Promise { + const [userResult, avatarResult] = await Promise.all([ + userStorage.getUser(), + userStorage.getAvatarUrl(), + ]); + + let user: UserView | null = null; + if (Result.isOk(userResult) && userResult.data) { + user = toView(userResult.data); + } + + let avatarUrl: string | null = null; + if (Result.isOk(avatarResult) && avatarResult.data && avatarResult.data.length > 0) { + avatarUrl = avatarResult.data; + } + + return { user, avatarUrl }; +} + +// ============================================================ +// Actors +// ============================================================ +const userInitActor = fromPromise(async () => readInitData()); + +const userFetchActor = fromPromise(async () => { + const result = await userRepository.getCurrentUser(); + if (Result.isErr(result)) return null; + const view = toView(result.data.toJson()); + // 持久化到本地 + await userStorage.setUser(result.data.toJson()); + return view; +}); + +const userLogoutActor = fromPromise(async () => { + await authRepository.logout(); + await userStorage.clearUserData(); +}); + +// ============================================================ +// Machine +// ============================================================ +export const userMachine = setup({ + types: { + context: {} as UserContext, + events: {} as UserEvent, + }, + actors: { + userInit: userInitActor, + userFetch: userFetchActor, + userLogout: userLogoutActor, + }, + actions: { + updateUser: assign(({ event }) => { + if (event.type !== "UserUpdate") return {}; + return { currentUser: event.user }; + }), + + updateUsername: assign(({ context, event }) => { + if (event.type !== "UserUpdateUsername" || !context.currentUser) return {}; + return { + currentUser: { ...context.currentUser, username: event.username }, + }; + }), + + updatePronouns: assign(({ event }) => { + if (event.type !== "UserUpdatePronouns") return {}; + return { pronouns: event.pronouns }; + }), + + clearUser: assign(() => ({ + currentUser: null, + avatarUrl: null, + })), + }, +}).createMachine({ + id: "user", + initial: "idle", + context: initialContext, + states: { + idle: { + on: { + UserInit: "initializing", + UserFetch: "fetching", + UserUpdate: { + actions: "updateUser", + }, + UserUpdateUsername: { + actions: "updateUsername", + }, + UserUpdatePronouns: { + actions: "updatePronouns", + }, + UserLogout: "loggingOut", + UserDeleteChatHistory: {}, + UserDeleteAccount: {}, + }, + }, + + initializing: { + invoke: { + src: "userInit", + onDone: { + target: "idle", + actions: assign(({ event }) => ({ + currentUser: event.output.user, + avatarUrl: event.output.avatarUrl, + })), + }, + onError: { + target: "idle", + }, + }, + }, + + fetching: { + invoke: { + src: "userFetch", + onDone: { + target: "idle", + actions: assign(({ event }) => ({ + currentUser: event.output, + })), + }, + onError: { + target: "idle", + actions: assign({ isLoading: false }), + }, + }, + entry: assign({ isLoading: true }), + }, + + loggingOut: { + invoke: { + src: "userLogout", + onDone: { + target: "idle", + actions: "clearUser", + }, + onError: { + target: "idle", + actions: "clearUser", + }, + }, + }, + }, +}); + +export type UserMachine = typeof userMachine; diff --git a/src/contexts/user/user-reducer.ts b/src/contexts/user/user-reducer.ts deleted file mode 100644 index 9203e07c..00000000 --- a/src/contexts/user/user-reducer.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * 纯 user reducer - */ -import { type UserEvent, type UserState } from "./user-types"; - -export function userReducer(state: UserState, event: UserEvent): UserState { - switch (event.type) { - case "_SetLoading": - return { ...state, isLoading: event.isLoading }; - case "_SetUser": - return { ...state, currentUser: event.user }; - case "_SetAvatarUrl": - return { ...state, avatarUrl: event.url }; - case "_SetPronouns": - return { ...state, pronouns: event.pronouns }; - case "_SetCoinBalance": - return { ...state, coinBalance: event.coinBalance }; - - case "UserUpdate": - return { ...state, currentUser: event.user }; - - case "UserUpdateUsername": { - if (!state.currentUser) return state; - return { - ...state, - currentUser: { ...state.currentUser, username: event.username }, - }; - } - - case "UserUpdatePronouns": - return { ...state, pronouns: event.pronouns }; - - case "UserLogout": - return { - ...state, - currentUser: null, - avatarUrl: null, - }; - - case "UserInit": - case "UserFetch": - case "UserDeleteChatHistory": - case "UserDeleteAccount": - // 由 side-effects 处理 - return state; - - default: - return state; - } -} diff --git a/src/contexts/user/user-side-effects.ts b/src/contexts/user/user-side-effects.ts deleted file mode 100644 index 67ad79fc..00000000 --- a/src/contexts/user/user-side-effects.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { Dispatch } from "react"; - -import { authRepository } from "@/data/repositories/auth_repository"; -import { userRepository } from "@/data/repositories/user_repository"; -import { authStorage } from "@/data/storage/auth_storage"; -import { UserStorage } from "@/data/storage/user/user_storage"; -import { Result } from "@/utils/result"; -import type { UserView } from "@/models/user/user"; - -import { type UserEvent } from "./user-types"; - -/** - * 用户 side-effects:拦截业务事件执行异步,dispatch 内部 `_Set*` 事件。 - */ -export type UserSideEffects = { - run: (event: UserEvent, dispatch: Dispatch) => void | Promise; -}; - -const userStorage = UserStorage.getInstance(); - -function toView(user: { - id: string; - username: string; - email?: string; - avatarUrl?: string; - intimacy?: number; - dolBalance?: number; - relationshipStage?: string; - currentMood?: string; - isGuest?: boolean; -}): UserView { - return { - id: user.id, - username: user.username, - email: user.email ?? "", - avatarUrl: user.avatarUrl ?? "", - intimacy: user.intimacy ?? 0, - dolBalance: user.dolBalance ?? 0, - relationshipStage: user.relationshipStage ?? "", - currentMood: user.currentMood ?? "", - isGuest: user.isGuest ?? false, - }; -} - -export const userSideEffects: UserSideEffects = { - run: async (event, dispatch) => { - switch (event.type) { - case "UserInit": { - const userResult = await userStorage.getUser(); - if (Result.isOk(userResult) && userResult.data) { - dispatch({ type: "_SetUser", user: toView(userResult.data) }); - } - const avatarResult = await userStorage.getAvatarUrl(); - if ( - Result.isOk(avatarResult) && - avatarResult.data && - avatarResult.data.length > 0 - ) { - dispatch({ type: "_SetAvatarUrl", url: avatarResult.data }); - } - break; - } - - case "UserFetch": { - if (!authStorage.hasLoginToken()) return; - dispatch({ type: "_SetLoading", isLoading: true }); - const result = await userRepository.getCurrentUser(); - if (Result.isOk(result)) { - await userStorage.setUser(result.data.toJson()); - dispatch({ type: "_SetUser", user: toView(result.data.toJson()) }); - } - dispatch({ type: "_SetLoading", isLoading: false }); - break; - } - - case "UserUpdate": { - await userStorage.setUser({ - id: event.user.id, - username: event.user.username, - email: event.user.email, - avatarUrl: event.user.avatarUrl, - intimacy: event.user.intimacy, - dolBalance: event.user.dolBalance, - relationshipStage: event.user.relationshipStage, - currentMood: event.user.currentMood, - isGuest: event.user.isGuest, - // 其余字段用 UserStorage.setUser 接受 UserData 即可 - platform: "web", - personalityTraits: {} as never, - preferredLanguage: "", - createdAt: "", - lastMessageAt: "", - loginProvider: "email", - } as never); - break; - } - - case "UserLogout": { - await authRepository.logout(); - authStorage.clearAuthData(); - await userStorage.clearUserData(); - dispatch({ type: "_SetUser", user: null }); - dispatch({ type: "_SetAvatarUrl", url: null }); - break; - } - - case "UserDeleteChatHistory": - case "UserDeleteAccount": - // TODO: 接入真实 API - break; - - case "UserUpdateUsername": - case "UserUpdatePronouns": - // 已由 reducer 处理 - break; - - case "_SetLoading": - case "_SetUser": - case "_SetAvatarUrl": - case "_SetPronouns": - case "_SetCoinBalance": - default: - // 内部事件 - break; - } - }, -}; diff --git a/src/contexts/user/user-types.ts b/src/contexts/user/user-types.ts index 30f01790..e9240e1b 100644 --- a/src/contexts/user/user-types.ts +++ b/src/contexts/user/user-types.ts @@ -1,44 +1,9 @@ /** - * 用户 State(对齐 Dart `lib/ui/user/bloc/user_state.dart`) + * User 类型(向后兼容 re-export) + * + * 原始文件定义了 UserState 接口、UserEvent 联合、initialUserState。 + * 状态机重构后: + * - `UserEvent` 联合由 `user-machine.ts` 重新导出(保留类型) + * - `UserState` 由 `user-context.tsx` 导出(保持原 API 兼容) */ -import type { UserView } from "@/models/user/user"; - -export interface UserState { - /** 当前用户信息(null = 未登录) */ - currentUser: UserView | null; - /** 用户代词 */ - pronouns: string; - /** 金币余额 */ - coinBalance: number; - /** 是否正在加载 */ - isLoading: boolean; - /** 用户头像 URL(独立字段;可能与 currentUser.avatarUrl 不同源) */ - avatarUrl: string | null; -} - -export const initialUserState: UserState = { - currentUser: null, - pronouns: "He", - coinBalance: 45, - isLoading: false, - avatarUrl: null, -}; - -/** - * 用户 Event - */ -export type UserEvent = - | { type: "UserInit" } - | { type: "UserFetch" } - | { type: "UserUpdate"; user: UserView } - | { type: "UserUpdateUsername"; username: string } - | { type: "UserUpdatePronouns"; pronouns: string } - | { type: "UserLogout" } - | { type: "UserDeleteChatHistory" } - | { type: "UserDeleteAccount" } - // ===== 内部事件 ===== - | { type: "_SetLoading"; isLoading: boolean } - | { type: "_SetUser"; user: UserView | null } - | { type: "_SetAvatarUrl"; url: string | null } - | { type: "_SetPronouns"; pronouns: string } - | { type: "_SetCoinBalance"; coinBalance: number }; +export type { UserEvent } from "./user-machine"; diff --git a/src/data/storage/app/app_storage.ts b/src/data/storage/app/app_storage.ts index 27d45761..475b4492 100644 --- a/src/data/storage/app/app_storage.ts +++ b/src/data/storage/app/app_storage.ts @@ -16,9 +16,9 @@ * - `recordXxx(today)` 写入 todayString,后续 `canXxx` 在同一天返回 `false`。 */ -import { LocalStorage } from "../local_storage"; import { Result, type Result as ResultT } from "@/utils/result"; import { StorageKeys } from "../storage_keys"; +import { LocalStorage } from "@/utils/local-storage"; export class AppStorage { private static _ls: LocalStorage | null = null; diff --git a/src/data/storage/auth/auth_storage.ts b/src/data/storage/auth/auth_storage.ts index 1dfcf6c2..b8e7b95a 100644 --- a/src/data/storage/auth/auth_storage.ts +++ b/src/data/storage/auth/auth_storage.ts @@ -14,10 +14,10 @@ * - `clearAuthData` 顺序清理:loginToken → guestToken → refreshToken,任一失败立即返回 */ -import { LocalStorage } from "../local_storage"; import { Result, type Result as ResultT } from "@/utils/result"; import { StorageKeys } from "../storage_keys"; import type { IAuthStorage } from "./iauth_storage"; +import { LocalStorage } from "@/utils/local-storage"; export class AuthStorage implements IAuthStorage { private readonly ls: LocalStorage; diff --git a/src/data/storage/chat/chat_storage.ts b/src/data/storage/chat/chat_storage.ts index 7254200f..fe50375d 100644 --- a/src/data/storage/chat/chat_storage.ts +++ b/src/data/storage/chat/chat_storage.ts @@ -18,13 +18,13 @@ import { GuestChatQuotaSchema, type GuestChatQuotaData, -} from "@/data/services/schemas/chat/guest_chat_quota"; +} from "@/data/schemas/chat/guest_chat_quota"; import { z } from "zod"; -import { LocalStorage } from "../local_storage"; import { type Result as ResultT } from "@/utils/result"; import { StorageKeys } from "../storage_keys"; import type { IChatStorage } from "./ichat_storage"; +import { LocalStorage } from "@/utils/local-storage"; export class ChatStorage implements IChatStorage { private readonly ls: LocalStorage; diff --git a/src/data/storage/chat/ichat_storage.ts b/src/data/storage/chat/ichat_storage.ts index bddd31b5..85e50b22 100644 --- a/src/data/storage/chat/ichat_storage.ts +++ b/src/data/storage/chat/ichat_storage.ts @@ -12,7 +12,7 @@ */ import type { Result } from "@/utils/result"; -import type { GuestChatQuotaData } from "@/data/services/schemas/chat/guest_chat_quota"; +import type { GuestChatQuotaData } from "@/data/schemas/chat/guest_chat_quota"; export interface IChatStorage { getGuestDailyChatQuota(): Promise>; diff --git a/src/data/storage/user/iuser_storage.ts b/src/data/storage/user/iuser_storage.ts index 6d8c50ef..c0f7b9ae 100644 --- a/src/data/storage/user/iuser_storage.ts +++ b/src/data/storage/user/iuser_storage.ts @@ -12,7 +12,7 @@ */ import type { Result } from "@/utils/result"; -import type { UserData } from "@/data/services/schemas/user/user"; +import type { UserData } from "@/data/schemas/user/user"; export interface IUserStorage { getUser(): Promise>; diff --git a/src/data/storage/user/user_storage.ts b/src/data/storage/user/user_storage.ts index 4fbca68e..09362788 100644 --- a/src/data/storage/user/user_storage.ts +++ b/src/data/storage/user/user_storage.ts @@ -15,11 +15,11 @@ * - 单例挂在 class 静态字段,HMR 复用安全 */ -import { UserSchema, type UserData } from "@/data/services/schemas/user/user"; -import { LocalStorage } from "../local_storage"; +import { UserSchema, type UserData } from "@/data/schemas/user/user"; import { type Result as ResultT } from "@/utils/result"; import { StorageKeys } from "../storage_keys"; import type { IUserStorage } from "./iuser_storage"; +import { LocalStorage } from "@/utils/local-storage"; export class UserStorage implements IUserStorage { private readonly ls: LocalStorage; diff --git a/src/utils/local-storage.ts b/src/utils/local-storage.ts new file mode 100644 index 00000000..ab0d400a --- /dev/null +++ b/src/utils/local-storage.ts @@ -0,0 +1,123 @@ +"use client"; + +/** + * localStorage 通用包装 + * + * 设计要点: + * 1. 全部方法返回 `Promise>`,与 `LocalChatStorage`(Dexie/异步)保持签名一致, + * 调用方可以统一 `await` 而不必关心底层是同步还是异步。 + * 2. 三重 SSR 保护: + * - 文件顶 `"use client"` 指令 + * - 构造时探测 `typeof window`,服务端拿不到 `window.localStorage` 时存 `null` + * - 每次调用入口再做可用性检查,避免运行时炸 + * 3. 测试友好:构造函数接受可选 `Storage` 注入,单例可通过 `_resetForTests` 重置。 + * 4. `getString` 把空串视作 `null`(对齐 Dart `SpAsyncUtil.getString` 行为)。 + * 5. `getJson` / `setJson` 在边界做 Zod 校验,读写都走 schema。 + */ + +import { type ZodType } from "zod"; +import { Result, type Result as ResultT } from "@/utils/result"; + +const SSR_ERROR_MSG = + "localStorage is not available in this environment (SSR or non-browser)"; + +export class LocalStorage { + private readonly storage: Storage | null; + private static _instance: LocalStorage | null = null; + + constructor(storage?: Storage | null) { + this.storage = + storage !== undefined + ? storage + : typeof window !== "undefined" + ? window.localStorage + : null; + } + + /** 全局单例(仅在浏览器环境有效)。 */ + static getInstance(): LocalStorage { + if (!LocalStorage._instance) { + LocalStorage._instance = new LocalStorage(); + } + return LocalStorage._instance; + } + + /** @internal 测试用:重置单例以便每个测试用例拿到独立实例。 */ + static _resetForTests(): void { + LocalStorage._instance = null; + } + + private isAvailable(): boolean { + return this.storage !== null; + } + + async getString(key: string): Promise> { + if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); + try { + const v = this.storage!.getItem(key); + if (v === null || v === "") return Result.ok(null); + return Result.ok(v); + } catch (e) { + return Result.err(e); + } + } + + async setString(key: string, value: string): Promise> { + if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); + try { + this.storage!.setItem(key, value); + return Result.ok(undefined); + } catch (e) { + return Result.err(e); + } + } + + async getJson(key: string, schema: ZodType): Promise> { + const r = await this.getString(key); + if (!r.success) return Result.err(r.error); + if (r.data === null) { + const value: T | null = null; + return Result.ok(value); + } + try { + const parsed = schema.parse(JSON.parse(r.data)); + return Result.ok(parsed); + } catch (e) { + return Result.err(e); + } + } + + async setJson( + key: string, + value: T, + schema?: ZodType, + ): Promise> { + if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); + try { + const validated = schema ? schema.parse(value) : value; + this.storage!.setItem(key, JSON.stringify(validated)); + return Result.ok(undefined); + } catch (e) { + return Result.err(e); + } + } + + async remove(key: string): Promise> { + if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); + try { + this.storage!.removeItem(key); + return Result.ok(undefined); + } catch (e) { + return Result.err(e); + } + } + + async has(key: string): Promise> { + if (!this.isAvailable()) return Result.err(new Error(SSR_ERROR_MSG)); + try { + return Result.ok(this.storage!.getItem(key) !== null); + } catch (e) { + return Result.err(e); + } + } +}