feat: 新增评价提交组件并优化多个前端页面交互

This commit is contained in:
2026-01-13 23:30:10 +08:00
parent ce4346c35a
commit d241d0b11e
10 changed files with 1094 additions and 94 deletions

5
web/components.d.ts vendored
View File

@@ -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']

View 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>

View File

@@ -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'
});
};

View File

@@ -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 },

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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('导出失败');
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 '已取消';
};