Files
hejiu/pages/event-detail/event-detail.vue
T
cg 81e7a3afa5 feat(event): 添加酒局报名审核功能并优化前端组件
- 新增 ApproveEvent API 接口用于审核报名用户
- 在 EventCard 组件中显示待审核状态(⏳ 待审核)
- 实现酒局发起人审核报名用户的完整流程
- 添加用户协议和隐私政策页面
- 优化 FeedCard 组件中的图片展示逻辑
- 修复连续打卡天数计算问题
- 更新 HaveADrink SDK 至 1.0.15 版本
2026-08-16 14:50:50 +08:00

739 lines
19 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view :class="themeClass" class="page-container evt-detail-page">
<!-- 顶部导航 -->
<view class="evt-nav" :style="{ paddingTop: navPaddingTop }">
<view class="evt-nav-inner">
<view class="evt-back" @click="goBack">
<text class="evt-back-icon">‹</text>
</view>
<text class="evt-nav-title">酒局详情</text>
<view class="evt-nav-ph"></view>
</view>
</view>
<scroll-view class="evt-scroll" scroll-y>
<view class="evt-body" v-if="event">
<!-- 状态 -->
<view class="evt-status" :class="'status-' + statusKey">
<text class="evt-status-text">{{ statusLabel }}</text>
</view>
<!-- 标题 -->
<text class="evt-title">{{ event.title }}</text>
<!-- 信息卡片 -->
<view class="evt-info-card">
<view class="evt-info-row">
<text class="evt-info-icon">🕐</text>
<view class="evt-info-content">
<text class="evt-info-label">时间</text>
<text class="evt-info-value">{{ event.time }}</text>
</view>
</view>
<view class="evt-info-row">
<text class="evt-info-icon">📍</text>
<view class="evt-info-content">
<text class="evt-info-label">地点</text>
<text class="evt-info-value">{{ event.location }}</text>
</view>
</view>
<view class="evt-info-row">
<text class="evt-info-icon">👥</text>
<view class="evt-info-content">
<text class="evt-info-label">人数</text>
<text class="evt-info-value">{{ event.joined }}/{{ event.maxPeople }} 人已报名</text>
</view>
</view>
<view class="evt-info-row" v-if="event.note">
<text class="evt-info-icon">📝</text>
<view class="evt-info-content">
<text class="evt-info-label">备注</text>
<text class="evt-info-value">{{ event.note }}</text>
</view>
</view>
</view>
<!-- 地图展示 -->
<view class="evt-map-section" v-if="event.latitude">
<map
class="evt-map"
:latitude="event.latitude"
:longitude="event.longitude"
:markers="eventMarkers"
:scale="15"
:show-location="true"
></map>
<view class="evt-map-nav-btn" @click="openNavigation">
<text class="evt-map-nav-icon">🧭</text>
<text class="evt-map-nav-text">导航到这里</text>
</view>
</view>
<!-- 发起人 -->
<view class="evt-organizer">
<text class="evt-section-label">发起人</text>
<view class="evt-org-row">
<view class="evt-org-avatar">
<text class="evt-org-avatar-text">{{ event.organizer ? event.organizer.nickname[0] : '酒' }}</text>
</view>
<text class="evt-org-name">{{ event.organizer ? event.organizer.nickname : '' }}</text>
</view>
</view>
<!-- 待审核报名(发起人可见,可同意/拒绝) -->
<view class="evt-pending" v-if="event.isOrganizer && pendingParticipants.length">
<text class="evt-section-label">待审核报名 ({{ pendingParticipants.length }})</text>
<view class="pending-item" v-for="p in pendingParticipants" :key="p.id">
<view class="pending-user">
<view class="evt-person-avatar">
<text class="evt-person-text">{{ p.nickname[0] }}</text>
</view>
<text class="pending-name">{{ p.nickname }}</text>
</view>
<view class="pending-actions">
<view class="pending-btn pending-approve" @click="handleApprove(p, 2)">
<text class="pending-btn-text">✓ 同意</text>
</view>
<view class="pending-btn pending-reject" @click="handleApprove(p, 3)">
<text class="pending-btn-text">✕ 拒绝</text>
</view>
</view>
</view>
</view>
<!-- 参与者(已通过审核) -->
<view class="evt-participants" v-if="approvedParticipants.length">
<text class="evt-section-label">已报名 ({{ approvedParticipants.length }})</text>
<view class="evt-people-grid">
<view class="evt-person" v-for="p in approvedParticipants" :key="p.id">
<view class="evt-person-avatar">
<text class="evt-person-text">{{ p.nickname[0] }}</text>
</view>
<text class="evt-person-name">{{ p.nickname }}</text>
</view>
</view>
</view>
</view>
</scroll-view>
<!-- 底部操作栏 -->
<view class="evt-action-bar" v-if="event">
<button
v-if="canJoin && !event.isOrganizer && (!event.isJoined || myApprove === 3)"
class="evt-btn evt-btn-primary"
@click="handleJoin"
>
{{ myApprove === 3 ? '重新报名' : '报名参加' }}
</button>
<view
v-if="event.isJoined && myApprove === 1 && !event.isOrganizer"
class="evt-ended-tip"
>
<text class="evt-ended-text">⏳ 已提交报名,等待发起人审核</text>
</view>
<view
v-if="event.isJoined && myApprove === 3 && !event.isOrganizer"
class="evt-ended-tip"
>
<text class="evt-ended-text">报名未通过,可重新申请</text>
</view>
<button
v-if="canJoin && event.isJoined && myApprove !== 3 && !event.isOrganizer"
class="evt-btn evt-btn-ghost"
@click="handleQuit"
>
取消报名
</button>
<button
v-if="statusKey === 'ongoing' && event.isJoined && myApprove === 2"
class="evt-btn evt-btn-primary"
@click="handleCheckIn"
>
到场签到
</button>
<view v-if="statusKey === 'ended' && !event.isOrganizer" class="evt-ended-tip">
<text class="evt-ended-text">酒局已结束</text>
</view>
<!-- 发起人:编辑/删除自己的酒局 -->
<view v-if="event.isOrganizer" class="evt-org-actions">
<button class="evt-btn evt-btn-ghost" @click="goEdit">
✏️ 编辑酒局
</button>
<button class="evt-btn evt-btn-danger" @click="confirmDelete">
🗑️ 删除酒局
</button>
</view>
</view>
</view>
</template>
<script>
import { GetEventDetail, JoinEvent, QuitEvent, CheckInEvent, DeleteEvent, ApproveEvent } from '../../common/api'
import wsManager from '../../common/websocket'
import themeMixin from '../../common/theme-mixin'
export default {
mixins: [themeMixin],
data() {
const menuBtn = uni.getMenuButtonBoundingClientRect()
return {
navPaddingTop: menuBtn.top + 'px',
eventId: '',
event: null
}
},
computed: {
/** 后端状态为数字枚举:1=报名中 2=待开始 3=进行中 4=已结束(兼容字符串返回) */
statusKey() {
if (!this.event) return ''
const map = { 1: 'open', 2: 'upcoming', 3: 'ongoing', 4: 'ended' }
const raw = this.event.status
return map[Number(raw)] || String(raw).toLowerCase()
},
/** 报名中/待开始阶段可报名或取消 */
canJoin() {
return this.statusKey === 'open' || this.statusKey === 'upcoming'
},
/** 我的报名审核状态:1=待审核 2=通过 3=拒绝(兼容后端未返回字段的旧数据) */
myApprove() {
if (!this.event) return 0
const raw = this.event.approveStatus
if (raw !== undefined && raw !== null && raw !== '') return Number(raw)
return this.event.isJoined ? 2 : 0
},
/** 待审核报名列表(发起人审核用) */
pendingParticipants() {
if (!this.event || !this.event.participants) return []
return this.event.participants.filter(p => Number(p.approveStatus) === 1)
},
/** 已通过审核的参与者(无字段的旧数据默认视为已通过) */
approvedParticipants() {
if (!this.event || !this.event.participants) return []
return this.event.participants.filter(p => Number(p.approveStatus) !== 1)
},
statusLabel() {
const map = { open: '报名中', upcoming: '待开始', ongoing: '进行中', ended: '已结束' }
return map[this.statusKey] || '报名中'
},
eventMarkers() {
if (!this.event || !this.event.latitude) return []
return [{
id: 1,
latitude: this.event.latitude,
longitude: this.event.longitude,
title: this.event.location,
width: 28,
height: 38
}]
}
},
onLoad(options) {
this.eventId = options.id || ''
this.loadDetail()
},
onShow() {
// 从编辑页返回时刷新详情
if (this.eventId && this._hasLoaded) {
this.loadDetail()
}
},
methods: {
goBack() {
uni.navigateBack()
},
async loadDetail() {
try {
const res = await GetEventDetail({ eventId: this.eventId })
this.event = res.data
this._hasLoaded = true
} catch (e) {
console.warn('加载酒局详情失败', e)
}
},
/** 发起人编辑酒局(复用发起页的编辑模式) */
goEdit() {
uni.navigateTo({ url: `/pages/event-create/event-create?id=${this.eventId}` })
},
/** 发起人删除酒局(二次确认,成功后返回上一页) */
confirmDelete() {
uni.showModal({
title: '删除酒局',
content: '确定要删除这场酒局吗?删除后不可恢复',
confirmText: '删除',
confirmColor: '#FF6B6B',
success: async (res) => {
if (!res.confirm) return
try {
await DeleteEvent({ eventId: this.eventId })
uni.showToast({ title: '已删除', icon: 'none' })
setTimeout(() => uni.navigateBack(), 800)
} catch (e) {
// httpRequest 已全局弹错误提示
}
}
})
},
async handleJoin() {
try {
await JoinEvent({ eventId: this.eventId })
uni.showToast({ title: '申请已提交,等待发起人审核', icon: 'none', duration: 2500 })
// 后端不会自动推送通知,前端主动给发起人发一条私信,
// 使其在消息列表(chat-list)看到未读提醒
this.notifyOrganizer()
this.loadDetail()
} catch (e) {
// httpRequest 已全局弹错误提示
}
},
/** 通过 WebSocket 给发起人发送报名提醒私信 */
notifyOrganizer() {
const organizer = this.event && this.event.organizer
if (!organizer || organizer.id === undefined || organizer.id === null) return
wsManager.send('chat', {
receiverId: organizer.id,
content: `我报名了你的酒局「${this.event.title}」,请审核 🍻`,
msgType: 'text',
clientMesgId: 'msg_' + Date.now()
})
},
/** 发起人审核报名:approve 2=同意 3=拒绝 */
async handleApprove(user, approve) {
try {
await ApproveEvent({ eventId: this.eventId, userId: user.id, approve })
uni.showToast({ title: approve === 2 ? '已同意报名' : '已拒绝报名', icon: 'none' })
// 审核结果私信通知报名用户
if (user && user.id !== undefined && user.id !== null) {
const resultText = approve === 2
? `你报名的酒局「${this.event.title}」已通过审核 🎉`
: `很遗憾,你报名的酒局「${this.event.title}」未通过审核`
wsManager.send('chat', {
receiverId: user.id,
content: resultText,
msgType: 'text',
clientMesgId: 'msg_' + Date.now()
})
}
this.loadDetail()
} catch (e) {
// httpRequest 已全局弹错误提示
}
},
async handleQuit() {
uni.showModal({
title: '取消报名',
content: '确定要退出这个酒局吗?',
confirmText: '退出',
confirmColor: '#FF6B6B',
success: async (res) => {
if (res.confirm) {
try {
await QuitEvent({ eventId: this.eventId })
uni.showToast({ title: '已取消报名', icon: 'none' })
this.loadDetail()
} catch (e) {
// httpRequest 已全局弹错误提示
}
}
}
})
},
async handleCheckIn() {
try {
await CheckInEvent({ eventId: this.eventId })
uni.showToast({ title: '签到成功 🍻', icon: 'none' })
} catch (e) {
uni.showToast({ title: '签到失败', icon: 'none' })
}
},
openNavigation() {
if (!this.event || !this.event.latitude) return
uni.openLocation({
latitude: this.event.latitude,
longitude: this.event.longitude,
name: this.event.location,
address: this.event.address || this.event.location,
fail: () => {
uni.showToast({ title: '无法打开地图', icon: 'none' })
}
})
}
}
}
</script>
<style lang="scss" scoped>
.evt-detail-page {
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.evt-nav {
background: $bg-base;
padding-left: $sp-lg;
padding-right: $sp-lg;
}
.evt-nav-inner {
display: flex;
align-items: center;
justify-content: space-between;
height: 88rpx;
}
.evt-back {
width: 64rpx;
height: 64rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: $bg-card;
&:active { background: $bg-card-alt; }
}
.evt-back-icon {
font-size: $fs-xl;
color: $text-primary;
font-weight: $fw-bold;
}
.evt-nav-title {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.evt-nav-ph {
width: 64rpx;
}
.evt-scroll {
flex: 1;
height: 0;
}
.evt-body {
padding: $sp-lg;
padding-bottom: $sp-xl;
}
.evt-status {
display: inline-flex;
padding: 8rpx 20rpx;
border-radius: $radius-full;
margin-bottom: $sp-lg;
&.status-open { background: rgba(94,198,160,0.12); }
&.status-upcoming { background: rgba(139,133,184,0.12); }
&.status-ongoing { background: rgba(232,168,56,0.12); }
&.status-ended { background: $bg-elevated; }
}
.evt-status-text {
font-size: $fs-sm;
font-weight: $fw-medium;
.status-open & { color: $mint; }
.status-upcoming & { color: $lavender; }
.status-ongoing & { color: $amber; }
.status-ended & { color: $text-tertiary; }
}
.evt-title {
display: block;
font-size: $fs-2xl;
font-weight: $fw-black;
color: $text-primary;
margin-bottom: $sp-xl;
line-height: $lh-tight;
}
/* 信息卡片 */
.evt-info-card {
background: $bg-card;
border-radius: $radius-xl;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
padding: $sp-xl;
margin-bottom: $sp-xl;
}
.evt-info-row {
display: flex;
align-items: flex-start;
gap: $sp-md;
padding: $sp-md 0;
&:not(:last-child) {
border-bottom: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
}
.evt-info-icon {
font-size: $fs-base;
width: 40rpx;
text-align: center;
flex-shrink: 0;
margin-top: 4rpx;
}
.evt-info-content {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.evt-info-label {
font-size: $fs-xs;
color: $text-tertiary;
}
.evt-info-value {
font-size: $fs-base;
color: $text-primary;
font-weight: $fw-medium;
}
/* 地图区域 */
.evt-map-section {
margin-bottom: $sp-xl;
border-radius: $radius-xl;
overflow: hidden;
border: 1rpx solid var(--border-faint, rgba(255,255,255,0.08));
}
.evt-map {
width: 100%;
height: 320rpx;
}
.evt-map-nav-btn {
display: flex;
align-items: center;
justify-content: center;
gap: $sp-sm;
padding: $sp-md;
background: $bg-card;
&:active {
background: $bg-card-alt;
}
}
.evt-map-nav-icon {
font-size: $fs-base;
}
.evt-map-nav-text {
font-size: $fs-base;
color: $amber;
font-weight: $fw-bold;
}
/* 发起人 & 参与者 */
.evt-section-label {
display: block;
font-size: $fs-sm;
color: $text-tertiary;
font-weight: $fw-medium;
margin-bottom: $sp-md;
}
.evt-organizer {
margin-bottom: $sp-xl;
}
.evt-org-row {
display: flex;
align-items: center;
gap: $sp-md;
}
.evt-org-avatar {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(232,168,56,0.15), rgba(139,133,184,0.15));
display: flex;
align-items: center;
justify-content: center;
}
.evt-org-avatar-text {
font-size: $fs-base;
font-weight: $fw-bold;
color: $amber;
}
.evt-org-name {
font-size: $fs-base;
font-weight: $fw-bold;
color: $text-primary;
}
.evt-people-grid {
display: flex;
flex-wrap: wrap;
gap: $sp-lg;
}
.evt-person {
display: flex;
flex-direction: column;
align-items: center;
gap: 8rpx;
width: 100rpx;
}
.evt-person-avatar {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
background: $bg-elevated;
display: flex;
align-items: center;
justify-content: center;
}
.evt-person-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $lavender;
}
.evt-person-name {
font-size: $fs-xs;
color: $text-secondary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100rpx;
text-align: center;
}
/* 底部操作栏 */
.evt-action-bar {
padding: $sp-md $sp-lg;
padding-bottom: calc(env(safe-area-inset-bottom) + #{$sp-md});
background: $bg-card;
border-top: 1rpx solid var(--divider-color, rgba(255,255,255,0.08));
}
.evt-btn {
width: 100%;
height: 96rpx;
line-height: 96rpx;
text-align: center;
border-radius: $radius-full;
font-size: $fs-lg;
font-weight: $fw-bold;
border: none;
&::after { border: none; }
&:active { transform: scale(0.98); }
}
.evt-btn-primary {
background: linear-gradient(135deg, $amber, $amber-deep);
color: $text-on-amber;
}
.evt-btn-ghost {
background: $bg-elevated;
color: $coral;
border: 1rpx solid rgba(255,107,107,0.2);
}
.evt-ended-tip {
text-align: center;
padding: $sp-md 0;
}
.evt-ended-text {
font-size: $fs-base;
color: $text-tertiary;
}
.evt-org-actions {
display: flex;
gap: $sp-md;
.evt-btn {
flex: 1;
}
}
.evt-btn-danger {
background: rgba(255,107,107,0.1);
color: $coral;
border: 1rpx solid rgba(255,107,107,0.25);
}
/* 待审核报名 */
.evt-pending {
margin-bottom: $sp-xl;
}
.pending-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: $sp-md $sp-lg;
background: $bg-card;
border: 1rpx solid rgba(232,168,56,0.25);
border-radius: $radius-lg;
margin-bottom: $sp-sm;
}
.pending-user {
display: flex;
align-items: center;
gap: $sp-md;
min-width: 0;
}
.pending-name {
font-size: $fs-base;
font-weight: $fw-medium;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pending-actions {
display: flex;
gap: $sp-sm;
flex-shrink: 0;
}
.pending-btn {
padding: 10rpx 24rpx;
border-radius: $radius-full;
&:active {
opacity: 0.7;
}
}
.pending-approve {
background: linear-gradient(135deg, $amber, $amber-deep);
}
.pending-reject {
background: rgba(255,107,107,0.1);
border: 1rpx solid rgba(255,107,107,0.25);
}
.pending-btn-text {
font-size: $fs-sm;
font-weight: $fw-bold;
color: $text-on-amber;
.pending-reject & {
color: $coral;
}
}
</style>