Конструктор образов — полный код¶
↑ Образы
OutfitBuilderPage¶
Файл: frontend/src/pages/OutfitBuilderPage.tsx
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { message } from 'antd';
import { Plus, X } from 'lucide-react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import PageHeader from '../components/layout/PageHeader';
import { outfitsApi } from '../api/outfits';
import { offlineOutfitsService } from '../offline/services/offlineOutfitsService';
import { findDuplicateOutfit } from '../offline/repositories/outfitsRepository';
import { useLocalItems } from '../offline/hooks/useLocalItems';
import { useLocalCategories, useLocalSlots } from '../offline/hooks/useLocalReference';
import { useLocalItem } from '../offline/hooks/useLocalItems';
import { useNetworkStatus } from '../offline/network/useNetworkStatus';
import { getItemImageUrl, handleImgError } from '../utils/media';
import { useOutfitBuilderStore } from '../stores/outfitBuilder';
import type { OutfitCreate, OutfitItemInput, Item } from '../types/api';
import { showAppConfirm } from '../utils/showAppConfirm';
import PrimaryButton from '../components/ui/PrimaryButton';
import s from './OutfitBuilderPage.module.css';
export default function OutfitBuilderPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const fromItemId = searchParams.get('fromItem')?.trim() || undefined;
const queryClient = useQueryClient();
const store = useOutfitBuilderStore();
const online = useNetworkStatus();
const [suggestedTitle, setSuggestedTitle] = useState('');
const lastAutoAppliedTitleRef = useRef<string>('');
const handledFromItemRef = useRef<string | null>(null);
const { data: slots, isLoading: slotsLoading } = useLocalSlots();
const { data: categories = [], isFetched: categoriesFetched } = useLocalCategories();
const { data: seedItem, isFetched: seedItemFetched, isLoading: seedItemLoading } = useLocalItem(fromItemId);
// Построение массива выбранных вещей для suggest-title
const suggestItems = useMemo(
() => store.slots
.filter((ss) => ss.item)
.map((ss) => ({ item_id: ss.item!.id, slot_id: ss.slot.id, layer_index: 0 })),
[store.slots],
);
// Инициализация слотов при загрузке (фильтруем dress)
useEffect(() => {
if (slots) store.initSlots(slots.filter((sl) => sl.code !== 'dress'));
}, [slots]);
// Обработка параметра fromItem (предзаполнение слота из WardrobeItemPage)
useEffect(() => {
if (!fromItemId) { handledFromItemRef.current = null; return; }
if (handledFromItemRef.current === fromItemId) return;
if (slotsLoading || store.slots.length === 0) return;
if (seedItemLoading || !seedItemFetched) return;
if (seedItem?.category_id != null && !categoriesFetched) return;
if (!seedItem) {
handledFromItemRef.current = fromItemId;
navigate('/outfits/build', { replace: true });
return;
}
// Определяем слот по категории вещи
const category = categories.find((c) => c.id === seedItem.category_id);
let slotId = category?.default_slot_id;
const allowed = new Set(store.slots.map((ss) => ss.slot.id));
if (slotId == null || !allowed.has(slotId)) slotId = store.slots[0]?.slot.id;
handledFromItemRef.current = fromItemId;
store.setSlotItem(slotId!, {
id: seedItem.id, title: seedItem.title,
media_id: seedItem.primary_media_id, media_url: seedItem.primary_media_url,
});
navigate('/outfits/build', { replace: true });
}, [fromItemId, slotsLoading, seedItem, seedItemFetched, seedItemLoading, categories, categoriesFetched, store.slots]);
// Автогенерация названия (debounce 500ms)
useEffect(() => {
if (!online || suggestItems.length === 0) { setSuggestedTitle(''); return; }
let cancelled = false;
const timerId = window.setTimeout(async () => {
try {
const response = await outfitsApi.suggestTitle({ outfit_items: suggestItems });
if (!cancelled) {
const nextSuggestion = response.title.trim();
setSuggestedTitle(nextSuggestion);
// Автоприменение если пользователь не менял вручную
const currentTitle = store.title.trim();
const lastAutoTitle = lastAutoAppliedTitleRef.current.trim();
const canAutoApply = currentTitle.length === 0 ||
(lastAutoTitle.length > 0 && currentTitle === lastAutoTitle);
if (canAutoApply) {
store.setTitle(nextSuggestion);
lastAutoAppliedTitleRef.current = nextSuggestion;
}
}
} catch { if (!cancelled) setSuggestedTitle(''); }
}, 500);
return () => { cancelled = true; window.clearTimeout(timerId); };
}, [online, JSON.stringify(suggestItems)]);
// Создание образа
const createMutation = useMutation({
mutationFn: (data: OutfitCreate) => offlineOutfitsService.create(data),
onSuccess: async () => {
message.success('Образ создан');
store.clearAll();
await queryClient.refetchQueries({ queryKey: ['outfits'] });
navigate('/outfits', { replace: true });
},
onError: () => message.error('Не удалось создать образ'),
});
const handleCreate = async () => {
const filledSlots = store.slots.filter((ss) => ss.item);
if (filledSlots.length === 0) {
message.warning('Добавьте хотя бы одну вещь');
return;
}
const items: OutfitItemInput[] = filledSlots.map((ss) => ({
item_id: ss.item!.id, slot_id: ss.slot.id, layer_index: 0,
}));
// Проверка на дубликат
const duplicateTitle = await findDuplicateOutfit(items);
if (duplicateTitle) {
message.warning(`Образ «${duplicateTitle}» уже содержит эти вещи`);
return;
}
createMutation.mutate({ title: store.title.trim() || undefined, outfit_items: items });
};
// Рендер: сцена (stage) + горизонтальные ряды вещей для каждого слота
return (
<>
<PageHeader title="Конструктор образа" showBack />
<div className={`${s.pageContent} ${s.builderPage}`}>
{/* Поле ввода названия с placeholder из AI-подсказки */}
<label className={s.titleField}>
<input type="text" value={store.title}
onChange={(e) => store.setTitle(e.target.value)}
placeholder={suggestedTitle || 'Название образа (необязательно)'} />
</label>
{/* Stage: визуальная сборка образа */}
<div className={s.stage}>
{store.slots.map((ss) => (
<div key={ss.slot.id} className={`${s.stageSlot} ${ss.item ? s.stageSlotFilled : ''}`}>
{ss.item ? (
<div className={s.stageSlotInner}>
<img src={getItemImageUrl(ss.item.media_id, ss.item.media_url)} alt={ss.item.title || ss.slot.name} />
<button onClick={() => store.clearSlot(ss.slot.id)}><X size={16} /></button>
</div>
) : (
<div className={s.stagePlaceholder}>{ss.slot.name}</div>
)}
</div>
))}
</div>
{/* Горизонтальные ряды вещей для каждого слота */}
{store.slots.map((ss) => (
<SlotSection key={ss.slot.id}
slotId={ss.slot.id} slotName={ss.slot.name} selectedItemId={ss.item?.id}
onSelectItem={(item) => store.setSlotItem(ss.slot.id, { id: item.id, title: item.title, media_id: item.primary_media_id, media_url: item.primary_media_url })}
onClearSlot={() => store.clearSlot(ss.slot.id)}
onViewAll={() => { store.startSelecting(); navigate(`/outfits/build/select?slotId=${ss.slot.id}`); }}
/>
))}
<PrimaryButton block onClick={handleCreate} loading={createMutation.isPending}>
Создать образ
</PrimaryButton>
</div>
</>
);
}
ItemSelectPage¶
Файл: frontend/src/pages/ItemSelectPage.tsx
Полноэкранная страница выбора вещи для конкретного слота.
import { Col, Row } from 'antd';
import { useNavigate, useSearchParams } from 'react-router-dom';
import ItemCard from '../components/common/ItemCard';
import PageHeader from '../components/layout/PageHeader';
import { useLocalItems } from '../offline/hooks/useLocalItems';
import { useLocalSlots } from '../offline/hooks/useLocalReference';
import { useOutfitBuilderStore } from '../stores/outfitBuilder';
import type { Item } from '../types/api';
import EmptyState from '../components/ui/EmptyState';
import PrimaryButton from '../components/ui/PrimaryButton';
import s from './ItemSelectPage.module.css';
export default function ItemSelectPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const slotParam = Number(searchParams.get('slotId') || 0);
const slotId = Number.isFinite(slotParam) && slotParam > 0 ? slotParam : undefined;
const store = useOutfitBuilderStore();
const { data: slots } = useLocalSlots();
const slotName = slots?.find((slot) => slot.id === slotId)?.name;
const selectedItemId = slotId
? store.slots.find((ss) => ss.slot.id === slotId)?.item?.id
: undefined;
// Загрузка вещей только для текущего слота
const { data, isLoading } = useLocalItems(slotId ? { slot_id: slotId } : {});
const items = data?.items ?? [];
const handleSelect = (item: Item) => {
if (!slotId) return;
// Повторный клик — снятие выбора
if (item.id === selectedItemId) {
store.clearSlot(slotId);
navigate(-1);
return;
}
// Установка вещи в слот
store.setSlotItem(slotId, {
id: item.id, title: item.title,
media_id: item.primary_media_id, media_url: item.primary_media_url,
});
navigate(-1); // Возврат в конструктор
};
return (
<>
<PageHeader title={slotName ? `Выбрать ${slotName.toLowerCase()}` : 'Выбор вещи'} showBack />
<div className={`${s.pageContent} ${s.itemSelectPage}`}>
{isLoading ? /* Skeleton */ null : items.length === 0 ? (
<EmptyState title="Нет вещей для этого слота"
action={<PrimaryButton block onClick={() => navigate('/add')}>Добавить вещь</PrimaryButton>} />
) : (
<Row gutter={[12, 12]}>
{items.map((item) => (
<Col xs={12} sm={8} md={6} key={item.id}>
<ItemCard item={item}
meta={item.id === selectedItemId ? 'Нажмите ещё раз, чтобы снять' : undefined}
onClick={handleSelect} />
</Col>
))}
</Row>
)}
</div>
</>
);
}
outfitBuilderStore (Zustand)¶
Файл: frontend/src/stores/outfitBuilder.ts
import { create } from 'zustand';
import type { Slot } from '../types/api';
export interface BuilderItem {
id: string;
title?: string;
media_id?: string;
media_url?: string;
}
export interface BuilderSlotState {
slot: Slot;
item?: BuilderItem;
}
interface OutfitBuilderStore {
slots: BuilderSlotState[];
title: string;
isSelecting: boolean; // Флаг: идёт навигация на ItemSelectPage
initSlots: (slots: Slot[]) => void;
setTitle: (title: string) => void;
startSelecting: () => void; // Предотвращает сброс слотов при возврате
setSlotItem: (slotId: number, item: BuilderItem) => void;
clearSlot: (slotId: number) => void;
clearAll: () => void;
}
export const useOutfitBuilderStore = create<OutfitBuilderStore>((set, get) => ({
slots: [],
title: '',
isSelecting: false,
initSlots: (slots) => {
const { isSelecting } = get();
// Если пользователь возвращается из выбора — не сбрасывать слоты
if (isSelecting) {
set({ isSelecting: false });
return;
}
set({
slots: slots
.filter((s) => s.min_layers > 0)
.sort((a, b) => a.order_index - b.order_index)
.map((slot) => ({ slot })),
});
},
startSelecting: () => set({ isSelecting: true }),
setTitle: (title) => set({ title }),
setSlotItem: (slotId, item) =>
set((state) => ({
slots: state.slots.map((ss) =>
ss.slot.id === slotId ? { ...ss, item } : ss,
),
})),
clearSlot: (slotId) =>
set((state) => ({
slots: state.slots.map((ss) =>
ss.slot.id === slotId ? { ...ss, item: undefined } : ss,
),
})),
clearAll: () =>
set((state) => ({
title: '',
slots: state.slots.map((ss) => ({ ...ss, item: undefined })),
})),
}));
Сохранение состояния при навигации¶
Проблема: при переходе на ItemSelectPage и обратно React заново маунтит OutfitBuilderPage, вызывая initSlots и сбрасывая выбранные вещи.
Решение: флаг isSelecting:
1. Перед навигацией: store.startSelecting() — устанавливает isSelecting = true
2. При возврате: initSlots() видит isSelecting === true → только сбрасывает флаг, не трогает слоты