addd
This commit is contained in:
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>社区节气活动系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
frontend/package.json
Normal file
23
frontend/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "community-activities-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@arco-design/web-vue": "^2.55.1",
|
||||
"axios": "^1.7.2",
|
||||
"lunar-javascript": "^1.6.13",
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.4.29",
|
||||
"vue-router": "^4.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.5",
|
||||
"vite": "^5.3.5"
|
||||
}
|
||||
}
|
||||
1160
frontend/pnpm-lock.yaml
generated
Normal file
1160
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
110
frontend/src/App.vue
Normal file
110
frontend/src/App.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div>
|
||||
<a-layout class="app-layout">
|
||||
<a-layout-header class="app-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">节气社区</div>
|
||||
<div class="brand-sub">传统文化活动发布与报名系统</div>
|
||||
</div>
|
||||
<a-menu mode="horizontal" :selected-keys="selectedKeys" @menu-item-click="handleMenu">
|
||||
<a-menu-item key="/activities">活动广场</a-menu-item>
|
||||
<a-menu-item key="/me/signups">我的报名</a-menu-item>
|
||||
<a-menu-item v-if="isAdmin" key="/admin/activities">管理后台</a-menu-item>
|
||||
</a-menu>
|
||||
<div class="user-block">
|
||||
<span v-if="user" class="user-name">你好,{{ user.nickname }}</span>
|
||||
<a-button v-if="user" size="small" @click="handleLogout">退出</a-button>
|
||||
<a-space v-else>
|
||||
<a-button size="small" type="primary" @click="go('/login')">登录</a-button>
|
||||
<a-button size="small" @click="go('/register')">注册</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-layout-header>
|
||||
<a-layout-content>
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { logout } from './api/auth';
|
||||
import { useAuthStore } from './store/auth';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const selectedKeys = computed(() => [route.path]);
|
||||
const user = computed(() => auth.user);
|
||||
const isAdmin = computed(() => auth.isAdmin);
|
||||
|
||||
const handleMenu = (key) => {
|
||||
router.push(key);
|
||||
};
|
||||
|
||||
const go = (path) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
auth.logout();
|
||||
Message.success('已退出登录');
|
||||
router.push('/activities');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-layout {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 16px 32px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
backdrop-filter: blur(12px);
|
||||
border-bottom: 1px solid rgba(15, 76, 92, 0.12);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.brand-sub {
|
||||
font-size: 12px;
|
||||
color: rgba(26, 27, 36, 0.7);
|
||||
}
|
||||
|
||||
.user-block {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/api/activities.js
Normal file
12
frontend/src/api/activities.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import http from './http';
|
||||
|
||||
export const listActivities = (params) => http.get('/api/public/activities', { params });
|
||||
export const getActivity = (id) => http.get(`/api/public/activities/${id}`);
|
||||
export const listActivitiesAdmin = (params) => http.get('/api/activities', { params });
|
||||
export const getActivityAdmin = (id) => http.get(`/api/activities/${id}`);
|
||||
export const createActivity = (data) => http.post('/api/activities', data);
|
||||
export const updateActivity = (id, data) => http.put(`/api/activities/${id}`, data);
|
||||
export const publishActivity = (id) => http.post(`/api/activities/${id}/publish`);
|
||||
export const closeActivity = (id) => http.post(`/api/activities/${id}/close`);
|
||||
export const signupActivity = (id) => http.post(`/api/activities/${id}/signup`);
|
||||
export const cancelSignup = (id) => http.post(`/api/activities/${id}/cancel`);
|
||||
5
frontend/src/api/admin.js
Normal file
5
frontend/src/api/admin.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import http from './http';
|
||||
|
||||
export const listSignupsByActivity = (id) => http.get(`/api/admin/activities/${id}/signups`);
|
||||
export const checkinSignup = (id) => http.post(`/api/admin/signups/${id}/checkin`);
|
||||
export const listUsers = () => http.get('/api/admin/users');
|
||||
6
frontend/src/api/auth.js
Normal file
6
frontend/src/api/auth.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import http from './http';
|
||||
|
||||
export const login = (data) => http.post('/api/auth/login', data);
|
||||
export const register = (data) => http.post('/api/auth/register', data);
|
||||
export const logout = () => http.post('/api/auth/logout');
|
||||
export const me = () => http.get('/api/me');
|
||||
32
frontend/src/api/http.js
Normal file
32
frontend/src/api/http.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import axios from 'axios';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: 'http://localhost:8080',
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
http.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.satoken = token;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => {
|
||||
const payload = response.data;
|
||||
if (payload && payload.success === false) {
|
||||
Message.error(payload.message || '请求失败');
|
||||
return Promise.reject(payload);
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
(error) => {
|
||||
Message.error('网络或服务器异常');
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default http;
|
||||
3
frontend/src/api/me.js
Normal file
3
frontend/src/api/me.js
Normal file
@@ -0,0 +1,3 @@
|
||||
import http from './http';
|
||||
|
||||
export const mySignups = () => http.get('/api/me/signups');
|
||||
16
frontend/src/main.js
Normal file
16
frontend/src/main.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue';
|
||||
import ArcoVue from '@arco-design/web-vue';
|
||||
import ArcoVueIcon from '@arco-design/web-vue/es/icon';
|
||||
import zhCN from '@arco-design/web-vue/es/locale/lang/zh-cn';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
import pinia from './store';
|
||||
import './styles/base.css';
|
||||
import '@arco-design/web-vue/dist/arco.css';
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(ArcoVue, { locale: zhCN });
|
||||
app.use(ArcoVueIcon);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.mount('#app');
|
||||
41
frontend/src/router/index.js
Normal file
41
frontend/src/router/index.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import LoginView from '../views/LoginView.vue';
|
||||
import RegisterView from '../views/RegisterView.vue';
|
||||
import ActivityList from '../views/ActivityList.vue';
|
||||
import ActivityDetail from '../views/ActivityDetail.vue';
|
||||
import MySignups from '../views/MySignups.vue';
|
||||
import AdminActivities from '../views/AdminActivities.vue';
|
||||
import AdminSignups from '../views/AdminSignups.vue';
|
||||
import pinia from '../store';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', redirect: '/activities' },
|
||||
{ path: '/login', component: LoginView },
|
||||
{ path: '/register', component: RegisterView },
|
||||
{ path: '/activities', component: ActivityList },
|
||||
{ path: '/activities/:id', component: ActivityDetail },
|
||||
{ path: '/me/signups', component: MySignups },
|
||||
{ path: '/admin/activities', component: AdminActivities },
|
||||
{ path: '/admin/activities/:id/signups', component: AdminSignups }
|
||||
]
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const auth = useAuthStore(pinia);
|
||||
const token = auth.token;
|
||||
if (to.path.startsWith('/login') || to.path.startsWith('/register')) {
|
||||
return next();
|
||||
}
|
||||
if (!token && to.path !== '/activities') {
|
||||
return next('/login');
|
||||
}
|
||||
if (to.path.startsWith('/admin') && !auth.isAdmin) {
|
||||
return next('/activities');
|
||||
}
|
||||
return next();
|
||||
});
|
||||
|
||||
export default router;
|
||||
37
frontend/src/store/auth.js
Normal file
37
frontend/src/store/auth.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const STORAGE_TOKEN = 'token';
|
||||
const STORAGE_USER = 'user';
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
token: localStorage.getItem(STORAGE_TOKEN) || '',
|
||||
user: (() => {
|
||||
const raw = localStorage.getItem(STORAGE_USER);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
})()
|
||||
}),
|
||||
getters: {
|
||||
isLogin: (state) => Boolean(state.token),
|
||||
isAdmin: (state) => state.user && state.user.role === 'admin'
|
||||
},
|
||||
actions: {
|
||||
setAuth(token, user) {
|
||||
this.token = token || '';
|
||||
this.user = user || null;
|
||||
if (this.token) {
|
||||
localStorage.setItem(STORAGE_TOKEN, this.token);
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_TOKEN);
|
||||
}
|
||||
if (this.user) {
|
||||
localStorage.setItem(STORAGE_USER, JSON.stringify(this.user));
|
||||
} else {
|
||||
localStorage.removeItem(STORAGE_USER);
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
this.setAuth('', null);
|
||||
}
|
||||
}
|
||||
});
|
||||
5
frontend/src/store/index.js
Normal file
5
frontend/src/store/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia';
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
export default pinia;
|
||||
90
frontend/src/styles/base.css
Normal file
90
frontend/src/styles/base.css
Normal file
@@ -0,0 +1,90 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=ZCOOL+XiaoWei&family=Noto+Serif+SC:wght@400;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--brand-ink: #1a1b24;
|
||||
--brand-ember: #e24a2d;
|
||||
--brand-ocean: #0f4c5c;
|
||||
--brand-sand: #f6efe6;
|
||||
--brand-mist: #e5eff1;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Noto Serif SC', 'ZCOOL XiaoWei', serif;
|
||||
color: var(--brand-ink);
|
||||
background:
|
||||
radial-gradient(1200px circle at 10% 10%, rgba(226, 74, 45, 0.15), transparent 50%),
|
||||
radial-gradient(900px circle at 90% 20%, rgba(15, 76, 92, 0.18), transparent 50%),
|
||||
linear-gradient(180deg, #fffdfb 0%, #f6efe6 45%, #eef5f6 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.page-shell {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 32px 20px 64px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 12px;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
margin: 0 0 24px;
|
||||
color: rgba(26, 27, 36, 0.7);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
background: linear-gradient(135deg, rgba(15, 76, 92, 0.9), rgba(226, 74, 45, 0.85));
|
||||
color: #fff;
|
||||
border-radius: 20px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 20px 40px rgba(26, 27, 36, 0.18);
|
||||
}
|
||||
|
||||
.hero-panel h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.hero-panel p {
|
||||
margin: 0;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeInUp 0.6s ease both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
83
frontend/src/styles/fullcalendar.css
Normal file
83
frontend/src/styles/fullcalendar.css
Normal file
@@ -0,0 +1,83 @@
|
||||
/* Minimal FullCalendar daygrid styles for offline usage */
|
||||
.fc {
|
||||
--fc-border-color: rgba(26, 27, 36, 0.12);
|
||||
--fc-page-bg-color: transparent;
|
||||
--fc-today-bg-color: rgba(226, 74, 45, 0.08);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.fc .fc-toolbar-title {
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.fc .fc-button {
|
||||
border: 1px solid rgba(26, 27, 36, 0.18);
|
||||
background: #fff;
|
||||
color: #1a1b24;
|
||||
padding: 4px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fc .fc-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid {
|
||||
border: 1px solid var(--fc-border-color);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.fc .fc-scrollgrid,
|
||||
.fc .fc-scrollgrid table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fc .fc-scrollgrid-section > td {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.fc .fc-col-header-cell {
|
||||
background: rgba(246, 239, 230, 0.6);
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--fc-border-color);
|
||||
text-align: center;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day {
|
||||
border-right: 1px solid var(--fc-border-color);
|
||||
border-bottom: 1px solid var(--fc-border-color);
|
||||
min-height: 82px;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day-frame {
|
||||
padding: 8px 6px;
|
||||
}
|
||||
|
||||
.fc .fc-day-today {
|
||||
background: var(--fc-today-bg-color);
|
||||
}
|
||||
|
||||
.fc .fc-day-other {
|
||||
color: rgba(26, 27, 36, 0.35);
|
||||
}
|
||||
|
||||
.fc .fc-daygrid-day-number {
|
||||
display: none;
|
||||
}
|
||||
139
frontend/src/views/ActivityDetail.vue
Normal file
139
frontend/src/views/ActivityDetail.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<a-button type="text" @click="goBack">← 返回活动广场</a-button>
|
||||
<a-card class="detail-card fade-in">
|
||||
<div class="detail-header">
|
||||
<div>
|
||||
<div class="section-title">{{ detail.title }}</div>
|
||||
<div class="section-subtitle">{{ detail.term }} · {{ detail.location }}</div>
|
||||
</div>
|
||||
<a-tag color="orange">{{ detail.status === 'published' ? '已发布' : '未发布' }}</a-tag>
|
||||
</div>
|
||||
|
||||
<a-descriptions :column="2" layout="inline" bordered>
|
||||
<a-descriptions-item label="活动时间">{{ formatDate(detail.startTime) }} - {{ formatDate(detail.endTime) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="报名时间">{{ formatDate(detail.signupStart) }} - {{ formatDate(detail.signupEnd) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="报名名额">{{ detail.quota }} 人</a-descriptions-item>
|
||||
<a-descriptions-item label="已报名">{{ detail.signupCount }} 人</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="block-title">活动简介</div>
|
||||
<div class="block-text">{{ detail.summary || '暂无简介' }}</div>
|
||||
</div>
|
||||
|
||||
<div class="content-block">
|
||||
<div class="block-title">活动详情</div>
|
||||
<div class="block-text" v-if="detail.content">{{ detail.content }}</div>
|
||||
<div class="block-text" v-else>暂无详细介绍</div>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<a-button type="primary" @click="handleSignup" :disabled="!canSignup">
|
||||
{{ buttonText }}
|
||||
</a-button>
|
||||
<span v-if="!token" class="hint">请先登录后报名</span>
|
||||
</div>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { getActivity, signupActivity } from '../api/activities';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const detail = ref({});
|
||||
const token = localStorage.getItem('token');
|
||||
|
||||
const fetchDetail = async () => {
|
||||
const res = await getActivity(route.params.id);
|
||||
detail.value = res.data || {};
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-';
|
||||
return value.replace('T', ' ');
|
||||
};
|
||||
|
||||
const canSignup = computed(() => {
|
||||
if (!token) return false;
|
||||
if (!detail.value.signupStart) return false;
|
||||
const now = new Date();
|
||||
const start = new Date(detail.value.signupStart);
|
||||
const end = new Date(detail.value.signupEnd);
|
||||
if (now < start || now > end) return false;
|
||||
return detail.value.signupCount < detail.value.quota;
|
||||
});
|
||||
|
||||
const buttonText = computed(() => {
|
||||
if (!token) return '登录后报名';
|
||||
if (!detail.value.signupStart) return '报名未开放';
|
||||
const now = new Date();
|
||||
const start = new Date(detail.value.signupStart);
|
||||
const end = new Date(detail.value.signupEnd);
|
||||
if (now < start) return '报名未开始';
|
||||
if (now > end) return '报名已结束';
|
||||
if (detail.value.signupCount >= detail.value.quota) return '名额已满';
|
||||
return '立即报名';
|
||||
});
|
||||
|
||||
const handleSignup = async () => {
|
||||
if (!token) {
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
await signupActivity(route.params.id);
|
||||
Message.success('报名成功');
|
||||
await fetchDetail();
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/activities');
|
||||
};
|
||||
|
||||
onMounted(fetchDetail);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-card {
|
||||
margin-top: 16px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.content-block {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-weight: 700;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.block-text {
|
||||
color: rgba(26, 27, 36, 0.75);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: rgba(26, 27, 36, 0.6);
|
||||
}
|
||||
</style>
|
||||
131
frontend/src/views/ActivityList.vue
Normal file
131
frontend/src/views/ActivityList.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="hero-panel fade-in">
|
||||
<h1>节气活动广场</h1>
|
||||
<p>二十四节气文化活动,等你来参与。</p>
|
||||
</div>
|
||||
|
||||
<a-card class="search-card fade-in">
|
||||
<div class="search-left">
|
||||
<a-input v-model="keyword" placeholder="搜索节气或活动主题" allow-clear />
|
||||
<a-button type="primary" @click="fetchList">搜索</a-button>
|
||||
</div>
|
||||
<a-button v-if="isAdmin" type="primary" @click="goAdmin">进入管理后台</a-button>
|
||||
</a-card>
|
||||
|
||||
<div class="card-grid">
|
||||
<a-card v-for="(item, idx) in list" :key="item.id" class="activity-card fade-in" :style="{ animationDelay: `${idx * 60}ms` }">
|
||||
<template #title>
|
||||
<div class="card-title">{{ item.title }}</div>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-tag color="arcoblue">{{ item.term }}</a-tag>
|
||||
</template>
|
||||
<div class="card-body">
|
||||
<div class="summary">{{ item.summary || '暂无简介' }}</div>
|
||||
<div class="meta">地点:{{ item.location }}</div>
|
||||
<div class="meta">时间:{{ formatDate(item.startTime) }} - {{ formatDate(item.endTime) }}</div>
|
||||
<div class="meta">报名:{{ formatDate(item.signupStart) }} - {{ formatDate(item.signupEnd) }}</div>
|
||||
<div class="meta">名额:{{ item.quota }} 人 | 已报:{{ item.signupCount }} 人 | 剩余:{{ remaining(item) }} 人</div>
|
||||
<div class="meta status">状态:{{ signupStatus(item) }}</div>
|
||||
</div>
|
||||
<a-button type="primary" @click="goDetail(item.id)">查看详情</a-button>
|
||||
</a-card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { listActivities } from '../api/activities';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const list = ref([]);
|
||||
const keyword = ref('');
|
||||
const isAdmin = computed(() => authStore.isAdmin);
|
||||
|
||||
const fetchList = async () => {
|
||||
const res = await listActivities({ keyword: keyword.value });
|
||||
list.value = res.data || [];
|
||||
};
|
||||
|
||||
const goDetail = (id) => {
|
||||
router.push(`/activities/${id}`);
|
||||
};
|
||||
|
||||
const goAdmin = () => {
|
||||
router.push('/admin/activities');
|
||||
};
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-';
|
||||
return value.replace('T', ' ');
|
||||
};
|
||||
|
||||
const remaining = (item) => {
|
||||
return Math.max(item.quota - item.signupCount, 0);
|
||||
};
|
||||
|
||||
const signupStatus = (item) => {
|
||||
const now = new Date();
|
||||
const start = new Date(item.signupStart);
|
||||
const end = new Date(item.signupEnd);
|
||||
if (now < start) return '未开始报名';
|
||||
if (now > end) return '报名已结束';
|
||||
if (remaining(item) === 0) return '名额已满';
|
||||
return '报名中';
|
||||
};
|
||||
|
||||
onMounted(fetchList);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-card {
|
||||
margin: 18px 0 24px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.search-left {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 14px 28px rgba(15, 76, 92, 0.08);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 8px 0;
|
||||
color: rgba(26, 27, 36, 0.8);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 13px;
|
||||
color: rgba(26, 27, 36, 0.7);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-weight: 600;
|
||||
color: var(--brand-ember);
|
||||
}
|
||||
</style>
|
||||
534
frontend/src/views/AdminActivities.vue
Normal file
534
frontend/src/views/AdminActivities.vue
Normal file
@@ -0,0 +1,534 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="section-title">活动管理</div>
|
||||
<div class="section-subtitle">发布节气文化活动、管理报名名额与状态</div>
|
||||
|
||||
<a-card class="search-card">
|
||||
<div class="filter-group">
|
||||
<a-select v-model="status" placeholder="筛选状态" allow-clear style="width: 160px;">
|
||||
<a-option value="draft">草稿</a-option>
|
||||
<a-option value="published">已发布</a-option>
|
||||
<a-option value="closed">已结束</a-option>
|
||||
</a-select>
|
||||
<a-input v-model="keyword" placeholder="搜索活动主题" allow-clear />
|
||||
<a-button type="primary" @click="fetchList">查询</a-button>
|
||||
</div>
|
||||
<div class="action-group">
|
||||
<a-button type="primary" @click="openCreate">新建活动</a-button>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-table class="table-shell" :columns="columns" :data="rows" row-key="id" :pagination="false">
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template #action="{ record }">
|
||||
<a-space>
|
||||
<a-button size="mini" type="text" @click="openEdit(record)">编辑</a-button>
|
||||
<a-button size="mini" type="text" @click="publish(record.id)" v-if="record.status !== 'published'">发布</a-button>
|
||||
<a-button size="mini" type="text" @click="close(record.id)" v-if="record.status === 'published'">结束</a-button>
|
||||
<a-button size="mini" type="text" @click="goSignups(record)">报名名单</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<a-modal v-model:visible="visible" :title="modalTitle" @ok="handleSave" :ok-text="isEdit ? '保存' : '创建'" width="1100px">
|
||||
<div class="modal-body">
|
||||
<div class="calendar-panel">
|
||||
<div class="calendar-head">
|
||||
<div>
|
||||
<div class="calendar-title">节气日历</div>
|
||||
<div class="calendar-tip">点击有节气的日期,一键生成活动草稿</div>
|
||||
</div>
|
||||
<div class="calendar-actions">
|
||||
<a-button size="mini" @click="prevMonth">上个月</a-button>
|
||||
<a-button size="mini" @click="goToday">本月</a-button>
|
||||
<a-button size="mini" @click="nextMonth">下个月</a-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="calendar-month">{{ monthLabel }}</div>
|
||||
<div class="calendar-grid">
|
||||
<div class="calendar-week" v-for="(week, wIndex) in calendarDays" :key="wIndex">
|
||||
<div class="calendar-cell" v-for="day in week" :key="day.key" :class="dayClass(day)" @click="selectDay(day)">
|
||||
<span class="cell-day">{{ day.date }}</span>
|
||||
<span v-if="day.term" class="term-chip">{{ day.term }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-panel">
|
||||
<a-form :model="form" layout="vertical" class="form-shell">
|
||||
<div class="form-title">活动信息</div>
|
||||
<a-form-item label="活动标题" required>
|
||||
<a-input v-model="form.title" placeholder="请输入活动标题" />
|
||||
</a-form-item>
|
||||
<a-form-item label="节气" required>
|
||||
<a-select v-model="form.term" placeholder="请选择节气">
|
||||
<a-option v-for="item in terms" :key="item" :value="item">{{ item }}</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="活动简介">
|
||||
<a-input v-model="form.summary" placeholder="一句话描述活动" />
|
||||
</a-form-item>
|
||||
<a-form-item label="活动详情">
|
||||
<a-textarea v-model="form.content" placeholder="详细介绍活动内容" :auto-size="{ minRows: 3 }" />
|
||||
</a-form-item>
|
||||
<a-form-item label="地点" required>
|
||||
<a-input v-model="form.location" placeholder="活动地点" />
|
||||
</a-form-item>
|
||||
<a-form-item label="活动时间" required>
|
||||
<a-range-picker v-model="form.activityTime" show-time format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" />
|
||||
</a-form-item>
|
||||
<a-form-item label="报名时间" required>
|
||||
<a-range-picker v-model="form.signupTime" show-time format="YYYY-MM-DD HH:mm" value-format="YYYY-MM-DDTHH:mm:ss" />
|
||||
</a-form-item>
|
||||
<a-form-item label="报名名额" required>
|
||||
<a-input-number v-model="form.quota" :min="1" />
|
||||
</a-form-item>
|
||||
<a-form-item label="封面图链接">
|
||||
<a-input v-model="form.coverUrl" placeholder="选填" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model="form.status" placeholder="默认草稿">
|
||||
<a-option value="draft">草稿</a-option>
|
||||
<a-option value="published">已发布</a-option>
|
||||
<a-option value="closed">已结束</a-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { listActivitiesAdmin, getActivityAdmin, createActivity, updateActivity, publishActivity, closeActivity } from '../api/activities';
|
||||
import { Solar } from 'lunar-javascript';
|
||||
|
||||
const router = useRouter();
|
||||
const rows = ref([]);
|
||||
const status = ref('');
|
||||
const keyword = ref('');
|
||||
const visible = ref(false);
|
||||
const isEdit = ref(false);
|
||||
const currentId = ref(null);
|
||||
const calendarBase = ref(new Date());
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
term: '',
|
||||
summary: '',
|
||||
content: '',
|
||||
location: '',
|
||||
activityTime: [],
|
||||
signupTime: [],
|
||||
quota: 20,
|
||||
coverUrl: '',
|
||||
status: 'draft'
|
||||
});
|
||||
|
||||
const terms = [
|
||||
'立春', '雨水', '惊蛰', '春分', '清明', '谷雨',
|
||||
'立夏', '小满', '芒种', '夏至', '小暑', '大暑',
|
||||
'立秋', '处暑', '白露', '秋分', '寒露', '霜降',
|
||||
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
|
||||
];
|
||||
|
||||
const columns = [
|
||||
{ title: '活动标题', dataIndex: 'title' },
|
||||
{ title: '节气', dataIndex: 'term' },
|
||||
{ title: '地点', dataIndex: 'location' },
|
||||
{ title: '开始时间', dataIndex: 'startTime' },
|
||||
{ title: '名额', dataIndex: 'quota' },
|
||||
{ title: '已报名', dataIndex: 'signupCount' },
|
||||
{ title: '状态', slotName: 'status' },
|
||||
{ title: '操作', slotName: 'action' }
|
||||
];
|
||||
|
||||
const modalTitle = computed(() => (isEdit.value ? '编辑活动' : '新建活动'));
|
||||
|
||||
const fetchList = async () => {
|
||||
const res = await listActivitiesAdmin({ status: status.value, keyword: keyword.value });
|
||||
rows.value = res.data || [];
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.title = '';
|
||||
form.term = '';
|
||||
form.summary = '';
|
||||
form.content = '';
|
||||
form.location = '';
|
||||
form.activityTime = [];
|
||||
form.signupTime = [];
|
||||
form.quota = 20;
|
||||
form.coverUrl = '';
|
||||
form.status = 'draft';
|
||||
};
|
||||
|
||||
const termCache = new Map();
|
||||
|
||||
const termForDate = (date) => {
|
||||
try {
|
||||
if (!date) return '';
|
||||
const key = `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
|
||||
if (termCache.has(key)) {
|
||||
return termCache.get(key);
|
||||
}
|
||||
const solar = Solar.fromDate(date);
|
||||
const term = solar.getLunar().getJieQi() || '';
|
||||
termCache.set(key, term);
|
||||
return term;
|
||||
} catch (err) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const buildCalendar = (baseDate) => {
|
||||
const year = baseDate.getFullYear();
|
||||
const month = baseDate.getMonth();
|
||||
const first = new Date(year, month, 1);
|
||||
const startOffset = first.getDay();
|
||||
const startDate = new Date(year, month, 1 - startOffset);
|
||||
const weeks = [];
|
||||
for (let w = 0; w < 6; w += 1) {
|
||||
const row = [];
|
||||
for (let d = 0; d < 7; d += 1) {
|
||||
const current = new Date(startDate);
|
||||
current.setDate(startDate.getDate() + w * 7 + d);
|
||||
const term = termForDate(current);
|
||||
row.push({
|
||||
key: `${current.getFullYear()}-${current.getMonth() + 1}-${current.getDate()}`,
|
||||
date: current.getDate(),
|
||||
fullDate: current,
|
||||
term,
|
||||
isCurrentMonth: current.getMonth() === month
|
||||
});
|
||||
}
|
||||
weeks.push(row);
|
||||
}
|
||||
return weeks;
|
||||
};
|
||||
|
||||
const calendarDays = computed(() => buildCalendar(calendarBase.value));
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
const year = calendarBase.value.getFullYear();
|
||||
const month = calendarBase.value.getMonth() + 1;
|
||||
return `${year}年${month}月`;
|
||||
});
|
||||
|
||||
const prevMonth = () => {
|
||||
const date = new Date(calendarBase.value);
|
||||
date.setMonth(date.getMonth() - 1);
|
||||
calendarBase.value = date;
|
||||
};
|
||||
|
||||
const nextMonth = () => {
|
||||
const date = new Date(calendarBase.value);
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
calendarBase.value = date;
|
||||
};
|
||||
|
||||
const goToday = () => {
|
||||
calendarBase.value = new Date();
|
||||
};
|
||||
|
||||
const dayClass = (day) => ({
|
||||
'is-other': !day.isCurrentMonth,
|
||||
'is-term': Boolean(day.term)
|
||||
});
|
||||
|
||||
const formatDateTime = (date, time = '09:00:00') => {
|
||||
const pad = (val) => String(val).padStart(2, '0');
|
||||
const year = date.getFullYear();
|
||||
const month = pad(date.getMonth() + 1);
|
||||
const day = pad(date.getDate());
|
||||
return `${year}-${month}-${day}T${time}`;
|
||||
};
|
||||
|
||||
const addDays = (date, days) => {
|
||||
const next = new Date(date);
|
||||
next.setDate(next.getDate() + days);
|
||||
return next;
|
||||
};
|
||||
|
||||
const selectDay = (day) => {
|
||||
if (!day.term) {
|
||||
Message.warning('这一天不是节气日,可手动填写活动信息');
|
||||
return;
|
||||
}
|
||||
const date = day.fullDate;
|
||||
form.term = day.term;
|
||||
form.title = `${day.term}节气文化活动`;
|
||||
form.summary = `围绕${day.term}开展的社区传统文化活动。`;
|
||||
form.content = `本次活动以${day.term}为主题,包含节气科普、互动体验与邻里交流等内容。`;
|
||||
form.activityTime = [formatDateTime(date, '09:00:00'), formatDateTime(date, '11:00:00')];
|
||||
form.signupTime = [
|
||||
formatDateTime(addDays(date, -7), '00:00:00'),
|
||||
formatDateTime(addDays(date, -1), '23:59:59')
|
||||
];
|
||||
form.status = 'draft';
|
||||
Message.success(`已根据${day.term}生成活动草稿`);
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
resetForm();
|
||||
isEdit.value = false;
|
||||
currentId.value = null;
|
||||
calendarBase.value = new Date();
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = async (record) => {
|
||||
isEdit.value = true;
|
||||
currentId.value = record.id;
|
||||
const res = await getActivityAdmin(record.id);
|
||||
const data = res.data || record;
|
||||
form.title = data.title;
|
||||
form.term = data.term;
|
||||
form.summary = data.summary || '';
|
||||
form.content = data.content || '';
|
||||
form.location = data.location;
|
||||
form.activityTime = [data.startTime, data.endTime];
|
||||
form.signupTime = [data.signupStart, data.signupEnd];
|
||||
form.quota = data.quota;
|
||||
form.coverUrl = data.coverUrl || '';
|
||||
form.status = data.status;
|
||||
calendarBase.value = new Date(data.startTime);
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.title || !form.term || !form.location || form.activityTime.length !== 2 || form.signupTime.length !== 2) {
|
||||
Message.warning('请填写完整的活动信息');
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
title: form.title,
|
||||
term: form.term,
|
||||
summary: form.summary,
|
||||
content: form.content,
|
||||
location: form.location,
|
||||
startTime: form.activityTime[0],
|
||||
endTime: form.activityTime[1],
|
||||
signupStart: form.signupTime[0],
|
||||
signupEnd: form.signupTime[1],
|
||||
quota: form.quota,
|
||||
status: form.status,
|
||||
coverUrl: form.coverUrl
|
||||
};
|
||||
if (isEdit.value) {
|
||||
await updateActivity(currentId.value, payload);
|
||||
Message.success('活动已更新');
|
||||
} else {
|
||||
await createActivity(payload);
|
||||
Message.success('活动已创建');
|
||||
}
|
||||
visible.value = false;
|
||||
await fetchList();
|
||||
};
|
||||
|
||||
const publish = async (id) => {
|
||||
await publishActivity(id);
|
||||
Message.success('活动已发布');
|
||||
await fetchList();
|
||||
};
|
||||
|
||||
const close = async (id) => {
|
||||
await closeActivity(id);
|
||||
Message.success('活动已结束');
|
||||
await fetchList();
|
||||
};
|
||||
|
||||
const goSignups = (record) => {
|
||||
router.push(`/admin/activities/${record.id}/signups`);
|
||||
};
|
||||
|
||||
const statusText = (value) => {
|
||||
if (value === 'published') return '已发布';
|
||||
if (value === 'closed') return '已结束';
|
||||
return '草稿';
|
||||
};
|
||||
|
||||
const statusColor = (value) => {
|
||||
if (value === 'published') return 'green';
|
||||
if (value === 'closed') return 'gray';
|
||||
return 'orange';
|
||||
};
|
||||
|
||||
onMounted(fetchList);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-card {
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 12px 24px rgba(15, 76, 92, 0.08);
|
||||
padding: 18px;
|
||||
gap: 16px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.action-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-left: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-group :deep(.arco-input-wrapper) {
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.table-shell :deep(.arco-table) {
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
box-shadow: 0 18px 40px rgba(15, 76, 92, 0.08);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.calendar-panel {
|
||||
background: linear-gradient(180deg, rgba(246, 239, 230, 0.9), rgba(238, 245, 246, 0.9));
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(226, 74, 45, 0.15);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
.calendar-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.calendar-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.calendar-tip {
|
||||
font-size: 12px;
|
||||
color: rgba(26, 27, 36, 0.7);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.calendar-month {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin: 10px 0 12px;
|
||||
color: #0f4c5c;
|
||||
}
|
||||
|
||||
.calendar-grid {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-week {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-radius: 12px;
|
||||
padding: 8px;
|
||||
min-height: 88px;
|
||||
cursor: pointer;
|
||||
border: 1px solid rgba(26, 27, 36, 0.08);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.calendar-cell:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 20px rgba(26, 27, 36, 0.12);
|
||||
}
|
||||
|
||||
.calendar-cell.is-other {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.calendar-cell.is-term {
|
||||
border-color: rgba(226, 74, 45, 0.4);
|
||||
background: rgba(255, 249, 245, 0.9);
|
||||
}
|
||||
|
||||
.cell-day {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.term-chip {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background: rgba(226, 74, 45, 0.18);
|
||||
color: #c23b22;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.form-panel {
|
||||
padding: 8px 12px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 12px 28px rgba(15, 76, 92, 0.08);
|
||||
border: 1px solid rgba(15, 76, 92, 0.08);
|
||||
}
|
||||
|
||||
.form-shell {
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.modal-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.search-card {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
63
frontend/src/views/AdminSignups.vue
Normal file
63
frontend/src/views/AdminSignups.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<a-button type="text" @click="goBack">← 返回活动管理</a-button>
|
||||
<div class="section-title">报名名单</div>
|
||||
<div class="section-subtitle">核对居民报名与签到情况</div>
|
||||
|
||||
<a-table :columns="columns" :data="rows" row-key="id" :pagination="false">
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="record.status === 'SIGNED' ? 'green' : 'gray'">
|
||||
{{ record.status === 'SIGNED' ? '已报名' : '已取消' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #checkin="{ record }">
|
||||
<a-tag :color="record.checkinStatus === 'CHECKED' ? 'green' : 'gray'">
|
||||
{{ record.checkinStatus === 'CHECKED' ? '已签到' : '未签到' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #action="{ record }">
|
||||
<a-button size="mini" type="primary" v-if="record.status === 'SIGNED' && record.checkinStatus !== 'CHECKED'" @click="checkin(record.id)">
|
||||
现场签到
|
||||
</a-button>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { listSignupsByActivity, checkinSignup } from '../api/admin';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const rows = ref([]);
|
||||
|
||||
const columns = [
|
||||
{ title: '用户名', dataIndex: 'username' },
|
||||
{ title: '昵称', dataIndex: 'nickname' },
|
||||
{ title: '电话', dataIndex: 'phone' },
|
||||
{ title: '报名状态', slotName: 'status' },
|
||||
{ title: '签到状态', slotName: 'checkin' },
|
||||
{ title: '操作', slotName: 'action' }
|
||||
];
|
||||
|
||||
const fetchList = async () => {
|
||||
const res = await listSignupsByActivity(route.params.id);
|
||||
rows.value = res.data || [];
|
||||
};
|
||||
|
||||
const checkin = async (id) => {
|
||||
await checkinSignup(id);
|
||||
Message.success('签到完成');
|
||||
await fetchList();
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.push('/admin/activities');
|
||||
};
|
||||
|
||||
onMounted(fetchList);
|
||||
</script>
|
||||
68
frontend/src/views/LoginView.vue
Normal file
68
frontend/src/views/LoginView.vue
Normal file
@@ -0,0 +1,68 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="hero-panel fade-in">
|
||||
<h1>欢迎回到节气社区</h1>
|
||||
<p>在这里发布、报名并参与二十四节气主题活动。</p>
|
||||
</div>
|
||||
|
||||
<a-card class="login-card fade-in">
|
||||
<div class="section-title">用户登录</div>
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item label="用户名" field="username" required>
|
||||
<a-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" field="password" required>
|
||||
<a-input-password v-model="form.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSubmit">登录</a-button>
|
||||
<a-button type="text" @click="goRegister">没有账号?去注册</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { login } from '../api/auth';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.username || !form.password) {
|
||||
Message.warning('请填写完整登录信息');
|
||||
return;
|
||||
}
|
||||
const res = await login(form);
|
||||
authStore.setAuth(res.data.token, {
|
||||
userId: res.data.userId,
|
||||
username: res.data.username,
|
||||
nickname: res.data.nickname,
|
||||
role: res.data.role
|
||||
});
|
||||
Message.success('登录成功');
|
||||
router.push('/activities');
|
||||
};
|
||||
|
||||
const goRegister = () => {
|
||||
router.push('/register');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-card {
|
||||
margin-top: 24px;
|
||||
max-width: 420px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 18px;
|
||||
}
|
||||
</style>
|
||||
61
frontend/src/views/MySignups.vue
Normal file
61
frontend/src/views/MySignups.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="section-title">我的报名</div>
|
||||
<div class="section-subtitle">查看报名状态与签到情况</div>
|
||||
|
||||
<a-table :columns="columns" :data="rows" row-key="id" :pagination="false">
|
||||
<template #status="{ record }">
|
||||
<a-tag :color="statusColor(record.status)">{{ statusText(record.status) }}</a-tag>
|
||||
</template>
|
||||
<template #checkinStatus="{ record }">
|
||||
<a-tag :color="record.checkinStatus === 'CHECKED' ? 'green' : 'gray'">
|
||||
{{ record.checkinStatus === 'CHECKED' ? '已签到' : '未签到' }}
|
||||
</a-tag>
|
||||
</template>
|
||||
<template #action="{ record }">
|
||||
<a-button size="mini" type="text" v-if="record.status === 'SIGNED'" @click="cancel(record.activityId)">取消报名</a-button>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { mySignups } from '../api/me';
|
||||
import { cancelSignup } from '../api/activities';
|
||||
|
||||
const rows = ref([]);
|
||||
|
||||
const columns = [
|
||||
{ title: '活动名称', dataIndex: 'activityTitle' },
|
||||
{ title: '节气', dataIndex: 'term' },
|
||||
{ title: '地点', dataIndex: 'location' },
|
||||
{ title: '活动时间', dataIndex: 'startTime' },
|
||||
{ title: '报名状态', slotName: 'status' },
|
||||
{ title: '签到状态', slotName: 'checkinStatus' },
|
||||
{ title: '操作', slotName: 'action' }
|
||||
];
|
||||
|
||||
const fetchList = async () => {
|
||||
const res = await mySignups();
|
||||
rows.value = res.data || [];
|
||||
};
|
||||
|
||||
const statusText = (value) => {
|
||||
return value === 'SIGNED' ? '已报名' : '已取消';
|
||||
};
|
||||
|
||||
const statusColor = (value) => {
|
||||
return value === 'SIGNED' ? 'green' : 'gray';
|
||||
};
|
||||
|
||||
const cancel = async (activityId) => {
|
||||
await cancelSignup(activityId);
|
||||
Message.success('已取消报名');
|
||||
await fetchList();
|
||||
};
|
||||
|
||||
onMounted(fetchList);
|
||||
</script>
|
||||
76
frontend/src/views/RegisterView.vue
Normal file
76
frontend/src/views/RegisterView.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="page-shell">
|
||||
<div class="hero-panel fade-in">
|
||||
<h1>加入节气社区</h1>
|
||||
<p>用节气活动连接邻里,共建和谐社区。</p>
|
||||
</div>
|
||||
|
||||
<a-card class="login-card fade-in">
|
||||
<div class="section-title">用户注册</div>
|
||||
<a-form :model="form" layout="vertical">
|
||||
<a-form-item label="用户名" field="username" required>
|
||||
<a-input v-model="form.username" placeholder="请输入用户名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="密码" field="password" required>
|
||||
<a-input-password v-model="form.password" placeholder="请输入密码" />
|
||||
</a-form-item>
|
||||
<a-form-item label="昵称" field="nickname" required>
|
||||
<a-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="联系电话" field="phone">
|
||||
<a-input v-model="form.phone" placeholder="选填" />
|
||||
</a-form-item>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="handleSubmit">注册并登录</a-button>
|
||||
<a-button type="text" @click="goLogin">已有账号?去登录</a-button>
|
||||
</a-space>
|
||||
</a-form>
|
||||
</a-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { Message } from '@arco-design/web-vue';
|
||||
import { register } from '../api/auth';
|
||||
import { useAuthStore } from '../store/auth';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
nickname: '',
|
||||
phone: ''
|
||||
});
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!form.username || !form.password || !form.nickname) {
|
||||
Message.warning('请填写完整注册信息');
|
||||
return;
|
||||
}
|
||||
const res = await register(form);
|
||||
authStore.setAuth(res.data.token, {
|
||||
userId: res.data.userId,
|
||||
username: res.data.username,
|
||||
nickname: res.data.nickname,
|
||||
role: res.data.role
|
||||
});
|
||||
Message.success('注册成功');
|
||||
router.push('/activities');
|
||||
};
|
||||
|
||||
const goLogin = () => {
|
||||
router.push('/login');
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-card {
|
||||
margin-top: 24px;
|
||||
max-width: 420px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 18px;
|
||||
}
|
||||
</style>
|
||||
9
frontend/vite.config.js
Normal file
9
frontend/vite.config.js
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
port: 5173
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user