20260630224653
This commit is contained in:
1 parent
cebbd3f92c
commit
48a0956e24
22 files changed
+1040
-1073
No files matched your search
@@ -87,7 +87,7 @@ create table b_clothing_tag (
|
|||||||
tag_group_id bigint not null,
|
tag_group_id bigint not null,
|
||||||
name varchar(32) not null,
|
name varchar(32) not null,
|
||||||
type smallint not null,
|
type smallint not null,
|
||||||
emoji varchar(16) default '',
|
color varchar(7) 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,
|
||||||
create_time timestamp,
|
create_time timestamp,
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
|||||||
return (
|
return (
|
||||||
<View className="items-center py-2 px-4">
|
<View className="items-center py-2 px-4">
|
||||||
<View className="bg-black/5 rounded-full px-3 py-1">
|
<View className="bg-black/5 rounded-full px-3 py-1">
|
||||||
<Text className="text-xs text-gray-500">{item.content}</Text>
|
<Text className="text-xs text-gray-500">{nickname} {item.content}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -273,7 +273,7 @@ export default function ChatHistoryPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>时间范围</Label>
|
<Label className="mb-2">时间范围</Label>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
|
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
@@ -289,7 +289,7 @@ export default function ChatHistoryPage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>娑堟伅绫诲瀷</Label>
|
<Label className="mb-2">娑堟伅绫诲瀷</Label>
|
||||||
<View className="flex-row gap-2 flex-wrap">
|
<View className="flex-row gap-2 flex-wrap">
|
||||||
{MSG_TYPES.map((t) => (
|
{MSG_TYPES.map((t) => (
|
||||||
<PressableFeedback key={t.value} onPress={() => setDraftTypeFilter(t.value)}>
|
<PressableFeedback key={t.value} onPress={() => setDraftTypeFilter(t.value)}>
|
||||||
|
|||||||
@@ -529,7 +529,7 @@ export default function HomeSettings() {
|
|||||||
<BottomSheet.Content>
|
<BottomSheet.Content>
|
||||||
<View className="px-4">
|
<View className="px-4">
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>邀请码</Label>
|
<Label className="mb-2">邀请码</Label>
|
||||||
<PressableFeedback
|
<PressableFeedback
|
||||||
className="flex-row gap-2 items-center justify-center h-20 bg-white rounded-xl shadow-2xl"
|
className="flex-row gap-2 items-center justify-center h-20 bg-white rounded-xl shadow-2xl"
|
||||||
onPress={copyInviteCode}
|
onPress={copyInviteCode}
|
||||||
@@ -553,7 +553,7 @@ export default function HomeSettings() {
|
|||||||
<BottomSheet.Overlay />
|
<BottomSheet.Overlay />
|
||||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>家庭名称</Label>
|
<Label className="mb-2">家庭名称</Label>
|
||||||
<BottomSheet.Input
|
<BottomSheet.Input
|
||||||
ref={familyNameInputRef}
|
ref={familyNameInputRef}
|
||||||
value={renameForm.name}
|
value={renameForm.name}
|
||||||
@@ -576,7 +576,7 @@ export default function HomeSettings() {
|
|||||||
<BottomSheet.Content animateOnMount={false}>
|
<BottomSheet.Content animateOnMount={false}>
|
||||||
<View className="gap-4">
|
<View className="gap-4">
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>我的昵称</Label>
|
<Label className="mb-2">我的昵称</Label>
|
||||||
<BottomSheet.Input
|
<BottomSheet.Input
|
||||||
ref={nicknameInputRef}
|
ref={nicknameInputRef}
|
||||||
value={nicknameForm.nickname}
|
value={nicknameForm.nickname}
|
||||||
|
|||||||
@@ -725,7 +725,7 @@ export default function UserSetting() {
|
|||||||
<BottomSheet.Overlay />
|
<BottomSheet.Overlay />
|
||||||
<BottomSheet.Content className="gap-4">
|
<BottomSheet.Content className="gap-4">
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>姓名</Label>
|
<Label className="mb-2">姓名</Label>
|
||||||
<BottomSheet.Input
|
<BottomSheet.Input
|
||||||
ref={nameInputRef}
|
ref={nameInputRef}
|
||||||
value={renameForm.name}
|
value={renameForm.name}
|
||||||
@@ -747,7 +747,7 @@ export default function UserSetting() {
|
|||||||
<BottomSheet.Overlay />
|
<BottomSheet.Overlay />
|
||||||
<BottomSheet.Content className="gap-4">
|
<BottomSheet.Content className="gap-4">
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>手机号</Label>
|
<Label className="mb-2">手机号</Label>
|
||||||
<BottomSheet.Input
|
<BottomSheet.Input
|
||||||
ref={phoneInputRef}
|
ref={phoneInputRef}
|
||||||
value={phoneForm.phone}
|
value={phoneForm.phone}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
|
|||||||
import { HeroUINativeConfig } from "heroui-native";
|
import { HeroUINativeConfig } from "heroui-native";
|
||||||
import { ChatWebSocketProvider } from "@/hooks/useChatWebSocket";
|
import { ChatWebSocketProvider } from "@/hooks/useChatWebSocket";
|
||||||
import { useSessionBootstrap } from "@/hooks/useSessionBootstrap";
|
import { useSessionBootstrap } from "@/hooks/useSessionBootstrap";
|
||||||
|
import { onToasterReset } from "@/utils/toaster";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
SplashScreen.preventAutoHideAsync();
|
SplashScreen.preventAutoHideAsync();
|
||||||
|
|
||||||
@@ -22,6 +24,11 @@ const config: HeroUINativeConfig = {
|
|||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { isReady } = useSessionBootstrap();
|
const { isReady } = useSessionBootstrap();
|
||||||
|
const [tsKey, setTsKey] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onToasterReset(() => setTsKey((k) => k + 1));
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (!isReady) return null;
|
if (!isReady) return null;
|
||||||
|
|
||||||
@@ -47,7 +54,7 @@ export default function RootLayout() {
|
|||||||
</HeroUINativeProvider>
|
</HeroUINativeProvider>
|
||||||
</KeyboardProvider>
|
</KeyboardProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
<Toaster duration={1000} />
|
<Toaster key={tsKey} duration={1000} />
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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 { router, useFocusEffect } from "expo-router";
|
||||||
import { Button, Card, Label, Menu, SearchField } from "heroui-native";
|
import { Button, Card, Dialog, Label, SearchField } from "heroui-native";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
FilterIcon,
|
FilterIcon,
|
||||||
@@ -67,6 +67,8 @@ export default function ClothingHome() {
|
|||||||
const [draftFilter, setDraftFilter] = useState<ClothesFilter>(() => createDefaultFilter());
|
const [draftFilter, setDraftFilter] = useState<ClothesFilter>(() => createDefaultFilter());
|
||||||
const [showFilterSheet, setShowFilterSheet] = useState(false);
|
const [showFilterSheet, setShowFilterSheet] = useState(false);
|
||||||
const [activeItem, setActiveItem] = useState<ClothingClothes | null>(null);
|
const [activeItem, setActiveItem] = useState<ClothingClothes | null>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<{ item: ClothingClothes; relatedOutfitCount: number } | null>(null);
|
||||||
|
|
||||||
const clothesCategories = useMemo(
|
const clothesCategories = useMemo(
|
||||||
() => categories.filter((item) => item.type === ClothingTargetType.Clothes),
|
() => categories.filter((item) => item.type === ClothingTargetType.Clothes),
|
||||||
@@ -266,33 +268,24 @@ export default function ClothingHome() {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (relatedOutfitIds.length === 0) {
|
setDeleteConfirm({ item, relatedOutfitCount: relatedOutfitIds.length });
|
||||||
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),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[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(
|
const renderCategoryName = useCallback(
|
||||||
(categoryId?: number | null) =>
|
(categoryId?: number | null) =>
|
||||||
clothesCategories.find((category) => category.id === categoryId)?.name ?? "未分类",
|
clothesCategories.find((category) => category.id === categoryId)?.name ?? "",
|
||||||
[clothesCategories],
|
[clothesCategories],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -305,9 +298,16 @@ export default function ClothingHome() {
|
|||||||
.filter((tag) => itemTagIds.has(tag.id))
|
.filter((tag) => itemTagIds.has(tag.id))
|
||||||
.slice(0, 3)
|
.slice(0, 3)
|
||||||
.map((tag) => (
|
.map((tag) => (
|
||||||
<View key={tag.id} className="rounded-full bg-gray-100 px-2 py-1">
|
<View
|
||||||
<Text className="text-[10px] text-gray-600" numberOfLines={1}>
|
key={tag.id}
|
||||||
{tag.emoji ? `${tag.emoji} ` : ""}
|
className="rounded-full px-2 py-1"
|
||||||
|
style={{ backgroundColor: tag.color || "#F3F4F6" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-[10px]"
|
||||||
|
style={{ color: tag.color ? "#fff" : "#4B5563" }}
|
||||||
|
numberOfLines={1}
|
||||||
|
>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -323,36 +323,39 @@ export default function ClothingHome() {
|
|||||||
activeOpacity={0.82}
|
activeOpacity={0.82}
|
||||||
delayLongPress={260}
|
delayLongPress={260}
|
||||||
onPress={() => handleEdit(item)}
|
onPress={() => handleEdit(item)}
|
||||||
onLongPress={() => setActiveItem(item)}
|
onLongPress={(e) => {
|
||||||
|
setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
|
||||||
|
setActiveItem(item);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Card className="overflow-hidden p-0">
|
<Card className="overflow-hidden p-0">
|
||||||
<ClothingImage fileKey={item.image_file_key} label={item.name} className="h-40 w-full" />
|
<ClothingImage fileKey={item.image_file_key} label={item.name} className="h-40 w-full" />
|
||||||
<Card.Body className="p-3">
|
<Card.Body className="px-3 pt-3 pb-2">
|
||||||
<View className="flex-row items-start justify-between gap-2">
|
<Card.Title className="text-lg" numberOfLines={1}>
|
||||||
<View className="flex-1">
|
{item.name}
|
||||||
<Card.Title className="text-sm" numberOfLines={1}>
|
</Card.Title>
|
||||||
{item.name}
|
{item.brand?.trim() ? (
|
||||||
</Card.Title>
|
<Card.Description className="mt-0.5" numberOfLines={1}>
|
||||||
<Card.Description className="mt-1" numberOfLines={1}>
|
{item.brand.trim()}
|
||||||
{item.brand?.trim() || renderCategoryName(item.category_id)}
|
</Card.Description>
|
||||||
</Card.Description>
|
) : null}
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
className="h-8 w-8 rounded-full bg-gray-100 items-center justify-center"
|
|
||||||
onPress={() => setActiveItem(item)}
|
|
||||||
>
|
|
||||||
<PencilIcon size={15} color="#4B5563" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="mt-3 flex-row flex-wrap gap-1.5">
|
{(() => {
|
||||||
<View className="rounded-full bg-gray-900 px-2 py-1">
|
const hasTags = (tagIdsByClothesId.get(item.id)?.size ?? 0) > 0;
|
||||||
<Text className="text-[10px] text-white" numberOfLines={1}>
|
if (!item.category_id && !hasTags) return null;
|
||||||
{renderCategoryName(item.category_id)}
|
return (
|
||||||
</Text>
|
<View className="mt-1.5 flex-row flex-wrap gap-1.5">
|
||||||
</View>
|
{item.category_id ? (
|
||||||
{renderItemTags(item)}
|
<View className="rounded-full bg-gray-900 px-2 py-1">
|
||||||
</View>
|
<Text className="text-[10px] text-white" numberOfLines={1}>
|
||||||
|
{renderCategoryName(item.category_id)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
{renderItemTags(item)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</Card.Body>
|
</Card.Body>
|
||||||
</Card>
|
</Card>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -406,7 +409,6 @@ export default function ClothingHome() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
|
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
|
||||||
{item.emoji ? `${item.emoji} ` : ""}
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -498,7 +500,7 @@ export default function ClothingHome() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>分类</Label>
|
<Label className="mb-2">分类</Label>
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className={`rounded-full border px-3 py-2 ${
|
className={`rounded-full border px-3 py-2 ${
|
||||||
@@ -521,7 +523,7 @@ export default function ClothingHome() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>标签</Label>
|
<Label className="mb-2">标签</Label>
|
||||||
{clothesTags.length > 0 ? (
|
{clothesTags.length > 0 ? (
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
{clothesTags.map(renderFilterTag)}
|
{clothesTags.map(renderFilterTag)}
|
||||||
@@ -548,28 +550,63 @@ export default function ClothingHome() {
|
|||||||
</BottomSheet.Portal>
|
</BottomSheet.Portal>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
<Menu
|
<Modal
|
||||||
isOpen={!!activeItem}
|
visible={!!activeItem}
|
||||||
onOpenChange={(open) => {
|
transparent
|
||||||
if (!open) setActiveItem(null);
|
animationType="none"
|
||||||
}}
|
onRequestClose={() => setActiveItem(null)}
|
||||||
>
|
>
|
||||||
<Menu.Portal>
|
<Pressable className="flex-1" onPress={() => setActiveItem(null)}>
|
||||||
<Menu.Overlay />
|
<View
|
||||||
<Menu.Content presentation="bottom-sheet">
|
className="absolute bg-white rounded-xl w-[200px] overflow-hidden shadow-lg"
|
||||||
<View className="px-2 pb-4">
|
style={{
|
||||||
<Menu.Item onPress={() => activeItem && handleEdit(activeItem)}>
|
left: Math.min(menuPos.x - 190, menuPos.x - 10),
|
||||||
<PencilIcon size={18} color="#374151" />
|
top: menuPos.y - 100,
|
||||||
<Menu.ItemTitle>编辑</Menu.ItemTitle>
|
}}
|
||||||
</Menu.Item>
|
>
|
||||||
<Menu.Item onPress={() => activeItem && handleRequestDelete(activeItem)}>
|
<TouchableOpacity
|
||||||
<Trash2Icon size={18} color="#EF4444" />
|
className="flex-row items-center gap-3 px-4 py-3 border-b border-gray-100"
|
||||||
<Menu.ItemTitle className="text-red-500">删除</Menu.ItemTitle>
|
activeOpacity={0.6}
|
||||||
</Menu.Item>
|
onPress={() => { if (activeItem) handleEdit(activeItem); }}
|
||||||
|
>
|
||||||
|
<PencilIcon size={18} color="#374151" />
|
||||||
|
<Text className="text-[15px] text-gray-800">编辑</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center gap-3 px-4 py-3"
|
||||||
|
activeOpacity={0.6}
|
||||||
|
onPress={() => { if (activeItem) handleRequestDelete(activeItem); }}
|
||||||
|
>
|
||||||
|
<Trash2Icon size={18} color="#EF4444" />
|
||||||
|
<Text className="text-[15px] text-red-500">删除</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Dialog isOpen={!!deleteConfirm} onOpenChange={(v) => { if (!v) setDeleteConfirm(null); }}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Title>
|
||||||
|
{deleteConfirm?.relatedOutfitCount ? "衣服已被搭配使用" : "删除衣服"}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
{deleteConfirm?.relatedOutfitCount
|
||||||
|
? `「${deleteConfirm.item.name}」已被 ${deleteConfirm.relatedOutfitCount} 个搭配使用,是否同时删除这些搭配?`
|
||||||
|
: `确认删除「${deleteConfirm?.item.name}」吗?`}
|
||||||
|
</Dialog.Description>
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Button variant="tertiary" className="flex-1" onPress={() => setDeleteConfirm(null)}>
|
||||||
|
<Button.Label>{deleteConfirm?.relatedOutfitCount ? "不删除" : "取消"}</Button.Label>
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" className="flex-1" onPress={() => handleConfirmDelete(!!deleteConfirm?.relatedOutfitCount)}>
|
||||||
|
<Button.Label>{deleteConfirm?.relatedOutfitCount ? "全部删除" : "删除"}</Button.Label>
|
||||||
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</Menu.Content>
|
</Dialog.Content>
|
||||||
</Menu.Portal>
|
</Dialog.Portal>
|
||||||
</Menu>
|
</Dialog>
|
||||||
|
|
||||||
<LoadingOverlay visible={isLoading} />
|
<LoadingOverlay visible={isLoading} />
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { Alert, Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Button, Card, Label, Tabs, TextField } from "heroui-native";
|
import { Button, Dialog, Label, Tabs, TextField } from "heroui-native";
|
||||||
import {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
|
ChevronDownIcon,
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
@@ -28,7 +29,12 @@ import {
|
|||||||
ClothingTargetType,
|
ClothingTargetType,
|
||||||
} from "@/types/clothing";
|
} from "@/types/clothing";
|
||||||
|
|
||||||
type ManageMode = "category" | "tag";
|
const TAG_COLORS = [
|
||||||
|
"#EF4444", "#F97316", "#EAB308", "#22C55E", "#14B8A6",
|
||||||
|
"#06B6D4", "#3B82F6", "#6366F1", "#8B5CF6", "#A855F7",
|
||||||
|
"#EC4899", "#78716C",
|
||||||
|
];
|
||||||
|
|
||||||
type MetadataForm =
|
type MetadataForm =
|
||||||
| {
|
| {
|
||||||
kind: "category";
|
kind: "category";
|
||||||
@@ -48,7 +54,7 @@ type MetadataForm =
|
|||||||
kind: "tag";
|
kind: "tag";
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
emoji: string;
|
color: string;
|
||||||
tagGroupId: number | null;
|
tagGroupId: number | null;
|
||||||
type: ClothingTargetType;
|
type: ClothingTargetType;
|
||||||
sortNumber?: number;
|
sortNumber?: number;
|
||||||
@@ -74,8 +80,11 @@ export default function ClothingMetadataManage() {
|
|||||||
const notifyModuleOperation = useModuleChatNotifier();
|
const notifyModuleOperation = useModuleChatNotifier();
|
||||||
|
|
||||||
const [activeType, setActiveType] = useState(String(ClothingTargetType.Clothes));
|
const [activeType, setActiveType] = useState(String(ClothingTargetType.Clothes));
|
||||||
const [mode, setMode] = useState<ManageMode>("category");
|
|
||||||
const [form, setForm] = useState<MetadataForm | null>(null);
|
const [form, setForm] = useState<MetadataForm | null>(null);
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<
|
||||||
|
{ kind: "category"; item: ClothingCategory } | { kind: "tag"; item: ClothingTag } | { kind: "tagGroup"; item: ClothingTagGroup } | null
|
||||||
|
>(null);
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
const targetType = Number(activeType) as ClothingTargetType;
|
const targetType = Number(activeType) as ClothingTargetType;
|
||||||
|
|
||||||
@@ -179,7 +188,7 @@ export default function ClothingMetadataManage() {
|
|||||||
setForm({
|
setForm({
|
||||||
kind: "tag",
|
kind: "tag",
|
||||||
name: "",
|
name: "",
|
||||||
emoji: "",
|
color: "",
|
||||||
tagGroupId: tagGroupId ?? currentTagGroups[0]?.id ?? null,
|
tagGroupId: tagGroupId ?? currentTagGroups[0]?.id ?? null,
|
||||||
type: targetType,
|
type: targetType,
|
||||||
});
|
});
|
||||||
@@ -192,7 +201,7 @@ export default function ClothingMetadataManage() {
|
|||||||
kind: "tag",
|
kind: "tag",
|
||||||
id: item.id,
|
id: item.id,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
emoji: item.emoji ?? "",
|
color: item.color ?? "",
|
||||||
tagGroupId: item.tag_group_id,
|
tagGroupId: item.tag_group_id,
|
||||||
type: item.type,
|
type: item.type,
|
||||||
sortNumber: item.sort_number,
|
sortNumber: item.sort_number,
|
||||||
@@ -256,7 +265,7 @@ export default function ClothingMetadataManage() {
|
|||||||
tag_group_id: form.tagGroupId!,
|
tag_group_id: form.tagGroupId!,
|
||||||
name,
|
name,
|
||||||
type: form.type,
|
type: form.type,
|
||||||
emoji: form.emoji.trim(),
|
color: form.color.trim(),
|
||||||
sort_number:
|
sort_number:
|
||||||
form.sortNumber ??
|
form.sortNumber ??
|
||||||
tags.filter((item) => item.type === form.type && item.tag_group_id === form.tagGroupId)
|
tags.filter((item) => item.type === form.type && item.tag_group_id === form.tagGroupId)
|
||||||
@@ -314,167 +323,121 @@ export default function ClothingMetadataManage() {
|
|||||||
tags,
|
tags,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const handleDeleteCategory = useCallback(
|
const handleDeleteCategory = useCallback((item: ClothingCategory) => {
|
||||||
(item: ClothingCategory) => {
|
setDeleteConfirm({ kind: "category", item });
|
||||||
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 handleDeleteTag = useCallback(
|
const handleDeleteTag = useCallback((item: ClothingTag) => {
|
||||||
(item: ClothingTag) => {
|
setDeleteConfirm({ kind: "tag", item });
|
||||||
const relationDeletes = tagRelations
|
}, []);
|
||||||
.filter((row) => row.tag_id === item.id)
|
|
||||||
.map((row) => ({ id: row.id }));
|
|
||||||
|
|
||||||
Alert.alert("删除标签", `确认删除「${item.name}」吗?对应引用关系也会移除。`, [
|
const handleDeleteTagGroup = useCallback((item: ClothingTagGroup) => {
|
||||||
{ text: "取消", style: "cancel" },
|
setDeleteConfirm({ kind: "tagGroup", item });
|
||||||
{
|
}, []);
|
||||||
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(
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
(item: ClothingTagGroup) => {
|
if (!deleteConfirm || isLoading) return;
|
||||||
const groupTags = tags.filter((tag) => tag.tag_group_id === item.id);
|
const { kind, item } = deleteConfirm;
|
||||||
const groupTagIds = new Set(groupTags.map((tag) => tag.id));
|
setDeleteConfirm(null);
|
||||||
const relationDeletes = tagRelations
|
startLoading("删除中...");
|
||||||
.filter((row) => groupTagIds.has(row.tag_id))
|
|
||||||
.map((row) => ({ id: row.id }));
|
|
||||||
|
|
||||||
Alert.alert("删除标签组", `确认删除「${item.name}」及其下 ${groupTags.length} 个标签吗?`, [
|
try {
|
||||||
{ text: "取消", style: "cancel" },
|
let ok = false;
|
||||||
{
|
if (kind === "category") {
|
||||||
text: "删除",
|
ok = await saveMetadataBundle({
|
||||||
style: "destructive",
|
categoryUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
|
||||||
onPress: async () => {
|
});
|
||||||
startLoading("删除中...");
|
} else if (kind === "tag") {
|
||||||
try {
|
const relationDeletes = tagRelations
|
||||||
const ok = await saveMetadataBundle({
|
.filter((row) => row.tag_id === item.id)
|
||||||
tagGroupUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
|
.map((row) => ({ id: row.id }));
|
||||||
tagUpdates: groupTags.map((tag) => ({
|
ok = await saveMetadataBundle({
|
||||||
id: tag.id,
|
tagUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
|
||||||
status: ClothingStatus.Disabled,
|
tagRelationDeletes: relationDeletes,
|
||||||
})),
|
});
|
||||||
tagRelationDeletes: relationDeletes,
|
} else if (kind === "tagGroup") {
|
||||||
});
|
const groupTags = tags.filter((tag) => tag.tag_group_id === item.id);
|
||||||
if (!ok) {
|
const groupTagIds = new Set(groupTags.map((tag) => tag.id));
|
||||||
stopLoading({ type: "error", message: "删除失败" });
|
const relationDeletes = tagRelations
|
||||||
return;
|
.filter((row) => groupTagIds.has(row.tag_id))
|
||||||
}
|
.map((row) => ({ id: row.id }));
|
||||||
await loadData();
|
ok = await saveMetadataBundle({
|
||||||
notifyModuleOperation({
|
tagGroupUpdates: [{ id: item.id, status: ClothingStatus.Disabled }],
|
||||||
module: "衣服搭配",
|
tagUpdates: groupTags.map((tag) => ({ id: tag.id, status: ClothingStatus.Disabled })),
|
||||||
action: `删除${targetTypeLabel(item.type)}标签组`,
|
tagRelationDeletes: relationDeletes,
|
||||||
target: item.name,
|
});
|
||||||
detail: `${groupTags.length} 个标签`,
|
}
|
||||||
sourceTable: "b_clothing_tag_group",
|
|
||||||
sourceId: item.id,
|
if (!ok) { stopLoading({ type: "error", message: "删除失败" }); return; }
|
||||||
});
|
await loadData();
|
||||||
stopLoading({ type: "success", message: "已删除" });
|
notifyModuleOperation({
|
||||||
} catch {
|
module: "衣服搭配",
|
||||||
stopLoading({ type: "error", message: "删除失败" });
|
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,
|
||||||
loadData,
|
});
|
||||||
notifyModuleOperation,
|
stopLoading({ type: "success", message: "已删除" });
|
||||||
saveMetadataBundle,
|
} catch {
|
||||||
startLoading,
|
stopLoading({ type: "error", message: "删除失败" });
|
||||||
stopLoading,
|
}
|
||||||
tagRelations,
|
}, [deleteConfirm, isLoading, loadData, notifyModuleOperation, saveMetadataBundle, startLoading, stopLoading, tagRelations, tags]);
|
||||||
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(
|
const renderCategory = useCallback(
|
||||||
(item: ClothingCategory) => (
|
(item: ClothingCategory) => (
|
||||||
<Card key={item.id} className="mb-2">
|
<View
|
||||||
<Card.Body>
|
key={item.id}
|
||||||
<View className="flex-row items-center gap-3">
|
className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-3 mb-2"
|
||||||
<View className="h-10 w-10 rounded-2xl bg-gray-100 items-center justify-center">
|
>
|
||||||
<FolderIcon size={18} color="#6B7280" />
|
<View className="h-9 w-9 rounded-xl bg-gray-100 items-center justify-center">
|
||||||
</View>
|
<FolderIcon size={18} color="#6B7280" />
|
||||||
<View className="flex-1">
|
</View>
|
||||||
<Card.Title className="text-sm" numberOfLines={1}>
|
<Text className="flex-1 text-sm font-medium text-gray-900" numberOfLines={1}>
|
||||||
{item.name}
|
{item.name}
|
||||||
</Card.Title>
|
</Text>
|
||||||
<Card.Description className="mt-1">{targetTypeLabel(item.type)}分类</Card.Description>
|
<TouchableOpacity
|
||||||
</View>
|
className="w-9 h-9 items-center justify-center rounded-full bg-gray-100"
|
||||||
<Button variant="secondary" isIconOnly size="sm" onPress={() => openEditCategory(item)}>
|
onPress={() => openEditCategory(item)}
|
||||||
<PencilIcon size={15} color="#374151" />
|
>
|
||||||
</Button>
|
<PencilIcon size={15} color="#374151" />
|
||||||
<Button
|
</TouchableOpacity>
|
||||||
variant="danger-soft"
|
<TouchableOpacity
|
||||||
isIconOnly
|
className="w-9 h-9 items-center justify-center rounded-full bg-red-50"
|
||||||
size="sm"
|
onPress={() => handleDeleteCategory(item)}
|
||||||
onPress={() => handleDeleteCategory(item)}
|
>
|
||||||
>
|
<Trash2Icon size={15} color="#EF4444" />
|
||||||
<Trash2Icon size={15} color="#EF4444" />
|
</TouchableOpacity>
|
||||||
</Button>
|
</View>
|
||||||
</View>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
),
|
),
|
||||||
[handleDeleteCategory, openEditCategory],
|
[handleDeleteCategory, openEditCategory],
|
||||||
);
|
);
|
||||||
@@ -482,77 +445,94 @@ export default function ClothingMetadataManage() {
|
|||||||
const renderTagGroup = useCallback(
|
const renderTagGroup = useCallback(
|
||||||
(group: ClothingTagGroup) => {
|
(group: ClothingTagGroup) => {
|
||||||
const groupTags = tagsByGroupId.get(group.id) ?? [];
|
const groupTags = tagsByGroupId.get(group.id) ?? [];
|
||||||
|
const collapsed = collapsedGroups.has(group.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card key={group.id} className="mb-3">
|
<View key={group.id} className="mb-3">
|
||||||
<Card.Header>
|
{/* Group header — same row style as categories */}
|
||||||
<View className="flex-row items-center gap-3">
|
<TouchableOpacity
|
||||||
<View className="h-10 w-10 rounded-2xl bg-gray-100 items-center justify-center">
|
className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-3"
|
||||||
<TagIcon size={18} color="#6B7280" />
|
activeOpacity={0.7}
|
||||||
</View>
|
onPress={() => toggleGroup(group.id)}
|
||||||
<View className="flex-1">
|
>
|
||||||
<Text className="text-sm font-semibold text-gray-900" numberOfLines={1}>
|
<View className="h-9 w-9 rounded-xl bg-gray-100 items-center justify-center">
|
||||||
{group.name}
|
<TagIcon size={18} color="#6B7280" />
|
||||||
</Text>
|
|
||||||
<Text className="mt-1 text-xs text-gray-500">
|
|
||||||
{targetTypeLabel(group.type)}标签组 · {groupTags.length} 个标签
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
|
||||||
onPress={() => openEditTagGroup(group)}
|
|
||||||
>
|
|
||||||
<PencilIcon size={15} color="#374151" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger-soft"
|
|
||||||
isIconOnly
|
|
||||||
size="sm"
|
|
||||||
onPress={() => handleDeleteTagGroup(group)}
|
|
||||||
>
|
|
||||||
<Trash2Icon size={15} color="#EF4444" />
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
</Card.Header>
|
<Text className="flex-1 text-sm font-semibold text-gray-900" numberOfLines={1}>
|
||||||
<Card.Body className="gap-3">
|
{group.name}
|
||||||
{groupTags.length > 0 ? (
|
</Text>
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<ChevronDownIcon
|
||||||
{groupTags.map((tag) => (
|
size={16}
|
||||||
|
color="#9CA3AF"
|
||||||
|
style={{ transform: [{ rotate: collapsed ? "0deg" : "180deg" }] }}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 items-center justify-center rounded-full bg-gray-100"
|
||||||
|
onPress={(e) => { e.stopPropagation?.(); openEditTagGroup(group); }}
|
||||||
|
>
|
||||||
|
<PencilIcon size={14} color="#374151" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-8 h-8 items-center justify-center rounded-full bg-red-50"
|
||||||
|
onPress={(e) => { e.stopPropagation?.(); handleDeleteTagGroup(group); }}
|
||||||
|
>
|
||||||
|
<Trash2Icon size={14} color="#EF4444" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Tags — same row style as categories, colored dot instead of icon */}
|
||||||
|
{!collapsed && (
|
||||||
|
<View className="mt-2 ml-3">
|
||||||
|
{groupTags.map((tag) => (
|
||||||
|
<View
|
||||||
|
key={tag.id}
|
||||||
|
className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-2.5 mb-1 ml-9"
|
||||||
|
>
|
||||||
|
<View
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: tag.color || "#D1D5DB" }}
|
||||||
|
/>
|
||||||
|
<Text className="flex-1 text-sm text-gray-900" numberOfLines={1}>
|
||||||
|
{tag.name}
|
||||||
|
</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
key={tag.id}
|
className="w-8 h-8 items-center justify-center rounded-full bg-gray-100"
|
||||||
className="rounded-full bg-gray-100 px-3 py-2 flex-row items-center gap-2"
|
|
||||||
onPress={() => openEditTag(tag)}
|
onPress={() => openEditTag(tag)}
|
||||||
>
|
>
|
||||||
<Text className="text-xs text-gray-700" numberOfLines={1}>
|
<PencilIcon size={14} color="#374151" />
|
||||||
{tag.emoji ? `${tag.emoji} ` : ""}
|
|
||||||
{tag.name}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity onPress={() => handleDeleteTag(tag)}>
|
|
||||||
<Trash2Icon size={13} color="#EF4444" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
<TouchableOpacity
|
||||||
</View>
|
className="w-8 h-8 items-center justify-center rounded-full bg-red-50"
|
||||||
) : (
|
onPress={() => handleDeleteTag(tag)}
|
||||||
<Text className="text-sm text-gray-500">暂无标签</Text>
|
>
|
||||||
)}
|
<Trash2Icon size={14} color="#EF4444" />
|
||||||
<Button variant="secondary" size="sm" onPress={() => openCreateTag(group.id)}>
|
</TouchableOpacity>
|
||||||
<PlusIcon size={16} color="#374151" />
|
</View>
|
||||||
<Button.Label>添加标签</Button.Label>
|
))}
|
||||||
</Button>
|
{groupTags.length === 0 && (
|
||||||
</Card.Body>
|
<Text className="text-xs text-gray-400 py-2 ml-9">暂无标签</Text>
|
||||||
</Card>
|
)}
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center gap-1.5 py-2 ml-9"
|
||||||
|
onPress={() => openCreateTag(group.id)}
|
||||||
|
>
|
||||||
|
<PlusIcon size={14} color="#9CA3AF" />
|
||||||
|
<Text className="text-xs text-gray-400">添加标签</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
|
collapsedGroups,
|
||||||
handleDeleteTag,
|
handleDeleteTag,
|
||||||
handleDeleteTagGroup,
|
handleDeleteTagGroup,
|
||||||
openCreateTag,
|
openCreateTag,
|
||||||
openEditTag,
|
openEditTag,
|
||||||
openEditTagGroup,
|
openEditTagGroup,
|
||||||
tagsByGroupId,
|
tagsByGroupId,
|
||||||
|
toggleGroup,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -571,83 +551,66 @@ export default function ClothingMetadataManage() {
|
|||||||
contentContainerStyle={{ paddingBottom: 120 }}
|
contentContainerStyle={{ paddingBottom: 120 }}
|
||||||
>
|
>
|
||||||
<View className="gap-3 mb-4">
|
<View className="gap-3 mb-4">
|
||||||
<Tabs value={activeType} onValueChange={setActiveType} variant="primary">
|
<View className="flex-row items-center justify-between">
|
||||||
<Tabs.List>
|
<Tabs value={activeType} onValueChange={setActiveType} variant="primary" className="flex-1">
|
||||||
<Tabs.Indicator />
|
<Tabs.List>
|
||||||
<Tabs.Trigger value={String(ClothingTargetType.Clothes)} className="flex-1">
|
<Tabs.Indicator />
|
||||||
<Tabs.Label>衣服</Tabs.Label>
|
<Tabs.Trigger value={String(ClothingTargetType.Clothes)} className="flex-1">
|
||||||
</Tabs.Trigger>
|
<Tabs.Label>衣服</Tabs.Label>
|
||||||
<Tabs.Trigger value={String(ClothingTargetType.Outfit)} className="flex-1">
|
</Tabs.Trigger>
|
||||||
<Tabs.Label>搭配</Tabs.Label>
|
<Tabs.Trigger value={String(ClothingTargetType.Outfit)} className="flex-1">
|
||||||
</Tabs.Trigger>
|
<Tabs.Label>搭配</Tabs.Label>
|
||||||
</Tabs.List>
|
</Tabs.Trigger>
|
||||||
</Tabs>
|
</Tabs.List>
|
||||||
|
</Tabs>
|
||||||
<Tabs
|
<TouchableOpacity
|
||||||
value={mode}
|
className="ml-3 w-10 h-10 rounded-full bg-gray-100 items-center justify-center"
|
||||||
onValueChange={(value) => setMode(value as ManageMode)}
|
onPress={handleRefresh}
|
||||||
variant="secondary"
|
>
|
||||||
>
|
<RotateCcwIcon size={18} color="#4B5563" />
|
||||||
<Tabs.List>
|
</TouchableOpacity>
|
||||||
<Tabs.Indicator />
|
</View>
|
||||||
<Tabs.Trigger value="category" className="flex-1">
|
|
||||||
<Tabs.Label>分类</Tabs.Label>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
<Tabs.Trigger value="tag" className="flex-1">
|
|
||||||
<Tabs.Label>标签</Tabs.Label>
|
|
||||||
</Tabs.Trigger>
|
|
||||||
</Tabs.List>
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<View className="flex-row gap-2">
|
<View className="flex-row gap-2">
|
||||||
{mode === "category" ? (
|
<Button className="flex-1" size="sm" onPress={openCreateCategory}>
|
||||||
<Button className="flex-1" onPress={openCreateCategory}>
|
<PlusIcon size={16} color="#fff" />
|
||||||
<PlusIcon size={18} color="#fff" />
|
<Button.Label>分类</Button.Label>
|
||||||
<Button.Label>新增分类</Button.Label>
|
</Button>
|
||||||
</Button>
|
<Button className="flex-1" size="sm" onPress={openCreateTagGroup}>
|
||||||
) : (
|
<PlusIcon size={16} color="#fff" />
|
||||||
<>
|
<Button.Label>标签组</Button.Label>
|
||||||
<Button className="flex-1" onPress={openCreateTagGroup}>
|
</Button>
|
||||||
<PlusIcon size={18} color="#fff" />
|
<Button variant="secondary" size="sm" className="flex-1" onPress={() => openCreateTag()} isDisabled={currentTagGroups.length === 0}>
|
||||||
<Button.Label>新增标签组</Button.Label>
|
<PlusIcon size={16} color="#374151" />
|
||||||
</Button>
|
<Button.Label>标签</Button.Label>
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
className="flex-1"
|
|
||||||
onPress={() => openCreateTag()}
|
|
||||||
isDisabled={currentTagGroups.length === 0}
|
|
||||||
>
|
|
||||||
<PlusIcon size={18} color="#374151" />
|
|
||||||
<Button.Label>新增标签</Button.Label>
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button variant="secondary" isIconOnly onPress={handleRefresh}>
|
|
||||||
<RotateCcwIcon size={18} color="#374151" />
|
|
||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{mode === "category" ? (
|
{/* 分类 */}
|
||||||
currentCategories.length > 0 ? (
|
<Text className="text-xs font-semibold text-gray-400 mb-2 ml-1">分类</Text>
|
||||||
currentCategories.map(renderCategory)
|
{currentCategories.length > 0 ? (
|
||||||
) : (
|
currentCategories.map(renderCategory)
|
||||||
<EmptyState
|
) : (
|
||||||
type={loading ? "loading" : "empty"}
|
<View className="py-6 items-center">
|
||||||
title={loading ? "正在加载分类" : "暂无分类"}
|
<Text className="text-xs text-gray-400">暂无分类</Text>
|
||||||
description="分类保持单层,可以按衣服和搭配分别维护"
|
</View>
|
||||||
center="content"
|
)}
|
||||||
/>
|
|
||||||
)
|
{/* 标签 */}
|
||||||
) : currentTagGroups.length > 0 ? (
|
<View className="mt-5 mb-2">
|
||||||
|
<Text className="text-xs font-semibold text-gray-400 ml-1">标签</Text>
|
||||||
|
</View>
|
||||||
|
{currentTagGroups.length > 0 ? (
|
||||||
currentTagGroups.map(renderTagGroup)
|
currentTagGroups.map(renderTagGroup)
|
||||||
) : (
|
) : (
|
||||||
<EmptyState
|
<View className="py-6 items-center">
|
||||||
type={loading ? "loading" : "empty"}
|
<Text className="text-xs text-gray-400">暂无标签组</Text>
|
||||||
title={loading ? "正在加载标签" : "暂无标签组"}
|
</View>
|
||||||
description="先创建标签组,再在标签组下创建标签"
|
)}
|
||||||
center="content"
|
|
||||||
/>
|
{loading && currentCategories.length === 0 && currentTagGroups.length === 0 && (
|
||||||
|
<EmptyState type="loading" title="加载中..." center="content" />
|
||||||
)}
|
)}
|
||||||
</LayoutView.ScrollView>
|
</LayoutView.ScrollView>
|
||||||
|
|
||||||
@@ -662,10 +625,10 @@ export default function ClothingMetadataManage() {
|
|||||||
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
<BottomSheet.Content animateOnMount={false} className="gap-4">
|
||||||
{form && (
|
{form && (
|
||||||
<>
|
<>
|
||||||
<Text className="text-lg font-semibold text-gray-900">{renderFormTitle()}</Text>
|
<Text className="text-lg font-semibold text-gray-900 text-center">{renderFormTitle()}</Text>
|
||||||
|
|
||||||
<TextField isRequired>
|
<TextField isRequired>
|
||||||
<Label>名称</Label>
|
<Label className="mb-2">名称</Label>
|
||||||
<BottomSheet.Input
|
<BottomSheet.Input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))}
|
onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))}
|
||||||
@@ -675,22 +638,49 @@ export default function ClothingMetadataManage() {
|
|||||||
|
|
||||||
{form.kind === "tag" && (
|
{form.kind === "tag" && (
|
||||||
<>
|
<>
|
||||||
<TextField>
|
<View>
|
||||||
<Label>表情</Label>
|
<Label className="mb-2">标签颜色</Label>
|
||||||
<BottomSheet.Input
|
<View className="flex-row flex-wrap gap-2">
|
||||||
value={form.emoji}
|
<TouchableOpacity
|
||||||
maxLength={4}
|
className={`w-9 h-9 rounded-full border-2 items-center justify-center ${
|
||||||
onChangeText={(emoji) =>
|
!form.color ? "border-gray-900" : "border-gray-200"
|
||||||
setForm((prev) =>
|
}`}
|
||||||
prev && prev.kind === "tag" ? { ...prev, emoji } : prev,
|
onPress={() =>
|
||||||
)
|
setForm((prev) =>
|
||||||
}
|
prev && prev.kind === "tag" ? { ...prev, color: "" } : prev,
|
||||||
placeholder="👕"
|
)
|
||||||
/>
|
}
|
||||||
</TextField>
|
>
|
||||||
|
<View className="w-3 h-px bg-gray-400 rotate-45 absolute" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
{TAG_COLORS.map((c) => {
|
||||||
|
const selected = form.color === c;
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={c}
|
||||||
|
className={`w-9 h-9 rounded-full items-center justify-center ${
|
||||||
|
selected ? "border-2 border-gray-900" : ""
|
||||||
|
}`}
|
||||||
|
style={{ backgroundColor: c }}
|
||||||
|
onPress={() =>
|
||||||
|
setForm((prev) =>
|
||||||
|
prev && prev.kind === "tag"
|
||||||
|
? { ...prev, color: c }
|
||||||
|
: prev,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selected && (
|
||||||
|
<CheckIcon size={16} color="#fff" />
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>所属标签组</Label>
|
<Label className="mb-2">所属标签组</Label>
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
{currentTagGroups.map((group) => {
|
{currentTagGroups.map((group) => {
|
||||||
const selected = form.tagGroupId === group.id;
|
const selected = form.tagGroupId === group.id;
|
||||||
@@ -724,7 +714,7 @@ export default function ClothingMetadataManage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>类型</Label>
|
<Label className="mb-2">类型</Label>
|
||||||
<View className="h-11 rounded-xl bg-gray-100 px-3 justify-center">
|
<View className="h-11 rounded-xl bg-gray-100 px-3 justify-center">
|
||||||
<Text className="text-sm text-gray-700">{targetTypeLabel(form.type)}</Text>
|
<Text className="text-sm text-gray-700">{targetTypeLabel(form.type)}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -749,6 +739,24 @@ export default function ClothingMetadataManage() {
|
|||||||
</BottomSheet.Portal>
|
</BottomSheet.Portal>
|
||||||
</BottomSheet>
|
</BottomSheet>
|
||||||
|
|
||||||
|
<Dialog isOpen={!!deleteConfirm} onOpenChange={(v) => { if (!v) setDeleteConfirm(null); }}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Title>{getDeleteTitle()}</Dialog.Title>
|
||||||
|
<Dialog.Description>{getDeleteDesc()}</Dialog.Description>
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Button variant="tertiary" className="flex-1" onPress={() => setDeleteConfirm(null)}>
|
||||||
|
<Button.Label>取消</Button.Label>
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" className="flex-1" onPress={handleConfirmDelete}>
|
||||||
|
<Button.Label>删除</Button.Label>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<LoadingOverlay visible={isLoading} />
|
<LoadingOverlay visible={isLoading} />
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,57 @@
|
|||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { TouchableOpacity, View } from "react-native";
|
import { Modal, Pressable, Text, TouchableOpacity, View } from "react-native";
|
||||||
import { router, useFocusEffect } from "expo-router";
|
import { router, useFocusEffect } from "expo-router";
|
||||||
import { Button, Card } from "heroui-native";
|
import { Button, Card, Dialog } from "heroui-native";
|
||||||
import { PencilIcon, PlusIcon, RotateCcwIcon, SparklesIcon } from "lucide-react-native";
|
import {
|
||||||
|
FileTextIcon,
|
||||||
|
PencilIcon,
|
||||||
|
PlusIcon,
|
||||||
|
RotateCcwIcon,
|
||||||
|
SparklesIcon,
|
||||||
|
Trash2Icon,
|
||||||
|
} 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";
|
||||||
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 { useClothingStore, useFamilyStore } from "@/store";
|
||||||
import { ClothingOutfit } from "@/types/clothing";
|
import { ClothingOutfit, ClothingStatus, ClothingTargetType } from "@/types/clothing";
|
||||||
|
|
||||||
export default function ClothingOutfitList() {
|
export default function ClothingOutfitList() {
|
||||||
const familyId = useFamilyStore((s) => s.getFamilyId());
|
const familyId = useFamilyStore((s) => s.getFamilyId());
|
||||||
const outfits = useClothingStore((s) => s.outfits);
|
const outfits = useClothingStore((s) => s.outfits);
|
||||||
const clothes = useClothingStore((s) => s.clothes);
|
const clothes = useClothingStore((s) => s.clothes);
|
||||||
const outfitClothes = useClothingStore((s) => s.outfitClothes);
|
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 fetchOutfits = useClothingStore((s) => s.fetchOutfits);
|
||||||
const fetchClothes = useClothingStore((s) => s.fetchClothes);
|
|
||||||
const fetchOutfitClothes = useClothingStore((s) => s.fetchOutfitClothes);
|
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 { 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 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<number, number[]>();
|
||||||
|
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 () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
@@ -28,8 +59,11 @@ export default function ClothingOutfitList() {
|
|||||||
fetchOutfits(familyId),
|
fetchOutfits(familyId),
|
||||||
fetchClothes(familyId),
|
fetchClothes(familyId),
|
||||||
fetchOutfitClothes(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(() => {
|
useEffect(() => {
|
||||||
if (!familyId) return;
|
if (!familyId) return;
|
||||||
@@ -47,6 +81,48 @@ export default function ClothingOutfitList() {
|
|||||||
runRefresh(loadData);
|
runRefresh(loadData);
|
||||||
}, [loadData, runRefresh]);
|
}, [loadData, runRefresh]);
|
||||||
|
|
||||||
|
const [activeItem, setActiveItem] = useState<ClothingOutfit | null>(null);
|
||||||
|
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
|
||||||
|
const [deleteConfirm, setDeleteConfirm] = useState<ClothingOutfit | null>(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(
|
const getCoverKey = useCallback(
|
||||||
(item: ClothingOutfit) => {
|
(item: ClothingOutfit) => {
|
||||||
if (item.cover_file_key) return item.cover_file_key;
|
if (item.cover_file_key) return item.cover_file_key;
|
||||||
@@ -81,34 +157,53 @@ export default function ClothingOutfitList() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = useCallback(
|
const renderItem = useCallback(
|
||||||
({ item }: { item: ClothingOutfit }) => (
|
({ item }: { item: ClothingOutfit }) => {
|
||||||
<TouchableOpacity
|
const categoryName = item.category_id ? categoriesById.get(item.category_id)?.name : null;
|
||||||
className="mb-3 flex-1"
|
const itemTagIds = outfitTagIds.get(item.id) ?? [];
|
||||||
activeOpacity={0.82}
|
const itemTags = itemTagIds.map((tid) => tagsById.get(tid)).filter(Boolean);
|
||||||
onPress={() => router.push(`/clothes/outfit/detail/${item.id}` as any)}
|
|
||||||
>
|
return (
|
||||||
<Card className="overflow-hidden p-0">
|
<TouchableOpacity
|
||||||
<ClothingImage fileKey={getCoverKey(item)} label={item.name} className="h-40 w-full" />
|
className="mb-3 flex-1"
|
||||||
<Card.Body className="p-3">
|
activeOpacity={0.82}
|
||||||
<View className="flex-row items-start justify-between gap-2">
|
delayLongPress={260}
|
||||||
<View className="flex-1">
|
onPress={() => router.push(`/clothes/outfit/detail/${item.id}` as any)}
|
||||||
<Card.Title className="text-sm" numberOfLines={1}>
|
onLongPress={(e) => {
|
||||||
{item.name}
|
setMenuPos({ x: e.nativeEvent.pageX, y: e.nativeEvent.pageY });
|
||||||
</Card.Title>
|
setActiveItem(item);
|
||||||
<Card.Description className="mt-1">{getClothesCount(item)} 件衣服</Card.Description>
|
}}
|
||||||
</View>
|
>
|
||||||
<TouchableOpacity
|
<Card className="overflow-hidden p-0">
|
||||||
className="h-8 w-8 rounded-full bg-gray-100 items-center justify-center"
|
<View className="w-full bg-gray-50" style={{ aspectRatio: 1 }}>
|
||||||
onPress={() => router.push(`/clothes/outfit/edit?id=${item.id}` as any)}
|
<ClothingImage fileKey={getCoverKey(item)} label={item.name} className="w-full h-full" resizeMode="contain" />
|
||||||
>
|
|
||||||
<PencilIcon size={15} color="#4B5563" />
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
</View>
|
||||||
</Card.Body>
|
<Card.Body className="px-3 pt-3 pb-2">
|
||||||
</Card>
|
<Card.Title className="text-sm" numberOfLines={1}>
|
||||||
</TouchableOpacity>
|
{item.name}
|
||||||
),
|
</Card.Title>
|
||||||
[getClothesCount, getCoverKey],
|
<Card.Description className="mt-0.5">
|
||||||
|
{getClothesCount(item)} 件衣服
|
||||||
|
{categoryName ? ` · ${categoryName}` : ""}
|
||||||
|
</Card.Description>
|
||||||
|
{itemTags.length > 0 && (
|
||||||
|
<View className="flex-row gap-1 mt-1.5">
|
||||||
|
{itemTags.slice(0, 2).map((tag) => (
|
||||||
|
<View
|
||||||
|
key={tag!.id}
|
||||||
|
className="rounded-full px-2 py-0.5"
|
||||||
|
style={{ backgroundColor: tag!.color || "#F3F4F6" }}
|
||||||
|
>
|
||||||
|
<Text className="text-[10px]" style={{ color: tag!.color ? "#fff" : "#6B7280" }}>{tag!.name}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[categoriesById, getClothesCount, getCoverKey, outfitTagIds, tagsById],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -144,6 +239,78 @@ export default function ClothingOutfitList() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={!!activeItem}
|
||||||
|
transparent
|
||||||
|
animationType="none"
|
||||||
|
onRequestClose={() => setActiveItem(null)}
|
||||||
|
>
|
||||||
|
<Pressable className="flex-1" onPress={() => setActiveItem(null)}>
|
||||||
|
<View
|
||||||
|
className="absolute bg-white rounded-xl w-[200px] overflow-hidden shadow-lg"
|
||||||
|
style={{
|
||||||
|
left: Math.min(menuPos.x - 190, menuPos.x - 10),
|
||||||
|
top: menuPos.y - 100,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center gap-3 px-4 py-3 border-b border-gray-100"
|
||||||
|
activeOpacity={0.6}
|
||||||
|
onPress={() => {
|
||||||
|
const item = activeItem;
|
||||||
|
setActiveItem(null);
|
||||||
|
if (item) router.push(`/clothes/outfit/edit?id=${item.id}` as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilIcon size={18} color="#374151" />
|
||||||
|
<Text className="text-[15px] text-gray-800">编辑</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center gap-3 px-4 py-3 border-b border-gray-100"
|
||||||
|
activeOpacity={0.6}
|
||||||
|
onPress={handleDeleteRequest}
|
||||||
|
>
|
||||||
|
<Trash2Icon size={18} color="#EF4444" />
|
||||||
|
<Text className="text-[15px] text-red-500">删除</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
className="flex-row items-center gap-3 px-4 py-3"
|
||||||
|
activeOpacity={0.6}
|
||||||
|
onPress={() => {
|
||||||
|
const item = activeItem;
|
||||||
|
setActiveItem(null);
|
||||||
|
if (item) router.push(`/clothes/outfit/detail/${item.id}` as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileTextIcon size={18} color="#374151" />
|
||||||
|
<Text className="text-[15px] text-gray-800">详细</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<Dialog isOpen={!!deleteConfirm} onOpenChange={(v) => { if (!v) setDeleteConfirm(null); }}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Title>删除搭配</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
确认删除「{deleteConfirm?.name}」吗?衣服本身不会被删除。
|
||||||
|
</Dialog.Description>
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Button variant="tertiary" className="flex-1" onPress={() => setDeleteConfirm(null)}>
|
||||||
|
<Button.Label>取消</Button.Label>
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" className="flex-1" onPress={handleDeleteConfirm}>
|
||||||
|
<Button.Label>删除</Button.Label>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<LoadingOverlay visible={isLoading} />
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,15 +2,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
|
|||||||
import { Text, TouchableOpacity, View } from "react-native";
|
import { Text, TouchableOpacity, View } from "react-native";
|
||||||
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
import { Stack, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import ImagePicker from "react-native-image-crop-picker";
|
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 {
|
import {
|
||||||
CheckIcon,
|
CheckIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
PlusIcon,
|
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
ShirtIcon,
|
|
||||||
TagIcon,
|
|
||||||
XIcon,
|
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import PageLayout from "@/layouts/PageLayout";
|
import PageLayout from "@/layouts/PageLayout";
|
||||||
@@ -79,9 +75,6 @@ export default function ClothingEdit() {
|
|||||||
const fetchTagGroups = useClothingStore((s) => s.fetchTagGroups);
|
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 saveCategoryChanges = useClothingStore((s) => s.saveCategoryChanges);
|
|
||||||
const saveTagGroupChanges = useClothingStore((s) => s.saveTagGroupChanges);
|
|
||||||
const saveTagChanges = useClothingStore((s) => s.saveTagChanges);
|
|
||||||
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();
|
||||||
@@ -89,10 +82,6 @@ export default function ClothingEdit() {
|
|||||||
|
|
||||||
const [form, setForm] = useState<ClothesFormState>(emptyForm);
|
const [form, setForm] = useState<ClothesFormState>(emptyForm);
|
||||||
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
const [selectedTagIds, setSelectedTagIds] = useState<number[]>([]);
|
||||||
const [newCategoryName, setNewCategoryName] = useState("");
|
|
||||||
const [newTagGroupName, setNewTagGroupName] = useState("");
|
|
||||||
const [newTagName, setNewTagName] = useState("");
|
|
||||||
const [newTagEmoji, setNewTagEmoji] = useState("");
|
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
|
||||||
@@ -216,183 +205,6 @@ export default function ClothingEdit() {
|
|||||||
}
|
}
|
||||||
}, [familyId, uploadClothesImage, uploading]);
|
}, [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 handleSave = useCallback(async () => {
|
||||||
const name = form.name.trim();
|
const name = form.name.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -537,7 +349,6 @@ export default function ClothingEdit() {
|
|||||||
onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))}
|
onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))}
|
||||||
>
|
>
|
||||||
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
|
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
|
||||||
{item.emoji ? `${item.emoji} ` : ""}
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -563,180 +374,90 @@ export default function ClothingEdit() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title={isEdit ? "编辑衣服" : "新增衣服"}>
|
<PageLayout title={isEdit ? "编辑衣服" : "新增衣服"}>
|
||||||
<Stack.Screen options={{ animation: "slide_from_right" }} />
|
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
<LayoutView.ScrollView bottomOffset={40}>
|
<LayoutView.ScrollView bottomOffset={40}>
|
||||||
<Card className="mb-4">
|
{/* 图片 */}
|
||||||
<Card.Body>
|
<TouchableOpacity
|
||||||
<TouchableOpacity
|
className="h-72 overflow-hidden rounded-2xl bg-gray-100 mb-4"
|
||||||
className="h-72 overflow-hidden rounded-2xl bg-gray-100"
|
activeOpacity={0.82}
|
||||||
activeOpacity={0.82}
|
onPress={handlePickImage}
|
||||||
onPress={handlePickImage}
|
disabled={uploading}
|
||||||
disabled={uploading}
|
>
|
||||||
|
{form.imageFileKey ? (
|
||||||
|
<ClothingImage
|
||||||
|
fileKey={form.imageFileKey}
|
||||||
|
label={form.name}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View className="h-full w-full items-center justify-center">
|
||||||
|
<ImageIcon size={34} color="#9CA3AF" />
|
||||||
|
<Text className="mt-2 text-sm text-gray-500">上传衣服图片</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 基础信息 */}
|
||||||
|
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">基础信息</Text>
|
||||||
|
<View className="gap-4 mb-4">
|
||||||
|
<TextField isRequired>
|
||||||
|
<Label className="mb-2">名称</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChangeText={(name) => setForm((prev) => ({ ...prev, name }))}
|
||||||
|
placeholder="例如:白色衬衫"
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField>
|
||||||
|
<Label className="mb-2">品牌</Label>
|
||||||
|
<Input
|
||||||
|
value={form.brand}
|
||||||
|
onChangeText={(brand) => setForm((prev) => ({ ...prev, brand }))}
|
||||||
|
placeholder="可选"
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
|
||||||
|
<TextField>
|
||||||
|
<Label className="mb-2">备注</Label>
|
||||||
|
<TextArea
|
||||||
|
value={form.remark}
|
||||||
|
onChangeText={(remark) => setForm((prev) => ({ ...prev, remark }))}
|
||||||
|
placeholder="尺码、材质、购买信息等"
|
||||||
|
/>
|
||||||
|
</TextField>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 分类 */}
|
||||||
|
<Text className="text-base font-semibold text-gray-900 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.categoryId == null
|
||||||
|
? "border-gray-900 bg-gray-900"
|
||||||
|
: "border-gray-200 bg-white"
|
||||||
|
}`}
|
||||||
|
onPress={() => setForm((prev) => ({ ...prev, categoryId: null }))}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={`text-xs ${form.categoryId == null ? "text-white" : "text-gray-700"}`}
|
||||||
>
|
>
|
||||||
{form.imageFileKey ? (
|
不选择
|
||||||
<ClothingImage
|
</Text>
|
||||||
fileKey={form.imageFileKey}
|
</TouchableOpacity>
|
||||||
label={form.name}
|
{clothesCategories.map(renderCategoryChip)}
|
||||||
className="h-full w-full"
|
</View>
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View className="h-full w-full items-center justify-center">
|
|
||||||
<ImageIcon size={34} color="#9CA3AF" />
|
|
||||||
<Text className="mt-2 text-sm text-gray-500">上传衣服图片</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text className="mt-2 text-xs text-gray-500">图片必填,上传时会裁剪为统一方图。</Text>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
{/* 标签 */}
|
||||||
<Card.Header>
|
{clothesTagGroups.length > 0 && (
|
||||||
<View className="flex-row items-center gap-2">
|
<>
|
||||||
<ShirtIcon size={18} color="#374151" />
|
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">标签</Text>
|
||||||
<Text className="text-base font-semibold text-gray-900">基础信息</Text>
|
<View className="gap-4 mb-4">
|
||||||
|
{clothesTagGroups.map(renderTagGroup)}
|
||||||
</View>
|
</View>
|
||||||
</Card.Header>
|
</>
|
||||||
<Card.Body className="gap-4">
|
)}
|
||||||
<TextField isRequired>
|
|
||||||
<Label>名称</Label>
|
|
||||||
<Input
|
|
||||||
value={form.name}
|
|
||||||
onChangeText={(name) => setForm((prev) => ({ ...prev, name }))}
|
|
||||||
placeholder="例如:白色衬衫"
|
|
||||||
/>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField>
|
|
||||||
<Label>品牌</Label>
|
|
||||||
<Input
|
|
||||||
value={form.brand}
|
|
||||||
onChangeText={(brand) => setForm((prev) => ({ ...prev, brand }))}
|
|
||||||
placeholder="可选"
|
|
||||||
/>
|
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField>
|
|
||||||
<Label>备注</Label>
|
|
||||||
<TextArea
|
|
||||||
value={form.remark}
|
|
||||||
onChangeText={(remark) => setForm((prev) => ({ ...prev, remark }))}
|
|
||||||
placeholder="尺码、材质、购买信息等"
|
|
||||||
/>
|
|
||||||
</TextField>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
|
||||||
<Card.Header>
|
|
||||||
<Text className="text-base font-semibold text-gray-900">分类</Text>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Body className="gap-4">
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
<TouchableOpacity
|
|
||||||
className={`rounded-full border px-3 py-2 ${
|
|
||||||
form.categoryId == null
|
|
||||||
? "border-gray-900 bg-gray-900"
|
|
||||||
: "border-gray-200 bg-white"
|
|
||||||
}`}
|
|
||||||
onPress={() => setForm((prev) => ({ ...prev, categoryId: null }))}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={`text-xs ${form.categoryId == null ? "text-white" : "text-gray-700"}`}
|
|
||||||
>
|
|
||||||
不选择
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{clothesCategories.map(renderCategoryChip)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-row gap-2">
|
|
||||||
<View className="flex-1">
|
|
||||||
<TextField>
|
|
||||||
<Label>新建分类</Label>
|
|
||||||
<Input
|
|
||||||
value={newCategoryName}
|
|
||||||
onChangeText={setNewCategoryName}
|
|
||||||
placeholder="例如:外套"
|
|
||||||
/>
|
|
||||||
</TextField>
|
|
||||||
</View>
|
|
||||||
<Button className="self-end" isIconOnly onPress={handleCreateCategory}>
|
|
||||||
<PlusIcon size={18} color="#fff" />
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
|
||||||
<Card.Header>
|
|
||||||
<View className="flex-row items-center gap-2">
|
|
||||||
<TagIcon size={18} color="#374151" />
|
|
||||||
<Text className="text-base font-semibold text-gray-900">标签</Text>
|
|
||||||
</View>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Body className="gap-4">
|
|
||||||
{clothesTagGroups.length > 0 ? (
|
|
||||||
clothesTagGroups.map(renderTagGroup)
|
|
||||||
) : (
|
|
||||||
<Text className="text-sm text-gray-500">暂无标签,先在下方新建。</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<View className="rounded-2xl bg-gray-50 p-3 gap-3">
|
|
||||||
<View className="flex-row gap-2">
|
|
||||||
<View className="w-16">
|
|
||||||
<TextField>
|
|
||||||
<Label>表情</Label>
|
|
||||||
<Input
|
|
||||||
value={newTagEmoji}
|
|
||||||
onChangeText={setNewTagEmoji}
|
|
||||||
placeholder="👕"
|
|
||||||
maxLength={4}
|
|
||||||
/>
|
|
||||||
</TextField>
|
|
||||||
</View>
|
|
||||||
<View className="flex-1">
|
|
||||||
<TextField>
|
|
||||||
<Label>标签组</Label>
|
|
||||||
<Input
|
|
||||||
value={newTagGroupName}
|
|
||||||
onChangeText={setNewTagGroupName}
|
|
||||||
placeholder="例如:季节"
|
|
||||||
/>
|
|
||||||
</TextField>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="flex-row gap-2">
|
|
||||||
<View className="flex-1">
|
|
||||||
<TextField>
|
|
||||||
<Label>标签</Label>
|
|
||||||
<Input
|
|
||||||
value={newTagName}
|
|
||||||
onChangeText={setNewTagName}
|
|
||||||
placeholder="例如:春秋"
|
|
||||||
/>
|
|
||||||
</TextField>
|
|
||||||
</View>
|
|
||||||
<Button className="self-end" onPress={handleCreateTag}>
|
|
||||||
<PlusIcon size={18} color="#fff" />
|
|
||||||
<Button.Label>添加</Button.Label>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{selectedTagIds.length > 0 && (
|
|
||||||
<TouchableOpacity
|
|
||||||
className="self-start rounded-full bg-gray-100 px-3 py-2 flex-row items-center gap-1"
|
|
||||||
onPress={() => setSelectedTagIds([])}
|
|
||||||
>
|
|
||||||
<XIcon size={14} color="#6B7280" />
|
|
||||||
<Text className="text-xs text-gray-600">清空已选标签</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button variant="secondary" className="flex-1" onPress={() => router.back()}>
|
<Button variant="secondary" className="flex-1" onPress={() => router.back()}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
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 { Stack, useFocusEffect, useLocalSearchParams, useRouter } from "expo-router";
|
||||||
import { Button, Card } from "heroui-native";
|
import { Button, Card, Dialog } from "heroui-native";
|
||||||
import {
|
import {
|
||||||
FolderIcon,
|
FolderIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
@@ -44,6 +44,7 @@ export default function ClothingOutfitDetail() {
|
|||||||
const { isLoading, startLoading, stopLoading } = useLoading();
|
const { isLoading, startLoading, stopLoading } = useLoading();
|
||||||
const notifyModuleOperation = useModuleChatNotifier();
|
const notifyModuleOperation = useModuleChatNotifier();
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
|
|
||||||
const outfit = useMemo(
|
const outfit = useMemo(
|
||||||
() => outfits.find((item) => item.id === outfitId) ?? null,
|
() => outfits.find((item) => item.id === outfitId) ?? null,
|
||||||
@@ -117,34 +118,33 @@ export default function ClothingOutfitDetail() {
|
|||||||
}, [familyId, loadData, outfitId]),
|
}, [familyId, loadData, outfitId]),
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDelete = useCallback(() => {
|
const handleDeleteRequest = useCallback(() => {
|
||||||
if (!familyId || !outfit || isLoading) return;
|
if (!familyId || !outfit || isLoading) return;
|
||||||
|
setShowDeleteDialog(true);
|
||||||
|
}, [familyId, outfit, isLoading]);
|
||||||
|
|
||||||
Alert.alert("删除搭配", `确认删除「${outfit.name}」吗?衣服本身不会被删除。`, [
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
{ text: "取消", style: "cancel" },
|
if (!familyId || !outfit || isLoading) return;
|
||||||
{
|
setShowDeleteDialog(false);
|
||||||
text: "删除",
|
startLoading("删除中...");
|
||||||
style: "destructive",
|
try {
|
||||||
onPress: async () => {
|
const state = useClothingStore.getState();
|
||||||
startLoading("删除中...");
|
const currentDetails = state.outfitClothes.filter(
|
||||||
try {
|
(item) => item.outfit_id === outfit.id,
|
||||||
const state = useClothingStore.getState();
|
);
|
||||||
const currentDetails = state.outfitClothes.filter(
|
const relationDeletes = state.tagRelations
|
||||||
(item) => item.outfit_id === outfit.id,
|
.filter(
|
||||||
);
|
(item) =>
|
||||||
const relationDeletes = state.tagRelations
|
item.target_type === ClothingTargetType.Outfit && item.target_id === outfit.id,
|
||||||
.filter(
|
)
|
||||||
(item) =>
|
.map((item) => ({ id: item.id }));
|
||||||
item.target_type === ClothingTargetType.Outfit && item.target_id === outfit.id,
|
const ok = await saveOutfitBundle({
|
||||||
)
|
outfitDeletes: [{ id: outfit.id }],
|
||||||
.map((item) => ({ id: item.id }));
|
clothesDeletes: currentDetails.map((item: ClothingOutfitClothes) => ({
|
||||||
const ok = await saveOutfitBundle({
|
id: item.id,
|
||||||
outfitDeletes: [{ id: outfit.id }],
|
})),
|
||||||
clothesDeletes: currentDetails.map((item: ClothingOutfitClothes) => ({
|
tagRelationDeletes: relationDeletes,
|
||||||
id: item.id,
|
});
|
||||||
})),
|
|
||||||
tagRelationDeletes: relationDeletes,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
stopLoading({ type: "error", message: "删除失败" });
|
stopLoading({ type: "error", message: "删除失败" });
|
||||||
@@ -172,12 +172,9 @@ export default function ClothingOutfitDetail() {
|
|||||||
});
|
});
|
||||||
stopLoading({ type: "success", message: "搭配已删除" });
|
stopLoading({ type: "success", message: "搭配已删除" });
|
||||||
router.replace("/clothes/outfit" as any);
|
router.replace("/clothes/outfit" as any);
|
||||||
} catch {
|
} catch {
|
||||||
stopLoading({ type: "error", message: "删除失败" });
|
stopLoading({ type: "error", message: "删除失败" });
|
||||||
}
|
}
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}, [
|
}, [
|
||||||
familyId,
|
familyId,
|
||||||
fetchOutfitClothes,
|
fetchOutfitClothes,
|
||||||
@@ -227,7 +224,7 @@ export default function ClothingOutfitDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="搭配详情">
|
<PageLayout title="搭配详情">
|
||||||
<Stack.Screen options={{ animation: "slide_from_right" }} />
|
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
<LayoutView.ScrollView
|
<LayoutView.ScrollView
|
||||||
refreshing={pageLoading}
|
refreshing={pageLoading}
|
||||||
@@ -283,9 +280,15 @@ export default function ClothingOutfitDetail() {
|
|||||||
{selectedTags.map((tag) => {
|
{selectedTags.map((tag) => {
|
||||||
const group = tagGroups.find((item) => item.id === tag.tag_group_id);
|
const group = tagGroups.find((item) => item.id === tag.tag_group_id);
|
||||||
return (
|
return (
|
||||||
<View key={tag.id} className="rounded-full bg-gray-100 px-3 py-2">
|
<View
|
||||||
<Text className="text-xs text-gray-700">
|
key={tag.id}
|
||||||
{tag.emoji ? `${tag.emoji} ` : ""}
|
className="rounded-full px-3 py-2"
|
||||||
|
style={{ backgroundColor: tag.color || "#F3F4F6" }}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className="text-xs"
|
||||||
|
style={{ color: tag.color ? "#fff" : "#374151" }}
|
||||||
|
>
|
||||||
{group ? `${group.name}/` : ""}
|
{group ? `${group.name}/` : ""}
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -326,7 +329,7 @@ export default function ClothingOutfitDetail() {
|
|||||||
<Button
|
<Button
|
||||||
variant="danger-soft"
|
variant="danger-soft"
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
onPress={handleDelete}
|
onPress={handleDeleteRequest}
|
||||||
isDisabled={isLoading}
|
isDisabled={isLoading}
|
||||||
>
|
>
|
||||||
<Trash2Icon size={18} color="#EF4444" />
|
<Trash2Icon size={18} color="#EF4444" />
|
||||||
@@ -339,6 +342,26 @@ export default function ClothingOutfitDetail() {
|
|||||||
</LayoutView.ScrollView>
|
</LayoutView.ScrollView>
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
|
|
||||||
|
<Dialog isOpen={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay />
|
||||||
|
<Dialog.Content>
|
||||||
|
<Dialog.Title>删除搭配</Dialog.Title>
|
||||||
|
<Dialog.Description>
|
||||||
|
确认删除「{outfit?.name}」吗?衣服本身不会被删除。
|
||||||
|
</Dialog.Description>
|
||||||
|
<View className="flex-row gap-3 mt-4">
|
||||||
|
<Button variant="tertiary" className="flex-1" onPress={() => setShowDeleteDialog(false)}>
|
||||||
|
<Button.Label>取消</Button.Label>
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" className="flex-1" onPress={handleDeleteConfirm}>
|
||||||
|
<Button.Label>删除</Button.Label>
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<LoadingOverlay visible={isLoading || pageLoading} />
|
<LoadingOverlay visible={isLoading || pageLoading} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { Button, Card, Input, Label, TextArea, TextField } from "heroui-native";
|
|||||||
import {
|
import {
|
||||||
BringToFrontIcon,
|
BringToFrontIcon,
|
||||||
ImageIcon,
|
ImageIcon,
|
||||||
|
Maximize2Icon,
|
||||||
|
Minimize2Icon,
|
||||||
MinusIcon,
|
MinusIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
SaveIcon,
|
SaveIcon,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
import { toast } from "sonner-native";
|
import { toast } from "sonner-native";
|
||||||
import PageLayout from "@/layouts/PageLayout";
|
import PageLayout from "@/layouts/PageLayout";
|
||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
import ClothingImage from "@/components/clothing/ClothingImage";
|
import ClothingImage from "@/components/clothing/ClothingImage";
|
||||||
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
import { LoadingOverlay } from "@/components/LoadingOverlay";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
@@ -88,29 +91,29 @@ function DraggableCanvasItem({
|
|||||||
onMove: (uid: string, x: number, y: number) => void;
|
onMove: (uid: string, x: number, y: number) => void;
|
||||||
}) {
|
}) {
|
||||||
const startRef = useRef({ x: item.x, y: item.y });
|
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(
|
const panResponder = useRef(
|
||||||
() =>
|
PanResponder.create({
|
||||||
PanResponder.create({
|
onStartShouldSetPanResponder: () => true,
|
||||||
onStartShouldSetPanResponder: () => true,
|
onMoveShouldSetPanResponder: () => true,
|
||||||
onMoveShouldSetPanResponder: () => true,
|
onPanResponderGrant: () => {
|
||||||
onPanResponderGrant: () => {
|
startRef.current = { x: itemRef.current.x, y: itemRef.current.y };
|
||||||
startRef.current = { x: item.x, y: item.y };
|
onSelect(itemRef.current.uid);
|
||||||
onSelect(item.uid);
|
},
|
||||||
},
|
onPanResponderMove: (_, gestureState) => {
|
||||||
onPanResponderMove: (_, gestureState) => {
|
const sz = sizeRef.current;
|
||||||
if (!canvasSize) return;
|
const it = itemRef.current;
|
||||||
const nextX = clamp(startRef.current.x + gestureState.dx / canvasSize, 0, 1 - item.width);
|
if (!sz) return;
|
||||||
const nextY = clamp(
|
const nextX = clamp(startRef.current.x + gestureState.dx / sz, 0, 1 - it.width);
|
||||||
startRef.current.y + gestureState.dy / canvasSize,
|
const nextY = clamp(startRef.current.y + gestureState.dy / sz, 0, 1 - it.height);
|
||||||
0,
|
onMove(it.uid, normalizeNumber(nextX), normalizeNumber(nextY));
|
||||||
1 - item.height,
|
},
|
||||||
);
|
}),
|
||||||
onMove(item.uid, normalizeNumber(nextX), normalizeNumber(nextY));
|
).current;
|
||||||
},
|
|
||||||
}),
|
|
||||||
[canvasSize, item.height, item.uid, item.width, item.x, item.y, onMove, onSelect],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canvasSize) return null;
|
if (!canvasSize) return null;
|
||||||
|
|
||||||
@@ -170,6 +173,8 @@ export default function ClothingOutfitEdit() {
|
|||||||
const [selectedUid, setSelectedUid] = useState<string | null>(null);
|
const [selectedUid, setSelectedUid] = useState<string | null>(null);
|
||||||
const [pageLoading, setPageLoading] = useState(true);
|
const [pageLoading, setPageLoading] = useState(true);
|
||||||
const [capturingCover, setCapturingCover] = useState(false);
|
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 clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]);
|
||||||
const outfitCategories = useMemo(
|
const outfitCategories = useMemo(
|
||||||
@@ -342,16 +347,14 @@ export default function ClothingOutfitEdit() {
|
|||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
|
|
||||||
const uri = await captureRef(canvasRef, {
|
const uri = await captureRef(canvasRef, {
|
||||||
format: "jpg",
|
format: "png",
|
||||||
quality: 0.86,
|
quality: 0.86,
|
||||||
width: 512,
|
|
||||||
height: 512,
|
|
||||||
result: "tmpfile",
|
result: "tmpfile",
|
||||||
});
|
});
|
||||||
const key = await uploadOutfitCover(familyId, {
|
const key = await uploadOutfitCover(familyId, {
|
||||||
uri,
|
uri,
|
||||||
name: `outfit_cover_${targetId}.jpg`,
|
name: `outfit_cover_${targetId}.png`,
|
||||||
type: "image/jpeg",
|
type: "image/png",
|
||||||
});
|
});
|
||||||
|
|
||||||
return key;
|
return key;
|
||||||
@@ -398,7 +401,6 @@ export default function ClothingOutfitEdit() {
|
|||||||
onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))}
|
onPress={() => setSelectedTagIds((prev) => toggleId(prev, item.id))}
|
||||||
>
|
>
|
||||||
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
|
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>
|
||||||
{item.emoji ? `${item.emoji} ` : ""}
|
|
||||||
{item.name}
|
{item.name}
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -613,202 +615,123 @@ export default function ClothingOutfitEdit() {
|
|||||||
stopLoading,
|
stopLoading,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const canvasContent = (
|
||||||
|
<View
|
||||||
|
ref={canvasRef}
|
||||||
|
collapsable={false}
|
||||||
|
className="w-full flex-1"
|
||||||
|
style={{ aspectRatio: canvasFullscreen ? undefined : 1 }}
|
||||||
|
onLayout={(event) => setCanvasSize(event.nativeEvent.layout.width)}
|
||||||
|
>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<View className="absolute inset-0 items-center justify-center" pointerEvents="none">
|
||||||
|
<ImageIcon size={34} color="#9CA3AF" />
|
||||||
|
<Text className="mt-2 text-sm text-gray-500">点击下方按钮添加衣服</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{items.map((item) => (
|
||||||
|
<DraggableCanvasItem
|
||||||
|
key={item.uid}
|
||||||
|
item={item}
|
||||||
|
canvasSize={canvasSize}
|
||||||
|
clothes={clothesById.get(item.clothesId)}
|
||||||
|
selected={!capturingCover && item.uid === selectedUid}
|
||||||
|
onSelect={setSelectedUid}
|
||||||
|
onMove={handleMove}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title={isEdit ? "编辑搭配" : "新增搭配"}>
|
<PageLayout title={isEdit ? "编辑搭配" : "新增搭配"}>
|
||||||
<Stack.Screen options={{ animation: "slide_from_right" }} />
|
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
<LayoutView.ScrollView bottomOffset={40}>
|
<LayoutView.ScrollView bottomOffset={40}>
|
||||||
<Card className="mb-4">
|
<View className="gap-4 mb-4">
|
||||||
<Card.Body className="gap-4">
|
<TextField isRequired>
|
||||||
<TextField isRequired>
|
<Label className="mb-2">搭配名称</Label>
|
||||||
<Label>搭配名称</Label>
|
<Input value={name} onChangeText={setName} placeholder="例如:周一通勤" />
|
||||||
<Input value={name} onChangeText={setName} placeholder="例如:周一通勤" />
|
</TextField>
|
||||||
</TextField>
|
|
||||||
|
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>备注</Label>
|
<Label className="mb-2">备注</Label>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={remark}
|
value={remark}
|
||||||
onChangeText={setRemark}
|
onChangeText={setRemark}
|
||||||
placeholder="适用场景、天气或搭配说明"
|
placeholder="适用场景、天气或搭配说明"
|
||||||
/>
|
|
||||||
</TextField>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
|
||||||
<Card.Header>
|
|
||||||
<Text className="text-base font-semibold text-gray-900">分类</Text>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Body>
|
|
||||||
<View className="flex-row flex-wrap gap-2">
|
|
||||||
<TouchableOpacity
|
|
||||||
className={`rounded-full border px-3 py-2 ${
|
|
||||||
categoryId == null ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"
|
|
||||||
}`}
|
|
||||||
onPress={() => setCategoryId(null)}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
className={`text-xs ${categoryId == null ? "text-white" : "text-gray-700"}`}
|
|
||||||
>
|
|
||||||
不选择
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{outfitCategories.map(renderCategoryChip)}
|
|
||||||
</View>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
|
||||||
<Card.Header>
|
|
||||||
<Text className="text-base font-semibold text-gray-900">标签</Text>
|
|
||||||
</Card.Header>
|
|
||||||
<Card.Body className="gap-4">
|
|
||||||
{outfitTagGroups.length > 0 ? (
|
|
||||||
outfitTagGroups.map(renderTagGroup)
|
|
||||||
) : (
|
|
||||||
<Text className="text-sm text-gray-500">暂无搭配标签,可在分类页维护。</Text>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedTagIds.length > 0 && (
|
|
||||||
<TouchableOpacity
|
|
||||||
className="self-start rounded-full bg-gray-100 px-3 py-2 flex-row items-center gap-1"
|
|
||||||
onPress={() => setSelectedTagIds([])}
|
|
||||||
>
|
|
||||||
<XIcon size={14} color="#6B7280" />
|
|
||||||
<Text className="text-xs text-gray-600">清空已选标签</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4 overflow-hidden p-0">
|
|
||||||
<View
|
|
||||||
ref={canvasRef}
|
|
||||||
collapsable={false}
|
|
||||||
className="w-full bg-gray-50"
|
|
||||||
style={{ aspectRatio: 1 }}
|
|
||||||
onLayout={(event) => setCanvasSize(event.nativeEvent.layout.width)}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
activeOpacity={1}
|
|
||||||
className="absolute inset-0"
|
|
||||||
onPress={() => setSelectedUid(null)}
|
|
||||||
/>
|
/>
|
||||||
{items.length === 0 && (
|
</TextField>
|
||||||
<View className="absolute inset-0 items-center justify-center">
|
</View>
|
||||||
<ImageIcon size={34} color="#9CA3AF" />
|
|
||||||
<Text className="mt-2 text-sm text-gray-500">从下方添加衣服到画布</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
{items.map((item) => (
|
|
||||||
<DraggableCanvasItem
|
|
||||||
key={item.uid}
|
|
||||||
item={item}
|
|
||||||
canvasSize={canvasSize}
|
|
||||||
clothes={clothesById.get(item.clothesId)}
|
|
||||||
selected={!capturingCover && item.uid === selectedUid}
|
|
||||||
onSelect={setSelectedUid}
|
|
||||||
onMove={handleMove}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
{/* 分类 */}
|
||||||
<Card.Header>
|
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">分类</Text>
|
||||||
<View className="flex-row items-center justify-between flex-1">
|
<View className="flex-row flex-wrap gap-2 mb-4">
|
||||||
<Text className="text-base font-semibold text-gray-900">画布操作</Text>
|
<TouchableOpacity
|
||||||
{!!selectedItem && (
|
className={`rounded-full border px-3 py-2 ${
|
||||||
<Text className="text-xs text-gray-500" numberOfLines={1}>
|
categoryId == null ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"
|
||||||
{clothesById.get(selectedItem.clothesId)?.name}
|
}`}
|
||||||
</Text>
|
onPress={() => setCategoryId(null)}
|
||||||
)}
|
>
|
||||||
</View>
|
<Text
|
||||||
</Card.Header>
|
className={`text-xs ${categoryId == null ? "text-white" : "text-gray-700"}`}
|
||||||
<Card.Body>
|
>
|
||||||
<View className="flex-row flex-wrap gap-2">
|
不选择
|
||||||
<Button
|
</Text>
|
||||||
variant="secondary"
|
</TouchableOpacity>
|
||||||
size="sm"
|
{outfitCategories.map(renderCategoryChip)}
|
||||||
isDisabled={!selectedItem}
|
</View>
|
||||||
onPress={() => handleScaleSelected(-0.04)}
|
|
||||||
>
|
|
||||||
<MinusIcon size={16} color="#374151" />
|
|
||||||
<Button.Label>缩小</Button.Label>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
isDisabled={!selectedItem}
|
|
||||||
onPress={() => handleScaleSelected(0.04)}
|
|
||||||
>
|
|
||||||
<PlusIcon size={16} color="#374151" />
|
|
||||||
<Button.Label>放大</Button.Label>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
isDisabled={!selectedItem}
|
|
||||||
onPress={() => handleLayerSelected("front")}
|
|
||||||
>
|
|
||||||
<BringToFrontIcon size={16} color="#374151" />
|
|
||||||
<Button.Label>置顶</Button.Label>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
isDisabled={!selectedItem}
|
|
||||||
onPress={() => handleLayerSelected("back")}
|
|
||||||
>
|
|
||||||
<SendToBackIcon size={16} color="#374151" />
|
|
||||||
<Button.Label>置底</Button.Label>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger-soft"
|
|
||||||
size="sm"
|
|
||||||
isDisabled={!selectedItem}
|
|
||||||
onPress={handleDeleteSelected}
|
|
||||||
>
|
|
||||||
<Trash2Icon size={16} color="#EF4444" />
|
|
||||||
<Button.Label>删除</Button.Label>
|
|
||||||
</Button>
|
|
||||||
</View>
|
|
||||||
</Card.Body>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="mb-4">
|
{/* 标签 */}
|
||||||
<Card.Header>
|
{outfitTagGroups.length > 0 && (
|
||||||
<Text className="text-base font-semibold text-gray-900">选择衣服</Text>
|
<>
|
||||||
</Card.Header>
|
<Text className="text-base font-semibold text-gray-900 mb-3 ml-1">标签</Text>
|
||||||
<Card.Body>
|
<View className="gap-4 mb-4">
|
||||||
{clothes.length > 0 ? (
|
{outfitTagGroups.map(renderTagGroup)}
|
||||||
<View className="flex-row flex-wrap gap-2">
|
</View>
|
||||||
{clothes.map((item) => (
|
</>
|
||||||
<TouchableOpacity
|
)}
|
||||||
key={item.id}
|
|
||||||
className="w-[31%] overflow-hidden rounded-2xl bg-gray-100"
|
{/* 画布操作栏 */}
|
||||||
activeOpacity={0.8}
|
<View className="flex-row gap-2 mb-3">
|
||||||
onPress={() => handleAddClothes(item)}
|
<Button variant="secondary" size="sm" isDisabled={!selectedItem} onPress={() => handleScaleSelected(-0.04)}>
|
||||||
>
|
<MinusIcon size={16} color="#374151" />
|
||||||
<ClothingImage
|
</Button>
|
||||||
fileKey={item.image_file_key}
|
<Button variant="secondary" size="sm" isDisabled={!selectedItem} onPress={() => handleScaleSelected(0.04)}>
|
||||||
label={item.name}
|
<PlusIcon size={16} color="#374151" />
|
||||||
className="h-24 w-full"
|
</Button>
|
||||||
/>
|
<Button variant="secondary" size="sm" isDisabled={!selectedItem} onPress={() => handleLayerSelected("front")}>
|
||||||
<View className="px-2 py-2">
|
<BringToFrontIcon size={16} color="#374151" />
|
||||||
<Text className="text-xs text-gray-800" numberOfLines={1}>
|
</Button>
|
||||||
{item.name}
|
<Button variant="secondary" size="sm" isDisabled={!selectedItem} onPress={() => handleLayerSelected("back")}>
|
||||||
</Text>
|
<SendToBackIcon size={16} color="#374151" />
|
||||||
</View>
|
</Button>
|
||||||
</TouchableOpacity>
|
<View className="flex-1" />
|
||||||
))}
|
{selectedItem && (
|
||||||
</View>
|
<Text className="text-xs text-gray-400 self-center mr-1" numberOfLines={1}>
|
||||||
) : (
|
{clothesById.get(selectedItem.clothesId)?.name}
|
||||||
<Text className="text-sm text-gray-500">暂无衣服,请先在衣橱中新增衣服。</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Card.Body>
|
<Button variant="danger-soft" size="sm" isDisabled={!selectedItem} onPress={handleDeleteSelected}>
|
||||||
</Card>
|
<Trash2Icon size={16} color="#EF4444" />
|
||||||
|
</Button>
|
||||||
|
<View className="flex-1" />
|
||||||
|
<Button variant="secondary" size="sm" onPress={() => setCanvasFullscreen((v) => !v)}>
|
||||||
|
{canvasFullscreen ? <Minimize2Icon size={16} color="#374151" /> : <Maximize2Icon size={16} color="#374151" />}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 画布 */}
|
||||||
|
{!canvasFullscreen && (
|
||||||
|
<Card className="mb-4 overflow-hidden p-0">
|
||||||
|
{canvasContent}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button className="mb-4" variant="secondary" onPress={() => setShowClothesSheet(true)}>
|
||||||
|
<PlusIcon size={18} color="#374151" />
|
||||||
|
<Button.Label>从衣橱添加衣服</Button.Label>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<Button variant="secondary" className="flex-1" onPress={() => router.back()}>
|
<Button variant="secondary" className="flex-1" onPress={() => router.back()}>
|
||||||
@@ -823,6 +746,58 @@ export default function ClothingOutfitEdit() {
|
|||||||
</LayoutView.ScrollView>
|
</LayoutView.ScrollView>
|
||||||
</LayoutView>
|
</LayoutView>
|
||||||
|
|
||||||
|
<BottomSheet isOpen={showClothesSheet} onOpenChange={setShowClothesSheet}>
|
||||||
|
<BottomSheet.Portal>
|
||||||
|
<BottomSheet.Overlay />
|
||||||
|
<BottomSheet.Content animateOnMount={false}>
|
||||||
|
{clothes.length > 0 ? (
|
||||||
|
<View className="flex-row flex-wrap gap-2">
|
||||||
|
{clothes.map((item) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={item.id}
|
||||||
|
className="w-[31%] overflow-hidden rounded-2xl bg-gray-100"
|
||||||
|
activeOpacity={0.8}
|
||||||
|
onPress={() => handleAddClothes(item)}
|
||||||
|
>
|
||||||
|
<ClothingImage
|
||||||
|
fileKey={item.image_file_key}
|
||||||
|
label={item.name}
|
||||||
|
className="h-24 w-full"
|
||||||
|
/>
|
||||||
|
<View className="px-2 py-2">
|
||||||
|
<Text className="text-xs text-gray-800" numberOfLines={1}>
|
||||||
|
{item.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<Text className="text-sm text-gray-500">暂无衣服,请先在衣橱中新增衣服。</Text>
|
||||||
|
)}
|
||||||
|
</BottomSheet.Content>
|
||||||
|
</BottomSheet.Portal>
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
|
{canvasFullscreen && (
|
||||||
|
<View
|
||||||
|
className="absolute inset-0 bg-black z-50 items-center justify-center"
|
||||||
|
style={{ paddingTop: 0 }}
|
||||||
|
>
|
||||||
|
<View className="absolute top-0 left-0 right-0 flex-row justify-end px-4 py-3 z-10">
|
||||||
|
<TouchableOpacity
|
||||||
|
className="w-10 h-10 rounded-full bg-white/20 items-center justify-center"
|
||||||
|
onPress={() => setCanvasFullscreen(false)}
|
||||||
|
>
|
||||||
|
<Minimize2Icon size={20} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View className="w-full" style={{ aspectRatio: 1, maxHeight: "90%" } as any}>
|
||||||
|
{canvasContent}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<LoadingOverlay visible={isLoading || pageLoading} />
|
<LoadingOverlay visible={isLoading || pageLoading} />
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -434,11 +434,18 @@ export default function FinanceAnalysis() {
|
|||||||
return (
|
return (
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
<LayoutView.ScrollView contentContainerStyle={{ gap: 16 }}>
|
<LayoutView.ScrollView contentContainerStyle={{ gap: 16 }}>
|
||||||
<View className="gap-3">
|
<View className="gap-3 pb-4">
|
||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-row items-center justify-between">
|
||||||
<Text className="text-base font-semibold text-gray-900">
|
<View className="flex-col gap-0.5 flex-1 mr-3">
|
||||||
{filter.startDate} 至 {filter.endDate}
|
<Text className="text-base font-semibold text-gray-900">
|
||||||
</Text>
|
{dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")}
|
||||||
|
</Text>
|
||||||
|
<Text className="text-xs text-gray-400">
|
||||||
|
{dayjs(filter.startDate).year() !== dayjs(filter.endDate).year()
|
||||||
|
? `${dayjs(filter.startDate).format("YYYY")} / ${dayjs(filter.endDate).format("YYYY")}`
|
||||||
|
: dayjs(filter.startDate).format("YYYY年")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="flex-row gap-2">
|
<View className="flex-row gap-2">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -476,19 +483,19 @@ export default function FinanceAnalysis() {
|
|||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="支出"
|
label="支出"
|
||||||
value={`¥${formatCompactMoney(totals.expense)}`}
|
value={formatCompactMoney(totals.expense)}
|
||||||
tone="expense"
|
tone="expense"
|
||||||
/>
|
/>
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="收入"
|
label="收入"
|
||||||
value={`¥${formatCompactMoney(totals.income)}`}
|
value={formatCompactMoney(totals.income)}
|
||||||
tone="income"
|
tone="income"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="flex-row gap-3">
|
<View className="flex-row gap-3">
|
||||||
<SummaryCard
|
<SummaryCard
|
||||||
label="结余"
|
label="结余"
|
||||||
value={`¥${formatCompactMoney(balance)}`}
|
value={formatCompactMoney(balance)}
|
||||||
tone={balance >= 0 ? "income" : "expense"}
|
tone={balance >= 0 ? "income" : "expense"}
|
||||||
/>
|
/>
|
||||||
<SummaryCard label="笔数" value={`${totals.count}`} tone="neutral" />
|
<SummaryCard label="笔数" value={`${totals.count}`} tone="neutral" />
|
||||||
@@ -524,7 +531,7 @@ export default function FinanceAnalysis() {
|
|||||||
<Text className="text-xs text-gray-500">支出峰值</Text>
|
<Text className="text-xs text-gray-500">支出峰值</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="mt-1 text-sm font-semibold text-rose-500">
|
<Text className="mt-1 text-sm font-semibold text-rose-500">
|
||||||
¥{formatMoney(trendSummary.maxExpense)}
|
{formatMoney(trendSummary.maxExpense)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-[11px] text-gray-400">
|
<Text className="text-[11px] text-gray-400">
|
||||||
{trendSummary.maxExpenseDay
|
{trendSummary.maxExpenseDay
|
||||||
@@ -538,7 +545,7 @@ export default function FinanceAnalysis() {
|
|||||||
<Text className="text-xs text-gray-500">收入峰值</Text>
|
<Text className="text-xs text-gray-500">收入峰值</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text className="mt-1 text-sm font-semibold text-emerald-600">
|
<Text className="mt-1 text-sm font-semibold text-emerald-600">
|
||||||
¥{formatMoney(trendSummary.maxIncome)}
|
{formatMoney(trendSummary.maxIncome)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-[11px] text-gray-400">
|
<Text className="text-[11px] text-gray-400">
|
||||||
{trendSummary.maxIncomeDay
|
{trendSummary.maxIncomeDay
|
||||||
@@ -549,7 +556,6 @@ export default function FinanceAnalysis() {
|
|||||||
<View className="flex-1 rounded-lg bg-white px-3 py-2">
|
<View className="flex-1 rounded-lg bg-white px-3 py-2">
|
||||||
<Text className="text-xs text-gray-500">日均支出</Text>
|
<Text className="text-xs text-gray-500">日均支出</Text>
|
||||||
<Text className="mt-1 text-sm font-semibold text-gray-900">
|
<Text className="mt-1 text-sm font-semibold text-gray-900">
|
||||||
¥
|
|
||||||
{formatMoney(
|
{formatMoney(
|
||||||
trendSummary.nonZeroDays > 0
|
trendSummary.nonZeroDays > 0
|
||||||
? trendSummary.totalExpense / trendData.length
|
? trendSummary.totalExpense / trendData.length
|
||||||
@@ -599,7 +605,7 @@ export default function FinanceAnalysis() {
|
|||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-row items-center justify-between">
|
||||||
<Text className="text-xs text-gray-700">{item.categoryName}</Text>
|
<Text className="text-xs text-gray-700">{item.categoryName}</Text>
|
||||||
<Text className="text-[11px] text-gray-400">
|
<Text className="text-[11px] text-gray-400">
|
||||||
¥{formatCompactMoney(item.amount)} ({Math.round(pct)}%)
|
{formatCompactMoney(item.amount)} ({Math.round(pct)}%)
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View className="h-2 bg-gray-50 rounded-full overflow-hidden">
|
<View className="h-2 bg-gray-50 rounded-full overflow-hidden">
|
||||||
@@ -638,7 +644,7 @@ export default function FinanceAnalysis() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>日期范围</Label>
|
<Label className="mb-2">日期范围</Label>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
|
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
|
||||||
activeOpacity={0.75}
|
activeOpacity={0.75}
|
||||||
@@ -652,7 +658,7 @@ export default function FinanceAnalysis() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>收支类型</Label>
|
<Label className="mb-2">收支类型</Label>
|
||||||
<View className="flex-row gap-2">
|
<View className="flex-row gap-2">
|
||||||
{[
|
{[
|
||||||
{ key: "all" as const, label: "全部" },
|
{ key: "all" as const, label: "全部" },
|
||||||
@@ -686,7 +692,7 @@ export default function FinanceAnalysis() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>成员</Label>
|
<Label className="mb-2">成员</Label>
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className={`h-9 px-3 rounded-full items-center justify-center border ${
|
className={`h-9 px-3 rounded-full items-center justify-center border ${
|
||||||
@@ -731,7 +737,7 @@ export default function FinanceAnalysis() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>分类</Label>
|
<Label className="mb-2">分类</Label>
|
||||||
<View className="flex-row flex-wrap gap-2">
|
<View className="flex-row flex-wrap gap-2">
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
className={`h-9 px-3 rounded-full items-center justify-center border ${
|
className={`h-9 px-3 rounded-full items-center justify-center border ${
|
||||||
@@ -781,7 +787,7 @@ export default function FinanceAnalysis() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>备注关键词</Label>
|
<Label className="mb-2">备注关键词</Label>
|
||||||
<BottomSheet.Input
|
<BottomSheet.Input
|
||||||
value={draftFilter.keyword}
|
value={draftFilter.keyword}
|
||||||
onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))}
|
onChangeText={(keyword) => 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"
|
className="text-[11px] font-medium w-14 text-right"
|
||||||
style={{ color: stack.color }}
|
style={{ color: stack.color }}
|
||||||
>
|
>
|
||||||
¥{formatMoney(stack.value)}
|
{formatMoney(stack.value)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ export default function Home() {
|
|||||||
if (!activeBill || isLoading) return;
|
if (!activeBill || isLoading) return;
|
||||||
|
|
||||||
const id = activeBill.id;
|
const id = activeBill.id;
|
||||||
const target = `${activeBill.category_type === FinanceType.Income ? "收入" : "支出"} ¥${formatMoney(
|
const target = `${activeBill.category_type === FinanceType.Income ? "收入" : "支出"} ${formatMoney(
|
||||||
Number(activeBill.amount),
|
Number(activeBill.amount),
|
||||||
)}`;
|
)}`;
|
||||||
const detail = activeBill.category_name || "未分类";
|
const detail = activeBill.category_name || "未分类";
|
||||||
@@ -286,7 +286,7 @@ export default function Home() {
|
|||||||
() => (
|
() => (
|
||||||
<View className="gap-3 pb-4">
|
<View className="gap-3 pb-4">
|
||||||
<View className="flex-row items-center justify-between">
|
<View className="flex-row items-center justify-between">
|
||||||
<View className="flex-col gap-1.5 flex-1 mr-3">
|
<View className="flex-col gap-0.5 flex-1 mr-3">
|
||||||
<Text className="text-base font-semibold text-gray-900">
|
<Text className="text-base font-semibold text-gray-900">
|
||||||
{dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")}
|
{dayjs(filter.startDate).format("MM.DD")} - {dayjs(filter.endDate).format("MM.DD")}
|
||||||
</Text>
|
</Text>
|
||||||
|
|||||||
@@ -204,12 +204,12 @@ export default function Add() {
|
|||||||
notifyModuleOperation({
|
notifyModuleOperation({
|
||||||
module: "记账",
|
module: "记账",
|
||||||
action: isEdit ? "更新记账" : "新增记账",
|
action: isEdit ? "更新记账" : "新增记账",
|
||||||
target: `${label} ¥${amount}`,
|
target: `${label} ${amount}`,
|
||||||
detail: selectedCategory.name,
|
detail: selectedCategory.name,
|
||||||
sourceTable: "b_finance",
|
sourceTable: "b_finance",
|
||||||
sourceId: targetId,
|
sourceId: targetId,
|
||||||
});
|
});
|
||||||
toast.success(isEdit ? "记账已更新" : `已记${label} ¥${amount}`);
|
toast.success(isEdit ? "记账已更新" : `已记${label} ${amount}`);
|
||||||
if (keepOpen) {
|
if (keepOpen) {
|
||||||
setAmount("");
|
setAmount("");
|
||||||
setRemark("");
|
setRemark("");
|
||||||
@@ -342,7 +342,7 @@ export default function Add() {
|
|||||||
|
|
||||||
{/* 备注 */}
|
{/* 备注 */}
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>备注</Label>
|
<Label className="mb-2">备注</Label>
|
||||||
<BottomSheet.Input
|
<BottomSheet.Input
|
||||||
placeholder="写点什么..."
|
placeholder="写点什么..."
|
||||||
value={remark}
|
value={remark}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Button, Label, Tabs, TextField } from "heroui-native";
|
|||||||
import BottomSheet from "@/components/BottomSheet";
|
import BottomSheet from "@/components/BottomSheet";
|
||||||
import { GripVerticalIcon, ImageIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react-native";
|
import { GripVerticalIcon, ImageIcon, PlusIcon, SaveIcon, Trash2Icon } from "lucide-react-native";
|
||||||
import Sortable, { SortableGridRenderItem } from "react-native-sortables";
|
import Sortable, { SortableGridRenderItem } from "react-native-sortables";
|
||||||
|
import { useAnimatedRef } from "react-native-reanimated";
|
||||||
import PageLayout from "@/layouts/PageLayout";
|
import PageLayout from "@/layouts/PageLayout";
|
||||||
import LayoutView from "@/components/layout/view";
|
import LayoutView from "@/components/layout/view";
|
||||||
import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon";
|
import FinanceCategoryIcon from "@/components/finance/FinanceCategoryIcon";
|
||||||
@@ -69,6 +70,8 @@ export default function FinanceCategoryList() {
|
|||||||
[activeType, categories],
|
[activeType, categories],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const scrollRef = useAnimatedRef<any>();
|
||||||
|
|
||||||
const closeSheet = useCallback(() => {
|
const closeSheet = useCallback(() => {
|
||||||
setForm(null);
|
setForm(null);
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -307,9 +310,9 @@ export default function FinanceCategoryList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="分类管理">
|
<PageLayout title="分类管理">
|
||||||
<Stack.Screen options={{ animation: "slide_from_right" }} />
|
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
<LayoutView.ScrollView bottomOffset={40}>
|
<LayoutView.ScrollView ref={scrollRef} bottomOffset={40}>
|
||||||
<View className="mb-3">
|
<View className="mb-3">
|
||||||
<Tabs value={activeType} onValueChange={setActiveType} variant="primary">
|
<Tabs value={activeType} onValueChange={setActiveType} variant="primary">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
@@ -333,6 +336,7 @@ export default function FinanceCategoryList() {
|
|||||||
<Sortable.Grid
|
<Sortable.Grid
|
||||||
columns={1}
|
columns={1}
|
||||||
customHandle
|
customHandle
|
||||||
|
scrollableRef={scrollRef}
|
||||||
data={currentCategories}
|
data={currentCategories}
|
||||||
keyExtractor={(item) => String(item.id)}
|
keyExtractor={(item) => String(item.id)}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
@@ -371,7 +375,7 @@ export default function FinanceCategoryList() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TextField>
|
<TextField>
|
||||||
<Label>分类名称</Label>
|
<Label className="mb-2">分类名称</Label>
|
||||||
<BottomSheet.Input
|
<BottomSheet.Input
|
||||||
value={form.name}
|
value={form.name}
|
||||||
onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))}
|
onChangeText={(name) => setForm((prev) => (prev ? { ...prev, name } : prev))}
|
||||||
@@ -380,7 +384,7 @@ export default function FinanceCategoryList() {
|
|||||||
</TextField>
|
</TextField>
|
||||||
|
|
||||||
<View>
|
<View>
|
||||||
<Label>类型</Label>
|
<Label className="mb-2">类型</Label>
|
||||||
<Tabs
|
<Tabs
|
||||||
value={form.type}
|
value={form.type}
|
||||||
onValueChange={(type) => setForm((prev) => (prev ? { ...prev, type } : prev))}
|
onValueChange={(type) => setForm((prev) => (prev ? { ...prev, type } : prev))}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default function FinanceDetail() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout title="记账详情">
|
<PageLayout title="记账详情">
|
||||||
<Stack.Screen options={{ animation: "slide_from_right" }} />
|
<Stack.Screen options={{ animation: "slide_from_bottom" }} />
|
||||||
<LayoutView>
|
<LayoutView>
|
||||||
<LayoutView.ScrollView>
|
<LayoutView.ScrollView>
|
||||||
{!bill && loaded ? (
|
{!bill && loaded ? (
|
||||||
@@ -118,7 +118,7 @@ export default function FinanceDetail() {
|
|||||||
<Text
|
<Text
|
||||||
className={`mt-2 text-3xl font-bold ${isIncome ? "text-emerald-600" : "text-rose-500"}`}
|
className={`mt-2 text-3xl font-bold ${isIncome ? "text-emerald-600" : "text-rose-500"}`}
|
||||||
>
|
>
|
||||||
{isIncome ? "+" : "-"}¥{formatMoney(bill.amount)}
|
{isIncome ? "+" : "-"}{formatMoney(bill.amount)}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import {
|
|||||||
type ViewStyle,
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Input, useBottomSheetAwareHandlers } from "heroui-native";
|
import { Input, useBottomSheetAwareHandlers } from "heroui-native";
|
||||||
|
import { Toaster } from "sonner-native";
|
||||||
|
import { resetToaster } from "@/utils/toaster";
|
||||||
|
|
||||||
// ── Props ──────────────────────────────────────────────
|
// ── Props ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -153,7 +155,7 @@ function AppBottomSheetRoot({
|
|||||||
input.measureInWindow((x, y, w, h) => {
|
input.measureInWindow((x, y, w, h) => {
|
||||||
const screenBottom = SCREEN_H - kbHeight;
|
const screenBottom = SCREEN_H - kbHeight;
|
||||||
const inputBottom = y + h;
|
const inputBottom = y + h;
|
||||||
const needed = inputBottom - screenBottom - 16;
|
const needed = inputBottom - screenBottom - 18;
|
||||||
keyboardY.setValue(needed > 0 ? -needed : 0);
|
keyboardY.setValue(needed > 0 ? -needed : 0);
|
||||||
});
|
});
|
||||||
}, 50);
|
}, 50);
|
||||||
@@ -172,6 +174,13 @@ function AppBottomSheetRoot({
|
|||||||
return () => { s1.remove(); s2.remove(); h1.remove(); h2.remove(); };
|
return () => { s1.remove(); s2.remove(); h1.remove(); h2.remove(); };
|
||||||
}, [visible, adjustForKeyboard]);
|
}, [visible, adjustForKeyboard]);
|
||||||
|
|
||||||
|
// Modal 关闭后恢复全局 Toaster
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) return;
|
||||||
|
const timer = setTimeout(() => resetToaster(), 100);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
if (!visible) return null;
|
if (!visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -206,6 +215,8 @@ function AppBottomSheetRoot({
|
|||||||
{children}
|
{children}
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
<Toaster duration={1000} />
|
||||||
</Modal>
|
</Modal>
|
||||||
</AppBottomSheetContext.Provider>
|
</AppBottomSheetContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -230,8 +241,8 @@ function AppBottomSheetContent({
|
|||||||
return (
|
return (
|
||||||
<AppBottomSheetContentContext.Provider value={{ ...ctx, shouldAutoFocusSingleInput: inputCount === 1 }}>
|
<AppBottomSheetContentContext.Provider value={{ ...ctx, shouldAutoFocusSingleInput: inputCount === 1 }}>
|
||||||
<View
|
<View
|
||||||
className={contentContainerClassName ?? className ?? "px-4 py-4"}
|
className={contentContainerClassName ?? className ?? ""}
|
||||||
style={[{ paddingTop:16,paddingBottom: 28,paddingLeft:24,paddingRight:24 }, style]}
|
style={[{ paddingVertical:24,paddingHorizontal:20 }, style]}
|
||||||
{...(props as Record<string, unknown>)}
|
{...(props as Record<string, unknown>)}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -89,9 +89,8 @@ export default function DateTime({
|
|||||||
<BottomSheet
|
<BottomSheet
|
||||||
isOpen={open}
|
isOpen={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
backdropOpacity={0.35}
|
|
||||||
>
|
>
|
||||||
<BottomSheet.Content className="pb-6">
|
<BottomSheet.Content>
|
||||||
<View className="w-full items-center">
|
<View className="w-full items-center">
|
||||||
<DateTimePicker
|
<DateTimePicker
|
||||||
mode="single"
|
mode="single"
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export default function ModuleLayout({ tabs }: { tabs: TabbarItem[] }) {
|
|||||||
}, [pathname, tabs]);
|
}, [pathname, tabs]);
|
||||||
|
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
router.back();
|
router.dismissTo("/tools");
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export interface ClothingTag {
|
|||||||
tag_group_id: number;
|
tag_group_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
type: ClothingTargetType;
|
type: ClothingTargetType;
|
||||||
emoji?: string;
|
color?: string;
|
||||||
sort_number: number;
|
sort_number: number;
|
||||||
status: ClothingStatus;
|
status: ClothingStatus;
|
||||||
create_time?: string;
|
create_time?: string;
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
let resetFn: (() => void) | null = null;
|
||||||
|
|
||||||
|
export function onToasterReset(fn: () => void) {
|
||||||
|
resetFn = fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetToaster() {
|
||||||
|
resetFn?.();
|
||||||
|
}
|
||||||
Reference in new issue
Block a user