import { ref, reactive, computed } from 'vue' import { listTaskImages, createTaskImage, deleteTaskImage, getTaskImageContent, } from '@/api/taskService.js' export const MAX_IMAGES = 20 // Generate a short stable id for a pending (not-yet-uploaded) image. let _localSeq = 0 const newLocalId = () => `p${++_localSeq}` // Read a File as base64 (data URL string — same shape backend accepts). const readFileAsDataURL = (file) => new Promise((resolve, reject) => { const reader = new FileReader() reader.onload = () => resolve(reader.result) reader.onerror = () => reject(reader.error) reader.readAsDataURL(file) }) /** * useTaskImages — composable owning the image sub-resource state for one * task edit dialog (spec 0004). * * Lifecycle is per-dialog: caller does `const img = useTaskImages()` when * the edit dialog opens and `img.reset()` (or discards the instance) when * it closes. Image blob URLs are tied to the instance lifetime so closing * the dialog releases memory. * * Mutating actions split into two phases: * - During edit: markDelete / unmarkDelete / cancelPending / addPending * only mutate local refs. Nothing is sent over the wire. * - On save: commit() walks pendingDeletes then pendingImages and posts * each one, returning { uploaded, deleted, failed } so the caller can * show partial-failure UI without losing successful uploads. * * Soft-cap of 20 images is enforced client-side via canAddMore (the backend * also returns 400 with "soft cap is 20" as a safety net — see migration * doc §"上限 5 → 20"). * * @param {import('vue').Ref|null} [taskIdRef] optional reactive * task id; if absent, callers must pass taskId into load/commit. */ export function useTaskImages(taskIdRef = null) { const images = ref([]) // [{ id, mime_type, file_name, key, etag, size_bytes, ... }] const pendingImages = ref([]) // [{ localId, base64, mime_type, file_name }] const pendingDeletes = ref([]) // [image_id] const imageCache = reactive({}) // image_id -> blob: URL const loadingMap = reactive({}) // image_id -> bool const isLoading = ref(false) const totalCount = computed( () => images.value.length - pendingDeletes.value.length + pendingImages.value.length ) const canAddMore = computed(() => totalCount.value < MAX_IMAGES) const resolveTaskId = (id) => id ?? taskIdRef?.value // ── List ──────────────────────────────────────────────────────────── async function load(id) { const taskId = resolveTaskId(id) if (!taskId) return isLoading.value = true try { images.value = await listTaskImages(taskId) // Drop stale pendingDeletes that no longer correspond to a loaded image // (e.g. user opened dialog, image was deleted in another tab). const liveIds = new Set(images.value.map((i) => i.id)) pendingDeletes.value = pendingDeletes.value.filter((pid) => liveIds.has(pid)) // Eagerly prime the blob cache so thumbnails render in the grid AND // become clickable. Without this, imageCache stays empty until the user // somehow opens the carousel — and the template's first v-if branch // (the one with @click="openImageInCarousel") is gated on imageCache, // so the user can't click an empty box to trigger the lazy load either. // With ≤20 images (soft cap), parallel blob fetches are fine; the // existing loadingMap drives the spinner. for (const image of images.value) { if (!pendingDeletes.value.includes(image.id)) { getContentUrl(image.id) } } } finally { isLoading.value = false } } // ── Pending (local-only) ──────────────────────────────────────────── async function addPending(file) { if (!canAddMore.value) { throw new Error('image_limit_reached') } const base64 = await readFileAsDataURL(file) pendingImages.value.push({ localId: newLocalId(), base64, mime_type: file.type || 'application/octet-stream', file_name: file.name, }) } function cancelPending(localId) { pendingImages.value = pendingImages.value.filter((p) => p.localId !== localId) } // ── Mark/unmark delete ────────────────────────────────────────────── function markDelete(imageId) { if (!pendingDeletes.value.includes(imageId)) { pendingDeletes.value.push(imageId) } // Drop the cached blob URL — the image is going away and we don't want // a dangling blob: URL leaking memory if the user unmarks and reopens // would re-fetch. (Unmark does not auto-restore; caller calls // getContentUrl again to refetch on demand.) if (imageCache[imageId]) { URL.revokeObjectURL(imageCache[imageId]) delete imageCache[imageId] } } function unmarkDelete(imageId) { pendingDeletes.value = pendingDeletes.value.filter((id) => id !== imageId) } // ── Lazy blob fetch with cache ────────────────────────────────────── async function getContentUrl(imageId) { if (imageCache[imageId]) return imageCache[imageId] if (pendingDeletes.value.includes(imageId)) return null loadingMap[imageId] = true try { // Find the image's task id by walking up — caller supplied it via // taskIdRef or passed to load(); we just need *some* task id here. // We resolve it from the first image that matches; if images is // empty we can't determine the task — return null. const image = images.value.find((i) => i.id === imageId) if (!image) return null const blob = await getTaskImageContent(image.task_id ?? resolveTaskId(), imageId) const url = URL.createObjectURL(blob) imageCache[imageId] = url return url } catch (err) { console.error(`useTaskImages: failed to load image ${imageId}`, err) return null } finally { loadingMap[imageId] = false } } // ── Commit (called from TaskView on save) ─────────────────────────── async function commit(id) { const taskId = resolveTaskId(id) const deleted = [] const uploaded = [] const failed = [] const successfulUploadIndices = new Set() // DELETE first — frees slots before we POST new ones, so the soft-cap // check on POST sees the post-delete count. for (const imageId of pendingDeletes.value) { try { await deleteTaskImage(taskId, imageId) deleted.push(imageId) } catch (err) { failed.push({ kind: 'delete', imageId, error: err }) } } // POST pending uploads. Stop posting on first failure and keep the // remaining queue so the caller can retry — matches the "保留成功、 // 提示重试" UX decision. for (let i = 0; i < pendingImages.value.length; i++) { const p = pendingImages.value[i] try { const created = await createTaskImage(taskId, { mime_type: p.mime_type, file_name: p.file_name, image_base64: p.base64, }) uploaded.push(created) successfulUploadIndices.add(i) } catch (err) { failed.push({ kind: 'upload', payload: p, error: err }) break // preserve remaining pendingImages so retry uses same array } } // Clear processed entries from pending state. pendingDeletes.value = pendingDeletes.value.filter((id) => !deleted.includes(id)) pendingImages.value = pendingImages.value.filter( (_, i) => !successfulUploadIndices.has(i) ) // Refetch image list if anything changed (so images[] picks up new ids // and excludes deleted ones). if (deleted.length > 0 || uploaded.length > 0) { await load(taskId) } return { deleted, uploaded, failed } } function reset() { for (const url of Object.values(imageCache)) { URL.revokeObjectURL(url) } for (const k of Object.keys(imageCache)) delete imageCache[k] for (const k of Object.keys(loadingMap)) delete loadingMap[k] images.value = [] pendingImages.value = [] pendingDeletes.value = [] isLoading.value = false } return { // state images, pendingImages, pendingDeletes, imageCache, loadingMap, isLoading, // computed totalCount, canAddMore, MAX_IMAGES, // actions load, addPending, cancelPending, markDelete, unmarkDelete, getContentUrl, commit, reset, } }