u
This commit is contained in:
1 parent
513a991b57
commit
8f8e8d03c8
22 files changed
+2212
-213
No files matched your search
@@ -1,104 +1,436 @@
|
||||
import React, { useState } from "react";
|
||||
import { View, Text, Button, ScrollView } from "react-native";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import DateTime from "@/components/DateTime";
|
||||
import DateTimeRange from "@/components/DateTimeRange";
|
||||
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";
|
||||
|
||||
export default function ChatTest() {
|
||||
// --- DateTime (单选) ---
|
||||
const [showDate, setShowDate] = useState(false);
|
||||
const [dateValue, setDateValue] = useState("2026-06-23");
|
||||
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 [showTime, setShowTime] = useState(false);
|
||||
const [timeValue, setTimeValue] = useState("20:00:00");
|
||||
// ====== 消息气泡 ======
|
||||
function ChatMessageItem({
|
||||
item,
|
||||
isSelf,
|
||||
members,
|
||||
isAdmin,
|
||||
onRecall,
|
||||
onDelete,
|
||||
}: {
|
||||
item: ChatMessage;
|
||||
isSelf: boolean;
|
||||
members: Record<number, { nickname: string; avatar: string }>;
|
||||
isAdmin: boolean;
|
||||
onRecall: (id: number) => void;
|
||||
onDelete: (id: number) => void;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const member = members[item.user_id];
|
||||
const nickname = item.nickname || member?.nickname || "未知";
|
||||
const [showActions, setShowActions] = useState(false);
|
||||
|
||||
const [showDatetime, setShowDatetime] = useState(false);
|
||||
const [datetimeValue, setDatetimeValue] = useState("2026-06-23 20:00");
|
||||
const msgTime = new Date(item.create_time.replace(" ", "T") + "Z").getTime();
|
||||
const within2Min = (Date.now() - msgTime) < 2 * 60 * 1000;
|
||||
const canRecall = isSelf && within2Min;
|
||||
const canDelete = isAdmin;
|
||||
|
||||
const [showMonth, setShowMonth] = useState(false);
|
||||
const [monthValue, setMonthValue] = useState("2026-06");
|
||||
// 撤回
|
||||
if (item.recalled === 1) {
|
||||
return (
|
||||
<View className="items-center py-2">
|
||||
<Text className="text-xs text-gray-400">{nickname} 撤回了一条消息</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const [showYear, setShowYear] = useState(false);
|
||||
const [yearValue, setYearValue] = useState("2026");
|
||||
return (
|
||||
<>
|
||||
<PressableFeedback
|
||||
onLongPress={() => (canRecall || canDelete) && setShowActions(true)}
|
||||
>
|
||||
<View className={cn("px-4 py-1", isSelf ? "items-end" : "items-start")}>
|
||||
{!isSelf && (
|
||||
<PressableFeedback onPress={() => router.navigate(`/family/member/${item.user_id}` as any)}>
|
||||
<Text className="text-xs text-gray-400 mb-0.5 ml-1">{nickname}</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
)}
|
||||
|
||||
// --- DateTimeRange (范围) ---
|
||||
const [showRange, setShowRange] = useState(false);
|
||||
const [rangeStart, setRangeStart] = useState("");
|
||||
const [rangeEnd, setRangeEnd] = useState("");
|
||||
{/* 文本 */}
|
||||
{item.msg_type === 0 && (
|
||||
<View
|
||||
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")}>
|
||||
{item.content}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
{item.msg_type === 1 && item.attachments && item.attachments.length > 0 && (
|
||||
<View className="flex-row flex-wrap gap-1 max-w-[75%] justify-end">
|
||||
{item.attachments.map((att, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
source={{ uri: buildFileUrl(att.file_key) }}
|
||||
className="w-28 h-28 rounded-xl bg-gray-200"
|
||||
resizeMode="cover"
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 视频 */}
|
||||
{item.msg_type === 2 && item.attachments && item.attachments.length > 0 && (
|
||||
<View className="w-48 h-32 rounded-xl bg-gray-800 items-center justify-center">
|
||||
<Text className="text-white text-2xl">▶</Text>
|
||||
{item.attachments[0].duration ? (
|
||||
<Text className="text-white text-xs absolute bottom-1 right-1">
|
||||
{item.attachments[0].duration}s
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 文件 */}
|
||||
{item.msg_type === 3 && item.attachments && item.attachments.length > 0 && (
|
||||
<View
|
||||
className={cn(
|
||||
"max-w-[75%] rounded-xl px-3 py-2 flex-row items-center gap-2",
|
||||
isSelf ? "bg-green-500" : "bg-white"
|
||||
)}
|
||||
>
|
||||
<Text className={cn(isSelf ? "text-white" : "text-gray-900")}>📎</Text>
|
||||
<View className="flex-1">
|
||||
<Text className={cn("text-sm", isSelf ? "text-white" : "text-gray-900")} numberOfLines={1}>
|
||||
{item.attachments[0].file_name}
|
||||
</Text>
|
||||
<Text className={cn("text-xs", isSelf ? "text-white/70" : "text-gray-400")}>
|
||||
{item.attachments[0].file_size
|
||||
? (item.attachments[0].file_size / 1024).toFixed(0) + " KB"
|
||||
: ""}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 转发 */}
|
||||
{item.msg_type === 4 && item.source_data && (
|
||||
<View className="max-w-[75%] rounded-xl overflow-hidden bg-white border border-gray-200">
|
||||
<View className="px-3 py-2">
|
||||
<Text className="text-xs text-gray-400">来自 {item.source_table}</Text>
|
||||
<Text className="text-sm text-gray-700 mt-1" numberOfLines={3}>
|
||||
{(() => {
|
||||
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))
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(" ");
|
||||
} catch {
|
||||
return item.source_data;
|
||||
}
|
||||
})()}
|
||||
</Text>
|
||||
</View>
|
||||
{item.content ? (
|
||||
<View className="px-3 pb-2">
|
||||
<Text className="text-sm text-gray-900">{item.content}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Text className="text-[10px] text-gray-300 mt-0.5 mr-1">{formatTime(item.create_time)}</Text>
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
{/* 操作面板 */}
|
||||
<BottomSheet
|
||||
isOpen={showActions}
|
||||
onClose={() => setShowActions(false)}
|
||||
snapPoints={["auto"]}
|
||||
>
|
||||
<View className="px-4 py-4 gap-2">
|
||||
{canRecall && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onRecall(item.id);
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
撤回
|
||||
</Button>
|
||||
)}
|
||||
{canDelete && (
|
||||
<Button
|
||||
onPress={() => {
|
||||
onDelete(item.id);
|
||||
setShowActions(false);
|
||||
}}
|
||||
className="w-full"
|
||||
color="danger"
|
||||
>
|
||||
删除
|
||||
</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() {
|
||||
const router = useRouter();
|
||||
const userId = useUserStore((s) => s.userInfo.id);
|
||||
const familyId = useFamilyStore((s) => s.currentFamily.family_id);
|
||||
|
||||
const [history, setHistory] = useState<ChatMessage[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [members, setMembers] = useState<Record<number, { nickname: string; avatar: string; role: number }>>({});
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [adminRole, setAdminRole] = useState(0);
|
||||
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const ws = useChatWebSocket();
|
||||
|
||||
// 成员映射
|
||||
useEffect(() => {
|
||||
if (!familyId) return;
|
||||
loadDataApi({
|
||||
view_name: "b_family_member",
|
||||
search_condition: "family_id = ?",
|
||||
search_columns: ["user_id", "nickname", "avatar", "role"],
|
||||
args: [familyId],
|
||||
order_by: "",
|
||||
}).then((res) => {
|
||||
if (res.isSuccess && res.data) {
|
||||
const map: Record<number, any> = {};
|
||||
for (const m of res.data) map[m.user_id] = m;
|
||||
setMembers(map);
|
||||
const me = map[userId];
|
||||
if (me) setAdminRole(me.role ?? 99);
|
||||
}
|
||||
});
|
||||
}, [familyId, userId]);
|
||||
|
||||
// 加载历史
|
||||
const loadHistory = useCallback(
|
||||
async (p: number) => {
|
||||
if (!familyId) return;
|
||||
const res = await loadDataPageApi({
|
||||
view_name: "b_chat",
|
||||
search_condition: "family_id = ? AND deleted = 0",
|
||||
order_by: "create_time DESC",
|
||||
args: [familyId],
|
||||
page: p,
|
||||
page_size: 50,
|
||||
search_columns: [],
|
||||
});
|
||||
if (res.isSuccess && res.data) {
|
||||
const rows = res.data.rows as ChatMessage[];
|
||||
const mediaIds = rows.filter((r) => r.msg_type >= 1 && r.msg_type <= 3).map((r) => r.id);
|
||||
if (mediaIds.length > 0) {
|
||||
const fileRes = await loadDataApi({
|
||||
view_name: "b_file",
|
||||
search_condition: `target_id IN (${mediaIds.join(",")})`,
|
||||
order_by: "sort_order ASC",
|
||||
args: [],
|
||||
search_columns: [],
|
||||
});
|
||||
if (fileRes.isSuccess && fileRes.data) {
|
||||
const fileMap: Record<number, any[]> = {};
|
||||
for (const f of fileRes.data) {
|
||||
if (!fileMap[f.target_id]) fileMap[f.target_id] = [];
|
||||
fileMap[f.target_id].push(f);
|
||||
}
|
||||
for (const r of rows) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
[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);
|
||||
setLoadingMore(false);
|
||||
}, [loadingMore, hasMore, page, loadHistory]);
|
||||
|
||||
// 合并实时消息
|
||||
const allMessages = (() => {
|
||||
const ids = new Set(history.map((m) => m.id));
|
||||
const merged = [...history];
|
||||
for (const m of ws.messages) {
|
||||
if (!ids.has(m.id)) {
|
||||
merged.push(m);
|
||||
ids.add(m.id);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
})();
|
||||
|
||||
// 发送
|
||||
const handleSend = useCallback(() => {
|
||||
const text = inputText.trim();
|
||||
if (!text) return;
|
||||
setInputText("");
|
||||
Keyboard.dismiss();
|
||||
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 (
|
||||
<LayoutView>
|
||||
<EmptyState type="loading" title="加载中..." />
|
||||
</LayoutView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView>
|
||||
<View style={{ padding: 16, gap: 16, marginTop: 30 }}>
|
||||
|
||||
{/* DateTime */}
|
||||
<Text style={{ fontSize: 18, fontWeight: "bold" }}>DateTime(单选)</Text>
|
||||
|
||||
<Button title={`日期: ${dateValue}`} onPress={() => setShowDate(true)} />
|
||||
<DateTime
|
||||
show={showDate}
|
||||
value={dateValue}
|
||||
mode="date"
|
||||
onConfirm={(v) => { setDateValue(v); setShowDate(false); }}
|
||||
onClose={() => setShowDate(false)}
|
||||
/>
|
||||
|
||||
<Button title={`时间: ${timeValue}`} onPress={() => setShowTime(true)} />
|
||||
<DateTime
|
||||
show={showTime}
|
||||
value={timeValue}
|
||||
mode="time"
|
||||
onConfirm={(v) => { setTimeValue(v); setShowTime(false); }}
|
||||
onClose={() => setShowTime(false)}
|
||||
/>
|
||||
|
||||
<Button title={`日期时间: ${datetimeValue}`} onPress={() => setShowDatetime(true)} />
|
||||
<DateTime
|
||||
show={showDatetime}
|
||||
value={datetimeValue}
|
||||
mode="datetime"
|
||||
onConfirm={(v) => { setDatetimeValue(v); setShowDatetime(false); }}
|
||||
onClose={() => setShowDatetime(false)}
|
||||
/>
|
||||
|
||||
<Button title={`月份: ${monthValue}`} onPress={() => setShowMonth(true)} />
|
||||
<DateTime
|
||||
show={showMonth}
|
||||
value={monthValue}
|
||||
mode="month"
|
||||
onConfirm={(v) => { setMonthValue(v); setShowMonth(false); }}
|
||||
onClose={() => setShowMonth(false)}
|
||||
/>
|
||||
|
||||
<Button title={`年份: ${yearValue}`} onPress={() => setShowYear(true)} />
|
||||
<DateTime
|
||||
show={showYear}
|
||||
value={yearValue}
|
||||
mode="year"
|
||||
onConfirm={(v) => { setYearValue(v); setShowYear(false); }}
|
||||
onClose={() => setShowYear(false)}
|
||||
/>
|
||||
|
||||
{/* DateTimeRange */}
|
||||
<Text style={{ fontSize: 18, fontWeight: "bold", marginTop: 16 }}>DateTimeRange(范围)</Text>
|
||||
|
||||
<Button
|
||||
title={rangeStart ? `${rangeStart} ~ ${rangeEnd || "..."}` : "选择日期范围"}
|
||||
onPress={() => setShowRange(true)}
|
||||
/>
|
||||
<DateTimeRange
|
||||
show={showRange}
|
||||
startDate={rangeStart || undefined}
|
||||
endDate={rangeEnd || undefined}
|
||||
onConfirm={(start, end) => {
|
||||
setRangeStart(start);
|
||||
setRangeEnd(end);
|
||||
setShowRange(false);
|
||||
}}
|
||||
onClose={() => setShowRange(false)}
|
||||
/>
|
||||
<View className="flex-1 bg-gray-50">
|
||||
{/* 连接状态 */}
|
||||
<View className="h-7 px-4 flex-row items-center gap-1.5">
|
||||
<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>
|
||||
</View>
|
||||
</LayoutView.ScrollView>
|
||||
|
||||
{/* 在线成员 + 搜索入口 */}
|
||||
<ChatOnlineBar users={ws.onlineUsers} onSearch={() => router.navigate("/chat/history")} />
|
||||
|
||||
{/* 消息列表 */}
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={allMessages}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
renderItem={({ item }) => (
|
||||
<ChatMessageItem
|
||||
item={item}
|
||||
isSelf={item.user_id === userId}
|
||||
members={members}
|
||||
isAdmin={adminRole === 0}
|
||||
onRecall={handleRecall}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
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
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 输入栏 */}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,6 @@ import { useFamilyStore, useFavoriteStore } from "@/store";
|
||||
import {
|
||||
ChevronRight,
|
||||
Grid3X3,
|
||||
UserPlus,
|
||||
User,
|
||||
Receipt,
|
||||
Heart,
|
||||
@@ -30,10 +29,9 @@ function getGreeting() {
|
||||
|
||||
/* ─── 类型 → 图标 ─── */
|
||||
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
|
||||
join: UserPlus,
|
||||
rename: User,
|
||||
bill: Receipt,
|
||||
fav: Heart,
|
||||
family: User,
|
||||
finance: Receipt,
|
||||
favorite: Heart,
|
||||
};
|
||||
|
||||
/* ─── 时间格式化 ─── */
|
||||
|
||||
@@ -1,19 +1,14 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { View, Text } from "react-native";
|
||||
import { View, Text, FlatList, ScrollView } from "react-native";
|
||||
import { Stack, useRouter } from "expo-router";
|
||||
import { CloseButton } from "heroui-native";
|
||||
import { CloseButton, Button, PressableFeedback } from "heroui-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useFamilyStore } from "@/store";
|
||||
import { loadFeeds, type FeedRecord } from "@/helpers/feed";
|
||||
import { UserPlus, User, Receipt, Heart } from "lucide-react-native";
|
||||
|
||||
/* ─── 类型 → 图标 ─── */
|
||||
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
|
||||
join: UserPlus,
|
||||
rename: User,
|
||||
bill: Receipt,
|
||||
fav: Heart,
|
||||
};
|
||||
import { loadFeedsPage, type FeedRecord } from "@/helpers/feed";
|
||||
import EmptyState from "@/components/EmptyStatus";
|
||||
import DateTimeRange from "@/components/DateTimeRange";
|
||||
import { User, Receipt, Heart, ArrowUp, Calendar } from "lucide-react-native";
|
||||
|
||||
/* ─── 时间格式化 ─── */
|
||||
function formatTime(dateStr: string) {
|
||||
@@ -36,75 +31,268 @@ function formatTime(dateStr: string) {
|
||||
|
||||
export default function Activities() {
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
const familyStore = useFamilyStore();
|
||||
const flatListRef = useRef<FlatList<FeedRecord>>(null);
|
||||
|
||||
const [feeds, setFeeds] = useState<FeedRecord[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [typeFilter, setTypeFilter] = useState("");
|
||||
const [dateFrom, setDateFrom] = useState("");
|
||||
const [dateTo, setDateTo] = useState("");
|
||||
const [showDatePicker, setShowDatePicker] = useState(false);
|
||||
const [showScrollTop, setShowScrollTop] = useState(false);
|
||||
|
||||
const fetchFeeds = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await loadFeeds(familyStore.currentFamily.family_id);
|
||||
setFeeds(data);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [familyStore.currentFamily.family_id]);
|
||||
const hasMore = feeds.length < total;
|
||||
|
||||
/* ─── 数据加载 ─── */
|
||||
const fetchFeeds = useCallback(
|
||||
async (pageNum: number, isLoadMore = false) => {
|
||||
if (isLoadMore) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await loadFeedsPage(
|
||||
familyStore.currentFamily.family_id,
|
||||
pageNum,
|
||||
20,
|
||||
typeFilter || undefined,
|
||||
dateFrom || undefined,
|
||||
dateTo || undefined,
|
||||
);
|
||||
|
||||
if (isLoadMore) {
|
||||
setFeeds((prev) => [...prev, ...data.rows]);
|
||||
} else {
|
||||
setFeeds(data.rows);
|
||||
}
|
||||
setTotal(data.total);
|
||||
setPage(pageNum);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
},
|
||||
[familyStore.currentFamily.family_id, typeFilter, dateFrom, dateTo],
|
||||
);
|
||||
|
||||
/* ─── 首次加载 & 类别/日期变更时重新加载 ─── */
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeeds();
|
||||
if (isInitialMount.current) {
|
||||
isInitialMount.current = false;
|
||||
fetchFeeds(1);
|
||||
return;
|
||||
}
|
||||
fetchFeeds(1);
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: false });
|
||||
}, [typeFilter, dateFrom, dateTo]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
/* ─── 滚动处理 ─── */
|
||||
const handleScroll = useCallback((event: { nativeEvent: { contentOffset: { y: number } } }) => {
|
||||
setShowScrollTop(event.nativeEvent.contentOffset.y > 400);
|
||||
}, []);
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (!loadingMore && hasMore) {
|
||||
fetchFeeds(page + 1, true);
|
||||
}
|
||||
}, [loadingMore, hasMore, page, fetchFeeds]);
|
||||
|
||||
const handleScrollToTop = useCallback(() => {
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
}, []);
|
||||
|
||||
/* ─── 日期范围确认 ─── */
|
||||
const handleDateConfirm = useCallback((start: string, end: string) => {
|
||||
setDateFrom(start);
|
||||
setDateTo(end);
|
||||
setShowDatePicker(false);
|
||||
}, []);
|
||||
|
||||
/* ─── 日期范围显示文本 ─── */
|
||||
const dateLabel = dateFrom
|
||||
? dateTo
|
||||
? `${dateFrom} ~ ${dateTo}`
|
||||
: dateFrom
|
||||
: "筛选日期";
|
||||
|
||||
/* ─── 渲染每条动态 ─── */
|
||||
const renderItem = useCallback(
|
||||
({ item, index }: { item: FeedRecord; index: number }) => {
|
||||
const isLast = index === feeds.length - 1;
|
||||
const Icon = item.type === "family" ? User : item.type === "finance" ? Receipt : item.type === "favorite" ? Heart : Receipt;
|
||||
|
||||
return (
|
||||
<View className="flex-row">
|
||||
{/* 时间轴列 */}
|
||||
<View className="items-center mr-3" style={{ width: 24 }}>
|
||||
<View className="w-7 h-7 rounded-full bg-accent/10 items-center justify-center mt-0.5">
|
||||
<Icon size={14} color="#0d9488" />
|
||||
</View>
|
||||
{!isLast && <View className="flex-1 w-px bg-gray-200 mt-1" />}
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<View className={`flex-1 ${isLast ? "" : "pb-6"}`}>
|
||||
<Text className="text-xs text-gray-400">{formatTime(item.create_time)}</Text>
|
||||
<Text className="text-[15px] text-gray-800 leading-5 mt-0.5">{item.content}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[feeds.length],
|
||||
);
|
||||
|
||||
/* ─── 空状态 ─── */
|
||||
const renderEmpty = useCallback(() => {
|
||||
if (loading) return null;
|
||||
return <EmptyState type="empty" />;
|
||||
}, [loading]);
|
||||
|
||||
/* ─── 底部加载指示器 ─── */
|
||||
const renderFooter = useCallback(() => {
|
||||
if (loadingMore) {
|
||||
return (
|
||||
<View className="py-4 items-center">
|
||||
<Text className="text-xs text-gray-400">加载更多...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
if (!loading && feeds.length > 0 && !hasMore) {
|
||||
return (
|
||||
<View className="py-4 items-center">
|
||||
<Text className="text-xs text-gray-300">没有更多了</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [loadingMore, loading, feeds.length, hasMore]);
|
||||
|
||||
return (
|
||||
<LayoutView>
|
||||
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||
|
||||
<LayoutView.ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
<LayoutView.FlatList
|
||||
ref={flatListRef}
|
||||
data={feeds}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
topExtraPadding={106 - insets.top}
|
||||
bottomExtraPadding={20}
|
||||
renderItem={renderItem}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
onScroll={handleScroll}
|
||||
scrollEventThrottle={16}
|
||||
/>
|
||||
|
||||
{/* ─── 浮动头部:类别筛选 + 日期筛选 ─── */}
|
||||
<View
|
||||
className="absolute top-0 left-0 right-0 z-10 bg-background"
|
||||
style={{ paddingTop: insets.top }}
|
||||
>
|
||||
{loading ? (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="text-sm text-gray-400">加载中...</Text>
|
||||
</View>
|
||||
) : feeds.length === 0 ? (
|
||||
<View className="flex-1 items-center justify-center py-20">
|
||||
<Text className="text-sm text-gray-400">暂无动态</Text>
|
||||
</View>
|
||||
) : (
|
||||
feeds.map((item, idx) => {
|
||||
const isLast = idx === feeds.length - 1;
|
||||
const Icon = TYPE_ICONS[item.type] || Receipt;
|
||||
|
||||
return (
|
||||
<View key={item.id} className="flex-row">
|
||||
{/* 时间轴列 */}
|
||||
<View className="items-center mr-3" style={{ width: 24 }}>
|
||||
<View className="w-7 h-7 rounded-full bg-accent/10 items-center justify-center mt-0.5">
|
||||
<Icon size={14} color="#0d9488" />
|
||||
</View>
|
||||
{!isLast && <View className="flex-1 w-px bg-gray-200 mt-1" />}
|
||||
</View>
|
||||
|
||||
{/* 内容 */}
|
||||
<View className={`flex-1 ${isLast ? "" : "pb-6"}`}>
|
||||
<Text className="text-xs text-gray-400">
|
||||
{formatTime(item.create_time)}
|
||||
<View className="px-4 pt-3 pb-2">
|
||||
{/* 类别筛选 chips */}
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||||
{([
|
||||
{ key: "", label: "全部" },
|
||||
{ key: "family", label: "家庭", Icon: User },
|
||||
{ key: "finance", label: "记账", Icon: Receipt },
|
||||
{ key: "favorite", label: "收藏", Icon: Heart },
|
||||
] as const).map((item) => {
|
||||
const { key, label } = item;
|
||||
const isActive = typeFilter === key;
|
||||
const Icon = "Icon" in item ? item.Icon : null;
|
||||
return (
|
||||
<PressableFeedback
|
||||
key={key}
|
||||
onPress={() => setTypeFilter(key)}
|
||||
className={`flex-row items-center gap-1 px-3.5 py-1.5 rounded-full mr-2 ${
|
||||
isActive ? "bg-accent" : "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon size={12} color={isActive ? "#fff" : "#9ca3af"} />
|
||||
)}
|
||||
<Text
|
||||
className={`text-xs ${
|
||||
isActive ? "text-white font-medium" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
<Text className="text-[15px] text-gray-800 leading-5 mt-0.5">
|
||||
{item.content}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
|
||||
<View className="h-4" />
|
||||
</LayoutView.ScrollView>
|
||||
{/* 日期范围筛选 */}
|
||||
<View className="flex-row items-center gap-2 mt-2">
|
||||
<PressableFeedback
|
||||
onPress={() => setShowDatePicker(true)}
|
||||
className={`flex-row items-center gap-1.5 px-3 py-1.5 rounded-full ${
|
||||
dateFrom ? "bg-accent/10" : "bg-gray-100"
|
||||
}`}
|
||||
>
|
||||
<Calendar size={12} color={dateFrom ? "#0d9488" : "#9ca3af"} />
|
||||
<Text
|
||||
className={`text-xs ${
|
||||
dateFrom ? "text-accent font-medium" : "text-gray-400"
|
||||
}`}
|
||||
>
|
||||
{dateLabel}
|
||||
</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
|
||||
{/* 关闭按钮 */}
|
||||
{dateFrom ? (
|
||||
<PressableFeedback
|
||||
onPress={() => {
|
||||
setDateFrom("");
|
||||
setDateTo("");
|
||||
}}
|
||||
className="px-2 py-1.5"
|
||||
>
|
||||
<Text className="text-xs text-gray-400">清除</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ─── 日期范围选择器 ─── */}
|
||||
<DateTimeRange
|
||||
show={showDatePicker}
|
||||
startDate={dateFrom || undefined}
|
||||
endDate={dateTo || undefined}
|
||||
onConfirm={handleDateConfirm}
|
||||
onClose={() => setShowDatePicker(false)}
|
||||
/>
|
||||
|
||||
{/* ─── 滚动到顶部 ─── */}
|
||||
{showScrollTop && (
|
||||
<Button
|
||||
className="absolute bottom-24 right-4 w-10 h-10 rounded-full shadow-md items-center justify-center bg-white border border-gray-100"
|
||||
onPress={handleScrollToTop}
|
||||
>
|
||||
<ArrowUp size={18} color="#0d9488" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* ─── 关闭按钮 ─── */}
|
||||
<View className="absolute bottom-6 left-0 right-0 items-center">
|
||||
<CloseButton
|
||||
onPress={() => router.back()}
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import PageLayout from "@/layouts/PageLayout";
|
||||
import EmptyState from "@/components/EmptyStatus";
|
||||
import { loadDataApi, loadDataPageApi } from "@/request/api";
|
||||
import { useFamilyStore } from "@/store";
|
||||
import { cn } from "@/utils/cn";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { useRouter } from "expo-router";
|
||||
import { SearchField, Spinner, PressableFeedback } from "heroui-native";
|
||||
|
||||
const MSG_TYPES = [
|
||||
{ label: "全部", value: -1 },
|
||||
{ label: "文本", value: 0 },
|
||||
{ label: "图片", value: 1 },
|
||||
{ label: "视频", value: 2 },
|
||||
{ label: "文件", value: 3 },
|
||||
{ label: "转发", value: 4 },
|
||||
];
|
||||
|
||||
function formatTime(ts: string) {
|
||||
if (!ts) return "";
|
||||
const d = new Date(ts.replace(" ", "T") + "Z");
|
||||
const m = (d.getMonth() + 1).toString().padStart(2, "0");
|
||||
const day = d.getDate().toString().padStart(2, "0");
|
||||
const h = d.getHours().toString().padStart(2, "0");
|
||||
const min = d.getMinutes().toString().padStart(2, "0");
|
||||
return `${m}-${day} ${h}:${min}`;
|
||||
}
|
||||
|
||||
export default function ChatHistoryPage() {
|
||||
const familyId = useFamilyStore((s) => s.currentFamily.family_id);
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [typeFilter, setTypeFilter] = useState(-1);
|
||||
const [results, setResults] = useState<any[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [searched, setSearched] = useState(false);
|
||||
const [memberMap, setMemberMap] = useState<Record<number, string>>({});
|
||||
|
||||
// 加载成员昵称映射
|
||||
useEffect(() => {
|
||||
if (!familyId) return;
|
||||
loadDataApi({
|
||||
view_name: "b_family_member",
|
||||
search_condition: "family_id = ?",
|
||||
search_columns: ["user_id", "nickname"],
|
||||
args: [familyId],
|
||||
order_by: "",
|
||||
}).then((res) => {
|
||||
if (res.isSuccess && res.data) {
|
||||
const map: Record<number, string> = {};
|
||||
for (const m of res.data) map[m.user_id] = m.nickname || "未知";
|
||||
setMemberMap(map);
|
||||
}
|
||||
});
|
||||
}, [familyId]);
|
||||
|
||||
const doSearch = useCallback(
|
||||
async (p: number) => {
|
||||
if (!familyId || !keyword.trim()) return;
|
||||
|
||||
if (p === 1) setLoading(true);
|
||||
else setLoadingMore(true);
|
||||
|
||||
try {
|
||||
const escaped = keyword.trim().replace(/'/g, "''");
|
||||
let condition = `family_id = ? AND deleted = 0 AND content ILIKE '%${escaped}%'`;
|
||||
if (typeFilter >= 0) condition += ` AND type = ${typeFilter}`;
|
||||
|
||||
const res = await loadDataPageApi({
|
||||
view_name: "b_chat",
|
||||
search_condition: condition,
|
||||
order_by: "create_time DESC",
|
||||
args: [familyId],
|
||||
page: p,
|
||||
page_size: 30,
|
||||
search_columns: [],
|
||||
});
|
||||
|
||||
if (res.isSuccess && res.data) {
|
||||
if (p === 1) setResults(res.data.rows);
|
||||
else setResults((prev) => [...prev, ...res.data.rows]);
|
||||
setHasMore(res.data.rows.length >= 30);
|
||||
setPage(p);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
setSearched(true);
|
||||
}
|
||||
},
|
||||
[familyId, keyword, typeFilter]
|
||||
);
|
||||
|
||||
const handleLoadMore = () => {
|
||||
if (loadingMore || !hasMore) return;
|
||||
doSearch(page + 1);
|
||||
};
|
||||
|
||||
const router = useRouter();
|
||||
const typeLabel = (t: number) => MSG_TYPES.find((m) => m.value === t)?.label || "未知";
|
||||
|
||||
return (
|
||||
<PageLayout title="聊天记录">
|
||||
<LayoutView>
|
||||
<View className="px-4 pt-4 gap-3">
|
||||
{/* 搜索栏 */}
|
||||
<SearchField value={keyword} onChange={setKeyword}>
|
||||
<SearchField.Group>
|
||||
<SearchField.SearchIcon />
|
||||
<SearchField.Input
|
||||
placeholder="搜索聊天记录..."
|
||||
onSubmitEditing={() => doSearch(1)}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
</SearchField.Group>
|
||||
</SearchField>
|
||||
|
||||
{/* 类型筛选 */}
|
||||
<View className="flex-row gap-2 flex-wrap">
|
||||
{MSG_TYPES.map((t) => (
|
||||
<PressableFeedback key={t.value} onPress={() => setTypeFilter(t.value)}>
|
||||
<View
|
||||
className={cn(
|
||||
"px-3 py-1 rounded-full",
|
||||
typeFilter === t.value ? "bg-green-500" : "bg-gray-200"
|
||||
)}
|
||||
>
|
||||
<Text className={cn("text-sm", typeFilter === t.value ? "text-white" : "text-gray-600")}>
|
||||
{t.label}
|
||||
</Text>
|
||||
</View>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 结果列表 */}
|
||||
<LayoutView.FlatList
|
||||
data={results}
|
||||
keyExtractor={(item: any) => String(item.id)}
|
||||
renderItem={({ item }: { item: any }) => (
|
||||
<View className="px-4 py-3 border-b border-gray-100">
|
||||
<View className="flex-row items-center gap-2 mb-1">
|
||||
<PressableFeedback
|
||||
onPress={() => router.navigate(`/family/member/${item.user_id}` as any)}
|
||||
>
|
||||
<Text className="text-xs text-gray-500">
|
||||
{memberMap[item.user_id] || "未知"}
|
||||
</Text>
|
||||
<PressableFeedback.Ripple />
|
||||
</PressableFeedback>
|
||||
<Text className="text-xs text-gray-400">{formatTime(item.create_time)}</Text>
|
||||
<View className="px-1.5 py-0.5 bg-gray-100 rounded">
|
||||
<Text className="text-[10px] text-gray-500">{typeLabel(item.type)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text className="text-sm text-gray-800" numberOfLines={3}>
|
||||
{item.content}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
onEndReached={handleLoadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListEmptyComponent={
|
||||
loading ? (
|
||||
<EmptyState type="loading" title="搜索中..." />
|
||||
) : searched ? (
|
||||
<EmptyState type="empty" title="没有找到相关消息" />
|
||||
) : null
|
||||
}
|
||||
ListFooterComponent={
|
||||
loadingMore ? (
|
||||
<View className="py-4 items-center">
|
||||
<Spinner size="sm" />
|
||||
</View>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
</LayoutView>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -273,7 +273,7 @@ export default function EntryFamily() {
|
||||
setCreateForm({ name: "" });
|
||||
|
||||
// 记录动态
|
||||
recordFeed(familyId, userStore.userInfo.id, "join", `{nickname} 创建了家庭「${createForm.name}」`);
|
||||
recordFeed(familyId, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 创建了家庭「${createForm.name}」`);
|
||||
|
||||
enterFamily(familyId);
|
||||
}
|
||||
@@ -352,7 +352,7 @@ export default function EntryFamily() {
|
||||
setJoinForm({ invite_code: "" });
|
||||
|
||||
// 记录动态
|
||||
recordFeed(family.id, userStore.userInfo.id, "join", "{nickname} 加入了家庭");
|
||||
recordFeed(family.id, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 加入了家庭`);
|
||||
|
||||
enterFamily(family.id);
|
||||
}
|
||||
|
||||
@@ -422,8 +422,8 @@ export default function UserSetting() {
|
||||
recordFeed(
|
||||
familyStore.currentFamily.family_id,
|
||||
userStore.userInfo.id,
|
||||
"rename",
|
||||
`{nickname} 修改了昵称为「${renameForm.name}」`,
|
||||
"family",
|
||||
`${userStore.userInfo.nickname} 修改了昵称为「${renameForm.name}」`,
|
||||
);
|
||||
|
||||
toast.success("名称修改成功");
|
||||
|
||||
@@ -176,8 +176,8 @@ export default function Tools() {
|
||||
recordFeed(
|
||||
familyStore.currentFamily.family_id,
|
||||
userStore.userInfo?.id ?? 0,
|
||||
"fav",
|
||||
`{nickname} 收藏了「${subItem.title}」`,
|
||||
"favorite",
|
||||
`${userStore.userInfo?.nickname ?? "成员"} 收藏了「${subItem.title}」`,
|
||||
);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { View, Text } from "react-native";
|
||||
import { Spinner } from "heroui-native";
|
||||
|
||||
interface LoadingOverlayProps {
|
||||
visible: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export default function LoadingOverlay({ visible, text = "加载中..." }: LoadingOverlayProps) {
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
className="absolute inset-0 z-50 items-center justify-center bg-black/30"
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<View className="bg-white rounded-2xl px-8 py-6 items-center gap-3 shadow-lg">
|
||||
<Spinner />
|
||||
<Text className="text-sm text-gray-500">{text}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loadDataApi, saveDataApi, getUniqueIdApi } from "@/request/api";
|
||||
import { loadDataApi, loadDataPageApi, saveDataApi, getUniqueIdApi } from "@/request/api";
|
||||
|
||||
export type FeedType = "join" | "rename" | "bill" | "fav";
|
||||
export type FeedType = "family" | "finance" | "favorite";
|
||||
|
||||
export interface FeedRecord {
|
||||
id: number;
|
||||
@@ -12,25 +12,7 @@ export interface FeedRecord {
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
/** 获取用户在家庭中的昵称(来自 b_family_member,非 b_user) */
|
||||
export async function getFamilyMemberNickname(
|
||||
familyId: number,
|
||||
userId: number,
|
||||
): Promise<string> {
|
||||
const res = await loadDataApi({
|
||||
view_name: "b_family_member",
|
||||
search_condition: "family_id = ? AND user_id = ?",
|
||||
search_columns: ["nickname"],
|
||||
order_by: "",
|
||||
args: [familyId, userId],
|
||||
});
|
||||
if (res.isSuccess && res.data?.length > 0) {
|
||||
return res.data[0].nickname || "成员";
|
||||
}
|
||||
return "成员";
|
||||
}
|
||||
|
||||
/** 写入一条动态 — content 直接包含所有展示信息,用 {nickname} 占位 */
|
||||
/** 写入一条动态 */
|
||||
export async function recordFeed(
|
||||
familyId: number,
|
||||
userId: number,
|
||||
@@ -60,7 +42,7 @@ export async function recordFeed(
|
||||
]);
|
||||
}
|
||||
|
||||
/** 加载家庭动态列表 — 自动替换 {nickname} 占位符 */
|
||||
/** 加载家庭动态列表 */
|
||||
export async function loadFeeds(familyId: number): Promise<FeedRecord[]> {
|
||||
const res = await loadDataApi({
|
||||
view_name: "b_family_feed",
|
||||
@@ -69,18 +51,49 @@ export async function loadFeeds(familyId: number): Promise<FeedRecord[]> {
|
||||
search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"],
|
||||
args: [familyId],
|
||||
});
|
||||
const feeds = (res.data || []) as FeedRecord[];
|
||||
return (res.data || []) as FeedRecord[];
|
||||
}
|
||||
|
||||
// 批量加载昵称:收集唯一 user_id,逐个查 b_family_member
|
||||
const uniqueUserIds = [...new Set(feeds.map((f) => f.user_id))];
|
||||
const nickMap = new Map<number, string>();
|
||||
for (const uid of uniqueUserIds) {
|
||||
nickMap.set(uid, await getFamilyMemberNickname(familyId, uid));
|
||||
/** 分页加载家庭动态列表 */
|
||||
export async function loadFeedsPage(
|
||||
familyId: number,
|
||||
page: number,
|
||||
pageSize: number,
|
||||
type?: string,
|
||||
dateFrom?: string,
|
||||
dateTo?: string,
|
||||
): Promise<{ rows: FeedRecord[]; total: number; page: number; page_size: number }> {
|
||||
const conditions: string[] = ["family_id = ?"];
|
||||
const args: any[] = [familyId];
|
||||
|
||||
if (type) {
|
||||
conditions.push("type = ?");
|
||||
args.push(type);
|
||||
}
|
||||
|
||||
// 替换 {nickname} 占位符,保证每次展示都是最新昵称
|
||||
return feeds.map((f) => ({
|
||||
...f,
|
||||
content: f.content.replace(/\{nickname\}/g, nickMap.get(f.user_id) || "成员"),
|
||||
}));
|
||||
if (dateFrom) {
|
||||
conditions.push("create_time >= ?");
|
||||
args.push(dateFrom);
|
||||
}
|
||||
|
||||
if (dateTo) {
|
||||
conditions.push("create_time <= ?");
|
||||
args.push(dateTo + " 23:59:59");
|
||||
}
|
||||
|
||||
const res = await loadDataPageApi({
|
||||
view_name: "b_family_feed",
|
||||
search_condition: conditions.join(" AND "),
|
||||
order_by: "create_time DESC",
|
||||
search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"],
|
||||
args,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
|
||||
if (!res.isSuccess || !res.data) {
|
||||
return { rows: [], total: 0, page, page_size: pageSize };
|
||||
}
|
||||
|
||||
return { rows: res.data.rows as FeedRecord[], total: res.data.total, page: res.data.page, page_size: res.data.page_size };
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner-native";
|
||||
import { useUserStore, useFamilyStore } from "@/store";
|
||||
|
||||
const RECONNECT_BASE = 1000;
|
||||
const RECONNECT_MAX = 30000;
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
family_id: number;
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
content: string;
|
||||
msg_type: number;
|
||||
source_table: string | null;
|
||||
source_id: number | null;
|
||||
source_data: string | null;
|
||||
attachments: Attachment[] | null;
|
||||
recalled: number;
|
||||
deleted: number;
|
||||
create_time: string;
|
||||
}
|
||||
|
||||
export interface Attachment {
|
||||
file_key: string;
|
||||
file_name: string;
|
||||
file_type: number;
|
||||
mime_type: string;
|
||||
file_size: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
export interface OnlineUser {
|
||||
user_id: number;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
}
|
||||
|
||||
interface SendPayload {
|
||||
type: "send";
|
||||
content: string;
|
||||
msg_type?: number;
|
||||
source_table?: string;
|
||||
source_id?: number;
|
||||
attachments?: Omit<Attachment, "id">[];
|
||||
}
|
||||
|
||||
export function useChatWebSocket() {
|
||||
const token = useUserStore((s) => s.userInfo?.token);
|
||||
const familyId = useFamilyStore((s) => s.currentFamily?.family_id);
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [onlineUsers, setOnlineUsers] = useState<OnlineUser[]>([]);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const lastReceivedId = useRef<number>(0);
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectDelay = useRef<number>(RECONNECT_BASE);
|
||||
const mounted = useRef(true);
|
||||
const wasConnected = useRef(false);
|
||||
|
||||
const buildWsUrl = useCallback(() => {
|
||||
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}`;
|
||||
}, [token, familyId]);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (!token || !familyId) return;
|
||||
if (wsRef.current) return;
|
||||
|
||||
const url = buildWsUrl();
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mounted.current) return;
|
||||
setIsConnected(true);
|
||||
reconnectDelay.current = RECONNECT_BASE;
|
||||
if (wasConnected.current) {
|
||||
toast.success("聊天已重连");
|
||||
}
|
||||
wasConnected.current = true;
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (!mounted.current) return;
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
handleMessage(msg);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mounted.current) return;
|
||||
setIsConnected(false);
|
||||
wsRef.current = null;
|
||||
if (wasConnected.current) {
|
||||
toast.error("聊天已断开");
|
||||
}
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
ws.close();
|
||||
};
|
||||
}, [token, familyId, buildWsUrl]);
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (!mounted.current) return;
|
||||
reconnectTimer.current = setTimeout(() => {
|
||||
reconnectDelay.current = Math.min(reconnectDelay.current * 2, RECONNECT_MAX);
|
||||
connect();
|
||||
}, reconnectDelay.current);
|
||||
}, [connect]);
|
||||
|
||||
const handleMessage = useCallback((msg: any) => {
|
||||
switch (msg.type) {
|
||||
case "message": {
|
||||
const data = msg.data as ChatMessage;
|
||||
if (data.id <= lastReceivedId.current) return;
|
||||
lastReceivedId.current = data.id;
|
||||
setMessages((prev) => [...prev, data]);
|
||||
break;
|
||||
}
|
||||
case "message_recalled": {
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === msg.message_id ? { ...m, recalled: 1 } : m))
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "message_deleted": {
|
||||
setMessages((prev) => prev.filter((m) => m.id !== msg.message_id));
|
||||
break;
|
||||
}
|
||||
case "online": {
|
||||
setOnlineUsers(msg.users || []);
|
||||
break;
|
||||
}
|
||||
case "offline": {
|
||||
setOnlineUsers((prev) =>
|
||||
prev.filter((u) => u.user_id !== msg.user_id)
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const send = useCallback((payload: SendPayload) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
wsRef.current.send(JSON.stringify(payload));
|
||||
}, []);
|
||||
|
||||
const recall = useCallback((messageId: number) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
wsRef.current.send(JSON.stringify({ type: "recall", message_id: messageId }));
|
||||
}, []);
|
||||
|
||||
const deleteMessage = useCallback((messageId: number) => {
|
||||
if (!wsRef.current || wsRef.current.readyState !== WebSocket.OPEN) return;
|
||||
wsRef.current.send(JSON.stringify({ type: "delete", message_id: messageId }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null;
|
||||
wsRef.current.close();
|
||||
wsRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [connect]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
onlineUsers,
|
||||
isConnected,
|
||||
send,
|
||||
recall,
|
||||
deleteMessage,
|
||||
};
|
||||
}
|
||||
@@ -48,6 +48,27 @@ export function loadDataApi(params: LoadDataParams) {
|
||||
return alovaInstance.Post<ResponseData<any[]>>(`/data/loadData`, params);
|
||||
}
|
||||
|
||||
interface LoadDataPageParams {
|
||||
view_name: string;
|
||||
search_condition: string;
|
||||
order_by: string;
|
||||
search_columns: string[];
|
||||
args: any[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
interface PageData<T> {
|
||||
rows: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export function loadDataPageApi(params: LoadDataPageParams) {
|
||||
return alovaInstance.Post<ResponseData<PageData<any>>>(`/data/loadDataPage`, params);
|
||||
}
|
||||
|
||||
interface SaveDataParams {
|
||||
table_name: string;
|
||||
key_field: string;
|
||||
|
||||
Reference in new issue
Block a user