Initial commit: 全友米项目完整代码
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
backend/uploads/*
|
||||
!backend/uploads/.gitkeep
|
||||
dist/
|
||||
.DS_Store
|
||||
*.log
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -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
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
Generated
+2140
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.
@@ -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 };
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 添加签到功能字段
|
||||
ALTER TABLE Registrations
|
||||
ADD COLUMN checkInStatus BOOLEAN DEFAULT false COMMENT '是否已签到',
|
||||
ADD COLUMN checkInTime DATETIME NULL COMMENT '签到时间';
|
||||
@@ -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
|
||||
);
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Generated
+1135
File diff suppressed because it is too large
Load Diff
@@ -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.
@@ -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>
|
||||
@@ -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}`)
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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
|
||||
@@ -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. 功能使用
|
||||
- 进入后台管理 -> 活动管理 -> 报名情况
|
||||
- 查看签到统计(已签到/未签到人数)
|
||||
- 点击"签到"按钮为用户签到
|
||||
- 点击手机号直接拨打电话
|
||||
- 点击车牌号复制到剪贴板
|
||||
|
||||
## 注意事项
|
||||
- 只有审核通过的用户才能签到
|
||||
- 签到后可以取消签到
|
||||
- 签到时间会自动记录
|
||||
Reference in New Issue
Block a user