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:
@@ -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)
|
||||
total.value++
|
||||
return 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++
|
||||
|
||||
// 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