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:
2026-06-09 13:30:54 +08:00
parent b362945195
commit 8cab34864e
114 changed files with 6744 additions and 276 deletions
+72
View File
@@ -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;
}
+77
View File
@@ -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;
}
}
+146
View File
@@ -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.setAvatarUrlAuthRepository 没有暴露
}
})();
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;
}
},
};
+77
View File
@@ -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);
});
});
+95
View File
@@ -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;
}
+111
View File
@@ -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;
}
}
+245
View File
@@ -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();
+81
View File
@@ -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;
+50
View File
@@ -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;
}
+35
View File
@@ -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;
}
}
+52
View File
@@ -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 };
+64
View File
@@ -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;
}
+50
View File
@@ -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;
}
}
+127
View File
@@ -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;
}
},
};
+44
View File
@@ -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 };