20260708221610

This commit is contained in:
oneao committed 2026-07-08 22:16:10 +08:00
1 parent f94b5152ed
commit d080a2a48d
26 files changed
+329 -115

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
EXPO_PUBLIC_API_URL=http://192.168.5.72:8087
EXPO_PUBLIC_API_URL=http://192.168.5.80:8087
EXPO_PUBLIC_G3_URL=http://118.89.70.199:9001
EXPO_PUBLIC_G3_BUCKET=test
@@ -1,5 +1,8 @@
import { useCallback, useEffect, useRef, useState, memo, useMemo } from "react";
import { Image, Text, TextInput, View } from "react-native";
import JizhangIcon from "@/static/images/subapps/jizhang.webp";
import CaidanIcon from "@/static/images/subapps/caidan.webp";
import YifudapeiIcon from "@/static/images/subapps/yifudapei.webp";
import { Image, ImageSourcePropType, Text, TextInput, TouchableOpacity, View } from "react-native";
import { KeyboardAvoidingView } from "react-native-keyboard-controller";
import { useRouter } from "expo-router";
import { Menu, PressableFeedback, Spinner } from "heroui-native";
@@ -28,6 +31,58 @@ function formatTime(ts: string) {
return `${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
}
// ===================== 模块卡片配置 =====================
const SUBAPP_ICONS: Record<string, ImageSourcePropType> = {
finance: JizhangIcon,
meal: CaidanIcon,
clothes: YifudapeiIcon,
};
const SOURCE_CARD_CONFIG: Record<string, { label: string; icon?: ImageSourcePropType; route: (id: number) => string }> = {
b_finance: { label: "记账", icon: SUBAPP_ICONS.finance, route: (id) => `/finance/detail/${id}` },
b_todo: { label: "待办", route: (id) => `/todo/detail/${id}` },
b_meal_dish: { label: "家庭菜单", icon: SUBAPP_ICONS.meal, route: (id) => `/meal/dish/detail/${id}` },
b_meal_plan: { label: "家庭菜单", icon: SUBAPP_ICONS.meal, route: (id) => `/meal/plan/detail/${id}` },
b_clothing_outfit: { label: "衣服穿搭", icon: SUBAPP_ICONS.clothes, route: (id) => `/clothes/outfit/detail/${id}` },
b_clothing_clothes: { label: "衣服穿搭", icon: SUBAPP_ICONS.clothes, route: (id) => `/clothes/detail/${id}` },
};
/** 从 content 中解析模块名和正文,格式:『【模块】正文』 */
function parseModuleContent(content: string): { module: string; body: string } {
const m = content.match(/^【(.+?)】(.*)$/);
return m ? { module: m[1], body: m[2] } : { module: "", body: content };
}
/** 根据 source_table 从 source_data 中提取关键信息 */
function formatSourceDetail(sourceTable: string, sourceData: string | null): string | null {
if (!sourceData) return null;
try {
const d = JSON.parse(sourceData);
switch (sourceTable) {
case "b_finance": {
const parts: string[] = [];
if (d.amount != null) parts.push(`¥${d.amount}`);
if (d.remark) parts.push(d.remark);
return parts.join(" · ") || null;
}
case "b_todo":
if (d.description) return String(d.description);
return null;
case "b_meal_dish": {
const parts: string[] = [];
if (d.cooking_time_minutes) parts.push(`约${d.cooking_time_minutes}分钟`);
if (d.difficulty) parts.push(`${"★".repeat(d.difficulty)}`);
return parts.join(" · ") || null;
}
default:
return null;
}
} catch {
return null;
}
}
// ===================== 在线栏 =====================
function ChatOnlineBar({ users, onSearch }: { users: OnlineUser[]; onSearch: () => void }) {
@@ -100,6 +155,18 @@ const ChatMessageItem = memo(function ChatMessageItem({
const canRecall = isSelf && within2Min;
const canShowActions = canRecall || isAdmin;
// 是否为模块卡片消息(Forward 或带 source 的 System)
const isCardMsg = item.msg_type === ChatMsgType.Forward || (item.msg_type === ChatMsgType.System && !!item.source_table);
const cardConfig = isCardMsg && item.source_table ? SOURCE_CARD_CONFIG[item.source_table] : null;
const { module: moduleName, body: moduleBody } = parseModuleContent(item.content);
const sourceDetail = isCardMsg && item.source_table ? formatSourceDetail(item.source_table, item.source_data) : null;
const handleCardPress = useCallback(() => {
if (cardConfig && item.source_id) {
router.push(cardConfig.route(item.source_id) as any);
}
}, [cardConfig, item.source_id, router]);
if (item.recalled === 1) {
return (
<View className="items-center py-2 px-4">
@@ -108,7 +175,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
);
}
if (item.msg_type === ChatMsgType.System) {
if (item.msg_type === ChatMsgType.System && !item.source_table) {
return (
<View className="items-center py-2 px-4">
<View className="bg-black/5 rounded-full px-3 py-1">
@@ -229,40 +296,46 @@ const ChatMessageItem = memo(function ChatMessageItem({
</View>
)}
{item.msg_type === ChatMsgType.Forward && item.source_data && (
<View className="max-w-[60%] rounded-xl overflow-hidden bg-white shadow-sm border border-gray-100">
<View className="px-3 py-2">
<Text className="text-[11px] text-gray-400">来自 {item.source_table}</Text>
<Text className="text-sm text-gray-700 mt-1 text-left" numberOfLines={3}>
{(() => {
try {
const d = JSON.parse(item.source_data);
return Object.entries(d)
.filter(
([k]) =>
![
"id",
"create_time",
"update_time",
"create_by",
"update_by",
"family_id",
].includes(k),
)
.map(([k, v]) => `${k}: ${v}`)
.join(" ");
} catch {
return item.source_data;
}
})()}
</Text>
</View>
{item.content ? (
<View className="px-3 pb-2 border-t border-gray-50">
<Text className="text-sm text-gray-900 text-left">{item.content}</Text>
{isCardMsg && (
<TouchableOpacity
activeOpacity={0.85}
disabled={!cardConfig || !item.source_id}
onPress={handleCardPress}
className="w-[85%]"
>
<View
className={cn(
"rounded-2xl overflow-hidden px-3.5 py-3",
isSelf
? "bg-accent rounded-br-md"
: "bg-white shadow-sm border border-gray-100 rounded-bl-md",
)}
>
{/* 模块标识:图标 + 名称 */}
<View className="flex-row items-center gap-1.5 mb-2">
{cardConfig?.icon ? (
<Image source={cardConfig.icon} style={{ width: 18, height: 18 }} />
) : (
<View className={cn("w-1.5 h-1.5 rounded-full", isSelf ? "bg-white" : "bg-gray-300")} />
)}
<Text className={cn("text-[11px] font-medium", isSelf ? "text-white" : "text-gray-400")}>
{cardConfig?.label ?? moduleName ?? "动态"}
</Text>
</View>
) : null}
</View>
{/* 操作描述 */}
<Text className={cn("text-[15px] leading-[22px] text-left", isSelf ? "text-white" : "text-gray-900")}>
{moduleBody || item.content}
</Text>
{/* 来源补充信息 */}
{sourceDetail && (
<Text className={cn("text-[13px] text-left mt-1", isSelf ? "text-white" : "text-gray-400")}>
{sourceDetail}
</Text>
)}
</View>
</TouchableOpacity>
)}
<Text className="text-[10px] text-gray-300 mt-0.5 mr-1">
@@ -547,7 +620,7 @@ export default function ChatPage() {
}
// Retry a failed message
function handleRetry(p: PendingMsg) {
const handleRetry = useCallback((p: PendingMsg) => {
setPending((prev) =>
prev.map((x) => (x.localId === p.localId ? { ...x, status: "sending" } : x)),
);
@@ -559,7 +632,50 @@ export default function ChatPage() {
);
toast.error("发送失败", { id: p.localId });
}, 10_000);
}
}, [ws]);
// memo 稳定引用,避免 inverted FlatList 反复 renderItem
const keyExtractor = useCallback((item: any) => {
const p = item._pending;
return p ? p.localId : String(item.id);
}, []);
const renderItem = useCallback(({ item }: { item: any }) => {
const pending = item._pending as PendingMsg | undefined;
if (pending) {
return (
<View className="flex-row items-center px-3 py-1.5 justify-end">
{pending.status === "sending" ? (
<View className="mr-1">
<Spinner size="sm" color="#9CA3AF" />
</View>
) : (
<PressableFeedback onPress={() => handleRetry(pending)}>
<View className="mr-1 p-1">
<AlertCircleIcon size={16} color="#EF4444" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
)}
<View className="max-w-[60%] rounded-2xl rounded-br-md px-3.5 py-2 bg-accent">
<Text className="text-[15px] leading-[22px] text-white">
{pending.content}
</Text>
</View>
</View>
);
}
return (
<ChatMessageItem
item={item}
isSelf={item.user_id === userId}
members={members}
isAdmin={adminRole === 0}
onRecall={ws.recall}
onDelete={ws.deleteMessage}
/>
);
}, [userId, members, adminRole, ws.recall, ws.deleteMessage, handleRetry]);
if (loading) {
return (
@@ -612,10 +728,7 @@ export default function ChatPage() {
}
data={invertedMessages}
inverted
keyExtractor={(item) => {
const p = (item as any)._pending;
return p ? p.localId : String(item.id);
}}
keyExtractor={keyExtractor}
contentContainerStyle={{ paddingTop: 40 }}
onEndReached={() => {
if (hasMore && !loadingMore) handleLoadMore();
@@ -637,42 +750,7 @@ export default function ChatPage() {
<Text className="text-gray-400 text-sm">暂无消息,开始聊天吧</Text>
</View>
}
renderItem={({ item }: { item: any }) => {
const pending = item._pending as PendingMsg | undefined;
if (pending) {
return (
<View className="flex-row items-center px-3 py-1.5 justify-end">
{pending.status === "sending" ? (
<View className="mr-1">
<Spinner size="sm" color="#9CA3AF" />
</View>
) : (
<PressableFeedback onPress={() => handleRetry(pending)}>
<View className="mr-1 p-1">
<AlertCircleIcon size={16} color="#EF4444" />
</View>
<PressableFeedback.Ripple />
</PressableFeedback>
)}
<View className="max-w-[60%] rounded-2xl rounded-br-md px-3.5 py-2 bg-accent">
<Text className="text-[15px] leading-[22px] text-white">
{pending.content}
</Text>
</View>
</View>
);
}
return (
<ChatMessageItem
item={item}
isSelf={item.user_id === userId}
members={members}
isAdmin={adminRole === 0}
onRecall={ws.recall}
onDelete={ws.deleteMessage}
/>
);
}}
renderItem={renderItem}
/>
</LayoutView>
</View>
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { View, Text } from "react-native";
import { Image, View, Text } from "react-native";
import { PressableFeedback } from "heroui-native";
import { useFamilyStore, useFavoriteStore } from "@/store";
import { loadDataApi } from "@/request/api";
@@ -34,17 +34,17 @@ function SectionLabel({
// ── 组件:宫格项 ──────────────────────────────────
function GridItem({
icon: Icon,
icon,
title,
onPress,
}: {
icon: any;
icon: React.ReactNode;
title: string;
onPress: () => void;
}) {
return (
<PressableFeedback onPress={onPress} className="flex-1 items-center py-3">
<Icon size={28} />
{icon}
<Text className="text-[12px] text-gray-600 mt-2">{title}</Text>
<PressableFeedback.Ripple />
</PressableFeedback>
@@ -100,12 +100,12 @@ export default function Home() {
{SUB_APPS.map((app) => (
<GridItem
key={app.id}
icon={app.icon}
icon={<Image source={app.icon} style={{ width: 28, height: 28 }} />}
title={app.title}
onPress={() => router.navigate(app.id as any)}
/>
))}
<GridItem icon={Grid3X3} title="更多工具" onPress={() => router.navigate("/tools")} />
<GridItem icon={<Grid3X3 size={28} color="#374151" />} title="更多工具" onPress={() => router.navigate("/tools")} />
{/* 填充空位保证对齐 */}
{Array.from({
length: Math.max(0, 2 - SUB_APPS.length),
@@ -21,7 +21,7 @@ const MSG_TYPES = [
{ label: "图片", value: ChatMsgType.Image },
{ label: "视频", value: ChatMsgType.Video },
{ label: "文件", value: ChatMsgType.File },
{ label: "转发", value: ChatMsgType.Forward },
{ label: "卡片", value: ChatMsgType.Forward },
{ label: "系统", value: ChatMsgType.System },
];
@@ -1,7 +1,7 @@
import LayoutView from "@/components/layout/view";
import { SearchField, CloseButton, PressableFeedback, useThemeColor } from "heroui-native";
import { toast } from "sonner-native";
import { View, Text } from "react-native";
import { Image, View, Text } from "react-native";
import { Stack, useRouter } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { getGroupedApps, type SubAppItem } from "@/configs/pages";
@@ -109,7 +109,7 @@ export default function Tools() {
}
}}
>
<View className="flex-row items-center mb-3 mt-5 first:mt-0">
<View className="flex-row items-center mb-2.5 mt-3 first:mt-0">
{/* 收藏分组特殊标记 */}
{item.title === "收藏" && (
<View className="w-5 h-5 rounded-full bg-accent/15 items-center justify-center mr-1.5">
@@ -129,9 +129,8 @@ export default function Tools() {
}
return (
<View key={`g-${idx}`} className="mb-4">
<View key={`g-${idx}`} className="mb-2">
{item.data.map((subItem) => {
const Icon = subItem.icon;
const isFav = favoriteStore.isFavorite(subItem.id);
return (
@@ -143,7 +142,7 @@ export default function Tools() {
onPress={() => router.navigate(subItem.id as any)}
className="flex-row items-center py-3.5 px-4"
>
<Icon size={28} />
<Image source={subItem.icon} style={{ width: 28, height: 28 }} />
<View className="flex-1 ml-3">
<Text className="text-[15px] text-gray-700 font-medium leading-5">
{subItem.title}
@@ -112,7 +112,7 @@ export default function ClothingHome() {
deleteS3FileQuietly(item.image_file_key);
if (deleteRelatedOutfits) state.outfits.filter((o) => relatedIds.includes(o.id)).forEach((o) => deleteS3FileQuietly(o.cover_file_key));
await loadBaseData();
notifyModuleOperation({ module: "衣服搭配", action: "删除衣服", target: item.name, detail: deleteRelatedOutfits ? `同时删除 ${relatedIds.length} 个关联搭配` : undefined, sourceTable: "b_clothing_clothes", sourceId: item.id });
notifyModuleOperation({ module: "衣服穿搭", action: "删除衣服", target: item.name, detail: deleteRelatedOutfits ? `同时删除 ${relatedIds.length} 个关联搭配` : undefined, sourceTable: "b_clothing_clothes", sourceId: item.id });
stopLoading({ type: "success", message: "已删除" });
} catch { stopLoading({ type: "error", message: "删除失败" }); }
}, [deleteClothesBundle, familyId, isLoading, loadBaseData, notifyModuleOperation, startLoading, stopLoading]);
@@ -80,7 +80,7 @@ export default function ClothingOutfitList() {
const state = useClothingStore.getState(); const details = state.outfitClothes.filter((r) => r.outfit_id === outfit.id);
const ok = await saveOutfitBundle({ outfitDeletes: [{ id: outfit.id }], clothesDeletes: details.map((r) => ({ id: r.id })), tagRelationDeletes: state.tagRelations.filter((r) => r.outfit_id === outfit.id).map((r) => ({ id: r.id })) });
if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; }
await loadData(); notifyModuleOperation({ module: "衣服搭配", action: "删除搭配", target: outfit.name, detail: `${details.length} 件衣服`, sourceTable: "b_clothing_outfit", sourceId: outfit.id });
await loadData(); notifyModuleOperation({ module: "衣服穿搭", action: "删除搭配", target: outfit.name, detail: `${details.length} 件衣服`, sourceTable: "b_clothing_outfit", sourceId: outfit.id });
stopLoading({ type: "success", message: "已删除" });
} catch { stopLoading({ type: "error", message: "删除失败" }); }
}, [deleteConfirm, familyId, isLoading, loadData, notifyModuleOperation, saveOutfitBundle, startLoading, stopLoading]);
@@ -63,7 +63,7 @@ export default function ClothingDetail() {
if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; }
deleteS3FileQuietly(item.image_file_key);
state.outfits.filter((o) => relatedIds.includes(o.id)).forEach((o) => deleteS3FileQuietly(o.cover_file_key));
notifyModuleOperation({ module: "衣服搭配", action: "删除衣服", target: item.name, detail: relatedIds.length > 0 ? `同时删除 ${relatedIds.length} 个关联搭配` : undefined, sourceTable: "b_clothing_clothes", sourceId: item.id });
notifyModuleOperation({ module: "衣服穿搭", action: "删除衣服", target: item.name, detail: relatedIds.length > 0 ? `同时删除 ${relatedIds.length} 个关联搭配` : undefined, sourceTable: "b_clothing_clothes", sourceId: item.id });
stopLoading({ type: "success", message: "已删除" }); router.replace("/clothes/home" as any);
} catch { stopLoading({ type: "error", message: "删除失败" }); }
}, [deleteClothesBundle, familyId, isLoading, item, notifyModuleOperation, router, startLoading, stopLoading]);
@@ -287,7 +287,7 @@ export default function ClothingEdit() {
}
await Promise.all([fetchClothes(familyId), fetchTagRelations(familyId)]);
notifyModuleOperation({
module: "衣服搭配",
module: "衣服穿搭",
action: isEdit ? "更新衣服" : "新增衣服",
target: name,
sourceTable: "b_clothing_clothes",
@@ -61,7 +61,7 @@ export default function OutfitDetail() {
const d = state.outfitClothes.filter((r) => r.outfit_id === item.id);
const ok = await saveOutfitBundle({ outfitDeletes: [{ id: item.id }], clothesDeletes: d.map((r) => ({ id: r.id })), tagRelationDeletes: state.tagRelations.filter((r) => r.outfit_id === item.id).map((r) => ({ id: r.id })) });
if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; }
notifyModuleOperation({ module: "衣服搭配", action: "删除搭配", target: item.name, detail: `${d.length} 件衣服`, sourceTable: "b_clothing_outfit", sourceId: item.id });
notifyModuleOperation({ module: "衣服穿搭", action: "删除搭配", target: item.name, detail: `${d.length} 件衣服`, sourceTable: "b_clothing_outfit", sourceId: item.id });
stopLoading({ type: "success", message: "已删除" }); router.replace("/clothes/outfit" as any);
} catch { stopLoading({ type: "error", message: "删除失败" }); }
}, [familyId, isLoading, item, notifyModuleOperation, router, saveOutfitBundle, startLoading, stopLoading]);
@@ -648,7 +648,7 @@ export default function ClothingOutfitEdit() {
fetchTagRelations(familyId),
]);
notifyModuleOperation({
module: "衣服搭配",
module: "衣服穿搭",
action: isEdit ? "更新搭配" : "新增搭配",
target: trimmedName,
detail: `${sortedItems.length} 件衣服`,
@@ -1,9 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Dimensions, FlatList, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { router, useFocusEffect } from "expo-router";
import dayjs from "dayjs";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Button, Dialog, Label, TextField } from "heroui-native";
import { CheckIcon, FilterIcon, PencilIcon, PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native";
import { toast } from "sonner-native";
import BottomSheet from "@/components/BottomSheet";
import ContextMenu from "@/components/ContextMenu";
import EmptyState from "@/components/EmptyStatus";
@@ -11,7 +13,7 @@ import LayoutView from "@/components/layout/view";
import MealDishImage from "@/components/meal/MealDishImage";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useListLoading, useLoading } from "@/hooks/useLoading";
import { useFamilyStore, useMealStore } from "@/store";
import { useFamilyStore, useMealStore, useUserStore } from "@/store";
import { MealDish, MealStatus, MealTag } from "@/types/meal";
// ============================================================
@@ -36,14 +38,20 @@ const MODULE_HEADER_H = 50; // 模块顶部 header 高度(对齐 LayoutView
export default function MealDishesPage() {
const familyId = useFamilyStore((s) => s.getFamilyId());
const userId = useUserStore((s) => s.getUserId());
const dishes = useMealStore((s) => s.dishes);
const categories = useMealStore((s) => s.categories);
const tags = useMealStore((s) => s.tags);
const dishTags = useMealStore((s) => s.dishTags);
const slots = useMealStore((s) => s.slots);
const fetchDishes = useMealStore((s) => s.fetchDishes);
const fetchCategories = useMealStore((s) => s.fetchCategories);
const fetchTags = useMealStore((s) => s.fetchTags);
const fetchDishTags = useMealStore((s) => s.fetchDishTags);
const fetchSlots = useMealStore((s) => s.fetchSlots);
const fetchPlansByDate = useMealStore((s) => s.fetchPlansByDate);
const createEmptyPlan = useMealStore((s) => s.createEmptyPlan);
const addDishToPlan = useMealStore((s) => s.addDishToPlan);
const saveDishChanges = useMealStore((s) => s.saveDishChanges);
const { isLoading, startLoading, stopLoading } = useLoading();
const { refreshing, runInitial, runRefresh } = useListLoading();
@@ -51,6 +59,10 @@ export default function MealDishesPage() {
const insets = useSafeAreaInsets();
const screenH = Dimensions.get("window").height;
// 日期常量
const today = dayjs().format("YYYY-MM-DD");
const tomorrow = dayjs().add(1, "day").format("YYYY-MM-DD");
// 筛选状态
const [keyword, setKeyword] = useState("");
const [filter, setFilter] = useState<DishFilter>(createDefaultFilter);
@@ -63,12 +75,17 @@ export default function MealDishesPage() {
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
const [deleteConfirm, setDeleteConfirm] = useState<MealDish | null>(null);
// 加入安排
const [addDishTarget, setAddDishTarget] = useState<MealDish | null>(null);
const [addDishDate, setAddDishDate] = useState(today);
// 左右两栏可用高度
const bodyH = screenH - insets.top - MODULE_HEADER_H - HEADER_H - TAB_BAR_H - insets.bottom;
// ======== 派生数据 ========
const enabledCats = useMemo(() => categories.filter((c) => c.status !== 0), [categories]);
const enabledSlots = useMemo(() => slots.filter((s) => s.status !== 0).sort((a, b) => a.sort_number - b.sort_number), [slots]);
const activeFilterCount = useMemo(() => {
let c = 0; if (filter.categoryId) c++; if (filter.tagIds.length > 0) c++; return c;
@@ -99,8 +116,8 @@ export default function MealDishesPage() {
const loadData = useCallback(async () => {
if (!familyId) return;
await Promise.all([fetchDishes(familyId), fetchCategories(familyId), fetchTags(familyId), fetchDishTags(familyId)]);
}, [familyId, fetchCategories, fetchDishTags, fetchDishes, fetchTags]);
await Promise.all([fetchDishes(familyId), fetchCategories(familyId), fetchTags(familyId), fetchDishTags(familyId), fetchSlots(familyId)]);
}, [familyId, fetchCategories, fetchDishTags, fetchDishes, fetchTags, fetchSlots]);
useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]);
useFocusEffect(useCallback(() => { if (familyId) runRefresh(loadData); }, [familyId, loadData, runRefresh]));
@@ -132,6 +149,50 @@ export default function MealDishesPage() {
return <TouchableOpacity key={t.id} className={`rounded-full border px-3 py-2 ${s ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`} onPress={() => setDraftFilter((p) => ({ ...p, tagIds: p.tagIds.includes(t.id) ? p.tagIds.filter((id) => id !== t.id) : [...p.tagIds, t.id] }))}><Text className={`text-xs ${s ? "text-white" : "text-gray-700"}`}>{t.name}</Text></TouchableOpacity>;
}, [draftFilter.tagIds]);
// ======== 加入安排 ========
const handleAddToSlot = useCallback(async (slotId: number, slotName: string) => {
if (!familyId || !userId || !addDishTarget) return;
const dish = addDishTarget;
const date = addDishDate;
setAddDishTarget(null);
startLoading("添加中...");
try {
// 获取该日期的安排
await fetchPlansByDate(familyId, date);
const plans = useMealStore.getState().plans;
let planId = plans.find((p) => p.meal_slot_id === slotId)?.id;
// 没有安排则创建
if (!planId) {
planId = await createEmptyPlan({ familyId, planDate: date, mealSlotId: slotId, userId });
}
if (!planId) {
stopLoading({ type: "error", message: "创建安排失败" });
return;
}
// 添加菜品
const result = await addDishToPlan({
familyId,
planId,
dishId: dish.id,
dishName: dish.name,
userId,
sortNumber: 0,
});
if (result.planItemId) {
const dateLabel = date === today ? "今天" : date === tomorrow ? "明天" : date;
toast.success(`已加入${dateLabel}${slotName}`);
stopLoading();
} else {
stopLoading({ type: "error", message: "添加失败" });
}
} catch {
stopLoading({ type: "error", message: "添加失败" });
}
}, [familyId, userId, addDishTarget, addDishDate, today, tomorrow, fetchPlansByDate, createEmptyPlan, addDishToPlan, startLoading, stopLoading]);
// ======== 渲染 ========
return (
@@ -227,6 +288,14 @@ export default function MealDishesPage() {
{!!item.cooking_time_minutes && <Text className="text-[11px] text-gray-400">约{item.cooking_time_minutes}分钟</Text>}
</View>
</View>
<TouchableOpacity
className="w-8 h-8 items-center justify-center rounded-full bg-accent/10 active:scale-90"
activeOpacity={0.7}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
onPress={() => { setAddDishDate(today); setAddDishTarget(item); }}
>
<PlusIcon size={18} color="#f97316" />
</TouchableOpacity>
<Text className="text-xs text-gray-300">{">"}</Text>
</TouchableOpacity>
)}
@@ -250,6 +319,52 @@ export default function MealDishesPage() {
</BottomSheet.Content></BottomSheet.Portal>
</BottomSheet>
{/* ======== 加入安排 BottomSheet ======== */}
<BottomSheet isOpen={!!addDishTarget} onOpenChange={(v) => { if (!v) setAddDishTarget(null); }}>
<BottomSheet.Portal><BottomSheet.Overlay />
<BottomSheet.Content animateOnMount={false} className="gap-4" style={{ paddingHorizontal: 16 }}>
<Text className="text-center text-lg font-semibold text-gray-900">
加入安排{addDishTarget ? ` · ${addDishTarget.name}` : ""}
</Text>
{/* 日期选择 */}
<View className="flex-row gap-3">
<TouchableOpacity
className={`flex-1 py-3 rounded-xl items-center ${addDishDate === today ? "bg-accent" : "bg-gray-100"}`}
onPress={() => setAddDishDate(today)}
>
<Text className={`text-sm font-medium ${addDishDate === today ? "text-white" : "text-gray-600"}`}>今天</Text>
</TouchableOpacity>
<TouchableOpacity
className={`flex-1 py-3 rounded-xl items-center ${addDishDate === tomorrow ? "bg-accent" : "bg-gray-100"}`}
onPress={() => setAddDishDate(tomorrow)}
>
<Text className={`text-sm font-medium ${addDishDate === tomorrow ? "text-white" : "text-gray-600"}`}>明天</Text>
</TouchableOpacity>
</View>
{/* 餐次选择 */}
<View>
<Text className="text-sm text-gray-500 mb-2">选择餐次</Text>
{enabledSlots.length > 0 ? (
<View className="flex-row flex-wrap gap-2">
{enabledSlots.map((slot) => (
<TouchableOpacity
key={slot.id}
className="px-5 py-3 rounded-xl bg-gray-100 active:bg-gray-200"
onPress={() => handleAddToSlot(slot.id, slot.name)}
>
<Text className="text-sm font-medium text-gray-700">{slot.name}</Text>
</TouchableOpacity>
))}
</View>
) : (
<Text className="text-sm text-gray-400">暂无餐次,请先在设置中添加</Text>
)}
</View>
</BottomSheet.Content></BottomSheet.Portal>
</BottomSheet>
{/* ======== 长按菜单 ======== */}
<ContextMenu visible={!!activeItem} position={menuPos} onClose={() => setActiveItem(null)}
items={[
@@ -110,14 +110,14 @@ export default function MealDishEdit() {
if (isEdit) {
const ok = await saveDishChanges({ updates: [{ id: editId, name: form.name.trim(), image_file_key: imageFileKey, category_id: form.categoryId, difficulty: form.difficulty, cooking_time_minutes: form.cookingTime ? Number(form.cookingTime) : 0, remark: form.remark.trim() }] });
if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; }
notifyOp({ module: "餐食", action: "编辑菜品", target: form.name.trim(), sourceTable: "b_meal_dish", sourceId: editId });
notifyOp({ module: "家庭菜单", action: "编辑菜品", target: form.name.trim(), sourceTable: "b_meal_dish", sourceId: editId });
} else {
const sc = steps.length + 1; const idsRes = await getUniqueIdApi(sc); const dishId = idsRes.data?.[0] ?? 0;
if (!dishId) { stopLoading({ type: "error", message: "创建失败" }); return; }
const sids2 = idsRes.data?.slice(1) ?? []; const sis2 = steps.map((s, i) => ({ id: sids2[i] ?? 0, family_id: familyId, dish_id: dishId, step_number: s.stepNumber, description: s.description, image_file_key: s.imageFileKey, sort_number: i }));
const ok = await saveDishBundle({ dishInserts: [{ id: dishId, family_id: familyId, name: form.name.trim(), image_file_key: imageFileKey, category_id: form.categoryId, difficulty: form.difficulty, cooking_time_minutes: form.cookingTime ? Number(form.cookingTime) : 0, remark: form.remark.trim(), status: MealStatus.Enabled, sort_number: 0 }], dishStepInserts: sis2 });
if (!ok) { stopLoading({ type: "error", message: "保存失败" }); return; }
notifyOp({ module: "餐食", action: "新增菜品", target: form.name.trim(), sourceTable: "b_meal_dish", sourceId: dishId });
notifyOp({ module: "家庭菜单", action: "新增菜品", target: form.name.trim(), sourceTable: "b_meal_dish", sourceId: dishId });
}
stopLoading({ type: "success", message: "已保存" }); router.back();
@@ -357,7 +357,7 @@ export default function MealPlanDetail() {
setComment("");
await loadData();
notifyOp({
module: "餐食",
module: "家庭菜单",
action: "评论菜单",
target: planId.toString(),
sourceTable: "b_meal_plan_log",
@@ -120,7 +120,7 @@ export default function MealPlanEdit() {
}
notifyOp({
module: "餐食",
module: "家庭菜单",
action: "创建菜单",
target: `${planDate} ${slots.find((s) => s.id === mealSlotId)?.name ?? ""}`,
sourceTable: "b_meal_plan",
+12 -9
View File
@@ -1,9 +1,12 @@
import { IconAppFinance, IconAppClothes, IconAppMeal, IconAppTodo, IconAppAnniversary } from "@/components/iconfont";
import type { ImageSourcePropType } from "react-native";
import JizhangIcon from "@/static/images/subapps/jizhang.webp";
import CaidanIcon from "@/static/images/subapps/caidan.webp";
import YifudapeiIcon from "@/static/images/subapps/yifudapei.webp";
export interface SubAppItem {
id: string;
title: string;
icon: any;
icon: ImageSourcePropType;
group: string;
description?: string;
}
@@ -25,21 +28,21 @@ export const SUB_APPS: SubAppItem[] = [
{
id: "/finance/home",
title: "记账",
icon: IconAppFinance,
icon: JizhangIcon,
group: "J",
description: "记录日常收支,管理你的每一笔钱",
},
{
id: "/clothes/home",
title: "衣服搭配",
icon: IconAppClothes,
title: "衣服穿搭",
icon: YifudapeiIcon,
group: "Y",
description: "帮你整理每日穿搭灵感",
},
{
id: "/meal/home",
title: "餐食",
icon: IconAppMeal,
title: "家庭菜单",
icon: CaidanIcon,
group: "J",
description: "家庭菜品库与餐食安排",
},
@@ -47,7 +50,7 @@ export const SUB_APPS: SubAppItem[] = [
// {
// id: "/todo/home",
// title: "家庭待办",
// icon: IconAppTodo,
// icon: require("@/static/images/subapps/daiban.webp"),
// group: "J",
// description: "家庭任务分配与跟踪",
// },
@@ -55,7 +58,7 @@ export const SUB_APPS: SubAppItem[] = [
// {
// id: "/anniversary/home",
// title: "纪念日",
// icon: IconAppAnniversary,
// icon: require("@/static/images/subapps/jinianri.webp"),
// group: "J",
// description: "记录家人的生日与重要纪念日",
// },
@@ -24,7 +24,7 @@ export function useModuleChatNotifier() {
chat.send({
type: "send",
msg_type: ChatMsgType.System,
msg_type: ChatMsgType.Forward,
content,
source_table: sourceTable,
source_id: sourceId,
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

+2 -2
View File
@@ -8,9 +8,9 @@ export enum ChatMsgType {
Video = 2,
/** 文件 */
File = 3,
/** 转发(模块内容分享) */
/** 模块卡片(记账、待办、餐食等操作动态,以卡片形式展示) */
Forward = 4,
/** 系统消息(加入家庭、改名、记账、收藏等) */
/** 系统消息(加入家庭、改名等纯通知) */
System = 5,
}
+18
View File
@@ -1,5 +1,23 @@
export {};
declare module "@/static/images/subapps/jizhang.webp" {
import { ImageSourcePropType } from "react-native";
const content: ImageSourcePropType;
export default content;
}
declare module "@/static/images/subapps/caidan.webp" {
import { ImageSourcePropType } from "react-native";
const content: ImageSourcePropType;
export default content;
}
declare module "@/static/images/subapps/yifudapei.webp" {
import { ImageSourcePropType } from "react-native";
const content: ImageSourcePropType;
export default content;
}
declare global {
enum HttpReponseCode {
Success = 1000,
+1
View File
@@ -11,6 +11,7 @@
"**/*.ts",
"**/*.tsx",
"./src/uniwind-types.d.ts",
"./src/types/global.d.ts",
"env.js",
".expo/types/**/*.ts",
"expo-env.d.ts",