20260625173106
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/** 聊天消息类型 */
|
||||
export enum ChatMsgType {
|
||||
/** 文本 */
|
||||
Text = 0,
|
||||
/** 图片 */
|
||||
Image = 1,
|
||||
/** 视频 */
|
||||
Video = 2,
|
||||
/** 文件 */
|
||||
File = 3,
|
||||
/** 转发(模块内容分享) */
|
||||
Forward = 4,
|
||||
/** 系统消息(加入家庭、改名、记账、收藏等) */
|
||||
System = 5,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** 附件文件类型 */
|
||||
export enum FileType {
|
||||
/** 图片 */
|
||||
Image = 1,
|
||||
/** 视频 */
|
||||
Video = 2,
|
||||
/** 文件 */
|
||||
File = 3,
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/** 记账类型 */
|
||||
export enum FinanceType {
|
||||
/** 支出 */
|
||||
Expense = 0,
|
||||
/** 收入 */
|
||||
Income = 1,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { ChatMsgType } from "./chat";
|
||||
export { FileType } from "./file";
|
||||
export { FinanceType } from "./finance";
|
||||
Reference in new issue
Block a user