diff --git a/code/app/app-go/internal/ws/handler.go b/code/app/app-go/internal/ws/handler.go index 5d929cdd..3e62e736 100644 --- a/code/app/app-go/internal/ws/handler.go +++ b/code/app/app-go/internal/ws/handler.go @@ -77,9 +77,13 @@ func AuthWS() fiber.Handler { } // 验证用户属于该家庭,同时获取 nickname / avatar / role + // JOIN b_user 确保能拿到 avatar(v_family_member 视图可能不含此字段) dbClient := db.New() - rows, err := dbClient.LoadData(c.Context(), "b_family_member", - "user_id = ? AND family_id = ?", "", nil, + rows, err := dbClient.LoadDataBySQL(c.Context(), + `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}) if err != nil || len(rows) == 0 { return httpx.Unauthorized(c, "不属于该家庭") diff --git a/code/app/app-rn/bugreport-sdk_gphone16k_x86_64-CP21.260330.005-2026-06-25-11-10-56.zip b/code/app/app-rn/bugreport-sdk_gphone16k_x86_64-CP21.260330.005-2026-06-25-11-10-56.zip new file mode 100644 index 00000000..45348139 Binary files /dev/null and b/code/app/app-rn/bugreport-sdk_gphone16k_x86_64-CP21.260330.005-2026-06-25-11-10-56.zip differ diff --git a/code/app/app-rn/bugreport-sdk_gphone16k_x86_64-CP21.260330.005-2026-06-25-11-11-33.zip b/code/app/app-rn/bugreport-sdk_gphone16k_x86_64-CP21.260330.005-2026-06-25-11-11-33.zip new file mode 100644 index 00000000..975a90f6 Binary files /dev/null and b/code/app/app-rn/bugreport-sdk_gphone16k_x86_64-CP21.260330.005-2026-06-25-11-11-33.zip differ diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx index 1deb836f..c9a60aa1 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx @@ -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 EmptyState from "@/components/EmptyStatus"; import { loadDataApi, loadDataPageApi } from "@/request/api"; import { useUserStore, useFamilyStore } from "@/store"; import { useChatWebSocket, ChatMessage, OnlineUser } from "@/hooks/useChatWebSocket"; -import { cn } from "@/utils/cn"; import { buildFileUrl } from "@/utils/file"; -import { useCallback, useEffect, useRef, useState } from "react"; -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 { cn } from "@/utils/cn"; import { ChatMsgType } from "@/types"; function formatTime(ts: string) { if (!ts) return ""; - const d = new Date(ts.replace(" ", "T") + "Z"); - const h = d.getHours().toString().padStart(2, "0"); - const m = d.getMinutes().toString().padStart(2, "0"); - return `${h}:${m}`; + const d = new Date(ts); + if (isNaN(d.getTime())) return ""; + return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`; } -// ====== 消息气泡 ====== +// ===================== 在线栏 ===================== + +function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) { + const router = useRouter(); + return ( + + + {users.slice(0, 6).map((u) => ( + router.navigate(`/family/member/${u.user_id}` as any)} + > + + {u.avatar ? ( + + ) : ( + + {u.nickname?.charAt(0) || "?"} + + )} + + + + + ))} + + + + + + + + + ); +} + +// ===================== 消息气泡 ===================== + function ChatMessageItem({ - item, - isSelf, - members, - isAdmin, - onRecall, - onDelete, + item, isSelf, members, isAdmin, onRecall, onDelete, }: { item: ChatMessage; isSelf: boolean; @@ -45,35 +75,29 @@ function ChatMessageItem({ const member = members[item.user_id]; const nickname = item.nickname || member?.nickname || "未知"; const [showActions, setShowActions] = useState(false); - - const msgTime = new Date(item.create_time.replace(" ", "T") + "Z").getTime(); - const within2Min = (Date.now() - msgTime) < 2 * 60 * 1000; + const msgTime = new Date(item.create_time).getTime(); + const within2Min = !isNaN(msgTime) && Date.now() - msgTime < 2 * 60 * 1000; const canRecall = isSelf && within2Min; - const canDelete = isAdmin; - // 撤回 if (item.recalled === 1) { return ( - - {nickname} 撤回了一条消息 + + {nickname} 撤回了一条消息 ); } - // 系统消息 if (item.msg_type === ChatMsgType.System) { return ( - - {item.content} + + {item.content} ); } return ( <> - (canRecall || canDelete) && setShowActions(true)} - > + (canRecall || isAdmin) && setShowActions(true)}> {!isSelf && ( router.navigate(`/family/member/${item.user_id}` as any)}> @@ -82,22 +106,15 @@ function ChatMessageItem({ )} - {/* 文本 */} {item.msg_type === ChatMsgType.Text && ( - + {item.content} )} - {/* 图片 */} - {item.msg_type === ChatMsgType.Image && item.attachments && item.attachments.length > 0 && ( + {item.msg_type === ChatMsgType.Image && item.attachments?.length && ( {item.attachments.map((att, i) => ( )} - {/* 视频 */} - {item.msg_type === ChatMsgType.Video && item.attachments && item.attachments.length > 0 && ( + {item.msg_type === ChatMsgType.Video && item.attachments?.length && ( ▶ {item.attachments[0].duration ? ( @@ -122,17 +138,19 @@ function ChatMessageItem({ )} - {/* 文件 */} - {item.msg_type === ChatMsgType.File && item.attachments && item.attachments.length > 0 && ( + {item.msg_type === ChatMsgType.File && item.attachments?.length && ( 📎 - + {item.attachments[0].file_name} @@ -144,7 +162,6 @@ function ChatMessageItem({ )} - {/* 转发 */} {item.msg_type === ChatMsgType.Forward && item.source_data && ( @@ -154,7 +171,8 @@ function ChatMessageItem({ try { const d = JSON.parse(item.source_data); 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}`) .join(" "); } catch { @@ -171,108 +189,77 @@ function ChatMessageItem({ )} - {formatTime(item.create_time)} + + {formatTime(item.create_time)} + - {/* 操作面板 */} - setShowActions(false)} - snapPoints={["auto"]} - > - - {canRecall && ( - - )} - {canDelete && ( - - )} - - - + {showActions && ( + + + + + {canRecall && ( + { + onRecall(item.id); + setShowActions(false); + }} + > + 撤回 + + )} + {isAdmin && ( + { + onDelete(item.id); + setShowActions(false); + }} + > + 删除 + + )} + + + + )} ); } -// ====== 在线头像栏 ====== -function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) { - const router = useRouter(); - return ( - - - {users.length} 人在线 - {users.slice(0, 6).map((u) => ( - router.navigate(`/family/member/${u.user_id}` as any)} - > - - {u.avatar ? ( - - ) : ( - - {u.nickname?.charAt(0) || "?"} - - )} - - - - ))} - - - - - - - - - ); -} +// ===================== 页面 ===================== -// ====== 页面 ====== export default function ChatPage() { const router = useRouter(); const userId = useUserStore((s) => s.userInfo.id); const familyId = useFamilyStore((s) => s.currentFamily.family_id); + const ws = useChatWebSocket(); const [history, setHistory] = useState([]); - const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(true); + const [page, setPage] = useState(1); const [members, setMembers] = useState>({}); const [inputText, setInputText] = useState(""); const [adminRole, setAdminRole] = useState(0); + const listRef = useRef(null); - const flatListRef = useRef(null); - const ws = useChatWebSocket(); + // 键盘弹起时自动滚到底部 + useEffect(() => { + const show = Keyboard.addListener("keyboardWillShow", () => { + setTimeout(() => listRef.current?.scrollToEnd?.({ animated: true }), 100); + }); + return () => show.remove(); + }, []); // 成员映射 useEffect(() => { if (!familyId) return; loadDataApi({ - view_name: "b_family_member", + view_name: "v_family_member", search_condition: "family_id = ?", search_columns: ["user_id", "nickname", "avatar", "role"], args: [familyId], @@ -280,7 +267,7 @@ export default function ChatPage() { }).then((res) => { if (res.isSuccess && res.data) { const map: Record = {}; - for (const m of res.data) map[m.user_id] = m; + for (const r of res.data) map[r.user_id] = r; setMembers(map); const me = map[userId]; if (me) setAdminRole(me.role ?? 99); @@ -302,12 +289,17 @@ export default function ChatPage() { search_columns: [], }); if (res.isSuccess && res.data) { - const rows = res.data.rows as ChatMessage[]; - const mediaIds = rows.filter((r) => r.msg_type >= ChatMsgType.Image && r.msg_type <= ChatMsgType.File).map((r) => r.id); - if (mediaIds.length > 0) { + const ordered = (res.data.rows as any[]) + .map((r) => ({ ...r, msg_type: r.type ?? r.msg_type ?? 0 } as ChatMessage)) + .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({ view_name: "b_file", - search_condition: `target_id IN (${mediaIds.join(",")})`, + search_condition: `target_id IN (${mids.join(",")})`, order_by: "sort_order ASC", args: [], search_columns: [], @@ -318,33 +310,35 @@ export default function ChatPage() { if (!fileMap[f.target_id]) fileMap[f.target_id] = []; 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 (p === 1) setHistory(rows.reverse()); - else setHistory((prev) => [...rows.reverse(), ...prev]); - setHasMore(rows.length >= 50); + + if (p === 1) setHistory(ordered); + else setHistory((prev) => [...ordered, ...prev]); + setHasMore(ordered.length >= 50); } }, - [familyId] + [familyId], ); useEffect(() => { loadHistory(1).finally(() => setLoading(false)); }, [loadHistory]); + // 加载更多 const handleLoadMore = useCallback(async () => { if (loadingMore || !hasMore) return; setLoadingMore(true); - const nextPage = page + 1; - await loadHistory(nextPage); - setPage(nextPage); + const np = page + 1; + await loadHistory(np); + setPage(np); setLoadingMore(false); }, [loadingMore, hasMore, page, loadHistory]); - // 合并实时消息 + // 合并消息 (旧→新) const allMessages = (() => { const ids = new Set(history.map((m) => m.id)); const merged = [...history]; @@ -366,9 +360,6 @@ export default function ChatPage() { ws.send({ type: "send", content: text }); }, [inputText, ws]); - const handleRecall = useCallback((id: number) => ws.recall(id), [ws]); - const handleDelete = useCallback((id: number) => ws.deleteMessage(id), [ws]); - if (loading) { return ( @@ -378,69 +369,79 @@ export default function ChatPage() { } return ( - - - {/* 连接状态 */} - - - {ws.isConnected ? "已连接" : "连接中..."} - - - {/* 在线成员 + 搜索入口 */} - router.navigate("/chat/history")} /> - - {/* 消息列表 */} - + + router.navigate("/chat/history")} + /> + } + footer={ + + + + + + + + + + } data={allMessages} 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 ? ( + + + + ) : null + } + ListEmptyComponent={ + + 暂无消息,开始聊天吧 + + } renderItem={({ item }) => ( )} - inverted - onEndReached={handleLoadMore} - onEndReachedThreshold={0.3} - className="flex-1" - contentContainerStyle={{ paddingVertical: 8 }} - ListFooterComponent={ - loadingMore ? ( - - - - ) : null - } /> - - {/* 输入栏 */} - - - - - - - - - - - + + ); } diff --git a/code/app/app-rn/src/components/layout/view.tsx b/code/app/app-rn/src/components/layout/view.tsx index 38140aa1..b516fce0 100644 --- a/code/app/app-rn/src/components/layout/view.tsx +++ b/code/app/app-rn/src/components/layout/view.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef } from "react"; +import React, { forwardRef, type ReactNode } from "react"; import { SectionList, FlatList, @@ -37,7 +37,6 @@ function Shadow({ children }: { children: React.ReactElement }) { function useContentPadding(config?: LayoutConfig) { const insets = useSafeAreaInsets(); - return { paddingTop: insets.top + (config?.topExtraPadding ?? 50), paddingBottom: insets.bottom + (config?.bottomExtraPadding ?? 80), @@ -68,11 +67,7 @@ LayoutView.ScrollView = forwardRef< any, React.ComponentProps & LayoutConfig >(({ topExtraPadding, bottomExtraPadding, contentContainerStyle, ...props }, ref) => { - const padding = useContentPadding({ - topExtraPadding, - bottomExtraPadding, - }); - + const padding = useContentPadding({ topExtraPadding, bottomExtraPadding }); return ( @@ -91,24 +86,15 @@ LayoutView.ScrollView = forwardRef< LayoutView.SectionView = forwardRef( ( - { - topExtraPadding, - bottomExtraPadding, - contentContainerStyle, - ...props - }: React.ComponentProps> & LayoutConfig, + props: React.ComponentProps> & LayoutConfig, ref: React.Ref>, ) => { + const { topExtraPadding, bottomExtraPadding, contentContainerStyle, ...rest } = props; const padding = useContentPadding({ topExtraPadding, bottomExtraPadding }); - return ( - + ); @@ -118,20 +104,59 @@ LayoutView.SectionView = forwardRef( LayoutConfig & { ref?: React.Ref> }, ) => React.ReactElement; -/* ========================= FlatList ========================= */ +/* ========================= FlatList / LegendList ========================= */ + +type PinnedListConfig = { + header?: ReactNode; + footer?: ReactNode; +}; LayoutView.FlatList = forwardRef( ( { + header, + footer, topExtraPadding, bottomExtraPadding, contentContainerStyle, ...props - }: React.ComponentProps> & LayoutConfig, + }: React.ComponentProps> & LayoutConfig & PinnedListConfig, ref: React.Ref>, ) => { const padding = useContentPadding({ topExtraPadding, bottomExtraPadding }); + if (header || footer) { + return ( + <> + {header ? ( + + {header} + + ) : null} + + + + + + {footer ? ( + + {footer} + + ) : null} + + ); + } + return ( @@ -141,37 +166,69 @@ LayoutView.FlatList = forwardRef( ); }, ) as ( - props: React.ComponentProps> & LayoutConfig & { ref?: React.Ref> }, + props: React.ComponentProps> & + LayoutConfig & + PinnedListConfig & { ref?: React.Ref> }, ) => React.ReactElement; -/* ========================= LegendList ========================= */ - LayoutView.LegendList = forwardRef( ( { + header, + footer, topExtraPadding, bottomExtraPadding, contentContainerStyle, ...props - }: React.ComponentProps> & LayoutConfig, + }: React.ComponentProps> & LayoutConfig & PinnedListConfig, ref: React.Ref, ) => { const padding = useContentPadding({ topExtraPadding, bottomExtraPadding }); + if (header || footer) { + return ( + <> + {header ? ( + + {header} + + ) : null} + + + + + + {footer ? ( + + {footer} + + ) : null} + + ); + } + return ( - + ); }, ) as ( - props: React.ComponentProps> & LayoutConfig & { ref?: React.Ref }, + props: React.ComponentProps> & + LayoutConfig & + PinnedListConfig & { ref?: React.Ref }, ) => React.ReactElement; /* ========================= */ diff --git a/code/app/app-rn/src/hooks/useChatWebSocket.ts b/code/app/app-rn/src/hooks/useChatWebSocket.ts index 0d66e709..bab5f63e 100644 --- a/code/app/app-rn/src/hooks/useChatWebSocket.ts +++ b/code/app/app-rn/src/hooks/useChatWebSocket.ts @@ -67,7 +67,7 @@ export function useChatWebSocket() { const apiUrl = process.env.EXPO_PUBLIC_API_URL || ""; const wsProtocol = apiUrl.startsWith("https") ? "wss" : "ws"; 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]); const connect = useCallback(() => { diff --git a/code/app/app-rn/src/layouts/AppLayout.tsx b/code/app/app-rn/src/layouts/AppLayout.tsx index 503f7c9e..eb770071 100644 --- a/code/app/app-rn/src/layouts/AppLayout.tsx +++ b/code/app/app-rn/src/layouts/AppLayout.tsx @@ -59,7 +59,7 @@ export default function AppLayout({ tabs }: { tabs: TabbarItem[] }) { - + - + router.push(id as any)} /> diff --git a/code/app/app-rn/src/request/index.ts b/code/app/app-rn/src/request/index.ts index c5f5d3b6..a4d78f9d 100644 --- a/code/app/app-rn/src/request/index.ts +++ b/code/app/app-rn/src/request/index.ts @@ -62,8 +62,6 @@ const alovaInstance = createAlova({ }, beforeRequest(method) { - console.log(process.env.EXPO_PUBLIC_API_URL) - // 白名单接口直接放行 if (isWhiteListed(method.url)) return;