diff --git a/code/app/app-go/db/finance_auto.sql b/code/app/app-go/db/finance_auto.sql index d02806c8..2d4d1b44 100644 --- a/code/app/app-go/db/finance_auto.sql +++ b/code/app/app-go/db/finance_auto.sql @@ -6,6 +6,7 @@ create table b_finance_auto ( type smallint not null, category_id bigint not null, amount numeric(12,2) not null, + title varchar(64) default '', remark varchar(255) default '', repeat_type smallint not null, repeat_date varchar(64) default '', diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx index b0cf552e..49dd0e1c 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState, memo, useMemo } from "react"; -import { Image, Text, TextInput, View, Keyboard } from "react-native"; +import { Image, Text, TextInput, View } from "react-native"; import { KeyboardAvoidingView } from "react-native-keyboard-controller"; import { useRouter } from "expo-router"; import { Menu, PressableFeedback, Spinner } from "heroui-native"; @@ -324,7 +324,6 @@ export default function ChatPage() { const [inputText, setInputText] = useState(""); const [adminRole, setAdminRole] = useState(0); const listRef = useRef(null); - const nearBottomRef = useRef(true); // ---- optimistic send ---- type PendingMsg = { @@ -438,37 +437,8 @@ export default function ChatPage() { return merged; }, [history, ws.messages, pending, familyId, userId]); - // Non-inverted: scrollToEnd = latest messages - const scrollToBottom = useCallback((animated = true) => { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - listRef.current?.scrollToEnd?.({ animated }); - }); - }); - }, []); - - // 延迟多次滚底,确保 FlatList 布局稳定后能滚到位 - const scrollToBottomMulti = useCallback((animated = true, times = 3, delay = 80) => { - for (let i = 0; i < times; i++) { - setTimeout(() => scrollToBottom(animated), i * delay); - } - }, [scrollToBottom]); - - // Keyboard appears → 等键盘动画完成再滚到底 - useEffect(() => { - const sub = Keyboard.addListener("keyboardDidShow", () => { - scrollToBottomMulti(true, 3, 120); - }); - return () => sub.remove(); - }, [scrollToBottomMulti]); - - // 首次加载完成后滚到底部 - const initialScrollDone = useRef(false); - useEffect(() => { - if (loading || initialScrollDone.current || allMessages.length === 0) return; - initialScrollDone.current = true; - scrollToBottomMulti(false, 4, 100); - }, [loading, allMessages.length, scrollToBottomMulti]); + // 倒序列表:最新消息在 index 0(inverted FlatList 底部) + const invertedMessages = useMemo(() => [...allMessages].reverse(), [allMessages]); // 成员映射 useEffect(() => { @@ -556,7 +526,6 @@ export default function ChatPage() { const text = inputText.trim(); if (!text) return; setInputText(""); - Keyboard.dismiss(); const localId = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; const now = new Date().toISOString(); @@ -592,20 +561,6 @@ export default function ChatPage() { }, 10_000); } - // 数据加载完成后,标记 listReady=true,让 FlatList 开始渲染 - // Auto-scroll when a new message arrives (WebSocket push) - const msgCountRef = useRef(0); - useEffect(() => { - if (msgCountRef.current === 0 && allMessages.length > 0) { - msgCountRef.current = allMessages.length; - return; - } - if (allMessages.length > msgCountRef.current) { - scrollToBottomMulti(true, 3, 80); - } - msgCountRef.current = allMessages.length; - }, [allMessages.length, scrollToBottomMulti]); - if (loading) { return ( @@ -635,7 +590,6 @@ export default function ChatPage() { placeholderTextColor="#86A19C" value={inputText} onChangeText={setInputText} - onFocus={() => scrollToBottomMulti(true, 3, 100)} onSubmitEditing={handleSend} returnKeyType="send" multiline @@ -656,31 +610,30 @@ export default function ChatPage() { } - data={allMessages} - keyExtractor={(item) => String(item.id)} - contentContainerStyle={{ paddingBottom: 40 }} - onScroll={({ nativeEvent }) => { - const { contentOffset, contentSize, layoutMeasurement } = nativeEvent; - nearBottomRef.current = contentSize.height - contentOffset.y - layoutMeasurement.height < 100; - if (contentOffset.y < 30 && hasMore && !loadingMore) { - handleLoadMore(); - } + data={invertedMessages} + inverted + keyExtractor={(item) => { + const p = (item as any)._pending; + return p ? p.localId : String(item.id); }} - scrollEventThrottle={200} - onContentSizeChange={() => { - if (!loadingMore && nearBottomRef.current) { - scrollToBottomMulti(false, 2, 50); - } + contentContainerStyle={{ paddingTop: 40 }} + onEndReached={() => { + if (hasMore && !loadingMore) handleLoadMore(); }} - ListHeaderComponent={ - loadingMore ? ( - - + onEndReachedThreshold={0.3} + ListFooterComponent={ + hasMore ? ( + + {loadingMore ? ( + + ) : ( + 上拉加载更多 + )} ) : null } ListEmptyComponent={ - + 暂无消息,开始聊天吧 } diff --git a/code/app/app-rn/src/app/finance/(tabs)/_layout.tsx b/code/app/app-rn/src/app/finance/(tabs)/_layout.tsx index 23bdbbb9..128f5ab1 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/_layout.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/_layout.tsx @@ -4,7 +4,7 @@ import ModuleLayout from "@/layouts/ModuleLayout"; import { TabbarItem } from "@/components/layout/tabbar"; import { useFinanceStore } from "@/store/finance"; import { useFamilyStore } from "@/store/family"; -import { ChartNoAxesColumnIcon, CircleDollarSignIcon, PlusIcon } from "lucide-react-native"; +import { ChartNoAxesColumnIcon, CircleDollarSignIcon, RepeatIcon } from "lucide-react-native"; const tabs: TabbarItem[] = [ { @@ -13,10 +13,9 @@ const tabs: TabbarItem[] = [ title: "记账", }, { - id: "/finance/add", - icon: PlusIcon, - title: "添加", - isCenter: true, + id: "/finance/auto-rules", + icon: RepeatIcon, + title: "自动", }, { id: "/finance/analysis", diff --git a/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx new file mode 100644 index 00000000..c1333771 --- /dev/null +++ b/code/app/app-rn/src/app/finance/(tabs)/auto-rules/index.tsx @@ -0,0 +1,544 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Alert, ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { Button, Label, TextField } from "heroui-native"; +import BottomSheet from "@/components/BottomSheet"; +import { CheckIcon, PlusIcon, PencilIcon, Trash2Icon } from "lucide-react-native"; +import LayoutView from "@/components/layout/view"; +import EmptyState from "@/components/EmptyStatus"; +import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; +import { useFamilyStore, useFinanceStore, useUserStore } from "@/store"; +import { FinanceAuto, FinanceAutoRepeatType, FinanceCategory, FinanceType } from "@/types/finance"; +import { REPEAT_LABELS, WEEK_DAYS } from "@/helpers/financeAuto"; +import { useListLoading, useLoading } from "@/hooks/useLoading"; +import { LoadingOverlay } from "@/components/LoadingOverlay"; + +const moneyFormatter = new Intl.NumberFormat("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + +function formatMoney(value: number) { + return moneyFormatter.format(Math.abs(value)); +} + +/** 格式化 repeat_date 为人类可读文本 */ +function formatRepeatDate(repeatType: number, repeatDate: string): string { + const exclude = repeatDate.startsWith("!"); + const raw = exclude ? repeatDate.slice(1) : repeatDate; + + switch (repeatType) { + case FinanceAutoRepeatType.Daily: { + if (!exclude) return "每天"; + if (!raw) return "每天"; + const days = raw.split(",").map(Number); + const labels = days.map((d) => WEEK_DAYS.find((w) => w.value === d)?.label || String(d)); + return `排除 周${labels.join("、")}`; + } + case FinanceAutoRepeatType.Weekly: { + if (!raw) return exclude ? "未设置" : "未设置"; + const days = raw.split(",").map(Number); + const labels = days.map((d) => WEEK_DAYS.find((w) => w.value === d)?.label || String(d)); + return exclude ? `排除 周${labels.join("、")}` : `周${labels.join("、")}`; + } + case FinanceAutoRepeatType.Monthly: { + if (!raw) return "未设置"; + const dates = raw.split(",").map(Number).sort((a, b) => a - b); + return exclude ? `排除 ${dates.join("、")}号` : `${dates.join("、")}号`; + } + default: + return raw || "未设置"; + } +} + +interface AutoFormState { + repeatType: FinanceAutoRepeatType; + repeatDate: string; + excludeMode: boolean; + title: string; + amount: string; + remark: string; + categoryId: number | null; +} + +function createEmptyForm(): AutoFormState { + return { + repeatType: FinanceAutoRepeatType.Daily, + repeatDate: "", + excludeMode: false, + title: "", + amount: "", + remark: "", + categoryId: null, + }; +} + +type FilterTab = "all" | "daily" | "weekly" | "monthly"; +const FILTER_TABS: { key: FilterTab; label: string }[] = [ + { key: "all", label: "全部" }, + { key: "daily", label: "每天" }, + { key: "weekly", label: "每周" }, + { key: "monthly", label: "每月" }, +]; + +export default function AutoRulesPage() { + const familyId = useFamilyStore((s) => s.getFamilyId()); + const userId = useUserStore((s) => s.getUserId()); + const categories = useFinanceStore((s) => s.categories); + const autoRules = useFinanceStore((s) => s.autoRules); + const fetchAutoRules = useFinanceStore((s) => s.fetchAutoRules); + const saveAutoRule = useFinanceStore((s) => s.saveAutoRule); + const loadCategories = useFinanceStore((s) => s.loadCategories); + const { loading, refreshing, runInitial, runRefresh } = useListLoading(); + const { isLoading, startLoading, stopLoading } = useLoading(); + + const [filterTab, setFilterTab] = useState("all"); + const [showEditor, setShowEditor] = useState(false); + const [editForm, setEditForm] = useState(createEmptyForm()); + const [editingRuleId, setEditingRuleId] = useState(null); + const [showCategoryPicker, setShowCategoryPicker] = useState(false); + + const loadData = useCallback(() => { + if (!familyId) return; + if (categories.length === 0) loadCategories(familyId); + fetchAutoRules(familyId); + }, [familyId, categories.length, fetchAutoRules, loadCategories]); + + useEffect(() => { + if (!familyId) return; + runInitial(async () => { loadData(); }); + }, [familyId]); + + const handleRefresh = useCallback(() => { + runRefresh(async () => { loadData(); }); + }, [loadData, runRefresh]); + + // 筛选后的规则 + const filteredRules = useMemo(() => { + if (filterTab === "all") return autoRules; + const typeMap: Record, FinanceAutoRepeatType> = { + daily: FinanceAutoRepeatType.Daily, + weekly: FinanceAutoRepeatType.Weekly, + monthly: FinanceAutoRepeatType.Monthly, + }; + return autoRules.filter((r) => r.repeat_type === typeMap[filterTab as Exclude]); + }, [autoRules, filterTab]); + + // ===== 编辑/新建 ===== + + const openNewEditor = useCallback(() => { + setEditingRuleId(null); + setEditForm(createEmptyForm()); + setShowEditor(true); + }, []); + + const openEditEditor = useCallback((rule: FinanceAuto) => { + const rd = rule.repeat_date ?? ""; + const exclude = rd.startsWith("!"); + setEditingRuleId(rule.id); + setEditForm({ + repeatType: rule.repeat_type, + repeatDate: exclude ? rd.slice(1) : rd, + excludeMode: exclude, + title: rule.title ?? "", + amount: String(Number(rule.amount) || ""), + remark: rule.remark ?? "", + categoryId: rule.category_id, + }); + setShowEditor(true); + }, []); + + const handleSave = useCallback(async () => { + if (!familyId || !userId) return; + if (!editForm.title.trim()) { + Alert.alert("提示", "请输入规则名称"); + return; + } + const amount = parseFloat(editForm.amount); + if (isNaN(amount) || amount <= 0) { + Alert.alert("提示", "请输入有效金额"); + return; + } + if (!editingRuleId && !editForm.categoryId) { + Alert.alert("提示", "请选择分类"); + return; + } + + startLoading("保存中..."); + try { + const finalRepeatDate = editForm.excludeMode && editForm.repeatDate + ? `!${editForm.repeatDate}` + : editForm.repeatDate; + + const base: Partial = { + id: editingRuleId ?? undefined, + family_id: familyId, + title: editForm.title, + amount, + remark: editForm.remark, + repeat_type: editForm.repeatType, + repeat_date: finalRepeatDate, + create_by: userId, + update_by: userId, + }; + + if (!editingRuleId) { + const cat = categories.find((c) => c.id === editForm.categoryId); + base.type = cat?.type ?? FinanceType.Expense; + base.category_id = editForm.categoryId!; + } + + const ok = await saveAutoRule(base); + if (ok) { + setShowEditor(false); + await fetchAutoRules(familyId); + stopLoading({ type: "success", message: editingRuleId ? "规则已更新" : "规则已创建" }); + } else { + stopLoading({ type: "error", message: "保存失败" }); + } + } catch { stopLoading({ type: "error", message: "保存失败" }); } + }, [familyId, userId, editForm, editingRuleId, categories, saveAutoRule, startLoading, stopLoading]); + + const handleDelete = useCallback((rule: FinanceAuto) => { + Alert.alert("确认删除", `确定要删除「${formatRepeatDate(rule.repeat_type, rule.repeat_date ?? "")}」的规则吗?`, [ + { text: "取消", style: "cancel" }, + { + text: "删除", style: "destructive", + onPress: async () => { + startLoading("删除中..."); + const ok = await saveAutoRule(rule, true); + if (ok) await fetchAutoRules(familyId); + stopLoading(ok ? { type: "success", message: "已删除" } : { type: "error", message: "删除失败" }); + }, + }, + ]); + }, [saveAutoRule, startLoading, stopLoading]); + + const getCategory = useCallback((id: number): FinanceCategory | undefined => { + return categories.find((c) => c.id === id); + }, [categories]); + + const selectedCategory = useMemo(() => { + if (!editForm.categoryId) return null; + return categories.find((c) => c.id === editForm.categoryId) ?? null; + }, [editForm.categoryId, categories]); + + const renderHeader = useCallback(() => ( + + + 自动记账规则 + 管理周期性自动记账 + + + + {FILTER_TABS.map((tab) => { + const active = filterTab === tab.key; + return ( + setFilterTab(tab.key)} + > + {tab.label} + + ); + })} + + + ), [filterTab, openNewEditor]); + + const sections = useMemo(() => [{ title: "", expenseTotal: 0, incomeTotal: 0, data: filteredRules as any }], [filteredRules]); + + const renderItem = useCallback(({ item: rule }: { item: FinanceAuto }) => { + const cat = getCategory(rule.category_id); + const isIncome = rule.type === FinanceType.Income; + const displayTitle = rule.title?.trim() || cat?.name || "未命名"; + return ( + + + + + + {displayTitle} + + {REPEAT_LABELS[rule.repeat_type]} + + + + {isIncome ? "+" : "-"}{formatMoney(Number(rule.amount))} + + openEditEditor(rule)}> + + + handleDelete(rule)}> + + + + ); + }, [getCategory, openEditEditor, handleDelete]); + + return ( + + String((item as FinanceAuto).id)} + renderItem={renderItem as any} + renderSectionHeader={() => null} + header={renderHeader()} + stickySectionHeadersEnabled={false} + refreshing={refreshing} + onRefresh={handleRefresh} + contentContainerStyle={{ paddingHorizontal: 16, paddingTop: 12, paddingBottom: 120 }} + ListEmptyComponent={() => ( + t.key === filterTab)?.label}规则`)} + description={loading ? "请稍候" : "点击右下角 + 添加一条规则"} + center="screen" + /> + )} + /> + + {/* 悬浮新增按钮 */} + + + + + {/* ===== 编辑/新建 BottomSheet ===== */} + + + + + {editingRuleId ? "编辑规则" : "新增规则"} + + + + {/* 标题 */} + + + setEditForm((p) => ({ ...p, title: v }))} + placeholder="如:每月房租" + /> + + + {/* 分类(仅新建时可选) */} + {!editingRuleId && ( + + + setShowCategoryPicker(true)} + > + {selectedCategory ? ( + + + {selectedCategory.name} + + ) : ( + 请选择分类 + )} + + + )} + + {/* 金额 */} + + + setEditForm((p) => ({ ...p, amount: v.replace(/[^0-9.]/g, "") }))} + placeholder="0.00" + keyboardType="decimal-pad" + /> + + + {/* 备注 */} + + + setEditForm((p) => ({ ...p, remark: v }))} + placeholder="如:房租、工资" + /> + + + {/* 频率 */} + + + + {Object.entries(REPEAT_LABELS).map(([k, v]) => { + const sel = editForm.repeatType === Number(k); + return ( + setEditForm((p) => ({ ...p, repeatType: Number(k), repeatDate: "" }))} + > + {v} + + ); + })} + + + + {/* 排除/指定 */} + + + + setEditForm((p) => ({ ...p, excludeMode: false }))} + > + 指定 + + setEditForm((p) => ({ ...p, excludeMode: true }))} + > + 排除 + + + + + {/* 每天 */} + {editForm.repeatType === FinanceAutoRepeatType.Daily && ( + + + {editForm.excludeMode && ( + + {WEEK_DAYS.map((d) => { + const sel = (editForm.repeatDate || "").split(",").includes(String(d.value)); + return ( + { + const arr = (editForm.repeatDate || "").split(",").filter(Boolean); + const idx = arr.indexOf(String(d.value)); + if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d.value)); + setEditForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + }} + > + {d.label} + + ); + })} + + )} + + )} + + {/* 每周 */} + {editForm.repeatType === FinanceAutoRepeatType.Weekly && ( + + + + {WEEK_DAYS.map((d) => { + const sel = (editForm.repeatDate || "").split(",").includes(String(d.value)); + return ( + { + const arr = (editForm.repeatDate || "").split(",").filter(Boolean); + const idx = arr.indexOf(String(d.value)); + if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d.value)); + setEditForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + }} + > + {d.label} + + ); + })} + + + )} + + {/* 每月 */} + {editForm.repeatType === FinanceAutoRepeatType.Monthly && ( + + + + {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => { + const sel = (editForm.repeatDate || "").split(",").includes(String(d)); + return ( + { + const arr = (editForm.repeatDate || "").split(",").filter(Boolean); + const idx = arr.indexOf(String(d)); + if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d)); + setEditForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + }} + > + {d} + + ); + })} + + + )} + + + + + + + + + + + {/* ===== 分类选择 ===== */} + + + + 选择分类 + + 支出 + + {categories.filter((c) => c.type === FinanceType.Expense).map((cat) => { + const sel = editForm.categoryId === cat.id; + return ( + { setEditForm((p) => ({ ...p, categoryId: cat.id })); setShowCategoryPicker(false); }} + > + + {cat.name} + + ); + })} + + 收入 + + {categories.filter((c) => c.type === FinanceType.Income).map((cat) => { + const sel = editForm.categoryId === cat.id; + return ( + { setEditForm((p) => ({ ...p, categoryId: cat.id })); setShowCategoryPicker(false); }} + > + + {cat.name} + + ); + })} + + + + + + + + + + ); +} diff --git a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx index 4caf4e20..9b84f72e 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Dimensions, Image, Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native"; +import { Alert, Dimensions, Image, Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native"; import { Button, Label, TextField } from "heroui-native"; import BottomSheet from "@/components/BottomSheet"; import { router, useFocusEffect } from "expo-router"; @@ -9,6 +9,7 @@ import { FileTextIcon, FilterIcon, PencilIcon, + PlusIcon, RepeatIcon, RotateCcwIcon, Trash2Icon, @@ -73,9 +74,13 @@ export default function Home() { const fetchAutoRules = useFinanceStore((s) => s.fetchAutoRules); const runAutoRules = useFinanceStore((s) => s.runAutoRules); const saveAutoRule = useFinanceStore((s) => s.saveAutoRule); + const isAutoBill = useFinanceStore((s) => s.isAutoBill); + const loadAutoBillIds = useFinanceStore((s) => s.loadAutoBillIds); const autoRunRef = useRef(false); const [showAutoSheet, setShowAutoSheet] = useState(false); - const [autoForm, setAutoForm] = useState({ repeatType: FinanceAutoRepeatType.Daily, repeatDate: "" }); + const [autoForm, setAutoForm] = useState({ repeatType: FinanceAutoRepeatType.Daily, repeatDate: "", title: "" }); + const [autoBaseBill, setAutoBaseBill] = useState(null); + const [autoExcludeMode, setAutoExcludeMode] = useState(false); const [filter, setFilter] = useState(() => createDefaultFilter()); const [draftFilter, setDraftFilter] = useState(() => createDefaultFilter()); @@ -203,9 +208,15 @@ export default function Home() { if (categories.length === 0) { loadCategories(familyId); } + loadAutoBillIds(); runInitial(() => loadBills(filter)); }, [familyId]); + // 关闭自动记账面板时清理基准账单 + useEffect(() => { + if (!showAutoSheet) setAutoBaseBill(null); + }, [showAutoSheet]); + useFocusEffect( useCallback(() => { if (!familyId) return; @@ -261,23 +272,33 @@ export default function Home() { const openAutoSheet = useCallback(() => { if (!activeBill) return; + setAutoBaseBill(activeBill); // 先保存账单引用,再关闭菜单 closeActionMenu(); - setAutoForm({ repeatType: FinanceAutoRepeatType.Daily, repeatDate: "" }); + setAutoForm({ repeatType: FinanceAutoRepeatType.Daily, repeatDate: "", title: activeBill?.category_name ?? "" }); + setAutoExcludeMode(false); setShowAutoSheet(true); }, [activeBill, closeActionMenu]); const handleSaveAuto = useCallback(async () => { - if (!familyId || !activeBill || !userId) return; + if (!familyId || !autoBaseBill || !userId) return; + if (!autoForm.title.trim()) { + Alert.alert("提示", "请输入规则名称"); + return; + } startLoading("保存中..."); try { + const finalRepeatDate = autoExcludeMode && autoForm.repeatDate + ? `!${autoForm.repeatDate}` + : autoForm.repeatDate; const ok = await saveAutoRule({ family_id: familyId, - type: activeBill.category_type, - category_id: activeBill.category_id, - amount: activeBill.amount, - remark: activeBill.remark ?? "", + type: autoBaseBill.category_type, + category_id: autoBaseBill.category_id, + amount: autoBaseBill.amount, + title: autoForm.title, + remark: autoBaseBill.remark ?? "", repeat_type: autoForm.repeatType, - repeat_date: autoForm.repeatDate, + repeat_date: finalRepeatDate, create_by: userId, }); if (ok) { @@ -287,7 +308,7 @@ export default function Home() { stopLoading({ type: "error", message: "保存失败" }); } } catch { stopLoading({ type: "error", message: "保存失败" }); } - }, [familyId, activeBill, userId, autoForm, saveAutoRule, startLoading, stopLoading]); + }, [familyId, autoBaseBill, userId, autoForm, autoExcludeMode, saveAutoRule, startLoading, stopLoading]); const handleDeleteBill = useCallback(async () => { if (!activeBill || isLoading) return; @@ -370,7 +391,7 @@ export default function Home() { 支出 - {formatMoney(totals.expense)} + -{formatMoney(totals.expense)} @@ -382,7 +403,7 @@ export default function Home() { 结余 = 0 ? "text-emerald-600" : "text-rose-500"}`}> - {formatMoney(totals.income - totals.expense)} + {totals.income - totals.expense >= 0 ? "+" : "-"}{formatMoney(totals.income - totals.expense)} @@ -452,9 +473,16 @@ export default function Home() { - - {item.category_name || "未分类"} - + + + {item.category_name || "未分类"} + + {isAutoBill(item.id) && ( + + 自动 + + )} + {!!remark && ( {remark} @@ -464,7 +492,7 @@ export default function Home() { {isIncome ? "+" : "-"}{formatMoney(Number(item.amount))} @@ -472,7 +500,7 @@ export default function Home() { ); }, - [userId], + [userId, isAutoBill], ); @@ -489,7 +517,7 @@ export default function Home() { refreshing={refreshing} onRefresh={handleRefresh} contentContainerStyle={[ - { paddingHorizontal: 16 }, + { paddingHorizontal: 16, paddingBottom: 120 }, sections.length === 0 ? { flexGrow: 1 } : undefined, ]} ListEmptyComponent={() => ( @@ -502,6 +530,15 @@ export default function Home() { )} /> + {/* 添加记账 FAB */} + router.push("/finance/add" as any)} + > + + + @@ -732,97 +769,177 @@ export default function Home() { {/* 自动记账 Sheet */} - - 自动记账 - - 基于「{activeBill?.category_name} {Number(activeBill?.amount || 0)}」创建规则 - + + 自动记账 - {/* 频率 */} - - - - {Object.entries(REPEAT_LABELS).map(([k, v]) => { - const sel = autoForm.repeatType === Number(k); - return ( - setAutoForm({ repeatType: Number(k), repeatDate: "" })} + + {/* 规则名称 */} + + + setAutoForm((p) => ({ ...p, title: v }))} + placeholder="如:每月房租" + /> + + + {/* 账单预览卡片 */} + {autoBaseBill && ( + + + + + + + {autoBaseBill.category_name || "未分类"} + + {!!autoBaseBill.remark?.trim() && ( + + {autoBaseBill.remark.trim()} + + )} + + + - {v} - - ); - })} - - + {autoBaseBill.category_type === FinanceType.Income ? "+" : "-"} + {formatMoney(Number(autoBaseBill.amount))} + + + + )} - {/* 每周:选周几 */} - {autoForm.repeatType === FinanceAutoRepeatType.Weekly && ( + {/* 频率 */} - - - {WEEK_DAYS.map((d) => { - const sel = (autoForm.repeatDate || "").split(",").includes(String(d.value)); + + + {Object.entries(REPEAT_LABELS).map(([k, v]) => { + const sel = autoForm.repeatType === Number(k); return ( - { - const arr = (autoForm.repeatDate || "").split(",").filter(Boolean); - const idx = arr.indexOf(String(d.value)); - if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d.value)); - setAutoForm((p) => ({ ...p, repeatDate: arr.sort().join(",") })); - }} + { setAutoForm({ repeatType: Number(k), repeatDate: "" }); setAutoExcludeMode(false); }} > - {d.label} + {v} ); })} - )} - {/* 每月/每季度:选日期 */} - {(autoForm.repeatType === FinanceAutoRepeatType.Monthly || autoForm.repeatType === FinanceAutoRepeatType.Quarterly) && ( + {/* 排除/指定模式切换 */} - - - {[1,5,10,15,20,25,28].map((d) => { - const sel = (autoForm.repeatDate || "").split(",").includes(String(d)); - return ( - setAutoForm((p) => ({ ...p, repeatDate: String(d) }))} - > - {d} - - ); - })} + + + setAutoExcludeMode(false)} + > + 指定 + + setAutoExcludeMode(true)} + > + 排除 + - )} - {/* 每年:选月日 */} - {autoForm.repeatType === FinanceAutoRepeatType.Yearly && ( - - - - {["1,1","3,1","6,1","9,1","12,1","1,15","3,15","6,15","9,15","12,15"].map((md) => { - const [m, d] = md.split(","); - const sel = autoForm.repeatDate === md; - return ( - setAutoForm((p) => ({ ...p, repeatDate: md }))} - > - {m}月{d}日 - - ); - })} + {/* 每天:排除星期几 */} + {autoForm.repeatType === FinanceAutoRepeatType.Daily && ( + + + {autoExcludeMode && ( + + {WEEK_DAYS.map((d) => { + const sel = (autoForm.repeatDate || "").split(",").includes(String(d.value)); + return ( + { + const arr = (autoForm.repeatDate || "").split(",").filter(Boolean); + const idx = arr.indexOf(String(d.value)); + if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d.value)); + setAutoForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + }} + > + {d.label} + + ); + })} + + )} - - )} + )} - + {/* 每周:选周几 */} + {autoForm.repeatType === FinanceAutoRepeatType.Weekly && ( + + + + {WEEK_DAYS.map((d) => { + const sel = (autoForm.repeatDate || "").split(",").includes(String(d.value)); + return ( + { + const arr = (autoForm.repeatDate || "").split(",").filter(Boolean); + const idx = arr.indexOf(String(d.value)); + if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d.value)); + setAutoForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + }} + > + {d.label} + + ); + })} + + + )} + + {/* 每月:选日期 */} + {autoForm.repeatType === FinanceAutoRepeatType.Monthly && ( + + + + {Array.from({ length: 31 }, (_, i) => i + 1).map((d) => { + const sel = (autoForm.repeatDate || "").split(",").includes(String(d)); + return ( + { + const arr = (autoForm.repeatDate || "").split(",").filter(Boolean); + const idx = arr.indexOf(String(d)); + if (idx >= 0) arr.splice(idx, 1); else arr.push(String(d)); + setAutoForm((p) => ({ ...p, repeatDate: arr.sort((a, b) => Number(a) - Number(b)).join(",") })); + }} + > + {d} + + ); + })} + + + )} + + + diff --git a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx index 5f496c4f..cf44e1e4 100644 --- a/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx +++ b/code/app/app-rn/src/app/meal/plan/detail/[id]/index.tsx @@ -18,6 +18,7 @@ import { getUniqueIdApi, loadDataApi, loadDataPageApi } from "@/request/api"; import { useFamilyStore, useMealStore, useUserStore } from "@/store"; import { MealPlanLogType, MealPlanStatus } from "@/types/meal"; import { buildFileUrl } from "@/utils/file"; +import { formatDisplayTime } from "@/utils/time"; // ============================================================ // 类型定义 @@ -45,19 +46,6 @@ interface TimelineEntry { // 时间格式化 // ============================================================ -function timeAgo(t: string) { - const d = dayjs(t); - const diff = dayjs().diff(d, "second"); - if (diff < 60) return "刚刚"; - if (diff < 3600) return `${Math.floor(diff / 60)}分钟前`; - if (diff < 86400) return `${Math.floor(diff / 3600)}小时前`; - return `${Math.floor(diff / 86400)}天前`; -} - -function formatTime(t: string) { - return dayjs(t).format("HH:mm"); -} - function formatPlanDate(d: string) { const date = dayjs(d); const today = dayjs().format("YYYY-MM-DD"); @@ -783,17 +771,14 @@ export default function MealPlanDetail() { {entry.nickName} - {timeAgo(entry.createTime)} - - - {formatTime(entry.createTime)} + {formatDisplayTime(entry.createTime)} {/* 操作内容 */} {entry.type === MealPlanLogType.Remark ? ( // 评论:气泡样式 - + {entry.content} diff --git a/code/app/app-rn/src/helpers/financeAuto.ts b/code/app/app-rn/src/helpers/financeAuto.ts index 4e3d80d5..4b3ec707 100644 --- a/code/app/app-rn/src/helpers/financeAuto.ts +++ b/code/app/app-rn/src/helpers/financeAuto.ts @@ -1,56 +1,81 @@ import dayjs from "dayjs"; import { FinanceAutoRepeatType } from "@/types/finance"; -// 判断今天是否应该执行 +/** repeat_date 以 ! 开头表示"排除模式":指定的日期/星期几不执行 */ +function isExcludeMode(repeatDate: string): boolean { + return repeatDate.startsWith("!"); +} + +/** 去掉排除前缀,返回干净的日期字符串 */ +function cleanRepeatDate(repeatDate: string): string { + return isExcludeMode(repeatDate) ? repeatDate.slice(1) : repeatDate; +} + +/** + * 粗粒度判断:从上次执行至今,是否"可能"有遗漏的触发日期 + * 只做时间周期级别的判断,不做具体日期匹配(日期匹配交给 shouldRunOnDate / getMissedDates) + */ export function shouldRun(repeatType: number, repeatDate: string, lastRun: string | null | undefined): boolean { const today = dayjs(); - if (!lastRun) return true; // 从未执行过 + if (!lastRun) return true; // 从未执行过,需要尝试 const last = dayjs(lastRun); + // 排除模式下每天都是潜在触发日,简单判断 lastRun 不是今天即可 + if (isExcludeMode(repeatDate)) { + return !last.isSame(today, "day"); + } + switch (repeatType) { case FinanceAutoRepeatType.Daily: return !last.isSame(today, "day"); case FinanceAutoRepeatType.Weekly: { - // repeatDate = "1,3,5" → 周一三五 - const days = (repeatDate || "").split(",").map(Number); - if (days.length === 0 || !days.includes(today.day())) return false; - return true; // 今天在指定周几中,且上次执行不是今天(由外层 last_run 检查保证) + const days = (repeatDate || "").split(",").map(Number).filter((d) => !isNaN(d)); + if (days.length === 0) return false; + let check = today; + for (let i = 0; i < 7; i++) { + if (days.includes(check.day())) { + return check.isAfter(last, "day"); + } + check = check.subtract(1, "day"); + } + return false; } - case FinanceAutoRepeatType.Monthly: { - const dates = (repeatDate || "").split(",").map(Number); - if (dates.length === 0 || !dates.includes(today.date())) return false; + case FinanceAutoRepeatType.Monthly: return !last.isSame(today, "month"); - } - case FinanceAutoRepeatType.Quarterly: { - const dates = (repeatDate || "").split(",").map(Number); - if (dates.length === 0 || !dates.includes(today.date())) return false; + case FinanceAutoRepeatType.Quarterly: return last.quarter() !== today.quarter() || last.year() !== today.year(); - } - case FinanceAutoRepeatType.Yearly: { - // repeatDate = "3,15" → 3月15日 - const parts = (repeatDate || "").split(",").map(Number); - if (parts.length !== 2 || parts[0] !== today.month() + 1 || parts[1] !== today.date()) return false; - return last.year() !== today.year(); - } + case FinanceAutoRepeatType.Yearly: + // 支持同一年内多个触发日(如 3/15 和 6/1),不再仅按年份判断 + return last.isBefore(today, "day"); default: return false; } } -// 获取应该补记账的日期列表(从上次执行到今天) +/** + * 获取应该补记账的日期列表(从上次执行到今天) + * 逐日遍历,通过 shouldRunOnDate 精确匹配触发日 + */ export function getMissedDates( repeatType: number, repeatDate: string, lastRun: string | null | undefined, ): string[] { const today = dayjs(); - if (!lastRun) return [today.format("YYYY-MM-DD")]; + + // 首次执行:只在今天恰好是触发日时才创建账单 + if (!lastRun) { + if (shouldRunOnDate(repeatType, repeatDate, today)) { + return [today.format("YYYY-MM-DD")]; + } + return []; + } const last = dayjs(lastRun); const dates: string[] = []; @@ -68,25 +93,43 @@ export function getMissedDates( } function shouldRunOnDate(repeatType: number, repeatDate: string, date: dayjs.Dayjs): boolean { + const exclude = isExcludeMode(repeatDate); + const raw = cleanRepeatDate(repeatDate); + switch (repeatType) { - case FinanceAutoRepeatType.Daily: - return true; + case FinanceAutoRepeatType.Daily: { + if (!exclude || !raw) return true; + // 每天 排除星期几(如 "!0" → 排除周日) + const excludedDays = raw.split(",").map(Number).filter((d) => !isNaN(d)); + return !excludedDays.includes(date.day()); + } + case FinanceAutoRepeatType.Weekly: { - const days = (repeatDate || "").split(",").map(Number); - return days.includes(date.day()); - } - case FinanceAutoRepeatType.Monthly: { - const dates = (repeatDate || "").split(",").map(Number); - return dates.includes(date.date()); + const days = raw.split(",").map(Number).filter((d) => !isNaN(d)); + if (days.length === 0) return false; + const match = days.includes(date.day()); + return exclude ? !match : match; } + + case FinanceAutoRepeatType.Monthly: case FinanceAutoRepeatType.Quarterly: { - const dates = (repeatDate || "").split(",").map(Number); - return dates.includes(date.date()); + const dates = raw.split(",").map(Number).filter((d) => !isNaN(d)); + if (dates.length === 0) return false; + const match = dates.includes(date.date()); + return exclude ? !match : match; } + case FinanceAutoRepeatType.Yearly: { - const parts = (repeatDate || "").split(",").map(Number); - return parts.length === 2 && parts[0] === date.month() + 1 && parts[1] === date.date(); + // 月,日 成对存储,如 "3,15,6,1" → 3月15日 + 6月1日 + const parts = raw.split(",").map(Number).filter((n) => !isNaN(n)); + for (let i = 0; i < parts.length - 1; i += 2) { + if (parts[i] === date.month() + 1 && parts[i + 1] === date.date()) { + return exclude ? false : true; + } + } + return exclude; // 排除模式未命中 → 执行;正常模式未命中 → 不执行 } + default: return false; } @@ -96,8 +139,6 @@ export const REPEAT_LABELS: Record = { [FinanceAutoRepeatType.Daily]: "每天", [FinanceAutoRepeatType.Weekly]: "每周", [FinanceAutoRepeatType.Monthly]: "每月", - [FinanceAutoRepeatType.Quarterly]: "每季度", - [FinanceAutoRepeatType.Yearly]: "每年", }; export const WEEK_DAYS = [ diff --git a/code/app/app-rn/src/store/finance.ts b/code/app/app-rn/src/store/finance.ts index 2ef9da54..e160cdb6 100644 --- a/code/app/app-rn/src/store/finance.ts +++ b/code/app/app-rn/src/store/finance.ts @@ -8,10 +8,14 @@ import { FinanceType, } from "@/types/finance"; import { shouldRun, getMissedDates } from "@/helpers/financeAuto"; +import AsyncStorage from "@react-native-async-storage/async-storage"; + +const AUTO_BILL_IDS_KEY = "finance_auto_bill_ids"; interface FinanceState { categories: FinanceCategory[]; autoRules: FinanceAuto[]; + autoBillIds: number[]; setCategories: (categories: FinanceCategory[]) => void; clearCategories: () => void; @@ -26,11 +30,15 @@ interface FinanceState { fetchAutoRules: (familyId: number) => Promise; saveAutoRule: (rule: Partial, isDelete?: boolean) => Promise; runAutoRules: (familyId: number, userId: number) => Promise; + loadAutoBillIds: () => Promise; + addAutoBillIds: (ids: number[]) => void; + isAutoBill: (id: number) => boolean; } export const useFinanceStore = create((set, get) => ({ categories: [], autoRules: [], + autoBillIds: [], setCategories: (categories: FinanceCategory[]) => set({ categories }), clearCategories: () => set({ categories: [], autoRules: [] }), @@ -132,6 +140,9 @@ export const useFinanceStore = create((set, get) => ({ if (inserts.length > 0) { await saveDataApi([{ table_name: "b_finance", key_field: "id", inserts, updates: [], deletes: [] }]); + // 记录自动生成的账单 ID + const newIds = inserts.map((item) => item.id as number); + get().addAutoBillIds(newIds); // 更新 last_run 为最近的一天 const lastDate = missedDates[missedDates.length - 1]; await saveDataApi([{ @@ -147,4 +158,19 @@ export const useFinanceStore = create((set, get) => ({ return count; }, + + loadAutoBillIds: async () => { + try { + const raw = await AsyncStorage.getItem(AUTO_BILL_IDS_KEY); + if (raw) set({ autoBillIds: JSON.parse(raw) }); + } catch { /* ignore */ } + }, + + addAutoBillIds: (ids: number[]) => { + const merged = [...new Set([...get().autoBillIds, ...ids])]; + set({ autoBillIds: merged }); + AsyncStorage.setItem(AUTO_BILL_IDS_KEY, JSON.stringify(merged)).catch(() => {}); + }, + + isAutoBill: (id: number) => get().autoBillIds.includes(id), })); diff --git a/code/app/app-rn/src/types/finance.ts b/code/app/app-rn/src/types/finance.ts index c66ecd37..337f4a93 100644 --- a/code/app/app-rn/src/types/finance.ts +++ b/code/app/app-rn/src/types/finance.ts @@ -61,6 +61,7 @@ export interface FinanceAuto { type: FinanceType; category_id: number; amount: number; + title?: string; remark?: string; repeat_type: FinanceAutoRepeatType; repeat_date?: string; diff --git a/code/app/app-rn/src/utils/time.ts b/code/app/app-rn/src/utils/time.ts index 0d48e970..43a31e50 100644 --- a/code/app/app-rn/src/utils/time.ts +++ b/code/app/app-rn/src/utils/time.ts @@ -98,7 +98,7 @@ export function normalizeDateTime(input?: string | Date | dayjs.Dayjs): string { if (!d.isValid()) return ""; - // string 才需要“猜精度”,Date / dayjs 默认是完整的 + // string 才需要"猜精度",Date / dayjs 默认是完整的 if (typeof raw === "string") { if (/^\d{4}$/.test(raw)) { d = d.startOf("year"); @@ -114,6 +114,38 @@ export function normalizeDateTime(input?: string | Date | dayjs.Dayjs): string { return d.format("YYYY-MM-DD HH:mm:ss"); } +/** + * 格式化显示时间(通用方法) + * < 60s → 刚刚 + * < 30min → X分钟前 + * 今天 → HH:mm + * 昨天 → 昨天 HH:mm + * 今年 → M月D日 HH:mm + * 往年 → YYYY年M月D日 HH:mm + */ +export function formatDisplayTime(input?: dayjs.ConfigType): string { + if (!input) return ""; + const time = dayjs(input); + if (!time.isValid()) return ""; + + const now = dayjs(); + const diffSeconds = now.diff(time, "second"); + if (diffSeconds < 0) return time.format("YYYY年M月D日 HH:mm"); + if (diffSeconds < 60) return "刚刚"; + + const diffMinutes = now.diff(time, "minute"); + if (diffMinutes < 30) return `${diffMinutes}分钟前`; + + const sameDay = time.format("YYYY-MM-DD") === now.format("YYYY-MM-DD"); + if (sameDay) return time.format("HH:mm"); + + const yesterday = now.subtract(1, "day").format("YYYY-MM-DD"); + if (time.format("YYYY-MM-DD") === yesterday) return `昨天 ${time.format("HH:mm")}`; + + if (time.year() === now.year()) return time.format("M月D日 HH:mm"); + return time.format("YYYY年M月D日 HH:mm"); +} + /** * 格式化时间为相对时间 * @param timestamp 秒级或毫秒级时间戳