20260627165246

This commit is contained in:
oneao committed 2026-06-27 16:52:47 +08:00
1 parent 5983d29128
commit bfb2b6037b
25 files changed
+2665 -518

No files matched your search

+1 -1
View File
@@ -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`.
+1 -1
View File
@@ -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`.
@@ -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)
}
}
@@ -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)
}
}
+38
View File
@@ -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);
@@ -389,7 +389,7 @@ export default function ChatPage() {
if (loading) {
return (
<LayoutView>
<EmptyState type="loading" title="加载中..." />
<EmptyState type="loading" title="加载中..." center="screen" />
</LayoutView>
);
}
@@ -4,7 +4,7 @@ import EmptyState from "@/components/EmptyStatus";
export default function Space() {
return (
<LayoutView>
<EmptyState type="loading" title="空间建设中" />
<EmptyState type="loading" title="空间建设中" center="screen" />
</LayoutView>
);
}
+229 -73
View File
@@ -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<any[]>([]);
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<Record<number, string>>({});
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 (
<PageLayout title="聊天记录">
<LayoutView>
<View className="px-4 pt-4 gap-3">
{/* 搜索栏 */}
<SearchField value={keyword} onChange={setKeyword}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input
placeholder="搜索聊天记录..."
onSubmitEditing={() => doSearch(1)}
returnKeyType="search"
/>
</SearchField.Group>
</SearchField>
{/* 类型筛选 */}
<View className="flex-row gap-2 flex-wrap">
{MSG_TYPES.map((t) => (
<PressableFeedback key={t.value} onPress={() => setTypeFilter(t.value)}>
<View
className={cn(
"px-3 py-1 rounded-full",
typeFilter === t.value ? "bg-green-500" : "bg-gray-200"
)}
>
<Text className={cn("text-sm", typeFilter === t.value ? "text-white" : "text-gray-600")}>
{t.label}
</Text>
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
))}
</View>
</View>
{/* 结果列表 */}
<LayoutView.FlatList
header={
<View className="gap-3">
<View className="flex-row items-center gap-2">
<View className="flex-1">
<SearchField value={keyword} onChange={setKeyword}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input
placeholder="搜索聊天记录..."
onSubmitEditing={handleSearch}
returnKeyType="search"
/>
</SearchField.Group>
</SearchField>
</View>
<TouchableOpacity
className="h-10 px-3 rounded-full bg-gray-900 flex-row items-center gap-1.5"
activeOpacity={0.78}
onPress={openFilterSheet}
>
<FilterIcon size={16} color="#fff" />
<Text className="text-sm font-medium text-white">
筛选{activeFilterCount ? ` ${activeFilterCount}` : ""}
</Text>
</TouchableOpacity>
</View>
<View className="flex-row flex-wrap gap-2">
<View className="rounded-full bg-gray-100 px-3 py-1.5">
<Text className="text-xs text-gray-600">类型:{typeLabel(typeFilter)}</Text>
</View>
<View className="rounded-full bg-gray-100 px-3 py-1.5">
<Text className="text-xs text-gray-600">{dateRangeLabel}</Text>
</View>
</View>
</View>
}
data={results}
keyExtractor={(item: any) => String(item.id)}
contentContainerStyle={results.length === 0 ? { flexGrow: 1 } : undefined}
renderItem={({ item }: { item: any }) => (
<View className="px-4 py-3 border-b border-gray-100">
<View className="flex-row items-center gap-2 mb-1">
@@ -170,10 +244,10 @@ export default function ChatHistoryPage() {
onEndReachedThreshold={0.3}
ListEmptyComponent={
loading ? (
<EmptyState type="loading" title="搜索中..." />
) : searched ? (
<EmptyState type="empty" title="没有找到相关消息" />
) : null
<EmptyState type="loading" title={emptyTitle} center="screen" />
) : (
<EmptyState type="empty" title={emptyTitle} center="screen" />
)
}
ListFooterComponent={
loadingMore ? (
@@ -183,6 +257,88 @@ export default function ChatHistoryPage() {
) : null
}
/>
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="px-4 pb-6 gap-4">
<View className="flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">筛选聊天记录</Text>
<TouchableOpacity
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
activeOpacity={0.75}
onPress={handleResetFilter}
>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
</View>
<View>
<Label>时间范围</Label>
<TouchableOpacity
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
activeOpacity={0.75}
onPress={() => setShowDateRange(true)}
>
<Text className="text-sm text-gray-900">
{draftStartDate
? `${draftStartDate} 至 ${draftEndDate || draftStartDate}`
: "全部时间"}
</Text>
<CalendarDaysIcon size={18} color="#6B7280" />
</TouchableOpacity>
</View>
<View>
<Label>消息类型</Label>
<View className="flex-row gap-2 flex-wrap">
{MSG_TYPES.map((t) => (
<PressableFeedback key={t.value} onPress={() => setDraftTypeFilter(t.value)}>
<View
className={cn(
"px-3 py-1.5 rounded-full",
draftTypeFilter === t.value ? "bg-green-500" : "bg-gray-200",
)}
>
<Text className={cn("text-sm", draftTypeFilter === t.value ? "text-white" : "text-gray-600")}>
{t.label}
</Text>
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
))}
</View>
</View>
<View className="flex-row gap-3 pt-2">
<Button
variant="secondary"
className="flex-1"
onPress={() => setShowFilterSheet(false)}
>
<Button.Label>取消</Button.Label>
</Button>
<Button className="flex-1" onPress={handleApplyFilter}>
<CheckIcon size={18} color="#fff" />
<Button.Label>应用</Button.Label>
</Button>
</View>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<DateTimeRange
show={showDateRange}
startDate={draftStartDate}
endDate={draftEndDate}
onClose={() => setShowDateRange(false)}
onConfirm={(start, end) => {
setDraftStartDate(start);
setDraftEndDate(end || start);
setShowDateRange(false);
}}
/>
</LayoutView>
</PageLayout>
);
@@ -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;
});
// 默认记账分类
+117 -157
View File
@@ -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<any>(null);
const nicknameInputRef = useRef<any>(null);
const [family, setFamily] = useState<any>({});
const [familyMemeber, setFamilyMember] = useState<any>({});
// 家庭名称
const [editingFamilyName, setEditingFamilyName] = useState(false);
const [renameForm, setRenameForm] = useState({
name: "",
});
const renameInputRef = useRef<any>(null);
const [showFamilyNameSheet, setShowFamilyNameSheet] = useState(false);
// 我的昵称
const [editingNickname, setEditingNickname] = useState(false);
const [nicknameForm, setNicknameForm] = useState({
nickname: "",
});
const nicknameInputRef = useRef<any>(null);
const [showNicknameSheet, setShowNicknameSheet] = useState(false);
// 退出/解散 家庭
const [showExit, setShowExit] = useState(false);
@@ -63,49 +63,30 @@ export default function HomeSettings() {
// 成员
const [familyMembers, setFamilyMembers] = useState<any>([]);
// 家庭名称编辑焦点管理
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() {
<Text className="text-sm text-muted mb-2 ml-2">常用设置</Text>
<ListGroup className="mb-6">
{/* 家庭名称 */}
{editingFamilyName ? (
<PressableFeedback
onPress={() => {
setRenameForm({ name: family.name || "" });
setShowFamilyNameSheet(true);
}}
>
<ListGroup.Item>
<ListGroup.ItemPrefix>
<StyledIonicons name="home-outline" size={22} className="text-foreground" />
@@ -406,63 +414,18 @@ export default function HomeSettings() {
<ListGroup.ItemTitle>家庭名称</ListGroup.ItemTitle>
</ListGroup.ItemContent>
<ListGroup.ItemSuffix>
<View className="flex-row items-center gap-2 flex-1 justify-end">
<Input
ref={renameInputRef}
value={renameForm.name}
onChangeText={(val) => setRenameForm({ name: val })}
className="flex-1 min-w-[100px]"
placeholder="请输入家庭名称"
onSubmitEditing={handleRename}
returnKeyType="done"
/>
<PressableFeedback
onPress={handleRename}
isDisabled={isLoading || !renameForm.name.trim()}
>
<View className="w-8 h-8 rounded-full items-center justify-center bg-green-500">
<CheckIcon size={16} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
<PressableFeedback
onPress={() => setEditingFamilyName(false)}
isDisabled={isLoading}
>
<View className="w-8 h-8 rounded-full items-center justify-center bg-gray-300">
<XIcon size={16} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
<Text className="text-sm text-muted" numberOfLines={1}>{family.name || "-"}</Text>
</ListGroup.ItemSuffix>
</ListGroup.Item>
) : (
<PressableFeedback
onPress={() => {
setEditingNickname(false);
setEditingFamilyName(true);
setRenameForm({ name: family.name });
}}
>
<ListGroup.Item>
<ListGroup.ItemPrefix>
<StyledIonicons name="home-outline" size={22} className="text-foreground" />
</ListGroup.ItemPrefix>
<ListGroup.ItemContent>
<ListGroup.ItemTitle>家庭名称</ListGroup.ItemTitle>
</ListGroup.ItemContent>
<ListGroup.ItemSuffix>
<Text className="text-sm text-muted">{family.name}</Text>
</ListGroup.ItemSuffix>
</ListGroup.Item>
<PressableFeedback.Ripple />
</PressableFeedback>
)}
<PressableFeedback.Ripple />
</PressableFeedback>
<Separator className="mx-4" />
{/* 我的昵称 */}
{editingNickname ? (
<PressableFeedback
onPress={() => {
setNicknameForm({ nickname: familyMemeber.nickname || "" });
setShowNicknameSheet(true);
}}
>
<ListGroup.Item>
<ListGroup.ItemPrefix>
<UserRoundIcon size={22} strokeWidth={1.5} className="text-foreground" />
@@ -471,62 +434,12 @@ export default function HomeSettings() {
<ListGroup.ItemTitle>我的昵称</ListGroup.ItemTitle>
</ListGroup.ItemContent>
<ListGroup.ItemSuffix>
<View className="flex-row items-center gap-2 flex-1 justify-end">
<Input
ref={nicknameInputRef}
value={nicknameForm.nickname}
onChangeText={(val) => setNicknameForm({ nickname: val })}
className="flex-1 min-w-[100px]"
placeholder="请输入昵称"
onSubmitEditing={handleNickname}
returnKeyType="done"
/>
<PressableFeedback
onPress={handleNickname}
isDisabled={isLoading || !nicknameForm.nickname.trim()}
>
<View className="w-8 h-8 rounded-full items-center justify-center bg-green-500">
<CheckIcon size={16} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
<PressableFeedback
onPress={() => setEditingNickname(false)}
isDisabled={isLoading}
>
<View className="w-8 h-8 rounded-full items-center justify-center bg-gray-300">
<XIcon size={16} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
<Text className="text-sm text-muted" numberOfLines={1}>{familyMemeber.nickname || "-"}</Text>
</ListGroup.ItemSuffix>
</ListGroup.Item>
) : (
<PressableFeedback
onPress={() => {
setEditingFamilyName(false);
setEditingNickname(true);
setNicknameForm({ nickname: familyMemeber.nickname });
}}
>
<ListGroup.Item>
<ListGroup.ItemPrefix>
<UserRoundIcon size={22} strokeWidth={1.5} className="text-foreground" />
</ListGroup.ItemPrefix>
<ListGroup.ItemContent>
<ListGroup.ItemTitle>我的昵称</ListGroup.ItemTitle>
</ListGroup.ItemContent>
<ListGroup.ItemSuffix>
<Text className="text-sm text-muted">{familyMemeber.nickname}</Text>
</ListGroup.ItemSuffix>
</ListGroup.Item>
<PressableFeedback.Ripple />
</PressableFeedback>
)}
<PressableFeedback.Ripple />
</PressableFeedback>
<Separator className="mx-4" />
<PressableFeedback onPress={() => setShowInvite(true)}>
<ListGroup.Item>
<ListGroup.ItemPrefix>
@@ -540,7 +453,6 @@ export default function HomeSettings() {
<PressableFeedback.Ripple />
</PressableFeedback>
</ListGroup>
<Text className="text-sm text-muted mb-2 ml-2">其他操作</Text>
<ListGroup>
<PressableFeedback
@@ -639,6 +551,54 @@ export default function HomeSettings() {
</BottomSheet.Portal>
</BottomSheet>
<BottomSheet isOpen={showFamilyNameSheet} onOpenChange={setShowFamilyNameSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="px-4 pb-6 gap-4">
<TextField>
<Label>家庭名称</Label>
<Input
ref={familyNameInputRef}
value={renameForm.name}
onChangeText={(name) => setRenameForm({ name })}
placeholder="请输入家庭名称"
onSubmitEditing={handleRename}
returnKeyType="done"
/>
</TextField>
<Button onPress={handleRename} isDisabled={isLoading || !renameForm.name.trim()}>
<Button.Label>保存</Button.Label>
</Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<BottomSheet isOpen={showNicknameSheet} onOpenChange={setShowNicknameSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="px-4 pb-6 gap-4">
<TextField>
<Label>我的昵称</Label>
<Input
ref={nicknameInputRef}
value={nicknameForm.nickname}
onChangeText={(nickname) => setNicknameForm({ nickname })}
placeholder="请输入昵称"
onSubmitEditing={handleNickname}
returnKeyType="done"
/>
</TextField>
<Button onPress={handleNickname} isDisabled={isLoading || !nicknameForm.nickname.trim()}>
<Button.Label>保存</Button.Label>
</Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<LoadingOverlay visible={isLoading} />
</PageLayout>
);
+123 -142
View File
@@ -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<any>(null);
const phoneInputRef = useRef<any>(null);
const [userInfo, setUserInfo] = useState<any>({});
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<any>(null);
const [showNameSheet, setShowNameSheet] = useState(false);
// 手机号
const [editingPhone, setEditingPhone] = useState(false);
const [phoneForm, setPhoneForm] = useState({
phone: "",
});
const phoneInputRef = useRef<any>(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() {
<Text className="text-sm text-muted mb-2 ml-2">基础信息</Text>
<ListGroup className="mb-6">
{/* 姓名 */}
{editingName ? (
<PressableFeedback
onPress={() => {
setRenameForm({ name: userInfo.name || "" });
setShowNameSheet(true);
}}
>
<ListGroup.Item>
<ListGroup.ItemPrefix>
<ContactRoundIcon size="22" strokeWidth={1.5} />
@@ -535,62 +559,19 @@ export default function UserSetting() {
<ListGroup.ItemTitle>姓名</ListGroup.ItemTitle>
</ListGroup.ItemContent>
<ListGroup.ItemSuffix>
<View className="flex-row items-center gap-2 flex-1 justify-end">
<Input
ref={nameInputRef}
value={renameForm.name}
onChangeText={(val) => setRenameForm({ name: val })}
className="flex-1 min-w-[100px]"
placeholder="请输入姓名"
onSubmitEditing={handleRename}
returnKeyType="done"
/>
<PressableFeedback
onPress={handleRename}
isDisabled={isLoading || !renameForm.name.trim()}
>
<View className="w-8 h-8 rounded-full items-center justify-center bg-green-500">
<CheckIcon size={16} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
<PressableFeedback
onPress={() => setEditingName(false)}
isDisabled={isLoading}
>
<View className="w-8 h-8 rounded-full items-center justify-center bg-gray-300">
<XIcon size={16} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
<Text className="text-sm text-muted" numberOfLines={1}>{userInfo.name || "-"}</Text>
</ListGroup.ItemSuffix>
</ListGroup.Item>
) : (
<PressableFeedback
onPress={() => {
setEditingPhone(false);
setEditingName(true);
setRenameForm({ name: userInfo.name });
}}
>
<ListGroup.Item>
<ListGroup.ItemPrefix>
<ContactRoundIcon size="22" strokeWidth={1.5} />
</ListGroup.ItemPrefix>
<ListGroup.ItemContent>
<ListGroup.ItemTitle>姓名</ListGroup.ItemTitle>
</ListGroup.ItemContent>
<ListGroup.ItemSuffix>
<Text className="text-sm text-muted">{userInfo.name || "-"}</Text>
</ListGroup.ItemSuffix>
</ListGroup.Item>
<PressableFeedback.Ripple />
</PressableFeedback>
)}
<PressableFeedback.Ripple />
</PressableFeedback>
<Separator className="mx-4" />
{/* 手机号 */}
{editingPhone ? (
<PressableFeedback
onPress={() => {
setPhoneForm({ phone: userInfo.phone || "" });
setShowPhoneSheet(true);
}}
>
<ListGroup.Item>
<ListGroup.ItemPrefix>
<PhoneIcon size="22" strokeWidth={1.5} />
@@ -599,60 +580,11 @@ export default function UserSetting() {
<ListGroup.ItemTitle>手机号</ListGroup.ItemTitle>
</ListGroup.ItemContent>
<ListGroup.ItemSuffix>
<View className="flex-row items-center gap-2 flex-1 justify-end">
<Input
ref={phoneInputRef}
value={phoneForm.phone}
onChangeText={(val) => setPhoneForm({ phone: val })}
className="flex-1 min-w-[100px]"
placeholder="请输入手机号"
inputMode="tel"
onSubmitEditing={handlePhone}
returnKeyType="done"
/>
<PressableFeedback
onPress={handlePhone}
isDisabled={isLoading || !phoneForm.phone.trim()}
>
<View className="w-8 h-8 rounded-full items-center justify-center bg-green-500">
<CheckIcon size={16} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
<PressableFeedback
onPress={() => setEditingPhone(false)}
isDisabled={isLoading}
>
<View className="w-8 h-8 rounded-full items-center justify-center bg-gray-300">
<XIcon size={16} color="white" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
</View>
<Text className="text-sm text-muted" numberOfLines={1}>{userInfo.phone || "-"}</Text>
</ListGroup.ItemSuffix>
</ListGroup.Item>
) : (
<PressableFeedback
onPress={() => {
setEditingName(false);
setEditingPhone(true);
setPhoneForm({ phone: userInfo.phone });
}}
>
<ListGroup.Item>
<ListGroup.ItemPrefix>
<PhoneIcon size="22" strokeWidth={1.5} />
</ListGroup.ItemPrefix>
<ListGroup.ItemContent>
<ListGroup.ItemTitle>手机号</ListGroup.ItemTitle>
</ListGroup.ItemContent>
<ListGroup.ItemSuffix>
<Text className="text-sm text-muted">{userInfo.phone || "-"}</Text>
</ListGroup.ItemSuffix>
</ListGroup.Item>
<PressableFeedback.Ripple />
</PressableFeedback>
)}
<PressableFeedback.Ripple />
</PressableFeedback>
<Separator className="mx-4" />
{/* 生日 */}
<PressableFeedback
@@ -784,6 +716,55 @@ export default function UserSetting() {
</Dialog.Portal>
</Dialog>
<BottomSheet isOpen={showNameSheet} onOpenChange={setShowNameSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="px-4 pb-6 gap-4">
<TextField>
<Label>姓名</Label>
<Input
ref={nameInputRef}
value={renameForm.name}
onChangeText={(name) => setRenameForm({ name })}
placeholder="请输入姓名"
onSubmitEditing={handleRename}
returnKeyType="done"
/>
</TextField>
<Button onPress={handleRename} isDisabled={isLoading || !renameForm.name.trim()}>
<Button.Label>保存</Button.Label>
</Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<BottomSheet isOpen={showPhoneSheet} onOpenChange={setShowPhoneSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="px-4 pb-6 gap-4">
<TextField>
<Label>手机号</Label>
<Input
ref={phoneInputRef}
value={phoneForm.phone}
onChangeText={(phone) => setPhoneForm({ phone })}
placeholder="请输入手机号"
inputMode="tel"
onSubmitEditing={handlePhone}
returnKeyType="done"
/>
</TextField>
<Button onPress={handlePhone} isDisabled={isLoading || !phoneForm.phone.trim()}>
<Button.Label>保存</Button.Label>
</Button>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<DateTime
show={showBirth}
value={birthForm.birth}
@@ -2,7 +2,6 @@ import { useEffect } from "react";
import { Stack } from "expo-router";
import ModuleLayout from "@/layouts/ModuleLayout";
import { TabbarItem } from "@/components/layout/tabbar";
import { loadDataApi } from "@/request/api";
import { useFinanceStore } from "@/store/finance";
import { useFamilyStore } from "@/store/family";
import {
@@ -20,6 +19,7 @@ const tabs: TabbarItem[] = [
{
id: "/finance/add",
icon: PlusIcon,
title: "添加",
isCenter: true,
},
{
@@ -30,25 +30,15 @@ const tabs: TabbarItem[] = [
];
export default function Layout() {
const { setCategories, getCategories } = useFinanceStore();
const { getCategories, loadCategories } = useFinanceStore();
const familyId = useFamilyStore((s) => 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 (
<>
@@ -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<FilterState>(() => createDefaultFilter());
const [showDateRange, setShowDateRange] = useState(false);
const [bills, setBills] = useState<FinanceBill[]>([]);
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<TrendDatum[]>(() => {
const map = new Map<string, TrendDatum>();
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<CategoryStat[]>(() => {
const map = new Map<string, Omit<CategoryStat, "percent">>();
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<MemberStat[]>(() => {
const map = new Map<number, MemberStat>();
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 (
<View className="flex-1 bg-background">
<Text className="px-5 py-3 text-lg font-semibold">Horizontal List</Text>
<ScrollShadow LinearGradientComponent={LinearGradient}>
<FlatList
data={horizontalData}
horizontal
renderItem={({ item }) => (
<Surface variant="2" className="w-32 h-24 justify-center px-4">
<Text>{item.title}</Text>
</Surface>
)}
showsHorizontalScrollIndicator={false}
contentContainerClassName="p-5 gap-4"
/>
</ScrollShadow>
<LayoutView>
<LayoutView.ScrollView contentContainerStyle={{ gap: 16 }}>
<View className="gap-3">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-xs text-gray-500">分析范围</Text>
<Text className="mt-1 text-base font-semibold text-gray-900">
{filter.startDate} 至 {filter.endDate}
</Text>
</View>
<Text className="px-5 py-3 text-lg font-semibold">Vertical Content</Text>
<ScrollShadow size={80} className="h-48" LinearGradientComponent={LinearGradient}>
<ScrollView contentContainerClassName="p-5" showsVerticalScrollIndicator={false}>
<Text className="mb-4 text-2xl font-bold">Long Content</Text>
<Text className="mb-4 text-base leading-6">
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.
<View className="flex-row gap-2">
<TouchableOpacity
className="h-10 w-10 rounded-full bg-gray-100 items-center justify-center"
activeOpacity={0.75}
onPress={handleReset}
>
<RotateCcwIcon size={18} color="#4B5563" />
</TouchableOpacity>
<TouchableOpacity
className="h-10 px-3 rounded-full bg-gray-900 flex-row items-center gap-1.5"
activeOpacity={0.8}
onPress={() => setShowDateRange(true)}
>
<CalendarDaysIcon size={16} color="#fff" />
<Text className="text-sm font-medium text-white">日期</Text>
</TouchableOpacity>
</View>
</View>
<View className="flex-row gap-3">
<SummaryCard label="支出" value={`¥${formatCompactMoney(totals.expense)}`} tone="expense" />
<SummaryCard label="收入" value={`¥${formatCompactMoney(totals.income)}`} tone="income" />
</View>
<View className="flex-row gap-3">
<SummaryCard label="结余" value={`¥${formatCompactMoney(balance)}`} tone={balance >= 0 ? "income" : "expense"} />
<SummaryCard label="笔数" value={`${totals.count}`} tone="neutral" />
</View>
</View>
{loading ? (
<EmptyState type="loading" title="正在分析记账数据" center="content" />
) : !hasData ? (
<EmptyState
type="empty"
title="暂无分析数据"
description="当前日期范围内没有记账记录"
center="content"
/>
) : (
<>
<Section title="收支趋势" subtitle="按天统计当前日期范围">
<View className="h-56 rounded-xl bg-white px-2 py-3">
<CartesianChart<TrendDatum, "label", "expense" | "income">
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 }) => (
<>
<Bar
points={points.expense}
chartBounds={chartBounds}
color="#FB7185"
roundedCorners={{ topLeft: 5, topRight: 5 }}
animate={{ type: "timing", duration: 300 }}
/>
<Line
points={points.income}
color="#10B981"
strokeWidth={3}
curveType="natural"
animate={{ type: "timing", duration: 300 }}
/>
</>
)}
</CartesianChart>
</View>
<View className="flex-row gap-4 px-1">
<LegendDot color="bg-rose-400" label="支出柱状" />
<LegendDot color="bg-emerald-500" label="收入曲线" />
</View>
</Section>
<Section title="分类排行" subtitle="按金额从高到低">
<View className="rounded-xl overflow-hidden bg-white">
{categoryStats.map((item, index) => (
<CategoryRow key={`${item.type}-${item.categoryId}`} item={item} isLast={index === categoryStats.length - 1} />
))}
</View>
</Section>
<Section title="成员统计" subtitle="按录入人聚合">
<View className="rounded-xl overflow-hidden bg-white">
{memberStats.map((item, index) => (
<MemberRow key={item.memberId || item.nickname} item={item} isLast={index === memberStats.length - 1} />
))}
</View>
</Section>
</>
)}
</LayoutView.ScrollView>
<DateTimeRange
show={showDateRange}
startDate={filter.startDate}
endDate={filter.endDate}
onClose={() => setShowDateRange(false)}
onConfirm={handleApplyDateRange}
/>
</LayoutView>
);
}
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 (
<View className="flex-1 rounded-xl bg-white px-4 py-3">
<Text className="text-xs text-gray-500">{label}</Text>
<Text className={`mt-1 text-xl font-bold ${valueClass}`} numberOfLines={1}>
{value}
</Text>
</View>
);
}
function Section({
title,
subtitle,
children,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
}) {
return (
<View className="gap-3">
<View>
<Text className="text-base font-semibold text-gray-900">{title}</Text>
<Text className="mt-0.5 text-xs text-gray-500">{subtitle}</Text>
</View>
{children}
</View>
);
}
function LegendDot({ color, label }: { color: string; label: string }) {
return (
<View className="flex-row items-center gap-1.5">
<View className={`h-2.5 w-2.5 rounded-full ${color}`} />
<Text className="text-xs text-gray-500">{label}</Text>
</View>
);
}
function CategoryRow({ item, isLast }: { item: CategoryStat; isLast: boolean }) {
const isIncome = item.type === FinanceType.Income;
const percentText = `${Math.round(item.percent * 100)}%`;
return (
<View className={`px-3 py-3 ${isLast ? "" : "border-b border-gray-100"}`}>
<View className="flex-row items-center">
<View className="w-10 h-10 rounded-xl bg-gray-100 items-center justify-center">
<FinanceCategoryIcon fileKey={item.iconFileKey} label={item.categoryName} size={28} />
</View>
<View className="ml-3 flex-1">
<View className="flex-row items-center gap-2">
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
{item.categoryName}
</Text>
<Text className="text-[11px] text-gray-400">{getTypeLabel(item.type)}</Text>
</View>
<Text className="mt-1 text-xs text-gray-500">
{item.count} 笔 · {percentText}
</Text>
<Text className="mb-4 text-base leading-6">
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.
</Text>
</ScrollView>
</ScrollShadow>
</View>
<Text className={`text-sm font-semibold ${isIncome ? "text-emerald-600" : "text-rose-500"}`}>
¥{formatMoney(item.amount)}
</Text>
</View>
<View className="mt-2 h-1.5 overflow-hidden rounded-full bg-gray-100">
<View
className={`h-full rounded-full ${isIncome ? "bg-emerald-500" : "bg-rose-400"}`}
style={{ width: `${Math.max(Math.min(item.percent * 100, 100), 4)}%` }}
/>
</View>
</View>
);
}
function MemberRow({ item, isLast }: { item: MemberStat; isLast: boolean }) {
const balance = item.income - item.expense;
return (
<View className={`flex-row items-center px-3 py-3 ${isLast ? "" : "border-b border-gray-100"}`}>
<View className="h-10 w-10 rounded-full bg-gray-100 items-center justify-center">
<Text className="text-sm font-semibold text-gray-700">{item.nickname.slice(0, 1)}</Text>
</View>
<View className="ml-3 flex-1">
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
{item.nickname}
</Text>
<Text className="mt-1 text-xs text-gray-500">
{item.count} 笔 · 支出 ¥{formatMoney(item.expense)} · 收入 ¥{formatMoney(item.income)}
</Text>
</View>
<Text className={`ml-3 text-sm font-semibold ${balance >= 0 ? "text-emerald-600" : "text-rose-500"}`}>
{balance >= 0 ? "+" : "-"}¥{formatMoney(Math.abs(balance))}
</Text>
</View>
);
}
@@ -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<any>([]);
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<FilterState>(() => createDefaultFilter());
const [draftFilter, setDraftFilter] = useState<FilterState>(() => createDefaultFilter());
const [showFilterSheet, setShowFilterSheet] = useState(false);
const [showDateRange, setShowDateRange] = useState(false);
const [bills, setBills] = useState<FinanceBill[]>([]);
const [activeBill, setActiveBill] = useState<FinanceBill | null>(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<FinanceBillSection[]>(() => {
const map = new Map<string, FinanceBillSection>();
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(
() => (
<View className="gap-3 pb-4">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-xs text-gray-500">当前范围</Text>
<Text className="mt-1 text-base font-semibold text-gray-900">
{filter.startDate} 至 {filter.endDate}
</Text>
</View>
<TouchableOpacity
className="h-10 px-3 rounded-full bg-gray-900 flex-row items-center gap-1.5"
activeOpacity={0.8}
onPress={openFilterSheet}
>
<FilterIcon size={16} color="#fff" />
<Text className="text-sm font-medium text-white">
筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""}
</Text>
</TouchableOpacity>
</View>
<View className="flex-row gap-3">
<View className="flex-1 rounded-xl bg-white px-4 py-3">
<Text className="text-xs text-gray-500">支出合计</Text>
<Text className="mt-1 text-xl font-bold text-rose-500">
¥{formatMoney(totals.expense)}
</Text>
</View>
<View className="flex-1 rounded-xl bg-white px-4 py-3">
<Text className="text-xs text-gray-500">收入合计</Text>
<Text className="mt-1 text-xl font-bold text-emerald-600">
¥{formatMoney(totals.income)}
</Text>
</View>
</View>
<View className="flex-row flex-wrap gap-2">
<View className="rounded-full bg-gray-100 px-3 py-1.5">
<Text className="text-xs text-gray-600">
分类:{selectedCategory ? selectedCategory.name : "全部"}
</Text>
</View>
<View className="rounded-full bg-gray-100 px-3 py-1.5">
<Text className="text-xs text-gray-600">
备注:{filter.keyword.trim() || "全部"}
</Text>
</View>
</View>
</View>
),
[activeFilterCount, filter, openFilterSheet, selectedCategory, totals],
);
const renderSectionHeader = useCallback(
({ section }: { section: any }) => {
const billSection = section as FinanceBillSection;
return (
<View className="bg-background pt-3 pb-2">
<View className="flex-row items-center justify-between">
<View>
<Text className="text-sm font-semibold text-gray-900">
{dayjs(billSection.title).format("MM月DD日")}
</Text>
<Text className="mt-0.5 text-xs text-gray-500">
{dayjs(billSection.title).format("YYYY年")}
</Text>
</View>
<View className="items-end">
<Text className="text-xs text-gray-500">
支出 ¥{formatMoney(billSection.expenseTotal)}
</Text>
<Text className="mt-0.5 text-xs text-gray-500">
收入 ¥{formatMoney(billSection.incomeTotal)}
</Text>
</View>
</View>
</View>
);
},
[],
);
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 (
<TouchableOpacity
className="flex-row items-center bg-white px-3 py-3 border-b border-gray-100"
activeOpacity={0.78}
delayLongPress={260}
onLongPress={() => setActiveBill(item)}
>
<View className="w-11 h-11 rounded-xl bg-gray-100 items-center justify-center">
<FinanceCategoryIcon
fileKey={item.category_icon_file_key}
label={item.category_name}
size={30}
/>
</View>
<View className="ml-3 flex-1">
<View className="flex-row items-center gap-2">
<Text className="text-sm font-medium text-gray-900" numberOfLines={1}>
{item.category_name || "未分类"}
</Text>
{!isOwnRecord && !!item.member_nickname && (
<Text className="text-[11px] text-gray-500" numberOfLines={1}>
{item.member_nickname}
</Text>
)}
</View>
<Text className="mt-1 text-xs text-gray-500" numberOfLines={1}>
{remark || "无备注"}
</Text>
</View>
<View className="items-end ml-3">
<Text className={`text-base font-semibold ${isIncome ? "text-emerald-600" : "text-gray-900"}`}>
{isIncome ? "+" : "-"}¥{formatMoney(Number(item.amount))}
</Text>
<Text className="mt-1 text-[11px] text-gray-400">
{getCategoryTypeLabel(item.category_type)}
</Text>
</View>
</TouchableOpacity>
);
},
[userId],
);
const renderCategoryOption = useCallback(
(item: FinanceCategory) => {
const selected = draftFilter.categoryId === item.id;
return (
<TouchableOpacity
key={item.id}
className={`w-[31%] rounded-xl px-2 py-3 items-center border ${
selected ? "border-gray-900 bg-gray-100" : "border-gray-100 bg-white"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
categoryId: prev.categoryId === item.id ? null : item.id,
}))
}
>
<FinanceCategoryIcon fileKey={item.icon_file_key} label={item.name} size={28} />
<Text className="mt-1.5 text-xs text-gray-700" numberOfLines={1}>
{item.name}
</Text>
</TouchableOpacity>
);
},
[draftFilter.categoryId],
);
return (
<LayoutView>
<LayoutView.SectionView<any>
sections={dataList}
keyExtractor={(item) => item.id.toString()}
stickySectionHeadersEnabled={true}
<LayoutView.SectionView<FinanceBill>
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={() => (
<EmptyState type="empty" title="暂无记账记录" description="快去添加你的第一笔记账吧" />
<EmptyState
type={loading ? "loading" : "empty"}
title={loading ? "正在加载记账记录" : "暂无记账记录"}
description={loading ? "请稍候" : "调整筛选条件或添加一笔新的记账"}
center="screen"
/>
)}
>
<Text className="text-base text-black">Hello</Text>
</LayoutView.SectionView>
/>
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<View className="px-4 pb-6 gap-4">
<View className="flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">筛选记账</Text>
<TouchableOpacity
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
onPress={handleResetFilter}
>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
</View>
<View>
<Label>日期范围</Label>
<TouchableOpacity
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
activeOpacity={0.75}
onPress={() => setShowDateRange(true)}
>
<Text className="text-sm text-gray-900">
{draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate}
</Text>
<CalendarDaysIcon size={18} color="#6B7280" />
</TouchableOpacity>
</View>
<TextField>
<Label>备注关键词</Label>
<Input
value={draftFilter.keyword}
onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))}
placeholder="按备注模糊搜索"
returnKeyType="search"
/>
</TextField>
<View>
<Label>分类</Label>
<View className="flex-row flex-wrap gap-2">
<TouchableOpacity
className={`w-[31%] rounded-xl px-2 py-3 items-center border ${
draftFilter.categoryId == null
? "border-gray-900 bg-gray-100"
: "border-gray-100 bg-white"
}`}
activeOpacity={0.75}
onPress={() => setDraftFilter((prev) => ({ ...prev, categoryId: null }))}
>
<SearchIcon size={24} color="#6B7280" />
<Text className="mt-1.5 text-xs text-gray-700">全部</Text>
</TouchableOpacity>
{categories.map(renderCategoryOption)}
</View>
</View>
<View className="flex-row gap-3 pt-2">
<Button
variant="secondary"
className="flex-1"
onPress={() => setShowFilterSheet(false)}
>
<Button.Label>取消</Button.Label>
</Button>
<Button className="flex-1" onPress={handleApplyFilter}>
<CheckIcon size={18} color="#fff" />
<Button.Label>应用</Button.Label>
</Button>
</View>
</View>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<DateTimeRange
show={showDateRange}
startDate={draftFilter.startDate}
endDate={draftFilter.endDate}
onClose={() => setShowDateRange(false)}
onConfirm={(startDate, endDate) => {
setDraftFilter((prev) => ({
...prev,
startDate,
endDate: endDate || startDate,
}));
setShowDateRange(false);
}}
/>
<Menu isOpen={showActionMenu} onOpenChange={(open) => { if (!open) closeActionMenu(); }}>
<Menu.Portal>
<Menu.Overlay />
<Menu.Content presentation="bottom-sheet">
<View className="px-2 pb-4">
<Menu.Item onPress={handleEditBill}>
<PencilIcon size={18} color="#374151" />
<Menu.ItemTitle>编辑</Menu.ItemTitle>
</Menu.Item>
<Menu.Item onPress={handleDeleteBill}>
<Trash2Icon size={18} color="#EF4444" />
<Menu.ItemTitle className="text-red-500">删除</Menu.ItemTitle>
</Menu.Item>
<Menu.Item onPress={handleOpenDetail}>
<FileTextIcon size={18} color="#374151" />
<Menu.ItemTitle>详细</Menu.ItemTitle>
</Menu.Item>
</View>
</Menu.Content>
</Menu.Portal>
</Menu>
<LoadingOverlay visible={isLoading} />
</LayoutView>
);
}
+159 -49
View File
@@ -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 (
<TouchableOpacity onPress={onPress} className="w-1/4 items-center mb-4">
<TouchableOpacity onPress={handlePress} className="w-1/4 items-center mb-4">
<View
className={`w-14 h-14 rounded-2xl items-center justify-center ${
isActive ? "bg-accent" : "bg-gray-100"
}`}
>
<IconFont name={item.icon || "IconFinanceZhichu"} size={28} />
<FinanceCategoryIcon
fileKey={item.icon_file_key}
label={item.name}
size={28}
/>
</View>
<Text
className={`text-[11px] mt-1.5 text-center ${isActive ? "font-semibold text-gray-900" : "text-gray-500"}`}
@@ -46,13 +55,18 @@ const CategoryItem = memo(function CategoryItem({
// ─── 页面 ─────────────────────────────────
export default function Add() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
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<any>(null);
const [activeTab, setActiveTab] = useState("0"); // 0=支出 1=收入
const [keyword, setKeyword] = useState("");
const [selectedCategory, setSelectedCategory] = useState<any>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<string | number | null>(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 (
<PageLayout title="记一笔">
<PageLayout title={isEdit ? "编辑记账" : "记一笔"}>
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView>
<LayoutView.ScrollView bottomOffset={50}>
@@ -137,7 +228,8 @@ export default function Add() {
</Tabs>
{/* 搜索 */}
<View className="mt-3 mb-2 flex-row items-center bg-gray-100 rounded-full px-3 h-9">
<View className="mt-3 mb-2 flex-row items-center gap-2">
<View className="flex-1 flex-row items-center bg-gray-100 rounded-full px-3 h-9">
<SearchIcon size={14} color="#9CA3AF" />
<TextInput
className="flex-1 ml-1.5 text-sm"
@@ -145,16 +237,23 @@ export default function Add() {
value={keyword}
onChangeText={setKeyword}
/>
</View>
<TouchableOpacity
className="w-9 h-9 rounded-full bg-gray-100 items-center justify-center"
onPress={() => router.push("/finance/category" as any)}
>
<Settings2Icon size={17} color="#6B7280" />
</TouchableOpacity>
</View>
{/* 分类网格 */}
<View className="flex-row flex-wrap">
{allCategories.map((item: any) => (
{allCategories.map((item) => (
<CategoryItem
key={item.id}
item={item}
isActive={selectedCategory?.id === item.id}
onPress={() => setSelectedCategory(item)}
onPress={handleSelectCategory}
/>
))}
</View>
@@ -162,16 +261,24 @@ export default function Add() {
</LayoutView>
{/* 记账 BottomSheet */}
<BottomSheet isOpen={!!selectedCategory} onOpenChange={(open) => { if (!open) setSelectedCategory(null); }}>
<BottomSheet isOpen={isSheetOpen} onOpenChange={handleSheetOpenChange}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
<BottomSheet.Content
animation="disabled"
enableBlurKeyboardOnGesture
keyboardBlurBehavior="restore"
>
<View className="px-4 pb-6 gap-4">
{/* 选中的分类 */}
{selectedCategory && (
<View className="flex-row items-center gap-3 py-2">
<View className="w-10 h-10 rounded-xl bg-accent/10 items-center justify-center">
<IconFont name={selectedCategory.icon || "IconFinanceZhichu"} size={22} />
<FinanceCategoryIcon
fileKey={selectedCategory.icon_file_key}
label={selectedCategory.name}
size={22}
/>
</View>
<Text className="text-base font-semibold text-gray-900">
{selectedCategory.name}
@@ -183,6 +290,7 @@ export default function Add() {
<TextField isRequired>
<Label>金额</Label>
<Input
ref={amountInputRef}
placeholder="0.00"
inputMode="decimal"
value={amount}
@@ -220,13 +328,15 @@ export default function Add() {
>
<Button.Label>保存</Button.Label>
</Button>
<Button
onPress={() => handleSave(true)}
className="flex-1"
variant="secondary"
>
<Button.Label>保存并再记</Button.Label>
</Button>
{!isEdit && (
<Button
onPress={() => handleSave(true)}
className="flex-1"
variant="secondary"
>
<Button.Label>保存并再记</Button.Label>
</Button>
)}
</View>
</View>
</BottomSheet.Content>
@@ -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<CategoryFormState | null>(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<SortableGridRenderItem<FinanceCategory>>(
({ item }) => (
<TouchableOpacity
className="h-16 flex-row items-center bg-white border-b border-gray-100"
activeOpacity={0.72}
onPress={() => handleEdit(item)}
>
<Sortable.Handle>
<View className="w-11 h-16 items-center justify-center">
<GripVerticalIcon size={20} color="#9CA3AF" />
</View>
</Sortable.Handle>
<View className="w-11 h-11 rounded-xl bg-gray-100 items-center justify-center">
<FinanceCategoryIcon fileKey={item.icon_file_key} label={item.name} size={30} />
</View>
<View className="flex-1 px-3">
<Text className="text-base font-medium text-gray-900" numberOfLines={1}>
{item.name}
</Text>
</View>
<TouchableOpacity
className="w-12 h-16 items-center justify-center"
onPress={() => handleDelete(item)}
>
<Trash2Icon size={18} color="#EF4444" />
</TouchableOpacity>
</TouchableOpacity>
),
[handleDelete, handleEdit]
);
return (
<PageLayout title="分类管理">
<Stack.Screen options={{ animation: "slide_from_right" }} />
<LayoutView>
<LayoutView.ScrollView bottomOffset={40}>
<View className="mb-3">
<Tabs value={activeType} onValueChange={setActiveType} variant="primary">
<Tabs.List>
<Tabs.Indicator />
<Tabs.Trigger value={String(FinanceType.Expense)} className="flex-1">
<Tabs.Label>支出</Tabs.Label>
</Tabs.Trigger>
<Tabs.Trigger value={String(FinanceType.Income)} className="flex-1">
<Tabs.Label>收入</Tabs.Label>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</View>
<Button className="mb-3" onPress={handleAdd}>
<PlusIcon size={18} color="#fff" />
<Button.Label>新增分类</Button.Label>
</Button>
<View className="overflow-hidden rounded-xl bg-white">
<Sortable.Grid
columns={1}
customHandle
data={currentCategories}
keyExtractor={(item) => String(item.id)}
renderItem={renderItem}
rowGap={0}
strategy="insert"
onDragEnd={handleDragEnd}
/>
</View>
</LayoutView.ScrollView>
</LayoutView>
<BottomSheet isOpen={!!form} onOpenChange={(open) => { if (!open) closeSheet(); }}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content>
{form && (
<View className="px-4 pb-6 gap-4">
<View className="items-center py-2">
<TouchableOpacity
className="w-24 h-24 rounded-3xl bg-gray-100 items-center justify-center"
onPress={handlePickImage}
disabled={uploading}
>
{form.iconFileKey ? (
<FinanceCategoryIcon fileKey={form.iconFileKey} label={form.name} size={72} />
) : (
<ImageIcon size={32} color="#9CA3AF" />
)}
</TouchableOpacity>
<Text className="mt-2 text-sm text-gray-500">选择图标</Text>
</View>
<TextField>
<Label>分类名称</Label>
<Input
value={form.name}
onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))}
placeholder="例如:咖啡"
/>
</TextField>
<View>
<Label>类型</Label>
<Tabs
value={form.type}
onValueChange={(type) => setForm((prev) => (prev ? { ...prev, type } : prev))}
variant="primary"
>
<Tabs.List>
<Tabs.Indicator />
<Tabs.Trigger value={String(FinanceType.Expense)} className="flex-1">
<Tabs.Label>支出</Tabs.Label>
</Tabs.Trigger>
<Tabs.Trigger value={String(FinanceType.Income)} className="flex-1">
<Tabs.Label>收入</Tabs.Label>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
</View>
<Button onPress={handleSave} isDisabled={saving || uploading}>
<SaveIcon size={18} color="#fff" />
<Button.Label>保存</Button.Label>
</Button>
</View>
)}
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
</PageLayout>
);
}
@@ -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 (
<View className="flex-row items-start justify-between gap-4 py-3 border-b border-gray-100">
<Text className="text-sm text-gray-500">{label}</Text>
<Text className="flex-1 text-right text-sm text-gray-900" numberOfLines={3}>
{value === null || value === undefined || value === "" ? "-" : String(value)}
</Text>
</View>
);
}
export default function FinanceDetail() {
const router = useRouter();
const { id } = useLocalSearchParams<{ id?: string }>();
const familyId = useFamilyStore((s) => s.getFamilyId());
const [bill, setBill] = useState<FinanceBill | null>(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 (
<PageLayout title="记账详情">
<Stack.Screen options={{ animation: "slide_from_right" }} />
<LayoutView>
<LayoutView.ScrollView>
{!bill && loaded ? (
<EmptyState
type="empty"
title="记录不存在"
description="这条记账可能已被删除"
footer={
<Text className="mt-4 text-sm text-accent" onPress={() => router.back()}>
返回
</Text>
}
/>
) : null}
{bill ? (
<View className="gap-4">
<View className="items-center rounded-xl bg-white px-4 py-6">
<FinanceCategoryIcon
fileKey={bill.category_icon_file_key}
label={bill.category_name}
size={56}
/>
<Text className="mt-3 text-base font-semibold text-gray-900">
{bill.category_name || "未分类"}
</Text>
<Text className={`mt-2 text-3xl font-bold ${isIncome ? "text-emerald-600" : "text-rose-500"}`}>
{isIncome ? "+" : "-"}¥{formatMoney(bill.amount)}
</Text>
</View>
<View className="rounded-xl bg-white px-4">
<DetailRow label="类别" value={bill.category_name || "未分类"} />
<DetailRow label="类型" value={isIncome ? "收入" : "支出"} />
<DetailRow label="备注" value={bill.remark || "无备注"} />
<DetailRow label="记账日期" value={dayjs(bill.record_time).format("YYYY-MM-DD")} />
</View>
<View className="rounded-xl bg-white px-4">
<DetailRow label="创建人" value={bill.member_nickname} />
<DetailRow label="创建时间" value={formatDateTime(bill.create_time)} />
<DetailRow label="更新人" value={bill.update_member_nickname} />
<DetailRow label="更新时间" value={formatDateTime(bill.update_time)} />
</View>
</View>
) : null}
</LayoutView.ScrollView>
</LayoutView>
</PageLayout>
);
}
+93 -7
View File
@@ -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<ViewStyle>;
}
const images = {
const images: Record<Exclude<EmptyStateType, "loading">, 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<EmptyStateType, string> = {
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 (
<View className="w-24 h-24 items-center justify-center">
<Animated.View
className="absolute w-20 h-20 rounded-full bg-foreground/10"
style={{
opacity: pulseOpacity,
transform: [{ scale: pulseScale }],
}}
/>
<View className="w-16 h-16 rounded-full bg-foreground/5 items-center justify-center">
<Animated.View style={{ transform: [{ rotate }] }}>
<LoaderCircleIcon size={40} color="#111827" strokeWidth={2.4} />
</Animated.View>
</View>
</View>
);
}
export default function EmptyState({
type = "empty",
title,
description,
footer,
center,
className,
style,
}: EmptyStateProps & { style?: StyleProp<ViewStyle> }) {
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 (
<View
className={className}
style={[
centerStyle,
{
justifyContent: "center",
alignItems: "center",
@@ -48,7 +130,11 @@ export default function EmptyState({
style,
]}
>
<Image source={images[type]} style={{ width: 224, height: 224, resizeMode: "contain" }} />
{type === "loading" ? (
<LoadingVisual />
) : (
<Image source={images[type]} style={{ width: 224, height: 224, resizeMode: "contain" }} />
)}
<Text className="text-xl font-semibold text-foreground text-center mt-4">{finalTitle}</Text>
@@ -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 (
<View
style={{
width: size,
height: size,
borderRadius: Math.max(6, size * 0.25),
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
backgroundColor: DEFAULT_BG,
}}
>
{showImage ? (
<Image
source={{ uri }}
style={{ width: size, height: size }}
contentFit="contain"
cachePolicy="disk"
transition={80}
onError={() => setFailed(true)}
/>
) : (
<Text style={{ color: "#6B7280", fontSize: Math.max(12, size * 0.5), fontWeight: "700" }}>
{fallbackText}
</Text>
)}
</View>
);
}
export default memo(FinanceCategoryIcon);
+42 -9
View File
@@ -23,6 +23,11 @@ type LayoutConfig = {
bottomExtraPadding?: number;
};
type PinnedListConfig = {
header?: ReactNode;
footer?: ReactNode;
};
function Wrapper({ children }: { children: React.ReactNode }) {
return <View style={{ flex: 1, overflow: "hidden" }}>{children}</View>;
}
@@ -83,14 +88,46 @@ LayoutView.ScrollView = forwardRef<
}) as any;
/* ========================= SectionList ========================= */
LayoutView.SectionView = forwardRef(
<T,>(
props: React.ComponentProps<typeof SectionList<T>> & LayoutConfig,
props: React.ComponentProps<typeof SectionList<T>> & LayoutConfig & PinnedListConfig,
ref: React.Ref<SectionList<T>>,
) => {
const { topExtraPadding, bottomExtraPadding, contentContainerStyle, ...rest } = props;
const { header, footer, topExtraPadding, bottomExtraPadding, contentContainerStyle, ...rest } = props;
const padding = useContentPadding({ topExtraPadding, bottomExtraPadding });
if (header || footer) {
return (
<>
{header ? (
<View
style={{
paddingTop: padding.paddingTop,
paddingHorizontal: padding.paddingHorizontal,
}}
>
{header}
</View>
) : null}
<Wrapper>
<Shadow>
<SectionList ref={ref} {...rest} contentContainerStyle={contentContainerStyle} />
</Shadow>
</Wrapper>
{footer ? (
<View
style={{
paddingBottom: padding.paddingBottom,
paddingHorizontal: padding.paddingHorizontal,
}}
>
{footer}
</View>
) : null}
</>
);
}
return (
<Wrapper>
<Shadow>
@@ -101,16 +138,12 @@ LayoutView.SectionView = forwardRef(
},
) as <T>(
props: React.ComponentProps<typeof SectionList<T>> &
LayoutConfig & { ref?: React.Ref<SectionList<T>> },
LayoutConfig &
PinnedListConfig & { ref?: React.Ref<SectionList<T>> },
) => React.ReactElement;
/* ========================= FlatList / LegendList ========================= */
type PinnedListConfig = {
header?: ReactNode;
footer?: ReactNode;
};
LayoutView.FlatList = forwardRef(
<T,>(
{
+6
View File
@@ -94,3 +94,9 @@ export function s3UploadFileApi(files: any[]) {
},
});
}
export function s3DeleteFileApi(keys: string[]) {
return alovaInstance.Post<ResponseData<null>>("/s3/delete", {
keys,
});
}
+43 -8
View File
@@ -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<void>;
upsertCategory: (category: FinanceCategory) => void;
removeCategory: (id: number) => void;
}
export const useFinanceStore = create<FinanceState>((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),
})),
}));
+44 -3
View File
@@ -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;
}
+2 -1
View File
@@ -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";
+11
View File
@@ -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);
}