20260726225220

This commit is contained in:
oneao committed 2026-07-26 22:52:21 +08:00
1 parent e3c7c6f8da
commit a8478d60ed
170 files changed
+6786 -1678

No files matched your search

+2 -2
View File
@@ -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
+21
View File
@@ -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>
+12 -6
View File
@@ -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>
+115 -57
View File
@@ -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;
}
+21 -3
View File
@@ -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 (
+2 -16
View File
@@ -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([
+36
View File
@@ -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 {};
+4
View File
@@ -0,0 +1,4 @@
待修改:
1、记账点击保存没有返回记账首页
2、衣服、菜品都允许排序
3、搭配分类会闪烁,有可能加载不出来