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
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:
@@ -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>
|
||||
Reference in New Issue
Block a user