release: freeze demo v1.1.0 2026-10-19
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

This commit is contained in:
WorkBuddy
2026-10-19 20:36:22 +08:00
parent 8b505d9133
commit 69ebf33e18
157 changed files with 21111 additions and 72 deletions
@@ -0,0 +1,279 @@
<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>