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

+5
View File
@@ -80,6 +80,7 @@
- 数据保存优先使用现有公共接口,例如 `data/loadData`、`data/saveData`。
- 尽量都走通用接口;只有通用接口明显无法保证体验或一致性时,再补特殊接口。
- 能在前端完成的能力优先放前端,减少后端特殊接口。
- 衣服和搭配支持「归属者」字段(`owner_id`,引用 `b_family_member.user_id`)。归属者仅作为标记,让家人知道这件衣服/搭配是谁的;数据仍为家庭共享,不做个人隔离,也不按归属者过滤可见性。
- 删除搭配时,只删除搭配和搭配明细,不删除衣服。
- 删除衣服时,如果衣服没有被搭配引用,直接删除或软删除该衣服。
- 删除衣服时,如果衣服已被搭配引用,需要提示用户是否同时删除相关搭配:
@@ -135,6 +136,7 @@
- name
- image_file_key
- category_id
- owner_id
- brand
- remark
- status
@@ -150,6 +152,7 @@
- `image_file_key`:走现有 S3 规则,上传到 `family_id/年/月/日/...`。
- `image_file_key` 必填。
- `status`:建议 1 启用,0 停用。
- `owner_id`:归属者,引用 `b_family_member.user_id`,可空。仅作为标记,让家人知道这件衣服是谁的,**不改变家庭共享语义,不做个人衣橱隔离**。
- 颜色、季节这类检索信息不做结构化字段,由标签承载。
### 搭配 b_clothing_outfit
@@ -162,6 +165,7 @@
- cover_file_key
- layout_json
- category_id
- owner_id
- remark
- status
- sort_number
@@ -177,6 +181,7 @@
- 缩略图由前端生成后通过 S3 上传,保存时写入 `cover_file_key`。
- 如果缩略图生成失败,可以降级使用搭配内第一件衣服图片作为封面。
- `layout_json` 保存画布级配置,例如画布宽高比例、背景、版本号等。
- `owner_id`:归属者,引用 `b_family_member.user_id`,可空。仅作为标记,让家人知道这套搭配是谁的,**不改变家庭共享语义,不做个人衣橱隔离**。
### 搭配明细 b_clothing_outfit_clothes
+11 -20
View File
@@ -1,7 +1,6 @@
drop table if exists b_clothing_tag_relation;
drop table if exists b_clothing_outfit_clothes;
drop table if exists b_clothing_tag;
drop table if exists b_clothing_tag_group;
drop table if exists b_clothing_category;
drop table if exists b_clothing_outfit;
drop table if exists b_clothing_clothes;
@@ -12,6 +11,7 @@ create table b_clothing_clothes (
name varchar(64) not null,
image_file_key varchar(512) not null,
category_id bigint,
owner_id bigint,
brand varchar(64) default '',
remark varchar(512) default '',
sort_number int not null default 0,
@@ -29,6 +29,7 @@ create table b_clothing_outfit (
cover_file_key varchar(512) default '',
layout_json jsonb,
category_id bigint,
owner_id bigint,
remark varchar(512) default '',
sort_number int not null default 0,
status smallint not null default 1,
@@ -68,23 +69,10 @@ create table b_clothing_category (
update_by bigint
);
create table b_clothing_tag_group (
id bigint primary key,
family_id bigint not null,
name varchar(32) not null,
type smallint not null,
sort_number int not null default 0,
status smallint not null default 1,
create_time timestamp,
update_time timestamp,
create_by bigint,
update_by bigint
);
create table b_clothing_tag (
id bigint primary key,
family_id bigint not null,
tag_group_id bigint not null,
parent_id bigint not null default 0,
name varchar(32) not null,
type smallint not null,
color varchar(7) default '',
@@ -111,9 +99,15 @@ create table b_clothing_tag_relation (
create index idx_b_clothing_clothes_family_status_category
on b_clothing_clothes (family_id, status, category_id, sort_number, id);
create index idx_b_clothing_clothes_family_owner
on b_clothing_clothes (family_id, owner_id, id);
create index idx_b_clothing_outfit_family_status_category
on b_clothing_outfit (family_id, status, category_id, sort_number, id);
create index idx_b_clothing_outfit_family_owner
on b_clothing_outfit (family_id, owner_id, id);
create index idx_b_clothing_outfit_clothes_outfit
on b_clothing_outfit_clothes (outfit_id, z_index, sort_number, id);
@@ -123,11 +117,8 @@ create index idx_b_clothing_outfit_clothes_clothes
create index idx_b_clothing_category_family_type
on b_clothing_category (family_id, status, type, sort_number, id);
create index idx_b_clothing_tag_group_family_type
on b_clothing_tag_group (family_id, status, type, sort_number, id);
create index idx_b_clothing_tag_family_group
on b_clothing_tag (family_id, status, type, tag_group_id, sort_number, id);
create index idx_b_clothing_tag_family_parent
on b_clothing_tag (family_id, status, type, parent_id, sort_number, id);
create index idx_b_clothing_tag_relation_target
on b_clothing_tag_relation (family_id, target_type, target_id, tag_id);
+12
View File
@@ -0,0 +1,12 @@
-- 增量迁移:给衣服和搭配表添加归属者字段
-- 说明:owner_id 引用 b_family_member.user_id;可空;仅为归属标记,
-- 数据仍为家庭共享,不做隔离。
alter table b_clothing_clothes add column if not exists owner_id bigint;
alter table b_clothing_outfit add column if not exists owner_id bigint;
create index if not exists idx_b_clothing_clothes_family_owner
on b_clothing_clothes (family_id, owner_id, id);
create index if not exists idx_b_clothing_outfit_family_owner
on b_clothing_outfit (family_id, owner_id, id);
+37
View File
@@ -0,0 +1,37 @@
-- 增量迁移:合并 b_clothing_tag_group 与 b_clothing_tag 为单表(加 parent_id)
-- 说明:parent_id = 0 表示标签组(父行);parent_id != 0 表示标签(子行),
-- 指向所属组的 id。type(0=衣服/1=搭配)保留在所有行上。
-- 应用方式:go run ./cmd/apply_sql app-go/db/clothing_merge_tag.sql
-- 1. 新增 parent_id 列
alter table b_clothing_tag add column if not exists parent_id bigint not null default 0;
-- 2. 把旧 tag_group_id 搬到 parent_id(仅当列仍存在时执行)
do $$
begin
if exists (
select 1 from information_schema.columns
where table_name = 'b_clothing_tag' and column_name = 'tag_group_id'
) then
update b_clothing_tag set parent_id = tag_group_id where parent_id = 0;
end if;
end $$;
-- 3. 把组数据搬进 b_clothing_tag(parent_id = 0)
insert into b_clothing_tag
(id, family_id, name, type, color, parent_id, sort_number, status,
create_time, update_time, create_by, update_by)
select id, family_id, name, type, '', 0, sort_number, status,
create_time, update_time, create_by, update_by
from b_clothing_tag_group
on conflict (id) do nothing;
-- 4. 清理旧结构
alter table b_clothing_tag drop column if exists tag_group_id;
drop index if exists idx_b_clothing_tag_family_group;
drop index if exists idx_b_clothing_tag_group_family_type;
drop table if exists b_clothing_tag_group;
-- 5. 新索引
create index if not exists idx_b_clothing_tag_family_parent
on b_clothing_tag (family_id, status, type, parent_id, sort_number, id);
@@ -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;
+536
View File
@@ -0,0 +1,536 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>分类与标签</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style>
:root {
--primary-color: #8b5cf6;
--primary-light: #ede9fe;
--bg-color: #f4f5f7;
--card-color: rgba(255, 255, 255, 0.85);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--danger-color: #ef4444;
--radius-lg: 20px;
--radius-md: 14px;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.06);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
-webkit-tap-highlight-color: transparent;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-color);
color: var(--text-primary);
padding-bottom: 100px;
}
.app-container {
max-width: 480px;
margin: 0 auto;
}
/* Header */
.header {
padding: 60px 20px 20px;
background: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
color: white;
border-radius: 0 0 30px 30px;
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.3);
}
.header h1 {
font-size: 28px;
font-weight: 700;
}
.header p {
font-size: 15px;
opacity: 0.9;
margin-top: 4px;
}
/* Content */
.content {
padding: 20px;
}
/* Section Divider */
.section-title {
font-size: 14px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 10px 0 10px 5px;
}
/* Card */
.card {
background: var(--card-color);
backdrop-filter: blur(10px);
border-radius: var(--radius-lg);
padding: 8px 16px;
margin-bottom: 16px;
box-shadow: var(--shadow);
border: 1px solid rgba(255, 255, 255, 0.2);
}
/* List Item */
.list-item {
display: flex;
align-items: center;
padding: 16px 0;
border-bottom: 1px solid #f0f0f0;
}
.list-item:last-child {
border-bottom: none;
}
.list-item .icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 14px;
font-size: 18px;
background: var(--primary-light);
color: var(--primary-color);
}
.list-item .info {
flex: 1;
}
.list-item .title {
font-weight: 600;
font-size: 16px;
}
.list-item .subtitle {
font-size: 13px;
color: var(--text-secondary);
margin-top: 2px;
}
.list-item .actions {
display: flex;
gap: 8px;
}
.btn-icon {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
background: #f3f4f6;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.btn-icon:active {
transform: scale(0.95);
background: #e5e7eb;
}
.btn-icon.delete {
color: var(--danger-color);
}
/* Tree View */
.tree-item {
padding: 14px 0;
}
.tree-parent {
display: flex;
align-items: center;
font-weight: 600;
font-size: 16px;
}
.tree-children {
padding-left: 30px;
margin-top: 8px;
border-left: 2px dashed #e0e0e0;
}
.tree-child {
display: flex;
align-items: center;
padding: 12px 0;
font-size: 15px;
color: var(--text-secondary);
}
.tree-child::before {
content: "";
width: 8px;
height: 8px;
border-radius: 50%;
background: #d1d5db;
margin-right: 12px;
}
/* FAB */
.fab {
position: fixed;
bottom: 30px;
right: 20px;
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
color: white;
border: none;
font-size: 24px;
cursor: pointer;
box-shadow: 0 8px 25px rgba(139, 92, 246, 0.4);
transition: all 0.3s;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
}
.fab:hover {
transform: scale(1.05) rotate(90deg);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(5px);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.modal-overlay.show {
opacity: 1;
visibility: visible;
}
.modal {
background: white;
border-radius: var(--radius-lg);
padding: 24px;
width: 90%;
max-width: 360px;
box-shadow: var(--shadow);
transform: translateY(20px);
transition: transform 0.3s;
}
.modal-overlay.show .modal {
transform: translateY(0);
}
.modal-header {
font-size: 20px;
font-weight: 700;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 16px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: var(--text-secondary);
}
.form-control {
width: 100%;
padding: 14px 16px;
border: 2px solid #e5e7eb;
border-radius: var(--radius-md);
font-size: 16px;
transition: border-color 0.2s;
}
.form-control:focus {
outline: none;
border-color: var(--primary-color);
}
.modal-footer {
display: flex;
gap: 12px;
margin-top: 24px;
}
.btn {
flex: 1;
padding: 14px;
border-radius: var(--radius-md);
border: none;
font-size: 16px;
font-weight: 600;
cursor: pointer;
}
.btn-primary {
background: var(--primary-color);
color: white;
}
.btn-secondary {
background: #f3f4f6;
color: var(--text-primary);
}
/* Empty State */
.empty-state {
text-align: center;
padding: 30px 20px;
color: var(--text-secondary);
}
.empty-state i {
font-size: 40px;
margin-bottom: 12px;
opacity: 0.5;
}
</style>
</head>
<body>
<div class="app-container">
<header class="header">
<h1>分类与标签</h1>
<p>统一管理您的内容组织方式</p>
</header>
<main class="content">
<!-- 分类管理模块 -->
<div class="section-title">分类管理</div>
<div class="card">
<div id="categoryList"></div>
</div>
<!-- 标签管理模块 -->
<div class="section-title">标签管理 (支持父子级)</div>
<div class="card">
<div id="tagTree"></div>
</div>
</main>
<!-- FAB -->
<button class="fab" id="fabBtn"><i class="fas fa-plus"></i></button>
<!-- Modal -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header" id="modalTitle">添加</div>
<div class="form-group">
<label for="inputName">名称</label>
<input type="text" id="inputName" class="form-control" placeholder="请输入名称">
</div>
<div class="form-group" id="parentGroup" style="display: none;">
<label for="selectParent">父级标签</label>
<select id="selectParent" class="form-control">
<option value="">无 (设为顶级)</option>
</select>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="cancelBtn">取消</button>
<button class="btn btn-primary" id="saveBtn">保存</button>
</div>
</div>
</div>
</div>
<script>
let categories = [{ id: 1, name: "💼 工作" }, { id: 2, name: "🏠 生活" }, { id: 3, name: "📚 学习" }];
let tags = [
{ id: 1, name: "🚀 项目", parentId: null },
{ id: 2, name: "前端", parentId: 1 },
{ id: 3, name: "后端", parentId: 1 },
{ id: 4, name: "🍎 健康", parentId: null },
{ id: 5, name: "健身", parentId: 4 }
];
let currentMode = 'category';
let editId = null;
const elements = {
categoryList: document.getElementById('categoryList'),
tagTree: document.getElementById('tagTree'),
fabBtn: document.getElementById('fabBtn'),
modal: document.getElementById('modalOverlay'),
modalTitle: document.getElementById('modalTitle'),
inputName: document.getElementById('inputName'),
parentGroup: document.getElementById('parentGroup'),
selectParent: document.getElementById('selectParent'),
cancelBtn: document.getElementById('cancelBtn'),
saveBtn: document.getElementById('saveBtn')
};
function renderCategories() {
if (categories.length === 0) {
elements.categoryList.innerHTML = `<div class="empty-state"><i class="far fa-folder-open"></i><p>暂无分类</p></div>`;
return;
}
elements.categoryList.innerHTML = categories.map(cat => `
<div class="list-item">
<div class="icon"><i class="fas fa-folder"></i></div>
<div class="info"><div class="title">${cat.name}</div></div>
<div class="actions">
<button class="btn-icon" onclick="editCategory(${cat.id})"><i class="fas fa-pen"></i></button>
<button class="btn-icon delete" onclick="deleteCategory(${cat.id})"><i class="fas fa-trash"></i></button>
</div>
</div>
`).join('');
}
function renderTags() {
const parents = tags.filter(t => t.parentId === null);
if (parents.length === 0) {
elements.tagTree.innerHTML = `<div class="empty-state"><i class="fas fa-tag"></i><p>暂无标签</p></div>`;
return;
}
elements.tagTree.innerHTML = parents.map(parent => {
const children = tags.filter(t => t.parentId === parent.id);
return `
<div class="tree-item">
<div class="tree-parent list-item">
<div class="icon"><i class="fas fa-tag"></i></div>
<div class="info"><div class="title">${parent.name}</div></div>
<div class="actions">
<button class="btn-icon" onclick="editTag(${parent.id})"><i class="fas fa-pen"></i></button>
<button class="btn-icon delete" onclick="deleteTag(${parent.id})"><i class="fas fa-trash"></i></button>
</div>
</div>
${children.map(child => `
<div class="tree-children">
<div class="tree-child list-item">
<div class="info"><div class="title">${child.name}</div></div>
<div class="actions">
<button class="btn-icon" onclick="editTag(${child.id})"><i class="fas fa-pen"></i></button>
<button class="btn-icon delete" onclick="deleteTag(${child.id})"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`).join('')}
</div>`;
}).join('');
}
function updateParentOptions() {
elements.selectParent.innerHTML = '<option value="">无 (设为顶级)</option>' +
tags.filter(t => t.parentId === null)
.map(t => `<option value="${t.id}">${t.name}</option>`).join('');
}
function openModal(mode, id = null) {
currentMode = mode;
editId = id;
elements.inputName.value = '';
elements.parentGroup.style.display = 'none';
if (mode === 'category') {
elements.modalTitle.textContent = id ? '编辑分类' : '新建分类';
if (id) { const cat = categories.find(c => c.id === id); if (cat) elements.inputName.value = cat.name; }
} else {
elements.modalTitle.textContent = id ? '编辑标签' : '新建标签';
elements.parentGroup.style.display = 'block';
updateParentOptions();
if (id) {
const tag = tags.find(t => t.id === id);
if (tag) {
elements.inputName.value = tag.name;
elements.selectParent.value = tag.parentId || '';
}
}
}
elements.modal.classList.add('show');
elements.inputName.focus();
}
function closeModal() { elements.modal.classList.remove('show'); }
function saveData() {
const name = elements.inputName.value.trim();
if (!name) { alert('名称不能为空'); return; }
if (currentMode === 'category') {
if (editId) { const cat = categories.find(c => c.id === editId); if (cat) cat.name = name; }
else { categories.push({ id: Date.now(), name }); }
renderCategories();
} else {
const parentId = elements.selectParent.value ? parseInt(elements.selectParent.value) : null;
if (editId) { const tag = tags.find(t => t.id === editId); if (tag) { tag.name = name; tag.parentId = parentId; } }
else { tags.push({ id: Date.now(), name, parentId }); }
renderTags();
}
closeModal();
}
// Global handlers
window.editCategory = (id) => openModal('category', id);
window.deleteCategory = (id) => {
if (confirm('确定删除?')) { categories = categories.filter(c => c.id !== id); renderCategories(); }
};
window.editTag = (id) => openModal('tag', id);
window.deleteTag = (id) => {
if (confirm('确定删除此标签及子标签?')) {
const idsToDelete = [id];
function findChildren(pid) { tags.filter(t => t.parentId === pid).forEach(child => { idsToDelete.push(child.id); findChildren(child.id); }); }
findChildren(id);
tags = tags.filter(t => !idsToDelete.includes(t.id));
renderTags();
}
};
// Events
elements.fabBtn.addEventListener('click', () => {
// 智能判断:如果分类为空,默认添加分类;否则弹出选择器或默认添加标签
// 为了简单直观,这里我们直接让FAB默认打开“添加标签”的窗口,因为标签更复杂
// 或者您可以改为打开一个Action Sheet
openModal('tag');
});
// 改进:长按FAB添加分类(可选交互)
let pressTimer;
elements.fabBtn.addEventListener('touchstart', () => {
pressTimer = setTimeout(() => openModal('category'), 500);
});
elements.fabBtn.addEventListener('touchend', () => clearTimeout(pressTimer));
elements.cancelBtn.addEventListener('click', closeModal);
elements.saveBtn.addEventListener('click', saveData);
elements.modal.addEventListener('click', (e) => { if (e.target === elements.modal) closeModal(); });
// Init
renderCategories();
renderTags();
</script>
</body>
</html>