20260625173106

This commit is contained in:
oneao committed 2026-06-25 17:31:07 +08:00
commit 2e928710df
178 files changed
+14812 -4314

No files matched your search

@@ -26,7 +26,7 @@ const tabs: TabbarItem[] = [
},
{
id: "/tools",
title: "",
title: "工具",
icon: LayoutGridIcon,
isCenter: true,
},
@@ -15,6 +15,7 @@ import {
PressableFeedback,
} from "heroui-native";
import { SendHorizonalIcon, SearchIcon } from "lucide-react-native";
import { ChatMsgType } from "@/types";
function formatTime(ts: string) {
if (!ts) return "";
@@ -59,6 +60,15 @@ function ChatMessageItem({
);
}
// 系统消息
if (item.msg_type === ChatMsgType.System) {
return (
<View className="items-center py-2">
<Text className="text-xs text-gray-400">{item.content}</Text>
</View>
);
}
return (
<>
<PressableFeedback
@@ -73,7 +83,7 @@ function ChatMessageItem({
)}
{/* 文本 */}
{item.msg_type === 0 && (
{item.msg_type === ChatMsgType.Text && (
<View
className={cn(
"max-w-[75%] rounded-2xl px-3 py-2",
@@ -87,7 +97,7 @@ function ChatMessageItem({
)}
{/* 图片 */}
{item.msg_type === 1 && item.attachments && item.attachments.length > 0 && (
{item.msg_type === ChatMsgType.Image && 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
@@ -101,7 +111,7 @@ function ChatMessageItem({
)}
{/* 视频 */}
{item.msg_type === 2 && item.attachments && item.attachments.length > 0 && (
{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">
<Text className="text-white text-2xl">▶</Text>
{item.attachments[0].duration ? (
@@ -113,7 +123,7 @@ function ChatMessageItem({
)}
{/* 文件 */}
{item.msg_type === 3 && item.attachments && item.attachments.length > 0 && (
{item.msg_type === ChatMsgType.File && item.attachments && item.attachments.length > 0 && (
<View
className={cn(
"max-w-[75%] rounded-xl px-3 py-2 flex-row items-center gap-2",
@@ -135,7 +145,7 @@ function ChatMessageItem({
)}
{/* 转发 */}
{item.msg_type === 4 && 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="px-3 py-2">
<Text className="text-xs text-gray-400">来自 {item.source_table}</Text>
@@ -293,7 +303,7 @@ export default function ChatPage() {
});
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);
const mediaIds = rows.filter((r) => r.msg_type >= ChatMsgType.Image && r.msg_type <= ChatMsgType.File).map((r) => r.id);
if (mediaIds.length > 0) {
const fileRes = await loadDataApi({
view_name: "b_file",
@@ -1,71 +1,72 @@
import { useEffect, useState, useCallback } from "react";
import LayoutView from "@/components/layout/view";
import { loadDataApi } from "@/request/api";
import { useEffect, useState } from "react";
import { View, Text } from "react-native";
import { useFamilyStore, useFavoriteStore } from "@/store";
import {
ChevronRight,
Grid3X3,
User,
Receipt,
Heart,
Clock3,
} from "lucide-react-native";
import { PressableFeedback } from "heroui-native";
import { useFamilyStore, useFavoriteStore } from "@/store";
import { loadDataApi } from "@/request/api";
import { SUB_APPS } from "@/configs/pages";
import { useRouter } from "expo-router";
import { loadFeeds, type FeedRecord } from "@/helpers/feed";
import { ChevronRight, Grid3X3 } from "lucide-react-native";
import LayoutView from "@/components/layout/view";
/* ─── 问候 ─── */
function getGreeting() {
const h = new Date().getHours();
if (h < 6) return "夜深了";
if (h < 9) return "早上好";
if (h < 12) return "上午好";
if (h < 14) return "中午好";
if (h < 18) return "下午好";
return "晚上好";
// ── 组件:Section 标签 ────────────────────────────
function SectionLabel({
title,
more,
onMore,
}: {
title: string;
more?: string;
onMore?: () => void;
}) {
return (
<View className="flex-row items-center justify-between px-1 mb-3">
<Text className="text-[13px] font-semibold text-gray-800">
{title}
</Text>
{more ? (
<PressableFeedback onPress={onMore} className="flex-row items-center">
<Text className="text-[12px] text-gray-400">{more}</Text>
<ChevronRight size={14} color="#9CA3AF" />
<PressableFeedback.Ripple />
</PressableFeedback>
) : null}
</View>
);
}
/* ─── 类型 → 图标 ─── */
const TYPE_ICONS: Record<string, React.ComponentType<any>> = {
family: User,
finance: Receipt,
favorite: Heart,
};
/* ─── 时间格式化 ─── */
function formatFeedTime(dateStr: string) {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return date.toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
});
// ── 组件:宫格项 ──────────────────────────────────
function GridItem({
icon: Icon,
title,
onPress,
}: {
icon: any;
title: string;
onPress: () => void;
}) {
return (
<PressableFeedback
onPress={onPress}
className="flex-1 items-center py-3"
>
<Icon size={28} />
<Text className="text-[12px] text-gray-600 mt-2">
{title}
</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
);
}
// ═══════════════════════════════════════════════════
// 🏠 首页
// ═══════════════════════════════════════════════════
export default function Home() {
const router = useRouter();
const familyStore = useFamilyStore();
const favoriteStore = useFavoriteStore();
const [family, setFamily] = useState<any>({});
const [duration, setDuration] = useState({
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
});
const [feeds, setFeeds] = useState<FeedRecord[]>([]);
async function loadFamilyData() {
const res = await loadDataApi({
@@ -78,186 +79,54 @@ export default function Home() {
if (res.isSuccess && res.data.length > 0) setFamily(res.data[0]);
}
function calcDuration(startTime: string) {
const start = new Date(startTime).getTime();
const now = Date.now();
let diff = Math.floor((now - start) / 1000);
const days = Math.floor(diff / (3600 * 24));
diff %= 3600 * 24;
const hours = Math.floor(diff / 3600);
diff %= 3600;
const minutes = Math.floor(diff / 60);
const seconds = diff % 60;
return { days, hours, minutes, seconds };
}
useEffect(() => {
loadFamilyData();
}, []);
useEffect(() => {
if (!family.create_time) return;
const timer = setInterval(
() => setDuration(calcDuration(family.create_time)),
1000,
);
return () => clearInterval(timer);
}, [family.create_time]);
useEffect(() => {
const fid = familyStore.currentFamily.family_id;
if (fid) {
favoriteStore.fetchFavorites(fid);
loadFeeds(fid).then(setFeeds).catch(console.error);
}
}, [familyStore.currentFamily.family_id]);
return (
<LayoutView>
<LayoutView.ScrollView
showsVerticalScrollIndicator={false}
>
<LayoutView.ScrollView showsVerticalScrollIndicator={false}>
{/* ═══════════ 头部 ═══════════ */}
<View className="pt-2 pb-8">
<Text className="text-[13px] text-gray-400 tracking-wide">
{getGreeting()}
</Text>
<Text className="text-[28px] font-bold text-gray-900 mt-1 tracking-tight">
<View className="pt-8 pb-6 items-center">
<Text className="text-[34px] font-bold text-gray-800 text-center">
{family.name || "我的空间"}
</Text>
{/* 计时 — 开放式,无卡片 */}
<View className="mt-6">
<View className="flex-row items-baseline">
<Text className="text-[44px] font-bold text-accent tracking-tighter">
{duration.days}
</Text>
<Text className="text-[16px] text-accent/60 font-medium ml-1.5">
天
</Text>
</View>
<Text className="text-sm text-gray-500 mt-2">
{String(duration.hours).padStart(2, "0")} 小时{" "}
{String(duration.minutes).padStart(2, "0")} 分钟{" "}
{String(duration.seconds).padStart(2, "0")} 秒
</Text>
<Text className="text-[13px] text-gray-400/70 mt-1.5">
已经在一起这么久啦
</Text>
</View>
</View>
{/* ═══════════ 快捷工具 ═══════════ */}
<View className="flex-row items-center mb-3">
<View className="w-1.5 h-4 rounded-full bg-accent mr-1.5" />
<Text className="text-[11px] text-gray-500 font-semibold tracking-wider uppercase">
快捷工具
</Text>
</View>
<SectionLabel title="快捷工具" />
{SUB_APPS.map((app) => {
const Icon = app.icon;
return (
<PressableFeedback
<View className="flex-row">
{SUB_APPS.map((app) => (
<GridItem
key={app.id}
icon={app.icon}
title={app.title}
onPress={() => router.navigate(app.id as any)}
className="flex-row items-center bg-white rounded-xl shadow-sm mb-2 px-4 py-3.5"
>
<View className="w-11 h-11 rounded-xl bg-accent/10 items-center justify-center mr-3.5">
<Icon size={22} />
</View>
<View className="flex-1">
<Text className="text-[15px] font-semibold text-gray-800">
{app.title}
</Text>
<Text className="text-[13px] text-gray-400 mt-0.5">
{app.description}
</Text>
</View>
<ChevronRight size={18} color="#d1d5db" />
<PressableFeedback.Ripple />
</PressableFeedback>
);
})}
{/* 更多工具 */}
<PressableFeedback
onPress={() => router.navigate("/tools")}
className="flex-row items-center bg-white rounded-xl shadow-sm mb-1 mt-1 px-4 py-3.5"
>
<View className="w-11 h-11 rounded-xl bg-gray-100 items-center justify-center mr-3.5">
<Grid3X3 size={20} color="#9ca3af" />
</View>
<Text className="flex-1 text-[15px] font-medium text-gray-500">
更多工具
</Text>
<ChevronRight size={18} color="#d1d5db" />
<PressableFeedback.Ripple />
</PressableFeedback>
{/* ═══════════ 最近动态 ═══════════ */}
<View className="flex-row items-center justify-between mb-4 mt-7">
<View className="flex-row items-center">
<View className="w-1.5 h-4 rounded-full bg-accent mr-1.5" />
<Text className="text-[11px] text-gray-500 font-semibold tracking-wider uppercase">
最近动态
</Text>
</View>
<PressableFeedback
onPress={() => router.navigate("/activities")}
className="flex-row items-center"
>
<Text className="text-[12px] text-gray-400">更多</Text>
<ChevronRight size={14} color="#d1d5db" />
<PressableFeedback.Ripple />
</PressableFeedback>
/>
))}
<GridItem
icon={Grid3X3}
title="更多工具"
onPress={() => router.navigate("/tools")}
/>
{/* 填充空位保证对齐 */}
{Array.from({
length: Math.max(0, 2 - SUB_APPS.length),
}).map((_, i) => (
<View key={`ph-${i}`} className="flex-1" />
))}
</View>
{feeds.length === 0 ? (
<Text className="text-sm text-gray-400 mb-6">
暂无动态
</Text>
) : (
feeds.slice(0, 3).map((item, idx) => {
const isLast = idx === Math.min(feeds.length, 3) - 1;
const Icon = TYPE_ICONS[item.type] || Clock3;
const meta = item.meta ? JSON.parse(item.meta) : null;
return (
<View key={item.id} className="flex-row">
{/* 时间轴列 */}
<View className="items-center mr-3" style={{ width: 16 }}>
<View className="w-2 h-2 rounded-full bg-accent mt-1.5" />
{!isLast && <View className="flex-1 w-px bg-gray-200" />}
</View>
{/* 内容 */}
<View className={`flex-1 ${isLast ? "" : "pb-5"}`}>
<Text className="text-xs text-gray-400">
{formatFeedTime(item.create_time)}
</Text>
<Text className="text-[15px] text-gray-800 leading-5 mt-0.5">
{item.content}
</Text>
{meta?.amount ? (
<Text className="text-[13px] font-semibold text-accent mt-0.5">
¥{meta.amount}
</Text>
) : null}
</View>
</View>
);
})
)}
{/* 底部留白 */}
<View className="h-4" />
<View className="h-8" />
</LayoutView.ScrollView>
</LayoutView>
);
@@ -1,305 +0,0 @@
import { useEffect, useState, useCallback, useRef } from "react";
import LayoutView from "@/components/layout/view";
import { View, Text, FlatList, ScrollView } from "react-native";
import { Stack, useRouter } from "expo-router";
import { CloseButton, Button, PressableFeedback } from "heroui-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useFamilyStore } from "@/store";
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) {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
const mins = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (mins < 1) return "刚刚";
if (mins < 60) return `${mins} 分钟前`;
if (hours < 24) return `${hours} 小时前`;
if (days < 7) return `${days} 天前`;
return date.toLocaleDateString("zh-CN", {
month: "short",
day: "numeric",
});
}
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 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(() => {
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.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 }}
>
<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>
<PressableFeedback.Ripple />
</PressableFeedback>
);
})}
</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()}
iconProps={{ size: 28 }}
style={{ padding: 24 }}
/>
</View>
</LayoutView>
);
}
+56 -69
View File
@@ -1,10 +1,11 @@
import { useEffect } from "react";
import { Stack } from "expo-router";
import { Image, Text, View } from "react-native";
import { Button, LinkButton, ControlField, Checkbox, Spinner } from "heroui-native";
import { Button, LinkButton, ControlField, Checkbox } from "heroui-native";
import { IconQq, IconWeixin } from "@/components/iconfont";
import { useState } from "react";
import { FadeIn } from "react-native-reanimated";
import { useLoading } from "@/hooks/useLoading";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useUserStore, useFamilyStore } from "@/store";
import { useRouter } from "expo-router";
import ExpoQQ from "expo-qq";
@@ -17,8 +18,8 @@ export default function Login() {
const router = useRouter();
const userStore = useUserStore();
const familyStore = useFamilyStore();
const { isLoading, startLoading, stopLoading } = useLoading();
const [loading, setLoading] = useState(false);
const [isAgreed, setIsAgreed] = useState(true);
const [form, setForm] = useState({
type: 0,
@@ -38,16 +39,13 @@ export default function Login() {
// 微信登录
async function handleLoginWechat() {
if (loading) {
return;
}
setLoading(true);
if (isLoading) return;
startLoading("正在唤起微信...");
// 检查微信是否安装
const isInstalled = await ExpoWeChat.isWXAppInstalled();
if (!isInstalled) {
toast.warning(onLoginFinished?.errorMessage || "请先安装微信");
setLoading(false);
stopLoading({ type: "error", message: "请先安装微信" });
return;
}
@@ -61,11 +59,8 @@ export default function Login() {
// QQ 登录
async function handleLoginQQ() {
if (loading) {
return;
}
setLoading(true);
if (isLoading) return;
startLoading("正在唤起 QQ...");
setForm((val) => ({
...val,
@@ -76,12 +71,15 @@ export default function Login() {
useEffect(() => {
if (!onLoginFinished) {
stopLoading();
return;
}
if (!onLoginFinished.success) {
toast.warning(onLoginFinished?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权");
setLoading(false);
toast.error(
onLoginFinished?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权"
);
stopLoading();
return;
}
@@ -89,8 +87,10 @@ export default function Login() {
try {
const tokenInfo = await ExpoQQ.getLoginTokenInfo();
if (!tokenInfo.openId) {
toast.warning(tokenInfo?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权");
setLoading(false);
toast.error(
tokenInfo?.errorMessage || "授权失败,请检查手机是否安装 QQ 或确认授权"
);
stopLoading();
return;
}
@@ -102,8 +102,7 @@ export default function Login() {
await ExpoQQ.sendGetUserInfoReq();
} catch (e) {
console.log(e);
setLoading(false);
stopLoading();
}
};
@@ -139,7 +138,7 @@ export default function Login() {
useEffect(() => {
const handleLogin = async () => {
if (!onGetUserInfo) {
setLoading(false);
stopLoading();
return;
}
@@ -150,13 +149,16 @@ export default function Login() {
avatar: onGetUserInfo.figureurlQq_2 || onGetUserInfo.figureurl_2,
});
if (!loginRes.isSuccess) return;
if (!loginRes.isSuccess) {
stopLoading();
return;
}
await handlePostLogin(loginRes.data);
} catch (err) {
console.error("QQ登录失败", err);
} finally {
setLoading(false);
stopLoading();
}
};
@@ -170,7 +172,7 @@ export default function Login() {
const handleLogin = async () => {
if (authResult.errorCode !== 0) {
console.log("授权失败:", authResult.errorMessage);
setLoading(false);
stopLoading();
return;
}
@@ -179,13 +181,16 @@ export default function Login() {
code: authResult.code,
});
if (!loginRes.isSuccess) return;
if (!loginRes.isSuccess) {
stopLoading();
return;
}
await handlePostLogin(loginRes.data);
} catch (err) {
console.error("微信登录失败", err);
} finally {
setLoading(false);
stopLoading();
}
};
@@ -193,25 +198,28 @@ export default function Login() {
}, [authResult]);
async function test1() {
if (loading) return;
setLoading(true);
if (isLoading) return;
startLoading("正在登录...");
try {
const loginRes = await loginTestApi({
user_id: 801787924787269,
});
if (!loginRes.isSuccess) return;
if (!loginRes.isSuccess) {
stopLoading();
return;
}
await handlePostLogin(loginRes.data);
stopLoading();
} catch (err) {
console.error("测试登录失败", err);
} finally {
setLoading(false);
stopLoading({ type: "error", message: "登录失败" });
}
}
function test2() {};
function test2() {}
return (
<>
@@ -227,53 +235,30 @@ export default function Login() {
className="w-20 h-20 mb-4"
resizeMode="contain"
/>
<Text className="text-xl font-semibold text-gray-900">Welcome to Allapp</Text>
<Text className="text-xl font-semibold text-gray-900">
Welcome to Allapp
</Text>
</View>
<View className="flex flex-col gap-4">
<Button onPress={handleLoginWechat}>
{loading ? (
<Spinner entering={FadeIn.delay(50)} color="white" />
) : (
<>
<IconWeixin color={"#fff"} />
<Button.Label>WeChat</Button.Label>
</>
)}
<IconWeixin color={"#fff"} />
<Button.Label>WeChat</Button.Label>
</Button>
<Button variant="outline" onPress={handleLoginQQ} isDisabled={loading}>
{loading ? (
<Spinner entering={FadeIn.delay(50)} />
) : (
<>
<IconQq />
<Button.Label>QQ</Button.Label>
</>
)}
<Button variant="outline" onPress={handleLoginQQ}>
<IconQq />
<Button.Label>QQ</Button.Label>
</Button>
<Button variant="outline" onPress={test1} isDisabled={loading}>
{loading ? (
<Spinner entering={FadeIn.delay(50)} />
) : (
<>
<IconQq />
<Button.Label>测试1</Button.Label>
</>
)}
<Button variant="outline" onPress={test1}>
<IconQq />
<Button.Label>测试1</Button.Label>
</Button>
<Button variant="outline" onPress={test2} isDisabled={loading}>
{loading ? (
<Spinner entering={FadeIn.delay(50)} />
) : (
<>
<IconQq />
<Button.Label>测试2</Button.Label>
</>
)}
<Button variant="outline" onPress={test2}>
<IconQq />
<Button.Label>测试2</Button.Label>
</Button>
</View>
@@ -296,6 +281,8 @@ export default function Login() {
</LinkButton>
</View>
</ControlField>
<LoadingOverlay visible={isLoading} />
</View>
</>
);
@@ -8,14 +8,16 @@ import { useCallback, useEffect, useState } from "react";
import { Text, View } from "react-native";
import { useRouter } from "expo-router";
import { SearchField, Spinner, PressableFeedback } from "heroui-native";
import { ChatMsgType } from "@/types";
const MSG_TYPES = [
{ label: "全部", value: -1 },
{ label: "文本", value: 0 },
{ label: "图片", value: 1 },
{ label: "视频", value: 2 },
{ label: "文件", value: 3 },
{ label: "转发", value: 4 },
{ label: "文本", value: ChatMsgType.Text },
{ label: "图片", value: ChatMsgType.Image },
{ label: "视频", value: ChatMsgType.Video },
{ label: "文件", value: ChatMsgType.File },
{ label: "转发", value: ChatMsgType.Forward },
{ label: "系统", value: ChatMsgType.System },
];
function formatTime(ts: string) {
@@ -21,7 +21,6 @@ import { generateCode } from "@/utils/common";
import { FadeIn } from "react-native-reanimated";
import { Keyboard } from "react-native";
import { useRouter } from "expo-router";
import { recordFeed } from "@/helpers/feed";
const BottomSheetTextInput = forwardRef<any, React.ComponentProps<typeof Input>>((props, ref) => {
const { onFocus, onBlur } = useBottomSheetAwareHandlers();
@@ -272,9 +271,6 @@ export default function EntryFamily() {
setShowCreate(false);
setCreateForm({ name: "" });
// 记录动态
recordFeed(familyId, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 创建了家庭「${createForm.name}」`);
enterFamily(familyId);
}
} catch (e) {
@@ -351,9 +347,6 @@ export default function EntryFamily() {
setShowJoin(false);
setJoinForm({ invite_code: "" });
// 记录动态
recordFeed(family.id, userStore.userInfo.id, "family", `${userStore.userInfo.nickname} 加入了家庭`);
enterFamily(family.id);
}
} catch (e) {
@@ -35,7 +35,6 @@ import { forwardRef, useRef } from "react";
import { FadeIn } from "react-native-reanimated";
import DateTime from "@/components/DateTime";
import { toDateString } from "@/utils/time";
import { recordFeed } from "@/helpers/feed";
const StyledIonicons = withUniwind(Ionicons);
@@ -418,14 +417,6 @@ export default function UserSetting() {
name: "",
});
// 记录动态
recordFeed(
familyStore.currentFamily.family_id,
userStore.userInfo.id,
"family",
`${userStore.userInfo.nickname} 修改了昵称为「${renameForm.name}」`,
);
toast.success("名称修改成功");
}
} catch (e) {
@@ -10,7 +10,6 @@ import EmptyState from "@/components/EmptyStatus";
import AZIndex from "@/components/AZIndex";
import { useFavoriteStore, useFamilyStore, useUserStore } from "@/store";
import { Heart } from "lucide-react-native";
import { recordFeed } from "@/helpers/feed";
export default function Tools() {
const router = useRouter();
@@ -171,15 +170,6 @@ export default function Tools() {
subItem.id
);
toast(result ? "已收藏" : "已取消收藏");
// 记录收藏动态(仅收藏时)
if (result) {
recordFeed(
familyStore.currentFamily.family_id,
userStore.userInfo?.id ?? 0,
"favorite",
`${userStore.userInfo?.nickname ?? "成员"} 收藏了「${subItem.title}」`,
);
}
}}
className="absolute right-4 top-0 bottom-0 justify-center px-2 z-10"
>
+1 -1
View File
@@ -1,5 +1,5 @@
import { useIsDark } from "@/hooks/use-color-scheme";
import { DarkTheme, DefaultTheme, ThemeProvider } from "@react-navigation/native";
import { DarkTheme, DefaultTheme, ThemeProvider } from "expo-router";
import { StatusBar } from "expo-status-bar";
import { HeroUINativeProvider } from "heroui-native";
import { GestureHandlerRootView } from "react-native-gesture-handler";
+274 -201
View File
@@ -1,241 +1,314 @@
import { View, Text, TouchableOpacity } from "react-native";
import { View, Text, TouchableOpacity, TextInput } from "react-native";
import { Stack } from "expo-router";
import PageLayout from "@/layouts/PageLayout";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useMemo, useCallback } from "react";
import LayoutView from "@/components/layout/view";
import { ScrollShadow, Tabs, TextField, Label, Button, Input, BottomSheet } from "heroui-native";
import { Tabs, Button, BottomSheet, Input, Label, TextField, PressableFeedback } from "heroui-native";
import { useFinanceStore } from "@/store/finance";
import { useUserStore, useFamilyStore } from "@/store";
import { getUniqueIdApi, saveDataApi } from "@/request/api";
import IconFont from "@/components/iconfont";
import { LegendList } from "@legendapp/list";
import { LinearGradient } from "expo-linear-gradient";
import { ChevronRight } from "lucide-react-native";
import { FinanceType } from "@/types";
import { getCurrentDate } from "@/utils/time";
import DateTime from "@/components/DateTime";
import { useLoading } from "@/hooks/useLoading";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { toast } from "sonner-native";
import { SearchIcon, ChevronLeft, ChevronRight } from "lucide-react-native";
export default function Add() {
const { getIncomeCategories, getExpenseCategories } = useFinanceStore((state) => state);
// ─── 简易日历 ─────────────────────────────
function MiniCalendar({
value,
onChange,
}: {
value: string; // YYYY-MM-DD
onChange: (d: string) => void;
}) {
const today = new Date();
const [viewYear, setViewYear] = useState(today.getFullYear());
const [viewMonth, setViewMonth] = useState(today.getMonth() + 1);
const [activeTab, setActiveTab] = useState("0");
const [categories, setCategories] = useState<any[]>([]);
const [showMoreCategories, setShowMoreCategories] = useState(false);
const selected = useMemo(() => {
const [y, m, d] = value.split("-").map(Number);
return { y, m, d };
}, [value]);
const listRef = useRef<any>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const days = useMemo(() => {
const firstDay = new Date(viewYear, viewMonth - 1, 1).getDay();
const daysInMonth = new Date(viewYear, viewMonth, 0).getDate();
const result: (number | null)[] = [];
for (let i = 0; i < firstDay; i++) result.push(null);
for (let d = 1; d <= daysInMonth; d++) result.push(d);
return result;
}, [viewYear, viewMonth]);
const [showRecordTime, setShowRecordTime] = useState(false);
const prevMonth = () => {
if (viewMonth === 1) { setViewYear(viewYear - 1); setViewMonth(12); }
else setViewMonth(viewMonth - 1);
};
const nextMonth = () => {
if (viewMonth === 12) { setViewYear(viewYear + 1); setViewMonth(1); }
else setViewMonth(viewMonth + 1);
};
const [form, setForm] = useState<any>({
id: 0,
amount: "",
record_time: getCurrentDate(),
});
useEffect(() => {
const newCategories = activeTab === "0" ? getExpenseCategories() : getIncomeCategories();
setCategories(newCategories);
}, [activeTab]);
useEffect(() => {
if (!selectedCategoryId) {
listRef.current?.scrollToOffset?.({
offset: 0,
animated: false,
});
return;
}
const item = categories.find((c) => c.id === selectedCategoryId);
if (!item) {
listRef.current?.scrollToOffset?.({
offset: 0,
animated: false,
});
return;
}
requestAnimationFrame(() => {
listRef.current?.scrollToItem?.({
item,
animated: true,
viewPosition: 0.5,
});
});
}, [selectedCategoryId, categories]);
const pad = (n: number) => String(n).padStart(2, "0");
return (
<PageLayout title="新增记账">
<View className="bg-gray-50 rounded-xl p-3">
{/* 月份切换 */}
<View className="flex-row items-center justify-between mb-2">
<PressableFeedback onPress={prevMonth} className="p-1">
<ChevronLeft size={18} color="#6B7280" />
<PressableFeedback.Ripple />
</PressableFeedback>
<Text className="text-sm font-semibold text-gray-700">
{viewYear} 年 {viewMonth} 月
</Text>
<PressableFeedback onPress={nextMonth} className="p-1">
<ChevronRight size={18} color="#6B7280" />
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
{/* 星期头 */}
<View className="flex-row mb-1">
{["日", "一", "二", "三", "四", "五", "六"].map((w) => (
<View key={w} className="flex-1 items-center py-1">
<Text className="text-[11px] text-gray-400">{w}</Text>
</View>
))}
</View>
{/* 日期网格 */}
<View className="flex-row flex-wrap">
{days.map((d, i) => {
const isToday = d === today.getDate() && viewMonth === today.getMonth() + 1 && viewYear === today.getFullYear();
const isSel = d === selected.d && viewMonth === selected.m && viewYear === selected.y;
return (
<View key={i} className="w-[14.28%] items-center py-0.5">
{d ? (
<PressableFeedback
onPress={() => onChange(`${viewYear}-${pad(viewMonth)}-${pad(d)}`)}
>
<View
className={`w-8 h-8 rounded-full items-center justify-center ${
isSel ? "bg-accent" : isToday ? "bg-accent/10" : ""
}`}
>
<Text
className={`text-xs ${
isSel ? "text-white font-semibold" : isToday ? "text-accent font-semibold" : "text-gray-700"
}`}
>
{d}
</Text>
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
) : (
<View className="w-8 h-8" />
)}
</View>
);
})}
</View>
</View>
);
}
// ─── 页面 ─────────────────────────────────
export default function Add() {
const { getExpenseCategories, getIncomeCategories } = useFinanceStore();
const userId = useUserStore((s) => s.userInfo.id);
const familyId = useFamilyStore((s) => s.currentFamily.family_id);
const { isLoading, startLoading, stopLoading } = useLoading();
const [activeTab, setActiveTab] = useState("0"); // 0=支出 1=收入
const [keyword, setKeyword] = useState("");
const [selectedCategory, setSelectedCategory] = useState<any>(null);
const [amount, setAmount] = useState("");
const [date, setDate] = useState(getCurrentDate());
const [remark, setRemark] = useState("");
const allCategories = useMemo(() => {
const cats = activeTab === "0" ? getExpenseCategories() : getIncomeCategories();
if (!keyword.trim()) return cats;
return cats.filter((c: any) => c.name.includes(keyword.trim()));
}, [activeTab, keyword, getExpenseCategories, getIncomeCategories]);
const resetForm = useCallback(() => {
setAmount("");
setDate(getCurrentDate());
setRemark("");
}, []);
const handleSave = useCallback(
async (keepOpen: boolean) => {
if (!amount || !selectedCategory) {
toast.error("请选择分类并填写金额");
return;
}
if (isLoading) return;
startLoading("保存中...");
try {
const idRes = await getUniqueIdApi(1);
const newId = idRes.data?.[0];
if (!newId) { toast.error("获取ID失败"); stopLoading(); return; }
await saveDataApi([{
table_name: "b_finance",
key_field: "id",
inserts: [{
id: newId,
category_id: selectedCategory.id,
family_id: familyId,
amount: Number(amount),
remark,
record_time: date,
}],
updates: [],
deletes: [],
}]);
const label = activeTab === "0" ? "支出" : "收入";
toast.success(`已记${label} ¥${amount}`);
if (keepOpen) {
setAmount("");
setRemark("");
} else {
resetForm();
setSelectedCategory(null);
}
} catch (e) {
toast.error("保存失败");
} finally {
stopLoading();
}
},
[amount, selectedCategory, activeTab, resetForm, isLoading, startLoading, stopLoading, familyId, date, remark]
);
return (
<PageLayout title="记一笔">
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView>
<LayoutView.ScrollView bottomOffset={50}>
<View className="flex-1">
<Tabs value={activeTab} onValueChange={setActiveTab} variant="primary">
<Tabs.List>
<Tabs.Indicator />
<Tabs.Trigger value="0" className="flex-1">
<Tabs.Label>支出</Tabs.Label>
</Tabs.Trigger>
<Tabs.Trigger value="1" className="flex-1">
<Tabs.Label>收入</Tabs.Label>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
<View className="my-4 gap-4">
{/* <View className="w-full items-center justify-center py-3">
<Input className="w-full" inputMode="decimal" />
</View> */}
<TextField isRequired>
<View className="flex-row items-center justify-between">
<Label>类别</Label>
<TouchableOpacity
className="flex-row items-center"
onPress={() => {
setShowMoreCategories(true);
}}
{/* 支出/收入 Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} variant="primary">
<Tabs.List>
<Tabs.Indicator />
<Tabs.Trigger value="0" className="flex-1">
<Tabs.Label>支出</Tabs.Label>
</Tabs.Trigger>
<Tabs.Trigger value="1" className="flex-1">
<Tabs.Label>收入</Tabs.Label>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
{/* 搜索 */}
<View className="mt-3 mb-2 flex-row items-center bg-gray-100 rounded-full px-3 h-9">
<SearchIcon size={14} color="#9CA3AF" />
<TextInput
className="flex-1 ml-1.5 text-sm"
placeholder="搜索分类..."
value={keyword}
onChangeText={setKeyword}
/>
</View>
{/* 分类网格 */}
<View className="flex-row flex-wrap">
{allCategories.map((item: any) => {
const isActive = selectedCategory?.id === item.id;
return (
<TouchableOpacity
key={item.id}
onPress={() => setSelectedCategory(item)}
className="w-1/4 items-center mb-4"
>
<View
className={`w-14 h-14 rounded-2xl items-center justify-center ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
<Text className="text-sm text-gray-400">更多</Text>
<ChevronRight size={16} color="#9CA3AF" />
</TouchableOpacity>
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
</View>
<Text
className={`text-[11px] mt-1.5 text-center ${isActive ? "font-semibold text-gray-900" : "text-gray-500"}`}
numberOfLines={1}
>
{item.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</LayoutView.ScrollView>
</LayoutView>
{/* 记账 BottomSheet */}
<BottomSheet isOpen={!!selectedCategory} onClose={() => setSelectedCategory(null)}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="px-4 pb-6 gap-4">
{/* 选中的分类 */}
{selectedCategory && (
<View className="flex-row items-center gap-3 py-2">
<View className="w-10 h-10 rounded-xl bg-accent/10 items-center justify-center">
<IconFont name={selectedCategory.icon || "IconFinanceZhichu"} size={22} />
</View>
<Text className="text-base font-semibold text-gray-900">
{selectedCategory.name}
</Text>
</View>
<ScrollShadow LinearGradientComponent={LinearGradient}>
<LegendList
ref={listRef}
horizontal
data={categories}
keyExtractor={(item) => item.id.toString()}
extraData={selectedCategoryId}
renderItem={({ item }) => {
const isActive = selectedCategoryId === item.id;
)}
return (
<TouchableOpacity
onPress={() => {
setSelectedCategoryId(item.id);
}}
className="items-center justify-center mr-3"
>
<View
className={`w-12 aspect-square rounded-full items-center justify-center ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
</View>
<Text
className={`text-[10px] mt-1 text-center ${
isActive ? "font-semibold text-gray-900" : "text-gray-500"
}`}
numberOfLines={1}
>
{item.name}
</Text>
</TouchableOpacity>
);
}}
showsHorizontalScrollIndicator={false}
className="flex-1"
/>
</ScrollShadow>
</TextField>
{/* 金额 */}
<TextField isRequired>
<Label>金额</Label>
<Input
placeholder="0.00"
inputMode="decimal"
value={form.amount}
onChangeText={(value) => {
setForm((prev: any) => ({
...prev,
amount: value,
}));
}}
value={amount}
onChangeText={setAmount}
className="text-2xl font-bold h-14"
/>
</TextField>
{/* 日期 — 日历 */}
<TextField isRequired>
<Label>日期</Label>
<TouchableOpacity
onPress={() => {
console.log("open date picker");
setShowRecordTime(true);
}}
>
<View pointerEvents="none">
<Input placeholder="请选择日期" value={form.record_time} />
</View>
</TouchableOpacity>
</TextField>
<MiniCalendar value={date} onChange={setDate} />
{/* 备注 */}
<TextField>
<Label>备注</Label>
<Input placeholder="请输入备注" />
<Input placeholder="写点什么..." value={remark} onChangeText={setRemark} />
</TextField>
</View>
<Button>
<Button.Label>保存</Button.Label>
</Button>
</View>
</LayoutView.ScrollView>
</LayoutView>
<DateTime
show={showRecordTime}
value={form.record_time}
onConfirm={(value) => {
setForm((prev: any) => ({
...prev,
record_time: value,
}));
setShowRecordTime(false);
}}
onClose={() => {
setShowRecordTime(false);
}}
/>
<BottomSheet isOpen={showMoreCategories} onOpenChange={setShowMoreCategories}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="flex-row flex-wrap">
{categories.map((item) => {
const isActive = selectedCategoryId === item.id;
return (
<TouchableOpacity
key={item.id}
onPress={() => {
setSelectedCategoryId(item.id);
setShowMoreCategories(false);
}}
className="items-center justify-center w-1/4 mb-4"
>
<View
className={`w-12 aspect-square rounded-full items-center justify-center ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
</View>
<Text
className={`text-[10px] mt-1 text-center ${
isActive ? "font-semibold text-gray-900" : "text-gray-500"
}`}
numberOfLines={1}
>
{item.name}
</Text>
</TouchableOpacity>
);
})}
{/* 按钮 */}
<View className="flex-row gap-3 mt-2">
<Button
onPress={() => handleSave(false)}
className="flex-1"
>
<Button.Label>保存</Button.Label>
</Button>
<Button
onPress={() => handleSave(true)}
className="flex-1"
variant="secondary"
>
<Button.Label>保存并再记</Button.Label>
</Button>
</View>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<LoadingOverlay visible={isLoading} />
</PageLayout>
);
}
@@ -69,7 +69,7 @@ export default function DateTimeRange({
<BottomSheet isOpen={open} onOpenChange={setOpen}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<BottomSheet.Content backgroundClassName="bg-white">
<View style={{ width: "100%", alignItems: "center" }}>
<DateTimePicker
mode="range"
@@ -1,23 +1,16 @@
import { View, Text } from "react-native";
import { Spinner } from "heroui-native";
import { View } from "react-native";
interface LoadingOverlayProps {
visible: boolean;
text?: string;
}
export default function LoadingOverlay({ visible, text = "加载中..." }: LoadingOverlayProps) {
export function LoadingOverlay({ visible }: LoadingOverlayProps) {
if (!visible) return null;
return (
<View
className="absolute inset-0 z-50 items-center justify-center bg-black/30"
className="absolute top-0 left-0 right-0 bottom-0 z-50 bg-black/5"
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>
/>
);
}
+75 -110
View File
@@ -1,8 +1,23 @@
import { View, StyleProp, ViewStyle, Pressable, StyleSheet } from "react-native";
import { View, Text, StyleProp, ViewStyle, Pressable, StyleSheet } from "react-native";
import { LucideIcon } from "lucide-react-native";
import { cn } from "@/utils/cn";
import Animated, { useSharedValue, useAnimatedStyle, withTiming } from "react-native-reanimated";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from "react-native-reanimated";
import { useEffect } from "react";
// ── 视觉常量 ──────────────────────────────────────
const ICON_SIZE = 20;
const LABEL_SIZE = 11;
const BAR_HEIGHT = 56;
const BAR_RADIUS = 16;
const COLOR_ACTIVE = "#10B981";
const COLOR_IDLE = "#9CA3AF";
const BAR_BG = "#FFFFFF";
// ── 类型 ──────────────────────────────────────────
export interface TabbarItem {
id: string;
icon: LucideIcon;
@@ -14,54 +29,46 @@ export interface TabbarProps {
tabs: TabbarItem[];
value?: string;
onChange?: (id: string) => void;
className?: string;
style?: StyleProp<ViewStyle>;
}
// ── 主组件 ────────────────────────────────────────
export default function Tabbar({ tabs, value, onChange, className, style }: TabbarProps) {
const activeTab = value;
const handlePress = (id: string) => {
onChange?.(id);
};
return (
<View className={cn("m-3", className)} style={style}>
<View className="shadow-2xl" style={styles.tabbarShadow}>
<View className="w-full flex-row items-center justify-between">
{tabs.map((tab) =>
tab.isCenter ? (
<CenterButton key={tab.id} tab={tab} onPress={() => handlePress(tab.id)} />
) : (
<TabItem
key={tab.id}
tab={tab}
isActive={activeTab === tab.id}
onPress={() => handlePress(tab.id)}
/>
),
)}
</View>
<View className={cn("px-4", className)} style={style}>
<View className="shadow-2xl" style={s.bar}>
{tabs.map((tab) => (
<TabItem
key={tab.id}
tab={tab}
active={value === tab.id}
onPress={() => onChange?.(tab.id)}
/>
))}
</View>
</View>
);
}
// ── 普通 Tab ──────────────────────────────────────
function TabItem({
tab,
isActive,
active,
onPress,
}: {
tab: TabbarItem;
isActive: boolean;
active: boolean;
onPress: () => void;
}) {
const Icon = tab.icon;
const scale = useSharedValue(1);
const scaleStyle = useAnimatedStyle(() => ({
useEffect(() => {
scale.value = withTiming(1, { duration: 150 });
}, [active]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
@@ -69,104 +76,62 @@ function TabItem({
<Pressable
onPress={onPress}
onPressIn={() => {
scale.value = withTiming(0.9, { duration: 80 });
scale.value = withTiming(0.92, { duration: 80 });
}}
onPressOut={() => {
scale.value = withTiming(1, { duration: 120 });
scale.value = withTiming(1, { duration: 150 });
}}
className="flex-1"
style={s.item}
>
<Animated.View style={scaleStyle} className="items-center justify-center">
<Icon size={24} color={isActive ? "#5FAF7B" : "#666"} />
{tab.title && (
<Animated.Text
<Animated.View style={[s.itemInner, animatedStyle]}>
<Icon
size={ICON_SIZE}
color={active ? COLOR_ACTIVE : COLOR_IDLE}
strokeWidth={active ? 2.2 : 1.8}
/>
{tab.title ? (
<Text
style={[
s.label,
{
fontSize: 10,
marginTop: 4,
fontWeight: isActive ? "600" : "400",
color: isActive ? "#5FAF7B" : "#666",
color: active ? COLOR_ACTIVE : COLOR_IDLE,
fontWeight: active ? "600" : "400",
},
]}
>
{tab.title}
</Animated.Text>
)}
</Text>
) : null}
</Animated.View>
</Pressable>
);
}
function CenterButton({ tab, onPress }: { tab: TabbarItem; onPress?: () => void }) {
const Icon = tab.icon;
const scale = useSharedValue(1);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
return (
<View className="flex-1 items-center justify-center">
<Pressable
className="absolute"
onPress={onPress}
onPressIn={() => {
scale.value = withTiming(0.9, { duration: 80 });
}}
onPressOut={() => {
scale.value = withTiming(1, { duration: 120 });
}}
>
<Animated.View style={animatedStyle}>
<View
style={{
width: 64,
height: 64,
borderRadius: 999,
backgroundColor: "#22C55E",
alignItems: "center",
justifyContent: "center",
borderWidth: 8,
borderColor: "white",
top: -4,
}}
>
{/* <Shadow
distance={6}
startColor="rgba(0,0,0,0.03)"
endColor="rgba(0,0,0,0)"
offset={[0, 2]}
style={[styles.centerButtonShadown]}
> */}
<Icon size={30} color="#fff" />
{/* </Shadow> */}
</View>
</Animated.View>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
centerButtonShadown: {
width: 44,
height: 44,
borderRadius: 999,
alignItems: "center",
justifyContent: "center",
borderWidth: 0.5,
borderColor: "rgba(255,255,255,0.6)",
backgroundColor: "#22C55E",
},
tabbarShadow: {
borderRadius: 999,
// ── 样式 ──────────────────────────────────────────
const s = StyleSheet.create({
// === Bar ===
bar: {
height: BAR_HEIGHT,
borderRadius: BAR_RADIUS,
backgroundColor: BAR_BG,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 4,
},
// === Tab Item ===
item: {
flex: 1,
height: "100%",
},
itemInner: {
flex: 1,
alignItems: "center",
justifyContent: "center",
borderWidth: 0.5,
borderColor: "rgba(255,255,255,0.6)",
height: 56,
backgroundColor: "#fff",
gap: 1,
},
label: {
fontSize: LABEL_SIZE,
marginTop: 2,
},
});
-99
View File
@@ -1,99 +0,0 @@
import { loadDataApi, loadDataPageApi, saveDataApi, getUniqueIdApi } from "@/request/api";
export type FeedType = "family" | "finance" | "favorite";
export interface FeedRecord {
id: number;
family_id: number;
user_id: number;
type: FeedType;
content: string;
meta: string | null;
create_time: string;
}
/** 写入一条动态 */
export async function recordFeed(
familyId: number,
userId: number,
type: FeedType,
content: string,
) {
const idRes = await getUniqueIdApi(1);
const feedId = idRes.data?.[0];
if (!feedId) return;
await saveDataApi([
{
table_name: "b_family_feed",
key_field: "id",
inserts: [
{
id: feedId,
family_id: familyId,
user_id: userId,
type,
content,
},
],
updates: [],
deletes: [],
},
]);
}
/** 加载家庭动态列表 */
export async function loadFeeds(familyId: number): Promise<FeedRecord[]> {
const res = await loadDataApi({
view_name: "b_family_feed",
search_condition: "family_id = ?",
order_by: "create_time DESC",
search_columns: ["id", "family_id", "user_id", "type", "content", "meta", "create_time"],
args: [familyId],
});
return (res.data || []) as FeedRecord[];
}
/** 分页加载家庭动态列表 */
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);
}
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 };
}
+61
View File
@@ -0,0 +1,61 @@
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner-native";
interface StopOptions {
type?: "success" | "error" | "none";
message?: string;
}
export function useLoading() {
const [isLoading, setIsLoading] = useState(false);
const toastIdRef = useRef<string | number | null>(null);
const startLoading = useCallback((message: string = "加载中...") => {
setIsLoading(true);
const id = toast.loading(message);
toastIdRef.current = id;
return id;
}, []);
const stopLoading = useCallback((options?: StopOptions) => {
setIsLoading(false);
const id = toastIdRef.current;
if (id) {
if (options?.type === "success") {
toast.success(options.message || "操作成功", { id });
} else if (options?.type === "error") {
toast.error(options.message || "操作失败", { id });
} else {
toast.dismiss(id);
}
toastIdRef.current = null;
}
}, []);
const withLoading = useCallback(
async <T,>(
promise: Promise<T>,
loadingMessage: string = "加载中...",
successMessage?: string,
errorMessage?: string
): Promise<T> => {
const id = startLoading(loadingMessage);
try {
const result = await promise;
stopLoading({ type: "success", message: successMessage || "操作成功" });
return result;
} catch (error) {
stopLoading({ type: "error", message: errorMessage || "操作失败" });
throw error;
}
},
[startLoading, stopLoading]
);
return {
isLoading,
startLoading,
stopLoading,
withLoading,
};
}
+15
View File
@@ -0,0 +1,15 @@
/** 聊天消息类型 */
export enum ChatMsgType {
/** 文本 */
Text = 0,
/** 图片 */
Image = 1,
/** 视频 */
Video = 2,
/** 文件 */
File = 3,
/** 转发(模块内容分享) */
Forward = 4,
/** 系统消息(加入家庭、改名、记账、收藏等) */
System = 5,
}
+9
View File
@@ -0,0 +1,9 @@
/** 附件文件类型 */
export enum FileType {
/** 图片 */
Image = 1,
/** 视频 */
Video = 2,
/** 文件 */
File = 3,
}
+7
View File
@@ -0,0 +1,7 @@
/** 记账类型 */
export enum FinanceType {
/** 支出 */
Expense = 0,
/** 收入 */
Income = 1,
}
+3
View File
@@ -0,0 +1,3 @@
export { ChatMsgType } from "./chat";
export { FileType } from "./file";
export { FinanceType } from "./finance";