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`。
|
- 数据保存优先使用现有公共接口,例如 `data/loadData`、`data/saveData`。
|
||||||
- 尽量都走通用接口;只有通用接口明显无法保证体验或一致性时,再补特殊接口。
|
- 尽量都走通用接口;只有通用接口明显无法保证体验或一致性时,再补特殊接口。
|
||||||
- 能在前端完成的能力优先放前端,减少后端特殊接口。
|
- 能在前端完成的能力优先放前端,减少后端特殊接口。
|
||||||
|
- 衣服和搭配支持「归属者」字段(`owner_id`,引用 `b_family_member.user_id`)。归属者仅作为标记,让家人知道这件衣服/搭配是谁的;数据仍为家庭共享,不做个人隔离,也不按归属者过滤可见性。
|
||||||
- 删除搭配时,只删除搭配和搭配明细,不删除衣服。
|
- 删除搭配时,只删除搭配和搭配明细,不删除衣服。
|
||||||
- 删除衣服时,如果衣服没有被搭配引用,直接删除或软删除该衣服。
|
- 删除衣服时,如果衣服没有被搭配引用,直接删除或软删除该衣服。
|
||||||
- 删除衣服时,如果衣服已被搭配引用,需要提示用户是否同时删除相关搭配:
|
- 删除衣服时,如果衣服已被搭配引用,需要提示用户是否同时删除相关搭配:
|
||||||
@@ -135,6 +136,7 @@
|
|||||||
- name
|
- name
|
||||||
- image_file_key
|
- image_file_key
|
||||||
- category_id
|
- category_id
|
||||||
|
- owner_id
|
||||||
- brand
|
- brand
|
||||||
- remark
|
- remark
|
||||||
- status
|
- status
|
||||||
@@ -150,6 +152,7 @@
|
|||||||
- `image_file_key`:走现有 S3 规则,上传到 `family_id/年/月/日/...`。
|
- `image_file_key`:走现有 S3 规则,上传到 `family_id/年/月/日/...`。
|
||||||
- `image_file_key` 必填。
|
- `image_file_key` 必填。
|
||||||
- `status`:建议 1 启用,0 停用。
|
- `status`:建议 1 启用,0 停用。
|
||||||
|
- `owner_id`:归属者,引用 `b_family_member.user_id`,可空。仅作为标记,让家人知道这件衣服是谁的,**不改变家庭共享语义,不做个人衣橱隔离**。
|
||||||
- 颜色、季节这类检索信息不做结构化字段,由标签承载。
|
- 颜色、季节这类检索信息不做结构化字段,由标签承载。
|
||||||
|
|
||||||
### 搭配 b_clothing_outfit
|
### 搭配 b_clothing_outfit
|
||||||
@@ -162,6 +165,7 @@
|
|||||||
- cover_file_key
|
- cover_file_key
|
||||||
- layout_json
|
- layout_json
|
||||||
- category_id
|
- category_id
|
||||||
|
- owner_id
|
||||||
- remark
|
- remark
|
||||||
- status
|
- status
|
||||||
- sort_number
|
- sort_number
|
||||||
@@ -177,6 +181,7 @@
|
|||||||
- 缩略图由前端生成后通过 S3 上传,保存时写入 `cover_file_key`。
|
- 缩略图由前端生成后通过 S3 上传,保存时写入 `cover_file_key`。
|
||||||
- 如果缩略图生成失败,可以降级使用搭配内第一件衣服图片作为封面。
|
- 如果缩略图生成失败,可以降级使用搭配内第一件衣服图片作为封面。
|
||||||
- `layout_json` 保存画布级配置,例如画布宽高比例、背景、版本号等。
|
- `layout_json` 保存画布级配置,例如画布宽高比例、背景、版本号等。
|
||||||
|
- `owner_id`:归属者,引用 `b_family_member.user_id`,可空。仅作为标记,让家人知道这套搭配是谁的,**不改变家庭共享语义,不做个人衣橱隔离**。
|
||||||
|
|
||||||
### 搭配明细 b_clothing_outfit_clothes
|
### 搭配明细 b_clothing_outfit_clothes
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
drop table if exists b_clothing_tag_relation;
|
drop table if exists b_clothing_tag_relation;
|
||||||
drop table if exists b_clothing_outfit_clothes;
|
drop table if exists b_clothing_outfit_clothes;
|
||||||
drop table if exists b_clothing_tag;
|
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_category;
|
||||||
drop table if exists b_clothing_outfit;
|
drop table if exists b_clothing_outfit;
|
||||||
drop table if exists b_clothing_clothes;
|
drop table if exists b_clothing_clothes;
|
||||||
@@ -12,6 +11,7 @@ create table b_clothing_clothes (
|
|||||||
name varchar(64) not null,
|
name varchar(64) not null,
|
||||||
image_file_key varchar(512) not null,
|
image_file_key varchar(512) not null,
|
||||||
category_id bigint,
|
category_id bigint,
|
||||||
|
owner_id bigint,
|
||||||
brand varchar(64) default '',
|
brand varchar(64) default '',
|
||||||
remark varchar(512) default '',
|
remark varchar(512) default '',
|
||||||
sort_number int not null default 0,
|
sort_number int not null default 0,
|
||||||
@@ -29,6 +29,7 @@ create table b_clothing_outfit (
|
|||||||
cover_file_key varchar(512) default '',
|
cover_file_key varchar(512) default '',
|
||||||
layout_json jsonb,
|
layout_json jsonb,
|
||||||
category_id bigint,
|
category_id bigint,
|
||||||
|
owner_id bigint,
|
||||||
remark varchar(512) default '',
|
remark varchar(512) default '',
|
||||||
sort_number int not null default 0,
|
sort_number int not null default 0,
|
||||||
status smallint not null default 1,
|
status smallint not null default 1,
|
||||||
@@ -68,23 +69,10 @@ create table b_clothing_category (
|
|||||||
update_by bigint
|
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 (
|
create table b_clothing_tag (
|
||||||
id bigint primary key,
|
id bigint primary key,
|
||||||
family_id bigint not null,
|
family_id bigint not null,
|
||||||
tag_group_id bigint not null,
|
parent_id bigint not null default 0,
|
||||||
name varchar(32) not null,
|
name varchar(32) not null,
|
||||||
type smallint not null,
|
type smallint not null,
|
||||||
color varchar(7) default '',
|
color varchar(7) default '',
|
||||||
@@ -111,9 +99,15 @@ create table b_clothing_tag_relation (
|
|||||||
create index idx_b_clothing_clothes_family_status_category
|
create index idx_b_clothing_clothes_family_status_category
|
||||||
on b_clothing_clothes (family_id, status, category_id, sort_number, id);
|
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
|
create index idx_b_clothing_outfit_family_status_category
|
||||||
on b_clothing_outfit (family_id, status, category_id, sort_number, id);
|
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
|
create index idx_b_clothing_outfit_clothes_outfit
|
||||||
on b_clothing_outfit_clothes (outfit_id, z_index, sort_number, id);
|
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
|
create index idx_b_clothing_category_family_type
|
||||||
on b_clothing_category (family_id, status, type, sort_number, id);
|
on b_clothing_category (family_id, status, type, sort_number, id);
|
||||||
|
|
||||||
create index idx_b_clothing_tag_group_family_type
|
create index idx_b_clothing_tag_family_parent
|
||||||
on b_clothing_tag_group (family_id, status, type, sort_number, id);
|
on b_clothing_tag (family_id, status, type, parent_id, 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_relation_target
|
create index idx_b_clothing_tag_relation_target
|
||||||
on b_clothing_tag_relation (family_id, target_type, target_id, tag_id);
|
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 fetchOutfits = useClothingStore((s) => s.fetchOutfits);
|
||||||
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
|
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
|
||||||
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
||||||
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
|
|
||||||
const fetchTags = useClothingStore((s) => s.fetchTags);
|
const fetchTags = useClothingStore((s) => s.fetchTags);
|
||||||
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
||||||
|
const fetchMembers = useClothingStore((s) => s.fetchMembers);
|
||||||
|
const getOwnerName = useClothingStore((s) => s.getOwnerName);
|
||||||
const deleteClothesBundle = useClothingStore((s) => s.deleteClothesBundle);
|
const deleteClothesBundle = useClothingStore((s) => s.deleteClothesBundle);
|
||||||
const { loading, refreshing, runInitial, runRefresh } = useListLoading();
|
const { loading, refreshing, runInitial, runRefresh } = useListLoading();
|
||||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||||
@@ -72,8 +73,8 @@ export default function ClothingHome() {
|
|||||||
|
|
||||||
const loadBaseData = useCallback(async () => {
|
const loadBaseData = useCallback(async () => {
|
||||||
if (!familyId) return;
|
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)]);
|
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, fetchTagGroups, fetchTagRelations, fetchTags]);
|
}, [familyId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagRelations, fetchTags, fetchMembers]);
|
||||||
|
|
||||||
useEffect(() => { if (familyId) runInitial(loadBaseData); }, [familyId]);
|
useEffect(() => { if (familyId) runInitial(loadBaseData); }, [familyId]);
|
||||||
useFocusEffect(useCallback(() => { if (familyId) loadBaseData(); }, [familyId, loadBaseData]));
|
useFocusEffect(useCallback(() => { if (familyId) loadBaseData(); }, [familyId, loadBaseData]));
|
||||||
@@ -149,9 +150,9 @@ export default function ClothingHome() {
|
|||||||
<TouchableOpacity key={item.id} activeOpacity={0.85} delayLongPress={260}
|
<TouchableOpacity key={item.id} activeOpacity={0.85} delayLongPress={260}
|
||||||
onPress={() => { setActiveItem(null); router.push(`/clothes/detail/${item.id}` as any); }}
|
onPress={() => { setActiveItem(null); router.push(`/clothes/detail/${item.id}` as any); }}
|
||||||
onLongPress={(e) => { setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY }); setActiveItem(item); }}>
|
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>
|
</TouchableOpacity>
|
||||||
), [renderCategoryName]);
|
), [getOwnerName, renderCategoryName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
|
|||||||
@@ -7,27 +7,24 @@ import LayoutView from "@/components/layout/view";
|
|||||||
import { useListLoading, useLoading } from "@/hooks/useLoading";
|
import { useListLoading, useLoading } from "@/hooks/useLoading";
|
||||||
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
||||||
import { useClothingStore, useFamilyStore } from "@/store";
|
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"];
|
const COLORS = ["#EF4444","#F97316","#F59E0B","#84CC16","#22C55E","#14B8A6","#06B6D4","#3B82F6","#6366F1","#8B5CF6","#A855F7","#D946EF","#EC4899","#78716C"];
|
||||||
|
|
||||||
export default function ClothingManage() {
|
export default function ClothingManage() {
|
||||||
const familyId = useFamilyStore((s) => s.getFamilyId());
|
const familyId = useFamilyStore((s) => s.getFamilyId());
|
||||||
const categories = useClothingStore((s) => s.categories);
|
const categories = useClothingStore((s) => s.categories);
|
||||||
const tagGroups = useClothingStore((s) => s.tagGroups);
|
|
||||||
const tags = useClothingStore((s) => s.tags);
|
const tags = useClothingStore((s) => s.tags);
|
||||||
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
||||||
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
|
|
||||||
const fetchTags = useClothingStore((s) => s.fetchTags);
|
const fetchTags = useClothingStore((s) => s.fetchTags);
|
||||||
const saveCategoryChanges = useClothingStore((s) => s.saveCategoryChanges);
|
const saveCategoryChanges = useClothingStore((s) => s.saveCategoryChanges);
|
||||||
const saveTagGroupChanges = useClothingStore((s) => s.saveTagGroupChanges);
|
|
||||||
const saveTagChanges = useClothingStore((s) => s.saveTagChanges);
|
const saveTagChanges = useClothingStore((s) => s.saveTagChanges);
|
||||||
const { refreshing, runInitial, runRefresh } = useListLoading();
|
const { refreshing, runInitial, runRefresh } = useListLoading();
|
||||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||||
|
|
||||||
const [tab, setTab] = useState<ClothingTargetType>(ClothingTargetType.Clothes);
|
const [tab, setTab] = useState<ClothingTargetType>(ClothingTargetType.Clothes);
|
||||||
const curCategories = useMemo(() => categories.filter((c) => c.type === tab), [categories, tab]);
|
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("");
|
const [catForm, setCatForm] = useState("");
|
||||||
@@ -37,9 +34,9 @@ export default function ClothingManage() {
|
|||||||
|
|
||||||
// 标签分组
|
// 标签分组
|
||||||
const [groupForm, setGroupForm] = useState("");
|
const [groupForm, setGroupForm] = useState("");
|
||||||
const [editingGroup, setEditingGroup] = useState<ClothingTagGroup | null>(null);
|
const [editingGroup, setEditingGroup] = useState<ClothingTag | null>(null);
|
||||||
const [showGroupSheet, setShowGroupSheet] = useState(false);
|
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: "" });
|
const [tagForm, setTagForm] = useState({ name: "", color: "" });
|
||||||
@@ -50,8 +47,8 @@ export default function ClothingManage() {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
await Promise.all([fetchCategories(familyId), fetchTagGroups(familyId), fetchTags(familyId)]);
|
await Promise.all([fetchCategories(familyId), fetchTags(familyId)]);
|
||||||
}, [familyId, fetchCategories, fetchTagGroups, fetchTags]);
|
}, [familyId, fetchCategories, fetchTags]);
|
||||||
useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]);
|
useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]);
|
||||||
|
|
||||||
// 分类
|
// 分类
|
||||||
@@ -68,24 +65,24 @@ export default function ClothingManage() {
|
|||||||
}, [catDelete, loadData, saveCategoryChanges, startLoading, stopLoading]);
|
}, [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 () => {
|
const saveGroup = useCallback(async () => {
|
||||||
if (!familyId || !groupForm.trim()) return; startLoading("保存中...");
|
if (!familyId || !groupForm.trim()) return; startLoading("保存中...");
|
||||||
try {
|
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: "已保存" });
|
setShowGroupSheet(false); await loadData(); stopLoading({ type: "success", message: "已保存" });
|
||||||
} catch { stopLoading({ type: "error", 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("删除中...");
|
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: "删除失败" }); }
|
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, saveTagGroupChanges, startLoading, stopLoading, tags]);
|
}, [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 openTag = useCallback((groupId: number, t?: ClothingTag) => { setActiveGroupId(groupId); setTagForm({ name: t?.name ?? "", color: t?.color ?? "" }); setEditingTag(t ?? null); setShowTagSheet(true); }, []);
|
||||||
const saveTag = useCallback(async () => {
|
const saveTag = useCallback(async () => {
|
||||||
if (!familyId || !activeGroupId || !tagForm.name.trim()) return; startLoading("保存中...");
|
if (!familyId || !activeGroupId || !tagForm.name.trim()) return; startLoading("保存中...");
|
||||||
try {
|
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: "已保存" });
|
setShowTagSheet(false); await loadData(); stopLoading({ type: "success", message: "已保存" });
|
||||||
} catch { stopLoading({ type: "error", message: "保存失败" }); }
|
} catch { stopLoading({ type: "error", message: "保存失败" }); }
|
||||||
}, [activeGroupId, editingTag, familyId, loadData, saveTagChanges, startLoading, stopLoading, tab, tagForm]);
|
}, [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: "删除失败" }); }
|
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]);
|
}, [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 (
|
return (
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { router, useFocusEffect } from "expo-router";
|
|||||||
import { Button, Dialog, Label, TextField } from "heroui-native";
|
import { Button, Dialog, Label, TextField } from "heroui-native";
|
||||||
import BottomSheet from "@/components/BottomSheet";
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
import ContextMenu from "@/components/ContextMenu";
|
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 LayoutView from "@/components/layout/view";
|
||||||
import EmptyState from "@/components/EmptyStatus";
|
import EmptyState from "@/components/EmptyStatus";
|
||||||
import ClothingImage from "@/components/clothing/ClothingImage";
|
import ClothingImage from "@/components/clothing/ClothingImage";
|
||||||
@@ -28,6 +28,8 @@ export default function ClothingOutfitList() {
|
|||||||
const fetchTags = useClothingStore((s) => s.fetchTags);
|
const fetchTags = useClothingStore((s) => s.fetchTags);
|
||||||
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
||||||
const fetchClothes = useClothingStore((s) => s.fetchClothes);
|
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 saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
|
||||||
const { loading, refreshing, runInitial, runRefresh } = useListLoading();
|
const { loading, refreshing, runInitial, runRefresh } = useListLoading();
|
||||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||||
@@ -46,8 +48,8 @@ export default function ClothingOutfitList() {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
await Promise.all([fetchOutfits(familyId), fetchClothes(familyId), fetchOutfitClothes(familyId), fetchCategories(familyId, ClothingTargetType.Outfit), fetchTags(familyId, ClothingTargetType.Outfit), fetchTagRelations(familyId, ClothingTargetType.Outfit)]);
|
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]);
|
}, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchCategories, fetchTags, fetchTagRelations, fetchMembers]);
|
||||||
useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]);
|
useEffect(() => { if (familyId) runInitial(loadData); }, [familyId]);
|
||||||
useFocusEffect(useCallback(() => { if (familyId) loadData(); }, [familyId, loadData]));
|
useFocusEffect(useCallback(() => { if (familyId) loadData(); }, [familyId, loadData]));
|
||||||
|
|
||||||
@@ -117,6 +119,7 @@ export default function ClothingOutfitList() {
|
|||||||
|
|
||||||
const renderGridItem = useCallback((item: any) => {
|
const renderGridItem = useCallback((item: any) => {
|
||||||
const catName = item.category_id ? categoriesById.get(item.category_id)?.name : null;
|
const catName = item.category_id ? categoriesById.get(item.category_id)?.name : null;
|
||||||
|
const ownerName = getOwnerName(item.owner_id);
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity key={item.id} activeOpacity={0.85} delayLongPress={260}
|
<TouchableOpacity key={item.id} activeOpacity={0.85} delayLongPress={260}
|
||||||
onPress={() => router.push(`/clothes/outfit/detail/${item.id}` as any)}
|
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">
|
<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>
|
<Text className="text-[9px] text-white font-medium">{getClothesCount(item)} 件</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
<Text className="text-xs font-medium text-gray-900 mt-1.5" numberOfLines={1}>{item.name}</Text>
|
<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>}
|
{catName && <Text className="text-[10px] text-gray-400">{catName}</Text>}
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}, [categoriesById, getClothesCount, getCoverKey]);
|
}, [categoriesById, getClothesCount, getCoverKey, getOwnerName]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { Button, Dialog, useThemeColor } from "heroui-native";
|
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 PageLayout from "@/layouts/PageLayout";
|
||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
import ClothingImage from "@/components/clothing/ClothingImage";
|
import ClothingImage from "@/components/clothing/ClothingImage";
|
||||||
@@ -28,9 +28,10 @@ export default function ClothingDetail() {
|
|||||||
const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
|
const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
|
||||||
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
|
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
|
||||||
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
||||||
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
|
|
||||||
const fetchTags = useClothingStore((s) => s.fetchTags);
|
const fetchTags = useClothingStore((s) => s.fetchTags);
|
||||||
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
||||||
|
const fetchMembers = useClothingStore((s) => s.fetchMembers);
|
||||||
|
const getOwnerName = useClothingStore((s) => s.getOwnerName);
|
||||||
const deleteClothesBundle = useClothingStore((s) => s.deleteClothesBundle);
|
const deleteClothesBundle = useClothingStore((s) => s.deleteClothesBundle);
|
||||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||||
const notifyModuleOperation = useModuleChatNotifier();
|
const notifyModuleOperation = useModuleChatNotifier();
|
||||||
@@ -39,6 +40,7 @@ export default function ClothingDetail() {
|
|||||||
|
|
||||||
const item = useMemo(() => clothes.find((c) => c.id === clothesId) ?? null, [clothesId, clothes]);
|
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 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 selectedTags = useMemo(() => {
|
||||||
const ids = new Set(tagRelations.filter((r) => r.target_type === ClothingTargetType.Clothes && r.target_id === clothesId).map((r) => r.tag_id));
|
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));
|
return tags.filter((t) => ids.has(t.id));
|
||||||
@@ -46,9 +48,9 @@ export default function ClothingDetail() {
|
|||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!familyId || !clothesId) return; setPageLoading(true);
|
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); }
|
finally { setPageLoading(false); }
|
||||||
}, [familyId, clothesId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagGroups, fetchTagRelations, fetchTags]);
|
}, [familyId, clothesId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTagRelations, fetchTags, fetchMembers]);
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
useFocusEffect(useCallback(() => { if (familyId && clothesId) loadData(); }, [familyId, loadData, clothesId]));
|
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="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>
|
<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 && (
|
{selectedTags.length > 0 && (
|
||||||
<View className="flex-row flex-wrap gap-1.5 flex-1">
|
<View className="flex-row flex-wrap gap-1.5 flex-1">
|
||||||
{selectedTags.map((tag) => (
|
{selectedTags.map((tag) => (
|
||||||
|
|||||||
@@ -16,13 +16,12 @@ import { LoadingOverlay } from "@/components/LoadingOverlay";
|
|||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
|
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
|
||||||
import { getUniqueIdApi } from "@/request/api";
|
import { getUniqueIdApi } from "@/request/api";
|
||||||
import { useClothingStore, useFamilyStore } from "@/store";
|
import { useClothingStore, useFamilyStore, useUserStore, type ClothingFamilyMember } from "@/store";
|
||||||
import {
|
import {
|
||||||
ClothingCategory,
|
ClothingCategory,
|
||||||
ClothingClothes,
|
ClothingClothes,
|
||||||
ClothingStatus,
|
ClothingStatus,
|
||||||
ClothingTag,
|
ClothingTag,
|
||||||
ClothingTagGroup,
|
|
||||||
ClothingTargetType,
|
ClothingTargetType,
|
||||||
} from "@/types/clothing";
|
} from "@/types/clothing";
|
||||||
import { deleteS3FileQuietly } from "@/utils/file";
|
import { deleteS3FileQuietly } from "@/utils/file";
|
||||||
@@ -31,6 +30,7 @@ interface ClothesFormState {
|
|||||||
name: string;
|
name: string;
|
||||||
imageFileKey: string;
|
imageFileKey: string;
|
||||||
categoryId: number | null;
|
categoryId: number | null;
|
||||||
|
ownerId: number | null;
|
||||||
brand: string;
|
brand: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
sortNumber?: number;
|
sortNumber?: number;
|
||||||
@@ -40,6 +40,7 @@ const emptyForm: ClothesFormState = {
|
|||||||
name: "",
|
name: "",
|
||||||
imageFileKey: "",
|
imageFileKey: "",
|
||||||
categoryId: null,
|
categoryId: null,
|
||||||
|
ownerId: null,
|
||||||
brand: "",
|
brand: "",
|
||||||
remark: "",
|
remark: "",
|
||||||
};
|
};
|
||||||
@@ -49,6 +50,7 @@ function createEditForm(item: ClothingClothes): ClothesFormState {
|
|||||||
name: item.name,
|
name: item.name,
|
||||||
imageFileKey: item.image_file_key,
|
imageFileKey: item.image_file_key,
|
||||||
categoryId: item.category_id ?? null,
|
categoryId: item.category_id ?? null,
|
||||||
|
ownerId: item.owner_id ?? null,
|
||||||
brand: item.brand ?? "",
|
brand: item.brand ?? "",
|
||||||
remark: item.remark ?? "",
|
remark: item.remark ?? "",
|
||||||
sortNumber: item.sort_number,
|
sortNumber: item.sort_number,
|
||||||
@@ -65,16 +67,17 @@ export default function ClothingEdit() {
|
|||||||
const editId = id ? Number(id) : 0;
|
const editId = id ? Number(id) : 0;
|
||||||
const isEdit = editId > 0;
|
const isEdit = editId > 0;
|
||||||
const familyId = useFamilyStore((s) => s.getFamilyId());
|
const familyId = useFamilyStore((s) => s.getFamilyId());
|
||||||
|
const currentUserId = useUserStore((s) => s.getUserId());
|
||||||
const clothes = useClothingStore((s) => s.clothes);
|
const clothes = useClothingStore((s) => s.clothes);
|
||||||
const categories = useClothingStore((s) => s.categories);
|
const categories = useClothingStore((s) => s.categories);
|
||||||
const tagGroups = useClothingStore((s) => s.tagGroups);
|
|
||||||
const tags = useClothingStore((s) => s.tags);
|
const tags = useClothingStore((s) => s.tags);
|
||||||
const tagRelations = useClothingStore((s) => s.tagRelations);
|
const tagRelations = useClothingStore((s) => s.tagRelations);
|
||||||
|
const members = useClothingStore((s) => s.members);
|
||||||
const fetchClothes = useClothingStore((s) => s.fetchClothes);
|
const fetchClothes = useClothingStore((s) => s.fetchClothes);
|
||||||
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
||||||
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
|
|
||||||
const fetchTags = useClothingStore((s) => s.fetchTags);
|
const fetchTags = useClothingStore((s) => s.fetchTags);
|
||||||
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
||||||
|
const fetchMembers = useClothingStore((s) => s.fetchMembers);
|
||||||
const saveClothesBundle = useClothingStore((s) => s.saveClothesBundle);
|
const saveClothesBundle = useClothingStore((s) => s.saveClothesBundle);
|
||||||
const uploadClothesImage = useClothingStore((s) => s.uploadClothesImage);
|
const uploadClothesImage = useClothingStore((s) => s.uploadClothesImage);
|
||||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||||
@@ -91,21 +94,21 @@ export default function ClothingEdit() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const clothesTagGroups = useMemo(
|
const clothesTagGroups = useMemo(
|
||||||
() => tagGroups.filter((item) => item.type === ClothingTargetType.Clothes),
|
() => tags.filter((item) => item.type === ClothingTargetType.Clothes && item.parent_id === 0),
|
||||||
[tagGroups],
|
[tags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const clothesTags = useMemo(
|
const clothesTags = useMemo(
|
||||||
() => tags.filter((item) => item.type === ClothingTargetType.Clothes),
|
() => tags.filter((item) => item.type === ClothingTargetType.Clothes && item.parent_id !== 0),
|
||||||
[tags],
|
[tags],
|
||||||
);
|
);
|
||||||
|
|
||||||
const tagsByGroupId = useMemo(() => {
|
const tagsByGroupId = useMemo(() => {
|
||||||
const map = new Map<number, ClothingTag[]>();
|
const map = new Map<number, ClothingTag[]>();
|
||||||
clothesTags.forEach((tag) => {
|
clothesTags.forEach((tag) => {
|
||||||
const groupTags = map.get(tag.tag_group_id) ?? [];
|
const groupTags = map.get(tag.parent_id) ?? [];
|
||||||
groupTags.push(tag);
|
groupTags.push(tag);
|
||||||
map.set(tag.tag_group_id, groupTags);
|
map.set(tag.parent_id, groupTags);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [clothesTags]);
|
}, [clothesTags]);
|
||||||
@@ -123,9 +126,9 @@ export default function ClothingEdit() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchClothes(familyId),
|
fetchClothes(familyId),
|
||||||
fetchCategories(familyId, ClothingTargetType.Clothes),
|
fetchCategories(familyId, ClothingTargetType.Clothes),
|
||||||
fetchTagGroups(familyId, ClothingTargetType.Clothes),
|
|
||||||
fetchTags(familyId, ClothingTargetType.Clothes),
|
fetchTags(familyId, ClothingTargetType.Clothes),
|
||||||
fetchTagRelations(familyId, ClothingTargetType.Clothes, isEdit ? editId : undefined),
|
fetchTagRelations(familyId, ClothingTargetType.Clothes, isEdit ? editId : undefined),
|
||||||
|
fetchMembers(familyId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -144,16 +147,20 @@ export default function ClothingEdit() {
|
|||||||
)
|
)
|
||||||
.map((row) => row.tag_id),
|
.map((row) => row.tag_id),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
// 新增时默认归属者为当前登录用户
|
||||||
|
setForm({ ...emptyForm, ownerId: currentUserId || null });
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setPageLoading(false);
|
setPageLoading(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
currentUserId,
|
||||||
editId,
|
editId,
|
||||||
familyId,
|
familyId,
|
||||||
fetchCategories,
|
fetchCategories,
|
||||||
fetchClothes,
|
fetchClothes,
|
||||||
fetchTagGroups,
|
fetchMembers,
|
||||||
fetchTagRelations,
|
fetchTagRelations,
|
||||||
fetchTags,
|
fetchTags,
|
||||||
isEdit,
|
isEdit,
|
||||||
@@ -248,6 +255,7 @@ export default function ClothingEdit() {
|
|||||||
name,
|
name,
|
||||||
image_file_key: form.imageFileKey,
|
image_file_key: form.imageFileKey,
|
||||||
category_id: form.categoryId,
|
category_id: form.categoryId,
|
||||||
|
owner_id: form.ownerId,
|
||||||
brand: form.brand.trim(),
|
brand: form.brand.trim(),
|
||||||
remark: form.remark.trim(),
|
remark: form.remark.trim(),
|
||||||
sort_number: form.sortNumber ?? clothes.length,
|
sort_number: form.sortNumber ?? clothes.length,
|
||||||
@@ -336,6 +344,32 @@ export default function ClothingEdit() {
|
|||||||
[form.categoryId],
|
[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(
|
const renderTagChip = useCallback(
|
||||||
(item: ClothingTag) => {
|
(item: ClothingTag) => {
|
||||||
const selected = selectedTagIds.includes(item.id);
|
const selected = selectedTagIds.includes(item.id);
|
||||||
@@ -359,7 +393,7 @@ export default function ClothingEdit() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTagGroup = useCallback(
|
const renderTagGroup = useCallback(
|
||||||
(group: ClothingTagGroup) => {
|
(group: ClothingTag) => {
|
||||||
const groupTags = tagsByGroupId.get(group.id) ?? [];
|
const groupTags = tagsByGroupId.get(group.id) ?? [];
|
||||||
if (groupTags.length === 0) return null;
|
if (groupTags.length === 0) return null;
|
||||||
|
|
||||||
@@ -448,6 +482,33 @@ export default function ClothingEdit() {
|
|||||||
{clothesCategories.map(renderCategoryChip)}
|
{clothesCategories.map(renderCategoryChip)}
|
||||||
</View>
|
</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 && (
|
{clothesTagGroups.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { Button, Dialog, useThemeColor } from "heroui-native";
|
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 PageLayout from "@/layouts/PageLayout";
|
||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
import ClothingImage from "@/components/clothing/ClothingImage";
|
import ClothingImage from "@/components/clothing/ClothingImage";
|
||||||
@@ -31,6 +31,8 @@ export default function OutfitDetail() {
|
|||||||
const fetchTags = useClothingStore((s) => s.fetchTags);
|
const fetchTags = useClothingStore((s) => s.fetchTags);
|
||||||
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
||||||
const fetchClothes = useClothingStore((s) => s.fetchClothes);
|
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 saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
|
||||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||||
const notifyModuleOperation = useModuleChatNotifier();
|
const notifyModuleOperation = useModuleChatNotifier();
|
||||||
@@ -39,15 +41,16 @@ export default function OutfitDetail() {
|
|||||||
|
|
||||||
const item = useMemo(() => outfits.find((o) => o.id === outfitId) ?? null, [outfitId, outfits]);
|
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 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 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 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 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 () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!familyId || !outfitId) return; setPageLoading(true);
|
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); }
|
finally { setPageLoading(false); }
|
||||||
}, [familyId, outfitId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTags, fetchTagRelations]);
|
}, [familyId, outfitId, fetchCategories, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchTags, fetchTagRelations, fetchMembers]);
|
||||||
useEffect(() => { loadData(); }, [loadData]);
|
useEffect(() => { loadData(); }, [loadData]);
|
||||||
useFocusEffect(useCallback(() => { if (familyId && outfitId) loadData(); }, [familyId, loadData, outfitId]));
|
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="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>
|
<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>
|
<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) => (
|
{selectedTags.map((tag) => (
|
||||||
<View key={tag.id} className="rounded-full px-2.5 py-1" style={{ backgroundColor: tag.color || "#F3F4F6" }}>
|
<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>
|
<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 { useLoading } from "@/hooks/useLoading";
|
||||||
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
|
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
|
||||||
import { getUniqueIdApi } from "@/request/api";
|
import { getUniqueIdApi } from "@/request/api";
|
||||||
import { useClothingStore, useFamilyStore } from "@/store";
|
import { useClothingStore, useFamilyStore, useUserStore, type ClothingFamilyMember } from "@/store";
|
||||||
import {
|
import {
|
||||||
ClothingCategory,
|
ClothingCategory,
|
||||||
ClothingClothes,
|
ClothingClothes,
|
||||||
ClothingStatus,
|
ClothingStatus,
|
||||||
ClothingTag,
|
ClothingTag,
|
||||||
ClothingTagGroup,
|
|
||||||
ClothingTargetType,
|
ClothingTargetType,
|
||||||
} from "@/types/clothing";
|
} from "@/types/clothing";
|
||||||
import { deleteS3FileQuietly } from "@/utils/file";
|
import { deleteS3FileQuietly } from "@/utils/file";
|
||||||
@@ -146,19 +145,20 @@ export default function ClothingOutfitEdit() {
|
|||||||
const editId = id ? Number(id) : 0;
|
const editId = id ? Number(id) : 0;
|
||||||
const isEdit = editId > 0;
|
const isEdit = editId > 0;
|
||||||
const familyId = useFamilyStore((s) => s.getFamilyId());
|
const familyId = useFamilyStore((s) => s.getFamilyId());
|
||||||
|
const currentUserId = useUserStore((s) => s.getUserId());
|
||||||
const accentColor = useThemeColor("accent");
|
const accentColor = useThemeColor("accent");
|
||||||
const clothes = useClothingStore((s) => s.clothes);
|
const clothes = useClothingStore((s) => s.clothes);
|
||||||
const categories = useClothingStore((s) => s.categories);
|
const categories = useClothingStore((s) => s.categories);
|
||||||
const tagGroups = useClothingStore((s) => s.tagGroups);
|
|
||||||
const tags = useClothingStore((s) => s.tags);
|
const tags = useClothingStore((s) => s.tags);
|
||||||
const tagRelations = useClothingStore((s) => s.tagRelations);
|
const tagRelations = useClothingStore((s) => s.tagRelations);
|
||||||
|
const members = useClothingStore((s) => s.members);
|
||||||
const fetchClothes = useClothingStore((s) => s.fetchClothes);
|
const fetchClothes = useClothingStore((s) => s.fetchClothes);
|
||||||
const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
|
const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
|
||||||
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
|
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
|
||||||
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
const fetchCategories = useClothingStore((s) => s.fetchCategories);
|
||||||
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
|
|
||||||
const fetchTags = useClothingStore((s) => s.fetchTags);
|
const fetchTags = useClothingStore((s) => s.fetchTags);
|
||||||
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
|
||||||
|
const fetchMembers = useClothingStore((s) => s.fetchMembers);
|
||||||
const saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
|
const saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
|
||||||
const uploadOutfitCover = useClothingStore((s) => s.uploadOutfitCover);
|
const uploadOutfitCover = useClothingStore((s) => s.uploadOutfitCover);
|
||||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||||
@@ -167,6 +167,7 @@ export default function ClothingOutfitEdit() {
|
|||||||
const canvasRef = useRef<View>(null);
|
const canvasRef = useRef<View>(null);
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||||
|
const [ownerId, setOwnerId] = useState<number | null>(null);
|
||||||
const [remark, setRemark] = useState("");
|
const [remark, setRemark] = useState("");
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||||
const [canvasSize, setCanvasSize] = useState(0);
|
const [canvasSize, setCanvasSize] = useState(0);
|
||||||
@@ -211,19 +212,19 @@ export default function ClothingOutfitEdit() {
|
|||||||
[categories],
|
[categories],
|
||||||
);
|
);
|
||||||
const outfitTagGroups = useMemo(
|
const outfitTagGroups = useMemo(
|
||||||
() => tagGroups.filter((item) => item.type === ClothingTargetType.Outfit),
|
() => tags.filter((item) => item.type === ClothingTargetType.Outfit && item.parent_id === 0),
|
||||||
[tagGroups],
|
[tags],
|
||||||
);
|
);
|
||||||
const outfitTags = useMemo(
|
const outfitTags = useMemo(
|
||||||
() => tags.filter((item) => item.type === ClothingTargetType.Outfit),
|
() => tags.filter((item) => item.type === ClothingTargetType.Outfit && item.parent_id !== 0),
|
||||||
[tags],
|
[tags],
|
||||||
);
|
);
|
||||||
const tagsByGroupId = useMemo(() => {
|
const tagsByGroupId = useMemo(() => {
|
||||||
const map = new Map<number, ClothingTag[]>();
|
const map = new Map<number, ClothingTag[]>();
|
||||||
outfitTags.forEach((tag) => {
|
outfitTags.forEach((tag) => {
|
||||||
const groupTags = map.get(tag.tag_group_id) ?? [];
|
const groupTags = map.get(tag.parent_id) ?? [];
|
||||||
groupTags.push(tag);
|
groupTags.push(tag);
|
||||||
map.set(tag.tag_group_id, groupTags);
|
map.set(tag.parent_id, groupTags);
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [outfitTags]);
|
}, [outfitTags]);
|
||||||
@@ -242,9 +243,9 @@ export default function ClothingOutfitEdit() {
|
|||||||
fetchOutfits(familyId),
|
fetchOutfits(familyId),
|
||||||
fetchOutfitClothes(familyId),
|
fetchOutfitClothes(familyId),
|
||||||
fetchCategories(familyId, ClothingTargetType.Outfit),
|
fetchCategories(familyId, ClothingTargetType.Outfit),
|
||||||
fetchTagGroups(familyId, ClothingTargetType.Outfit),
|
|
||||||
fetchTags(familyId, ClothingTargetType.Outfit),
|
fetchTags(familyId, ClothingTargetType.Outfit),
|
||||||
fetchTagRelations(familyId, ClothingTargetType.Outfit, isEdit ? editId : undefined),
|
fetchTagRelations(familyId, ClothingTargetType.Outfit, isEdit ? editId : undefined),
|
||||||
|
fetchMembers(familyId),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
@@ -262,6 +263,7 @@ export default function ClothingOutfitEdit() {
|
|||||||
|
|
||||||
setName(outfit.name);
|
setName(outfit.name);
|
||||||
setCategoryId(outfit.category_id ?? null);
|
setCategoryId(outfit.category_id ?? null);
|
||||||
|
setOwnerId(outfit.owner_id ?? null);
|
||||||
setRemark(outfit.remark ?? "");
|
setRemark(outfit.remark ?? "");
|
||||||
setSelectedTagIds(
|
setSelectedTagIds(
|
||||||
state.tagRelations
|
state.tagRelations
|
||||||
@@ -284,18 +286,22 @@ export default function ClothingOutfitEdit() {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
setSelectedUid(details[0] ? `detail_${details[0].id}` : null);
|
setSelectedUid(details[0] ? `detail_${details[0].id}` : null);
|
||||||
|
} else {
|
||||||
|
// 新增时默认归属者为当前登录用户
|
||||||
|
setOwnerId(currentUserId || null);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setPageLoading(false);
|
setPageLoading(false);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
currentUserId,
|
||||||
editId,
|
editId,
|
||||||
familyId,
|
familyId,
|
||||||
fetchCategories,
|
fetchCategories,
|
||||||
fetchClothes,
|
fetchClothes,
|
||||||
|
fetchMembers,
|
||||||
fetchOutfitClothes,
|
fetchOutfitClothes,
|
||||||
fetchOutfits,
|
fetchOutfits,
|
||||||
fetchTagGroups,
|
|
||||||
fetchTagRelations,
|
fetchTagRelations,
|
||||||
fetchTags,
|
fetchTags,
|
||||||
isEdit,
|
isEdit,
|
||||||
@@ -417,6 +423,27 @@ export default function ClothingOutfitEdit() {
|
|||||||
[categoryId],
|
[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(
|
const renderTagChip = useCallback(
|
||||||
(item: ClothingTag) => {
|
(item: ClothingTag) => {
|
||||||
const selected = selectedTagIds.includes(item.id);
|
const selected = selectedTagIds.includes(item.id);
|
||||||
@@ -440,7 +467,7 @@ export default function ClothingOutfitEdit() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderTagGroup = useCallback(
|
const renderTagGroup = useCallback(
|
||||||
(group: ClothingTagGroup) => {
|
(group: ClothingTag) => {
|
||||||
const groupTags = tagsByGroupId.get(group.id) ?? [];
|
const groupTags = tagsByGroupId.get(group.id) ?? [];
|
||||||
if (groupTags.length === 0) return null;
|
if (groupTags.length === 0) return null;
|
||||||
|
|
||||||
@@ -531,6 +558,7 @@ export default function ClothingOutfitEdit() {
|
|||||||
background: "light",
|
background: "light",
|
||||||
},
|
},
|
||||||
category_id: categoryId,
|
category_id: categoryId,
|
||||||
|
owner_id: ownerId,
|
||||||
remark: remark.trim(),
|
remark: remark.trim(),
|
||||||
sort_number: currentOutfit?.sort_number ?? state.outfits.length,
|
sort_number: currentOutfit?.sort_number ?? state.outfits.length,
|
||||||
status: ClothingStatus.Enabled,
|
status: ClothingStatus.Enabled,
|
||||||
@@ -637,6 +665,7 @@ export default function ClothingOutfitEdit() {
|
|||||||
items,
|
items,
|
||||||
name,
|
name,
|
||||||
notifyModuleOperation,
|
notifyModuleOperation,
|
||||||
|
ownerId,
|
||||||
remark,
|
remark,
|
||||||
router,
|
router,
|
||||||
saveOutfitBundle,
|
saveOutfitBundle,
|
||||||
@@ -712,6 +741,31 @@ export default function ClothingOutfitEdit() {
|
|||||||
{outfitCategories.map(renderCategoryChip)}
|
{outfitCategories.map(renderCategoryChip)}
|
||||||
</View>
|
</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 && (
|
{outfitTagGroups.length > 0 && (
|
||||||
<>
|
<>
|
||||||
@@ -797,11 +851,11 @@ export default function ClothingOutfitEdit() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 标签按分组 */}
|
{/* 标签按分组 */}
|
||||||
{tags.filter((t) => t.type === ClothingTargetType.Clothes).length > 0 && (() => {
|
{tags.filter((t) => t.type === ClothingTargetType.Clothes && t.parent_id !== 0).length > 0 && (() => {
|
||||||
const ctGroups = tagGroups.filter((g) => g.type === ClothingTargetType.Clothes);
|
const ctGroups = tags.filter((g) => g.type === ClothingTargetType.Clothes && g.parent_id === 0);
|
||||||
const ctTags = tags.filter((t) => t.type === ClothingTargetType.Clothes);
|
const ctTags = tags.filter((t) => t.type === ClothingTargetType.Clothes && t.parent_id !== 0);
|
||||||
return ctGroups.map((g) => {
|
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;
|
if (gTags.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<View key={g.id}>
|
<View key={g.id}>
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
|
import { UserIcon } from "lucide-react-native";
|
||||||
import ClothingImage from "./ClothingImage";
|
import ClothingImage from "./ClothingImage";
|
||||||
import { ClothingClothes } from "@/types/clothing";
|
import { ClothingClothes } from "@/types/clothing";
|
||||||
|
|
||||||
interface ClothesGridItemProps {
|
interface ClothesGridItemProps {
|
||||||
item: ClothingClothes;
|
item: ClothingClothes;
|
||||||
categoryName?: string;
|
categoryName?: string;
|
||||||
|
ownerName?: string;
|
||||||
aspectRatio?: number;
|
aspectRatio?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ClothesGridItem({ item, categoryName, aspectRatio = 1 }: ClothesGridItemProps) {
|
export default function ClothesGridItem({ item, categoryName, ownerName, aspectRatio = 1 }: ClothesGridItemProps) {
|
||||||
return (
|
return (
|
||||||
<View className="flex-col">
|
<View className="flex-col">
|
||||||
<View className="relative rounded-xl overflow-hidden bg-gray-100" style={{ aspectRatio }}>
|
<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>
|
<Text className="text-[9px] text-white font-medium">{categoryName}</Text>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
<Text className="text-xs font-medium text-gray-900 mt-1.5" numberOfLines={1}>{item.name}</Text>
|
<Text className="text-xs font-medium text-gray-900 mt-1.5" numberOfLines={1}>{item.name}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
ClothingSaveChanges,
|
ClothingSaveChanges,
|
||||||
ClothingStatus,
|
ClothingStatus,
|
||||||
ClothingTag,
|
ClothingTag,
|
||||||
ClothingTagGroup,
|
|
||||||
ClothingTagRelation,
|
ClothingTagRelation,
|
||||||
ClothingTargetType,
|
ClothingTargetType,
|
||||||
} from "@/types/clothing";
|
} from "@/types/clothing";
|
||||||
@@ -18,19 +17,25 @@ const TABLES = {
|
|||||||
outfit: "b_clothing_outfit",
|
outfit: "b_clothing_outfit",
|
||||||
outfitClothes: "b_clothing_outfit_clothes",
|
outfitClothes: "b_clothing_outfit_clothes",
|
||||||
category: "b_clothing_category",
|
category: "b_clothing_category",
|
||||||
tagGroup: "b_clothing_tag_group",
|
|
||||||
tag: "b_clothing_tag",
|
tag: "b_clothing_tag",
|
||||||
tagRelation: "b_clothing_tag_relation",
|
tagRelation: "b_clothing_tag_relation",
|
||||||
|
familyMember: "b_family_member",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export interface ClothingFamilyMember {
|
||||||
|
user_id: number;
|
||||||
|
nickname: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ClothingState {
|
interface ClothingState {
|
||||||
clothes: ClothingClothes[];
|
clothes: ClothingClothes[];
|
||||||
outfits: ClothingOutfit[];
|
outfits: ClothingOutfit[];
|
||||||
outfitClothes: ClothingOutfitClothes[];
|
outfitClothes: ClothingOutfitClothes[];
|
||||||
categories: ClothingCategory[];
|
categories: ClothingCategory[];
|
||||||
tagGroups: ClothingTagGroup[];
|
|
||||||
tags: ClothingTag[];
|
tags: ClothingTag[];
|
||||||
tagRelations: ClothingTagRelation[];
|
tagRelations: ClothingTagRelation[];
|
||||||
|
members: ClothingFamilyMember[];
|
||||||
|
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
|
|
||||||
@@ -39,13 +44,14 @@ interface ClothingState {
|
|||||||
fetchOutfits: (familyId: number) => Promise<void>;
|
fetchOutfits: (familyId: number) => Promise<void>;
|
||||||
fetchOutfitClothes: (familyId: number, outfitId?: number) => Promise<void>;
|
fetchOutfitClothes: (familyId: number, outfitId?: number) => Promise<void>;
|
||||||
fetchCategories: (familyId: number, type?: ClothingTargetType) => Promise<void>;
|
fetchCategories: (familyId: number, type?: ClothingTargetType) => Promise<void>;
|
||||||
fetchTagGroups: (familyId: number, type?: ClothingTargetType) => Promise<void>;
|
fetchTags: (familyId: number, type?: ClothingTargetType, parentId?: number) => Promise<void>;
|
||||||
fetchTags: (familyId: number, type?: ClothingTargetType, tagGroupId?: number) => Promise<void>;
|
|
||||||
fetchTagRelations: (
|
fetchTagRelations: (
|
||||||
familyId: number,
|
familyId: number,
|
||||||
targetType?: ClothingTargetType,
|
targetType?: ClothingTargetType,
|
||||||
targetId?: number,
|
targetId?: number,
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
|
fetchMembers: (familyId: number) => Promise<void>;
|
||||||
|
getOwnerName: (ownerId?: number | null) => string;
|
||||||
|
|
||||||
saveClothesChanges: (changes: ClothingSaveChanges<ClothingClothes>) => Promise<boolean>;
|
saveClothesChanges: (changes: ClothingSaveChanges<ClothingClothes>) => Promise<boolean>;
|
||||||
saveOutfitChanges: (changes: ClothingSaveChanges<ClothingOutfit>) => Promise<boolean>;
|
saveOutfitChanges: (changes: ClothingSaveChanges<ClothingOutfit>) => Promise<boolean>;
|
||||||
@@ -53,16 +59,12 @@ interface ClothingState {
|
|||||||
changes: ClothingSaveChanges<ClothingOutfitClothes>,
|
changes: ClothingSaveChanges<ClothingOutfitClothes>,
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
saveCategoryChanges: (changes: ClothingSaveChanges<ClothingCategory>) => Promise<boolean>;
|
saveCategoryChanges: (changes: ClothingSaveChanges<ClothingCategory>) => Promise<boolean>;
|
||||||
saveTagGroupChanges: (changes: ClothingSaveChanges<ClothingTagGroup>) => Promise<boolean>;
|
|
||||||
saveTagChanges: (changes: ClothingSaveChanges<ClothingTag>) => Promise<boolean>;
|
saveTagChanges: (changes: ClothingSaveChanges<ClothingTag>) => Promise<boolean>;
|
||||||
saveTagRelationChanges: (changes: ClothingSaveChanges<ClothingTagRelation>) => Promise<boolean>;
|
saveTagRelationChanges: (changes: ClothingSaveChanges<ClothingTagRelation>) => Promise<boolean>;
|
||||||
saveMetadataBundle: (payload: {
|
saveMetadataBundle: (payload: {
|
||||||
categoryInserts?: Partial<ClothingCategory>[];
|
categoryInserts?: Partial<ClothingCategory>[];
|
||||||
categoryUpdates?: Partial<ClothingCategory>[];
|
categoryUpdates?: Partial<ClothingCategory>[];
|
||||||
categoryDeletes?: Partial<ClothingCategory>[];
|
categoryDeletes?: Partial<ClothingCategory>[];
|
||||||
tagGroupInserts?: Partial<ClothingTagGroup>[];
|
|
||||||
tagGroupUpdates?: Partial<ClothingTagGroup>[];
|
|
||||||
tagGroupDeletes?: Partial<ClothingTagGroup>[];
|
|
||||||
tagInserts?: Partial<ClothingTag>[];
|
tagInserts?: Partial<ClothingTag>[];
|
||||||
tagUpdates?: Partial<ClothingTag>[];
|
tagUpdates?: Partial<ClothingTag>[];
|
||||||
tagDeletes?: Partial<ClothingTag>[];
|
tagDeletes?: Partial<ClothingTag>[];
|
||||||
@@ -121,9 +123,9 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
|
|||||||
outfits: [],
|
outfits: [],
|
||||||
outfitClothes: [],
|
outfitClothes: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
tagGroups: [],
|
|
||||||
tags: [],
|
tags: [],
|
||||||
tagRelations: [],
|
tagRelations: [],
|
||||||
|
members: [],
|
||||||
|
|
||||||
clear: () =>
|
clear: () =>
|
||||||
set({
|
set({
|
||||||
@@ -131,9 +133,9 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
|
|||||||
outfits: [],
|
outfits: [],
|
||||||
outfitClothes: [],
|
outfitClothes: [],
|
||||||
categories: [],
|
categories: [],
|
||||||
tagGroups: [],
|
|
||||||
tags: [],
|
tags: [],
|
||||||
tagRelations: [],
|
tagRelations: [],
|
||||||
|
members: [],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
fetchAll: async (familyId: number) => {
|
fetchAll: async (familyId: number) => {
|
||||||
@@ -141,9 +143,9 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
|
|||||||
get().fetchClothes(familyId),
|
get().fetchClothes(familyId),
|
||||||
get().fetchOutfits(familyId),
|
get().fetchOutfits(familyId),
|
||||||
get().fetchCategories(familyId),
|
get().fetchCategories(familyId),
|
||||||
get().fetchTagGroups(familyId),
|
|
||||||
get().fetchTags(familyId),
|
get().fetchTags(familyId),
|
||||||
get().fetchTagRelations(familyId),
|
get().fetchTagRelations(familyId),
|
||||||
|
get().fetchMembers(familyId),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -213,24 +215,7 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
fetchTagGroups: async (familyId: number, type?: ClothingTargetType) => {
|
fetchTags: async (familyId: number, type?: ClothingTargetType, parentId?: number) => {
|
||||||
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) => {
|
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
|
|
||||||
const conditions = ["family_id = ?", "status = ?"];
|
const conditions = ["family_id = ?", "status = ?"];
|
||||||
@@ -241,16 +226,16 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
|
|||||||
args.push(type);
|
args.push(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tagGroupId !== undefined) {
|
if (parentId !== undefined) {
|
||||||
conditions.push("tag_group_id = ?");
|
conditions.push("parent_id = ?");
|
||||||
args.push(tagGroupId);
|
args.push(parentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await loadDataApi({
|
const res = await loadDataApi({
|
||||||
view_name: TABLES.tag,
|
view_name: TABLES.tag,
|
||||||
search_condition: conditions.join(" and "),
|
search_condition: conditions.join(" and "),
|
||||||
search_columns: [],
|
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,
|
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>) => {
|
saveClothesChanges: async (changes: ClothingSaveChanges<ClothingClothes>) => {
|
||||||
const res = await saveDataApi([
|
const res = await saveDataApi([
|
||||||
{ table_name: TABLES.clothes, key_field: "id", ...normalizeChanges(changes) },
|
{ table_name: TABLES.clothes, key_field: "id", ...normalizeChanges(changes) },
|
||||||
@@ -320,13 +327,6 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
|
|||||||
return res.isSuccess;
|
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>) => {
|
saveTagChanges: async (changes: ClothingSaveChanges<ClothingTag>) => {
|
||||||
const res = await saveDataApi([
|
const res = await saveDataApi([
|
||||||
{ table_name: TABLES.tag, key_field: "id", ...normalizeChanges(changes) },
|
{ table_name: TABLES.tag, key_field: "id", ...normalizeChanges(changes) },
|
||||||
@@ -350,13 +350,6 @@ export const useClothingStore = create<ClothingState>((set, get) => ({
|
|||||||
updates: payload.categoryUpdates ?? [],
|
updates: payload.categoryUpdates ?? [],
|
||||||
deletes: payload.categoryDeletes ?? [],
|
deletes: payload.categoryDeletes ?? [],
|
||||||
},
|
},
|
||||||
{
|
|
||||||
table_name: TABLES.tagGroup,
|
|
||||||
key_field: "id",
|
|
||||||
inserts: payload.tagGroupInserts ?? [],
|
|
||||||
updates: payload.tagGroupUpdates ?? [],
|
|
||||||
deletes: payload.tagGroupDeletes ?? [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
table_name: TABLES.tag,
|
table_name: TABLES.tag,
|
||||||
key_field: "id",
|
key_field: "id",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface ClothingClothes {
|
|||||||
name: string;
|
name: string;
|
||||||
image_file_key: string;
|
image_file_key: string;
|
||||||
category_id?: number | null;
|
category_id?: number | null;
|
||||||
|
owner_id?: number | null;
|
||||||
brand?: string;
|
brand?: string;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
sort_number: number;
|
sort_number: number;
|
||||||
@@ -37,6 +38,7 @@ export interface ClothingOutfit {
|
|||||||
cover_file_key?: string;
|
cover_file_key?: string;
|
||||||
layout_json?: ClothingOutfitLayout | Record<string, unknown>;
|
layout_json?: ClothingOutfitLayout | Record<string, unknown>;
|
||||||
category_id?: number | null;
|
category_id?: number | null;
|
||||||
|
owner_id?: number | null;
|
||||||
remark?: string;
|
remark?: string;
|
||||||
sort_number: number;
|
sort_number: number;
|
||||||
status: ClothingStatus;
|
status: ClothingStatus;
|
||||||
@@ -76,23 +78,10 @@ export interface ClothingCategory {
|
|||||||
update_by?: number;
|
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 {
|
export interface ClothingTag {
|
||||||
id: number;
|
id: number;
|
||||||
family_id: number;
|
family_id: number;
|
||||||
tag_group_id: number;
|
parent_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: ClothingTargetType;
|
type: ClothingTargetType;
|
||||||
color?: string;
|
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