Files
CosScene/clients/pages/event/detail.vue
T
2026-05-09 16:40:29 +08:00

486 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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.
<script setup>
import { ref, computed } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import {
getEventDetail,
registerEvent,
cancelRegistration,
cancelEvent,
getRegistrations,
addEventPhoto,
} from "@/api/event";
import { uploadImage } from "@/api/spot";
import { resolveImageUrl } from "@/utils/image";
const detail = ref(null);
const loading = ref(true);
const eventId = ref(0);
const isOwner = ref(false);
const registrations = ref([]);
const showRegistrations = ref(false);
const statusLabels = {
upcoming: "即将开始",
ongoing: "进行中",
ended: "已结束",
cancelled: "已取消",
};
const statusColors = {
upcoming: "#6366f1",
ongoing: "#22c55e",
ended: "#9ca3af",
cancelled: "#ef4444",
};
async function loadDetail() {
loading.value = true;
try {
const res = await getEventDetail(eventId.value);
detail.value = res;
const userStr = uni.getStorageSync("userInfo");
if (userStr) {
try {
const u = typeof userStr === "string" ? JSON.parse(userStr) : userStr;
isOwner.value = u.id === res.creator?.id;
} catch {}
}
} catch (e) {
uni.showToast({ title: "加载失败", icon: "none" });
} finally {
loading.value = false;
}
}
async function handleRegister() {
try {
await registerEvent(eventId.value);
uni.showToast({ title: "报名成功", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "报名失败", icon: "none" });
}
}
async function handleCancelRegistration() {
uni.showModal({
title: "提示",
content: "确定取消报名?",
success: async (r) => {
if (!r.confirm) return;
try {
await cancelRegistration(eventId.value);
uni.showToast({ title: "已取消", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "操作失败", icon: "none" });
}
},
});
}
async function handleCancel() {
uni.showModal({
title: "提示",
content: "确定取消活动?取消后将通知所有报名用户。",
success: async (r) => {
if (!r.confirm) return;
try {
await cancelEvent(eventId.value);
uni.showToast({ title: "已取消", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: e.message || "操作失败", icon: "none" });
}
},
});
}
async function loadRegistrations() {
try {
const res = await getRegistrations(eventId.value);
registrations.value = Array.isArray(res) ? res : res.items || [];
showRegistrations.value = true;
} catch (e) {
uni.showToast({ title: "无权查看", icon: "none" });
}
}
async function handleUploadPhoto() {
uni.chooseImage({
count: 1,
success: async (chooseRes) => {
const tempPath = chooseRes.tempFilePaths[0];
try {
uni.showLoading({ title: "上传中..." });
const uploadRes = await uploadImage(tempPath);
const imageUrl = uploadRes.url || uploadRes.path;
await addEventPhoto(eventId.value, { image_url: imageUrl });
uni.showToast({ title: "上传成功", icon: "success" });
await loadDetail();
} catch (e) {
uni.showToast({ title: "上传失败", icon: "none" });
} finally {
uni.hideLoading();
}
},
});
}
function previewPhotos(idx) {
const urls = (detail.value?.photos || []).map((p) => resolveImageUrl(p.image_url));
uni.previewImage({ urls, current: urls[idx] || urls[0] });
}
function formatDateTime(d) {
if (!d) return "待定";
const dt = new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`;
}
const canRegister = computed(() => {
if (!detail.value) return false;
if (detail.value.status !== "upcoming") return false;
if (detail.value.has_registered) return false;
if (isOwner.value) return false;
if (detail.value.max_participants > 0 && detail.value.registration_count >= detail.value.max_participants) return false;
return true;
});
onLoad((query) => {
eventId.value = Number(query.id);
loadDetail();
});
</script>
<template>
<view class="detail-page">
<view v-if="loading" class="loading-tip">加载中...</view>
<template v-else-if="detail">
<image
v-if="detail.cover_url"
class="cover-image"
:src="resolveImageUrl(detail.cover_url)"
mode="aspectFill"
/>
<view class="header-card">
<view class="title-row">
<text class="title">{{ detail.title }}</text>
<view class="status-tag" :style="{ background: statusColors[detail.status] || '#9ca3af' }">
{{ statusLabels[detail.status] || detail.status }}
</view>
</view>
<view class="creator-row" v-if="detail.creator">
<text class="creator-nick">{{ detail.creator.nickname }}</text>
</view>
</view>
<view class="info-card">
<view class="info-row">
<text class="info-label">城市</text>
<text class="info-value">{{ detail.city }}</text>
</view>
<view class="info-row" v-if="detail.location_name">
<text class="info-label">地点</text>
<text class="info-value">{{ detail.location_name }}</text>
</view>
<view class="info-row">
<text class="info-label">开始时间</text>
<text class="info-value">{{ formatDateTime(detail.start_time) }}</text>
</view>
<view class="info-row">
<text class="info-label">结束时间</text>
<text class="info-value">{{ formatDateTime(detail.end_time) }}</text>
</view>
<view class="info-row">
<text class="info-label">人数限制</text>
<text class="info-value">{{ detail.max_participants > 0 ? detail.max_participants + '人' : '不限' }}</text>
</view>
<view class="info-row">
<text class="info-label">已报名</text>
<text class="info-value">{{ detail.registration_count }}</text>
</view>
</view>
<view v-if="detail.description" class="desc-card">
<text class="desc-title">活动详情</text>
<text class="desc-text">{{ detail.description }}</text>
</view>
<view v-if="detail.reject_reason" class="reject-card">
<uni-icons type="info" size="16" color="#ef4444" />
<text class="reject-text">驳回原因{{ detail.reject_reason }}</text>
</view>
<!-- Registration list for owner -->
<view v-if="isOwner" class="section">
<view class="section-header" @tap="loadRegistrations">
<text class="section-title">报名列表</text>
<uni-icons type="right" size="16" color="#6366f1" />
</view>
<view v-if="showRegistrations" class="reg-list">
<view v-if="registrations.length === 0" class="reg-empty">暂无报名</view>
<view v-for="reg in registrations" :key="reg.id" class="reg-item">
<text class="reg-name">{{ reg.user?.nickname || '匿名' }}</text>
<text class="reg-time">{{ formatDateTime(reg.created_at) }}</text>
</view>
</view>
</view>
<!-- Photo album -->
<view class="section">
<view class="section-header">
<text class="section-title">活动相册{{ detail.photos?.length || 0 }}</text>
<view
v-if="detail.has_registered || isOwner"
class="upload-btn"
@tap="handleUploadPhoto"
>
<uni-icons type="plusempty" size="14" color="#6366f1" />
<text>上传</text>
</view>
</view>
<view v-if="detail.photos && detail.photos.length > 0" class="photo-grid">
<image
v-for="(photo, idx) in detail.photos"
:key="photo.id"
class="photo-item"
:src="resolveImageUrl(photo.image_url)"
mode="aspectFill"
@tap="previewPhotos(idx)"
/>
</view>
<view v-else class="photo-empty">
<text>暂无照片</text>
</view>
</view>
<!-- Bottom actions -->
<view class="bottom-bar">
<template v-if="isOwner">
<view
v-if="detail.status !== 'cancelled' && detail.status !== 'ended'"
class="btn btn-cancel"
@tap="handleCancel"
>取消活动</view>
<view
class="btn btn-edit"
@tap="uni.navigateTo({ url: `/pages/event/create?id=${detail.id}` })"
v-if="detail.status === 'upcoming'"
>编辑</view>
</template>
<template v-else>
<view v-if="canRegister" class="btn btn-register" @tap="handleRegister">
我要报名
</view>
<view
v-else-if="detail.has_registered"
class="btn btn-unregister"
@tap="handleCancelRegistration"
>取消报名</view>
<view v-else class="btn btn-disabled">
{{ detail.status === 'cancelled' ? '已取消' : detail.status === 'ended' ? '已结束' : '人数已满' }}
</view>
</template>
</view>
</template>
</view>
</template>
<style scoped>
.detail-page {
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 140rpx;
}
.loading-tip {
text-align: center;
padding: 100rpx 0;
font-size: 28rpx;
color: #9ca3af;
}
.cover-image {
width: 100%;
height: 360rpx;
}
.header-card,
.info-card,
.desc-card,
.reject-card,
.section {
background: #fff;
margin: 16rpx 20rpx 0;
border-radius: 20rpx;
padding: 28rpx;
}
.title-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.title {
font-size: 34rpx;
font-weight: 700;
color: #1e1e2e;
flex: 1;
}
.status-tag {
font-size: 22rpx;
color: #fff;
padding: 4rpx 16rpx;
border-radius: 20rpx;
flex-shrink: 0;
margin-left: 12rpx;
}
.creator-row {
margin-top: 12rpx;
}
.creator-nick {
font-size: 26rpx;
color: #6366f1;
}
.info-row {
display: flex;
align-items: center;
padding: 14rpx 0;
border-bottom: 1rpx solid #f3f4f6;
}
.info-row:last-child {
border-bottom: none;
}
.info-label {
width: 160rpx;
font-size: 26rpx;
color: #9ca3af;
flex-shrink: 0;
}
.info-value {
flex: 1;
font-size: 26rpx;
color: #374151;
}
.desc-title,
.section-title {
font-size: 28rpx;
font-weight: 600;
color: #1e1e2e;
margin-bottom: 12rpx;
}
.desc-text {
font-size: 26rpx;
color: #4b5563;
line-height: 1.7;
white-space: pre-wrap;
}
.reject-card {
display: flex;
align-items: flex-start;
gap: 8rpx;
background: #fef2f2;
}
.reject-text {
font-size: 26rpx;
color: #ef4444;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.upload-btn {
display: flex;
align-items: center;
gap: 6rpx;
font-size: 24rpx;
color: #6366f1;
}
.reg-list {
margin-top: 12rpx;
}
.reg-empty {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 16rpx 0;
}
.reg-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 0;
border-top: 1rpx solid #f3f4f6;
}
.reg-name {
font-size: 26rpx;
color: #1e1e2e;
}
.reg-time {
font-size: 22rpx;
color: #9ca3af;
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-top: 8rpx;
}
.photo-item {
width: calc(33.33% - 8rpx);
height: 200rpx;
border-radius: 12rpx;
}
.photo-empty {
text-align: center;
font-size: 26rpx;
color: #9ca3af;
padding: 20rpx 0;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
display: flex;
gap: 16rpx;
padding: 20rpx 28rpx;
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.btn {
flex: 1;
text-align: center;
padding: 22rpx 0;
border-radius: 16rpx;
font-size: 28rpx;
font-weight: 600;
}
.btn-register {
background: #6366f1;
color: #fff;
}
.btn-unregister {
background: #fef3c7;
color: #d97706;
}
.btn-cancel {
background: #fee2e2;
color: #ef4444;
}
.btn-edit {
background: #6366f1;
color: #fff;
}
.btn-disabled {
background: #e5e7eb;
color: #9ca3af;
}
</style>