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 && (
+
+ )}
>
);
}
-// ====== 在线头像栏 ======
-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;