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);
+ }}
+ />
+
+
+
+
);
}
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);
+}