diff --git a/code/app/app-go/db/clothing.sql b/code/app/app-go/db/clothing.sql index 0f7ad163..f5d239b2 100644 --- a/code/app/app-go/db/clothing.sql +++ b/code/app/app-go/db/clothing.sql @@ -87,7 +87,7 @@ create table b_clothing_tag ( tag_group_id bigint not null, name varchar(32) not null, type smallint not null, - emoji varchar(16) default '', + color varchar(7) default '', sort_number int not null default 0, status smallint not null default 1, create_time timestamp, diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx index a176699e..d074f4cf 100644 --- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx +++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx @@ -112,7 +112,7 @@ const ChatMessageItem = memo(function ChatMessageItem({ return ( - {item.content} + {nickname} {item.content} ); diff --git a/code/app/app-rn/src/app/(main)/chat/history.tsx b/code/app/app-rn/src/app/(main)/chat/history.tsx index 0ec58f76..fda6582c 100644 --- a/code/app/app-rn/src/app/(main)/chat/history.tsx +++ b/code/app/app-rn/src/app/(main)/chat/history.tsx @@ -273,7 +273,7 @@ export default function ChatHistoryPage() { - + - + {MSG_TYPES.map((t) => ( setDraftTypeFilter(t.value)}> diff --git a/code/app/app-rn/src/app/(main)/family/settings.tsx b/code/app/app-rn/src/app/(main)/family/settings.tsx index 028c5835..91f0b849 100644 --- a/code/app/app-rn/src/app/(main)/family/settings.tsx +++ b/code/app/app-rn/src/app/(main)/family/settings.tsx @@ -529,7 +529,7 @@ export default function HomeSettings() { - + - + - + - + - + { + onToasterReset(() => setTsKey((k) => k + 1)); + }, []); if (!isReady) return null; @@ -47,7 +54,7 @@ export default function RootLayout() { - + ); } diff --git a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx index 9c9d091a..6cacbd4d 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx @@ -1,7 +1,7 @@ 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 { Button, Card, Label, Menu, SearchField } from "heroui-native"; +import { Button, Card, Dialog, Label, SearchField } from "heroui-native"; import { CheckIcon, FilterIcon, @@ -67,6 +67,8 @@ export default function ClothingHome() { const [draftFilter, setDraftFilter] = useState(() => createDefaultFilter()); const [showFilterSheet, setShowFilterSheet] = useState(false); const [activeItem, setActiveItem] = useState(null); + const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); + const [deleteConfirm, setDeleteConfirm] = useState<{ item: ClothingClothes; relatedOutfitCount: number } | null>(null); const clothesCategories = useMemo( () => categories.filter((item) => item.type === ClothingTargetType.Clothes), @@ -266,33 +268,24 @@ export default function ClothingHome() { ), ); - if (relatedOutfitIds.length === 0) { - 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), - }, - ], - ); + setDeleteConfirm({ item, relatedOutfitCount: relatedOutfitIds.length }); }, - [familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations, handleDelete], + [familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations], + ); + + const handleConfirmDelete = useCallback( + (withOutfits: boolean) => { + const data = deleteConfirm; + if (!data) return; + setDeleteConfirm(null); + handleDelete(data.item, withOutfits); + }, + [deleteConfirm, handleDelete], ); const renderCategoryName = useCallback( (categoryId?: number | null) => - clothesCategories.find((category) => category.id === categoryId)?.name ?? "未分类", + clothesCategories.find((category) => category.id === categoryId)?.name ?? "", [clothesCategories], ); @@ -305,9 +298,16 @@ export default function ClothingHome() { .filter((tag) => itemTagIds.has(tag.id)) .slice(0, 3) .map((tag) => ( - - - {tag.emoji ? `${tag.emoji} ` : ""} + + {tag.name} @@ -323,36 +323,39 @@ export default function ClothingHome() { activeOpacity={0.82} delayLongPress={260} onPress={() => handleEdit(item)} - onLongPress={() => setActiveItem(item)} + onLongPress={(e) => { + setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); + setActiveItem(item); + }} > - - - - - {item.name} - - - {item.brand?.trim() || renderCategoryName(item.category_id)} - - - setActiveItem(item)} - > - - - + + + {item.name} + + {item.brand?.trim() ? ( + + {item.brand.trim()} + + ) : null} - - - - {renderCategoryName(item.category_id)} - - - {renderItemTags(item)} - + {(() => { + const hasTags = (tagIdsByClothesId.get(item.id)?.size ?? 0) > 0; + if (!item.category_id && !hasTags) return null; + return ( + + {item.category_id ? ( + + + {renderCategoryName(item.category_id)} + + + ) : null} + {renderItemTags(item)} + + ); + })()} @@ -406,7 +409,6 @@ export default function ClothingHome() { } > - {item.emoji ? `${item.emoji} ` : ""} {item.name} @@ -498,7 +500,7 @@ export default function ClothingHome() { - + - + {clothesTags.length > 0 ? ( {clothesTags.map(renderFilterTag)} @@ -548,28 +550,63 @@ export default function ClothingHome() { - { - if (!open) setActiveItem(null); - }} + setActiveItem(null)} > - - - - - activeItem && handleEdit(activeItem)}> - - 编辑 - - activeItem && handleRequestDelete(activeItem)}> - - 删除 - + setActiveItem(null)}> + + { if (activeItem) handleEdit(activeItem); }} + > + + 编辑 + + { if (activeItem) handleRequestDelete(activeItem); }} + > + + 删除 + + + + + + { if (!v) setDeleteConfirm(null); }}> + + + + + {deleteConfirm?.relatedOutfitCount ? "衣服已被搭配使用" : "删除衣服"} + + + {deleteConfirm?.relatedOutfitCount + ? `「${deleteConfirm.item.name}」已被 ${deleteConfirm.relatedOutfitCount} 个搭配使用,是否同时删除这些搭配?` + : `确认删除「${deleteConfirm?.item.name}」吗?`} + + + + - - - + + + diff --git a/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx index c1d009f1..38087821 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Alert, Text, TouchableOpacity, View } from "react-native"; -import { Button, Card, Label, Tabs, TextField } from "heroui-native"; +import { Text, TouchableOpacity, View } from "react-native"; +import { Button, Dialog, Label, Tabs, TextField } from "heroui-native"; import { CheckIcon, + ChevronDownIcon, FolderIcon, PencilIcon, PlusIcon, @@ -28,7 +29,12 @@ import { ClothingTargetType, } from "@/types/clothing"; -type ManageMode = "category" | "tag"; +const TAG_COLORS = [ + "#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6", + "#06B6D4", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7", + "#EC4899", "#78716C", +]; + type MetadataForm = | { kind: "category"; @@ -48,7 +54,7 @@ type MetadataForm = kind: "tag"; id?: number; name: string; - emoji: string; + color: string; tagGroupId: number | null; type: ClothingTargetType; sortNumber?: number; @@ -74,8 +80,11 @@ export default function ClothingMetadataManage() { const notifyModuleOperation = useModuleChatNotifier(); const [activeType, setActiveType] = useState(String(ClothingTargetType.Clothes)); - const [mode, setMode] = useState("category"); const [form, setForm] = useState(null); + const [deleteConfirm, setDeleteConfirm] = useState< + { kind: "category"; item: ClothingCategory } | { kind: "tag"; item: ClothingTag } | { kind: "tagGroup"; item: ClothingTagGroup } | null + >(null); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); const targetType = Number(activeType) as ClothingTargetType; @@ -179,7 +188,7 @@ export default function ClothingMetadataManage() { setForm({ kind: "tag", name: "", - emoji: "", + color: "", tagGroupId: tagGroupId ?? currentTagGroups[0]?.id ?? null, type: targetType, }); @@ -192,7 +201,7 @@ export default function ClothingMetadataManage() { kind: "tag", id: item.id, name: item.name, - emoji: item.emoji ?? "", + color: item.color ?? "", tagGroupId: item.tag_group_id, type: item.type, sortNumber: item.sort_number, @@ -256,7 +265,7 @@ export default function ClothingMetadataManage() { tag_group_id: form.tagGroupId!, name, type: form.type, - emoji: form.emoji.trim(), + color: form.color.trim(), sort_number: form.sortNumber ?? tags.filter((item) => item.type === form.type && item.tag_group_id === form.tagGroupId) @@ -314,167 +323,121 @@ export default function ClothingMetadataManage() { tags, ]); - const handleDeleteCategory = useCallback( - (item: ClothingCategory) => { - Alert.alert("删除分类", `确认删除「${item.name}」吗?已使用该分类的数据会显示为未分类。`, [ - { text: "取消", style: "cancel" }, - { - text: "删除", - style: "destructive", - onPress: async () => { - startLoading("删除中..."); - try { - const ok = await saveMetadataBundle({ - categoryUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], - }); - if (!ok) { - 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 handleDeleteCategory = useCallback((item: ClothingCategory) => { + setDeleteConfirm({ kind: "category", item }); + }, []); - const handleDeleteTag = useCallback( - (item: ClothingTag) => { - const relationDeletes = tagRelations - .filter((row) => row.tag_id === item.id) - .map((row) => ({ id: row.id })); + const handleDeleteTag = useCallback((item: ClothingTag) => { + setDeleteConfirm({ kind: "tag", item }); + }, []); - 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 }], - tagRelationDeletes: relationDeletes, - }); - if (!ok) { - 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) => { + setDeleteConfirm({ kind: "tagGroup", item }); + }, []); - const handleDeleteTagGroup = useCallback( - (item: ClothingTagGroup) => { - const groupTags = tags.filter((tag) => tag.tag_group_id === item.id); - const groupTagIds = new Set(groupTags.map((tag) => tag.id)); - const relationDeletes = tagRelations - .filter((row) => groupTagIds.has(row.tag_id)) - .map((row) => ({ id: row.id })); + const handleConfirmDelete = useCallback(async () => { + if (!deleteConfirm || isLoading) return; + const { kind, item } = deleteConfirm; + setDeleteConfirm(null); + startLoading("删除中..."); - 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 }], - tagUpdates: groupTags.map((tag) => ({ - id: tag.id, - status: ClothingStatus.Disabled, - })), - tagRelationDeletes: relationDeletes, - }); - if (!ok) { - stopLoading({ type: "error", message: "删除失败" }); - return; - } - await loadData(); - notifyModuleOperation({ - module: "衣服搭配", - action: `删除${targetTypeLabel(item.type)}标签组`, - target: item.name, - detail: `${groupTags.length} 个标签`, - sourceTable: "b_clothing_tag_group", - sourceId: item.id, - }); - stopLoading({ type: "success", message: "已删除" }); - } catch { - stopLoading({ type: "error", message: "删除失败" }); - } - }, - }, - ]); - }, - [ - loadData, - notifyModuleOperation, - saveMetadataBundle, - startLoading, - stopLoading, - tagRelations, - tags, - ], - ); + try { + let ok = false; + if (kind === "category") { + ok = await saveMetadataBundle({ + categoryUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], + }); + } else if (kind === "tag") { + const relationDeletes = tagRelations + .filter((row) => row.tag_id === item.id) + .map((row) => ({ id: row.id })); + ok = await saveMetadataBundle({ + tagUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], + tagRelationDeletes: relationDeletes, + }); + } else if (kind === "tagGroup") { + const groupTags = tags.filter((tag) => tag.tag_group_id === item.id); + const groupTagIds = new Set(groupTags.map((tag) => tag.id)); + const relationDeletes = tagRelations + .filter((row) => groupTagIds.has(row.tag_id)) + .map((row) => ({ id: row.id })); + ok = await saveMetadataBundle({ + tagGroupUpdates: [{ id: item.id, status: ClothingStatus.Disabled }], + tagUpdates: groupTags.map((tag) => ({ id: tag.id, status: ClothingStatus.Disabled })), + tagRelationDeletes: relationDeletes, + }); + } + + if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; } + await loadData(); + notifyModuleOperation({ + module: "衣服搭配", + action: `删除${targetTypeLabel(item.type)}${ + kind === "category" ? "分类" : kind === "tagGroup" ? "标签组" : "标签" + }`, + target: item.name, + detail: kind === "tagGroup" ? `${tags.filter((tag) => tag.tag_group_id === item.id).length} 个标签` : undefined, + sourceTable: kind === "category" ? "b_clothing_category" : kind === "tagGroup" ? "b_clothing_tag_group" : "b_clothing_tag", + sourceId: item.id, + }); + stopLoading({ type: "success", message: "已删除" }); + } catch { + stopLoading({ type: "error", message: "删除失败" }); + } + }, [deleteConfirm, isLoading, loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading, tagRelations, tags]); + + const getDeleteTitle = () => { + if (!deleteConfirm) return ""; + if (deleteConfirm.kind === "category") return "删除分类"; + if (deleteConfirm.kind === "tagGroup") return "删除标签组"; + return "删除标签"; + }; + + const getDeleteDesc = () => { + if (!deleteConfirm) return ""; + 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( (item: ClothingCategory) => ( - - - - - - - - - {item.name} - - {targetTypeLabel(item.type)}分类 - - - - - - + + + + + + {item.name} + + openEditCategory(item)} + > + + + handleDeleteCategory(item)} + > + + + ), [handleDeleteCategory, openEditCategory], ); @@ -482,77 +445,94 @@ export default function ClothingMetadataManage() { const renderTagGroup = useCallback( (group: ClothingTagGroup) => { const groupTags = tagsByGroupId.get(group.id) ?? []; + const collapsed = collapsedGroups.has(group.id); return ( - - - - - - - - - {group.name} - - - {targetTypeLabel(group.type)}标签组 · {groupTags.length} 个标签 - - - - + + {/* Group header — same row style as categories */} + toggleGroup(group.id)} + > + + - - - {groupTags.length > 0 ? ( - - {groupTags.map((tag) => ( + + {group.name} + + + { e.stopPropagation?.(); openEditTagGroup(group); }} + > + + + { e.stopPropagation?.(); handleDeleteTagGroup(group); }} + > + + + + + {/* Tags — same row style as categories, colored dot instead of icon */} + {!collapsed && ( + + {groupTags.map((tag) => ( + + + + {tag.name} + openEditTag(tag)} > - - {tag.emoji ? `${tag.emoji} ` : ""} - {tag.name} - - handleDeleteTag(tag)}> - - + - ))} - - ) : ( - 暂无标签 - )} - - - + handleDeleteTag(tag)} + > + + + + ))} + {groupTags.length === 0 && ( + 暂无标签 + )} + openCreateTag(group.id)} + > + + 添加标签 + + + )} + ); }, [ + collapsedGroups, handleDeleteTag, handleDeleteTagGroup, openCreateTag, openEditTag, openEditTagGroup, tagsByGroupId, + toggleGroup, ], ); @@ -571,83 +551,66 @@ export default function ClothingMetadataManage() { contentContainerStyle={{ paddingBottom: 120 }} > - - - - - 衣服 - - - 搭配 - - - - - setMode(value as ManageMode)} - variant="secondary" - > - - - - 分类 - - - 标签 - - - + + + + + + 衣服 + + + 搭配 + + + + + + + - {mode === "category" ? ( - - ) : ( - <> - - - - )} - + + - {mode === "category" ? ( - currentCategories.length > 0 ? ( - currentCategories.map(renderCategory) - ) : ( - - ) - ) : currentTagGroups.length > 0 ? ( + {/* 分类 */} + 分类 + {currentCategories.length > 0 ? ( + currentCategories.map(renderCategory) + ) : ( + + 暂无分类 + + )} + + {/* 标签 */} + + 标签 + + {currentTagGroups.length > 0 ? ( currentTagGroups.map(renderTagGroup) ) : ( - + + 暂无标签组 + + )} + + {loading && currentCategories.length === 0 && currentTagGroups.length === 0 && ( + )} @@ -662,10 +625,10 @@ export default function ClothingMetadataManage() { {form && ( <> - {renderFormTitle()} + {renderFormTitle()} - + setForm((prev) => (prev ? { ...prev, name } : prev))} @@ -675,22 +638,49 @@ export default function ClothingMetadataManage() { {form.kind === "tag" && ( <> - - - - setForm((prev) => - prev && prev.kind === "tag" ? { ...prev, emoji } : prev, - ) - } - placeholder="👕" - /> - + + + + + setForm((prev) => + prev && prev.kind === "tag" ? { ...prev, color: "" } : prev, + ) + } + > + + + {TAG_COLORS.map((c) => { + const selected = form.color === c; + return ( + + setForm((prev) => + prev && prev.kind === "tag" + ? { ...prev, color: c } + : prev, + ) + } + > + {selected && ( + + )} + + ); + })} + + - + {currentTagGroups.map((group) => { const selected = form.tagGroupId === group.id; @@ -724,7 +714,7 @@ export default function ClothingMetadataManage() { )} - + {targetTypeLabel(form.type)} @@ -749,6 +739,24 @@ export default function ClothingMetadataManage() { + { if (!v) setDeleteConfirm(null); }}> + + + + {getDeleteTitle()} + {getDeleteDesc()} + + + + + + + + ); diff --git a/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx index ea940ce6..14430f68 100644 --- a/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx +++ b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx @@ -1,26 +1,57 @@ -import { useCallback, useEffect, useMemo } from "react"; -import { TouchableOpacity, View } from "react-native"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Modal, Pressable, Text, TouchableOpacity, View } from "react-native"; import { router, useFocusEffect } from "expo-router"; -import { Button, Card } from "heroui-native"; -import { PencilIcon, PlusIcon, RotateCcwIcon, SparklesIcon } from "lucide-react-native"; +import { Button, Card, Dialog } from "heroui-native"; +import { + FileTextIcon, + PencilIcon, + PlusIcon, + RotateCcwIcon, + SparklesIcon, + Trash2Icon, +} from "lucide-react-native"; import LayoutView from "@/components/layout/view"; import EmptyState from "@/components/EmptyStatus"; 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 { ClothingOutfit } from "@/types/clothing"; +import { ClothingOutfit, ClothingStatus, ClothingTargetType } from "@/types/clothing"; export default function ClothingOutfitList() { const familyId = useFamilyStore((s) => s.getFamilyId()); const outfits = useClothingStore((s) => s.outfits); const clothes = useClothingStore((s) => s.clothes); 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 fetchClothes = useClothingStore((s) => s.fetchClothes); 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 { isLoading, startLoading, stopLoading } = useLoading(); + const notifyModuleOperation = useModuleChatNotifier(); 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(); + 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 () => { if (!familyId) return; @@ -28,8 +59,11 @@ export default function ClothingOutfitList() { fetchOutfits(familyId), fetchClothes(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(() => { if (!familyId) return; @@ -47,6 +81,48 @@ export default function ClothingOutfitList() { runRefresh(loadData); }, [loadData, runRefresh]); + const [activeItem, setActiveItem] = useState(null); + const [menuPos, setMenuPos] = useState({ x: 0, y: 0 }); + const [deleteConfirm, setDeleteConfirm] = useState(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( (item: ClothingOutfit) => { if (item.cover_file_key) return item.cover_file_key; @@ -81,34 +157,53 @@ export default function ClothingOutfitList() { ); const renderItem = useCallback( - ({ item }: { item: ClothingOutfit }) => ( - router.push(`/clothes/outfit/detail/${item.id}` as any)} - > - - - - - - - {item.name} - - {getClothesCount(item)} 件衣服 - - router.push(`/clothes/outfit/edit?id=${item.id}` as any)} - > - - + ({ 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 ( + router.push(`/clothes/outfit/detail/${item.id}` as any)} + onLongPress={(e) => { + setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); + setActiveItem(item); + }} + > + + + - - - - ), - [getClothesCount, getCoverKey], + + + {item.name} + + + {getClothesCount(item)} 件衣服 + {categoryName ? ` · ${categoryName}` : ""} + + {itemTags.length > 0 && ( + + {itemTags.slice(0, 2).map((tag) => ( + + {tag!.name} + + ))} + + )} + + + + ); + }, + [categoriesById, getClothesCount, getCoverKey, outfitTagIds, tagsById], ); return ( @@ -144,6 +239,78 @@ export default function ClothingOutfitList() { /> )} /> + + setActiveItem(null)} + > + setActiveItem(null)}> + + { + const item = activeItem; + setActiveItem(null); + if (item) router.push(`/clothes/outfit/edit?id=${item.id}` as any); + }} + > + + 编辑 + + + + 删除 + + { + const item = activeItem; + setActiveItem(null); + if (item) router.push(`/clothes/outfit/detail/${item.id}` as any); + }} + > + + 详细 + + + + + + { if (!v) setDeleteConfirm(null); }}> + + + + 删除搭配 + + 确认删除「{deleteConfirm?.name}」吗?衣服本身不会被删除。 + + + + + + + + + + ); } diff --git a/code/app/app-rn/src/app/clothes/edit/index.tsx b/code/app/app-rn/src/app/clothes/edit/index.tsx index 2f13e915..403c4337 100644 --- a/code/app/app-rn/src/app/clothes/edit/index.tsx +++ b/code/app/app-rn/src/app/clothes/edit/index.tsx @@ -2,15 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Text, TouchableOpacity, View } from "react-native"; import { Stack, useLocalSearchParams, useRouter } from "expo-router"; 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 { CheckIcon, ImageIcon, - PlusIcon, SaveIcon, - ShirtIcon, - TagIcon, - XIcon, } from "lucide-react-native"; import { toast } from "sonner-native"; import PageLayout from "@/layouts/PageLayout"; @@ -79,9 +75,6 @@ export default function ClothingEdit() { const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups); const fetchTags = useClothingStore((s) => s.fetchTags); 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 uploadClothesImage = useClothingStore((s) => s.uploadClothesImage); const { isLoading, startLoading, stopLoading } = useLoading(); @@ -89,10 +82,6 @@ export default function ClothingEdit() { const [form, setForm] = useState(emptyForm); const [selectedTagIds, setSelectedTagIds] = useState([]); - const [newCategoryName, setNewCategoryName] = useState(""); - const [newTagGroupName, setNewTagGroupName] = useState(""); - const [newTagName, setNewTagName] = useState(""); - const [newTagEmoji, setNewTagEmoji] = useState(""); const [uploading, setUploading] = useState(false); const [pageLoading, setPageLoading] = useState(true); @@ -216,183 +205,6 @@ export default function ClothingEdit() { } }, [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 name = form.name.trim(); if (!name) { @@ -537,7 +349,6 @@ export default function ClothingEdit() { onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))} > - {item.emoji ? `${item.emoji} ` : ""} {item.name} @@ -563,180 +374,90 @@ export default function ClothingEdit() { return ( - + - - - + {form.imageFileKey ? ( + + ) : ( + + + 上传衣服图片 + + )} + + + {/* 基础信息 */} + 基础信息 + + + + setForm((prev) => ({ ...prev, name }))} + placeholder="例如:白色衬衫" + /> + + + + + setForm((prev) => ({ ...prev, brand }))} + placeholder="可选" + /> + + + + +