20260630224653

This commit is contained in:
oneao committed 2026-06-30 22:46:53 +08:00
1 parent cebbd3f92c
commit 48a0956e24
22 files changed
+828 -861

No files matched your search

+1 -1
View File
@@ -87,7 +87,7 @@ create table b_clothing_tag (
tag_group_id bigint not null, tag_group_id bigint not null,
name varchar(32) not null, name varchar(32) not null,
type smallint not null, type smallint not null,
emoji varchar(16) default '', color varchar(7) default '',
sort_number int not null default 0, sort_number int not null default 0,
status smallint not null default 1, status smallint not null default 1,
create_time timestamp, create_time timestamp,
@@ -112,7 +112,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
return ( return (
<View className="items-center py-2 px-4"> <View className="items-center py-2 px-4">
<View className="bg-black/5 rounded-full px-3 py-1"> <View className="bg-black/5 rounded-full px-3 py-1">
<Text className="text-xs text-gray-500">{item.content}</Text> <Text className="text-xs text-gray-500">{nickname} {item.content}</Text>
</View> </View>
</View> </View>
); );
@@ -273,7 +273,7 @@ export default function ChatHistoryPage() {
</View> </View>
<View> <View>
<Label>时间范围</Label> <Label className="mb-2">时间范围</Label>
<TouchableOpacity <TouchableOpacity
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between" className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
activeOpacity={0.75} activeOpacity={0.75}
@@ -289,7 +289,7 @@ export default function ChatHistoryPage() {
</View> </View>
<View> <View>
<Label>娑堟伅绫诲瀷</Label> <Label className="mb-2">娑堟伅绫诲瀷</Label>
<View className="flex-row gap-2 flex-wrap"> <View className="flex-row gap-2 flex-wrap">
{MSG_TYPES.map((t) => ( {MSG_TYPES.map((t) => (
<PressableFeedback key={t.value} onPress={() => setDraftTypeFilter(t.value)}> <PressableFeedback key={t.value} onPress={() => setDraftTypeFilter(t.value)}>
@@ -529,7 +529,7 @@ export default function HomeSettings() {
<BottomSheet.Content> <BottomSheet.Content>
<View className="px-4"> <View className="px-4">
<TextField> <TextField>
<Label>邀请码</Label> <Label className="mb-2">邀请码</Label>
<PressableFeedback <PressableFeedback
className="flex-row gap-2 items-center justify-center h-20 bg-white rounded-xl shadow-2xl" className="flex-row gap-2 items-center justify-center h-20 bg-white rounded-xl shadow-2xl"
onPress={copyInviteCode} onPress={copyInviteCode}
@@ -553,7 +553,7 @@ export default function HomeSettings() {
<BottomSheet.Overlay /> <BottomSheet.Overlay />
<BottomSheet.Content animateOnMount={false} className="gap-4"> <BottomSheet.Content animateOnMount={false} className="gap-4">
<TextField> <TextField>
<Label>家庭名称</Label> <Label className="mb-2">家庭名称</Label>
<BottomSheet.Input <BottomSheet.Input
ref={familyNameInputRef} ref={familyNameInputRef}
value={renameForm.name} value={renameForm.name}
@@ -576,7 +576,7 @@ export default function HomeSettings() {
<BottomSheet.Content animateOnMount={false}> <BottomSheet.Content animateOnMount={false}>
<View className="gap-4"> <View className="gap-4">
<TextField> <TextField>
<Label>我的昵称</Label> <Label className="mb-2">我的昵称</Label>
<BottomSheet.Input <BottomSheet.Input
ref={nicknameInputRef} ref={nicknameInputRef}
value={nicknameForm.nickname} value={nicknameForm.nickname}
@@ -725,7 +725,7 @@ export default function UserSetting() {
<BottomSheet.Overlay /> <BottomSheet.Overlay />
<BottomSheet.Content className="gap-4"> <BottomSheet.Content className="gap-4">
<TextField> <TextField>
<Label>姓名</Label> <Label className="mb-2">姓名</Label>
<BottomSheet.Input <BottomSheet.Input
ref={nameInputRef} ref={nameInputRef}
value={renameForm.name} value={renameForm.name}
@@ -747,7 +747,7 @@ export default function UserSetting() {
<BottomSheet.Overlay /> <BottomSheet.Overlay />
<BottomSheet.Content className="gap-4"> <BottomSheet.Content className="gap-4">
<TextField> <TextField>
<Label>手机号</Label> <Label className="mb-2">手机号</Label>
<BottomSheet.Input <BottomSheet.Input
ref={phoneInputRef} ref={phoneInputRef}
value={phoneForm.phone} value={phoneForm.phone}
+8 -1
View File
@@ -11,6 +11,8 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
import { HeroUINativeConfig } from "heroui-native"; import { HeroUINativeConfig } from "heroui-native";
import { ChatWebSocketProvider } from "@/hooks/useChatWebSocket"; import { ChatWebSocketProvider } from "@/hooks/useChatWebSocket";
import { useSessionBootstrap } from "@/hooks/useSessionBootstrap"; import { useSessionBootstrap } from "@/hooks/useSessionBootstrap";
import { onToasterReset } from "@/utils/toaster";
import { useEffect, useState } from "react";
SplashScreen.preventAutoHideAsync(); SplashScreen.preventAutoHideAsync();
@@ -22,6 +24,11 @@ const config: HeroUINativeConfig = {
export default function RootLayout() { export default function RootLayout() {
const { isReady } = useSessionBootstrap(); const { isReady } = useSessionBootstrap();
const [tsKey, setTsKey] = useState(0);
useEffect(() => {
onToasterReset(() => setTsKey((k) => k + 1));
}, []);
if (!isReady) return null; if (!isReady) return null;
@@ -47,7 +54,7 @@ export default function RootLayout() {
</HeroUINativeProvider> </HeroUINativeProvider>
</KeyboardProvider> </KeyboardProvider>
</ThemeProvider> </ThemeProvider>
<Toaster duration={1000} /> <Toaster key={tsKey} duration={1000} />
</GestureHandlerRootView> </GestureHandlerRootView>
); );
} }
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Text, TouchableOpacity, View } from "react-native"; import { Modal, Pressable, Text, TouchableOpacity, View } from "react-native";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { Button, Card, Label, Menu, SearchField } from "heroui-native"; import { Button, Card, Dialog, Label, SearchField } from "heroui-native";
import { import {
CheckIcon, CheckIcon,
FilterIcon, FilterIcon,
@@ -67,6 +67,8 @@ export default function ClothingHome() {
const [draftFilter, setDraftFilter] = useState<ClothesFilter>(() => createDefaultFilter()); const [draftFilter, setDraftFilter] = useState<ClothesFilter>(() => createDefaultFilter());
const [showFilterSheet, setShowFilterSheet] = useState(false); const [showFilterSheet, setShowFilterSheet] = useState(false);
const [activeItem, setActiveItem] = useState<ClothingClothes | null>(null); const [activeItem, setActiveItem] = useState<ClothingClothes | null>(null);
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
const [deleteConfirm, setDeleteConfirm] = useState<{ item: ClothingClothes; relatedOutfitCount: number } | null>(null);
const clothesCategories = useMemo( const clothesCategories = useMemo(
() => categories.filter((item) => item.type === ClothingTargetType.Clothes), () => categories.filter((item) => item.type === ClothingTargetType.Clothes),
@@ -266,33 +268,24 @@ export default function ClothingHome() {
), ),
); );
if (relatedOutfitIds.length === 0) { setDeleteConfirm({ item, relatedOutfitCount: relatedOutfitIds.length });
Alert.alert("删除衣服", `确认删除「${item.name}」吗?`, [
{ text: "取消", style: "cancel" },
{ text: "删除", style: "destructive", onPress: () => handleDelete(item, false) },
]);
return;
}
Alert.alert(
"衣服已被搭配使用",
`「${item.name}」已被 ${relatedOutfitIds.length} 个搭配使用,是否同时删除这些搭配?`,
[
{ text: "不删除", style: "cancel" },
{
text: "全部删除",
style: "destructive",
onPress: () => handleDelete(item, true),
}, },
], [familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations],
); );
const handleConfirmDelete = useCallback(
(withOutfits: boolean) => {
const data = deleteConfirm;
if (!data) return;
setDeleteConfirm(null);
handleDelete(data.item, withOutfits);
}, },
[familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations, handleDelete], [deleteConfirm, handleDelete],
); );
const renderCategoryName = useCallback( const renderCategoryName = useCallback(
(categoryId?: number | null) => (categoryId?: number | null) =>
clothesCategories.find((category) => category.id === categoryId)?.name ?? "未分类", clothesCategories.find((category) => category.id === categoryId)?.name ?? "",
[clothesCategories], [clothesCategories],
); );
@@ -305,9 +298,16 @@ export default function ClothingHome() {
.filter((tag) => itemTagIds.has(tag.id)) .filter((tag) => itemTagIds.has(tag.id))
.slice(0, 3) .slice(0, 3)
.map((tag) => ( .map((tag) => (
<View key={tag.id} className="rounded-full bg-gray-100 px-2 py-1"> <View
<Text className="text-[10px] text-gray-600" numberOfLines={1}> key={tag.id}
{tag.emoji ? `${tag.emoji} ` : ""} className="rounded-full px-2 py-1"
style={{ backgroundColor: tag.color || "#F3F4F6" }}
>
<Text
className="text-[10px]"
style={{ color: tag.color ? "#fff" : "#4B5563" }}
numberOfLines={1}
>
{tag.name} {tag.name}
</Text> </Text>
</View> </View>
@@ -323,36 +323,39 @@ export default function ClothingHome() {
activeOpacity={0.82} activeOpacity={0.82}
delayLongPress={260} delayLongPress={260}
onPress={() => handleEdit(item)} onPress={() => handleEdit(item)}
onLongPress={() => setActiveItem(item)} onLongPress={(e) => {
setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
setActiveItem(item);
}}
> >
<Card className="overflow-hidden p-0"> <Card className="overflow-hidden p-0">
<ClothingImage fileKey={item.image_file_key} label={item.name} className="h-40 w-full" /> <ClothingImage fileKey={item.image_file_key} label={item.name} className="h-40 w-full" />
<Card.Body className="p-3"> <Card.Body className="px-3 pt-3 pb-2">
<View className="flex-row items-start justify-between gap-2"> <Card.Title className="text-lg" numberOfLines={1}>
<View className="flex-1">
<Card.Title className="text-sm" numberOfLines={1}>
{item.name} {item.name}
</Card.Title> </Card.Title>
<Card.Description className="mt-1" numberOfLines={1}> {item.brand?.trim() ? (
{item.brand?.trim() || renderCategoryName(item.category_id)} <Card.Description className="mt-0.5" numberOfLines={1}>
{item.brand.trim()}
</Card.Description> </Card.Description>
</View> ) : null}
<TouchableOpacity
className="h-8 w-8 rounded-full bg-gray-100 items-center justify-center"
onPress={() => setActiveItem(item)}
>
<PencilIcon size={15} color="#4B5563" />
</TouchableOpacity>
</View>
<View className="mt-3 flex-row flex-wrap gap-1.5"> {(() => {
const hasTags = (tagIdsByClothesId.get(item.id)?.size ?? 0) > 0;
if (!item.category_id && !hasTags) return null;
return (
<View className="mt-1.5 flex-row flex-wrap gap-1.5">
{item.category_id ? (
<View className="rounded-full bg-gray-900 px-2 py-1"> <View className="rounded-full bg-gray-900 px-2 py-1">
<Text className="text-[10px] text-white" numberOfLines={1}> <Text className="text-[10px] text-white" numberOfLines={1}>
{renderCategoryName(item.category_id)} {renderCategoryName(item.category_id)}
</Text> </Text>
</View> </View>
) : null}
{renderItemTags(item)} {renderItemTags(item)}
</View> </View>
);
})()}
</Card.Body> </Card.Body>
</Card> </Card>
</TouchableOpacity> </TouchableOpacity>
@@ -406,7 +409,6 @@ export default function ClothingHome() {
} }
> >
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}> <Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
{item.emoji ? `${item.emoji} ` : ""}
{item.name} {item.name}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -498,7 +500,7 @@ export default function ClothingHome() {
</View> </View>
<View> <View>
<Label>分类</Label> <Label className="mb-2">分类</Label>
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
<TouchableOpacity <TouchableOpacity
className={`rounded-full border px-3 py-2 ${ className={`rounded-full border px-3 py-2 ${
@@ -521,7 +523,7 @@ export default function ClothingHome() {
</View> </View>
<View> <View>
<Label>标签</Label> <Label className="mb-2">标签</Label>
{clothesTags.length > 0 ? ( {clothesTags.length > 0 ? (
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
{clothesTags.map(renderFilterTag)} {clothesTags.map(renderFilterTag)}
@@ -548,28 +550,63 @@ export default function ClothingHome() {
</BottomSheet.Portal> </BottomSheet.Portal>
</BottomSheet> </BottomSheet>
<Menu <Modal
isOpen={!!activeItem} visible={!!activeItem}
onOpenChange={(open) => { transparent
if (!open) setActiveItem(null); animationType="none"
onRequestClose={() => setActiveItem(null)}
>
<Pressable className="flex-1" onPress={() => setActiveItem(null)}>
<View
className="absolute bg-white rounded-xl w-[200px] overflow-hidden shadow-lg"
style={{
left: Math.min(menuPos.x - 190, menuPos.x - 10),
top: menuPos.y - 100,
}} }}
> >
<Menu.Portal> <TouchableOpacity
<Menu.Overlay /> className="flex-row items-center gap-3 px-4 py-3 border-b border-gray-100"
<Menu.Content presentation="bottom-sheet"> activeOpacity={0.6}
<View className="px-2 pb-4"> onPress={() => { if (activeItem) handleEdit(activeItem); }}
<Menu.Item onPress={() => activeItem && handleEdit(activeItem)}> >
<PencilIcon size={18} color="#374151" /> <PencilIcon size={18} color="#374151" />
<Menu.ItemTitle>编辑</Menu.ItemTitle> <Text className="text-[15px] text-gray-800">编辑</Text>
</Menu.Item> </TouchableOpacity>
<Menu.Item onPress={() => activeItem && handleRequestDelete(activeItem)}> <TouchableOpacity
className="flex-row items-center gap-3 px-4 py-3"
activeOpacity={0.6}
onPress={() => { if (activeItem) handleRequestDelete(activeItem); }}
>
<Trash2Icon size={18} color="#EF4444" /> <Trash2Icon size={18} color="#EF4444" />
<Menu.ItemTitle className="text-red-500">删除</Menu.ItemTitle> <Text className="text-[15px] text-red-500">删除</Text>
</Menu.Item> </TouchableOpacity>
</View> </View>
</Menu.Content> </Pressable>
</Menu.Portal> </Modal>
</Menu>
<Dialog isOpen={!!deleteConfirm} onOpenChange={(v) => { if (!v) setDeleteConfirm(null); }}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>
{deleteConfirm?.relatedOutfitCount ? "衣服已被搭配使用" : "删除衣服"}
</Dialog.Title>
<Dialog.Description>
{deleteConfirm?.relatedOutfitCount
? `「${deleteConfirm.item.name}」已被 ${deleteConfirm.relatedOutfitCount} 个搭配使用,是否同时删除这些搭配?`
: `确认删除「${deleteConfirm?.item.name}」吗?`}
</Dialog.Description>
<View className="flex-row gap-3 mt-4">
<Button variant="tertiary" className="flex-1" onPress={() => setDeleteConfirm(null)}>
<Button.Label>{deleteConfirm?.relatedOutfitCount ? "不删除" : "取消"}</Button.Label>
</Button>
<Button variant="danger" className="flex-1" onPress={() => handleConfirmDelete(!!deleteConfirm?.relatedOutfitCount)}>
<Button.Label>{deleteConfirm?.relatedOutfitCount ? "全部删除" : "删除"}</Button.Label>
</Button>
</View>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
<LoadingOverlay visible={isLoading} /> <LoadingOverlay visible={isLoading} />
</LayoutView> </LayoutView>
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Text, TouchableOpacity, View } from "react-native"; import { Text, TouchableOpacity, View } from "react-native";
import { Button, Card, Label, Tabs, TextField } from "heroui-native"; import { Button, Dialog, Label, Tabs, TextField } from "heroui-native";
import { import {
CheckIcon, CheckIcon,
ChevronDownIcon,
FolderIcon, FolderIcon,
PencilIcon, PencilIcon,
PlusIcon, PlusIcon,
@@ -28,7 +29,12 @@ import {
ClothingTargetType, ClothingTargetType,
} from "@/types/clothing"; } from "@/types/clothing";
type ManageMode = "category" | "tag"; const TAG_COLORS = [
"#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
"#06B6D4", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7",
"#EC4899", "#78716C",
];
type MetadataForm = type MetadataForm =
| { | {
kind: "category"; kind: "category";
@@ -48,7 +54,7 @@ type MetadataForm =
kind: "tag"; kind: "tag";
id?: number; id?: number;
name: string; name: string;
emoji: string; color: string;
tagGroupId: number | null; tagGroupId: number | null;
type: ClothingTargetType; type: ClothingTargetType;
sortNumber?: number; sortNumber?: number;
@@ -74,8 +80,11 @@ export default function ClothingMetadataManage() {
const notifyModuleOperation = useModuleChatNotifier(); const notifyModuleOperation = useModuleChatNotifier();
const [activeType, setActiveType] = useState(String(ClothingTargetType.Clothes)); const [activeType, setActiveType] = useState(String(ClothingTargetType.Clothes));
const [mode, setMode] = useState<ManageMode>("category");
const [form, setForm] = useState<MetadataForm | null>(null); const [form, setForm] = useState<MetadataForm | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<
{ kind: "category"; item: ClothingCategory } | { kind: "tag"; item: ClothingTag } | { kind: "tagGroup"; item: ClothingTagGroup } | null
>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Set<number>>(new Set());
const targetType = Number(activeType) as ClothingTargetType; const targetType = Number(activeType) as ClothingTargetType;
@@ -179,7 +188,7 @@ export default function ClothingMetadataManage() {
setForm({ setForm({
kind: "tag", kind: "tag",
name: "", name: "",
emoji: "", color: "",
tagGroupId: tagGroupId ?? currentTagGroups[0]?.id ?? null, tagGroupId: tagGroupId ?? currentTagGroups[0]?.id ?? null,
type: targetType, type: targetType,
}); });
@@ -192,7 +201,7 @@ export default function ClothingMetadataManage() {
kind: "tag", kind: "tag",
id: item.id, id: item.id,
name: item.name, name: item.name,
emoji: item.emoji ?? "", color: item.color ?? "",
tagGroupId: item.tag_group_id, tagGroupId: item.tag_group_id,
type: item.type, type: item.type,
sortNumber: item.sort_number, sortNumber: item.sort_number,
@@ -256,7 +265,7 @@ export default function ClothingMetadataManage() {
tag_group_id: form.tagGroupId!, tag_group_id: form.tagGroupId!,
name, name,
type: form.type, type: form.type,
emoji: form.emoji.trim(), color: form.color.trim(),
sort_number: sort_number:
form.sortNumber ?? form.sortNumber ??
tags.filter((item) => item.type === form.type && item.tag_group_id === form.tagGroupId) tags.filter((item) => item.type === form.type && item.tag_group_id === form.tagGroupId)
@@ -314,167 +323,121 @@ export default function ClothingMetadataManage() {
tags, tags,
]); ]);
const handleDeleteCategory = useCallback( const handleDeleteCategory = useCallback((item: ClothingCategory) => {
(item: ClothingCategory) => { setDeleteConfirm({ kind: "category", item });
Alert.alert("删除分类", `确认删除「${item.name}」吗?已使用该分类的数据会显示为未分类。`, [ }, []);
{ text: "取消", style: "cancel" },
{ const handleDeleteTag = useCallback((item: ClothingTag) => {
text: "删除", setDeleteConfirm({ kind: "tag", item });
style: "destructive", }, []);
onPress: async () => {
const handleDeleteTagGroup = useCallback((item: ClothingTagGroup) => {
setDeleteConfirm({ kind: "tagGroup", item });
}, []);
const handleConfirmDelete = useCallback(async () => {
if (!deleteConfirm || isLoading) return;
const { kind, item } = deleteConfirm;
setDeleteConfirm(null);
startLoading("删除中..."); startLoading("删除中...");
try { try {
const ok = await saveMetadataBundle({ let ok = false;
if (kind === "category") {
ok = await saveMetadataBundle({
categoryUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], categoryUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
}); });
if (!ok) { } else if (kind === "tag") {
stopLoading({ type: "error", message: "删除失败" });
return;
}
await loadData();
notifyModuleOperation({
module: "衣服搭配",
action: `删除${targetTypeLabel(item.type)}分类`,
target: item.name,
sourceTable: "b_clothing_category",
sourceId: item.id,
});
stopLoading({ type: "success", message: "已删除" });
} catch {
stopLoading({ type: "error", message: "删除失败" });
}
},
},
]);
},
[loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading],
);
const handleDeleteTag = useCallback(
(item: ClothingTag) => {
const relationDeletes = tagRelations const relationDeletes = tagRelations
.filter((row) => row.tag_id === item.id) .filter((row) => row.tag_id === item.id)
.map((row) => ({ id: row.id })); .map((row) => ({ id: row.id }));
ok = await saveMetadataBundle({
Alert.alert("删除标签", `确认删除「${item.name}」吗?对应引用关系也会移除。`, [
{ text: "取消", style: "cancel" },
{
text: "删除",
style: "destructive",
onPress: async () => {
startLoading("删除中...");
try {
const ok = await saveMetadataBundle({
tagUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], tagUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
tagRelationDeletes: relationDeletes, tagRelationDeletes: relationDeletes,
}); });
if (!ok) { } else if (kind === "tagGroup") {
stopLoading({ type: "error", message: "删除失败" });
return;
}
await loadData();
notifyModuleOperation({
module: "衣服搭配",
action: `删除${targetTypeLabel(item.type)}标签`,
target: item.name,
sourceTable: "b_clothing_tag",
sourceId: item.id,
});
stopLoading({ type: "success", message: "已删除" });
} catch {
stopLoading({ type: "error", message: "删除失败" });
}
},
},
]);
},
[loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading, tagRelations],
);
const handleDeleteTagGroup = useCallback(
(item: ClothingTagGroup) => {
const groupTags = tags.filter((tag) => tag.tag_group_id === item.id); const groupTags = tags.filter((tag) => tag.tag_group_id === item.id);
const groupTagIds = new Set(groupTags.map((tag) => tag.id)); const groupTagIds = new Set(groupTags.map((tag) => tag.id));
const relationDeletes = tagRelations const relationDeletes = tagRelations
.filter((row) => groupTagIds.has(row.tag_id)) .filter((row) => groupTagIds.has(row.tag_id))
.map((row) => ({ id: row.id })); .map((row) => ({ id: row.id }));
ok = await saveMetadataBundle({
Alert.alert("删除标签组", `确认删除「${item.name}」及其下 ${groupTags.length} 个标签吗?`, [
{ text: "取消", style: "cancel" },
{
text: "删除",
style: "destructive",
onPress: async () => {
startLoading("删除中...");
try {
const ok = await saveMetadataBundle({
tagGroupUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], tagGroupUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
tagUpdates: groupTags.map((tag) => ({ tagUpdates: groupTags.map((tag) => ({ id: tag.id, status: ClothingStatus.Disabled })),
id: tag.id,
status: ClothingStatus.Disabled,
})),
tagRelationDeletes: relationDeletes, tagRelationDeletes: relationDeletes,
}); });
if (!ok) {
stopLoading({ type: "error", message: "删除失败" });
return;
} }
if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; }
await loadData(); await loadData();
notifyModuleOperation({ notifyModuleOperation({
module: "衣服搭配", module: "衣服搭配",
action: `删除${targetTypeLabel(item.type)}标签组`, action: `删除${targetTypeLabel(item.type)}${
kind === "category" ? "分类" : kind === "tagGroup" ? "标签组" : "标签"
}`,
target: item.name, target: item.name,
detail: `${groupTags.length} 个标签`, detail: kind === "tagGroup" ? `${tags.filter((tag) => tag.tag_group_id === item.id).length} 个标签` : undefined,
sourceTable: "b_clothing_tag_group", sourceTable: kind === "category" ? "b_clothing_category" : kind === "tagGroup" ? "b_clothing_tag_group" : "b_clothing_tag",
sourceId: item.id, sourceId: item.id,
}); });
stopLoading({ type: "success", message: "已删除" }); stopLoading({ type: "success", message: "已删除" });
} catch { } catch {
stopLoading({ type: "error", message: "删除失败" }); stopLoading({ type: "error", message: "删除失败" });
} }
}, }, [deleteConfirm, isLoading, loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading, tagRelations, tags]);
},
]); const getDeleteTitle = () => {
}, if (!deleteConfirm) return "";
[ if (deleteConfirm.kind === "category") return "删除分类";
loadData, if (deleteConfirm.kind === "tagGroup") return "删除标签组";
notifyModuleOperation, return "删除标签";
saveMetadataBundle, };
startLoading,
stopLoading, const getDeleteDesc = () => {
tagRelations, if (!deleteConfirm) return "";
tags, if (deleteConfirm.kind === "category")
], return `确认删除「${deleteConfirm.item.name}」吗?已使用该分类的数据会显示为未分类。`;
); if (deleteConfirm.kind === "tagGroup") {
const count = tags.filter((tag) => tag.tag_group_id === deleteConfirm.item.id).length;
return `确认删除「${deleteConfirm.item.name}」及其下 ${count} 个标签吗?`;
}
return `确认删除「${deleteConfirm.item.name}」吗?对应引用关系也会移除。`;
};
const toggleGroup = useCallback((groupId: number) => {
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId);
else next.add(groupId);
return next;
});
}, []);
const renderCategory = useCallback( const renderCategory = useCallback(
(item: ClothingCategory) => ( (item: ClothingCategory) => (
<Card key={item.id} className="mb-2"> <View
<Card.Body> key={item.id}
<View className="flex-row items-center gap-3"> className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-3 mb-2"
<View className="h-10 w-10 rounded-2xl bg-gray-100 items-center justify-center"> >
<View className="h-9 w-9 rounded-xl bg-gray-100 items-center justify-center">
<FolderIcon size={18} color="#6B7280" /> <FolderIcon size={18} color="#6B7280" />
</View> </View>
<View className="flex-1"> <Text className="flex-1 text-sm font-medium text-gray-900" numberOfLines={1}>
<Card.Title className="text-sm" numberOfLines={1}>
{item.name} {item.name}
</Card.Title> </Text>
<Card.Description className="mt-1">{targetTypeLabel(item.type)}分类</Card.Description> <TouchableOpacity
</View> className="w-9 h-9 items-center justify-center rounded-full bg-gray-100"
<Button variant="secondary" isIconOnly size="sm" onPress={() => openEditCategory(item)}> onPress={() => openEditCategory(item)}
>
<PencilIcon size={15} color="#374151" /> <PencilIcon size={15} color="#374151" />
</Button> </TouchableOpacity>
<Button <TouchableOpacity
variant="danger-soft" className="w-9 h-9 items-center justify-center rounded-full bg-red-50"
isIconOnly
size="sm"
onPress={() => handleDeleteCategory(item)} onPress={() => handleDeleteCategory(item)}
> >
<Trash2Icon size={15} color="#EF4444" /> <Trash2Icon size={15} color="#EF4444" />
</Button> </TouchableOpacity>
</View> </View>
</Card.Body>
</Card>
), ),
[handleDeleteCategory, openEditCategory], [handleDeleteCategory, openEditCategory],
); );
@@ -482,77 +445,94 @@ export default function ClothingMetadataManage() {
const renderTagGroup = useCallback( const renderTagGroup = useCallback(
(group: ClothingTagGroup) => { (group: ClothingTagGroup) => {
const groupTags = tagsByGroupId.get(group.id) ?? []; const groupTags = tagsByGroupId.get(group.id) ?? [];
const collapsed = collapsedGroups.has(group.id);
return ( return (
<Card key={group.id} className="mb-3"> <View key={group.id} className="mb-3">
<Card.Header> {/* Group header — same row style as categories */}
<View className="flex-row items-center gap-3"> <TouchableOpacity
<View className="h-10 w-10 rounded-2xl bg-gray-100 items-center justify-center"> className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-3"
activeOpacity={0.7}
onPress={() => toggleGroup(group.id)}
>
<View className="h-9 w-9 rounded-xl bg-gray-100 items-center justify-center">
<TagIcon size={18} color="#6B7280" /> <TagIcon size={18} color="#6B7280" />
</View> </View>
<View className="flex-1"> <Text className="flex-1 text-sm font-semibold text-gray-900" numberOfLines={1}>
<Text className="text-sm font-semibold text-gray-900" numberOfLines={1}>
{group.name} {group.name}
</Text> </Text>
<Text className="mt-1 text-xs text-gray-500"> <ChevronDownIcon
{targetTypeLabel(group.type)}标签组 · {groupTags.length} 个标签 size={16}
</Text> color="#9CA3AF"
</View> style={{ transform: [{ rotate: collapsed ? "0deg" : "180deg" }] }}
<Button />
variant="secondary"
isIconOnly
size="sm"
onPress={() => openEditTagGroup(group)}
>
<PencilIcon size={15} color="#374151" />
</Button>
<Button
variant="danger-soft"
isIconOnly
size="sm"
onPress={() => handleDeleteTagGroup(group)}
>
<Trash2Icon size={15} color="#EF4444" />
</Button>
</View>
</Card.Header>
<Card.Body className="gap-3">
{groupTags.length > 0 ? (
<View className="flex-row flex-wrap gap-2">
{groupTags.map((tag) => (
<TouchableOpacity <TouchableOpacity
key={tag.id} className="w-8 h-8 items-center justify-center rounded-full bg-gray-100"
className="rounded-full bg-gray-100 px-3 py-2 flex-row items-center gap-2" onPress={(e) => { e.stopPropagation?.(); openEditTagGroup(group); }}
onPress={() => openEditTag(tag)}
> >
<Text className="text-xs text-gray-700" numberOfLines={1}> <PencilIcon size={14} color="#374151" />
{tag.emoji ? `${tag.emoji} ` : ""} </TouchableOpacity>
<TouchableOpacity
className="w-8 h-8 items-center justify-center rounded-full bg-red-50"
onPress={(e) => { e.stopPropagation?.(); handleDeleteTagGroup(group); }}
>
<Trash2Icon size={14} color="#EF4444" />
</TouchableOpacity>
</TouchableOpacity>
{/* Tags — same row style as categories, colored dot instead of icon */}
{!collapsed && (
<View className="mt-2 ml-3">
{groupTags.map((tag) => (
<View
key={tag.id}
className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-2.5 mb-1 ml-9"
>
<View
className="w-3 h-3 rounded-full"
style={{ backgroundColor: tag.color || "#D1D5DB" }}
/>
<Text className="flex-1 text-sm text-gray-900" numberOfLines={1}>
{tag.name} {tag.name}
</Text> </Text>
<TouchableOpacity onPress={() => handleDeleteTag(tag)}> <TouchableOpacity
<Trash2Icon size={13} color="#EF4444" /> className="w-8 h-8 items-center justify-center rounded-full bg-gray-100"
onPress={() => openEditTag(tag)}
>
<PencilIcon size={14} color="#374151" />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
className="w-8 h-8 items-center justify-center rounded-full bg-red-50"
onPress={() => handleDeleteTag(tag)}
>
<Trash2Icon size={14} color="#EF4444" />
</TouchableOpacity> </TouchableOpacity>
))}
</View> </View>
) : ( ))}
<Text className="text-sm text-gray-500">暂无标签</Text> {groupTags.length === 0 && (
<Text className="text-xs text-gray-400 py-2 ml-9">暂无标签</Text>
)} )}
<Button variant="secondary" size="sm" onPress={() => openCreateTag(group.id)}> <TouchableOpacity
<PlusIcon size={16} color="#374151" /> className="flex-row items-center gap-1.5 py-2 ml-9"
<Button.Label>添加标签</Button.Label> onPress={() => openCreateTag(group.id)}
</Button> >
</Card.Body> <PlusIcon size={14} color="#9CA3AF" />
</Card> <Text className="text-xs text-gray-400">添加标签</Text>
</TouchableOpacity>
</View>
)}
</View>
); );
}, },
[ [
collapsedGroups,
handleDeleteTag, handleDeleteTag,
handleDeleteTagGroup, handleDeleteTagGroup,
openCreateTag, openCreateTag,
openEditTag, openEditTag,
openEditTagGroup, openEditTagGroup,
tagsByGroupId, tagsByGroupId,
toggleGroup,
], ],
); );
@@ -571,7 +551,8 @@ export default function ClothingMetadataManage() {
contentContainerStyle={{ paddingBottom: 120 }} contentContainerStyle={{ paddingBottom: 120 }}
> >
<View className="gap-3 mb-4"> <View className="gap-3 mb-4">
<Tabs value={activeType} onValueChange={setActiveType} variant="primary"> <View className="flex-row items-center justify-between">
<Tabs value={activeType} onValueChange={setActiveType} variant="primary" className="flex-1">
<Tabs.List> <Tabs.List>
<Tabs.Indicator /> <Tabs.Indicator />
<Tabs.Trigger value={String(ClothingTargetType.Clothes)} className="flex-1"> <Tabs.Trigger value={String(ClothingTargetType.Clothes)} className="flex-1">
@@ -582,72 +563,54 @@ export default function ClothingMetadataManage() {
</Tabs.Trigger> </Tabs.Trigger>
</Tabs.List> </Tabs.List>
</Tabs> </Tabs>
<TouchableOpacity
<Tabs className="ml-3 w-10 h-10 rounded-full bg-gray-100 items-center justify-center"
value={mode} onPress={handleRefresh}
onValueChange={(value) => setMode(value as ManageMode)}
variant="secondary"
> >
<Tabs.List> <RotateCcwIcon size={18} color="#4B5563" />
<Tabs.Indicator /> </TouchableOpacity>
<Tabs.Trigger value="category" className="flex-1"> </View>
<Tabs.Label>分类</Tabs.Label>
</Tabs.Trigger>
<Tabs.Trigger value="tag" className="flex-1">
<Tabs.Label>标签</Tabs.Label>
</Tabs.Trigger>
</Tabs.List>
</Tabs>
<View className="flex-row gap-2"> <View className="flex-row gap-2">
{mode === "category" ? ( <Button className="flex-1" size="sm" onPress={openCreateCategory}>
<Button className="flex-1" onPress={openCreateCategory}> <PlusIcon size={16} color="#fff" />
<PlusIcon size={18} color="#fff" /> <Button.Label>分类</Button.Label>
<Button.Label>新增分类</Button.Label>
</Button> </Button>
) : ( <Button className="flex-1" size="sm" onPress={openCreateTagGroup}>
<> <PlusIcon size={16} color="#fff" />
<Button className="flex-1" onPress={openCreateTagGroup}> <Button.Label>标签组</Button.Label>
<PlusIcon size={18} color="#fff" />
<Button.Label>新增标签组</Button.Label>
</Button> </Button>
<Button <Button variant="secondary" size="sm" className="flex-1" onPress={() => openCreateTag()} isDisabled={currentTagGroups.length === 0}>
variant="secondary" <PlusIcon size={16} color="#374151" />
className="flex-1" <Button.Label>标签</Button.Label>
onPress={() => openCreateTag()}
isDisabled={currentTagGroups.length === 0}
>
<PlusIcon size={18} color="#374151" />
<Button.Label>新增标签</Button.Label>
</Button>
</>
)}
<Button variant="secondary" isIconOnly onPress={handleRefresh}>
<RotateCcwIcon size={18} color="#374151" />
</Button> </Button>
</View> </View>
</View> </View>
{mode === "category" ? ( {/* 分类 */}
currentCategories.length > 0 ? ( <Text className="text-xs font-semibold text-gray-400 mb-2 ml-1">分类</Text>
{currentCategories.length > 0 ? (
currentCategories.map(renderCategory) currentCategories.map(renderCategory)
) : ( ) : (
<EmptyState <View className="py-6 items-center">
type={loading ? "loading" : "empty"} <Text className="text-xs text-gray-400">暂无分类</Text>
title={loading ? "正在加载分类" : "暂无分类"} </View>
description="分类保持单层,可以按衣服和搭配分别维护" )}
center="content"
/> {/* 标签 */}
) <View className="mt-5 mb-2">
) : currentTagGroups.length > 0 ? ( <Text className="text-xs font-semibold text-gray-400 ml-1">标签</Text>
</View>
{currentTagGroups.length > 0 ? (
currentTagGroups.map(renderTagGroup) currentTagGroups.map(renderTagGroup)
) : ( ) : (
<EmptyState <View className="py-6 items-center">
type={loading ? "loading" : "empty"} <Text className="text-xs text-gray-400">暂无标签组</Text>
title={loading ? "正在加载标签" : "暂无标签组"} </View>
description="先创建标签组,再在标签组下创建标签" )}
center="content"
/> {loading && currentCategories.length === 0 && currentTagGroups.length === 0 && (
<EmptyState type="loading" title="加载中..." center="content" />
)} )}
</LayoutView.ScrollView> </LayoutView.ScrollView>
@@ -662,10 +625,10 @@ export default function ClothingMetadataManage() {
<BottomSheet.Content animateOnMount={false} className="gap-4"> <BottomSheet.Content animateOnMount={false} className="gap-4">
{form && ( {form && (
<> <>
<Text className="text-lg font-semibold text-gray-900">{renderFormTitle()}</Text> <Text className="text-lg font-semibold text-gray-900 text-center">{renderFormTitle()}</Text>
<TextField isRequired> <TextField isRequired>
<Label>名称</Label> <Label className="mb-2">名称</Label>
<BottomSheet.Input <BottomSheet.Input
value={form.name} value={form.name}
onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))} onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))}
@@ -675,22 +638,49 @@ export default function ClothingMetadataManage() {
{form.kind === "tag" && ( {form.kind === "tag" && (
<> <>
<TextField> <View>
<Label>表情</Label> <Label className="mb-2">标签颜色</Label>
<BottomSheet.Input <View className="flex-row flex-wrap gap-2">
value={form.emoji} <TouchableOpacity
maxLength={4} className={`w-9 h-9 rounded-full border-2 items-center justify-center ${
onChangeText={(emoji) => !form.color ? "border-gray-900" : "border-gray-200"
}`}
onPress={() =>
setForm((prev) => setForm((prev) =>
prev && prev.kind === "tag" ? { ...prev, emoji } : prev, prev && prev.kind === "tag" ? { ...prev, color: "" } : prev,
) )
} }
placeholder="👕" >
/> <View className="w-3 h-px bg-gray-400 rotate-45 absolute" />
</TextField> </TouchableOpacity>
{TAG_COLORS.map((c) => {
const selected = form.color === c;
return (
<TouchableOpacity
key={c}
className={`w-9 h-9 rounded-full items-center justify-center ${
selected ? "border-2 border-gray-900" : ""
}`}
style={{ backgroundColor: c }}
onPress={() =>
setForm((prev) =>
prev && prev.kind === "tag"
? { ...prev, color: c }
: prev,
)
}
>
{selected && (
<CheckIcon size={16} color="#fff" />
)}
</TouchableOpacity>
);
})}
</View>
</View>
<View> <View>
<Label>所属标签组</Label> <Label className="mb-2">所属标签组</Label>
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
{currentTagGroups.map((group) => { {currentTagGroups.map((group) => {
const selected = form.tagGroupId === group.id; const selected = form.tagGroupId === group.id;
@@ -724,7 +714,7 @@ export default function ClothingMetadataManage() {
)} )}
<View> <View>
<Label>类型</Label> <Label className="mb-2">类型</Label>
<View className="h-11 rounded-xl bg-gray-100 px-3 justify-center"> <View className="h-11 rounded-xl bg-gray-100 px-3 justify-center">
<Text className="text-sm text-gray-700">{targetTypeLabel(form.type)}</Text> <Text className="text-sm text-gray-700">{targetTypeLabel(form.type)}</Text>
</View> </View>
@@ -749,6 +739,24 @@ export default function ClothingMetadataManage() {
</BottomSheet.Portal> </BottomSheet.Portal>
</BottomSheet> </BottomSheet>
<Dialog isOpen={!!deleteConfirm} onOpenChange={(v) => { if (!v) setDeleteConfirm(null); }}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>{getDeleteTitle()}</Dialog.Title>
<Dialog.Description>{getDeleteDesc()}</Dialog.Description>
<View className="flex-row gap-3 mt-4">
<Button variant="tertiary" className="flex-1" onPress={() => setDeleteConfirm(null)}>
<Button.Label>取消</Button.Label>
</Button>
<Button variant="danger" className="flex-1" onPress={handleConfirmDelete}>
<Button.Label>删除</Button.Label>
</Button>
</View>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
<LoadingOverlay visible={isLoading} /> <LoadingOverlay visible={isLoading} />
</LayoutView> </LayoutView>
); );
@@ -1,26 +1,57 @@
import { useCallback, useEffect, useMemo } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { TouchableOpacity, View } from "react-native"; import { Modal, Pressable, Text, TouchableOpacity, View } from "react-native";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { Button, Card } from "heroui-native"; import { Button, Card, Dialog } from "heroui-native";
import { PencilIcon, PlusIcon, RotateCcwIcon, SparklesIcon } from "lucide-react-native"; import {
FileTextIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
SparklesIcon,
Trash2Icon,
} from "lucide-react-native";
import LayoutView from "@/components/layout/view"; import LayoutView from "@/components/layout/view";
import EmptyState from "@/components/EmptyStatus"; import EmptyState from "@/components/EmptyStatus";
import ClothingImage from "@/components/clothing/ClothingImage"; import ClothingImage from "@/components/clothing/ClothingImage";
import { useListLoading } from "@/hooks/useLoading"; import { useListLoading, useLoading } from "@/hooks/useLoading";
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useClothingStore, useFamilyStore } from "@/store"; import { useClothingStore, useFamilyStore } from "@/store";
import { ClothingOutfit } from "@/types/clothing"; import { ClothingOutfit, ClothingStatus, ClothingTargetType } from "@/types/clothing";
export default function ClothingOutfitList() { export default function ClothingOutfitList() {
const familyId = useFamilyStore((s) => s.getFamilyId()); const familyId = useFamilyStore((s) => s.getFamilyId());
const outfits = useClothingStore((s) => s.outfits); const outfits = useClothingStore((s) => s.outfits);
const clothes = useClothingStore((s) => s.clothes); const clothes = useClothingStore((s) => s.clothes);
const outfitClothes = useClothingStore((s) => s.outfitClothes); const outfitClothes = useClothingStore((s) => s.outfitClothes);
const categories = useClothingStore((s) => s.categories);
const tags = useClothingStore((s) => s.tags);
const tagRelations = useClothingStore((s) => s.tagRelations);
const fetchOutfits = useClothingStore((s) => s.fetchOutfits); const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
const fetchClothes = useClothingStore((s) => s.fetchClothes);
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes); const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
const fetchCategories = useClothingStore((s) => s.fetchCategories);
const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchClothes = useClothingStore((s) => s.fetchClothes);
const saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
const { loading, refreshing, runInitial, runRefresh } = useListLoading(); const { loading, refreshing, runInitial, runRefresh } = useListLoading();
const { isLoading, startLoading, stopLoading } = useLoading();
const notifyModuleOperation = useModuleChatNotifier();
const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]); const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]);
const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
const outfitTagIds = useMemo(() => {
const map = new Map<number, number[]>();
tagRelations
.filter((r) => r.target_type === ClothingTargetType.Outfit)
.forEach((r) => {
const ids = map.get(r.target_id) ?? [];
ids.push(r.tag_id);
map.set(r.target_id, ids);
});
return map;
}, [tagRelations]);
const tagsById = useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!familyId) return; if (!familyId) return;
@@ -28,8 +59,11 @@ export default function ClothingOutfitList() {
fetchOutfits(familyId), fetchOutfits(familyId),
fetchClothes(familyId), fetchClothes(familyId),
fetchOutfitClothes(familyId), fetchOutfitClothes(familyId),
fetchCategories(familyId, ClothingTargetType.Outfit),
fetchTags(familyId, ClothingTargetType.Outfit),
fetchTagRelations(familyId, ClothingTargetType.Outfit),
]); ]);
}, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits]); }, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchCategories, fetchTags, fetchTagRelations]);
useEffect(() => { useEffect(() => {
if (!familyId) return; if (!familyId) return;
@@ -47,6 +81,48 @@ export default function ClothingOutfitList() {
runRefresh(loadData); runRefresh(loadData);
}, [loadData, runRefresh]); }, [loadData, runRefresh]);
const [activeItem, setActiveItem] = useState<ClothingOutfit | null>(null);
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
const [deleteConfirm, setDeleteConfirm] = useState<ClothingOutfit | null>(null);
const handleDeleteRequest = useCallback(() => {
if (!activeItem) return;
setDeleteConfirm(activeItem);
setActiveItem(null);
}, [activeItem]);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteConfirm || !familyId || isLoading) return;
const outfit = deleteConfirm;
setDeleteConfirm(null);
startLoading("删除中...");
try {
const state = useClothingStore.getState();
const details = state.outfitClothes.filter((row) => row.outfit_id === outfit.id);
const relationDeletes = state.tagRelations
.filter((row) => row.target_type === ClothingTargetType.Outfit && row.target_id === outfit.id)
.map((row) => ({ id: row.id }));
const ok = await saveOutfitBundle({
outfitDeletes: [{ id: outfit.id }],
clothesDeletes: details.map((row) => ({ id: row.id })),
tagRelationDeletes: relationDeletes,
});
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,
});
stopLoading({ type: "success", message: "已删除" });
} catch {
stopLoading({ type: "error", message: "删除失败" });
}
}, [deleteConfirm, familyId, isLoading, loadData, notifyModuleOperation, saveOutfitBundle, startLoading, stopLoading]);
const getCoverKey = useCallback( const getCoverKey = useCallback(
(item: ClothingOutfit) => { (item: ClothingOutfit) => {
if (item.cover_file_key) return item.cover_file_key; if (item.cover_file_key) return item.cover_file_key;
@@ -81,34 +157,53 @@ export default function ClothingOutfitList() {
); );
const renderItem = useCallback( const renderItem = useCallback(
({ item }: { item: ClothingOutfit }) => ( ({ item }: { item: ClothingOutfit }) => {
const categoryName = item.category_id ? categoriesById.get(item.category_id)?.name : null;
const itemTagIds = outfitTagIds.get(item.id) ?? [];
const itemTags = itemTagIds.map((tid) => tagsById.get(tid)).filter(Boolean);
return (
<TouchableOpacity <TouchableOpacity
className="mb-3 flex-1" className="mb-3 flex-1"
activeOpacity={0.82} activeOpacity={0.82}
delayLongPress={260}
onPress={() => router.push(`/clothes/outfit/detail/${item.id}` as any)} onPress={() => router.push(`/clothes/outfit/detail/${item.id}` as any)}
onLongPress={(e) => {
setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
setActiveItem(item);
}}
> >
<Card className="overflow-hidden p-0"> <Card className="overflow-hidden p-0">
<ClothingImage fileKey={getCoverKey(item)} label={item.name} className="h-40 w-full" /> <View className="w-full bg-gray-50" style={{ aspectRatio: 1 }}>
<Card.Body className="p-3"> <ClothingImage fileKey={getCoverKey(item)} label={item.name} className="w-full h-full" resizeMode="contain" />
<View className="flex-row items-start justify-between gap-2"> </View>
<View className="flex-1"> <Card.Body className="px-3 pt-3 pb-2">
<Card.Title className="text-sm" numberOfLines={1}> <Card.Title className="text-sm" numberOfLines={1}>
{item.name} {item.name}
</Card.Title> </Card.Title>
<Card.Description className="mt-1">{getClothesCount(item)} 件衣服</Card.Description> <Card.Description className="mt-0.5">
</View> {getClothesCount(item)} 件衣服
<TouchableOpacity {categoryName ? ` · ${categoryName}` : ""}
className="h-8 w-8 rounded-full bg-gray-100 items-center justify-center" </Card.Description>
onPress={() => router.push(`/clothes/outfit/edit?id=${item.id}` as any)} {itemTags.length > 0 && (
<View className="flex-row gap-1 mt-1.5">
{itemTags.slice(0, 2).map((tag) => (
<View
key={tag!.id}
className="rounded-full px-2 py-0.5"
style={{ backgroundColor: tag!.color || "#F3F4F6" }}
> >
<PencilIcon size={15} color="#4B5563" /> <Text className="text-[10px]" style={{ color: tag!.color ? "#fff" : "#6B7280" }}>{tag!.name}</Text>
</TouchableOpacity>
</View> </View>
))}
</View>
)}
</Card.Body> </Card.Body>
</Card> </Card>
</TouchableOpacity> </TouchableOpacity>
), );
[getClothesCount, getCoverKey], },
[categoriesById, getClothesCount, getCoverKey, outfitTagIds, tagsById],
); );
return ( return (
@@ -144,6 +239,78 @@ export default function ClothingOutfitList() {
/> />
)} )}
/> />
<Modal
visible={!!activeItem}
transparent
animationType="none"
onRequestClose={() => setActiveItem(null)}
>
<Pressable className="flex-1" onPress={() => setActiveItem(null)}>
<View
className="absolute bg-white rounded-xl w-[200px] overflow-hidden shadow-lg"
style={{
left: Math.min(menuPos.x - 190, menuPos.x - 10),
top: menuPos.y - 100,
}}
>
<TouchableOpacity
className="flex-row items-center gap-3 px-4 py-3 border-b border-gray-100"
activeOpacity={0.6}
onPress={() => {
const item = activeItem;
setActiveItem(null);
if (item) router.push(`/clothes/outfit/edit?id=${item.id}` as any);
}}
>
<PencilIcon size={18} color="#374151" />
<Text className="text-[15px] text-gray-800">编辑</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center gap-3 px-4 py-3 border-b border-gray-100"
activeOpacity={0.6}
onPress={handleDeleteRequest}
>
<Trash2Icon size={18} color="#EF4444" />
<Text className="text-[15px] text-red-500">删除</Text>
</TouchableOpacity>
<TouchableOpacity
className="flex-row items-center gap-3 px-4 py-3"
activeOpacity={0.6}
onPress={() => {
const item = activeItem;
setActiveItem(null);
if (item) router.push(`/clothes/outfit/detail/${item.id}` as any);
}}
>
<FileTextIcon size={18} color="#374151" />
<Text className="text-[15px] text-gray-800">详细</Text>
</TouchableOpacity>
</View>
</Pressable>
</Modal>
<Dialog isOpen={!!deleteConfirm} onOpenChange={(v) => { if (!v) setDeleteConfirm(null); }}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>删除搭配</Dialog.Title>
<Dialog.Description>
确认删除「{deleteConfirm?.name}」吗?衣服本身不会被删除。
</Dialog.Description>
<View className="flex-row gap-3 mt-4">
<Button variant="tertiary" className="flex-1" onPress={() => setDeleteConfirm(null)}>
<Button.Label>取消</Button.Label>
</Button>
<Button variant="danger" className="flex-1" onPress={handleDeleteConfirm}>
<Button.Label>删除</Button.Label>
</Button>
</View>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
<LoadingOverlay visible={isLoading} />
</LayoutView> </LayoutView>
); );
} }
+21 -300
View File
@@ -2,15 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Text, TouchableOpacity, View } from "react-native"; import { Text, TouchableOpacity, View } from "react-native";
import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import ImagePicker from "react-native-image-crop-picker"; import ImagePicker from "react-native-image-crop-picker";
import { Button, Card, Input, Label, TextArea, TextField } from "heroui-native"; import { Button, Input, Label, TextArea, TextField } from "heroui-native";
import { import {
CheckIcon, CheckIcon,
ImageIcon, ImageIcon,
PlusIcon,
SaveIcon, SaveIcon,
ShirtIcon,
TagIcon,
XIcon,
} from "lucide-react-native"; } from "lucide-react-native";
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import PageLayout from "@/layouts/PageLayout"; import PageLayout from "@/layouts/PageLayout";
@@ -79,9 +75,6 @@ export default function ClothingEdit() {
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups); const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
const fetchTags = useClothingStore((s) => s.fetchTags); const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations); const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
const saveCategoryChanges = useClothingStore((s) => s.saveCategoryChanges);
const saveTagGroupChanges = useClothingStore((s) => s.saveTagGroupChanges);
const saveTagChanges = useClothingStore((s) => s.saveTagChanges);
const saveClothesBundle = useClothingStore((s) => s.saveClothesBundle); const saveClothesBundle = useClothingStore((s) => s.saveClothesBundle);
const uploadClothesImage = useClothingStore((s) => s.uploadClothesImage); const uploadClothesImage = useClothingStore((s) => s.uploadClothesImage);
const { isLoading, startLoading, stopLoading } = useLoading(); const { isLoading, startLoading, stopLoading } = useLoading();
@@ -89,10 +82,6 @@ export default function ClothingEdit() {
const [form, setForm] = useState<ClothesFormState>(emptyForm); const [form, setForm] = useState<ClothesFormState>(emptyForm);
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]); const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [newCategoryName, setNewCategoryName] = useState("");
const [newTagGroupName, setNewTagGroupName] = useState("");
const [newTagName, setNewTagName] = useState("");
const [newTagEmoji, setNewTagEmoji] = useState("");
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
@@ -216,183 +205,6 @@ export default function ClothingEdit() {
} }
}, [familyId, uploadClothesImage, uploading]); }, [familyId, uploadClothesImage, uploading]);
const handleCreateCategory = useCallback(async () => {
const name = newCategoryName.trim();
if (!familyId || !name) {
toast.error("请输入分类名称");
return;
}
const existing = clothesCategories.find((item) => item.name === name);
if (existing) {
setForm((prev) => ({ ...prev, categoryId: existing.id }));
setNewCategoryName("");
return;
}
const id = (await getUniqueIdApi(1)).data?.[0];
if (!id) {
toast.error("获取分类 ID 失败");
return;
}
const ok = await saveCategoryChanges({
inserts: [
{
id,
family_id: familyId,
name,
type: ClothingTargetType.Clothes,
sort_number: clothesCategories.length,
status: ClothingStatus.Enabled,
},
],
});
if (!ok) {
toast.error("分类创建失败");
return;
}
await fetchCategories(familyId, ClothingTargetType.Clothes);
setForm((prev) => ({ ...prev, categoryId: id }));
setNewCategoryName("");
notifyModuleOperation({
module: "衣服搭配",
action: "新增衣服分类",
target: name,
sourceTable: "b_clothing_category",
sourceId: id,
});
toast.success("分类已创建");
}, [
clothesCategories,
familyId,
fetchCategories,
newCategoryName,
notifyModuleOperation,
saveCategoryChanges,
]);
const handleCreateTag = useCallback(async () => {
const groupName = newTagGroupName.trim();
const tagName = newTagName.trim();
const emoji = newTagEmoji.trim();
if (!familyId || !groupName || !tagName) {
toast.error("请输入标签组和标签名称");
return;
}
let group = clothesTagGroups.find((item) => item.name === groupName);
if (!group) {
const groupId = (await getUniqueIdApi(1)).data?.[0];
if (!groupId) {
toast.error("获取标签组 ID 失败");
return;
}
const ok = await saveTagGroupChanges({
inserts: [
{
id: groupId,
family_id: familyId,
name: groupName,
type: ClothingTargetType.Clothes,
sort_number: clothesTagGroups.length,
status: ClothingStatus.Enabled,
},
],
});
if (!ok) {
toast.error("标签组创建失败");
return;
}
group = {
id: groupId,
family_id: familyId,
name: groupName,
type: ClothingTargetType.Clothes,
sort_number: clothesTagGroups.length,
status: ClothingStatus.Enabled,
};
}
const existingTag = clothesTags.find(
(item) => item.tag_group_id === group?.id && item.name === tagName,
);
if (existingTag) {
setSelectedTagIds((prev) =>
prev.includes(existingTag.id) ? prev : [...prev, existingTag.id],
);
setNewTagName("");
setNewTagEmoji("");
await Promise.all([
fetchTagGroups(familyId, ClothingTargetType.Clothes),
fetchTags(familyId, ClothingTargetType.Clothes),
]);
return;
}
const tagId = (await getUniqueIdApi(1)).data?.[0];
if (!tagId) {
toast.error("获取标签 ID 失败");
return;
}
const groupTagCount = clothesTags.filter((item) => item.tag_group_id === group?.id).length;
const ok = await saveTagChanges({
inserts: [
{
id: tagId,
family_id: familyId,
tag_group_id: group.id,
name: tagName,
type: ClothingTargetType.Clothes,
emoji,
sort_number: groupTagCount,
status: ClothingStatus.Enabled,
},
],
});
if (!ok) {
toast.error("标签创建失败");
return;
}
await Promise.all([
fetchTagGroups(familyId, ClothingTargetType.Clothes),
fetchTags(familyId, ClothingTargetType.Clothes),
]);
setSelectedTagIds((prev) => [...prev, tagId]);
setNewTagName("");
setNewTagEmoji("");
notifyModuleOperation({
module: "衣服搭配",
action: "新增衣服标签",
target: tagName,
detail: group.name,
sourceTable: "b_clothing_tag",
sourceId: tagId,
});
toast.success("标签已创建");
}, [
clothesTagGroups,
clothesTags,
familyId,
fetchTagGroups,
fetchTags,
newTagEmoji,
newTagGroupName,
newTagName,
notifyModuleOperation,
saveTagChanges,
saveTagGroupChanges,
]);
const handleSave = useCallback(async () => { const handleSave = useCallback(async () => {
const name = form.name.trim(); const name = form.name.trim();
if (!name) { if (!name) {
@@ -537,7 +349,6 @@ export default function ClothingEdit() {
onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))} onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))}
> >
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}> <Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
{item.emoji ? `${item.emoji} ` : ""}
{item.name} {item.name}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -563,13 +374,12 @@ export default function ClothingEdit() {
return ( return (
<PageLayout title={isEdit ? "编辑衣服" : "新增衣服"}> <PageLayout title={isEdit ? "编辑衣服" : "新增衣服"}>
<Stack.Screen options={{ animation: "slide_from_right" }} /> <Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView> <LayoutView>
<LayoutView.ScrollView bottomOffset={40}> <LayoutView.ScrollView bottomOffset={40}>
<Card className="mb-4"> {/* 图片 */}
<Card.Body>
<TouchableOpacity <TouchableOpacity
className="h-72 overflow-hidden rounded-2xl bg-gray-100" className="h-72 overflow-hidden rounded-2xl bg-gray-100 mb-4"
activeOpacity={0.82} activeOpacity={0.82}
onPress={handlePickImage} onPress={handlePickImage}
disabled={uploading} disabled={uploading}
@@ -587,20 +397,12 @@ export default function ClothingEdit() {
</View> </View>
)} )}
</TouchableOpacity> </TouchableOpacity>
<Text className="mt-2 text-xs text-gray-500">图片必填,上传时会裁剪为统一方图。</Text>
</Card.Body>
</Card>
<Card className="mb-4"> {/* 基础信息 */}
<Card.Header> <Text className="text-base font-semibold text-gray-900 mb-3 ml-1">基础信息</Text>
<View className="flex-row items-center gap-2"> <View className="gap-4 mb-4">
<ShirtIcon size={18} color="#374151" />
<Text className="text-base font-semibold text-gray-900">基础信息</Text>
</View>
</Card.Header>
<Card.Body className="gap-4">
<TextField isRequired> <TextField isRequired>
<Label>名称</Label> <Label className="mb-2">名称</Label>
<Input <Input
value={form.name} value={form.name}
onChangeText={(name) => setForm((prev) => ({ ...prev, name }))} onChangeText={(name) => setForm((prev) => ({ ...prev, name }))}
@@ -609,7 +411,7 @@ export default function ClothingEdit() {
</TextField> </TextField>
<TextField> <TextField>
<Label>品牌</Label> <Label className="mb-2">品牌</Label>
<Input <Input
value={form.brand} value={form.brand}
onChangeText={(brand) => setForm((prev) => ({ ...prev, brand }))} onChangeText={(brand) => setForm((prev) => ({ ...prev, brand }))}
@@ -618,22 +420,18 @@ export default function ClothingEdit() {
</TextField> </TextField>
<TextField> <TextField>
<Label>备注</Label> <Label className="mb-2">备注</Label>
<TextArea <TextArea
value={form.remark} value={form.remark}
onChangeText={(remark) => setForm((prev) => ({ ...prev, remark }))} onChangeText={(remark) => setForm((prev) => ({ ...prev, remark }))}
placeholder="尺码、材质、购买信息等" placeholder="尺码、材质、购买信息等"
/> />
</TextField> </TextField>
</Card.Body> </View>
</Card>
<Card className="mb-4"> {/* 分类 */}
<Card.Header> <Text className="text-base font-semibold text-gray-900 mb-3 ml-1">分类</Text>
<Text className="text-base font-semibold text-gray-900">分类</Text> <View className="flex-row flex-wrap gap-2 mb-4">
</Card.Header>
<Card.Body className="gap-4">
<View className="flex-row flex-wrap gap-2">
<TouchableOpacity <TouchableOpacity
className={`rounded-full border px-3 py-2 ${ className={`rounded-full border px-3 py-2 ${
form.categoryId == null form.categoryId == null
@@ -651,93 +449,16 @@ export default function ClothingEdit() {
{clothesCategories.map(renderCategoryChip)} {clothesCategories.map(renderCategoryChip)}
</View> </View>
<View className="flex-row gap-2"> {/* 标签 */}
<View className="flex-1"> {clothesTagGroups.length > 0 && (
<TextField> <>
<Label>新建分类</Label> <Text className="text-base font-semibold text-gray-900 mb-3 ml-1">标签</Text>
<Input <View className="gap-4 mb-4">
value={newCategoryName} {clothesTagGroups.map(renderTagGroup)}
onChangeText={setNewCategoryName}
placeholder="例如:外套"
/>
</TextField>
</View> </View>
<Button className="self-end" isIconOnly onPress={handleCreateCategory}> </>
<PlusIcon size={18} color="#fff" />
</Button>
</View>
</Card.Body>
</Card>
<Card className="mb-4">
<Card.Header>
<View className="flex-row items-center gap-2">
<TagIcon size={18} color="#374151" />
<Text className="text-base font-semibold text-gray-900">标签</Text>
</View>
</Card.Header>
<Card.Body className="gap-4">
{clothesTagGroups.length > 0 ? (
clothesTagGroups.map(renderTagGroup)
) : (
<Text className="text-sm text-gray-500">暂无标签,先在下方新建。</Text>
)} )}
<View className="rounded-2xl bg-gray-50 p-3 gap-3">
<View className="flex-row gap-2">
<View className="w-16">
<TextField>
<Label>表情</Label>
<Input
value={newTagEmoji}
onChangeText={setNewTagEmoji}
placeholder="👕"
maxLength={4}
/>
</TextField>
</View>
<View className="flex-1">
<TextField>
<Label>标签组</Label>
<Input
value={newTagGroupName}
onChangeText={setNewTagGroupName}
placeholder="例如:季节"
/>
</TextField>
</View>
</View>
<View className="flex-row gap-2">
<View className="flex-1">
<TextField>
<Label>标签</Label>
<Input
value={newTagName}
onChangeText={setNewTagName}
placeholder="例如:春秋"
/>
</TextField>
</View>
<Button className="self-end" onPress={handleCreateTag}>
<PlusIcon size={18} color="#fff" />
<Button.Label>添加</Button.Label>
</Button>
</View>
</View>
{selectedTagIds.length > 0 && (
<TouchableOpacity
className="self-start rounded-full bg-gray-100 px-3 py-2 flex-row items-center gap-1"
onPress={() => setSelectedTagIds([])}
>
<XIcon size={14} color="#6B7280" />
<Text className="text-xs text-gray-600">清空已选标签</Text>
</TouchableOpacity>
)}
</Card.Body>
</Card>
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<Button variant="secondary" className="flex-1" onPress={() => router.back()}> <Button variant="secondary" className="flex-1" onPress={() => router.back()}>
<Button.Label>取消</Button.Label> <Button.Label>取消</Button.Label>
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { Alert, Text, TouchableOpacity, View } from "react-native"; import { Text, TouchableOpacity, View } from "react-native";
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router"; import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
import { Button, Card } from "heroui-native"; import { Button, Card, Dialog } from "heroui-native";
import { import {
FolderIcon, FolderIcon,
PencilIcon, PencilIcon,
@@ -44,6 +44,7 @@ export default function ClothingOutfitDetail() {
const { isLoading, startLoading, stopLoading } = useLoading(); const { isLoading, startLoading, stopLoading } = useLoading();
const notifyModuleOperation = useModuleChatNotifier(); const notifyModuleOperation = useModuleChatNotifier();
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const outfit = useMemo( const outfit = useMemo(
() => outfits.find((item) => item.id === outfitId) ?? null, () => outfits.find((item) => item.id === outfitId) ?? null,
@@ -117,15 +118,14 @@ export default function ClothingOutfitDetail() {
}, [familyId, loadData, outfitId]), }, [familyId, loadData, outfitId]),
); );
const handleDelete = useCallback(() => { const handleDeleteRequest = useCallback(() => {
if (!familyId || !outfit || isLoading) return; if (!familyId || !outfit || isLoading) return;
setShowDeleteDialog(true);
}, [familyId, outfit, isLoading]);
Alert.alert("删除搭配", `确认删除「${outfit.name}」吗?衣服本身不会被删除。`, [ const handleDeleteConfirm = useCallback(async () => {
{ text: "取消", style: "cancel" }, if (!familyId || !outfit || isLoading) return;
{ setShowDeleteDialog(false);
text: "删除",
style: "destructive",
onPress: async () => {
startLoading("删除中..."); startLoading("删除中...");
try { try {
const state = useClothingStore.getState(); const state = useClothingStore.getState();
@@ -175,9 +175,6 @@ export default function ClothingOutfitDetail() {
} catch { } catch {
stopLoading({ type: "error", message: "删除失败" }); stopLoading({ type: "error", message: "删除失败" });
} }
},
},
]);
}, [ }, [
familyId, familyId,
fetchOutfitClothes, fetchOutfitClothes,
@@ -227,7 +224,7 @@ export default function ClothingOutfitDetail() {
return ( return (
<PageLayout title="搭配详情"> <PageLayout title="搭配详情">
<Stack.Screen options={{ animation: "slide_from_right" }} /> <Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView> <LayoutView>
<LayoutView.ScrollView <LayoutView.ScrollView
refreshing={pageLoading} refreshing={pageLoading}
@@ -283,9 +280,15 @@ export default function ClothingOutfitDetail() {
{selectedTags.map((tag) => { {selectedTags.map((tag) => {
const group = tagGroups.find((item) => item.id === tag.tag_group_id); const group = tagGroups.find((item) => item.id === tag.tag_group_id);
return ( return (
<View key={tag.id} className="rounded-full bg-gray-100 px-3 py-2"> <View
<Text className="text-xs text-gray-700"> key={tag.id}
{tag.emoji ? `${tag.emoji} ` : ""} className="rounded-full px-3 py-2"
style={{ backgroundColor: tag.color || "#F3F4F6" }}
>
<Text
className="text-xs"
style={{ color: tag.color ? "#fff" : "#374151" }}
>
{group ? `${group.name}/` : ""} {group ? `${group.name}/` : ""}
{tag.name} {tag.name}
</Text> </Text>
@@ -326,7 +329,7 @@ export default function ClothingOutfitDetail() {
<Button <Button
variant="danger-soft" variant="danger-soft"
className="flex-1" className="flex-1"
onPress={handleDelete} onPress={handleDeleteRequest}
isDisabled={isLoading} isDisabled={isLoading}
> >
<Trash2Icon size={18} color="#EF4444" /> <Trash2Icon size={18} color="#EF4444" />
@@ -339,6 +342,26 @@ export default function ClothingOutfitDetail() {
</LayoutView.ScrollView> </LayoutView.ScrollView>
</LayoutView> </LayoutView>
<Dialog isOpen={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>
<Dialog.Title>删除搭配</Dialog.Title>
<Dialog.Description>
确认删除「{outfit?.name}」吗?衣服本身不会被删除。
</Dialog.Description>
<View className="flex-row gap-3 mt-4">
<Button variant="tertiary" className="flex-1" onPress={() => setShowDeleteDialog(false)}>
<Button.Label>取消</Button.Label>
</Button>
<Button variant="danger" className="flex-1" onPress={handleDeleteConfirm}>
<Button.Label>删除</Button.Label>
</Button>
</View>
</Dialog.Content>
</Dialog.Portal>
</Dialog>
<LoadingOverlay visible={isLoading || pageLoading} /> <LoadingOverlay visible={isLoading || pageLoading} />
</PageLayout> </PageLayout>
); );
@@ -6,6 +6,8 @@ import { Button, Card, Input, Label, TextArea, TextField } from "heroui-native";
import { import {
BringToFrontIcon, BringToFrontIcon,
ImageIcon, ImageIcon,
Maximize2Icon,
Minimize2Icon,
MinusIcon, MinusIcon,
PlusIcon, PlusIcon,
SaveIcon, SaveIcon,
@@ -16,6 +18,7 @@ import {
import { toast } from "sonner-native"; import { toast } from "sonner-native";
import PageLayout from "@/layouts/PageLayout"; import PageLayout from "@/layouts/PageLayout";
import LayoutView from "@/components/layout/view"; import LayoutView from "@/components/layout/view";
import BottomSheet from "@/components/BottomSheet";
import ClothingImage from "@/components/clothing/ClothingImage"; import ClothingImage from "@/components/clothing/ClothingImage";
import { LoadingOverlay } from "@/components/LoadingOverlay"; import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useLoading } from "@/hooks/useLoading"; import { useLoading } from "@/hooks/useLoading";
@@ -88,29 +91,29 @@ function DraggableCanvasItem({
onMove: (uid: string, x: number, y: number) => void; onMove: (uid: string, x: number, y: number) => void;
}) { }) {
const startRef = useRef({ x: item.x, y: item.y }); const startRef = useRef({ x: item.x, y: item.y });
const itemRef = useRef(item);
const sizeRef = useRef(canvasSize);
itemRef.current = item;
sizeRef.current = canvasSize;
const panResponder = useMemo( const panResponder = useRef(
() =>
PanResponder.create({ PanResponder.create({
onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: () => { onPanResponderGrant: () => {
startRef.current = { x: item.x, y: item.y }; startRef.current = { x: itemRef.current.x, y: itemRef.current.y };
onSelect(item.uid); onSelect(itemRef.current.uid);
}, },
onPanResponderMove: (_, gestureState) => { onPanResponderMove: (_, gestureState) => {
if (!canvasSize) return; const sz = sizeRef.current;
const nextX = clamp(startRef.current.x + gestureState.dx / canvasSize, 0, 1 - item.width); const it = itemRef.current;
const nextY = clamp( if (!sz) return;
startRef.current.y + gestureState.dy / canvasSize, const nextX = clamp(startRef.current.x + gestureState.dx / sz, 0, 1 - it.width);
0, const nextY = clamp(startRef.current.y + gestureState.dy / sz, 0, 1 - it.height);
1 - item.height, onMove(it.uid, normalizeNumber(nextX), normalizeNumber(nextY));
);
onMove(item.uid, normalizeNumber(nextX), normalizeNumber(nextY));
}, },
}), }),
[canvasSize, item.height, item.uid, item.width, item.x, item.y, onMove, onSelect], ).current;
);
if (!canvasSize) return null; if (!canvasSize) return null;
@@ -170,6 +173,8 @@ export default function ClothingOutfitEdit() {
const [selectedUid, setSelectedUid] = useState<string | null>(null); const [selectedUid, setSelectedUid] = useState<string | null>(null);
const [pageLoading, setPageLoading] = useState(true); const [pageLoading, setPageLoading] = useState(true);
const [capturingCover, setCapturingCover] = useState(false); const [capturingCover, setCapturingCover] = useState(false);
const [showClothesSheet, setShowClothesSheet] = useState(false);
const [canvasFullscreen, setCanvasFullscreen] = useState(false);
const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]); const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]);
const outfitCategories = useMemo( const outfitCategories = useMemo(
@@ -342,16 +347,14 @@ export default function ClothingOutfitEdit() {
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve())); await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
const uri = await captureRef(canvasRef, { const uri = await captureRef(canvasRef, {
format: "jpg", format: "png",
quality: 0.86, quality: 0.86,
width: 512,
height: 512,
result: "tmpfile", result: "tmpfile",
}); });
const key = await uploadOutfitCover(familyId, { const key = await uploadOutfitCover(familyId, {
uri, uri,
name: `outfit_cover_${targetId}.jpg`, name: `outfit_cover_${targetId}.png`,
type: "image/jpeg", type: "image/png",
}); });
return key; return key;
@@ -398,7 +401,6 @@ export default function ClothingOutfitEdit() {
onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))} onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))}
> >
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}> <Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
{item.emoji ? `${item.emoji} ` : ""}
{item.name} {item.name}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -613,92 +615,18 @@ export default function ClothingOutfitEdit() {
stopLoading, stopLoading,
]); ]);
return ( const canvasContent = (
<PageLayout title={isEdit ? "编辑搭配" : "新增搭配"}>
<Stack.Screen options={{ animation: "slide_from_right" }} />
<LayoutView>
<LayoutView.ScrollView bottomOffset={40}>
<Card className="mb-4">
<Card.Body className="gap-4">
<TextField isRequired>
<Label>搭配名称</Label>
<Input value={name} onChangeText={setName} placeholder="例如:周一通勤" />
</TextField>
<TextField>
<Label>备注</Label>
<TextArea
value={remark}
onChangeText={setRemark}
placeholder="适用场景、天气或搭配说明"
/>
</TextField>
</Card.Body>
</Card>
<Card className="mb-4">
<Card.Header>
<Text className="text-base font-semibold text-gray-900">分类</Text>
</Card.Header>
<Card.Body>
<View className="flex-row flex-wrap gap-2">
<TouchableOpacity
className={`rounded-full border px-3 py-2 ${
categoryId == null ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"
}`}
onPress={() => setCategoryId(null)}
>
<Text
className={`text-xs ${categoryId == null ? "text-white" : "text-gray-700"}`}
>
不选择
</Text>
</TouchableOpacity>
{outfitCategories.map(renderCategoryChip)}
</View>
</Card.Body>
</Card>
<Card className="mb-4">
<Card.Header>
<Text className="text-base font-semibold text-gray-900">标签</Text>
</Card.Header>
<Card.Body className="gap-4">
{outfitTagGroups.length > 0 ? (
outfitTagGroups.map(renderTagGroup)
) : (
<Text className="text-sm text-gray-500">暂无搭配标签,可在分类页维护。</Text>
)}
{selectedTagIds.length > 0 && (
<TouchableOpacity
className="self-start rounded-full bg-gray-100 px-3 py-2 flex-row items-center gap-1"
onPress={() => setSelectedTagIds([])}
>
<XIcon size={14} color="#6B7280" />
<Text className="text-xs text-gray-600">清空已选标签</Text>
</TouchableOpacity>
)}
</Card.Body>
</Card>
<Card className="mb-4 overflow-hidden p-0">
<View <View
ref={canvasRef} ref={canvasRef}
collapsable={false} collapsable={false}
className="w-full bg-gray-50" className="w-full flex-1"
style={{ aspectRatio: 1 }} style={{ aspectRatio: canvasFullscreen ? undefined : 1 }}
onLayout={(event) => setCanvasSize(event.nativeEvent.layout.width)} onLayout={(event) => setCanvasSize(event.nativeEvent.layout.width)}
> >
<TouchableOpacity
activeOpacity={1}
className="absolute inset-0"
onPress={() => setSelectedUid(null)}
/>
{items.length === 0 && ( {items.length === 0 && (
<View className="absolute inset-0 items-center justify-center"> <View className="absolute inset-0 items-center justify-center" pointerEvents="none">
<ImageIcon size={34} color="#9CA3AF" /> <ImageIcon size={34} color="#9CA3AF" />
<Text className="mt-2 text-sm text-gray-500">从下方添加衣服到画布</Text> <Text className="mt-2 text-sm text-gray-500">点击下方按钮添加衣服</Text>
</View> </View>
)} )}
{items.map((item) => ( {items.map((item) => (
@@ -713,75 +641,115 @@ export default function ClothingOutfitEdit() {
/> />
))} ))}
</View> </View>
</Card> );
<Card className="mb-4"> return (
<Card.Header> <PageLayout title={isEdit ? "编辑搭配" : "新增搭配"}>
<View className="flex-row items-center justify-between flex-1"> <Stack.Screen options={{ animation: "slide_from_bottom" }} />
<Text className="text-base font-semibold text-gray-900">画布操作</Text> <LayoutView>
{!!selectedItem && ( <LayoutView.ScrollView bottomOffset={40}>
<Text className="text-xs text-gray-500" numberOfLines={1}> <View className="gap-4 mb-4">
<TextField isRequired>
<Label className="mb-2">搭配名称</Label>
<Input value={name} onChangeText={setName} placeholder="例如:周一通勤" />
</TextField>
<TextField>
<Label className="mb-2">备注</Label>
<TextArea
value={remark}
onChangeText={setRemark}
placeholder="适用场景、天气或搭配说明"
/>
</TextField>
</View>
{/* 分类 */}
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">分类</Text>
<View className="flex-row flex-wrap gap-2 mb-4">
<TouchableOpacity
className={`rounded-full border px-3 py-2 ${
categoryId == null ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"
}`}
onPress={() => setCategoryId(null)}
>
<Text
className={`text-xs ${categoryId == null ? "text-white" : "text-gray-700"}`}
>
不选择
</Text>
</TouchableOpacity>
{outfitCategories.map(renderCategoryChip)}
</View>
{/* 标签 */}
{outfitTagGroups.length > 0 && (
<>
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">标签</Text>
<View className="gap-4 mb-4">
{outfitTagGroups.map(renderTagGroup)}
</View>
</>
)}
{/* 画布操作栏 */}
<View className="flex-row gap-2 mb-3">
<Button variant="secondary" size="sm" isDisabled={!selectedItem} onPress={() => handleScaleSelected(-0.04)}>
<MinusIcon size={16} color="#374151" />
</Button>
<Button variant="secondary" size="sm" isDisabled={!selectedItem} onPress={() => handleScaleSelected(0.04)}>
<PlusIcon size={16} color="#374151" />
</Button>
<Button variant="secondary" size="sm" isDisabled={!selectedItem} onPress={() => handleLayerSelected("front")}>
<BringToFrontIcon size={16} color="#374151" />
</Button>
<Button variant="secondary" size="sm" isDisabled={!selectedItem} onPress={() => handleLayerSelected("back")}>
<SendToBackIcon size={16} color="#374151" />
</Button>
<View className="flex-1" />
{selectedItem && (
<Text className="text-xs text-gray-400 self-center mr-1" numberOfLines={1}>
{clothesById.get(selectedItem.clothesId)?.name} {clothesById.get(selectedItem.clothesId)?.name}
</Text> </Text>
)} )}
</View> <Button variant="danger-soft" size="sm" isDisabled={!selectedItem} onPress={handleDeleteSelected}>
</Card.Header>
<Card.Body>
<View className="flex-row flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
isDisabled={!selectedItem}
onPress={() => handleScaleSelected(-0.04)}
>
<MinusIcon size={16} color="#374151" />
<Button.Label>缩小</Button.Label>
</Button>
<Button
variant="secondary"
size="sm"
isDisabled={!selectedItem}
onPress={() => handleScaleSelected(0.04)}
>
<PlusIcon size={16} color="#374151" />
<Button.Label>放大</Button.Label>
</Button>
<Button
variant="secondary"
size="sm"
isDisabled={!selectedItem}
onPress={() => handleLayerSelected("front")}
>
<BringToFrontIcon size={16} color="#374151" />
<Button.Label>置顶</Button.Label>
</Button>
<Button
variant="secondary"
size="sm"
isDisabled={!selectedItem}
onPress={() => handleLayerSelected("back")}
>
<SendToBackIcon size={16} color="#374151" />
<Button.Label>置底</Button.Label>
</Button>
<Button
variant="danger-soft"
size="sm"
isDisabled={!selectedItem}
onPress={handleDeleteSelected}
>
<Trash2Icon size={16} color="#EF4444" /> <Trash2Icon size={16} color="#EF4444" />
<Button.Label>删除</Button.Label> </Button>
<View className="flex-1" />
<Button variant="secondary" size="sm" onPress={() => setCanvasFullscreen((v) => !v)}>
{canvasFullscreen ? <Minimize2Icon size={16} color="#374151" /> : <Maximize2Icon size={16} color="#374151" />}
</Button> </Button>
</View> </View>
</Card.Body>
</Card>
<Card className="mb-4"> {/* 画布 */}
<Card.Header> {!canvasFullscreen && (
<Text className="text-base font-semibold text-gray-900">选择衣服</Text> <Card className="mb-4 overflow-hidden p-0">
</Card.Header> {canvasContent}
<Card.Body> </Card>
)}
<Button className="mb-4" variant="secondary" onPress={() => setShowClothesSheet(true)}>
<PlusIcon size={18} color="#374151" />
<Button.Label>从衣橱添加衣服</Button.Label>
</Button>
<View className="flex-row gap-3">
<Button variant="secondary" className="flex-1" onPress={() => router.back()}>
<XIcon size={18} color="#374151" />
<Button.Label>取消</Button.Label>
</Button>
<Button className="flex-1" onPress={handleSave} isDisabled={isLoading || pageLoading}>
<SaveIcon size={18} color="#fff" />
<Button.Label>保存</Button.Label>
</Button>
</View>
</LayoutView.ScrollView>
</LayoutView>
<BottomSheet isOpen={showClothesSheet} onOpenChange={setShowClothesSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content animateOnMount={false}>
{clothes.length > 0 ? ( {clothes.length > 0 ? (
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
{clothes.map((item) => ( {clothes.map((item) => (
@@ -807,21 +775,28 @@ export default function ClothingOutfitEdit() {
) : ( ) : (
<Text className="text-sm text-gray-500">暂无衣服,请先在衣橱中新增衣服。</Text> <Text className="text-sm text-gray-500">暂无衣服,请先在衣橱中新增衣服。</Text>
)} )}
</Card.Body> </BottomSheet.Content>
</Card> </BottomSheet.Portal>
</BottomSheet>
<View className="flex-row gap-3"> {canvasFullscreen && (
<Button variant="secondary" className="flex-1" onPress={() => router.back()}> <View
<XIcon size={18} color="#374151" /> className="absolute inset-0 bg-black z-50 items-center justify-center"
<Button.Label>取消</Button.Label> style={{ paddingTop: 0 }}
</Button> >
<Button className="flex-1" onPress={handleSave} isDisabled={isLoading || pageLoading}> <View className="absolute top-0 left-0 right-0 flex-row justify-end px-4 py-3 z-10">
<SaveIcon size={18} color="#fff" /> <TouchableOpacity
<Button.Label>保存</Button.Label> className="w-10 h-10 rounded-full bg-white/20 items-center justify-center"
</Button> onPress={() => setCanvasFullscreen(false)}
>
<Minimize2Icon size={20} color="#fff" />
</TouchableOpacity>
</View> </View>
</LayoutView.ScrollView> <View className="w-full" style={{ aspectRatio: 1, maxHeight: "90%" } as any}>
</LayoutView> {canvasContent}
</View>
</View>
)}
<LoadingOverlay visible={isLoading || pageLoading} /> <LoadingOverlay visible={isLoading || pageLoading} />
</PageLayout> </PageLayout>
@@ -434,11 +434,18 @@ export default function FinanceAnalysis() {
return ( return (
<LayoutView> <LayoutView>
<LayoutView.ScrollView contentContainerStyle={{ gap: 16 }}> <LayoutView.ScrollView contentContainerStyle={{ gap: 16 }}>
<View className="gap-3"> <View className="gap-3 pb-4">
<View className="flex-row items-center justify-between"> <View className="flex-row items-center justify-between">
<View className="flex-col gap-0.5 flex-1 mr-3">
<Text className="text-base font-semibold text-gray-900"> <Text className="text-base font-semibold text-gray-900">
{filter.startDate} 至 {filter.endDate} {dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")}
</Text> </Text>
<Text className="text-xs text-gray-400">
{dayjs(filter.startDate).year() !== dayjs(filter.endDate).year()
? `${dayjs(filter.startDate).format("YYYY")} / ${dayjs(filter.endDate).format("YYYY")}`
: dayjs(filter.startDate).format("YYYY年")}
</Text>
</View>
<View className="flex-row gap-2"> <View className="flex-row gap-2">
<TouchableOpacity <TouchableOpacity
@@ -476,19 +483,19 @@ export default function FinanceAnalysis() {
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<SummaryCard <SummaryCard
label="支出" label="支出"
value={`¥${formatCompactMoney(totals.expense)}`} value={formatCompactMoney(totals.expense)}
tone="expense" tone="expense"
/> />
<SummaryCard <SummaryCard
label="收入" label="收入"
value={`¥${formatCompactMoney(totals.income)}`} value={formatCompactMoney(totals.income)}
tone="income" tone="income"
/> />
</View> </View>
<View className="flex-row gap-3"> <View className="flex-row gap-3">
<SummaryCard <SummaryCard
label="结余" label="结余"
value={`¥${formatCompactMoney(balance)}`} value={formatCompactMoney(balance)}
tone={balance >= 0 ? "income" : "expense"} tone={balance >= 0 ? "income" : "expense"}
/> />
<SummaryCard label="笔数" value={`${totals.count}`} tone="neutral" /> <SummaryCard label="笔数" value={`${totals.count}`} tone="neutral" />
@@ -524,7 +531,7 @@ export default function FinanceAnalysis() {
<Text className="text-xs text-gray-500">支出峰值</Text> <Text className="text-xs text-gray-500">支出峰值</Text>
</View> </View>
<Text className="mt-1 text-sm font-semibold text-rose-500"> <Text className="mt-1 text-sm font-semibold text-rose-500">
¥{formatMoney(trendSummary.maxExpense)} {formatMoney(trendSummary.maxExpense)}
</Text> </Text>
<Text className="text-[11px] text-gray-400"> <Text className="text-[11px] text-gray-400">
{trendSummary.maxExpenseDay {trendSummary.maxExpenseDay
@@ -538,7 +545,7 @@ export default function FinanceAnalysis() {
<Text className="text-xs text-gray-500">收入峰值</Text> <Text className="text-xs text-gray-500">收入峰值</Text>
</View> </View>
<Text className="mt-1 text-sm font-semibold text-emerald-600"> <Text className="mt-1 text-sm font-semibold text-emerald-600">
¥{formatMoney(trendSummary.maxIncome)} {formatMoney(trendSummary.maxIncome)}
</Text> </Text>
<Text className="text-[11px] text-gray-400"> <Text className="text-[11px] text-gray-400">
{trendSummary.maxIncomeDay {trendSummary.maxIncomeDay
@@ -549,7 +556,6 @@ export default function FinanceAnalysis() {
<View className="flex-1 rounded-lg bg-white px-3 py-2"> <View className="flex-1 rounded-lg bg-white px-3 py-2">
<Text className="text-xs text-gray-500">日均支出</Text> <Text className="text-xs text-gray-500">日均支出</Text>
<Text className="mt-1 text-sm font-semibold text-gray-900"> <Text className="mt-1 text-sm font-semibold text-gray-900">
¥
{formatMoney( {formatMoney(
trendSummary.nonZeroDays > 0 trendSummary.nonZeroDays > 0
? trendSummary.totalExpense / trendData.length ? trendSummary.totalExpense / trendData.length
@@ -599,7 +605,7 @@ export default function FinanceAnalysis() {
<View className="flex-row items-center justify-between"> <View className="flex-row items-center justify-between">
<Text className="text-xs text-gray-700">{item.categoryName}</Text> <Text className="text-xs text-gray-700">{item.categoryName}</Text>
<Text className="text-[11px] text-gray-400"> <Text className="text-[11px] text-gray-400">
¥{formatCompactMoney(item.amount)} ({Math.round(pct)}%) {formatCompactMoney(item.amount)} ({Math.round(pct)}%)
</Text> </Text>
</View> </View>
<View className="h-2 bg-gray-50 rounded-full overflow-hidden"> <View className="h-2 bg-gray-50 rounded-full overflow-hidden">
@@ -638,7 +644,7 @@ export default function FinanceAnalysis() {
</View> </View>
<View> <View>
<Label>日期范围</Label> <Label className="mb-2">日期范围</Label>
<TouchableOpacity <TouchableOpacity
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between" className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
activeOpacity={0.75} activeOpacity={0.75}
@@ -652,7 +658,7 @@ export default function FinanceAnalysis() {
</View> </View>
<View> <View>
<Label>收支类型</Label> <Label className="mb-2">收支类型</Label>
<View className="flex-row gap-2"> <View className="flex-row gap-2">
{[ {[
{ key: "all" as const, label: "全部" }, { key: "all" as const, label: "全部" },
@@ -686,7 +692,7 @@ export default function FinanceAnalysis() {
</View> </View>
<View> <View>
<Label>成员</Label> <Label className="mb-2">成员</Label>
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
<TouchableOpacity <TouchableOpacity
className={`h-9 px-3 rounded-full items-center justify-center border ${ className={`h-9 px-3 rounded-full items-center justify-center border ${
@@ -731,7 +737,7 @@ export default function FinanceAnalysis() {
</View> </View>
<View> <View>
<Label>分类</Label> <Label className="mb-2">分类</Label>
<View className="flex-row flex-wrap gap-2"> <View className="flex-row flex-wrap gap-2">
<TouchableOpacity <TouchableOpacity
className={`h-9 px-3 rounded-full items-center justify-center border ${ className={`h-9 px-3 rounded-full items-center justify-center border ${
@@ -781,7 +787,7 @@ export default function FinanceAnalysis() {
</View> </View>
<TextField> <TextField>
<Label>备注关键词</Label> <Label className="mb-2">备注关键词</Label>
<BottomSheet.Input <BottomSheet.Input
value={draftFilter.keyword} value={draftFilter.keyword}
onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))} onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))}
@@ -911,7 +917,7 @@ function DailyBarChart({ data, maxVal: rawMax }: { data: BarItem[]; maxVal: numb
className="text-[11px] font-medium w-14 text-right" className="text-[11px] font-medium w-14 text-right"
style={{ color: stack.color }} style={{ color: stack.color }}
> >
¥{formatMoney(stack.value)} {formatMoney(stack.value)}
</Text> </Text>
</View> </View>
); );
@@ -245,7 +245,7 @@ export default function Home() {
if (!activeBill || isLoading) return; if (!activeBill || isLoading) return;
const id = activeBill.id; const id = activeBill.id;
const target = `${activeBill.category_type === FinanceType.Income ? "收入" : "支出"} ¥${formatMoney( const target = `${activeBill.category_type === FinanceType.Income ? "收入" : "支出"} ${formatMoney(
Number(activeBill.amount), Number(activeBill.amount),
)}`; )}`;
const detail = activeBill.category_name || "未分类"; const detail = activeBill.category_name || "未分类";
@@ -286,7 +286,7 @@ export default function Home() {
() => ( () => (
<View className="gap-3 pb-4"> <View className="gap-3 pb-4">
<View className="flex-row items-center justify-between"> <View className="flex-row items-center justify-between">
<View className="flex-col gap-1.5 flex-1 mr-3"> <View className="flex-col gap-0.5 flex-1 mr-3">
<Text className="text-base font-semibold text-gray-900"> <Text className="text-base font-semibold text-gray-900">
{dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")} {dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")}
</Text> </Text>
@@ -204,12 +204,12 @@ export default function Add() {
notifyModuleOperation({ notifyModuleOperation({
module: "记账", module: "记账",
action: isEdit ? "更新记账" : "新增记账", action: isEdit ? "更新记账" : "新增记账",
target: `${label} ¥${amount}`, target: `${label} ${amount}`,
detail: selectedCategory.name, detail: selectedCategory.name,
sourceTable: "b_finance", sourceTable: "b_finance",
sourceId: targetId, sourceId: targetId,
}); });
toast.success(isEdit ? "记账已更新" : `已记${label} ¥${amount}`); toast.success(isEdit ? "记账已更新" : `已记${label} ${amount}`);
if (keepOpen) { if (keepOpen) {
setAmount(""); setAmount("");
setRemark(""); setRemark("");
@@ -342,7 +342,7 @@ export default function Add() {
{/* 备注 */} {/* 备注 */}
<TextField> <TextField>
<Label>备注</Label> <Label className="mb-2">备注</Label>
<BottomSheet.Input <BottomSheet.Input
placeholder="写点什么..." placeholder="写点什么..."
value={remark} value={remark}
@@ -6,6 +6,7 @@ import { Button, Label, Tabs, TextField } from "heroui-native";
import BottomSheet from "@/components/BottomSheet"; import BottomSheet from "@/components/BottomSheet";
import { GripVerticalIcon, ImageIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react-native"; import { GripVerticalIcon, ImageIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react-native";
import Sortable, { SortableGridRenderItem } from "react-native-sortables"; import Sortable, { SortableGridRenderItem } from "react-native-sortables";
import { useAnimatedRef } from "react-native-reanimated";
import PageLayout from "@/layouts/PageLayout"; import PageLayout from "@/layouts/PageLayout";
import LayoutView from "@/components/layout/view"; import LayoutView from "@/components/layout/view";
import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon"; import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon";
@@ -69,6 +70,8 @@ export default function FinanceCategoryList() {
[activeType, categories], [activeType, categories],
); );
const scrollRef = useAnimatedRef<any>();
const closeSheet = useCallback(() => { const closeSheet = useCallback(() => {
setForm(null); setForm(null);
setSaving(false); setSaving(false);
@@ -307,9 +310,9 @@ export default function FinanceCategoryList() {
return ( return (
<PageLayout title="分类管理"> <PageLayout title="分类管理">
<Stack.Screen options={{ animation: "slide_from_right" }} /> <Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView> <LayoutView>
<LayoutView.ScrollView bottomOffset={40}> <LayoutView.ScrollView ref={scrollRef} bottomOffset={40}>
<View className="mb-3"> <View className="mb-3">
<Tabs value={activeType} onValueChange={setActiveType} variant="primary"> <Tabs value={activeType} onValueChange={setActiveType} variant="primary">
<Tabs.List> <Tabs.List>
@@ -333,6 +336,7 @@ export default function FinanceCategoryList() {
<Sortable.Grid <Sortable.Grid
columns={1} columns={1}
customHandle customHandle
scrollableRef={scrollRef}
data={currentCategories} data={currentCategories}
keyExtractor={(item) => String(item.id)} keyExtractor={(item) => String(item.id)}
renderItem={renderItem} renderItem={renderItem}
@@ -371,7 +375,7 @@ export default function FinanceCategoryList() {
</View> </View>
<TextField> <TextField>
<Label>分类名称</Label> <Label className="mb-2">分类名称</Label>
<BottomSheet.Input <BottomSheet.Input
value={form.name} value={form.name}
onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))} onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))}
@@ -380,7 +384,7 @@ export default function FinanceCategoryList() {
</TextField> </TextField>
<View> <View>
<Label>类型</Label> <Label className="mb-2">类型</Label>
<Tabs <Tabs
value={form.type} value={form.type}
onValueChange={(type) => setForm((prev) => (prev ? { ...prev, type } : prev))} onValueChange={(type) => setForm((prev) => (prev ? { ...prev, type } : prev))}
@@ -88,7 +88,7 @@ export default function FinanceDetail() {
return ( return (
<PageLayout title="记账详情"> <PageLayout title="记账详情">
<Stack.Screen options={{ animation: "slide_from_right" }} /> <Stack.Screen options={{ animation: "slide_from_bottom" }} />
<LayoutView> <LayoutView>
<LayoutView.ScrollView> <LayoutView.ScrollView>
{!bill && loaded ? ( {!bill && loaded ? (
@@ -118,7 +118,7 @@ export default function FinanceDetail() {
<Text <Text
className={`mt-2 text-3xl font-bold ${isIncome ? "text-emerald-600" : "text-rose-500"}`} className={`mt-2 text-3xl font-bold ${isIncome ? "text-emerald-600" : "text-rose-500"}`}
> >
{isIncome ? "+" : "-"}¥{formatMoney(bill.amount)} {isIncome ? "+" : "-"}{formatMoney(bill.amount)}
</Text> </Text>
</View> </View>
+14 -3
View File
@@ -24,6 +24,8 @@ import {
type ViewStyle, type ViewStyle,
} from "react-native"; } from "react-native";
import { Input, useBottomSheetAwareHandlers } from "heroui-native"; import { Input, useBottomSheetAwareHandlers } from "heroui-native";
import { Toaster } from "sonner-native";
import { resetToaster } from "@/utils/toaster";
// ── Props ────────────────────────────────────────────── // ── Props ──────────────────────────────────────────────
@@ -153,7 +155,7 @@ function AppBottomSheetRoot({
input.measureInWindow((x, y, w, h) => { input.measureInWindow((x, y, w, h) => {
const screenBottom = SCREEN_H - kbHeight; const screenBottom = SCREEN_H - kbHeight;
const inputBottom = y + h; const inputBottom = y + h;
const needed = inputBottom - screenBottom - 16; const needed = inputBottom - screenBottom - 18;
keyboardY.setValue(needed > 0 ? -needed : 0); keyboardY.setValue(needed > 0 ? -needed : 0);
}); });
}, 50); }, 50);
@@ -172,6 +174,13 @@ function AppBottomSheetRoot({
return () => { s1.remove(); s2.remove(); h1.remove(); h2.remove(); }; return () => { s1.remove(); s2.remove(); h1.remove(); h2.remove(); };
}, [visible, adjustForKeyboard]); }, [visible, adjustForKeyboard]);
// Modal 关闭后恢复全局 Toaster
useEffect(() => {
if (visible) return;
const timer = setTimeout(() => resetToaster(), 100);
return () => clearTimeout(timer);
}, [visible]);
if (!visible) return null; if (!visible) return null;
return ( return (
@@ -206,6 +215,8 @@ function AppBottomSheetRoot({
{children} {children}
</View> </View>
</Animated.View> </Animated.View>
<Toaster duration={1000} />
</Modal> </Modal>
</AppBottomSheetContext.Provider> </AppBottomSheetContext.Provider>
); );
@@ -230,8 +241,8 @@ function AppBottomSheetContent({
return ( return (
<AppBottomSheetContentContext.Provider value={{ ...ctx, shouldAutoFocusSingleInput: inputCount === 1 }}> <AppBottomSheetContentContext.Provider value={{ ...ctx, shouldAutoFocusSingleInput: inputCount === 1 }}>
<View <View
className={contentContainerClassName ?? className ?? "px-4 py-4"} className={contentContainerClassName ?? className ?? ""}
style={[{ paddingTop:16,paddingBottom: 28,paddingLeft:24,paddingRight:24 }, style]} style={[{ paddingVertical:24,paddingHorizontal:20 }, style]}
{...(props as Record<string, unknown>)} {...(props as Record<string, unknown>)}
> >
{children} {children}
+1 -2
View File
@@ -89,9 +89,8 @@ export default function DateTime({
<BottomSheet <BottomSheet
isOpen={open} isOpen={open}
onOpenChange={setOpen} onOpenChange={setOpen}
backdropOpacity={0.35}
> >
<BottomSheet.Content className="pb-6"> <BottomSheet.Content>
<View className="w-full items-center"> <View className="w-full items-center">
<DateTimePicker <DateTimePicker
mode="single" mode="single"
+1 -1
View File
@@ -27,7 +27,7 @@ export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) {
}, [pathname, tabs]); }, [pathname, tabs]);
function handleClose() { function handleClose() {
router.back(); router.dismissTo("/tools");
} }
return ( return (
+1 -1
View File
@@ -95,7 +95,7 @@ export interface ClothingTag {
tag_group_id: number; tag_group_id: number;
name: string; name: string;
type: ClothingTargetType; type: ClothingTargetType;
emoji?: string; color?: string;
sort_number: number; sort_number: number;
status: ClothingStatus; status: ClothingStatus;
create_time?: string; create_time?: string;
+9
View File
@@ -0,0 +1,9 @@
let resetFn: (() => void) | null = null;
export function onToasterReset(fn: () => void) {
resetFn = fn;
}
export function resetToaster() {
resetFn?.();
}