20260703173210
This commit is contained in:
1 parent
d0f7f0d3d8
commit
3556d5e8c1
15 files changed
+853
-143
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user