20260703173210

This commit is contained in:
oneao committed 2026-07-03 17:32:11 +08:00
1 parent d0f7f0d3d8
commit 3556d5e8c1
15 files changed
+853 -143

No files matched your search

@@ -31,9 +31,10 @@ export default function ClothingHome() {
const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
const fetchCategories = useClothingStore((s) => s.fetchCategories);
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
const fetchMembers = useClothingStore((s) => s.fetchMembers);
const getOwnerName = useClothingStore((s) => s.getOwnerName);
const deleteClothesBundle = useClothingStore((s) => s.deleteClothesBundle);
const { loading, refreshing, runInitial, runRefresh } = useListLoading();
const { isLoading, startLoading, stopLoading } = useLoading();
@@ -72,8 +73,8 @@ export default function ClothingHome() {
const loadBaseData = useCallback(async () => {
if (!familyId) return;
await Promise.all([fetchClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Clothes), fetchTagGroups(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId)]);
}, [familyId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagGroups, fetchTagRelations, fetchTags]);
await Promise.all([fetchClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId), fetchMembers(familyId)]);
}, [familyId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagRelations, fetchTags, fetchMembers]);
useEffect(() => { if (familyId) runInitial(loadBaseData); }, [familyId]);
useFocusEffect(useCallback(() => { if (familyId) loadBaseData(); }, [familyId, loadBaseData]));
@@ -149,9 +150,9 @@ export default function ClothingHome() {
<TouchableOpacity key={item.id} activeOpacity={0.85} delayLongPress={260}
onPress={() => { setActiveItem(null); router.push(`/clothes/detail/${item.id}` as any); }}
onLongPress={(e) => { setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); setActiveItem(item); }}>
<ClothesGridItem item={item} categoryName={renderCategoryName(item.category_id)} aspectRatio={item.r} />
<ClothesGridItem item={item} categoryName={renderCategoryName(item.category_id)} ownerName={getOwnerName(item.owner_id)} aspectRatio={item.r} />
</TouchableOpacity>
), [renderCategoryName]);
), [getOwnerName, renderCategoryName]);
return (
<LayoutView>
@@ -7,27 +7,24 @@ import LayoutView from "@/components/layout/view";
import { useListLoading, useLoading } from "@/hooks/useLoading";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useClothingStore, useFamilyStore } from "@/store";
import { ClothingCategory, ClothingTag, ClothingTagGroup, ClothingTargetType } from "@/types/clothing";
import { ClothingCategory, ClothingTag, ClothingTargetType } from "@/types/clothing";
const COLORS = ["#EF4444","#F97316","#F59E0B","#84CC16","#22C55E","#14B8A6","#06B6D4","#3B82F6","#6366F1","#8B5CF6","#A855F7","#D946EF","#EC4899","#78716C"];
export default function ClothingManage() {
const familyId = useFamilyStore((s) => s.getFamilyId());
const categories = useClothingStore((s) => s.categories);
const tagGroups = useClothingStore((s) => s.tagGroups);
const tags = useClothingStore((s) => s.tags);
const fetchCategories = useClothingStore((s) => s.fetchCategories);
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
const fetchTags = useClothingStore((s) => s.fetchTags);
const saveCategoryChanges = useClothingStore((s) => s.saveCategoryChanges);
const saveTagGroupChanges = useClothingStore((s) => s.saveTagGroupChanges);
const saveTagChanges = useClothingStore((s) => s.saveTagChanges);
const { refreshing, runInitial, runRefresh } = useListLoading();
const { isLoading, startLoading, stopLoading } = useLoading();
const [tab, setTab] = useState<ClothingTargetType>(ClothingTargetType.Clothes);
const curCategories = useMemo(() => categories.filter((c) => c.type === tab), [categories, tab]);
const curTagGroups = useMemo(() => tagGroups.filter((g) => g.type === tab), [tagGroups, tab]);
const curTagGroups = useMemo(() => tags.filter((t) => t.parent_id === 0 && t.type === tab), [tags, tab]);
// 分类
const [catForm, setCatForm] = useState("");
@@ -37,9 +34,9 @@ export default function ClothingManage() {
// 标签分组
const [groupForm, setGroupForm] = useState("");
const [editingGroup, setEditingGroup] = useState<ClothingTagGroup | null>(null);
const [editingGroup, setEditingGroup] = useState<ClothingTag | null>(null);
const [showGroupSheet, setShowGroupSheet] = useState(false);
const [groupDelete, setGroupDelete] = useState<ClothingTagGroup | null>(null);
const [groupDelete, setGroupDelete] = useState<ClothingTag | null>(null);
// 标签
const [tagForm, setTagForm] = useState({ name: "", color: "" });
@@ -50,8 +47,8 @@ export default function ClothingManage() {
const loadData = useCallback(async () => {
if (!familyId) return;
await Promise.all([fetchCategories(familyId), fetchTagGroups(familyId), fetchTags(familyId)]);
}, [familyId, fetchCategories, fetchTagGroups, fetchTags]);
await Promise.all([fetchCategories(familyId), fetchTags(familyId)]);
}, [familyId, fetchCategories, fetchTags]);
useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]);
// 分类
@@ -68,24 +65,24 @@ export default function ClothingManage() {
}, [catDelete, loadData, saveCategoryChanges, startLoading, stopLoading]);
// 标签分组
const openGroup = useCallback((g?: ClothingTagGroup) => { setGroupForm(g?.name ?? ""); setEditingGroup(g ?? null); setShowGroupSheet(true); }, []);
const openGroup = useCallback((g?: ClothingTag) => { setGroupForm(g?.name ?? ""); setEditingGroup(g ?? null); setShowGroupSheet(true); }, []);
const saveGroup = useCallback(async () => {
if (!familyId || !groupForm.trim()) return; startLoading("保存中...");
try {
editingGroup ? await saveTagGroupChanges({ updates: [{ id: editingGroup.id, name: groupForm.trim() }] }) : await saveTagGroupChanges({ inserts: [{ name: groupForm.trim(), type: tab, family_id: familyId }] });
editingGroup ? await saveTagChanges({ updates: [{ id: editingGroup.id, name: groupForm.trim() }] }) : await saveTagChanges({ inserts: [{ name: groupForm.trim(), type: tab, family_id: familyId, parent_id: 0, color: "" }] });
setShowGroupSheet(false); await loadData(); stopLoading({ type: "success", message: "已保存" });
} catch { stopLoading({ type: "error", message: "保存失败" }); }
}, [editingGroup, familyId, groupForm, loadData, saveTagGroupChanges, startLoading, stopLoading, tab]);
}, [editingGroup, familyId, groupForm, loadData, saveTagChanges, startLoading, stopLoading, tab]);
const delGroup = useCallback(async () => { if (!groupDelete) return; startLoading("删除中...");
try { await saveTagChanges({ deletes: tags.filter((t) => t.tag_group_id === groupDelete.id).map((t) => ({ id: t.id })) }); await saveTagGroupChanges({ deletes: [{ id: groupDelete.id }] }); setGroupDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); }
}, [groupDelete, loadData, saveTagChanges, saveTagGroupChanges, startLoading, stopLoading, tags]);
try { const childIds = tags.filter((t) => t.parent_id === groupDelete.id).map((t) => ({ id: t.id })); await saveTagChanges({ deletes: [{ id: groupDelete.id }, ...childIds] }); setGroupDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); }
}, [groupDelete, loadData, saveTagChanges, startLoading, stopLoading, tags]);
// 标签
const openTag = useCallback((groupId: number, t?: ClothingTag) => { setActiveGroupId(groupId); setTagForm({ name: t?.name ?? "", color: t?.color ?? "" }); setEditingTag(t ?? null); setShowTagSheet(true); }, []);
const saveTag = useCallback(async () => {
if (!familyId || !activeGroupId || !tagForm.name.trim()) return; startLoading("保存中...");
try {
editingTag ? await saveTagChanges({ updates: [{ id: editingTag.id, name: tagForm.name.trim(), color: tagForm.color }] }) : await saveTagChanges({ inserts: [{ name: tagForm.name.trim(), color: tagForm.color, tag_group_id: activeGroupId, type: tab, family_id: familyId }] });
editingTag ? await saveTagChanges({ updates: [{ id: editingTag.id, name: tagForm.name.trim(), color: tagForm.color }] }) : await saveTagChanges({ inserts: [{ name: tagForm.name.trim(), color: tagForm.color, parent_id: activeGroupId, type: tab, family_id: familyId }] });
setShowTagSheet(false); await loadData(); stopLoading({ type: "success", message: "已保存" });
} catch { stopLoading({ type: "error", message: "保存失败" }); }
}, [activeGroupId, editingTag, familyId, loadData, saveTagChanges, startLoading, stopLoading, tab, tagForm]);
@@ -93,7 +90,7 @@ export default function ClothingManage() {
try { await saveTagChanges({ deletes: [{ id: tagDelete.id }] }); setTagDelete(null); await loadData(); stopLoading({ type: "success", message: "已删除" }); } catch { stopLoading({ type: "error", message: "删除失败" }); }
}, [loadData, saveTagChanges, startLoading, stopLoading, tagDelete]);
const getTagsByGroup = useCallback((groupId: number) => tags.filter((t) => t.tag_group_id === groupId), [tags]);
const getTagsByGroup = useCallback((groupId: number) => tags.filter((t) => t.parent_id === groupId), [tags]);
return (
<LayoutView>
@@ -4,7 +4,7 @@ import { router, useFocusEffect } from "expo-router";
import { Button, Dialog, Label, TextField } from "heroui-native";
import BottomSheet from "@/components/BottomSheet";
import ContextMenu from "@/components/ContextMenu";
import { FilterIcon, PencilIcon, PlusIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native";
import { FilterIcon, PencilIcon, PlusIcon, RotateCcwIcon, Trash2Icon, UserIcon } from "lucide-react-native";
import LayoutView from "@/components/layout/view";
import EmptyState from "@/components/EmptyStatus";
import ClothingImage from "@/components/clothing/ClothingImage";
@@ -28,6 +28,8 @@ export default function ClothingOutfitList() {
const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
const fetchClothes = useClothingStore((s) => s.fetchClothes);
const fetchMembers = useClothingStore((s) => s.fetchMembers);
const getOwnerName = useClothingStore((s) => s.getOwnerName);
const saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
const { loading, refreshing, runInitial, runRefresh } = useListLoading();
const { isLoading, startLoading, stopLoading } = useLoading();
@@ -46,8 +48,8 @@ export default function ClothingOutfitList() {
const loadData = useCallback(async () => {
if (!familyId) return;
await Promise.all([fetchOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit)]);
}, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchCategories, fetchTags, fetchTagRelations]);
await Promise.all([fetchOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit), fetchMembers(familyId)]);
}, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchCategories, fetchTags, fetchTagRelations, fetchMembers]);
useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]);
useFocusEffect(useCallback(() => { if (familyId) loadData(); }, [familyId, loadData]));
@@ -117,6 +119,7 @@ export default function ClothingOutfitList() {
const renderGridItem = useCallback((item: any) => {
const catName = item.category_id ? categoriesById.get(item.category_id)?.name : null;
const ownerName = getOwnerName(item.owner_id);
return (
<TouchableOpacity key={item.id} activeOpacity={0.85} delayLongPress={260}
onPress={() => router.push(`/clothes/outfit/detail/${item.id}` as any)}
@@ -127,13 +130,19 @@ export default function ClothingOutfitList() {
<View className="absolute bottom-1.5 left-1.5 rounded-full bg-black/50 px-2 py-0.5">
<Text className="text-[9px] text-white font-medium">{getClothesCount(item)} 件</Text>
</View>
{!!ownerName && (
<View className="absolute top-1.5 right-1.5 rounded-full bg-black/50 px-1.5 py-0.5 flex-row items-center">
<UserIcon size={8} color="#fff" />
<Text className="text-[9px] text-white font-medium ml-0.5" numberOfLines={1}>{ownerName}</Text>
</View>
)}
</View>
<Text className="text-xs font-medium text-gray-900 mt-1.5" numberOfLines={1}>{item.name}</Text>
{catName && <Text className="text-[10px] text-gray-400">{catName}</Text>}
</View>
</TouchableOpacity>
);
}, [categoriesById, getClothesCount, getCoverKey]);
}, [categoriesById, getClothesCount, getCoverKey, getOwnerName]);
return (
<LayoutView>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Text, View } from "react-native";
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
import { Button, Dialog, useThemeColor } from "heroui-native";
import { PencilIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native";
import { PencilIcon, RotateCcwIcon, Trash2Icon, UserIcon } from "lucide-react-native";
import PageLayout from "@/layouts/PageLayout";
import LayoutView from "@/components/layout/view";
import ClothingImage from "@/components/clothing/ClothingImage";
@@ -28,9 +28,10 @@ export default function ClothingDetail() {
const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
const fetchCategories = useClothingStore((s) => s.fetchCategories);
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
const fetchMembers = useClothingStore((s) => s.fetchMembers);
const getOwnerName = useClothingStore((s) => s.getOwnerName);
const deleteClothesBundle = useClothingStore((s) => s.deleteClothesBundle);
const { isLoading, startLoading, stopLoading } = useLoading();
const notifyModuleOperation = useModuleChatNotifier();
@@ -39,6 +40,7 @@ export default function ClothingDetail() {
const item = useMemo(() => clothes.find((c) => c.id === clothesId) ?? null, [clothesId, clothes]);
const categoryName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_id]);
const ownerName = useMemo(() => getOwnerName(item?.owner_id), [getOwnerName, item?.owner_id]);
const selectedTags = useMemo(() => {
const ids = new Set(tagRelations.filter((r) => r.target_type === ClothingTargetType.Clothes && r.target_id === clothesId).map((r) => r.tag_id));
return tags.filter((t) => ids.has(t.id));
@@ -46,9 +48,9 @@ export default function ClothingDetail() {
const loadData = useCallback(async () => {
if (!familyId || !clothesId) return; setPageLoading(true);
try { await Promise.all([fetchClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Clothes), fetchTagGroups(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId, ClothingTargetType.Clothes, clothesId)]); }
try { await Promise.all([fetchClothes(familyId), fetchOutfits(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Clothes), fetchTags(familyId, ClothingTargetType.Clothes), fetchTagRelations(familyId, ClothingTargetType.Clothes, clothesId), fetchMembers(familyId)]); }
finally { setPageLoading(false); }
}, [familyId, clothesId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagGroups, fetchTagRelations, fetchTags]);
}, [familyId, clothesId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagRelations, fetchTags, fetchMembers]);
useEffect(() => { loadData(); }, [loadData]);
useFocusEffect(useCallback(() => { if (familyId && clothesId) loadData(); }, [familyId, loadData, clothesId]));
@@ -89,6 +91,12 @@ export default function ClothingDetail() {
{/* 分类 + 标签数 - 横向排列,不套卡片 */}
<View className="flex-row items-center gap-3 mt-4 pb-4 border-b border-gray-100">
<View className="rounded-full bg-accent px-3 py-1"><Text className="text-xs text-white">{categoryName}</Text></View>
{!!ownerName && (
<View className="flex-row items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1">
<UserIcon size={10} color="#4B5563" />
<Text className="text-xs text-gray-600">{ownerName}</Text>
</View>
)}
{selectedTags.length > 0 && (
<View className="flex-row flex-wrap gap-1.5 flex-1">
{selectedTags.map((tag) => (
+73 -12
View File
@@ -16,13 +16,12 @@ import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useLoading } from "@/hooks/useLoading";
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
import { getUniqueIdApi } from "@/request/api";
import { useClothingStore, useFamilyStore } from "@/store";
import { useClothingStore, useFamilyStore, useUserStore, type ClothingFamilyMember } from "@/store";
import {
ClothingCategory,
ClothingClothes,
ClothingStatus,
ClothingTag,
ClothingTagGroup,
ClothingTargetType,
} from "@/types/clothing";
import { deleteS3FileQuietly } from "@/utils/file";
@@ -31,6 +30,7 @@ interface ClothesFormState {
name: string;
imageFileKey: string;
categoryId: number | null;
ownerId: number | null;
brand: string;
remark: string;
sortNumber?: number;
@@ -40,6 +40,7 @@ const emptyForm: ClothesFormState = {
name: "",
imageFileKey: "",
categoryId: null,
ownerId: null,
brand: "",
remark: "",
};
@@ -49,6 +50,7 @@ function createEditForm(item: ClothingClothes): ClothesFormState {
name: item.name,
imageFileKey: item.image_file_key,
categoryId: item.category_id ?? null,
ownerId: item.owner_id ?? null,
brand: item.brand ?? "",
remark: item.remark ?? "",
sortNumber: item.sort_number,
@@ -65,16 +67,17 @@ export default function ClothingEdit() {
const editId = id ? Number(id) : 0;
const isEdit = editId > 0;
const familyId = useFamilyStore((s) => s.getFamilyId());
const currentUserId = useUserStore((s) => s.getUserId());
const clothes = useClothingStore((s) => s.clothes);
const categories = useClothingStore((s) => s.categories);
const tagGroups = useClothingStore((s) => s.tagGroups);
const tags = useClothingStore((s) => s.tags);
const tagRelations = useClothingStore((s) => s.tagRelations);
const members = useClothingStore((s) => s.members);
const fetchClothes = useClothingStore((s) => s.fetchClothes);
const fetchCategories = useClothingStore((s) => s.fetchCategories);
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
const fetchMembers = useClothingStore((s) => s.fetchMembers);
const saveClothesBundle = useClothingStore((s) => s.saveClothesBundle);
const uploadClothesImage = useClothingStore((s) => s.uploadClothesImage);
const { isLoading, startLoading, stopLoading } = useLoading();
@@ -91,21 +94,21 @@ export default function ClothingEdit() {
);
const clothesTagGroups = useMemo(
() => tagGroups.filter((item) => item.type === ClothingTargetType.Clothes),
[tagGroups],
() => tags.filter((item) => item.type === ClothingTargetType.Clothes && item.parent_id === 0),
[tags],
);
const clothesTags = useMemo(
() => tags.filter((item) => item.type === ClothingTargetType.Clothes),
() => tags.filter((item) => item.type === ClothingTargetType.Clothes && item.parent_id !== 0),
[tags],
);
const tagsByGroupId = useMemo(() => {
const map = new Map<number, ClothingTag[]>();
clothesTags.forEach((tag) => {
const groupTags = map.get(tag.tag_group_id) ?? [];
const groupTags = map.get(tag.parent_id) ?? [];
groupTags.push(tag);
map.set(tag.tag_group_id, groupTags);
map.set(tag.parent_id, groupTags);
});
return map;
}, [clothesTags]);
@@ -123,9 +126,9 @@ export default function ClothingEdit() {
await Promise.all([
fetchClothes(familyId),
fetchCategories(familyId, ClothingTargetType.Clothes),
fetchTagGroups(familyId, ClothingTargetType.Clothes),
fetchTags(familyId, ClothingTargetType.Clothes),
fetchTagRelations(familyId, ClothingTargetType.Clothes, isEdit ? editId : undefined),
fetchMembers(familyId),
]);
if (isEdit) {
@@ -144,16 +147,20 @@ export default function ClothingEdit() {
)
.map((row) => row.tag_id),
);
} else {
// 新增时默认归属者为当前登录用户
setForm({ ...emptyForm, ownerId: currentUserId || null });
}
} finally {
setPageLoading(false);
}
}, [
currentUserId,
editId,
familyId,
fetchCategories,
fetchClothes,
fetchTagGroups,
fetchMembers,
fetchTagRelations,
fetchTags,
isEdit,
@@ -248,6 +255,7 @@ export default function ClothingEdit() {
name,
image_file_key: form.imageFileKey,
category_id: form.categoryId,
owner_id: form.ownerId,
brand: form.brand.trim(),
remark: form.remark.trim(),
sort_number: form.sortNumber ?? clothes.length,
@@ -336,6 +344,32 @@ export default function ClothingEdit() {
[form.categoryId],
);
const renderMemberChip = useCallback(
(member: ClothingFamilyMember) => {
const selected = form.ownerId === member.user_id;
return (
<TouchableOpacity
key={member.user_id}
className={`rounded-full border px-3 py-2 ${
selected ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"
}`}
onPress={() =>
setForm((prev) => ({
...prev,
ownerId: prev.ownerId === member.user_id ? null : member.user_id,
}))
}
>
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
{member.nickname || `用户${member.user_id}`}
</Text>
</TouchableOpacity>
);
},
[form.ownerId],
);
const renderTagChip = useCallback(
(item: ClothingTag) => {
const selected = selectedTagIds.includes(item.id);
@@ -359,7 +393,7 @@ export default function ClothingEdit() {
);
const renderTagGroup = useCallback(
(group: ClothingTagGroup) => {
(group: ClothingTag) => {
const groupTags = tagsByGroupId.get(group.id) ?? [];
if (groupTags.length === 0) return null;
@@ -448,6 +482,33 @@ export default function ClothingEdit() {
{clothesCategories.map(renderCategoryChip)}
</View>
{/* 归属者 */}
{members.length > 0 && (
<>
<Text className="text-base font-semibold text-gray-900 mb-1 ml-1">归属者</Text>
<Text className="text-xs text-gray-400 mb-3 ml-1">
衣服仍为家庭共享,仅用于让家人知道这件衣服是谁的
</Text>
<View className="flex-row flex-wrap gap-2 mb-4">
<TouchableOpacity
className={`rounded-full border px-3 py-2 ${
form.ownerId == null
? "border-gray-900 bg-gray-900"
: "border-gray-200 bg-white"
}`}
onPress={() => setForm((prev) => ({ ...prev, ownerId: null }))}
>
<Text
className={`text-xs ${form.ownerId == null ? "text-white" : "text-gray-700"}`}
>
不指定
</Text>
</TouchableOpacity>
{members.map(renderMemberChip)}
</View>
</>
)}
{/* 标签 */}
{clothesTagGroups.length > 0 && (
<>
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
import { Button, Dialog, useThemeColor } from "heroui-native";
import { PencilIcon, RotateCcwIcon, Trash2Icon } from "lucide-react-native";
import { PencilIcon, RotateCcwIcon, Trash2Icon, UserIcon } from "lucide-react-native";
import PageLayout from "@/layouts/PageLayout";
import LayoutView from "@/components/layout/view";
import ClothingImage from "@/components/clothing/ClothingImage";
@@ -31,6 +31,8 @@ export default function OutfitDetail() {
const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
const fetchClothes = useClothingStore((s) => s.fetchClothes);
const fetchMembers = useClothingStore((s) => s.fetchMembers);
const getOwnerName = useClothingStore((s) => s.getOwnerName);
const saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
const { isLoading, startLoading, stopLoading } = useLoading();
const notifyModuleOperation = useModuleChatNotifier();
@@ -39,15 +41,16 @@ export default function OutfitDetail() {
const item = useMemo(() => outfits.find((o) => o.id === outfitId) ?? null, [outfitId, outfits]);
const categoryName = useMemo(() => { if (!item?.category_id) return "未分类"; return categories.find((c) => c.id === item.category_id)?.name ?? "未分类"; }, [categories, item?.category_id]);
const ownerName = useMemo(() => getOwnerName(item?.owner_id), [getOwnerName, item?.owner_id]);
const details = useMemo(() => outfitClothes.filter((r) => r.outfit_id === outfitId).sort((a, b) => a.z_index - b.z_index || a.sort_number - b.sort_number).map((r) => ({ ...r, clothes: clothes.find((c) => c.id === r.clothes_id) })).filter((r) => r.clothes), [clothes, outfitClothes, outfitId]);
const selectedTags = useMemo(() => { const ids = new Set(tagRelations.filter((r) => r.target_type === ClothingTargetType.Outfit && r.target_id === outfitId).map((r) => r.tag_id)); return tags.filter((t) => ids.has(t.id)); }, [outfitId, tagRelations, tags]);
const getCoverKey = useCallback(() => { if (!item) return ""; if (item.cover_file_key) return item.cover_file_key; return details[0]?.clothes?.image_file_key ?? ""; }, [details, item]);
const loadData = useCallback(async () => {
if (!familyId || !outfitId) return; setPageLoading(true);
try { await Promise.all([fetchOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit, outfitId)]); }
try { await Promise.all([fetchOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit, outfitId), fetchMembers(familyId)]); }
finally { setPageLoading(false); }
}, [familyId, outfitId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTags, fetchTagRelations]);
}, [familyId, outfitId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTags, fetchTagRelations, fetchMembers]);
useEffect(() => { loadData(); }, [loadData]);
useFocusEffect(useCallback(() => { if (familyId && outfitId) loadData(); }, [familyId, loadData, outfitId]));
@@ -85,6 +88,12 @@ export default function OutfitDetail() {
<View className="flex-row items-center gap-3 mt-4 pb-4 border-b border-gray-100">
<View className="rounded-full bg-accent px-3 py-1"><Text className="text-xs text-white">{categoryName}</Text></View>
<Text className="text-xs text-gray-500">{details.length} 件衣服</Text>
{!!ownerName && (
<View className="flex-row items-center gap-1 rounded-full bg-gray-100 px-2.5 py-1">
<UserIcon size={10} color="#4B5563" />
<Text className="text-xs text-gray-600">{ownerName}</Text>
</View>
)}
{selectedTags.map((tag) => (
<View key={tag.id} className="rounded-full px-2.5 py-1" style={{ backgroundColor: tag.color || "#F3F4F6" }}>
<Text className="text-xs font-medium" style={{ color: tag.color ? "#fff" : "#4B5563" }}>{tag.name}</Text>
@@ -23,13 +23,12 @@ import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useLoading } from "@/hooks/useLoading";
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
import { getUniqueIdApi } from "@/request/api";
import { useClothingStore, useFamilyStore } from "@/store";
import { useClothingStore, useFamilyStore, useUserStore, type ClothingFamilyMember } from "@/store";
import {
ClothingCategory,
ClothingClothes,
ClothingStatus,
ClothingTag,
ClothingTagGroup,
ClothingTargetType,
} from "@/types/clothing";
import { deleteS3FileQuietly } from "@/utils/file";
@@ -146,19 +145,20 @@ export default function ClothingOutfitEdit() {
const editId = id ? Number(id) : 0;
const isEdit = editId > 0;
const familyId = useFamilyStore((s) => s.getFamilyId());
const currentUserId = useUserStore((s) => s.getUserId());
const accentColor = useThemeColor("accent");
const clothes = useClothingStore((s) => s.clothes);
const categories = useClothingStore((s) => s.categories);
const tagGroups = useClothingStore((s) => s.tagGroups);
const tags = useClothingStore((s) => s.tags);
const tagRelations = useClothingStore((s) => s.tagRelations);
const members = useClothingStore((s) => s.members);
const fetchClothes = useClothingStore((s) => s.fetchClothes);
const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
const fetchCategories = useClothingStore((s) => s.fetchCategories);
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
const fetchMembers = useClothingStore((s) => s.fetchMembers);
const saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
const uploadOutfitCover = useClothingStore((s) => s.uploadOutfitCover);
const { isLoading, startLoading, stopLoading } = useLoading();
@@ -167,6 +167,7 @@ export default function ClothingOutfitEdit() {
const canvasRef = useRef<View>(null);
const [name, setName] = useState("");
const [categoryId, setCategoryId] = useState<number | null>(null);
const [ownerId, setOwnerId] = useState<number | null>(null);
const [remark, setRemark] = useState("");
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
const [canvasSize, setCanvasSize] = useState(0);
@@ -211,19 +212,19 @@ export default function ClothingOutfitEdit() {
[categories],
);
const outfitTagGroups = useMemo(
() => tagGroups.filter((item) => item.type === ClothingTargetType.Outfit),
[tagGroups],
() => tags.filter((item) => item.type === ClothingTargetType.Outfit && item.parent_id === 0),
[tags],
);
const outfitTags = useMemo(
() => tags.filter((item) => item.type === ClothingTargetType.Outfit),
() => tags.filter((item) => item.type === ClothingTargetType.Outfit && item.parent_id !== 0),
[tags],
);
const tagsByGroupId = useMemo(() => {
const map = new Map<number, ClothingTag[]>();
outfitTags.forEach((tag) => {
const groupTags = map.get(tag.tag_group_id) ?? [];
const groupTags = map.get(tag.parent_id) ?? [];
groupTags.push(tag);
map.set(tag.tag_group_id, groupTags);
map.set(tag.parent_id, groupTags);
});
return map;
}, [outfitTags]);
@@ -242,9 +243,9 @@ export default function ClothingOutfitEdit() {
fetchOutfits(familyId),
fetchOutfitClothes(familyId),
fetchCategories(familyId, ClothingTargetType.Outfit),
fetchTagGroups(familyId, ClothingTargetType.Outfit),
fetchTags(familyId, ClothingTargetType.Outfit),
fetchTagRelations(familyId, ClothingTargetType.Outfit, isEdit ? editId : undefined),
fetchMembers(familyId),
]);
if (isEdit) {
@@ -262,6 +263,7 @@ export default function ClothingOutfitEdit() {
setName(outfit.name);
setCategoryId(outfit.category_id ?? null);
setOwnerId(outfit.owner_id ?? null);
setRemark(outfit.remark ?? "");
setSelectedTagIds(
state.tagRelations
@@ -284,18 +286,22 @@ export default function ClothingOutfitEdit() {
})),
);
setSelectedUid(details[0] ? `detail_${details[0].id}` : null);
} else {
// 新增时默认归属者为当前登录用户
setOwnerId(currentUserId || null);
}
} finally {
setPageLoading(false);
}
}, [
currentUserId,
editId,
familyId,
fetchCategories,
fetchClothes,
fetchMembers,
fetchOutfitClothes,
fetchOutfits,
fetchTagGroups,
fetchTagRelations,
fetchTags,
isEdit,
@@ -417,6 +423,27 @@ export default function ClothingOutfitEdit() {
[categoryId],
);
const renderMemberChip = useCallback(
(member: ClothingFamilyMember) => {
const selected = ownerId === member.user_id;
return (
<TouchableOpacity
key={member.user_id}
className={`rounded-full border px-3 py-2 ${
selected ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"
}`}
onPress={() => setOwnerId((prev) => (prev === member.user_id ? null : member.user_id))}
>
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
{member.nickname || `用户${member.user_id}`}
</Text>
</TouchableOpacity>
);
},
[ownerId],
);
const renderTagChip = useCallback(
(item: ClothingTag) => {
const selected = selectedTagIds.includes(item.id);
@@ -440,7 +467,7 @@ export default function ClothingOutfitEdit() {
);
const renderTagGroup = useCallback(
(group: ClothingTagGroup) => {
(group: ClothingTag) => {
const groupTags = tagsByGroupId.get(group.id) ?? [];
if (groupTags.length === 0) return null;
@@ -531,6 +558,7 @@ export default function ClothingOutfitEdit() {
background: "light",
},
category_id: categoryId,
owner_id: ownerId,
remark: remark.trim(),
sort_number: currentOutfit?.sort_number ?? state.outfits.length,
status: ClothingStatus.Enabled,
@@ -637,6 +665,7 @@ export default function ClothingOutfitEdit() {
items,
name,
notifyModuleOperation,
ownerId,
remark,
router,
saveOutfitBundle,
@@ -712,6 +741,31 @@ export default function ClothingOutfitEdit() {
{outfitCategories.map(renderCategoryChip)}
</View>
{/* 归属者 */}
{members.length > 0 && (
<>
<Text className="text-base font-semibold text-gray-900 mb-1 ml-1">归属者</Text>
<Text className="text-xs text-gray-400 mb-3 ml-1">
搭配仍为家庭共享,仅用于让家人知道这套搭配是谁的
</Text>
<View className="flex-row flex-wrap gap-2 mb-4">
<TouchableOpacity
className={`rounded-full border px-3 py-2 ${
ownerId == null ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"
}`}
onPress={() => setOwnerId(null)}
>
<Text
className={`text-xs ${ownerId == null ? "text-white" : "text-gray-700"}`}
>
不指定
</Text>
</TouchableOpacity>
{members.map(renderMemberChip)}
</View>
</>
)}
{/* 标签 */}
{outfitTagGroups.length > 0 && (
<>
@@ -797,11 +851,11 @@ export default function ClothingOutfitEdit() {
)}
{/* 标签按分组 */}
{tags.filter((t) => t.type === ClothingTargetType.Clothes).length > 0 && (() => {
const ctGroups = tagGroups.filter((g) => g.type === ClothingTargetType.Clothes);
const ctTags = tags.filter((t) => t.type === ClothingTargetType.Clothes);
{tags.filter((t) => t.type === ClothingTargetType.Clothes && t.parent_id !== 0).length > 0 && (() => {
const ctGroups = tags.filter((g) => g.type === ClothingTargetType.Clothes && g.parent_id === 0);
const ctTags = tags.filter((t) => t.type === ClothingTargetType.Clothes && t.parent_id !== 0);
return ctGroups.map((g) => {
const gTags = ctTags.filter((t) => t.tag_group_id === g.id);
const gTags = ctTags.filter((t) => t.parent_id === g.id);
if (gTags.length === 0) return null;
return (
<View key={g.id}>
@@ -1,14 +1,16 @@
import { Text, View } from "react-native";
import { UserIcon } from "lucide-react-native";
import ClothingImage from "./ClothingImage";
import { ClothingClothes } from "@/types/clothing";
interface ClothesGridItemProps {
item: ClothingClothes;
categoryName?: string;
ownerName?: string;
aspectRatio?: number;
}
export default function ClothesGridItem({ item, categoryName, aspectRatio = 1 }: ClothesGridItemProps) {
export default function ClothesGridItem({ item, categoryName, ownerName, aspectRatio = 1 }: ClothesGridItemProps) {
return (
<View className="flex-col">
<View className="relative rounded-xl overflow-hidden bg-gray-100" style={{ aspectRatio }}>
@@ -18,6 +20,12 @@ export default function ClothesGridItem({ item, categoryName, aspectRatio = 1 }:
<Text className="text-[9px] text-white font-medium">{categoryName}</Text>
</View>
)}
{!!ownerName && (
<View className="absolute top-1.5 right-1.5 rounded-full bg-black/50 px-1.5 py-0.5 flex-row items-center">
<UserIcon size={8} color="#fff" />
<Text className="text-[9px] text-white font-medium ml-0.5" numberOfLines={1}>{ownerName}</Text>
</View>
)}
</View>
<Text className="text-xs font-medium text-gray-900 mt-1.5" numberOfLines={1}>{item.name}</Text>
</View>
+41 -48
View File
@@ -8,7 +8,6 @@ import {
ClothingSaveChanges,
ClothingStatus,
ClothingTag,
ClothingTagGroup,
ClothingTagRelation,
ClothingTargetType,
} from "@/types/clothing";
@@ -18,19 +17,25 @@ const TABLES = {
outfit: "b_clothing_outfit",
outfitClothes: "b_clothing_outfit_clothes",
category: "b_clothing_category",
tagGroup: "b_clothing_tag_group",
tag: "b_clothing_tag",
tagRelation: "b_clothing_tag_relation",
familyMember: "b_family_member",
} as const;
export interface ClothingFamilyMember {
user_id: number;
nickname: string;
avatar?: string;
}
interface ClothingState {
clothes: ClothingClothes[];
outfits: ClothingOutfit[];
outfitClothes: ClothingOutfitClothes[];
categories: ClothingCategory[];
tagGroups: ClothingTagGroup[];
tags: ClothingTag[];
tagRelations: ClothingTagRelation[];
members: ClothingFamilyMember[];
clear: () => void;
@@ -39,13 +44,14 @@ interface ClothingState {
fetchOutfits: (familyId: number) => Promise<void>;
fetchOutfitClothes: (familyId: number, outfitId?: number) => Promise<void>;
fetchCategories: (familyId: number, type?: ClothingTargetType) => Promise<void>;
fetchTagGroups: (familyId: number, type?: ClothingTargetType) => Promise<void>;
fetchTags: (familyId: number, type?: ClothingTargetType, tagGroupId?: number) => Promise<void>;
fetchTags: (familyId: number, type?: ClothingTargetType, parentId?: number) => Promise<void>;
fetchTagRelations: (
familyId: number,
targetType?: ClothingTargetType,
targetId?: number,
) => Promise<void>;
fetchMembers: (familyId: number) => Promise<void>;
getOwnerName: (ownerId?: number | null) => string;
saveClothesChanges: (changes: ClothingSaveChanges<ClothingClothes>) => Promise<boolean>;
saveOutfitChanges: (changes: ClothingSaveChanges<ClothingOutfit>) => Promise<boolean>;
@@ -53,16 +59,12 @@ interface ClothingState {
changes: ClothingSaveChanges<ClothingOutfitClothes>,
) => Promise<boolean>;
saveCategoryChanges: (changes: ClothingSaveChanges<ClothingCategory>) => Promise<boolean>;
saveTagGroupChanges: (changes: ClothingSaveChanges<ClothingTagGroup>) => Promise<boolean>;
saveTagChanges: (changes: ClothingSaveChanges<ClothingTag>) => Promise<boolean>;
saveTagRelationChanges: (changes: ClothingSaveChanges<ClothingTagRelation>) => Promise<boolean>;
saveMetadataBundle: (payload: {
categoryInserts?: Partial<ClothingCategory>[];
categoryUpdates?: Partial<ClothingCategory>[];
categoryDeletes?: Partial<ClothingCategory>[];
tagGroupInserts?: Partial<ClothingTagGroup>[];
tagGroupUpdates?: Partial<ClothingTagGroup>[];
tagGroupDeletes?: Partial<ClothingTagGroup>[];
tagInserts?: Partial<ClothingTag>[];
tagUpdates?: Partial<ClothingTag>[];
tagDeletes?: Partial<ClothingTag>[];
@@ -121,9 +123,9 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
outfits: [],
outfitClothes: [],
categories: [],
tagGroups: [],
tags: [],
tagRelations: [],
members: [],
clear: () =>
set({
@@ -131,9 +133,9 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
outfits: [],
outfitClothes: [],
categories: [],
tagGroups: [],
tags: [],
tagRelations: [],
members: [],
}),
fetchAll: async (familyId: number) => {
@@ -141,9 +143,9 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
get().fetchClothes(familyId),
get().fetchOutfits(familyId),
get().fetchCategories(familyId),
get().fetchTagGroups(familyId),
get().fetchTags(familyId),
get().fetchTagRelations(familyId),
get().fetchMembers(familyId),
]);
},
@@ -213,24 +215,7 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
}
},
fetchTagGroups: async (familyId: number, type?: ClothingTargetType) => {
if (!familyId) return;
const { condition, args } = typeCondition(type);
const res = await loadDataApi({
view_name: TABLES.tagGroup,
search_condition: condition,
search_columns: [],
order_by: "type asc, sort_number asc, id asc",
args: [familyId, ClothingStatus.Enabled, ...args],
});
if (res.isSuccess) {
set({ tagGroups: res.data as ClothingTagGroup[] });
}
},
fetchTags: async (familyId: number, type?: ClothingTargetType, tagGroupId?: number) => {
fetchTags: async (familyId: number, type?: ClothingTargetType, parentId?: number) => {
if (!familyId) return;
const conditions = ["family_id = ?", "status = ?"];
@@ -241,16 +226,16 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
args.push(type);
}
if (tagGroupId !== undefined) {
conditions.push("tag_group_id = ?");
args.push(tagGroupId);
if (parentId !== undefined) {
conditions.push("parent_id = ?");
args.push(parentId);
}
const res = await loadDataApi({
view_name: TABLES.tag,
search_condition: conditions.join(" and "),
search_columns: [],
order_by: "type asc, tag_group_id asc, sort_number asc, id asc",
order_by: "type asc, parent_id asc, sort_number asc, id asc",
args,
});
@@ -292,6 +277,28 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
}
},
fetchMembers: async (familyId: number) => {
if (!familyId) return;
const res = await loadDataApi({
view_name: TABLES.familyMember,
search_condition: "family_id = ?",
search_columns: ["user_id", "nickname"],
order_by: "id asc",
args: [familyId],
});
if (res.isSuccess) {
set({ members: (res.data as ClothingFamilyMember[]) ?? [] });
}
},
getOwnerName: (ownerId?: number | null) => {
if (!ownerId) return "";
const m = get().members.find((row) => row.user_id === ownerId);
return m?.nickname || `用户${ownerId}`;
},
saveClothesChanges: async (changes: ClothingSaveChanges<ClothingClothes>) => {
const res = await saveDataApi([
{ table_name: TABLES.clothes, key_field: "id", ...normalizeChanges(changes) },
@@ -320,13 +327,6 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
return res.isSuccess;
},
saveTagGroupChanges: async (changes: ClothingSaveChanges<ClothingTagGroup>) => {
const res = await saveDataApi([
{ table_name: TABLES.tagGroup, key_field: "id", ...normalizeChanges(changes) },
]);
return res.isSuccess;
},
saveTagChanges: async (changes: ClothingSaveChanges<ClothingTag>) => {
const res = await saveDataApi([
{ table_name: TABLES.tag, key_field: "id", ...normalizeChanges(changes) },
@@ -350,13 +350,6 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
updates: payload.categoryUpdates ?? [],
deletes: payload.categoryDeletes ?? [],
},
{
table_name: TABLES.tagGroup,
key_field: "id",
inserts: payload.tagGroupInserts ?? [],
updates: payload.tagGroupUpdates ?? [],
deletes: payload.tagGroupDeletes ?? [],
},
{
table_name: TABLES.tag,
key_field: "id",
+3 -14
View File
@@ -14,6 +14,7 @@ export interface ClothingClothes {
name: string;
image_file_key: string;
category_id?: number | null;
owner_id?: number | null;
brand?: string;
remark?: string;
sort_number: number;
@@ -37,6 +38,7 @@ export interface ClothingOutfit {
cover_file_key?: string;
layout_json?: ClothingOutfitLayout | Record<string, unknown>;
category_id?: number | null;
owner_id?: number | null;
remark?: string;
sort_number: number;
status: ClothingStatus;
@@ -76,23 +78,10 @@ export interface ClothingCategory {
update_by?: number;
}
export interface ClothingTagGroup {
id: number;
family_id: number;
name: string;
type: ClothingTargetType;
sort_number: number;
status: ClothingStatus;
create_time?: string;
update_time?: string;
create_by?: number;
update_by?: number;
}
export interface ClothingTag {
id: number;
family_id: number;
tag_group_id: number;
parent_id: number;
name: string;
type: ClothingTargetType;
color?: string;