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.
This commit is contained in:
2026-06-09 17:26:16 +08:00
parent 50940961ec
commit 5e645b003e
33 changed files with 1466 additions and 1273 deletions
+2
View File
@@ -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": {
+58 -4
View File
@@ -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: {}
+48 -33
View File
@@ -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<AuthState | null>(null);
const AuthDispatchCtx = createContext<Dispatch<AuthEvent> | 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<Dispatch<AuthEvent>>(
(event) => {
rawDispatch(event);
void sideEffects.run(event, rawDispatch);
},
[sideEffects],
export function AuthProvider({ children }: AuthProviderProps) {
const [state, send] = useMachine(authMachine);
// 映射 XState 状态机快照 → 原 AuthState 形状
const authState = useMemo<AuthState>(
() => ({
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 (
<AuthStateCtx.Provider value={ctxValue}>
<AuthDispatchCtx.Provider value={dispatch}>
{children}
</AuthDispatchCtx.Provider>
<AuthStateCtx.Provider value={authState}>
<AuthDispatchCtx.Provider value={send}>{children}</AuthDispatchCtx.Provider>
</AuthStateCtx.Provider>
);
}
+322
View File
@@ -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<string | undefined> {
const r = await AuthStorage.getInstance().getDeviceId();
if (r.success && r.data != null) {
return r.data;
}
return undefined;
}
// ============================================================
// Actors(异步服务)
// ============================================================
const emailLoginActor = fromPromise<LoginType, { email: string; password: string }>(
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<LoginType, { idToken: string }>(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<LoginType, Record<string, never>>(
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;
-77
View File
@@ -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;
}
}
-146
View File
@@ -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<AuthEvent>,
) => void | Promise<void>;
};
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.setAvatarUrlAuthRepository 没有暴露
}
})();
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;
}
},
};
+6 -74
View File
@@ -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";
+1 -2
View File
@@ -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";
@@ -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);
});
});
+45 -55
View File
@@ -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<ChatState | null>(null);
const ChatDispatchCtx = createContext<Dispatch<ChatEvent> | null>(null);
export interface ChatProviderProps {
children: ReactNode;
/** 可注入 side-effects(用于测试)。默认创建一个新的实例。 */
sideEffects?: ChatSideEffects;
}
export function ChatProvider({
children,
sideEffects,
}: ChatProviderProps) {
// 始终在第一次渲染时创建 side-effects(保证 dispose 与组件生命周期一致)
const effectsRef = useRef<ChatSideEffects | null>(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<Dispatch<ChatEvent>>(
(event) => {
rawDispatch(event);
void effectsRef.current?.run(event, rawDispatch, () => stateRef.current);
},
[],
// 映射 XState 状态机快照 → 原 ChatState 形状
const chatState = useMemo<ChatState>(
() => ({
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 (
<ChatStateCtx.Provider value={ctxValue}>
<ChatDispatchCtx.Provider value={dispatch}>
{children}
</ChatDispatchCtx.Provider>
<ChatStateCtx.Provider value={chatState}>
<ChatDispatchCtx.Provider value={send}>{children}</ChatDispatchCtx.Provider>
</ChatStateCtx.Provider>
);
}
+371
View File
@@ -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 长生命周期 actorfromCallback)—— 下轮处理
*
* 设计要点:
* - 使用 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<number | null>,
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<InitResult> {
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<number | null>,
50,
),
localMessages:
localResult && Result.isOk(localResult) && localResult.data
? localMessagesToUi(localResult.data)
: [],
};
}
// ============================================================
// Actors
// ============================================================
const chatInitActor = fromPromise<InitResult>(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;
-111
View File
@@ -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;
}
}
-245
View File
@@ -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<ChatEvent>,
getState: () => ChatState,
) => void | Promise<void>;
/** 组件卸载前清理 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<ChatEvent>) {
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();
+8 -78
View File
@@ -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";
+10 -5
View File
@@ -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.tsxuseMachine 包装)
*
* 注: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";
+7 -3
View File
@@ -1,7 +1,11 @@
/**
* @file Automatically generated by barrelsby.
* @file Sidebar 公共导出
*
* 状态机重构后:
* - reducer 逻辑已合并到 sidebar-machine.ts
* - 类型由 sidebar-types.ts 重新导出
* - React 入口为 sidebar-context.tsxuseMachine 包装)
*/
export * from "./sidebar-context";
export * from "./sidebar-reducer";
export * from "./sidebar-types";
export { sidebarMachine } from "./sidebar-machine";
+39 -17
View File
@@ -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<SidebarState | null>(null);
const SidebarDispatchCtx = createContext<Dispatch<SidebarEvent> | 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<SidebarState>(
() => ({
currentPage: state.context.currentPage,
selectedBottomNavItem: state.context.selectedBottomNavItem,
characterName: state.context.characterName,
characterProgress: state.context.characterProgress,
}),
[state],
);
return (
<SidebarStateCtx.Provider value={ctxValue}>
<SidebarDispatchCtx.Provider value={dispatch}>
{children}
</SidebarDispatchCtx.Provider>
<SidebarStateCtx.Provider value={sidebarState}>
<SidebarDispatchCtx.Provider value={send}>{children}</SidebarDispatchCtx.Provider>
</SidebarStateCtx.Provider>
);
}
@@ -43,8 +67,6 @@ export function useSidebarState(): SidebarState {
export function useSidebarDispatch(): Dispatch<SidebarEvent> {
const ctx = useContext(SidebarDispatchCtx);
if (!ctx)
throw new Error(
"useSidebarDispatch must be used inside <SidebarProvider>",
);
throw new Error("useSidebarDispatch must be used inside <SidebarProvider>");
return ctx;
}
+119
View File
@@ -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;
-35
View File
@@ -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;
}
}
+12 -50
View File
@@ -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";
+7 -4
View File
@@ -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.tsxuseMachine 包装)
*/
export * from "./user-context";
export * from "./user-reducer";
export * from "./user-side-effects";
export * from "./user-types";
export { userMachine } from "./user-machine";
+40 -26
View File
@@ -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<UserState | null>(null);
const UserDispatchCtx = createContext<Dispatch<UserEvent> | 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<Dispatch<UserEvent>>(
(event) => {
rawDispatch(event);
void sideEffects.run(event, rawDispatch);
},
[sideEffects],
export function UserProvider({ children }: UserProviderProps) {
const [state, send] = useMachine(userMachine);
// 映射 XState 状态机快照 → 原 UserState 形状
const userState = useMemo<UserState>(
() => ({
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 (
<UserStateCtx.Provider value={ctxValue}>
<UserDispatchCtx.Provider value={dispatch}>
{children}
</UserDispatchCtx.Provider>
<UserStateCtx.Provider value={userState}>
<UserDispatchCtx.Provider value={send}>{children}</UserDispatchCtx.Provider>
</UserStateCtx.Provider>
);
}
+233
View File
@@ -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` actionsinline 在 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<InitData> {
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<InitData>(async () => readInitData());
const userFetchActor = fromPromise<UserView | null>(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<void>(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;
-50
View File
@@ -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;
}
}
-127
View File
@@ -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<UserEvent>) => void | Promise<void>;
};
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;
}
},
};
+7 -42
View File
@@ -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";
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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<Result<GuestChatQuotaData | null>>;
+1 -1
View File
@@ -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<Result<UserData | null>>;
+2 -2
View File
@@ -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;
+123
View File
@@ -0,0 +1,123 @@
"use client";
/**
* localStorage 通用包装
*
* 设计要点:
* 1. 全部方法返回 `Promise<Result<T>>`,与 `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<ResultT<string | null>> {
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<ResultT<void>> {
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<T>(key: string, schema: ZodType<T>): Promise<ResultT<T | null>> {
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<T>(
key: string,
value: T,
schema?: ZodType<T>,
): Promise<ResultT<void>> {
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<ResultT<void>> {
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<ResultT<boolean>> {
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);
}
}
}