20260707213510
This commit is contained in:
1 parent
f8e16efa83
commit
f94b5152ed
10 files changed
+918
-219
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -61,6 +61,7 @@ export interface FinanceAuto {
|
||||
type: FinanceType;
|
||||
category_id: number;
|
||||
amount: number;
|
||||
title?: string;
|
||||
remark?: string;
|
||||
repeat_type: FinanceAutoRepeatType;
|
||||
repeat_date?: string;
|
||||
|
||||
@@ -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 秒级或毫秒级时间戳
|
||||
|
||||
Reference in new issue
Block a user