feat(03-05): 3-step presigned upload + quota state in auth store + progress UI
- api/client.js: extend request() to attach .status and .payload on 413 structured errors; remove legacy uploadDocument multipart function
- stores/auth.js: add quota ref({used_bytes:0, limit_bytes:0}) and fetchQuota() action (silent catch); expose in store return
- stores/documents.js: replace single upload() with uploadToMinIO XHR helper + 3-step async action (getUploadUrl→XHR PUT→confirmUpload); track uploadProgress map keyed by filename+timestamp (T-03-25); call fetchQuota after upload success and document delete
- components/upload/UploadProgress.vue: add aria progressbar per row, percentage label, quota rejection error block (role=alert, red-50/red-200) from item.quotaError; use plain anchor for Manage storage link
This commit is contained in:
@@ -34,21 +34,26 @@ async function request(path, options = {}) {
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `HTTP ${res.status}`
|
||||
try { msg = (await res.json()).detail || msg } catch {}
|
||||
throw new Error(msg)
|
||||
let payload = null
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (typeof body.detail === 'object' && body.detail !== null) {
|
||||
payload = body.detail
|
||||
msg = body.detail.message || `HTTP ${res.status}`
|
||||
} else {
|
||||
msg = body.detail || msg
|
||||
}
|
||||
} catch {}
|
||||
const err = new Error(msg)
|
||||
err.status = res.status
|
||||
if (payload) err.payload = payload
|
||||
throw err
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Documents ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function uploadDocument(file, autoClassify = true) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
form.append('auto_classify', autoClassify ? 'true' : 'false')
|
||||
return request('/api/documents/upload', { method: 'POST', body: form })
|
||||
}
|
||||
|
||||
export function listDocuments({ topic, page = 1, perPage = 20 } = {}) {
|
||||
const params = new URLSearchParams({ page, per_page: perPage })
|
||||
if (topic) params.set('topic', topic)
|
||||
|
||||
@@ -7,14 +7,64 @@
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-800 truncate">{{ item.name }}</p>
|
||||
<p v-if="item.error" class="text-xs text-red-500 mt-0.5">{{ item.error }}</p>
|
||||
<p v-else-if="item.done" class="text-xs text-green-600 mt-0.5">
|
||||
|
||||
<!-- Error state (non-quota) -->
|
||||
<p v-if="item.error" class="text-sm text-red-500 mt-0.5">{{ item.error }}</p>
|
||||
|
||||
<!-- Done state -->
|
||||
<p v-else-if="item.done" class="text-sm text-green-600 mt-0.5">
|
||||
Done{{ item.topics?.length ? ` — classified as: ${item.topics.join(', ')}` : ' — no topics assigned' }}
|
||||
</p>
|
||||
<p v-else class="text-xs text-gray-400 mt-0.5">Uploading…</p>
|
||||
|
||||
<!-- Quota rejection error block (413 response — T-03-23, UI-SPEC) -->
|
||||
<div
|
||||
v-else-if="item.quotaError"
|
||||
role="alert"
|
||||
class="mt-1 p-3 rounded-lg bg-red-50 border border-red-200"
|
||||
>
|
||||
<p class="text-sm font-semibold text-red-700">Not enough storage</p>
|
||||
<p class="text-sm text-red-600 mt-1">
|
||||
This file ({{ (item.quotaError.rejected_bytes / 1048576).toFixed(1) }} MB) would exceed your quota.
|
||||
</p>
|
||||
<p class="text-sm text-red-600">
|
||||
You're using {{ (item.quotaError.used_bytes / 1048576).toFixed(1) }} MB of {{ (item.quotaError.limit_bytes / 1048576).toFixed(1) }} MB.
|
||||
</p>
|
||||
<!-- Plain anchor to avoid router-link import dependency in upload component -->
|
||||
<a href="/settings" class="text-sm text-red-600 underline hover:text-red-700 font-semibold">
|
||||
Manage storage →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- In-progress: progress bar + percentage + status -->
|
||||
<template v-else>
|
||||
<!-- Progress bar track + fill (UI-SPEC Upload Progress Bar Contract) -->
|
||||
<div
|
||||
v-if="item.progress !== undefined"
|
||||
class="w-full h-2 bg-gray-100 rounded-full mt-1 overflow-hidden"
|
||||
>
|
||||
<div
|
||||
role="progressbar"
|
||||
:aria-valuenow="item.progress"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-label="`Upload progress for ${item.name}`"
|
||||
class="h-full rounded-full transition-all duration-300 bg-indigo-500"
|
||||
:style="{ width: `${item.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<!-- Percentage label (shown during upload, hidden after completion) -->
|
||||
<p
|
||||
v-if="item.progress !== undefined"
|
||||
class="text-sm text-gray-400 mt-1 text-right"
|
||||
>{{ item.progress }}%</p>
|
||||
<!-- Step status string (UI-SPEC Copywriting Contract) -->
|
||||
<p class="text-sm text-gray-400 mt-0.5">{{ item.status || 'Uploading…' }}</p>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Icon slot: error / done / spinner -->
|
||||
<div class="shrink-0">
|
||||
<svg v-if="item.error" class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<svg v-if="item.error || item.quotaError" class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<svg v-else-if="item.done" class="w-5 h-5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
|
||||
@@ -22,6 +22,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref(null)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const quota = ref({ used_bytes: 0, limit_bytes: 0 })
|
||||
|
||||
/**
|
||||
* Register a new account.
|
||||
@@ -122,15 +123,30 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch current user quota from the server and update quota ref.
|
||||
* Silently ignores errors — QuotaBar handles error display by hiding itself (UI-SPEC).
|
||||
*/
|
||||
async function fetchQuota() {
|
||||
try {
|
||||
const data = await api.getMyQuota()
|
||||
quota.value = { used_bytes: data.used_bytes, limit_bytes: data.limit_bytes }
|
||||
} catch {
|
||||
// Silently ignore — QuotaBar hides itself on fetch error (UI-SPEC)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
quota,
|
||||
register,
|
||||
login,
|
||||
logout,
|
||||
logoutAll,
|
||||
refresh,
|
||||
fetchQuota,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,12 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import * as api from '../api/client.js'
|
||||
import { useAuthStore } from './auth.js'
|
||||
|
||||
/**
|
||||
* uploadToMinIO — XHR PUT helper for presigned MinIO uploads.
|
||||
*
|
||||
* Security: NO Authorization header is set — the presigned URL is self-authenticating (T-03-22).
|
||||
* Progress events are mapped to onProgress(0–100) for the XHR PUT phase.
|
||||
*/
|
||||
function uploadToMinIO(url, file, onProgress) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100))
|
||||
})
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status < 400) resolve()
|
||||
else reject(new Error(`PUT failed: ${xhr.status}`))
|
||||
})
|
||||
xhr.addEventListener('error', () => reject(new Error('Network error during upload')))
|
||||
xhr.open('PUT', url)
|
||||
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream')
|
||||
// NOTE: no Authorization header — presigned URL is self-authenticating (T-03-22, CLAUDE.md)
|
||||
xhr.send(file)
|
||||
})
|
||||
}
|
||||
|
||||
export const useDocumentsStore = defineStore('documents', () => {
|
||||
const documents = ref([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
// uploadProgress: keyed by "${file.name}__${Date.now()}" to prevent collision (T-03-25)
|
||||
const uploadProgress = ref({})
|
||||
|
||||
async function fetchDocuments({ topic, page = 1, perPage = 20 } = {}) {
|
||||
loading.value = true
|
||||
@@ -22,17 +49,73 @@ export const useDocumentsStore = defineStore('documents', () => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Three-step upload:
|
||||
* Step 1: POST /api/documents/upload-url → {upload_url, document_id} (0% → 5%)
|
||||
* Step 2: XHR PUT bytes directly to MinIO presigned URL (5% → 90%)
|
||||
* Step 3: POST /api/documents/{id}/confirm → {id, size_bytes, used_bytes, status} (92% → 100%)
|
||||
*
|
||||
* On 413 quota exceeded: err.payload = {used_bytes, limit_bytes, rejected_bytes} from confirm step.
|
||||
* On success: authStore.fetchQuota() is called to refresh sidebar quota bar (STORE-04).
|
||||
* Returns { rowKey, doc } on success so callers can clear the progress entry.
|
||||
*/
|
||||
async function upload(file, autoClassify = true) {
|
||||
const doc = await api.uploadDocument(file, autoClassify)
|
||||
documents.value.unshift(doc)
|
||||
const authStore = useAuthStore()
|
||||
// Composite key prevents collision when same filename uploaded twice (T-03-25)
|
||||
const rowKey = `${file.name}__${Date.now()}`
|
||||
uploadProgress.value[rowKey] = 0
|
||||
try {
|
||||
// Step 1: get presigned PUT URL (UI-SPEC: 0% → 5%, status "Preparing upload…")
|
||||
const { upload_url, document_id } = await api.getUploadUrl(
|
||||
file.name,
|
||||
file.type || 'application/octet-stream',
|
||||
)
|
||||
uploadProgress.value[rowKey] = 5
|
||||
|
||||
// Step 2: XHR PUT to MinIO (UI-SPEC: 5% → 90%, status "Uploading…")
|
||||
await uploadToMinIO(upload_url, file, (pct) => {
|
||||
// Map XHR progress 0–100 into visual range 5–90
|
||||
uploadProgress.value[rowKey] = 5 + Math.round(pct * 0.85)
|
||||
})
|
||||
uploadProgress.value[rowKey] = 92
|
||||
|
||||
// Step 3: confirm (UI-SPEC: 92% → 100%, status "Processing…" → "Done — classifying…")
|
||||
const doc = await api.confirmUpload(document_id)
|
||||
uploadProgress.value[rowKey] = 100
|
||||
|
||||
documents.value.unshift({
|
||||
id: doc.id,
|
||||
original_name: file.name,
|
||||
filename: file.name,
|
||||
mime_type: file.type,
|
||||
size_bytes: doc.size_bytes,
|
||||
topics: [],
|
||||
created_at: new Date().toISOString(),
|
||||
classified_at: null,
|
||||
})
|
||||
total.value++
|
||||
return doc
|
||||
|
||||
// Refresh quota after successful upload (STORE-04, UI-SPEC Data Source)
|
||||
await authStore.fetchQuota()
|
||||
|
||||
return { rowKey, doc }
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
// Propagate rowKey so the DropZone/parent can target the correct progress row
|
||||
throw Object.assign(e, { rowKey })
|
||||
}
|
||||
// NOTE: uploadProgress[rowKey] is intentionally left in place after throw/return —
|
||||
// the parent component reads it to render the bar or quota error block; the parent
|
||||
// is responsible for clearing the entry when the row is dismissed.
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
const authStore = useAuthStore()
|
||||
await api.deleteDocument(id)
|
||||
documents.value = documents.value.filter(d => d.id !== id)
|
||||
total.value--
|
||||
// Refresh quota after document delete (STORE-06, UI-SPEC Data Source)
|
||||
await authStore.fetchQuota()
|
||||
}
|
||||
|
||||
async function reclassify(id, topics = null) {
|
||||
@@ -42,5 +125,5 @@ export const useDocumentsStore = defineStore('documents', () => {
|
||||
return result.topics
|
||||
}
|
||||
|
||||
return { documents, total, loading, error, fetchDocuments, upload, remove, reclassify }
|
||||
return { documents, total, loading, error, uploadProgress, fetchDocuments, upload, remove, reclassify }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user