This commit is contained in:
王子琦
2026-01-21 10:23:12 +08:00
parent 358b121e56
commit 996c6ce750
59 changed files with 4876 additions and 0 deletions

12
frontend/index.html Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

110
frontend/src/App.vue Normal file
View 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>

View 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`);

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

@@ -0,0 +1,3 @@
import http from './http';
export const mySignups = () => http.get('/api/me/signups');

16
frontend/src/main.js Normal file
View 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');

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

View 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);
}
}
});

View File

@@ -0,0 +1,5 @@
import { createPinia } from 'pinia';
const pinia = createPinia();
export default pinia;

View 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);
}
}

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

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

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

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

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

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

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

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

@@ -0,0 +1,9 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173
}
});