20260701221108

This commit is contained in:
oneao committed 2026-07-01 22:11:08 +08:00
1 parent e4ac6f7cc9
commit e513390ccb
9 files changed
+864 -440

No files matched your search

+1 -1
View File
@@ -1 +1 @@
EXPO_PUBLIC_API_URL=http://192.168.5.23:8087
EXPO_PUBLIC_API_URL=http://192.168.5.47:8087
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Pressable, Text, TouchableOpacity, View } from "react-native";
import { router, useFocusEffect } from "expo-router";
import { Button, Card, Dialog, Label, SearchField } from "heroui-native";
import { Button, Card, Dialog, Label, TextField } from "heroui-native";
import {
CheckIcon,
FilterIcon,
@@ -419,31 +419,32 @@ export default function ClothingHome() {
const renderHeader = useCallback(
() => (
<View className="gap-3 pb-4">
<SearchField value={keyword} onChange={setKeyword}>
<SearchField.Group>
<SearchField.SearchIcon />
<SearchField.Input placeholder="搜索衣服、品牌、备注" />
<SearchField.ClearButton />
</SearchField.Group>
</SearchField>
<View className="flex-row gap-2">
<Button className="flex-1" onPress={() => router.push("/clothes/edit" as any)}>
<PlusIcon size={18} color="#fff" />
<Button.Label>新增衣服</Button.Label>
</Button>
<Button variant="secondary" isIconOnly onPress={handleRefresh}>
<RotateCcwIcon size={18} color="#374151" />
</Button>
<Button variant="secondary" onPress={handleOpenFilter}>
<FilterIcon size={17} color="#374151" />
<Button.Label>筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""}</Button.Label>
</Button>
<View className="flex-row items-center pb-4">
<Text className="text-xs text-gray-400 flex-1">
共 {clothes.length} 件衣服
</Text>
<View className="flex-row gap-2 items-center">
<TouchableOpacity
className="h-10 px-3 rounded-full bg-accent flex-row items-center gap-1.5"
activeOpacity={0.8}
onPress={handleOpenFilter}
>
<FilterIcon size={16} color="#fff" />
<Text className="text-sm font-medium text-white">
筛选{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""}
</Text>
</TouchableOpacity>
<TouchableOpacity
className="h-10 w-10 rounded-full bg-gray-100 items-center justify-center"
activeOpacity={0.75}
onPress={handleRefresh}
>
<RotateCcwIcon size={18} color="#4B5563" />
</TouchableOpacity>
</View>
</View>
),
[activeFilterCount, handleOpenFilter, handleRefresh, keyword],
[activeFilterCount, handleOpenFilter, handleRefresh],
);
const filterChanged =
@@ -472,14 +473,6 @@ export default function ClothingHome() {
title={loading ? "正在加载衣橱" : "暂无衣服"}
description={loading ? "请稍候" : "新增第一件衣服后,可以在这里筛选和管理"}
center="screen"
footer={
!loading ? (
<Button className="mt-4" onPress={() => router.push("/clothes/edit" as any)}>
<PlusIcon size={18} color="#fff" />
<Button.Label>新增衣服</Button.Label>
</Button>
) : undefined
}
/>
)}
/>
@@ -488,17 +481,30 @@ export default function ClothingHome() {
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content animateOnMount={false} className="gap-4">
<View className="flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">筛选衣服</Text>
<TouchableOpacity
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
onPress={handleResetFilter}
>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
<View className="flex-row items-center">
<View className="flex-1" />
<Text className="text-center text-lg font-semibold text-gray-900">筛选衣服</Text>
<View className="flex-1 items-end">
<TouchableOpacity
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
onPress={handleResetFilter}
>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
</View>
</View>
<TextField>
<Label className="mb-2">关键词</Label>
<BottomSheet.Input
value={keyword}
onChangeText={setKeyword}
placeholder="搜索衣服、品牌、备注"
returnKeyType="search"
/>
</TextField>
<View>
<Label className="mb-2">分类</Label>
<View className="flex-row flex-wrap gap-2">
@@ -550,6 +556,14 @@ export default function ClothingHome() {
</BottomSheet.Portal>
</BottomSheet>
<TouchableOpacity
className="absolute bottom-20 right-5 w-14 h-14 rounded-full bg-accent items-center justify-center shadow-lg z-50"
activeOpacity={0.85}
onPress={() => router.push("/clothes/edit" as any)}
>
<PlusIcon size={24} color="#fff" />
</TouchableOpacity>
<Modal
visible={!!activeItem}
transparent
@@ -5,9 +5,7 @@ import {
CheckIcon,
ChevronDownIcon,
FolderIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
SaveIcon,
TagIcon,
Trash2Icon,
@@ -415,29 +413,25 @@ export default function ClothingMetadataManage() {
const renderCategory = useCallback(
(item: ClothingCategory) => (
<View
<TouchableOpacity
key={item.id}
className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-3 mb-2"
className="flex-row items-center gap-2.5 bg-gray-50 rounded-2xl px-3.5 py-3 mb-1.5"
activeOpacity={0.6}
onPress={() => openEditCategory(item)}
>
<View className="h-9 w-9 rounded-xl bg-gray-100 items-center justify-center">
<View className="h-9 w-9 rounded-xl bg-white items-center justify-center">
<FolderIcon size={18} color="#6B7280" />
</View>
<Text className="flex-1 text-sm font-medium text-gray-900" numberOfLines={1}>
{item.name}
</Text>
<TouchableOpacity
className="w-9 h-9 items-center justify-center rounded-full bg-gray-100"
onPress={() => openEditCategory(item)}
className="w-8 h-8 items-center justify-center rounded-full"
onPress={(e) => { e.stopPropagation?.(); handleDeleteCategory(item); }}
>
<PencilIcon size={15} color="#374151" />
<Trash2Icon size={14} color="#EF4444" />
</TouchableOpacity>
<TouchableOpacity
className="w-9 h-9 items-center justify-center rounded-full bg-red-50"
onPress={() => handleDeleteCategory(item)}
>
<Trash2Icon size={15} color="#EF4444" />
</TouchableOpacity>
</View>
</TouchableOpacity>
),
[handleDeleteCategory, openEditCategory],
);
@@ -448,75 +442,69 @@ export default function ClothingMetadataManage() {
const collapsed = collapsedGroups.has(group.id);
return (
<View key={group.id} className="mb-3">
{/* Group header — same row style as categories */}
<View key={group.id} className="mb-1.5">
{/* Group header */}
<TouchableOpacity
className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-3"
activeOpacity={0.7}
className="flex-row items-center gap-2.5 bg-gray-50 rounded-2xl px-3.5 py-3"
activeOpacity={0.6}
onPress={() => toggleGroup(group.id)}
onLongPress={() => openEditTagGroup(group)}
>
<View className="h-9 w-9 rounded-xl bg-gray-100 items-center justify-center">
<View className="h-9 w-9 rounded-xl bg-white items-center justify-center">
<TagIcon size={18} color="#6B7280" />
</View>
<Text className="flex-1 text-sm font-semibold text-gray-900" numberOfLines={1}>
{group.name}
</Text>
<ChevronDownIcon
size={16}
color="#9CA3AF"
style={{ transform: [{ rotate: collapsed ? "0deg" : "180deg" }] }}
/>
<Text className="text-xs text-gray-400">{groupTags.length}</Text>
<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"
className="w-7 h-7 items-center justify-center rounded-full"
onPress={(e) => { e.stopPropagation?.(); handleDeleteTagGroup(group); }}
>
<Trash2Icon size={14} color="#EF4444" />
<Trash2Icon size={13} color="#EF4444" />
</TouchableOpacity>
<ChevronDownIcon
size={16}
color="#D1D5DB"
style={{ transform: [{ rotate: collapsed ? "0deg" : "180deg" }] }}
/>
</TouchableOpacity>
{/* Tags — same row style as categories, colored dot instead of icon */}
{/* Tags — with left border to show hierarchy */}
{!collapsed && (
<View className="mt-2 ml-3">
<View className="ml-2.5 pl-3 border-l-2 border-gray-100">
{groupTags.map((tag) => (
<View
<TouchableOpacity
key={tag.id}
className="flex-row items-center gap-3 bg-white rounded-xl px-3 py-2.5 mb-1 ml-9"
className="flex-row items-center gap-2.5 py-2.5 border-b border-gray-50"
activeOpacity={0.6}
onPress={() => openEditTag(tag)}
>
<View
className="w-3 h-3 rounded-full"
className="w-2.5 h-2.5 rounded-full ml-0.5"
style={{ backgroundColor: tag.color || "#D1D5DB" }}
/>
<Text className="flex-1 text-sm text-gray-900" numberOfLines={1}>
<Text className="flex-1 text-sm text-gray-700" numberOfLines={1}>
{tag.name}
</Text>
<TouchableOpacity
className="w-8 h-8 items-center justify-center rounded-full bg-gray-100"
onPress={() => openEditTag(tag)}
className="w-7 h-7 items-center justify-center rounded-full"
onPress={(e) => { e.stopPropagation?.(); handleDeleteTag(tag); }}
>
<PencilIcon size={14} color="#374151" />
<Trash2Icon size={13} color="#EF4444" />
</TouchableOpacity>
<TouchableOpacity
className="w-8 h-8 items-center justify-center rounded-full bg-red-50"
onPress={() => handleDeleteTag(tag)}
>
<Trash2Icon size={14} color="#EF4444" />
</TouchableOpacity>
</View>
</TouchableOpacity>
))}
{groupTags.length === 0 && (
<Text className="text-xs text-gray-400 py-2 ml-9">暂无标签</Text>
<Text className="text-xs text-gray-400 py-2">暂无标签</Text>
)}
<TouchableOpacity
className="flex-row items-center gap-1.5 py-2 ml-9"
className="flex-row items-center gap-1.5 py-2.5"
onPress={() => openCreateTag(group.id)}
>
<PlusIcon size={14} color="#9CA3AF" />
<View className="w-4 h-4 rounded-full border border-dashed border-gray-300 items-center justify-center">
<PlusIcon size={9} color="#9CA3AF" />
</View>
<Text className="text-xs text-gray-400">添加标签</Text>
</TouchableOpacity>
</View>
@@ -548,10 +536,10 @@ export default function ClothingMetadataManage() {
<LayoutView.ScrollView
refreshing={refreshing}
onRefresh={handleRefresh}
contentContainerStyle={{ paddingBottom: 120 }}
contentContainerStyle={{ paddingBottom: 40 }}
>
<View className="gap-3 mb-4">
<View className="flex-row items-center justify-between">
<View className="gap-3 mb-3">
<View className="flex-row items-center">
<Tabs value={activeType} onValueChange={setActiveType} variant="primary" className="flex-1">
<Tabs.List>
<Tabs.Indicator />
@@ -563,32 +551,19 @@ export default function ClothingMetadataManage() {
</Tabs.Trigger>
</Tabs.List>
</Tabs>
<TouchableOpacity
className="ml-3 w-10 h-10 rounded-full bg-gray-100 items-center justify-center"
onPress={handleRefresh}
>
<RotateCcwIcon size={18} color="#4B5563" />
</TouchableOpacity>
</View>
<View className="flex-row gap-2">
<Button className="flex-1" size="sm" onPress={openCreateCategory}>
<PlusIcon size={16} color="#fff" />
<Button.Label>分类</Button.Label>
</Button>
<Button className="flex-1" size="sm" onPress={openCreateTagGroup}>
<PlusIcon size={16} color="#fff" />
<Button.Label>标签组</Button.Label>
</Button>
<Button variant="secondary" size="sm" className="flex-1" onPress={() => openCreateTag()} isDisabled={currentTagGroups.length === 0}>
<PlusIcon size={16} color="#374151" />
<Button.Label>标签</Button.Label>
</Button>
</View>
</View>
{/* 分类 */}
<Text className="text-xs font-semibold text-gray-400 mb-2 ml-1">分类</Text>
<View className="flex-row items-center justify-between mb-2">
<Text className="text-sm font-semibold text-gray-500">分类</Text>
<TouchableOpacity
className="w-7 h-7 rounded-full bg-gray-100 items-center justify-center"
onPress={openCreateCategory}
>
<PlusIcon size={16} color="#6B7280" />
</TouchableOpacity>
</View>
{currentCategories.length > 0 ? (
currentCategories.map(renderCategory)
) : (
@@ -598,8 +573,14 @@ export default function ClothingMetadataManage() {
)}
{/* 标签 */}
<View className="mt-5 mb-2">
<Text className="text-xs font-semibold text-gray-400 ml-1">标签</Text>
<View className="flex-row items-center justify-between mt-3 mb-2">
<Text className="text-sm font-semibold text-gray-500">标签</Text>
<TouchableOpacity
className="w-7 h-7 rounded-full bg-gray-100 items-center justify-center"
onPress={() => currentTagGroups.length > 0 ? openCreateTag() : openCreateTagGroup()}
>
<PlusIcon size={16} color="#6B7280" />
</TouchableOpacity>
</View>
{currentTagGroups.length > 0 ? (
currentTagGroups.map(renderTagGroup)
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Modal, Pressable, Text, TouchableOpacity, View } from "react-native";
import { router, useFocusEffect } from "expo-router";
import { Button, Card, Dialog } from "heroui-native";
import { Button, Dialog, Label, TextField } from "heroui-native";
import BottomSheet from "@/components/BottomSheet";
import {
FileTextIcon,
FilterIcon,
PencilIcon,
PlusIcon,
RotateCcwIcon,
SparklesIcon,
Trash2Icon,
} from "lucide-react-native";
import LayoutView from "@/components/layout/view";
@@ -17,7 +18,7 @@ import { useListLoading, useLoading } from "@/hooks/useLoading";
import { useModuleChatNotifier } from "@/hooks/useModuleChatNotifier";
import { LoadingOverlay } from "@/components/LoadingOverlay";
import { useClothingStore, useFamilyStore } from "@/store";
import { ClothingOutfit, ClothingStatus, ClothingTargetType } from "@/types/clothing";
import { ClothingOutfit, ClothingTargetType } from "@/types/clothing";
export default function ClothingOutfitList() {
const familyId = useFamilyStore((s) => s.getFamilyId());
@@ -84,6 +85,10 @@ export default function ClothingOutfitList() {
const [activeItem, setActiveItem] = useState<ClothingOutfit | null>(null);
const [menuPos, setMenuPos] = useState({ x: 0, y: 0 });
const [deleteConfirm, setDeleteConfirm] = useState<ClothingOutfit | null>(null);
const [keyword, setKeyword] = useState("");
const [showFilterSheet, setShowFilterSheet] = useState(false);
const [filterCategoryId, setFilterCategoryId] = useState<number | null>(null);
const [filterTagIds, setFilterTagIds] = useState<number[]>([]);
const handleDeleteRequest = useCallback(() => {
if (!activeItem) return;
@@ -139,21 +144,55 @@ export default function ClothingOutfitList() {
[outfitClothes],
);
const filteredOutfits = useMemo(() => {
const kw = keyword.trim().toLowerCase();
return outfits.filter((item) => {
if (filterCategoryId && item.category_id !== filterCategoryId) return false;
if (filterTagIds.length > 0) {
const itemTagIds = outfitTagIds.get(item.id);
if (!itemTagIds) return false;
if (!filterTagIds.every((tid) => itemTagIds.includes(tid))) return false;
}
if (!kw) return true;
return item.name.toLowerCase().includes(kw);
});
}, [outfits, keyword, filterCategoryId, filterTagIds, outfitTagIds]);
const activeFilterCount = useMemo(() => {
let c = 0;
if (filterCategoryId) c++;
if (filterTagIds.length > 0) c++;
return c;
}, [filterCategoryId, filterTagIds]);
const renderHeader = useCallback(
() => (
<View className="gap-3 pb-4">
<View className="flex-row gap-2">
<Button className="flex-1" onPress={() => router.push("/clothes/outfit/edit" as any)}>
<PlusIcon size={18} color="#fff" />
<Button.Label>新增搭配</Button.Label>
</Button>
<Button variant="secondary" isIconOnly onPress={handleRefresh}>
<RotateCcwIcon size={18} color="#374151" />
</Button>
<View className="flex-row items-center pb-4">
<Text className="text-xs text-gray-400 flex-1">
共 {outfits.length} 套搭配
</Text>
<View className="flex-row gap-2 items-center">
<TouchableOpacity
className="h-10 px-3 rounded-full bg-accent flex-row items-center gap-1.5"
activeOpacity={0.8}
onPress={() => setShowFilterSheet(true)}
>
<FilterIcon size={16} color="#fff" />
<Text className="text-sm font-medium text-white">
搜索{activeFilterCount > 0 ? ` ${activeFilterCount}` : ""}
</Text>
</TouchableOpacity>
<TouchableOpacity
className="h-10 w-10 rounded-full bg-gray-100 items-center justify-center"
activeOpacity={0.75}
onPress={handleRefresh}
>
<RotateCcwIcon size={18} color="#4B5563" />
</TouchableOpacity>
</View>
</View>
),
[handleRefresh],
[activeFilterCount, handleRefresh, outfits.length],
);
const renderItem = useCallback(
@@ -164,7 +203,7 @@ export default function ClothingOutfitList() {
return (
<TouchableOpacity
className="mb-3 flex-1"
className="bg-white rounded-xl mb-2 flex-row overflow-hidden"
activeOpacity={0.82}
delayLongPress={260}
onPress={() => router.push(`/clothes/outfit/detail/${item.id}` as any)}
@@ -173,33 +212,24 @@ export default function ClothingOutfitList() {
setActiveItem(item);
}}
>
<Card className="overflow-hidden p-0">
<View className="w-full bg-gray-50" style={{ aspectRatio: 1 }}>
<ClothingImage fileKey={getCoverKey(item)} label={item.name} className="w-full h-full" resizeMode="contain" />
</View>
<Card.Body className="px-3 pt-3 pb-2">
<Card.Title className="text-sm" numberOfLines={1}>
{item.name}
</Card.Title>
<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>
<View className="w-24" style={{ aspectRatio: 3 / 4 }}>
<ClothingImage fileKey={getCoverKey(item)} label={item.name} className="w-full h-full bg-gray-50" resizeMode="contain" />
</View>
<View className="flex-1 px-3 py-3 justify-center">
<Text className="text-[15px] font-semibold text-gray-900" numberOfLines={1}>{item.name}</Text>
<Text className="text-xs text-gray-400 mt-0.5">
{getClothesCount(item)} 件衣服{categoryName ? ` · ${categoryName}` : ""}
</Text>
{itemTags.length > 0 && (
<View className="flex-row gap-1 mt-1.5">
{itemTags.slice(0, 3).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>
)}
</View>
</TouchableOpacity>
);
},
@@ -209,18 +239,16 @@ export default function ClothingOutfitList() {
return (
<LayoutView>
<LayoutView.FlatList
data={outfits}
data={filteredOutfits}
keyExtractor={(item) => String(item.id)}
renderItem={renderItem}
numColumns={2}
columnWrapperStyle={{ gap: 12 }}
header={renderHeader()}
refreshing={refreshing}
onRefresh={handleRefresh}
showsVerticalScrollIndicator={false}
contentContainerStyle={[
{ paddingHorizontal: 16, paddingBottom: 120 },
outfits.length === 0 ? { flexGrow: 1 } : undefined,
filteredOutfits.length === 0 ? { flexGrow: 1 } : undefined,
]}
ListEmptyComponent={() => (
<EmptyState
@@ -228,18 +256,99 @@ export default function ClothingOutfitList() {
title={loading ? "正在加载搭配" : "暂无搭配"}
description={loading ? "请稍候" : "创建第一套搭配后,可以在这里查看和编辑"}
center="screen"
footer={
!loading ? (
<Button className="mt-4" onPress={() => router.push("/clothes/outfit/edit" as any)}>
<SparklesIcon size={18} color="#fff" />
<Button.Label>新增搭配</Button.Label>
</Button>
) : undefined
}
/>
)}
/>
<BottomSheet isOpen={showFilterSheet} onOpenChange={setShowFilterSheet}>
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content animateOnMount={false} className="gap-4">
<View className="flex-row items-center">
<View className="flex-1" />
<Text className="text-center text-lg font-semibold text-gray-900">搜索搭配</Text>
<View className="flex-1 items-end">
<TouchableOpacity
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
onPress={() => { setFilterCategoryId(null); setFilterTagIds([]); setKeyword(""); }}
>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
</View>
</View>
<TextField>
<Label className="mb-2">关键词</Label>
<BottomSheet.Input
value={keyword}
onChangeText={setKeyword}
placeholder="搜索搭配名称"
returnKeyType="search"
/>
</TextField>
<View>
<Label className="mb-2">分类</Label>
<View className="flex-row flex-wrap gap-2">
<TouchableOpacity
className={`rounded-full border px-3 py-2 ${filterCategoryId == null ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`}
onPress={() => setFilterCategoryId(null)}
>
<Text className={`text-xs ${filterCategoryId == null ? "text-white" : "text-gray-700"}`}>全部</Text>
</TouchableOpacity>
{categories.filter(c => c.type === ClothingTargetType.Outfit).map(c => {
const selected = filterCategoryId === c.id;
return (
<TouchableOpacity
key={c.id}
className={`rounded-full border px-3 py-2 ${selected ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`}
onPress={() => setFilterCategoryId(selected ? null : c.id)}
>
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>{c.name}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View>
<Label className="mb-2">标签</Label>
{tags.filter(t => t.type === ClothingTargetType.Outfit).length > 0 ? (
<View className="flex-row flex-wrap gap-2">
{tags.filter(t => t.type === ClothingTargetType.Outfit).map(t => {
const selected = filterTagIds.includes(t.id);
return (
<TouchableOpacity
key={t.id}
className={`rounded-full border px-3 py-2 ${selected ? "border-gray-900 bg-gray-900" : "border-gray-200 bg-white"}`}
onPress={() => setFilterTagIds(prev => selected ? prev.filter(id => id !== t.id) : [...prev, t.id])}
>
<Text className={`text-xs ${selected ? "text-white" : "text-gray-700"}`}>{t.name}</Text>
</TouchableOpacity>
);
})}
</View>
) : (
<Text className="text-sm text-gray-500">暂无标签</Text>
)}
</View>
<Button className="w-full" onPress={() => setShowFilterSheet(false)}>
<Button.Label>确定</Button.Label>
</Button>
</BottomSheet.Content>
</BottomSheet.Portal>
</BottomSheet>
<TouchableOpacity
className="absolute bottom-20 right-5 w-14 h-14 rounded-full bg-accent items-center justify-center shadow-lg z-50"
activeOpacity={0.85}
onPress={() => router.push("/clothes/outfit/edit" as any)}
>
<PlusIcon size={24} color="#fff" />
</TouchableOpacity>
<Modal
visible={!!activeItem}
transparent
@@ -6,8 +6,6 @@ import { Button, Card, Input, Label, TextArea, TextField } from "heroui-native";
import {
BringToFrontIcon,
ImageIcon,
Maximize2Icon,
Minimize2Icon,
MinusIcon,
PlusIcon,
SaveIcon,
@@ -174,7 +172,6 @@ export default function ClothingOutfitEdit() {
const [pageLoading, setPageLoading] = useState(true);
const [capturingCover, setCapturingCover] = useState(false);
const [showClothesSheet, setShowClothesSheet] = useState(false);
const [canvasFullscreen, setCanvasFullscreen] = useState(false);
const clothesById = useMemo(() => new Map(clothes.map((item) => [item.id, item])), [clothes]);
const outfitCategories = useMemo(
@@ -620,7 +617,7 @@ export default function ClothingOutfitEdit() {
ref={canvasRef}
collapsable={false}
className="w-full flex-1"
style={{ aspectRatio: canvasFullscreen ? undefined : 1 }}
style={{ aspectRatio: 3 / 4 }}
onLayout={(event) => setCanvasSize(event.nativeEvent.layout.width)}
>
{items.length === 0 && (
@@ -715,18 +712,12 @@ export default function ClothingOutfitEdit() {
<Button variant="danger-soft" size="sm" isDisabled={!selectedItem} onPress={handleDeleteSelected}>
<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>
)}
<Card className="mb-4 overflow-hidden p-0">
{canvasContent}
</Card>
<Button className="mb-4" variant="secondary" onPress={() => setShowClothesSheet(true)}>
<PlusIcon size={18} color="#374151" />
@@ -779,25 +770,6 @@ export default function ClothingOutfitEdit() {
</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} />
</PageLayout>
);
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Text, TouchableOpacity, View } from "react-native";
import { Dimensions, ScrollView, Text, TouchableOpacity, View } from "react-native";
import dayjs from "dayjs";
/* ============== 饼图颜色 ============== */
@@ -632,171 +632,222 @@ export default function FinanceAnalysis() {
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content animateOnMount={false} className="gap-4">
<View className="flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">筛选分析</Text>
<TouchableOpacity
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
onPress={handleResetFilter}
>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
</View>
<View>
<Label className="mb-2">日期范围</Label>
<TouchableOpacity
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
activeOpacity={0.75}
onPress={() => setShowDateRange(true)}
>
<Text className="text-sm text-gray-900">
{draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate}
</Text>
<CalendarDaysIcon size={18} color="#6B7280" />
</TouchableOpacity>
</View>
<View>
<Label className="mb-2">收支类型</Label>
<View className="flex-row gap-2">
{[
{ key: "all" as const, label: "全部" },
{ key: FinanceType.Expense, label: "支出" },
{ key: FinanceType.Income, label: "收入" },
].map((option) => {
const selected = draftFilter.type === option.key;
return (
<TouchableOpacity
key={String(option.key)}
className={`flex-1 h-10 rounded-full items-center justify-center border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
type: option.key as FilterState["type"],
}))
}
>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View>
<Label className="mb-2">成员</Label>
<View className="flex-row flex-wrap gap-2">
<View className="flex-row items-center">
<View className="flex-1" />
<Text className="text-center text-lg font-semibold text-gray-900">筛选分析</Text>
<View className="flex-1 items-end">
<TouchableOpacity
className={`h-9 px-3 rounded-full items-center justify-center border ${
draftFilter.memberId == null
? "bg-accent/10 border-accent"
: "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() => setDraftFilter((prev) => ({ ...prev, memberId: null }))}
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
onPress={handleResetFilter}
>
<Text
className={`text-sm ${draftFilter.memberId == null ? "text-accent font-semibold" : "text-gray-700"}`}
>
全部
</Text>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
{members.map((member) => {
const selected = draftFilter.memberId === member.id;
return (
<TouchableOpacity
key={member.id}
className={`h-9 px-3 rounded-full items-center justify-center border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
memberId: prev.memberId === member.id ? null : member.id,
}))
}
>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{member.nickname}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View>
<Label className="mb-2">分类</Label>
<View className="flex-row flex-wrap gap-2">
<ScrollView
showsVerticalScrollIndicator={false}
style={{ maxHeight: Dimensions.get("window").height * 0.55 }}
contentContainerStyle={{ gap: 16, paddingBottom: 8 }}
>
<View>
<Label className="mb-2">日期范围</Label>
<TouchableOpacity
className={`h-9 px-3 rounded-full items-center justify-center border ${
draftFilter.categoryId == null
? "bg-accent/10 border-accent"
: "bg-white border-gray-200"
}`}
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
activeOpacity={0.75}
onPress={() => setDraftFilter((prev) => ({ ...prev, categoryId: null }))}
onPress={() => setShowDateRange(true)}
>
<Text
className={`text-sm ${draftFilter.categoryId == null ? "text-accent font-semibold" : "text-gray-700"}`}
>
全部
<Text className="text-sm text-gray-900">
{draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate}
</Text>
<CalendarDaysIcon size={18} color="#6B7280" />
</TouchableOpacity>
{categories.map((cat) => {
const selected = draftFilter.categoryId === cat.id;
return (
<TouchableOpacity
key={cat.id}
className={`h-9 px-3 rounded-full flex-row items-center gap-1.5 border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
categoryId: prev.categoryId === cat.id ? null : cat.id,
}))
}
>
<FinanceCategoryIcon
fileKey={cat.icon_file_key}
label={cat.name}
size={16}
/>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{cat.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<TextField>
<Label className="mb-2">备注关键词</Label>
<BottomSheet.Input
value={draftFilter.keyword}
onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))}
placeholder="按备注模糊搜索"
returnKeyType="search"
/>
</TextField>
<View>
<Label className="mb-2">收支类型</Label>
<View className="flex-row gap-2">
{[
{ key: "all" as const, label: "全部" },
{ key: FinanceType.Expense, label: "支出" },
{ key: FinanceType.Income, label: "收入" },
].map((option) => {
const selected = draftFilter.type === option.key;
return (
<TouchableOpacity
key={String(option.key)}
className={`flex-1 h-10 rounded-full items-center justify-center border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
type: option.key as FilterState["type"],
}))
}
>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{option.label}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View className="flex-row gap-3 pt-2">
<View>
<Label className="mb-2">成员</Label>
<View className="flex-row flex-wrap gap-2">
<TouchableOpacity
className={`h-9 px-3 rounded-full items-center justify-center border ${
draftFilter.memberId == null
? "bg-accent/10 border-accent"
: "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() => setDraftFilter((prev) => ({ ...prev, memberId: null }))}
>
<Text
className={`text-sm ${draftFilter.memberId == null ? "text-accent font-semibold" : "text-gray-700"}`}
>
全部
</Text>
</TouchableOpacity>
{members.map((member) => {
const selected = draftFilter.memberId === member.id;
return (
<TouchableOpacity
key={member.id}
className={`h-9 px-3 rounded-full items-center justify-center border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
memberId: prev.memberId === member.id ? null : member.id,
}))
}
>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{member.nickname}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View>
<Label className="mb-2">分类</Label>
{/* 全部 */}
<View className="flex-row flex-wrap gap-2 mb-2">
<TouchableOpacity
className={`h-9 px-3 rounded-full items-center justify-center border ${
draftFilter.categoryId == null
? "bg-accent/10 border-accent"
: "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() => setDraftFilter((prev) => ({ ...prev, categoryId: null }))}
>
<Text
className={`text-sm ${draftFilter.categoryId == null ? "text-accent font-semibold" : "text-gray-700"}`}
>
全部
</Text>
</TouchableOpacity>
</View>
{/* 支出 */}
<Text className="text-xs text-gray-400 mb-1.5 ml-0.5">支出</Text>
<View className="flex-row flex-wrap gap-2 mb-3">
{categories
.filter((c) => c.type === FinanceType.Expense)
.map((cat) => {
const selected = draftFilter.categoryId === cat.id;
return (
<TouchableOpacity
key={cat.id}
className={`h-9 px-3 rounded-full flex-row items-center gap-1.5 border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
categoryId: prev.categoryId === cat.id ? null : cat.id,
}))
}
>
<FinanceCategoryIcon
fileKey={cat.icon_file_key}
label={cat.name}
size={16}
/>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{cat.name}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* 收入 */}
<Text className="text-xs text-gray-400 mb-1.5 ml-0.5">收入</Text>
<View className="flex-row flex-wrap gap-2">
{categories
.filter((c) => c.type === FinanceType.Income)
.map((cat) => {
const selected = draftFilter.categoryId === cat.id;
return (
<TouchableOpacity
key={cat.id}
className={`h-9 px-3 rounded-full flex-row items-center gap-1.5 border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
categoryId: prev.categoryId === cat.id ? null : cat.id,
}))
}
>
<FinanceCategoryIcon
fileKey={cat.icon_file_key}
label={cat.name}
size={16}
/>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{cat.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<TextField>
<Label className="mb-2">备注关键词</Label>
<BottomSheet.Input
value={draftFilter.keyword}
onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))}
placeholder="按备注模糊搜索"
returnKeyType="search"
/>
</TextField>
</ScrollView>
<View className="flex-row gap-3">
<Button
variant="secondary"
className="flex-1"
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Image, Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { Dimensions, Image, Modal, Pressable, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { Button, Label, TextField } from "heroui-native";
import BottomSheet from "@/components/BottomSheet";
import { router, useFocusEffect } from "expo-router";
@@ -10,7 +10,6 @@ import {
FilterIcon,
PencilIcon,
RotateCcwIcon,
SearchIcon,
Trash2Icon,
} from "lucide-react-native";
import dayjs from "dayjs";
@@ -427,33 +426,6 @@ export default function Home() {
[userId],
);
const renderCategoryOption = useCallback(
(item: FinanceCategory) => {
const selected = draftFilter.categoryId === item.id;
return (
<TouchableOpacity
key={item.id}
className={`w-[31%] rounded-xl px-2 py-3 items-center border ${
selected ? "border-gray-900 bg-gray-100" : "border-gray-100 bg-white"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
categoryId: prev.categoryId === item.id ? null : item.id,
}))
}
>
<FinanceCategoryIcon fileKey={item.icon_file_key} label={item.name} size={28} />
<Text className="mt-1.5 text-xs text-gray-700" numberOfLines={1}>
{item.name}
</Text>
</TouchableOpacity>
);
},
[draftFilter.categoryId],
);
return (
<LayoutView>
@@ -485,73 +457,143 @@ export default function Home() {
<BottomSheet.Portal>
<BottomSheet.Overlay />
<BottomSheet.Content animateOnMount={false} className="gap-4">
<View className="flex-row items-center justify-between">
<Text className="text-lg font-semibold text-gray-900">筛选记账</Text>
<TouchableOpacity
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
onPress={handleResetFilter}
>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
<View className="flex-row items-center">
<View className="flex-1" />
<Text className="text-center text-lg font-semibold text-gray-900">筛选记账</Text>
<View className="flex-1 items-end">
<TouchableOpacity
className="h-9 px-3 rounded-full bg-gray-100 flex-row items-center gap-1"
onPress={handleResetFilter}
>
<RotateCcwIcon size={14} color="#4B5563" />
<Text className="text-xs text-gray-600">重置</Text>
</TouchableOpacity>
</View>
</View>
<View>
<Label className="mb-2">日期范围</Label>
<TouchableOpacity
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
activeOpacity={0.75}
onPress={() => setShowDateRange(true)}
>
<Text className="text-sm text-gray-900">
{draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate}
</Text>
<CalendarDaysIcon size={18} color="#6B7280" />
</TouchableOpacity>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
style={{ maxHeight: Dimensions.get("window").height * 0.55 }}
contentContainerStyle={{ gap: 16, paddingBottom: 8 }}
>
<View>
<Label className="mb-2">日期范围</Label>
<TouchableOpacity
className="h-12 rounded-xl bg-gray-50 px-3 flex-row items-center justify-between"
activeOpacity={0.75}
onPress={() => setShowDateRange(true)}
>
<Text className="text-sm text-gray-900">
{draftFilter.startDate} 至 {draftFilter.endDate || draftFilter.startDate}
</Text>
<CalendarDaysIcon size={18} color="#6B7280" />
</TouchableOpacity>
</View>
<TextField>
<Label className="mb-2">备注关键词</Label>
<BottomSheet.Input
value={draftFilter.keyword}
onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))}
placeholder="按备注模糊搜索"
returnKeyType="search"
/>
</TextField>
<TextField>
<Label className="mb-2">备注关键词</Label>
<BottomSheet.Input
value={draftFilter.keyword}
onChangeText={(keyword) => setDraftFilter((prev) => ({ ...prev, keyword }))}
placeholder="按备注模糊搜索"
returnKeyType="search"
/>
</TextField>
<View>
<Label className="mb-2">分类</Label>
<ScrollView
style={{ maxHeight: 240 }}
showsVerticalScrollIndicator={false}
>
{/* 支出 */}
<Text className="text-xs text-gray-400 mb-1.5 ml-0.5">支出</Text>
<View className="flex-row flex-wrap gap-2 mb-3">
<View>
<Label className="mb-2">分类</Label>
{/* 全部 */}
<View className="flex-row flex-wrap gap-2 mb-2">
<TouchableOpacity
className={`w-[31%] rounded-xl px-2 py-3 items-center border ${
className={`h-9 px-3 rounded-full items-center justify-center border ${
draftFilter.categoryId == null
? "border-gray-900 bg-gray-100"
: "border-gray-100 bg-white"
? "bg-accent/10 border-accent"
: "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() => setDraftFilter((prev) => ({ ...prev, categoryId: null }))}
>
<SearchIcon size={24} color="#6B7280" />
<Text className="mt-1.5 text-xs text-gray-700">全部</Text>
<Text
className={`text-sm ${draftFilter.categoryId == null ? "text-accent font-semibold" : "text-gray-700"}`}
>
全部
</Text>
</TouchableOpacity>
{categories.filter(c => c.type === FinanceType.Expense).map(renderCategoryOption)}
</View>
{/* 支出 */}
<Text className="text-xs text-gray-400 mb-1.5 ml-0.5">支出</Text>
<View className="flex-row flex-wrap gap-2 mb-3">
{categories
.filter((c) => c.type === FinanceType.Expense)
.map((cat) => {
const selected = draftFilter.categoryId === cat.id;
return (
<TouchableOpacity
key={cat.id}
className={`h-9 px-3 rounded-full flex-row items-center gap-1.5 border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
categoryId: prev.categoryId === cat.id ? null : cat.id,
}))
}
>
<FinanceCategoryIcon
fileKey={cat.icon_file_key}
label={cat.name}
size={16}
/>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{cat.name}
</Text>
</TouchableOpacity>
);
})}
</View>
{/* 收入 */}
<Text className="text-xs text-gray-400 mb-1.5 ml-0.5">收入</Text>
<View className="flex-row flex-wrap gap-2">
{categories.filter(c => c.type === FinanceType.Income).map(renderCategoryOption)}
{categories
.filter((c) => c.type === FinanceType.Income)
.map((cat) => {
const selected = draftFilter.categoryId === cat.id;
return (
<TouchableOpacity
key={cat.id}
className={`h-9 px-3 rounded-full flex-row items-center gap-1.5 border ${
selected ? "bg-accent/10 border-accent" : "bg-white border-gray-200"
}`}
activeOpacity={0.75}
onPress={() =>
setDraftFilter((prev) => ({
...prev,
categoryId: prev.categoryId === cat.id ? null : cat.id,
}))
}
>
<FinanceCategoryIcon
fileKey={cat.icon_file_key}
label={cat.name}
size={16}
/>
<Text
className={`text-sm ${selected ? "text-accent font-semibold" : "text-gray-700"}`}
>
{cat.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</ScrollView>
</View>
</View>
</ScrollView>
<View className="flex-row gap-3 pt-2">
<View className="flex-row gap-3">
<Button
variant="secondary"
className="flex-1"
@@ -148,15 +148,15 @@ function AppBottomSheetRoot({
const adjustForKeyboard = useCallback((kbHeight: number) => {
const input = focusedInputRef.current;
if (!input) {
keyboardY.setValue(-kbHeight);
keyboardY.setValue(-Math.min(kbHeight, 0));
return;
}
setTimeout(() => {
input.measureInWindow((x, y, w, h) => {
const screenBottom = SCREEN_H - kbHeight;
const inputBottom = y + h;
const needed = inputBottom - screenBottom - 18;
keyboardY.setValue(needed > 0 ? -needed : 0);
const needed = inputBottom - screenBottom;
keyboardY.setValue(needed > 0 ? -(needed - 16) : 0);
});
}, 50);
}, []);
@@ -279,7 +279,7 @@ const AppBottomSheetInput = forwardRef<TextInput, ComponentProps<typeof Input>>(
);
AppBottomSheetInput.displayName = "AppBottomSheetInput";
function AppBottomSheetTitle(props: TextProps) { return <Text {...props} />; }
function AppBottomSheetTitle(props: TextProps) { return <Text className="text-center text-base font-semibold" {...props} />; }
function AppBottomSheetDescription(props: TextProps) { return <Text {...props} />; }
const BottomSheet = Object.assign(AppBottomSheetRoot, {