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:
浅念
2026-09-23 11:54:26 +08:00
parent 7adc1b0858
commit c1869d7314
5 changed files with 132 additions and 2 deletions
+18
View File
@@ -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`
})
}
+5 -1
View File
@@ -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",
+5 -1
View File
@@ -503,7 +503,11 @@
"batchColReason": "原因",
"view": "详情",
"selectedCount": "{n} 张已选",
"onlyPublicSelectable": "仅已公开照片可批量选择"
"onlyPublicSelectable": "仅已公开照片可批量选择",
"upload": "上传照片",
"uploadBadType": "类型不支持",
"uploadResult": "上传完成:成功 {ok} 张,失败 {fail} 张",
"uploadResultAllOk": "已上传 {ok} 张,处理完成后显示"
},
"transfer": {
"entry": "负责人交接",
+23
View File
@@ -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)