upload: 上传vite前端成果物

This commit is contained in:
2026-01-12 13:48:45 +08:00
parent 1eab0b0ef1
commit 10cd9f1d06
46 changed files with 5845 additions and 0 deletions

24
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

3
web/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
web/README.md Normal file
View File

@@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

90
web/auto-imports.d.ts vendored Normal file
View File

@@ -0,0 +1,90 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const EffectScope: typeof import('vue').EffectScope
const acceptHMRUpdate: typeof import('pinia').acceptHMRUpdate
const computed: typeof import('vue').computed
const createApp: typeof import('vue').createApp
const createPinia: typeof import('pinia').createPinia
const customRef: typeof import('vue').customRef
const defineAsyncComponent: typeof import('vue').defineAsyncComponent
const defineComponent: typeof import('vue').defineComponent
const defineStore: typeof import('pinia').defineStore
const effectScope: typeof import('vue').effectScope
const getActivePinia: typeof import('pinia').getActivePinia
const getCurrentInstance: typeof import('vue').getCurrentInstance
const getCurrentScope: typeof import('vue').getCurrentScope
const getCurrentWatcher: typeof import('vue').getCurrentWatcher
const h: typeof import('vue').h
const inject: typeof import('vue').inject
const isProxy: typeof import('vue').isProxy
const isReactive: typeof import('vue').isReactive
const isReadonly: typeof import('vue').isReadonly
const isRef: typeof import('vue').isRef
const isShallow: typeof import('vue').isShallow
const mapActions: typeof import('pinia').mapActions
const mapGetters: typeof import('pinia').mapGetters
const mapState: typeof import('pinia').mapState
const mapStores: typeof import('pinia').mapStores
const mapWritableState: typeof import('pinia').mapWritableState
const markRaw: typeof import('vue').markRaw
const nextTick: typeof import('vue').nextTick
const onActivated: typeof import('vue').onActivated
const onBeforeMount: typeof import('vue').onBeforeMount
const onBeforeRouteLeave: typeof import('vue-router').onBeforeRouteLeave
const onBeforeRouteUpdate: typeof import('vue-router').onBeforeRouteUpdate
const onBeforeUnmount: typeof import('vue').onBeforeUnmount
const onBeforeUpdate: typeof import('vue').onBeforeUpdate
const onDeactivated: typeof import('vue').onDeactivated
const onErrorCaptured: typeof import('vue').onErrorCaptured
const onMounted: typeof import('vue').onMounted
const onRenderTracked: typeof import('vue').onRenderTracked
const onRenderTriggered: typeof import('vue').onRenderTriggered
const onScopeDispose: typeof import('vue').onScopeDispose
const onServerPrefetch: typeof import('vue').onServerPrefetch
const onUnmounted: typeof import('vue').onUnmounted
const onUpdated: typeof import('vue').onUpdated
const onWatcherCleanup: typeof import('vue').onWatcherCleanup
const provide: typeof import('vue').provide
const reactive: typeof import('vue').reactive
const readonly: typeof import('vue').readonly
const ref: typeof import('vue').ref
const resolveComponent: typeof import('vue').resolveComponent
const setActivePinia: typeof import('pinia').setActivePinia
const setMapStoreSuffix: typeof import('pinia').setMapStoreSuffix
const shallowReactive: typeof import('vue').shallowReactive
const shallowReadonly: typeof import('vue').shallowReadonly
const shallowRef: typeof import('vue').shallowRef
const storeToRefs: typeof import('pinia').storeToRefs
const toRaw: typeof import('vue').toRaw
const toRef: typeof import('vue').toRef
const toRefs: typeof import('vue').toRefs
const toValue: typeof import('vue').toValue
const triggerRef: typeof import('vue').triggerRef
const unref: typeof import('vue').unref
const useAttrs: typeof import('vue').useAttrs
const useCssModule: typeof import('vue').useCssModule
const useCssVars: typeof import('vue').useCssVars
const useId: typeof import('vue').useId
const useLink: typeof import('vue-router').useLink
const useModel: typeof import('vue').useModel
const useRoute: typeof import('vue-router').useRoute
const useRouter: typeof import('vue-router').useRouter
const useSlots: typeof import('vue').useSlots
const useTemplateRef: typeof import('vue').useTemplateRef
const watch: typeof import('vue').watch
const watchEffect: typeof import('vue').watchEffect
const watchPostEffect: typeof import('vue').watchPostEffect
const watchSyncEffect: typeof import('vue').watchSyncEffect
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, Slot, Slots, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, ShallowRef, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
}

54
web/components.d.ts vendored Normal file
View File

@@ -0,0 +1,54 @@
/* eslint-disable */
// @ts-nocheck
// biome-ignore lint: disable
// oxlint-disable
// ------
// Generated by unplugin-vue-components
// Read more: https://github.com/vuejs/core/pull/3399
export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
VanActionBar: typeof import('vant/es')['ActionBar']
VanActionBarButton: typeof import('vant/es')['ActionBarButton']
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']
VanEmpty: typeof import('vant/es')['Empty']
VanField: typeof import('vant/es')['Field']
VanForm: typeof import('vant/es')['Form']
VanGrid: typeof import('vant/es')['Grid']
VanGridItem: typeof import('vant/es')['GridItem']
VanIcon: typeof import('vant/es')['Icon']
VanImage: typeof import('vant/es')['Image']
VanList: typeof import('vant/es')['List']
VanLoading: typeof import('vant/es')['Loading']
VanNavBar: typeof import('vant/es')['NavBar']
VanPopup: typeof import('vant/es')['Popup']
VanProgress: typeof import('vant/es')['Progress']
VanPullRefresh: typeof import('vant/es')['PullRefresh']
VanRadio: typeof import('vant/es')['Radio']
VanRadioGroup: typeof import('vant/es')['RadioGroup']
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']
VanTabs: typeof import('vant/es')['Tabs']
VanTag: typeof import('vant/es')['Tag']
VanTimePicker: typeof import('vant/es')['TimePicker']
WebQRScanner: typeof import('./src/components/WebQRScanner.vue')['default']
}
}

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2802
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
web/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"dayjs": "^1.11.19",
"html5-qrcode": "^2.3.8",
"pinia": "^3.0.4",
"pinia-plugin-persistedstate": "^4.7.1",
"vant": "^4.9.22",
"vue": "^3.5.24",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vant/auto-import-resolver": "^1.3.0",
"@vitejs/plugin-basic-ssl": "^2.1.3",
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.8.1",
"sass": "^1.97.2",
"typescript": "~5.9.3",
"unplugin-auto-import": "^20.3.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.2.4",
"vue-tsc": "^3.1.4"
}
}

1
web/public/vite.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

48
web/src/App.vue Normal file
View File

@@ -0,0 +1,48 @@
<template>
<van-config-provider theme="light">
<router-view v-slot="{ Component }">
<keep-alive include="Home,MyRegistrations,MyReviews">
<component :is="Component" />
</keep-alive>
</router-view>
<van-tabbar v-model="active" route v-if="showTabBar">
<van-tabbar-item replace to="/home" icon="home-o">首页</van-tabbar-item>
<van-tabbar-item replace to="/registrations/my" icon="coupon-o">报名</van-tabbar-item>
<van-tabbar-item replace to="/user" icon="user-o">我的</van-tabbar-item>
</van-tabbar>
</van-config-provider>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const active = ref(0);
const showTabBar = computed(() => {
// Show tabbar only on top-level pages
const tabs = ['/home', '/user', '/registrations/my'];
return tabs.includes(route.path);
});
</script>
<style>
/* Global fix for Vant Toast/Dialog rendering */
.van-toast, .van-dialog, .van-notify, .van-image-preview {
z-index: 9999 !important;
}
/* Force Toast visibility */
.van-toast {
background-color: rgba(0, 0, 0, 0.7) !important;
color: #fff !important;
.van-toast__text {
color: #fff !important;
}
.van-icon {
color: #fff !important;
}
}
</style>

View File

@@ -0,0 +1 @@
placeholder

22
web/src/assets/main.scss Normal file
View File

@@ -0,0 +1,22 @@
:root {
--van-primary-color: #1989fa;
--van-success-color: #07c160;
--van-danger-color: #ee0a24;
--van-warning-color: #ff976a;
}
body {
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica,
Segoe UI, Arial, Roboto, 'PingFang SC', 'miui', 'Hiragino Sans GB',
'Microsoft Yahei', sans-serif;
background-color: #f7f8fa; /* Light gray background common in apps */
-webkit-font-smoothing: antialiased;
}
#app {
min-height: 100vh;
display: flex;
flex-direction: column;
}

1
web/src/assets/vue.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@@ -0,0 +1,100 @@
<template>
<div class="web-qr-scanner">
<div id="qr-reader" class="qr-reader"></div>
<div class="scanner-controls">
<van-button block round @click="$emit('close')">关闭</van-button>
</div>
</div>
</template>
<script setup lang="ts">
// eslint-disable-next-line @typescript-eslint/no-unused-vars
import { onMounted, onBeforeUnmount } from 'vue';
import { Html5Qrcode } from 'html5-qrcode';
import { showToast } from 'vant';
const emit = defineEmits(['result', 'error', 'close']);
let html5QrCode: Html5Qrcode | null = null;
onMounted(() => {
startScanning();
});
onBeforeUnmount(() => {
stopScanning();
});
const startScanning = async () => {
try {
const devices = await Html5Qrcode.getCameras();
if (devices && devices.length) {
let cameraId = devices[0]!.id;
if (devices.length > 1 && devices[1]) {
cameraId = devices[1].id;
}
html5QrCode = new Html5Qrcode("qr-reader");
await html5QrCode.start(
cameraId,
{
fps: 10,
qrbox: { width: 250, height: 250 }
},
(decodedText) => {
emit('result', decodedText);
stopScanning();
},
// eslint-disable-next-line @typescript-eslint/no-unused-vars
(_errorMessage) => {
// ignore error
}
);
} else {
showToast('未发现相机设备');
emit('error', new Error('No cameras found'));
}
} catch (err) {
showToast('无法启动相机');
emit('error', err);
}
};
const stopScanning = () => {
if (html5QrCode && html5QrCode.isScanning) {
html5QrCode.stop().then(() => {
html5QrCode?.clear();
}).catch(err => {
console.error("Failed to stop scanning", err);
});
}
};
</script>
<style scoped>
.web-qr-scanner {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: black;
z-index: 2000;
display: flex;
flex-direction: column;
justify-content: center;
}
.qr-reader {
width: 100%;
}
.scanner-controls {
padding: 20px;
background: transparent;
position: absolute;
bottom: 20px;
width: 100%;
box-sizing: border-box;
}
</style>

7
web/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue';
const component: DefineComponent<{}, {}, any>;
export default component;
}

18
web/src/main.ts Normal file
View File

@@ -0,0 +1,18 @@
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate';
import App from './App.vue';
import router from './router';
// Import Vant styles
import 'vant/lib/index.css';
import './assets/main.scss';
const app = createApp(App);
const pinia = createPinia();
pinia.use(piniaPluginPersistedstate);
app.use(pinia);
app.use(router);
app.mount('#app');

110
web/src/router/index.ts Normal file
View File

@@ -0,0 +1,110 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router';
// Basic wrapper layout (we will implement this later)
// For now, just use RouterView
const routes: Array<RouteRecordRaw> = [
{
path: '/',
redirect: '/home',
},
{
path: '/login',
name: 'Login',
component: () => import('@/views/auth/Login.vue'),
meta: { title: '登录' }
},
{
path: '/register',
name: 'Register',
component: () => import('@/views/auth/Register.vue'),
meta: { title: '注册' }
},
{
path: '/home',
name: 'Home',
component: () => import('@/views/home/Home.vue'),
meta: { title: '校园活动' }
},
{
path: '/activity/:id',
name: 'ActivityDetail',
component: () => import('@/views/activity/ActivityDetail.vue'),
meta: { title: '活动详情' }
},
{
path: '/registrations/my',
name: 'MyRegistrations',
component: () => import('@/views/registration/MyRegistrations.vue'),
meta: { title: '我的票券' }
},
{
path: '/reviews/my',
name: 'MyReviews',
component: () => import('@/views/review/MyReviews.vue'),
meta: { title: '我的评价' }
},
{
path: '/admin/dashboard',
name: 'AdminDashboard',
component: () => import('@/views/admin/AdminDashboard.vue'),
meta: { title: '管理后台' }
},
{
path: '/admin/activity/new',
name: 'CreateActivity',
component: () => import('@/views/admin/ActivityEdit.vue'),
meta: { title: '创建活动' }
},
{
path: '/admin/activity/edit/:id',
name: 'EditActivity',
component: () => import('@/views/admin/ActivityEdit.vue'),
meta: { title: '编辑活动' }
},
{
path: '/admin/activity/checkin/:id',
name: 'CheckinManagement',
component: () => import('@/views/admin/CheckinManagement.vue'),
meta: { title: '签到管理' }
},
{
path: '/admin/activity/stats/:id',
name: 'ActivityStats',
component: () => import('@/views/admin/ActivityStats.vue'),
meta: { title: '数据统计' }
},
{
path: '/admin/activity/reviews/:id',
name: 'ActivityReviews',
component: () => import('@/views/admin/ActivityReviews.vue'),
meta: { title: '活动评价' }
},
// Placeholder routes
{
path: '/user',
name: 'User',
component: () => import('@/views/user/UserProfile.vue'),
}
];
const router = createRouter({
history: createWebHistory(),
routes,
});
router.beforeEach((to, _from, next) => {
document.title = (to.meta.title as string) || 'Campus Activity System';
// Check if route requires auth
const publicPages = ['/login', '/register', '/home', '/'];
const authRequired = !publicPages.includes(to.path) && !to.path.startsWith('/activity/');
const loggedIn = localStorage.getItem('auth') ? JSON.parse(localStorage.getItem('auth')!).token : null;
if (authRequired && !loggedIn) {
next('/login');
} else {
next();
}
});
export default router;

View File

@@ -0,0 +1,26 @@
import request from '@/utils/request';
import type { Activity, ActivityListResponse, ActivitySearchParams } from '@/types/activity';
export const getActivities = (params?: ActivitySearchParams) => {
return request.get<any, ActivityListResponse>('/activities', { params });
};
export const getActivityDetail = (id: number) => {
return request.get<any, Activity>(`/activities/${id}`);
};
export const getCalendarActivities = (year: number, month: number) => {
return request.get<any, Activity[]>('/activities/calendar', { params: { year, month } });
};
export const createActivity = (data: Partial<Activity>) => {
return request.post<any, number>('/activities', data);
};
export const updateActivity = (id: number, data: Partial<Activity>) => {
return request.put(`/activities/${id}`, data);
};
export const deleteActivity = (id: number) => {
return request.delete(`/activities/${id}`);
};

20
web/src/services/auth.ts Normal file
View File

@@ -0,0 +1,20 @@
import request from '@/utils/request';
import type { LoginResult, RegisterParams } from '@/types/auth';
export const login = (data: any) => {
return request.post<any, LoginResult>('/auth/login', data);
};
export const register = (data: RegisterParams) => {
return request.post('/auth/register', data);
};
export const getUserInfo = () => {
return request.get('/auth/me');
};
export const logout = () => {
// Client-side logout usually, but if there's a backend endpoint:
// return request.post('/auth/logout');
return Promise.resolve();
};

View File

@@ -0,0 +1,36 @@
import request from '@/utils/request';
export interface CheckinResult {
id: number;
userId: number;
userName: number;
studentId: string;
activityId: number;
checkInTime: string;
}
export interface QrCodeData {
qrCodeUrl: string;
qrCodeContent: string;
expiresAt: string;
}
// Admin: Generate Check-in QR for students to scan
export const generateCheckinQr = (activityId: number) => {
return request.post<any, QrCodeData>(`/checkin/qrcode/${activityId}`);
};
// Student: Scan Activity QR to check in
export const scanCheckinQr = (qrCodeContent: string) => {
return request.post<any, CheckinResult>('/checkin/scan', { qrCodeContent });
};
// Admin: Scan Student Ticket to check in
export const adminScanTicket = (activityId: number, ticketCode: string) => {
return request.post<any, CheckinResult>('/checkin/ticket', { activityId, ticketCode });
};
// Get check-in records for activity
export const getCheckinRecords = (activityId: number) => {
return request.get<any, { records: CheckinResult[]; total: number }>(`/checkin/activity/${activityId}`);
};

View File

@@ -0,0 +1,17 @@
import request from '@/utils/request';
import type { Registration, RegistrationListResponse } from '@/types/registration';
// Register for an activity
export const registerActivity = (activityId: number) => {
return request.post<any, Registration>('/registrations', { activityId });
};
// Cancel registration
export const cancelRegistration = (id: number) => {
return request.delete(`/registrations/${id}`);
};
// Get my registrations
export const getMyRegistrations = (params?: any) => {
return request.get<any, RegistrationListResponse>('/registrations/my', { params });
};

View File

@@ -0,0 +1,23 @@
import request from '@/utils/request';
import type { Review, ReviewListResponse, CreateReviewParams } from '@/types/review';
export const submitReview = (data: CreateReviewParams) => {
return request.post<any, Review>('/reviews', data);
};
export const getActivityReviews = (activityId: number, params?: any) => {
return request.get<any, ReviewListResponse>(`/reviews/activity/${activityId}`, { params });
};
export const getReviewsByActivity = (params: { activityId: number; current?: number; size?: number }) => {
return request.get<any, ReviewListResponse>(`/reviews/activity/${params.activityId}`, {
params: {
current: params.current || 1,
size: params.size || 10
}
});
};
export const getMyReviews = (params?: any) => {
return request.get<any, ReviewListResponse>('/reviews/my', { params });
};

41
web/src/services/stats.ts Normal file
View File

@@ -0,0 +1,41 @@
import request from '@/utils/request';
export interface ActivityStats {
activityId: number;
activityTitle: string;
registeredCount: number;
checkedInCount: number;
checkInRate: number;
reviewCount: number;
averageRating: number;
ratingDistribution: Record<string, number>;
}
export interface OverviewStats {
totalActivities: number;
totalRegistrations: number;
totalCheckIns: number;
totalReviews: number;
averageRating: number;
monthlyStats: {
month: string;
activityCount: number;
registrationCount: number;
}[];
}
export const getActivityStats = (activityId: number) => {
return request.get<any, ActivityStats>(`/statistics/activity/${activityId}`);
};
export const getOverviewStats = () => {
return request.get<any, OverviewStats>('/statistics/overview');
};
export const exportActivityStats = (activityId: number, format = 'excel') => {
// Return blob or handle download
return request.get(`/statistics/activity/${activityId}/export`, {
params: { format },
responseType: 'blob'
});
};

View File

@@ -0,0 +1,16 @@
import { defineStore } from 'pinia';
import { ref } from 'vue';
import type { Activity } from '@/types/activity';
export const useActivityStore = defineStore('activity', () => {
const currentActivity = ref<Activity | null>(null);
const setActivity = (activity: Activity) => {
currentActivity.value = activity;
};
return {
currentActivity,
setActivity,
};
});

40
web/src/stores/auth.ts Normal file
View File

@@ -0,0 +1,40 @@
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import type { UserInfo } from '@/types/auth';
export const useAuthStore = defineStore(
'auth',
() => {
const token = ref<string>('');
const user = ref<UserInfo | null>(null);
const isLoggedIn = computed(() => !!token.value);
const isAdmin = computed(() => user.value?.role === 1);
const setToken = (newToken: string) => {
token.value = newToken;
};
const setUser = (newUser: UserInfo) => {
user.value = newUser;
};
const logout = () => {
token.value = '';
user.value = null;
};
return {
token,
user,
isLoggedIn,
isAdmin,
setToken,
setUser,
logout,
};
},
{
persist: true, // Auto-save to localStorage
}
);

38
web/src/types/activity.ts Normal file
View File

@@ -0,0 +1,38 @@
export interface Activity {
id: number;
title: string;
description?: string;
coverImage?: string;
startTime: string; // ISO 8601
endTime: string;
registrationDeadline?: string;
location: string;
maxParticipants: number;
currentParticipants: number;
status: number; // 0:Draft, 1:Published, 2:Ongoing, 3:Ended, 4:Canceled
category?: string;
adminId: number;
adminName?: string;
averageRating?: number;
reviewCount?: number;
isRegistered?: boolean;
createdAt?: string;
}
export interface ActivitySearchParams {
current?: number;
size?: number;
status?: number;
keyword?: string;
category?: string;
startDate?: string;
endDate?: string;
}
export interface ActivityListResponse {
records: Activity[];
total: number;
pages: number;
current: number;
size: number;
}

27
web/src/types/auth.ts Normal file
View File

@@ -0,0 +1,27 @@
export interface LoginResult {
accessToken: string;
refreshToken: string;
expiresIn: number;
tokenType: string;
userInfo: UserInfo;
}
export interface UserInfo {
id: number;
username: string;
name: string;
role: number; // 0: Student, 1: Admin
avatar?: string | null;
studentId?: string;
email?: string;
phone?: string;
}
export interface RegisterParams {
username: string;
password: string;
name: string;
studentId?: string;
email?: string;
phone?: string;
}

View File

@@ -0,0 +1,21 @@
export interface Registration {
id: number;
activityId: number;
activityTitle: string;
activityStartTime: string;
activityEndTime: string;
activityLocation: string;
ticketCode: string; // Used for QR code
ticketPdfUrl?: string;
status: number; // 1: Active, 0: Canceled
createdAt: string;
canceledAt?: string;
}
export interface RegistrationListResponse {
records: Registration[];
total: number;
pages: number;
current: number;
size: number;
}

25
web/src/types/review.ts Normal file
View File

@@ -0,0 +1,25 @@
export interface Review {
id: number;
userId: number;
userName: string;
userAvatar?: string;
activityId: number;
activityTitle: string;
rating: number; // 1-5
content?: string;
createdAt: string;
}
export interface ReviewListResponse {
records: Review[];
total: number;
pages: number;
current: number;
size: number;
}
export interface CreateReviewParams {
activityId: number;
rating: number;
content?: string;
}

50
web/src/utils/request.ts Normal file
View File

@@ -0,0 +1,50 @@
import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from 'axios';
import { useAuthStore } from '@/stores/auth';
import { showToast } from 'vant';
const service = axios.create({
baseURL: 'http://100.64.32.254:8080/api/v1', // Proxy will handle this to backend
timeout: 10000,
});
// Request Interceptor
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const authStore = useAuthStore();
if (authStore.token) {
config.headers['Authorization'] = `Bearer ${authStore.token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response Interceptor
service.interceptors.response.use(
(response: AxiosResponse) => {
const res = response.data;
// According to API docs: code 200 is success
if (res.code !== 200) {
showToast(res.message || 'Error');
// Handle specific error codes if needed
// 401: Token expired or invalid
if (res.code === 401) {
const authStore = useAuthStore();
authStore.logout();
location.reload();
}
return Promise.reject(new Error(res.message || 'Error'));
}
return res.data; // Return the actual data part directly
},
(error) => {
console.error('Request Error:', error);
showToast(error.message || 'Request Failed');
return Promise.reject(error);
}
);
export default service;

View File

@@ -0,0 +1,153 @@
<template>
<div class="activity-detail-page" v-if="activity">
<van-nav-bar
title="活动详情"
left-arrow
@click-left="onClickLeft"
fixed
placeholder
/>
<van-image
width="100%"
height="200"
fit="cover"
:src="activity.coverImage || 'https://fastly.jsdelivr.net/npm/@vant/assets/ipad.jpeg'"
/>
<div class="content">
<h1 class="title">{{ activity.title }}</h1>
<div class="meta">
<van-tag type="primary">{{ activity.category }}</van-tag>
<van-tag type="success" style="margin-left: 8px">{{ formatStatus(activity.status) }}</van-tag>
</div>
<van-cell-group inset class="info-group">
<van-cell icon="clock-o" title="时间" :label="`${formatTime(activity.startTime)} - ${formatTime(activity.endTime)}`" />
<van-cell icon="location-o" title="地点" :label="activity.location" />
<van-cell icon="friends-o" title="人数" :label="`${activity.currentParticipants} / ${activity.maxParticipants}`" />
<van-cell icon="manager-o" title="主办方" :label="activity.adminName || '管理员'" />
</van-cell-group>
<div class="description">
<h3>活动简介</h3>
<p>{{ activity.description || '暂无简介' }}</p>
</div>
</div>
<van-action-bar>
<van-action-bar-icon icon="chat-o" text="评价" @click="goToReviews" />
<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>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { getActivityDetail } from '@/services/activity';
import type { Activity } from '@/types/activity';
import { showToast } from 'vant';
import dayjs from 'dayjs';
const route = useRoute();
// const router = useRouter(); // Unused
const activity = ref<Activity | null>(null);
const id = Number(route.params.id);
onMounted(async () => {
try {
const res = await getActivityDetail(id);
activity.value = res;
} catch (error) {
showToast('加载失败');
}
});
const onClickLeft = () => history.back();
import { registerActivity } from '@/services/registration';
const handleRegister = async () => {
if (!activity.value) return;
try {
await registerActivity(activity.value.id);
showToast('报名成功!可在“我的票券”中查看');
// Refresh activity data to update status
const updated = await getActivityDetail(id);
activity.value = updated;
} catch (error) {
// Error handled by interceptor
}
};
const goToReviews = () => {
// To be implemented
showToast('评价功能即将上线');
};
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD HH:mm');
};
const formatStatus = (status: number) => {
const map: Record<number, string> = {
0: '草稿',
1: '报名中',
2: '进行中',
3: '已结束',
4: '已取消',
};
return map[status] || '未知';
};
</script>
<style scoped lang="scss">
.activity-detail-page {
padding-bottom: 50px;
background-color: #f7f8fa;
min-height: 100vh;
.content {
padding: 16px;
.title {
font-size: 24px;
margin: 0 0 10px;
background: #fff;
padding: 16px;
border-radius: 8px;
}
.meta {
margin-bottom: 16px;
padding: 0 16px;
}
.info-group {
margin-bottom: 16px;
}
.description {
background: #fff;
padding: 16px;
border-radius: 8px;
h3 {
margin: 0 0 10px;
font-size: 16px;
}
p {
color: #666;
line-height: 1.6;
margin: 0;
}
}
}
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<div class="activity-edit-page">
<van-nav-bar
:title="isEdit ? '编辑活动' : '创建活动'"
left-text="取消"
left-arrow
@click-left="onClickLeft"
/>
<van-form @submit="onSubmit">
<van-cell-group inset style="margin-top: 16px;">
<van-field
v-model="form.title"
name="title"
label="标题"
placeholder="活动标题"
:rules="[{ required: true, message: '必填' }]"
/>
<van-field
v-model="form.description"
name="description"
label="简介"
type="textarea"
placeholder="活动简介"
autosize
/>
<van-field
v-model="form.location"
name="location"
label="地点"
placeholder="活动地点"
:rules="[{ required: true, message: '必填' }]"
/>
<van-field
v-model="form.category"
name="category"
label="分类"
placeholder="例如:音乐, 体育"
/>
<van-field
v-model="form.startTime"
is-link
readonly
name="startTime"
label="开始时间"
placeholder="点击选择时间"
@click="showStartTimePicker = true"
:rules="[{ required: true, message: '必填' }]"
/>
<van-popup v-model:show="showStartTimePicker" position="bottom">
<van-date-picker
v-model="pickerValues.startDate"
title="选择日期"
@confirm="onConfirmStartDate"
@cancel="showStartTimePicker = false"
/>
</van-popup>
<van-popup v-model:show="showStartTimeTimePicker" position="bottom">
<van-time-picker
v-model="pickerValues.startTime"
title="选择时间"
@confirm="onConfirmStartTime"
@cancel="showStartTimeTimePicker = false"
/>
</van-popup>
<van-field
v-model="form.endTime"
is-link
readonly
name="endTime"
label="结束时间"
placeholder="点击选择时间"
@click="showEndTimePicker = true"
:rules="[{ required: true, message: '必填' }]"
/>
<van-popup v-model:show="showEndTimePicker" position="bottom">
<van-date-picker
v-model="pickerValues.endDate"
title="选择日期"
@confirm="onConfirmEndDate"
@cancel="showEndTimePicker = false"
/>
</van-popup>
<van-popup v-model:show="showEndTimeTimePicker" position="bottom">
<van-time-picker
v-model="pickerValues.endTime"
title="选择时间"
@confirm="onConfirmEndTime"
@cancel="showEndTimeTimePicker = false"
/>
</van-popup>
<van-field
name="maxParticipants"
label="最大人数"
>
<template #input>
<van-stepper v-model="form.maxParticipants" min="1" max="10000" integer />
</template>
</van-field>
<van-field name="status" label="状态" v-if="isEdit">
<template #input>
<van-radio-group v-model="form.status" direction="horizontal">
<van-radio :name="0">草稿</van-radio>
<van-radio :name="1">发布</van-radio>
<van-radio :name="3">结束</van-radio>
<van-radio :name="4">取消</van-radio>
</van-radio-group>
</template>
</van-field>
</van-cell-group>
<div style="margin: 32px 16px;">
<van-button round block type="primary" native-type="submit" :loading="loading">
{{ isEdit ? '更新' : '创建' }}
</van-button>
</div>
</van-form>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, computed } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { createActivity, updateActivity, getActivityDetail } from '@/services/activity';
import { showToast } from 'vant';
import dayjs from 'dayjs';
const route = useRoute();
const router = useRouter();
const isEdit = computed(() => !!route.params.id);
const id = Number(route.params.id);
const loading = ref(false);
const showStartTimePicker = ref(false);
const showEndTimePicker = ref(false);
const showStartTimeTimePicker = ref(false);
const showEndTimeTimePicker = ref(false);
const tempDate = ref<string[]>([]);
const pickerValues = reactive({
startDate: dayjs().format('YYYY-MM-DD').split('-'),
startTime: dayjs().format('HH:mm').split(':'),
endDate: dayjs().format('YYYY-MM-DD').split('-'),
endTime: dayjs().format('HH:mm').split(':'),
});
const form = reactive({
title: '',
description: '',
location: '',
category: '',
startTime: '',
endTime: '',
maxParticipants: 100,
status: 0,
});
const onClickLeft = () => history.back();
onMounted(async () => {
if (isEdit.value) {
try {
const res = await getActivityDetail(id);
Object.assign(form, res);
// Format time for display in native format if needed, but here we keep string
// Just ensure it's displayable. The field displays standard string.
// If comes from API, it is usually ISO.
// Let's format it to friendly string for Vant inputs?
// Actually our inputs are text type (readonly), displaying the string directly.
if (form.startTime) {
const start = dayjs(form.startTime);
form.startTime = start.format('YYYY-MM-DD HH:mm:ss');
pickerValues.startDate = start.format('YYYY-MM-DD').split('-');
pickerValues.startTime = start.format('HH:mm').split(':');
}
if (form.endTime) {
const end = dayjs(form.endTime);
form.endTime = end.format('YYYY-MM-DD HH:mm:ss');
pickerValues.endDate = end.format('YYYY-MM-DD').split('-');
pickerValues.endTime = end.format('HH:mm').split(':');
}
} catch (error) {
showToast('加载失败');
}
}
});
const onConfirmStartDate = ({ selectedValues }: any) => {
tempDate.value = selectedValues;
showStartTimePicker.value = false;
showStartTimeTimePicker.value = true;
};
const onConfirmStartTime = ({ selectedValues }: any) => {
const dateStr = tempDate.value.join('-');
const timeStr = selectedValues.join(':') + ':00';
form.startTime = `${dateStr} ${timeStr}`;
showStartTimeTimePicker.value = false;
};
const onConfirmEndDate = ({ selectedValues }: any) => {
tempDate.value = selectedValues;
showEndTimePicker.value = false;
showEndTimeTimePicker.value = true;
};
const onConfirmEndTime = ({ selectedValues }: any) => {
const dateStr = tempDate.value.join('-');
const timeStr = selectedValues.join(':') + ':00';
form.endTime = `${dateStr} ${timeStr}`;
showEndTimeTimePicker.value = false;
};
const onSubmit = async () => {
loading.value = true;
try {
// Ensure ISO format
// Ensure valid format
if (!form.startTime || !form.endTime) {
showToast('请选择完整的时间');
loading.value = false;
return;
}
const data = {
...form,
maxParticipants: Number(form.maxParticipants),
startTime: dayjs(form.startTime).format('YYYY-MM-DDTHH:mm:ss'),
endTime: dayjs(form.endTime).format('YYYY-MM-DDTHH:mm:ss')
};
if (isEdit.value) {
await updateActivity(id, data);
showToast('已更新');
} else {
await createActivity(data);
showToast('已创建');
}
router.back();
} catch (error) {
// Error handled
} finally {
loading.value = false;
}
};
</script>
<style scoped lang="scss">
.activity-edit-page {
min-height: 100vh;
background-color: #f7f8fa;
}
</style>

View File

@@ -0,0 +1,136 @@
<template>
<div class="activity-reviews-page">
<van-nav-bar
title="活动评价"
left-arrow
@click-left="onClickLeft"
fixed
placeholder
/>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多评价了"
@load="onLoad"
>
<div v-for="item in list" :key="item.id" class="review-card">
<div class="header">
<div class="user-info">
<van-image round width="30" height="30" :src="item.userAvatar || 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'" />
<span class="username">{{ item.userName }}</span>
</div>
<span class="time">{{ formatTime(item.createdAt) }}</span>
</div>
<van-rate v-model="item.rating" readonly size="14px" color="#ffd21e" />
<p class="content">{{ item.content || '暂无内容' }}</p>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRoute } from 'vue-router';
import { getReviewsByActivity } from '@/services/review';
import type { Review } from '@/types/review';
import dayjs from 'dayjs';
const route = useRoute();
const activityId = Number(route.params.id);
const list = ref<Review[]>([]);
const loading = ref(false);
const finished = ref(false);
const refreshing = ref(false);
const currentPage = ref(1);
const onClickLeft = () => history.back();
const onLoad = async () => {
if (refreshing.value) {
list.value = [];
refreshing.value = false;
}
try {
const res = await getReviewsByActivity({
activityId,
current: currentPage.value,
size: 10
});
// Check struct
const records = res.records || [];
const total = res.total || 0;
list.value.push(...records);
loading.value = false;
currentPage.value++;
if (list.value.length >= total) {
finished.value = true;
}
} catch (error) {
loading.value = false;
finished.value = true;
}
};
const onRefresh = () => {
finished.value = false;
loading.value = true;
currentPage.value = 1;
onLoad();
};
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD');
};
</script>
<style scoped lang="scss">
.activity-reviews-page {
min-height: 100vh;
background-color: #f7f8fa;
.review-card {
background: #fff;
margin: 16px;
padding: 16px;
border-radius: 8px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
.user-info {
display: flex;
align-items: center;
.username {
margin-left: 8px;
font-size: 14px;
color: #333;
}
}
.time {
font-size: 12px;
color: #999;
}
}
.content {
margin: 8px 0 0;
font-size: 14px;
color: #666;
line-height: 1.5;
}
}
}
</style>

View File

@@ -0,0 +1,181 @@
<template>
<div class="stats-page">
<van-nav-bar
title="数据统计"
left-arrow
@click-left="onClickLeft"
fixed
placeholder
/>
<div v-if="stats" class="stats-content">
<div class="header-card">
<h3>{{ stats.activityTitle }}</h3>
<van-grid :column-num="3" :border="false">
<van-grid-item>
<div class="stat-num">{{ stats.registeredCount }}</div>
<div class="stat-label">报名人数</div>
</van-grid-item>
<van-grid-item>
<div class="stat-num">{{ stats.checkedInCount }}</div>
<div class="stat-label">签到人数</div>
</van-grid-item>
<van-grid-item>
<div class="stat-num">{{ (stats.checkInRate * 100).toFixed(1) }}%</div>
<div class="stat-label">签到率</div>
</van-grid-item>
</van-grid>
</div>
<div class="chart-card">
<h4>评分分布</h4>
<div class="rating-dist">
<div class="rate-row" v-for="score in [5,4,3,2,1]" :key="score">
<span class="label">{{ score }}</span>
<van-progress
:percentage="getPercentage(score)"
:show-pivot="true"
color="#ffd21e"
stroke-width="8"
/>
<span class="count">{{ stats.ratingDistribution[score] || 0 }}</span>
</div>
</div>
<div class="avg-rating">
平均分: <strong>{{ stats.averageRating.toFixed(1) }}</strong>
</div>
</div>
<div class="actions">
<van-button icon="down" block type="success" @click="handleExport">导出数据报表</van-button>
</div>
</div>
<van-loading v-else class="loading" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { getActivityStats, exportActivityStats } from '@/services/stats';
import type { ActivityStats } from '@/services/stats';
import { showToast } from 'vant';
const route = useRoute();
const activityId = Number(route.params.id);
const stats = ref<ActivityStats | null>(null);
onMounted(async () => {
try {
const res = await getActivityStats(activityId);
stats.value = res;
} catch (error) {
showToast('加载统计数据失败');
}
});
const onClickLeft = () => history.back();
const getPercentage = (score: number) => {
if (!stats.value || !stats.value.reviewCount) return 0;
const count = stats.value.ratingDistribution[score] || 0;
return Math.round((count / stats.value.reviewCount) * 100);
};
const handleExport = async () => {
try {
const blob = await exportActivityStats(activityId);
// Download logic
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`);
document.body.appendChild(link);
link.click();
showToast('导出成功');
} catch (error) {
showToast('导出失败');
}
};
</script>
<style scoped lang="scss">
.stats-page {
min-height: 100vh;
background-color: #f7f8fa;
.stats-content {
padding: 16px;
}
.header-card, .chart-card {
background: #fff;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.stat-num {
font-size: 20px;
font-weight: bold;
color: #333;
}
.stat-label {
font-size: 12px;
color: #999;
margin-top: 4px;
}
.chart-card {
h4 {
margin: 0 0 16px;
}
.rating-dist {
.rate-row {
display: flex;
align-items: center;
margin-bottom: 12px;
.label {
width: 30px;
font-size: 12px;
}
.van-progress {
flex: 1;
margin: 0 10px;
}
.count {
width: 20px;
font-size: 12px;
color: #999;
}
}
}
.avg-rating {
text-align: center;
margin-top: 16px;
font-size: 16px;
strong {
font-size: 24px;
color: #ffd21e;
}
}
}
.actions {
margin-top: 32px;
}
.loading {
margin-top: 100px;
text-align: center;
}
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<div class="admin-dashboard-page">
<van-nav-bar
title="管理后台"
left-arrow
@click-left="onClickLeft"
right-text="新建"
@click-right="onCreate"
fixed
placeholder
/>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
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>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { getActivities, deleteActivity } from '@/services/activity';
import type { Activity } from '@/types/activity';
import { showDialog, showToast } from 'vant';
const router = useRouter();
const list = ref<Activity[]>([]);
const loading = ref(false);
const finished = ref(false);
const refreshing = ref(false);
const currentPage = ref(1);
const onLoad = async () => {
if (refreshing.value) {
list.value = [];
refreshing.value = false;
}
try {
const res = await getActivities({
current: currentPage.value,
size: 10
});
const records = res.records || [];
const total = res.total || 0;
list.value.push(...records);
loading.value = false;
currentPage.value++;
if (list.value.length >= total) {
finished.value = true;
}
} catch (error) {
loading.value = false;
finished.value = true;
}
};
const onRefresh = () => {
finished.value = false;
loading.value = true;
currentPage.value = 1;
onLoad();
};
const onClickLeft = () => {
router.push('/user');
};
const onCreate = () => {
router.push('/admin/activity/new');
};
const onEdit = (item: Activity) => {
router.push(`/admin/activity/edit/${item.id}`);
};
const onCheckin = (item: Activity) => {
router.push(`/admin/activity/checkin/${item.id}`);
};
const onStats = (item: Activity) => {
router.push(`/admin/activity/stats/${item.id}`);
};
const onReviews = (item: Activity) => {
router.push(`/admin/activity/reviews/${item.id}`);
};
const onDelete = (item: Activity) => {
showDialog({
title: '删除活动',
message: `确定要删除 "${item.title}" 吗?`,
showCancelButton: true
}).then(async () => {
try {
await deleteActivity(item.id);
showToast('已删除');
onRefresh();
} catch (error) {
// Error handled
}
});
};
const formatStatus = (status: number) => {
const map: Record<number, string> = {
0: '草稿',
1: '已发布',
2: '进行中',
3: '已结束',
4: '已取消',
};
return map[status] || '未知';
};
const getStatusType = (status: number) => {
if (status === 1) return 'success';
if (status === 2) return 'primary';
if (status === 3) return 'default';
return 'warning';
};
</script>
<style scoped lang="scss">
.admin-dashboard-page {
min-height: 100vh;
background-color: #f7f8fa;
padding-bottom: 20px;
.delete-button {
height: 100%;
}
}
</style>

View File

@@ -0,0 +1,259 @@
<template>
<div class="checkin-management-page">
<van-nav-bar
title="签到管理"
left-arrow
@click-left="onClickLeft"
fixed
placeholder
/>
<div class="activity-info" v-if="activity">
<h3>{{ activity.title }}</h3>
<p>时间: {{ formatTime(activity.startTime) }}</p>
</div>
<van-tabs v-model:active="activeTab">
<van-tab title="生成二维码">
<div class="qr-section">
<p class="tip">请学生使用APP扫描下方二维码签到</p>
<div class="qr-container" v-if="qrCodeUrl">
<!-- In real implementation backend returns URL to image, or content to generate -->
<!-- We will use a placeholder or assume qrCodeUrl is valid image -->
<!-- If it is just content, we might need a library like qrcode.js.
For now assuming backend returns a valid image URL or we mock it.
Actually the API returns qrCodeUrl. -->
<van-image :src="qrCodeUrl" width="250" height="250" />
<p class="code-text">有效至: {{ formatTime(expiresAt) }}</p>
</div>
<van-empty v-else description="点击生成二维码" />
<div class="actions">
<van-button type="primary" block @click="generateQr">刷新二维码</van-button>
</div>
</div>
</van-tab>
<van-tab title="手动签到">
<div class="manual-section">
<van-form @submit="onManualCheckin">
<van-cell-group inset>
<van-field
v-model="ticketCode"
name="ticketCode"
label="票码"
placeholder="请输入学电子票码"
:rules="[{ required: true, message: '请输入票码' }]"
/>
</van-cell-group>
<div style="margin: 16px;">
<van-button round block type="primary" native-type="submit">
确认签到
</van-button>
<van-button round block plain type="primary" style="margin-top: 10px;" @click="showScanner = true">
扫描入场码
</van-button>
</div>
</van-form>
</div>
</van-tab>
<van-tab title="签到记录">
<van-list
v-model:loading="loadingRecords"
:finished="finishedRecords"
finished-text="没有更多记录了"
@load="onLoadRecords"
>
<van-cell
v-for="item in records"
:key="item.id"
:title="item.userName"
:label="`学号: ${item.studentId}`"
:value="formatTime(item.checkInTime)"
/>
</van-list>
</van-tab>
</van-tabs>
<!-- Scanner Overlay -->
<van-popup v-model:show="showScanner" position="bottom" :style="{ height: '100%' }">
<WebQRScanner
v-if="showScanner"
@result="handleScanResult"
@close="showScanner = false"
/>
</van-popup>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { getActivityDetail } from '@/services/activity';
import { generateCheckinQr, adminScanTicket, getCheckinRecords } from '@/services/checkin';
import type { Activity } from '@/types/activity';
import type { CheckinResult } from '@/services/checkin';
import { showToast } from 'vant';
import dayjs from 'dayjs';
import WebQRScanner from '@/components/WebQRScanner.vue';
const route = useRoute();
const activityId = Number(route.params.id);
const activity = ref<Activity | null>(null);
const activeTab = ref(0);
// QR Code
const qrCodeUrl = ref('');
const expiresAt = ref('');
// Manual Checkin
const ticketCode = ref('');
const showScanner = ref(false);
// Records
const records = ref<CheckinResult[]>([]);
const loadingRecords = ref(false);
const finishedRecords = ref(false);
const currentPage = ref(1);
onMounted(async () => {
loadActivity();
generateQr(); // Auto generate on load
});
const loadActivity = async () => {
try {
activity.value = await getActivityDetail(activityId);
} catch (error) {
showToast('加载活动信息失败');
}
};
const onClickLeft = () => history.back();
const generateQr = async () => {
try {
const res = await generateCheckinQr(activityId);
qrCodeUrl.value = res.qrCodeUrl || `https://api.qrserver.com/v1/create-qr-code/?size=250x250&data=${res.qrCodeContent}`;
expiresAt.value = res.expiresAt;
showToast('二维码已更新');
} catch (error) {
// showToast('生成二维码失败');
}
};
const onManualCheckin = async () => {
try {
await adminScanTicket(activityId, ticketCode.value);
showToast('签到成功');
ticketCode.value = '';
// Refresh records if on that tab
refreshRecordsIfActive();
} catch (error) {
// handled
}
};
const handleScanResult = async (decodedText: string) => {
try {
showToast({ type: 'loading', message: '处理中...', duration: 0 });
await adminScanTicket(activityId, decodedText);
showToast({ type: 'success', message: '签到成功' });
// Close scanner after success
setTimeout(() => {
showScanner.value = false;
refreshRecordsIfActive();
}, 1000);
} catch (error) {
showToast('签到失败/无效入场码');
}
};
const refreshRecordsIfActive = () => {
// Refresh records if on that tab (usually admins want to see list update)
// Even if not on tab 2, we might want to refresh next time.
// For simplicity, just reset and fetch if active.
// Actually better to just refetch next time tab opens or now.
// Let's force refresh if we are on tab 2 or want to see it.
records.value = [];
currentPage.value = 1;
finishedRecords.value = false;
onLoadRecords();
};
const onLoadRecords = async () => {
try {
const res = await getCheckinRecords(activityId);
// Handle both Page object and Array response
const list = Array.isArray(res) ? res : (res.records || []);
const total = Array.isArray(res) ? res.length : (res.total || 0);
if (currentPage.value === 1) {
records.value = [];
}
records.value.push(...list);
// Use loose comparison for end of list since we might not have reliable totals for array
if (list.length === 0 || records.value.length >= total) {
finishedRecords.value = true;
}
loadingRecords.value = false;
} catch (error) {
loadingRecords.value = false;
finishedRecords.value = true;
}
};
const formatTime = (time: string) => {
return time ? dayjs(time).format('MM-DD HH:mm') : '-';
};
</script>
<style scoped lang="scss">
.checkin-management-page {
min-height: 100vh;
background-color: #f7f8fa;
.activity-info {
background: #fff;
padding: 16px;
h3 {
margin: 0 0 8px;
}
p {
margin: 0;
color: #666;
font-size: 14px;
}
}
.qr-section {
padding: 40px 16px;
text-align: center;
background: #fff;
.tip {
margin-bottom: 20px;
color: #666;
}
.qr-container {
margin-bottom: 20px;
.code-text {
color: #999;
font-size: 12px;
margin-top: 10px;
}
}
}
.manual-section {
padding-top: 20px;
}
}
</style>

View File

@@ -0,0 +1,110 @@
<template>
<div class="login-page">
<div class="header">
<img src="@/assets/logo-placeholder.png" alt="Logo" class="logo" v-if="false" />
<h2 class="title">校园活动管理平台</h2>
<p class="subtitle">欢迎登录</p>
</div>
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="username"
name="username"
label="用户名"
placeholder="请输入用户名"
:rules="[{ required: true, message: '请输入用户名' }]"
/>
<van-field
v-model="password"
type="password"
name="password"
label="密码"
placeholder="请输入密码"
:rules="[{ required: true, message: '请输入密码' }]"
/>
</van-cell-group>
<div style="margin: 32px 16px;">
<van-button round block type="primary" native-type="submit" :loading="loading">
登录
</van-button>
<div class="actions">
<router-link to="/register" class="link">注册账号</router-link>
</div>
</div>
</van-form>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { login } from '@/services/auth';
import { showToast } from 'vant';
const router = useRouter();
const authStore = useAuthStore();
const username = ref('');
const password = ref('');
const loading = ref(false);
const onSubmit = async () => {
loading.value = true;
try {
const res = await login({
username: username.value,
password: password.value,
});
// Save token and user info
authStore.setToken(res.accessToken);
authStore.setUser(res.userInfo);
showToast('登录成功');
router.replace('/home');
} catch (error) {
// Error is handled by interceptor or catch block
} finally {
loading.value = false;
}
};
</script>
<style scoped lang="scss">
.login-page {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
background-color: #f7f8fa;
.header {
text-align: center;
margin-bottom: 40px;
.title {
font-size: 28px;
color: #333;
margin: 10px 0;
}
.subtitle {
font-size: 14px;
color: #999;
}
}
.actions {
margin-top: 16px;
text-align: center;
.link {
color: var(--van-primary-color);
font-size: 14px;
text-decoration: none;
}
}
}
</style>

View File

@@ -0,0 +1,109 @@
<template>
<div class="register-page">
<van-nav-bar
title="注册账号"
left-text="返回"
left-arrow
@click-left="onClickLeft"
/>
<div class="content">
<van-form @submit="onSubmit">
<van-cell-group inset>
<van-field
v-model="form.username"
name="username"
label="用户名"
placeholder="3-50个字符"
:rules="[{ required: true, message: '请输入用户名' }]"
/>
<van-field
v-model="form.password"
type="password"
name="password"
label="密码"
placeholder="6-20个字符"
:rules="[{ required: true, message: '请输入密码' }]"
/>
<van-field
v-model="form.name"
name="name"
label="姓名"
placeholder="请输入真实姓名"
:rules="[{ required: true, message: '请输入姓名' }]"
/>
<van-field
v-model="form.studentId"
name="studentId"
label="学号"
placeholder="非在校生可不填"
/>
<van-field
v-model="form.email"
name="email"
label="邮箱"
placeholder="选填"
/>
<van-field
v-model="form.phone"
name="phone"
label="手机号"
placeholder="选填"
/>
</van-cell-group>
<div style="margin: 32px 16px;">
<van-button round block type="primary" native-type="submit" :loading="loading">
注册
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue';
import { useRouter } from 'vue-router';
import { register } from '@/services/auth';
import { showToast } from 'vant';
const router = useRouter();
const form = reactive({
username: '',
password: '',
name: '',
studentId: '',
email: '',
phone: ''
});
const loading = ref(false);
const onClickLeft = () => history.back();
const onSubmit = async () => {
loading.value = true;
try {
await register(form);
showToast('注册成功');
router.replace('/login');
} catch (error) {
// Error handled by interceptor
} finally {
loading.value = false;
}
};
</script>
<style scoped lang="scss">
.register-page {
min-height: 100vh;
background-color: #f7f8fa;
.content {
padding-top: 20px;
}
}
</style>

143
web/src/views/home/Home.vue Normal file
View File

@@ -0,0 +1,143 @@
<template>
<div class="home-page">
<van-search
v-model="keyword"
show-action
placeholder="搜索活动"
@search="onSearch"
@cancel="onCancel"
/>
<van-tabs v-model:active="activeTab" sticky>
<van-tab title="列表">
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多活动了"
@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>
</van-list>
</van-pull-refresh>
</van-tab>
<van-tab title="日历">
<van-calendar
title="活动日历"
:poppable="false"
:show-confirm="false"
:style="{ height: '500px' }"
/>
</van-tab>
</van-tabs>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { getActivities } from '@/services/activity';
import type { Activity } from '@/types/activity';
import dayjs from 'dayjs';
const router = useRouter();
const keyword = ref('');
const activeTab = ref(0);
const list = ref<Activity[]>([]);
const loading = ref(false);
const finished = ref(false);
const refreshing = ref(false);
const currentPage = ref(1);
const onLoad = async () => {
if (refreshing.value) {
list.value = [];
refreshing.value = false;
}
try {
const res = await getActivities({
current: currentPage.value,
size: 10,
keyword: keyword.value,
});
const { records, total } = res;
list.value.push(...records);
loading.value = false;
currentPage.value++;
if (list.value.length >= total) {
finished.value = true;
}
} catch (error) {
loading.value = false;
finished.value = true;
}
};
const onRefresh = () => {
finished.value = false;
loading.value = true;
currentPage.value = 1;
onLoad();
};
const onSearch = () => {
list.value = [];
currentPage.value = 1;
finished.value = false;
loading.value = true;
onLoad();
};
const onCancel = () => {
keyword.value = '';
onSearch();
};
const goToDetail = (id: number) => {
router.push(`/activity/${id}`);
};
const formatTime = (time: string) => {
return dayjs(time).format('MM-DD HH:mm');
};
const formatStatus = (status: number) => {
const map: Record<number, string> = {
0: '草稿',
1: '报名中',
2: '进行中',
3: '已结束',
4: '已取消',
};
return map[status] || '未知';
};
</script>
<style scoped lang="scss">
.home-page {
padding-bottom: 50px; // For TabBar
background-color: #f7f8fa;
min-height: 100vh;
}
</style>

View File

@@ -0,0 +1,295 @@
<template>
<div class="my-registrations-page">
<van-nav-bar
title="我的报名"
fixed
placeholder
/>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多记录了"
@load="onLoad"
>
<div v-for="item in list" :key="item.id" class="ticket-card">
<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>
</div>
<div class="ticket-body">
<p><strong>开始:</strong> {{ formatTime(item.activityStartTime) }}</p>
<p><strong>结束:</strong> {{ formatTime(item.activityEndTime) }}</p>
<p><strong>地点:</strong> {{ item.activityLocation }}</p>
<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
@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>
</div>
</div>
</van-list>
</van-pull-refresh>
<!-- Floating Scan Button -->
<div class="floating-scan-btn" @click="showScanner = true">
<van-icon name="scan" size="32" color="#fff" />
</div>
<!-- QR Scanner Overlay -->
<van-popup v-model:show="showScanner" position="bottom" :style="{ height: '100%' }">
<WebQRScanner
v-if="showScanner"
@result="handleScanResult"
@close="showScanner = false"
/>
</van-popup>
<!-- QR Code Dialog -->
<van-dialog v-model:show="qrVisible" title="入场二维码" :show-confirm-button="false" close-on-click-overlay>
<div style="text-align: center; padding: 20px;">
<van-image
width="200"
height="200"
:src="currentQrUrl"
/>
<p>{{ currentTicketCode }}</p>
</div>
</van-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onActivated } from 'vue';
import { useRouter } from 'vue-router';
import { getMyRegistrations, cancelRegistration } from '@/services/registration';
import { scanCheckinQr } from '@/services/checkin';
import type { Registration } from '@/types/registration';
import { showToast, showDialog } from 'vant';
import dayjs from 'dayjs';
import WebQRScanner from '@/components/WebQRScanner.vue';
const router = useRouter();
const list = ref<Registration[]>([]);
const loading = ref(false);
const finished = ref(false);
const refreshing = ref(false);
const currentPage = ref(1);
const qrVisible = ref(false);
const currentQrUrl = ref('');
const currentTicketCode = ref('');
const showScanner = ref(false);
const onLoad = async () => {
if (refreshing.value) {
list.value = [];
refreshing.value = false;
}
try {
const res = await getMyRegistrations({
current: currentPage.value,
size: 10
});
// Check if res is array (if backend returns array directly) or paginated object
// Based on API docs: { code: 200, data: { records: [] } }
// Request interceptor returns res.data which is 'data' object.
const records = res.records || [];
const total = res.total || 0;
if (currentPage.value === 1) {
list.value = [];
}
list.value.push(...records);
loading.value = false;
currentPage.value++;
if (list.value.length >= total) {
finished.value = true;
}
} catch (error) {
loading.value = false;
finished.value = true;
}
};
const onRefresh = () => {
finished.value = false;
loading.value = true;
currentPage.value = 1;
onLoad();
};
onActivated(() => {
onRefresh();
});
const formatTime = (time: string) => {
if (!time) return '待定';
return dayjs(time).format('YYYY-MM-DD HH:mm');
};
const isEnded = (endTime: string) => {
if (!endTime) return false;
return dayjs().isAfter(dayjs(endTime));
};
const showQrCode = (item: Registration) => {
// Use a QR code generator API or just display code for now if no image
// 'https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=' + item.ticketCode
currentQrUrl.value = item.ticketPdfUrl || `https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${item.ticketCode}`;
currentTicketCode.value = item.ticketCode;
qrVisible.value = true;
};
const handleCancel = (item: Registration) => {
showDialog({
title: '取消报名',
message: '确定要取消这次报名吗?',
showCancelButton: true
}).then(async () => {
try {
await cancelRegistration(item.id);
showToast('取消成功');
onRefresh();
} catch (error) {
// Error handled
}
});
};
const handleReview = (item: Registration) => {
// 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.
// Actually we have `submitReview` in `activity` or `review` services.
// Let's navigate to `/activity/:id` and hash #review or new route `/review/new/:activityId`
// But strictly `ActivityDetail` has review ability? No, we didn't implement UI for review submission in detail yet.
// Wait, I will need to check if I have a review submission page.
// I created `MyReviews.vue` (list) and `ActivityReviews.vue` (admin list).
// I need a place to submit review. Pop up a dialog here? Or navigate to Activity Detail and scroll to review?
// Let's create a simple review submission dialog HERE or navigate to Activity Detail.
// For now, navigate to ActivityDetail, assuming user can review there.
// Or better, navigate to ActivityDetail.
router.push(`/activity/${item.activityId}`);
};
const getStatusType = (status: number) => {
if (status === 1) return 'primary';
if (status === 2) return 'success';
return 'default';
};
const getStatusText = (status: number) => {
if (status === 1) return '已报名';
if (status === 2) return '已签到';
return '已取消';
};
const handleScanResult = async (decodedText: string) => {
// Stop scanning immediately to prevent multiple scans
// showScanner.value = false; // Wait, user wants to see success msg? Or just close?
// "Increase scanning success after auto close" -> Close AFTER success.
try {
showToast({ type: 'loading', message: '签到中...', duration: 0 });
await scanCheckinQr(decodedText);
showToast({ type: 'success', message: '签到成功' });
// Close scanner after short delay to let user see success
setTimeout(() => {
showScanner.value = false;
onRefresh();
}, 1000);
} catch (error) {
showToast('签到失败/无效二维码');
// Keep scanner open to try again?
// Or close? User said "auto close", usually implies success case.
}
};
</script>
<style scoped lang="scss">
.my-registrations-page {
min-height: 100vh;
background-color: #f7f8fa;
.ticket-card {
background: #fff;
margin: 16px;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
.ticket-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
border-bottom: 1px solid #eee;
padding-bottom: 12px;
h3 {
margin: 0;
font-size: 16px;
color: #333;
}
}
.ticket-body {
color: #666;
font-size: 14px;
p {
margin: 4px 0;
}
}
.ticket-footer {
margin-top: 12px;
padding-top: 12px;
border-top: 1px dashed #eee;
display: flex;
justify-content: flex-end;
gap: 10px;
}
}
.floating-scan-btn {
position: fixed;
right: 24px;
bottom: 80px;
width: 60px;
height: 60px;
background-color: var(--van-primary-color);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
z-index: 1000;
cursor: pointer;
&:active {
transform: scale(0.95);
}
}
}
</style>

View File

@@ -0,0 +1,121 @@
<template>
<div class="my-reviews-page">
<van-nav-bar
title="我的评价"
left-arrow
@click-left="onClickLeft"
fixed
placeholder
/>
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多评价了"
@load="onLoad"
>
<div v-for="item in list" :key="item.id" class="review-card">
<div class="header">
<h3>{{ item.activityTitle }}</h3>
<span class="time">{{ formatTime(item.createdAt) }}</span>
</div>
<van-rate v-model="item.rating" readonly size="14px" color="#ffd21e" />
<p class="content">{{ item.content || '暂无内容' }}</p>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { getMyReviews } from '@/services/review';
import type { Review } from '@/types/review';
import dayjs from 'dayjs';
const list = ref<Review[]>([]);
const loading = ref(false);
const finished = ref(false);
const refreshing = ref(false);
const currentPage = ref(1);
const onClickLeft = () => history.back();
const onLoad = async () => {
if (refreshing.value) {
list.value = [];
refreshing.value = false;
}
try {
const res = await getMyReviews({
current: currentPage.value,
size: 10
});
const records = res.records || [];
const total = res.total || 0;
list.value.push(...records);
loading.value = false;
currentPage.value++;
if (list.value.length >= total) {
finished.value = true;
}
} catch (error) {
loading.value = false;
finished.value = true;
}
};
const onRefresh = () => {
finished.value = false;
loading.value = true;
currentPage.value = 1;
onLoad();
};
const formatTime = (time: string) => {
return dayjs(time).format('YYYY-MM-DD');
};
</script>
<style scoped lang="scss">
.my-reviews-page {
min-height: 100vh;
background-color: #f7f8fa;
.review-card {
background: #fff;
margin: 16px;
padding: 16px;
border-radius: 8px;
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
h3 {
margin: 0;
font-size: 16px;
color: #333;
}
.time {
font-size: 12px;
color: #999;
}
}
.content {
margin: 8px 0 0;
font-size: 14px;
color: #666;
line-height: 1.5;
}
}
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="user-profile">
<div class="user-card" v-if="authStore.isLoggedIn && user">
<van-image
round
width="80"
height="80"
:src="user.avatar || 'https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg'"
/>
<div class="info">
<h3>{{ user.name }}</h3>
<p>{{ user.role === 1 ? '管理员' : '学生' }}</p>
<p v-if="user.studentId">ID: {{ user.studentId }}</p>
</div>
</div>
<div class="user-card" v-else @click="router.push('/login')">
<van-image
round
width="80"
height="80"
src="https://fastly.jsdelivr.net/npm/@vant/assets/cat.jpeg"
/>
<div class="info">
<h3>未登录</h3>
<p>点击登录/注册</p>
</div>
</div>
<van-cell-group class="menu-group" inset v-if="authStore.isLoggedIn">
<van-cell title="我的报名" is-link to="/registrations/my" />
<van-cell title="我的评价" is-link to="/reviews/my" />
<van-cell title="修改密码" is-link to="/auth/password" />
</van-cell-group>
<van-cell-group class="menu-group" inset v-if="user?.role === 1">
<van-cell title="管理后台" is-link to="/admin/dashboard" icon="setting-o" />
</van-cell-group>
<div class="logout-btn" v-if="authStore.isLoggedIn">
<van-button block color="#ee0a24" @click="handleLogout">退出登录</van-button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { showDialog } from 'vant';
const router = useRouter();
const authStore = useAuthStore();
const user = computed(() => authStore.user);
const handleLogout = () => {
showDialog({
title: '退出登录',
message: '确定要退出登录吗?',
showCancelButton: true,
}).then(() => {
authStore.logout();
router.replace('/login');
}).catch(() => {
// on cancel
});
};
</script>
<style scoped lang="scss">
.user-profile {
min-height: 100vh;
background-color: #f7f8fa;
padding-top: 20px;
.user-card {
display: flex;
align-items: center;
background: #fff;
padding: 20px;
margin: 0 16px 20px;
border-radius: 8px;
.info {
margin-left: 16px;
h3 {
margin: 0 0 5px;
font-size: 18px;
}
p {
margin: 0;
font-size: 14px;
color: #666;
}
}
}
.menu-group {
margin-bottom: 20px;
}
.logout-btn {
padding: 0 16px;
}
}
</style>

27
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": [
"vite/client"
],
"baseUrl": ".",
"paths": {
"@/*": [
"src/*"
]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue"
]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

32
web/vite.config.ts Normal file
View File

@@ -0,0 +1,32 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import Components from 'unplugin-vue-components/vite';
import { VantResolver } from '@vant/auto-import-resolver';
import AutoImport from 'unplugin-auto-import/vite';
import path from 'path';
import basicSsl from '@vitejs/plugin-basic-ssl';
export default defineConfig({
plugins: [
vue(),
basicSsl(),
Components({
resolvers: [VantResolver()],
}),
AutoImport({
imports: ['vue', 'vue-router', 'pinia'],
resolvers: [VantResolver()],
}),
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 3000,
open: true,
host: true,
}
});