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
@@ -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)