20260627165246
This commit is contained in:
1 parent
5983d29128
commit
bfb2b6037b
25 files changed
+2665
-518
No files matched your search
+1
-1
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
// 默认记账分类
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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,>(
|
||||
{
|
||||
|
||||
@@ -94,3 +94,9 @@ export function s3UploadFileApi(files: any[]) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function s3DeleteFileApi(keys: string[]) {
|
||||
return alovaInstance.Post<ResponseData<null>>("/s3/delete", {
|
||||
keys,
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
})),
|
||||
}));
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user