refactor: migrate state imports from contexts to stores directory
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
/**
|
||||
* AuthContext:基于 XState v5 的 React Context Provider
|
||||
*
|
||||
* 业务事件(`Auth*Submitted`)通过 `useMachine(authMachine).send()` 派发。
|
||||
* XState 自动处理异步副作用(invoke + fromPromise actors)。
|
||||
*
|
||||
* 兼容性:保持原 `useAuthState()` / `useAuthDispatch()` API,
|
||||
* 内部将 XState 状态机快照映射回原 `AuthState` 形状。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
return (
|
||||
<AuthStateCtx.Provider value={authState}>
|
||||
<AuthDispatchCtx.Provider value={send}>{children}</AuthDispatchCtx.Provider>
|
||||
</AuthStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthState(): AuthState {
|
||||
const ctx = useContext(AuthStateCtx);
|
||||
if (!ctx) throw new Error("useAuthState must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useAuthDispatch(): Dispatch<AuthEvent> {
|
||||
const ctx = useContext(AuthDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useAuthDispatch must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Auth 类型(向后兼容 re-export)
|
||||
*
|
||||
* 原始文件定义了 AuthState 接口、AuthEvent 联合、initialAuthState。
|
||||
* 状态机重构后:
|
||||
* - `AuthEvent` 联合由 `auth-machine.ts` 重新导出(保留类型)
|
||||
* - `AuthState` 由 `auth-context.tsx` 导出(保持原 API 兼容)
|
||||
*/
|
||||
export type { AuthEvent } from "./auth-machine";
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* @file Automatically generated by barrelsby.
|
||||
*/
|
||||
|
||||
export * from "./auth-context";
|
||||
export * from "./auth-machine";
|
||||
export * from "./auth-types";
|
||||
@@ -0,0 +1,85 @@
|
||||
"use client";
|
||||
/**
|
||||
* ChatContext:基于 XState v5 的 React Context Provider
|
||||
*
|
||||
* 业务事件通过 `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,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function ChatProvider({ children }: ChatProviderProps) {
|
||||
const [state, send] = useMachine(chatMachine);
|
||||
|
||||
// 映射 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],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatStateCtx.Provider value={chatState}>
|
||||
<ChatDispatchCtx.Provider value={send}>{children}</ChatDispatchCtx.Provider>
|
||||
</ChatStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatState(): ChatState {
|
||||
const ctx = useContext(ChatStateCtx);
|
||||
if (!ctx) throw new Error("useChatState must be used inside <ChatProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useChatDispatch(): Dispatch<ChatEvent> {
|
||||
const ctx = useContext(ChatDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useChatDispatch must be used inside <ChatProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -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<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;
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Chat 类型(向后兼容 re-export)
|
||||
*
|
||||
* 原始文件定义了 ChatState 接口、ChatEvent 联合、initialChatState、GuestChatQuota。
|
||||
* 状态机重构后:
|
||||
* - `ChatEvent` 联合由 `chat-machine.ts` 重新导出(保留类型)
|
||||
* - `ChatState` 由 `chat-context.tsx` 导出(保持原 API 兼容)
|
||||
* - `GuestChatQuota` 重新导出
|
||||
*/
|
||||
export type { ChatEvent } from "./chat-machine";
|
||||
export { GuestChatQuota } from "./chat-machine";
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @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-types";
|
||||
export { chatMachine } from "./chat-machine";
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* @file Sidebar 公共导出
|
||||
*
|
||||
* 状态机重构后:
|
||||
* - reducer 逻辑已合并到 sidebar-machine.ts
|
||||
* - 类型由 sidebar-types.ts 重新导出
|
||||
* - React 入口为 sidebar-context.tsx(useMachine 包装)
|
||||
*/
|
||||
export * from "./sidebar-context";
|
||||
export * from "./sidebar-types";
|
||||
export { sidebarMachine } from "./sidebar-machine";
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
/**
|
||||
* SidebarContext:基于 XState v5 的 React Context Provider
|
||||
*
|
||||
* 业务事件通过 `useMachine(sidebarMachine).send()` 派发。
|
||||
* XState 自动处理状态转换(无异步副作用)。
|
||||
*
|
||||
* 兼容性:保持原 `useSidebarState()` / `useSidebarDispatch()` API。
|
||||
* 内部将 XState 状态机快照映射回原 `SidebarState` 形状。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { useMachine } from "@xstate/react";
|
||||
|
||||
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, 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={sidebarState}>
|
||||
<SidebarDispatchCtx.Provider value={send}>{children}</SidebarDispatchCtx.Provider>
|
||||
</SidebarStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarState(): SidebarState {
|
||||
const ctx = useContext(SidebarStateCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useSidebarState must be used inside <SidebarProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useSidebarDispatch(): Dispatch<SidebarEvent> {
|
||||
const ctx = useContext(SidebarDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useSidebarDispatch must be used inside <SidebarProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Sidebar 类型(向后兼容 re-export)
|
||||
*
|
||||
* 原始文件定义了 SidebarPage、BottomNavItem、SidebarState、SidebarEvent。
|
||||
* 状态机重构后:
|
||||
* - 枚举(SidebarPage、BottomNavItem)由 `sidebar-machine.ts` 重新导出
|
||||
* - 事件联合(SidebarEvent)由 `sidebar-machine.ts` 重新导出
|
||||
* - SidebarState 由 `sidebar-context.tsx` 导出(保持原 API 兼容)
|
||||
*/
|
||||
export {
|
||||
SidebarPage,
|
||||
BottomNavItem,
|
||||
type SidebarEvent,
|
||||
} from "./sidebar-machine";
|
||||
@@ -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