diff --git a/code/app/app-go/db/clothing.sql b/code/app/app-go/db/clothing.sql
index 0f7ad163..f5d239b2 100644
--- a/code/app/app-go/db/clothing.sql
+++ b/code/app/app-go/db/clothing.sql
@@ -87,7 +87,7 @@ create table b_clothing_tag (
tag_group_id bigint not null,
name varchar(32) not null,
type smallint not null,
- emoji varchar(16) default '',
+ color varchar(7) default '',
sort_number int not null default 0,
status smallint not null default 1,
create_time timestamp,
diff --git a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx
index a176699e..d074f4cf 100644
--- a/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx
+++ b/code/app/app-rn/src/app/(main)/(tabs)/chat/index.tsx
@@ -112,7 +112,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
return (
- {item.content}
+ {nickname} {item.content}
);
diff --git a/code/app/app-rn/src/app/(main)/chat/history.tsx b/code/app/app-rn/src/app/(main)/chat/history.tsx
index 0ec58f76..fda6582c 100644
--- a/code/app/app-rn/src/app/(main)/chat/history.tsx
+++ b/code/app/app-rn/src/app/(main)/chat/history.tsx
@@ -273,7 +273,7 @@ export default function ChatHistoryPage() {
-
+
-
+
{MSG_TYPES.map((t) => (
setDraftTypeFilter(t.value)}>
diff --git a/code/app/app-rn/src/app/(main)/family/settings.tsx b/code/app/app-rn/src/app/(main)/family/settings.tsx
index 028c5835..91f0b849 100644
--- a/code/app/app-rn/src/app/(main)/family/settings.tsx
+++ b/code/app/app-rn/src/app/(main)/family/settings.tsx
@@ -529,7 +529,7 @@ export default function HomeSettings() {
-
+
-
+
-
+
-
+
-
+
{
+ onToasterReset(() => setTsKey((k) => k + 1));
+ }, []);
if (!isReady) return null;
@@ -47,7 +54,7 @@ export default function RootLayout() {
-
+
);
}
diff --git a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx
index 9c9d091a..6cacbd4d 100644
--- a/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx
+++ b/code/app/app-rn/src/app/clothes/(tabs)/home/index.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
-import { Alert, Text, TouchableOpacity, View } from "react-native";
+import { Modal, Pressable, Text, TouchableOpacity, View } from "react-native";
import { router, useFocusEffect } from "expo-router";
-import { Button, Card, Label, Menu, SearchField } from "heroui-native";
+import { Button, Card, Dialog, Label, SearchField } from "heroui-native";
import {
CheckIcon,
FilterIcon,
@@ -67,6 +67,8 @@ export default function ClothingHome() {
const [draftFilter, setDraftFilter] = useState(() => createDefaultFilter());
const [showFilterSheet, setShowFilterSheet] = useState(false);
const [activeItem, setActiveItem] = useState(null);
+ const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
+ const [deleteConfirm, setDeleteConfirm] = useState<{ item: ClothingClothes; relatedOutfitCount: number } | null>(null);
const clothesCategories = useMemo(
() => categories.filter((item) => item.type === ClothingTargetType.Clothes),
@@ -266,33 +268,24 @@ export default function ClothingHome() {
),
);
- if (relatedOutfitIds.length === 0) {
- Alert.alert("删除衣服", `确认删除「${item.name}」吗?`, [
- { text: "取消", style: "cancel" },
- { text: "删除", style: "destructive", onPress: () => handleDelete(item, false) },
- ]);
- return;
- }
-
- Alert.alert(
- "衣服已被搭配使用",
- `「${item.name}」已被 ${relatedOutfitIds.length} 个搭配使用,是否同时删除这些搭配?`,
- [
- { text: "不删除", style: "cancel" },
- {
- text: "全部删除",
- style: "destructive",
- onPress: () => handleDelete(item, true),
- },
- ],
- );
+ setDeleteConfirm({ item, relatedOutfitCount: relatedOutfitIds.length });
},
- [familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations, handleDelete],
+ [familyId, fetchOutfitClothes, fetchOutfits, fetchTagRelations],
+ );
+
+ const handleConfirmDelete = useCallback(
+ (withOutfits: boolean) => {
+ const data = deleteConfirm;
+ if (!data) return;
+ setDeleteConfirm(null);
+ handleDelete(data.item, withOutfits);
+ },
+ [deleteConfirm, handleDelete],
);
const renderCategoryName = useCallback(
(categoryId?: number | null) =>
- clothesCategories.find((category) => category.id === categoryId)?.name ?? "未分类",
+ clothesCategories.find((category) => category.id === categoryId)?.name ?? "",
[clothesCategories],
);
@@ -305,9 +298,16 @@ export default function ClothingHome() {
.filter((tag) => itemTagIds.has(tag.id))
.slice(0, 3)
.map((tag) => (
-
-
- {tag.emoji ? `${tag.emoji} ` : ""}
+
+
{tag.name}
@@ -323,36 +323,39 @@ export default function ClothingHome() {
activeOpacity={0.82}
delayLongPress={260}
onPress={() => handleEdit(item)}
- onLongPress={() => setActiveItem(item)}
+ onLongPress={(e) => {
+ setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
+ setActiveItem(item);
+ }}
>
-
-
-
-
- {item.name}
-
-
- {item.brand?.trim() || renderCategoryName(item.category_id)}
-
-
- setActiveItem(item)}
- >
-
-
-
+
+
+ {item.name}
+
+ {item.brand?.trim() ? (
+
+ {item.brand.trim()}
+
+ ) : null}
-
-
-
- {renderCategoryName(item.category_id)}
-
-
- {renderItemTags(item)}
-
+ {(() => {
+ const hasTags = (tagIdsByClothesId.get(item.id)?.size ?? 0) > 0;
+ if (!item.category_id && !hasTags) return null;
+ return (
+
+ {item.category_id ? (
+
+
+ {renderCategoryName(item.category_id)}
+
+
+ ) : null}
+ {renderItemTags(item)}
+
+ );
+ })()}
@@ -406,7 +409,6 @@ export default function ClothingHome() {
}
>
- {item.emoji ? `${item.emoji} ` : ""}
{item.name}
@@ -498,7 +500,7 @@ export default function ClothingHome() {
-
+
-
+
{clothesTags.length > 0 ? (
{clothesTags.map(renderFilterTag)}
@@ -548,28 +550,63 @@ export default function ClothingHome() {
-
+
+
+
diff --git a/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx
index c1d009f1..38087821 100644
--- a/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx
+++ b/code/app/app-rn/src/app/clothes/(tabs)/manage/index.tsx
@@ -1,8 +1,9 @@
import { useCallback, useEffect, useMemo, useState } from "react";
-import { Alert, Text, TouchableOpacity, View } from "react-native";
-import { Button, Card, Label, Tabs, TextField } from "heroui-native";
+import { Text, TouchableOpacity, View } from "react-native";
+import { Button, Dialog, Label, Tabs, TextField } from "heroui-native";
import {
CheckIcon,
+ ChevronDownIcon,
FolderIcon,
PencilIcon,
PlusIcon,
@@ -28,7 +29,12 @@ import {
ClothingTargetType,
} from "@/types/clothing";
-type ManageMode = "category" | "tag";
+const TAG_COLORS = [
+ "#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
+ "#06B6D4", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7",
+ "#EC4899", "#78716C",
+];
+
type MetadataForm =
| {
kind: "category";
@@ -48,7 +54,7 @@ type MetadataForm =
kind: "tag";
id?: number;
name: string;
- emoji: string;
+ color: string;
tagGroupId: number | null;
type: ClothingTargetType;
sortNumber?: number;
@@ -74,8 +80,11 @@ export default function ClothingMetadataManage() {
const notifyModuleOperation = useModuleChatNotifier();
const [activeType, setActiveType] = useState(String(ClothingTargetType.Clothes));
- const [mode, setMode] = useState("category");
const [form, setForm] = useState(null);
+ const [deleteConfirm, setDeleteConfirm] = useState<
+ { kind: "category"; item: ClothingCategory } | { kind: "tag"; item: ClothingTag } | { kind: "tagGroup"; item: ClothingTagGroup } | null
+ >(null);
+ const [collapsedGroups, setCollapsedGroups] = useState>(new Set());
const targetType = Number(activeType) as ClothingTargetType;
@@ -179,7 +188,7 @@ export default function ClothingMetadataManage() {
setForm({
kind: "tag",
name: "",
- emoji: "",
+ color: "",
tagGroupId: tagGroupId ?? currentTagGroups[0]?.id ?? null,
type: targetType,
});
@@ -192,7 +201,7 @@ export default function ClothingMetadataManage() {
kind: "tag",
id: item.id,
name: item.name,
- emoji: item.emoji ?? "",
+ color: item.color ?? "",
tagGroupId: item.tag_group_id,
type: item.type,
sortNumber: item.sort_number,
@@ -256,7 +265,7 @@ export default function ClothingMetadataManage() {
tag_group_id: form.tagGroupId!,
name,
type: form.type,
- emoji: form.emoji.trim(),
+ color: form.color.trim(),
sort_number:
form.sortNumber ??
tags.filter((item) => item.type === form.type && item.tag_group_id === form.tagGroupId)
@@ -314,167 +323,121 @@ export default function ClothingMetadataManage() {
tags,
]);
- const handleDeleteCategory = useCallback(
- (item: ClothingCategory) => {
- Alert.alert("删除分类", `确认删除「${item.name}」吗?已使用该分类的数据会显示为未分类。`, [
- { text: "取消", style: "cancel" },
- {
- text: "删除",
- style: "destructive",
- onPress: async () => {
- startLoading("删除中...");
- try {
- const ok = await saveMetadataBundle({
- categoryUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
- });
- if (!ok) {
- stopLoading({ type: "error", message: "删除失败" });
- return;
- }
- await loadData();
- notifyModuleOperation({
- module: "衣服搭配",
- action: `删除${targetTypeLabel(item.type)}分类`,
- target: item.name,
- sourceTable: "b_clothing_category",
- sourceId: item.id,
- });
- stopLoading({ type: "success", message: "已删除" });
- } catch {
- stopLoading({ type: "error", message: "删除失败" });
- }
- },
- },
- ]);
- },
- [loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading],
- );
+ const handleDeleteCategory = useCallback((item: ClothingCategory) => {
+ setDeleteConfirm({ kind: "category", item });
+ }, []);
- const handleDeleteTag = useCallback(
- (item: ClothingTag) => {
- const relationDeletes = tagRelations
- .filter((row) => row.tag_id === item.id)
- .map((row) => ({ id: row.id }));
+ const handleDeleteTag = useCallback((item: ClothingTag) => {
+ setDeleteConfirm({ kind: "tag", item });
+ }, []);
- Alert.alert("删除标签", `确认删除「${item.name}」吗?对应引用关系也会移除。`, [
- { text: "取消", style: "cancel" },
- {
- text: "删除",
- style: "destructive",
- onPress: async () => {
- startLoading("删除中...");
- try {
- const ok = await saveMetadataBundle({
- tagUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
- tagRelationDeletes: relationDeletes,
- });
- if (!ok) {
- stopLoading({ type: "error", message: "删除失败" });
- return;
- }
- await loadData();
- notifyModuleOperation({
- module: "衣服搭配",
- action: `删除${targetTypeLabel(item.type)}标签`,
- target: item.name,
- sourceTable: "b_clothing_tag",
- sourceId: item.id,
- });
- stopLoading({ type: "success", message: "已删除" });
- } catch {
- stopLoading({ type: "error", message: "删除失败" });
- }
- },
- },
- ]);
- },
- [loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading, tagRelations],
- );
+ const handleDeleteTagGroup = useCallback((item: ClothingTagGroup) => {
+ setDeleteConfirm({ kind: "tagGroup", item });
+ }, []);
- const handleDeleteTagGroup = useCallback(
- (item: ClothingTagGroup) => {
- const groupTags = tags.filter((tag) => tag.tag_group_id === item.id);
- const groupTagIds = new Set(groupTags.map((tag) => tag.id));
- const relationDeletes = tagRelations
- .filter((row) => groupTagIds.has(row.tag_id))
- .map((row) => ({ id: row.id }));
+ const handleConfirmDelete = useCallback(async () => {
+ if (!deleteConfirm || isLoading) return;
+ const { kind, item } = deleteConfirm;
+ setDeleteConfirm(null);
+ startLoading("删除中...");
- Alert.alert("删除标签组", `确认删除「${item.name}」及其下 ${groupTags.length} 个标签吗?`, [
- { text: "取消", style: "cancel" },
- {
- text: "删除",
- style: "destructive",
- onPress: async () => {
- startLoading("删除中...");
- try {
- const ok = await saveMetadataBundle({
- tagGroupUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
- tagUpdates: groupTags.map((tag) => ({
- id: tag.id,
- status: ClothingStatus.Disabled,
- })),
- tagRelationDeletes: relationDeletes,
- });
- if (!ok) {
- stopLoading({ type: "error", message: "删除失败" });
- return;
- }
- await loadData();
- notifyModuleOperation({
- module: "衣服搭配",
- action: `删除${targetTypeLabel(item.type)}标签组`,
- target: item.name,
- detail: `${groupTags.length} 个标签`,
- sourceTable: "b_clothing_tag_group",
- sourceId: item.id,
- });
- stopLoading({ type: "success", message: "已删除" });
- } catch {
- stopLoading({ type: "error", message: "删除失败" });
- }
- },
- },
- ]);
- },
- [
- loadData,
- notifyModuleOperation,
- saveMetadataBundle,
- startLoading,
- stopLoading,
- tagRelations,
- tags,
- ],
- );
+ try {
+ let ok = false;
+ if (kind === "category") {
+ ok = await saveMetadataBundle({
+ categoryUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
+ });
+ } else if (kind === "tag") {
+ const relationDeletes = tagRelations
+ .filter((row) => row.tag_id === item.id)
+ .map((row) => ({ id: row.id }));
+ ok = await saveMetadataBundle({
+ tagUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
+ tagRelationDeletes: relationDeletes,
+ });
+ } else if (kind === "tagGroup") {
+ const groupTags = tags.filter((tag) => tag.tag_group_id === item.id);
+ const groupTagIds = new Set(groupTags.map((tag) => tag.id));
+ const relationDeletes = tagRelations
+ .filter((row) => groupTagIds.has(row.tag_id))
+ .map((row) => ({ id: row.id }));
+ ok = await saveMetadataBundle({
+ tagGroupUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
+ tagUpdates: groupTags.map((tag) => ({ id: tag.id, status: ClothingStatus.Disabled })),
+ tagRelationDeletes: relationDeletes,
+ });
+ }
+
+ if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; }
+ await loadData();
+ notifyModuleOperation({
+ module: "衣服搭配",
+ action: `删除${targetTypeLabel(item.type)}${
+ kind === "category" ? "分类" : kind === "tagGroup" ? "标签组" : "标签"
+ }`,
+ target: item.name,
+ detail: kind === "tagGroup" ? `${tags.filter((tag) => tag.tag_group_id === item.id).length} 个标签` : undefined,
+ sourceTable: kind === "category" ? "b_clothing_category" : kind === "tagGroup" ? "b_clothing_tag_group" : "b_clothing_tag",
+ sourceId: item.id,
+ });
+ stopLoading({ type: "success", message: "已删除" });
+ } catch {
+ stopLoading({ type: "error", message: "删除失败" });
+ }
+ }, [deleteConfirm, isLoading, loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading, tagRelations, tags]);
+
+ const getDeleteTitle = () => {
+ if (!deleteConfirm) return "";
+ if (deleteConfirm.kind === "category") return "删除分类";
+ if (deleteConfirm.kind === "tagGroup") return "删除标签组";
+ return "删除标签";
+ };
+
+ const getDeleteDesc = () => {
+ if (!deleteConfirm) return "";
+ if (deleteConfirm.kind === "category")
+ return `确认删除「${deleteConfirm.item.name}」吗?已使用该分类的数据会显示为未分类。`;
+ if (deleteConfirm.kind === "tagGroup") {
+ const count = tags.filter((tag) => tag.tag_group_id === deleteConfirm.item.id).length;
+ return `确认删除「${deleteConfirm.item.name}」及其下 ${count} 个标签吗?`;
+ }
+ return `确认删除「${deleteConfirm.item.name}」吗?对应引用关系也会移除。`;
+ };
+
+ const toggleGroup = useCallback((groupId: number) => {
+ setCollapsedGroups((prev) => {
+ const next = new Set(prev);
+ if (next.has(groupId)) next.delete(groupId);
+ else next.add(groupId);
+ return next;
+ });
+ }, []);
const renderCategory = useCallback(
(item: ClothingCategory) => (
-
-
-
-
-
-
-
-
- {item.name}
-
- {targetTypeLabel(item.type)}分类
-
-
-
-
-
-
+
+
+
+
+
+ {item.name}
+
+ openEditCategory(item)}
+ >
+
+
+ handleDeleteCategory(item)}
+ >
+
+
+
),
[handleDeleteCategory, openEditCategory],
);
@@ -482,77 +445,94 @@ export default function ClothingMetadataManage() {
const renderTagGroup = useCallback(
(group: ClothingTagGroup) => {
const groupTags = tagsByGroupId.get(group.id) ?? [];
+ const collapsed = collapsedGroups.has(group.id);
return (
-
-
-
-
-
-
-
-
- {group.name}
-
-
- {targetTypeLabel(group.type)}标签组 · {groupTags.length} 个标签
-
-
-
-
+
+ {/* Group header — same row style as categories */}
+ toggleGroup(group.id)}
+ >
+
+
-
-
- {groupTags.length > 0 ? (
-
- {groupTags.map((tag) => (
+
+ {group.name}
+
+
+ { e.stopPropagation?.(); openEditTagGroup(group); }}
+ >
+
+
+ { e.stopPropagation?.(); handleDeleteTagGroup(group); }}
+ >
+
+
+
+
+ {/* Tags — same row style as categories, colored dot instead of icon */}
+ {!collapsed && (
+
+ {groupTags.map((tag) => (
+
+
+
+ {tag.name}
+
openEditTag(tag)}
>
-
- {tag.emoji ? `${tag.emoji} ` : ""}
- {tag.name}
-
- handleDeleteTag(tag)}>
-
-
+
- ))}
-
- ) : (
- 暂无标签
- )}
-
-
-
+ handleDeleteTag(tag)}
+ >
+
+
+
+ ))}
+ {groupTags.length === 0 && (
+ 暂无标签
+ )}
+ openCreateTag(group.id)}
+ >
+
+ 添加标签
+
+
+ )}
+
);
},
[
+ collapsedGroups,
handleDeleteTag,
handleDeleteTagGroup,
openCreateTag,
openEditTag,
openEditTagGroup,
tagsByGroupId,
+ toggleGroup,
],
);
@@ -571,83 +551,66 @@ export default function ClothingMetadataManage() {
contentContainerStyle={{ paddingBottom: 120 }}
>
-
-
-
-
- 衣服
-
-
- 搭配
-
-
-
-
- setMode(value as ManageMode)}
- variant="secondary"
- >
-
-
-
- 分类
-
-
- 标签
-
-
-
+
+
+
+
+
+ 衣服
+
+
+ 搭配
+
+
+
+
+
+
+
- {mode === "category" ? (
-
- ) : (
- <>
-
-
- >
- )}
-
- {mode === "category" ? (
- currentCategories.length > 0 ? (
- currentCategories.map(renderCategory)
- ) : (
-
- )
- ) : currentTagGroups.length > 0 ? (
+ {/* 分类 */}
+ 分类
+ {currentCategories.length > 0 ? (
+ currentCategories.map(renderCategory)
+ ) : (
+
+ 暂无分类
+
+ )}
+
+ {/* 标签 */}
+
+ 标签
+
+ {currentTagGroups.length > 0 ? (
currentTagGroups.map(renderTagGroup)
) : (
-
+
+ 暂无标签组
+
+ )}
+
+ {loading && currentCategories.length === 0 && currentTagGroups.length === 0 && (
+
)}
@@ -662,10 +625,10 @@ export default function ClothingMetadataManage() {
{form && (
<>
- {renderFormTitle()}
+ {renderFormTitle()}
-
+
setForm((prev) => (prev ? { ...prev, name } : prev))}
@@ -675,22 +638,49 @@ export default function ClothingMetadataManage() {
{form.kind === "tag" && (
<>
-
-
-
- setForm((prev) =>
- prev && prev.kind === "tag" ? { ...prev, emoji } : prev,
- )
- }
- placeholder="👕"
- />
-
+
+
+
+
+ setForm((prev) =>
+ prev && prev.kind === "tag" ? { ...prev, color: "" } : prev,
+ )
+ }
+ >
+
+
+ {TAG_COLORS.map((c) => {
+ const selected = form.color === c;
+ return (
+
+ setForm((prev) =>
+ prev && prev.kind === "tag"
+ ? { ...prev, color: c }
+ : prev,
+ )
+ }
+ >
+ {selected && (
+
+ )}
+
+ );
+ })}
+
+
-
+
{currentTagGroups.map((group) => {
const selected = form.tagGroupId === group.id;
@@ -724,7 +714,7 @@ export default function ClothingMetadataManage() {
)}
-
+
{targetTypeLabel(form.type)}
@@ -749,6 +739,24 @@ export default function ClothingMetadataManage() {
+
+
);
diff --git a/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx
index ea940ce6..14430f68 100644
--- a/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx
+++ b/code/app/app-rn/src/app/clothes/(tabs)/outfit/index.tsx
@@ -1,26 +1,57 @@
-import { useCallback, useEffect, useMemo } from "react";
-import { TouchableOpacity, View } from "react-native";
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { Modal, Pressable, Text, TouchableOpacity, View } from "react-native";
import { router, useFocusEffect } from "expo-router";
-import { Button, Card } from "heroui-native";
-import { PencilIcon, PlusIcon, RotateCcwIcon, SparklesIcon } from "lucide-react-native";
+import { Button, Card, Dialog } from "heroui-native";
+import {
+ FileTextIcon,
+ PencilIcon,
+ PlusIcon,
+ RotateCcwIcon,
+ SparklesIcon,
+ Trash2Icon,
+} from "lucide-react-native";
import LayoutView from "@/components/layout/view";
import EmptyState from "@/components/EmptyStatus";
import ClothingImage from "@/components/clothing/ClothingImage";
-import { useListLoading } from "@/hooks/useLoading";
+import { useListLoading, useLoading } from "@/hooks/useLoading";
+import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
+import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useClothingStore, useFamilyStore } from "@/store";
-import { ClothingOutfit } from "@/types/clothing";
+import { ClothingOutfit, ClothingStatus, ClothingTargetType } from "@/types/clothing";
export default function ClothingOutfitList() {
const familyId = useFamilyStore((s) => s.getFamilyId());
const outfits = useClothingStore((s) => s.outfits);
const clothes = useClothingStore((s) => s.clothes);
const outfitClothes = useClothingStore((s) => s.outfitClothes);
+ const categories = useClothingStore((s) => s.categories);
+ const tags = useClothingStore((s) => s.tags);
+ const tagRelations = useClothingStore((s) => s.tagRelations);
const fetchOutfits = useClothingStore((s) => s.fetchOutfits);
- const fetchClothes = useClothingStore((s) => s.fetchClothes);
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
+ const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
+ const fetchCategories = useClothingStore((s) => s.fetchCategories);
+ const fetchTags = useClothingStore((s) => s.fetchTags);
+ const fetchClothes = useClothingStore((s) => s.fetchClothes);
+ const saveOutfitBundle = useClothingStore((s) => s.saveOutfitBundle);
const { loading, refreshing, runInitial, runRefresh } = useListLoading();
+ const { isLoading, startLoading, stopLoading } = useLoading();
+ const notifyModuleOperation = useModuleChatNotifier();
const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]);
+ const categoriesById = useMemo(() => new Map(categories.map((c) => [c.id, c])), [categories]);
+ const outfitTagIds = useMemo(() => {
+ const map = new Map();
+ tagRelations
+ .filter((r) => r.target_type === ClothingTargetType.Outfit)
+ .forEach((r) => {
+ const ids = map.get(r.target_id) ?? [];
+ ids.push(r.tag_id);
+ map.set(r.target_id, ids);
+ });
+ return map;
+ }, [tagRelations]);
+ const tagsById = useMemo(() => new Map(tags.map((t) => [t.id, t])), [tags]);
const loadData = useCallback(async () => {
if (!familyId) return;
@@ -28,8 +59,11 @@ export default function ClothingOutfitList() {
fetchOutfits(familyId),
fetchClothes(familyId),
fetchOutfitClothes(familyId),
+ fetchCategories(familyId, ClothingTargetType.Outfit),
+ fetchTags(familyId, ClothingTargetType.Outfit),
+ fetchTagRelations(familyId, ClothingTargetType.Outfit),
]);
- }, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits]);
+ }, [familyId, fetchClothes, fetchOutfitClothes, fetchOutfits, fetchCategories, fetchTags, fetchTagRelations]);
useEffect(() => {
if (!familyId) return;
@@ -47,6 +81,48 @@ export default function ClothingOutfitList() {
runRefresh(loadData);
}, [loadData, runRefresh]);
+ const [activeItem, setActiveItem] = useState(null);
+ const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
+ const [deleteConfirm, setDeleteConfirm] = useState(null);
+
+ const handleDeleteRequest = useCallback(() => {
+ if (!activeItem) return;
+ setDeleteConfirm(activeItem);
+ setActiveItem(null);
+ }, [activeItem]);
+
+ const handleDeleteConfirm = useCallback(async () => {
+ if (!deleteConfirm || !familyId || isLoading) return;
+ const outfit = deleteConfirm;
+ setDeleteConfirm(null);
+ startLoading("删除中...");
+ try {
+ const state = useClothingStore.getState();
+ const details = state.outfitClothes.filter((row) => row.outfit_id === outfit.id);
+ const relationDeletes = state.tagRelations
+ .filter((row) => row.target_type === ClothingTargetType.Outfit && row.target_id === outfit.id)
+ .map((row) => ({ id: row.id }));
+ const ok = await saveOutfitBundle({
+ outfitDeletes: [{ id: outfit.id }],
+ clothesDeletes: details.map((row) => ({ id: row.id })),
+ tagRelationDeletes: relationDeletes,
+ });
+ if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; }
+ await loadData();
+ notifyModuleOperation({
+ module: "衣服搭配",
+ action: "删除搭配",
+ target: outfit.name,
+ detail: `${details.length} 件衣服`,
+ sourceTable: "b_clothing_outfit",
+ sourceId: outfit.id,
+ });
+ stopLoading({ type: "success", message: "已删除" });
+ } catch {
+ stopLoading({ type: "error", message: "删除失败" });
+ }
+ }, [deleteConfirm, familyId, isLoading, loadData, notifyModuleOperation, saveOutfitBundle, startLoading, stopLoading]);
+
const getCoverKey = useCallback(
(item: ClothingOutfit) => {
if (item.cover_file_key) return item.cover_file_key;
@@ -81,34 +157,53 @@ export default function ClothingOutfitList() {
);
const renderItem = useCallback(
- ({ item }: { item: ClothingOutfit }) => (
- router.push(`/clothes/outfit/detail/${item.id}` as any)}
- >
-
-
-
-
-
-
- {item.name}
-
- {getClothesCount(item)} 件衣服
-
- router.push(`/clothes/outfit/edit?id=${item.id}` as any)}
- >
-
-
+ ({ item }: { item: ClothingOutfit }) => {
+ const categoryName = item.category_id ? categoriesById.get(item.category_id)?.name : null;
+ const itemTagIds = outfitTagIds.get(item.id) ?? [];
+ const itemTags = itemTagIds.map((tid) => tagsById.get(tid)).filter(Boolean);
+
+ return (
+ router.push(`/clothes/outfit/detail/${item.id}` as any)}
+ onLongPress={(e) => {
+ setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
+ setActiveItem(item);
+ }}
+ >
+
+
+
-
-
-
- ),
- [getClothesCount, getCoverKey],
+
+
+ {item.name}
+
+
+ {getClothesCount(item)} 件衣服
+ {categoryName ? ` · ${categoryName}` : ""}
+
+ {itemTags.length > 0 && (
+
+ {itemTags.slice(0, 2).map((tag) => (
+
+ {tag!.name}
+
+ ))}
+
+ )}
+
+
+
+ );
+ },
+ [categoriesById, getClothesCount, getCoverKey, outfitTagIds, tagsById],
);
return (
@@ -144,6 +239,78 @@ export default function ClothingOutfitList() {
/>
)}
/>
+
+ setActiveItem(null)}
+ >
+ setActiveItem(null)}>
+
+ {
+ const item = activeItem;
+ setActiveItem(null);
+ if (item) router.push(`/clothes/outfit/edit?id=${item.id}` as any);
+ }}
+ >
+
+ 编辑
+
+
+
+ 删除
+
+ {
+ const item = activeItem;
+ setActiveItem(null);
+ if (item) router.push(`/clothes/outfit/detail/${item.id}` as any);
+ }}
+ >
+
+ 详细
+
+
+
+
+
+
+
+
);
}
diff --git a/code/app/app-rn/src/app/clothes/edit/index.tsx b/code/app/app-rn/src/app/clothes/edit/index.tsx
index 2f13e915..403c4337 100644
--- a/code/app/app-rn/src/app/clothes/edit/index.tsx
+++ b/code/app/app-rn/src/app/clothes/edit/index.tsx
@@ -2,15 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
import ImagePicker from "react-native-image-crop-picker";
-import { Button, Card, Input, Label, TextArea, TextField } from "heroui-native";
+import { Button, Input, Label, TextArea, TextField } from "heroui-native";
import {
CheckIcon,
ImageIcon,
- PlusIcon,
SaveIcon,
- ShirtIcon,
- TagIcon,
- XIcon,
} from "lucide-react-native";
import { toast } from "sonner-native";
import PageLayout from "@/layouts/PageLayout";
@@ -79,9 +75,6 @@ export default function ClothingEdit() {
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
const fetchTags = useClothingStore((s) => s.fetchTags);
const fetchTagRelations = useClothingStore((s) => s.fetchTagRelations);
- const saveCategoryChanges = useClothingStore((s) => s.saveCategoryChanges);
- const saveTagGroupChanges = useClothingStore((s) => s.saveTagGroupChanges);
- const saveTagChanges = useClothingStore((s) => s.saveTagChanges);
const saveClothesBundle = useClothingStore((s) => s.saveClothesBundle);
const uploadClothesImage = useClothingStore((s) => s.uploadClothesImage);
const { isLoading, startLoading, stopLoading } = useLoading();
@@ -89,10 +82,6 @@ export default function ClothingEdit() {
const [form, setForm] = useState(emptyForm);
const [selectedTagIds, setSelectedTagIds] = useState([]);
- const [newCategoryName, setNewCategoryName] = useState("");
- const [newTagGroupName, setNewTagGroupName] = useState("");
- const [newTagName, setNewTagName] = useState("");
- const [newTagEmoji, setNewTagEmoji] = useState("");
const [uploading, setUploading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
@@ -216,183 +205,6 @@ export default function ClothingEdit() {
}
}, [familyId, uploadClothesImage, uploading]);
- const handleCreateCategory = useCallback(async () => {
- const name = newCategoryName.trim();
- if (!familyId || !name) {
- toast.error("请输入分类名称");
- return;
- }
-
- const existing = clothesCategories.find((item) => item.name === name);
- if (existing) {
- setForm((prev) => ({ ...prev, categoryId: existing.id }));
- setNewCategoryName("");
- return;
- }
-
- const id = (await getUniqueIdApi(1)).data?.[0];
- if (!id) {
- toast.error("获取分类 ID 失败");
- return;
- }
-
- const ok = await saveCategoryChanges({
- inserts: [
- {
- id,
- family_id: familyId,
- name,
- type: ClothingTargetType.Clothes,
- sort_number: clothesCategories.length,
- status: ClothingStatus.Enabled,
- },
- ],
- });
-
- if (!ok) {
- toast.error("分类创建失败");
- return;
- }
-
- await fetchCategories(familyId, ClothingTargetType.Clothes);
- setForm((prev) => ({ ...prev, categoryId: id }));
- setNewCategoryName("");
- notifyModuleOperation({
- module: "衣服搭配",
- action: "新增衣服分类",
- target: name,
- sourceTable: "b_clothing_category",
- sourceId: id,
- });
- toast.success("分类已创建");
- }, [
- clothesCategories,
- familyId,
- fetchCategories,
- newCategoryName,
- notifyModuleOperation,
- saveCategoryChanges,
- ]);
-
- const handleCreateTag = useCallback(async () => {
- const groupName = newTagGroupName.trim();
- const tagName = newTagName.trim();
- const emoji = newTagEmoji.trim();
-
- if (!familyId || !groupName || !tagName) {
- toast.error("请输入标签组和标签名称");
- return;
- }
-
- let group = clothesTagGroups.find((item) => item.name === groupName);
- if (!group) {
- const groupId = (await getUniqueIdApi(1)).data?.[0];
- if (!groupId) {
- toast.error("获取标签组 ID 失败");
- return;
- }
-
- const ok = await saveTagGroupChanges({
- inserts: [
- {
- id: groupId,
- family_id: familyId,
- name: groupName,
- type: ClothingTargetType.Clothes,
- sort_number: clothesTagGroups.length,
- status: ClothingStatus.Enabled,
- },
- ],
- });
-
- if (!ok) {
- toast.error("标签组创建失败");
- return;
- }
-
- group = {
- id: groupId,
- family_id: familyId,
- name: groupName,
- type: ClothingTargetType.Clothes,
- sort_number: clothesTagGroups.length,
- status: ClothingStatus.Enabled,
- };
- }
-
- const existingTag = clothesTags.find(
- (item) => item.tag_group_id === group?.id && item.name === tagName,
- );
- if (existingTag) {
- setSelectedTagIds((prev) =>
- prev.includes(existingTag.id) ? prev : [...prev, existingTag.id],
- );
- setNewTagName("");
- setNewTagEmoji("");
- await Promise.all([
- fetchTagGroups(familyId, ClothingTargetType.Clothes),
- fetchTags(familyId, ClothingTargetType.Clothes),
- ]);
- return;
- }
-
- const tagId = (await getUniqueIdApi(1)).data?.[0];
- if (!tagId) {
- toast.error("获取标签 ID 失败");
- return;
- }
-
- const groupTagCount = clothesTags.filter((item) => item.tag_group_id === group?.id).length;
- const ok = await saveTagChanges({
- inserts: [
- {
- id: tagId,
- family_id: familyId,
- tag_group_id: group.id,
- name: tagName,
- type: ClothingTargetType.Clothes,
- emoji,
- sort_number: groupTagCount,
- status: ClothingStatus.Enabled,
- },
- ],
- });
-
- if (!ok) {
- toast.error("标签创建失败");
- return;
- }
-
- await Promise.all([
- fetchTagGroups(familyId, ClothingTargetType.Clothes),
- fetchTags(familyId, ClothingTargetType.Clothes),
- ]);
- setSelectedTagIds((prev) => [...prev, tagId]);
- setNewTagName("");
- setNewTagEmoji("");
- notifyModuleOperation({
- module: "衣服搭配",
- action: "新增衣服标签",
- target: tagName,
- detail: group.name,
- sourceTable: "b_clothing_tag",
- sourceId: tagId,
- });
- toast.success("标签已创建");
- }, [
- clothesTagGroups,
- clothesTags,
- familyId,
- fetchTagGroups,
- fetchTags,
- newTagEmoji,
- newTagGroupName,
- newTagName,
- notifyModuleOperation,
- saveTagChanges,
- saveTagGroupChanges,
- ]);
-
const handleSave = useCallback(async () => {
const name = form.name.trim();
if (!name) {
@@ -537,7 +349,6 @@ export default function ClothingEdit() {
onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))}
>
- {item.emoji ? `${item.emoji} ` : ""}
{item.name}
@@ -563,180 +374,90 @@ export default function ClothingEdit() {
return (
-
+
-
-
-
+ {form.imageFileKey ? (
+
+ ) : (
+
+
+ 上传衣服图片
+
+ )}
+
+
+ {/* 基础信息 */}
+ 基础信息
+
+
+
+ setForm((prev) => ({ ...prev, name }))}
+ placeholder="例如:白色衬衫"
+ />
+
+
+
+
+ setForm((prev) => ({ ...prev, brand }))}
+ placeholder="可选"
+ />
+
+
+
+
+
+
+
+ {/* 分类 */}
+ 分类
+
+ setForm((prev) => ({ ...prev, categoryId: null }))}
+ >
+
- {form.imageFileKey ? (
-
- ) : (
-
-
- 上传衣服图片
-
- )}
-
- 图片必填,上传时会裁剪为统一方图。
-
-
+ 不选择
+
+
+ {clothesCategories.map(renderCategoryChip)}
+
-
-
-
-
- 基础信息
+ {/* 标签 */}
+ {clothesTagGroups.length > 0 && (
+ <>
+ 标签
+
+ {clothesTagGroups.map(renderTagGroup)}
-
-
-
-
- setForm((prev) => ({ ...prev, name }))}
- placeholder="例如:白色衬衫"
- />
-
-
-
-
- setForm((prev) => ({ ...prev, brand }))}
- placeholder="可选"
- />
-
-
-
-
-
-
-
-
-
-
- 分类
-
-
-
- setForm((prev) => ({ ...prev, categoryId: null }))}
- >
-
- 不选择
-
-
- {clothesCategories.map(renderCategoryChip)}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 标签
-
-
-
- {clothesTagGroups.length > 0 ? (
- clothesTagGroups.map(renderTagGroup)
- ) : (
- 暂无标签,先在下方新建。
- )}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- 添加
-
-
-
-
- {selectedTagIds.length > 0 && (
- setSelectedTagIds([])}
- >
-
- 清空已选标签
-
- )}
-
-
+ >
+ )}
router.back()}>
diff --git a/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx b/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx
index 38df1094..50ac49de 100644
--- a/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx
+++ b/code/app/app-rn/src/app/clothes/outfit/detail/[id]/index.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
-import { Alert, Text, TouchableOpacity, View } from "react-native";
+import { Text, TouchableOpacity, View } from "react-native";
import { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
-import { Button, Card } from "heroui-native";
+import { Button, Card, Dialog } from "heroui-native";
import {
FolderIcon,
PencilIcon,
@@ -44,6 +44,7 @@ export default function ClothingOutfitDetail() {
const { isLoading, startLoading, stopLoading } = useLoading();
const notifyModuleOperation = useModuleChatNotifier();
const [pageLoading, setPageLoading] = useState(true);
+ const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const outfit = useMemo(
() => outfits.find((item) => item.id === outfitId) ?? null,
@@ -117,34 +118,33 @@ export default function ClothingOutfitDetail() {
}, [familyId, loadData, outfitId]),
);
- const handleDelete = useCallback(() => {
+ const handleDeleteRequest = useCallback(() => {
if (!familyId || !outfit || isLoading) return;
+ setShowDeleteDialog(true);
+ }, [familyId, outfit, isLoading]);
- Alert.alert("删除搭配", `确认删除「${outfit.name}」吗?衣服本身不会被删除。`, [
- { text: "取消", style: "cancel" },
- {
- text: "删除",
- style: "destructive",
- onPress: async () => {
- startLoading("删除中...");
- try {
- const state = useClothingStore.getState();
- const currentDetails = state.outfitClothes.filter(
- (item) => item.outfit_id === outfit.id,
- );
- const relationDeletes = state.tagRelations
- .filter(
- (item) =>
- item.target_type === ClothingTargetType.Outfit && item.target_id === outfit.id,
- )
- .map((item) => ({ id: item.id }));
- const ok = await saveOutfitBundle({
- outfitDeletes: [{ id: outfit.id }],
- clothesDeletes: currentDetails.map((item: ClothingOutfitClothes) => ({
- id: item.id,
- })),
- tagRelationDeletes: relationDeletes,
- });
+ const handleDeleteConfirm = useCallback(async () => {
+ if (!familyId || !outfit || isLoading) return;
+ setShowDeleteDialog(false);
+ startLoading("删除中...");
+ try {
+ const state = useClothingStore.getState();
+ const currentDetails = state.outfitClothes.filter(
+ (item) => item.outfit_id === outfit.id,
+ );
+ const relationDeletes = state.tagRelations
+ .filter(
+ (item) =>
+ item.target_type === ClothingTargetType.Outfit && item.target_id === outfit.id,
+ )
+ .map((item) => ({ id: item.id }));
+ const ok = await saveOutfitBundle({
+ outfitDeletes: [{ id: outfit.id }],
+ clothesDeletes: currentDetails.map((item: ClothingOutfitClothes) => ({
+ id: item.id,
+ })),
+ tagRelationDeletes: relationDeletes,
+ });
if (!ok) {
stopLoading({ type: "error", message: "删除失败" });
@@ -172,12 +172,9 @@ export default function ClothingOutfitDetail() {
});
stopLoading({ type: "success", message: "搭配已删除" });
router.replace("/clothes/outfit" as any);
- } catch {
- stopLoading({ type: "error", message: "删除失败" });
- }
- },
- },
- ]);
+ } catch {
+ stopLoading({ type: "error", message: "删除失败" });
+ }
}, [
familyId,
fetchOutfitClothes,
@@ -227,7 +224,7 @@ export default function ClothingOutfitDetail() {
return (
-
+
{
const group = tagGroups.find((item) => item.id === tag.tag_group_id);
return (
-
-
- {tag.emoji ? `${tag.emoji} ` : ""}
+
+
{group ? `${group.name}/` : ""}
{tag.name}
@@ -326,7 +329,7 @@ export default function ClothingOutfitDetail() {
@@ -339,6 +342,26 @@ export default function ClothingOutfitDetail() {
+
+
);
diff --git a/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx b/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx
index d3d3c782..0e3942fd 100644
--- a/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx
+++ b/code/app/app-rn/src/app/clothes/outfit/edit/index.tsx
@@ -6,6 +6,8 @@ import { Button, Card, Input, Label, TextArea, TextField } from "heroui-native";
import {
BringToFrontIcon,
ImageIcon,
+ Maximize2Icon,
+ Minimize2Icon,
MinusIcon,
PlusIcon,
SaveIcon,
@@ -16,6 +18,7 @@ import {
import { toast } from "sonner-native";
import PageLayout from "@/layouts/PageLayout";
import LayoutView from "@/components/layout/view";
+import BottomSheet from "@/components/BottomSheet";
import ClothingImage from "@/components/clothing/ClothingImage";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useLoading } from "@/hooks/useLoading";
@@ -88,29 +91,29 @@ function DraggableCanvasItem({
onMove: (uid: string, x: number, y: number) => void;
}) {
const startRef = useRef({ x: item.x, y: item.y });
+ const itemRef = useRef(item);
+ const sizeRef = useRef(canvasSize);
+ itemRef.current = item;
+ sizeRef.current = canvasSize;
- const panResponder = useMemo(
- () =>
- PanResponder.create({
- onStartShouldSetPanResponder: () => true,
- onMoveShouldSetPanResponder: () => true,
- onPanResponderGrant: () => {
- startRef.current = { x: item.x, y: item.y };
- onSelect(item.uid);
- },
- onPanResponderMove: (_, gestureState) => {
- if (!canvasSize) return;
- const nextX = clamp(startRef.current.x + gestureState.dx / canvasSize, 0, 1 - item.width);
- const nextY = clamp(
- startRef.current.y + gestureState.dy / canvasSize,
- 0,
- 1 - item.height,
- );
- onMove(item.uid, normalizeNumber(nextX), normalizeNumber(nextY));
- },
- }),
- [canvasSize, item.height, item.uid, item.width, item.x, item.y, onMove, onSelect],
- );
+ const panResponder = useRef(
+ PanResponder.create({
+ onStartShouldSetPanResponder: () => true,
+ onMoveShouldSetPanResponder: () => true,
+ onPanResponderGrant: () => {
+ startRef.current = { x: itemRef.current.x, y: itemRef.current.y };
+ onSelect(itemRef.current.uid);
+ },
+ onPanResponderMove: (_, gestureState) => {
+ const sz = sizeRef.current;
+ const it = itemRef.current;
+ if (!sz) return;
+ const nextX = clamp(startRef.current.x + gestureState.dx / sz, 0, 1 - it.width);
+ const nextY = clamp(startRef.current.y + gestureState.dy / sz, 0, 1 - it.height);
+ onMove(it.uid, normalizeNumber(nextX), normalizeNumber(nextY));
+ },
+ }),
+ ).current;
if (!canvasSize) return null;
@@ -170,6 +173,8 @@ export default function ClothingOutfitEdit() {
const [selectedUid, setSelectedUid] = useState(null);
const [pageLoading, setPageLoading] = useState(true);
const [capturingCover, setCapturingCover] = useState(false);
+ const [showClothesSheet, setShowClothesSheet] = useState(false);
+ const [canvasFullscreen, setCanvasFullscreen] = useState(false);
const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]);
const outfitCategories = useMemo(
@@ -342,16 +347,14 @@ export default function ClothingOutfitEdit() {
await new Promise((resolve) => requestAnimationFrame(() => resolve()));
const uri = await captureRef(canvasRef, {
- format: "jpg",
+ format: "png",
quality: 0.86,
- width: 512,
- height: 512,
result: "tmpfile",
});
const key = await uploadOutfitCover(familyId, {
uri,
- name: `outfit_cover_${targetId}.jpg`,
- type: "image/jpeg",
+ name: `outfit_cover_${targetId}.png`,
+ type: "image/png",
});
return key;
@@ -398,7 +401,6 @@ export default function ClothingOutfitEdit() {
onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))}
>
- {item.emoji ? `${item.emoji} ` : ""}
{item.name}
@@ -613,202 +615,123 @@ export default function ClothingOutfitEdit() {
stopLoading,
]);
+ const canvasContent = (
+ setCanvasSize(event.nativeEvent.layout.width)}
+ >
+ {items.length === 0 && (
+
+
+ 点击下方按钮添加衣服
+
+ )}
+ {items.map((item) => (
+
+ ))}
+
+ );
+
return (
-
+
-
-
-
-
-
-
+
+
+
+
+
-
-
-
-
-
-
-
-
-
- 分类
-
-
-
- setCategoryId(null)}
- >
-
- 不选择
-
-
- {outfitCategories.map(renderCategoryChip)}
-
-
-
-
-
-
- 标签
-
-
- {outfitTagGroups.length > 0 ? (
- outfitTagGroups.map(renderTagGroup)
- ) : (
- 暂无搭配标签,可在分类页维护。
- )}
-
- {selectedTagIds.length > 0 && (
- setSelectedTagIds([])}
- >
-
- 清空已选标签
-
- )}
-
-
-
-
- setCanvasSize(event.nativeEvent.layout.width)}
- >
- setSelectedUid(null)}
+
+
+
- {items.length === 0 && (
-
-
- 从下方添加衣服到画布
-
- )}
- {items.map((item) => (
-
- ))}
-
-
+
+
-
-
-
- 画布操作
- {!!selectedItem && (
-
- {clothesById.get(selectedItem.clothesId)?.name}
-
- )}
-
-
-
-
- handleScaleSelected(-0.04)}
- >
-
- 缩小
-
- handleScaleSelected(0.04)}
- >
-
- 放大
-
- handleLayerSelected("front")}
- >
-
- 置顶
-
- handleLayerSelected("back")}
- >
-
- 置底
-
-
-
- 删除
-
-
-
-
+ {/* 分类 */}
+ 分类
+
+ setCategoryId(null)}
+ >
+
+ 不选择
+
+
+ {outfitCategories.map(renderCategoryChip)}
+
-
-
- 选择衣服
-
-
- {clothes.length > 0 ? (
-
- {clothes.map((item) => (
- handleAddClothes(item)}
- >
-
-
-
- {item.name}
-
-
-
- ))}
-
- ) : (
- 暂无衣服,请先在衣橱中新增衣服。
- )}
-
-
+ {/* 标签 */}
+ {outfitTagGroups.length > 0 && (
+ <>
+ 标签
+
+ {outfitTagGroups.map(renderTagGroup)}
+
+ >
+ )}
+
+ {/* 画布操作栏 */}
+
+ handleScaleSelected(-0.04)}>
+
+
+ handleScaleSelected(0.04)}>
+
+
+ handleLayerSelected("front")}>
+
+
+ handleLayerSelected("back")}>
+
+
+
+ {selectedItem && (
+
+ {clothesById.get(selectedItem.clothesId)?.name}
+
+ )}
+
+
+
+
+ setCanvasFullscreen((v) => !v)}>
+ {canvasFullscreen ? : }
+
+
+
+ {/* 画布 */}
+ {!canvasFullscreen && (
+
+ {canvasContent}
+
+ )}
+
+ setShowClothesSheet(true)}>
+
+ 从衣橱添加衣服
+
router.back()}>
@@ -823,6 +746,58 @@ export default function ClothingOutfitEdit() {
+
+
+
+
+ {clothes.length > 0 ? (
+
+ {clothes.map((item) => (
+ handleAddClothes(item)}
+ >
+
+
+
+ {item.name}
+
+
+
+ ))}
+
+ ) : (
+ 暂无衣服,请先在衣橱中新增衣服。
+ )}
+
+
+
+
+ {canvasFullscreen && (
+
+
+ setCanvasFullscreen(false)}
+ >
+
+
+
+
+ {canvasContent}
+
+
+ )}
+
);
diff --git a/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx
index f264c0d0..5202d7b2 100644
--- a/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx
+++ b/code/app/app-rn/src/app/finance/(tabs)/analysis/index.tsx
@@ -434,11 +434,18 @@ export default function FinanceAnalysis() {
return (
-
+
-
- {filter.startDate} 至 {filter.endDate}
-
+
+
+ {dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")}
+
+
+ {dayjs(filter.startDate).year() !== dayjs(filter.endDate).year()
+ ? `${dayjs(filter.startDate).format("YYYY")} / ${dayjs(filter.endDate).format("YYYY")}`
+ : dayjs(filter.startDate).format("YYYY年")}
+
+
= 0 ? "income" : "expense"}
/>
@@ -524,7 +531,7 @@ export default function FinanceAnalysis() {
支出峰值
- ¥{formatMoney(trendSummary.maxExpense)}
+ {formatMoney(trendSummary.maxExpense)}
{trendSummary.maxExpenseDay
@@ -538,7 +545,7 @@ export default function FinanceAnalysis() {
收入峰值
- ¥{formatMoney(trendSummary.maxIncome)}
+ {formatMoney(trendSummary.maxIncome)}
{trendSummary.maxIncomeDay
@@ -549,7 +556,6 @@ export default function FinanceAnalysis() {
日均支出
- ¥
{formatMoney(
trendSummary.nonZeroDays > 0
? trendSummary.totalExpense / trendData.length
@@ -599,7 +605,7 @@ export default function FinanceAnalysis() {
{item.categoryName}
- ¥{formatCompactMoney(item.amount)} ({Math.round(pct)}%)
+ {formatCompactMoney(item.amount)} ({Math.round(pct)}%)
@@ -638,7 +644,7 @@ export default function FinanceAnalysis() {
-
+
-
+
{[
{ key: "all" as const, label: "全部" },
@@ -686,7 +692,7 @@ export default function FinanceAnalysis() {
-
+
-
+
-
+
setDraftFilter((prev) => ({ ...prev, keyword }))}
@@ -911,7 +917,7 @@ function DailyBarChart({ data, maxVal: rawMax }: { data: BarItem[]; maxVal: numb
className="text-[11px] font-medium w-14 text-right"
style={{ color: stack.color }}
>
- ¥{formatMoney(stack.value)}
+ {formatMoney(stack.value)}
);
diff --git a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx
index 261e6377..7cb22b0f 100644
--- a/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx
+++ b/code/app/app-rn/src/app/finance/(tabs)/home/index.tsx
@@ -245,7 +245,7 @@ export default function Home() {
if (!activeBill || isLoading) return;
const id = activeBill.id;
- const target = `${activeBill.category_type === FinanceType.Income ? "收入" : "支出"} ¥${formatMoney(
+ const target = `${activeBill.category_type === FinanceType.Income ? "收入" : "支出"} ${formatMoney(
Number(activeBill.amount),
)}`;
const detail = activeBill.category_name || "未分类";
@@ -286,7 +286,7 @@ export default function Home() {
() => (
-
+
{dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")}
diff --git a/code/app/app-rn/src/app/finance/add/index.tsx b/code/app/app-rn/src/app/finance/add/index.tsx
index 6ed97e0a..03e7005a 100644
--- a/code/app/app-rn/src/app/finance/add/index.tsx
+++ b/code/app/app-rn/src/app/finance/add/index.tsx
@@ -204,12 +204,12 @@ export default function Add() {
notifyModuleOperation({
module: "记账",
action: isEdit ? "更新记账" : "新增记账",
- target: `${label} ¥${amount}`,
+ target: `${label} ${amount}`,
detail: selectedCategory.name,
sourceTable: "b_finance",
sourceId: targetId,
});
- toast.success(isEdit ? "记账已更新" : `已记${label} ¥${amount}`);
+ toast.success(isEdit ? "记账已更新" : `已记${label} ${amount}`);
if (keepOpen) {
setAmount("");
setRemark("");
@@ -342,7 +342,7 @@ export default function Add() {
{/* 备注 */}
-
+
();
+
const closeSheet = useCallback(() => {
setForm(null);
setSaving(false);
@@ -307,9 +310,9 @@ export default function FinanceCategoryList() {
return (
-
+
-
+
@@ -333,6 +336,7 @@ export default function FinanceCategoryList() {
String(item.id)}
renderItem={renderItem}
@@ -371,7 +375,7 @@ export default function FinanceCategoryList() {
-
+
setForm((prev) => (prev ? { ...prev, name } : prev))}
@@ -380,7 +384,7 @@ export default function FinanceCategoryList() {
-
+
setForm((prev) => (prev ? { ...prev, type } : prev))}
diff --git a/code/app/app-rn/src/app/finance/detail/[id].tsx b/code/app/app-rn/src/app/finance/detail/[id].tsx
index d5c0a636..1c64205f 100644
--- a/code/app/app-rn/src/app/finance/detail/[id].tsx
+++ b/code/app/app-rn/src/app/finance/detail/[id].tsx
@@ -88,7 +88,7 @@ export default function FinanceDetail() {
return (
-
+
{!bill && loaded ? (
@@ -118,7 +118,7 @@ export default function FinanceDetail() {
- {isIncome ? "+" : "-"}¥{formatMoney(bill.amount)}
+ {isIncome ? "+" : "-"}{formatMoney(bill.amount)}
diff --git a/code/app/app-rn/src/components/BottomSheet.tsx b/code/app/app-rn/src/components/BottomSheet.tsx
index 0d42d893..5e0dd61f 100644
--- a/code/app/app-rn/src/components/BottomSheet.tsx
+++ b/code/app/app-rn/src/components/BottomSheet.tsx
@@ -24,6 +24,8 @@ import {
type ViewStyle,
} from "react-native";
import { Input, useBottomSheetAwareHandlers } from "heroui-native";
+import { Toaster } from "sonner-native";
+import { resetToaster } from "@/utils/toaster";
// ── Props ──────────────────────────────────────────────
@@ -153,7 +155,7 @@ function AppBottomSheetRoot({
input.measureInWindow((x, y, w, h) => {
const screenBottom = SCREEN_H - kbHeight;
const inputBottom = y + h;
- const needed = inputBottom - screenBottom - 16;
+ const needed = inputBottom - screenBottom - 18;
keyboardY.setValue(needed > 0 ? -needed : 0);
});
}, 50);
@@ -172,6 +174,13 @@ function AppBottomSheetRoot({
return () => { s1.remove(); s2.remove(); h1.remove(); h2.remove(); };
}, [visible, adjustForKeyboard]);
+ // Modal 关闭后恢复全局 Toaster
+ useEffect(() => {
+ if (visible) return;
+ const timer = setTimeout(() => resetToaster(), 100);
+ return () => clearTimeout(timer);
+ }, [visible]);
+
if (!visible) return null;
return (
@@ -206,6 +215,8 @@ function AppBottomSheetRoot({
{children}
+
+
);
@@ -230,8 +241,8 @@ function AppBottomSheetContent({
return (
)}
>
{children}
diff --git a/code/app/app-rn/src/components/DateTime.tsx b/code/app/app-rn/src/components/DateTime.tsx
index 28c04308..20a1fdeb 100644
--- a/code/app/app-rn/src/components/DateTime.tsx
+++ b/code/app/app-rn/src/components/DateTime.tsx
@@ -89,9 +89,8 @@ export default function DateTime({
-
+
void) | null = null;
+
+export function onToasterReset(fn: () => void) {
+ resetFn = fn;
+}
+
+export function resetToaster() {
+ resetFn?.();
+}