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,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>
+235
View File
@@ -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>