upload: 上传vite前端成果物
This commit is contained in:
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal 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
3
web/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
5
web/README.md
Normal file
5
web/README.md
Normal 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
90
web/auto-imports.d.ts
vendored
Normal 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
54
web/components.d.ts
vendored
Normal 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
13
web/index.html
Normal 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
2802
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
web/package.json
Normal file
34
web/package.json
Normal 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
1
web/public/vite.svg
Normal 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
48
web/src/App.vue
Normal 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>
|
||||
1
web/src/assets/logo-placeholder.png
Normal file
1
web/src/assets/logo-placeholder.png
Normal file
@@ -0,0 +1 @@
|
||||
placeholder
|
||||
22
web/src/assets/main.scss
Normal file
22
web/src/assets/main.scss
Normal 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
1
web/src/assets/vue.svg
Normal 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 |
100
web/src/components/WebQRScanner.vue
Normal file
100
web/src/components/WebQRScanner.vue
Normal 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
7
web/src/env.d.ts
vendored
Normal 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
18
web/src/main.ts
Normal 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
110
web/src/router/index.ts
Normal 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;
|
||||
26
web/src/services/activity.ts
Normal file
26
web/src/services/activity.ts
Normal 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
20
web/src/services/auth.ts
Normal 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();
|
||||
};
|
||||
36
web/src/services/checkin.ts
Normal file
36
web/src/services/checkin.ts
Normal 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}`);
|
||||
};
|
||||
17
web/src/services/registration.ts
Normal file
17
web/src/services/registration.ts
Normal 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 });
|
||||
};
|
||||
23
web/src/services/review.ts
Normal file
23
web/src/services/review.ts
Normal 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
41
web/src/services/stats.ts
Normal 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'
|
||||
});
|
||||
};
|
||||
16
web/src/stores/activity.ts
Normal file
16
web/src/stores/activity.ts
Normal 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
40
web/src/stores/auth.ts
Normal 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
38
web/src/types/activity.ts
Normal 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
27
web/src/types/auth.ts
Normal 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;
|
||||
}
|
||||
21
web/src/types/registration.ts
Normal file
21
web/src/types/registration.ts
Normal 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
25
web/src/types/review.ts
Normal 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
50
web/src/utils/request.ts
Normal 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;
|
||||
153
web/src/views/activity/ActivityDetail.vue
Normal file
153
web/src/views/activity/ActivityDetail.vue
Normal 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>
|
||||
259
web/src/views/admin/ActivityEdit.vue
Normal file
259
web/src/views/admin/ActivityEdit.vue
Normal 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>
|
||||
136
web/src/views/admin/ActivityReviews.vue
Normal file
136
web/src/views/admin/ActivityReviews.vue
Normal 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>
|
||||
181
web/src/views/admin/ActivityStats.vue
Normal file
181
web/src/views/admin/ActivityStats.vue
Normal 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>
|
||||
166
web/src/views/admin/AdminDashboard.vue
Normal file
166
web/src/views/admin/AdminDashboard.vue
Normal 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>
|
||||
259
web/src/views/admin/CheckinManagement.vue
Normal file
259
web/src/views/admin/CheckinManagement.vue
Normal 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>
|
||||
110
web/src/views/auth/Login.vue
Normal file
110
web/src/views/auth/Login.vue
Normal 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>
|
||||
109
web/src/views/auth/Register.vue
Normal file
109
web/src/views/auth/Register.vue
Normal 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
143
web/src/views/home/Home.vue
Normal 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>
|
||||
295
web/src/views/registration/MyRegistrations.vue
Normal file
295
web/src/views/registration/MyRegistrations.vue
Normal 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>
|
||||
121
web/src/views/review/MyReviews.vue
Normal file
121
web/src/views/review/MyReviews.vue
Normal 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>
|
||||
108
web/src/views/user/UserProfile.vue
Normal file
108
web/src/views/user/UserProfile.vue
Normal 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
27
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
32
web/vite.config.ts
Normal 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,
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user