feat: 新增评价提交组件并优化多个前端页面交互
This commit is contained in:
5
web/components.d.ts
vendored
5
web/components.d.ts
vendored
@@ -11,6 +11,7 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ReviewSubmitDialog: typeof import('./src/components/ReviewSubmitDialog.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
VanActionBar: typeof import('vant/es')['ActionBar']
|
||||
@@ -18,12 +19,13 @@ declare module 'vue' {
|
||||
VanActionBarIcon: typeof import('vant/es')['ActionBarIcon']
|
||||
VanButton: typeof import('vant/es')['Button']
|
||||
VanCalendar: typeof import('vant/es')['Calendar']
|
||||
VanCard: typeof import('vant/es')['Card']
|
||||
VanCell: typeof import('vant/es')['Cell']
|
||||
VanCellGroup: typeof import('vant/es')['CellGroup']
|
||||
VanConfigProvider: typeof import('vant/es')['ConfigProvider']
|
||||
VanDatePicker: typeof import('vant/es')['DatePicker']
|
||||
VanDialog: typeof import('vant/es')['Dialog']
|
||||
VanDropdownItem: typeof import('vant/es')['DropdownItem']
|
||||
VanDropdownMenu: typeof import('vant/es')['DropdownMenu']
|
||||
VanEmpty: typeof import('vant/es')['Empty']
|
||||
VanField: typeof import('vant/es')['Field']
|
||||
VanForm: typeof import('vant/es')['Form']
|
||||
@@ -42,7 +44,6 @@ declare module 'vue' {
|
||||
VanRate: typeof import('vant/es')['Rate']
|
||||
VanSearch: typeof import('vant/es')['Search']
|
||||
VanStepper: typeof import('vant/es')['Stepper']
|
||||
VanSwipeCell: typeof import('vant/es')['SwipeCell']
|
||||
VanTab: typeof import('vant/es')['Tab']
|
||||
VanTabbar: typeof import('vant/es')['Tabbar']
|
||||
VanTabbarItem: typeof import('vant/es')['TabbarItem']
|
||||
|
||||
120
web/src/components/ReviewSubmitDialog.vue
Normal file
120
web/src/components/ReviewSubmitDialog.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<van-dialog
|
||||
v-model:show="visible"
|
||||
title="评价活动"
|
||||
show-cancel-button
|
||||
:before-close="handleBeforeClose"
|
||||
@confirm="handleSubmit"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="review-form">
|
||||
<div class="form-item">
|
||||
<label>评分</label>
|
||||
<van-rate v-model="formData.rating" :count="5" color="#ffd21e" void-icon="star" void-color="#eee" />
|
||||
</div>
|
||||
<div class="form-item">
|
||||
<label>评论内容(可选)</label>
|
||||
<van-field
|
||||
v-model="formData.content"
|
||||
rows="4"
|
||||
autosize
|
||||
type="textarea"
|
||||
maxlength="500"
|
||||
placeholder="请输入您的评价..."
|
||||
show-word-limit
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</van-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { submitReview } from '@/services/review';
|
||||
import { showToast } from 'vant';
|
||||
|
||||
interface Props {
|
||||
show: boolean;
|
||||
activityId: number;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'update:show', value: boolean): void;
|
||||
(e: 'success'): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = ref(false);
|
||||
const formData = ref({
|
||||
rating: 5,
|
||||
content: ''
|
||||
});
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
visible.value = newVal;
|
||||
if (newVal) {
|
||||
// Reset form when dialog opens
|
||||
formData.value = {
|
||||
rating: 5,
|
||||
content: ''
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
watch(visible, (newVal) => {
|
||||
if (!newVal) {
|
||||
emit('update:show', false);
|
||||
}
|
||||
});
|
||||
|
||||
const handleBeforeClose = (action: string) => {
|
||||
if (action === 'confirm') {
|
||||
return true; // Allow close, handleSubmit will be called
|
||||
}
|
||||
return true; // Allow cancel
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (formData.value.rating === 0) {
|
||||
showToast('请选择评分');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await submitReview({
|
||||
activityId: props.activityId,
|
||||
rating: formData.value.rating,
|
||||
content: formData.value.content || undefined
|
||||
});
|
||||
showToast('评价成功');
|
||||
emit('success');
|
||||
visible.value = false;
|
||||
} catch (error) {
|
||||
// Error handled by interceptor
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
visible.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.review-form {
|
||||
padding: 20px;
|
||||
|
||||
.form-item {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 10px;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -15,3 +15,10 @@ export const cancelRegistration = (id: number) => {
|
||||
export const getMyRegistrations = (params?: any) => {
|
||||
return request.get<any, RegistrationListResponse>('/registrations/my', { params });
|
||||
};
|
||||
|
||||
// Download ticket PDF
|
||||
export const downloadTicket = (id: number): Promise<Blob> => {
|
||||
return request.get(`/registrations/${id}/ticket`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export const getOverviewStats = () => {
|
||||
return request.get<any, OverviewStats>('/statistics/overview');
|
||||
};
|
||||
|
||||
export const exportActivityStats = (activityId: number, format = 'excel') => {
|
||||
export const exportActivityStats = (activityId: number, format = 'csv') => {
|
||||
// Return blob or handle download
|
||||
return request.get(`/statistics/activity/${activityId}/export`, {
|
||||
params: { format },
|
||||
|
||||
@@ -24,6 +24,11 @@ service.interceptors.request.use(
|
||||
// Response Interceptor
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse) => {
|
||||
// For blob responses (file downloads), return data directly
|
||||
if (response.config.responseType === 'blob') {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
const res = response.data;
|
||||
// According to API docs: code 200 is success
|
||||
if (res.code !== 200) {
|
||||
|
||||
@@ -33,31 +33,93 @@
|
||||
<h3>活动简介</h3>
|
||||
<p>{{ activity.description || '暂无简介' }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Reviews Section -->
|
||||
<div class="reviews-section">
|
||||
<div class="reviews-header">
|
||||
<h3>活动评价</h3>
|
||||
<div class="rating-summary" v-if="activity.reviewCount && activity.reviewCount > 0">
|
||||
<van-rate :model-value="activity.averageRating" readonly size="16px" color="#ffd21e" />
|
||||
<span class="rating-text">{{ activity.averageRating?.toFixed(1) }} ({{ activity.reviewCount }}条评价)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-pull-refresh v-model="reviewsRefreshing" @refresh="onReviewsRefresh">
|
||||
<van-list
|
||||
v-model:loading="reviewsLoading"
|
||||
:finished="reviewsFinished"
|
||||
finished-text="没有更多评价了"
|
||||
@load="onLoadReviews"
|
||||
>
|
||||
<div v-if="reviews.length === 0 && !reviewsLoading" class="empty-reviews">
|
||||
<van-empty description="暂无评价" />
|
||||
</div>
|
||||
<div v-for="review in reviews" :key="review.id" class="review-card">
|
||||
<div class="review-header">
|
||||
<div class="user-info">
|
||||
<van-image
|
||||
round
|
||||
width="32"
|
||||
height="32"
|
||||
:src="review.userAvatar || 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'"
|
||||
/>
|
||||
<span class="username">{{ review.userName }}</span>
|
||||
</div>
|
||||
<span class="time">{{ formatReviewTime(review.createdAt) }}</span>
|
||||
</div>
|
||||
<van-rate :model-value="review.rating" readonly size="14px" color="#ffd21e" />
|
||||
<p class="review-content" v-if="review.content">{{ review.content }}</p>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-action-bar>
|
||||
<van-action-bar-icon icon="chat-o" text="评价" @click="goToReviews" />
|
||||
<van-action-bar-icon icon="chat-o" :text="`评价(${activity.reviewCount || 0})`" @click="scrollToReviews" />
|
||||
<van-action-bar-button type="danger" text="立即报名" @click="handleRegister" v-if="!activity.isRegistered && activity.status === 1" />
|
||||
<van-action-bar-button type="success" text="已报名" disabled v-if="activity.isRegistered" />
|
||||
<van-action-bar-button type="warning" text="已结束" disabled v-if="activity.status > 2" />
|
||||
<van-action-bar-button type="primary" text="写评价" @click="showReviewDialog" v-if="canReview" />
|
||||
<van-action-bar-button type="warning" text="已结束" disabled v-if="activity.status > 2 && !canReview" />
|
||||
</van-action-bar>
|
||||
|
||||
<!-- Review Submit Dialog -->
|
||||
<ReviewSubmitDialog
|
||||
v-model:show="reviewDialogVisible"
|
||||
:activity-id="id"
|
||||
@success="handleReviewSuccess"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { getActivityDetail } from '@/services/activity';
|
||||
import { getActivityReviews } from '@/services/review';
|
||||
import type { Activity } from '@/types/activity';
|
||||
import type { Review } from '@/types/review';
|
||||
import { showToast } from 'vant';
|
||||
import dayjs from 'dayjs';
|
||||
import ReviewSubmitDialog from '@/components/ReviewSubmitDialog.vue';
|
||||
|
||||
const route = useRoute();
|
||||
// const router = useRouter(); // Unused
|
||||
const activity = ref<Activity | null>(null);
|
||||
const reviews = ref<Review[]>([]);
|
||||
const reviewsLoading = ref(false);
|
||||
const reviewsFinished = ref(false);
|
||||
const reviewsRefreshing = ref(false);
|
||||
const reviewsCurrentPage = ref(1);
|
||||
const reviewDialogVisible = ref(false);
|
||||
|
||||
const id = Number(route.params.id);
|
||||
|
||||
const canReview = computed(() => {
|
||||
if (!activity.value) return false;
|
||||
// User can review if they registered and activity has ended
|
||||
return activity.value.isRegistered && activity.value.status === 3;
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await getActivityDetail(id);
|
||||
@@ -85,9 +147,65 @@ const handleRegister = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const goToReviews = () => {
|
||||
// To be implemented
|
||||
showToast('评价功能即将上线');
|
||||
const onLoadReviews = async () => {
|
||||
if (reviewsRefreshing.value) {
|
||||
reviews.value = [];
|
||||
reviewsRefreshing.value = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getActivityReviews(id, {
|
||||
current: reviewsCurrentPage.value,
|
||||
size: 10
|
||||
});
|
||||
|
||||
const records = res.records || [];
|
||||
const total = res.total || 0;
|
||||
|
||||
reviews.value.push(...records);
|
||||
reviewsLoading.value = false;
|
||||
reviewsCurrentPage.value++;
|
||||
|
||||
if (reviews.value.length >= total) {
|
||||
reviewsFinished.value = true;
|
||||
}
|
||||
} catch (error) {
|
||||
reviewsLoading.value = false;
|
||||
reviewsFinished.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
const onReviewsRefresh = () => {
|
||||
reviewsFinished.value = false;
|
||||
reviewsLoading.value = true;
|
||||
reviewsCurrentPage.value = 1;
|
||||
onLoadReviews();
|
||||
};
|
||||
|
||||
const scrollToReviews = () => {
|
||||
const reviewsSection = document.querySelector('.reviews-section');
|
||||
if (reviewsSection) {
|
||||
reviewsSection.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
};
|
||||
|
||||
const showReviewDialog = () => {
|
||||
reviewDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const handleReviewSuccess = async () => {
|
||||
// Refresh reviews and activity data
|
||||
onReviewsRefresh();
|
||||
try {
|
||||
const updated = await getActivityDetail(id);
|
||||
activity.value = updated;
|
||||
} catch (error) {
|
||||
// Error handled
|
||||
}
|
||||
};
|
||||
|
||||
const formatReviewTime = (time: string) => {
|
||||
return dayjs(time).format('YYYY-MM-DD');
|
||||
};
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
@@ -148,6 +266,82 @@ const formatStatus = (status: number) => {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.reviews-section {
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.reviews-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #eee;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.rating-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.rating-text {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-reviews {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.review-card {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.review-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.username {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.time {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.review-content {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -85,15 +85,22 @@ const getPercentage = (score: number) => {
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
showToast({ type: 'loading', message: '导出中...', duration: 0 });
|
||||
const blob = await exportActivityStats(activityId);
|
||||
// Download logic
|
||||
|
||||
// Create download link
|
||||
const url = window.URL.createObjectURL(new Blob([blob as any]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `activity-${activityId}-stats.xlsx`);
|
||||
link.setAttribute('download', `activity-${activityId}-stats.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
showToast('导出成功');
|
||||
|
||||
// Cleanup
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showToast({ type: 'success', message: '导出成功' });
|
||||
} catch (error) {
|
||||
showToast('导出失败');
|
||||
}
|
||||
|
||||
@@ -17,28 +17,58 @@
|
||||
finished-text="没有更多活动了"
|
||||
@load="onLoad"
|
||||
>
|
||||
<van-swipe-cell v-for="item in list" :key="item.id">
|
||||
<van-card
|
||||
:desc="item.description"
|
||||
:title="item.title"
|
||||
:thumb="item.coverImage || 'https://fastly.jsdelivr.net/npm/@vant/assets/ipad.jpeg'"
|
||||
@click="onEdit(item)"
|
||||
>
|
||||
<template #tags>
|
||||
<van-tag plain type="primary" style="margin-right: 5px">{{ item.category }}</van-tag>
|
||||
<van-tag :type="getStatusType(item.status)">{{ formatStatus(item.status) }}</van-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<van-button size="small" icon="edit" @click.stop="onEdit(item)">编辑</van-button>
|
||||
<van-button size="small" icon="qr" @click.stop="onCheckin(item)">签到</van-button>
|
||||
<van-button size="small" icon="chart-trending-o" @click.stop="onStats(item)">统计</van-button>
|
||||
<van-button size="small" icon="chat-o" @click.stop="onReviews(item)">评价</van-button>
|
||||
</template>
|
||||
</van-card>
|
||||
<template #right>
|
||||
<van-button square text="删除" type="danger" class="delete-button" @click="onDelete(item)" />
|
||||
</template>
|
||||
</van-swipe-cell>
|
||||
<div class="activity-list">
|
||||
<div v-for="item in list" :key="item.id" class="activity-card" @click="onEdit(item)">
|
||||
<div class="card-cover">
|
||||
<van-image
|
||||
fit="cover"
|
||||
:src="item.coverImage || getRandomCover(item.id)"
|
||||
class="cover-image"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<van-icon name="photo-o" size="30" />
|
||||
</div>
|
||||
</template>
|
||||
</van-image>
|
||||
<div class="status-tag">
|
||||
<van-tag :type="getStatusType(item.status)" round size="medium">
|
||||
{{ formatStatus(item.status) }}
|
||||
</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="activity-title van-ellipsis">{{ item.title }}</h3>
|
||||
|
||||
<div class="info-row">
|
||||
<van-icon name="clock-o" class="icon" />
|
||||
<span class="text">{{ formatTimeRange(item.startTime, item.endTime) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<van-icon name="location-o" class="icon" />
|
||||
<span class="text van-ellipsis">{{ item.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<van-tag plain type="primary" size="medium">{{ item.category || '未分类' }}</van-tag>
|
||||
<div class="participants">
|
||||
<van-icon name="friends-o" />
|
||||
<span>{{ item.currentParticipants }}/{{ item.maxParticipants }} 已报名</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<van-button size="small" icon="edit" @click.stop="onEdit(item)">编辑</van-button>
|
||||
<van-button size="small" icon="qr" @click.stop="onCheckin(item)">签到</van-button>
|
||||
<van-button size="small" icon="chart-trending-o" @click.stop="onStats(item)">统计</van-button>
|
||||
<van-button size="small" icon="chat-o" @click.stop="onReviews(item)">评价</van-button>
|
||||
<van-button size="small" icon="delete-o" type="danger" @click.stop="onDelete(item)">删除</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</div>
|
||||
@@ -50,6 +80,7 @@ import { useRouter } from 'vue-router';
|
||||
import { getActivities, deleteActivity } from '@/services/activity';
|
||||
import type { Activity } from '@/types/activity';
|
||||
import { showDialog, showToast } from 'vant';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@@ -88,9 +119,11 @@ const onLoad = async () => {
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
refreshing.value = true;
|
||||
finished.value = false;
|
||||
loading.value = true;
|
||||
currentPage.value = 1;
|
||||
list.value = [];
|
||||
onLoad();
|
||||
};
|
||||
|
||||
@@ -151,6 +184,19 @@ const getStatusType = (status: number) => {
|
||||
if (status === 3) return 'default';
|
||||
return 'warning';
|
||||
};
|
||||
|
||||
const formatTimeRange = (start: string, end: string) => {
|
||||
const s = dayjs(start);
|
||||
const e = dayjs(end);
|
||||
if (s.isSame(e, 'day')) {
|
||||
return `${s.format('MM-DD HH:mm')} - ${e.format('HH:mm')}`;
|
||||
}
|
||||
return `${s.format('MM-DD HH:mm')} - ${e.format('MM-DD HH:mm')}`;
|
||||
};
|
||||
|
||||
const getRandomCover = (id: number) => {
|
||||
return `https://picsum.photos/seed/${id}/600/400`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -158,9 +204,112 @@ const getStatusType = (status: number) => {
|
||||
min-height: 100vh;
|
||||
background-color: #f7f8fa;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
.delete-button {
|
||||
height: 100%;
|
||||
.activity-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
color: #dcdee0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 12px 16px;
|
||||
|
||||
.activity-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
color: #646566;
|
||||
font-size: 14px;
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f5f6f7;
|
||||
|
||||
.participants {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #969799;
|
||||
|
||||
.van-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f5f6f7;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,57 +2,150 @@
|
||||
<div class="home-page">
|
||||
<van-search
|
||||
v-model="keyword"
|
||||
show-action
|
||||
placeholder="搜索活动"
|
||||
:show-action="showSearchAction"
|
||||
action-text="取消"
|
||||
placeholder="搜索精彩活动"
|
||||
shape="round"
|
||||
background="#fff"
|
||||
@search="onSearch"
|
||||
@cancel="onCancel"
|
||||
@focus="onSearchFocus"
|
||||
/>
|
||||
|
||||
<van-tabs v-model:active="activeTab" sticky>
|
||||
<van-tab title="列表">
|
||||
|
||||
<van-tabs v-model:active="activeTab" sticky color="#1989fa">
|
||||
<van-tab title="活动列表">
|
||||
<!-- 筛选控件 -->
|
||||
<div class="filter-section">
|
||||
<van-dropdown-menu>
|
||||
<van-dropdown-item v-model="filterStatus" :options="statusOptions" @change="onFilterChange" />
|
||||
<van-dropdown-item v-model="filterTimeRange" :options="timeRangeOptions" @change="onFilterChange" />
|
||||
</van-dropdown-menu>
|
||||
</div>
|
||||
|
||||
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
|
||||
<van-list
|
||||
v-model:loading="loading"
|
||||
:finished="finished"
|
||||
finished-text="没有更多活动了"
|
||||
:immediate-check="false"
|
||||
@load="onLoad"
|
||||
>
|
||||
<van-card
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
:price="formatStatus(item.status)"
|
||||
:desc="item.location"
|
||||
:title="item.title"
|
||||
:thumb="item.coverImage || 'https://fastly.jsdelivr.net/npm/@vant/assets/ipad.jpeg'"
|
||||
@click="goToDetail(item.id)"
|
||||
>
|
||||
<template #tags>
|
||||
<van-tag plain type="primary" style="margin-right: 5px">{{ item.category }}</van-tag>
|
||||
<van-tag plain type="warning">{{ formatTime(item.startTime) }}</van-tag>
|
||||
</template>
|
||||
<template #footer>
|
||||
<span>{{ item.currentParticipants }}/{{ item.maxParticipants }} Joined</span>
|
||||
</template>
|
||||
</van-card>
|
||||
<div class="activity-list">
|
||||
<div
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="activity-card"
|
||||
@click="goToDetail(item.id)"
|
||||
>
|
||||
<div class="card-cover">
|
||||
<van-image
|
||||
fit="cover"
|
||||
:src="item.coverImage || getRandomCover(item.id)"
|
||||
class="cover-image"
|
||||
>
|
||||
<template #error>
|
||||
<div class="image-placeholder">
|
||||
<van-icon name="photo-o" size="30" />
|
||||
</div>
|
||||
</template>
|
||||
</van-image>
|
||||
<div class="status-tag">
|
||||
<van-tag :type="getStatusType(item.status)" round size="medium">
|
||||
{{ formatStatus(item.status) }}
|
||||
</van-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-content">
|
||||
<h3 class="activity-title van-ellipsis">{{ item.title }}</h3>
|
||||
|
||||
<div class="info-row">
|
||||
<van-icon name="clock-o" class="icon" />
|
||||
<span class="text">{{ formatTimeRange(item.startTime, item.endTime) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
<van-icon name="location-o" class="icon" />
|
||||
<span class="text van-ellipsis">{{ item.location }}</span>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<van-tag plain type="primary" size="medium">{{ item.category || '未分类' }}</van-tag>
|
||||
<div class="participants">
|
||||
<van-icon name="friends-o" />
|
||||
<span>{{ item.currentParticipants }}/{{ item.maxParticipants }} 已报名</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
</van-pull-refresh>
|
||||
</van-tab>
|
||||
<van-tab title="日历">
|
||||
<van-calendar
|
||||
title="活动日历"
|
||||
:poppable="false"
|
||||
:show-confirm="false"
|
||||
:style="{ height: '500px' }"
|
||||
/>
|
||||
<van-tab title="活动日历">
|
||||
<div class="calendar-container">
|
||||
<van-calendar
|
||||
title="活动日历"
|
||||
:poppable="false"
|
||||
:show-confirm="false"
|
||||
:style="{ height: '500px' }"
|
||||
color="#1989fa"
|
||||
:formatter="calendarFormatter"
|
||||
:min-date="minDate"
|
||||
:max-date="maxDate"
|
||||
@select="onSelectDate"
|
||||
@month-show="onMonthChange"
|
||||
/>
|
||||
|
||||
<!-- 选中日期的活动列表 -->
|
||||
<div v-if="selectedDateActivities.length > 0" class="selected-date-activities">
|
||||
<div class="section-title">
|
||||
<van-icon name="calendar-o" />
|
||||
<span>{{ formatSelectedDate() }} 的活动</span>
|
||||
</div>
|
||||
<div class="date-activity-list">
|
||||
<div
|
||||
v-for="item in selectedDateActivities"
|
||||
:key="item.id"
|
||||
class="date-activity-card"
|
||||
@click="goToDetail(item.id)"
|
||||
>
|
||||
<div class="date-activity-content">
|
||||
<h4 class="date-activity-title">{{ item.title }}</h4>
|
||||
<div class="date-activity-time">
|
||||
<van-icon name="clock-o" size="14" />
|
||||
<span>{{ formatActivityTime(item.startTime, item.endTime) }}</span>
|
||||
</div>
|
||||
<div class="date-activity-location">
|
||||
<van-icon name="location-o" size="14" />
|
||||
<span>{{ item.location }}</span>
|
||||
</div>
|
||||
<div class="date-activity-footer">
|
||||
<van-tag :type="getStatusType(item.status)">
|
||||
{{ formatStatus(item.status) }}
|
||||
</van-tag>
|
||||
<span class="participants">
|
||||
{{ item.currentParticipants }}/{{ item.maxParticipants }}人
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedDate" class="no-activities">
|
||||
<van-empty description="该日期暂无活动" />
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
</van-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getActivities } from '@/services/activity';
|
||||
import { getActivities, getCalendarActivities } from '@/services/activity';
|
||||
import type { Activity } from '@/types/activity';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
@@ -65,6 +158,43 @@ const loading = ref(false);
|
||||
const finished = ref(false);
|
||||
const refreshing = ref(false);
|
||||
const currentPage = ref(1);
|
||||
const showSearchAction = ref(false);
|
||||
|
||||
// 筛选相关状态
|
||||
const filterStatus = ref(0);
|
||||
const filterTimeRange = ref(0);
|
||||
|
||||
// 状态选项
|
||||
const statusOptions = [
|
||||
{ text: '全部状态', value: 0 },
|
||||
{ text: '报名中', value: 1 },
|
||||
{ text: '进行中', value: 2 },
|
||||
{ text: '已结束', value: 3 },
|
||||
];
|
||||
|
||||
// 时间范围选项
|
||||
const timeRangeOptions = [
|
||||
{ text: '全部时间', value: 0 },
|
||||
{ text: '本周', value: 1 },
|
||||
{ text: '本月', value: 2 },
|
||||
{ text: '下月', value: 3 },
|
||||
];
|
||||
|
||||
// 日历相关状态
|
||||
const calendarActivities = ref<Activity[]>([]);
|
||||
const selectedDate = ref<Date | null>(null);
|
||||
const minDate = ref(new Date(new Date().getFullYear(), 0, 1));
|
||||
const maxDate = ref(new Date(new Date().getFullYear() + 2, 11, 31));
|
||||
|
||||
// 选中日期的活动列表
|
||||
const selectedDateActivities = computed(() => {
|
||||
if (!selectedDate.value) return [];
|
||||
const selectedDateStr = dayjs(selectedDate.value).format('YYYY-MM-DD');
|
||||
return calendarActivities.value.filter(activity => {
|
||||
const activityDate = dayjs(activity.startTime).format('YYYY-MM-DD');
|
||||
return activityDate === selectedDateStr;
|
||||
});
|
||||
});
|
||||
|
||||
const onLoad = async () => {
|
||||
if (refreshing.value) {
|
||||
@@ -73,15 +203,38 @@ const onLoad = async () => {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await getActivities({
|
||||
loading.value = true;
|
||||
|
||||
// 构建请求参数
|
||||
const params: any = {
|
||||
current: currentPage.value,
|
||||
size: 10,
|
||||
keyword: keyword.value,
|
||||
});
|
||||
|
||||
};
|
||||
|
||||
// 添加状态筛选
|
||||
if (filterStatus.value !== 0) {
|
||||
params.status = filterStatus.value;
|
||||
}
|
||||
|
||||
// 添加时间范围筛选
|
||||
const timeRange = getTimeRange(filterTimeRange.value);
|
||||
if (timeRange.startDate) {
|
||||
params.startDate = timeRange.startDate;
|
||||
}
|
||||
if (timeRange.endDate) {
|
||||
params.endDate = timeRange.endDate;
|
||||
}
|
||||
|
||||
const res = await getActivities(params);
|
||||
|
||||
const { records, total } = res;
|
||||
list.value.push(...records);
|
||||
|
||||
if (currentPage.value === 1) {
|
||||
list.value = records;
|
||||
} else {
|
||||
list.value.push(...records);
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
currentPage.value++;
|
||||
|
||||
@@ -91,12 +244,12 @@ const onLoad = async () => {
|
||||
} catch (error) {
|
||||
loading.value = false;
|
||||
finished.value = true;
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
finished.value = false;
|
||||
loading.value = true;
|
||||
currentPage.value = 1;
|
||||
onLoad();
|
||||
};
|
||||
@@ -106,11 +259,17 @@ const onSearch = () => {
|
||||
currentPage.value = 1;
|
||||
finished.value = false;
|
||||
loading.value = true;
|
||||
showSearchAction.value = true;
|
||||
onLoad();
|
||||
};
|
||||
|
||||
const onSearchFocus = () => {
|
||||
showSearchAction.value = true;
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
keyword.value = '';
|
||||
showSearchAction.value = false;
|
||||
onSearch();
|
||||
};
|
||||
|
||||
@@ -118,8 +277,13 @@ const goToDetail = (id: number) => {
|
||||
router.push(`/activity/${id}`);
|
||||
};
|
||||
|
||||
const formatTime = (time: string) => {
|
||||
return dayjs(time).format('MM-DD HH:mm');
|
||||
const formatTimeRange = (start: string, end: string) => {
|
||||
const s = dayjs(start);
|
||||
const e = dayjs(end);
|
||||
if (s.isSame(e, 'day')) {
|
||||
return `${s.format('MM-DD HH:mm')} - ${e.format('HH:mm')}`;
|
||||
}
|
||||
return `${s.format('MM-DD HH:mm')} - ${e.format('MM-DD HH:mm')}`;
|
||||
};
|
||||
|
||||
const formatStatus = (status: number) => {
|
||||
@@ -132,12 +296,324 @@ const formatStatus = (status: number) => {
|
||||
};
|
||||
return map[status] || '未知';
|
||||
};
|
||||
|
||||
const getStatusType = (status: number) => {
|
||||
const map: Record<number, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
|
||||
0: 'default',
|
||||
1: 'success',
|
||||
2: 'primary',
|
||||
3: 'default',
|
||||
4: 'danger',
|
||||
};
|
||||
return map[status] || 'default';
|
||||
};
|
||||
|
||||
const getRandomCover = (id: number) => {
|
||||
return `https://picsum.photos/seed/${id}/600/400`;
|
||||
};
|
||||
|
||||
// 筛选条件变化处理
|
||||
const onFilterChange = () => {
|
||||
list.value = [];
|
||||
currentPage.value = 1;
|
||||
finished.value = false;
|
||||
loading.value = true;
|
||||
onLoad();
|
||||
};
|
||||
|
||||
// 获取时间范围
|
||||
const getTimeRange = (range: number) => {
|
||||
const now = dayjs();
|
||||
let startDate = '';
|
||||
let endDate = '';
|
||||
|
||||
switch (range) {
|
||||
case 1: // 本周
|
||||
startDate = now.startOf('week').format('YYYY-MM-DD HH:mm:ss');
|
||||
endDate = now.endOf('week').format('YYYY-MM-DD HH:mm:ss');
|
||||
break;
|
||||
case 2: // 本月
|
||||
startDate = now.startOf('month').format('YYYY-MM-DD HH:mm:ss');
|
||||
endDate = now.endOf('month').format('YYYY-MM-DD HH:mm:ss');
|
||||
break;
|
||||
case 3: // 下月
|
||||
startDate = now.add(1, 'month').startOf('month').format('YYYY-MM-DD HH:mm:ss');
|
||||
endDate = now.add(1, 'month').endOf('month').format('YYYY-MM-DD HH:mm:ss');
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
};
|
||||
|
||||
// 加载日历数据
|
||||
const loadCalendarData = async (year: number, month: number) => {
|
||||
try {
|
||||
const res = await getCalendarActivities(year, month);
|
||||
calendarActivities.value = res;
|
||||
} catch (error) {
|
||||
console.error('加载日历数据失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 日历formatter - 标记有活动的日期
|
||||
const calendarFormatter = (day: any) => {
|
||||
const dateStr = dayjs(day.date).format('YYYY-MM-DD');
|
||||
const hasActivity = calendarActivities.value.some(activity => {
|
||||
const activityDate = dayjs(activity.startTime).format('YYYY-MM-DD');
|
||||
return activityDate === dateStr;
|
||||
});
|
||||
|
||||
if (hasActivity) {
|
||||
day.bottomInfo = '有活动';
|
||||
day.className = 'has-activity';
|
||||
}
|
||||
|
||||
return day;
|
||||
};
|
||||
|
||||
// 月份切换事件
|
||||
const onMonthChange = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1;
|
||||
loadCalendarData(year, month);
|
||||
};
|
||||
|
||||
// 日期选择事件
|
||||
const onSelectDate = (date: Date) => {
|
||||
selectedDate.value = date;
|
||||
};
|
||||
|
||||
// 格式化选中的日期
|
||||
const formatSelectedDate = () => {
|
||||
if (!selectedDate.value) return '';
|
||||
return dayjs(selectedDate.value).format('YYYY年MM月DD日');
|
||||
};
|
||||
|
||||
// 格式化活动时间
|
||||
const formatActivityTime = (start: string, end: string) => {
|
||||
const s = dayjs(start);
|
||||
const e = dayjs(end);
|
||||
if (s.isSame(e, 'day')) {
|
||||
return `${s.format('HH:mm')} - ${e.format('HH:mm')}`;
|
||||
}
|
||||
return `${s.format('MM-DD HH:mm')} - ${e.format('MM-DD HH:mm')}`;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
onLoad();
|
||||
// 加载当前月份的日历数据
|
||||
const now = new Date();
|
||||
loadCalendarData(now.getFullYear(), now.getMonth() + 1);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.home-page {
|
||||
padding-bottom: 50px; // For TabBar
|
||||
padding-bottom: 60px;
|
||||
background-color: #f7f8fa;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #f5f6f7;
|
||||
}
|
||||
|
||||
.calendar-container {
|
||||
background: #fff;
|
||||
|
||||
:deep(.has-activity) {
|
||||
.van-calendar__day {
|
||||
color: #1989fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.van-calendar__bottom-info {
|
||||
color: #1989fa;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
border-bottom: 1px solid #f5f6f7;
|
||||
|
||||
.van-icon {
|
||||
margin-right: 8px;
|
||||
color: #1989fa;
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.selected-date-activities {
|
||||
margin-top: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.date-activity-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.date-activity-card {
|
||||
background: #f7f8fa;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
background: #e8eaed;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.date-activity-content {
|
||||
.date-activity-title {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.date-activity-time,
|
||||
.date-activity-location {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
color: #646566;
|
||||
|
||||
.van-icon {
|
||||
margin-right: 6px;
|
||||
color: #969799;
|
||||
}
|
||||
}
|
||||
|
||||
.date-activity-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 10px;
|
||||
|
||||
.participants {
|
||||
font-size: 12px;
|
||||
color: #969799;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.no-activities {
|
||||
padding: 40px 20px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.activity-list {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.card-cover {
|
||||
position: relative;
|
||||
height: 160px;
|
||||
width: 100%;
|
||||
|
||||
.cover-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.image-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #f5f5f5;
|
||||
color: #dcdee0;
|
||||
}
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 12px 16px;
|
||||
|
||||
.activity-title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
color: #323233;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
color: #646566;
|
||||
font-size: 14px;
|
||||
|
||||
.icon {
|
||||
margin-right: 8px;
|
||||
font-size: 16px;
|
||||
color: #969799;
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #f5f6f7;
|
||||
|
||||
.participants {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
color: #969799;
|
||||
|
||||
.van-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
<div class="ticket-header">
|
||||
<h3>{{ item.activityTitle }}</h3>
|
||||
<!-- Status: 0=Cancelled, 1=Valid, 2=Checked In -->
|
||||
<van-tag :type="getStatusType(item.status)">
|
||||
{{ getStatusText(item.status) }}
|
||||
<van-tag :type="getStatusType(item)">
|
||||
{{ getStatusText(item) }}
|
||||
</van-tag>
|
||||
</div>
|
||||
<div class="ticket-body">
|
||||
@@ -28,17 +28,26 @@
|
||||
<p><strong>票码:</strong> {{ item.ticketCode }}</p>
|
||||
</div>
|
||||
<div class="ticket-footer">
|
||||
<van-button
|
||||
v-if="isEnded(item.activityEndTime) || item.status === 2"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
<van-button
|
||||
v-if="isEnded(item.activityEndTime) || item.status === 2"
|
||||
size="small"
|
||||
type="warning"
|
||||
plain
|
||||
@click="handleReview(item)"
|
||||
>
|
||||
去评价
|
||||
</van-button>
|
||||
<van-button v-if="item.status === 1" size="small" type="primary" plain @click="showQrCode(item)">入场码</van-button>
|
||||
<van-button v-if="item.status === 1" size="small" type="danger" plain @click="handleCancel(item)">取消报名</van-button>
|
||||
<van-button
|
||||
v-if="item.status === 1 && !isEnded(item.activityEndTime)"
|
||||
size="small"
|
||||
type="success"
|
||||
plain
|
||||
@click="handleDownloadTicket(item)"
|
||||
>
|
||||
下载电子票
|
||||
</van-button>
|
||||
<van-button v-if="item.status === 1 && !isEnded(item.activityEndTime)" size="small" type="primary" plain @click="showQrCode(item)">入场码</van-button>
|
||||
<van-button v-if="item.status === 1 && !isEnded(item.activityEndTime)" size="small" type="danger" plain @click="handleCancel(item)">取消报名</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-list>
|
||||
@@ -75,7 +84,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onActivated } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { getMyRegistrations, cancelRegistration } from '@/services/registration';
|
||||
import { getMyRegistrations, cancelRegistration, downloadTicket } from '@/services/registration';
|
||||
import { scanCheckinQr } from '@/services/checkin';
|
||||
import type { Registration } from '@/types/registration';
|
||||
import { showToast, showDialog } from 'vant';
|
||||
@@ -176,7 +185,7 @@ const handleCancel = (item: Registration) => {
|
||||
};
|
||||
|
||||
const handleReview = (item: Registration) => {
|
||||
// Navigate to write review page or list?
|
||||
// Navigate to write review page or list?
|
||||
// Requirement says "enter review page"
|
||||
// We can use the ActivityDetail or a dedicated review page.
|
||||
// Assuming ActivityDetail has review function or we add one.
|
||||
@@ -192,15 +201,47 @@ const handleReview = (item: Registration) => {
|
||||
router.push(`/activity/${item.activityId}`);
|
||||
};
|
||||
|
||||
const getStatusType = (status: number) => {
|
||||
if (status === 1) return 'primary';
|
||||
if (status === 2) return 'success';
|
||||
const handleDownloadTicket = async (item: Registration) => {
|
||||
try {
|
||||
showToast({ type: 'loading', message: '下载中...', duration: 0 });
|
||||
|
||||
const blob = await downloadTicket(item.id);
|
||||
|
||||
// 创建 Blob URL
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `ticket_${item.ticketCode}.pdf`;
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
// 清理
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
showToast({ type: 'success', message: '下载成功' });
|
||||
} catch (error) {
|
||||
showToast('下载失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusType = (item: Registration) => {
|
||||
// Check if activity has ended first
|
||||
if (isEnded(item.activityEndTime)) return 'warning';
|
||||
if (item.status === 1) return 'primary';
|
||||
if (item.status === 2) return 'success';
|
||||
return 'default';
|
||||
};
|
||||
|
||||
const getStatusText = (status: number) => {
|
||||
if (status === 1) return '已报名';
|
||||
if (status === 2) return '已签到';
|
||||
const getStatusText = (item: Registration) => {
|
||||
// Check if activity has ended first
|
||||
if (isEnded(item.activityEndTime)) return '已结束';
|
||||
if (item.status === 1) return '已报名';
|
||||
if (item.status === 2) return '已签到';
|
||||
return '已取消';
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user