20260726225220
This commit is contained in:
1 parent
e3c7c6f8da
commit
a8478d60ed
170 files changed
+6786
-1678
No files matched your search
@@ -1,5 +1,5 @@
|
||||
EXPO_PUBLIC_API_URL=http://192.168.5.80:8087
|
||||
EXPO_PUBLIC_API_URL=http://192.168.5.115:8087
|
||||
# EXPO_PUBLIC_API_URL=https://www.oneao.cn/app
|
||||
EXPO_PUBLIC_WS_URL=ws://192.168.5.80:8087
|
||||
EXPO_PUBLIC_WS_URL=ws://192.168.5.115:8087
|
||||
EXPO_PUBLIC_G3_URL=http://118.89.70.199:9001
|
||||
EXPO_PUBLIC_G3_BUCKET=app-dev
|
||||
@@ -0,0 +1,21 @@
|
||||
// 开发期测试用的路径别名解析器:把 @/ 映射到 src/,并为 .ts/.tsx 探测扩展名
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolve as pathResolve } from "node:path";
|
||||
|
||||
const SRC = pathResolve(process.cwd(), "src");
|
||||
|
||||
const EXTS = [".ts", ".tsx", ".js", ".mjs", "/index.ts", "/index.tsx"];
|
||||
|
||||
export async function resolve(specifier, context, nextResolve) {
|
||||
if (specifier.startsWith("@/")) {
|
||||
const base = pathResolve(SRC, specifier.slice(2));
|
||||
for (const ext of EXTS) {
|
||||
try {
|
||||
return await nextResolve(pathToFileURL(base + ext).href, context);
|
||||
} catch {
|
||||
// 尝试下一个扩展名
|
||||
}
|
||||
}
|
||||
}
|
||||
return nextResolve(specifier, context);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import dayjs from "dayjs";
|
||||
import { getMissedDates, buildAutoBillInserts } from "@/helpers/financeAuto";
|
||||
|
||||
// 对应 src/types/finance.ts 中 FinanceAutoRepeatType 的枚举值(从 1 开始)
|
||||
const Daily = 1;
|
||||
const Weekly = 2;
|
||||
const Monthly = 3;
|
||||
const Quarterly = 4;
|
||||
const Yearly = 5;
|
||||
|
||||
// b_finance 表合法列(由前端查询推断,确认不含 type)
|
||||
const B_FINANCE_COLUMNS = new Set([
|
||||
"id", "family_id", "category_id", "amount", "remark",
|
||||
"record_time", "create_by", "update_by", "create_time", "update_time",
|
||||
]);
|
||||
|
||||
// 统一使用本地时间,与 financeAuto.ts 内部 dayjs() 保持一致
|
||||
const todayStr = () => dayjs().format("YYYY-MM-DD");
|
||||
const yesterday = () => dayjs().subtract(1, "day").format("YYYY-MM-DD");
|
||||
const day = () => dayjs().day();
|
||||
const dateOfMonth = () => String(dayjs().date());
|
||||
const monthDay = () => `${dayjs().month() + 1},${dayjs().date()}`;
|
||||
|
||||
let passed = 0;
|
||||
function check(name, cond) {
|
||||
if (!cond) {
|
||||
console.error(" ✗ " + name);
|
||||
throw new Error("FAILED: " + name);
|
||||
}
|
||||
passed++;
|
||||
console.log(" ✓ " + name);
|
||||
}
|
||||
|
||||
console.log("== 1. 触发日期计算 (getMissedDates) ==");
|
||||
{
|
||||
const dates = getMissedDates(Daily, "", yesterday());
|
||||
check("Daily 上次昨天→今天1条", dates.length === 1);
|
||||
check("Daily 日期=今天", dates[0] === todayStr());
|
||||
}
|
||||
{
|
||||
const dates = getMissedDates(Monthly, dateOfMonth(), yesterday());
|
||||
check("Monthly 今天日期→1条", dates.length === 1 && dates[0] === todayStr());
|
||||
}
|
||||
{
|
||||
const dates = getMissedDates(Yearly, monthDay(), yesterday());
|
||||
check("Yearly 今天月日→1条", dates.length === 1 && dates[0] === todayStr());
|
||||
}
|
||||
{
|
||||
const dates = getMissedDates(Daily, "!" + day(), yesterday()); // 排除今天星期
|
||||
check("Daily 排除今天→0条", dates.length === 0);
|
||||
}
|
||||
{
|
||||
const dates = getMissedDates(Daily, "", todayStr()); // lastRun=今天
|
||||
check("lastRun=今天→0条", dates.length === 0);
|
||||
}
|
||||
|
||||
console.log("== 2. 写入 b_finance 的记录构造 (buildAutoBillInserts) ==");
|
||||
{
|
||||
const rule = { category_id: 10, amount: 99.5, remark: "房租" };
|
||||
const missed = [todayStr(), yesterday()];
|
||||
const ids = [1001, 1002];
|
||||
const inserts = buildAutoBillInserts(rule, missed, ids, 7, 3);
|
||||
check("生成2条记录", inserts.length === 2);
|
||||
let ok = true;
|
||||
for (const rec of inserts) {
|
||||
for (const k of Object.keys(rec)) {
|
||||
if (!B_FINANCE_COLUMNS.has(k)) ok = false;
|
||||
}
|
||||
if ("type" in rec) ok = false;
|
||||
}
|
||||
check("记录字段均在 b_finance 合法列内且不含 type", ok);
|
||||
check("remark 正确透传", inserts[0].remark === "房租");
|
||||
check("record_time 使用补记账日期", inserts[1].record_time === yesterday());
|
||||
}
|
||||
{
|
||||
const inserts = buildAutoBillInserts({ category_id: 1, amount: 1 }, [todayStr()], [], 7, 3);
|
||||
check("ids 为空→0条", inserts.length === 0);
|
||||
}
|
||||
|
||||
console.log("\n全部通过:" + passed + " 项 ✅");
|
||||
@@ -552,6 +552,12 @@ export default function HomeSettings() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<BottomSheet.Header
|
||||
title="家庭名称"
|
||||
onCancel={() => setShowFamilyNameSheet(false)}
|
||||
onConfirm={handleRename}
|
||||
confirmDisabled={isLoading || !renameForm.name.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">家庭名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -563,9 +569,6 @@ export default function HomeSettings() {
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</TextField>
|
||||
<Button onPress={handleRename} isDisabled={isLoading || !renameForm.name.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -575,6 +578,12 @@ export default function HomeSettings() {
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false}>
|
||||
<View className="gap-4">
|
||||
<BottomSheet.Header
|
||||
title="我的昵称"
|
||||
onCancel={() => setShowNicknameSheet(false)}
|
||||
onConfirm={handleNickname}
|
||||
confirmDisabled={isLoading || !nicknameForm.nickname.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">我的昵称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -586,12 +595,6 @@ export default function HomeSettings() {
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</TextField>
|
||||
<Button
|
||||
onPress={handleNickname}
|
||||
isDisabled={isLoading || !nicknameForm.nickname.trim()}
|
||||
>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
|
||||
@@ -731,6 +731,12 @@ export default function UserSetting() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4">
|
||||
<BottomSheet.Header
|
||||
title="修改姓名"
|
||||
onCancel={() => setShowNameSheet(false)}
|
||||
onConfirm={handleRename}
|
||||
confirmDisabled={isLoading || !renameForm.name.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">姓名</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -742,9 +748,6 @@ export default function UserSetting() {
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</TextField>
|
||||
<Button onPress={handleRename} isDisabled={isLoading || !renameForm.name.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -753,6 +756,12 @@ export default function UserSetting() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4">
|
||||
<BottomSheet.Header
|
||||
title="修改手机号"
|
||||
onCancel={() => setShowPhoneSheet(false)}
|
||||
onConfirm={handlePhone}
|
||||
confirmDisabled={isLoading || !phoneForm.phone.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">手机号</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -764,9 +773,6 @@ export default function UserSetting() {
|
||||
returnKeyType="done"
|
||||
/>
|
||||
</TextField>
|
||||
<Button onPress={handlePhone} isDisabled={isLoading || !phoneForm.phone.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Trash2Icon,
|
||||
} from "lucide-react-native";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import CategoryChipBar from "@/components/CategoryChipBar";
|
||||
import ContextMenu from "@/components/ContextMenu";
|
||||
import EmptyState from "@/components/EmptyStatus";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
@@ -78,9 +79,17 @@ export default function ClothingHome() {
|
||||
const hasMore = clothes.length < clothesTotal;
|
||||
|
||||
const clothesCategories = useMemo(
|
||||
() => categories.filter((c) => c.type === ClothingTargetType.Clothes),
|
||||
() =>
|
||||
categories
|
||||
.filter((c) => c.type === ClothingTargetType.Clothes)
|
||||
.sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[categories],
|
||||
);
|
||||
|
||||
// 顶部分类快捷筛选(与筛选弹层的分类状态共用 filter.categoryId)
|
||||
const handleQuickCategory = useCallback((id: number | null) => {
|
||||
setFilter((p) => ({ ...p, categoryId: id }));
|
||||
}, []);
|
||||
const clothesTags = useMemo(
|
||||
() => tags.filter((t) => t.type === ClothingTargetType.Clothes),
|
||||
[tags],
|
||||
@@ -353,6 +362,12 @@ export default function ClothingHome() {
|
||||
]}
|
||||
>
|
||||
{renderHeader()}
|
||||
<CategoryChipBar
|
||||
categories={clothesCategories}
|
||||
activeId={filter.categoryId}
|
||||
onChange={handleQuickCategory}
|
||||
style={{ marginBottom: 14 }}
|
||||
/>
|
||||
{filteredClothes.length === 0 ? (
|
||||
<EmptyState
|
||||
type={loading ? "loading" : "empty"}
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Text, TouchableOpacity, View } from "react-native";
|
||||
import { Button, Dialog, Label, TextField } from "heroui-native";
|
||||
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native";
|
||||
import { GripVerticalIcon, PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native";
|
||||
import Sortable, { SortableGridRenderItem } from "react-native-sortables";
|
||||
import { useAnimatedRef } from "react-native-reanimated";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { useListLoading, useLoading } from "@/hooks/useLoading";
|
||||
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
||||
import { useClothingStore, useFamilyStore } from "@/store";
|
||||
import { ClothingCategory, ClothingTag, ClothingTargetType } from "@/types/clothing";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
const COLORS = [
|
||||
"#EF4444",
|
||||
@@ -217,14 +220,151 @@ export default function ClothingManage() {
|
||||
}
|
||||
}, [loadData, saveTagChanges, startLoading, stopLoading, tagDelete]);
|
||||
|
||||
const getTagsByGroup = useCallback(
|
||||
(groupId: number) => tags.filter((t) => t.parent_id === groupId),
|
||||
// ── 拖拽排序 ──────────────────────────────────
|
||||
const scrollRef = useAnimatedRef<any>();
|
||||
|
||||
const sortedCurCategories = useMemo(
|
||||
() => [...curCategories].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[curCategories],
|
||||
);
|
||||
const sortedCurTagGroups = useMemo(
|
||||
() => [...curTagGroups].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[curTagGroups],
|
||||
);
|
||||
const getSortedTagsByGroup = useCallback(
|
||||
(groupId: number) =>
|
||||
tags
|
||||
.filter((t) => t.parent_id === groupId)
|
||||
.sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[tags],
|
||||
);
|
||||
|
||||
const handleCatDragEnd = useCallback(
|
||||
async ({ data }: { data: ClothingCategory[] }) => {
|
||||
const updates = data.map((item, index) => ({ id: item.id, sort_number: index }));
|
||||
const ok = await saveCategoryChanges({ updates });
|
||||
if (!ok) toast.error("排序保存失败");
|
||||
else await loadData();
|
||||
},
|
||||
[loadData, saveCategoryChanges],
|
||||
);
|
||||
|
||||
const handleTagGroupDragEnd = useCallback(
|
||||
async ({ data }: { data: ClothingTag[] }) => {
|
||||
const updates = data.map((item, index) => ({ id: item.id, sort_number: index }));
|
||||
const ok = await saveTagChanges({ updates });
|
||||
if (!ok) toast.error("排序保存失败");
|
||||
else await loadData();
|
||||
},
|
||||
[loadData, saveTagChanges],
|
||||
);
|
||||
|
||||
const handleChildTagDragEnd = useCallback(
|
||||
async (data: ClothingTag[]) => {
|
||||
const updates = data.map((item, index) => ({ id: item.id, sort_number: index }));
|
||||
const ok = await saveTagChanges({ updates });
|
||||
if (!ok) toast.error("排序保存失败");
|
||||
else await loadData();
|
||||
},
|
||||
[loadData, saveTagChanges],
|
||||
);
|
||||
|
||||
const renderChildTagItem = useCallback<SortableGridRenderItem<ClothingTag>>(
|
||||
({ item }) => (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Sortable.Handle>
|
||||
<GripVerticalIcon size={16} color="#9CA3AF" />
|
||||
</Sortable.Handle>
|
||||
<TouchableOpacity
|
||||
className="rounded-full px-2.5 py-1"
|
||||
style={{ backgroundColor: item.color || "#E5E7EB" }}
|
||||
onPress={() => openTag(item.parent_id as number, item)}
|
||||
>
|
||||
<Text className="text-xs font-medium" style={{ color: item.color ? "#fff" : "#4B5563" }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
[openTag],
|
||||
);
|
||||
|
||||
const renderGroupItem = useCallback<SortableGridRenderItem<ClothingTag>>(
|
||||
({ item }) => {
|
||||
const gt = getSortedTagsByGroup(item.id);
|
||||
return (
|
||||
<View className="bg-white rounded-xl px-4 py-3.5 border border-gray-100">
|
||||
<View className="flex-row items-center justify-between mb-2.5">
|
||||
<View className="flex-row items-center gap-2 flex-1">
|
||||
<Sortable.Handle>
|
||||
<GripVerticalIcon size={18} color="#9CA3AF" />
|
||||
</Sortable.Handle>
|
||||
<TouchableOpacity className="flex-1" onPress={() => openGroup(item)}>
|
||||
<Text className="text-sm font-semibold text-gray-900">{item.name}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={() => setGroupDelete(item)}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{gt.length > 0 && (
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
customHandle
|
||||
scrollableRef={scrollRef}
|
||||
data={gt}
|
||||
keyExtractor={(t) => String(t.id)}
|
||||
renderItem={renderChildTagItem}
|
||||
rowGap={8}
|
||||
onDragEnd={({ data }) => handleChildTagDragEnd(data)}
|
||||
/>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
className="mt-2 self-start rounded-full px-2.5 py-1 bg-gray-50 border border-dashed border-gray-300"
|
||||
onPress={() => openTag(item.id)}
|
||||
>
|
||||
<Text className="text-xs text-gray-400">+ 添加</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[getSortedTagsByGroup, handleChildTagDragEnd, openGroup, openTag, renderChildTagItem],
|
||||
);
|
||||
|
||||
const renderCatItem = useCallback<SortableGridRenderItem<ClothingCategory>>(
|
||||
({ item }) => (
|
||||
<TouchableOpacity
|
||||
className="bg-white rounded-xl px-4 py-3.5 border border-gray-100 flex-row items-center justify-between"
|
||||
activeOpacity={0.72}
|
||||
onPress={() => openCat(item)}
|
||||
>
|
||||
<View className="flex-row items-center gap-2 flex-1">
|
||||
<Sortable.Handle>
|
||||
<GripVerticalIcon size={18} color="#9CA3AF" />
|
||||
</Sortable.Handle>
|
||||
<Text className="text-sm font-semibold text-gray-900">{item.name}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
setCatDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
[openCat],
|
||||
);
|
||||
|
||||
return (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView
|
||||
ref={scrollRef}
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => runRefresh(loadData)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -285,38 +425,17 @@ export default function ClothingManage() {
|
||||
<Text className="text-xs font-medium text-white">添加</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{curCategories.length > 0 ? (
|
||||
<View className="gap-3">
|
||||
{curCategories.map((c) => (
|
||||
<View
|
||||
key={c.id}
|
||||
className="bg-white rounded-xl px-4 py-3.5 border border-gray-100"
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between"
|
||||
onPress={() => openCat(c)}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-gray-900">{c.name}</Text>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
setCatDelete(c);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{sortedCurCategories.length > 0 ? (
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
customHandle
|
||||
scrollableRef={scrollRef}
|
||||
data={sortedCurCategories}
|
||||
keyExtractor={(c) => String(c.id)}
|
||||
renderItem={renderCatItem}
|
||||
rowGap={12}
|
||||
onDragEnd={handleCatDragEnd}
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-sm text-gray-400 py-4">暂无分类,点击右上角添加</Text>
|
||||
)}
|
||||
@@ -337,60 +456,17 @@ export default function ClothingManage() {
|
||||
<Text className="text-xs font-medium text-white">新建分组</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{curTagGroups.length > 0 ? (
|
||||
<View className="gap-3">
|
||||
{curTagGroups.map((group) => {
|
||||
const gTags = getTagsByGroup(group.id);
|
||||
return (
|
||||
<View
|
||||
key={group.id}
|
||||
className="bg-white rounded-xl px-4 py-3.5 border border-gray-100"
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center justify-between mb-2.5">
|
||||
<TouchableOpacity className="flex-1" onPress={() => openGroup(group)}>
|
||||
<Text className="text-sm font-semibold text-gray-900">{group.name}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={() => setGroupDelete(group)}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex-row flex-wrap gap-1.5">
|
||||
{gTags.map((t) => (
|
||||
<TouchableOpacity
|
||||
key={t.id}
|
||||
className="rounded-full px-2.5 py-1"
|
||||
style={{ backgroundColor: t.color || "#E5E7EB" }}
|
||||
onPress={() => openTag(group.id, t)}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-medium"
|
||||
style={{ color: t.color ? "#fff" : "#4B5563" }}
|
||||
>
|
||||
{t.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
className="rounded-full px-2.5 py-1 bg-gray-50 border border-dashed border-gray-300"
|
||||
onPress={() => openTag(group.id)}
|
||||
>
|
||||
<Text className="text-xs text-gray-400">+ 添加</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{sortedCurTagGroups.length > 0 ? (
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
customHandle
|
||||
scrollableRef={scrollRef}
|
||||
data={sortedCurTagGroups}
|
||||
keyExtractor={(g) => String(g.id)}
|
||||
renderItem={renderGroupItem}
|
||||
rowGap={12}
|
||||
onDragEnd={handleTagGroupDragEnd}
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-sm text-gray-400 py-4">暂无标签分组,先新建分组再添加标签</Text>
|
||||
)}
|
||||
@@ -401,10 +477,13 @@ export default function ClothingManage() {
|
||||
<BottomSheet isOpen={showCatSheet} onOpenChange={setShowCatSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">
|
||||
{editingCat ? "编辑分类" : "添加分类"}
|
||||
</Text>
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }} onSubmit={saveCat}>
|
||||
<BottomSheet.Header
|
||||
title={editingCat ? "编辑分类" : "添加分类"}
|
||||
onCancel={() => setShowCatSheet(false)}
|
||||
onConfirm={saveCat}
|
||||
confirmDisabled={!catForm.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -414,14 +493,6 @@ export default function ClothingManage() {
|
||||
autoFocus
|
||||
/>
|
||||
</TextField>
|
||||
<View className="flex-row gap-3">
|
||||
<Button variant="secondary" className="flex-1" onPress={() => setShowCatSheet(false)}>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={saveCat} isDisabled={!catForm.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -430,10 +501,13 @@ export default function ClothingManage() {
|
||||
<BottomSheet isOpen={showGroupSheet} onOpenChange={setShowGroupSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">
|
||||
{editingGroup ? "编辑分组" : "新建分组"}
|
||||
</Text>
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }} onSubmit={saveGroup}>
|
||||
<BottomSheet.Header
|
||||
title={editingGroup ? "编辑分组" : "新建分组"}
|
||||
onCancel={() => setShowGroupSheet(false)}
|
||||
onConfirm={saveGroup}
|
||||
confirmDisabled={!groupForm.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -443,18 +517,6 @@ export default function ClothingManage() {
|
||||
autoFocus
|
||||
/>
|
||||
</TextField>
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onPress={() => setShowGroupSheet(false)}
|
||||
>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={saveGroup} isDisabled={!groupForm.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -464,9 +526,12 @@ export default function ClothingManage() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">
|
||||
{editingTag ? "编辑标签" : "添加标签"}
|
||||
</Text>
|
||||
<BottomSheet.Header
|
||||
title={editingTag ? "编辑标签" : "添加标签"}
|
||||
onCancel={() => setShowTagSheet(false)}
|
||||
onConfirm={saveTag}
|
||||
confirmDisabled={!tagForm.name.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -507,14 +572,6 @@ export default function ClothingManage() {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row gap-3">
|
||||
<Button variant="secondary" className="flex-1" onPress={() => setShowTagSheet(false)}>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={saveTag} isDisabled={!tagForm.name.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { Button, Dialog, Label, TextField } from "heroui-native";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import CategoryChipBar from "@/components/CategoryChipBar";
|
||||
import ContextMenu from "@/components/ContextMenu";
|
||||
import {
|
||||
FilterIcon,
|
||||
@@ -180,6 +181,14 @@ export default function ClothingOutfitList() {
|
||||
[outfitClothes],
|
||||
);
|
||||
|
||||
const outfitCategories = useMemo(
|
||||
() =>
|
||||
categories
|
||||
.filter((c) => c.type === ClothingTargetType.Outfit)
|
||||
.sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const filteredOutfits = useMemo(() => {
|
||||
const kw = keyword.trim().toLowerCase();
|
||||
return outfits.filter((item) => {
|
||||
@@ -309,6 +318,12 @@ export default function ClothingOutfitList() {
|
||||
]}
|
||||
>
|
||||
{renderHeader()}
|
||||
<CategoryChipBar
|
||||
categories={outfitCategories}
|
||||
activeId={filterCategoryId}
|
||||
onChange={setFilterCategoryId}
|
||||
style={{ marginBottom: 14 }}
|
||||
/>
|
||||
{filteredOutfits.length === 0 ? (
|
||||
<EmptyState
|
||||
type={loading ? "loading" : "empty"}
|
||||
@@ -380,9 +395,7 @@ export default function ClothingOutfitList() {
|
||||
全部
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{categories
|
||||
.filter((c) => c.type === ClothingTargetType.Outfit)
|
||||
.map((c) => {
|
||||
{outfitCategories.map((c) => {
|
||||
const s = filterCategoryId === c.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
NativeSyntheticEvent,
|
||||
NativeScrollEvent,
|
||||
PanResponder,
|
||||
@@ -93,14 +94,16 @@ function createCanvasItem(clothesId: number, index: number, zIndex: number): Can
|
||||
|
||||
function DraggableCanvasItem({
|
||||
item,
|
||||
canvasSize,
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
clothes,
|
||||
selected,
|
||||
onSelect,
|
||||
onMove,
|
||||
}: {
|
||||
item: CanvasItem;
|
||||
canvasSize: number;
|
||||
canvasWidth: number;
|
||||
canvasHeight: number;
|
||||
clothes?: ClothingClothes;
|
||||
selected: boolean;
|
||||
onSelect: (uid: string) => void;
|
||||
@@ -108,9 +111,11 @@ function DraggableCanvasItem({
|
||||
}) {
|
||||
const startRef = useRef({ x: item.x, y: item.y });
|
||||
const itemRef = useRef(item);
|
||||
const sizeRef = useRef(canvasSize);
|
||||
const widthRef = useRef(canvasWidth);
|
||||
const heightRef = useRef(canvasHeight);
|
||||
itemRef.current = item;
|
||||
sizeRef.current = canvasSize;
|
||||
widthRef.current = canvasWidth;
|
||||
heightRef.current = canvasHeight;
|
||||
|
||||
const panResponder = useRef(
|
||||
PanResponder.create({
|
||||
@@ -121,17 +126,18 @@ function DraggableCanvasItem({
|
||||
onSelect(itemRef.current.uid);
|
||||
},
|
||||
onPanResponderMove: (_, gestureState) => {
|
||||
const sz = sizeRef.current;
|
||||
const w = widthRef.current;
|
||||
const h = heightRef.current;
|
||||
const it = itemRef.current;
|
||||
if (!sz) return;
|
||||
const nextX = clamp(startRef.current.x + gestureState.dx / sz, 0, 1 - it.width);
|
||||
const nextY = clamp(startRef.current.y + gestureState.dy / sz, 0, 1 - it.height);
|
||||
if (!w || !h) return;
|
||||
const nextX = clamp(startRef.current.x + gestureState.dx / w, 0, 1 - it.width);
|
||||
const nextY = clamp(startRef.current.y + gestureState.dy / h, 0, 1 - it.height);
|
||||
onMove(it.uid, normalizeNumber(nextX), normalizeNumber(nextY));
|
||||
},
|
||||
}),
|
||||
).current;
|
||||
|
||||
if (!canvasSize) return null;
|
||||
if (!canvasWidth || !canvasHeight) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
@@ -140,10 +146,10 @@ function DraggableCanvasItem({
|
||||
selected ? "border-2 border-gray-900" : "border border-white"
|
||||
}`}
|
||||
style={{
|
||||
left: item.x * canvasSize,
|
||||
top: item.y * canvasSize,
|
||||
width: item.width * canvasSize,
|
||||
height: item.height * canvasSize,
|
||||
left: item.x * canvasWidth,
|
||||
top: item.y * canvasHeight,
|
||||
width: item.width * canvasWidth,
|
||||
height: item.height * canvasHeight,
|
||||
zIndex: item.zIndex,
|
||||
}}
|
||||
>
|
||||
@@ -190,6 +196,7 @@ export default function ClothingOutfitEdit() {
|
||||
const [remark, setRemark] = useState("");
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
const [canvasSize, setCanvasSize] = useState(0);
|
||||
const [canvasHeight, setCanvasHeight] = useState(0);
|
||||
const [items, setItems] = useState<CanvasItem[]>([]);
|
||||
const [selectedUid, setSelectedUid] = useState<string | null>(null);
|
||||
const [pageLoading, setPageLoading] = useState(true);
|
||||
@@ -246,11 +253,13 @@ export default function ClothingOutfitEdit() {
|
||||
|
||||
const clothesFilterCount = useMemo(() => {
|
||||
let c = 0;
|
||||
if (clothesFilterCatId) c++;
|
||||
if (clothesFilterTagIds.length > 0) c++;
|
||||
if (clothesFilterOwnerId) c++;
|
||||
return c;
|
||||
}, [clothesFilterCatId, clothesFilterTagIds, clothesFilterOwnerId]);
|
||||
}, [clothesFilterTagIds, clothesFilterOwnerId]);
|
||||
|
||||
// 从衣橱选择衣服弹窗默认高度:固定接近顶部,约为屏幕高度减去外壳估算
|
||||
const clothesSheetHeight = Math.max(Dimensions.get("window").height - 200, 360);
|
||||
|
||||
const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]);
|
||||
const outfitCategories = useMemo(
|
||||
@@ -729,7 +738,11 @@ export default function ClothingOutfitEdit() {
|
||||
collapsable={false}
|
||||
className="w-full flex-1"
|
||||
style={{ aspectRatio: 3 / 4 }}
|
||||
onLayout={(event) => setCanvasSize(event.nativeEvent.layout.width)}
|
||||
onLayout={(event) => {
|
||||
const { width, height } = event.nativeEvent.layout;
|
||||
setCanvasSize(width);
|
||||
setCanvasHeight(height);
|
||||
}}
|
||||
>
|
||||
{items.length === 0 && (
|
||||
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
|
||||
@@ -741,7 +754,8 @@ export default function ClothingOutfitEdit() {
|
||||
<DraggableCanvasItem
|
||||
key={item.uid}
|
||||
item={item}
|
||||
canvasSize={canvasSize}
|
||||
canvasWidth={canvasSize}
|
||||
canvasHeight={canvasHeight}
|
||||
clothes={clothesById.get(item.clothesId)}
|
||||
selected={!capturingCover && item.uid === selectedUid}
|
||||
onSelect={setSelectedUid}
|
||||
@@ -890,7 +904,11 @@ export default function ClothingOutfitEdit() {
|
||||
</LayoutView>
|
||||
|
||||
{/* 从衣橱选择衣服 */}
|
||||
<BottomSheet isOpen={showClothesSheet} onOpenChange={setShowClothesSheet}>
|
||||
<BottomSheet
|
||||
isOpen={showClothesSheet}
|
||||
onOpenChange={setShowClothesSheet}
|
||||
avoidKeyboard={false}
|
||||
>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-3" style={{ paddingHorizontal: 16 }}>
|
||||
@@ -915,9 +933,40 @@ export default function ClothingOutfitEdit() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 分类快捷选择(直接放在外部,不进筛选) */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8, paddingRight: 4 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className={`rounded-full border px-3 py-2 ${clothesFilterCatId == null ? "border-accent bg-accent" : "border-gray-200 bg-white"}`}
|
||||
onPress={() => setClothesFilterCatId(null)}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${clothesFilterCatId == null ? "text-white" : "text-gray-700"}`}
|
||||
>
|
||||
全部
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{clothesCategories.map((c) => (
|
||||
<TouchableOpacity
|
||||
key={c.id}
|
||||
className={`rounded-full border px-3 py-2 ${clothesFilterCatId === c.id ? "border-accent bg-accent" : "border-gray-200 bg-white"}`}
|
||||
onPress={() => setClothesFilterCatId((p) => (p === c.id ? null : c.id))}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${clothesFilterCatId === c.id ? "text-white" : "text-gray-700"}`}
|
||||
>
|
||||
{c.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* 衣服列表 */}
|
||||
<ScrollView
|
||||
className="max-h-80"
|
||||
style={{ height: clothesSheetHeight }}
|
||||
showsVerticalScrollIndicator
|
||||
onMomentumScrollEnd={async (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
||||
@@ -1029,36 +1078,6 @@ export default function ClothingOutfitEdit() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 分类 */}
|
||||
<View>
|
||||
<Label className="mb-2">分类</Label>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<TouchableOpacity
|
||||
className={`rounded-full border px-3 py-2 ${clothesFilterCatId == null ? "border-accent bg-accent" : "border-gray-200 bg-white"}`}
|
||||
onPress={() => setClothesFilterCatId(null)}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${clothesFilterCatId == null ? "text-white" : "text-gray-700"}`}
|
||||
>
|
||||
全部
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{clothesCategories.map((c) => (
|
||||
<TouchableOpacity
|
||||
key={c.id}
|
||||
className={`rounded-full border px-3 py-2 ${clothesFilterCatId === c.id ? "border-accent bg-accent" : "border-gray-200 bg-white"}`}
|
||||
onPress={() => setClothesFilterCatId((p) => (p === c.id ? null : c.id))}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${clothesFilterCatId === c.id ? "text-white" : "text-gray-700"}`}
|
||||
>
|
||||
{c.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 标签 */}
|
||||
<View>
|
||||
<Label className="mb-2">标签</Label>
|
||||
|
||||
@@ -592,20 +592,26 @@ export default function FinanceAnalysis() {
|
||||
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-1" />
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">筛选分析</Text>
|
||||
<View className="flex-1 items-end">
|
||||
<BottomSheet.Content
|
||||
animateOnMount={false}
|
||||
className="gap-4"
|
||||
onSubmit={handleApplyFilter}
|
||||
>
|
||||
<BottomSheet.Header
|
||||
title="筛选分析"
|
||||
confirmText="应用"
|
||||
onCancel={() => setShowFilterSheet(false)}
|
||||
onConfirm={handleApplyFilter}
|
||||
rightExtra={
|
||||
<TouchableOpacity
|
||||
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
|
||||
className="h-8 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>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -807,20 +813,6 @@ export default function FinanceAnalysis() {
|
||||
/>
|
||||
</TextField>
|
||||
</ScrollView>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<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>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -385,9 +385,12 @@ export default function AutoRulesPage() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content style={{ paddingHorizontal: 16, maxHeight: 600 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950 mb-3">
|
||||
{editingRuleId ? "编辑规则" : "新增规则"}
|
||||
</Text>
|
||||
<BottomSheet.Header
|
||||
title={editingRuleId ? "编辑规则" : "新增规则"}
|
||||
onCancel={() => setShowEditor(false)}
|
||||
onConfirm={handleSave}
|
||||
/>
|
||||
<View className="mb-3" />
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -616,16 +619,6 @@ export default function AutoRulesPage() {
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View className="flex-row gap-3 pt-3">
|
||||
<Button variant="secondary" className="flex-1" onPress={() => setShowEditor(false)}>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={handleSave}>
|
||||
<CheckIcon size={18} color="#fff" />
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -256,11 +256,15 @@ export default function Home() {
|
||||
// 自动记账:加载规则并执行
|
||||
if (!autoRunRef.current && userId) {
|
||||
autoRunRef.current = true;
|
||||
fetchAutoRules(familyId).then(() => {
|
||||
runAutoRules(familyId, userId).then((count) => {
|
||||
fetchAutoRules(familyId)
|
||||
.then(() => runAutoRules(familyId, userId))
|
||||
.then((count) => {
|
||||
if (count > 0) loadBills(filter);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("自动记账执行失败", err);
|
||||
Alert.alert("自动记账失败", err?.message || "请稍后重试");
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [familyId, filter, loadBills, fetchAutoRules, runAutoRules, userId]),
|
||||
);
|
||||
@@ -589,20 +593,26 @@ export default function Home() {
|
||||
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-1" />
|
||||
<Text className="text-center text-lg font-semibold text-gray-900">筛选记账</Text>
|
||||
<View className="flex-1 items-end">
|
||||
<BottomSheet.Content
|
||||
animateOnMount={false}
|
||||
className="gap-4"
|
||||
onSubmit={handleApplyFilter}
|
||||
>
|
||||
<BottomSheet.Header
|
||||
title="筛选记账"
|
||||
confirmText="应用"
|
||||
onCancel={() => setShowFilterSheet(false)}
|
||||
onConfirm={handleApplyFilter}
|
||||
rightExtra={
|
||||
<TouchableOpacity
|
||||
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
|
||||
className="h-8 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>
|
||||
}
|
||||
/>
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -725,20 +735,6 @@ export default function Home() {
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<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>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -817,8 +813,13 @@ export default function Home() {
|
||||
<BottomSheet isOpen={showAutoSheet} onOpenChange={setShowAutoSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content style={{ paddingHorizontal: 16, maxHeight: 560 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950 mb-3">自动记账</Text>
|
||||
<BottomSheet.Content style={{ paddingHorizontal: 16, maxHeight: 560 }} onSubmit={handleSaveAuto}>
|
||||
<BottomSheet.Header
|
||||
title="自动记账"
|
||||
onCancel={() => setShowAutoSheet(false)}
|
||||
onConfirm={handleSaveAuto}
|
||||
/>
|
||||
<View className="mb-3" />
|
||||
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -1031,20 +1032,6 @@ export default function Home() {
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<View className="flex-row gap-3 pt-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onPress={() => setShowAutoSheet(false)}
|
||||
>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={handleSaveAuto}>
|
||||
<CheckIcon size={18} color="#fff" />
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -62,6 +62,9 @@ export default function Add() {
|
||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||
const notifyModuleOperation = useModuleChatNotifier();
|
||||
const amountInputRef = useRef<any>(null);
|
||||
const remarkInputRef = useRef<any>(null);
|
||||
const enteredRemarkRef = useRef(false);
|
||||
const amountProgrammaticFocusRef = useRef(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState("0"); // 0=支出 1=收入
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -335,6 +338,28 @@ export default function Add() {
|
||||
ref={amountInputRef}
|
||||
placeholder="0.00"
|
||||
inputMode="decimal"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
enteredRemarkRef.current = true;
|
||||
Keyboard.dismiss();
|
||||
// 收起后重新聚焦,强制触发键盘弹出事件,让面板重新适配
|
||||
setTimeout(() => remarkInputRef.current?.focus(), 100);
|
||||
}}
|
||||
onFocus={() => {
|
||||
// 从备注框点回金额框时,键盘已打开不会重新弹出,
|
||||
// 需强制收起再聚焦以触发适配,避免备注内容被遮挡。
|
||||
// 程序化 focus 触发的 onFocus 直接跳过,避免循环
|
||||
if (!enteredRemarkRef.current) return;
|
||||
if (amountProgrammaticFocusRef.current) {
|
||||
amountProgrammaticFocusRef.current = false;
|
||||
return;
|
||||
}
|
||||
enteredRemarkRef.current = false;
|
||||
Keyboard.dismiss();
|
||||
amountProgrammaticFocusRef.current = true;
|
||||
setTimeout(() => amountInputRef.current?.focus(), 100);
|
||||
}}
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
className="text-4xl font-bold text-center w-full"
|
||||
@@ -346,6 +371,7 @@ export default function Add() {
|
||||
<TextField>
|
||||
<Label className="mb-2">备注</Label>
|
||||
<BottomSheet.Input
|
||||
ref={remarkInputRef}
|
||||
placeholder="写点什么..."
|
||||
value={remark}
|
||||
onChangeText={setRemark}
|
||||
|
||||
@@ -356,9 +356,15 @@ export default function FinanceCategoryList() {
|
||||
>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||
<BottomSheet.Content animateOnMount={false} className="gap-4" onSubmit={handleSave}>
|
||||
{form && (
|
||||
<>
|
||||
<BottomSheet.Header
|
||||
title={form.id ? "编辑分类" : "新增分类"}
|
||||
onCancel={closeSheet}
|
||||
onConfirm={handleSave}
|
||||
confirmDisabled={saving || uploading}
|
||||
/>
|
||||
<View className="items-center py-2">
|
||||
<TouchableOpacity
|
||||
className="w-24 h-24 rounded-3xl bg-gray-100 items-center justify-center"
|
||||
@@ -402,10 +408,6 @@ export default function FinanceCategoryList() {
|
||||
</Tabs>
|
||||
</View>
|
||||
|
||||
<Button onPress={handleSave} isDisabled={saving || uploading}>
|
||||
<SaveIcon size={18} color="#fff" />
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</BottomSheet.Content>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import { Button, Dialog, Label, TextField } from "heroui-native";
|
||||
import { PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native";
|
||||
import { GripVerticalIcon, PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native";
|
||||
import Sortable, { SortableGridRenderItem } from "react-native-sortables";
|
||||
import { useAnimatedRef } from "react-native-reanimated";
|
||||
import BottomSheet from "@/components/BottomSheet";
|
||||
import LayoutView from "@/components/layout/view";
|
||||
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
||||
@@ -9,6 +11,7 @@ import { useListLoading, useLoading } from "@/hooks/useLoading";
|
||||
import { getUniqueIdApi } from "@/request/api";
|
||||
import { useFamilyStore, useMealStore } from "@/store";
|
||||
import { MealCategory, MealSlot, MealStatus, MealTag } from "@/types/meal";
|
||||
import { toast } from "sonner-native";
|
||||
|
||||
type ManageTab = "category" | "tag" | "slot";
|
||||
|
||||
@@ -387,14 +390,195 @@ export default function MealManagePage() {
|
||||
}
|
||||
}, [loadData, saveSlotChanges, slotDelete, startLoading, stopLoading]);
|
||||
|
||||
const getTagsByGroup = useCallback(
|
||||
(gId: number) => tags.filter((t) => t.parent_id === gId),
|
||||
// ── 拖拽排序 ──────────────────────────────────
|
||||
const scrollRef = useAnimatedRef<any>();
|
||||
|
||||
const sortedCategories = useMemo(
|
||||
() => [...categories].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[categories],
|
||||
);
|
||||
const sortedSlots = useMemo(
|
||||
() => [...slots].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[slots],
|
||||
);
|
||||
const sortedParentTags = useMemo(
|
||||
() => [...parentTags].sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[parentTags],
|
||||
);
|
||||
const getSortedTagsByGroup = useCallback(
|
||||
(gId: number) =>
|
||||
tags
|
||||
.filter((t) => t.parent_id === gId)
|
||||
.sort((a, b) => a.sort_number - b.sort_number || a.id - b.id),
|
||||
[tags],
|
||||
);
|
||||
|
||||
const handleCatDragEnd = useCallback(
|
||||
async ({ data }: { data: MealCategory[] }) => {
|
||||
const updates = data.map((item, index) => ({ id: item.id, sort_number: index }));
|
||||
const ok = await saveCategoryChanges({ updates });
|
||||
if (!ok) toast.error("排序保存失败");
|
||||
else await loadData();
|
||||
},
|
||||
[loadData, saveCategoryChanges],
|
||||
);
|
||||
|
||||
const handleSlotDragEnd = useCallback(
|
||||
async ({ data }: { data: MealSlot[] }) => {
|
||||
const updates = data.map((item, index) => ({ id: item.id, sort_number: index }));
|
||||
const ok = await saveSlotChanges({ updates });
|
||||
if (!ok) toast.error("排序保存失败");
|
||||
else await loadData();
|
||||
},
|
||||
[loadData, saveSlotChanges],
|
||||
);
|
||||
|
||||
const handleParentTagDragEnd = useCallback(
|
||||
async ({ data }: { data: MealTag[] }) => {
|
||||
const updates = data.map((item, index) => ({ id: item.id, sort_number: index }));
|
||||
const ok = await saveTagChanges({ updates });
|
||||
if (!ok) toast.error("排序保存失败");
|
||||
else await loadData();
|
||||
},
|
||||
[loadData, saveTagChanges],
|
||||
);
|
||||
|
||||
const handleChildTagDragEnd = useCallback(
|
||||
async (data: MealTag[]) => {
|
||||
const updates = data.map((item, index) => ({ id: item.id, sort_number: index }));
|
||||
const ok = await saveTagChanges({ updates });
|
||||
if (!ok) toast.error("排序保存失败");
|
||||
else await loadData();
|
||||
},
|
||||
[loadData, saveTagChanges],
|
||||
);
|
||||
|
||||
const renderChildTagItem = useCallback<SortableGridRenderItem<MealTag>>(
|
||||
({ item }) => (
|
||||
<View className="flex-row items-center gap-2">
|
||||
<Sortable.Handle>
|
||||
<GripVerticalIcon size={16} color="#9CA3AF" />
|
||||
</Sortable.Handle>
|
||||
<TouchableOpacity
|
||||
className="rounded-full px-2.5 py-1"
|
||||
style={{ backgroundColor: item.color || "#E5E7EB" }}
|
||||
onPress={() => openTag(item.parent_id as number, item)}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-medium"
|
||||
style={{ color: item.color ? "#fff" : "#4B5563" }}
|
||||
>
|
||||
{item.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
),
|
||||
[openTag],
|
||||
);
|
||||
|
||||
const renderGroupItem = useCallback<SortableGridRenderItem<MealTag>>(
|
||||
({ item }) => {
|
||||
const gt = getSortedTagsByGroup(item.id);
|
||||
return (
|
||||
<View className="bg-white rounded-xl px-4 py-3.5 border border-gray-100">
|
||||
<View className="flex-row items-center justify-between mb-2.5">
|
||||
<View className="flex-row items-center gap-2 flex-1">
|
||||
<Sortable.Handle>
|
||||
<GripVerticalIcon size={18} color="#9CA3AF" />
|
||||
</Sortable.Handle>
|
||||
<TouchableOpacity className="flex-1" onPress={() => openGroup(item)}>
|
||||
<Text className="text-sm font-semibold text-gray-950">{item.name}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={() => setGroupDelete(item)}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{gt.length > 0 && (
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
customHandle
|
||||
scrollableRef={scrollRef}
|
||||
data={gt}
|
||||
keyExtractor={(t) => String(t.id)}
|
||||
renderItem={renderChildTagItem}
|
||||
rowGap={8}
|
||||
onDragEnd={({ data }) => handleChildTagDragEnd(data)}
|
||||
/>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
className="mt-2 self-start rounded-full px-2.5 py-1 bg-gray-50 border border-dashed border-gray-300"
|
||||
onPress={() => openTag(item.id)}
|
||||
>
|
||||
<Text className="text-xs text-gray-400">+ 添加</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
},
|
||||
[getSortedTagsByGroup, handleChildTagDragEnd, openGroup, openTag, renderChildTagItem],
|
||||
);
|
||||
|
||||
const renderCatItem = useCallback<SortableGridRenderItem<MealCategory>>(
|
||||
({ item }) => (
|
||||
<TouchableOpacity
|
||||
className="bg-white rounded-xl px-4 py-3.5 border border-gray-100 flex-row items-center justify-between"
|
||||
activeOpacity={0.72}
|
||||
onPress={() => openCat(item)}
|
||||
>
|
||||
<View className="flex-row items-center gap-2 flex-1">
|
||||
<Sortable.Handle>
|
||||
<GripVerticalIcon size={18} color="#9CA3AF" />
|
||||
</Sortable.Handle>
|
||||
<Text className="text-sm font-semibold text-gray-950">{item.name}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
setCatDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
[openCat],
|
||||
);
|
||||
|
||||
const renderSlotItem = useCallback<SortableGridRenderItem<MealSlot>>(
|
||||
({ item }) => (
|
||||
<TouchableOpacity
|
||||
className="bg-white rounded-xl px-4 py-3.5 border border-gray-100 flex-row items-center justify-between"
|
||||
activeOpacity={0.72}
|
||||
onPress={() => openSlot(item)}
|
||||
>
|
||||
<View className="flex-row items-center gap-2 flex-1">
|
||||
<Sortable.Handle>
|
||||
<GripVerticalIcon size={18} color="#9CA3AF" />
|
||||
</Sortable.Handle>
|
||||
<Text className="text-sm font-semibold text-gray-950">{item.name}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
setSlotDelete(item);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
),
|
||||
[openSlot],
|
||||
);
|
||||
|
||||
return (
|
||||
<LayoutView>
|
||||
<LayoutView.ScrollView
|
||||
ref={scrollRef}
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => runRefresh(loadData)}
|
||||
showsVerticalScrollIndicator={false}
|
||||
@@ -447,38 +631,17 @@ export default function MealManagePage() {
|
||||
<Text className="text-xs font-medium text-white">添加</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{categories.length > 0 ? (
|
||||
<View className="gap-3">
|
||||
{categories.map((c) => (
|
||||
<View
|
||||
key={c.id}
|
||||
className="bg-white rounded-xl px-4 py-3.5 border border-gray-100"
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
}}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center justify-between"
|
||||
onPress={() => openCat(c)}
|
||||
>
|
||||
<Text className="text-sm font-semibold text-gray-950">{c.name}</Text>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
setCatDelete(c);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
{sortedCategories.length > 0 ? (
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
customHandle
|
||||
scrollableRef={scrollRef}
|
||||
data={sortedCategories}
|
||||
keyExtractor={(c) => String(c.id)}
|
||||
renderItem={renderCatItem}
|
||||
rowGap={12}
|
||||
onDragEnd={handleCatDragEnd}
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-sm text-gray-400 py-4">暂无分类,点击右上角添加</Text>
|
||||
)}
|
||||
@@ -501,60 +664,17 @@ export default function MealManagePage() {
|
||||
<Text className="text-xs font-medium text-white">新建分组</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{parentTags.length > 0 ? (
|
||||
<View className="gap-3">
|
||||
{parentTags.map((g) => {
|
||||
const gt = getTagsByGroup(g.id);
|
||||
return (
|
||||
<View
|
||||
key={g.id}
|
||||
className="bg-white rounded-xl px-4 py-3.5 border border-gray-100"
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
}}
|
||||
>
|
||||
<View className="flex-row items-center justify-between mb-2.5">
|
||||
<TouchableOpacity className="flex-1" onPress={() => openGroup(g)}>
|
||||
<Text className="text-sm font-semibold text-gray-950">{g.name}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={() => setGroupDelete(g)}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View className="flex-row flex-wrap gap-1.5">
|
||||
{gt.map((t) => (
|
||||
<TouchableOpacity
|
||||
key={t.id}
|
||||
className="rounded-full px-2.5 py-1"
|
||||
style={{ backgroundColor: t.color || "#E5E7EB" }}
|
||||
onPress={() => openTag(g.id, t)}
|
||||
>
|
||||
<Text
|
||||
className="text-xs font-medium"
|
||||
style={{ color: t.color ? "#fff" : "#4B5563" }}
|
||||
>
|
||||
{t.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
className="rounded-full px-2.5 py-1 bg-gray-50 border border-dashed border-gray-300"
|
||||
onPress={() => openTag(g.id)}
|
||||
>
|
||||
<Text className="text-xs text-gray-400">+ 添加</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{sortedParentTags.length > 0 ? (
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
customHandle
|
||||
scrollableRef={scrollRef}
|
||||
data={sortedParentTags}
|
||||
keyExtractor={(g) => String(g.id)}
|
||||
renderItem={renderGroupItem}
|
||||
rowGap={12}
|
||||
onDragEnd={handleParentTagDragEnd}
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-sm text-gray-400 py-4">暂无标签分组,先新建分组再添加标签</Text>
|
||||
)}
|
||||
@@ -577,40 +697,17 @@ export default function MealManagePage() {
|
||||
<Text className="text-xs font-medium text-white">添加</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{slots.length > 0 ? (
|
||||
<View className="gap-3">
|
||||
{slots.map((s, i) => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={s.id}
|
||||
className="bg-white rounded-xl px-4 py-3.5 border border-gray-100"
|
||||
style={{
|
||||
shadowColor: "#000",
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.04,
|
||||
shadowRadius: 2,
|
||||
elevation: 1,
|
||||
}}
|
||||
onPress={() => openSlot(s)}
|
||||
>
|
||||
<View className="flex-row items-center justify-between">
|
||||
<View className="flex-1">
|
||||
<Text className="text-sm font-semibold text-gray-950">{s.name}</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
className="w-7 h-7 items-center justify-center"
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
setSlotDelete(s);
|
||||
}}
|
||||
>
|
||||
<Trash2Icon size={14} color="#EF4444" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
{sortedSlots.length > 0 ? (
|
||||
<Sortable.Grid
|
||||
columns={1}
|
||||
customHandle
|
||||
scrollableRef={scrollRef}
|
||||
data={sortedSlots}
|
||||
keyExtractor={(s) => String(s.id)}
|
||||
renderItem={renderSlotItem}
|
||||
rowGap={12}
|
||||
onDragEnd={handleSlotDragEnd}
|
||||
/>
|
||||
) : (
|
||||
<Text className="text-sm text-gray-400 py-4">暂无餐段,点击右上角添加</Text>
|
||||
)}
|
||||
@@ -625,10 +722,13 @@ export default function MealManagePage() {
|
||||
<BottomSheet isOpen={showCatSheet} onOpenChange={setShowCatSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950">
|
||||
{editingCat ? "编辑分类" : "添加分类"}
|
||||
</Text>
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }} onSubmit={saveCat}>
|
||||
<BottomSheet.Header
|
||||
title={editingCat ? "编辑分类" : "添加分类"}
|
||||
onCancel={() => setShowCatSheet(false)}
|
||||
onConfirm={saveCat}
|
||||
confirmDisabled={!catForm.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -638,24 +738,19 @@ export default function MealManagePage() {
|
||||
ref={catInputRef}
|
||||
/>
|
||||
</TextField>
|
||||
<View className="flex-row gap-3">
|
||||
<Button variant="secondary" className="flex-1" onPress={() => setShowCatSheet(false)}>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={saveCat} isDisabled={!catForm.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
<BottomSheet isOpen={showGroupSheet} onOpenChange={setShowGroupSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950">
|
||||
{editingGroup ? "编辑分组" : "新建分组"}
|
||||
</Text>
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }} onSubmit={saveGroup}>
|
||||
<BottomSheet.Header
|
||||
title={editingGroup ? "编辑分组" : "新建分组"}
|
||||
onCancel={() => setShowGroupSheet(false)}
|
||||
onConfirm={saveGroup}
|
||||
confirmDisabled={!groupForm.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -665,18 +760,6 @@ export default function MealManagePage() {
|
||||
ref={groupInputRef}
|
||||
/>
|
||||
</TextField>
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onPress={() => setShowGroupSheet(false)}
|
||||
>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={saveGroup} isDisabled={!groupForm.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -684,9 +767,12 @@ export default function MealManagePage() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950">
|
||||
{editingTag ? "编辑标签" : "添加标签"}
|
||||
</Text>
|
||||
<BottomSheet.Header
|
||||
title={editingTag ? "编辑标签" : "添加标签"}
|
||||
onCancel={() => setShowTagSheet(false)}
|
||||
onConfirm={saveTag}
|
||||
confirmDisabled={!tagForm.name.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -727,24 +813,19 @@ export default function MealManagePage() {
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<View className="flex-row gap-3">
|
||||
<Button variant="secondary" className="flex-1" onPress={() => setShowTagSheet(false)}>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={saveTag} isDisabled={!tagForm.name.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
<BottomSheet isOpen={showSlotSheet} onOpenChange={setShowSlotSheet}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950">
|
||||
{editingSlot ? "编辑餐段" : "添加餐段"}
|
||||
</Text>
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }} onSubmit={saveSlot}>
|
||||
<BottomSheet.Header
|
||||
title={editingSlot ? "编辑餐段" : "添加餐段"}
|
||||
onCancel={() => setShowSlotSheet(false)}
|
||||
onConfirm={saveSlot}
|
||||
confirmDisabled={!slotForm.trim()}
|
||||
/>
|
||||
<TextField>
|
||||
<Label className="mb-2">名称</Label>
|
||||
<BottomSheet.Input
|
||||
@@ -754,18 +835,6 @@ export default function MealManagePage() {
|
||||
ref={slotInputRef}
|
||||
/>
|
||||
</TextField>
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onPress={() => setShowSlotSheet(false)}
|
||||
>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={saveSlot} isDisabled={!slotForm.trim()}>
|
||||
<Button.Label>保存</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useLoading } from "@/hooks/useLoading";
|
||||
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
|
||||
import { getUniqueIdApi } from "@/request/api";
|
||||
import { useFamilyStore, useMealStore, useUserStore } from "@/store";
|
||||
import { MealDishDifficulty, MealStatus } from "@/types/meal";
|
||||
import { MealDishDifficulty, MealDishTag, MealStatus } from "@/types/meal";
|
||||
|
||||
interface DishForm {
|
||||
name: string;
|
||||
@@ -54,10 +54,16 @@ export default function MealDishEdit() {
|
||||
const fetchDishSteps = useMealStore((s) => s.fetchDishSteps);
|
||||
const uploadDishImage = useMealStore((s) => s.uploadDishImage);
|
||||
const uploadStepImage = useMealStore((s) => s.uploadStepImage);
|
||||
const tags = useMealStore((s) => s.tags);
|
||||
const fetchTags = useMealStore((s) => s.fetchTags);
|
||||
const fetchDishTags = useMealStore((s) => s.fetchDishTags);
|
||||
const dishTags = useMealStore((s) => s.dishTags);
|
||||
const saveDishTagChanges = useMealStore((s) => s.saveDishTagChanges);
|
||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||
const notifyOp = useModuleChatNotifier();
|
||||
|
||||
const [form, setForm] = useState<DishForm>(emptyForm);
|
||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||
const [steps, setSteps] = useState<
|
||||
{
|
||||
key: string;
|
||||
@@ -89,13 +95,53 @@ export default function MealDishEdit() {
|
||||
setSteps((p) => p.map((s) => (s.key === key ? { ...s, description: desc } : s)));
|
||||
}, []);
|
||||
|
||||
const saveDishTagsForDish = useCallback(
|
||||
async (dishId: number) => {
|
||||
const existing = (useMealStore.getState().dishTags ?? []).filter(
|
||||
(r) => r.dish_id === dishId,
|
||||
);
|
||||
const existingIds = new Set(existing.map((r) => r.tag_id));
|
||||
const toAdd = selectedTagIds.filter((id) => !existingIds.has(id));
|
||||
const toRemove = existing.filter((r) => !selectedTagIds.includes(r.tag_id));
|
||||
const inserts: Partial<MealDishTag>[] = [];
|
||||
const deletes: Partial<MealDishTag>[] = [];
|
||||
if (toAdd.length > 0) {
|
||||
const res = await getUniqueIdApi(toAdd.length);
|
||||
const ids = res.data ?? [];
|
||||
toAdd.forEach((tid, i) => {
|
||||
inserts.push({
|
||||
id: ids[i] ?? 0,
|
||||
family_id: familyId,
|
||||
dish_id: dishId,
|
||||
tag_id: tid,
|
||||
create_by: userId,
|
||||
update_by: userId,
|
||||
});
|
||||
});
|
||||
}
|
||||
toRemove.forEach((r) => deletes.push({ id: r.id }));
|
||||
if (inserts.length > 0 || deletes.length > 0) {
|
||||
const ok = await saveDishTagChanges({ inserts, deletes });
|
||||
if (ok) await fetchDishTags(familyId, dishId);
|
||||
return ok;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
[familyId, userId, selectedTagIds, saveDishTagChanges, fetchDishTags],
|
||||
);
|
||||
|
||||
const enabledCats = useMemo(() => categories.filter((c) => c.status !== 0), [categories]);
|
||||
const enabledTags = useMemo(() => tags.filter((t) => t.status !== 0), [tags]);
|
||||
const parentTags = useMemo(
|
||||
() => enabledTags.filter((t) => !t.parent_id),
|
||||
[enabledTags],
|
||||
);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!familyId) return;
|
||||
setPageLoading(true);
|
||||
try {
|
||||
await Promise.all([fetchDishes(familyId), fetchCategories(familyId)]);
|
||||
await Promise.all([fetchDishes(familyId), fetchCategories(familyId), fetchTags(familyId)]);
|
||||
if (isEdit) {
|
||||
const st = useMealStore.getState();
|
||||
const item = st.dishes.find((d) => d.id === editId);
|
||||
@@ -124,6 +170,9 @@ export default function MealDishEdit() {
|
||||
})),
|
||||
);
|
||||
setStepsIdsToDelete([]);
|
||||
await fetchDishTags(familyId, editId);
|
||||
const dt = useMealStore.getState().dishTags;
|
||||
setSelectedTagIds(dt.filter((r) => r.dish_id === editId).map((r) => r.tag_id));
|
||||
}
|
||||
} finally {
|
||||
setPageLoading(false);
|
||||
@@ -210,6 +259,11 @@ export default function MealDishEdit() {
|
||||
sourceTable: "b_meal_dish",
|
||||
sourceId: editId,
|
||||
});
|
||||
try {
|
||||
await saveDishTagsForDish(editId);
|
||||
} catch {
|
||||
/* 标签保存失败不影响主流程 */
|
||||
}
|
||||
} else {
|
||||
const sc = steps.length + 1;
|
||||
const idsRes = await getUniqueIdApi(sc);
|
||||
@@ -256,6 +310,11 @@ export default function MealDishEdit() {
|
||||
sourceTable: "b_meal_dish",
|
||||
sourceId: dishId,
|
||||
});
|
||||
try {
|
||||
await saveDishTagsForDish(dishId);
|
||||
} catch {
|
||||
/* 标签保存失败不影响主流程 */
|
||||
}
|
||||
}
|
||||
|
||||
stopLoading({ type: "success", message: "已保存" });
|
||||
@@ -274,6 +333,7 @@ export default function MealDishEdit() {
|
||||
uploadDishImage,
|
||||
saveDishBundle,
|
||||
saveDishChanges,
|
||||
saveDishTagsForDish,
|
||||
notifyOp,
|
||||
router,
|
||||
]);
|
||||
@@ -348,6 +408,59 @@ export default function MealDishEdit() {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Label className="mb-2">标签</Label>
|
||||
{enabledTags.length > 0 ? (
|
||||
<View className="gap-3">
|
||||
{parentTags.map((p) => {
|
||||
const children = enabledTags.filter((c) => c.parent_id === p.id);
|
||||
const pSel = children.some((c) => selectedTagIds.includes(c.id));
|
||||
return (
|
||||
<View key={p.id} className="gap-2">
|
||||
<View
|
||||
className={
|
||||
"self-start rounded-full border px-4 py-2 " +
|
||||
(pSel ? "border-accent bg-accent" : "border-gray-200 bg-white")
|
||||
}
|
||||
>
|
||||
<Text className={"text-sm " + (pSel ? "text-white" : "text-gray-600")}>
|
||||
{p.name}
|
||||
</Text>
|
||||
</View>
|
||||
{children.length > 0 && (
|
||||
<View className="flex-row flex-wrap gap-2 pl-3">
|
||||
{children.map((c) => {
|
||||
const cSel = selectedTagIds.includes(c.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={c.id}
|
||||
className={
|
||||
"rounded-full border px-3 py-1.5 " +
|
||||
(cSel ? "border-accent bg-accent" : "border-gray-200 bg-white")
|
||||
}
|
||||
onPress={() =>
|
||||
setSelectedTagIds((prev) =>
|
||||
cSel ? prev.filter((id) => id !== c.id) : [...prev, c.id],
|
||||
)
|
||||
}
|
||||
>
|
||||
<Text className={"text-xs " + (cSel ? "text-white" : "text-gray-600")}>
|
||||
{c.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-sm text-gray-400">暂无标签</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="mb-4">
|
||||
<Label className="mb-2">难度</Label>
|
||||
<View className="flex-row gap-2">
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ScrollView,
|
||||
Text,
|
||||
TextInput,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
@@ -111,6 +112,8 @@ export default function MealPlanDetail() {
|
||||
const removeDishFromPlan = useMealStore((s) => s.removeDishFromPlan);
|
||||
const confirmPlan = useMealStore((s) => s.confirmPlan);
|
||||
const completePlan = useMealStore((s) => s.completePlan);
|
||||
const revertPlanToPlanning = useMealStore((s) => s.revertPlanToPlanning);
|
||||
const revertPlanToConfirmed = useMealStore((s) => s.revertPlanToConfirmed);
|
||||
const planRatings = useMealStore((s) => s.planRatings);
|
||||
const fetchPlanRatings = useMealStore((s) => s.fetchPlanRatings);
|
||||
const ratePlan = useMealStore((s) => s.ratePlan);
|
||||
@@ -131,12 +134,16 @@ export default function MealPlanDetail() {
|
||||
const [showCookSheet, setShowCookSheet] = useState(false);
|
||||
const [completing, setCompleting] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const [reverting, setReverting] = useState(false);
|
||||
const [showRateSheet, setShowRateSheet] = useState(false);
|
||||
const [rateValue, setRateValue] = useState(5);
|
||||
const [rateContent, setRateContent] = useState("");
|
||||
const [rating, setRating] = useState(false);
|
||||
const scrollRef = useRef<any>(null);
|
||||
|
||||
// 加菜弹窗默认高度:固定接近顶部,约为屏幕高度减去外壳估算
|
||||
const dishSheetMaxHeight = Math.max(Dimensions.get("window").height - 200, 360);
|
||||
|
||||
// 加菜筛选
|
||||
const [showDishFilterSheet, setShowDishFilterSheet] = useState(false);
|
||||
const [dishKeyword, setDishKeyword] = useState("");
|
||||
@@ -192,10 +199,9 @@ export default function MealPlanDetail() {
|
||||
|
||||
const dishFilterCount = useMemo(() => {
|
||||
let c = 0;
|
||||
if (dishFilterCatId) c++;
|
||||
if (dishFilterTagIds.length > 0) c++;
|
||||
return c;
|
||||
}, [dishFilterCatId, dishFilterTagIds]);
|
||||
}, [dishFilterTagIds]);
|
||||
|
||||
// 菜单状态
|
||||
const isPlanning = plan?.status === MealPlanStatus.Planning;
|
||||
@@ -375,6 +381,44 @@ export default function MealPlanDetail() {
|
||||
}
|
||||
}, [familyId, planId, isConfirmed, userId, completePlan, refreshPlan]);
|
||||
|
||||
// 撤回确认(已确认 → 进行中)
|
||||
const handleRevertConfirm = useCallback(async () => {
|
||||
if (!familyId || !planId || !isConfirmed) return;
|
||||
setReverting(true);
|
||||
try {
|
||||
const ok = await revertPlanToPlanning({ planId, familyId, userId });
|
||||
if (ok) {
|
||||
await refreshPlan();
|
||||
toast.success("已撤回确认,回到进行中");
|
||||
} else {
|
||||
toast.error("操作失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("操作失败");
|
||||
} finally {
|
||||
setReverting(false);
|
||||
}
|
||||
}, [familyId, planId, isConfirmed, userId, revertPlanToPlanning, refreshPlan]);
|
||||
|
||||
// 撤回完成(已完成 → 已确认)
|
||||
const handleRevertComplete = useCallback(async () => {
|
||||
if (!familyId || !planId || !isCompleted) return;
|
||||
setReverting(true);
|
||||
try {
|
||||
const ok = await revertPlanToConfirmed({ planId, familyId, userId });
|
||||
if (ok) {
|
||||
await refreshPlan();
|
||||
toast.success("已撤回完成,回到已确认");
|
||||
} else {
|
||||
toast.error("操作失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("操作失败");
|
||||
} finally {
|
||||
setReverting(false);
|
||||
}
|
||||
}, [familyId, planId, isCompleted, userId, revertPlanToConfirmed, refreshPlan]);
|
||||
|
||||
// 添加评论
|
||||
const addComment = useCallback(async () => {
|
||||
if (!familyId || !comment.trim() || sending || isLocked) return;
|
||||
@@ -796,17 +840,37 @@ export default function MealPlanDetail() {
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{isConfirmed && (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 rounded-full border border-gray-300 bg-white px-4 py-1.5 active:bg-gray-50"
|
||||
onPress={handleRevertConfirm}
|
||||
disabled={reverting}
|
||||
>
|
||||
<RotateCcwIcon size={14} color="#6b7280" />
|
||||
<Text className="text-xs font-semibold text-gray-600">撤回</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 rounded-full bg-green-500 px-4 py-1.5 active:bg-green-600"
|
||||
onPress={handleComplete}
|
||||
disabled={completing}
|
||||
>
|
||||
{completing ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<CheckCircleIcon size={14} color="#fff" />
|
||||
)}
|
||||
<Text className="text-xs font-semibold text-white">完成菜单</Text>
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
)}
|
||||
{isCompleted && (
|
||||
<TouchableOpacity
|
||||
className="flex-row items-center gap-1 rounded-full bg-green-500 px-4 py-1.5 active:bg-green-600"
|
||||
onPress={handleComplete}
|
||||
disabled={completing}
|
||||
className="flex-row items-center gap-1 rounded-full border border-gray-300 bg-white px-4 py-1.5 active:bg-gray-50"
|
||||
onPress={handleRevertComplete}
|
||||
disabled={reverting}
|
||||
>
|
||||
{completing ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<CheckCircleIcon size={14} color="#fff" />
|
||||
)}
|
||||
<Text className="text-xs font-semibold text-white">完成菜单</Text>
|
||||
<RotateCcwIcon size={14} color="#6b7280" />
|
||||
<Text className="text-xs font-semibold text-gray-600">撤回</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{isCompleted && !hasRated && (
|
||||
@@ -973,6 +1037,7 @@ export default function MealPlanDetail() {
|
||||
{/* ======== 添加菜品 Sheet ======== */}
|
||||
<BottomSheet
|
||||
isOpen={showAddDish}
|
||||
avoidKeyboard={false}
|
||||
onOpenChange={(v) => {
|
||||
setShowAddDish(v);
|
||||
if (!v) {
|
||||
@@ -1008,9 +1073,40 @@ export default function MealPlanDetail() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 分类快捷选择(直接放在外部,不进筛选) */}
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={{ gap: 8, paddingRight: 4 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className={`rounded-full border px-3 py-2 ${dishFilterCatId == null ? "border-accent bg-accent" : "border-gray-200 bg-white"}`}
|
||||
onPress={() => setDishFilterCatId(null)}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${dishFilterCatId == null ? "text-white" : "text-gray-700"}`}
|
||||
>
|
||||
全部
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{categories.map((c) => (
|
||||
<TouchableOpacity
|
||||
key={c.id}
|
||||
className={`rounded-full border px-3 py-2 ${dishFilterCatId === c.id ? "border-accent bg-accent" : "border-gray-200 bg-white"}`}
|
||||
onPress={() => setDishFilterCatId((p) => (p === c.id ? null : c.id))}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${dishFilterCatId === c.id ? "text-white" : "text-gray-700"}`}
|
||||
>
|
||||
{c.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{/* 菜品列表 */}
|
||||
<ScrollView
|
||||
className="max-h-80"
|
||||
style={{ height: dishSheetMaxHeight }}
|
||||
showsVerticalScrollIndicator
|
||||
onMomentumScrollEnd={async (e: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
|
||||
@@ -1080,35 +1176,6 @@ export default function MealPlanDetail() {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
{/* 分类 */}
|
||||
<View>
|
||||
<Label className="mb-2">分类</Label>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
<TouchableOpacity
|
||||
className={`rounded-full border px-3 py-2 ${dishFilterCatId == null ? "border-accent bg-accent" : "border-gray-200 bg-white"}`}
|
||||
onPress={() => setDishFilterCatId(null)}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${dishFilterCatId == null ? "text-white" : "text-gray-700"}`}
|
||||
>
|
||||
全部
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{categories.map((c) => (
|
||||
<TouchableOpacity
|
||||
key={c.id}
|
||||
className={`rounded-full border px-3 py-2 ${dishFilterCatId === c.id ? "border-accent bg-accent" : "border-gray-200 bg-white"}`}
|
||||
onPress={() => setDishFilterCatId((p) => (p === c.id ? null : c.id))}
|
||||
>
|
||||
<Text
|
||||
className={`text-xs ${dishFilterCatId === c.id ? "text-white" : "text-gray-700"}`}
|
||||
>
|
||||
{c.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
{/* 标签 */}
|
||||
<View>
|
||||
<Label className="mb-2">标签</Label>
|
||||
@@ -1195,7 +1262,13 @@ export default function MealPlanDetail() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950">评价菜单</Text>
|
||||
<BottomSheet.Header
|
||||
title="评价菜单"
|
||||
confirmText="提交"
|
||||
onCancel={() => setShowRateSheet(false)}
|
||||
onConfirm={handleRate}
|
||||
confirmDisabled={rating}
|
||||
/>
|
||||
|
||||
{/* 星级 */}
|
||||
<View className="flex-row justify-center gap-1">
|
||||
@@ -1217,23 +1290,6 @@ export default function MealPlanDetail() {
|
||||
onChangeText={setRateContent}
|
||||
placeholder="写点评价..."
|
||||
/>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onPress={() => setShowRateSheet(false)}
|
||||
>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={handleRate} isDisabled={rating}>
|
||||
{rating ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Button.Label>提交</Button.Label>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -282,30 +282,34 @@ export default function MealPlanEdit() {
|
||||
<BottomSheet isOpen={showDate} onOpenChange={setShowDate}>
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16, maxHeight: 320 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950">选择日期</Text>
|
||||
<BottomSheet.Content
|
||||
className="gap-4"
|
||||
style={{ paddingHorizontal: 16, maxHeight: 320 }}
|
||||
onSubmit={() => {
|
||||
if (dayjs(dateVal).isValid()) {
|
||||
setPlanDate(dateVal);
|
||||
setShowDate(false);
|
||||
} else {
|
||||
toast.error("日期格式无效");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<BottomSheet.Header
|
||||
title="选择日期"
|
||||
confirmText="确定"
|
||||
onCancel={() => setShowDate(false)}
|
||||
onConfirm={() => {
|
||||
if (dayjs(dateVal).isValid()) {
|
||||
setPlanDate(dateVal);
|
||||
setShowDate(false);
|
||||
} else {
|
||||
toast.error("日期格式无效");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<View>
|
||||
<BottomSheet.Input value={dateVal} onChangeText={setDateVal} placeholder={today} />
|
||||
</View>
|
||||
<View className="flex-row gap-3">
|
||||
<Button variant="secondary" className="flex-1" onPress={() => setShowDate(false)}>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex-1"
|
||||
onPress={() => {
|
||||
if (dayjs(dateVal).isValid()) {
|
||||
setPlanDate(dateVal);
|
||||
setShowDate(false);
|
||||
} else {
|
||||
toast.error("日期格式无效");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CheckIcon size={18} color="#fff" />
|
||||
<Button.Label>确定</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -638,7 +638,15 @@ export default function TodoDetail() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16, maxHeight: 480 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950">完成任务</Text>
|
||||
<BottomSheet.Header
|
||||
title="完成任务"
|
||||
confirmText="完成"
|
||||
onCancel={() => {
|
||||
setShowCompleteSheet(false);
|
||||
setCompleteFiles([]);
|
||||
}}
|
||||
onConfirm={handleComplete}
|
||||
/>
|
||||
<TextArea
|
||||
value={completeNote}
|
||||
onChangeText={setCompleteNote}
|
||||
@@ -668,21 +676,6 @@ export default function TodoDetail() {
|
||||
<ImageIcon size={16} color="#4B5563" />
|
||||
<Button.Label>添加图片</Button.Label>
|
||||
</Button>
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onPress={() => {
|
||||
setShowCompleteSheet(false);
|
||||
setCompleteFiles([]);
|
||||
}}
|
||||
>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={handleComplete}>
|
||||
<Button.Label>确认完成</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
@@ -736,7 +729,12 @@ export default function TodoDetail() {
|
||||
<BottomSheet.Portal>
|
||||
<BottomSheet.Overlay />
|
||||
<BottomSheet.Content className="gap-4" style={{ paddingHorizontal: 16, maxHeight: 380 }}>
|
||||
<Text className="text-center text-lg font-semibold text-gray-950">评价任务</Text>
|
||||
<BottomSheet.Header
|
||||
title="评价任务"
|
||||
confirmText="提交"
|
||||
onCancel={() => setShowRateSheet(false)}
|
||||
onConfirm={handleRate}
|
||||
/>
|
||||
<View className="flex-row justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((s) => (
|
||||
<TouchableOpacity key={s} onPress={() => setRateValue(s)}>
|
||||
@@ -749,18 +747,6 @@ export default function TodoDetail() {
|
||||
))}
|
||||
</View>
|
||||
<TextArea value={rateContent} onChangeText={setRateContent} placeholder="写点评价..." />
|
||||
<View className="flex-row gap-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onPress={() => setShowRateSheet(false)}
|
||||
>
|
||||
<Button.Label>取消</Button.Label>
|
||||
</Button>
|
||||
<Button className="flex-1" onPress={handleRate}>
|
||||
<Button.Label>提交</Button.Label>
|
||||
</Button>
|
||||
</View>
|
||||
</BottomSheet.Content>
|
||||
</BottomSheet.Portal>
|
||||
</BottomSheet>
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type TextInput,
|
||||
type TextProps,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
import { Input, TextArea, useBottomSheetAwareHandlers } from "heroui-native";
|
||||
import { Toaster } from "sonner-native";
|
||||
import { resetToaster } from "@/utils/toaster";
|
||||
import { useKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
|
||||
// ── Props ──────────────────────────────────────────────
|
||||
|
||||
@@ -38,6 +40,8 @@ type AppBottomSheetProps = {
|
||||
animationDuration?: number;
|
||||
backgroundStyle?: ViewStyle;
|
||||
handleIndicatorStyle?: ViewStyle;
|
||||
/** 键盘弹出时是否整体上移面板,默认 true;高弹窗(如接近顶部)可设为 false 以免顶部内容被推出屏幕 */
|
||||
avoidKeyboard?: boolean;
|
||||
};
|
||||
|
||||
type AppBottomSheetContentProps = {
|
||||
@@ -48,25 +52,29 @@ type AppBottomSheetContentProps = {
|
||||
animation?: unknown;
|
||||
backgroundClassName?: string;
|
||||
style?: ViewStyle;
|
||||
/** 当内容中只有一个输入框时,按回车触发该回调(一般传保存事件) */
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
type CtxValue = {
|
||||
isOpen: boolean;
|
||||
animationDuration: number;
|
||||
registerInput: (node: TextInput | null) => void;
|
||||
};
|
||||
type ContentCtxValue = CtxValue & { shouldAutoFocusSingleInput: boolean };
|
||||
type ContentCtxValue = CtxValue & {
|
||||
shouldAutoFocusSingleInput: boolean;
|
||||
isSingleInput: boolean;
|
||||
onSubmit?: () => void;
|
||||
};
|
||||
|
||||
const AppBottomSheetContext = createContext<CtxValue>({
|
||||
isOpen: false,
|
||||
animationDuration: 300,
|
||||
registerInput: () => {},
|
||||
});
|
||||
const AppBottomSheetContentContext = createContext<ContentCtxValue>({
|
||||
isOpen: false,
|
||||
animationDuration: 300,
|
||||
registerInput: () => {},
|
||||
shouldAutoFocusSingleInput: false,
|
||||
isSingleInput: false,
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────
|
||||
@@ -100,6 +108,20 @@ function countInputs(children: ReactNode): number {
|
||||
});
|
||||
return c;
|
||||
}
|
||||
// 仅统计单行 Input(TextArea 回车用于换行,不参与回车提交)
|
||||
function countSingleLineInputs(children: ReactNode): number {
|
||||
let c = 0;
|
||||
Children.forEach(children, (child) => {
|
||||
if (!isValidElement(child)) return;
|
||||
const t = child.type as { displayName?: string };
|
||||
if (typeof t === "object" && t?.displayName === "AppBottomSheetInput") {
|
||||
c += 1;
|
||||
return;
|
||||
}
|
||||
c += countSingleLineInputs((child as ReactElement<{ children?: ReactNode }>).props.children);
|
||||
});
|
||||
return c;
|
||||
}
|
||||
|
||||
// ── Root ───────────────────────────────────────────────
|
||||
|
||||
@@ -114,15 +136,18 @@ function AppBottomSheetRoot({
|
||||
animationDuration = 260,
|
||||
backgroundStyle,
|
||||
handleIndicatorStyle,
|
||||
avoidKeyboard = true,
|
||||
}: AppBottomSheetProps) {
|
||||
const slideY = useRef(new Animated.Value(SCREEN_H)).current;
|
||||
const backdropFade = useRef(new Animated.Value(0)).current;
|
||||
const keyboardY = useRef(new Animated.Value(0)).current;
|
||||
const panelY = useRef(Animated.add(slideY, keyboardY)).current;
|
||||
// useKeyboardAnimation 的 height 为负值(0 → -键盘高度),直接叠加即可让面板随键盘上移
|
||||
const { height } = useKeyboardAnimation();
|
||||
const panelY = useRef(
|
||||
avoidKeyboard ? Animated.add(slideY, height) : slideY,
|
||||
).current;
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
const animatingRef = useRef(false);
|
||||
const focusedInputRef = useRef<TextInput | null>(null);
|
||||
const onOpenRef = useRef(onOpen);
|
||||
onOpenRef.current = onOpen;
|
||||
|
||||
@@ -149,7 +174,6 @@ function AppBottomSheetRoot({
|
||||
const doAnimateOut = useCallback(
|
||||
(done: () => void) => {
|
||||
Keyboard.dismiss();
|
||||
keyboardY.setValue(0);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideY, {
|
||||
toValue: SCREEN_H,
|
||||
@@ -182,44 +206,6 @@ function AppBottomSheetRoot({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const adjustForKeyboard = useCallback((kbHeight: number) => {
|
||||
const input = focusedInputRef.current;
|
||||
if (!input) {
|
||||
keyboardY.setValue(-Math.min(kbHeight, 0));
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
input.measureInWindow((x, y, w, h) => {
|
||||
const screenBottom = SCREEN_H - kbHeight;
|
||||
const inputBottom = y + h;
|
||||
const needed = inputBottom - screenBottom;
|
||||
keyboardY.setValue(needed > 0 ? -(needed - 24) : 0);
|
||||
});
|
||||
}, 50);
|
||||
}, []);
|
||||
|
||||
const registerInput = useCallback((node: TextInput | null) => {
|
||||
focusedInputRef.current = node;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
const s1 = Keyboard.addListener("keyboardWillShow", (e) =>
|
||||
adjustForKeyboard(e.endCoordinates.height),
|
||||
);
|
||||
const s2 = Keyboard.addListener("keyboardDidShow", (e) =>
|
||||
adjustForKeyboard(e.endCoordinates.height),
|
||||
);
|
||||
const h1 = Keyboard.addListener("keyboardWillHide", () => keyboardY.setValue(0));
|
||||
const h2 = Keyboard.addListener("keyboardDidHide", () => keyboardY.setValue(0));
|
||||
return () => {
|
||||
s1.remove();
|
||||
s2.remove();
|
||||
h1.remove();
|
||||
h2.remove();
|
||||
};
|
||||
}, [visible, adjustForKeyboard]);
|
||||
|
||||
// Modal 关闭后恢复全局 Toaster
|
||||
useEffect(() => {
|
||||
if (visible) return;
|
||||
@@ -230,13 +216,14 @@ function AppBottomSheetRoot({
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<AppBottomSheetContext.Provider value={{ isOpen, animationDuration, registerInput }}>
|
||||
<AppBottomSheetContext.Provider value={{ isOpen, animationDuration }}>
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={() => onOpenChange?.(false)}
|
||||
statusBarTranslucent
|
||||
navigationBarTranslucent
|
||||
>
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={() => onOpenChange?.(false)}>
|
||||
<Animated.View
|
||||
@@ -299,13 +286,20 @@ function AppBottomSheetContent({
|
||||
className,
|
||||
contentContainerClassName,
|
||||
style,
|
||||
onSubmit,
|
||||
...props
|
||||
}: AppBottomSheetContentProps) {
|
||||
const ctx = useContext(AppBottomSheetContext);
|
||||
const inputCount = countInputs(children);
|
||||
const singleLineInputCount = countSingleLineInputs(children);
|
||||
return (
|
||||
<AppBottomSheetContentContext.Provider
|
||||
value={{ ...ctx, shouldAutoFocusSingleInput: inputCount === 1 }}
|
||||
value={{
|
||||
...ctx,
|
||||
shouldAutoFocusSingleInput: inputCount === 1,
|
||||
isSingleInput: singleLineInputCount === 1,
|
||||
onSubmit,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={contentContainerClassName ?? className ?? ""}
|
||||
@@ -321,13 +315,12 @@ function AppBottomSheetContent({
|
||||
// ── Input ──────────────────────────────────────────────
|
||||
|
||||
const AppBottomSheetInput = forwardRef<TextInput, ComponentProps<typeof Input>>(
|
||||
({ onFocus, onBlur, ...props }, ref) => {
|
||||
({ onFocus, onBlur, onSubmitEditing, returnKeyType, ...props }, ref) => {
|
||||
const handlers = useBottomSheetAwareHandlers();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const { registerInput } = useContext(AppBottomSheetContentContext);
|
||||
const { isSingleInput, onSubmit } = useContext(AppBottomSheetContentContext);
|
||||
const submitOnEnter = isSingleInput && !!onSubmit;
|
||||
const handleRef = useCallback(
|
||||
(node: TextInput | null) => {
|
||||
inputRef.current = node;
|
||||
setInputRef(ref, node);
|
||||
},
|
||||
[ref],
|
||||
@@ -338,13 +331,17 @@ const AppBottomSheetInput = forwardRef<TextInput, ComponentProps<typeof Input>>(
|
||||
ref={handleRef}
|
||||
onFocus={(e) => {
|
||||
handlers.onFocus(e);
|
||||
registerInput(inputRef.current);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
handlers.onBlur(e);
|
||||
onBlur?.(e);
|
||||
}}
|
||||
returnKeyType={returnKeyType ?? (submitOnEnter ? "done" : undefined)}
|
||||
onSubmitEditing={(e) => {
|
||||
onSubmitEditing?.(e);
|
||||
if (submitOnEnter) onSubmit?.();
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -357,11 +354,8 @@ AppBottomSheetInput.displayName = "AppBottomSheetInput";
|
||||
const AppBottomSheetTextArea = forwardRef<TextInput, ComponentProps<typeof TextArea>>(
|
||||
({ onFocus, onBlur, ...props }, ref) => {
|
||||
const handlers = useBottomSheetAwareHandlers();
|
||||
const inputRef = useRef<TextInput | null>(null);
|
||||
const { registerInput } = useContext(AppBottomSheetContentContext);
|
||||
const handleRef = useCallback(
|
||||
(node: TextInput | null) => {
|
||||
inputRef.current = node;
|
||||
setInputRef(ref, node);
|
||||
},
|
||||
[ref],
|
||||
@@ -372,7 +366,6 @@ const AppBottomSheetTextArea = forwardRef<TextInput, ComponentProps<typeof TextA
|
||||
ref={handleRef}
|
||||
onFocus={(e) => {
|
||||
handlers.onFocus(e);
|
||||
registerInput(inputRef.current);
|
||||
onFocus?.(e);
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
@@ -386,6 +379,70 @@ const AppBottomSheetTextArea = forwardRef<TextInput, ComponentProps<typeof TextA
|
||||
);
|
||||
AppBottomSheetTextArea.displayName = "AppBottomSheetTextArea";
|
||||
|
||||
// ── Header(左取消 / 中标题 / 右保存,小按钮) ─────────
|
||||
|
||||
type AppBottomSheetHeaderProps = {
|
||||
title?: string;
|
||||
cancelText?: string;
|
||||
confirmText?: string;
|
||||
onCancel?: () => void;
|
||||
onConfirm?: () => void;
|
||||
confirmDisabled?: boolean;
|
||||
/** 渲染在确认按钮左侧的额外内容(如「重置」按钮) */
|
||||
rightExtra?: ReactNode;
|
||||
};
|
||||
|
||||
function AppBottomSheetHeader({
|
||||
title,
|
||||
cancelText = "取消",
|
||||
confirmText = "保存",
|
||||
onCancel,
|
||||
onConfirm,
|
||||
confirmDisabled = false,
|
||||
rightExtra,
|
||||
}: AppBottomSheetHeaderProps) {
|
||||
return (
|
||||
<View className="flex-row items-center">
|
||||
<View className="flex-1 flex-row justify-start">
|
||||
{onCancel ? (
|
||||
<TouchableOpacity
|
||||
className="h-8 px-3.5 rounded-full bg-gray-100 items-center justify-center"
|
||||
onPress={onCancel}
|
||||
>
|
||||
<Text className="text-[13px] text-gray-600">{cancelText}</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
{!!title && (
|
||||
<Text
|
||||
className="text-base font-semibold text-gray-950 text-center px-2"
|
||||
numberOfLines={1}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
<View className="flex-1 flex-row justify-end items-center gap-2">
|
||||
{rightExtra}
|
||||
{onConfirm ? (
|
||||
<TouchableOpacity
|
||||
disabled={confirmDisabled}
|
||||
className={`h-8 px-3.5 rounded-full items-center justify-center ${
|
||||
confirmDisabled ? "bg-gray-200" : "bg-accent"
|
||||
}`}
|
||||
onPress={onConfirm}
|
||||
>
|
||||
<Text
|
||||
className={`text-[13px] font-medium ${confirmDisabled ? "text-gray-400" : "text-white"}`}
|
||||
>
|
||||
{confirmText}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function AppBottomSheetTitle(props: TextProps) {
|
||||
return <Text className="text-center text-base font-semibold" {...props} />;
|
||||
}
|
||||
@@ -397,6 +454,7 @@ const BottomSheet = Object.assign(AppBottomSheetRoot, {
|
||||
Portal: AppBottomSheetPortal,
|
||||
Overlay: AppBottomSheetOverlay,
|
||||
Content: AppBottomSheetContent,
|
||||
Header: AppBottomSheetHeader,
|
||||
Input: AppBottomSheetInput,
|
||||
TextArea: AppBottomSheetTextArea,
|
||||
Title: AppBottomSheetTitle,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ScrollView, Text, TouchableOpacity } from "react-native";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
|
||||
type CategoryChipItem = { id: number; name: string };
|
||||
|
||||
type CategoryChipBarProps = {
|
||||
categories: CategoryChipItem[];
|
||||
activeId: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
};
|
||||
|
||||
/** 横向滑动的分类快捷筛选条(全部 + 分类),点击选中,再点取消 */
|
||||
export default function CategoryChipBar({
|
||||
categories,
|
||||
activeId,
|
||||
onChange,
|
||||
style,
|
||||
}: CategoryChipBarProps) {
|
||||
if (categories.length === 0) return null;
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={[{ flexGrow: 0, marginHorizontal: -16 }, style]}
|
||||
contentContainerStyle={{ paddingHorizontal: 16, gap: 8 }}
|
||||
>
|
||||
<TouchableOpacity
|
||||
className={`h-9 px-4 rounded-full items-center justify-center ${
|
||||
activeId == null ? "bg-accent" : "bg-gray-100"
|
||||
}`}
|
||||
onPress={() => onChange(null)}
|
||||
>
|
||||
<Text
|
||||
className={`text-[13px] font-medium ${activeId == null ? "text-white" : "text-gray-600"}`}
|
||||
>
|
||||
全部
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{categories.map((c) => {
|
||||
const active = activeId === c.id;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={c.id}
|
||||
className={`h-9 px-4 rounded-full items-center justify-center ${
|
||||
active ? "bg-accent" : "bg-gray-100"
|
||||
}`}
|
||||
onPress={() => onChange(active ? null : c.id)}
|
||||
>
|
||||
<Text
|
||||
className={`text-[13px] font-medium ${active ? "text-white" : "text-gray-600"}`}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{c.name}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -169,3 +169,33 @@ export const WEEK_DAYS = [
|
||||
{ value: 6, label: "六" },
|
||||
{ value: 0, label: "日" },
|
||||
];
|
||||
|
||||
/**
|
||||
* 构造写入 b_finance 的自动记账记录。
|
||||
* 注意:b_finance 表没有 type 列(type 是 b_finance_category 的冗余字段),
|
||||
* 这里绝不能包含 type,否则后端 insert 会因未知列报错。
|
||||
*/
|
||||
export function buildAutoBillInserts(
|
||||
rule: { category_id: number; amount: number; remark?: string | null },
|
||||
missedDates: string[],
|
||||
ids: number[],
|
||||
userId: number,
|
||||
familyId: number,
|
||||
): any[] {
|
||||
const inserts: any[] = [];
|
||||
for (let i = 0; i < missedDates.length; i++) {
|
||||
if (!ids[i]) continue;
|
||||
inserts.push({
|
||||
id: ids[i],
|
||||
family_id: familyId,
|
||||
category_id: rule.category_id,
|
||||
amount: rule.amount,
|
||||
remark: rule.remark || "",
|
||||
record_time: missedDates[i],
|
||||
create_by: userId,
|
||||
update_by: userId,
|
||||
});
|
||||
}
|
||||
return inserts;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { View } from "react-native";
|
||||
import { View, BackHandler } from "react-native";
|
||||
import Tabbar, { TabbarItem } from "@/components/layout/tabbar";
|
||||
import Navbar from "@/components/layout/navbar";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { Slot, useRouter, usePathname } from "expo-router";
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useMemo, useRef, useEffect } from "react";
|
||||
|
||||
export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) {
|
||||
const router = useRouter();
|
||||
@@ -26,8 +26,26 @@ export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) {
|
||||
return lastValidTabRef.current;
|
||||
}, [pathname, tabs]);
|
||||
|
||||
// 区分当前是否在 tab 页上
|
||||
const isTabPage = useMemo(
|
||||
() => tabs.some((t) => t.id === pathname && !t.isCenter),
|
||||
[pathname, tabs],
|
||||
);
|
||||
|
||||
// 在 tab 页时,手势返回直接回首页;子页面时正常返回
|
||||
useEffect(() => {
|
||||
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||
if (isTabPage) {
|
||||
router.dismissTo("/home");
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return () => handler.remove();
|
||||
}, [isTabPage, router]);
|
||||
|
||||
function handleClose() {
|
||||
router.dismissTo("/tools");
|
||||
router.dismissTo("/home");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
FinanceCategoryStatus,
|
||||
FinanceType,
|
||||
} from "@/types/finance";
|
||||
import { shouldRun, getMissedDates } from "@/helpers/financeAuto";
|
||||
import { shouldRun, getMissedDates, buildAutoBillInserts } from "@/helpers/financeAuto";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
|
||||
const AUTO_BILL_IDS_KEY = "finance_auto_bill_ids";
|
||||
@@ -141,22 +141,8 @@ export const useFinanceStore = create<FinanceState>((set, get) => ({
|
||||
const missedDates = getMissedDates(rule.repeat_type, rule.repeat_date ?? "", rule.last_run);
|
||||
if (missedDates.length === 0) continue;
|
||||
|
||||
const inserts: any[] = [];
|
||||
const ids = (await getUniqueIdApi(missedDates.length)).data ?? [];
|
||||
for (let i = 0; i < missedDates.length; i++) {
|
||||
if (!ids[i]) continue;
|
||||
inserts.push({
|
||||
id: ids[i],
|
||||
family_id: familyId,
|
||||
category_id: rule.category_id,
|
||||
amount: rule.amount,
|
||||
remark: rule.remark || "",
|
||||
type: rule.type,
|
||||
record_time: missedDates[i],
|
||||
create_by: userId,
|
||||
update_by: userId,
|
||||
});
|
||||
}
|
||||
const inserts = buildAutoBillInserts(rule, missedDates, ids, userId, familyId);
|
||||
|
||||
if (inserts.length > 0) {
|
||||
await saveDataApi([
|
||||
|
||||
@@ -113,6 +113,10 @@ interface MealState {
|
||||
}) => Promise<number | null>;
|
||||
confirmPlan: (params: { planId: number; familyId: number; userId: number }) => Promise<boolean>;
|
||||
completePlan: (params: { planId: number; familyId: number; userId: number }) => Promise<boolean>;
|
||||
/** 撤回确认:已确认 → 进行中 */
|
||||
revertPlanToPlanning: (params: { planId: number; familyId: number; userId: number }) => Promise<boolean>;
|
||||
/** 撤回完成:已完成 → 已确认 */
|
||||
revertPlanToConfirmed: (params: { planId: number; familyId: number; userId: number }) => Promise<boolean>;
|
||||
addDishToPlan: (params: {
|
||||
familyId: number;
|
||||
planId: number;
|
||||
@@ -612,6 +616,38 @@ export const useMealStore = create<MealState>((set, get) => ({
|
||||
});
|
||||
},
|
||||
|
||||
// 撤回确认(已确认 → 进行中)
|
||||
revertPlanToPlanning: async ({ planId, familyId, userId }) => {
|
||||
if (!familyId || !planId) return false;
|
||||
const now = new Date().toISOString();
|
||||
return get().savePlanChanges({
|
||||
updates: [
|
||||
{
|
||||
id: planId,
|
||||
status: MealPlanStatus.Planning,
|
||||
update_time: now,
|
||||
update_by: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
// 撤回完成(已完成 → 已确认)
|
||||
revertPlanToConfirmed: async ({ planId, familyId, userId }) => {
|
||||
if (!familyId || !planId) return false;
|
||||
const now = new Date().toISOString();
|
||||
return get().savePlanChanges({
|
||||
updates: [
|
||||
{
|
||||
id: planId,
|
||||
status: MealPlanStatus.Confirmed,
|
||||
update_time: now,
|
||||
update_by: userId,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
// 添加菜品到菜单(事件驱动:写入 plan_item + plan_log)
|
||||
addDishToPlan: async ({ familyId, planId, dishId, dishName, userId, sortNumber }) => {
|
||||
if (!familyId || !planId) return {};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
待修改:
|
||||
1、记账点击保存没有返回记账首页
|
||||
2、衣服、菜品都允许排序
|
||||
3、搭配分类会闪烁,有可能加载不出来
|
||||
Reference in new issue
Block a user