diff --git a/code/app/CLAUDE.md b/code/app/CLAUDE.md index 3d156a88..421b8ff1 100644 --- a/code/app/CLAUDE.md +++ b/code/app/CLAUDE.md @@ -72,7 +72,7 @@ Auth endpoints: `/app/auth/login/{qq,wechat,test}`, `/app/bind/{qq,wechat}`. | `POST /app/s3/upload` | Upload files (multipart form, field name `files`), stored under `YYYY/MM/DD/{uuid}.ext`, returns `string[]` keys | | `POST /app/s3/delete` | Delete files by keys, body `{keys: string[]}`, failure is logged but still returns success | -On the frontend: `s3UploadFileApi(files[])` wraps files into a `FormData` with key `"files"` and `multipart/form-data` content type. +On the frontend: `s3UploadFileApi(files[])` wraps files into a `FormData` with key `"files"` and `multipart/form-data` content type. For deleting uploaded files, use `deleteS3FileQuietly()` from `app-rn/src/utils/file.ts`; do not reimplement S3 key checks or silent delete/catch logic in pages or components. Auth token is passed in a custom `Token` header. JWT middleware stores `user_id` in context. Audit fields (`create_time`, `update_time`, `create_by`, `update_by`) are auto-injected by `pkg/db/meta_field.go` for all tables except `b_user` and `b_user_oauth`. diff --git a/code/app/CODEX.md b/code/app/CODEX.md index 1fda7022..6e22b712 100644 --- a/code/app/CODEX.md +++ b/code/app/CODEX.md @@ -72,7 +72,7 @@ Auth endpoints: `/app/auth/login/{qq,wechat,test}`, `/app/bind/{qq,wechat}`. | `POST /app/s3/upload` | Upload files (multipart form, field name `files`), stored under `YYYY/MM/DD/{uuid}.ext`, returns `string[]` keys | | `POST /app/s3/delete` | Delete files by keys, body `{keys: string[]}`, failure is logged but still returns success | -On the frontend: `s3UploadFileApi(files[])` wraps files into a `FormData` with key `"files"` and `multipart/form-data` content type. +On the frontend: `s3UploadFileApi(files[])` wraps files into a `FormData` with key `"files"` and `multipart/form-data` content type. For deleting uploaded files, use `deleteS3FileQuietly()` from `app-rn/src/utils/file.ts`; do not reimplement S3 key checks or silent delete/catch logic in pages or components. Auth token is passed in a custom `Token` header. JWT middleware stores `user_id` in context. Audit fields (`create_time`, `update_time`, `create_by`, `update_by`) are auto-injected by `pkg/db/meta_field.go` for all tables except `b_user` and `b_user_oauth`. diff --git a/code/app/app-go/.codex/apply_finance_category_schema.go b/code/app/app-go/.codex/apply_finance_category_schema.go new file mode 100644 index 00000000..40e8f808 --- /dev/null +++ b/code/app/app-go/.codex/apply_finance_category_schema.go @@ -0,0 +1,34 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jackc/pgx/v5" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + sqlBytes, err := os.ReadFile("db/finance_category.sql") + must(err) + + conn, err := pgx.Connect(ctx, "postgres://postgres:zhang520..@117.72.182.135:5432/allapp?sslmode=disable") + must(err) + defer conn.Close(ctx) + + _, err = conn.Exec(ctx, string(sqlBytes)) + must(err) + + fmt.Println("finance category schema applied") +} + +func must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/code/app/app-go/.codex/check_finance_category_schema.go b/code/app/app-go/.codex/check_finance_category_schema.go new file mode 100644 index 00000000..bb4bdbdb --- /dev/null +++ b/code/app/app-go/.codex/check_finance_category_schema.go @@ -0,0 +1,43 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/jackc/pgx/v5" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + conn, err := pgx.Connect(ctx, "postgres://postgres:zhang520..@117.72.182.135:5432/allapp?sslmode=disable") + must(err) + defer conn.Close(ctx) + + rows, err := conn.Query(ctx, ` + select table_name, column_name, data_type + from information_schema.columns + where table_schema = 'public' + and table_name in ('b_finance_category', 'b_finance_category_default') + order by table_name, ordinal_position + `) + must(err) + defer rows.Close() + + for rows.Next() { + var tableName, columnName, dataType string + must(rows.Scan(&tableName, &columnName, &dataType)) + fmt.Printf("%s.%s %s\n", tableName, columnName, dataType) + } + must(rows.Err()) +} + +func must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/code/app/app-go/db/finance_category.sql b/code/app/app-go/db/finance_category.sql new file mode 100644 index 00000000..99ba6656 --- /dev/null +++ b/code/app/app-go/db/finance_category.sql @@ -0,0 +1,38 @@ +drop table if exists b_finance_category; +drop table if exists b_finance_category_default; + +create table b_finance_category_default ( + id bigint primary key, + name varchar(32) not null, + type smallint not null, + icon_file_key varchar(512) not null, + icon_mime varchar(64) default '', + sort_number int not null default 0, + status smallint not null default 1, + create_time timestamp, + update_time timestamp, + create_by bigint, + update_by bigint +); + +create table b_finance_category ( + id bigint primary key, + family_id bigint not null, + name varchar(32) not null, + type smallint not null, + icon_file_key varchar(512) not null, + icon_source smallint not null, + icon_mime varchar(64) default '', + sort_number int not null default 0, + status smallint not null default 1, + create_time timestamp, + update_time timestamp, + create_by bigint, + update_by bigint +); + +create index idx_b_finance_category_family_status_type + on b_finance_category (family_id, status, type, sort_number, id); + +create index idx_b_finance_category_default_status_type + on b_finance_category_default (status, type, sort_number, id); diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx index d33dd7a8..91aac27b 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx @@ -389,7 +389,7 @@ export default function ChatPage() { if (loading) { return ( - + ); } diff --git a/code/app/app-rn/src/app/(main)/(tabs)/space/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/space/index.tsx index f221b50d..189975f2 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/space/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/space/index.tsx @@ -4,7 +4,7 @@ import EmptyState from "@/components/EmptyStatus"; export default function Space() { return ( - + ); } diff --git a/code/app/app-rn/src/app/(main)/chat/history.tsx b/code/app/app-rn/src/app/(main)/chat/history.tsx index 72367687..c70012df 100644 --- a/code/app/app-rn/src/app/(main)/chat/history.tsx +++ b/code/app/app-rn/src/app/(main)/chat/history.tsx @@ -3,13 +3,16 @@ import PageLayout from "@/layouts/PageLayout"; import EmptyState from "@/components/EmptyStatus"; import { loadDataApi, loadDataPageApi } from "@/request/api"; import { cn } from "@/utils/cn"; -import { useCallback, useEffect, useState } from "react"; -import { Text, View } from "react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; import { useRouter } from "expo-router"; -import { SearchField, Spinner, PressableFeedback } from "heroui-native"; +import { BottomSheet, Button, Label, SearchField, Spinner, PressableFeedback } from "heroui-native"; +import { CalendarDaysIcon, CheckIcon, FilterIcon, RotateCcwIcon } from "lucide-react-native"; +import dayjs from "dayjs"; import { ChatMsgType, normalizeChatMessage } from "@/types"; import { useListLoading } from "@/hooks/useLoading"; import { useFamilyStore } from "@/store"; +import DateTimeRange from "@/components/DateTimeRange"; const MSG_TYPES = [ { label: "全部", value: -1 }, @@ -33,12 +36,19 @@ function formatTime(ts: string) { export default function ChatHistoryPage() { const [keyword, setKeyword] = useState(""); + const [appliedKeyword, setAppliedKeyword] = useState(""); const [typeFilter, setTypeFilter] = useState(-1); + const [startDate, setStartDate] = useState(""); + const [endDate, setEndDate] = useState(""); + const [draftTypeFilter, setDraftTypeFilter] = useState(-1); + const [draftStartDate, setDraftStartDate] = useState(""); + const [draftEndDate, setDraftEndDate] = useState(""); + const [showFilterSheet, setShowFilterSheet] = useState(false); + const [showDateRange, setShowDateRange] = useState(false); const [results, setResults] = useState([]); const [page, setPage] = useState(1); const { loading, loadingMore, runInitial, runLoadMore } = useListLoading(); const [hasMore, setHasMore] = useState(true); - const [searched, setSearched] = useState(false); const [memberMap, setMemberMap] = useState>({}); const familyId = useFamilyStore((s) => s.getFamilyId()); @@ -61,90 +71,154 @@ export default function ChatHistoryPage() { }); }, [familyId]); - const doSearch = useCallback( + const buildCondition = useCallback(() => { + const conditions = ["family_id = ?", "deleted = 0"]; + + const escaped = appliedKeyword.trim().replace(/'/g, "''"); + if (escaped) { + conditions.push(`content ILIKE '%${escaped}%'`); + } + + if (typeFilter >= 0) { + conditions.push(`type = ${typeFilter}`); + } + + if (startDate) { + conditions.push("create_time >= ?"); + } + + if (endDate) { + conditions.push("create_time < ?"); + } + + return conditions.join(" AND "); + }, [appliedKeyword, endDate, startDate, typeFilter]); + + const loadHistoryPage = useCallback( async (p: number) => { - if (!familyId || !keyword.trim()) return; + if (!familyId) return; - try { - const run = p === 1 ? runInitial : runLoadMore; - await run(async () => { - const escaped = keyword.trim().replace(/'/g, "''"); - let condition = `family_id = ? AND deleted = 0 AND content ILIKE '%${escaped}%'`; - if (typeFilter >= 0) condition += ` AND type = ${typeFilter}`; - - const res = await loadDataPageApi({ - view_name: "b_chat", - search_condition: condition, - order_by: "create_time DESC", - args: [familyId], - page: p, - page_size: 30, - search_columns: [], - }); - - if (res.isSuccess && res.data) { - const rows = (res.data.rows as any[]).map(normalizeChatMessage); - if (p === 1) setResults(rows); - else setResults((prev) => [...prev, ...rows]); - setHasMore(rows.length >= 30); - setPage(p); - } + const run = p === 1 ? runInitial : runLoadMore; + await run(async () => { + const res = await loadDataPageApi({ + view_name: "b_chat", + search_condition: buildCondition(), + order_by: "create_time DESC", + args: [ + familyId, + ...(startDate ? [startDate] : []), + ...(endDate ? [dayjs(endDate).add(1, "day").format("YYYY-MM-DD")] : []), + ], + page: p, + page_size: 30, + search_columns: [], }); - } finally { - setSearched(true); - } + + if (res.isSuccess && res.data) { + const rows = (res.data.rows as any[]).map(normalizeChatMessage); + if (p === 1) setResults(rows); + else setResults((prev) => [...prev, ...rows]); + setHasMore(rows.length >= 30); + setPage(p); + } + }); }, - [familyId, keyword, typeFilter, runInitial, runLoadMore] + [buildCondition, endDate, familyId, runInitial, runLoadMore, startDate], ); + useEffect(() => { + loadHistoryPage(1); + }, [loadHistoryPage]); + const handleLoadMore = () => { if (loadingMore || !hasMore) return; - doSearch(page + 1); + loadHistoryPage(page + 1); }; + const handleSearch = useCallback(() => { + const nextKeyword = keyword.trim(); + if (nextKeyword === appliedKeyword) { + loadHistoryPage(1); + return; + } + + setAppliedKeyword(nextKeyword); + }, [appliedKeyword, keyword, loadHistoryPage]); + + const openFilterSheet = useCallback(() => { + setDraftTypeFilter(typeFilter); + setDraftStartDate(startDate); + setDraftEndDate(endDate); + setShowFilterSheet(true); + }, [endDate, startDate, typeFilter]); + + const handleApplyFilter = useCallback(() => { + setTypeFilter(draftTypeFilter); + setStartDate(draftStartDate); + setEndDate(draftEndDate); + setShowFilterSheet(false); + }, [draftEndDate, draftStartDate, draftTypeFilter]); + + const handleResetFilter = useCallback(() => { + setDraftTypeFilter(-1); + setDraftStartDate(""); + setDraftEndDate(""); + }, []); + const router = useRouter(); const typeLabel = (t: number) => MSG_TYPES.find((m) => m.value === t)?.label || "未知"; + const dateRangeLabel = startDate ? `${startDate} 至 ${endDate || startDate}` : "全部时间"; + const activeFilterCount = (typeFilter >= 0 ? 1 : 0) + (startDate ? 1 : 0); + const emptyTitle = useMemo(() => { + if (loading) return "加载中..."; + if (appliedKeyword || typeFilter >= 0 || startDate) return "没有找到相关消息"; + return "暂无聊天记录"; + }, [appliedKeyword, loading, startDate, typeFilter]); return ( - - {/* 搜索栏 */} - - - - doSearch(1)} - returnKeyType="search" - /> - - - - {/* 类型筛选 */} - - {MSG_TYPES.map((t) => ( - setTypeFilter(t.value)}> - - - {t.label} - - - - - ))} - - - - {/* 结果列表 */} + + + + + + + + + + + + + 筛选{activeFilterCount ? ` ${activeFilterCount}` : ""} + + + + + + + 类型:{typeLabel(typeFilter)} + + + {dateRangeLabel} + + + + } data={results} keyExtractor={(item: any) => String(item.id)} + contentContainerStyle={results.length === 0 ? { flexGrow: 1 } : undefined} renderItem={({ item }: { item: any }) => ( @@ -170,10 +244,10 @@ export default function ChatHistoryPage() { onEndReachedThreshold={0.3} ListEmptyComponent={ loading ? ( - - ) : searched ? ( - - ) : null + + ) : ( + + ) } ListFooterComponent={ loadingMore ? ( @@ -183,6 +257,88 @@ export default function ChatHistoryPage() { ) : null } /> + + + + + + + 筛选聊天记录 + + + 重置 + + + + + + setShowDateRange(true)} + > + + {draftStartDate + ? `${draftStartDate} 至 ${draftEndDate || draftStartDate}` + : "全部时间"} + + + + + + + + + {MSG_TYPES.map((t) => ( + setDraftTypeFilter(t.value)}> + + + {t.label} + + + + + ))} + + + + + + + + + + + + setShowDateRange(false)} + onConfirm={(start, end) => { + setDraftStartDate(start); + setDraftEndDate(end || start); + setShowDateRange(false); + }} + /> ); diff --git a/code/app/app-rn/src/app/(main)/family/entry.tsx b/code/app/app-rn/src/app/(main)/family/entry.tsx index 86aef013..ba88bfd4 100644 --- a/code/app/app-rn/src/app/(main)/family/entry.tsx +++ b/code/app/app-rn/src/app/(main)/family/entry.tsx @@ -195,7 +195,13 @@ export default function EntryFamily() { view_name: "b_finance_category_default", search_condition: "", order_by: "", - search_columns: ["name", "icon", "type", "sort_number"], + search_columns: [ + "name", + "type", + "icon_file_key", + "icon_mime", + "sort_number", + ], args: [], }); @@ -206,6 +212,8 @@ export default function EntryFamily() { defaultFinanceCategoryRes.data.forEach((item) => { item.family_id = familyId; + item.icon_source = 0; + item.status = 1; }); // 默认记账分类 diff --git a/code/app/app-rn/src/app/(main)/family/settings.tsx b/code/app/app-rn/src/app/(main)/family/settings.tsx index 76edc6a7..c1948417 100644 --- a/code/app/app-rn/src/app/(main)/family/settings.tsx +++ b/code/app/app-rn/src/app/(main)/family/settings.tsx @@ -1,6 +1,6 @@ import { Ionicons } from "@expo/vector-icons"; import LayoutView from "@/components/layout/view"; -import { View, Text, Keyboard, TouchableOpacity } from "react-native"; +import { InteractionManager, Keyboard, View, Text, TouchableOpacity } from "react-native"; import PageLayout from "@/layouts/PageLayout"; import { Button, @@ -21,7 +21,7 @@ import { loadDataApi, saveDataApi } from "@/request/api"; import { useFamilyStore, useUserStore } from "@/store"; import { toast } from "sonner-native"; import { router } from "expo-router"; -import { CopyIcon, CheckIcon, XIcon } from "lucide-react-native"; +import { CopyIcon } from "lucide-react-native"; import * as Clipboard from "expo-clipboard"; import { formatRelativeTime } from "@/utils/time"; import { UserRoundIcon } from "lucide-react-native"; @@ -36,23 +36,23 @@ export default function HomeSettings() { const familyStore = useFamilyStore(); const userStore = useUserStore(); const { isLoading, startLoading, stopLoading } = useLoading(); + const familyNameInputRef = useRef(null); + const nicknameInputRef = useRef(null); const [family, setFamily] = useState({}); const [familyMemeber, setFamilyMember] = useState({}); // 家庭名称 - const [editingFamilyName, setEditingFamilyName] = useState(false); const [renameForm, setRenameForm] = useState({ name: "", }); - const renameInputRef = useRef(null); + const [showFamilyNameSheet, setShowFamilyNameSheet] = useState(false); // 我的昵称 - const [editingNickname, setEditingNickname] = useState(false); const [nicknameForm, setNicknameForm] = useState({ nickname: "", }); - const nicknameInputRef = useRef(null); + const [showNicknameSheet, setShowNicknameSheet] = useState(false); // 退出/解散 家庭 const [showExit, setShowExit] = useState(false); @@ -63,49 +63,30 @@ export default function HomeSettings() { // 成员 const [familyMembers, setFamilyMembers] = useState([]); - // 家庭名称编辑焦点管理 - useEffect(() => { - if (editingFamilyName) { - setTimeout(() => renameInputRef.current?.focus(), 100); - } else { - setRenameForm({ name: "" }); - Keyboard.dismiss(); - } - }, [editingFamilyName]); - - // 昵称编辑焦点管理 - useEffect(() => { - if (editingNickname) { - setTimeout(() => nicknameInputRef.current?.focus(), 100); - } else { - setNicknameForm({ nickname: "" }); - Keyboard.dismiss(); - } - }, [editingNickname]); - // 加载 async function loadFamilyData() { try { - const familyRes = await loadDataApi({ - view_name: `b_family`, - search_condition: `id = ?`, - search_columns: [], - order_by: "", - args: [familyStore.currentFamily?.family_id], - }); + const [familyRes, memberRes] = await Promise.all([ + loadDataApi({ + view_name: `b_family`, + search_condition: `id = ?`, + search_columns: [], + order_by: "", + args: [familyStore.currentFamily?.family_id], + }), + loadDataApi({ + view_name: `b_family_member`, + search_condition: `family_id = ? AND user_id = ?`, + search_columns: [], + order_by: "", + args: [familyStore.currentFamily.family_id, userStore.userInfo.id], + }), + ]); if (familyRes.isSuccess && familyRes.data.length > 0) { setFamily(familyRes.data[0]); } - const memberRes = await loadDataApi({ - view_name: `b_family_member`, - search_condition: `family_id = ? AND user_id = ?`, - search_columns: [], - order_by: "", - args: [familyStore.currentFamily.family_id, userStore.userInfo.id], - }); - if (memberRes.isSuccess && memberRes.data.length > 0) { setFamilyMember(memberRes.data[0]); } @@ -115,10 +96,33 @@ export default function HomeSettings() { } useEffect(() => { - loadFamilyData(); - loadMembers(); + const task = InteractionManager.runAfterInteractions(() => { + loadFamilyData(); + loadMembers(); + }); + return () => task.cancel(); }, []); + useEffect(() => { + if (showFamilyNameSheet) { + requestAnimationFrame(() => { + familyNameInputRef.current?.focus(); + }); + } else { + Keyboard.dismiss(); + } + }, [showFamilyNameSheet]); + + useEffect(() => { + if (showNicknameSheet) { + requestAnimationFrame(() => { + nicknameInputRef.current?.focus(); + }); + } else { + Keyboard.dismiss(); + } + }, [showNicknameSheet]); + async function handleRename() { if (isLoading) return; @@ -128,7 +132,7 @@ export default function HomeSettings() { } if (renameForm.name === family.name) { - setEditingFamilyName(false); + setShowFamilyNameSheet(false); toast.success("家庭名称未变化"); return; } @@ -159,7 +163,7 @@ export default function HomeSettings() { name: renameForm.name, })); - setEditingFamilyName(false); + setShowFamilyNameSheet(false); stopLoading({ type: "success", message: "家庭名称修改成功" }); } else { stopLoading({ type: "error", message: saveRes.message || "修改失败" }); @@ -179,7 +183,7 @@ export default function HomeSettings() { } if (nicknameForm.nickname === familyMemeber.nickname) { - setEditingNickname(false); + setShowNicknameSheet(false); toast.success("昵称未变化"); return; } @@ -210,7 +214,7 @@ export default function HomeSettings() { nickname: nicknameForm.nickname, })); - setEditingNickname(false); + setShowNicknameSheet(false); stopLoading({ type: "success", message: "昵称修改成功" }); } else { stopLoading({ type: "error", message: saveRes.message || "修改失败" }); @@ -396,8 +400,12 @@ export default function HomeSettings() { 常用设置 - {/* 家庭名称 */} - {editingFamilyName ? ( + { + setRenameForm({ name: family.name || "" }); + setShowFamilyNameSheet(true); + }} + > @@ -406,63 +414,18 @@ export default function HomeSettings() { 家庭名称 - - setRenameForm({ name: val })} - className="flex-1 min-w-[100px]" - placeholder="请输入家庭名称" - onSubmitEditing={handleRename} - returnKeyType="done" - /> - - - - - - - setEditingFamilyName(false)} - isDisabled={isLoading} - > - - - - - - + {family.name || "-"} - ) : ( - { - setEditingNickname(false); - setEditingFamilyName(true); - setRenameForm({ name: family.name }); - }} - > - - - - - - 家庭名称 - - - {family.name} - - - - - )} + + - - {/* 我的昵称 */} - {editingNickname ? ( + { + setNicknameForm({ nickname: familyMemeber.nickname || "" }); + setShowNicknameSheet(true); + }} + > @@ -471,62 +434,12 @@ export default function HomeSettings() { 我的昵称 - - setNicknameForm({ nickname: val })} - className="flex-1 min-w-[100px]" - placeholder="请输入昵称" - onSubmitEditing={handleNickname} - returnKeyType="done" - /> - - - - - - - setEditingNickname(false)} - isDisabled={isLoading} - > - - - - - - + {familyMemeber.nickname || "-"} - ) : ( - { - setEditingFamilyName(false); - setEditingNickname(true); - setNicknameForm({ nickname: familyMemeber.nickname }); - }} - > - - - - - - 我的昵称 - - - {familyMemeber.nickname} - - - - - )} - + + - setShowInvite(true)}> @@ -540,7 +453,6 @@ export default function HomeSettings() { - 其他操作 + + + + + + + + setRenameForm({ name })} + placeholder="请输入家庭名称" + onSubmitEditing={handleRename} + returnKeyType="done" + /> + + + + + + + + + + + + + + + setNicknameForm({ nickname })} + placeholder="请输入昵称" + onSubmitEditing={handleNickname} + returnKeyType="done" + /> + + + + + + + ); diff --git a/code/app/app-rn/src/app/(main)/family/user.tsx b/code/app/app-rn/src/app/(main)/family/user.tsx index 9cf4eaa5..4cc772d7 100644 --- a/code/app/app-rn/src/app/(main)/family/user.tsx +++ b/code/app/app-rn/src/app/(main)/family/user.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState, useRef } from "react"; -import { View, Text, Pressable, Keyboard } from "react-native"; +import { useEffect, useRef, useState } from "react"; +import { InteractionManager, Keyboard, View, Text, Pressable } from "react-native"; import PageLayout from "@/layouts/PageLayout"; import LayoutView from "@/components/layout/view"; import { @@ -8,8 +8,11 @@ import { ListGroup, Separator, Button, + BottomSheet, Dialog, Input, + Label, + TextField, } from "heroui-native"; import { useLoading } from "@/hooks/useLoading"; import { LoadingOverlay } from "@/components/LoadingOverlay"; @@ -18,7 +21,7 @@ import { useUserStore, useFamilyStore } from "@/store"; import { CameraIcon } from "lucide-react-native"; import ImagePicker from "react-native-image-crop-picker"; import { s3UploadFileApi, bindQqApi, bindWxApi } from "@/request/api"; -import { buildFileUrl } from "@/utils/file"; +import { buildFileUrl, deleteS3FileQuietly } from "@/utils/file"; import { PhoneIcon, CakeIcon, ContactRoundIcon } from "lucide-react-native"; import { IconQq, IconWeixin } from "@/components/iconfont"; import { useRouter } from "expo-router"; @@ -30,7 +33,6 @@ import { useEvent } from "expo"; import { toast } from "sonner-native"; import DateTime from "@/components/DateTime"; import { toDateString } from "@/utils/time"; -import { CheckIcon, XIcon } from "lucide-react-native"; import { KeyboardAvoidingView } from "react-native-keyboard-controller"; const StyledIonicons = withUniwind(Ionicons); @@ -40,6 +42,8 @@ export default function UserSetting() { const userStore = useUserStore(); const familyStore = useFamilyStore(); const { isLoading, startLoading, stopLoading } = useLoading(); + const nameInputRef = useRef(null); + const phoneInputRef = useRef(null); const [userInfo, setUserInfo] = useState({}); const [bindQq, setBindQq] = useState(false); @@ -53,18 +57,16 @@ export default function UserSetting() { const authResult = useEvent(ExpoWeChat, "onAuthResult"); // 名称 - const [editingName, setEditingName] = useState(false); const [renameForm, setRenameForm] = useState({ name: "", }); - const nameInputRef = useRef(null); + const [showNameSheet, setShowNameSheet] = useState(false); // 手机号 - const [editingPhone, setEditingPhone] = useState(false); const [phoneForm, setPhoneForm] = useState({ phone: "", }); - const phoneInputRef = useRef(null); + const [showPhoneSheet, setShowPhoneSheet] = useState(false); // 年龄 const [showBirth, setShowBirth] = useState(false); @@ -74,34 +76,40 @@ export default function UserSetting() { }); useEffect(() => { - loadUserInfo(); + const task = InteractionManager.runAfterInteractions(() => { + loadUserInfo(); + }); + return () => task.cancel(); }, []); useEffect(() => { - ExpoQQ.init("102826474", ""); - ExpoWeChat.registerApp("wxdab3e21a1f7e392f", ""); + if (showNameSheet) { + requestAnimationFrame(() => { + nameInputRef.current?.focus(); + }); + } else { + Keyboard.dismiss(); + } + }, [showNameSheet]); + + useEffect(() => { + if (showPhoneSheet) { + requestAnimationFrame(() => { + phoneInputRef.current?.focus(); + }); + } else { + Keyboard.dismiss(); + } + }, [showPhoneSheet]); + + useEffect(() => { + const task = InteractionManager.runAfterInteractions(() => { + ExpoQQ.init("102826474", ""); + ExpoWeChat.registerApp("wxdab3e21a1f7e392f", ""); + }); + return () => task.cancel(); }, []); - // 名称编辑焦点管理 - useEffect(() => { - if (editingName) { - setTimeout(() => nameInputRef.current?.focus(), 100); - } else { - setRenameForm({ name: "" }); - Keyboard.dismiss(); - } - }, [editingName]); - - // 手机号编辑焦点管理 - useEffect(() => { - if (editingPhone) { - setTimeout(() => phoneInputRef.current?.focus(), 100); - } else { - setPhoneForm({ phone: "" }); - Keyboard.dismiss(); - } - }, [editingPhone]); - async function loadUserInfo() { try { const userId = userStore.userInfo.id; @@ -278,7 +286,8 @@ export default function UserSetting() { const key = uploadRes.data?.[0]; if (!key) return; - saveDataApi([ + const oldAvatar = userStore.userInfo.avatar; + const saveRes = await saveDataApi([ { table_name: "b_user", key_field: "id", @@ -291,12 +300,22 @@ export default function UserSetting() { }, ], }, - ]).catch(console.log); + ]); + + if (!saveRes.isSuccess) { + toast.error(saveRes.message || "头像保存失败"); + deleteS3FileQuietly(key); + return; + } userStore.setUserInfo({ ...userStore.userInfo, avatar: key, }); + + if (oldAvatar && oldAvatar !== key) { + deleteS3FileQuietly(oldAvatar); + } } catch (e) { console.log("图片选择/上传失败", e); } @@ -323,7 +342,7 @@ export default function UserSetting() { } if (renameForm.name === userInfo.name) { - setEditingName(false); + setShowNameSheet(false); toast.success("名称未变化"); return; } @@ -374,7 +393,7 @@ export default function UserSetting() { name: renameForm.name, })); - setEditingName(false); + setShowNameSheet(false); stopLoading({ type: "success", message: "名称修改成功" }); } else { stopLoading({ type: "error", message: saveRes.message || "修改失败" }); @@ -395,7 +414,7 @@ export default function UserSetting() { } if (phoneForm.phone === userInfo.phone) { - setEditingPhone(false); + setShowPhoneSheet(false); toast.success("手机号未变化"); return; } @@ -426,7 +445,7 @@ export default function UserSetting() { phone: phoneForm.phone, })); - setEditingPhone(false); + setShowPhoneSheet(false); stopLoading({ type: "success", message: "手机号修改成功" }); } else { stopLoading({ type: "error", message: saveRes.message || "修改失败" }); @@ -526,7 +545,12 @@ export default function UserSetting() { 基础信息 {/* 姓名 */} - {editingName ? ( + { + setRenameForm({ name: userInfo.name || "" }); + setShowNameSheet(true); + }} + > @@ -535,62 +559,19 @@ export default function UserSetting() { 姓名 - - setRenameForm({ name: val })} - className="flex-1 min-w-[100px]" - placeholder="请输入姓名" - onSubmitEditing={handleRename} - returnKeyType="done" - /> - - - - - - - setEditingName(false)} - isDisabled={isLoading} - > - - - - - - + {userInfo.name || "-"} - ) : ( - { - setEditingPhone(false); - setEditingName(true); - setRenameForm({ name: userInfo.name }); - }} - > - - - - - - 姓名 - - - {userInfo.name || "-"} - - - - - )} + + {/* 手机号 */} - {editingPhone ? ( + { + setPhoneForm({ phone: userInfo.phone || "" }); + setShowPhoneSheet(true); + }} + > @@ -599,60 +580,11 @@ export default function UserSetting() { 手机号 - - setPhoneForm({ phone: val })} - className="flex-1 min-w-[100px]" - placeholder="请输入手机号" - inputMode="tel" - onSubmitEditing={handlePhone} - returnKeyType="done" - /> - - - - - - - setEditingPhone(false)} - isDisabled={isLoading} - > - - - - - - + {userInfo.phone || "-"} - ) : ( - { - setEditingName(false); - setEditingPhone(true); - setPhoneForm({ phone: userInfo.phone }); - }} - > - - - - - - 手机号 - - - {userInfo.phone || "-"} - - - - - )} + + {/* 生日 */} + + + + + + + + setRenameForm({ name })} + placeholder="请输入姓名" + onSubmitEditing={handleRename} + returnKeyType="done" + /> + + + + + + + + + + + + + + + setPhoneForm({ phone })} + placeholder="请输入手机号" + inputMode="tel" + onSubmitEditing={handlePhone} + returnKeyType="done" + /> + + + + + + + s.getFamilyId()); useEffect(() => { const categories = getCategories(); if (categories.length > 0 || !familyId) return; - loadDataApi({ - view_name: "b_finance_category", - search_columns: [], - search_condition: "family_id = ?", - order_by: "", - args: [familyId], - }).then((res) => { - if (res.isSuccess) { - setCategories(res.data); - } - }); - }, [familyId, getCategories, setCategories]); + loadCategories(familyId); + }, [familyId, getCategories, loadCategories]); return ( <> diff --git a/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx index 1c12e8bb..9cf0b83a 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx @@ -1,46 +1,527 @@ -import { ScrollShadow, Surface } from "heroui-native"; -import { LinearGradient } from "expo-linear-gradient"; -import { FlatList, ScrollView, Text, View } from "react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { CartesianChart, Bar, Line } from "victory-native"; +import dayjs from "dayjs"; +import { CalendarDaysIcon, RotateCcwIcon } from "lucide-react-native"; -export default function ScrollShadowExample() { - const horizontalData = Array.from({ length: 10 }, (_, i) => ({ - id: i, - title: `Card ${i + 1}`, - })); +import DateTimeRange from "@/components/DateTimeRange"; +import EmptyState from "@/components/EmptyStatus"; +import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; +import LayoutView from "@/components/layout/view"; +import { useListLoading } from "@/hooks/useLoading"; +import { loadDataBySqlApi } from "@/request/api"; +import { useFamilyStore } from "@/store"; +import { FinanceBill, FinanceType } from "@/types/finance"; +import { getEndOfMonth, getStartOfMonth } from "@/utils/time"; + +interface FilterState { + startDate: string; + endDate: string; +} + +interface TrendDatum { + [key: string]: string | number; + day: string; + label: string; + expense: number; + income: number; +} + +interface CategoryStat { + categoryId: number; + categoryName: string; + iconFileKey?: string; + type: FinanceType; + amount: number; + count: number; + percent: number; +} + +interface MemberStat { + memberId: number; + nickname: string; + expense: number; + income: number; + count: number; +} + +const moneyFormatter = new Intl.NumberFormat("zh-CN", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function createDefaultFilter(): FilterState { + return { + startDate: getStartOfMonth(), + endDate: getEndOfMonth(), + }; +} + +function formatMoney(value: number) { + return moneyFormatter.format(value || 0); +} + +function formatCompactMoney(value: number) { + const absValue = Math.abs(value); + if (absValue >= 10000) { + return `${formatMoney(value / 10000)}万`; + } + return formatMoney(value); +} + +function getDateRange(startDate: string, endDate: string) { + const days: string[] = []; + const start = dayjs(startDate); + const end = dayjs(endDate); + const count = Math.max(end.diff(start, "day"), 0); + + for (let i = 0; i <= count; i += 1) { + days.push(start.add(i, "day").format("YYYY-MM-DD")); + } + + return days; +} + +function getTypeLabel(type: FinanceType) { + return type === FinanceType.Income ? "收入" : "支出"; +} + +export default function FinanceAnalysis() { + const familyId = useFamilyStore((s) => s.getFamilyId()); + const { loading, runInitial } = useListLoading(); + + const [filter, setFilter] = useState(() => createDefaultFilter()); + const [showDateRange, setShowDateRange] = useState(false); + const [bills, setBills] = useState([]); + + const loadBills = useCallback( + async (nextFilter = filter) => { + if (!familyId) { + setBills([]); + return; + } + + const res = await loadDataBySqlApi({ + sql: ` + select + f.id, + f.family_id, + f.category_id, + f.amount, + f.remark, + f.record_time, + f.create_by, + f.create_time, + f.update_by, + f.update_time, + c.name as category_name, + c.type as category_type, + c.icon_file_key as category_icon_file_key, + coalesce(m.nickname, u.name) as member_nickname + from b_finance f + left join b_finance_category c on c.id = f.category_id + left join b_family_member m on m.family_id = f.family_id and m.user_id = f.create_by + left join b_user u on u.id = f.create_by + where f.family_id = ? + and f.record_time >= ? + and f.record_time < ? + order by f.record_time asc, f.create_time asc, f.id asc + `, + args: [ + familyId, + nextFilter.startDate, + dayjs(nextFilter.endDate).add(1, "day").format("YYYY-MM-DD"), + ], + }); + + if (res.isSuccess) { + setBills((res.data ?? []) as FinanceBill[]); + } + }, + [familyId, filter], + ); + + useEffect(() => { + runInitial(() => loadBills(filter)); + }, [familyId]); + + const totals = useMemo( + () => + bills.reduce( + (sum, item) => { + const amount = Number(item.amount) || 0; + if (item.category_type === FinanceType.Income) { + sum.income += amount; + } else { + sum.expense += amount; + } + sum.count += 1; + return sum; + }, + { expense: 0, income: 0, count: 0 }, + ), + [bills], + ); + + const trendData = useMemo(() => { + const map = new Map(); + + getDateRange(filter.startDate, filter.endDate).forEach((day) => { + map.set(day, { + day, + label: dayjs(day).format("MM/DD"), + expense: 0, + income: 0, + }); + }); + + bills.forEach((item) => { + const day = dayjs(item.record_time).format("YYYY-MM-DD"); + const stat = + map.get(day) ?? + ({ + day, + label: dayjs(day).format("MM/DD"), + expense: 0, + income: 0, + } satisfies TrendDatum); + const amount = Number(item.amount) || 0; + + if (item.category_type === FinanceType.Income) { + stat.income += amount; + } else { + stat.expense += amount; + } + + map.set(day, stat); + }); + + return Array.from(map.values()); + }, [bills, filter]); + + const categoryStats = useMemo(() => { + const map = new Map>(); + + bills.forEach((item) => { + const amount = Number(item.amount) || 0; + const key = `${item.category_type}-${item.category_id}`; + const stat = map.get(key) ?? { + categoryId: item.category_id, + categoryName: item.category_name || "未分类", + iconFileKey: item.category_icon_file_key, + type: item.category_type, + amount: 0, + count: 0, + }; + + stat.amount += amount; + stat.count += 1; + map.set(key, stat); + }); + + return Array.from(map.values()) + .map((item) => ({ + ...item, + percent: + item.type === FinanceType.Income + ? totals.income > 0 + ? item.amount / totals.income + : 0 + : totals.expense > 0 + ? item.amount / totals.expense + : 0, + })) + .sort((a, b) => b.amount - a.amount) + .slice(0, 8); + }, [bills, totals.expense, totals.income]); + + const memberStats = useMemo(() => { + const map = new Map(); + + bills.forEach((item) => { + const memberId = Number(item.create_by) || 0; + const stat = map.get(memberId) ?? { + memberId, + nickname: item.member_nickname || "未知成员", + expense: 0, + income: 0, + count: 0, + }; + const amount = Number(item.amount) || 0; + + if (item.category_type === FinanceType.Income) { + stat.income += amount; + } else { + stat.expense += amount; + } + + stat.count += 1; + map.set(memberId, stat); + }); + + return Array.from(map.values()) + .sort((a, b) => b.expense + b.income - (a.expense + a.income)) + .slice(0, 6); + }, [bills]); + + const maxTrendValue = useMemo( + () => + Math.max( + 1, + ...trendData.map((item) => Math.max(Number(item.expense) || 0, Number(item.income) || 0)), + ), + [trendData], + ); + + const handleApplyDateRange = useCallback( + (startDate: string, endDate: string) => { + const nextFilter = { + startDate, + endDate: endDate || startDate, + }; + setFilter(nextFilter); + setShowDateRange(false); + runInitial(() => loadBills(nextFilter)); + }, + [loadBills, runInitial], + ); + + const handleReset = useCallback(() => { + const nextFilter = createDefaultFilter(); + setFilter(nextFilter); + runInitial(() => loadBills(nextFilter)); + }, [loadBills, runInitial]); + + const balance = totals.income - totals.expense; + const hasData = bills.length > 0; return ( - - Horizontal List - - ( - - {item.title} - - )} - showsHorizontalScrollIndicator={false} - contentContainerClassName="p-5 gap-4" - /> - + + + + + + 分析范围 + + {filter.startDate} 至 {filter.endDate} + + - Vertical Content - - - Long Content - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor - incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud - exercitation ullamco laboris. + + + + + setShowDateRange(true)} + > + + 日期 + + + + + + + + + + = 0 ? "income" : "expense"} /> + + + + + {loading ? ( + + ) : !hasData ? ( + + ) : ( + <> +
+ + + data={trendData} + xKey="label" + yKeys={["expense", "income"]} + domain={{ y: [0, maxTrendValue] }} + domainPadding={{ left: 18, right: 18, top: 16 }} + axisOptions={{ + lineColor: "#E5E7EB", + labelColor: "#6B7280", + tickCount: 4, + }} + > + {({ points, chartBounds }) => ( + <> + + + + )} + + + + + + +
+ +
+ + {categoryStats.map((item, index) => ( + + ))} + +
+ +
+ + {memberStats.map((item, index) => ( + + ))} + +
+ + )} +
+ + setShowDateRange(false)} + onConfirm={handleApplyDateRange} + /> +
+ ); +} + +function SummaryCard({ + label, + value, + tone, +}: { + label: string; + value: string; + tone: "expense" | "income" | "neutral"; +}) { + const valueClass = + tone === "expense" ? "text-rose-500" : tone === "income" ? "text-emerald-600" : "text-gray-900"; + + return ( + + {label} + + {value} + + + ); +} + +function Section({ + title, + subtitle, + children, +}: { + title: string; + subtitle: string; + children: React.ReactNode; +}) { + return ( + + + {title} + {subtitle} + + {children} + + ); +} + +function LegendDot({ color, label }: { color: string; label: string }) { + return ( + + + {label} + + ); +} + +function CategoryRow({ item, isLast }: { item: CategoryStat; isLast: boolean }) { + const isIncome = item.type === FinanceType.Income; + const percentText = `${Math.round(item.percent * 100)}%`; + + return ( + + + + + + + + + {item.categoryName} + + {getTypeLabel(item.type)} + + + {item.count} 笔 · {percentText} - - Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque - laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi - architecto beatae vitae. - - - + + + ¥{formatMoney(item.amount)} + + + + + + + ); +} + +function MemberRow({ item, isLast }: { item: MemberStat; isLast: boolean }) { + const balance = item.income - item.expense; + + return ( + + + {item.nickname.slice(0, 1)} + + + + {item.nickname} + + + {item.count} 笔 · 支出 ¥{formatMoney(item.expense)} · 收入 ¥{formatMoney(item.income)} + + + = 0 ? "text-emerald-600" : "text-rose-500"}`}> + {balance >= 0 ? "+" : "-"}¥{formatMoney(Math.abs(balance))} + ); } diff --git a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx index f4f606a1..0040b0d5 100644 --- a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx @@ -1,25 +1,576 @@ -import React, { useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { BottomSheet, Button, Input, Label, Menu, TextField } from "heroui-native"; +import { router, useFocusEffect } from "expo-router"; +import { + CalendarDaysIcon, + CheckIcon, + FileTextIcon, + FilterIcon, + PencilIcon, + RotateCcwIcon, + SearchIcon, + Trash2Icon, +} from "lucide-react-native"; +import dayjs from "dayjs"; import LayoutView from "@/components/layout/view"; -import { Text } from "react-native"; import EmptyState from "@/components/EmptyStatus"; -import { loadDataApi } from "@/request/api"; +import DateTimeRange from "@/components/DateTimeRange"; +import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; +import { loadDataBySqlApi, saveDataApi } from "@/request/api"; +import { useFamilyStore, useFinanceStore, useUserStore } from "@/store"; +import { FinanceBill, FinanceCategory, FinanceType } from "@/types/finance"; +import { getEndOfMonth, getStartOfMonth } from "@/utils/time"; +import { useListLoading, useLoading } from "@/hooks/useLoading"; +import { LoadingOverlay } from "@/components/LoadingOverlay"; + +interface FinanceBillSection { + title: string; + expenseTotal: number; + incomeTotal: number; + data: FinanceBill[]; +} + +interface FilterState { + startDate: string; + endDate: string; + categoryId: number | null; + keyword: string; +} + +const moneyFormatter = new Intl.NumberFormat("zh-CN", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function formatMoney(value: number) { + return moneyFormatter.format(value || 0); +} + +function getCategoryTypeLabel(type: FinanceType) { + return type === FinanceType.Income ? "收入" : "支出"; +} + +function createDefaultFilter(): FilterState { + return { + startDate: getStartOfMonth(), + endDate: getEndOfMonth(), + categoryId: null, + keyword: "", + }; +} export default function Home() { - const [dataList, setDataList] = useState([]); + const familyId = useFamilyStore((s) => s.getFamilyId()); + const userId = useUserStore((s) => s.getUserId()); + const categories = useFinanceStore((s) => s.categories); + const loadCategories = useFinanceStore((s) => s.loadCategories); + const { loading, refreshing, runInitial, runRefresh } = useListLoading(); + const { isLoading, startLoading, stopLoading } = useLoading(); + + const [filter, setFilter] = useState(() => createDefaultFilter()); + const [draftFilter, setDraftFilter] = useState(() => createDefaultFilter()); + const [showFilterSheet, setShowFilterSheet] = useState(false); + const [showDateRange, setShowDateRange] = useState(false); + const [bills, setBills] = useState([]); + const [activeBill, setActiveBill] = useState(null); + const showActionMenu = activeBill != null; + + const selectedCategory = useMemo( + () => categories.find((item) => item.id === filter.categoryId) ?? null, + [categories, filter.categoryId], + ); + + const totals = useMemo( + () => + bills.reduce( + (sum, item) => { + if (item.category_type === FinanceType.Income) { + sum.income += Number(item.amount) || 0; + } else { + sum.expense += Number(item.amount) || 0; + } + return sum; + }, + { expense: 0, income: 0 }, + ), + [bills], + ); + + const sections = useMemo(() => { + const map = new Map(); + + bills.forEach((item) => { + const date = dayjs(item.record_time).format("YYYY-MM-DD"); + const section = map.get(date) ?? { + title: date, + expenseTotal: 0, + incomeTotal: 0, + data: [], + }; + + if (item.category_type === FinanceType.Income) { + section.incomeTotal += Number(item.amount) || 0; + } else { + section.expenseTotal += Number(item.amount) || 0; + } + section.data.push(item); + map.set(date, section); + }); + + return Array.from(map.values()); + }, [bills]); + + const activeFilterCount = useMemo(() => { + let count = 0; + if (filter.categoryId) count += 1; + if (filter.keyword.trim()) count += 1; + if (filter.startDate !== getStartOfMonth() || filter.endDate !== getEndOfMonth()) count += 1; + return count; + }, [filter]); + + const loadBills = useCallback( + async (nextFilter = filter) => { + if (!familyId) return; + + const conditions = ["f.family_id = ?", "f.record_time >= ?", "f.record_time < ?"]; + const args: any[] = [ + familyId, + nextFilter.startDate, + dayjs(nextFilter.endDate).add(1, "day").format("YYYY-MM-DD"), + ]; + + if (nextFilter.categoryId) { + conditions.push("f.category_id = ?"); + args.push(nextFilter.categoryId); + } + + const keyword = nextFilter.keyword.trim(); + if (keyword) { + conditions.push("coalesce(f.remark, '') like ?"); + args.push(`%${keyword}%`); + } + + const res = await loadDataBySqlApi({ + sql: ` + select + f.id, + f.family_id, + f.category_id, + f.amount, + f.remark, + f.record_time, + f.create_by, + f.create_time, + f.update_by, + f.update_time, + c.name as category_name, + c.type as category_type, + c.icon_file_key as category_icon_file_key, + coalesce(m.nickname, u.name) as member_nickname, + coalesce(um.nickname, uu.name) as update_member_nickname + from b_finance f + left join b_finance_category c on c.id = f.category_id + left join b_family_member m on m.family_id = f.family_id and m.user_id = f.create_by + left join b_user u on u.id = f.create_by + left join b_family_member um on um.family_id = f.family_id and um.user_id = f.update_by + left join b_user uu on uu.id = f.update_by + where ${conditions.join(" and ")} + order by f.record_time desc, f.create_time desc, f.id desc + `, + args, + }); + + if (res.isSuccess) { + setBills((res.data ?? []) as FinanceBill[]); + } + }, + [familyId, filter], + ); + + useEffect(() => { + if (!familyId) return; + if (categories.length === 0) { + loadCategories(familyId); + } + runInitial(() => loadBills(filter)); + }, [familyId]); + + useFocusEffect( + useCallback(() => { + if (!familyId) return; + loadBills(filter); + }, [familyId, filter, loadBills]), + ); + + const handleRefresh = useCallback(() => { + runRefresh(() => loadBills(filter)); + }, [filter, loadBills, runRefresh]); + + const openFilterSheet = useCallback(() => { + setDraftFilter(filter); + setShowFilterSheet(true); + }, [filter]); + + const handleApplyFilter = useCallback(() => { + setFilter(draftFilter); + setShowFilterSheet(false); + runInitial(() => loadBills(draftFilter)); + }, [draftFilter, loadBills, runInitial]); + + const handleResetFilter = useCallback(() => { + setDraftFilter(createDefaultFilter()); + }, []); + + const closeActionMenu = useCallback(() => { + setActiveBill(null); + }, []); + + const handleEditBill = useCallback(() => { + if (!activeBill) return; + const id = activeBill.id; + closeActionMenu(); + router.push(`/finance/add?id=${id}` as any); + }, [activeBill, closeActionMenu]); + + const handleOpenDetail = useCallback(() => { + if (!activeBill) return; + const id = activeBill.id; + closeActionMenu(); + router.push(`/finance/detail/${id}` as any); + }, [activeBill, closeActionMenu]); + + const handleDeleteBill = useCallback(async () => { + if (!activeBill || isLoading) return; + + const id = activeBill.id; + closeActionMenu(); + startLoading("删除中..."); + + try { + const res = await saveDataApi([ + { + table_name: "b_finance", + key_field: "id", + inserts: [], + updates: [], + deletes: [{ id }], + }, + ]); + + if (res.isSuccess) { + setBills((prev) => prev.filter((item) => item.id !== id)); + stopLoading({ type: "success", message: "已删除" }); + } else { + stopLoading({ type: "error", message: res.message || "删除失败" }); + } + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [activeBill, closeActionMenu, isLoading, startLoading, stopLoading]); + + const renderHeader = useCallback( + () => ( + + + + 当前范围 + + {filter.startDate} 至 {filter.endDate} + + + + + + + 筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""} + + + + + + + 支出合计 + + ¥{formatMoney(totals.expense)} + + + + 收入合计 + + ¥{formatMoney(totals.income)} + + + + + + + + 分类:{selectedCategory ? selectedCategory.name : "全部"} + + + + + 备注:{filter.keyword.trim() || "全部"} + + + + + ), + [activeFilterCount, filter, openFilterSheet, selectedCategory, totals], + ); + + const renderSectionHeader = useCallback( + ({ section }: { section: any }) => { + const billSection = section as FinanceBillSection; + + return ( + + + + + {dayjs(billSection.title).format("MM月DD日")} + + + {dayjs(billSection.title).format("YYYY年")} + + + + + 支出 ¥{formatMoney(billSection.expenseTotal)} + + + 收入 ¥{formatMoney(billSection.incomeTotal)} + + + + + ); + }, + [], + ); + + const renderItem = useCallback( + ({ item }: { item: FinanceBill }) => { + const isIncome = item.category_type === FinanceType.Income; + const isOwnRecord = String(item.create_by ?? "") === String(userId ?? ""); + const remark = item.remark?.trim(); + + return ( + setActiveBill(item)} + > + + + + + + + + {item.category_name || "未分类"} + + {!isOwnRecord && !!item.member_nickname && ( + + {item.member_nickname} + + )} + + + {remark || "无备注"} + + + + + + {isIncome ? "+" : "-"}¥{formatMoney(Number(item.amount))} + + + {getCategoryTypeLabel(item.category_type)} + + + + ); + }, + [userId], + ); + + const renderCategoryOption = useCallback( + (item: FinanceCategory) => { + const selected = draftFilter.categoryId === item.id; + + return ( + + setDraftFilter((prev) => ({ + ...prev, + categoryId: prev.categoryId === item.id ? null : item.id, + })) + } + > + + + {item.name} + + + ); + }, + [draftFilter.categoryId], + ); return ( - - sections={dataList} - keyExtractor={(item) => item.id.toString()} - stickySectionHeadersEnabled={true} + + sections={sections} + keyExtractor={(item) => String(item.id)} + renderItem={renderItem} + renderSectionHeader={renderSectionHeader} + header={renderHeader()} + stickySectionHeadersEnabled={false} showsVerticalScrollIndicator={false} + refreshing={refreshing} + onRefresh={handleRefresh} + contentContainerStyle={sections.length === 0 ? { flexGrow: 1 } : undefined} ListEmptyComponent={() => ( - + )} - > - Hello - + /> + + + + + + + + 筛选记账 + + + 重置 + + + + + + setShowDateRange(true)} + > + + {draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate} + + + + + + + + setDraftFilter((prev) => ({ ...prev, keyword }))} + placeholder="按备注模糊搜索" + returnKeyType="search" + /> + + + + + + setDraftFilter((prev) => ({ ...prev, categoryId: null }))} + > + + 全部 + + {categories.map(renderCategoryOption)} + + + + + + + + + + + + + setShowDateRange(false)} + onConfirm={(startDate, endDate) => { + setDraftFilter((prev) => ({ + ...prev, + startDate, + endDate: endDate || startDate, + })); + setShowDateRange(false); + }} + /> + + { if (!open) closeActionMenu(); }}> + + + + + + + 编辑 + + + + 删除 + + + + 详细 + + + + + + + ); } diff --git a/code/app/app-rn/src/app/finance/add/index.tsx b/code/app/app-rn/src/app/finance/add/index.tsx index 21748d7a..007d2370 100644 --- a/code/app/app-rn/src/app/finance/add/index.tsx +++ b/code/app/app-rn/src/app/finance/add/index.tsx @@ -1,19 +1,20 @@ -import { View, Text, TouchableOpacity, TextInput } from "react-native"; -import { Stack } from "expo-router"; +import { View, Text, TouchableOpacity, TextInput, Keyboard } from "react-native"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import PageLayout from "@/layouts/PageLayout"; -import { useState, useMemo, useCallback, memo } from "react"; +import { useState, useMemo, useCallback, memo, useEffect, useRef } from "react"; import LayoutView from "@/components/layout/view"; import { Tabs, Button, BottomSheet, Input, Label, TextField, PressableFeedback } from "heroui-native"; import { useFinanceStore } from "@/store/finance"; import { useFamilyStore } from "@/store"; -import { getUniqueIdApi, saveDataApi } from "@/request/api"; -import IconFont from "@/components/iconfont"; +import { getUniqueIdApi, loadDataApi, saveDataApi } from "@/request/api"; import { getCurrentDate } from "@/utils/time"; import { useLoading } from "@/hooks/useLoading"; import { LoadingOverlay } from "@/components/LoadingOverlay"; import DateTime from "@/components/DateTime"; import { toast } from "sonner-native"; -import { SearchIcon, ChevronRight } from "lucide-react-native"; +import { SearchIcon, ChevronRight, Settings2Icon } from "lucide-react-native"; +import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; +import { FinanceCategory, FinanceType } from "@/types/finance"; // ─── 分类项(memo 避免选中状态变化时全量重渲染)───────── const CategoryItem = memo(function CategoryItem({ @@ -21,18 +22,26 @@ const CategoryItem = memo(function CategoryItem({ isActive, onPress, }: { - item: any; + item: FinanceCategory; isActive: boolean; - onPress: () => void; + onPress: (item: FinanceCategory) => void; }) { + const handlePress = useCallback(() => { + onPress(item); + }, [item, onPress]); + return ( - + - + (); + const editId = id ? Number(id) : 0; + const isEdit = !!editId; const categories = useFinanceStore((s) => s.categories); const familyId = useFamilyStore((s) => s.currentFamily.family_id); const { isLoading, startLoading, stopLoading } = useLoading(); + const amountInputRef = useRef(null); const [activeTab, setActiveTab] = useState("0"); // 0=支出 1=收入 const [keyword, setKeyword] = useState(""); - const [selectedCategory, setSelectedCategory] = useState(null); + const [selectedCategoryId, setSelectedCategoryId] = useState(null); const [amount, setAmount] = useState(""); const [date, setDate] = useState(getCurrentDate()); @@ -60,18 +74,77 @@ export default function Add() { const [showDatePicker, setShowDatePicker] = useState(false); const allCategories = useMemo(() => { - const type = activeTab === "0" ? 0 : 1; - const cats = categories.filter((c: any) => c.type === type); + const type = activeTab === "0" ? FinanceType.Expense : FinanceType.Income; + const cats = categories.filter((c) => c.type === type); if (!keyword.trim()) return cats; - return cats.filter((c: any) => c.name.includes(keyword.trim())); + return cats.filter((c) => c.name.includes(keyword.trim())); }, [activeTab, keyword, categories]); + const selectedCategory = useMemo( + () => categories.find((category) => category.id === selectedCategoryId) ?? null, + [categories, selectedCategoryId] + ); + const isSheetOpen = selectedCategory != null; + + const handleSelectCategory = useCallback((item: FinanceCategory) => { + setSelectedCategoryId(item.id); + }, []); + + const handleSheetOpenChange = useCallback((open: boolean) => { + if (!open) { + setSelectedCategoryId(null); + } + }, []); + const resetForm = useCallback(() => { setAmount(""); setDate(getCurrentDate()); setRemark(""); }, []); + useEffect(() => { + if (!isEdit || !editId) return; + + const loadBill = async () => { + const res = await loadDataApi({ + view_name: "b_finance", + search_condition: "id = ? and family_id = ?", + search_columns: [], + order_by: "", + args: [editId, familyId], + }); + + if (!res.isSuccess || !res.data?.length) { + toast.error("记账记录不存在"); + router.back(); + return; + } + + const bill = res.data[0]; + setSelectedCategoryId(bill.category_id); + setAmount(String(bill.amount ?? "")); + setDate(bill.record_time ? String(bill.record_time).slice(0, 10) : getCurrentDate()); + setRemark(bill.remark ?? ""); + }; + + loadBill(); + }, [editId, familyId, isEdit, router]); + + useEffect(() => { + if (!isEdit || !selectedCategory) return; + setActiveTab(String(selectedCategory.type)); + }, [isEdit, selectedCategory]); + + useEffect(() => { + if (isSheetOpen) { + requestAnimationFrame(() => { + amountInputRef.current?.focus(); + }); + } else { + Keyboard.dismiss(); + } + }, [isSheetOpen]); + const handleSave = useCallback( async (keepOpen: boolean) => { if (!amount || !selectedCategory) { @@ -81,45 +154,63 @@ export default function Add() { if (isLoading) return; startLoading("保存中..."); try { - const idRes = await getUniqueIdApi(1); - const newId = idRes.data?.[0]; - if (!newId) { toast.error("获取ID失败"); stopLoading(); return; } + const row = { + id: editId, + category_id: selectedCategory.id, + family_id: familyId, + amount: Number(amount), + remark, + record_time: date, + }; - await saveDataApi([{ - table_name: "b_finance", - key_field: "id", - inserts: [{ - id: newId, - category_id: selectedCategory.id, - family_id: familyId, - amount: Number(amount), - remark, - record_time: date, - }], - updates: [], - deletes: [], - }]); + if (isEdit) { + await saveDataApi([{ + table_name: "b_finance", + key_field: "id", + inserts: [], + updates: [row], + deletes: [], + }]); + } else { + const idRes = await getUniqueIdApi(1); + const newId = idRes.data?.[0]; + if (!newId) { toast.error("获取ID失败"); stopLoading(); return; } + + await saveDataApi([{ + table_name: "b_finance", + key_field: "id", + inserts: [{ + ...row, + id: newId, + }], + updates: [], + deletes: [], + }]); + } const label = activeTab === "0" ? "支出" : "收入"; - toast.success(`已记${label} ¥${amount}`); + toast.success(isEdit ? "记账已更新" : `已记${label} ¥${amount}`); if (keepOpen) { setAmount(""); setRemark(""); } else { resetForm(); - setSelectedCategory(null); + setSelectedCategoryId(null); + if (isEdit) { + router.back(); + } } - } catch (e) { + } catch { toast.error("保存失败"); } finally { stopLoading(); } }, - [amount, selectedCategory, activeTab, resetForm, isLoading, startLoading, stopLoading, familyId, date, remark] + [amount, selectedCategory, activeTab, resetForm, isLoading, startLoading, stopLoading, familyId, date, remark, editId, isEdit, router] ); return ( - + @@ -137,7 +228,8 @@ export default function Add() { {/* 搜索 */} - + + + + router.push("/finance/category" as any)} + > + + {/* 分类网格 */} - {allCategories.map((item: any) => ( + {allCategories.map((item) => ( setSelectedCategory(item)} + onPress={handleSelectCategory} /> ))} @@ -162,16 +261,24 @@ export default function Add() { {/* 记账 BottomSheet */} - { if (!open) setSelectedCategory(null); }}> + - + {/* 选中的分类 */} {selectedCategory && ( - + {selectedCategory.name} @@ -183,6 +290,7 @@ export default function Add() { 保存 - + {!isEdit && ( + + )} diff --git a/code/app/app-rn/src/app/finance/category/index.tsx b/code/app/app-rn/src/app/finance/category/index.tsx new file mode 100644 index 00000000..1a3c9dc3 --- /dev/null +++ b/code/app/app-rn/src/app/finance/category/index.tsx @@ -0,0 +1,389 @@ +import { useCallback, useMemo, useState } from "react"; +import { Text, TouchableOpacity, View } from "react-native"; +import { Stack } from "expo-router"; +import ImagePicker from "react-native-image-crop-picker"; +import { BottomSheet, Button, Input, Label, Tabs, TextField } from "heroui-native"; +import { GripVerticalIcon, ImageIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react-native"; +import Sortable, { SortableGridRenderItem } from "react-native-sortables"; +import PageLayout from "@/layouts/PageLayout"; +import LayoutView from "@/components/layout/view"; +import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; +import { getUniqueIdApi, saveDataApi, s3UploadFileApi } from "@/request/api"; +import { useFamilyStore, useFinanceStore } from "@/store"; +import { + FinanceCategory, + FinanceCategoryIconSource, + FinanceCategoryStatus, + FinanceType, +} from "@/types/finance"; +import { toast } from "sonner-native"; +import { deleteS3FileQuietly } from "@/utils/file"; + +interface CategoryFormState { + id?: number; + name: string; + type: string; + iconFileKey: string; + iconMime: string; + iconSource: FinanceCategoryIconSource; + sortNumber?: number; +} + +function createEmptyForm(type: string): CategoryFormState { + return { + name: "", + type, + iconFileKey: "", + iconMime: "", + iconSource: FinanceCategoryIconSource.Custom, + }; +} + +function createEditForm(item: FinanceCategory): CategoryFormState { + return { + id: item.id, + name: item.name, + type: String(item.type), + iconFileKey: item.icon_file_key, + iconMime: item.icon_mime ?? "", + iconSource: item.icon_source, + sortNumber: item.sort_number, + }; +} + +export default function FinanceCategoryList() { + const familyId = useFamilyStore((s) => s.getFamilyId()); + const categories = useFinanceStore((s) => s.categories); + const loadCategories = useFinanceStore((s) => s.loadCategories); + const removeCategory = useFinanceStore((s) => s.removeCategory); + const [activeType, setActiveType] = useState(String(FinanceType.Expense)); + const [form, setForm] = useState(null); + const [saving, setSaving] = useState(false); + const [uploading, setUploading] = useState(false); + + const currentCategories = useMemo( + () => + categories + .filter((item) => item.type === Number(activeType)) + .sort((a, b) => a.sort_number - b.sort_number || a.id - b.id), + [activeType, categories] + ); + + const closeSheet = useCallback(() => { + setForm(null); + setSaving(false); + setUploading(false); + }, []); + + const handleAdd = useCallback(() => { + setForm(createEmptyForm(activeType)); + }, [activeType]); + + const handleEdit = useCallback((item: FinanceCategory) => { + setForm(createEditForm(item)); + }, []); + + const handlePickImage = useCallback(async () => { + if (!form) return; + + let toastId: string | number | undefined; + try { + setUploading(true); + toastId = toast.loading("图标上传中"); + const img = await ImagePicker.openPicker({ + cropping: true, + width: 256, + height: 256, + compressImageQuality: 0.8, + mediaType: "photo", + }); + + const file = { + uri: img.path, + name: img.filename || `finance_category_${Date.now()}.jpg`, + type: img.mime || "image/jpeg", + }; + const uploadRes = await s3UploadFileApi([file]); + + if (!uploadRes.isSuccess) { + toast.error(uploadRes.message || "图标上传失败", { id: toastId }); + return; + } + + const key = uploadRes.data?.[0]; + if (!key) { + toast.error("图标上传失败", { id: toastId }); + return; + } + + setForm((prev) => + prev + ? { + ...prev, + iconFileKey: key, + iconMime: file.type, + iconSource: FinanceCategoryIconSource.Custom, + } + : prev + ); + toast.success("图标已上传", { id: toastId }); + } catch (error) { + const code = typeof error === "object" && error && "code" in error ? error.code : ""; + if (code === "E_PICKER_CANCELLED") { + if (toastId) toast.dismiss(toastId); + } else if (toastId) { + toast.error("图标选择失败", { id: toastId }); + } + } finally { + setUploading(false); + } + }, [form]); + + const handleDelete = useCallback( + async (item: FinanceCategory) => { + const res = await saveDataApi([ + { + table_name: "b_finance_category", + key_field: "id", + inserts: [], + updates: [{ id: item.id, status: FinanceCategoryStatus.Disabled }], + deletes: [], + }, + ]); + + if (res.isSuccess) { + deleteS3FileQuietly(item.icon_file_key); + removeCategory(item.id); + toast.success("分类已删除"); + loadCategories(familyId); + } else { + toast.error(res.message || "删除失败"); + } + }, + [familyId, loadCategories, removeCategory] + ); + + const handleDragEnd = useCallback( + async ({ data }: { data: FinanceCategory[] }) => { + const updates = data.map((item, index) => ({ + id: item.id, + sort_number: index, + })); + + const res = await saveDataApi([ + { + table_name: "b_finance_category", + key_field: "id", + inserts: [], + updates, + deletes: [], + }, + ]); + + if (res.isSuccess) { + loadCategories(familyId); + } else { + toast.error(res.message || "排序保存失败"); + } + }, + [familyId, loadCategories] + ); + + const handleSave = useCallback(async () => { + if (!form) return; + + const trimmedName = form.name.trim(); + if (!trimmedName) { + toast.error("请输入分类名称"); + return; + } + if (!form.iconFileKey) { + toast.error("请选择分类图标"); + return; + } + if (saving) return; + + setSaving(true); + try { + const id = form.id ?? (await getUniqueIdApi(1)).data?.[0]; + if (!id) { + toast.error("获取分类 ID 失败"); + return; + } + + const categoryType = Number(form.type); + const nextSortNumber = categories.filter((item) => item.type === categoryType).length; + const row = { + id, + family_id: familyId, + name: trimmedName, + type: categoryType, + icon_file_key: form.iconFileKey, + icon_source: form.iconSource, + icon_mime: form.iconMime, + sort_number: form.sortNumber ?? nextSortNumber, + status: FinanceCategoryStatus.Enabled, + }; + + const res = await saveDataApi([ + { + table_name: "b_finance_category", + key_field: "id", + inserts: form.id ? [] : [row], + updates: form.id ? [row] : [], + deletes: [], + }, + ]); + + if (res.isSuccess) { + const oldIconKey = categories.find((item) => item.id === form.id)?.icon_file_key; + if (oldIconKey && oldIconKey !== form.iconFileKey) { + deleteS3FileQuietly(oldIconKey); + } + toast.success(form.id ? "分类已保存" : "分类已新增"); + await loadCategories(familyId); + closeSheet(); + } else { + toast.error(res.message || "保存失败"); + } + } catch { + toast.error("保存失败"); + } finally { + setSaving(false); + } + }, [categories, closeSheet, familyId, form, loadCategories, saving]); + + const renderItem = useCallback>( + ({ item }) => ( + handleEdit(item)} + > + + + + + + + + + + + + + {item.name} + + + + handleDelete(item)} + > + + + + ), + [handleDelete, handleEdit] + ); + + return ( + + + + + + + + + + 支出 + + + 收入 + + + + + + + + + String(item.id)} + renderItem={renderItem} + rowGap={0} + strategy="insert" + onDragEnd={handleDragEnd} + /> + + + + + { if (!open) closeSheet(); }}> + + + + {form && ( + + + + {form.iconFileKey ? ( + + ) : ( + + )} + + 选择图标 + + + + + setForm((prev) => (prev ? { ...prev, name } : prev))} + placeholder="例如:咖啡" + /> + + + + + setForm((prev) => (prev ? { ...prev, type } : prev))} + variant="primary" + > + + + + 支出 + + + 收入 + + + + + + + + )} + + + + + ); +} diff --git a/code/app/app-rn/src/app/finance/detail/[id].tsx b/code/app/app-rn/src/app/finance/detail/[id].tsx new file mode 100644 index 00000000..d62f1bd2 --- /dev/null +++ b/code/app/app-rn/src/app/finance/detail/[id].tsx @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useState } from "react"; +import { Text, View } from "react-native"; +import { Stack, useLocalSearchParams, useRouter } from "expo-router"; +import dayjs from "dayjs"; +import PageLayout from "@/layouts/PageLayout"; +import LayoutView from "@/components/layout/view"; +import EmptyState from "@/components/EmptyStatus"; +import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; +import { loadDataBySqlApi } from "@/request/api"; +import { useFamilyStore } from "@/store"; +import { FinanceBill, FinanceType } from "@/types/finance"; + +const moneyFormatter = new Intl.NumberFormat("zh-CN", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, +}); + +function formatMoney(value?: number) { + return moneyFormatter.format(Number(value) || 0); +} + +function formatDateTime(value?: string) { + if (!value) return "-"; + const date = dayjs(value); + return date.isValid() ? date.format("YYYY-MM-DD HH:mm:ss") : value; +} + +function DetailRow({ label, value }: { label: string; value?: string | number | null }) { + return ( + + {label} + + {value === null || value === undefined || value === "" ? "-" : String(value)} + + + ); +} + +export default function FinanceDetail() { + const router = useRouter(); + const { id } = useLocalSearchParams<{ id?: string }>(); + const familyId = useFamilyStore((s) => s.getFamilyId()); + const [bill, setBill] = useState(null); + const [loaded, setLoaded] = useState(false); + + const loadDetail = useCallback(async () => { + if (!id || !familyId) return; + + const res = await loadDataBySqlApi({ + sql: ` + select + f.id, + f.family_id, + f.category_id, + f.amount, + f.remark, + f.record_time, + f.create_by, + f.create_time, + f.update_by, + f.update_time, + c.name as category_name, + c.type as category_type, + c.icon_file_key as category_icon_file_key, + coalesce(m.nickname, u.name) as member_nickname, + coalesce(um.nickname, uu.name) as update_member_nickname + from b_finance f + left join b_finance_category c on c.id = f.category_id + left join b_family_member m on m.family_id = f.family_id and m.user_id = f.create_by + left join b_user u on u.id = f.create_by + left join b_family_member um on um.family_id = f.family_id and um.user_id = f.update_by + left join b_user uu on uu.id = f.update_by + where f.id = ? and f.family_id = ? + `, + args: [Number(id), familyId], + }); + + if (res.isSuccess && res.data?.length) { + setBill(res.data[0] as FinanceBill); + } + setLoaded(true); + }, [familyId, id]); + + useEffect(() => { + loadDetail(); + }, [loadDetail]); + + const isIncome = bill?.category_type === FinanceType.Income; + + return ( + + + + + {!bill && loaded ? ( + router.back()}> + 返回 + + } + /> + ) : null} + + {bill ? ( + + + + + {bill.category_name || "未分类"} + + + {isIncome ? "+" : "-"}¥{formatMoney(bill.amount)} + + + + + + + + + + + + + + + + + + ) : null} + + + + ); +} diff --git a/code/app/app-rn/src/components/EmptyStatus.tsx b/code/app/app-rn/src/components/EmptyStatus.tsx index 284adc43..a12aabca 100644 --- a/code/app/app-rn/src/components/EmptyStatus.tsx +++ b/code/app/app-rn/src/components/EmptyStatus.tsx @@ -1,45 +1,127 @@ -import { View, Text, Image, StyleProp, ViewStyle } from "react-native"; +import { useEffect, useRef } from "react"; +import { View, Text, Image, StyleProp, ViewStyle, Animated, Easing } from "react-native"; +import { LoaderCircleIcon } from "lucide-react-native"; + +type EmptyStateType = "empty" | "error" | "404" | "network" | "loading"; interface EmptyStateProps { - type?: "empty" | "error" | "404" | "network" | "loading"; + type?: EmptyStateType; title?: string; description?: string; footer?: React.ReactNode; + center?: "screen" | "content"; className?: string; style?: StyleProp; } -const images = { +const images: Record, any> = { empty: require("@/static/images/status/记录空空的.png"), error: require("@/static/images/status/图片加载失败.png"), "404": require("@/static/images/status/404黑洞.png"), network: require("@/static/images/status/网络加载异常.png"), - loading: require("@/static/images/status/正在紧急开发.png"), }; -const defaultTitles = { +const defaultTitles: Record = { empty: "记录空空的", error: "加载失败", "404": "页面走丢了", network: "网络异常", - loading: "正在紧急开发", + loading: "加载中", }; +function LoadingVisual() { + const rotateValue = useRef(new Animated.Value(0)).current; + const pulseValue = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const rotateAnimation = Animated.loop( + Animated.timing(rotateValue, { + toValue: 1, + duration: 1100, + easing: Easing.linear, + useNativeDriver: true, + }), + ); + const pulseAnimation = Animated.loop( + Animated.sequence([ + Animated.timing(pulseValue, { + toValue: 1, + duration: 850, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), + Animated.timing(pulseValue, { + toValue: 0, + duration: 850, + easing: Easing.in(Easing.quad), + useNativeDriver: true, + }), + ]), + ); + + rotateAnimation.start(); + pulseAnimation.start(); + + return () => { + rotateAnimation.stop(); + pulseAnimation.stop(); + }; + }, [pulseValue, rotateValue]); + + const rotate = rotateValue.interpolate({ + inputRange: [0, 1], + outputRange: ["0deg", "360deg"], + }); + const pulseScale = pulseValue.interpolate({ + inputRange: [0, 1], + outputRange: [0.86, 1.08], + }); + const pulseOpacity = pulseValue.interpolate({ + inputRange: [0, 1], + outputRange: [0.22, 0.06], + }); + + return ( + + + + + + + + + ); +} + export default function EmptyState({ type = "empty", title, description, footer, + center, className, style, }: EmptyStateProps & { style?: StyleProp }) { const finalTitle = title ?? defaultTitles[type]; + const centerStyle: ViewStyle | undefined = + center === "screen" + ? { flex: 1, alignSelf: "stretch" } + : center === "content" + ? { flexGrow: 1, minHeight: 320, alignSelf: "stretch" } + : undefined; return ( - + {type === "loading" ? ( + + ) : ( + + )} {finalTitle} diff --git a/code/app/app-rn/src/components/finance/FinanceCategoryIcon.tsx b/code/app/app-rn/src/components/finance/FinanceCategoryIcon.tsx new file mode 100644 index 00000000..0e46d375 --- /dev/null +++ b/code/app/app-rn/src/components/finance/FinanceCategoryIcon.tsx @@ -0,0 +1,50 @@ +import { Image } from "expo-image"; +import { memo, useMemo, useState } from "react"; +import { Text, View } from "react-native"; +import { buildFileUrl } from "@/utils/file"; + +interface FinanceCategoryIconProps { + fileKey?: string; + label?: string; + size?: number; +} + +const DEFAULT_BG = "#F3F4F6"; + +function FinanceCategoryIcon({ fileKey, label, size = 28 }: FinanceCategoryIconProps) { + const [failed, setFailed] = useState(false); + const uri = useMemo(() => buildFileUrl(fileKey), [fileKey]); + const showImage = !!uri && !failed; + const fallbackText = label?.trim().slice(0, 1) || "+"; + + return ( + + {showImage ? ( + setFailed(true)} + /> + ) : ( + + {fallbackText} + + )} + + ); +} + +export default memo(FinanceCategoryIcon); diff --git a/code/app/app-rn/src/components/layout/view.tsx b/code/app/app-rn/src/components/layout/view.tsx index b516fce0..207c0033 100644 --- a/code/app/app-rn/src/components/layout/view.tsx +++ b/code/app/app-rn/src/components/layout/view.tsx @@ -23,6 +23,11 @@ type LayoutConfig = { bottomExtraPadding?: number; }; +type PinnedListConfig = { + header?: ReactNode; + footer?: ReactNode; +}; + function Wrapper({ children }: { children: React.ReactNode }) { return {children}; } @@ -83,14 +88,46 @@ LayoutView.ScrollView = forwardRef< }) as any; /* ========================= SectionList ========================= */ - LayoutView.SectionView = forwardRef( ( - props: React.ComponentProps> & LayoutConfig, + props: React.ComponentProps> & LayoutConfig & PinnedListConfig, ref: React.Ref>, ) => { - const { topExtraPadding, bottomExtraPadding, contentContainerStyle, ...rest } = props; + const { header, footer, topExtraPadding, bottomExtraPadding, contentContainerStyle, ...rest } = props; const padding = useContentPadding({ topExtraPadding, bottomExtraPadding }); + + if (header || footer) { + return ( + <> + {header ? ( + + {header} + + ) : null} + + + + + + {footer ? ( + + {footer} + + ) : null} + + ); + } + return ( @@ -101,16 +138,12 @@ LayoutView.SectionView = forwardRef( }, ) as ( props: React.ComponentProps> & - LayoutConfig & { ref?: React.Ref> }, + LayoutConfig & + PinnedListConfig & { ref?: React.Ref> }, ) => React.ReactElement; /* ========================= FlatList / LegendList ========================= */ -type PinnedListConfig = { - header?: ReactNode; - footer?: ReactNode; -}; - LayoutView.FlatList = forwardRef( ( { diff --git a/code/app/app-rn/src/request/api.ts b/code/app/app-rn/src/request/api.ts index 7591f65c..1f1afd7b 100644 --- a/code/app/app-rn/src/request/api.ts +++ b/code/app/app-rn/src/request/api.ts @@ -94,3 +94,9 @@ export function s3UploadFileApi(files: any[]) { }, }); } + +export function s3DeleteFileApi(keys: string[]) { + return alovaInstance.Post>("/s3/delete", { + keys, + }); +} diff --git a/code/app/app-rn/src/store/finance.ts b/code/app/app-rn/src/store/finance.ts index cb9402a7..8149d289 100644 --- a/code/app/app-rn/src/store/finance.ts +++ b/code/app/app-rn/src/store/finance.ts @@ -1,21 +1,56 @@ import { create } from "zustand"; +import { loadDataApi } from "@/request/api"; +import { FinanceCategory, FinanceCategoryStatus, FinanceType } from "@/types/finance"; interface FinanceState { - categories: any[]; - setCategories: (categories: any[]) => void; + categories: FinanceCategory[]; + setCategories: (categories: FinanceCategory[]) => void; clearCategories: () => void; - getCategories: () => any[]; - getExpenseCategories: () => any[]; - getIncomeCategories: () => any[]; + getCategories: () => FinanceCategory[]; + getExpenseCategories: () => FinanceCategory[]; + getIncomeCategories: () => FinanceCategory[]; + loadCategories: (familyId: number) => Promise; + upsertCategory: (category: FinanceCategory) => void; + removeCategory: (id: number) => void; } export const useFinanceStore = create((set, get) => ({ categories: [], - setCategories: (categories: any[]) => set({ categories }), + setCategories: (categories: FinanceCategory[]) => set({ categories }), clearCategories: () => set({ categories: [] }), getCategories: () => get().categories, - getExpenseCategories: () => get().categories.filter((category) => category.type === 0), - getIncomeCategories: () => get().categories.filter((category) => category.type === 1), + getExpenseCategories: () => get().categories.filter((category) => category.type === FinanceType.Expense), + getIncomeCategories: () => get().categories.filter((category) => category.type === FinanceType.Income), + loadCategories: async (familyId: number) => { + if (!familyId) return; + + const res = await loadDataApi({ + view_name: "b_finance_category", + search_columns: [], + search_condition: "family_id = ? and status = ?", + order_by: "type asc, sort_number asc, id asc", + args: [familyId, FinanceCategoryStatus.Enabled], + }); + + if (res.isSuccess) { + set({ categories: res.data as FinanceCategory[] }); + } + }, + upsertCategory: (category: FinanceCategory) => + set((state) => { + const index = state.categories.findIndex((item) => item.id === category.id); + if (index < 0) { + return { categories: [...state.categories, category] }; + } + + const categories = [...state.categories]; + categories[index] = category; + return { categories }; + }), + removeCategory: (id: number) => + set((state) => ({ + categories: state.categories.filter((category) => category.id !== id), + })), })); diff --git a/code/app/app-rn/src/types/finance.ts b/code/app/app-rn/src/types/finance.ts index 7e75a7fc..b8a3ce96 100644 --- a/code/app/app-rn/src/types/finance.ts +++ b/code/app/app-rn/src/types/finance.ts @@ -1,7 +1,48 @@ -/** 记账类型 */ export enum FinanceType { - /** 支出 */ Expense = 0, - /** 收入 */ Income = 1, } + +export enum FinanceCategoryIconSource { + System = 0, + Custom = 1, +} + +export enum FinanceCategoryStatus { + Disabled = 0, + Enabled = 1, +} + +export interface FinanceCategory { + id: number; + family_id: number; + name: string; + type: FinanceType; + icon_file_key: string; + icon_source: FinanceCategoryIconSource; + icon_mime?: string; + sort_number: number; + status: FinanceCategoryStatus; + create_time?: string; + update_time?: string; + create_by?: number; + update_by?: number; +} + +export interface FinanceBill { + id: number; + family_id: number; + category_id: number; + amount: number; + remark?: string; + record_time: string; + create_by?: number; + category_name: string; + category_type: FinanceType; + category_icon_file_key: string; + member_nickname?: string; + create_time?: string; + update_time?: string; + update_by?: number; + update_member_nickname?: string; +} diff --git a/code/app/app-rn/src/types/index.ts b/code/app/app-rn/src/types/index.ts index 641c1826..92d79253 100644 --- a/code/app/app-rn/src/types/index.ts +++ b/code/app/app-rn/src/types/index.ts @@ -1,4 +1,5 @@ export { ChatMsgType, normalizeChatMessage } from "./chat"; export type { ChatMessage, OnlineUser, SendChatPayload, ChatAttachment } from "./chat"; export { FileType } from "./file"; -export { FinanceType } from "./finance"; +export { FinanceCategoryIconSource, FinanceCategoryStatus, FinanceType } from "./finance"; +export type { FinanceCategory } from "./finance"; diff --git a/code/app/app-rn/src/utils/file.ts b/code/app/app-rn/src/utils/file.ts index 7f74c538..30a1691b 100644 --- a/code/app/app-rn/src/utils/file.ts +++ b/code/app/app-rn/src/utils/file.ts @@ -1,3 +1,5 @@ +import { s3DeleteFileApi } from "@/request/api"; + const S3_URL = process.env.EXPO_PUBLIC_G3_URL ?? ""; const S3_BUCKET = process.env.EXPO_PUBLIC_G3_BUCKET ?? ""; @@ -38,3 +40,12 @@ export function buildFileUrl(keyOrUrl?: string) { return keyOrUrl; } + +export function canDeleteS3File(value?: string): value is string { + return isS3Key(value); +} + +export function deleteS3FileQuietly(value?: string) { + if (!canDeleteS3File(value)) return; + s3DeleteFileApi([value]).catch(() => undefined); +}