20260708221610
This commit is contained in:
1 parent
f94b5152ed
commit
d080a2a48d
26 files changed
+329
-115
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -8,9 +8,9 @@ export enum ChatMsgType {
|
||||
Video = 2,
|
||||
/** 文件 */
|
||||
File = 3,
|
||||
/** 转发(模块内容分享) */
|
||||
/** 模块卡片(记账、待办、餐食等操作动态,以卡片形式展示) */
|
||||
Forward = 4,
|
||||
/** 系统消息(加入家庭、改名、记账、收藏等) */
|
||||
/** 系统消息(加入家庭、改名等纯通知) */
|
||||
System = 5,
|
||||
}
|
||||
|
||||
|
||||
+18
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user