refactor: migrate state imports from contexts to stores directory
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @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-types";
|
||||
export { userMachine } from "./user-machine";
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
/**
|
||||
* UserContext:基于 XState v5 的 React Context Provider
|
||||
*
|
||||
* 业务事件通过 `useMachine(userMachine).send()` 派发。
|
||||
* XState 自动处理异步副作用(invoke + fromPromise actors)。
|
||||
*
|
||||
* 兼容性:保持原 `useUserState()` / `useUserDispatch()` API。
|
||||
* 内部将 XState 状态机快照映射回原 `UserState` 形状。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<UserStateCtx.Provider value={userState}>
|
||||
<UserDispatchCtx.Provider value={send}>{children}</UserDispatchCtx.Provider>
|
||||
</UserStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserState(): UserState {
|
||||
const ctx = useContext(UserStateCtx);
|
||||
if (!ctx) throw new Error("useUserState must be used inside <UserProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useUserDispatch(): Dispatch<UserEvent> {
|
||||
const ctx = useContext(UserDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useUserDispatch must be used inside <UserProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -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<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;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* User 类型(向后兼容 re-export)
|
||||
*
|
||||
* 原始文件定义了 UserState 接口、UserEvent 联合、initialUserState。
|
||||
* 状态机重构后:
|
||||
* - `UserEvent` 联合由 `user-machine.ts` 重新导出(保留类型)
|
||||
* - `UserState` 由 `user-context.tsx` 导出(保持原 API 兼容)
|
||||
*/
|
||||
export type { UserEvent } from "./user-machine";
|
||||
Reference in New Issue
Block a user