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,289 @@
<template>
<PageLayout title="基础资料">
<div class="pb">
<UiLoading v-if="loading" text="加载中…" />
<template v-else>
<div class="card">
<UiSectionHeader title="基本信息" />
<p class="pb__hint">由实名认证自动生成</p>
<div class="pb__readonly">
<div class="pb__row">
<span class="pb__row-label">性别</span>
<span class="pb__row-value">{{ genderLabel || '未认证' }}</span>
</div>
<div class="pb__row">
<span class="pb__row-label">年龄</span>
<span class="pb__row-value">{{ profile?.age ? `${profile.age}岁` : '未认证' }}</span>
</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="个人资料" />
<p class="pb__hint">请如实填写</p>
<van-field
v-model="regionLabel"
is-link
readonly
label="居住区域"
placeholder="请选择"
:border="false"
@click="openPicker('region')"
/>
<van-field
v-model="form.heightCm"
type="digit"
label="身高(cm)"
placeholder="请输入"
/>
<van-field
v-model="maritalLabel"
is-link
readonly
label="婚姻状况"
placeholder="请选择"
@click="openPicker('marital')"
/>
<van-field
v-model="childrenLabel"
is-link
readonly
label="子女情况"
placeholder="请选择"
@click="openPicker('children')"
/>
<van-field
v-model="educationLabel"
is-link
readonly
label="学历"
placeholder="请选择"
@click="openPicker('education')"
/>
<van-field
v-model="occupationLabel"
is-link
readonly
label="职业类型"
placeholder="请选择"
@click="openPicker('occupation')"
/>
</div>
</template>
<div class="pb__actions">
<van-button type="primary" block round :loading="saving" :disabled="loading" @click="onNext">
下一步
</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker
:columns="picker.columns"
@cancel="picker.show = false"
@confirm="onPickerConfirm"
/>
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const saving = ref(false)
const profile = ref<any>(null)
const form = reactive({
regionName: '',
heightCm: '',
maritalStatus: '',
childrenStatus: '',
educationCode: '',
occupationCode: '',
})
const REGION_OPTIONS = [
{ text: '丹阳', value: '丹阳' },
{ text: '镇江市区', value: '镇江市区' },
{ text: '镇江其他辖区', value: '镇江其他辖区' },
{ text: '南京', value: '南京' },
]
const REGION_CODES: Record<string, string> = { 丹阳: '321181', 镇江市区: '321100', 南京: '320100' }
const MARITAL_OPTIONS = [
{ text: '未婚', value: 'never_married' },
{ text: '离异', value: 'divorced' },
{ text: '丧偶', value: 'widowed' },
]
const CHILDREN_OPTIONS = [
{ text: '无子女', value: 'none' },
{ text: '有子女', value: 'yes' },
]
const EDUCATION_OPTIONS = [
{ text: '高中/中专', value: 'senior_high' },
{ text: '大专', value: 'college' },
{ text: '本科', value: 'bachelor' },
{ text: '硕士', value: 'master' },
{ text: '博士', value: 'doctor' },
]
const OCCUPATION_OPTIONS = [
{ text: '机关事业单位', value: 'government' },
{ text: '教育', value: 'education' },
{ text: '医疗', value: 'medical' },
{ text: '企业职员', value: 'enterprise' },
{ text: '技术人员', value: 'technical' },
{ text: '个体经营', value: 'self_employed' },
{ text: '自由职业', value: 'freelance' },
{ text: '服务行业', value: 'service' },
{ text: '农业', value: 'agriculture' },
{ text: '其他', value: 'other' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const regionLabel = computed(() => labelOf(REGION_OPTIONS, form.regionName))
const maritalLabel = computed(() => labelOf(MARITAL_OPTIONS, form.maritalStatus))
const childrenLabel = computed(() => labelOf(CHILDREN_OPTIONS, form.childrenStatus))
const educationLabel = computed(() => labelOf(EDUCATION_OPTIONS, form.educationCode))
const occupationLabel = computed(() => labelOf(OCCUPATION_OPTIONS, form.occupationCode))
const genderLabel = computed(() => {
const g = profile.value?.gender
return g === 'male' ? '男' : g === 'female' ? '女' : ''
})
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
region: REGION_OPTIONS,
marital: MARITAL_OPTIONS,
children: CHILDREN_OPTIONS,
education: EDUCATION_OPTIONS,
occupation: OCCUPATION_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'region') form.regionName = value
else if (key === 'marital') form.maritalStatus = value
else if (key === 'children') form.childrenStatus = value
else if (key === 'education') form.educationCode = value
else if (key === 'occupation') form.occupationCode = value
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
profile.value = p
form.regionName = p?.regionName || ''
form.heightCm = p?.heightCm ? String(p.heightCm) : ''
form.maritalStatus = p?.maritalStatus || ''
form.childrenStatus = p?.childrenStatus || ''
form.educationCode = p?.educationCode || ''
form.occupationCode = p?.occupationCode || ''
} catch {
/* 首次填写无资料 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!form.regionName) return appStore.toast('请选择居住区域')
if (!form.heightCm) return appStore.toast('请填写身高')
if (!form.maritalStatus) return appStore.toast('请选择婚姻状况')
if (!form.childrenStatus) return appStore.toast('请选择子女情况')
if (!form.educationCode) return appStore.toast('请选择学历')
if (!form.occupationCode) return appStore.toast('请选择职业类型')
saving.value = true
try {
await profileService.updateBasicProfile({
regionName: form.regionName,
regionCode: REGION_CODES[form.regionName] || null,
heightCm: Number(form.heightCm),
maritalStatus: form.maritalStatus,
childrenStatus: form.childrenStatus,
educationCode: form.educationCode,
occupationCode: form.occupationCode,
})
router.push('/profile/lifestyle')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pb__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pb {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 0 8px;
margin-bottom: 12px;
}
.card :deep(.ui-section-header) {
padding: 0 16px;
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pb__readonly {
padding: 4px 16px 4px;
}
.pb__row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid var(--mini-border-weak, #f2f3f5);
}
.pb__row:last-child {
border-bottom: none;
}
.pb__row-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pb__row-value {
font-size: 14px;
color: var(--mini-text-strong);
}
.pb__actions {
margin-top: 8px;
}
</style>
@@ -0,0 +1,324 @@
<template>
<PageLayout title="资料确认">
<div class="pc">
<UiLoading v-if="loading" text="加载中…" />
<UiErrorState v-else-if="error" @retry="loadAll" />
<template v-else-if="profile">
<!-- 审核状态 -->
<div class="card">
<div class="pc__status-row">
<span class="pc__status-label">当前审核状态</span>
<UiTag :type="reviewTag">{{ reviewLabel }}</UiTag>
</div>
<div v-if="reviewComment" class="pc__comment">
<span class="pc__comment-title">审核反馈:</span>
<span class="pc__comment-text">{{ reviewComment }}</span>
</div>
</div>
<!-- 完整度 -->
<div class="card">
<UiSectionHeader title="资料完整度" />
<p class="pc__hint">{{ completion }}%</p>
<div class="pc__progress">
<div class="pc__progress-track">
<div class="pc__progress-fill" :style="{ width: completion + '%' }"></div>
</div>
<div class="pc__progress-hint">
{{ completion >= 100 ? '资料已完整,可提交审核' : '部分资料尚未填写,提交前请确认' }}
</div>
</div>
</div>
<!-- 资料概览 -->
<div class="card">
<UiSectionHeader title="资料概览" />
<p class="pc__hint">只读展示,点选对应项可修改</p>
<van-cell-group :border="false">
<van-cell title="昵称" :value="profile.displayName || '未设置'" is-link @click="router.push('/profile/basic')" />
<van-cell title="年龄" :value="profile.age ? profile.age + '岁' : '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="身高" :value="profile.heightCm ? profile.heightCm + 'cm' : '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="婚姻状况" :value="maritalLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="子女情况" :value="childrenLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="学历" :value="educationLabel || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="居住区域" :value="profile.regionName || '-'" is-link @click="router.push('/profile/basic')" />
<van-cell title="生活照" :value="`${photoCount}张`" is-link @click="router.push('/profile/photos')" />
</van-cell-group>
</div>
<!-- 自我介绍 -->
<div class="card">
<UiSectionHeader title="自我介绍" more-text="修改" @more="router.push('/profile/intro')" />
<div class="pc__intro">{{ profile.selfIntro || '暂未填写' }}</div>
</div>
<!-- 择偶条件 -->
<div class="card">
<UiSectionHeader title="择偶条件" more-text="修改" @more="router.push('/profile/preference')" />
<div v-if="preferences">
<div class="pc__row">
<span class="pc__row-label">年龄范围</span>
<span class="pc__row-value">{{ preferences.minAge || '-' }} ~ {{ preferences.maxAge || '-' }}岁</span>
</div>
<div class="pc__row">
<span class="pc__row-label">期望婚姻状况</span>
<span class="pc__row-value">
{{ (preferences.maritalStatuses || []).map((c: string) => maritalText(c)).join('、') || '不限' }}
</span>
</div>
<div class="pc__row">
<span class="pc__row-label">最低学历</span>
<span class="pc__row-value">{{ educationText(preferences.minEducationCode) || '不限' }}</span>
</div>
</div>
<UiEmpty v-else description="尚未填写择偶条件" />
</div>
<!-- 提交须知 -->
<div class="pc__notice">
<UiSectionHeader title="提交前请确认" />
<div class="pc__notice-item">· 提交后资料将进入人工审核</div>
<div class="pc__notice-item">· 审核期间你可继续修改,但需重新提交</div>
<div class="pc__notice-item">· 审核通过后即可开始匹配</div>
<div class="pc__notice-item">· 资料须真实有效,禁止虚假信息</div>
</div>
<div class="pc__actions">
<van-button
type="primary"
block
round
:loading="submitting"
:disabled="!resubmitAllowed"
@click="onSubmit"
>
{{ isPending ? '审核中,不可重复提交' : '提交审核' }}
</van-button>
</div>
</template>
<UiEmpty v-else description="资料加载失败,请重试" />
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } 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 UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
import UiTag from '@/components/UiTag.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const error = ref(false)
const submitting = ref(false)
const profile = ref<any>(null)
const preferences = ref<any>(null)
const photoCount = ref(0)
const completion = ref(0)
const reviewStatus = ref('not_submitted')
const reviewComment = ref('')
const MARITAL: Record<string, string> = { never_married: '未婚', divorced: '离异', widowed: '丧偶', single: '未婚' }
const CHILDREN: Record<string, string> = { none: '无子女', has: '有子女', yes: '有子女' }
const EDUCATION: Record<string, string> = {
junior_high: '初中',
senior_high: '高中/中专',
high_school: '高中/中专',
college: '大专',
bachelor: '本科',
master: '硕士',
doctor: '博士',
}
const REVIEW_TEXT: Record<string, string> = {
not_submitted: '未提交',
pending: '审核中',
pending_review: '审核中',
approved: '已通过',
need_edit: '需修改',
rejected: '未通过',
}
function maritalText(code: string): string {
return MARITAL[code] || code
}
function educationText(code?: string): string {
if (!code) return ''
return EDUCATION[code] || code
}
const maritalLabel = computed(() => MARITAL[profile.value?.maritalStatus] || '')
const childrenLabel = computed(() => CHILDREN[profile.value?.childrenStatus] || '')
const educationLabel = computed(() => educationText(profile.value?.educationCode))
const isPending = computed(() => reviewStatus.value === 'pending' || reviewStatus.value === 'pending_review')
const reviewLabel = computed(() => REVIEW_TEXT[reviewStatus.value] || '未提交')
const reviewTag = computed<'primary' | 'success' | 'warning' | 'danger' | 'info'>(() => {
if (reviewStatus.value === 'approved') return 'success'
if (reviewStatus.value === 'need_edit' || isPending.value) return 'warning'
if (reviewStatus.value === 'rejected') return 'danger'
return 'info'
})
const resubmitAllowed = computed(
() => reviewStatus.value === 'not_submitted' || reviewStatus.value === 'need_edit' || reviewStatus.value === 'rejected',
)
async function loadAll() {
loading.value = true
error.value = false
try {
const [p, prefs, review, photosRes, comp] = await Promise.all([
profileService.getProfile().catch(() => null),
profileService.getPreferences().catch(() => null),
profileService.getReviewStatus().catch(() => null),
profileService.getPhotos().catch(() => ({ photos: [] })),
profileService.getCompletion().catch(() => null),
])
profile.value = p
preferences.value = prefs
const photos = (photosRes as any)?.photos || (photosRes as any)?.items || (photosRes as any)?.records || []
photoCount.value = photos.length
reviewStatus.value = (review as any)?.status || 'not_submitted'
const items = (review as any)?.items || []
reviewComment.value = items
.filter((i: any) => i.status === 'need_edit' || i.status === 'rejected')
.map((i: any) => i.reasonText)
.filter(Boolean)
.join(';')
const c = comp as any
const raw = c?.completion ?? c?.profileCompletion ?? (p as any)?.profileCompletion ?? 0
completion.value = Math.max(0, Math.min(100, Math.round(Number(raw) || 0)))
if (!p) error.value = true
} catch {
error.value = true
} finally {
loading.value = false
}
}
async function onSubmit() {
if (submitting.value || !resubmitAllowed.value) return
try {
await showConfirmDialog({
title: '确认提交审核?',
message: '提交后资料将进入人工审核,审核期间你可继续修改,但需重新提交审核。',
})
} catch {
return
}
submitting.value = true
try {
await profileService.submitReview()
appStore.toastSuccess('已提交审核')
router.replace('/review/pending')
} catch (err: any) {
appStore.toastError(err?.message || '提交失败')
} finally {
submitting.value = false
}
}
onMounted(loadAll)
</script>
<style scoped>
.pc__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pc {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.card :deep(.van-cell) {
padding-left: 0;
padding-right: 0;
}
.pc__status-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0 4px;
}
.pc__status-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pc__comment {
margin-top: 8px;
padding: 10px 12px;
background: rgba(245, 166, 35, 0.1);
border-radius: 10px;
font-size: 13px;
color: var(--mini-text);
}
.pc__comment-title {
color: #b9770e;
}
.pc__progress {
margin-top: 4px;
}
.pc__progress-track {
height: 8px;
border-radius: 4px;
background: var(--mini-bg);
overflow: hidden;
}
.pc__progress-fill {
height: 100%;
background: var(--mini-accent-pink);
border-radius: 4px;
transition: width 0.3s;
}
.pc__progress-hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin-top: 8px;
}
.pc__intro {
font-size: 13px;
color: var(--mini-text);
line-height: 1.8;
padding: 4px 0;
}
.pc__row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.pc__row-label {
font-size: 14px;
color: var(--mini-text-secondary);
}
.pc__row-value {
font-size: 14px;
color: var(--mini-text);
text-align: right;
}
.pc__notice-item {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.9;
}
.pc__actions {
margin-top: 4px;
}
</style>
@@ -0,0 +1,213 @@
<template>
<PageLayout title="婚恋态度">
<div class="pt">
<UiLoading v-if="loading" text="加载中…" />
<div class="card">
<UiSectionHeader title="婚恋态度" />
<p class="pt__hint">让我们更了解你的期待</p>
<div class="pt__field pt__field--column">
<div class="pt__label">当前婚恋目标</div>
<textarea
v-model="form.relationshipGoal"
class="pt__textarea"
maxlength="100"
placeholder="寻找以婚姻为目标的稳定关系"
/>
</div>
<van-field
v-model="marriagePlanLabel"
is-link
readonly
label="希望多久进入婚姻"
placeholder="请选择"
:border="false"
@click="openPicker('marriagePlan')"
/>
<van-field
v-model="activityLabel"
is-link
readonly
label="参加公益相亲活动"
placeholder="请选择"
@click="openPicker('activity')"
/>
<van-field
v-model="matchmakerLabel"
is-link
readonly
label="接受公益红娘推荐"
placeholder="请选择"
@click="openPicker('matchmaker')"
/>
</div>
<div class="pt__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker :columns="picker.columns" @cancel="picker.show = false" @confirm="onPickerConfirm" />
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const DEFAULT_GOAL = '寻找以婚姻为目标的稳定关系'
const loading = ref(true)
const saving = ref(false)
const form = reactive({
relationshipGoal: DEFAULT_GOAL,
marriagePlan: '',
activityWillingness: '',
matchmakerWillingness: '',
})
const MARRIAGE_PLAN_OPTIONS = [
{ text: '半年内', value: 'half_year' },
{ text: '1年内', value: 'one_year' },
{ text: '1~2年', value: 'one_to_two_years' },
{ text: '2年以上', value: 'two_years_plus' },
{ text: '顺其自然', value: 'let_it_be' },
]
const ACTIVITY_OPTIONS = [
{ text: '愿意', value: 'willing' },
{ text: '暂不考虑', value: 'not_now' },
]
const MATCHMAKER_OPTIONS = [
{ text: '接受', value: 'accept' },
{ text: '暂不接受', value: 'not_now' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const marriagePlanLabel = computed(() => labelOf(MARRIAGE_PLAN_OPTIONS, form.marriagePlan))
const activityLabel = computed(() => labelOf(ACTIVITY_OPTIONS, form.activityWillingness))
const matchmakerLabel = computed(() => labelOf(MATCHMAKER_OPTIONS, form.matchmakerWillingness))
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
marriagePlan: MARRIAGE_PLAN_OPTIONS,
activity: ACTIVITY_OPTIONS,
matchmaker: MATCHMAKER_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'marriagePlan') form.marriagePlan = value
else if (key === 'activity') form.activityWillingness = value
else if (key === 'matchmaker') form.matchmakerWillingness = value
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
form.relationshipGoal = p?.relationshipGoal || DEFAULT_GOAL
form.marriagePlan = p?.marriagePlan || ''
form.activityWillingness = p?.activityWillingness || ''
form.matchmakerWillingness = p?.matchmakerWillingness || ''
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!form.marriagePlan) return appStore.toast('请选择希望多久进入婚姻')
if (!form.activityWillingness) return appStore.toast('请选择是否参加公益活动')
if (!form.matchmakerWillingness) return appStore.toast('请选择是否接受红娘推荐')
saving.value = true
try {
await profileService.updateProfile({
relationshipGoal: form.relationshipGoal,
marriagePlan: form.marriagePlan,
activityWillingness: form.activityWillingness,
matchmakerWillingness: form.matchmakerWillingness,
})
router.push('/profile/intro')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pt__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pt {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 0 8px;
}
.card :deep(.ui-section-header) {
padding: 0 16px;
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pt__field--column {
padding: 8px 16px 4px;
}
.pt__label {
font-size: 14px;
color: var(--mini-text-secondary);
margin-bottom: 8px;
}
.pt__textarea {
width: 100%;
min-height: 84px;
border: 1px solid var(--mini-border-weak, #f2f3f5);
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
color: var(--mini-text);
background: var(--mini-bg);
box-sizing: border-box;
resize: none;
font-family: inherit;
}
.pt__actions {
margin-top: 20px;
}
</style>
@@ -0,0 +1,173 @@
<template>
<PageLayout title="兴趣与生活方式">
<div class="pi">
<UiLoading v-if="loading" text="加载中…" />
<div class="card">
<UiSectionHeader title="兴趣" />
<p class="pi__hint">最多选择 {{ MAX_TAGS }} 项({{ selectedCount }}/{{ MAX_TAGS }})</p>
<div class="pi__tags">
<span
v-for="t in interests"
:key="t.code"
class="pi__tag"
:class="{ 'pi__tag--selected': t.selected }"
@click="toggleInterest(t)"
>
{{ t.name }}
</span>
</div>
</div>
<div class="card">
<UiSectionHeader title="生活方式" />
<p class="pi__hint">可选,多选</p>
<div class="pi__tags">
<span
v-for="t in lifestyles"
:key="t.code"
class="pi__tag"
:class="{ 'pi__tag--selected': t.selected }"
@click="t.selected = !t.selected"
>
{{ t.name }}
</span>
</div>
</div>
<div class="pi__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const MAX_TAGS = 8
const loading = ref(true)
const saving = ref(false)
const interests = ref([
{ code: 'travel', name: '旅行', selected: false },
{ code: 'reading', name: '阅读', selected: false },
{ code: 'sports', name: '运动', selected: false },
{ code: 'fitness', name: '健身', selected: false },
{ code: 'photography', name: '摄影', selected: false },
{ code: 'movie', name: '电影', selected: false },
{ code: 'music', name: '音乐', selected: false },
{ code: 'food', name: '美食', selected: false },
{ code: 'cooking', name: '烹饪', selected: false },
{ code: 'outdoor', name: '户外', selected: false },
{ code: 'pets', name: '宠物', selected: false },
{ code: 'games', name: '游戏', selected: false },
{ code: 'culture', name: '传统文化', selected: false },
{ code: 'charity', name: '公益活动', selected: false },
{ code: 'other', name: '其他', selected: false },
])
const lifestyles = ref([
{ code: 'quiet', name: '喜欢安静', selected: false },
{ code: 'social', name: '喜欢社交', selected: false },
{ code: 'outdoor', name: '喜欢户外', selected: false },
{ code: 'homebody', name: '居家型', selected: false },
{ code: 'regular_work', name: '工作规律', selected: false },
{ code: 'frequent_travel', name: '经常出差', selected: false },
{ code: 'family_oriented', name: '注重家庭', selected: false },
{ code: 'other', name: '其他', selected: false },
])
const selectedCount = computed(() => interests.value.filter((o) => o.selected).length)
function toggleInterest(t: { code: string; name: string; selected: boolean }) {
if (!t.selected && selectedCount.value >= MAX_TAGS) {
appStore.toast(`最多选择 ${MAX_TAGS} 个兴趣`)
return
}
t.selected = !t.selected
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
const savedInterests: string[] = p?.interests || []
const savedLifestyles: string[] = p?.lifestyles || []
interests.value = interests.value.map((o) => ({ ...o, selected: savedInterests.includes(o.code) }))
lifestyles.value = lifestyles.value.map((o) => ({ ...o, selected: savedLifestyles.includes(o.code) }))
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
saving.value = true
try {
await profileService.updateProfile({
interests: interests.value.filter((o) => o.selected).map((o) => o.code),
lifestyles: lifestyles.value.filter((o) => o.selected).map((o) => o.code),
})
router.push('/profile/intent')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pi__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pi {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.pi__tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 4px;
}
.pi__tag {
font-size: 13px;
color: var(--mini-text);
background: var(--mini-bg);
border-radius: 16px;
padding: 7px 14px;
border: 1px solid transparent;
}
.pi__tag--selected {
color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.08);
border-color: var(--mini-tabbar-active);
}
.pi__actions {
margin-top: 8px;
}
</style>
@@ -0,0 +1,177 @@
<template>
<PageLayout title="个人介绍">
<div class="pri">
<UiLoading v-if="loading" text="加载中…" />
<div class="card">
<UiSectionHeader title="简单介绍一下自己" />
<p class="pri__hint">{{ MIN_LEN }}~{{ MAX_LEN }} 字</p>
<div class="pri__wrap">
<textarea
v-model="selfIntro"
class="pri__textarea"
:maxlength="MAX_LEN"
placeholder="可以介绍工作、性格、兴趣、生活状态,以及你期待怎样的感情"
/>
<div class="pri__counter">{{ selfIntro.length }}/{{ MAX_LEN }}</div>
</div>
</div>
<div class="card">
<UiSectionHeader title="填写须知" />
<div class="pri__guidelines">
<div v-for="(g, i) in guidelines" :key="i" class="pri__guideline">
<span class="pri__dot"></span>
<span class="pri__guideline-text">{{ g }}</span>
</div>
</div>
</div>
<div v-if="!isWizard" class="card">
<UiSectionHeader title="其他资料入口" />
<p class="pri__hint">点选可跳转对应步骤修改</p>
<van-cell-group :border="false">
<van-cell title="基础资料" is-link @click="router.push('/profile/basic')" />
<van-cell title="职业与生活" is-link @click="router.push('/profile/lifestyle')" />
<van-cell title="头像与照片" is-link @click="router.push('/profile/photos')" />
<van-cell title="兴趣与生活方式" is-link @click="router.push('/profile/interests')" />
<van-cell title="婚恋态度" is-link @click="router.push('/profile/intent')" />
<van-cell title="择偶条件" is-link @click="router.push('/profile/preference')" />
</van-cell-group>
</div>
<div class="pri__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const MIN_LEN = 20
const MAX_LEN = 500
const loading = ref(true)
const saving = ref(false)
const selfIntro = ref('')
const guidelines = [
'可以介绍工作、性格、兴趣、生活状态',
'以及你期待怎样的感情',
'禁止填写手机号、微信号、二维码',
'禁止广告、色情低俗、投资引流内容',
]
// 带 step=1 进入时为向导模式(隐藏资料入口),否则作为「我的资料」入口展示
const isWizard = computed(() => route.query.step === '1')
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
selfIntro.value = p?.selfIntro || ''
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
const text = selfIntro.value.trim()
if (text.length < MIN_LEN) return appStore.toast(`至少填写 ${MIN_LEN} 字`)
if (text.length > MAX_LEN) return appStore.toast(`最多填写 ${MAX_LEN} 字`)
saving.value = true
try {
await profileService.updateProfile({ selfIntro: text })
router.push('/profile/preference')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pri__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pri {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.pri__wrap {
position: relative;
margin-top: 4px;
}
.pri__textarea {
width: 100%;
min-height: 140px;
border: 1px solid var(--mini-border-weak, #f2f3f5);
border-radius: 10px;
padding: 12px;
font-size: 14px;
color: var(--mini-text);
background: var(--mini-bg);
box-sizing: border-box;
resize: none;
font-family: inherit;
}
.pri__counter {
text-align: right;
font-size: 12px;
color: var(--mini-text-muted);
margin-top: 6px;
}
.pri__guidelines {
margin-top: 4px;
}
.pri__guideline {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
}
.pri__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--mini-tabbar-active);
flex: none;
}
.pri__guideline-text {
font-size: 13px;
color: var(--mini-text-secondary);
}
.card :deep(.van-cell) {
padding-left: 0;
padding-right: 0;
}
.pri__actions {
margin-top: 8px;
}
</style>
@@ -0,0 +1,242 @@
<template>
<PageLayout title="职业与生活">
<div class="pl">
<UiLoading v-if="loading" text="加载中…" />
<div class="card">
<UiSectionHeader title="职业与生活" />
<p class="pl__hint">完善你的生活状态</p>
<van-field
v-model="occupationLabel"
is-link
readonly
label="职业类别"
placeholder="请选择"
:border="false"
@click="openPicker('occupation')"
/>
<van-field
v-model="workRegionLabel"
is-link
readonly
label="工作区域"
placeholder="请选择"
@click="openPicker('workRegion')"
/>
<van-field
v-model="smokingLabel"
is-link
readonly
label="是否吸烟"
placeholder="请选择"
@click="openPicker('smoking')"
/>
<van-field
v-model="drinkingLabel"
is-link
readonly
label="是否饮酒"
placeholder="请选择"
@click="openPicker('drinking')"
/>
<van-field
v-model="longTermLabel"
is-link
readonly
label="是否长期居住丹阳"
placeholder="请选择"
@click="openPicker('longTerm')"
/>
<van-field
v-model="futureLabel"
is-link
readonly
label="未来是否愿意继续在丹阳"
placeholder="请选择"
@click="openPicker('future')"
/>
</div>
<div class="pl__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker :columns="picker.columns" @cancel="picker.show = false" @confirm="onPickerConfirm" />
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { profileService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
occupationCode: '',
workRegionCode: '',
smokingStatus: '',
drinkingStatus: '',
longTermDanyang: '',
futureDanyang: '',
})
const OCCUPATION_OPTIONS = [
{ text: '机关事业单位', value: 'government' },
{ text: '教育', value: 'education' },
{ text: '医疗', value: 'medical' },
{ text: '企业职员', value: 'enterprise' },
{ text: '技术人员', value: 'technical' },
{ text: '个体经营', value: 'self_employed' },
{ text: '自由职业', value: 'freelance' },
{ text: '服务行业', value: 'service' },
{ text: '农业', value: 'agriculture' },
{ text: '其他', value: 'other' },
]
const WORK_REGION_OPTIONS = [
{ text: '丹阳', value: '丹阳' },
{ text: '镇江市区', value: '镇江市区' },
{ text: '镇江其他辖区', value: '镇江其他辖区' },
{ text: '南京', value: '南京' },
{ text: '不限', value: '不限' },
]
const SMOKING_OPTIONS = [
{ text: '不吸烟', value: 'never' },
{ text: '偶尔', value: 'sometimes' },
{ text: '经常', value: 'often' },
]
const DRINKING_OPTIONS = [
{ text: '不饮酒', value: 'never' },
{ text: '偶尔', value: 'sometimes' },
{ text: '经常', value: 'often' },
]
const YES_NO_OPTIONS = [
{ text: '是', value: 'yes' },
{ text: '否', value: 'no' },
]
const FUTURE_OPTIONS = [
{ text: '愿意', value: 'willing' },
{ text: '不确定', value: 'uncertain' },
{ text: '不愿意', value: 'unwilling' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const occupationLabel = computed(() => labelOf(OCCUPATION_OPTIONS, form.occupationCode))
const workRegionLabel = computed(() => labelOf(WORK_REGION_OPTIONS, form.workRegionCode))
const smokingLabel = computed(() => labelOf(SMOKING_OPTIONS, form.smokingStatus))
const drinkingLabel = computed(() => labelOf(DRINKING_OPTIONS, form.drinkingStatus))
const longTermLabel = computed(() => labelOf(YES_NO_OPTIONS, form.longTermDanyang))
const futureLabel = computed(() => labelOf(FUTURE_OPTIONS, form.futureDanyang))
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
occupation: OCCUPATION_OPTIONS,
workRegion: WORK_REGION_OPTIONS,
smoking: SMOKING_OPTIONS,
drinking: DRINKING_OPTIONS,
longTerm: YES_NO_OPTIONS,
future: FUTURE_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'occupation') form.occupationCode = value
else if (key === 'workRegion') form.workRegionCode = value
else if (key === 'smoking') form.smokingStatus = value
else if (key === 'drinking') form.drinkingStatus = value
else if (key === 'longTerm') form.longTermDanyang = value
else if (key === 'future') form.futureDanyang = value
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getProfile()
form.occupationCode = p?.occupationCode || ''
form.workRegionCode = p?.workRegionCode || ''
form.smokingStatus = p?.smokingStatus || ''
form.drinkingStatus = p?.drinkingStatus || ''
form.longTermDanyang = p?.longTermDanyang || ''
form.futureDanyang = p?.futureDanyang || ''
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!form.occupationCode) return appStore.toast('请选择职业类别')
if (!form.workRegionCode) return appStore.toast('请选择工作区域')
saving.value = true
try {
await profileService.updateProfile({
occupationCode: form.occupationCode,
workRegionCode: form.workRegionCode,
smokingStatus: form.smokingStatus,
drinkingStatus: form.drinkingStatus,
longTermDanyang: form.longTermDanyang,
futureDanyang: form.futureDanyang,
})
router.push('/profile/photos')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pl__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pl {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 0 8px;
}
.card :deep(.ui-section-header) {
padding: 0 16px;
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pl__actions {
margin-top: 20px;
}
</style>
@@ -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>
@@ -0,0 +1,435 @@
<template>
<PageLayout title="择偶条件">
<div class="pf">
<UiLoading v-if="loading" text="加载中…" />
<!-- 年龄 -->
<div class="card">
<UiSectionHeader title="年龄范围" />
<p class="pf__hint">可设置必须符合 / 最好符合</p>
<div class="pf__range">
<van-field v-model="pref.ageMin" type="digit" placeholder="最小" class="pf__range-field" />
<span class="pf__dash">~</span>
<van-field v-model="pref.ageMax" type="digit" placeholder="最大" class="pf__range-field" />
<span class="pf__unit">岁</span>
</div>
<div class="pf__importance">
<span class="pf__importance-label">重要程度</span>
<span
v-for="o in IMPORTANCE_OPTIONS"
:key="o.value"
class="pf__imp"
:class="{ 'pf__imp--active': pref.agePriority === o.value }"
@click="setImportance('agePriority', o.value)"
>{{ o.text }}</span>
</div>
</div>
<!-- 身高 -->
<div class="card">
<UiSectionHeader title="身高范围" />
<p class="pf__hint">可设置必须符合 / 最好符合</p>
<div class="pf__range">
<van-field v-model="pref.heightMin" type="digit" placeholder="最小" class="pf__range-field" />
<span class="pf__dash">~</span>
<van-field v-model="pref.heightMax" type="digit" placeholder="最大" class="pf__range-field" />
<span class="pf__unit">cm</span>
</div>
<div class="pf__importance">
<span class="pf__importance-label">重要程度</span>
<span
v-for="o in IMPORTANCE_OPTIONS"
:key="o.value"
class="pf__imp"
:class="{ 'pf__imp--active': pref.heightPriority === o.value }"
@click="setImportance('heightPriority', o.value)"
>{{ o.text }}</span>
</div>
</div>
<!-- 婚姻状况 -->
<div class="card">
<UiSectionHeader title="期望婚姻状况" />
<p class="pf__hint">多选</p>
<div class="pf__tags">
<span
v-for="o in MARITAL_OPTIONS"
:key="o.value"
class="pf__tag"
:class="{ 'pf__tag--selected': pref.maritalStatusAccepted.includes(o.value) }"
@click="toggleMarital(o.value)"
>
{{ o.text }}
</span>
</div>
<div class="pf__importance">
<span class="pf__importance-label">重要程度</span>
<span
v-for="o in IMPORTANCE_OPTIONS"
:key="o.value"
class="pf__imp"
:class="{ 'pf__imp--active': pref.maritalPriority === o.value }"
@click="setImportance('maritalPriority', o.value)"
>{{ o.text }}</span>
</div>
</div>
<!-- 学历 -->
<div class="card">
<UiSectionHeader title="期望学历" />
<van-field
v-model="educationLabel"
is-link
readonly
label="最低学历"
placeholder="不限"
:border="false"
@click="openPicker('education')"
/>
<div class="pf__importance">
<span class="pf__importance-label">重要程度</span>
<span
v-for="o in IMPORTANCE_OPTIONS"
:key="o.value"
class="pf__imp"
:class="{ 'pf__imp--active': pref.educationPriority === o.value }"
@click="setImportance('educationPriority', o.value)"
>{{ o.text }}</span>
</div>
</div>
<!-- 更多条件 -->
<div class="card">
<UiSectionHeader title="更多择偶条件" />
<p class="pf__hint">可选,均允许不限</p>
<van-field
v-model="childrenLabel"
is-link
readonly
label="是否接受有子女"
placeholder="不限"
:border="false"
@click="openPicker('children')"
/>
<van-field
v-model="smokingLabel"
is-link
readonly
label="是否吸烟"
placeholder="不限"
@click="openPicker('smoking')"
/>
<van-field
v-model="drinkingLabel"
is-link
readonly
label="是否饮酒"
placeholder="不限"
@click="openPicker('drinking')"
/>
<van-field
v-model="marriagePlanLabel"
is-link
readonly
label="结婚时间计划"
placeholder="不限"
@click="openPicker('marriagePlan')"
/>
</div>
<div class="pf__actions">
<van-button type="primary" block round :loading="saving" @click="onNext">下一步</van-button>
</div>
<van-popup v-model:show="picker.show" position="bottom" round>
<van-picker :columns="picker.columns" @cancel="picker.show = false" @confirm="onPickerConfirm" />
</van-popup>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { showConfirmDialog } 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 UiSectionHeader from '@/components/UiSectionHeader.vue'
const router = useRouter()
const appStore = useAppStore()
const IMPORTANCE_OPTIONS = [
{ text: '必须符合', value: 'required' },
{ text: '最好符合', value: 'preferred' },
]
const loading = ref(true)
const saving = ref(false)
const pref = reactive({
ageMin: '',
ageMax: '',
agePriority: 'preferred',
heightMin: '',
heightMax: '',
heightPriority: 'preferred',
maritalStatusAccepted: [] as string[],
maritalPriority: 'preferred',
educationCode: '',
educationPriority: 'preferred',
childrenAcceptance: 'any',
smokingPreference: '',
drinkingPreference: '',
marriagePlanPreference: '',
})
const MARITAL_OPTIONS = [
{ text: '未婚', value: 'never_married' },
{ text: '离异', value: 'divorced' },
{ text: '丧偶', value: 'widowed' },
]
const EDUCATION_OPTIONS = [
{ text: '不限', value: '' },
{ text: '高中/中专以上', value: 'senior_high' },
{ text: '大专以上', value: 'college' },
{ text: '本科以上', value: 'bachelor' },
{ text: '研究生以上', value: 'master' },
]
const CHILDREN_OPTIONS = [
{ text: '不限', value: 'any' },
{ text: '可接受', value: 'accept' },
{ text: '不接受', value: 'reject' },
]
const SMOKING_OPTIONS = [
{ text: '不限', value: '' },
{ text: '不吸烟', value: 'never' },
{ text: '偶尔', value: 'sometimes' },
{ text: '经常', value: 'often' },
]
const DRINKING_OPTIONS = [
{ text: '不限', value: '' },
{ text: '不饮酒', value: 'never' },
{ text: '偶尔', value: 'sometimes' },
{ text: '经常', value: 'often' },
]
const MARRIAGE_PLAN_OPTIONS = [
{ text: '不限', value: '' },
{ text: '半年内', value: 'half_year' },
{ text: '1年内', value: 'one_year' },
{ text: '1~2年', value: 'one_to_two_years' },
{ text: '顺其自然', value: 'let_it_be' },
]
function labelOf(options: Array<{ text: string; value: string }>, value: string): string {
return options.find((o) => o.value === value)?.text || ''
}
const educationLabel = computed(() => labelOf(EDUCATION_OPTIONS, pref.educationCode) || '不限')
const childrenLabel = computed(() => labelOf(CHILDREN_OPTIONS, pref.childrenAcceptance) || '不限')
const smokingLabel = computed(() => labelOf(SMOKING_OPTIONS, pref.smokingPreference) || '不限')
const drinkingLabel = computed(() => labelOf(DRINKING_OPTIONS, pref.drinkingPreference) || '不限')
const marriagePlanLabel = computed(() => labelOf(MARRIAGE_PLAN_OPTIONS, pref.marriagePlanPreference) || '不限')
const picker = reactive<{ show: boolean; key: string; columns: any[] }>({ show: false, key: '', columns: [] })
const PICKER_COLUMNS: Record<string, any[]> = {
education: EDUCATION_OPTIONS,
children: CHILDREN_OPTIONS,
smoking: SMOKING_OPTIONS,
drinking: DRINKING_OPTIONS,
marriagePlan: MARRIAGE_PLAN_OPTIONS,
}
function openPicker(key: string) {
picker.key = key
picker.columns = PICKER_COLUMNS[key]
picker.show = true
}
function onPickerConfirm({ selectedOptions }: any) {
const value = selectedOptions?.[0]?.value
const key = picker.key
picker.show = false
if (value === undefined) return
if (key === 'education') pref.educationCode = value
else if (key === 'children') pref.childrenAcceptance = value || 'any'
else if (key === 'smoking') pref.smokingPreference = value
else if (key === 'drinking') pref.drinkingPreference = value
else if (key === 'marriagePlan') pref.marriagePlanPreference = value
}
function toggleMarital(code: string) {
const list = [...pref.maritalStatusAccepted]
const idx = list.indexOf(code)
if (idx >= 0) list.splice(idx, 1)
else list.push(code)
pref.maritalStatusAccepted = list
}
/** Required 不可自动放宽:必须符合 → 最好符合 需二次确认 */
async function setImportance(field: string, value: string) {
if ((pref as any)[field] === 'required' && value === 'preferred') {
try {
await showConfirmDialog({
title: '确认放宽此条件?',
message: '"必须符合"的条件不会被系统放宽。改为"最好符合"后,可能匹配到略不满足此条件的对象。',
})
} catch {
return
}
}
;(pref as any)[field] = value
}
function toApiPayload() {
const num = (v: string) => (v === '' || v === null ? null : Number(v))
return {
minAge: num(pref.ageMin),
maxAge: num(pref.ageMax),
agePriority: pref.agePriority,
minHeightCm: num(pref.heightMin),
maxHeightCm: num(pref.heightMax),
heightPriority: pref.heightPriority,
maritalStatuses: pref.maritalStatusAccepted,
maritalPriority: pref.maritalPriority,
minEducationCode: pref.educationCode || null,
educationPriority: pref.educationPriority,
childrenAcceptance: pref.childrenAcceptance,
smokingPreference: pref.smokingPreference || null,
drinkingPreference: pref.drinkingPreference || null,
marriagePlanPreference: pref.marriagePlanPreference || null,
}
}
function fromApiPayload(p: any) {
if (!p) return
const str = (v: any) => (v === undefined || v === null ? '' : String(v))
pref.ageMin = str(p.minAge)
pref.ageMax = str(p.maxAge)
pref.agePriority = p.agePriority || 'preferred'
pref.heightMin = str(p.minHeightCm)
pref.heightMax = str(p.maxHeightCm)
pref.heightPriority = p.heightPriority || 'preferred'
pref.maritalStatusAccepted = p.maritalStatuses || []
pref.maritalPriority = p.maritalPriority || 'preferred'
pref.educationCode = p.minEducationCode || ''
pref.educationPriority = p.educationPriority || 'preferred'
pref.childrenAcceptance = p.childrenAcceptance || 'any'
pref.smokingPreference = p.smokingPreference || ''
pref.drinkingPreference = p.drinkingPreference || ''
pref.marriagePlanPreference = p.marriagePlanPreference || ''
}
async function load() {
loading.value = true
try {
const p: any = await profileService.getPreferences()
fromApiPayload(p)
} catch {
/* 首次填写 */
} finally {
loading.value = false
}
}
async function onNext() {
if (saving.value) return
if (!pref.ageMin || !pref.ageMax) return appStore.toast('请填写年龄范围')
saving.value = true
try {
await profileService.updatePreferences(toApiPayload())
router.push('/profile/confirm')
} catch (err: any) {
appStore.toastError(err?.message || '保存失败')
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.pf__hint {
font-size: 12px;
color: var(--mini-text-secondary);
margin: 0 0 8px;
line-height: 1.4;
}
.pf {
padding: 16px;
}
.card {
background: var(--mini-surface);
border-radius: var(--mini-radius);
box-shadow: var(--mini-shadow);
padding: 4px 16px 14px;
margin-bottom: 12px;
}
.pf__range {
display: flex;
align-items: center;
gap: 8px;
}
.pf__range-field {
flex: 1;
padding-left: 0;
padding-right: 0;
}
.pf__dash {
color: var(--mini-text-muted);
}
.pf__unit {
font-size: 13px;
color: var(--mini-text-secondary);
}
.pf__tags {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 4px 0 8px;
}
.pf__tag {
font-size: 13px;
color: var(--mini-text);
background: var(--mini-bg);
border-radius: 16px;
padding: 7px 14px;
border: 1px solid transparent;
}
.pf__tag--selected {
color: var(--mini-tabbar-active);
background: rgba(244, 0, 90, 0.08);
border-color: var(--mini-tabbar-active);
}
.pf__importance {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
.pf__importance-label {
font-size: 12px;
color: var(--mini-text-muted);
}
.pf__imp {
font-size: 12px;
color: var(--mini-text-secondary);
background: var(--mini-bg);
border-radius: 12px;
padding: 4px 12px;
}
.pf__imp--active {
color: #fff;
background: var(--mini-tabbar-active);
}
.card :deep(.van-field) {
padding-top: 10px;
padding-bottom: 10px;
}
.pf__actions {
margin-top: 8px;
}
</style>