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:
@@ -17,6 +17,7 @@
|
|||||||
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
"@fingerprintjs/fingerprintjs": "^5.2.0",
|
||||||
"@greatsumini/react-facebook-login": "^3.4.0",
|
"@greatsumini/react-facebook-login": "^3.4.0",
|
||||||
"@react-oauth/google": "^0.13.5",
|
"@react-oauth/google": "^0.13.5",
|
||||||
|
"@xstate/react": "^5",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"dexie": "^4.4.3",
|
"dexie": "^4.4.3",
|
||||||
"lottie-react": "^2.4.1",
|
"lottie-react": "^2.4.1",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"ofetch": "^1.5.1",
|
"ofetch": "^1.5.1",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4",
|
"react-dom": "19.2.4",
|
||||||
|
"xstate": "^5",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Generated
+58
-4
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@react-oauth/google':
|
'@react-oauth/google':
|
||||||
specifier: ^0.13.5
|
specifier: ^0.13.5
|
||||||
version: 0.13.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
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:
|
classnames:
|
||||||
specifier: ^2.5.1
|
specifier: ^2.5.1
|
||||||
version: 2.5.1
|
version: 2.5.1
|
||||||
@@ -38,6 +41,9 @@ importers:
|
|||||||
react-dom:
|
react-dom:
|
||||||
specifier: 19.2.4
|
specifier: 19.2.4
|
||||||
version: 19.2.4(react@19.2.4)
|
version: 19.2.4(react@19.2.4)
|
||||||
|
xstate:
|
||||||
|
specifier: ^5
|
||||||
|
version: 5.32.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^4.4.3
|
specifier: ^4.4.3
|
||||||
version: 4.4.3
|
version: 4.4.3
|
||||||
@@ -604,7 +610,7 @@ packages:
|
|||||||
libc: [musl]
|
libc: [musl]
|
||||||
|
|
||||||
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
|
'@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}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
@@ -1015,6 +1021,15 @@ packages:
|
|||||||
'@vitest/utils@4.1.8':
|
'@vitest/utils@4.1.8':
|
||||||
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
|
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:
|
acorn-jsx@5.3.2:
|
||||||
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -2535,6 +2550,20 @@ packages:
|
|||||||
uri-js@4.4.1:
|
uri-js@4.4.1:
|
||||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
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:
|
vite@8.0.16:
|
||||||
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
@@ -2676,6 +2705,9 @@ packages:
|
|||||||
xmlchars@2.2.0:
|
xmlchars@2.2.0:
|
||||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||||
|
|
||||||
|
xstate@5.32.0:
|
||||||
|
resolution: {integrity: sha512-zsk73aWGmxn9z34P0kbiod5JwTvdYRW3+IDxITq8sd9+VWwMyW7BUzpplnYy9mIEXa6V8IMDv7Hy4m0mhT5+2Q==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3107,6 +3139,8 @@ snapshots:
|
|||||||
'@nodelib/fs.walk@1.2.8':
|
'@nodelib/fs.walk@1.2.8':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nodelib/fs.scandir': 2.1.5
|
'@nodelib/fs.scandir': 2.1.5
|
||||||
|
fastq: 1.20.1
|
||||||
|
|
||||||
'@nolyfill/is-core-module@1.0.39': {}
|
'@nolyfill/is-core-module@1.0.39': {}
|
||||||
|
|
||||||
'@oxc-project/types@0.133.0': {}
|
'@oxc-project/types@0.133.0': {}
|
||||||
@@ -3167,9 +3201,7 @@ snapshots:
|
|||||||
'@rolldown/binding-win32-x64-msvc@1.0.3':
|
'@rolldown/binding-win32-x64-msvc@1.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
'@rolldown/pluginutils@1.0.1':
|
'@rolldown/pluginutils@1.0.1': {}
|
||||||
dependencies:
|
|
||||||
tslib: 2.8.1
|
|
||||||
|
|
||||||
'@rtsao/scc@1.1.0': {}
|
'@rtsao/scc@1.1.0': {}
|
||||||
|
|
||||||
@@ -3511,6 +3543,16 @@ snapshots:
|
|||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
tinyrainbow: 3.1.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):
|
acorn-jsx@5.3.2(acorn@8.16.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
acorn: 8.16.0
|
acorn: 8.16.0
|
||||||
@@ -5297,6 +5339,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
punycode: 2.3.1
|
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):
|
vite@8.0.16(@types/node@20.19.41)(jiti@2.7.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
@@ -5417,6 +5469,8 @@ snapshots:
|
|||||||
|
|
||||||
xmlchars@2.2.0: {}
|
xmlchars@2.2.0: {}
|
||||||
|
|
||||||
|
xstate@5.32.0: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|||||||
@@ -1,59 +1,74 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
/**
|
||||||
* AuthContext:提供 State + Dispatch 的 React Context Provider。
|
* AuthContext:基于 XState v5 的 React Context Provider
|
||||||
*
|
*
|
||||||
* 业务事件(`Auth*Submitted`)通过 `sideEffects.run` 拦截 → 异步完成后 dispatch 内部 `_Set*` 事件。
|
* 业务事件(`Auth*Submitted`)通过 `useMachine(authMachine).send()` 派发。
|
||||||
* UI 侧仅消费 `useAuthState()`,调用 `useAuthDispatch()` 派发业务事件。
|
* XState 自动处理异步副作用(invoke + fromPromise actors)。
|
||||||
|
*
|
||||||
|
* 兼容性:保持原 `useAuthState()` / `useAuthDispatch()` API,
|
||||||
|
* 内部将 XState 状态机快照映射回原 `AuthState` 形状。
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
useReducer,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useMachine } from "@xstate/react";
|
||||||
|
|
||||||
import { authReducer } from "./auth-reducer";
|
import { authMachine } from "./auth-machine";
|
||||||
import {
|
import type { AuthEvent, AuthContext as MachineContext } from "./auth-machine";
|
||||||
type AuthEvent,
|
|
||||||
type AuthState,
|
/**
|
||||||
initialAuthState,
|
* 对外暴露的 State 形状(保持与原 AuthState 兼容)
|
||||||
} from "./auth-types";
|
*/
|
||||||
import {
|
export interface AuthState {
|
||||||
type AuthSideEffects,
|
authPanelMode: MachineContext["authPanelMode"];
|
||||||
authSideEffects as defaultSideEffects,
|
authMode: MachineContext["authMode"];
|
||||||
} from "./auth-side-effects";
|
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 AuthStateCtx = createContext<AuthState | null>(null);
|
||||||
const AuthDispatchCtx = createContext<Dispatch<AuthEvent> | null>(null);
|
const AuthDispatchCtx = createContext<Dispatch<AuthEvent> | null>(null);
|
||||||
|
|
||||||
export interface AuthProviderProps {
|
export interface AuthProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** 可注入 side-effects(用于测试 / Storybook)。默认使用全局单例。 */
|
|
||||||
sideEffects?: AuthSideEffects;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
children,
|
const [state, send] = useMachine(authMachine);
|
||||||
sideEffects = defaultSideEffects,
|
|
||||||
}: AuthProviderProps) {
|
// 映射 XState 状态机快照 → 原 AuthState 形状
|
||||||
const [state, rawDispatch] = useReducer(authReducer, initialAuthState);
|
const authState = useMemo<AuthState>(
|
||||||
const dispatch = useCallback<Dispatch<AuthEvent>>(
|
() => ({
|
||||||
(event) => {
|
authPanelMode: state.context.authPanelMode,
|
||||||
rawDispatch(event);
|
authMode: state.context.authMode,
|
||||||
void sideEffects.run(event, rawDispatch);
|
email: state.context.email,
|
||||||
},
|
password: state.context.password,
|
||||||
[sideEffects],
|
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 (
|
return (
|
||||||
<AuthStateCtx.Provider value={ctxValue}>
|
<AuthStateCtx.Provider value={authState}>
|
||||||
<AuthDispatchCtx.Provider value={dispatch}>
|
<AuthDispatchCtx.Provider value={send}>{children}</AuthDispatchCtx.Provider>
|
||||||
{children}
|
|
||||||
</AuthDispatchCtx.Provider>
|
|
||||||
</AuthStateCtx.Provider>
|
</AuthStateCtx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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.setAvatarUrl;AuthRepository 没有暴露
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
dispatch({ type: "_SetSuccess", loginType: "facebook" });
|
|
||||||
} else {
|
|
||||||
dispatch({ type: "_SetError", errorMessage: authResult.error.message });
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "AuthAppleLoginSubmitted": {
|
|
||||||
dispatch({
|
|
||||||
type: "_SetError",
|
|
||||||
errorMessage: "Apple login not implemented",
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "AuthWebGoogleLoginSuccess": {
|
|
||||||
dispatch({ type: "_SetLoading", isLoading: true });
|
|
||||||
const guestId =
|
|
||||||
(authStorage.getDeviceId?.() ?? undefined) || undefined;
|
|
||||||
const result = await authRepository.googleLogin({
|
|
||||||
idToken: event.idToken,
|
|
||||||
guestId,
|
|
||||||
});
|
|
||||||
if (Result.isOk(result)) {
|
|
||||||
dispatch({ type: "_SetSuccess", loginType: "google" });
|
|
||||||
} else {
|
|
||||||
dispatch({ type: "_SetError", errorMessage: result.error.message });
|
|
||||||
}
|
|
||||||
// 消费 result 避免未使用警告
|
|
||||||
void handleGoogleCredentialResponse;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
// 内部事件 / 非业务事件不处理
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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` 的字段)
|
* Auth 类型(向后兼容 re-export)
|
||||||
*/
|
|
||||||
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` 的事件类型)
|
|
||||||
*
|
*
|
||||||
* 私有内部事件以 `_` 前缀命名(reducer / side-effects 内部消费)。
|
* 原始文件定义了 AuthState 接口、AuthEvent 联合、initialAuthState。
|
||||||
|
* 状态机重构后:
|
||||||
|
* - `AuthEvent` 联合由 `auth-machine.ts` 重新导出(保留类型)
|
||||||
|
* - `AuthState` 由 `auth-context.tsx` 导出(保持原 API 兼容)
|
||||||
*/
|
*/
|
||||||
export type AuthEvent =
|
export type { AuthEvent } from "./auth-machine";
|
||||||
| { 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",
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3,6 +3,5 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./auth-context";
|
export * from "./auth-context";
|
||||||
export * from "./auth-reducer";
|
export * from "./auth-machine";
|
||||||
export * from "./auth-side-effects";
|
|
||||||
export * from "./auth-types";
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,82 +1,72 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
/**
|
||||||
* ChatContext:提供 State + Dispatch 的 React Context Provider。
|
* ChatContext:基于 XState v5 的 React Context Provider
|
||||||
*
|
*
|
||||||
* 业务事件(`ChatSendMessage`、`ChatInit` 等)通过 `sideEffects.run` 拦截
|
* 业务事件通过 `useMachine(chatMachine).send()` 派发。
|
||||||
* → 异步完成后 dispatch 内部 `_StateUpdate` 等事件。
|
* XState 自动处理 HTTP 副作用(invoke + fromPromise actors)。
|
||||||
* UI 侧仅消费 `useChatState()`,调用 `useChatDispatch()` 派发业务事件。
|
*
|
||||||
|
* 兼容性:保持原 `useChatState()` / `useChatDispatch()` API。
|
||||||
|
* 内部将 XState 状态机快照映射回原 `ChatState` 形状。
|
||||||
|
*
|
||||||
|
* 注:WebSocket 长生命周期 actor 暂未集成到状态机(保留在 chat-side-effects.ts
|
||||||
|
* 由外部 chat-side-effects 模块管理),待下一轮迁移。
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
|
||||||
useMemo,
|
useMemo,
|
||||||
useReducer,
|
|
||||||
useRef,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useMachine } from "@xstate/react";
|
||||||
|
|
||||||
import { chatReducer } from "./chat-reducer";
|
import { chatMachine } from "./chat-machine";
|
||||||
import {
|
import type { ChatEvent, ChatContext as MachineContext } from "./chat-machine";
|
||||||
type ChatEvent,
|
|
||||||
type ChatState,
|
/**
|
||||||
initialChatState,
|
* 对外暴露的 State 形状(保持与原 ChatState 兼容)
|
||||||
} from "./chat-types";
|
*/
|
||||||
import {
|
export interface ChatState {
|
||||||
type ChatSideEffects,
|
messages: MachineContext["messages"];
|
||||||
createChatSideEffects as createDefaultSideEffects,
|
isReplyingAI: boolean;
|
||||||
} from "./chat-side-effects";
|
isGuest: boolean;
|
||||||
|
guestRemainingQuota: number;
|
||||||
|
guestTotalQuota: number;
|
||||||
|
quotaExceededTrigger: number;
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
historyOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
const ChatStateCtx = createContext<ChatState | null>(null);
|
const ChatStateCtx = createContext<ChatState | null>(null);
|
||||||
const ChatDispatchCtx = createContext<Dispatch<ChatEvent> | null>(null);
|
const ChatDispatchCtx = createContext<Dispatch<ChatEvent> | null>(null);
|
||||||
|
|
||||||
export interface ChatProviderProps {
|
export interface ChatProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** 可注入 side-effects(用于测试)。默认创建一个新的实例。 */
|
|
||||||
sideEffects?: ChatSideEffects;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatProvider({
|
export function ChatProvider({ children }: ChatProviderProps) {
|
||||||
children,
|
const [state, send] = useMachine(chatMachine);
|
||||||
sideEffects,
|
|
||||||
}: ChatProviderProps) {
|
|
||||||
// 始终在第一次渲染时创建 side-effects(保证 dispose 与组件生命周期一致)
|
|
||||||
const effectsRef = useRef<ChatSideEffects | null>(null);
|
|
||||||
if (effectsRef.current == null) {
|
|
||||||
effectsRef.current = sideEffects ?? createDefaultSideEffects();
|
|
||||||
}
|
|
||||||
|
|
||||||
const [state, rawDispatch] = useReducer(chatReducer, initialChatState);
|
// 映射 XState 状态机快照 → 原 ChatState 形状
|
||||||
|
const chatState = useMemo<ChatState>(
|
||||||
// 同步 state 到 ref(在 effect 中更新,避免在 render 阶段修改 ref)
|
() => ({
|
||||||
const stateRef = useRef(state);
|
messages: state.context.messages,
|
||||||
useEffect(() => {
|
isReplyingAI: state.context.isReplyingAI,
|
||||||
stateRef.current = state;
|
isGuest: state.context.isGuest,
|
||||||
}, [state]);
|
guestRemainingQuota: state.context.guestRemainingQuota,
|
||||||
|
guestTotalQuota: state.context.guestTotalQuota,
|
||||||
const dispatch = useCallback<Dispatch<ChatEvent>>(
|
quotaExceededTrigger: state.context.quotaExceededTrigger,
|
||||||
(event) => {
|
isLoadingMore: state.context.isLoadingMore,
|
||||||
rawDispatch(event);
|
hasMore: state.context.hasMore,
|
||||||
void effectsRef.current?.run(event, rawDispatch, () => stateRef.current);
|
historyOffset: state.context.historyOffset,
|
||||||
},
|
}),
|
||||||
[],
|
[state],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const effects = effectsRef.current;
|
|
||||||
return () => {
|
|
||||||
effects?.dispose?.();
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const ctxValue = useMemo(() => state, [state]);
|
|
||||||
return (
|
return (
|
||||||
<ChatStateCtx.Provider value={ctxValue}>
|
<ChatStateCtx.Provider value={chatState}>
|
||||||
<ChatDispatchCtx.Provider value={dispatch}>
|
<ChatDispatchCtx.Provider value={send}>{children}</ChatDispatchCtx.Provider>
|
||||||
{children}
|
|
||||||
</ChatDispatchCtx.Provider>
|
|
||||||
</ChatStateCtx.Provider>
|
</ChatStateCtx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -1,81 +1,11 @@
|
|||||||
import type { UiMessage } from "@/models/chat/ui-message";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 聊天 State(对齐 Dart `lib/ui/chat/bloc/chat_state.dart`)
|
* Chat 类型(向后兼容 re-export)
|
||||||
*/
|
|
||||||
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`)
|
|
||||||
*
|
*
|
||||||
* 私有内部事件以 `_` 前缀命名。
|
* 原始文件定义了 ChatState 接口、ChatEvent 联合、initialChatState、GuestChatQuota。
|
||||||
|
* 状态机重构后:
|
||||||
|
* - `ChatEvent` 联合由 `chat-machine.ts` 重新导出(保留类型)
|
||||||
|
* - `ChatState` 由 `chat-context.tsx` 导出(保持原 API 兼容)
|
||||||
|
* - `GuestChatQuota` 重新导出
|
||||||
*/
|
*/
|
||||||
export type ChatEvent =
|
export type { ChatEvent } from "./chat-machine";
|
||||||
| { type: "ChatInit" }
|
export { GuestChatQuota } from "./chat-machine";
|
||||||
| { 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;
|
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* @file Automatically generated by barrelsby.
|
* @file Chat 公共导出
|
||||||
|
*
|
||||||
|
* 状态机重构后:
|
||||||
|
* - 业务类(Dart 风格 context/reducer/side-effects)已合并到 chat-machine.ts
|
||||||
|
* - 类型由 chat-types.ts 重新导出
|
||||||
|
* - React 入口为 chat-context.tsx(useMachine 包装)
|
||||||
|
*
|
||||||
|
* 注:chat-side-effects.ts 暂未删除(其中 WebSocket 长生命周期逻辑
|
||||||
|
* 待下一轮迁移到 fromCallback actor)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./chat-context";
|
export * from "./chat-context";
|
||||||
export * from "./chat-reducer";
|
|
||||||
export * from "./chat-side-effects";
|
|
||||||
export * from "./chat-types";
|
export * from "./chat-types";
|
||||||
export * from "./__tests__/chat-reducer.test";
|
export { chatMachine } from "./chat-machine";
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file Automatically generated by barrelsby.
|
* @file Sidebar 公共导出
|
||||||
|
*
|
||||||
|
* 状态机重构后:
|
||||||
|
* - reducer 逻辑已合并到 sidebar-machine.ts
|
||||||
|
* - 类型由 sidebar-types.ts 重新导出
|
||||||
|
* - React 入口为 sidebar-context.tsx(useMachine 包装)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./sidebar-context";
|
export * from "./sidebar-context";
|
||||||
export * from "./sidebar-reducer";
|
|
||||||
export * from "./sidebar-types";
|
export * from "./sidebar-types";
|
||||||
|
export { sidebarMachine } from "./sidebar-machine";
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
"use client";
|
"use client";
|
||||||
/**
|
/**
|
||||||
* SidebarContext:提供 State + Dispatch。
|
* SidebarContext:基于 XState v5 的 React Context Provider
|
||||||
|
*
|
||||||
|
* 业务事件通过 `useMachine(sidebarMachine).send()` 派发。
|
||||||
|
* XState 自动处理状态转换(无异步副作用)。
|
||||||
|
*
|
||||||
|
* 兼容性:保持原 `useSidebarState()` / `useSidebarDispatch()` API。
|
||||||
|
* 内部将 XState 状态机快照映射回原 `SidebarState` 形状。
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
@@ -8,27 +14,45 @@ import {
|
|||||||
createContext,
|
createContext,
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
useReducer,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useMachine } from "@xstate/react";
|
||||||
|
|
||||||
import { sidebarReducer } from "./sidebar-reducer";
|
import { sidebarMachine } from "./sidebar-machine";
|
||||||
import {
|
import type {
|
||||||
type SidebarEvent,
|
SidebarContext as MachineContext,
|
||||||
type SidebarState,
|
SidebarEvent,
|
||||||
initialSidebarState,
|
} from "./sidebar-machine";
|
||||||
} from "./sidebar-types";
|
|
||||||
|
/**
|
||||||
|
* 对外暴露的 State 形状(保持与原 SidebarState 兼容)
|
||||||
|
*/
|
||||||
|
export interface SidebarState {
|
||||||
|
currentPage: MachineContext["currentPage"];
|
||||||
|
selectedBottomNavItem: MachineContext["selectedBottomNavItem"];
|
||||||
|
characterName: string;
|
||||||
|
characterProgress: number;
|
||||||
|
}
|
||||||
|
|
||||||
const SidebarStateCtx = createContext<SidebarState | null>(null);
|
const SidebarStateCtx = createContext<SidebarState | null>(null);
|
||||||
const SidebarDispatchCtx = createContext<Dispatch<SidebarEvent> | null>(null);
|
const SidebarDispatchCtx = createContext<Dispatch<SidebarEvent> | null>(null);
|
||||||
|
|
||||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||||
const [state, dispatch] = useReducer(sidebarReducer, initialSidebarState);
|
const [state, send] = useMachine(sidebarMachine);
|
||||||
const ctxValue = useMemo(() => state, [state]);
|
|
||||||
|
// 映射 XState 状态机快照 → 原 SidebarState 形状
|
||||||
|
const sidebarState = useMemo<SidebarState>(
|
||||||
|
() => ({
|
||||||
|
currentPage: state.context.currentPage,
|
||||||
|
selectedBottomNavItem: state.context.selectedBottomNavItem,
|
||||||
|
characterName: state.context.characterName,
|
||||||
|
characterProgress: state.context.characterProgress,
|
||||||
|
}),
|
||||||
|
[state],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarStateCtx.Provider value={ctxValue}>
|
<SidebarStateCtx.Provider value={sidebarState}>
|
||||||
<SidebarDispatchCtx.Provider value={dispatch}>
|
<SidebarDispatchCtx.Provider value={send}>{children}</SidebarDispatchCtx.Provider>
|
||||||
{children}
|
|
||||||
</SidebarDispatchCtx.Provider>
|
|
||||||
</SidebarStateCtx.Provider>
|
</SidebarStateCtx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,8 +67,6 @@ export function useSidebarState(): SidebarState {
|
|||||||
export function useSidebarDispatch(): Dispatch<SidebarEvent> {
|
export function useSidebarDispatch(): Dispatch<SidebarEvent> {
|
||||||
const ctx = useContext(SidebarDispatchCtx);
|
const ctx = useContext(SidebarDispatchCtx);
|
||||||
if (!ctx)
|
if (!ctx)
|
||||||
throw new Error(
|
throw new Error("useSidebarDispatch must be used inside <SidebarProvider>");
|
||||||
"useSidebarDispatch must be used inside <SidebarProvider>",
|
|
||||||
);
|
|
||||||
return ctx;
|
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;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 = {
|
export {
|
||||||
DefaultView: "defaultView",
|
SidebarPage,
|
||||||
Profile: "profile",
|
BottomNavItem,
|
||||||
Topics: "topics",
|
type SidebarEvent,
|
||||||
Gifts: "gifts",
|
} from "./sidebar-machine";
|
||||||
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 };
|
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @file Automatically generated by barrelsby.
|
* @file User 公共导出
|
||||||
|
*
|
||||||
|
* 状态机重构后:
|
||||||
|
* - 业务类(Dart 风格 context/reducer/side-effects)已合并到 user-machine.ts
|
||||||
|
* - 类型由 user-types.ts 重新导出
|
||||||
|
* - React 入口为 user-context.tsx(useMachine 包装)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export * from "./user-context";
|
export * from "./user-context";
|
||||||
export * from "./user-reducer";
|
|
||||||
export * from "./user-side-effects";
|
|
||||||
export * from "./user-types";
|
export * from "./user-types";
|
||||||
|
export { userMachine } from "./user-machine";
|
||||||
|
|||||||
@@ -1,51 +1,65 @@
|
|||||||
"use client";
|
"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 {
|
import {
|
||||||
type Dispatch,
|
type Dispatch,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
createContext,
|
createContext,
|
||||||
useCallback,
|
|
||||||
useContext,
|
useContext,
|
||||||
useMemo,
|
useMemo,
|
||||||
useReducer,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { useMachine } from "@xstate/react";
|
||||||
|
|
||||||
import { userReducer } from "./user-reducer";
|
import { userMachine } from "./user-machine";
|
||||||
import { type UserEvent, type UserState, initialUserState } from "./user-types";
|
import type { UserContext as MachineContext, UserEvent } from "./user-machine";
|
||||||
import {
|
|
||||||
type UserSideEffects,
|
/**
|
||||||
userSideEffects as defaultSideEffects,
|
* 对外暴露的 State 形状(保持与原 UserState 兼容)
|
||||||
} from "./user-side-effects";
|
*/
|
||||||
|
export interface UserState {
|
||||||
|
currentUser: MachineContext["currentUser"];
|
||||||
|
pronouns: string;
|
||||||
|
coinBalance: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
const UserStateCtx = createContext<UserState | null>(null);
|
const UserStateCtx = createContext<UserState | null>(null);
|
||||||
const UserDispatchCtx = createContext<Dispatch<UserEvent> | null>(null);
|
const UserDispatchCtx = createContext<Dispatch<UserEvent> | null>(null);
|
||||||
|
|
||||||
export interface UserProviderProps {
|
export interface UserProviderProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
sideEffects?: UserSideEffects;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserProvider({
|
export function UserProvider({ children }: UserProviderProps) {
|
||||||
children,
|
const [state, send] = useMachine(userMachine);
|
||||||
sideEffects = defaultSideEffects,
|
|
||||||
}: UserProviderProps) {
|
// 映射 XState 状态机快照 → 原 UserState 形状
|
||||||
const [state, rawDispatch] = useReducer(userReducer, initialUserState);
|
const userState = useMemo<UserState>(
|
||||||
const dispatch = useCallback<Dispatch<UserEvent>>(
|
() => ({
|
||||||
(event) => {
|
currentUser: state.context.currentUser,
|
||||||
rawDispatch(event);
|
pronouns: state.context.pronouns,
|
||||||
void sideEffects.run(event, rawDispatch);
|
coinBalance: state.context.coinBalance,
|
||||||
},
|
isLoading:
|
||||||
[sideEffects],
|
state.matches("initializing") ||
|
||||||
|
state.matches("fetching") ||
|
||||||
|
state.matches("loggingOut") ||
|
||||||
|
state.context.isLoading,
|
||||||
|
avatarUrl: state.context.avatarUrl,
|
||||||
|
}),
|
||||||
|
[state],
|
||||||
);
|
);
|
||||||
|
|
||||||
const ctxValue = useMemo(() => state, [state]);
|
|
||||||
return (
|
return (
|
||||||
<UserStateCtx.Provider value={ctxValue}>
|
<UserStateCtx.Provider value={userState}>
|
||||||
<UserDispatchCtx.Provider value={dispatch}>
|
<UserDispatchCtx.Provider value={send}>{children}</UserDispatchCtx.Provider>
|
||||||
{children}
|
|
||||||
</UserDispatchCtx.Provider>
|
|
||||||
</UserStateCtx.Provider>
|
</UserStateCtx.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -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 type { UserEvent } from "./user-machine";
|
||||||
|
|
||||||
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 };
|
|
||||||
|
|||||||
@@ -16,9 +16,9 @@
|
|||||||
* - `recordXxx(today)` 写入 todayString,后续 `canXxx` 在同一天返回 `false`。
|
* - `recordXxx(today)` 写入 todayString,后续 `canXxx` 在同一天返回 `false`。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LocalStorage } from "../local_storage";
|
|
||||||
import { Result, type Result as ResultT } from "@/utils/result";
|
import { Result, type Result as ResultT } from "@/utils/result";
|
||||||
import { StorageKeys } from "../storage_keys";
|
import { StorageKeys } from "../storage_keys";
|
||||||
|
import { LocalStorage } from "@/utils/local-storage";
|
||||||
|
|
||||||
export class AppStorage {
|
export class AppStorage {
|
||||||
private static _ls: LocalStorage | null = null;
|
private static _ls: LocalStorage | null = null;
|
||||||
|
|||||||
@@ -14,10 +14,10 @@
|
|||||||
* - `clearAuthData` 顺序清理:loginToken → guestToken → refreshToken,任一失败立即返回
|
* - `clearAuthData` 顺序清理:loginToken → guestToken → refreshToken,任一失败立即返回
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { LocalStorage } from "../local_storage";
|
|
||||||
import { Result, type Result as ResultT } from "@/utils/result";
|
import { Result, type Result as ResultT } from "@/utils/result";
|
||||||
import { StorageKeys } from "../storage_keys";
|
import { StorageKeys } from "../storage_keys";
|
||||||
import type { IAuthStorage } from "./iauth_storage";
|
import type { IAuthStorage } from "./iauth_storage";
|
||||||
|
import { LocalStorage } from "@/utils/local-storage";
|
||||||
|
|
||||||
export class AuthStorage implements IAuthStorage {
|
export class AuthStorage implements IAuthStorage {
|
||||||
private readonly ls: LocalStorage;
|
private readonly ls: LocalStorage;
|
||||||
|
|||||||
@@ -18,13 +18,13 @@
|
|||||||
import {
|
import {
|
||||||
GuestChatQuotaSchema,
|
GuestChatQuotaSchema,
|
||||||
type GuestChatQuotaData,
|
type GuestChatQuotaData,
|
||||||
} from "@/data/services/schemas/chat/guest_chat_quota";
|
} from "@/data/schemas/chat/guest_chat_quota";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { LocalStorage } from "../local_storage";
|
|
||||||
import { type Result as ResultT } from "@/utils/result";
|
import { type Result as ResultT } from "@/utils/result";
|
||||||
import { StorageKeys } from "../storage_keys";
|
import { StorageKeys } from "../storage_keys";
|
||||||
import type { IChatStorage } from "./ichat_storage";
|
import type { IChatStorage } from "./ichat_storage";
|
||||||
|
import { LocalStorage } from "@/utils/local-storage";
|
||||||
|
|
||||||
export class ChatStorage implements IChatStorage {
|
export class ChatStorage implements IChatStorage {
|
||||||
private readonly ls: LocalStorage;
|
private readonly ls: LocalStorage;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Result } from "@/utils/result";
|
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 {
|
export interface IChatStorage {
|
||||||
getGuestDailyChatQuota(): Promise<Result<GuestChatQuotaData | null>>;
|
getGuestDailyChatQuota(): Promise<Result<GuestChatQuotaData | null>>;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { Result } from "@/utils/result";
|
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 {
|
export interface IUserStorage {
|
||||||
getUser(): Promise<Result<UserData | null>>;
|
getUser(): Promise<Result<UserData | null>>;
|
||||||
|
|||||||
@@ -15,11 +15,11 @@
|
|||||||
* - 单例挂在 class 静态字段,HMR 复用安全
|
* - 单例挂在 class 静态字段,HMR 复用安全
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { UserSchema, type UserData } from "@/data/services/schemas/user/user";
|
import { UserSchema, type UserData } from "@/data/schemas/user/user";
|
||||||
import { LocalStorage } from "../local_storage";
|
|
||||||
import { type Result as ResultT } from "@/utils/result";
|
import { type Result as ResultT } from "@/utils/result";
|
||||||
import { StorageKeys } from "../storage_keys";
|
import { StorageKeys } from "../storage_keys";
|
||||||
import type { IUserStorage } from "./iuser_storage";
|
import type { IUserStorage } from "./iuser_storage";
|
||||||
|
import { LocalStorage } from "@/utils/local-storage";
|
||||||
|
|
||||||
export class UserStorage implements IUserStorage {
|
export class UserStorage implements IUserStorage {
|
||||||
private readonly ls: LocalStorage;
|
private readonly ls: LocalStorage;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user