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,309 @@
<template>
<PageLayout title="活动互选">
<div class="mr">
<UiErrorState v-if="error" @retry="load" />
<UiLoading v-else-if="loading" />
<!-- 单个互选对象:确认 / 等待 / 已确认 -->
<template v-else-if="hasPartner">
<div class="mr__partner">
<UiAvatar :src="partnerAvatar" :name="partnerName" :size="60" />
<div class="mr__partner-name">{{ partnerName }}</div>
<div class="mr__partner-hint">活动互选一致,双方确认后才可开启沟通</div>
</div>
<UiEmpty v-if="!status.exists && !statusLoaded" description="未找到该活动的互选记录" />
<div v-if="bothConfirmed" class="mr__status mr__status--success">
<div class="mr__status-icon mr__status-icon--success">✓</div>
<div class="mr__status-title">双方已确认</div>
<div class="mr__status-hint">
{{ hasActiveMatch ? '你们已有沟通入口,可直接进入缘分中心查看' : '沟通入口已开启,可前往缘分中心查看' }}
</div>
<van-button type="primary" round @click="goFate">
{{ hasActiveMatch ? '查看已有沟通' : '查看沟通' }}
</van-button>
</div>
<div v-else-if="partnerDeclined" class="mr__status">
<div class="mr__status-icon">💤</div>
<div class="mr__status-title">对方暂不联系</div>
<div class="mr__status-hint">对方已选择暂不开通沟通,您可以稍后再看看其他缘分。</div>
<van-button round plain @click="router.back()">返回</van-button>
</div>
<div v-else-if="myAccepted" class="mr__status">
<div class="mr__status-icon">⏳</div>
<div class="mr__status-title">已发送邀请</div>
<div class="mr__status-hint">对方确认后将收到平台通知</div>
<div class="mr__status-actions">
<van-button type="primary" round @click="goWaiting">查看等待状态</van-button>
<van-button round plain type="danger" :loading="acting === 'decline'" @click="onDecline">
暂不联系
</van-button>
</div>
</div>
<div v-else class="mr__status">
<div class="mr__status-icon">💬</div>
<div class="mr__status-title">向对方表达兴趣</div>
<div class="mr__status-hint">双方确认后才会开启沟通并收到平台通知</div>
<div class="mr__status-actions">
<van-button type="primary" round :loading="acting === 'confirm'" @click="onConfirm">
确认开启沟通
</van-button>
<van-button round plain @click="onDecline" :loading="acting === 'decline'">
暂不联系
</van-button>
</div>
</div>
</template>
<!-- 互选结果列表 -->
<template v-else>
<div v-if="results.length">
<div
v-for="r in results"
:key="r.partnerId"
class="mr__card"
@click="openPartner(r)"
>
<UiAvatar :src="avatarOf(r)" :name="r.partnerName" :size="48" />
<div class="mr__card-main">
<div class="mr__card-name">{{ r.partnerName || '新朋友' }}</div>
<UiTag :type="r.bothConfirmed ? 'success' : 'warning'">
{{ r.bothConfirmed ? '双方已确认' : '等待确认' }}
</UiTag>
</div>
<van-icon name="arrow" color="#9aa0b5" />
</div>
</div>
<UiEmpty v-else description="暂无互选结果">
<span class="mr__empty-hint">参加活动并向感兴趣的人表达意向后,双方一致即可在此查看</span>
</UiEmpty>
</template>
</div>
</PageLayout>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { activityMutualService } from '@/api/services'
import { useAppStore } from '@/stores/app'
import PageLayout from '@/layouts/PageLayout.vue'
import UiAvatar from '@/components/UiAvatar.vue'
import UiTag from '@/components/UiTag.vue'
import UiLoading from '@/components/UiLoading.vue'
import UiEmpty from '@/components/UiEmpty.vue'
import UiErrorState from '@/components/UiErrorState.vue'
const route = useRoute()
const router = useRouter()
const appStore = useAppStore()
const activityId = computed(() => String(route.query.id || ''))
const partnerId = computed(() => String(route.query.partnerId || ''))
const hasPartner = computed(() => !!partnerId.value)
const loading = ref(true)
const error = ref(false)
const results = ref<any[]>([])
const status = ref<any>({ exists: false })
const statusLoaded = ref(false)
const partnerName = ref('新朋友')
const partnerAvatar = ref('')
const hasActiveMatch = ref(false)
const acting = ref('')
const myAccepted = computed(() => status.value?.myStatus === 'accepted')
const bothConfirmed = computed(() => !!status.value?.bothConfirmed)
const partnerDeclined = computed(() => status.value?.partnerStatus === 'declined')
function avatarOf(r: any): string {
const raw = r.partnerAvatar || r.partnerAvatarFileId
return typeof raw === 'string' && raw.includes('://') ? raw : ''
}
async function load() {
loading.value = true
error.value = false
try {
if (!activityId.value) {
results.value = []
return
}
if (hasPartner.value) {
await loadPartner()
} else {
const result: any = await activityMutualService.getResults(activityId.value)
results.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
}
} catch (err: any) {
error.value = true
appStore.toastError(err?.message || '加载互选结果失败')
} finally {
loading.value = false
}
}
async function loadPartner() {
const [statusRes, listRes]: any[] = await Promise.all([
activityMutualService.getMyConsentStatus(activityId.value, partnerId.value).catch(() => null),
activityMutualService.getResults(activityId.value).catch(() => null),
])
status.value = statusRes || { exists: false }
statusLoaded.value = true
const list: any[] = listRes?.items || listRes?.records || (Array.isArray(listRes) ? listRes : [])
const me = list.find((x) => (x.partnerId || x.targetUserId) === partnerId.value)
if (me) {
partnerName.value = me.partnerName || me.name || '新朋友'
partnerAvatar.value = avatarOf({ partnerAvatar: me.partnerAvatar, partnerAvatarFileId: me.partnerAvatarFileId })
hasActiveMatch.value = !!me.hasActiveMatch
}
}
function openPartner(r: any) {
const pid = r.partnerId || r.targetUserId
if (!pid) return
router.push({ path: '/activity/mutual/result', query: { id: activityId.value, partnerId: pid } })
}
function goWaiting() {
router.push({ path: '/activity/mutual/waiting', query: { id: activityId.value, partnerId: partnerId.value } })
}
function goFate() {
router.push('/fate-center')
}
async function onConfirm() {
if (acting.value) return
acting.value = 'confirm'
try {
// 二阶段同意(INV-014/015):先确保同意记录存在,再提交「我方确认」。
if (!status.value?.exists) {
await activityMutualService.createConsent(activityId.value, partnerId.value)
}
const result: any = await activityMutualService.confirmConnection(activityId.value, partnerId.value)
status.value = { ...(status.value || {}), exists: true, ...result }
if (result?.bothConfirmed) {
hasActiveMatch.value = !!result?.hasActiveMatch
appStore.toastSuccess('双方已确认,可查看沟通')
} else {
appStore.toastSuccess('已发送邀请,等待对方确认')
goWaiting()
}
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
acting.value = ''
}
}
async function onDecline() {
if (acting.value) return
acting.value = 'decline'
try {
await activityMutualService.declineConnection(activityId.value, partnerId.value)
appStore.toastSuccess('已选择暂不联系')
router.back()
} catch (err: any) {
appStore.toastError(err?.message || '操作失败')
} finally {
acting.value = ''
}
}
onMounted(load)
</script>
<style scoped>
.mr {
padding: 12px 16px 16px;
}
.mr__partner {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 24px 0 8px;
}
.mr__partner-name {
font-size: 17px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__partner-hint {
font-size: 12px;
color: var(--mini-text-secondary);
}
.mr__status {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 24px 16px;
margin-top: 12px;
box-shadow: var(--mini-shadow);
text-align: center;
}
.mr__status--success {
background: rgba(47, 179, 109, 0.08);
}
.mr__status-icon {
font-size: 40px;
}
.mr__status-icon--success {
width: 52px;
height: 52px;
border-radius: 50%;
background: var(--mini-success);
color: #fff;
font-size: 26px;
display: flex;
align-items: center;
justify-content: center;
}
.mr__status-title {
font-size: 16px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__status-hint {
font-size: 12px;
color: var(--mini-text-secondary);
line-height: 1.6;
}
.mr__status-actions {
display: flex;
gap: 12px;
margin-top: 6px;
}
.mr__card {
display: flex;
align-items: center;
gap: 12px;
background: var(--mini-surface);
border-radius: var(--mini-radius);
padding: 12px;
margin-bottom: 10px;
box-shadow: var(--mini-shadow);
}
.mr__card-main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 8px;
}
.mr__card-name {
font-size: 15px;
font-weight: 600;
color: var(--mini-text-strong);
}
.mr__empty-hint {
font-size: 12px;
color: var(--mini-text-muted);
}
</style>