20260625215406

This commit is contained in:
oneao committed 2026-06-25 21:54:06 +08:00
1 parent 2e928710df
commit 5e6f9252d5
9 files changed
+295 -235

No files matched your search

+6 -2
View File
@@ -77,9 +77,13 @@ func AuthWS() fiber.Handler {
} }
// 验证用户属于该家庭,同时获取 nickname / avatar / role // 验证用户属于该家庭,同时获取 nickname / avatar / role
// JOIN b_user 确保能拿到 avatar(v_family_member 视图可能不含此字段)
dbClient := db.New() dbClient := db.New()
rows, err := dbClient.LoadData(c.Context(), "b_family_member", rows, err := dbClient.LoadDataBySQL(c.Context(),
"user_id = ? AND family_id = ?", "", nil, `SELECT m.role, m.nickname, u.avatar
FROM b_family_member m
JOIN b_user u ON m.user_id = u.id
WHERE m.user_id = ? AND m.family_id = ?`,
[]any{userID, familyID}) []any{userID, familyID})
if err != nil || len(rows) == 0 { if err != nil || len(rows) == 0 {
return httpx.Unauthorized(c, "不属于该家庭") return httpx.Unauthorized(c, "不属于该家庭")
@@ -1,38 +1,68 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Image, Text, TextInput, View, Keyboard } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import { useRouter } from "expo-router";
import { Menu, PressableFeedback, Spinner } from "heroui-native";
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
import LayoutView from "@/components/layout/view"; import LayoutView from "@/components/layout/view";
import EmptyState from "@/components/EmptyStatus"; import EmptyState from "@/components/EmptyStatus";
import { loadDataApi, loadDataPageApi } from "@/request/api"; import { loadDataApi, loadDataPageApi } from "@/request/api";
import { useUserStore, useFamilyStore } from "@/store"; import { useUserStore, useFamilyStore } from "@/store";
import { useChatWebSocket, ChatMessage, OnlineUser } from "@/hooks/useChatWebSocket"; import { useChatWebSocket, ChatMessage, OnlineUser } from "@/hooks/useChatWebSocket";
import { cn } from "@/utils/cn";
import { buildFileUrl } from "@/utils/file"; import { buildFileUrl } from "@/utils/file";
import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "@/utils/cn";
import { FlatList, Image, Text, TextInput, View, Keyboard } from "react-native";
import { useRouter } from "expo-router";
import {
Button,
Spinner,
BottomSheet,
PressableFeedback,
} from "heroui-native";
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
import { ChatMsgType } from "@/types"; import { ChatMsgType } from "@/types";
function formatTime(ts: string) { function formatTime(ts: string) {
if (!ts) return ""; if (!ts) return "";
const d = new Date(ts.replace(" ", "T") + "Z"); const d = new Date(ts);
const h = d.getHours().toString().padStart(2, "0"); if (isNaN(d.getTime())) return "";
const m = d.getMinutes().toString().padStart(2, "0"); return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
return `${h}:${m}`;
} }
// ====== 消息气泡 ====== // ===================== 在线栏 =====================
function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) {
const router = useRouter();
return (
<View className="flex-row items-center justify-between px-4 h-12 bg-white rounded-2xl shadow-md">
<View className="flex-row items-center gap-3 flex-1">
{users.slice(0, 6).map((u) => (
<PressableFeedback
key={u.user_id}
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
>
<View>
{u.avatar ? (
<Image
source={{ uri: buildFileUrl(u.avatar) }}
className="w-7 h-7 rounded-full bg-gray-200"
/>
) : (
<View className="w-7 h-7 rounded-full bg-gray-300 items-center justify-center">
<Text className="text-xs text-white">{u.nickname?.charAt(0) || "?"}</Text>
</View>
)}
<View className="absolute -right-0.5 -bottom-0.5 w-3 h-3 rounded-full bg-green-500 border-2 border-white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
))}
</View>
<PressableFeedback onPress={onSearch}>
<View className="w-8 h-8 items-center justify-center">
<SearchIcon size={18} color="#9CA3AF" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
);
}
// ===================== 消息气泡 =====================
function ChatMessageItem({ function ChatMessageItem({
item, item, isSelf, members, isAdmin, onRecall, onDelete,
isSelf,
members,
isAdmin,
onRecall,
onDelete,
}: { }: {
item: ChatMessage; item: ChatMessage;
isSelf: boolean; isSelf: boolean;
@@ -45,35 +75,29 @@ function ChatMessageItem({
const member = members[item.user_id]; const member = members[item.user_id];
const nickname = item.nickname || member?.nickname || "未知"; const nickname = item.nickname || member?.nickname || "未知";
const [showActions, setShowActions] = useState(false); const [showActions, setShowActions] = useState(false);
const msgTime = new Date(item.create_time).getTime();
const msgTime = new Date(item.create_time.replace(" ", "T") + "Z").getTime(); const within2Min = !isNaN(msgTime) && Date.now() - msgTime < 2 * 60 * 1000;
const within2Min = (Date.now() - msgTime) < 2 * 60 * 1000;
const canRecall = isSelf && within2Min; const canRecall = isSelf && within2Min;
const canDelete = isAdmin;
// 撤回
if (item.recalled === 1) { if (item.recalled === 1) {
return ( return (
<View className="items-center py-2"> <View className="items-center py-3 px-4">
<Text className="text-xs text-gray-400">{nickname} 撤回了一条消息</Text> <Text className="text-sm text-gray-400">{nickname} 撤回了一条消息</Text>
</View> </View>
); );
} }
// 系统消息
if (item.msg_type === ChatMsgType.System) { if (item.msg_type === ChatMsgType.System) {
return ( return (
<View className="items-center py-2"> <View className="items-center py-3 px-4">
<Text className="text-xs text-gray-400">{item.content}</Text> <Text className="text-sm text-gray-400">{item.content}</Text>
</View> </View>
); );
} }
return ( return (
<> <>
<PressableFeedback <PressableFeedback onLongPress={() => (canRecall || isAdmin) && setShowActions(true)}>
onLongPress={() => (canRecall || canDelete) && setShowActions(true)}
>
<View className={cn("px-4 py-1", isSelf ? "items-end" : "items-start")}> <View className={cn("px-4 py-1", isSelf ? "items-end" : "items-start")}>
{!isSelf && ( {!isSelf && (
<PressableFeedback onPress={() => router.navigate(`/family/member/${item.user_id}` as any)}> <PressableFeedback onPress={() => router.navigate(`/family/member/${item.user_id}` as any)}>
@@ -82,22 +106,15 @@ function ChatMessageItem({
</PressableFeedback> </PressableFeedback>
)} )}
{/* 文本 */}
{item.msg_type === ChatMsgType.Text && ( {item.msg_type === ChatMsgType.Text && (
<View <View className={cn("max-w-[75%] rounded-2xl px-3 py-2", isSelf ? "bg-green-500" : "bg-white")}>
className={cn(
"max-w-[75%] rounded-2xl px-3 py-2",
isSelf ? "bg-green-500" : "bg-white"
)}
>
<Text className={cn("text-base leading-5", isSelf ? "text-white" : "text-gray-900")}> <Text className={cn("text-base leading-5", isSelf ? "text-white" : "text-gray-900")}>
{item.content} {item.content}
</Text> </Text>
</View> </View>
)} )}
{/* 图片 */} {item.msg_type === ChatMsgType.Image && item.attachments?.length && (
{item.msg_type === ChatMsgType.Image && item.attachments && item.attachments.length > 0 && (
<View className="flex-row flex-wrap gap-1 max-w-[75%] justify-end"> <View className="flex-row flex-wrap gap-1 max-w-[75%] justify-end">
{item.attachments.map((att, i) => ( {item.attachments.map((att, i) => (
<Image <Image
@@ -110,8 +127,7 @@ function ChatMessageItem({
</View> </View>
)} )}
{/* 视频 */} {item.msg_type === ChatMsgType.Video && item.attachments?.length && (
{item.msg_type === ChatMsgType.Video && item.attachments && item.attachments.length > 0 && (
<View className="w-48 h-32 rounded-xl bg-gray-800 items-center justify-center"> <View className="w-48 h-32 rounded-xl bg-gray-800 items-center justify-center">
<Text className="text-white text-2xl">▶</Text> <Text className="text-white text-2xl">▶</Text>
{item.attachments[0].duration ? ( {item.attachments[0].duration ? (
@@ -122,17 +138,19 @@ function ChatMessageItem({
</View> </View>
)} )}
{/* 文件 */} {item.msg_type === ChatMsgType.File && item.attachments?.length && (
{item.msg_type === ChatMsgType.File && item.attachments && item.attachments.length > 0 && (
<View <View
className={cn( className={cn(
"max-w-[75%] rounded-xl px-3 py-2 flex-row items-center gap-2", "max-w-[75%] rounded-xl px-3 py-2 flex-row items-center gap-2",
isSelf ? "bg-green-500" : "bg-white" isSelf ? "bg-green-500" : "bg-white",
)} )}
> >
<Text className={cn(isSelf ? "text-white" : "text-gray-900")}>📎</Text> <Text className={cn(isSelf ? "text-white" : "text-gray-900")}>📎</Text>
<View className="flex-1"> <View className="flex-1">
<Text className={cn("text-sm", isSelf ? "text-white" : "text-gray-900")} numberOfLines={1}> <Text
className={cn("text-sm", isSelf ? "text-white" : "text-gray-900")}
numberOfLines={1}
>
{item.attachments[0].file_name} {item.attachments[0].file_name}
</Text> </Text>
<Text className={cn("text-xs", isSelf ? "text-white/70" : "text-gray-400")}> <Text className={cn("text-xs", isSelf ? "text-white/70" : "text-gray-400")}>
@@ -144,7 +162,6 @@ function ChatMessageItem({
</View> </View>
)} )}
{/* 转发 */}
{item.msg_type === ChatMsgType.Forward && item.source_data && ( {item.msg_type === ChatMsgType.Forward && item.source_data && (
<View className="max-w-[75%] rounded-xl overflow-hidden bg-white border border-gray-200"> <View className="max-w-[75%] rounded-xl overflow-hidden bg-white border border-gray-200">
<View className="px-3 py-2"> <View className="px-3 py-2">
@@ -154,7 +171,8 @@ function ChatMessageItem({
try { try {
const d = JSON.parse(item.source_data); const d = JSON.parse(item.source_data);
return Object.entries(d) return Object.entries(d)
.filter(([k]) => !["id", "create_time", "update_time", "create_by", "update_by", "family_id"].includes(k)) .filter(([k]) =>
!["id", "create_time", "update_time", "create_by", "update_by", "family_id"].includes(k))
.map(([k, v]) => `${k}: ${v}`) .map(([k, v]) => `${k}: ${v}`)
.join(" "); .join(" ");
} catch { } catch {
@@ -171,108 +189,77 @@ function ChatMessageItem({
</View> </View>
)} )}
<Text className="text-[10px] text-gray-300 mt-0.5 mr-1">{formatTime(item.create_time)}</Text> <Text className="text-[10px] text-gray-300 mt-0.5 mr-1">
{formatTime(item.create_time)}
</Text>
</View> </View>
<PressableFeedback.Ripple /> <PressableFeedback.Ripple />
</PressableFeedback> </PressableFeedback>
{/* 操作面板 */} {showActions && (
<BottomSheet <Menu isOpen={showActions} onOpenChange={setShowActions}>
isOpen={showActions} <Menu.Portal>
onClose={() => setShowActions(false)} <Menu.Overlay />
snapPoints={["auto"]} <Menu.Content presentation="bottom-sheet">
> {canRecall && (
<View className="px-4 py-4 gap-2"> <Menu.Item
{canRecall && ( onPress={() => {
<Button onRecall(item.id);
onPress={() => { setShowActions(false);
onRecall(item.id); }}
setShowActions(false); >
}} <Menu.ItemTitle>撤回</Menu.ItemTitle>
className="w-full" </Menu.Item>
> )}
撤回 {isAdmin && (
</Button> <Menu.Item
)} onPress={() => {
{canDelete && ( onDelete(item.id);
<Button setShowActions(false);
onPress={() => { }}
onDelete(item.id); >
setShowActions(false); <Menu.ItemTitle>删除</Menu.ItemTitle>
}} </Menu.Item>
className="w-full" )}
color="danger" </Menu.Content>
> </Menu.Portal>
删除 </Menu>
</Button> )}
)}
<Button onPress={() => setShowActions(false)} className="w-full" variant="light">
取消
</Button>
</View>
</BottomSheet>
</> </>
); );
} }
// ====== 在线头像栏 ====== // ===================== 页面 =====================
function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) {
const router = useRouter();
return (
<View className="h-12 px-4 flex-row items-center justify-between border-b border-gray-100 bg-white">
<View className="flex-row items-center gap-3">
<Text className="text-xs text-gray-400">{users.length} 人在线</Text>
{users.slice(0, 6).map((u) => (
<PressableFeedback
key={u.user_id}
onPress={() => router.navigate(`/family/member/${u.user_id}` as any)}
>
<View className="items-center">
{u.avatar ? (
<Image source={{ uri: u.avatar }} className="w-7 h-7 rounded-full bg-gray-200" />
) : (
<View className="w-7 h-7 rounded-full bg-gray-300 items-center justify-center">
<Text className="text-xs text-white">{u.nickname?.charAt(0) || "?"}</Text>
</View>
)}
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
))}
</View>
<PressableFeedback onPress={onSearch}>
<View className="w-8 h-8 items-center justify-center">
<SearchIcon size={18} color="#9ca3af" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
);
}
// ====== 页面 ======
export default function ChatPage() { export default function ChatPage() {
const router = useRouter(); const router = useRouter();
const userId = useUserStore((s) => s.userInfo.id); const userId = useUserStore((s) => s.userInfo.id);
const familyId = useFamilyStore((s) => s.currentFamily.family_id); const familyId = useFamilyStore((s) => s.currentFamily.family_id);
const ws = useChatWebSocket();
const [history, setHistory] = useState<ChatMessage[]>([]); const [history, setHistory] = useState<ChatMessage[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false); const [loadingMore, setLoadingMore] = useState(false);
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(1);
const [members, setMembers] = useState<Record<number, { nickname: string; avatar: string; role: number }>>({}); const [members, setMembers] = useState<Record<number, { nickname: string; avatar: string; role: number }>>({});
const [inputText, setInputText] = useState(""); const [inputText, setInputText] = useState("");
const [adminRole, setAdminRole] = useState(0); const [adminRole, setAdminRole] = useState(0);
const listRef = useRef<any>(null);
const flatListRef = useRef<FlatList>(null); // 键盘弹起时自动滚到底部
const ws = useChatWebSocket(); useEffect(() => {
const show = Keyboard.addListener("keyboardWillShow", () => {
setTimeout(() => listRef.current?.scrollToEnd?.({ animated: true }), 100);
});
return () => show.remove();
}, []);
// 成员映射 // 成员映射
useEffect(() => { useEffect(() => {
if (!familyId) return; if (!familyId) return;
loadDataApi({ loadDataApi({
view_name: "b_family_member", view_name: "v_family_member",
search_condition: "family_id = ?", search_condition: "family_id = ?",
search_columns: ["user_id", "nickname", "avatar", "role"], search_columns: ["user_id", "nickname", "avatar", "role"],
args: [familyId], args: [familyId],
@@ -280,7 +267,7 @@ export default function ChatPage() {
}).then((res) => { }).then((res) => {
if (res.isSuccess && res.data) { if (res.isSuccess && res.data) {
const map: Record<number, any> = {}; const map: Record<number, any> = {};
for (const m of res.data) map[m.user_id] = m; for (const r of res.data) map[r.user_id] = r;
setMembers(map); setMembers(map);
const me = map[userId]; const me = map[userId];
if (me) setAdminRole(me.role ?? 99); if (me) setAdminRole(me.role ?? 99);
@@ -302,12 +289,17 @@ export default function ChatPage() {
search_columns: [], search_columns: [],
}); });
if (res.isSuccess && res.data) { if (res.isSuccess && res.data) {
const rows = res.data.rows as ChatMessage[]; const ordered = (res.data.rows as any[])
const mediaIds = rows.filter((r) => r.msg_type >= ChatMsgType.Image && r.msg_type <= ChatMsgType.File).map((r) => r.id); .map((r) => ({ ...r, msg_type: r.type ?? r.msg_type ?? 0 } as ChatMessage))
if (mediaIds.length > 0) { .reverse();
const mids = ordered
.filter((r) => r.msg_type >= ChatMsgType.Image && r.msg_type <= ChatMsgType.File)
.map((r) => r.id);
if (mids.length) {
const fileRes = await loadDataApi({ const fileRes = await loadDataApi({
view_name: "b_file", view_name: "b_file",
search_condition: `target_id IN (${mediaIds.join(",")})`, search_condition: `target_id IN (${mids.join(",")})`,
order_by: "sort_order ASC", order_by: "sort_order ASC",
args: [], args: [],
search_columns: [], search_columns: [],
@@ -318,33 +310,35 @@ export default function ChatPage() {
if (!fileMap[f.target_id]) fileMap[f.target_id] = []; if (!fileMap[f.target_id]) fileMap[f.target_id] = [];
fileMap[f.target_id].push(f); fileMap[f.target_id].push(f);
} }
for (const r of rows) { for (const r of ordered) {
if (fileMap[r.id]) (r as any).attachments = fileMap[r.id]; if (fileMap[r.id]) (r as any).attachments = fileMap[r.id];
} }
} }
} }
if (p === 1) setHistory(rows.reverse());
else setHistory((prev) => [...rows.reverse(), ...prev]); if (p === 1) setHistory(ordered);
setHasMore(rows.length >= 50); else setHistory((prev) => [...ordered, ...prev]);
setHasMore(ordered.length >= 50);
} }
}, },
[familyId] [familyId],
); );
useEffect(() => { useEffect(() => {
loadHistory(1).finally(() => setLoading(false)); loadHistory(1).finally(() => setLoading(false));
}, [loadHistory]); }, [loadHistory]);
// 加载更多
const handleLoadMore = useCallback(async () => { const handleLoadMore = useCallback(async () => {
if (loadingMore || !hasMore) return; if (loadingMore || !hasMore) return;
setLoadingMore(true); setLoadingMore(true);
const nextPage = page + 1; const np = page + 1;
await loadHistory(nextPage); await loadHistory(np);
setPage(nextPage); setPage(np);
setLoadingMore(false); setLoadingMore(false);
}, [loadingMore, hasMore, page, loadHistory]); }, [loadingMore, hasMore, page, loadHistory]);
// 合并实时消息 // 合并消息 (旧→新)
const allMessages = (() => { const allMessages = (() => {
const ids = new Set(history.map((m) => m.id)); const ids = new Set(history.map((m) => m.id));
const merged = [...history]; const merged = [...history];
@@ -366,9 +360,6 @@ export default function ChatPage() {
ws.send({ type: "send", content: text }); ws.send({ type: "send", content: text });
}, [inputText, ws]); }, [inputText, ws]);
const handleRecall = useCallback((id: number) => ws.recall(id), [ws]);
const handleDelete = useCallback((id: number) => ws.deleteMessage(id), [ws]);
if (loading) { if (loading) {
return ( return (
<LayoutView> <LayoutView>
@@ -378,69 +369,79 @@ export default function ChatPage() {
} }
return ( return (
<LayoutView> <KeyboardAvoidingView
<View className="flex-1 bg-gray-50"> style={{ flex: 1 }}
{/* 连接状态 */} behavior="padding"
<View className="h-7 px-4 flex-row items-center gap-1.5"> keyboardVerticalOffset={-60}
<View className={cn("w-2 h-2 rounded-full", ws.isConnected ? "bg-green-500" : "bg-red-400")} /> >
<Text className="text-xs text-gray-400">{ws.isConnected ? "已连接" : "连接中..."}</Text> <LayoutView>
</View> <LayoutView.FlatList
ref={listRef}
{/* 在线成员 + 搜索入口 */} header={
<ChatOnlineBar users={ws.onlineUsers} onSearch={() => router.navigate("/chat/history")} /> <ChatOnlineBar
users={ws.onlineUsers}
{/* 消息列表 */} onSearch={() => router.navigate("/chat/history")}
<FlatList />
ref={flatListRef} }
footer={
<View className="flex-row items-center gap-2 px-3 py-2 bg-white rounded-2xl shadow-md">
<TextInput
className="flex-1 h-10 bg-gray-100 rounded-full px-4 text-base"
placeholder="输入消息..."
value={inputText}
onChangeText={setInputText}
onSubmitEditing={handleSend}
returnKeyType="send"
/>
<PressableFeedback onPress={handleSend} isDisabled={!inputText.trim()}>
<View
className={cn(
"w-10 h-10 rounded-full items-center justify-center",
inputText.trim() ? "bg-green-500" : "bg-gray-300",
)}
>
<SendHorizonalIcon size={18} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
}
data={allMessages} data={allMessages}
keyExtractor={(item) => String(item.id)} keyExtractor={(item) => String(item.id)}
onScroll={({ nativeEvent }) => {
if (nativeEvent.contentOffset.y < 30 && hasMore && !loadingMore) {
handleLoadMore();
}
}}
scrollEventThrottle={200}
onContentSizeChange={() => {
if (loadingMore) return;
listRef.current?.scrollToEnd({ animated: false });
}}
ListHeaderComponent={
loadingMore ? (
<View className="py-2 items-center">
<Spinner size="sm" />
</View>
) : null
}
ListEmptyComponent={
<View className="items-center py-20">
<Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text>
</View>
}
renderItem={({ item }) => ( renderItem={({ item }) => (
<ChatMessageItem <ChatMessageItem
item={item} item={item}
isSelf={item.user_id === userId} isSelf={item.user_id === userId}
members={members} members={members}
isAdmin={adminRole === 0} isAdmin={adminRole === 0}
onRecall={handleRecall} onRecall={ws.recall}
onDelete={handleDelete} onDelete={ws.deleteMessage}
/> />
)} )}
inverted
onEndReached={handleLoadMore}
onEndReachedThreshold={0.3}
className="flex-1"
contentContainerStyle={{ paddingVertical: 8 }}
ListFooterComponent={
loadingMore ? (
<View className="py-4 items-center">
<Spinner size="sm" />
</View>
) : null
}
/> />
</LayoutView>
{/* 输入栏 */} </KeyboardAvoidingView>
<View className="flex-row items-center gap-2 px-3 py-2 bg-white border-t border-gray-200">
<TextInput
className="flex-1 h-10 bg-gray-100 rounded-full px-4 text-base"
placeholder="输入消息..."
value={inputText}
onChangeText={setInputText}
onSubmitEditing={handleSend}
returnKeyType="send"
/>
<PressableFeedback onPress={handleSend} disabled={!inputText.trim()}>
<View
className={cn(
"w-10 h-10 rounded-full items-center justify-center",
inputText.trim() ? "bg-green-500" : "bg-gray-300"
)}
>
<SendHorizonalIcon size={18} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
</View>
</LayoutView>
); );
} }
+88 -31
View File
@@ -1,4 +1,4 @@
import React, { forwardRef } from "react"; import React, { forwardRef, type ReactNode } from "react";
import { import {
SectionList, SectionList,
FlatList, FlatList,
@@ -37,7 +37,6 @@ function Shadow({ children }: { children: React.ReactElement }) {
function useContentPadding(config?: LayoutConfig) { function useContentPadding(config?: LayoutConfig) {
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
return { return {
paddingTop: insets.top + (config?.topExtraPadding ?? 50), paddingTop: insets.top + (config?.topExtraPadding ?? 50),
paddingBottom: insets.bottom + (config?.bottomExtraPadding ?? 80), paddingBottom: insets.bottom + (config?.bottomExtraPadding ?? 80),
@@ -68,11 +67,7 @@ LayoutView.ScrollView = forwardRef<
any, any,
React.ComponentProps<typeof KeyboardAwareScrollView> & LayoutConfig React.ComponentProps<typeof KeyboardAwareScrollView> & LayoutConfig
>(({ topExtraPadding, bottomExtraPadding, contentContainerStyle, ...props }, ref) => { >(({ topExtraPadding, bottomExtraPadding, contentContainerStyle, ...props }, ref) => {
const padding = useContentPadding({ const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
topExtraPadding,
bottomExtraPadding,
});
return ( return (
<Wrapper> <Wrapper>
<Shadow> <Shadow>
@@ -91,24 +86,15 @@ LayoutView.ScrollView = forwardRef<
LayoutView.SectionView = forwardRef( LayoutView.SectionView = forwardRef(
<T,>( <T,>(
{ props: React.ComponentProps<typeof SectionList<T>> & LayoutConfig,
topExtraPadding,
bottomExtraPadding,
contentContainerStyle,
...props
}: React.ComponentProps<typeof SectionList<T>> & LayoutConfig,
ref: React.Ref<SectionList<T>>, ref: React.Ref<SectionList<T>>,
) => { ) => {
const { topExtraPadding, bottomExtraPadding, contentContainerStyle, ...rest } = props;
const padding = useContentPadding({ topExtraPadding, bottomExtraPadding }); const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
return ( return (
<Wrapper> <Wrapper>
<Shadow> <Shadow>
<SectionList <SectionList ref={ref} {...rest} contentContainerStyle={[padding, contentContainerStyle]} />
ref={ref}
{...props}
contentContainerStyle={[padding, contentContainerStyle]}
/>
</Shadow> </Shadow>
</Wrapper> </Wrapper>
); );
@@ -118,20 +104,59 @@ LayoutView.SectionView = forwardRef(
LayoutConfig & { ref?: React.Ref<SectionList<T>> }, LayoutConfig & { ref?: React.Ref<SectionList<T>> },
) => React.ReactElement; ) => React.ReactElement;
/* ========================= FlatList ========================= */ /* ========================= FlatList / LegendList ========================= */
type PinnedListConfig = {
header?: ReactNode;
footer?: ReactNode;
};
LayoutView.FlatList = forwardRef( LayoutView.FlatList = forwardRef(
<T,>( <T,>(
{ {
header,
footer,
topExtraPadding, topExtraPadding,
bottomExtraPadding, bottomExtraPadding,
contentContainerStyle, contentContainerStyle,
...props ...props
}: React.ComponentProps<typeof FlatList<T>> & LayoutConfig, }: React.ComponentProps<typeof FlatList<T>> & LayoutConfig & PinnedListConfig,
ref: React.Ref<FlatList<T>>, ref: React.Ref<FlatList<T>>,
) => { ) => {
const padding = useContentPadding({ topExtraPadding, bottomExtraPadding }); const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
if (header || footer) {
return (
<>
{header ? (
<View
style={{
paddingTop: padding.paddingTop,
paddingHorizontal: padding.paddingHorizontal,
}}
>
{header}
</View>
) : null}
<Wrapper>
<Shadow>
<FlatList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
</Shadow>
</Wrapper>
{footer ? (
<View
style={{
paddingBottom: padding.paddingBottom,
paddingHorizontal: padding.paddingHorizontal,
}}
>
{footer}
</View>
) : null}
</>
);
}
return ( return (
<Wrapper> <Wrapper>
<Shadow> <Shadow>
@@ -141,37 +166,69 @@ LayoutView.FlatList = forwardRef(
); );
}, },
) as <T>( ) as <T>(
props: React.ComponentProps<typeof FlatList<T>> & LayoutConfig & { ref?: React.Ref<FlatList<T>> }, props: React.ComponentProps<typeof FlatList<T>> &
LayoutConfig &
PinnedListConfig & { ref?: React.Ref<FlatList<T>> },
) => React.ReactElement; ) => React.ReactElement;
/* ========================= LegendList ========================= */
LayoutView.LegendList = forwardRef( LayoutView.LegendList = forwardRef(
<T,>( <T,>(
{ {
header,
footer,
topExtraPadding, topExtraPadding,
bottomExtraPadding, bottomExtraPadding,
contentContainerStyle, contentContainerStyle,
...props ...props
}: React.ComponentProps<typeof LegendList<T>> & LayoutConfig, }: React.ComponentProps<typeof LegendList<T>> & LayoutConfig & PinnedListConfig,
ref: React.Ref<any>, ref: React.Ref<any>,
) => { ) => {
const padding = useContentPadding({ topExtraPadding, bottomExtraPadding }); const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
if (header || footer) {
return (
<>
{header ? (
<View
style={{
paddingTop: padding.paddingTop,
paddingHorizontal: padding.paddingHorizontal,
}}
>
{header}
</View>
) : null}
<Wrapper>
<Shadow>
<LegendList ref={ref} {...props} contentContainerStyle={contentContainerStyle} />
</Shadow>
</Wrapper>
{footer ? (
<View
style={{
paddingBottom: padding.paddingBottom,
paddingHorizontal: padding.paddingHorizontal,
}}
>
{footer}
</View>
) : null}
</>
);
}
return ( return (
<Wrapper> <Wrapper>
<Shadow> <Shadow>
<LegendList <LegendList ref={ref} {...props} contentContainerStyle={[padding, contentContainerStyle]} />
ref={ref}
{...props}
contentContainerStyle={[padding, contentContainerStyle]}
/>
</Shadow> </Shadow>
</Wrapper> </Wrapper>
); );
}, },
) as <T>( ) as <T>(
props: React.ComponentProps<typeof LegendList<T>> & LayoutConfig & { ref?: React.Ref<any> }, props: React.ComponentProps<typeof LegendList<T>> &
LayoutConfig &
PinnedListConfig & { ref?: React.Ref<any> },
) => React.ReactElement; ) => React.ReactElement;
/* ========================= */ /* ========================= */
@@ -67,7 +67,7 @@ export function useChatWebSocket() {
const apiUrl = process.env.EXPO_PUBLIC_API_URL || ""; const apiUrl = process.env.EXPO_PUBLIC_API_URL || "";
const wsProtocol = apiUrl.startsWith("https") ? "wss" : "ws"; const wsProtocol = apiUrl.startsWith("https") ? "wss" : "ws";
const host = apiUrl.replace(/^https?:\/\//, ""); const host = apiUrl.replace(/^https?:\/\//, "");
return `${wsProtocol}://${host}/app/chat/ws?token=${token}&family_id=${familyId}`; return `${wsProtocol}://${host}/chat/ws?token=${token}&family_id=${familyId}`;
}, [token, familyId]); }, [token, familyId]);
const connect = useCallback(() => { const connect = useCallback(() => {
+1 -1
View File
@@ -59,7 +59,7 @@ export default function AppLayout({ tabs }: { tabs: TabbarItem[] }) {
</View> </View>
</View> </View>
<View style={{ position: "absolute", left: 0, right: 0, bottom: 0 }}> <View style={{ position: "absolute", left: 0, right: 0, bottom: 0 }}>
<View style={{ paddingBottom: insets.bottom }}> <View style={{ paddingBottom: insets.bottom + 16 }}>
<Tabbar <Tabbar
tabs={tabs} tabs={tabs}
value={activeTabItem?.id} value={activeTabItem?.id}
+1 -1
View File
@@ -65,7 +65,7 @@ export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) {
zIndex: 10, zIndex: 10,
}} }}
> >
<View style={{ paddingBottom: insets.bottom }}> <View style={{ paddingBottom: insets.bottom + 16 }}>
<Tabbar tabs={tabs} value={activeTabItem?.id} onChange={(id) => router.push(id as any)} /> <Tabbar tabs={tabs} value={activeTabItem?.id} onChange={(id) => router.push(id as any)} />
</View> </View>
</View> </View>
-2
View File
@@ -62,8 +62,6 @@ const alovaInstance = createAlova({
}, },
beforeRequest(method) { beforeRequest(method) {
console.log(process.env.EXPO_PUBLIC_API_URL)
// 白名单接口直接放行 // 白名单接口直接放行
if (isWhiteListed(method.url)) return; if (isWhiteListed(method.url)) return;