feat(admin): photo upload in album photos tab (same staging pipeline as app)
- 照片页签工具栏加「上传照片」(多选 jpeg/png/heic)→ /v1/uploads
init → PUT staging → complete(clientTaskId 幂等;categoryId 随当前分类筛选)
- 逐张结果:部分成功语义(成功 N 张 toast + 失败明细前 3 条);完成后 reload
- 类型 UploadInitPayload/UploadSession/UploadCompleteResponse + api/uploads.ts
- 问题1 修复说明:详情页 404 系并行部署把 2618ad3 server 覆盖回 8e674a9,
已重滚双栈至 ui06-2618ad3(assets 端点恢复 200,详情页零失败请求)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* 照片直传域(对齐 @photo/contracts tags [uploads]):
|
||||
* init(预签名)→ PUT staging → complete(受理;处理异步)。
|
||||
* 管理端「上传照片」与 App 同链路(clientTaskId 幂等)。
|
||||
*/
|
||||
import request from '@/utils/http'
|
||||
|
||||
/** 初始化照片直传(返回 staging 直传凭证) */
|
||||
export function fetchInitUpload(data: Api.Album.UploadInitPayload) {
|
||||
return request.post<Api.Album.UploadSession>({ url: '/v1/uploads', data })
|
||||
}
|
||||
|
||||
/** 完成直传(服务端核验后受理照片处理;返回 photoId) */
|
||||
export function fetchCompleteUpload(uploadId: string) {
|
||||
return request.post<Api.Album.UploadCompleteResponse>({
|
||||
url: `/v1/uploads/${uploadId}/complete`
|
||||
})
|
||||
}
|
||||
@@ -503,7 +503,11 @@
|
||||
"batchColReason": "Reason",
|
||||
"view": "Details",
|
||||
"selectedCount": "{n} selected",
|
||||
"onlyPublicSelectable": "Only published photos can be selected"
|
||||
"onlyPublicSelectable": "Only published photos can be selected",
|
||||
"upload": "Upload photos",
|
||||
"uploadBadType": "unsupported type",
|
||||
"uploadResult": "Uploaded: {ok} ok, {fail} failed",
|
||||
"uploadResultAllOk": "Uploaded {ok}; visible when processed"
|
||||
},
|
||||
"transfer": {
|
||||
"entry": "Transfer Owner",
|
||||
|
||||
@@ -503,7 +503,11 @@
|
||||
"batchColReason": "原因",
|
||||
"view": "详情",
|
||||
"selectedCount": "{n} 张已选",
|
||||
"onlyPublicSelectable": "仅已公开照片可批量选择"
|
||||
"onlyPublicSelectable": "仅已公开照片可批量选择",
|
||||
"upload": "上传照片",
|
||||
"uploadBadType": "类型不支持",
|
||||
"uploadResult": "上传完成:成功 {ok} 张,失败 {fail} 张",
|
||||
"uploadResultAllOk": "已上传 {ok} 张,处理完成后显示"
|
||||
},
|
||||
"transfer": {
|
||||
"entry": "负责人交接",
|
||||
|
||||
Vendored
+23
@@ -202,6 +202,29 @@ declare namespace Api {
|
||||
/** 照片可见性 */
|
||||
type PhotoVisibility = 'unpublished' | 'published' | 'hidden' | 'deleted'
|
||||
|
||||
/** 照片直传初始化请求(POST /v1/uploads;与 App 同链路) */
|
||||
interface UploadInitPayload {
|
||||
albumId: string
|
||||
clientTaskId: string
|
||||
filename: string
|
||||
bytes: number
|
||||
contentType: 'image/jpeg' | 'image/png' | 'image/heic'
|
||||
categoryId?: string
|
||||
}
|
||||
|
||||
/** 直传会话(init/renew 响应;PUT staging 后 complete 换取处理受理) */
|
||||
interface UploadSession {
|
||||
uploadId: string
|
||||
objectKey: string
|
||||
credential: UploadCredential
|
||||
}
|
||||
|
||||
/** 直传完成响应(受理:处理异步,状态经照片列表 processingState 观察) */
|
||||
interface UploadCompleteResponse {
|
||||
photoId: string
|
||||
state: string
|
||||
}
|
||||
|
||||
/** 照片处理状态 */
|
||||
type PhotoProcessingState =
|
||||
'pending_upload' | 'uploaded' | 'processing' | 'ready' | 'processing_failed'
|
||||
|
||||
@@ -37,6 +37,25 @@
|
||||
:label="categoryLabel(category)"
|
||||
/>
|
||||
</ElSelect>
|
||||
<ElButton
|
||||
type="primary"
|
||||
plain
|
||||
:loading="uploadingCount > 0"
|
||||
data-test="photo-upload-btn"
|
||||
@click="photoInput?.click()"
|
||||
>
|
||||
{{ t('albumsManage.photos.upload') }}
|
||||
<span v-if="uploadingCount > 0">({{ uploadingCount }})</span>
|
||||
</ElButton>
|
||||
<input
|
||||
ref="photoInput"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/heic"
|
||||
multiple
|
||||
class="hidden"
|
||||
data-test="photo-upload-input"
|
||||
@change="onPhotosChange"
|
||||
/>
|
||||
<div class="seg push">
|
||||
<ElTooltip :content="t('albumsManage.list.viewGrid')" placement="top">
|
||||
<button
|
||||
@@ -252,6 +271,7 @@
|
||||
summarizeBatchHideResults,
|
||||
type BatchHideSummary
|
||||
} from '@/utils/albums/batchHide'
|
||||
import { fetchCompleteUpload, fetchInitUpload } from '@/api/uploads'
|
||||
import PhotoGrid from '@/components/business/albums/PhotoGrid.vue'
|
||||
import PhotoPreviewDialog from '@/components/business/albums/PhotoPreviewDialog.vue'
|
||||
|
||||
@@ -289,6 +309,67 @@
|
||||
gridSelected.value = next
|
||||
}
|
||||
|
||||
// ── 上传照片(与 App 同链路:init → PUT staging → complete;逐张结果) ──
|
||||
const photoInput = ref<HTMLInputElement | null>(null)
|
||||
const uploadingCount = ref(0)
|
||||
|
||||
async function onPhotosChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const files = Array.from(input.files ?? [])
|
||||
input.value = '' // 允许连续重选
|
||||
if (files.length === 0) return
|
||||
const CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/heic'])
|
||||
let ok = 0
|
||||
const failures: string[] = []
|
||||
for (const file of files) {
|
||||
if (!CONTENT_TYPES.has(file.type)) {
|
||||
failures.push(`${file.name}(${t('albumsManage.photos.uploadBadType')})`)
|
||||
continue
|
||||
}
|
||||
uploadingCount.value++
|
||||
try {
|
||||
// ① init:clientTaskId 幂等(每次会话重生成;重试时复用同值)
|
||||
const clientTaskId =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const init = await fetchInitUpload({
|
||||
albumId: props.album.id,
|
||||
clientTaskId,
|
||||
filename: file.name,
|
||||
bytes: file.size,
|
||||
contentType: file.type as Api.Album.UploadInitPayload['contentType'],
|
||||
categoryId: categoryFilter.value === '' ? undefined : categoryFilter.value
|
||||
})
|
||||
// ② PUT staging(裸 fetch:预签名自带凭证;Content-Type 与 init 一致)
|
||||
const put = await fetch(init.credential.url, {
|
||||
method: init.credential.method,
|
||||
headers: { ...init.credential.headers, 'Content-Type': file.type },
|
||||
body: file
|
||||
})
|
||||
if (!put.ok) throw new Error(`PUT ${put.status}`)
|
||||
// ③ complete:核验受理(处理异步,状态经列表观察)
|
||||
await fetchCompleteUpload(init.uploadId)
|
||||
ok++
|
||||
} catch (e) {
|
||||
failures.push(`${file.name}(${(e as Error).message})`)
|
||||
} finally {
|
||||
uploadingCount.value--
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
ElMessage.warning(
|
||||
t('albumsManage.photos.uploadResult', { ok, fail: failures.length }) +
|
||||
':' +
|
||||
failures.slice(0, 3).join('、') +
|
||||
(failures.length > 3 ? ' …' : '')
|
||||
)
|
||||
} else {
|
||||
ElMessage.success(t('albumsManage.photos.uploadResultAllOk', { ok }))
|
||||
}
|
||||
if (ok > 0) reload()
|
||||
}
|
||||
|
||||
// ── 管理大图(示例 16) ──
|
||||
const viewerVisible = ref(false)
|
||||
const viewerPhoto = ref<Api.Album.AdminPhoto | null>(null)
|
||||
|
||||
Reference in New Issue
Block a user