Initial commit: 全友米项目完整代码

This commit is contained in:
Yvan
2026-04-09 09:40:45 +08:00
commit 89960b1cb8
54 changed files with 14574 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
# Node modules
node_modules
*/node_modules
# 前端构建产物
frontend/dist
# 环境变量文件(保留 .env 用于构建)
.env.local
.env.*.local
# Git
.git
.gitignore
# IDE
.vscode
.idea
*.swp
*.swo
# 日志
logs
*.log
npm-debug.log*
# 操作系统
.DS_Store
Thumbs.db
# 临时文件
*.tmp
*.temp
# 上传文件
backend/uploads/*
!backend/uploads/.gitkeep
# 测试
coverage
.nyc_output
# 文档
*.md
!README.md
# Docker 相关
Dockerfile
docker-compose.yml
.dockerignore
*.tar
# 构建脚本
build.sh
build.bat
+8
View File
@@ -0,0 +1,8 @@
node_modules/
.env
.env.local
backend/uploads/*
!backend/uploads/.gitkeep
dist/
.DS_Store
*.log
+2
View File
@@ -0,0 +1,2 @@
{
}
View File
+279
View File
@@ -0,0 +1,279 @@
# 泉友米车友会 - 部署说明
版本: 0.0.1
## 🚀 快速部署(推荐)
### Windows 用户
```cmd
# 1. 构建镜像
build.bat
# 2. 启动服务
docker-compose up -d
# 3. 初始化管理员
docker exec -it quanyoumi-app sh
node init-admin.js
```
### Linux/Mac 用户
```bash
# 1. 构建镜像
chmod +x build.sh
./build.sh
# 2. 启动服务
docker-compose up -d
# 3. 初始化管理员
docker exec -it quanyoumi-app sh
node init-admin.js
```
## 📝 部署前准备
### 1. 修改配置文件
编辑 `docker-compose.yml`,修改以下配置:
```yaml
environment:
- DB_PASSWORD=your_mysql_password # 修改数据库密码
- JWT_SECRET=your_jwt_secret_key # 修改JWT密钥(随机字符串)
- AMAP_KEY=your_amap_key # 填入高德地图Key
```
同时修改 MySQL 服务的密码:
```yaml
db:
environment:
- MYSQL_ROOT_PASSWORD=your_mysql_password # 与上面保持一致
```
### 2. 获取高德地图 Key
1. 访问 https://console.amap.com/
2. 注册/登录账号
3. 创建应用,选择 Web 端(JS API)
4. 复制 Key 填入配置
## 🔧 详细步骤
### 步骤 1: 构建 Docker 镜像
镜像包含前端(Vue3)和后端(Node.js),版本号 0.0.1。
**Windows:**
```cmd
build.bat
```
**Linux/Mac:**
```bash
chmod +x build.sh
./build.sh
```
### 步骤 2: 启动服务
使用 docker-compose 启动完整环境(应用+MySQL+MinIO):
```bash
docker-compose up -d
```
查看启动状态:
```bash
docker-compose ps
```
查看日志:
```bash
docker-compose logs -f app
```
### 步骤 3: 初始化数据库
数据库会自动初始化(使用 database.sql),等待约 30 秒。
检查数据库是否就绪:
```bash
docker-compose logs db | grep "ready for connections"
```
### 步骤 4: 创建管理员账号
进入应用容器:
```bash
docker exec -it quanyoumi-app sh
```
运行初始化脚本:
```bash
node init-admin.js
```
按提示输入:
- 用户名(例如:admin)
- 密码(至少6位)
- 确认密码
退出容器:
```bash
exit
```
### 步骤 5: 访问应用
- **前端页面**: http://localhost:3000
- **后台管理**: http://localhost:3000/admin/login
- **MinIO 控制台**: http://localhost:9001 (minioadmin/minioadmin)
## 📦 镜像信息
- **镜像名称**: quanyoumi-app
- **版本**: 0.0.1
- **基础镜像**: node:18-alpine
- **大小**: 约 200MB
- **包含内容**:
- 前端构建产物(Vue3 + Vant4)
- 后端服务(Node.js + Express)
- 依赖包(仅生产环境)
## 🔍 验证部署
### 1. 检查服务状态
```bash
docker-compose ps
```
应该看到 3 个服务都是 Up 状态:
- quanyoumi-app
- quanyoumi-db
- quanyoumi-minio
### 2. 检查应用日志
```bash
docker-compose logs app
```
应该看到:
```
服务器运行在 http://localhost:3000
运行模式: 生产环境(前后端一体)
```
### 3. 测试访问
浏览器访问 http://localhost:3000,应该能看到首页。
## 🛠️ 常见问题
### 问题 1: 端口被占用
错误信息:`port is already allocated`
解决方法:
```bash
# 修改 docker-compose.yml 中的端口映射
ports:
- "3001:3000" # 改为其他端口
```
### 问题 2: 数据库连接失败
错误信息:`ECONNREFUSED` 或 `Access denied`
解决方法:
1. 检查数据库是否启动:`docker-compose ps db`
2. 检查密码是否一致(app 和 db 的密码要相同)
3. 等待数据库完全启动(约 30 秒)
### 问题 3: 前端页面空白
解决方法:
1. 检查浏览器控制台是否有错误
2. 确认后端 API 是否正常:访问 http://localhost:3000/api/activities
3. 重新构建镜像:`docker-compose build --no-cache`
### 问题 4: 图片上传失败
解决方法:
1. 检查 MinIO 是否运行:`docker-compose ps minio`
2. 访问 MinIO 控制台创建 bucket:http://localhost:9001
3. 检查环境变量配置是否正确
## 🔄 更新应用
```bash
# 1. 停止服务
docker-compose down
# 2. 拉取最新代码
git pull
# 3. 重新构建
docker-compose build --no-cache
# 4. 启动服务
docker-compose up -d
```
## 🗑️ 清理
### 停止服务(保留数据)
```bash
docker-compose down
```
### 完全清理(删除数据)
```bash
docker-compose down -v
```
### 删除镜像
```bash
docker rmi quanyoumi-app:0.0.1
```
## 📊 生产环境建议
1. **使用反向代理**
- 推荐使用 Nginx 或 Caddy
- 配置 HTTPS 证书
- 设置域名
2. **修改默认密码**
- MySQL root 密码
- MinIO 访问密钥
- JWT 密钥(长随机字符串)
3. **数据备份**
- 定期备份 MySQL 数据
- 备份 MinIO 存储
- 备份上传文件
4. **监控告警**
- 配置日志收集
- 设置资源监控
- 配置告警通知
5. **安全加固**
- 限制数据库外部访问
- 使用防火墙规则
- 定期更新镜像
## 📞 技术支持
如有问题,请查看:
1. 应用日志:`docker-compose logs app`
2. 数据库日志:`docker-compose logs db`
3. MinIO 日志:`docker-compose logs minio`
或联系技术支持团队。
+57
View File
@@ -0,0 +1,57 @@
# 多阶段构建 - 前端构建阶段
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
# 复制前端依赖文件
COPY frontend/package*.json ./
# 安装前端依赖(包括 devDependencies,因为需要 vite 构建)
RUN npm ci
# 复制前端源码
COPY frontend/ ./
# 构建前端(使用默认的 .env 文件中的配置)
RUN npm run build
# 后端运行阶段
FROM node:18-alpine
WORKDIR /app
# 安装 dumb-init 和 envsubst
RUN apk add --no-cache dumb-init gettext
# 复制后端依赖文件
COPY backend/package*.json ./
# 安装后端依赖(仅生产依赖)
RUN npm ci --only=production
# 复制后端源码
COPY backend/ ./
# 从前端构建阶段复制构建产物
COPY --from=frontend-builder /app/frontend/dist ./public
# 创建上传目录
RUN mkdir -p uploads
# 复制启动脚本
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# 暴露端口
EXPOSE 3000
# 设置环境变量
ENV NODE_ENV=production
# 健康检查
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/activities', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"
# 使用启动脚本
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]
+17
View File
@@ -0,0 +1,17 @@
PORT=3000
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your-db-password
DB_NAME=quanyoumi
JWT_SECRET=your-secret-key-change-in-production
# MinIO 配置
MINIO_ENDPOINT=localhost # 后端连接 MinIO 的地址
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=jFywUsaCb4zNr2LV7hTP
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=quanyoumi
# MINIO_PUBLIC_URL(可选):前端访问文件的公开地址,不填则自动用 MINIO_ENDPOINT 拼接
# 生产环境如果用了反向代理(如 Nginx),填写外网地址,例如:https://cdn.yourdomain.com
# MINIO_PUBLIC_URL=http://localhost:9000
+27
View File
@@ -0,0 +1,27 @@
// 初始化管理员账号
require('dotenv').config();
const bcrypt = require('bcryptjs');
const { Admin, sequelize } = require('./models');
async function initAdmin() {
try {
await sequelize.sync({ force: false });
const hashedPassword = bcrypt.hashSync('admin123', 10);
await Admin.create({
username: 'admin',
password: hashedPassword,
name: '管理员'
});
console.log('管理员账号创建成功');
console.log('用户名: admin');
console.log('密码: admin123');
process.exit(0);
} catch (error) {
console.error('创建失败:', error.message);
process.exit(1);
}
}
initAdmin();
+19
View File
@@ -0,0 +1,19 @@
const jwt = require('jsonwebtoken');
const auth = (req, res, next) => {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: '未授权访问' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.adminId = decoded.id;
next();
} catch (error) {
res.status(401).json({ error: '无效的token' });
}
};
module.exports = auth;
+30
View File
@@ -0,0 +1,30 @@
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { uploadBuffer } = require('../utils/minio');
// 使用内存存储,文件不落盘,不限制大小
const upload = multer({
storage: multer.memoryStorage(),
fileFilter: (req, file, cb) => {
const extname = /jpeg|jpg|png|gif|mp4|mov|avi/.test(
path.extname(file.originalname).toLowerCase()
);
const mimetype = /image|video/.test(file.mimetype);
if (mimetype && extname) return cb(null, true);
cb(new Error('只允许上传图片或视频文件'));
}
});
// 将 req.files 或 req.file 上传到 MinIO,返回 url 数组或单个 url
const uploadFilesToMinio = async (files) => {
const list = Array.isArray(files) ? files : [files];
return Promise.all(list.map(async (file) => {
const ext = path.extname(file.originalname).toLowerCase();
const filename = `${Date.now()}-${uuidv4()}${ext}`;
const url = await uploadBuffer(file.buffer, filename, file.mimetype);
return { ...file, filename, url };
}));
};
module.exports = { upload, uploadFilesToMinio };
+44
View File
@@ -0,0 +1,44 @@
const { DataTypes } = require('sequelize');
const sequelize = require('./db');
const Activity = sequelize.define('Activity', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
title: {
type: DataTypes.STRING,
allowNull: false
},
description: DataTypes.TEXT,
location: DataTypes.STRING,
locationLat: DataTypes.DECIMAL(10, 7),
locationLng: DataTypes.DECIMAL(10, 7),
startTime: DataTypes.DATE,
endTime: DataTypes.DATE,
registrationDeadline: DataTypes.DATE,
maxParticipants: DataTypes.INTEGER,
status: {
type: DataTypes.ENUM('upcoming', 'ongoing', 'completed'),
defaultValue: 'upcoming'
},
registrationEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: true,
comment: '报名开关'
},
coverImage: DataTypes.STRING,
price: {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0,
comment: '人均费用'
},
budget: {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0,
comment: '活动预算'
}
});
module.exports = Activity;
+22
View File
@@ -0,0 +1,22 @@
const { DataTypes } = require('sequelize');
const sequelize = require('./db');
const Admin = sequelize.define('Admin', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
username: {
type: DataTypes.STRING,
allowNull: false,
unique: true
},
password: {
type: DataTypes.STRING,
allowNull: false
},
name: DataTypes.STRING
});
module.exports = Admin;
+32
View File
@@ -0,0 +1,32 @@
const { DataTypes } = require('sequelize');
const sequelize = require('./db');
const Expense = sequelize.define('Expense', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
activityId: {
type: DataTypes.INTEGER,
allowNull: false
},
item: {
type: DataTypes.STRING,
allowNull: false
},
amount: {
type: DataTypes.DECIMAL(10, 2),
allowNull: false
},
category: DataTypes.STRING,
receipt: DataTypes.STRING,
note: DataTypes.TEXT,
media: {
type: DataTypes.JSON,
defaultValue: [],
comment: '经费相关照片/视频,最多5个'
}
});
module.exports = Expense;
+30
View File
@@ -0,0 +1,30 @@
const { DataTypes } = require('sequelize');
const sequelize = require('./db');
const Photo = sequelize.define('Photo', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
activityId: {
type: DataTypes.INTEGER,
allowNull: false
},
url: {
type: DataTypes.STRING,
allowNull: false
},
type: {
type: DataTypes.ENUM('image', 'video'),
defaultValue: 'image'
},
description: DataTypes.STRING,
uploadedBy: DataTypes.STRING,
status: {
type: DataTypes.ENUM('pending', 'approved', 'rejected'),
defaultValue: 'approved'
}
});
module.exports = Photo;
+40
View File
@@ -0,0 +1,40 @@
const { DataTypes } = require('sequelize');
const sequelize = require('./db');
const Registration = sequelize.define('Registration', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
activityId: {
type: DataTypes.INTEGER,
allowNull: false
},
name: {
type: DataTypes.STRING,
allowNull: false
},
phone: {
type: DataTypes.STRING,
allowNull: false
},
carModel: DataTypes.STRING,
carNumber: DataTypes.STRING,
peopleCount: {
type: DataTypes.INTEGER,
defaultValue: 1
},
status: {
type: DataTypes.ENUM('pending', 'approved', 'rejected'),
defaultValue: 'pending'
},
note: DataTypes.TEXT,
checkInStatus: {
type: DataTypes.BOOLEAN,
defaultValue: false
},
checkInTime: DataTypes.DATE
});
module.exports = Registration;
+26
View File
@@ -0,0 +1,26 @@
const { DataTypes } = require('sequelize');
const sequelize = require('./db');
const Sponsor = sequelize.define('Sponsor', {
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true
},
activityId: {
type: DataTypes.INTEGER,
allowNull: false
},
name: {
type: DataTypes.STRING,
allowNull: false
},
logo: DataTypes.STRING,
type: {
type: DataTypes.ENUM('title', 'sponsor'),
defaultValue: 'sponsor'
},
link: DataTypes.STRING
});
module.exports = Sponsor;
+14
View File
@@ -0,0 +1,14 @@
const { Sequelize } = require('sequelize');
const sequelize = new Sequelize(
process.env.DB_NAME || 'quanyoumi',
process.env.DB_USER || 'root',
process.env.DB_PASSWORD || '',
{
host: process.env.DB_HOST || 'localhost',
dialect: 'mysql',
logging: false
}
);
module.exports = sequelize;
+22
View File
@@ -0,0 +1,22 @@
const sequelize = require('./db');
const Activity = require('./Activity');
const Registration = require('./Registration');
const Expense = require('./Expense');
const Photo = require('./Photo');
const Sponsor = require('./Sponsor');
const Admin = require('./Admin');
// 同步数据库
sequelize.sync({ alter: true }).then(() => {
console.log('数据库同步完成');
});
module.exports = {
sequelize,
Activity,
Registration,
Expense,
Photo,
Sponsor,
Admin
};
+2140
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "quanyoumi-backend",
"version": "1.0.0",
"description": "泉友米车友会管理系统后端",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"minio": "^8.0.7",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.0",
"sequelize": "^6.33.0",
"uuid": "^9.0.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
+113
View File
@@ -0,0 +1,113 @@
const express = require('express');
const router = express.Router();
const { Activity, Photo, Sponsor, Expense, Registration } = require('../models');
const { upload, uploadFilesToMinio } = require('../middleware/upload');
// 获取活动列表
router.get('/activities', async (req, res) => {
try {
const { status } = req.query;
const where = status ? { status } : {};
const activities = await Activity.findAll({ where, order: [['startTime', 'DESC']] });
res.json(activities);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 获取活动详情
router.get('/activities/:id', async (req, res) => {
try {
const activity = await Activity.findByPk(req.params.id);
// 只返回已审核通过的照片
const photos = await Photo.findAll({
where: { activityId: req.params.id, status: 'approved' }
});
const sponsors = await Sponsor.findAll({ where: { activityId: req.params.id } });
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
res.json({ activity, photos, sponsors, expenses });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 获取活动经费(公开接口)
router.get('/activities/:id/expenses', async (req, res) => {
try {
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
res.json(expenses);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 用户上传照片(需要审核)
router.post('/activities/:id/photos', upload.array('photos', 10), async (req, res) => {
try {
const { uploadedBy } = req.body;
const uploaded = await uploadFilesToMinio(req.files);
const photos = uploaded.map(file => ({
activityId: req.params.id,
url: file.url,
type: /\.(mp4|mov|avi)$/i.test(file.filename) ? 'video' : 'image',
uploadedBy,
status: 'pending'
}));
const created = await Photo.bulkCreate(photos);
res.json(created);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 用户验证登录
router.post('/auth/verify/:activityId', async (req, res) => {
try {
const { phone, carNumber } = req.body;
const { activityId } = req.params;
console.log('验证请求:', { phone, carNumber, activityId });
if (!phone || !carNumber) {
return res.status(400).json({ error: '请输入手机号和车牌号' });
}
const registration = await Registration.findOne({
where: {
activityId,
phone,
carNumber
}
});
console.log('查询结果:', registration ? '找到' : '未找到');
if (!registration) {
// 调试:查询该活动下该手机号的所有报名
const allRegs = await Registration.findAll({
where: { activityId, phone }
});
console.log('该手机号的所有报名:', allRegs.map(r => ({ id: r.id, carNumber: r.carNumber })));
return res.status(404).json({ error: '未找到报名信息,请检查车牌号是否正确' });
}
res.json({
registration: {
id: registration.id,
name: registration.name,
phone: registration.phone,
carNumber: registration.carNumber,
carModel: registration.carModel,
status: registration.status,
activityId: registration.activityId
}
});
} catch (error) {
console.error('验证错误:', error);
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+294
View File
@@ -0,0 +1,294 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const auth = require('../middleware/auth');
const { upload, uploadFilesToMinio } = require('../middleware/upload');
const { deleteFile } = require('../utils/minio');
const { Admin, Activity, Registration, Expense, Photo, Sponsor } = require('../models');
// 管理员登录
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
const admin = await Admin.findOne({ where: { username } });
if (!admin || !bcrypt.compareSync(password, admin.password)) {
return res.status(401).json({ error: '用户名或密码错误' });
}
const token = jwt.sign({ id: admin.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.json({ token, admin: { id: admin.id, username: admin.username, name: admin.name } });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 创建活动
router.post('/activities', auth, async (req, res) => {
try {
const activity = await Activity.create(req.body);
res.json(activity);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 编辑活动
router.put('/activities/:id', auth, async (req, res) => {
try {
await Activity.update(req.body, { where: { id: req.params.id } });
const activity = await Activity.findByPk(req.params.id);
res.json(activity);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 审核报名
router.put('/registrations/:id', auth, async (req, res) => {
try {
const { status, peopleCount } = req.body;
const updateData = {};
if (status) updateData.status = status;
if (peopleCount !== undefined) updateData.peopleCount = peopleCount;
await Registration.update(updateData, { where: { id: req.params.id } });
const registration = await Registration.findByPk(req.params.id);
res.json(registration);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 签到/取消签到
router.put('/registrations/:id/checkin', auth, async (req, res) => {
try {
const registration = await Registration.findByPk(req.params.id);
if (!registration) {
return res.status(404).json({ error: '报名信息不存在' });
}
const newStatus = !registration.checkInStatus;
await Registration.update({
checkInStatus: newStatus,
checkInTime: newStatus ? new Date() : null
}, { where: { id: req.params.id } });
const updated = await Registration.findByPk(req.params.id);
res.json(updated);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 获取所有报名(含待审核)
router.get('/registrations', auth, async (req, res) => {
try {
const { activityId } = req.query;
const where = activityId ? { activityId } : {};
const registrations = await Registration.findAll({ where, order: [['createdAt', 'DESC']] });
res.json(registrations);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 上传经费明细
router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
try {
const { activityId, item, amount, category, note } = req.body;
let receipt = null;
let media = [];
if (req.files && req.files.length > 0) {
const uploaded = await uploadFilesToMinio(req.files);
media = uploaded.map(file => ({
url: file.url,
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
}));
}
const expense = await Expense.create({
activityId,
item,
amount,
category,
receipt,
note,
media
});
res.json(expense);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 获取经费列表
router.get('/expenses', auth, async (req, res) => {
try {
const { activityId } = req.query;
const where = activityId ? { activityId } : {};
const expenses = await Expense.findAll({ where });
res.json(expenses);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 更新经费
router.put('/expenses/:id', auth, upload.array('media', 5), async (req, res) => {
try {
const { item, amount, category, note, existingMedia } = req.body;
const updateData = { item, amount, category, note };
// 处理现有媒体
let media = [];
if (existingMedia) {
try {
media = JSON.parse(existingMedia);
} catch (e) {
media = [];
}
}
// 处理新上传的媒体
if (req.files && req.files.length > 0) {
const uploaded = await uploadFilesToMinio(req.files);
const newMedia = uploaded.map(file => ({
url: file.url,
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
}));
media = [...media, ...newMedia].slice(0, 5); // 限制最多5个
}
updateData.media = media;
await Expense.update(updateData, { where: { id: req.params.id } });
const expense = await Expense.findByPk(req.params.id);
res.json(expense);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 删除经费
router.delete('/expenses/:id', auth, async (req, res) => {
try {
await Expense.destroy({ where: { id: req.params.id } });
res.json({ message: '删除成功' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 上传活动照片
router.post('/photos', auth, upload.array('photos', 10), async (req, res) => {
try {
const { activityId } = req.body;
const uploaded = await uploadFilesToMinio(req.files);
const photos = uploaded.map(file => ({
activityId,
url: file.url,
type: /\.(mp4|mov|avi)$/i.test(file.filename) ? 'video' : 'image',
status: 'approved'
}));
const created = await Photo.bulkCreate(photos);
res.json(created);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 获取待审核照片
router.get('/photos/pending', auth, async (req, res) => {
try {
const photos = await Photo.findAll({
where: { status: 'pending' },
order: [['createdAt', 'DESC']]
});
res.json(photos);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 审核照片
router.put('/photos/:id', auth, async (req, res) => {
try {
const { status } = req.body;
await Photo.update({ status }, { where: { id: req.params.id } });
const photo = await Photo.findByPk(req.params.id);
res.json(photo);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 删除照片
router.delete('/photos/:id', auth, async (req, res) => {
try {
const photo = await Photo.findByPk(req.params.id);
if (!photo) {
return res.status(404).json({ error: '照片不存在' });
}
// 从URL提取文件名
const filename = photo.url.split('/').pop();
// 从MinIO删除文件
await deleteFile(filename);
// 从数据库删除记录
await photo.destroy();
res.json({ message: '删除成功' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 添加赞助商
router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
try {
const { activityId, name, type, link } = req.body;
let logo = null;
if (req.file) {
const [uploaded] = await uploadFilesToMinio(req.file);
logo = uploaded.url;
}
const sponsor = await Sponsor.create({ activityId, name, logo, type, link });
res.json(sponsor);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 更新赞助商
router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => {
try {
const { name, type, link } = req.body;
const updateData = { name, type, link };
if (req.file) {
const [uploaded] = await uploadFilesToMinio(req.file);
updateData.logo = uploaded.url;
}
await Sponsor.update(updateData, { where: { id: req.params.id } });
const sponsor = await Sponsor.findByPk(req.params.id);
res.json(sponsor);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 删除赞助商
router.delete('/sponsors/:id', auth, async (req, res) => {
try {
await Sponsor.destroy({ where: { id: req.params.id } });
res.json({ message: '删除成功' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+47
View File
@@ -0,0 +1,47 @@
const express = require('express');
const router = express.Router();
const { Registration, Activity } = require('../models');
// 提交报名
router.post('/registrations', async (req, res) => {
try {
const { activityId, name, phone, carModel, carNumber, note } = req.body;
const activity = await Activity.findByPk(activityId);
if (!activity) {
return res.status(404).json({ error: '活动不存在' });
}
const registration = await Registration.create({
activityId, name, phone, carModel, carNumber, note
});
res.json(registration);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 查询报名状态
router.get('/registrations/:id', async (req, res) => {
try {
const registration = await Registration.findByPk(req.params.id);
res.json(registration);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 获取活动报名列表(已通过)
router.get('/activities/:id/registrations', async (req, res) => {
try {
const registrations = await Registration.findAll({
where: { activityId: req.params.id, status: 'approved' }
});
res.json(registrations);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+36
View File
@@ -0,0 +1,36 @@
require('dotenv').config();
const express = require('express');
const cors = require('cors');
const path = require('path');
const app = express();
// 中间件
app.use(cors());
app.use(express.json());
// API 路由
app.use('/api', require('./routes/activity'));
app.use('/api', require('./routes/registration'));
app.use('/api/admin', require('./routes/admin'));
// 生产环境下提供前端静态文件
if (process.env.NODE_ENV === 'production') {
// 静态文件目录
app.use(express.static(path.join(__dirname, 'public')));
// 所有其他请求返回 index.html(支持前端路由)
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
if (process.env.NODE_ENV === 'production') {
console.log('运行模式: 生产环境(前后端一体)');
} else {
console.log('运行模式: 开发环境(仅后端API)');
}
});
Binary file not shown.
+88
View File
@@ -0,0 +1,88 @@
const Minio = require('minio');
const config = {
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
port: parseInt(process.env.MINIO_PORT) || 9000,
useSSL: process.env.MINIO_USE_SSL === 'true',
accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
};
console.log('MinIO 配置:', {
...config,
secretKey: '***' // 隐藏密钥
});
const client = new Minio.Client(config);
const BUCKET = process.env.MINIO_BUCKET || 'quanyoumi';
// 确保 bucket 存在
const ensureBucket = async () => {
try {
console.log(`检查 bucket: ${BUCKET}`);
const exists = await client.bucketExists(BUCKET);
console.log(`Bucket ${BUCKET} 存在:`, exists);
if (!exists) {
console.log(`创建 bucket: ${BUCKET}`);
await client.makeBucket(BUCKET);
// 设置公开读取策略
const policy = JSON.stringify({
Version: '2012-10-17',
Statement: [{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${BUCKET}/*`]
}]
});
await client.setBucketPolicy(BUCKET, policy);
console.log(`Bucket ${BUCKET} 创建成功并设置为公开读取`);
}
} catch (error) {
console.error('ensureBucket 错误:', error);
throw error;
}
};
// 上传 buffer 到 MinIO,返回公开访问 URL
const uploadBuffer = async (buffer, filename, mimetype) => {
try {
console.log(`开始上传文件: ${filename}, 大小: ${buffer.length} bytes`);
await ensureBucket();
await client.putObject(BUCKET, filename, buffer, buffer.length, { 'Content-Type': mimetype });
console.log(`文件上传成功: ${filename}`);
// 如果配置了公开 URL 就用,否则用 endpoint 拼接
const publicUrl = process.env.MINIO_PUBLIC_URL;
if (publicUrl) {
return `${publicUrl}/${BUCKET}/${filename}`;
}
const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http';
const endpoint = process.env.MINIO_ENDPOINT || 'localhost';
const port = process.env.MINIO_PORT || 9000;
return `${protocol}://${endpoint}:${port}/${BUCKET}/${filename}`;
} catch (error) {
console.error('uploadBuffer 错误:', error);
throw error;
}
};
// 从 MinIO 删除文件
const deleteFile = async (filename) => {
try {
console.log(`开始删除文件: ${filename}`);
await client.removeObject(BUCKET, filename);
console.log(`文件删除成功: ${filename}`);
return true;
} catch (error) {
console.error('deleteFile 错误:', error);
throw error;
}
};
module.exports = { client, BUCKET, uploadBuffer, deleteFile };
+69
View File
@@ -0,0 +1,69 @@
@echo off
REM 泉友米车友会 - Docker 构建脚本 (Windows)
REM 版本: 0.0.1
echo ==========================================
echo 泉友米车友会 Docker 镜像构建
echo 版本: 0.0.1
echo ==========================================
REM 检查 Docker 是否安装
docker --version >nul 2>&1
if errorlevel 1 (
echo 错误: Docker 未安装,请先安装 Docker Desktop
pause
exit /b 1
)
echo.
echo 开始构建 Docker 镜像...
docker build -t quanyoumi-app:0.0.1 -t quanyoumi-app:latest .
if errorlevel 1 (
echo.
echo 构建失败!
pause
exit /b 1
)
echo.
echo ==========================================
echo 构建完成!
echo ==========================================
echo.
echo 镜像信息:
docker images | findstr quanyoumi-app
echo.
REM 标记镜像到阿里云仓库
echo 正在标记镜像到阿里云仓库...
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo.
echo 正在推送镜像到阿里云仓库...
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
if errorlevel 1 (
echo.
echo 推送失败!请检查是否已登录阿里云镜像仓库
echo 登录命令: docker login --username=你的用户名 registry.cn-hangzhou.aliyuncs.com
pause
exit /b 1
)
echo.
echo ==========================================
echo 推送完成!
echo ==========================================
echo.
echo 镜像地址:
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo.
echo 在 NAS 上使用:
echo docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
echo docker-compose up -d
echo.
pause
+55
View File
@@ -0,0 +1,55 @@
#!/bin/bash
# 泉友米车友会 - Docker 构建脚本
# 版本: 0.0.1
set -e
echo "=========================================="
echo "泉友米车友会 Docker 镜像构建"
echo "版本: 0.0.1"
echo "=========================================="
# 检查 Docker 是否安装
if ! command -v docker &> /dev/null; then
echo "错误: Docker 未安装,请先安装 Docker"
exit 1
fi
# 构建镜像
echo ""
echo "开始构建 Docker 镜像..."
docker build -t quanyoumi-app:0.0.1 -t quanyoumi-app:latest .
echo ""
echo "=========================================="
echo "构建完成!"
echo "=========================================="
echo ""
echo "镜像信息:"
docker images | grep quanyoumi-app
echo ""
# 标记镜像到阿里云仓库
echo "正在标记镜像到阿里云仓库..."
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo ""
echo "正在推送镜像到阿里云仓库..."
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo ""
echo "=========================================="
echo "推送完成!"
echo "=========================================="
echo ""
echo "镜像地址:"
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1"
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest"
echo ""
echo "在 NAS 上使用:"
echo " docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1"
echo " docker-compose up -d"
echo ""
+4
View File
@@ -0,0 +1,4 @@
-- 添加签到功能字段
ALTER TABLE Registrations
ADD COLUMN checkInStatus BOOLEAN DEFAULT false COMMENT '是否已签到',
ADD COLUMN checkInTime DATETIME NULL COMMENT '签到时间';
+90
View File
@@ -0,0 +1,90 @@
-- 创建数据库
CREATE DATABASE IF NOT EXISTS quanyoumi CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE quanyoumi;
-- 活动表
CREATE TABLE IF NOT EXISTS Activities (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
location VARCHAR(255),
locationLat DECIMAL(10, 7),
locationLng DECIMAL(10, 7),
startTime DATETIME,
endTime DATETIME,
registrationDeadline DATETIME,
maxParticipants INT,
budget DECIMAL(10, 2) DEFAULT 0 COMMENT '活动预算',
status ENUM('upcoming', 'ongoing', 'completed') DEFAULT 'upcoming',
coverImage VARCHAR(255),
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- 报名表
CREATE TABLE IF NOT EXISTS Registrations (
id INT AUTO_INCREMENT PRIMARY KEY,
activityId INT NOT NULL,
name VARCHAR(100) NOT NULL,
phone VARCHAR(20) NOT NULL,
carModel VARCHAR(100),
carNumber VARCHAR(20),
peopleCount INT DEFAULT 1,
status ENUM('pending', 'approved', 'rejected') DEFAULT 'pending',
note TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (activityId) REFERENCES Activities(id) ON DELETE CASCADE
);
-- 经费表
CREATE TABLE IF NOT EXISTS Expenses (
id INT AUTO_INCREMENT PRIMARY KEY,
activityId INT NOT NULL,
item VARCHAR(255) NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
category VARCHAR(100),
receipt VARCHAR(255),
note TEXT,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (activityId) REFERENCES Activities(id) ON DELETE CASCADE
);
-- 照片表
CREATE TABLE IF NOT EXISTS Photos (
id INT AUTO_INCREMENT PRIMARY KEY,
activityId INT NOT NULL,
url VARCHAR(255) NOT NULL,
type ENUM('image', 'video') DEFAULT 'image',
description VARCHAR(255),
uploadedBy VARCHAR(100),
status ENUM('pending', 'approved', 'rejected') DEFAULT 'approved',
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (activityId) REFERENCES Activities(id) ON DELETE CASCADE
);
-- 赞助商表
CREATE TABLE IF NOT EXISTS Sponsors (
id INT AUTO_INCREMENT PRIMARY KEY,
activityId INT NOT NULL,
name VARCHAR(255) NOT NULL,
logo VARCHAR(255),
type ENUM('title', 'sponsor') DEFAULT 'sponsor',
link VARCHAR(255),
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (activityId) REFERENCES Activities(id) ON DELETE CASCADE
);
-- 管理员表
CREATE TABLE IF NOT EXISTS Admins (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
name VARCHAR(100),
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
+99
View File
@@ -0,0 +1,99 @@
version: '3.8'
services:
# 应用服务(前端+后端)
app:
image: registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
container_name: quanyoumi-app
restart: unless-stopped
ports:
- "3000:3000"
environment:
- NODE_ENV=production
- PORT=3000
# 数据库配置
- DB_HOST=db
- DB_PORT=3306
- DB_USER=root
- DB_PASSWORD=quanyoumi2024
- DB_NAME=quanyoumi
# JWT 密钥(请修改为随机字符串)
- JWT_SECRET=quanyoumi_jwt_secret_change_this_in_production_2024
# 高德地图配置(必填)
- AMAP_KEY=fd5b77136ba34fe6b4f0f86dca7782d9
- AMAP_SECURITY_CODE=7f61638e5635d944a534ff747dc615ac
# MinIO 配置
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin123
- MINIO_BUCKET=quanyoumi
volumes:
- app-uploads:/app/uploads
depends_on:
- db
- minio
networks:
- quanyoumi-network
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/activities', (r) => process.exit(r.statusCode === 200 ? 0 : 1))"]
interval: 30s
timeout: 3s
retries: 3
start_period: 10s
# MySQL 数据库
db:
image: mysql:8.0
container_name: quanyoumi-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=quanyoumi2024
- MYSQL_DATABASE=quanyoumi
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
- ./database.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- quanyoumi-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pquanyoumi2024"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: quanyoumi-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin123
volumes:
- minio-data:/data
ports:
- "9001:9001"
networks:
- quanyoumi-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
start_period: 10s
volumes:
mysql-data:
driver: local
minio-data:
driver: local
app-uploads:
driver: local
networks:
quanyoumi-network:
driver: bridge
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
set -e
echo "=========================================="
echo "泉友米车友会 - 启动中..."
echo "=========================================="
# 替换前端 index.html 中的环境变量占位符
if [ -f "/app/public/index.html" ]; then
echo "配置高德地图 Key..."
# 使用环境变量替换占位符(直接修改文件)
AMAP_KEY="${AMAP_KEY:-YOUR_AMAP_KEY_PLACEHOLDER}"
AMAP_SECURITY_CODE="${AMAP_SECURITY_CODE:-YOUR_AMAP_SECURITY_CODE_PLACEHOLDER}"
# 使用 sed -i 直接修改文件
sed -i "s|YOUR_AMAP_KEY_PLACEHOLDER|${AMAP_KEY}|g" /app/public/index.html
sed -i "s|YOUR_AMAP_SECURITY_CODE_PLACEHOLDER|${AMAP_SECURITY_CODE}|g" /app/public/index.html
echo "环境变量注入完成:"
echo " AMAP_KEY: ${AMAP_KEY}"
echo " AMAP_SECURITY_CODE: ${AMAP_SECURITY_CODE}"
if [ "$AMAP_KEY" != "YOUR_AMAP_KEY_PLACEHOLDER" ] && [ -n "$AMAP_KEY" ]; then
echo "✓ 高德地图 Key 已配置"
else
echo "⚠ 警告: 高德地图 Key 未配置,地图功能将不可用"
echo " 请在 docker-compose.yml 中设置 AMAP_KEY 环境变量"
fi
fi
echo "=========================================="
echo "启动应用服务..."
echo "=========================================="
# 使用 dumb-init 启动应用
exec dumb-init "$@"
+9
View File
@@ -0,0 +1,9 @@
# 高德地图API Key
VITE_AMAP_KEY=your_amap_key_here
# 高德地图安全密钥(securityJsCode)
# 获取方式:登录 https://console.amap.com/ -> 应用管理 -> 查看应用详情 -> 安全密钥
VITE_AMAP_SECURITY_CODE=your_security_code_here
# API基础地址
VITE_API_BASE_URL=/api
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>泉友米车友会</title>
<script>
// 运行时配置(可以通过环境变量注入)
window.AMAP_CONFIG = {
key: 'YOUR_AMAP_KEY_PLACEHOLDER',
securityCode: 'YOUR_AMAP_SECURITY_CODE_PLACEHOLDER'
};
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+201
View File
@@ -0,0 +1,201 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>高德地图搜索测试</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
#container {
width: 100%;
height: 400px;
margin: 20px 0;
border: 1px solid #ccc;
}
.search-box {
margin: 20px 0;
}
input {
padding: 10px;
width: 300px;
font-size: 14px;
}
button {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
}
.results {
margin-top: 20px;
}
.result-item {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
}
.result-item:hover {
background: #f5f5f5;
}
.log {
background: #f5f5f5;
padding: 10px;
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<h1>高德地图搜索功能测试</h1>
<div class="search-box">
<input type="text" id="keyword" placeholder="输入地点名称,如:泉州市政府" />
<button onclick="searchPlace()">搜索</button>
</div>
<div id="container"></div>
<div class="results" id="results"></div>
<div class="log" id="log"></div>
<script>
// 替换为你的高德地图Key和安全密钥
const AMAP_KEY = 'fd5b77136ba34fe6b4f0f86dca7782d9';
const SECURITY_CODE = 'b2e67bb4d5eed8848f0b8aae6ca667fc';
let map = null;
let placeSearch = null;
let marker = null;
function log(message) {
const logDiv = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logDiv.innerHTML += `[${time}] ${message}<br>`;
logDiv.scrollTop = logDiv.scrollHeight;
console.log(message);
}
function initMap() {
log('开始初始化地图...');
map = new AMap.Map('container', {
zoom: 13,
center: [118.6762, 24.8742] // 泉州市中心
});
log('地图初始化成功');
// 初始化PlaceSearch
AMap.plugin(['AMap.PlaceSearch'], function() {
placeSearch = new AMap.PlaceSearch({
pageSize: 10,
pageIndex: 1,
city: '泉州',
citylimit: false,
extensions: 'base'
});
log('PlaceSearch插件加载成功');
log('配置信息:city=泉州, citylimit=false, extensions=base');
});
}
function searchPlace() {
const keyword = document.getElementById('keyword').value.trim();
if (!keyword) {
alert('请输入搜索关键词');
return;
}
if (!placeSearch) {
log('错误:PlaceSearch未初始化');
alert('地图搜索功能未就绪,请刷新页面重试');
return;
}
log(`开始搜索:${keyword}`);
placeSearch.search(keyword, function(status, result) {
log(`搜索状态:${status}`);
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = '';
if (status === 'complete' && result.info === 'OK') {
if (result.poiList && result.poiList.pois && result.poiList.pois.length > 0) {
log(`找到 ${result.poiList.pois.length} 个结果`);
result.poiList.pois.forEach((poi, index) => {
const div = document.createElement('div');
div.className = 'result-item';
div.innerHTML = `
<strong>${index + 1}. ${poi.name}</strong><br>
地址:${poi.address || '无'}<br>
坐标:${poi.location.lng}, ${poi.location.lat}
`;
div.onclick = () => showOnMap(poi);
resultsDiv.appendChild(div);
});
} else {
log('搜索结果为空');
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
}
} else if (status === 'no_data') {
log('无搜索结果');
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
} else {
log(`搜索失败:${status} - ${result.info || '未知错误'}`);
log(`完整结果:${JSON.stringify(result)}`);
resultsDiv.innerHTML = `<p style="color: red;">搜索失败:${result.info || status}</p>`;
}
});
}
function showOnMap(poi) {
const position = [poi.location.lng, poi.location.lat];
if (marker) {
marker.setPosition(position);
} else {
marker = new AMap.Marker({
position: position,
map: map
});
}
map.setCenter(position);
map.setZoom(16);
log(`在地图上显示:${poi.name}`);
}
// 配置安全密钥(必须在加载地图前配置)
window._AMapSecurityConfig = {
securityJsCode: SECURITY_CODE
};
log(`配置安全密钥:${SECURITY_CODE}`);
// 加载高德地图脚本
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${AMAP_KEY}`;
script.onload = () => {
log('高德地图脚本加载成功');
initMap();
};
script.onerror = () => {
log('错误:高德地图脚本加载失败');
alert('地图加载失败,请检查网络连接或API Key配置');
};
document.head.appendChild(script);
</script>
</body>
</html>
+1135
View File
File diff suppressed because it is too large Load Diff
+20
View File
@@ -0,0 +1,20 @@
{
"name": "quanyoumi-frontend",
"version": "1.0.0",
"description": "泉友米车友会前端",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.3.4",
"vue-router": "^4.2.5",
"vant": "^4.7.1",
"axios": "^1.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.4.0",
"vite": "^4.5.0"
}
}
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
<template>
<router-view />
</template>
<script setup>
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: #f7f8fa;
}
</style>
+69
View File
@@ -0,0 +1,69 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 10000
});
api.interceptors.request.use(config => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export default {
// 活动相关
getActivities: (params) => api.get('/activities', { params }),
getActivity: (id) => api.get(`/activities/${id}`),
// 报名相关
submitRegistration: (data) => api.post('/registrations', data),
getRegistration: (id) => api.get(`/registrations/${id}`),
getActivityRegistrations: (id) => api.get(`/activities/${id}/registrations`),
// 用户上传照片
uploadUserPhotos: (id, data) => api.post(`/activities/${id}/photos`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000 // 5分钟超时
}),
// 用户验证登录
verifyUser: (activityId, data) => api.post(`/auth/verify/${activityId}`, data),
// 管理员相关
adminLogin: (data) => api.post('/admin/login', data),
createActivity: (data) => api.post('/admin/activities', data),
updateActivity: (id, data) => api.put(`/admin/activities/${id}`, data),
approveRegistration: (id, status) => api.put(`/admin/registrations/${id}`, { status }),
updateRegistration: (id, data) => api.put(`/admin/registrations/${id}`, data),
checkInRegistration: (id) => api.put(`/admin/registrations/${id}/checkin`),
getAllRegistrations: (params) => api.get('/admin/registrations', { params }),
addExpense: (data) => api.post('/admin/expenses', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
getExpenses: (params) => api.get('/admin/expenses', { params }),
updateExpense: (id, data) => api.put(`/admin/expenses/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
deleteExpense: (id) => api.delete(`/admin/expenses/${id}`),
uploadPhotos: (data) => api.post('/admin/photos', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 300000 // 5分钟超时,适配大文件上传
}),
getPendingPhotos: () => api.get('/admin/photos/pending'),
approvePhoto: (id, status) => api.put(`/admin/photos/${id}`, { status }),
deletePhoto: (id) => api.delete(`/admin/photos/${id}`),
addSponsor: (data) => api.post('/admin/sponsors', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000 // 1分钟超时
}),
updateSponsor: (id, data) => api.put(`/admin/sponsors/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
deleteSponsor: (id) => api.delete(`/admin/sponsors/${id}`)
};
+118
View File
@@ -0,0 +1,118 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
import Vant from 'vant';
import 'vant/lib/index.css';
// 全局样式 - 防止移动端输入框自动缩放
const globalStyle = document.createElement('style');
globalStyle.textContent = `
/* 防止iOS输入框自动缩放 */
input, textarea, select {
font-size: 16px !important;
}
/* 防止页面缩放 */
* {
touch-action: manipulation;
}
/* 优化输入框体验 */
input:focus, textarea:focus, select:focus {
font-size: 16px !important;
}
`;
document.head.appendChild(globalStyle);
// 获取高德地图配置(优先使用运行时配置,其次使用构建时环境变量)
const getAmapKey = () => {
if (window.AMAP_CONFIG && window.AMAP_CONFIG.key && window.AMAP_CONFIG.key !== 'YOUR_AMAP_KEY_PLACEHOLDER') {
return window.AMAP_CONFIG.key;
}
return import.meta.env.VITE_AMAP_KEY;
};
const getAmapSecurityCode = () => {
if (window.AMAP_CONFIG && window.AMAP_CONFIG.securityCode && window.AMAP_CONFIG.securityCode !== 'YOUR_AMAP_SECURITY_CODE_PLACEHOLDER') {
return window.AMAP_CONFIG.securityCode;
}
return import.meta.env.VITE_AMAP_SECURITY_CODE || 'b2e67bb4d5eed8848f0b8aae6ca667fc';
};
// 配置高德地图安全密钥(必须在加载地图前配置)
window._AMapSecurityConfig = {
securityJsCode: getAmapSecurityCode()
};
console.log('高德地图配置:', {
hasKey: !!getAmapKey(),
keySource: window.AMAP_CONFIG && window.AMAP_CONFIG.key !== 'YOUR_AMAP_KEY_PLACEHOLDER' ? 'runtime' : 'build-time',
hasSecurityCode: !!getAmapSecurityCode()
});
const app = createApp(App);
app.use(router);
app.use(Vant);
app.mount('#app');
// 动态加载高德地图(含插件)
const loadAMap = () => {
return new Promise((resolve, reject) => {
const amapKey = getAmapKey();
if (!amapKey || amapKey === 'YOUR_AMAP_KEY_PLACEHOLDER' || amapKey === 'YOUR_AMAP_KEY') {
console.error('高德地图Key未配置!');
console.error('请在 docker-compose.yml 中配置 AMAP_KEY 环境变量');
console.error('当前 Key 值:', amapKey);
reject(new Error('高德地图Key未配置'));
return;
}
if (window.AMap) {
// 如果已加载,检查插件是否可用
if (window.AMap.Geocoder && window.AMap.PlaceSearch) {
console.log('高德地图已加载,插件可用');
resolve(window.AMap);
} else {
// 插件未加载,异步加载
console.log('加载高德地图插件...');
window.AMap.plugin(['AMap.Geocoder', 'AMap.PlaceSearch', 'AMap.AutoComplete'], () => {
console.log('高德地图插件加载完成');
resolve(window.AMap);
});
}
return;
}
console.log('开始加载高德地图脚本...');
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${amapKey}&plugin=AMap.Geocoder,AMap.PlaceSearch,AMap.AutoComplete`;
script.async = true;
script.onload = () => {
if (window.AMap) {
console.log('高德地图脚本加载成功');
// 脚本加载后再确保插件加载
window.AMap.plugin(['AMap.Geocoder', 'AMap.PlaceSearch', 'AMap.AutoComplete'], () => {
console.log('高德地图插件初始化完成');
resolve(window.AMap);
});
} else {
console.error('高德地图加载失败:window.AMap未定义');
reject(new Error('高德地图加载失败'));
}
};
script.onerror = (error) => {
console.error('高德地图脚本加载失败:', error);
reject(new Error('高德地图脚本加载失败'));
};
document.head.appendChild(script);
});
};
// 导出加载函数供其他组件使用
window.loadAMap = loadAMap;
// 预加载地图
loadAMap().catch(err => {
console.error('地图预加载失败:', err);
});
+61
View File
@@ -0,0 +1,61 @@
import { createRouter, createWebHistory } from 'vue-router';
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue')
},
{
path: '/landing',
name: 'LandingPage',
component: () => import('../views/LandingPage.vue')
},
{
path: '/activity/:id',
name: 'ActivityDetail',
component: () => import('../views/ActivityDetail.vue')
},
{
path: '/register/:id',
name: 'Register',
component: () => import('../views/Register.vue')
},
{
path: '/share/:id',
name: 'SharePage',
component: () => import('../views/SharePage.vue')
},
{
path: '/admin/login',
name: 'AdminLogin',
component: () => import('../views/admin/Login.vue')
},
{
path: '/admin',
name: 'Admin',
component: () => import('../views/admin/Dashboard.vue'),
meta: { requiresAuth: true }
},
{
path: '/admin/activity/:id',
name: 'ActivityManage',
component: () => import('../views/admin/ActivityManage.vue'),
meta: { requiresAuth: true }
}
];
const router = createRouter({
history: createWebHistory(),
routes
});
router.beforeEach((to, from, next) => {
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
next('/admin/login');
} else {
next();
}
});
export default router;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
<template>
<div class="home">
<div class="hero">
<van-swipe class="hero-swipe" :autoplay="3000" indicator-color="white">
<van-swipe-item v-for="(image, index) in bannerImages" :key="index">
<div class="swipe-item" :style="{ backgroundImage: `url(${image})` }"></div>
</van-swipe-item>
</van-swipe>
<div class="hero-content">
<h1 class="club-name">泉友米车友会</h1>
<p class="club-slogan">驾驭激情 · 共享旅程</p>
<van-button
type="primary"
round
size="small"
@click="$router.push('/landing')"
class="landing-btn"
>
查看宣传页
</van-button>
</div>
</div>
<van-tabs v-model:active="activeTab" @change="loadActivities" class="custom-tabs">
<van-tab title="精彩活动" name="upcoming"></van-tab>
<van-tab title="往期回顾" name="completed"></van-tab>
</van-tabs>
<div class="activity-list">
<div v-if="loading" class="loading-state">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="activities.length === 0" class="empty-state">
<van-empty description="暂无活动" />
</div>
<div v-else class="activity-cards">
<div
v-for="activity in activities"
:key="activity.id"
class="activity-card"
@click="$router.push(`/activity/${activity.id}`)"
>
<div class="card-image" :style="{ backgroundImage: `url(${activity.coverImage || 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/5.jpg'})` }">
<div class="card-badge">{{ activity.status === 'upcoming' ? '报名中' : '已结束' }}</div>
</div>
<div class="card-content">
<h3 class="card-title">{{ activity.title }}</h3>
<p class="card-desc">{{ activity.description }}</p>
<div class="card-meta">
<div class="meta-item">
<van-icon name="clock-o" />
<span>{{ formatDate(activity.startTime) }}</span>
</div>
<div class="meta-item">
<van-icon name="location-o" />
<span>{{ activity.location }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="footer">
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="beian-link">
闽ICP备2021011414号-1
</a>
</footer>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
const router = useRouter();
const activeTab = ref('upcoming');
const activities = ref([]);
const loading = ref(false);
const finished = ref(false);
// 轮播图图片 - 小米汽车官方高清图片
const bannerImages = ref([
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/2-1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/1.jpg',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/yu7_20250626/pc/1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/4-1.png'
]);
const loadActivities = async () => {
loading.value = true;
try {
const { data } = await api.getActivities({ status: activeTab.value });
activities.value = data;
finished.value = true;
} catch (error) {
console.error(error);
}
loading.value = false;
};
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN');
};
loadActivities();
</script>
<style scoped>
.home {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #fff 100%);
}
.hero {
position: relative;
height: 240px;
overflow: hidden;
}
.hero-swipe {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100%;
}
.swipe-item {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
position: relative;
}
.swipe-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.5) 100%);
}
.swipe-item::after {
content: '';
position: absolute;
bottom: -2px;
left: 0;
right: 0;
height: 60px;
background: #f8f9fa;
border-radius: 50% 50% 0 0 / 100% 100% 0 0;
}
.hero-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
text-align: center;
}
.club-name {
font-size: 32px;
font-weight: bold;
margin: 0;
text-shadow: 0 2px 8px rgba(0,0,0,0.2);
letter-spacing: 2px;
}
.club-slogan {
font-size: 14px;
margin: 12px 0 0;
opacity: 0.9;
letter-spacing: 4px;
}
.landing-btn {
margin-top: 20px;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
border: none;
font-size: 14px;
font-weight: 600;
padding: 0 24px;
box-shadow: 0 4px 12px rgba(255, 103, 0, 0.4);
}
.custom-tabs {
margin-top: -30px;
position: relative;
z-index: 2;
}
.activity-list {
padding: 16px;
}
.loading-state,
.empty-state {
padding: 60px 0;
text-align: center;
}
.activity-cards {
display: flex;
flex-direction: column;
gap: 16px;
}
.activity-card {
background: #fff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
transition: transform 0.3s, box-shadow 0.3s;
}
.activity-card:active {
transform: scale(0.98);
}
.card-image {
height: 180px;
background-size: cover;
background-position: center;
background-color: #e8e8e8;
position: relative;
}
.card-badge {
position: absolute;
top: 12px;
right: 12px;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
color: #fff;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: bold;
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.3);
}
.card-content {
padding: 16px;
}
.card-title {
font-size: 18px;
font-weight: bold;
margin: 0 0 8px;
color: #333;
}
.card-desc {
font-size: 14px;
color: #666;
margin: 0 0 12px;
line-height: 1.5;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-meta {
display: flex;
gap: 16px;
}
.meta-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #999;
}
.meta-item .van-icon {
font-size: 14px;
color: #FF6700;
}
.footer {
padding: 24px 16px;
text-align: center;
background: #fff;
border-top: 1px solid #f0f0f0;
margin-top: 32px;
}
.beian-link {
font-size: 13px;
color: #999;
text-decoration: none;
transition: color 0.3s ease;
}
.beian-link:hover {
color: #FF6700;
}
.beian-link:active {
color: #FF8533;
}
</style>
+957
View File
@@ -0,0 +1,957 @@
<template>
<div class="landing-page">
<!-- 背景音乐控制 -->
<div class="music-control" @click="toggleMusic">
<van-icon :name="isPlaying ? 'volume-o' : 'volume'" :class="{ playing: isPlaying }" />
</div>
<audio ref="audioPlayer" loop>
<source src="/are-you-ok.mp3" type="audio/mpeg">
</audio>
<!-- 顶部导航栏 -->
<div class="top-nav">
<van-icon name="arrow-left" class="nav-back" @click="$router.push('/')" />
<span class="nav-title">小米汽车车友会</span>
<div class="nav-placeholder"></div>
</div>
<!-- Hero轮播区 -->
<div class="hero-section">
<van-swipe class="hero-swipe" :autoplay="4000" indicator-color="#FF6700">
<van-swipe-item v-for="(image, index) in heroImages" :key="index">
<div class="swipe-item" :style="{ backgroundImage: `url(${image})` }">
<div class="swipe-overlay"></div>
</div>
</van-swipe-item>
</van-swipe>
<div class="hero-content">
<div class="hero-badge">小米汽车车友会</div>
<h1 class="hero-title">驾驭未来 · 智享生活</h1>
<p class="hero-subtitle">Are You OK? 一起探索小米汽车的无限可能</p>
<div class="hero-stats">
<div class="stat-item">
<div class="stat-number">1000+</div>
<div class="stat-label">车友</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-number">50+</div>
<div class="stat-label">活动</div>
</div>
<div class="stat-divider"></div>
<div class="stat-item">
<div class="stat-number">20+</div>
<div class="stat-label">城市</div>
</div>
</div>
</div>
</div>
<!-- 特色卡片区 -->
<div class="features-section">
<h2 class="section-title">
<span class="title-icon">✨</span>
车友会特色
</h2>
<div class="feature-cards">
<div
v-for="(feature, index) in features"
:key="index"
class="feature-card"
:style="{ backgroundImage: `url(${feature.image})` }"
>
<div class="card-overlay"></div>
<div class="card-content">
<div class="card-icon">{{ feature.icon }}</div>
<h3 class="card-title">{{ feature.title }}</h3>
<p class="card-desc">{{ feature.desc }}</p>
</div>
</div>
</div>
</div>
<!-- 活动预览区 -->
<div class="activities-section">
<h2 class="section-title">
<span class="title-icon">🎯</span>
精彩活动
</h2>
<div v-if="loading" class="loading-state">
<van-loading size="24px">加载中...</van-loading>
</div>
<div v-else-if="activities.length === 0" class="empty-state">
<van-empty description="敬请期待更多精彩活动" />
</div>
<div v-else class="activity-preview">
<div
v-for="activity in activities.slice(0, 3)"
:key="activity.id"
class="activity-card"
@click="$router.push(`/activity/${activity.id}`)"
>
<div class="activity-image" :style="{ backgroundImage: `url(${activity.coverImage || heroImages[0]})` }">
<div class="activity-badge">报名中</div>
</div>
<div class="activity-info">
<h3 class="activity-title">{{ activity.title }}</h3>
<div class="activity-meta">
<van-icon name="clock-o" />
<span>{{ formatDate(activity.startTime) }}</span>
</div>
<div class="activity-meta">
<van-icon name="location-o" />
<span>{{ activity.location }}</span>
</div>
</div>
</div>
</div>
<van-button
type="primary"
block
round
size="large"
@click="$router.push('/')"
class="view-more-btn"
>
查看全部活动
<van-icon name="arrow" />
</van-button>
</div>
<!-- 赞助商区域 -->
<div class="sponsors-section">
<h2 class="section-title">
<span class="title-icon">🤝</span>
合作伙伴
</h2>
<div class="sponsors-grid">
<div class="sponsor-card">
<div class="sponsor-logo xiaomi-logo">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="80" height="80" rx="20" fill="#FF6700"/>
<text x="50" y="65" font-family="Arial, sans-serif" font-size="40" font-weight="bold" fill="white" text-anchor="middle">MI</text>
</svg>
</div>
<div class="sponsor-info">
<h3 class="sponsor-name">小米汽车泉州信兴汽车城授权服务中心</h3>
<p class="sponsor-desc">泉州德劲新能源科技有限公司</p>
</div>
</div>
<div class="sponsor-card">
<div class="sponsor-logo europark-logo">
<svg viewBox="0 0 200 80" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="europark-gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#4A90E2;stop-opacity:1" />
<stop offset="100%" style="stop-color:#7B68EE;stop-opacity:1" />
</linearGradient>
</defs>
<rect width="200" height="80" rx="12" fill="url(#europark-gradient)"/>
<text x="100" y="50" font-family="Microsoft YaHei, Arial, sans-serif" font-size="32" font-weight="bold" fill="white" text-anchor="middle">欧乐堡</text>
</svg>
</div>
<div class="sponsor-info">
<h3 class="sponsor-name">泉州欧乐堡</h3>
<p class="sponsor-desc">童话世界 · 海洋王国</p>
</div>
</div>
</div>
</div>
<!-- CTA区域 -->
<div class="cta-section">
<div class="cta-content">
<h2 class="cta-title">加入我们</h2>
<p class="cta-desc">与千位小米汽车车主一起,探索智能出行新体验</p>
<van-button
type="primary"
round
size="large"
@click="$router.push('/')"
class="cta-btn"
>
立即加入车友会
</van-button>
</div>
<div class="cta-bg"></div>
</div>
<!-- 页脚 -->
<footer class="footer">
<div class="footer-content">
<div class="footer-logo">
<div class="logo-icon">🚗</div>
<div class="logo-text">
<div class="logo-name">小米汽车车友会</div>
<div class="logo-slogan">驾驭未来 · 智享生活</div>
</div>
</div>
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer" class="beian-link">
闽ICP备2021011414号-1
</a>
</div>
</footer>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import api from '../api';
const router = useRouter();
const audioPlayer = ref(null);
const isPlaying = ref(false);
const activities = ref([]);
const loading = ref(false);
// Hero轮播图 - 小米汽车官方高清图片
const heroImages = ref([
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/2-1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/1.jpg',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/yu7_20250626/pc/1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/4-1.png',
'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/4.jpg'
]);
// 特色功能 - 使用小米汽车真实图片
const features = ref([
{
icon: '🚀',
title: '极致性能',
desc: '零百加速2.78秒,800V超级快充,续航800km+',
image: 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/5.jpg'
},
{
icon: '🤝',
title: '车友互动',
desc: '定期组织自驾游、车友聚会、技术交流等活动',
image: 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/yu7_20250522/pc/3.jpg'
},
{
icon: '🎁',
title: '专属福利',
desc: '车友专属优惠、保养折扣、配件优惠等福利',
image: 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_20260319/pc/8-1.png'
},
{
icon: '🌟',
title: '智能生态',
desc: '小米全生态互联,打造智能出行新体验',
image: 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/yu7_20250626/pc/15.png'
}
]);
// 音乐控制
const toggleMusic = () => {
if (!audioPlayer.value) return;
if (isPlaying.value) {
audioPlayer.value.pause();
isPlaying.value = false;
} else {
audioPlayer.value.play().catch(err => {
console.log('音乐播放需要用户交互:', err);
});
isPlaying.value = true;
}
};
// 尝试自动播放音乐(需要用户交互)
const tryAutoPlay = () => {
if (!audioPlayer.value || isPlaying.value) return;
audioPlayer.value.play().then(() => {
isPlaying.value = true;
// 移除事件监听
document.removeEventListener('click', tryAutoPlay);
document.removeEventListener('touchstart', tryAutoPlay);
}).catch(() => {
// 自动播放失败,等待用户点击
});
};
// 加载活动
const loadActivities = async () => {
loading.value = true;
try {
const { data } = await api.getActivities({ status: 'upcoming' });
activities.value = data;
} catch (error) {
console.error('加载活动失败:', error);
}
loading.value = false;
};
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN', {
month: '2-digit',
day: '2-digit'
});
};
onMounted(() => {
loadActivities();
// 监听用户交互,尝试自动播放音乐
document.addEventListener('click', tryAutoPlay, { once: true });
document.addEventListener('touchstart', tryAutoPlay, { once: true });
// 3秒后如果还没播放,尝试一次
setTimeout(() => {
if (!isPlaying.value) {
tryAutoPlay();
}
}, 3000);
});
onUnmounted(() => {
if (audioPlayer.value && isPlaying.value) {
audioPlayer.value.pause();
}
// 清理事件监听
document.removeEventListener('click', tryAutoPlay);
document.removeEventListener('touchstart', tryAutoPlay);
});
</script>
<style scoped>
.landing-page {
min-height: 100vh;
background: #fff;
}
/* 顶部导航栏 */
.top-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 46px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
z-index: 999;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.nav-back {
font-size: 20px;
color: #333;
cursor: pointer;
padding: 8px;
}
.nav-title {
font-size: 16px;
font-weight: 600;
color: #333;
}
.nav-placeholder {
width: 36px;
}
/* 音乐控制 */
.music-control {
position: fixed;
top: 60px;
right: 20px;
z-index: 1000;
width: 44px;
height: 44px;
background: rgba(255, 255, 255, 0.9);
backdrop-filter: blur(10px);
color: #FF6700;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
transition: all 0.3s;
}
.music-control:hover {
background: rgba(255, 255, 255, 1);
box-shadow: 0 4px 16px rgba(255, 103, 0, 0.2);
}
.music-control:active {
transform: scale(0.95);
}
.music-control .van-icon {
font-size: 22px;
transition: transform 0.3s;
}
.music-control .van-icon.playing {
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.music-text {
font-size: 14px;
font-weight: 500;
}
/* Hero区域 */
.hero-section {
position: relative;
height: 100vh;
min-height: 600px;
overflow: hidden;
margin-top: 46px;
}
.hero-swipe {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
height: 100%;
}
.swipe-item {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
position: relative;
}
.swipe-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-content {
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #fff;
text-align: center;
padding: 20px;
}
.hero-badge {
display: inline-block;
background: rgba(255, 103, 0, 0.9);
padding: 8px 24px;
border-radius: 30px;
font-size: 14px;
font-weight: 600;
margin-bottom: 24px;
letter-spacing: 2px;
box-shadow: 0 4px 16px rgba(255, 103, 0, 0.4);
animation: fadeInDown 1s;
}
.hero-title {
font-size: 48px;
font-weight: bold;
margin: 0 0 16px;
text-shadow: 0 4px 16px rgba(0,0,0,0.3);
letter-spacing: 4px;
animation: fadeInUp 1s 0.2s both;
}
.hero-subtitle {
font-size: 18px;
margin: 0 0 48px;
opacity: 0.95;
letter-spacing: 2px;
animation: fadeInUp 1s 0.4s both;
}
.hero-stats {
display: flex;
gap: 32px;
align-items: center;
animation: fadeInUp 1s 0.6s both;
}
.stat-item {
text-align: center;
}
.stat-number {
font-size: 36px;
font-weight: bold;
color: #FF6700;
margin-bottom: 8px;
}
.stat-label {
font-size: 14px;
opacity: 0.9;
}
.stat-divider {
width: 1px;
height: 40px;
background: rgba(255,255,255,0.3);
}
@keyframes fadeInDown {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 特色区域 */
.features-section,
.activities-section {
padding: 80px 16px;
max-width: 1200px;
margin: 0 auto;
}
.section-title {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
font-size: 32px;
font-weight: bold;
color: #333;
margin: 0 0 48px;
text-align: center;
}
.title-icon {
font-size: 36px;
}
.feature-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.feature-card {
position: relative;
height: 320px;
border-radius: 24px;
overflow: hidden;
background-size: cover;
background-position: center;
cursor: pointer;
transition: transform 0.3s, box-shadow 0.3s;
}
.feature-card:hover {
transform: translateY(-8px);
box-shadow: 0 12px 32px rgba(0,0,0,0.15);
}
.card-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.7) 100%);
}
.card-content {
position: relative;
z-index: 1;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 24px;
color: #fff;
}
.card-icon {
font-size: 48px;
margin-bottom: 16px;
}
.card-title {
font-size: 24px;
font-weight: bold;
margin: 0 0 12px;
}
.card-desc {
font-size: 14px;
line-height: 1.6;
opacity: 0.95;
margin: 0;
}
/* 活动预览 */
.activity-preview {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 24px;
margin-bottom: 32px;
}
.activity-card {
background: #fff;
border-radius: 20px;
overflow: hidden;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
cursor: pointer;
transition: transform 0.3s, box-shadow 0.3s;
}
.activity-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 24px rgba(0,0,0,0.12);
}
.activity-image {
height: 200px;
background-size: cover;
background-position: center;
position: relative;
}
.activity-badge {
position: absolute;
top: 16px;
right: 16px;
background: #FF6700;
color: #fff;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.3);
}
.activity-info {
padding: 20px;
}
.activity-title {
font-size: 18px;
font-weight: bold;
color: #333;
margin: 0 0 16px;
}
.activity-meta {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #666;
margin-bottom: 8px;
}
.activity-meta .van-icon {
color: #FF6700;
}
.view-more-btn {
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
border: none;
font-size: 17px;
font-weight: 600;
height: 52px;
box-shadow: 0 4px 16px rgba(255, 103, 0, 0.3);
}
.view-more-btn .van-icon {
margin-left: 8px;
}
/* 赞助商区域 */
.sponsors-section {
padding: 60px 16px;
max-width: 900px;
margin: 0 auto;
background: #fafafa;
}
.sponsors-grid {
display: flex;
justify-content: center;
align-items: stretch;
gap: 16px;
margin-top: 32px;
flex-wrap: wrap;
}
.sponsor-card {
background: #fff;
border-radius: 12px;
padding: 12px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
transition: transform 0.3s, box-shadow 0.3s;
flex: 0 1 140px;
max-width: 150px;
min-width: 120px;
}
.sponsor-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.sponsor-logo {
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.sponsor-logo svg {
width: 100%;
height: 100%;
}
.xiaomi-logo svg {
filter: drop-shadow(0 2px 8px rgba(255, 103, 0, 0.2));
}
.europark-logo svg {
filter: drop-shadow(0 2px 8px rgba(74, 144, 226, 0.2));
}
.sponsor-info {
width: 100%;
}
.sponsor-name {
font-size: 11px;
font-weight: 600;
color: #333;
margin: 0 0 4px;
line-height: 1.3;
word-break: break-all;
}
.sponsor-desc {
font-size: 10px;
color: #999;
margin: 0;
line-height: 1.4;
word-break: break-all;
}
/* CTA区域 */
.cta-section {
position: relative;
padding: 100px 20px;
text-align: center;
overflow: hidden;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
}
.cta-bg {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
opacity: 0.1;
background-image: url('https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/7-1-1.jpeg');
background-size: cover;
background-position: center;
}
.cta-content {
position: relative;
z-index: 1;
max-width: 600px;
margin: 0 auto;
}
.cta-title {
font-size: 40px;
font-weight: bold;
color: #fff;
margin: 0 0 16px;
}
.cta-desc {
font-size: 18px;
color: rgba(255,255,255,0.95);
margin: 0 0 32px;
line-height: 1.6;
}
.cta-btn {
background: #fff;
color: #FF6700;
font-size: 17px;
font-weight: 600;
height: 52px;
padding: 0 48px;
box-shadow: 0 4px 16px rgba(0,0,0,0.2);
}
/* 页脚 */
.footer {
background: #1a1a1a;
padding: 48px 20px;
}
.footer-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
}
.footer-logo {
display: flex;
align-items: center;
gap: 16px;
}
.logo-icon {
font-size: 48px;
}
.logo-text {
text-align: left;
}
.logo-name {
font-size: 20px;
font-weight: bold;
color: #fff;
margin-bottom: 4px;
}
.logo-slogan {
font-size: 14px;
color: #999;
letter-spacing: 2px;
}
.beian-link {
font-size: 13px;
color: #666;
text-decoration: none;
transition: color 0.3s;
}
.beian-link:hover {
color: #FF6700;
}
.loading-state,
.empty-state {
padding: 60px 0;
text-align: center;
}
/* 响应式 */
@media (max-width: 768px) {
.hero-title {
font-size: 32px;
}
.hero-subtitle {
font-size: 16px;
}
.hero-stats {
gap: 20px;
}
.stat-number {
font-size: 28px;
}
.section-title {
font-size: 28px;
}
.feature-cards {
grid-template-columns: 1fr;
}
.activity-preview {
grid-template-columns: 1fr;
}
.cta-title {
font-size: 32px;
}
.sponsors-grid {
gap: 12px;
}
.sponsor-card {
flex: 0 1 calc(20% - 10px);
min-width: 100px;
max-width: 130px;
padding: 10px;
}
.sponsor-logo {
width: 40px;
height: 40px;
margin-bottom: 6px;
}
.sponsor-name {
font-size: 10px;
}
.sponsor-desc {
font-size: 9px;
}
}
</style>
+370
View File
@@ -0,0 +1,370 @@
<template>
<div class="register">
<div class="register-header">
<div class="header-back" @click="$router.back()">
<van-icon name="arrow-left" />
</div>
<h1 class="header-title">活动报名</h1>
</div>
<div class="register-content">
<div class="form-card">
<van-form @submit="onSubmit">
<div class="form-section">
<div class="section-label">基本信息</div>
<van-field
v-model="form.name"
label="姓名"
placeholder="请输入您的姓名"
required
:rules="[{ required: true, message: '请输入姓名' }]"
/>
<van-field
v-model="form.phone"
label="手机号"
type="tel"
placeholder="请输入手机号"
maxlength="11"
required
:rules="[{ required: true, message: '请输入手机号' }, { pattern: /^1\d{10}$/, message: '请输入正确的手机号' }]"
/>
<van-field
v-model.number="form.peopleCount"
label="人数"
type="number"
placeholder="请输入参加人数"
required
:rules="[{ required: true, message: '请输入人数' }]"
/>
</div>
<div class="form-section">
<div class="section-label">车辆信息</div>
<van-field
v-model="form.carModel"
label="车型"
placeholder="请选择车型"
readonly
is-link
@click="showCarModelPicker = true"
/>
<van-field
label="车牌号"
:rules="[{ required: true, message: '请输入车牌号' }]"
class="car-number-field"
>
<template #input>
<div class="license-plate-input">
<span class="plate-prefix">闽</span>
<input
ref="letterInput"
v-model="carLetter"
@input="handleLetterInput"
placeholder="_"
maxlength="1"
class="plate-letter"
/>
<span class="plate-dot">·</span>
<input
ref="digitsInput"
v-model="carDigits"
@input="handleDigitsInput"
placeholder="______"
maxlength="6"
class="plate-digits"
/>
</div>
</template>
</van-field>
</div>
<div class="form-section">
<div class="section-label">其他信息</div>
<van-field
v-model="form.note"
label="备注"
type="textarea"
placeholder="有什么想说的吗?"
rows="3"
maxlength="200"
show-word-limit
/>
</div>
<div class="submit-btn">
<van-button round block type="primary" native-type="submit" size="large" color="#FF6700">
提交报名
</van-button>
</div>
</van-form>
</div>
</div>
<!-- 车型选择器 -->
<van-popup v-model:show="showCarModelPicker" position="bottom" round>
<van-picker
:columns="carModels"
@confirm="onCarModelConfirm"
@cancel="showCarModelPicker = false"
/>
</van-popup>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { showToast, showDialog } from 'vant';
import api from '../api';
const route = useRoute();
const router = useRouter();
const showCarModelPicker = ref(false);
const carModels = ref([
{ text: '小米SU7', value: '小米SU7' },
{ text: '小米YU7', value: '小米YU7' },
{ text: '小米SU7 Ultra', value: '小米SU7 Ultra' }
]);
const form = ref({
name: '',
phone: '',
peopleCount: 1,
carModel: '',
carNumber: '',
note: ''
});
const carLetter = ref('');
const carDigits = ref('');
const letterInput = ref(null);
const digitsInput = ref(null);
const handleLetterInput = (e) => {
const value = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
carLetter.value = value;
form.value.carNumber = value + carDigits.value;
// 输入完1位字母后自动跳到数字输入框
if (value.length === 1) {
setTimeout(() => {
digitsInput.value?.focus();
}, 0);
}
};
const handleDigitsInput = (e) => {
const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
carDigits.value = value;
form.value.carNumber = carLetter.value + value;
};
// 车型选择确认
const onCarModelConfirm = ({ selectedOptions }) => {
form.value.carModel = selectedOptions[0].value;
showCarModelPicker.value = false;
};
// 车牌号格式化已被handleLetterInput和handleDigitsInput替代
// 从缓存加载数据
onMounted(() => {
const cached = localStorage.getItem('userRegistrationInfo');
if (cached) {
try {
const data = JSON.parse(cached);
form.value = { ...form.value, ...data };
} catch (e) {
console.error('加载缓存失败', e);
}
}
});
const onSubmit = async () => {
try {
// 添加"闽"前缀到车牌号
const fullCarNumber = form.value.carNumber ? `闽${form.value.carNumber}` : '';
// 保存到缓存
localStorage.setItem('userRegistrationInfo', JSON.stringify({
name: form.value.name,
phone: form.value.phone,
carModel: form.value.carModel,
carNumber: fullCarNumber
}));
const { data } = await api.submitRegistration({
activityId: route.params.id,
...form.value,
carNumber: fullCarNumber
});
// 保存报名ID和登录信息
const registrations = JSON.parse(localStorage.getItem('myRegistrations') || '[]');
registrations.push({
activityId: route.params.id,
registrationId: data.id,
date: new Date().toISOString()
});
localStorage.setItem('myRegistrations', JSON.stringify(registrations));
// 自动保存登录状态
localStorage.setItem(`user_reg_${route.params.id}`, JSON.stringify({
id: data.id,
name: form.value.name,
phone: form.value.phone,
carNumber: fullCarNumber,
carModel: form.value.carModel,
status: 'pending',
activityId: route.params.id
}));
await showDialog({
title: '报名成功',
message: `您的报名ID: ${data.id}\n已自动登录,请等待管理员审核`
});
router.back();
} catch (error) {
showToast('报名失败');
}
};
</script>
<style scoped>
.register {
min-height: 100vh;
background: linear-gradient(180deg, #FF6700 0%, #FF8533 100%);
}
.register-header {
position: relative;
padding: 16px;
color: #fff;
}
.header-back {
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.2);
border-radius: 50%;
font-size: 20px;
backdrop-filter: blur(10px);
margin-bottom: 16px;
}
.header-title {
font-size: 28px;
font-weight: bold;
margin: 0;
}
.register-content {
padding: 0 16px 16px;
}
.form-card {
background: #fff;
border-radius: 20px;
padding: 24px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.form-section {
margin-bottom: 24px;
}
.form-section:last-of-type {
margin-bottom: 32px;
}
.section-label {
font-size: 14px;
font-weight: bold;
color: #333;
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid #FF6700;
}
/* 车牌号字段特殊样式 - 垂直居中 */
.car-number-field :deep(.van-cell) {
align-items: center !important;
}
.car-number-field :deep(.van-field__label) {
padding-top: 5px !important;
}
.submit-btn {
margin-top: 24px;
}
/* 车牌输入样式 - 橙色主题 */
.license-plate-input {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.3);
width: fit-content;
margin-left: auto;
}
.plate-prefix {
color: #fff;
font-weight: 700;
font-size: 16px;
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
}
.plate-letter {
width: 22px;
height: 28px;
border: 2px solid rgba(255,255,255,0.9);
border-radius: 3px;
background: #fff;
text-align: center;
font-size: 16px;
font-weight: 700;
color: #333;
outline: none;
text-transform: uppercase;
padding: 0;
}
.plate-dot {
color: #fff;
font-size: 18px;
font-weight: 700;
margin: 0 1px;
}
.plate-digits {
width: 110px;
height: 28px;
border: 2px solid rgba(255,255,255,0.9);
border-radius: 3px;
background: #fff;
text-align: center;
font-size: 16px;
font-weight: 700;
color: #333;
outline: none;
letter-spacing: 1px;
text-transform: uppercase;
padding: 0;
}
.plate-letter::placeholder,
.plate-digits::placeholder {
color: #ddd;
font-weight: 400;
}
</style>
+450
View File
@@ -0,0 +1,450 @@
<template>
<div class="share-page">
<div v-if="loading" class="loading-container">
<van-loading size="32px">加载中...</van-loading>
</div>
<div v-else-if="activity.id" class="share-content">
<div class="share-header" :style="{ backgroundImage: `url(${activity.coverImage || 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/5.jpg'})` }">
<div class="header-overlay"></div>
<div class="header-back" @click="$router.push('/')">
<van-icon name="arrow-left" />
</div>
<div class="header-info">
<div class="club-badge">泉友米车友会</div>
<h1 class="activity-title">{{ activity.title }}</h1>
<div class="activity-status">
<span class="status-badge" :class="activity.status">
{{ activity.status === 'upcoming' ? '🔥 报名中' : '✅ 已结束' }}
</span>
</div>
</div>
</div>
<div class="share-body">
<div class="info-cards">
<div class="info-card">
<van-icon name="clock-o" class="card-icon" />
<div class="card-content">
<div class="card-label">活动时间</div>
<div class="card-value">{{ formatDate(activity.startTime) }}</div>
</div>
</div>
<div class="info-card">
<van-icon name="location-o" class="card-icon" />
<div class="card-content">
<div class="card-label">活动地点</div>
<div class="card-value">{{ activity.location }}</div>
</div>
</div>
<div class="info-card">
<van-icon name="calendar-o" class="card-icon" />
<div class="card-content">
<div class="card-label">报名截止</div>
<div class="card-value">{{ formatDate(activity.registrationDeadline) }}</div>
</div>
</div>
</div>
<div class="description-section">
<h3 class="section-title">
<van-icon name="notes-o" />
活动介绍
</h3>
<p class="description-text">{{ activity.description }}</p>
</div>
<div v-if="registrations.length" class="members-section">
<h3 class="section-title">
<van-icon name="friends-o" />
已报名 <span class="member-count">{{ registrations.length }}人</span>
</h3>
<div class="member-avatars">
<div v-for="(reg, index) in registrations.slice(0, 10)" :key="reg.id" class="member-avatar" :style="{ zIndex: 10 - index }">
{{ reg.name.charAt(0) }}
</div>
<div v-if="registrations.length > 10" class="member-more">
+{{ registrations.length - 10 }}
</div>
</div>
</div>
<div class="action-section">
<van-button
type="primary"
block
round
size="large"
@click="goToDetail"
class="action-btn"
>
<van-icon name="arrow" />
查看详情并报名
</van-button>
<div class="share-tip">
<van-icon name="info-o" />
点击按钮查看完整活动信息
</div>
</div>
</div>
<div class="share-footer">
<div class="footer-logo">
<div class="logo-icon">🚗</div>
<div class="logo-text">
<div class="logo-name">泉友米车友会</div>
<div class="logo-slogan">驾驭激情 · 共享旅程</div>
</div>
</div>
</div>
</div>
<div v-else class="error-state">
<van-empty description="活动不存在或已删除" />
<van-button type="primary" round @click="$router.push('/')">返回首页</van-button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import api from '../api';
const route = useRoute();
const router = useRouter();
const activity = ref({});
const registrations = ref([]);
const loading = ref(true);
const loadData = async () => {
try {
const activityId = route.params.id || route.query.id;
if (!activityId) {
loading.value = false;
return;
}
const { data } = await api.getActivity(activityId);
activity.value = data.activity;
const { data: regs } = await api.getActivityRegistrations(activityId);
registrations.value = regs;
} catch (error) {
console.error('加载失败:', error);
} finally {
loading.value = false;
}
};
const formatDate = (date) => {
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
const goToDetail = () => {
router.push(`/activity/${activity.value.id}`);
};
onMounted(loadData);
</script>
<style scoped>
.share-page {
min-height: 100vh;
background: linear-gradient(180deg, #f8f9fa 0%, #fff 100%);
}
.loading-container,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
gap: 20px;
padding: 20px;
}
.share-header {
position: relative;
height: 320px;
background-size: cover;
background-position: center;
display: flex;
align-items: flex-end;
padding: 24px;
}
.header-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0,0,0,0.2) 0%, rgba(0,0,0,0.7) 100%);
}
.header-back {
position: absolute;
top: 16px;
left: 16px;
z-index: 2;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255,255,255,0.3);
border-radius: 50%;
color: #fff;
font-size: 20px;
backdrop-filter: blur(10px);
cursor: pointer;
}
.header-info {
position: relative;
z-index: 1;
width: 100%;
}
.club-badge {
display: inline-block;
background: rgba(255,255,255,0.25);
backdrop-filter: blur(10px);
color: #fff;
padding: 6px 16px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
margin-bottom: 12px;
border: 1px solid rgba(255,255,255,0.3);
}
.activity-title {
font-size: 28px;
font-weight: bold;
color: #fff;
margin: 0 0 12px;
text-shadow: 0 2px 12px rgba(0,0,0,0.4);
line-height: 1.3;
}
.activity-status {
display: flex;
gap: 8px;
}
.status-badge {
display: inline-block;
padding: 6px 16px;
border-radius: 20px;
font-size: 14px;
font-weight: 600;
backdrop-filter: blur(10px);
}
.status-badge.upcoming {
background: rgba(255,107,107,0.9);
color: #fff;
box-shadow: 0 4px 12px rgba(255,107,107,0.4);
}
.status-badge.completed {
background: rgba(76,175,80,0.9);
color: #fff;
}
.share-body {
padding: 20px 16px;
}
.info-cards {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.info-card {
display: flex;
align-items: center;
gap: 16px;
background: #fff;
padding: 16px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
}
.card-icon {
font-size: 28px;
color: #667eea;
}
.card-content {
flex: 1;
}
.card-label {
font-size: 13px;
color: #999;
margin-bottom: 4px;
}
.card-value {
font-size: 15px;
color: #333;
font-weight: 500;
}
.description-section,
.members-section {
background: #fff;
padding: 20px;
border-radius: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
margin-bottom: 16px;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 17px;
font-weight: bold;
color: #333;
margin: 0 0 16px;
}
.section-title .van-icon {
font-size: 20px;
color: #667eea;
}
.member-count {
color: #667eea;
font-size: 15px;
}
.description-text {
font-size: 15px;
line-height: 1.8;
color: #666;
margin: 0;
white-space: pre-line;
}
.member-avatars {
display: flex;
align-items: center;
}
.member-avatar {
width: 44px;
height: 44px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
border: 3px solid #fff;
margin-left: -12px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.member-avatar:first-child {
margin-left: 0;
}
.member-more {
width: 44px;
height: 44px;
border-radius: 50%;
background: #f0f0f0;
color: #666;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
border: 3px solid #fff;
margin-left: -12px;
}
.action-section {
margin-top: 24px;
}
.action-btn {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
font-size: 17px;
font-weight: 600;
height: 52px;
box-shadow: 0 4px 16px rgba(102,126,234,0.4);
}
.action-btn .van-icon {
margin-left: 8px;
}
.share-tip {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
margin-top: 12px;
font-size: 13px;
color: #999;
}
.share-tip .van-icon {
font-size: 16px;
}
.share-footer {
padding: 32px 16px;
border-top: 1px solid #f0f0f0;
background: #fff;
}
.footer-logo {
display: flex;
align-items: center;
gap: 16px;
justify-content: center;
}
.logo-icon {
font-size: 48px;
}
.logo-text {
text-align: left;
}
.logo-name {
font-size: 18px;
font-weight: bold;
color: #333;
margin-bottom: 4px;
}
.logo-slogan {
font-size: 13px;
color: #999;
letter-spacing: 2px;
}
</style>
File diff suppressed because it is too large Load Diff
+920
View File
@@ -0,0 +1,920 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<div class="header-content">
<h1>管理后台</h1>
<p>泉友米车友会</p>
</div>
<van-button class="logout-btn" size="small" round @click="logout">
<van-icon name="logout" /> 退出
</van-button>
</div>
<div class="dashboard-content">
<div class="content-header">
<h2>活动管理</h2>
<van-button type="primary" round icon="plus" @click="createNewActivity">
创建活动
</van-button>
</div>
<div class="activity-cards">
<div v-for="activity in activities" :key="activity.id" class="activity-card" @click="viewActivityDetail(activity)">
<div class="card-header">
<h3>{{ activity.title }}</h3>
<van-tag :type="activity.status === 'upcoming' ? 'primary' : 'default'">
{{ activity.status === 'upcoming' ? '进行中' : '已结束' }}
</van-tag>
</div>
<p class="card-desc">{{ activity.description }}</p>
<div class="card-info">
<div class="info-row">
<van-icon name="location-o" />
<span>{{ activity.location }}</span>
</div>
<div class="info-row">
<van-icon name="clock-o" />
<span>{{ formatDate(activity.startTime) }}</span>
</div>
</div>
<div class="card-footer">
<van-icon name="arrow" />
</div>
</div>
<van-empty v-if="!activities.length" description="暂无活动" />
</div>
</div>
<!-- 活动表单弹窗 -->
<van-popup v-model:show="showActivityForm" position="bottom" round>
<div class="form-popup">
<div class="popup-header">
<h3>创建活动</h3>
<van-icon name="cross" @click="closeActivityForm" />
</div>
<van-form @submit="submitActivity" class="popup-form">
<van-cell-group>
<van-field
v-model="activityForm.title"
label="活动标题"
placeholder="请输入活动标题"
required
:rules="[{ required: true, message: '请输入活动标题' }]"
/>
<van-field
v-model="activityForm.description"
label="活动描述"
type="textarea"
placeholder="请输入活动描述,包括活动内容、注意事项等"
rows="3"
autosize
maxlength="500"
show-word-limit
/>
<van-field
v-model="activityForm.price"
label="人均费用"
type="number"
placeholder="请输入人均费用(选填)"
>
<template #left-icon>
<van-icon name="gold-coin-o" style="margin-right: 8px;" />
</template>
<template #button>
<span style="color: #999;">元</span>
</template>
</van-field>
<van-field
v-model="activityForm.budget"
label="活动预算"
type="number"
placeholder="请输入预算金额(选填)"
>
<template #left-icon>
<van-icon name="balance-o" style="margin-right: 8px;" />
</template>
</van-field>
<van-field
v-model="activityForm.location"
label="活动地点"
placeholder="点击选择地点"
readonly
is-link
required
:rules="[{ required: true, message: '请选择活动地点' }]"
@click="openMapPicker"
>
<template #left-icon>
<van-icon name="location-o" style="margin-right: 8px;" />
</template>
</van-field>
<van-field
v-model="startTimeDisplay"
label="开始时间"
placeholder="请选择开始时间"
readonly
is-link
required
:rules="[{ required: true, message: '请选择开始时间' }]"
@click="showStartTimePicker = true"
>
<template #left-icon>
<van-icon name="clock-o" style="margin-right: 8px;" />
</template>
</van-field>
<van-field
v-model="deadlineDisplay"
label="报名截止"
placeholder="请选择报名截止时间"
readonly
is-link
required
:rules="[{ required: true, message: '请选择报名截止时间' }]"
@click="showDeadlinePicker = true"
>
<template #left-icon>
<van-icon name="calendar-o" style="margin-right: 8px;" />
</template>
</van-field>
</van-cell-group>
<div class="form-actions">
<van-button round block type="primary" native-type="submit" size="large">
创建活动
</van-button>
</div>
</van-form>
</div>
</van-popup>
<!-- 开始时间选择器 -->
<van-popup v-model:show="showStartTimePicker" position="bottom" round>
<div class="datetime-picker-popup">
<div class="picker-header">
<van-button size="small" @click="showStartTimePicker = false">取消</van-button>
<h3>选择开始时间</h3>
<van-button size="small" type="primary" @click="confirmStartTime">确定</van-button>
</div>
<div class="picker-body">
<input
type="datetime-local"
v-model="startTimeInput"
class="datetime-input"
/>
</div>
</div>
</van-popup>
<!-- 报名截止时间选择器 -->
<van-popup v-model:show="showDeadlinePicker" position="bottom" round>
<div class="datetime-picker-popup">
<div class="picker-header">
<van-button size="small" @click="showDeadlinePicker = false">取消</van-button>
<h3>选择报名截止时间</h3>
<van-button size="small" type="primary" @click="confirmDeadline">确定</van-button>
</div>
<div class="picker-body">
<input
type="datetime-local"
v-model="deadlineInput"
class="datetime-input"
/>
</div>
</div>
</van-popup>
<!-- 地图选点弹窗 -->
<van-popup v-model:show="showMapPicker" position="bottom" round :style="{ height: '80%' }">
<div class="map-picker">
<div class="map-picker-header">
<van-icon name="arrow-left" @click="showMapPicker = false" />
<h3>选择地点</h3>
<van-button type="primary" size="small" @click="confirmLocation">确定</van-button>
</div>
<div class="map-search">
<van-search
v-model="searchKeyword"
placeholder="搜索地点"
@search="searchLocation"
@clear="clearSearch"
show-action
action-text="搜索"
/>
</div>
<div v-if="searchResults.length" class="search-results">
<div
v-for="(result, index) in searchResults"
:key="index"
class="search-result-item"
@click="selectSearchResult(result)"
>
<div class="result-name">{{ result.name }}</div>
<div class="result-address">{{ result.address }}</div>
</div>
</div>
<div id="map-picker-container" class="map-container"></div>
<div class="selected-location">
<van-icon name="location-o" />
<span>{{ selectedLocation.name || '请在地图上选择位置' }}</span>
</div>
</div>
</van-popup>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { showToast } from 'vant';
import api from '../../api';
const router = useRouter();
const activities = ref([]);
const showActivityForm = ref(false);
const showMapPicker = ref(false);
const showStartTimePicker = ref(false);
const showDeadlinePicker = ref(false);
const searchKeyword = ref('');
const searchResults = ref([]);
const selectedLocation = ref({});
let pickerMap = null;
let pickerMarker = null;
let geocoder = null;
const activityForm = ref({
title: '',
description: '',
budget: '',
location: '',
locationLat: null,
locationLng: null,
startTime: '',
registrationDeadline: ''
});
// 日期时间选择器的值
const startTimeInput = ref('');
const deadlineInput = ref('');
// 显示格式化的日期时间
const startTimeDisplay = computed(() => {
if (!activityForm.value.startTime) return '';
return formatDateTime(activityForm.value.startTime);
});
const deadlineDisplay = computed(() => {
if (!activityForm.value.registrationDeadline) return '';
return formatDateTime(activityForm.value.registrationDeadline);
});
const formatDate = (date) => {
return new Date(date).toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
};
// 格式化日期时间显示
const formatDateTime = (dateTime) => {
if (!dateTime) return '';
const date = new Date(dateTime);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
};
// 开始时间确认
const confirmStartTime = () => {
if (!startTimeInput.value) {
showToast('请选择开始时间');
return;
}
const selectedDate = new Date(startTimeInput.value);
activityForm.value.startTime = selectedDate.toISOString();
showStartTimePicker.value = false;
};
// 报名截止时间确认
const confirmDeadline = () => {
if (!deadlineInput.value) {
showToast('请选择报名截止时间');
return;
}
const selectedDate = new Date(deadlineInput.value);
if (activityForm.value.startTime && selectedDate > new Date(activityForm.value.startTime)) {
showToast('报名截止时间不能晚于活动开始时间');
return;
}
activityForm.value.registrationDeadline = selectedDate.toISOString();
showDeadlinePicker.value = false;
};
// 打开地图选择器
const openMapPicker = () => {
showMapPicker.value = true;
};
// 关闭活动表单
const closeActivityForm = () => {
showActivityForm.value = false;
activityForm.value = {
title: '',
description: '',
budget: '',
location: '',
locationLat: null,
locationLng: null,
startTime: '',
registrationDeadline: ''
};
startTimeInput.value = '';
deadlineInput.value = '';
};
const loadActivities = async () => {
try {
const { data } = await api.getActivities();
activities.value = data;
} catch (error) {
showToast('加载失败');
}
};
const createNewActivity = () => {
activityForm.value = {
title: '',
description: '',
budget: '',
location: '',
locationLat: null,
locationLng: null,
startTime: '',
registrationDeadline: ''
};
startTimeInput.value = '';
deadlineInput.value = '';
showActivityForm.value = true;
};
const viewActivityDetail = (activity) => {
router.push(`/admin/activity/${activity.id}`);
};
const initMapPicker = async () => {
try {
console.log('开始初始化地图选择器...');
const AMap = await window.loadAMap();
console.log('地图API加载完成');
if (pickerMap) {
console.log('销毁旧地图实例');
pickerMap.destroy();
}
const center = activityForm.value.locationLat && activityForm.value.locationLng
? [activityForm.value.locationLng, activityForm.value.locationLat]
: [118.6762, 24.8742];
console.log('地图中心点:', center);
pickerMap = new AMap.Map('map-picker-container', {
zoom: 15,
center: center
});
console.log('地图实例创建完成');
geocoder = new AMap.Geocoder();
console.log('地理编码器创建完成');
pickerMarker = new AMap.Marker({
position: center,
map: pickerMap,
draggable: true
});
console.log('地图标记创建完成');
if (activityForm.value.location) {
selectedLocation.value = {
name: activityForm.value.location,
lat: activityForm.value.locationLat,
lng: activityForm.value.locationLng
};
}
pickerMap.on('click', (e) => {
const { lng, lat } = e.lnglat;
pickerMarker.setPosition([lng, lat]);
selectedLocation.value = {
name: '获取地址中...',
lat: lat,
lng: lng
};
if (geocoder) {
geocoder.getAddress([lng, lat], (status, result) => {
if (status === 'complete' && result.info === 'OK') {
selectedLocation.value.name = result.regeocode.formattedAddress;
} else {
console.warn('逆地理编码失败:', status, result);
selectedLocation.value.name = `泉州市区 (${lat.toFixed(4)}, ${lng.toFixed(4)})`;
}
});
} else {
selectedLocation.value.name = `泉州市区 (${lat.toFixed(4)}, ${lng.toFixed(4)})`;
}
});
pickerMarker.on('dragend', (e) => {
const { lng, lat } = e.lnglat;
selectedLocation.value = {
name: '获取地址中...',
lat: lat,
lng: lng
};
if (geocoder) {
geocoder.getAddress([lng, lat], (status, result) => {
if (status === 'complete' && result.info === 'OK') {
selectedLocation.value.name = result.regeocode.formattedAddress;
} else {
console.warn('逆地理编码失败:', status, result);
selectedLocation.value.name = `泉州市区 (${lat.toFixed(4)}, ${lng.toFixed(4)})`;
}
});
} else {
selectedLocation.value.name = `泉州市区 (${lat.toFixed(4)}, ${lng.toFixed(4)})`;
}
});
console.log('地图初始化完成');
} catch (error) {
console.error('地图初始化失败:', error);
showToast('地图加载失败: ' + error.message);
}
};
const searchLocation = async () => {
if (!searchKeyword.value.trim()) {
showToast('请输入搜索关键词');
return;
}
try {
console.log('开始搜索地点:', searchKeyword.value);
const AMap = await window.loadAMap();
if (!AMap.PlaceSearch) {
console.error('PlaceSearch插件未加载');
showToast('地图搜索功能未就绪');
return;
}
const placeSearch = new AMap.PlaceSearch({
pageSize: 10,
pageIndex: 1,
city: '泉州',
citylimit: false,
extensions: 'base'
});
placeSearch.search(searchKeyword.value, (status, result) => {
console.log('搜索状态:', status);
console.log('搜索结果:', result);
if (status === 'complete' && result.info === 'OK') {
if (result.poiList && result.poiList.pois && result.poiList.pois.length > 0) {
searchResults.value = result.poiList.pois.map(poi => ({
name: poi.name,
address: poi.address || poi.district || '',
lat: poi.location.lat,
lng: poi.location.lng
}));
console.log('搜索成功,找到', searchResults.value.length, '个结果');
} else {
searchResults.value = [];
showToast('未找到相关地点');
}
} else if (status === 'no_data') {
searchResults.value = [];
showToast('未找到相关地点');
} else {
console.error('搜索失败:', status, result);
searchResults.value = [];
showToast('搜索失败: ' + (result.info || status));
}
});
} catch (error) {
console.error('搜索异常:', error);
showToast('搜索失败: ' + error.message);
}
};
const selectSearchResult = (result) => {
selectedLocation.value = result;
pickerMarker.setPosition([result.lng, result.lat]);
pickerMap.setCenter([result.lng, result.lat]);
searchResults.value = [];
searchKeyword.value = '';
};
const clearSearch = () => {
searchResults.value = [];
};
const confirmLocation = () => {
if (selectedLocation.value.lat && selectedLocation.value.lng) {
activityForm.value.location = selectedLocation.value.name;
activityForm.value.locationLat = selectedLocation.value.lat;
activityForm.value.locationLng = selectedLocation.value.lng;
showMapPicker.value = false;
} else {
showToast('请在地图上选择位置');
}
};
watch(showMapPicker, (val) => {
if (val) {
setTimeout(() => {
initMapPicker();
}, 300);
}
});
const submitActivity = async () => {
try {
const formData = { ...activityForm.value };
// 清理空字符串,转换为null
if (formData.budget === '' || formData.budget === undefined) {
formData.budget = null;
}
if (formData.price === '' || formData.price === undefined) {
formData.price = null;
}
await api.createActivity(formData);
showToast('创建成功');
closeActivityForm();
loadActivities();
} catch (error) {
showToast('创建失败');
}
};
const logout = () => {
localStorage.removeItem('token');
router.push('/admin/login');
};
onMounted(() => {
loadActivities();
});
</script>
<style scoped>
.dashboard {
min-height: 100vh;
background: #f8f9fa;
}
.dashboard-header {
background: #fff;
padding: 24px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
display: flex;
justify-content: space-between;
align-items: center;
}
.header-content h1 {
font-size: 24px;
margin: 0 0 4px;
font-weight: 700;
color: #1a1a1a;
}
.header-content p {
font-size: 14px;
margin: 0;
color: #666;
}
.logout-btn {
background: #f5f5f5;
border: none;
color: #666;
font-weight: 500;
}
.logout-btn:active {
background: #e8e8e8;
}
.dashboard-content {
padding: 20px 16px;
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.content-header h2 {
font-size: 20px;
margin: 0;
color: #1a1a1a;
font-weight: 700;
}
.activity-cards {
display: flex;
flex-direction: column;
gap: 12px;
}
.activity-card {
background: #fff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1px solid #f0f0f0;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
}
.activity-card:active {
transform: scale(0.98);
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 12px;
gap: 12px;
}
.card-header h3 {
font-size: 17px;
margin: 0;
color: #1a1a1a;
font-weight: 700;
line-height: 1.4;
flex: 1;
}
.card-desc {
font-size: 14px;
color: #666;
margin: 0 0 12px;
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.card-info {
display: flex;
flex-direction: column;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #f5f5f5;
}
.info-row {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: #666;
}
.info-row .van-icon {
font-size: 16px;
color: #FF6700;
}
.card-footer {
position: absolute;
right: 20px;
bottom: 20px;
color: #ddd;
font-size: 16px;
}
/* 弹窗 */
.form-popup {
max-height: 85vh;
display: flex;
flex-direction: column;
background: #fff;
border-radius: 16px 16px 0 0;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #f0f0f0;
flex-shrink: 0;
}
.popup-header h3 {
font-size: 18px;
margin: 0;
color: #1a1a1a;
font-weight: 700;
}
.popup-header .van-icon {
font-size: 20px;
color: #999;
cursor: pointer;
}
.popup-form {
padding: 0;
overflow-y: auto;
flex: 1;
}
.popup-form .van-cell-group {
margin: 0;
background: #fff;
}
.popup-form .van-cell {
padding: 14px 20px;
}
.popup-form .van-field__label {
width: 90px;
color: #333;
font-weight: 600;
font-size: 14px;
}
.popup-form .van-field__control {
color: #1a1a1a;
font-size: 14px;
}
.popup-form .van-field__control::placeholder {
color: #bbb;
}
.form-actions {
margin: 0;
padding: 16px 20px 20px;
flex-shrink: 0;
background: #fff;
border-top: 1px solid #f5f5f5;
}
.form-actions .van-button {
height: 48px;
font-size: 16px;
font-weight: 600;
}
/* 地图选点 */
.map-picker {
height: 100%;
display: flex;
flex-direction: column;
}
.map-picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #f0f0f0;
}
.map-picker-header h3 {
flex: 1;
text-align: center;
font-size: 16px;
margin: 0;
color: #333;
}
.map-picker-header .van-icon {
font-size: 20px;
color: #333;
}
.map-search {
padding: 12px 16px;
background: #fff;
}
.search-results {
max-height: 200px;
overflow-y: auto;
background: #fff;
border-bottom: 1px solid #f0f0f0;
}
.search-result-item {
padding: 12px 16px;
border-bottom: 1px solid #f5f5f5;
cursor: pointer;
}
.search-result-item:active {
background: #f5f5f5;
}
.result-name {
font-size: 14px;
color: #333;
margin-bottom: 4px;
}
.result-address {
font-size: 12px;
color: #999;
}
.map-container {
flex: 1;
width: 100%;
}
.selected-location {
padding: 12px 16px;
background: #fff;
border-top: 1px solid #f0f0f0;
display: flex;
align-items: center;
gap: 8px;
}
.selected-location .van-icon {
color: #FF6700;
font-size: 18px;
}
.selected-location span {
flex: 1;
font-size: 14px;
color: #333;
}
.datetime-picker-popup {
background: #fff;
border-radius: 16px 16px 0 0;
overflow: hidden;
}
.picker-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
}
.picker-header h3 {
font-size: 16px;
margin: 0;
color: #333;
font-weight: 600;
}
.picker-body {
padding: 20px;
}
.datetime-input {
width: 100%;
padding: 12px;
font-size: 16px;
border: 1px solid #e8e8e8;
border-radius: 8px;
outline: none;
color: #333;
}
.datetime-input:focus {
border-color: #FF6700;
}
</style>
+226
View File
@@ -0,0 +1,226 @@
<template>
<div class="login">
<div class="login-bg">
<div class="bg-circle circle-1"></div>
<div class="bg-circle circle-2"></div>
<div class="bg-circle circle-3"></div>
</div>
<div class="login-content">
<div class="logo">
<div class="logo-icon">
<van-icon name="manager-o" />
</div>
<h1>泉友米管理后台</h1>
<p>欢迎回来</p>
</div>
<van-form @submit="onSubmit" class="login-form">
<van-cell-group inset>
<van-field
v-model="form.username"
label="用户名"
placeholder="请输入用户名"
required
left-icon="user-o"
clearable
/>
<van-field
v-model="form.password"
type="password"
label="密码"
placeholder="请输入密码"
required
left-icon="lock"
clearable
/>
</van-cell-group>
<div class="form-actions">
<van-button round block type="primary" native-type="submit" size="large">
登录
</van-button>
</div>
</van-form>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { showToast } from 'vant';
import api from '../../api';
const router = useRouter();
const form = ref({
username: '',
password: ''
});
const onSubmit = async () => {
try {
const { data } = await api.adminLogin(form.value);
localStorage.setItem('token', data.token);
showToast('登录成功');
router.push('/admin');
} catch (error) {
showToast('登录失败');
}
};
</script>
<style scoped>
.login {
min-height: 100vh;
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
position: relative;
overflow: hidden;
}
.login-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
.bg-circle {
position: absolute;
border-radius: 50%;
background: rgba(255,255,255,0.1);
animation: float 20s ease-in-out infinite;
}
.circle-1 {
width: 300px;
height: 300px;
top: -100px;
right: -100px;
animation-delay: 0s;
}
.circle-2 {
width: 200px;
height: 200px;
bottom: -50px;
left: -50px;
animation-delay: 5s;
}
.circle-3 {
width: 150px;
height: 150px;
top: 50%;
left: 10%;
animation-delay: 10s;
}
@keyframes float {
0%, 100% {
transform: translate(0, 0) scale(1);
opacity: 0.3;
}
50% {
transform: translate(30px, -30px) scale(1.1);
opacity: 0.5;
}
}
.login-content {
width: 100%;
max-width: 400px;
position: relative;
z-index: 1;
}
.logo {
text-align: center;
margin-bottom: 48px;
}
.logo-icon {
width: 80px;
height: 80px;
margin: 0 auto 20px;
background: rgba(255,255,255,0.25);
border-radius: 24px;
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(10px);
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.logo-icon .van-icon {
font-size: 42px;
color: #fff;
}
.logo h1 {
color: #fff;
font-size: 28px;
font-weight: 800;
margin: 0 0 8px;
text-shadow: 0 2px 12px rgba(0,0,0,0.2);
letter-spacing: 1px;
}
.logo p {
color: rgba(255,255,255,0.9);
font-size: 15px;
margin: 0;
font-weight: 500;
}
.login-form {
position: relative;
}
.login-form .van-cell-group {
border-radius: 20px;
overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
backdrop-filter: blur(10px);
background: rgba(255,255,255,0.95);
}
.login-form .van-field {
padding: 16px;
}
.login-form .van-field__label {
font-weight: 600;
color: #333;
font-size: 14px;
}
.login-form .van-field__control {
font-size: 15px;
}
.form-actions {
margin-top: 32px;
}
.form-actions .van-button {
height: 52px;
font-size: 17px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(0,0,0,0.2);
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
border: none;
transition: all 0.3s ease;
}
.form-actions .van-button:active {
transform: translateY(2px);
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
}
</style>
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
});
+31
View File
@@ -0,0 +1,31 @@
@echo off
echo ========================================
echo 泉友米车友会管理系统
echo ========================================
echo.
echo 请确保已完成以下步骤:
echo 1. 安装MySQL并创建数据库 quanyoumi
echo 2. 配置 backend/.env 文件
echo 3. 运行 node backend/init-admin.js 初始化管理员
echo.
echo 按任意键启动后端服务...
pause > nul
cd backend
start cmd /k "npm run dev"
echo.
echo 后端服务已启动在 http://localhost:3000
echo.
echo 按任意键启动前端服务...
pause > nul
cd ../frontend
start cmd /k "npm run dev"
echo.
echo 前端服务已启动在 http://localhost:5173
echo 管理后台: http://localhost:5173/admin/login
echo 默认账号: admin / admin123
echo.
pause
+53
View File
@@ -0,0 +1,53 @@
# 签到功能实施说明
## 已完成的修改
### 1. 数据库修改
- 创建了迁移脚本 `database-migration-checkin.sql`
- 添加了两个字段:
- `checkInStatus` (BOOLEAN) - 签到状态
- `checkInTime` (DATETIME) - 签到时间
### 2. 后端修改
- **模型更新** (`backend/models/Registration.js`)
- 添加签到字段定义
- **API接口** (`backend/routes/admin.js`)
- 新增 `PUT /admin/registrations/:id/checkin` - 签到/取消签到接口
- **前端API** (`frontend/src/api/index.js`)
- 添加 `checkInRegistration` 方法
### 3. 前端修改
- **后台管理页面** (`frontend/src/views/admin/ActivityManage.vue`)
- 统计栏新增:已签到/未签到统计(一行三个布局)
- 报名列表显示签到状态标签
- 报名列表显示签到时间
- 添加签到/取消签到按钮(仅已通过审核的用户可签到)
- 手机号改为可点击拨打的链接
- 车牌号可点击复制
## 使用步骤
### 1. 执行数据库迁移
```bash
mysql -u root -p quanyoumi < database-migration-checkin.sql
```
### 2. 重启后端服务
```bash
cd backend
npm start
```
### 3. 功能使用
- 进入后台管理 -> 活动管理 -> 报名情况
- 查看签到统计(已签到/未签到人数)
- 点击"签到"按钮为用户签到
- 点击手机号直接拨打电话
- 点击车牌号复制到剪贴板
## 注意事项
- 只有审核通过的用户才能签到
- 签到后可以取消签到
- 签到时间会自动记录