Appearance
Image Polling + Draft Redirect Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: После upload фото — поллить статус обработки. После создания черновика на /new — redirect на /edit/{id} с передачей состояния через Pinia (без мерцания).
Architecture: Новый Pinia store draftTransfer для передачи form state при redirect. Polling в useImageUpload через setInterval с GET /vendor/products/{id}. Redirect логика в ProductFormPage.vue.
Tech Stack: Vue 3.5, Pinia, Nuxt 4, Vitest + @nuxt/test-utils
Task 1: Исправить imageStatusSchema (error → failed)
Бэкенд возвращает 'failed', а не 'error'. Нужно привести в соответствие.
Files:
- Modify:
app/entities/product/model/product.schema.ts:16
Step 1: Проверить текущую schema
В product.schema.ts:16:
ts
export const imageStatusSchema = z.enum(['processing', 'ready', 'error'])Бэкенд отдаёт 'failed' (из ProductImage.php). Нужно заменить 'error' на 'failed'.
Step 2: Обновить schema
В product.schema.ts:16 заменить:
ts
export const imageStatusSchema = z.enum(['processing', 'ready', 'failed'])Step 3: Обновить ImageUploader.vue
В ImageUploader.vue:64 проверяется image.status === 'processing' — это ок, не меняем.
Step 4: Запустить typecheck
Run: npm run typecheck Expected: PASS (тип ImageStatus обновится автоматически через z.infer)
Step 5: Коммит
bash
git add app/entities/product/model/product.schema.ts
git commit -m "fix(product): imageStatusSchema 'error' → 'failed' to match backend"Task 2: Создать useDraftTransferStore
Pinia store для передачи состояния формы при redirect с /new на /edit/{id}.
Files:
- Create:
app/stores/draftTransfer.ts - Create:
app/stores/draftTransfer.test.ts
Step 1: Написать тест
Файл app/stores/draftTransfer.test.ts:
ts
import { describe, it, expect, beforeEach } from 'vitest'
const { useDraftTransferStore } = await import('./draftTransfer')
describe('useDraftTransferStore', () => {
beforeEach(() => {
const store = useDraftTransferStore()
store.$reset()
})
it('starts empty', () => {
const store = useDraftTransferStore()
expect(store.hasTransfer).toBe(false)
expect(store.productId).toBeNull()
})
it('save() stores form data and productId', () => {
const store = useDraftTransferStore()
const formSnapshot = { title: 'Test', price: 100 }
const images = [{ id: 1, status: 'ready' }]
store.save(formSnapshot as any, images as any, [], 42)
expect(store.hasTransfer).toBe(true)
expect(store.productId).toBe(42)
expect(store.formSnapshot).toEqual(formSnapshot)
expect(store.images).toEqual(images)
})
it('save() stores pending files', () => {
const store = useDraftTransferStore()
const file = new File(['test'], 'photo.jpg', { type: 'image/jpeg' })
store.save({} as any, [], [file], 42)
expect(store.pendingFiles).toHaveLength(1)
expect(store.pendingFiles[0]!.name).toBe('photo.jpg')
})
it('take() returns data and clears store', () => {
const store = useDraftTransferStore()
store.save({ title: 'Test' } as any, [], [], 42)
const result = store.take()
expect(result).not.toBeNull()
expect(result!.productId).toBe(42)
expect(result!.formSnapshot).toEqual({ title: 'Test' })
// Store cleared
expect(store.hasTransfer).toBe(false)
expect(store.productId).toBeNull()
})
it('take() returns null when empty', () => {
const store = useDraftTransferStore()
expect(store.take()).toBeNull()
})
})Step 2: Запустить тест — убедиться что FAIL
Run: npm run test:run -- app/stores/draftTransfer.test.ts Expected: FAIL — модуль не существует
Step 3: Реализовать store
Файл app/stores/draftTransfer.ts:
ts
import type { ProductImage } from '~/entities/product/model/product.schema'
interface DraftTransferState {
formSnapshot: Record<string, unknown> | null
images: ProductImage[]
pendingFiles: File[]
productId: number | null
}
export const useDraftTransferStore = defineStore('draftTransfer', {
state: (): DraftTransferState => ({
formSnapshot: null,
images: [],
pendingFiles: [],
productId: null,
}),
getters: {
hasTransfer: (state) => state.productId !== null,
},
actions: {
save(
formSnapshot: Record<string, unknown>,
images: ProductImage[],
pendingFiles: File[],
productId: number,
) {
this.formSnapshot = formSnapshot
this.images = images
this.pendingFiles = pendingFiles
this.productId = productId
},
take() {
if (!this.hasTransfer) return null
const data = {
formSnapshot: this.formSnapshot!,
images: [...this.images],
pendingFiles: [...this.pendingFiles],
productId: this.productId!,
}
this.$reset()
return data
},
},
})Step 4: Запустить тест — убедиться что PASS
Run: npm run test:run -- app/stores/draftTransfer.test.ts Expected: PASS
Step 5: Коммит
bash
git add app/stores/draftTransfer.ts app/stores/draftTransfer.test.ts
git commit -m "feat(stores): add useDraftTransferStore for form state transfer on redirect"Task 3: Добавить polling в useImageUpload
После upload, если status === 'processing', поллить GET /vendor/products/{productId} каждые 2.5 сек.
Files:
- Modify:
app/features/image-upload/composables/useImageUpload.ts
Step 1: Добавить polling логику
В useImageUpload.ts после строки 22 (const uploading = ref(false)) добавить:
ts
const POLL_INTERVAL = 2500
const POLL_MAX_ATTEMPTS = 24
let pollTimer: ReturnType<typeof setInterval> | null = null
let pollAttempts = 0Step 2: Добавить функцию hasProcessingImages
После setServerImages (строка 26):
ts
function hasProcessingImages(): boolean {
return images.value.some(img => img.status === 'processing')
}Step 3: Добавить функцию pollImageStatuses
ts
async function pollImageStatuses() {
if (!productId.value) return
try {
const response = await api.get<ApiItemResponse<{ images: ProductImage[] }>>(
`/vendor/products/${productId.value}`,
)
const serverImages = (response.data as unknown as { images: ProductImage[] }).images ?? []
// Обновить только изменившиеся
images.value = images.value.map(img => {
const updated = serverImages.find(s => s.id === img.id)
return updated && updated.status !== img.status ? updated : img
})
} catch {
// Игнорируем ошибки polling — тихий retry
}
}Step 4: Добавить startPolling/stopPolling
ts
function startPolling() {
if (pollTimer) return
pollAttempts = 0
pollTimer = setInterval(async () => {
pollAttempts++
if (!hasProcessingImages() || pollAttempts >= POLL_MAX_ATTEMPTS) {
stopPolling()
return
}
await pollImageStatuses()
if (!hasProcessingImages()) {
stopPolling()
}
}, POLL_INTERVAL)
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}Step 5: Вызывать startPolling после успешного upload
В addFiles (строка 82-84), после images.value = [...images.value, serverImage]:
ts
if (serverImage) {
images.value = [...images.value, serverImage]
localImages.value = localImages.value.filter(l => l.id !== localId)
if (serverImage.status === 'processing') {
startPolling()
}
}Step 6: Cleanup на unmount
В useImageUpload, в самом конце перед return:
ts
onUnmounted(() => {
stopPolling()
})Добавить импорт onUnmounted в строку 1:
ts
import { ref, readonly, onUnmounted, type Ref } from 'vue'Step 7: Экспортировать startPolling для гидрации
В return добавить startPolling:
ts
return {
// ... existing exports
startPolling,
}Step 8: Импортировать ProductImage в pollImageStatuses
Тип ProductImage уже импортирован (строка 5). Также нужен тип ProductDetail:
Заменить строку 5:
ts
import type { ProductImage, ProductDetail } from '~/entities/product/model/product.schema'И в pollImageStatuses использовать ProductDetail:
ts
const response = await api.get<ApiItemResponse<ProductDetail>>(
`/vendor/products/${productId.value}`,
)
const serverImages = response.data.images ?? []Step 9: Запустить typecheck
Run: npm run typecheck Expected: PASS
Step 10: Коммит
bash
git add app/features/image-upload/composables/useImageUpload.ts
git commit -m "feat(image-upload): poll image processing status after upload"Task 4: Добавить redirect + гидрацию в ProductFormPage
После saveDraft() в create mode → сохранить в Pinia → redirect на /edit/{id}. На mount в edit mode → проверить Pinia → гидрация без загрузки.
Files:
- Modify:
app/features/product-form/ui/ProductFormPage.vue
Step 1: Добавить redirect в onSaveDraft
В ProductFormPage.vue, заменить onSaveDraft (строки 190-192):
ts
async function onSaveDraft() {
const id = await saveDraft()
if (id && props.mode === 'create') {
const transferStore = useDraftTransferStore()
transferStore.save(JSON.parse(JSON.stringify(form)), [...imageUpload.images.value], [], id)
skipGuard.value = true
await navigateTo(`/cabinet/products/${id}/edit`, { replace: true })
}
}Step 2: Добавить redirect в onAddFiles
Заменить onAddFiles (строки 112-118):
ts
async function onAddFiles(files: FileList) {
const fileArray = Array.from(files)
if (props.mode === 'create') {
const id = await ensureDraftForUpload()
if (id) {
const transferStore = useDraftTransferStore()
transferStore.save(JSON.parse(JSON.stringify(form)), [...imageUpload.images.value], fileArray, id)
skipGuard.value = true
await navigateTo(`/cabinet/products/${id}/edit`, { replace: true })
}
} else {
imageUpload.addFiles(fileArray)
}
}Step 3: Гидрация из Pinia на mount
В onMounted (строки 203-218), заменить блок if (props.mode === 'edit'):
ts
onMounted(async () => {
window.addEventListener('beforeunload', onBeforeUnload)
await loadCategories()
if (props.mode === 'edit' && props.productId) {
const transferStore = useDraftTransferStore()
const transfer = transferStore.take()
if (transfer && transfer.productId === props.productId) {
// Гидрация из Pinia — без запроса к серверу
Object.assign(form, transfer.formSnapshot)
imageUpload.setServerImages(transfer.images)
// Восстановить category chain
const chain = buildCategoryChain(form.category_ids ?? [], [...partCategories.value], 'part')
if (chain.length) {
selectedCategoryChain.value = chain.map((c) => c.id)
}
takeSnapshot()
// Загрузить pending files
if (transfer.pendingFiles.length) {
imageUpload.addFiles(transfer.pendingFiles)
}
// Запустить polling если есть processing images
if (transfer.images.some(img => img.status === 'processing')) {
imageUpload.startPolling()
}
} else {
// Обычная загрузка с сервера
const product = await loadProduct()
if (product) {
imageUpload.setServerImages(product.images ?? [])
const chain = buildCategoryChain(product.categories ?? [], [...partCategories.value], 'part')
if (chain.length) {
selectedCategoryChain.value = chain.map((c) => c.id)
}
// Запустить polling если есть processing images
if (product.images?.some(img => img.status === 'processing')) {
imageUpload.startPolling()
}
}
}
}
})Step 4: Запустить lint + typecheck
Run: npm run lint:fix && npm run typecheck Expected: PASS
Step 5: Коммит
bash
git add app/features/product-form/ui/ProductFormPage.vue
git commit -m "feat(product-form): redirect to edit after draft creation with Pinia state transfer"Task 5: Ручное тестирование + финальная проверка
Step 1: Запустить все тесты
Run: npm run test:run Expected: PASS
Step 2: Запустить typecheck
Run: npm run typecheck Expected: PASS
Step 3: Запустить lint
Run: npm run lint Expected: PASS
Step 4: Ручной тест-план (dev сервер)
- Открыть
/cabinet/products/new - Нажать "Сохранить черновик" → URL должен измениться на
/cabinet/products/{id}/edit - Форма не должна мерцать — данные сохранены
- Перезагрузить страницу → данные загружаются с сервера (edit mode)
- Добавить фото → фото загружается, спиннер отображается во время processing
- Через 2-5 сек спиннер исчезает, фото отображается с URL
- На странице
/newдобавить фото → redirect на edit, фото начинает грузиться - Перезагрузить → фото уже есть (загружено на сервер ранее)
Step 5: Финальный коммит (если были правки)
bash
git add -A
git commit -m "fix: adjustments after manual testing"