20260707213510

This commit is contained in:
oneao committed 2026-07-07 21:35:10 +08:00
1 parent f8e16efa83
commit f94b5152ed
10 files changed
+918 -219

No files matched your search

+1
View File
@@ -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 '',
@@ -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<any>(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 (
<LayoutView>
@@ -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() {
</PressableFeedback>
</View>
}
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 ? (
<View className="py-2 items-center">
<Spinner size="sm" />
onEndReachedThreshold={0.3}
ListFooterComponent={
hasMore ? (
<View style={{ transform: [{ scaleY: -1 }] }} className="py-3 items-center">
{loadingMore ? (
<Spinner size="sm" />
) : (
<Text className="text-xs text-gray-300">上拉加载更多</Text>
)}
</View>
) : null
}
ListEmptyComponent={
<View className="items-center py-20">
<View style={{ transform: [{ scaleY: -1 }] }} className="items-center py-20">
<Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text>
</View>
}
@@ -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",
@@ -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<FilterTab>("all");
const [showEditor, setShowEditor] = useState(false);
const [editForm, setEditForm] = useState<AutoFormState>(createEmptyForm());
const [editingRuleId, setEditingRuleId] = useState<number | null>(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<Exclude<FilterTab, "all">, FinanceAutoRepeatType> = {
daily: FinanceAutoRepeatType.Daily,
weekly: FinanceAutoRepeatType.Weekly,
monthly: FinanceAutoRepeatType.Monthly,
};
return autoRules.filter((r) => r.repeat_type === typeMap[filterTab as Exclude<FilterTab, "all">]);
}, [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<FinanceAuto> = {
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(() => (
<View>
<View className="pb-2">
<Text className="text-xl font-bold text-gray-900">自动记账规则</Text>
<Text className="text-xs text-gray-400 mt-0.5">管理周期性自动记账</Text>
</View>
<View className="flex-row gap-1.5">
{FILTER_TABS.map((tab) => {
const active = filterTab === tab.key;
return (
<TouchableOpacity key={tab.key}
className={`h-8 px-3 rounded-full items-center justify-center ${active ? "bg-accent" : "bg-gray-100"}`}
onPress={() => setFilterTab(tab.key)}
>
<Text className={`text-xs font-medium ${active ? "text-white" : "text-gray-600"}`}>{tab.label}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
), [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 (
<View className="flex-row items-center bg-white rounded-xl px-4 py-3.5 mb-3">
<View className="w-10 h-10 rounded-xl bg-gray-100 items-center justify-center">
<FinanceCategoryIcon fileKey={cat?.icon_file_key ?? ""} label={cat?.name ?? ""} size={28} />
</View>
<View className="ml-3 flex-1 flex-row items-center gap-2">
<Text className="text-[15px] font-medium text-gray-900" numberOfLines={1}>{displayTitle}</Text>
<View className="rounded-full bg-purple-50 px-2 py-0.5">
<Text className="text-[11px] font-medium text-purple-600">{REPEAT_LABELS[rule.repeat_type]}</Text>
</View>
</View>
<Text className={`text-[15px] font-semibold mr-3 ${isIncome ? "text-emerald-600" : "text-rose-500"}`}>
{isIncome ? "+" : "-"}{formatMoney(Number(rule.amount))}
</Text>
<TouchableOpacity className="w-8 h-8 rounded-full bg-gray-100 items-center justify-center mr-1" onPress={() => openEditEditor(rule)}>
<PencilIcon size={14} color="#4B5563" />
</TouchableOpacity>
<TouchableOpacity className="w-8 h-8 rounded-full bg-red-50 items-center justify-center" onPress={() => handleDelete(rule)}>
<Trash2Icon size={14} color="#EF4444" />
</TouchableOpacity>
</View>
);
}, [getCategory, openEditEditor, handleDelete]);
return (
<LayoutView>
<LayoutView.SectionView
sections={sections}
keyExtractor={(item: any) => 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={() => (
<EmptyState
type={loading ? "loading" : "empty"}
title={loading ? "正在加载" : (filterTab === "all" ? "暂无自动记账规则" : `暂无${FILTER_TABS.find((t) => t.key === filterTab)?.label}规则`)}
description={loading ? "请稍候" : "点击右下角 + 添加一条规则"}
center="screen"
/>
)}
/>
{/* 悬浮新增按钮 */}
<TouchableOpacity
className="absolute bottom-20 right-5 w-14 h-14 rounded-full bg-accent items-center justify-center shadow-xl z-50 active:scale-95"
activeOpacity={0.85}
onPress={openNewEditor}
>
<PlusIcon size={24} color="#fff" />
</TouchableOpacity>
{/* ===== 编辑/新建 BottomSheet ===== */}
<BottomSheet isOpen={showEditor} onOpenChange={setShowEditor}>
<BottomSheet.Portal><BottomSheet.Overlay />
<BottomSheet.Content style={{ paddingHorizontal: 16, maxHeight: 600 }}>
<Text className="text-center text-lg font-semibold text-gray-950 mb-3">
{editingRuleId ? "编辑规则" : "新增规则"}
</Text>
<ScrollView showsVerticalScrollIndicator={false} style={{ maxHeight: 480 }}
contentContainerStyle={{ gap: 14, paddingBottom: 4 }}
>
{/* 标题 */}
<TextField>
<Label className="mb-2">规则名称</Label>
<BottomSheet.Input
value={editForm.title}
onChangeText={(v) => setEditForm((p) => ({ ...p, title: v }))}
placeholder="如:每月房租"
/>
</TextField>
{/* 分类(仅新建时可选) */}
{!editingRuleId && (
<View>
<Label className="mb-2">选择分类</Label>
<TouchableOpacity
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center"
activeOpacity={0.75}
onPress={() => setShowCategoryPicker(true)}
>
{selectedCategory ? (
<View className="flex-row items-center gap-2">
<FinanceCategoryIcon fileKey={selectedCategory.icon_file_key} label={selectedCategory.name} size={20} />
<Text className="text-sm text-gray-900">{selectedCategory.name}</Text>
</View>
) : (
<Text className="text-sm text-gray-400">请选择分类</Text>
)}
</TouchableOpacity>
</View>
)}
{/* 金额 */}
<TextField>
<Label className="mb-2">金额</Label>
<BottomSheet.Input
value={editForm.amount}
onChangeText={(v) => setEditForm((p) => ({ ...p, amount: v.replace(/[^0-9.]/g, "") }))}
placeholder="0.00"
keyboardType="decimal-pad"
/>
</TextField>
{/* 备注 */}
<TextField>
<Label className="mb-2">备注(选填)</Label>
<BottomSheet.Input
value={editForm.remark}
onChangeText={(v) => setEditForm((p) => ({ ...p, remark: v }))}
placeholder="如:房租、工资"
/>
</TextField>
{/* 频率 */}
<View>
<Label className="mb-2">重复频率</Label>
<View className="flex-row flex-wrap gap-2">
{Object.entries(REPEAT_LABELS).map(([k, v]) => {
const sel = editForm.repeatType === Number(k);
return (
<TouchableOpacity key={k}
className={`rounded-full border px-3 py-2 ${sel ? "bg-accent border-accent" : "bg-white border-gray-200"}`}
onPress={() => setEditForm((p) => ({ ...p, repeatType: Number(k), repeatDate: "" }))}
>
<Text className={`text-sm font-medium ${sel ? "text-white" : "text-gray-700"}`}>{v}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{/* 排除/指定 */}
<View>
<Label className="mb-2">触发规则</Label>
<View className="flex-row bg-gray-100 rounded-lg p-0.5">
<TouchableOpacity
className={`flex-1 py-2 rounded-md items-center ${!editForm.excludeMode ? "bg-white shadow-sm" : ""}`}
onPress={() => setEditForm((p) => ({ ...p, excludeMode: false }))}
>
<Text className={`text-sm font-medium ${!editForm.excludeMode ? "text-gray-900" : "text-gray-500"}`}>指定</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 py-2 rounded-md items-center ${editForm.excludeMode ? "bg-white shadow-sm" : ""}`}
onPress={() => setEditForm((p) => ({ ...p, excludeMode: true }))}
>
<Text className={`text-sm font-medium ${editForm.excludeMode ? "text-gray-900" : "text-gray-500"}`}>排除</Text>
</TouchableOpacity>
</View>
</View>
{/* 每天 */}
{editForm.repeatType === FinanceAutoRepeatType.Daily && (
<View>
<Label className="mb-2">{editForm.excludeMode ? "选择要排除的星期" : "每天执行"}</Label>
{editForm.excludeMode && (
<View className="flex-row gap-1.5">
{WEEK_DAYS.map((d) => {
const sel = (editForm.repeatDate || "").split(",").includes(String(d.value));
return (
<TouchableOpacity key={d.value}
className={`w-9 h-9 rounded-full items-center justify-center ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => {
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(",") }));
}}
>
<Text className={`text-sm font-medium ${sel ? "text-white" : "text-gray-600"}`}>{d.label}</Text>
</TouchableOpacity>
);
})}
</View>
)}
</View>
)}
{/* 每周 */}
{editForm.repeatType === FinanceAutoRepeatType.Weekly && (
<View>
<Label className="mb-2">{editForm.excludeMode ? "选择要排除的星期" : "选择要执行的星期"}</Label>
<View className="flex-row gap-1.5">
{WEEK_DAYS.map((d) => {
const sel = (editForm.repeatDate || "").split(",").includes(String(d.value));
return (
<TouchableOpacity key={d.value}
className={`w-9 h-9 rounded-full items-center justify-center ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => {
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(",") }));
}}
>
<Text className={`text-sm font-medium ${sel ? "text-white" : "text-gray-600"}`}>{d.label}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
{/* 每月 */}
{editForm.repeatType === FinanceAutoRepeatType.Monthly && (
<View>
<Label className="mb-2">{editForm.excludeMode ? "选择要排除的日期" : "选择要执行的日期"}</Label>
<View className="flex-row flex-wrap gap-1.5">
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => {
const sel = (editForm.repeatDate || "").split(",").includes(String(d));
return (
<TouchableOpacity key={d}
className={`w-9 h-9 rounded-full items-center justify-center ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => {
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(",") }));
}}
>
<Text className={`text-xs font-medium ${sel ? "text-white" : "text-gray-600"}`}>{d}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
</ScrollView>
<View className="flex-row gap-3 pt-3">
<Button variant="secondary" className="flex-1" onPress={() => setShowEditor(false)}>
<Button.Label>取消</Button.Label>
</Button>
<Button className="flex-1" onPress={handleSave}>
<CheckIcon size={18} color="#fff" />
<Button.Label>保存</Button.Label>
</Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
{/* ===== 分类选择 ===== */}
<BottomSheet isOpen={showCategoryPicker} onOpenChange={setShowCategoryPicker}>
<BottomSheet.Portal><BottomSheet.Overlay />
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
<Text className="text-center text-lg font-semibold text-gray-950">选择分类</Text>
<ScrollView style={{ maxHeight: 300 }}>
<Text className="text-xs text-gray-400 mb-2">支出</Text>
<View className="flex-row flex-wrap gap-2 mb-3">
{categories.filter((c) => c.type === FinanceType.Expense).map((cat) => {
const sel = editForm.categoryId === cat.id;
return (
<TouchableOpacity key={cat.id}
className={`h-10 px-3 rounded-full flex-row items-center gap-1.5 border ${sel ? "bg-accent/10 border-accent" : "bg-white border-gray-200"}`}
onPress={() => { setEditForm((p) => ({ ...p, categoryId: cat.id })); setShowCategoryPicker(false); }}
>
<FinanceCategoryIcon fileKey={cat.icon_file_key} label={cat.name} size={16} />
<Text className={`text-sm ${sel ? "text-accent font-semibold" : "text-gray-700"}`}>{cat.name}</Text>
</TouchableOpacity>
);
})}
</View>
<Text className="text-xs text-gray-400 mb-2">收入</Text>
<View className="flex-row flex-wrap gap-2">
{categories.filter((c) => c.type === FinanceType.Income).map((cat) => {
const sel = editForm.categoryId === cat.id;
return (
<TouchableOpacity key={cat.id}
className={`h-10 px-3 rounded-full flex-row items-center gap-1.5 border ${sel ? "bg-accent/10 border-accent" : "bg-white border-gray-200"}`}
onPress={() => { setEditForm((p) => ({ ...p, categoryId: cat.id })); setShowCategoryPicker(false); }}
>
<FinanceCategoryIcon fileKey={cat.icon_file_key} label={cat.name} size={16} />
<Text className={`text-sm ${sel ? "text-accent font-semibold" : "text-gray-700"}`}>{cat.name}</Text>
</TouchableOpacity>
);
})}
</View>
</ScrollView>
<Button variant="secondary" className="w-full" onPress={() => setShowCategoryPicker(false)}>
<Button.Label>取消</Button.Label>
</Button>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<LoadingOverlay visible={isLoading} />
</LayoutView>
);
}
@@ -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<FinanceBill | null>(null);
const [autoExcludeMode, setAutoExcludeMode] = useState(false);
const [filter, setFilter] = useState<FilterState>(() => createDefaultFilter());
const [draftFilter, setDraftFilter] = useState<FilterState>(() => 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() {
<View className="flex-1 rounded-xl bg-white px-4 py-3">
<Text className="text-xs text-gray-500">支出</Text>
<Text className="mt-1 text-lg font-semibold text-rose-500">
{formatMoney(totals.expense)}
-{formatMoney(totals.expense)}
</Text>
</View>
<View className="flex-1 rounded-xl bg-white px-4 py-3">
@@ -382,7 +403,7 @@ export default function Home() {
<View className="flex-1 rounded-xl bg-white px-4 py-3">
<Text className="text-xs text-gray-500">结余</Text>
<Text className={`mt-1 text-lg font-semibold ${totals.income - totals.expense >= 0 ? "text-emerald-600" : "text-rose-500"}`}>
{formatMoney(totals.income - totals.expense)}
{totals.income - totals.expense >= 0 ? "+" : "-"}{formatMoney(totals.income - totals.expense)}
</Text>
</View>
</View>
@@ -452,9 +473,16 @@ export default function Home() {
</View>
<View className="ml-3 flex-1">
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
{item.category_name || "未分类"}
</Text>
<View className="flex-row items-center gap-1.5">
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
{item.category_name || "未分类"}
</Text>
{isAutoBill(item.id) && (
<View className="rounded-full bg-purple-100 px-1.5 py-0.5">
<Text className="text-[10px] font-medium text-purple-600">自动</Text>
</View>
)}
</View>
{!!remark && (
<Text className="mt-0.5 text-xs text-gray-500" numberOfLines={1}>
{remark}
@@ -464,7 +492,7 @@ export default function Home() {
<View className="items-end ml-3">
<Text
className={`text-base font-semibold ${isIncome ? "text-emerald-600" : "text-gray-900"}`}
className={`text-base font-semibold ${isIncome ? "text-emerald-600" : "text-rose-500"}`}
>
{isIncome ? "+" : "-"}{formatMoney(Number(item.amount))}
</Text>
@@ -472,7 +500,7 @@ export default function Home() {
</TouchableOpacity>
);
},
[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 */}
<TouchableOpacity
className="absolute bottom-20 right-5 w-14 h-14 rounded-full bg-accent items-center justify-center shadow-xl z-50 active:scale-95"
activeOpacity={0.85}
onPress={() => router.push("/finance/add" as any)}
>
<PlusIcon size={24} color="#fff" />
</TouchableOpacity>
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
@@ -732,97 +769,177 @@ export default function Home() {
{/* 自动记账 Sheet */}
<BottomSheet isOpen={showAutoSheet} onOpenChange={setShowAutoSheet}>
<BottomSheet.Portal><BottomSheet.Overlay />
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16, maxHeight: 460 }}>
<Text className="text-center text-lg font-semibold text-gray-950">自动记账</Text>
<Text className="text-xs text-gray-500 text-center -mt-3">
基于「{activeBill?.category_name} {Number(activeBill?.amount || 0)}」创建规则
</Text>
<BottomSheet.Content style={{ paddingHorizontal: 16, maxHeight: 560 }}>
<Text className="text-center text-lg font-semibold text-gray-950 mb-3">自动记账</Text>
{/* 频率 */}
<View>
<Label className="mb-2">重复频率</Label>
<View className="flex-row flex-wrap gap-2">
{Object.entries(REPEAT_LABELS).map(([k, v]) => {
const sel = autoForm.repeatType === Number(k);
return (
<TouchableOpacity key={k}
className={`rounded-full border px-3 py-2 ${sel ? "bg-accent border-accent" : "bg-white border-gray-200"}`}
onPress={() => setAutoForm({ repeatType: Number(k), repeatDate: "" })}
<ScrollView
showsVerticalScrollIndicator={false}
style={{ maxHeight: 440 }}
contentContainerStyle={{ gap: 14, paddingBottom: 4 }}
>
{/* 规则名称 */}
<TextField>
<Label className="mb-2">规则名称</Label>
<BottomSheet.Input
value={autoForm.title}
onChangeText={(v) => setAutoForm((p) => ({ ...p, title: v }))}
placeholder="如:每月房租"
/>
</TextField>
{/* 账单预览卡片 */}
{autoBaseBill && (
<View className="flex-row items-center rounded-xl bg-white">
<View className="w-11 h-11 rounded-xl bg-gray-200 items-center justify-center">
<FinanceCategoryIcon
fileKey={autoBaseBill.category_icon_file_key}
label={autoBaseBill.category_name}
size={30}
/>
</View>
<View className="ml-3 flex-1">
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
{autoBaseBill.category_name || "未分类"}
</Text>
{!!autoBaseBill.remark?.trim() && (
<Text className="mt-0.5 text-xs text-gray-500" numberOfLines={1}>
{autoBaseBill.remark.trim()}
</Text>
)}
</View>
<View className="items-end ml-3">
<Text
className={`text-base font-semibold ${autoBaseBill.category_type === FinanceType.Income ? "text-emerald-600" : "text-rose-500"}`}
>
<Text className={`text-sm font-medium ${sel ? "text-white" : "text-gray-700"}`}>{v}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
{autoBaseBill.category_type === FinanceType.Income ? "+" : "-"}
{formatMoney(Number(autoBaseBill.amount))}
</Text>
</View>
</View>
)}
{/* 每周:选周几 */}
{autoForm.repeatType === FinanceAutoRepeatType.Weekly && (
{/* 频率 */}
<View>
<Label className="mb-2">选择星期</Label>
<View className="flex-row gap-1.5">
{WEEK_DAYS.map((d) => {
const sel = (autoForm.repeatDate || "").split(",").includes(String(d.value));
<Label className="mb-2">重复频率</Label>
<View className="flex-row flex-wrap gap-2">
{Object.entries(REPEAT_LABELS).map(([k, v]) => {
const sel = autoForm.repeatType === Number(k);
return (
<TouchableOpacity key={d.value}
className={`w-9 h-9 rounded-full items-center justify-center ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => {
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(",") }));
}}
<TouchableOpacity key={k}
className={`rounded-full border px-3 py-2 ${sel ? "bg-accent border-accent" : "bg-white border-gray-200"}`}
onPress={() => { setAutoForm({ repeatType: Number(k), repeatDate: "" }); setAutoExcludeMode(false); }}
>
<Text className={`text-sm font-medium ${sel ? "text-white" : "text-gray-600"}`}>{d.label}</Text>
<Text className={`text-sm font-medium ${sel ? "text-white" : "text-gray-700"}`}>{v}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
{/* 每月/每季度:选日期 */}
{(autoForm.repeatType === FinanceAutoRepeatType.Monthly || autoForm.repeatType === FinanceAutoRepeatType.Quarterly) && (
{/* 排除/指定模式切换 */}
<View>
<Label className="mb-2">选择日期</Label>
<View className="flex-row flex-wrap gap-1.5">
{[1,5,10,15,20,25,28].map((d) => {
const sel = (autoForm.repeatDate || "").split(",").includes(String(d));
return (
<TouchableOpacity key={d}
className={`w-10 h-9 rounded-full items-center justify-center ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => setAutoForm((p) => ({ ...p, repeatDate: String(d) }))}
>
<Text className={`text-xs font-medium ${sel ? "text-white" : "text-gray-600"}`}>{d}</Text>
</TouchableOpacity>
);
})}
<Label className="mb-2">触发规则</Label>
<View className="flex-row bg-gray-100 rounded-lg p-0.5">
<TouchableOpacity
className={`flex-1 py-2 rounded-md items-center ${!autoExcludeMode ? "bg-white shadow-sm" : ""}`}
onPress={() => setAutoExcludeMode(false)}
>
<Text className={`text-sm font-medium ${!autoExcludeMode ? "text-gray-900" : "text-gray-500"}`}>指定</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 py-2 rounded-md items-center ${autoExcludeMode ? "bg-white shadow-sm" : ""}`}
onPress={() => setAutoExcludeMode(true)}
>
<Text className={`text-sm font-medium ${autoExcludeMode ? "text-gray-900" : "text-gray-500"}`}>排除</Text>
</TouchableOpacity>
</View>
</View>
)}
{/* 每年:选月日 */}
{autoForm.repeatType === FinanceAutoRepeatType.Yearly && (
<View>
<Label className="mb-2">选择月日</Label>
<View className="flex-row flex-wrap gap-1.5">
{["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 (
<TouchableOpacity key={md}
className={`rounded-full px-3 py-2 ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => setAutoForm((p) => ({ ...p, repeatDate: md }))}
>
<Text className={`text-xs font-medium ${sel ? "text-white" : "text-gray-600"}`}>{m}月{d}日</Text>
</TouchableOpacity>
);
})}
{/* 每天:排除星期几 */}
{autoForm.repeatType === FinanceAutoRepeatType.Daily && (
<View>
<Label className="mb-2">
{autoExcludeMode ? "选择要排除的星期" : "每天执行"}
</Label>
{autoExcludeMode && (
<View className="flex-row gap-1.5">
{WEEK_DAYS.map((d) => {
const sel = (autoForm.repeatDate || "").split(",").includes(String(d.value));
return (
<TouchableOpacity key={d.value}
className={`w-9 h-9 rounded-full items-center justify-center ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => {
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(",") }));
}}
>
<Text className={`text-sm font-medium ${sel ? "text-white" : "text-gray-600"}`}>{d.label}</Text>
</TouchableOpacity>
);
})}
</View>
)}
</View>
</View>
)}
)}
<View className="flex-row gap-3">
{/* 每周:选周几 */}
{autoForm.repeatType === FinanceAutoRepeatType.Weekly && (
<View>
<Label className="mb-2">
{autoExcludeMode ? "选择要排除的星期" : "选择要执行的星期"}
</Label>
<View className="flex-row gap-1.5">
{WEEK_DAYS.map((d) => {
const sel = (autoForm.repeatDate || "").split(",").includes(String(d.value));
return (
<TouchableOpacity key={d.value}
className={`w-9 h-9 rounded-full items-center justify-center ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => {
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(",") }));
}}
>
<Text className={`text-sm font-medium ${sel ? "text-white" : "text-gray-600"}`}>{d.label}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
{/* 每月:选日期 */}
{autoForm.repeatType === FinanceAutoRepeatType.Monthly && (
<View>
<Label className="mb-2">
{autoExcludeMode ? "选择要排除的日期" : "选择要执行的日期"}
</Label>
<View className="flex-row flex-wrap gap-1.5">
{Array.from({ length: 31 }, (_, i) => i + 1).map((d) => {
const sel = (autoForm.repeatDate || "").split(",").includes(String(d));
return (
<TouchableOpacity key={d}
className={`w-9 h-9 rounded-full items-center justify-center ${sel ? "bg-accent" : "bg-gray-100"}`}
onPress={() => {
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(",") }));
}}
>
<Text className={`text-xs font-medium ${sel ? "text-white" : "text-gray-600"}`}>{d}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
)}
</ScrollView>
<View className="flex-row gap-3 pt-3">
<Button variant="secondary" className="flex-1" onPress={() => setShowAutoSheet(false)}><Button.Label>取消</Button.Label></Button>
<Button className="flex-1" onPress={handleSaveAuto}><CheckIcon size={18} color="#fff" /><Button.Label>保存</Button.Label></Button>
</View>
@@ -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}
</Text>
<Text className="text-[10px] text-gray-400">
{timeAgo(entry.createTime)}
</Text>
<Text className="text-[10px] text-gray-300">
{formatTime(entry.createTime)}
{formatDisplayTime(entry.createTime)}
</Text>
</View>
{/* 操作内容 */}
{entry.type === MealPlanLogType.Remark ? (
// 评论:气泡样式
<View className="bg-gray-50 rounded-2xl rounded-tl-sm px-4 py-3">
<View className="bg-gray-50 rounded-2xl rounded-tl-sm">
<Text className="text-sm text-gray-700 leading-relaxed">
{entry.content}
</Text>
+77 -36
View File
@@ -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<number, string> = {
[FinanceAutoRepeatType.Daily]: "每天",
[FinanceAutoRepeatType.Weekly]: "每周",
[FinanceAutoRepeatType.Monthly]: "每月",
[FinanceAutoRepeatType.Quarterly]: "每季度",
[FinanceAutoRepeatType.Yearly]: "每年",
};
export const WEEK_DAYS = [
+26
View File
@@ -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<void>;
saveAutoRule: (rule: Partial<FinanceAuto>, isDelete?: boolean) => Promise<boolean>;
runAutoRules: (familyId: number, userId: number) => Promise<number>;
loadAutoBillIds: () => Promise<void>;
addAutoBillIds: (ids: number[]) => void;
isAutoBill: (id: number) => boolean;
}
export const useFinanceStore = create<FinanceState>((set, get) => ({
categories: [],
autoRules: [],
autoBillIds: [],
setCategories: (categories: FinanceCategory[]) => set({ categories }),
clearCategories: () => set({ categories: [], autoRules: [] }),
@@ -132,6 +140,9 @@ export const useFinanceStore = create<FinanceState>((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<FinanceState>((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),
}));
+1
View File
@@ -61,6 +61,7 @@ export interface FinanceAuto {
type: FinanceType;
category_id: number;
amount: number;
title?: string;
remark?: string;
repeat_type: FinanceAutoRepeatType;
repeat_date?: string;
+33 -1
View File
@@ -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 秒级或毫秒级时间戳