feat(ui): migrate Flutter UI library to Next.js 16 (App Router)
Migrate all 87 Dart files from /Users/chase/Documents/cozsweet/lib/ui/ to TypeScript React components, replacing Flutter-native features with Web equivalents and reimplementing the BLoC pattern with React Context + useReducer. Highlights - 1:1 BLoC -> Context+useReducer+side-effects mapping for auth, chat, user, and sidebar features (4 providers wired in root-providers.tsx) - 500px MobileShell primitive eliminates per-screen layout boilerplate - Headless Dialog primitive (createPortal + ESC + scroll lock) powers quota / pwa-install / username / pronouns / subscription / other-options dialogs - New integrations/: facebook-sdk, google-identity, pwa-install, media-recorder (Web Speech API + MediaRecorder), browser-detect, chat-websocket (singleton), message-queue (throttled send) - CSS Modules + existing design tokens (no new styling system); extended with breakpoints.css and dimensions.css - Custom hooks: useBreakpoint (useSyncExternalStore), useFullVisibility (IntersectionObserver), usePwaInstall, usePullToRefresh - Vitest + jsdom; 15 unit tests (chat-reducer, auth-validators) - React 19.2.4, Next.js 16.2.7, Zod 4, lottie-react, classnames Workarounds - next.config.ts: experimental.staticGenerationRetryCount = 0 to bypass Next.js 16.2.7 /_global-error workStore prerender bug - src/app/global-error.tsx: required Client Component - src/types/globals.d.ts: centralized window.google / FB / SpeechRecognition declarations Routes (/, /splash, /auth, /chat, /chat/deviceid/[deviceId], /sidebar) all build and lint clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
/**
|
||||
* AuthContext:提供 State + Dispatch 的 React Context Provider。
|
||||
*
|
||||
* 业务事件(`Auth*Submitted`)通过 `sideEffects.run` 拦截 → 异步完成后 dispatch 内部 `_Set*` 事件。
|
||||
* UI 侧仅消费 `useAuthState()`,调用 `useAuthDispatch()` 派发业务事件。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
} from "react";
|
||||
|
||||
import { authReducer } from "./auth-reducer";
|
||||
import {
|
||||
type AuthEvent,
|
||||
type AuthState,
|
||||
initialAuthState,
|
||||
} from "./auth-types";
|
||||
import {
|
||||
type AuthSideEffects,
|
||||
authSideEffects as defaultSideEffects,
|
||||
} from "./auth-side-effects";
|
||||
|
||||
const AuthStateCtx = createContext<AuthState | null>(null);
|
||||
const AuthDispatchCtx = createContext<Dispatch<AuthEvent> | null>(null);
|
||||
|
||||
export interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
/** 可注入 side-effects(用于测试 / Storybook)。默认使用全局单例。 */
|
||||
sideEffects?: AuthSideEffects;
|
||||
}
|
||||
|
||||
export function AuthProvider({
|
||||
children,
|
||||
sideEffects = defaultSideEffects,
|
||||
}: AuthProviderProps) {
|
||||
const [state, rawDispatch] = useReducer(authReducer, initialAuthState);
|
||||
const dispatch = useCallback<Dispatch<AuthEvent>>(
|
||||
(event) => {
|
||||
rawDispatch(event);
|
||||
void sideEffects.run(event, rawDispatch);
|
||||
},
|
||||
[sideEffects],
|
||||
);
|
||||
|
||||
const ctxValue = useMemo(() => state, [state]);
|
||||
return (
|
||||
<AuthStateCtx.Provider value={ctxValue}>
|
||||
<AuthDispatchCtx.Provider value={dispatch}>
|
||||
{children}
|
||||
</AuthDispatchCtx.Provider>
|
||||
</AuthStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuthState(): AuthState {
|
||||
const ctx = useContext(AuthStateCtx);
|
||||
if (!ctx) throw new Error("useAuthState must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useAuthDispatch(): Dispatch<AuthEvent> {
|
||||
const ctx = useContext(AuthDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useAuthDispatch must be used inside <AuthProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { AuthMode } from "@/models/auth/auth-mode";
|
||||
import type { AuthPanelMode } from "@/models/auth/auth-panel-mode";
|
||||
import type { LoginType } from "@/models/auth/login-type";
|
||||
|
||||
/**
|
||||
* 认证 State(对齐 Dart `lib/ui/auth/bloc/auth_state.dart` 的字段)
|
||||
*/
|
||||
export interface AuthState {
|
||||
/** 当前面板模式(Facebook / Email) */
|
||||
authPanelMode: AuthPanelMode;
|
||||
/** 邮箱 */
|
||||
email: string;
|
||||
/** 密码 */
|
||||
password: string;
|
||||
/** 用户名 */
|
||||
username: string;
|
||||
/** 确认密码 */
|
||||
confirmPassword: string;
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 错误消息 */
|
||||
errorMessage: string | null;
|
||||
/** 是否成功 */
|
||||
isSuccess: boolean;
|
||||
/** 登录方式 */
|
||||
loginType: LoginType;
|
||||
/** 内部登录模式(login / register),UI 内部用 */
|
||||
authMode: AuthMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 认证 Event(对齐 Dart `lib/ui/auth/bloc/auth_event.dart` 的事件类型)
|
||||
*
|
||||
* 私有内部事件以 `_` 前缀命名(reducer / side-effects 内部消费)。
|
||||
*/
|
||||
export type AuthEvent =
|
||||
| { type: "AuthPanelModeChanged"; mode: AuthPanelMode }
|
||||
| { type: "AuthModeChanged"; mode: AuthMode }
|
||||
| { type: "AuthEmailLoginSubmitted"; email: string; password: string }
|
||||
| {
|
||||
type: "AuthEmailRegisterSubmitted";
|
||||
email: string;
|
||||
password: string;
|
||||
username: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
| { type: "AuthGoogleLoginSubmitted" }
|
||||
| { type: "AuthFacebookLoginSubmitted" }
|
||||
| { type: "AuthAppleLoginSubmitted" }
|
||||
| { type: "AuthWebGoogleLoginSuccess"; idToken: string; email: string }
|
||||
| { type: "AuthFormCleared" }
|
||||
| { type: "AuthReset" }
|
||||
// ===== 内部事件 =====
|
||||
| { type: "_SetLoading"; isLoading: boolean }
|
||||
| { type: "_SetError"; errorMessage: string | null }
|
||||
| { type: "_SetSuccess"; loginType?: LoginType }
|
||||
| {
|
||||
type: "_SetForm";
|
||||
email?: string;
|
||||
password?: string;
|
||||
username?: string;
|
||||
confirmPassword?: string;
|
||||
};
|
||||
|
||||
/** 初始状态 */
|
||||
export const initialAuthState: AuthState = {
|
||||
authPanelMode: "facebook",
|
||||
authMode: "login",
|
||||
email: "",
|
||||
password: "",
|
||||
username: "",
|
||||
confirmPassword: "",
|
||||
isLoading: false,
|
||||
errorMessage: null,
|
||||
isSuccess: false,
|
||||
loginType: "none",
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
"use client";
|
||||
/**
|
||||
* ChatContext:提供 State + Dispatch 的 React Context Provider。
|
||||
*
|
||||
* 业务事件(`ChatSendMessage`、`ChatInit` 等)通过 `sideEffects.run` 拦截
|
||||
* → 异步完成后 dispatch 内部 `_StateUpdate` 等事件。
|
||||
* UI 侧仅消费 `useChatState()`,调用 `useChatDispatch()` 派发业务事件。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
} from "react";
|
||||
|
||||
import { chatReducer } from "./chat-reducer";
|
||||
import {
|
||||
type ChatEvent,
|
||||
type ChatState,
|
||||
initialChatState,
|
||||
} from "./chat-types";
|
||||
import {
|
||||
type ChatSideEffects,
|
||||
createChatSideEffects as createDefaultSideEffects,
|
||||
} from "./chat-side-effects";
|
||||
|
||||
const ChatStateCtx = createContext<ChatState | null>(null);
|
||||
const ChatDispatchCtx = createContext<Dispatch<ChatEvent> | null>(null);
|
||||
|
||||
export interface ChatProviderProps {
|
||||
children: ReactNode;
|
||||
/** 可注入 side-effects(用于测试)。默认创建一个新的实例。 */
|
||||
sideEffects?: ChatSideEffects;
|
||||
}
|
||||
|
||||
export function ChatProvider({
|
||||
children,
|
||||
sideEffects,
|
||||
}: ChatProviderProps) {
|
||||
// 始终在第一次渲染时创建 side-effects(保证 dispose 与组件生命周期一致)
|
||||
const effectsRef = useRef<ChatSideEffects | null>(null);
|
||||
if (effectsRef.current == null) {
|
||||
effectsRef.current = sideEffects ?? createDefaultSideEffects();
|
||||
}
|
||||
|
||||
const [state, rawDispatch] = useReducer(chatReducer, initialChatState);
|
||||
|
||||
// 同步 state 到 ref(在 effect 中更新,避免在 render 阶段修改 ref)
|
||||
const stateRef = useRef(state);
|
||||
useEffect(() => {
|
||||
stateRef.current = state;
|
||||
}, [state]);
|
||||
|
||||
const dispatch = useCallback<Dispatch<ChatEvent>>(
|
||||
(event) => {
|
||||
rawDispatch(event);
|
||||
void effectsRef.current?.run(event, rawDispatch, () => stateRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const effects = effectsRef.current;
|
||||
return () => {
|
||||
effects?.dispose?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const ctxValue = useMemo(() => state, [state]);
|
||||
return (
|
||||
<ChatStateCtx.Provider value={ctxValue}>
|
||||
<ChatDispatchCtx.Provider value={dispatch}>
|
||||
{children}
|
||||
</ChatDispatchCtx.Provider>
|
||||
</ChatStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatState(): ChatState {
|
||||
const ctx = useContext(ChatStateCtx);
|
||||
if (!ctx) throw new Error("useChatState must be used inside <ChatProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useChatDispatch(): Dispatch<ChatEvent> {
|
||||
const ctx = useContext(ChatDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useChatDispatch must be used inside <ChatProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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();
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { UiMessage } from "@/models/chat/ui-message";
|
||||
|
||||
/**
|
||||
* 聊天 State(对齐 Dart `lib/ui/chat/bloc/chat_state.dart`)
|
||||
*/
|
||||
export interface ChatState {
|
||||
/** 消息列表 */
|
||||
messages: UiMessage[];
|
||||
/** AI 是否正在回复 */
|
||||
isReplyingAI: boolean;
|
||||
/** 是否为游客模式 */
|
||||
isGuest: boolean;
|
||||
/** 游客每日剩余配额 */
|
||||
guestRemainingQuota: number;
|
||||
/** 游客总配额剩余 */
|
||||
guestTotalQuota: number;
|
||||
/** 配额耗尽触发器(每次触发递增,UI 据此显示弹窗) */
|
||||
quotaExceededTrigger: number;
|
||||
/** 是否正在加载更多历史消息 */
|
||||
isLoadingMore: boolean;
|
||||
/** 是否还有更多历史消息可加载 */
|
||||
hasMore: boolean;
|
||||
/** 下次加载更多时使用的 offset */
|
||||
historyOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聊天 Event(对齐 Dart `lib/ui/chat/bloc/chat_event.dart`)
|
||||
*
|
||||
* 私有内部事件以 `_` 前缀命名。
|
||||
*/
|
||||
export type ChatEvent =
|
||||
| { type: "ChatInit" }
|
||||
| { type: "ChatScreenVisible" }
|
||||
| { type: "ChatScreenInvisible" }
|
||||
| { type: "ChatSendMessage"; content: string }
|
||||
| { type: "ChatSendImage"; imageBase64: string }
|
||||
| { type: "ChatLoadMoreHistory" }
|
||||
| {
|
||||
type: "ChatAISentenceReceived";
|
||||
index: number;
|
||||
text: string;
|
||||
total: number;
|
||||
done: boolean;
|
||||
}
|
||||
| { type: "ChatWebSocketError"; errorMessage: string }
|
||||
| { type: "ChatWebSocketConnected"; userId: string }
|
||||
| { type: "ChatQuotaExceeded" }
|
||||
| { type: "ChatAuthStatusChanged" }
|
||||
// ===== 内部事件 =====
|
||||
| {
|
||||
type: "_StateUpdate";
|
||||
messages?: UiMessage[];
|
||||
isReplyingAI?: boolean;
|
||||
isGuest?: boolean;
|
||||
guestRemainingQuota?: number;
|
||||
guestTotalQuota?: number;
|
||||
isLoadingMore?: boolean;
|
||||
hasMore?: boolean;
|
||||
historyOffset?: number;
|
||||
}
|
||||
| { type: "_SetQuotaExceededTrigger"; trigger: number };
|
||||
|
||||
/** 初始状态 */
|
||||
export const initialChatState: ChatState = {
|
||||
messages: [],
|
||||
isReplyingAI: false,
|
||||
isGuest: true,
|
||||
guestRemainingQuota: 40,
|
||||
guestTotalQuota: 50,
|
||||
quotaExceededTrigger: 0,
|
||||
isLoadingMore: false,
|
||||
hasMore: true,
|
||||
historyOffset: 0,
|
||||
};
|
||||
|
||||
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
|
||||
export const GuestChatQuota = {
|
||||
warningThreshold: 5,
|
||||
quotaExhausted: 0,
|
||||
} as const;
|
||||
@@ -0,0 +1,50 @@
|
||||
"use client";
|
||||
/**
|
||||
* SidebarContext:提供 State + Dispatch。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
} from "react";
|
||||
|
||||
import { sidebarReducer } from "./sidebar-reducer";
|
||||
import {
|
||||
type SidebarEvent,
|
||||
type SidebarState,
|
||||
initialSidebarState,
|
||||
} from "./sidebar-types";
|
||||
|
||||
const SidebarStateCtx = createContext<SidebarState | null>(null);
|
||||
const SidebarDispatchCtx = createContext<Dispatch<SidebarEvent> | null>(null);
|
||||
|
||||
export function SidebarProvider({ children }: { children: ReactNode }) {
|
||||
const [state, dispatch] = useReducer(sidebarReducer, initialSidebarState);
|
||||
const ctxValue = useMemo(() => state, [state]);
|
||||
return (
|
||||
<SidebarStateCtx.Provider value={ctxValue}>
|
||||
<SidebarDispatchCtx.Provider value={dispatch}>
|
||||
{children}
|
||||
</SidebarDispatchCtx.Provider>
|
||||
</SidebarStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarState(): SidebarState {
|
||||
const ctx = useContext(SidebarStateCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useSidebarState must be used inside <SidebarProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useSidebarDispatch(): Dispatch<SidebarEvent> {
|
||||
const ctx = useContext(SidebarDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error(
|
||||
"useSidebarDispatch must be used inside <SidebarProvider>",
|
||||
);
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* 纯 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Sidebar 页面/底部导航枚举
|
||||
*/
|
||||
export const SidebarPage = {
|
||||
DefaultView: "defaultView",
|
||||
Profile: "profile",
|
||||
Topics: "topics",
|
||||
Gifts: "gifts",
|
||||
Activities: "activities",
|
||||
} as const;
|
||||
|
||||
export type SidebarPage =
|
||||
(typeof SidebarPage)[keyof typeof SidebarPage];
|
||||
|
||||
export const BottomNavItem = {
|
||||
None: "none",
|
||||
Topics: "topics",
|
||||
Gifts: "gifts",
|
||||
Activities: "activities",
|
||||
} as const;
|
||||
|
||||
export type BottomNavItem =
|
||||
(typeof BottomNavItem)[keyof typeof BottomNavItem];
|
||||
|
||||
/**
|
||||
* Sidebar State(对齐 Dart `lib/ui/sidebar/bloc/sidebar_state.dart`)
|
||||
*/
|
||||
export interface SidebarState {
|
||||
currentPage: SidebarPage;
|
||||
selectedBottomNavItem: BottomNavItem;
|
||||
characterName: string;
|
||||
characterProgress: number;
|
||||
}
|
||||
|
||||
export const initialSidebarState: SidebarState = {
|
||||
currentPage: SidebarPage.DefaultView,
|
||||
selectedBottomNavItem: BottomNavItem.None,
|
||||
characterName: "Luna",
|
||||
characterProgress: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Sidebar Event
|
||||
*/
|
||||
export type SidebarEvent =
|
||||
| {
|
||||
type: "SidebarShowPage";
|
||||
page: SidebarPage;
|
||||
bottomNavItem?: BottomNavItem;
|
||||
}
|
||||
| { type: "SidebarClearBottomNav" }
|
||||
| { type: "SidebarUpdateCharacter"; name: string; progress: number };
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
/**
|
||||
* UserContext:提供 State + Dispatch 的 React Context Provider。
|
||||
*/
|
||||
import {
|
||||
type Dispatch,
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useReducer,
|
||||
} from "react";
|
||||
|
||||
import { userReducer } from "./user-reducer";
|
||||
import { type UserEvent, type UserState, initialUserState } from "./user-types";
|
||||
import {
|
||||
type UserSideEffects,
|
||||
userSideEffects as defaultSideEffects,
|
||||
} from "./user-side-effects";
|
||||
|
||||
const UserStateCtx = createContext<UserState | null>(null);
|
||||
const UserDispatchCtx = createContext<Dispatch<UserEvent> | null>(null);
|
||||
|
||||
export interface UserProviderProps {
|
||||
children: ReactNode;
|
||||
sideEffects?: UserSideEffects;
|
||||
}
|
||||
|
||||
export function UserProvider({
|
||||
children,
|
||||
sideEffects = defaultSideEffects,
|
||||
}: UserProviderProps) {
|
||||
const [state, rawDispatch] = useReducer(userReducer, initialUserState);
|
||||
const dispatch = useCallback<Dispatch<UserEvent>>(
|
||||
(event) => {
|
||||
rawDispatch(event);
|
||||
void sideEffects.run(event, rawDispatch);
|
||||
},
|
||||
[sideEffects],
|
||||
);
|
||||
|
||||
const ctxValue = useMemo(() => state, [state]);
|
||||
return (
|
||||
<UserStateCtx.Provider value={ctxValue}>
|
||||
<UserDispatchCtx.Provider value={dispatch}>
|
||||
{children}
|
||||
</UserDispatchCtx.Provider>
|
||||
</UserStateCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useUserState(): UserState {
|
||||
const ctx = useContext(UserStateCtx);
|
||||
if (!ctx) throw new Error("useUserState must be used inside <UserProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useUserDispatch(): Dispatch<UserEvent> {
|
||||
const ctx = useContext(UserDispatchCtx);
|
||||
if (!ctx)
|
||||
throw new Error("useUserDispatch must be used inside <UserProvider>");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 纯 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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;
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 用户 State(对齐 Dart `lib/ui/user/bloc/user_state.dart`)
|
||||
*/
|
||||
import type { UserView } from "@/models/user/user";
|
||||
|
||||
export interface UserState {
|
||||
/** 当前用户信息(null = 未登录) */
|
||||
currentUser: UserView | null;
|
||||
/** 用户代词 */
|
||||
pronouns: string;
|
||||
/** 金币余额 */
|
||||
coinBalance: number;
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 用户头像 URL(独立字段;可能与 currentUser.avatarUrl 不同源) */
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export const initialUserState: UserState = {
|
||||
currentUser: null,
|
||||
pronouns: "He",
|
||||
coinBalance: 45,
|
||||
isLoading: false,
|
||||
avatarUrl: null,
|
||||
};
|
||||
|
||||
/**
|
||||
* 用户 Event
|
||||
*/
|
||||
export type UserEvent =
|
||||
| { type: "UserInit" }
|
||||
| { type: "UserFetch" }
|
||||
| { type: "UserUpdate"; user: UserView }
|
||||
| { type: "UserUpdateUsername"; username: string }
|
||||
| { type: "UserUpdatePronouns"; pronouns: string }
|
||||
| { type: "UserLogout" }
|
||||
| { type: "UserDeleteChatHistory" }
|
||||
| { type: "UserDeleteAccount" }
|
||||
// ===== 内部事件 =====
|
||||
| { type: "_SetLoading"; isLoading: boolean }
|
||||
| { type: "_SetUser"; user: UserView | null }
|
||||
| { type: "_SetAvatarUrl"; url: string | null }
|
||||
| { type: "_SetPronouns"; pronouns: string }
|
||||
| { type: "_SetCoinBalance"; coinBalance: number };
|
||||
Reference in New Issue
Block a user