Files
danyang-match/h5-demo/src/pages/onboarding/ProfilePhotosPage.vue
T
WorkBuddy 69ebf33e18
CI/CD Pipeline / Backend Build & Test (push) Canceled after 0s
CI/CD Pipeline / Admin Web Build (push) Canceled after 0s
CI/CD Pipeline / Mini Program Compile Check (push) Canceled after 0s
CI/CD Pipeline / Migration Validation (push) Canceled after 0s
CI/CD Pipeline / Test Shell Guard (Phase 23 Gate) (push) Canceled after 0s
CI/CD Pipeline / UI Contracts (source-structure gates) (push) Canceled after 0s
CI/CD Pipeline / E2E (live stack) (push) Canceled after 0s
release: freeze demo v1.1.0 2026-10-19
2026-10-19 20:36:22 +08:00

280 lines
7.6 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<PageLayout title="头像与照片">
<div class="pp">
<UiLoading v-if="loading" text="加载照片…" />
<UiErrorState v-else-if="error" @retry="loadPhotos" />
<template v-else>
<div class="card">
<UiSectionHeader title="主头像" />
<p class="pp__hint">必须上传 1 张清晰正面照</p>
<div class="pp__grid">
<div v-for="p in avatarPhotos" :key="p.photoId" class="pp__item" @click="preview(p)">
<img :src="p.url" class="pp__img" alt="" />
<div v-if="p.isMain || p.main" class="pp__main-flag">主头像</div>
<div class="pp__remove" @click.stop="onRemove(p)">×</div>
</div>
<div v-if="avatarPhotos.length === 0" class="pp__add" @click="choose('avatar')">
<span class="pp__add-icon">+</span>
<span class="pp__add-text">上传头像</span>
</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="生活照片" />
<p class="pp__hint">最多 {{ MAX_LIFESTYLE }} 张(可选)</p>
<div class="pp__grid">
<div v-for="p in lifestylePhotos" :key="p.photoId" class="pp__item" @click="preview(p)">
<img :src="p.url" class="pp__img" alt="" />
<div class="pp__remove" @click.stop="onRemove(p)">×</div>
</div>
<div v-if="lifestylePhotos.length < MAX_LIFESTYLE" class="pp__add" @click="choose('lifestyle')">
<span class="pp__add-icon">+</span>
<span class="pp__add-text">上传照片</span>
</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="上传须知" />
<div class="pp__guidelines">
<div v-for="(g, i) in guidelines" :key="i" class="pp__guideline">
<span class="pp__dot"></span>
<span class="pp__guideline-text">{{ g }}</span>
</div>
</div>
</div>
</template>
<div class="pp__actions">
<van-button type="primary" block round :disabled="loading || error" @click="onNext">下一步</van-button>
</div>
<input ref="fileInput" type="file" accept="image/*" class="pp__hidden-input" @change="onFileChange" />
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog, showImagePreview } from 'vant'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const MAX_LIFESTYLE = 5 // 总上限 6(含 1 张头像)减 1
const fileInput = ref<HTMLInputElement | null>(null)
const loading = ref(true)
const error = ref(false)
const uploadTarget = ref<'avatar' | 'lifestyle'>('avatar')
const avatarPhotos = ref<any[]>([])
const lifestylePhotos = ref<any[]>([])
const guidelines = [
'请使用本人近期真实照片',
'禁止网图、二维码、广告、联系方式图片',
'建议真人生活照,避免过度美颜',
]
async function loadPhotos() {
loading.value = true
error.value = false
try {
const result: any = await profileService.getPhotos()
const photos = result?.photos || result?.items || result?.records || []
avatarPhotos.value = photos.filter((p: any) => p.photoType === 'avatar')
lifestylePhotos.value = photos.filter((p: any) => p.photoType === 'lifestyle')
} catch {
error.value = true
} finally {
loading.value = false
}
}
function choose(target: 'avatar' | 'lifestyle') {
if (target === 'lifestyle' && lifestylePhotos.value.length >= MAX_LIFESTYLE) {
appStore.toast(`最多上传 ${MAX_LIFESTYLE} 张生活照`)
return
}
uploadTarget.value = target
fileInput.value?.click()
}
async function onFileChange(evt: Event) {
const input = evt.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const photoType = uploadTarget.value
try {
const token: any = await profileService.requestUploadToken({
fileName: `photo_${Date.now()}.jpg`,
contentType: file.type || 'image/jpeg',
})
await fetch(token.uploadUrl, {
method: 'PUT',
body: file,
headers: { 'Content-Type': file.type || 'image/jpeg' },
})
const confirmRes: any = await profileService.confirmPhoto({ fileKey: token.fileKey, photoType })
if ((photoType === 'avatar' || avatarPhotos.value.length === 0) && confirmRes?.photoId) {
await profileService.setMainPhoto(confirmRes.photoId)
}
appStore.toastSuccess('上传成功')
await loadPhotos()
} catch {
appStore.toastError('上传失败,请稍后重试')
}
}
async function onRemove(photo: any) {
try {
await showConfirmDialog({ title: '删除照片', message: '确定删除这张照片?删除后不可恢复。' })
} catch {
return
}
try {
await profileService.deletePhoto(photo.photoId)
appStore.toastSuccess('已删除')
await loadPhotos()
} catch (err: any) {
appStore.toastError(err?.message || '删除失败')
}
}
function preview(photo: any) {
const urls = [...avatarPhotos.value, ...lifestylePhotos.value].map((p) => p.url).filter(Boolean)
if (!urls.length) return
showImagePreview({ images: urls, startPosition: urls.indexOf(photo.url) })
}
function onNext() {
if (avatarPhotos.value.length === 0) {
appStore.toast('请至少上传 1 张主头像')
return
}
router.push('/profile/interests')
}
onMounted(loadPhotos)
</script>
<style scoped>
.pp__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pp {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.pp__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
margin-top: 4px;
}
.pp__item {
position: relative;
padding-top: 100%;
border-radius: 10px;
overflow: hidden;
background: var(--mini-bg);
}
.pp__img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.pp__main-flag {
position: absolute;
left: 0;
bottom: 0;
font-size: 10px;
color: #fff;
background: var(--mini-tabbar-active);
padding: 1px 6px;
border-top-right-radius: 8px;
}
.pp__remove {
position: absolute;
top: 2px;
right: 2px;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
}
.pp__add {
position: relative;
padding-top: 100%;
border: 1px dashed var(--mini-border, #dcdfe6);
border-radius: 10px;
color: var(--mini-text-secondary);
}
.pp__add-icon {
position: absolute;
top: 28%;
left: 0;
right: 0;
text-align: center;
font-size: 24px;
}
.pp__add-text {
position: absolute;
top: 58%;
left: 0;
right: 0;
text-align: center;
font-size: 11px;
}
.pp__guidelines {
margin-top: 4px;
}
.pp__guideline {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.pp__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mini-tabbar-active);
flex: none;
}
.pp__guideline-text {
font-size: 13px;
color: var(--mini-text-secondary);
}
.pp__actions {
margin-top: 8px;
}
.pp__hidden-input {
display: none;
}
</style>