This commit is contained in:
oneao committed 2026-06-25 16:47:54 +08:00
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>
);
}
+46 -33
View File
@@ -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,
};
}
+21
View File
@@ -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;