20260625215406
This commit is contained in:
1 parent
2e928710df
commit
5e6f9252d5
9 files changed
+295
-235
No files matched your search
@@ -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, "不属于该家庭")
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user