refactor(auth): unify Facebook and Google login into AuthPlatform class

Replace the separate FacebookLogin and GoogleLogin classes with a single
AuthPlatform class that takes a provider name ("facebook" | "google") as a
constructor argument. This consolidates duplicate OAuth sign-in logic behind
one entry point while preserving the existing NextAuth flow.

- Add new src/lib/auth/auth_platform.ts exporting AuthPlatform and
  AuthProvider type
- Update auth-facebook-panel.tsx and splash-button.tsx to use the new API
- Rename initialContext → initialState in the auth machine for consistency
- Update inline docs and comments to reference AuthPlatform

No behavioral change for end users; both providers still route through
NextAuth's signIn() with the same callbacks and cookies.
This commit is contained in:
2026-06-10 18:58:15 +08:00
parent 744e23fc29
commit 47591be41c
30 changed files with 111 additions and 117 deletions
+23
View File
@@ -0,0 +1,23 @@
/**
* Chat 状态机:事件联合
*
* 事件名与原 Dart ChatEvent 对齐。
*/
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" };
+330
View File
@@ -0,0 +1,330 @@
/**
* Chat 状态机(XState v5
*
* 原始 Dart: lib/ui/chat/bloc/chat bloc + chat state + chat event
*
* 本轮迁移范围:
* ✅ HTTP 流程(init / send message / load more history
* ✅ 上下文数据(messages / quota / 标志位)
* ⏳ WebSocket 长生命周期 actorfromCallback)—— 下轮处理
*
* 设计要点:
* - 使用 XState v5 `setup({...}).createMachine({...})` 声明式 API
* - HTTP 操作用 `fromPromise` actor(一次性 Promise
* - 保持事件类型名与原 Dart 一致,便于业务层迁移对照
* - actions 全部 inline 在 setup() 中(确保类型推断正确)
*/
import { setup, fromPromise, assign } from "xstate";
import type { UiMessage } from "@/models/chat/ui-message";
import { chatRepository } from "@/data/repositories/chat_repository";
import { ChatStorage } from "@/data/storage/chat/chat_storage";
import { AuthStorage } from "@/data/storage/auth/auth_storage";
import { formatDate } from "@/utils/date";
import { Result } from "@/utils/result";
import { ChatState, initialState } from "./chat-state";
import type { ChatEvent } from "./machine/chat-events";
/** 游客配额阈值(与 Dart `GuestChatQuota` 保持一致) */
export const GuestChatQuota = {
warningThreshold: 5,
quotaExhausted: 0,
} as const;
// 重新导出 State / Event 类型,保持 machine 文件的公共 API 不变
export type { ChatState } from "./chat-state";
export { initialState } from "./chat-state";
export type { ChatEvent } from "./machine/chat-events";
// ============================================================
// 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 ChatState,
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: initialState,
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;
+30
View File
@@ -0,0 +1,30 @@
/**
* Chat 状态机:State 形状 + 初始值
*
* 注:GuestChatQuota 仍位于 chat-machine.ts(属于业务常量,与上下文配对紧密)。
*/
import type { UiMessage } from "@/models/chat/ui-message";
export interface ChatState {
messages: UiMessage[];
isReplyingAI: boolean;
isGuest: boolean;
guestRemainingQuota: number;
guestTotalQuota: number;
quotaExceededTrigger: number;
isLoadingMore: boolean;
hasMore: boolean;
historyOffset: number;
}
export const initialState: ChatState = {
messages: [],
isReplyingAI: false,
isGuest: true,
guestRemainingQuota: 40,
guestTotalQuota: 50,
quotaExceededTrigger: 0,
isLoadingMore: false,
hasMore: true,
historyOffset: 0,
};