refactor: migrate state imports from contexts to stores directory

This commit is contained in:
2026-06-09 18:47:04 +08:00
parent f79755f6ec
commit a5d8214650
29 changed files with 72 additions and 50 deletions
+87
View File
@@ -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;
}
+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;
+9
View File
@@ -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";
+7
View File
@@ -0,0 +1,7 @@
/**
* @file Automatically generated by barrelsby.
*/
export * from "./auth-context";
export * from "./auth-machine";
export * from "./auth-types";
+85
View File
@@ -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;
}
+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;
+11
View File
@@ -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";
+14
View File
@@ -0,0 +1,14 @@
/**
* @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-types";
export { chatMachine } from "./chat-machine";
+11
View File
@@ -0,0 +1,11 @@
/**
* @file Sidebar 公共导出
*
* 状态机重构后:
* - reducer 逻辑已合并到 sidebar-machine.ts
* - 类型由 sidebar-types.ts 重新导出
* - React 入口为 sidebar-context.tsxuseMachine 包装)
*/
export * from "./sidebar-context";
export * from "./sidebar-types";
export { sidebarMachine } from "./sidebar-machine";
+72
View File
@@ -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;
}
+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;
+14
View File
@@ -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";
+11
View File
@@ -0,0 +1,11 @@
/**
* @file User 公共导出
*
* 状态机重构后:
* - 业务类(Dart 风格 context/reducer/side-effects)已合并到 user-machine.ts
* - 类型由 user-types.ts 重新导出
* - React 入口为 user-context.tsxuseMachine 包装)
*/
export * from "./user-context";
export * from "./user-types";
export { userMachine } from "./user-machine";
+78
View File
@@ -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;
}
+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;
+9
View File
@@ -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";