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,181 @@
|
||||
<template>
|
||||
<PageLayout title="活动签到">
|
||||
<UiLoading v-if="loading" text="生成签到码中…" />
|
||||
<UiErrorState v-else-if="error" :message="errorMessage" @retry="load" />
|
||||
<template v-else>
|
||||
<div class="ck">
|
||||
<div class="ck__card">
|
||||
<div class="ck__activity">
|
||||
<div class="ck__title">{{ token.activityTitle || '活动签到' }}</div>
|
||||
<div v-if="token.locationName" class="ck__location">📍 {{ token.locationName }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ck__qr">
|
||||
<div class="ck__qr-icon">▣</div>
|
||||
<div class="ck__qr-hint">请向工作人员展示此签到码</div>
|
||||
</div>
|
||||
|
||||
<div class="ck__token">
|
||||
<div class="ck__token-label">签到码</div>
|
||||
<div class="ck__token-value">{{ token.token }}</div>
|
||||
</div>
|
||||
|
||||
<div class="ck__expire">有效期至 {{ formatDateTime(token.expiresAt) || '—' }}</div>
|
||||
</div>
|
||||
|
||||
<van-button type="primary" block round @click="copyToken">复制签到码</van-button>
|
||||
<van-button class="ck__refresh" plain block round :loading="refreshing" @click="refresh">
|
||||
刷新签到码
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const activityId = computed(() => String(route.query.id || ''))
|
||||
const token = ref<Record<string, any>>({})
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const error = ref(false)
|
||||
const errorMessage = ref('生成签到码失败')
|
||||
|
||||
async function fetchToken(isRefresh: boolean) {
|
||||
if (isRefresh) refreshing.value = true
|
||||
else loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
error.value = true
|
||||
errorMessage.value = '缺少活动信息'
|
||||
return
|
||||
}
|
||||
token.value = (await activityService.generateCheckinToken(activityId.value)) || {}
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
errorMessage.value = err?.message || '生成签到码失败'
|
||||
if (isRefresh) appStore.toastError(errorMessage.value)
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
fetchToken(false)
|
||||
}
|
||||
function refresh() {
|
||||
fetchToken(true)
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
const value = token.value?.token
|
||||
if (!value) return
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
} else {
|
||||
const el = document.createElement('textarea')
|
||||
el.value = value
|
||||
document.body.appendChild(el)
|
||||
el.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(el)
|
||||
}
|
||||
appStore.toastSuccess('签到码已复制')
|
||||
} catch {
|
||||
appStore.toastError('复制失败,请长按手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ck {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
.ck__card {
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ck__activity {
|
||||
text-align: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ck__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.ck__location {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ck__qr {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
.ck__qr-icon {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 12px;
|
||||
background: var(--mini-bg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 64px;
|
||||
color: var(--mini-text-muted);
|
||||
}
|
||||
.ck__qr-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
.ck__token {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: var(--mini-bg);
|
||||
border-radius: var(--mini-radius);
|
||||
}
|
||||
.ck__token-label {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
.ck__token-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
word-break: break-all;
|
||||
margin-top: 6px;
|
||||
-webkit-user-select: all;
|
||||
user-select: all;
|
||||
}
|
||||
.ck__expire {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.ck__refresh {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<PageLayout title="活动详情">
|
||||
<UiLoading v-if="loading" text="正在加载活动…" />
|
||||
<UiErrorState v-else-if="error" message="活动信息加载失败" @retry="load" />
|
||||
<UiEmpty v-else-if="!activity" description="该活动不存在或已下线" />
|
||||
<template v-else>
|
||||
<div class="ad">
|
||||
<div class="ad__hero">
|
||||
<div class="ad__badge">{{ typeLabel }}</div>
|
||||
<div class="ad__title">{{ activity.title }}</div>
|
||||
<div class="ad__slogan">真实相识 · 公益免费 · 尊重意愿</div>
|
||||
</div>
|
||||
|
||||
<div class="ad__block">
|
||||
<UiSectionHeader title="活动信息" />
|
||||
<div class="ad__card">
|
||||
<div v-if="activity.activityId" class="ad__row">
|
||||
<span class="ad__label">活动编号</span>
|
||||
<span class="ad__value">{{ activity.activityId }}</span>
|
||||
</div>
|
||||
<div class="ad__feature">
|
||||
<div class="ad__icon">时</div>
|
||||
<div class="ad__feature-copy">
|
||||
<div class="ad__feature-title">{{ formatDateTime(activity.startAt) || '时间待定' }}</div>
|
||||
<div class="ad__feature-desc">请提前 15 分钟到场签到</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ad__feature">
|
||||
<div class="ad__icon">址</div>
|
||||
<div class="ad__feature-copy">
|
||||
<div class="ad__feature-title">{{ activity.locationName || '地点待定' }}</div>
|
||||
<div class="ad__feature-desc">
|
||||
{{ activity.locationAddress || '报名通过后显示详细地址' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ad__feature">
|
||||
<div class="ad__icon">额</div>
|
||||
<div class="ad__feature-copy">
|
||||
<div class="ad__feature-title">{{ quotaText }}</div>
|
||||
<div class="ad__feature-desc">报名提交后需等待工作人员审核</div>
|
||||
</div>
|
||||
<UiTag type="primary">公益免费</UiTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activity.description" class="ad__block">
|
||||
<UiSectionHeader title="活动介绍" />
|
||||
<div class="ad__card">
|
||||
<p class="ad__body">{{ activity.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ad__block">
|
||||
<UiSectionHeader title="报名须知" />
|
||||
<div class="ad__card">
|
||||
<div v-for="(r, i) in requirements" :key="i" class="ad__rule">
|
||||
<span class="ad__rule-index">{{ i + 1 }}</span>
|
||||
<span class="ad__rule-text">{{ r }}</span>
|
||||
</div>
|
||||
<div class="ad__notice">
|
||||
不同活动类型的资格要求可能不同,最终以本活动页面和审核结果为准。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ad__block">
|
||||
<UiSectionHeader title="主办信息" />
|
||||
<div class="ad__card">
|
||||
<div class="ad__feature-title">善缘丹阳公益婚恋服务平台</div>
|
||||
<div class="ad__feature-desc">主办单位关系不会自动授予用户资料访问权限。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ad__action">
|
||||
<template v-if="!isRegistered">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:disabled="!!activity.isFull"
|
||||
@click="goRegister"
|
||||
>
|
||||
{{ activity.isFull ? '名额已满' : '报名参加活动' }}
|
||||
</van-button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="ad__action-status">
|
||||
<UiTag :type="statusTag.type">{{ statusTag.text }}</UiTag>
|
||||
<span class="ad__action-hint">{{ checkinText }}</span>
|
||||
</div>
|
||||
<div class="ad__action-btns">
|
||||
<van-button size="small" plain @click="goMyActivities">我的活动</van-button>
|
||||
<van-button v-if="canCheckin" size="small" type="primary" @click="goCheckin">签到</van-button>
|
||||
<van-button size="small" plain type="primary" @click="goFeedback">活动反馈</van-button>
|
||||
<van-button
|
||||
v-if="canCancel"
|
||||
size="small"
|
||||
plain
|
||||
type="danger"
|
||||
:loading="cancelling"
|
||||
@click="onCancel"
|
||||
>
|
||||
取消报名
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiSectionHeader from '@/components/UiSectionHeader.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 activity = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const cancelling = ref(false)
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
matchmaking: '公益联谊',
|
||||
interest_social: '兴趣社交',
|
||||
relationship_education: '婚恋课堂',
|
||||
premarital_guidance: '婚前辅导',
|
||||
marriage_custom_culture: '婚俗文化',
|
||||
anti_fraud: '反诈宣传',
|
||||
}
|
||||
const typeLabel = computed(
|
||||
() => TYPE_LABELS[activity.value?.activityType] || activity.value?.typeLabel || '公益活动',
|
||||
)
|
||||
|
||||
const registrationStatus = computed(() => String(activity.value?.registrationStatus || ''))
|
||||
const isRegistered = computed(
|
||||
() => !!activity.value?.registered || ['pending', 'approved'].includes(registrationStatus.value),
|
||||
)
|
||||
const checkinStatus = computed(() => String(activity.value?.checkinStatus || ''))
|
||||
const canCheckin = computed(
|
||||
() => registrationStatus.value === 'approved' && checkinStatus.value !== 'checked_in',
|
||||
)
|
||||
const canCancel = computed(() => ['pending', 'approved'].includes(registrationStatus.value))
|
||||
|
||||
const statusTag = computed<{ type: 'primary' | 'success' | 'warning' | 'danger' | 'info'; text: string }>(
|
||||
() => {
|
||||
switch (registrationStatus.value) {
|
||||
case 'pending':
|
||||
return { type: 'warning', text: '报名待审核' }
|
||||
case 'approved':
|
||||
return { type: 'success', text: '报名已通过' }
|
||||
case 'rejected':
|
||||
return { type: 'danger', text: '报名未通过' }
|
||||
case 'cancelled':
|
||||
return { type: 'info', text: '已取消报名' }
|
||||
default:
|
||||
return { type: 'primary', text: '已报名' }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const checkinText = computed(() => {
|
||||
if (checkinStatus.value === 'checked_in') return '已签到'
|
||||
if (registrationStatus.value === 'approved') return '待签到'
|
||||
return ''
|
||||
})
|
||||
|
||||
const quotaText = computed(() => {
|
||||
const a = activity.value || {}
|
||||
if (typeof a.remainingSeats === 'number') return `剩余 ${a.remainingSeats} 个名额`
|
||||
if (a.quotaText) return a.quotaText
|
||||
return '名额以审核结果为准'
|
||||
})
|
||||
|
||||
const requirements = computed(() => {
|
||||
const a = activity.value || {}
|
||||
const el = a.eligibility || {}
|
||||
const list: string[] = ['年满 18 周岁的成年单身人士']
|
||||
if (el.requiresIdentityVerified) list.push('需完成实名认证并通过资料审核')
|
||||
if (el.requiresLocalEligibility) list.push('需符合丹阳本地资格(户籍 / 工作 / 居住)')
|
||||
if (a.minAge || a.maxAge) {
|
||||
list.push(`年龄在 ${a.minAge ?? '不限'}-${a.maxAge ?? '不限'} 周岁之间`)
|
||||
}
|
||||
if (a.needReview !== false) list.push('报名提交后需经工作人员审核通过')
|
||||
return list
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
activity.value = null
|
||||
return
|
||||
}
|
||||
activity.value = await activityService.getActivityDetail(activityId.value)
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载活动失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goRegister() {
|
||||
if (!activityId.value) return
|
||||
router.push({ path: '/activity/register', query: { id: activityId.value } })
|
||||
}
|
||||
function goMyActivities() {
|
||||
router.push('/my/activities')
|
||||
}
|
||||
function goCheckin() {
|
||||
router.push({ path: '/activity/checkin', query: { id: activityId.value } })
|
||||
}
|
||||
function goFeedback() {
|
||||
router.push({ path: '/activity/feedback', query: { id: activityId.value } })
|
||||
}
|
||||
|
||||
async function onCancel() {
|
||||
if (cancelling.value) return
|
||||
try {
|
||||
await showConfirmDialog({ title: '取消报名', message: '确定要取消本次活动报名吗?' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
cancelling.value = true
|
||||
try {
|
||||
await activityService.cancelRegistration(activityId.value)
|
||||
appStore.toastSuccess('已取消报名')
|
||||
await load()
|
||||
} catch (err: any) {
|
||||
appStore.toastError(err?.message || '取消失败')
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ad {
|
||||
padding: 0 16px 120px;
|
||||
}
|
||||
.ad__hero {
|
||||
background: var(--mini-brand-gradient);
|
||||
margin: 0 -16px;
|
||||
padding: 20px 16px 22px;
|
||||
color: #fff;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
}
|
||||
.ad__badge {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ad__title {
|
||||
font-size: 19px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ad__slogan {
|
||||
font-size: 12px;
|
||||
opacity: 0.85;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ad__block {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.ad__card {
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 14px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
}
|
||||
.ad__row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ad__value {
|
||||
color: var(--mini-text);
|
||||
}
|
||||
.ad__feature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.ad__icon {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 10px;
|
||||
background: rgba(244, 0, 90, 0.08);
|
||||
color: var(--mini-tabbar-active);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 14px;
|
||||
flex: none;
|
||||
}
|
||||
.ad__feature-copy {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.ad__feature-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.ad__feature-desc {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.ad__body {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--mini-text);
|
||||
}
|
||||
.ad__rule {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--mini-text);
|
||||
padding: 4px 0;
|
||||
}
|
||||
.ad__rule-index {
|
||||
color: var(--mini-tabbar-active);
|
||||
font-weight: 600;
|
||||
}
|
||||
.ad__notice {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--mini-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.ad__action {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
|
||||
background: var(--mini-surface);
|
||||
box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
.ad__action-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.ad__action-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
}
|
||||
.ad__action-btns {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<PageLayout title="活动反馈">
|
||||
<UiLoading v-if="loading" text="正在加载活动…" />
|
||||
<UiErrorState v-else-if="error" @retry="load" />
|
||||
|
||||
<!-- 已提交 -->
|
||||
<div v-else-if="submitted" class="fb__done">
|
||||
<div class="fb__done-icon">✓</div>
|
||||
<div class="fb__done-title">反馈已提交</div>
|
||||
<div class="fb__done-hint">感谢您的参与!</div>
|
||||
<div class="fb__done-actions">
|
||||
<van-button type="primary" round size="small" @click="goMatches">查看活动缘分</van-button>
|
||||
<van-button plain round size="small" @click="router.replace('/home')">返回首页</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="fb">
|
||||
<div class="fb__header">
|
||||
<div class="fb__title">活动反馈</div>
|
||||
<div class="fb__name">{{ activity?.title || '本次公益活动' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="fb__section">
|
||||
<div class="fb__section-title">整体体验</div>
|
||||
<div class="fb__options">
|
||||
<span
|
||||
v-for="o in overallOptions"
|
||||
:key="o.code"
|
||||
class="fb__option"
|
||||
:class="{ 'fb__option--active': overallExperience === o.code }"
|
||||
@click="overallExperience = o.code"
|
||||
>
|
||||
{{ o.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fb__section">
|
||||
<div class="fb__section-title">是否愿意参加下一次活动</div>
|
||||
<div class="fb__options">
|
||||
<span
|
||||
v-for="o in willingOptions"
|
||||
:key="o.code"
|
||||
class="fb__option"
|
||||
:class="{ 'fb__option--active': willingNextTime === o.code }"
|
||||
@click="willingNextTime = o.code"
|
||||
>
|
||||
{{ o.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="fb__section">
|
||||
<div class="fb__section-title">是否在活动中认识了想进一步了解的人</div>
|
||||
<div class="fb__options">
|
||||
<span
|
||||
class="fb__option"
|
||||
:class="{ 'fb__option--active': metSomeone === true }"
|
||||
@click="metSomeone = true"
|
||||
>
|
||||
是
|
||||
</span>
|
||||
<span
|
||||
class="fb__option"
|
||||
:class="{ 'fb__option--active': metSomeone === false }"
|
||||
@click="metSomeone = false"
|
||||
>
|
||||
否
|
||||
</span>
|
||||
</div>
|
||||
<div class="fb__hint">进一步了解意向将随反馈一并提交,双方互相表达后即可查看缘分。</div>
|
||||
</div>
|
||||
|
||||
<van-button
|
||||
class="fb__submit"
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:loading="submitting"
|
||||
:disabled="!overallExperience || !willingNextTime"
|
||||
@click="onSubmit"
|
||||
>
|
||||
提交反馈
|
||||
</van-button>
|
||||
<van-button class="fb__skip" plain block @click="router.back()">跳过反馈</van-button>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const activityId = computed(() => String(route.query.id || ''))
|
||||
const activity = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const overallExperience = ref('')
|
||||
const willingNextTime = ref('')
|
||||
const metSomeone = ref<boolean | null>(null)
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
|
||||
const overallOptions = [
|
||||
{ code: 'very_satisfied', label: '😊 非常满意' },
|
||||
{ code: 'satisfied', label: '🙂 满意' },
|
||||
{ code: 'normal', label: '😐 一般' },
|
||||
{ code: 'dissatisfied', label: '😕 不满意' },
|
||||
]
|
||||
const willingOptions = [
|
||||
{ code: 'willing', label: '💚 愿意' },
|
||||
{ code: 'maybe', label: '🤔 看情况' },
|
||||
{ code: 'not_now', label: '😶 暂不参加' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
activity.value = null
|
||||
return
|
||||
}
|
||||
activity.value = await activityService.getActivityDetail(activityId.value)
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载活动失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goMatches() {
|
||||
if (activityId.value) router.push({ path: '/activity/matches', query: { id: activityId.value } })
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value) return
|
||||
if (!overallExperience.value || !willingNextTime.value) {
|
||||
appStore.toast('请填写必填项')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const result: any = await activityService.submitFeedback(activityId.value, {
|
||||
overallExperience: overallExperience.value,
|
||||
willingNextTime: willingNextTime.value,
|
||||
metSomeoneInteresting: metSomeone.value,
|
||||
interestTargetUserIds: [],
|
||||
})
|
||||
submitted.value = true
|
||||
const mutuals: any[] = result?.newMutuals || []
|
||||
if (mutuals.length) {
|
||||
await showConfirmDialog({
|
||||
title: '双向匹配成功!',
|
||||
message: `您与 ${mutuals.length} 位朋友互相表达了进一步了解意愿,可前往活动缘分查看。`,
|
||||
confirmButtonText: '去查看',
|
||||
showCancelButton: false,
|
||||
}).catch(() => undefined)
|
||||
goMatches()
|
||||
}
|
||||
} catch (err: any) {
|
||||
appStore.toastError(err?.message || '提交失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fb {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
.fb__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fb__title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.fb__name {
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.fb__section {
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 14px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.fb__section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.fb__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.fb__option {
|
||||
font-size: 13px;
|
||||
padding: 7px 14px;
|
||||
border-radius: 16px;
|
||||
background: var(--mini-bg);
|
||||
color: var(--mini-text);
|
||||
border: 1px solid var(--mini-border);
|
||||
}
|
||||
.fb__option--active {
|
||||
color: var(--mini-tabbar-active);
|
||||
border-color: var(--mini-tabbar-active);
|
||||
background: rgba(244, 0, 90, 0.06);
|
||||
font-weight: 600;
|
||||
}
|
||||
.fb__hint {
|
||||
font-size: 11px;
|
||||
color: var(--mini-text-muted);
|
||||
line-height: 1.6;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.fb__submit {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.fb__skip {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.fb__done {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60px 16px;
|
||||
}
|
||||
.fb__done-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: var(--mini-success);
|
||||
color: #fff;
|
||||
font-size: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.fb__done-title {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
margin-top: 16px;
|
||||
}
|
||||
.fb__done-hint {
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.fb__done-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,140 @@
|
||||
<template>
|
||||
<PageLayout title="活动缘分">
|
||||
<div class="am">
|
||||
<div v-if="activityTitle" class="am__activity">{{ activityTitle }}</div>
|
||||
|
||||
<UiErrorState v-if="error" @retry="load" />
|
||||
<UiLoading v-else-if="loading" />
|
||||
<template v-else>
|
||||
<div v-if="matches.length">
|
||||
<div
|
||||
v-for="m in matches"
|
||||
:key="m.targetUserId"
|
||||
class="am__card"
|
||||
@click="goResult(m)"
|
||||
>
|
||||
<UiAvatar :src="avatarOf(m)" :name="m.name" :size="52" />
|
||||
<div class="am__info">
|
||||
<div class="am__name">{{ m.name || '新朋友' }}</div>
|
||||
<div class="am__meta">
|
||||
<span v-if="m.age">{{ m.age }}岁</span>
|
||||
<span v-if="m.locationName">{{ m.locationName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="am__cta">
|
||||
<span class="am__cta-text">查看缘分</span>
|
||||
<van-icon name="arrow" color="#9aa0b5" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiEmpty v-else description="暂无双向匹配">
|
||||
<van-button type="primary" round size="small" @click="router.back()">返回</van-button>
|
||||
</UiEmpty>
|
||||
</template>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import PageLayout from '@/layouts/PageLayout.vue'
|
||||
import UiAvatar from '@/components/UiAvatar.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 matches = ref<any[]>([])
|
||||
const activityTitle = ref('')
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
function avatarOf(m: any): string {
|
||||
const raw = m.avatarUrl || m.fileKey
|
||||
return typeof raw === 'string' && raw.includes('://') ? raw : ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
matches.value = []
|
||||
return
|
||||
}
|
||||
const [result, detail]: any[] = await Promise.all([
|
||||
activityService.getMutualMatches(activityId.value),
|
||||
activityService.getActivityDetail(activityId.value).catch(() => null),
|
||||
])
|
||||
matches.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
|
||||
activityTitle.value = detail?.title || ''
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载活动缘分失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goResult(m: any) {
|
||||
if (!m?.targetUserId) return
|
||||
router.push({
|
||||
path: '/activity/mutual/result',
|
||||
query: { id: activityId.value, partnerId: m.targetUserId },
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.am {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
.am__activity {
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.am__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);
|
||||
}
|
||||
.am__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.am__name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.am__meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
.am__cta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--mini-tabbar-active);
|
||||
flex: none;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<PageLayout title="等待确认">
|
||||
<div class="mw">
|
||||
<UiErrorState v-if="error" @retry="load" />
|
||||
<UiLoading v-else-if="loading" />
|
||||
|
||||
<UiEmpty v-else-if="!status.exists" description="未找到该活动的互选记录" />
|
||||
|
||||
<template v-else>
|
||||
<div class="mw__partner">
|
||||
<UiAvatar :src="partnerAvatar" :name="partnerName" :size="60" />
|
||||
<div class="mw__partner-name">{{ partnerName }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="bothConfirmed" class="mw__panel mw__panel--success">
|
||||
<div class="mw__icon mw__icon--success">✓</div>
|
||||
<div class="mw__title">双方已确认!</div>
|
||||
<div class="mw__hint">
|
||||
{{ hasActiveMatch ? '你们已有沟通入口,可前往缘分中心查看' : '沟通入口已开启,可前往缘分中心查看' }}
|
||||
</div>
|
||||
<van-button type="primary" round @click="goFate">
|
||||
{{ hasActiveMatch ? '查看已有沟通' : '查看沟通' }}
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="partnerDeclined" class="mw__panel">
|
||||
<div class="mw__icon">💤</div>
|
||||
<div class="mw__title">对方暂不联系</div>
|
||||
<div class="mw__hint">对方已选择暂不开通沟通,您可以稍后再看看其他缘分。</div>
|
||||
<van-button round plain @click="router.back()">返回</van-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="mw__panel">
|
||||
<div class="mw__icon">⏳</div>
|
||||
<div class="mw__title">已发送邀请,等待对方确认</div>
|
||||
<div class="mw__hint">对方确认后将收到平台通知;本页面会自动刷新状态</div>
|
||||
<div class="mw__actions">
|
||||
<van-button type="primary" round :loading="refreshing" @click="refresh">
|
||||
刷新状态
|
||||
</van-button>
|
||||
<van-button round plain type="danger" :loading="declining" @click="onDecline">
|
||||
暂不联系
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, 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 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 POLL_MS = 10000
|
||||
|
||||
const activityId = computed(() => String(route.query.id || ''))
|
||||
const partnerId = computed(() => String(route.query.partnerId || ''))
|
||||
|
||||
const loading = ref(true)
|
||||
const refreshing = ref(false)
|
||||
const declining = ref(false)
|
||||
const error = ref(false)
|
||||
const status = ref<any>({ exists: false })
|
||||
const partnerName = ref('新朋友')
|
||||
const partnerAvatar = ref('')
|
||||
const hasActiveMatch = ref(false)
|
||||
|
||||
let timer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const bothConfirmed = computed(() => !!status.value?.bothConfirmed)
|
||||
const partnerDeclined = computed(() => status.value?.partnerStatus === 'declined')
|
||||
|
||||
async function fetchStatus(isRefresh: boolean) {
|
||||
if (isRefresh) refreshing.value = true
|
||||
else loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value || !partnerId.value) {
|
||||
status.value = { exists: false }
|
||||
return
|
||||
}
|
||||
const res: any = await activityMutualService.getMyConsentStatus(activityId.value, partnerId.value)
|
||||
status.value = res || { exists: false }
|
||||
// 对方确认后拉取一次结果,用于复用已有沟通(INV-019),并停止轮询
|
||||
if (res?.bothConfirmed) {
|
||||
await loadResult()
|
||||
stopPoll()
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
if (isRefresh) appStore.toastError(err?.message || '刷新失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadResult() {
|
||||
try {
|
||||
const list: any = await activityMutualService.getResults(activityId.value)
|
||||
const arr: any[] = list?.items || list?.records || (Array.isArray(list) ? list : [])
|
||||
const me = arr.find((x) => (x.partnerId || x.targetUserId) === partnerId.value)
|
||||
if (me) {
|
||||
partnerName.value = me.partnerName || me.name || partnerName.value
|
||||
hasActiveMatch.value = !!me.hasActiveMatch
|
||||
}
|
||||
} catch {
|
||||
/* 结果信息可选,不影响等待状态 */
|
||||
}
|
||||
}
|
||||
|
||||
function startPoll() {
|
||||
if (timer) return
|
||||
timer = setInterval(() => {
|
||||
if (bothConfirmed.value) return
|
||||
fetchStatus(true)
|
||||
}, POLL_MS)
|
||||
}
|
||||
function stopPoll() {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
fetchStatus(false)
|
||||
}
|
||||
function refresh() {
|
||||
fetchStatus(true)
|
||||
}
|
||||
|
||||
async function onDecline() {
|
||||
if (declining.value) return
|
||||
declining.value = true
|
||||
try {
|
||||
await activityMutualService.declineConnection(activityId.value, partnerId.value)
|
||||
appStore.toastSuccess('已选择暂不联系')
|
||||
router.back()
|
||||
} catch (err: any) {
|
||||
appStore.toastError(err?.message || '操作失败')
|
||||
} finally {
|
||||
declining.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goFate() {
|
||||
router.push('/fate-center')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
load()
|
||||
startPoll()
|
||||
})
|
||||
onUnmounted(stopPoll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mw {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
.mw__partner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 28px 0 12px;
|
||||
}
|
||||
.mw__partner-name {
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.mw__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 28px 16px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
text-align: center;
|
||||
}
|
||||
.mw__panel--success {
|
||||
background: rgba(47, 179, 109, 0.08);
|
||||
}
|
||||
.mw__icon {
|
||||
font-size: 44px;
|
||||
}
|
||||
.mw__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;
|
||||
}
|
||||
.mw__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.mw__hint {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.mw__actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="act">
|
||||
<div class="act__hero">
|
||||
<div class="act__hero-top">
|
||||
<div class="act__title">公益活动</div>
|
||||
<span class="act__my" @click="router.push('/my/activities')">
|
||||
<van-icon name="calendar-o" /> 我的活动
|
||||
</span>
|
||||
</div>
|
||||
<div class="act__cats">
|
||||
<span
|
||||
v-for="c in categories"
|
||||
:key="c.code"
|
||||
class="act__cat"
|
||||
:class="{ 'act__cat--active': activeCategory === c.code }"
|
||||
@click="activeCategory = c.code"
|
||||
>
|
||||
{{ c.label }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="act__heading">
|
||||
<div class="act__heading-title">丹阳公益活动</div>
|
||||
<div class="act__heading-sub">丰富活动,连接更多可能</div>
|
||||
</div>
|
||||
|
||||
<div class="act__body">
|
||||
<UiErrorState v-if="error" @retry="load" />
|
||||
<UiLoading v-else-if="firstLoading" />
|
||||
<template v-else>
|
||||
<div v-if="filtered.length">
|
||||
<div
|
||||
v-for="a in filtered"
|
||||
:key="a.id || a.activityId"
|
||||
class="act__card"
|
||||
@click="goDetail(a)"
|
||||
>
|
||||
<div class="act__cover">{{ (a.title || '活').slice(0, 1) }}</div>
|
||||
<div class="act__info">
|
||||
<div class="act__cat-tag">{{ categoryLabel(a) }}</div>
|
||||
<div class="act__card-title">{{ a.title }}</div>
|
||||
<div class="act__meta">
|
||||
<span>{{ formatDateTime(a.activityStartAt || a.startAt) }}</span>
|
||||
<span v-if="a.locationName || a.location">{{ a.locationName || a.location }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<van-icon name="arrow" color="#9aa0b5" />
|
||||
</div>
|
||||
</div>
|
||||
<UiEmpty v-else description="暂无进行中的活动" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import UiLoading from '@/components/UiLoading.vue'
|
||||
import UiEmpty from '@/components/UiEmpty.vue'
|
||||
import UiErrorState from '@/components/UiErrorState.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const categories = [
|
||||
{ code: 'all', label: '全部' },
|
||||
{ code: 'matchmaking', label: '公益联谊' },
|
||||
{ code: 'interest_social', label: '兴趣社交' },
|
||||
{ code: 'relationship_education', label: '婚恋课堂' },
|
||||
{ code: 'premarital_guidance', label: '婚前辅导' },
|
||||
{ code: 'marriage_custom_culture', label: '婚俗文化' },
|
||||
{ code: 'anti_fraud', label: '反诈宣传' },
|
||||
]
|
||||
|
||||
const activities = ref<any[]>([])
|
||||
const activeCategory = ref('all')
|
||||
const firstLoading = ref(true)
|
||||
const error = ref(false)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (activeCategory.value === 'all') return activities.value
|
||||
return activities.value.filter((a) => (a.activityType || a.category) === activeCategory.value)
|
||||
})
|
||||
|
||||
function categoryLabel(a: any) {
|
||||
const code = a.activityType || a.category
|
||||
return categories.find((c) => c.code === code)?.label || '公益活动'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
firstLoading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const result: any = await activityService.getActivities({ pageSize: 50 })
|
||||
activities.value = result?.items || result?.records || result || []
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载活动失败')
|
||||
} finally {
|
||||
firstLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(a: any) {
|
||||
const id = a.id || a.activityId
|
||||
if (id) router.push({ path: '/activity/detail', query: { id } })
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.act {
|
||||
min-height: 100vh;
|
||||
background: var(--mini-bg);
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
.act__hero {
|
||||
background: var(--mini-brand-gradient);
|
||||
padding: 18px 16px 16px;
|
||||
color: #fff;
|
||||
border-bottom-left-radius: 20px;
|
||||
border-bottom-right-radius: 20px;
|
||||
}
|
||||
.act__hero-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.act__title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.act__my {
|
||||
font-size: 13px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
padding: 6px 12px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.act__cats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
margin-top: 14px;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
.act__cat {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
color: #fff;
|
||||
}
|
||||
.act__cat--active {
|
||||
background: #fff;
|
||||
color: var(--mini-tabbar-active);
|
||||
font-weight: 600;
|
||||
}
|
||||
.act__heading {
|
||||
padding: 16px 16px 4px;
|
||||
}
|
||||
.act__heading-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.act__heading-sub {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.act__body {
|
||||
padding: 8px 16px 0;
|
||||
}
|
||||
.act__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);
|
||||
}
|
||||
.act__cover {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(135deg, #8a6cff 0%, #6244e0 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
flex: none;
|
||||
}
|
||||
.act__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.act__cat-tag {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
color: var(--mini-tabbar-active);
|
||||
background: rgba(244, 0, 90, 0.08);
|
||||
padding: 1px 8px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.act__card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.act__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<PageLayout title="活动报名">
|
||||
<UiLoading v-if="loading" text="正在加载活动…" />
|
||||
<UiErrorState v-else-if="error" @retry="load" />
|
||||
<UiEmpty v-else-if="!activity" description="该活动不存在或已下线" />
|
||||
<template v-else>
|
||||
<div class="reg">
|
||||
<div class="reg__card">
|
||||
<div class="reg__title">{{ activity.title }}</div>
|
||||
<div class="reg__meta">
|
||||
<span>{{ formatDateTime(activity.startAt) || '时间待定' }}</span>
|
||||
<span v-if="activity.locationName">{{ activity.locationName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reg__card">
|
||||
<div class="reg__label">备注 / 留言(选填)</div>
|
||||
<van-field
|
||||
v-model="remark"
|
||||
type="textarea"
|
||||
rows="3"
|
||||
maxlength="200"
|
||||
show-word-limit
|
||||
placeholder="可填写方便工作人员联系的信息或特殊说明"
|
||||
autosize
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="reg__notice">
|
||||
报名提交后需等待工作人员审核;不同活动类型的资格要求以活动页面和审核结果为准。
|
||||
</div>
|
||||
|
||||
<van-button
|
||||
class="reg__submit"
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
:loading="submitting"
|
||||
@click="onSubmit"
|
||||
>
|
||||
提交报名
|
||||
</van-button>
|
||||
</div>
|
||||
</template>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import PageLayout from '@/layouts/PageLayout.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 activity = ref<any>(null)
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const remark = ref('')
|
||||
const submitting = ref(false)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
if (!activityId.value) {
|
||||
activity.value = null
|
||||
return
|
||||
}
|
||||
activity.value = await activityService.getActivityDetail(activityId.value)
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载活动失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (submitting.value || !activityId.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = remark.value.trim() ? { remark: remark.value.trim() } : {}
|
||||
await activityService.register(activityId.value, payload)
|
||||
appStore.toastSuccess('报名已提交,请等待审核')
|
||||
router.replace({ path: '/my/activities' })
|
||||
} catch (err: any) {
|
||||
appStore.toastError(err?.message || '报名失败')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reg {
|
||||
padding: 16px 16px 24px;
|
||||
}
|
||||
.reg__card {
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 14px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.reg__title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
}
|
||||
.reg__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.reg__label {
|
||||
font-size: 13px;
|
||||
color: var(--mini-text-strong);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.reg__card :deep(.van-field) {
|
||||
background: var(--mini-bg);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
.reg__notice {
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-muted);
|
||||
line-height: 1.6;
|
||||
margin: 4px 4px 16px;
|
||||
}
|
||||
.reg__submit {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<PageLayout title="我的活动">
|
||||
<div class="ma">
|
||||
<div class="ma__filters">
|
||||
<span
|
||||
v-for="f in filters"
|
||||
:key="f.code"
|
||||
class="ma__chip"
|
||||
:class="{ 'ma__chip--active': activeFilter === f.code }"
|
||||
@click="activeFilter = f.code"
|
||||
>
|
||||
{{ f.label }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<UiErrorState v-if="error" @retry="load" />
|
||||
<UiLoading v-else-if="loading" />
|
||||
<template v-else>
|
||||
<div v-if="filtered.length">
|
||||
<div
|
||||
v-for="a in filtered"
|
||||
:key="a.activityId"
|
||||
class="ma__card"
|
||||
@click="goDetail(a)"
|
||||
>
|
||||
<div class="ma__top">
|
||||
<div class="ma__title">{{ a.title }}</div>
|
||||
<UiTag :type="statusTag(a.status).type">{{ statusTag(a.status).text }}</UiTag>
|
||||
</div>
|
||||
<div class="ma__meta">
|
||||
<span>{{ formatDateTime(a.startAt) || '时间待定' }}</span>
|
||||
<span v-if="a.locationName">{{ a.locationName }}</span>
|
||||
</div>
|
||||
<div v-if="a.checkinStatus === 'checked_in'" class="ma__badge ma__badge--done">已签到</div>
|
||||
<div class="ma__actions" @click.stop>
|
||||
<van-button size="mini" plain @click="goDetail(a)">详情</van-button>
|
||||
<van-button
|
||||
v-if="canCheckin(a)"
|
||||
size="mini"
|
||||
type="primary"
|
||||
@click="goCheckin(a)"
|
||||
>
|
||||
签到
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="a.status === 'approved'"
|
||||
size="mini"
|
||||
plain
|
||||
type="primary"
|
||||
@click="goFeedback(a)"
|
||||
>
|
||||
反馈
|
||||
</van-button>
|
||||
<van-button
|
||||
v-if="canCancel(a)"
|
||||
size="mini"
|
||||
plain
|
||||
type="danger"
|
||||
:loading="cancelling === a.activityId"
|
||||
@click="onCancel(a)"
|
||||
>
|
||||
取消报名
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UiEmpty v-else description="还没有报名任何活动">
|
||||
<van-button type="primary" round size="small" @click="router.push('/activity')">
|
||||
去逛公益活动
|
||||
</van-button>
|
||||
</UiEmpty>
|
||||
</template>
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { activityService } from '@/api/services'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { showConfirmDialog } from 'vant'
|
||||
import { formatDateTime } from '@/utils/datetime'
|
||||
import PageLayout from '@/layouts/PageLayout.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 router = useRouter()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const filters = [
|
||||
{ code: 'all', label: '全部' },
|
||||
{ code: 'pending', label: '待审核' },
|
||||
{ code: 'approved', label: '已通过' },
|
||||
]
|
||||
const activeFilter = ref('all')
|
||||
|
||||
const list = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const error = ref(false)
|
||||
const cancelling = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (activeFilter.value === 'all') return list.value
|
||||
return list.value.filter((a) => a.status === activeFilter.value)
|
||||
})
|
||||
|
||||
function statusTag(status: string): { type: 'primary' | 'success' | 'warning' | 'danger' | 'info'; text: string } {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { type: 'warning', text: '待审核' }
|
||||
case 'approved':
|
||||
return { type: 'success', text: '已通过' }
|
||||
case 'rejected':
|
||||
return { type: 'danger', text: '未通过' }
|
||||
case 'cancelled':
|
||||
return { type: 'info', text: '已取消' }
|
||||
default:
|
||||
return { type: 'info', text: '未知状态' }
|
||||
}
|
||||
}
|
||||
|
||||
function canCheckin(a: any): boolean {
|
||||
return a.status === 'approved' && a.checkinStatus !== 'checked_in'
|
||||
}
|
||||
function canCancel(a: any): boolean {
|
||||
return a.status === 'pending' || a.status === 'approved'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = false
|
||||
try {
|
||||
const result: any = await activityService.getMyActivities({ pageSize: 50 })
|
||||
list.value = result?.items || result?.records || (Array.isArray(result) ? result : [])
|
||||
} catch (err: any) {
|
||||
error.value = true
|
||||
appStore.toastError(err?.message || '加载我的活动失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(a: any) {
|
||||
if (a?.activityId) router.push({ path: '/activity/detail', query: { id: a.activityId } })
|
||||
}
|
||||
function goCheckin(a: any) {
|
||||
if (a?.activityId) router.push({ path: '/activity/checkin', query: { id: a.activityId } })
|
||||
}
|
||||
function goFeedback(a: any) {
|
||||
if (a?.activityId) router.push({ path: '/activity/feedback', query: { id: a.activityId } })
|
||||
}
|
||||
|
||||
async function onCancel(a: any) {
|
||||
if (cancelling.value) return
|
||||
try {
|
||||
await showConfirmDialog({ title: '取消报名', message: `确定取消「${a.title}」的报名吗?` })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
cancelling.value = a.activityId
|
||||
try {
|
||||
await activityService.cancelRegistration(a.activityId)
|
||||
appStore.toastSuccess('已取消报名')
|
||||
const target = list.value.find((x) => x.activityId === a.activityId)
|
||||
if (target) target.status = 'cancelled'
|
||||
} catch (err: any) {
|
||||
appStore.toastError(err?.message || '取消失败')
|
||||
} finally {
|
||||
cancelling.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ma {
|
||||
padding: 12px 16px 16px;
|
||||
}
|
||||
.ma__filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ma__chip {
|
||||
font-size: 12px;
|
||||
padding: 5px 12px;
|
||||
border-radius: 14px;
|
||||
background: var(--mini-surface);
|
||||
color: var(--mini-text-secondary);
|
||||
border: 1px solid var(--mini-border);
|
||||
}
|
||||
.ma__chip--active {
|
||||
color: var(--mini-tabbar-active);
|
||||
border-color: var(--mini-tabbar-active);
|
||||
background: rgba(244, 0, 90, 0.06);
|
||||
}
|
||||
.ma__card {
|
||||
position: relative;
|
||||
background: var(--mini-surface);
|
||||
border-radius: var(--mini-radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 10px;
|
||||
box-shadow: var(--mini-shadow);
|
||||
}
|
||||
.ma__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.ma__title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--mini-text-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.ma__meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: var(--mini-text-secondary);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.ma__badge {
|
||||
position: absolute;
|
||||
right: 14px;
|
||||
top: 44px;
|
||||
font-size: 11px;
|
||||
color: var(--mini-success);
|
||||
}
|
||||
.ma__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user