20260626173529

This commit is contained in:
oneao committed 2026-06-26 17:35:29 +08:00
1 parent b424369b4b
commit 5983d29128
91 files changed
+9236 -2095

No files matched your search

@@ -1,15 +0,0 @@
import { useColorScheme } from "react-native";
import { useAppStore } from "@/store/app";
function useIsDark() {
const systemScheme = useColorScheme(); // 'light' | 'dark'
const themeScheme = useAppStore((state) => state.themeScheme);
if (themeScheme === "system") {
return systemScheme === "dark";
}
return themeScheme === "dark";
}
export { useIsDark };
@@ -1,21 +0,0 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
@@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from "@/constants/theme";
import { useColorScheme } from "@/hooks/use-color-scheme";
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark,
) {
const theme = useColorScheme() ?? "light";
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
@@ -1,60 +1,47 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
createContext,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
} from "react";
import { toast } from "sonner-native";
import { useUserStore, useFamilyStore } from "@/store";
import { useChatStore } from "@/store/chat";
import {
ChatMessage,
OnlineUser,
SendChatPayload,
normalizeChatMessage,
} from "@/types/chat";
const RECONNECT_BASE = 1000;
const RECONNECT_MAX = 30000;
export interface ChatMessage {
id: number;
family_id: number;
user_id: number;
nickname: string;
avatar: string;
content: string;
msg_type: number;
source_table: string | null;
source_id: number | null;
source_data: string | null;
attachments: Attachment[] | null;
recalled: number;
deleted: number;
create_time: string;
export type { ChatMessage, OnlineUser };
interface ChatWebSocketValue {
messages: ChatMessage[];
onlineUsers: OnlineUser[];
isConnected: boolean;
unreadCount: number;
clearUnread: () => void;
send: (payload: SendChatPayload) => void;
recall: (messageId: number) => void;
deleteMessage: (messageId: number) => void;
}
export interface Attachment {
file_key: string;
file_name: string;
file_type: number;
mime_type: string;
file_size: number;
width?: number;
height?: number;
duration?: number;
}
const ChatWebSocketContext = createContext<ChatWebSocketValue | null>(null);
export interface OnlineUser {
user_id: number;
nickname: string;
avatar: string;
}
interface SendPayload {
type: "send";
content: string;
msg_type?: number;
source_table?: string;
source_id?: number;
attachments?: Omit<Attachment, "id">[];
}
export function useChatWebSocket() {
function useChatWebSocketConnection(): ChatWebSocketValue {
const token = useUserStore((s) => s.userInfo?.token);
const familyId = useFamilyStore((s) => s.currentFamily?.family_id);
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
const [isConnected, setIsConnected] = useState(false);
const messages = useChatStore((s) => s.messages);
const onlineUsers = useChatStore((s) => s.onlineUsers);
const isConnected = useChatStore((s) => s.isConnected);
const unreadCount = useChatStore((s) => s.unreadCount);
const clearUnread = useChatStore((s) => s.clearUnread);
const wsRef = useRef<WebSocket | null>(null);
const lastReceivedId = useRef<number>(0);
@@ -62,6 +49,7 @@ export function useChatWebSocket() {
const reconnectDelay = useRef<number>(RECONNECT_BASE);
const mounted = useRef(true);
const wasConnected = useRef(false);
const shouldReconnect = useRef(false);
const buildWsUrl = useCallback(() => {
const apiUrl = process.env.EXPO_PUBLIC_API_URL || "";
@@ -80,7 +68,7 @@ export function useChatWebSocket() {
ws.onopen = () => {
if (!mounted.current) return;
setIsConnected(true);
useChatStore.getState().setConnected(true);
reconnectDelay.current = RECONNECT_BASE;
if (wasConnected.current) {
toast.success("聊天已重连");
@@ -100,12 +88,14 @@ export function useChatWebSocket() {
ws.onclose = () => {
if (!mounted.current) return;
setIsConnected(false);
useChatStore.getState().setConnected(false);
wsRef.current = null;
if (wasConnected.current) {
toast.error("聊天已断开");
}
scheduleReconnect();
if (shouldReconnect.current) {
scheduleReconnect();
}
};
ws.onerror = () => {
@@ -114,7 +104,8 @@ export function useChatWebSocket() {
}, [token, familyId, buildWsUrl]);
const scheduleReconnect = useCallback(() => {
if (!mounted.current) return;
if (!mounted.current || !shouldReconnect.current) return;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
reconnectTimer.current = setTimeout(() => {
reconnectDelay.current = Math.min(reconnectDelay.current * 2, RECONNECT_MAX);
connect();
@@ -127,33 +118,29 @@ export function useChatWebSocket() {
const data = msg.data as ChatMessage;
if (data.id <= lastReceivedId.current) return;
lastReceivedId.current = data.id;
setMessages((prev) => [...prev, data]);
useChatStore.getState().addMessage(normalizeChatMessage(data));
break;
}
case "message_recalled": {
setMessages((prev) =>
prev.map((m) => (m.id === msg.message_id ? { ...m, recalled: 1 } : m))
);
useChatStore.getState().recallMessage(msg.message_id);
break;
}
case "message_deleted": {
setMessages((prev) => prev.filter((m) => m.id !== msg.message_id));
useChatStore.getState().deleteMessage(msg.message_id);
break;
}
case "online": {
setOnlineUsers(msg.users || []);
useChatStore.getState().setOnlineUsers(msg.users || []);
break;
}
case "offline": {
setOnlineUsers((prev) =>
prev.filter((u) => u.user_id !== msg.user_id)
);
useChatStore.getState().removeOnlineUser(msg.user_id);
break;
}
}
}, []);
const send = useCallback((payload: SendPayload) => {
const send = useCallback((payload: SendChatPayload) => {
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
wsRef.current.send(JSON.stringify(payload));
}, []);
@@ -170,10 +157,31 @@ export function useChatWebSocket() {
useEffect(() => {
mounted.current = true;
shouldReconnect.current = !!token && !!familyId;
if (!token || !familyId) {
useChatStore.getState().clear();
lastReceivedId.current = 0;
wasConnected.current = false;
if (reconnectTimer.current) {
clearTimeout(reconnectTimer.current);
reconnectTimer.current = null;
}
if (wsRef.current) {
wsRef.current.onclose = null;
wsRef.current.close();
wsRef.current = null;
}
return;
}
useChatStore.getState().clear();
lastReceivedId.current = 0;
wasConnected.current = false;
connect();
return () => {
mounted.current = false;
shouldReconnect.current = false;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (wsRef.current) {
wsRef.current.onclose = null;
@@ -181,14 +189,41 @@ export function useChatWebSocket() {
wsRef.current = null;
}
};
}, [connect]);
}, [connect, token, familyId]);
useEffect(() => {
return () => {
mounted.current = false;
};
}, []);
return {
messages,
onlineUsers,
isConnected,
unreadCount,
clearUnread,
send,
recall,
deleteMessage,
};
}
export function ChatWebSocketProvider({ children }: { children: ReactNode }) {
const value = useChatWebSocketConnection();
return (
<ChatWebSocketContext.Provider value={value}>
{children}
</ChatWebSocketContext.Provider>
);
}
export function useChatWebSocket() {
const context = useContext(ChatWebSocketContext);
if (!context) {
throw new Error("useChatWebSocket must be used within ChatWebSocketProvider");
}
return context;
}
+72 -1
View File
@@ -6,6 +6,13 @@ interface StopOptions {
message?: string;
}
interface RunLoadingOptions {
loadingMessage?: string;
successMessage?: string;
errorMessage?: string;
showSuccess?: boolean;
}
export function useLoading() {
const [isLoading, setIsLoading] = useState(false);
const toastIdRef = useRef<string | number | null>(null);
@@ -39,7 +46,7 @@ export function useLoading() {
successMessage?: string,
errorMessage?: string
): Promise<T> => {
const id = startLoading(loadingMessage);
startLoading(loadingMessage);
try {
const result = await promise;
stopLoading({ type: "success", message: successMessage || "操作成功" });
@@ -52,10 +59,74 @@ export function useLoading() {
[startLoading, stopLoading]
);
const runWithLoading = useCallback(
async <T,>(task: () => Promise<T>, options?: RunLoadingOptions): Promise<T | undefined> => {
if (isLoading) return undefined;
startLoading(options?.loadingMessage || "加载中...");
try {
const result = await task();
if (options?.showSuccess) {
stopLoading({ type: "success", message: options.successMessage });
} else {
stopLoading();
}
return result;
} catch (error) {
stopLoading({ type: "error", message: options?.errorMessage || "操作失败" });
throw error;
}
},
[isLoading, startLoading, stopLoading],
);
return {
isLoading,
startLoading,
stopLoading,
withLoading,
runWithLoading,
};
}
export function useListLoading() {
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const runInitial = useCallback(async <T,>(task: () => Promise<T>) => {
setLoading(true);
try {
return await task();
} finally {
setLoading(false);
}
}, []);
const runRefresh = useCallback(async <T,>(task: () => Promise<T>) => {
setRefreshing(true);
try {
return await task();
} finally {
setRefreshing(false);
}
}, []);
const runLoadMore = useCallback(async <T,>(task: () => Promise<T>) => {
setLoadingMore(true);
try {
return await task();
} finally {
setLoadingMore(false);
}
}, []);
return {
loading,
refreshing,
loadingMore,
runInitial,
runRefresh,
runLoadMore,
};
}
@@ -0,0 +1,109 @@
import { useEffect, useState } from "react";
import * as SplashScreen from "expo-splash-screen";
import { useRouter } from "expo-router";
import { toast } from "sonner-native";
import { loadDataApi } from "@/request/api";
import { useFamilyStore, useUserStore } from "@/store";
export function useSessionBootstrap() {
const router = useRouter();
const authExpired = useUserStore((s) => s.authExpired);
const [isReady, setIsReady] = useState(false);
useEffect(() => {
const init = async () => {
try {
await new Promise((resolve) => setTimeout(resolve, 0));
} finally {
setIsReady(true);
}
};
init();
}, []);
useEffect(() => {
const userStore = useUserStore.getState();
const familyStore = useFamilyStore.getState();
if (authExpired) {
requestAnimationFrame(() => {
userStore.clear();
familyStore.clear();
router.replace("/auth/login");
});
}
}, [authExpired, router]);
return { isReady };
}
export function bootstrapSession(router: ReturnType<typeof useRouter>) {
let isMounted = true;
const run = async () => {
try {
const { isLogged, userInfo } = useUserStore.getState();
const familyStore = useFamilyStore.getState();
if (!isLogged()) {
router.replace("/auth/login");
return;
}
const checkRes = await loadDataApi({
view_name: "b_user",
search_condition: "id = ?",
search_columns: ["id"],
order_by: "",
args: [userInfo.id],
});
if (!checkRes.isSuccess || !checkRes.data || checkRes.data.length === 0) {
toast.error("账号不存在或已被禁用");
useUserStore.getState().clear();
familyStore.clear();
router.replace("/auth/login");
return;
}
const res = await loadDataApi({
view_name: "b_family_member",
search_condition: "user_id = ?",
search_columns: ["family_id"],
order_by: "",
args: [userInfo.id],
});
if (!res.isSuccess) {
throw new Error("获取家庭信息失败");
}
if (res.data.length === 0) {
familyStore.clear();
toast.warning("请先创建或加入家庭");
router.replace("/family/entry");
return;
}
familyStore.setCurrentFamily({
family_id: res.data[0].family_id,
});
router.replace("/home");
} catch (e) {
console.error(e);
toast.error("初始化失败,请稍后重试");
} finally {
if (isMounted) {
await SplashScreen.hideAsync();
}
}
};
run();
return () => {
isMounted = false;
};
}