Compare commits
3
Commits
89960b1cb8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd49a9de88 | ||
|
|
2530ceb6b4 | ||
|
|
4920620d2c |
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/qym.iml" filepath="$PROJECT_DIR$/.idea/qym.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
# 泉友米车友会 - 部署说明
|
|
||||||
|
|
||||||
版本: 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`
|
|
||||||
|
|
||||||
或联系技术支持团队。
|
|
||||||
+5
-5
@@ -20,14 +20,14 @@ FROM node:18-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# 安装 dumb-init 和 envsubst
|
# 安装 dumb-init、envsubst 和 SQLite 编译依赖
|
||||||
RUN apk add --no-cache dumb-init gettext
|
RUN apk add --no-cache dumb-init gettext python3 make g++ sqlite-dev
|
||||||
|
|
||||||
# 复制后端依赖文件
|
# 复制后端依赖文件
|
||||||
COPY backend/package*.json ./
|
COPY backend/package*.json ./
|
||||||
|
|
||||||
# 安装后端依赖(仅生产依赖)
|
# 安装后端依赖(仅生产依赖)
|
||||||
RUN npm ci --only=production
|
RUN npm install --only=production
|
||||||
|
|
||||||
# 复制后端源码
|
# 复制后端源码
|
||||||
COPY backend/ ./
|
COPY backend/ ./
|
||||||
@@ -35,8 +35,8 @@ COPY backend/ ./
|
|||||||
# 从前端构建阶段复制构建产物
|
# 从前端构建阶段复制构建产物
|
||||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||||
|
|
||||||
# 创建上传目录
|
# 创建上传和数据目录
|
||||||
RUN mkdir -p uploads
|
RUN mkdir -p uploads data
|
||||||
|
|
||||||
# 复制启动脚本
|
# 复制启动脚本
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/
|
COPY docker-entrypoint.sh /usr/local/bin/
|
||||||
|
|||||||
+3
-14
@@ -1,17 +1,6 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
DB_HOST=localhost
|
|
||||||
DB_USER=root
|
|
||||||
DB_PASSWORD=your-db-password
|
|
||||||
DB_NAME=quanyoumi
|
|
||||||
JWT_SECRET=your-secret-key-change-in-production
|
JWT_SECRET=your-secret-key-change-in-production
|
||||||
|
|
||||||
# MinIO 配置
|
# 高德地图配置
|
||||||
MINIO_ENDPOINT=localhost # 后端连接 MinIO 的地址
|
AMAP_KEY=your-amap-key
|
||||||
MINIO_PORT=9000
|
AMAP_SECURITY_CODE=your-amap-security-code
|
||||||
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
|
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,49 @@
|
|||||||
|
const { Expense } = require('./models');
|
||||||
|
|
||||||
|
async function fixExpenseMedia() {
|
||||||
|
try {
|
||||||
|
console.log('开始修复经费媒体数据...');
|
||||||
|
|
||||||
|
const expenses = await Expense.findAll();
|
||||||
|
console.log(`找到 ${expenses.length} 条经费记录`);
|
||||||
|
|
||||||
|
let fixedCount = 0;
|
||||||
|
|
||||||
|
for (const expense of expenses) {
|
||||||
|
let needUpdate = false;
|
||||||
|
let media = expense.media || [];
|
||||||
|
|
||||||
|
// 检查是否有异常数据
|
||||||
|
if (Array.isArray(media) && media.length > 5) {
|
||||||
|
console.log(`经费 #${expense.id} 有 ${media.length} 个媒体文件,超过限制`);
|
||||||
|
media = media.slice(0, 5);
|
||||||
|
needUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有无效的媒体项
|
||||||
|
const validMedia = media.filter(item => {
|
||||||
|
return item && item.url && (item.type === 'image' || item.type === 'video');
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validMedia.length !== media.length) {
|
||||||
|
console.log(`经费 #${expense.id} 有无效媒体项,从 ${media.length} 清理到 ${validMedia.length}`);
|
||||||
|
media = validMedia;
|
||||||
|
needUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needUpdate) {
|
||||||
|
await expense.update({ media });
|
||||||
|
fixedCount++;
|
||||||
|
console.log(`✓ 修复经费 #${expense.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n修复完成!共修复 ${fixedCount} 条记录`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('修复失败:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fixExpenseMedia();
|
||||||
+55
-10
@@ -7,19 +7,64 @@ async function initAdmin() {
|
|||||||
try {
|
try {
|
||||||
await sequelize.sync({ force: false });
|
await sequelize.sync({ force: false });
|
||||||
|
|
||||||
const hashedPassword = bcrypt.hashSync('admin123', 10);
|
// 检查role字段是否存在
|
||||||
await Admin.create({
|
const [results] = await sequelize.query("PRAGMA table_info(Admins);");
|
||||||
username: 'admin',
|
const hasRoleColumn = results.some(col => col.name === 'role');
|
||||||
password: hashedPassword,
|
|
||||||
name: '管理员'
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('管理员账号创建成功');
|
if (!hasRoleColumn) {
|
||||||
console.log('用户名: admin');
|
console.log('⚠️ role字段不存在,请先运行迁移: node backend/migrations/index.js');
|
||||||
console.log('密码: admin123');
|
console.log(' 或运行: npm run migrate (在backend目录)');
|
||||||
|
process.exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查yvan超级管理员
|
||||||
|
const yvan = await Admin.findOne({ where: { username: 'yvan' } });
|
||||||
|
if (!yvan) {
|
||||||
|
const hashedPassword = bcrypt.hashSync('Xu950329.', 10);
|
||||||
|
await Admin.create({
|
||||||
|
username: 'yvan',
|
||||||
|
password: hashedPassword,
|
||||||
|
name: 'Yvan',
|
||||||
|
role: 'super'
|
||||||
|
});
|
||||||
|
console.log('✓ 超级管理员yvan创建成功');
|
||||||
|
} else {
|
||||||
|
// 确保yvan是super角色
|
||||||
|
if (yvan.role !== 'super') {
|
||||||
|
await yvan.update({ role: 'super' });
|
||||||
|
console.log('✓ 超级管理员yvan角色已更新');
|
||||||
|
} else {
|
||||||
|
console.log('✓ 超级管理员yvan已存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查默认管理员
|
||||||
|
const admin = await Admin.findOne({ where: { username: 'admin' } });
|
||||||
|
if (!admin) {
|
||||||
|
const hashedPassword = bcrypt.hashSync('admin123', 10);
|
||||||
|
await Admin.create({
|
||||||
|
username: 'admin',
|
||||||
|
password: hashedPassword,
|
||||||
|
name: '管理员',
|
||||||
|
role: 'admin'
|
||||||
|
});
|
||||||
|
console.log('✓ 默认管理员admin创建成功');
|
||||||
|
} else {
|
||||||
|
// 确保admin是admin角色
|
||||||
|
if (!admin.role || admin.role === 'super') {
|
||||||
|
await admin.update({ role: 'admin' });
|
||||||
|
console.log('✓ 默认管理员admin角色已更新');
|
||||||
|
} else {
|
||||||
|
console.log('✓ 默认管理员admin已存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n初始化完成!');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建失败:', error.message);
|
console.error('✗ 初始化失败:', error.message);
|
||||||
|
console.error(' 详细错误:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { Admin } = require('../models');
|
||||||
|
|
||||||
|
const authSuper = async (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);
|
||||||
|
const admin = await Admin.findByPk(decoded.id);
|
||||||
|
|
||||||
|
if (!admin || admin.role !== 'super') {
|
||||||
|
return res.status(403).json({ error: '需要超级管理员权限' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.adminId = decoded.id;
|
||||||
|
req.admin = admin;
|
||||||
|
next();
|
||||||
|
} catch (error) {
|
||||||
|
res.status(401).json({ error: '无效的token' });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = authSuper;
|
||||||
@@ -1,11 +1,27 @@
|
|||||||
const multer = require('multer');
|
const multer = require('multer');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const fs = require('fs');
|
||||||
const { uploadBuffer } = require('../utils/minio');
|
|
||||||
|
// 确保上传目录存在
|
||||||
|
const uploadDir = path.join(__dirname, '../uploads');
|
||||||
|
if (!fs.existsSync(uploadDir)) {
|
||||||
|
fs.mkdirSync(uploadDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置本地存储
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueName = `${Date.now()}-${Math.random().toString(36).substring(7)}${path.extname(file.originalname)}`;
|
||||||
|
cb(null, uniqueName);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 使用内存存储,文件不落盘,不限制大小
|
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
storage: multer.memoryStorage(),
|
storage,
|
||||||
|
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
|
||||||
fileFilter: (req, file, cb) => {
|
fileFilter: (req, file, cb) => {
|
||||||
const extname = /jpeg|jpg|png|gif|mp4|mov|avi/.test(
|
const extname = /jpeg|jpg|png|gif|mp4|mov|avi/.test(
|
||||||
path.extname(file.originalname).toLowerCase()
|
path.extname(file.originalname).toLowerCase()
|
||||||
@@ -16,15 +32,14 @@ const upload = multer({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 将 req.files 或 req.file 上传到 MinIO,返回 url 数组或单个 url
|
// 返回文件访问URL
|
||||||
const uploadFilesToMinio = async (files) => {
|
const getFileUrls = (files) => {
|
||||||
const list = Array.isArray(files) ? files : [files];
|
const list = Array.isArray(files) ? files : [files];
|
||||||
return Promise.all(list.map(async (file) => {
|
return list.map(file => ({
|
||||||
const ext = path.extname(file.originalname).toLowerCase();
|
url: `/uploads/${file.filename}`,
|
||||||
const filename = `${Date.now()}-${uuidv4()}${ext}`;
|
filename: file.filename,
|
||||||
const url = await uploadBuffer(file.buffer, filename, file.mimetype);
|
mimetype: file.mimetype
|
||||||
return { ...file, filename, url };
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { upload, uploadFilesToMinio };
|
module.exports = { upload, getFileUrls };
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/**
|
||||||
|
* 迁移: 初始化数据库结构(确保所有表和字段存在)
|
||||||
|
* 版本: 0000
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 检查并创建所有必需的表和字段...');
|
||||||
|
|
||||||
|
// Activities 表的额外字段
|
||||||
|
const activityFields = [
|
||||||
|
{ name: 'registrationEnabled', sql: 'ALTER TABLE Activities ADD COLUMN registrationEnabled INTEGER DEFAULT 1;' },
|
||||||
|
{ name: 'coverImage', sql: 'ALTER TABLE Activities ADD COLUMN coverImage TEXT;' },
|
||||||
|
{ name: 'price', sql: 'ALTER TABLE Activities ADD COLUMN price DECIMAL(10, 2) DEFAULT 0;' },
|
||||||
|
{ name: 'budget', sql: 'ALTER TABLE Activities ADD COLUMN budget DECIMAL(10, 2) DEFAULT 0;' },
|
||||||
|
{ name: 'locationLat', sql: 'ALTER TABLE Activities ADD COLUMN locationLat DECIMAL(10, 7);' },
|
||||||
|
{ name: 'locationLng', sql: 'ALTER TABLE Activities ADD COLUMN locationLng DECIMAL(10, 7);' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of activityFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrations 表的额外字段
|
||||||
|
const registrationFields = [
|
||||||
|
{ name: 'peopleCount', sql: 'ALTER TABLE Registrations ADD COLUMN peopleCount INTEGER DEFAULT 1;' },
|
||||||
|
{ name: 'note', sql: 'ALTER TABLE Registrations ADD COLUMN note TEXT;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of registrationFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expenses 表的额外字段
|
||||||
|
const expenseFields = [
|
||||||
|
{ name: 'category', sql: 'ALTER TABLE Expenses ADD COLUMN category TEXT;' },
|
||||||
|
{ name: 'receipt', sql: 'ALTER TABLE Expenses ADD COLUMN receipt TEXT;' },
|
||||||
|
{ name: 'note', sql: 'ALTER TABLE Expenses ADD COLUMN note TEXT;' },
|
||||||
|
{ name: 'media', sql: 'ALTER TABLE Expenses ADD COLUMN media TEXT;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of expenseFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos 表的额外字段
|
||||||
|
const photoFields = [
|
||||||
|
{ name: 'description', sql: 'ALTER TABLE Photos ADD COLUMN description TEXT;' },
|
||||||
|
{ name: 'uploadedBy', sql: 'ALTER TABLE Photos ADD COLUMN uploadedBy TEXT;' },
|
||||||
|
{ name: 'status', sql: 'ALTER TABLE Photos ADD COLUMN status TEXT DEFAULT "approved";' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of photoFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sponsors 表的额外字段
|
||||||
|
const sponsorFields = [
|
||||||
|
{ name: 'logo', sql: 'ALTER TABLE Sponsors ADD COLUMN logo TEXT;' },
|
||||||
|
{ name: 'type', sql: 'ALTER TABLE Sponsors ADD COLUMN type TEXT DEFAULT "sponsor";' },
|
||||||
|
{ name: 'link', sql: 'ALTER TABLE Sponsors ADD COLUMN link TEXT;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of sponsorFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins 表的额外字段
|
||||||
|
const adminFields = [
|
||||||
|
{ name: 'name', sql: 'ALTER TABLE Admins ADD COLUMN name TEXT;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of adminFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' - 初始化完成');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 回滚不支持');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* 迁移: 添加弹幕功能字段
|
||||||
|
* 版本: 0001
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 danmakuEnabled 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Activities ADD COLUMN danmakuEnabled INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(' - 添加 danmakuContent 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Activities ADD COLUMN danmakuContent TEXT;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
// SQLite 不支持 DROP COLUMN,需要重建表
|
||||||
|
console.log(' - 回滚不支持,请手动处理');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* 迁移: 添加赞助商排序字段
|
||||||
|
* 版本: 0002
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 Sponsors.sortOrder 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Sponsors ADD COLUMN sortOrder INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 回滚不支持');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
/**
|
||||||
|
* 迁移: 添加签到功能字段
|
||||||
|
* 版本: 0003
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 Registrations.checkInStatus 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Registrations ADD COLUMN checkInStatus INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(' - 添加 Registrations.checkInTime 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Registrations ADD COLUMN checkInTime DATETIME;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 回滚不支持');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* 迁移: 添加奖品表
|
||||||
|
* 版本: 0004
|
||||||
|
* 日期: 2025-01-10
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 创建 Prizes 表...');
|
||||||
|
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS Prizes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activityId INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK(level IN ('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||||
|
description TEXT,
|
||||||
|
image TEXT,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
sortOrder INTEGER DEFAULT 0,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 删除 Prizes 表...');
|
||||||
|
await manager.executeSql('DROP TABLE IF EXISTS Prizes;');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加奖品来源字段...');
|
||||||
|
|
||||||
|
// 添加source字段,默认值为purchase
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Prizes ADD COLUMN source TEXT DEFAULT 'purchase';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 添加sponsor字段
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Prizes ADD COLUMN sponsor TEXT;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 为现有数据设置默认值
|
||||||
|
await manager.executeSql(`
|
||||||
|
UPDATE Prizes SET source = 'purchase' WHERE source IS NULL;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(' - 奖品来源字段添加完成');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 移除奖品来源字段...');
|
||||||
|
|
||||||
|
// SQLite不支持DROP COLUMN,需要重建表
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE Prizes_backup AS SELECT
|
||||||
|
id, activityId, name, level, description, image, quantity, sortOrder, createdAt, updatedAt
|
||||||
|
FROM Prizes;
|
||||||
|
`);
|
||||||
|
|
||||||
|
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||||
|
|
||||||
|
await manager.executeSql(`ALTER TABLE Prizes_backup RENAME TO Prizes;`);
|
||||||
|
|
||||||
|
console.log(' - 奖品来源字段移除完成');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加特等奖等级...');
|
||||||
|
|
||||||
|
// SQLite不支持直接修改CHECK约束,需要重建表
|
||||||
|
// 1. 创建新表结构(包含特等奖)
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE Prizes_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activityId INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||||
|
description TEXT,
|
||||||
|
image TEXT,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
sortOrder INTEGER DEFAULT 0,
|
||||||
|
source TEXT DEFAULT 'purchase',
|
||||||
|
sponsor TEXT,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
updatedAt DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. 复制数据
|
||||||
|
await manager.executeSql(`
|
||||||
|
INSERT INTO Prizes_new
|
||||||
|
SELECT * FROM Prizes;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. 删除旧表
|
||||||
|
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||||
|
|
||||||
|
// 4. 重命名新表
|
||||||
|
await manager.executeSql(`ALTER TABLE Prizes_new RENAME TO Prizes;`);
|
||||||
|
|
||||||
|
console.log(' - 特等奖等级添加完成');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 移除特等奖等级...');
|
||||||
|
|
||||||
|
// 1. 创建旧表结构(不包含特等奖)
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE Prizes_old (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activityId INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK(level IN ('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||||
|
description TEXT,
|
||||||
|
image TEXT,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
sortOrder INTEGER DEFAULT 0,
|
||||||
|
source TEXT DEFAULT 'purchase',
|
||||||
|
sponsor TEXT,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
updatedAt DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. 复制数据(排除特等奖)
|
||||||
|
await manager.executeSql(`
|
||||||
|
INSERT INTO Prizes_old
|
||||||
|
SELECT * FROM Prizes WHERE level != '特等奖';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. 删除新表
|
||||||
|
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||||
|
|
||||||
|
// 4. 重命名旧表
|
||||||
|
await manager.executeSql(`ALTER TABLE Prizes_old RENAME TO Prizes;`);
|
||||||
|
|
||||||
|
console.log(' - 特等奖等级移除完成');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// 添加管理员角色字段
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 Admins.role 字段...');
|
||||||
|
await manager.executeSql('ALTER TABLE Admins ADD COLUMN role TEXT DEFAULT "admin";');
|
||||||
|
console.log(' - 角色字段添加完成');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
await manager.executeSql('ALTER TABLE Admins DROP COLUMN role;');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// 添加四等奖等级
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加四等奖等级...');
|
||||||
|
|
||||||
|
// SQLite不支持直接修改CHECK约束,需要重建表
|
||||||
|
// 1. 创建新表结构(包含四等奖)
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE Prizes_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activityId INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼')),
|
||||||
|
description TEXT,
|
||||||
|
image TEXT,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
sortOrder INTEGER DEFAULT 0,
|
||||||
|
source TEXT DEFAULT 'purchase',
|
||||||
|
sponsor TEXT,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
updatedAt DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. 复制数据
|
||||||
|
await manager.executeSql(`
|
||||||
|
INSERT INTO Prizes_new
|
||||||
|
SELECT * FROM Prizes;
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. 删除旧表
|
||||||
|
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||||
|
|
||||||
|
// 4. 重命名新表
|
||||||
|
await manager.executeSql(`ALTER TABLE Prizes_new RENAME TO Prizes;`);
|
||||||
|
|
||||||
|
console.log(' - 四等奖等级添加完成');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 移除四等奖等级...');
|
||||||
|
|
||||||
|
// 1. 创建旧表结构(不包含四等奖)
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE Prizes_old (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activityId INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||||
|
description TEXT,
|
||||||
|
image TEXT,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
sortOrder INTEGER DEFAULT 0,
|
||||||
|
source TEXT DEFAULT 'purchase',
|
||||||
|
sponsor TEXT,
|
||||||
|
createdAt DATETIME NOT NULL,
|
||||||
|
updatedAt DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 2. 复制数据(排除四等奖)
|
||||||
|
await manager.executeSql(`
|
||||||
|
INSERT INTO Prizes_old
|
||||||
|
SELECT * FROM Prizes WHERE level != '四等奖';
|
||||||
|
`);
|
||||||
|
|
||||||
|
// 3. 删除新表
|
||||||
|
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||||
|
|
||||||
|
// 4. 重命名旧表
|
||||||
|
await manager.executeSql(`ALTER TABLE Prizes_old RENAME TO Prizes;`);
|
||||||
|
|
||||||
|
console.log(' - 四等奖等级移除完成');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* 迁移 0009: 添加进群状态字段
|
||||||
|
* 为 Registrations 表添加 groupStatus 字段
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 Registrations.groupStatus 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Registrations ADD COLUMN groupStatus INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 移除 Registrations.groupStatus 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Registrations DROP COLUMN groupStatus;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
# 数据库迁移系统
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本项目使用版本化的数据库迁移系统,支持增量更新和自动同步。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- ✅ 版本管理:每个迁移有唯一版本号
|
||||||
|
- ✅ 增量执行:只执行未执行的迁移
|
||||||
|
- ✅ 自动运行:服务器启动时自动执行迁移
|
||||||
|
- ✅ 幂等性:重复执行不会出错
|
||||||
|
- ✅ 迁移记录:记录所有已执行的迁移
|
||||||
|
|
||||||
|
## 迁移文件命名规范
|
||||||
|
|
||||||
|
```
|
||||||
|
XXXX-description.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- `XXXX`: 4位数字版本号(如 0001, 0002)
|
||||||
|
- `description`: 迁移描述(使用短横线分隔)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- `0001-add-danmaku-fields.js`
|
||||||
|
- `0002-add-user-avatar.js`
|
||||||
|
|
||||||
|
## 创建新迁移
|
||||||
|
|
||||||
|
### 1. 创建迁移文件
|
||||||
|
|
||||||
|
在 `backend/migrations/` 目录下创建新文件:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* 迁移: 添加用户头像字段
|
||||||
|
* 版本: 0002
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 avatar 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Users ADD COLUMN avatar TEXT;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
// 回滚逻辑(可选)
|
||||||
|
console.log(' - 回滚不支持');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 迁移方法
|
||||||
|
|
||||||
|
#### `up(manager)` - 升级
|
||||||
|
执行数据库变更的方法。
|
||||||
|
|
||||||
|
可用的 manager 方法:
|
||||||
|
- `executeSql(sql)`: 执行SQL语句
|
||||||
|
- `db`: 直接访问数据库连接
|
||||||
|
|
||||||
|
#### `down(manager)` - 回滚(可选)
|
||||||
|
回滚数据库变更的方法。
|
||||||
|
|
||||||
|
注意:SQLite 不支持 DROP COLUMN,回滚可能需要重建表。
|
||||||
|
|
||||||
|
## 执行迁移
|
||||||
|
|
||||||
|
### 自动执行(推荐)
|
||||||
|
|
||||||
|
服务器启动时会自动执行所有未执行的迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发环境
|
||||||
|
cd backend
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 生产环境(Docker)
|
||||||
|
docker-compose up -d
|
||||||
|
# 或
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动执行
|
||||||
|
|
||||||
|
#### 在容器内执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
migrate.bat
|
||||||
|
|
||||||
|
# Linux/Mac
|
||||||
|
chmod +x migrate.sh
|
||||||
|
./migrate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 直接在容器中执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 迁移状态
|
||||||
|
|
||||||
|
迁移执行记录保存在 `migrations` 表中:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM migrations;
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
```
|
||||||
|
id | version | name | executed_at
|
||||||
|
---+---------+-----------------------+---------------------
|
||||||
|
1 | 1 | add-danmaku-fields | 2025-01-09 10:30:00
|
||||||
|
2 | 2 | add-user-avatar | 2025-01-09 11:00:00
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 迁移失败怎么办?
|
||||||
|
|
||||||
|
A: 检查错误日志,修复问题后重新启动服务器。已执行的迁移不会重复执行。
|
||||||
|
|
||||||
|
### Q: 如何回滚迁移?
|
||||||
|
|
||||||
|
A: 目前不支持自动回滚。如需回滚,请手动修改数据库或创建新的迁移来撤销更改。
|
||||||
|
|
||||||
|
### Q: 可以修改已执行的迁移吗?
|
||||||
|
|
||||||
|
A: 不建议。已执行的迁移不应修改。如需更改,请创建新的迁移。
|
||||||
|
|
||||||
|
### Q: 迁移在什么时候执行?
|
||||||
|
|
||||||
|
A: 服务器启动时自动执行。也可以手动运行 `migrate.bat` 或 `migrate.sh`。
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **版本号递增**:新迁移的版本号应大于所有现有迁移
|
||||||
|
2. **描述清晰**:文件名和注释应清楚说明迁移目的
|
||||||
|
3. **测试迁移**:在开发环境测试后再部署到生产环境
|
||||||
|
4. **幂等性**:迁移应该可以安全地重复执行
|
||||||
|
5. **小步迭代**:每个迁移只做一件事,便于追踪和回滚
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### 添加新字段
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Activities ADD COLUMN featured INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建新表
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS Comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activityId INTEGER NOT NULL,
|
||||||
|
userId INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改数据
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
await manager.executeSql(`
|
||||||
|
UPDATE Activities SET status = 'completed' WHERE endTime < datetime('now');
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'data', 'database.sqlite');
|
||||||
|
const migrationsDir = __dirname;
|
||||||
|
|
||||||
|
class MigrationManager {
|
||||||
|
constructor() {
|
||||||
|
this.db = new sqlite3.Database(dbPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化迁移表
|
||||||
|
async initMigrationTable() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
version INTEGER NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取已执行的迁移版本
|
||||||
|
async getExecutedMigrations() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.all('SELECT version FROM migrations ORDER BY version', (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows.map(r => r.version));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录迁移执行
|
||||||
|
async recordMigration(version, name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.run(
|
||||||
|
'INSERT INTO migrations (version, name) VALUES (?, ?)',
|
||||||
|
[version, name],
|
||||||
|
(err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行SQL语句
|
||||||
|
async executeSql(sql) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.run(sql, (err) => {
|
||||||
|
if (err) {
|
||||||
|
// 忽略字段已存在的错误
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载所有迁移文件
|
||||||
|
loadMigrations() {
|
||||||
|
const files = fs.readdirSync(migrationsDir)
|
||||||
|
.filter(f => f.match(/^\d{4}-.*\.js$/) && f !== 'index.js')
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
return files.map(file => {
|
||||||
|
const version = parseInt(file.split('-')[0]);
|
||||||
|
const name = file.replace(/^\d{4}-/, '').replace('.js', '');
|
||||||
|
const migration = require(path.join(migrationsDir, file));
|
||||||
|
return { version, name, file, migration };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
async runMigrations() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 开始数据库迁移...\n');
|
||||||
|
|
||||||
|
// 初始化迁移表
|
||||||
|
await this.initMigrationTable();
|
||||||
|
|
||||||
|
// 获取已执行的迁移
|
||||||
|
const executed = await this.getExecutedMigrations();
|
||||||
|
console.log(`📊 已执行迁移: ${executed.length} 个`);
|
||||||
|
|
||||||
|
// 加载所有迁移文件
|
||||||
|
const migrations = this.loadMigrations();
|
||||||
|
console.log(`📁 发现迁移文件: ${migrations.length} 个\n`);
|
||||||
|
|
||||||
|
// 执行未执行的迁移
|
||||||
|
let count = 0;
|
||||||
|
for (const { version, name, file, migration } of migrations) {
|
||||||
|
if (!executed.includes(version)) {
|
||||||
|
console.log(`⏳ 执行迁移 [${version}] ${name}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migration.up(this);
|
||||||
|
await this.recordMigration(version, name);
|
||||||
|
console.log(`✅ 迁移 [${version}] ${name} 完成\n`);
|
||||||
|
count++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 迁移 [${version}] ${name} 失败:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`⏭️ 跳过已执行的迁移 [${version}] ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✨ 迁移完成!共执行 ${count} 个新迁移`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ 迁移失败:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果直接运行此文件
|
||||||
|
if (require.main === module) {
|
||||||
|
const manager = new MigrationManager();
|
||||||
|
manager.runMigrations()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MigrationManager;
|
||||||
@@ -38,6 +38,15 @@ const Activity = sequelize.define('Activity', {
|
|||||||
type: DataTypes.DECIMAL(10, 2),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
comment: '活动预算'
|
comment: '活动预算'
|
||||||
|
},
|
||||||
|
danmakuEnabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
comment: '弹幕开关'
|
||||||
|
},
|
||||||
|
danmakuContent: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
comment: '弹幕内容JSON'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,12 @@ const Admin = sequelize.define('Admin', {
|
|||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
allowNull: false
|
allowNull: false
|
||||||
},
|
},
|
||||||
name: DataTypes.STRING
|
name: DataTypes.STRING,
|
||||||
|
role: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
defaultValue: 'admin'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = Admin;
|
module.exports = Admin;
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('./db');
|
||||||
|
|
||||||
|
const Prize = sequelize.define('Prize', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
activityId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: DataTypes.ENUM('特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: DataTypes.TEXT,
|
||||||
|
image: DataTypes.STRING,
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 1
|
||||||
|
},
|
||||||
|
sortOrder: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '排序值,数字越小越靠前'
|
||||||
|
},
|
||||||
|
source: {
|
||||||
|
type: DataTypes.ENUM('member', 'sponsor', 'purchase'),
|
||||||
|
defaultValue: 'purchase',
|
||||||
|
comment: '来源: member-群友赞助, sponsor-赞助商提供, purchase-采购'
|
||||||
|
},
|
||||||
|
sponsor: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
comment: '赞助者名称(群友或赞助商)'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Prize;
|
||||||
@@ -34,7 +34,11 @@ const Registration = sequelize.define('Registration', {
|
|||||||
type: DataTypes.BOOLEAN,
|
type: DataTypes.BOOLEAN,
|
||||||
defaultValue: false
|
defaultValue: false
|
||||||
},
|
},
|
||||||
checkInTime: DataTypes.DATE
|
checkInTime: DataTypes.DATE,
|
||||||
|
groupStatus: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = Registration;
|
module.exports = Registration;
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ const Sponsor = sequelize.define('Sponsor', {
|
|||||||
type: DataTypes.ENUM('title', 'sponsor'),
|
type: DataTypes.ENUM('title', 'sponsor'),
|
||||||
defaultValue: 'sponsor'
|
defaultValue: 'sponsor'
|
||||||
},
|
},
|
||||||
link: DataTypes.STRING
|
link: DataTypes.STRING,
|
||||||
|
sortOrder: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '排序值,数字越小越靠前'
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = Sponsor;
|
module.exports = Sponsor;
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('./db');
|
||||||
|
|
||||||
|
const VenueImage = sequelize.define('VenueImage', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
activityId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
sortOrder: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '排序值,数字越小越靠前'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = VenueImage;
|
||||||
+13
-10
@@ -1,14 +1,17 @@
|
|||||||
const { Sequelize } = require('sequelize');
|
const { Sequelize } = require('sequelize');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
const sequelize = new Sequelize(
|
// 确保data目录存在
|
||||||
process.env.DB_NAME || 'quanyoumi',
|
const dataDir = path.join(__dirname, '../data');
|
||||||
process.env.DB_USER || 'root',
|
if (!fs.existsSync(dataDir)) {
|
||||||
process.env.DB_PASSWORD || '',
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
{
|
}
|
||||||
host: process.env.DB_HOST || 'localhost',
|
|
||||||
dialect: 'mysql',
|
const sequelize = new Sequelize({
|
||||||
logging: false
|
dialect: 'sqlite',
|
||||||
}
|
storage: path.join(dataDir, 'database.sqlite'),
|
||||||
);
|
logging: false
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = sequelize;
|
module.exports = sequelize;
|
||||||
|
|||||||
+10
-3
@@ -4,11 +4,16 @@ const Registration = require('./Registration');
|
|||||||
const Expense = require('./Expense');
|
const Expense = require('./Expense');
|
||||||
const Photo = require('./Photo');
|
const Photo = require('./Photo');
|
||||||
const Sponsor = require('./Sponsor');
|
const Sponsor = require('./Sponsor');
|
||||||
|
const Prize = require('./Prize');
|
||||||
const Admin = require('./Admin');
|
const Admin = require('./Admin');
|
||||||
|
const VenueImage = require('./VenueImage');
|
||||||
|
|
||||||
// 同步数据库
|
// 同步数据库
|
||||||
sequelize.sync({ alter: true }).then(() => {
|
// 使用 force: false 避免 SQLite alter 表时的约束冲突问题
|
||||||
console.log('数据库同步完成');
|
sequelize.sync({ force: false }).then(() => {
|
||||||
|
console.log('✓ 数据库同步完成');
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('✗ 数据库同步失败:', err.message);
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
@@ -18,5 +23,7 @@ module.exports = {
|
|||||||
Expense,
|
Expense,
|
||||||
Photo,
|
Photo,
|
||||||
Sponsor,
|
Sponsor,
|
||||||
Admin
|
Prize,
|
||||||
|
Admin,
|
||||||
|
VenueImage
|
||||||
};
|
};
|
||||||
|
|||||||
Generated
+1442
-335
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,15 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"better-sqlite3": "^11.10.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"minio": "^8.0.7",
|
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"mysql2": "^3.6.0",
|
|
||||||
"sequelize": "^6.33.0",
|
"sequelize": "^6.33.0",
|
||||||
"uuid": "^9.0.1"
|
"sql.js": "^1.14.1",
|
||||||
|
"sqlite3": "^5.1.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.1"
|
"nodemon": "^3.0.1"
|
||||||
|
|||||||
+64
-11
@@ -1,14 +1,18 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { Activity, Photo, Sponsor, Expense, Registration } = require('../models');
|
const { Activity, Photo, Sponsor, Expense, Registration, Prize, VenueImage } = require('../models');
|
||||||
const { upload, uploadFilesToMinio } = require('../middleware/upload');
|
const { upload, getFileUrls } = require('../middleware/upload');
|
||||||
|
|
||||||
// 获取活动列表
|
// 获取活动列表
|
||||||
router.get('/activities', async (req, res) => {
|
router.get('/activities', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status } = req.query;
|
const { status } = req.query;
|
||||||
const where = status ? { status } : {};
|
const where = status ? { status } : {};
|
||||||
const activities = await Activity.findAll({ where, order: [['startTime', 'DESC']] });
|
const activities = await Activity.findAll({
|
||||||
|
where,
|
||||||
|
order: [['startTime', 'DESC']],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
res.json(activities);
|
res.json(activities);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -18,15 +22,42 @@ router.get('/activities', async (req, res) => {
|
|||||||
// 获取活动详情
|
// 获取活动详情
|
||||||
router.get('/activities/:id', async (req, res) => {
|
router.get('/activities/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const activity = await Activity.findByPk(req.params.id);
|
const activity = await Activity.findByPk(req.params.id, { raw: true });
|
||||||
|
if (!activity) {
|
||||||
|
return res.status(404).json({ error: '活动不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
// 只返回已审核通过的照片
|
// 只返回已审核通过的照片
|
||||||
const photos = await Photo.findAll({
|
const photos = await Photo.findAll({
|
||||||
where: { activityId: req.params.id, status: 'approved' }
|
where: { activityId: req.params.id, status: 'approved' },
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
const sponsors = await Sponsor.findAll({
|
||||||
|
where: { activityId: req.params.id },
|
||||||
|
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
const expenses = await Expense.findAll({
|
||||||
|
where: { activityId: req.params.id },
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
// 手动解析JSON字段
|
||||||
|
const parsedExpenses = expenses.map(expense => ({
|
||||||
|
...expense,
|
||||||
|
media: typeof expense.media === 'string' ? JSON.parse(expense.media || '[]') : (expense.media || [])
|
||||||
|
}));
|
||||||
|
const prizes = await Prize.findAll({
|
||||||
|
where: { activityId: req.params.id },
|
||||||
|
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
const venueImages = await VenueImage.findAll({
|
||||||
|
where: { activityId: req.params.id },
|
||||||
|
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
|
||||||
|
raw: true
|
||||||
});
|
});
|
||||||
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 });
|
res.json({ activity, photos, sponsors, expenses: parsedExpenses, prizes, venueImages: venueImages.map(img => img.image).filter(Boolean) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -35,8 +66,30 @@ router.get('/activities/:id', async (req, res) => {
|
|||||||
// 获取活动经费(公开接口)
|
// 获取活动经费(公开接口)
|
||||||
router.get('/activities/:id/expenses', async (req, res) => {
|
router.get('/activities/:id/expenses', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
|
const expenses = await Expense.findAll({
|
||||||
res.json(expenses);
|
where: { activityId: req.params.id },
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
// 手动解析JSON字段
|
||||||
|
const parsedExpenses = expenses.map(expense => ({
|
||||||
|
...expense,
|
||||||
|
media: typeof expense.media === 'string' ? JSON.parse(expense.media || '[]') : (expense.media || [])
|
||||||
|
}));
|
||||||
|
res.json(parsedExpenses);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取场地图片列表
|
||||||
|
router.get('/activities/:id/venue-images', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const images = await VenueImage.findAll({
|
||||||
|
where: { activityId: req.params.id },
|
||||||
|
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
res.json(images.map(img => img.image).filter(Boolean));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -46,7 +99,7 @@ router.get('/activities/:id/expenses', async (req, res) => {
|
|||||||
router.post('/activities/:id/photos', upload.array('photos', 10), async (req, res) => {
|
router.post('/activities/:id/photos', upload.array('photos', 10), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { uploadedBy } = req.body;
|
const { uploadedBy } = req.body;
|
||||||
const uploaded = await uploadFilesToMinio(req.files);
|
const uploaded = getFileUrls(req.files);
|
||||||
const photos = uploaded.map(file => ({
|
const photos = uploaded.map(file => ({
|
||||||
activityId: req.params.id,
|
activityId: req.params.id,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
|
|||||||
+268
-19
@@ -3,9 +3,11 @@ const router = express.Router();
|
|||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const auth = require('../middleware/auth');
|
const auth = require('../middleware/auth');
|
||||||
const { upload, uploadFilesToMinio } = require('../middleware/upload');
|
const authSuper = require('../middleware/authSuper');
|
||||||
const { deleteFile } = require('../utils/minio');
|
const { upload, getFileUrls } = require('../middleware/upload');
|
||||||
const { Admin, Activity, Registration, Expense, Photo, Sponsor } = require('../models');
|
const { Admin, Activity, Registration, Expense, Photo, Sponsor, Prize } = require('../models');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
// 管理员登录
|
// 管理员登录
|
||||||
router.post('/login', async (req, res) => {
|
router.post('/login', async (req, res) => {
|
||||||
@@ -16,7 +18,146 @@ router.post('/login', async (req, res) => {
|
|||||||
return res.status(401).json({ error: '用户名或密码错误' });
|
return res.status(401).json({ error: '用户名或密码错误' });
|
||||||
}
|
}
|
||||||
const token = jwt.sign({ id: admin.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
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 } });
|
res.json({
|
||||||
|
token,
|
||||||
|
admin: {
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
name: admin.name,
|
||||||
|
role: admin.role
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Token续期
|
||||||
|
router.post('/refresh-token', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const admin = await Admin.findByPk(req.adminId);
|
||||||
|
if (!admin) {
|
||||||
|
return res.status(401).json({ error: '用户不存在' });
|
||||||
|
}
|
||||||
|
const token = jwt.sign({ id: admin.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
res.json({ token });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== 管理员管理API(仅超级管理员) ==========
|
||||||
|
|
||||||
|
// 获取管理员列表
|
||||||
|
router.get('/admins', authSuper, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const admins = await Admin.findAll({
|
||||||
|
attributes: ['id', 'username', 'name', 'role', 'createdAt'],
|
||||||
|
order: [['createdAt', 'ASC']],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
res.json(admins);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 创建管理员
|
||||||
|
router.post('/admins', authSuper, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password, name, role } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await Admin.findOne({ where: { username } });
|
||||||
|
if (existing) {
|
||||||
|
return res.status(400).json({ error: '用户名已存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = bcrypt.hashSync(password, 10);
|
||||||
|
const admin = await Admin.create({
|
||||||
|
username,
|
||||||
|
password: hashedPassword,
|
||||||
|
name: name || username,
|
||||||
|
role: role || 'admin'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
id: admin.id,
|
||||||
|
username: admin.username,
|
||||||
|
name: admin.name,
|
||||||
|
role: admin.role
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 修改管理员信息
|
||||||
|
router.put('/admins/:id', authSuper, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, role } = req.body;
|
||||||
|
const admin = await Admin.findByPk(req.params.id);
|
||||||
|
|
||||||
|
if (!admin) {
|
||||||
|
return res.status(404).json({ error: '管理员不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData = {};
|
||||||
|
if (name !== undefined) updateData.name = name;
|
||||||
|
if (role !== undefined) updateData.role = role;
|
||||||
|
|
||||||
|
await Admin.update(updateData, { where: { id: req.params.id } });
|
||||||
|
const updated = await Admin.findByPk(req.params.id, {
|
||||||
|
attributes: ['id', 'username', 'name', 'role']
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(updated);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 修改管理员密码
|
||||||
|
router.put('/admins/:id/password', authSuper, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { newPassword } = req.body;
|
||||||
|
|
||||||
|
if (!newPassword) {
|
||||||
|
return res.status(400).json({ error: '新密码不能为空' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const admin = await Admin.findByPk(req.params.id);
|
||||||
|
if (!admin) {
|
||||||
|
return res.status(404).json({ error: '管理员不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashedPassword = bcrypt.hashSync(newPassword, 10);
|
||||||
|
await Admin.update({ password: hashedPassword }, { where: { id: req.params.id } });
|
||||||
|
|
||||||
|
res.json({ message: '密码修改成功' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除管理员
|
||||||
|
router.delete('/admins/:id', authSuper, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const admin = await Admin.findByPk(req.params.id);
|
||||||
|
if (!admin) {
|
||||||
|
return res.status(404).json({ error: '管理员不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止删除超级管理员
|
||||||
|
if (admin.role === 'super') {
|
||||||
|
return res.status(403).json({ error: '不能删除超级管理员' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await Admin.destroy({ where: { id: req.params.id } });
|
||||||
|
res.json({ message: '删除成功' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -46,10 +187,12 @@ router.put('/activities/:id', auth, async (req, res) => {
|
|||||||
// 审核报名
|
// 审核报名
|
||||||
router.put('/registrations/:id', auth, async (req, res) => {
|
router.put('/registrations/:id', auth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { status, peopleCount } = req.body;
|
const { status, peopleCount, carNumber, groupStatus } = req.body;
|
||||||
const updateData = {};
|
const updateData = {};
|
||||||
if (status) updateData.status = status;
|
if (status) updateData.status = status;
|
||||||
if (peopleCount !== undefined) updateData.peopleCount = peopleCount;
|
if (peopleCount !== undefined) updateData.peopleCount = peopleCount;
|
||||||
|
if (carNumber !== undefined) updateData.carNumber = carNumber;
|
||||||
|
if (groupStatus !== undefined) updateData.groupStatus = groupStatus;
|
||||||
|
|
||||||
await Registration.update(updateData, { where: { id: req.params.id } });
|
await Registration.update(updateData, { where: { id: req.params.id } });
|
||||||
const registration = await Registration.findByPk(req.params.id);
|
const registration = await Registration.findByPk(req.params.id);
|
||||||
@@ -80,6 +223,26 @@ router.put('/registrations/:id/checkin', auth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 进群/取消进群
|
||||||
|
router.put('/registrations/:id/group', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const registration = await Registration.findByPk(req.params.id);
|
||||||
|
if (!registration) {
|
||||||
|
return res.status(404).json({ error: '报名信息不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const newStatus = !registration.groupStatus;
|
||||||
|
await Registration.update({
|
||||||
|
groupStatus: newStatus
|
||||||
|
}, { 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) => {
|
router.get('/registrations', auth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -92,6 +255,16 @@ router.get('/registrations', auth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 删除报名
|
||||||
|
router.delete('/registrations/:id', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await Registration.destroy({ where: { id: req.params.id } });
|
||||||
|
res.json({ message: '删除成功' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 上传经费明细
|
// 上传经费明细
|
||||||
router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
|
router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -100,7 +273,7 @@ router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
|
|||||||
let media = [];
|
let media = [];
|
||||||
|
|
||||||
if (req.files && req.files.length > 0) {
|
if (req.files && req.files.length > 0) {
|
||||||
const uploaded = await uploadFilesToMinio(req.files);
|
const uploaded = getFileUrls(req.files);
|
||||||
media = uploaded.map(file => ({
|
media = uploaded.map(file => ({
|
||||||
url: file.url,
|
url: file.url,
|
||||||
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
|
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
|
||||||
@@ -127,8 +300,13 @@ router.get('/expenses', auth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const { activityId } = req.query;
|
const { activityId } = req.query;
|
||||||
const where = activityId ? { activityId } : {};
|
const where = activityId ? { activityId } : {};
|
||||||
const expenses = await Expense.findAll({ where });
|
const expenses = await Expense.findAll({ where, raw: true });
|
||||||
res.json(expenses);
|
// 手动解析JSON字段
|
||||||
|
const parsedExpenses = expenses.map(expense => ({
|
||||||
|
...expense,
|
||||||
|
media: typeof expense.media === 'string' ? JSON.parse(expense.media || '[]') : (expense.media || [])
|
||||||
|
}));
|
||||||
|
res.json(parsedExpenses);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -152,7 +330,7 @@ router.put('/expenses/:id', auth, upload.array('media', 5), async (req, res) =>
|
|||||||
|
|
||||||
// 处理新上传的媒体
|
// 处理新上传的媒体
|
||||||
if (req.files && req.files.length > 0) {
|
if (req.files && req.files.length > 0) {
|
||||||
const uploaded = await uploadFilesToMinio(req.files);
|
const uploaded = getFileUrls(req.files);
|
||||||
const newMedia = uploaded.map(file => ({
|
const newMedia = uploaded.map(file => ({
|
||||||
url: file.url,
|
url: file.url,
|
||||||
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
|
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
|
||||||
@@ -184,7 +362,7 @@ router.delete('/expenses/:id', auth, async (req, res) => {
|
|||||||
router.post('/photos', auth, upload.array('photos', 10), async (req, res) => {
|
router.post('/photos', auth, upload.array('photos', 10), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { activityId } = req.body;
|
const { activityId } = req.body;
|
||||||
const uploaded = await uploadFilesToMinio(req.files);
|
const uploaded = getFileUrls(req.files);
|
||||||
const photos = uploaded.map(file => ({
|
const photos = uploaded.map(file => ({
|
||||||
activityId,
|
activityId,
|
||||||
url: file.url,
|
url: file.url,
|
||||||
@@ -231,11 +409,12 @@ router.delete('/photos/:id', auth, async (req, res) => {
|
|||||||
return res.status(404).json({ error: '照片不存在' });
|
return res.status(404).json({ error: '照片不存在' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 从URL提取文件名
|
// 删除本地文件
|
||||||
const filename = photo.url.split('/').pop();
|
const filename = photo.url.split('/').pop();
|
||||||
|
const filePath = path.join(__dirname, '../uploads', filename);
|
||||||
// 从MinIO删除文件
|
if (fs.existsSync(filePath)) {
|
||||||
await deleteFile(filename);
|
fs.unlinkSync(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
// 从数据库删除记录
|
// 从数据库删除记录
|
||||||
await photo.destroy();
|
await photo.destroy();
|
||||||
@@ -249,13 +428,20 @@ router.delete('/photos/:id', auth, async (req, res) => {
|
|||||||
// 添加赞助商
|
// 添加赞助商
|
||||||
router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
|
router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { activityId, name, type, link } = req.body;
|
const { activityId, name, type, link, sortOrder } = req.body;
|
||||||
let logo = null;
|
let logo = null;
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
const [uploaded] = await uploadFilesToMinio(req.file);
|
const [uploaded] = getFileUrls(req.file);
|
||||||
logo = uploaded.url;
|
logo = uploaded.url;
|
||||||
}
|
}
|
||||||
const sponsor = await Sponsor.create({ activityId, name, logo, type, link });
|
const sponsor = await Sponsor.create({
|
||||||
|
activityId,
|
||||||
|
name,
|
||||||
|
logo,
|
||||||
|
type,
|
||||||
|
link,
|
||||||
|
sortOrder: sortOrder || 0
|
||||||
|
});
|
||||||
res.json(sponsor);
|
res.json(sponsor);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -265,12 +451,18 @@ router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
|
|||||||
// 更新赞助商
|
// 更新赞助商
|
||||||
router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => {
|
router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { name, type, link } = req.body;
|
const { name, type, link, sortOrder, removeLogo } = req.body;
|
||||||
const updateData = { name, type, link };
|
const updateData = { name, type, link };
|
||||||
|
|
||||||
|
if (sortOrder !== undefined) {
|
||||||
|
updateData.sortOrder = sortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
const [uploaded] = await uploadFilesToMinio(req.file);
|
const [uploaded] = getFileUrls(req.file);
|
||||||
updateData.logo = uploaded.url;
|
updateData.logo = uploaded.url;
|
||||||
|
} else if (removeLogo === 'true') {
|
||||||
|
updateData.logo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Sponsor.update(updateData, { where: { id: req.params.id } });
|
await Sponsor.update(updateData, { where: { id: req.params.id } });
|
||||||
@@ -291,4 +483,61 @@ router.delete('/sponsors/:id', auth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 添加奖品
|
||||||
|
router.post('/prizes', auth, upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { activityId, name, level, description, quantity, sortOrder, source, sponsor } = req.body;
|
||||||
|
let image = null;
|
||||||
|
if (req.file) {
|
||||||
|
const [uploaded] = getFileUrls([req.file]);
|
||||||
|
image = uploaded.url;
|
||||||
|
}
|
||||||
|
const prize = await Prize.create({
|
||||||
|
activityId,
|
||||||
|
name,
|
||||||
|
level,
|
||||||
|
description,
|
||||||
|
image,
|
||||||
|
quantity: quantity || 1,
|
||||||
|
sortOrder: sortOrder || 0,
|
||||||
|
source: source || 'purchase',
|
||||||
|
sponsor: sponsor || null
|
||||||
|
});
|
||||||
|
res.json(prize);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新奖品
|
||||||
|
router.put('/prizes/:id', auth, upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, level, description, quantity, sortOrder, source, sponsor, removeImage } = req.body;
|
||||||
|
const updateData = { name, level, description, quantity, sortOrder, source, sponsor };
|
||||||
|
|
||||||
|
if (req.file) {
|
||||||
|
const [uploaded] = getFileUrls([req.file]);
|
||||||
|
updateData.image = uploaded.url;
|
||||||
|
} else if (removeImage === 'true') {
|
||||||
|
updateData.image = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Prize.update(updateData, { where: { id: req.params.id } });
|
||||||
|
const prize = await Prize.findByPk(req.params.id);
|
||||||
|
res.json(prize);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除奖品
|
||||||
|
router.delete('/prizes/:id', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await Prize.destroy({ where: { id: req.params.id } });
|
||||||
|
res.json({ message: '删除成功' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ router.post('/registrations', async (req, res) => {
|
|||||||
activityId, name, phone, carModel, carNumber, note
|
activityId, name, phone, carModel, carNumber, note
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(registration);
|
res.json(registration.toJSON());
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ router.post('/registrations', async (req, res) => {
|
|||||||
// 查询报名状态
|
// 查询报名状态
|
||||||
router.get('/registrations/:id', async (req, res) => {
|
router.get('/registrations/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const registration = await Registration.findByPk(req.params.id);
|
const registration = await Registration.findByPk(req.params.id, { raw: true });
|
||||||
res.json(registration);
|
res.json(registration);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ error: error.message });
|
res.status(500).json({ error: error.message });
|
||||||
@@ -36,7 +36,8 @@ router.get('/registrations/:id', async (req, res) => {
|
|||||||
router.get('/activities/:id/registrations', async (req, res) => {
|
router.get('/activities/:id/registrations', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const registrations = await Registration.findAll({
|
const registrations = await Registration.findAll({
|
||||||
where: { activityId: req.params.id, status: 'approved' }
|
where: { activityId: req.params.id, status: 'approved' },
|
||||||
|
raw: true
|
||||||
});
|
});
|
||||||
res.json(registrations);
|
res.json(registrations);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const auth = require('../middleware/auth');
|
||||||
|
const { upload, getFileUrls } = require('../middleware/upload');
|
||||||
|
const { VenueImage } = require('../models');
|
||||||
|
|
||||||
|
// 获取场地图片列表
|
||||||
|
router.get('/venue-images', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { activityId } = req.query;
|
||||||
|
const images = await VenueImage.findAll({
|
||||||
|
where: { activityId },
|
||||||
|
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
|
||||||
|
raw: true
|
||||||
|
});
|
||||||
|
res.json(images);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 上传场地图片
|
||||||
|
router.post('/venue-images', auth, upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { activityId, sortOrder } = req.body;
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: '请选择图片' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [uploaded] = getFileUrls([req.file]);
|
||||||
|
const image = await VenueImage.create({
|
||||||
|
activityId,
|
||||||
|
image: uploaded.url,
|
||||||
|
sortOrder: sortOrder || 0
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(image);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 更新场地图片排序
|
||||||
|
router.put('/venue-images/:id', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { sortOrder } = req.body;
|
||||||
|
await VenueImage.update({ sortOrder }, { where: { id: req.params.id } });
|
||||||
|
const image = await VenueImage.findByPk(req.params.id);
|
||||||
|
res.json(image);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 删除场地图片
|
||||||
|
router.delete('/venue-images/:id', auth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
await VenueImage.destroy({ where: { id: req.params.id } });
|
||||||
|
res.json({ message: '删除成功' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+24
-6
@@ -9,10 +9,14 @@ const app = express();
|
|||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// 静态文件服务 - 提供上传的文件
|
||||||
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||||
|
|
||||||
// API 路由
|
// API 路由
|
||||||
app.use('/api', require('./routes/activity'));
|
app.use('/api', require('./routes/activity'));
|
||||||
app.use('/api', require('./routes/registration'));
|
app.use('/api', require('./routes/registration'));
|
||||||
app.use('/api/admin', require('./routes/admin'));
|
app.use('/api/admin', require('./routes/admin'));
|
||||||
|
app.use('/api/admin', require('./routes/venue'));
|
||||||
|
|
||||||
// 生产环境下提供前端静态文件
|
// 生产环境下提供前端静态文件
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@@ -26,11 +30,25 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`服务器运行在 http://localhost:${PORT}`);
|
// 启动服务器前运行数据库迁移
|
||||||
if (process.env.NODE_ENV === 'production') {
|
const MigrationManager = require('./migrations/index');
|
||||||
console.log('运行模式: 生产环境(前后端一体)');
|
const runMigrations = async () => {
|
||||||
} else {
|
try {
|
||||||
console.log('运行模式: 开发环境(仅后端API)');
|
const manager = new MigrationManager();
|
||||||
|
await manager.runMigrations();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('数据库迁移失败,但服务器将继续启动');
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runMigrations().then(() => {
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`服务器运行在 http://localhost:${PORT}`);
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
console.log('运行模式: 生产环境(前后端一体)');
|
||||||
|
} else {
|
||||||
|
console.log('运行模式: 开发环境(仅后端API)');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
@echo off
|
@echo off
|
||||||
REM 泉友米车友会 - Docker 构建脚本 (Windows)
|
REM 泉友米车友会 - Docker 构建脚本 (Windows)
|
||||||
REM 版本: 0.0.1
|
|
||||||
|
REM ==========================================
|
||||||
|
REM 版本号配置(修改这里即可)
|
||||||
|
REM ==========================================
|
||||||
|
set VERSION_FILE=.version
|
||||||
|
set MAJOR=0
|
||||||
|
set MINOR=0
|
||||||
|
|
||||||
|
REM 读取或初始化补丁版本号
|
||||||
|
if exist %VERSION_FILE% (
|
||||||
|
set /p PATCH=<%VERSION_FILE%
|
||||||
|
) else (
|
||||||
|
set PATCH=1
|
||||||
|
)
|
||||||
|
|
||||||
|
REM 组合完整版本号
|
||||||
|
set VERSION=%MAJOR%.%MINOR%.%PATCH%
|
||||||
|
|
||||||
echo ==========================================
|
echo ==========================================
|
||||||
echo 泉友米车友会 Docker 镜像构建
|
echo 泉友米车友会 Docker 镜像构建
|
||||||
echo 版本: 0.0.1
|
echo 版本: %VERSION%
|
||||||
echo ==========================================
|
echo ==========================================
|
||||||
|
|
||||||
REM 检查 Docker 是否安装
|
REM 检查 Docker 是否安装
|
||||||
@@ -17,7 +33,7 @@ if errorlevel 1 (
|
|||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo 开始构建 Docker 镜像...
|
echo 开始构建 Docker 镜像...
|
||||||
docker build -t quanyoumi-app:0.0.1 -t quanyoumi-app:latest .
|
docker build -t quanyoumi-app:%VERSION% -t quanyoumi-app:latest .
|
||||||
|
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
echo.
|
echo.
|
||||||
@@ -26,23 +42,30 @@ if errorlevel 1 (
|
|||||||
exit /b 1
|
exit /b 1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
REM 构建成功后自动递增版本号
|
||||||
|
set /a NEXT_PATCH=%PATCH%+1
|
||||||
|
echo %NEXT_PATCH%>%VERSION_FILE%
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ==========================================
|
echo ==========================================
|
||||||
echo 构建完成!
|
echo 构建完成!版本号已更新
|
||||||
echo ==========================================
|
echo ==========================================
|
||||||
echo.
|
echo.
|
||||||
|
echo 当前版本: %VERSION%
|
||||||
|
echo 下次版本: %MAJOR%.%MINOR%.%NEXT_PATCH%
|
||||||
|
echo.
|
||||||
echo 镜像信息:
|
echo 镜像信息:
|
||||||
docker images | findstr quanyoumi-app
|
docker images | findstr quanyoumi-app
|
||||||
echo.
|
echo.
|
||||||
|
|
||||||
REM 标记镜像到阿里云仓库
|
REM 标记镜像到阿里云仓库
|
||||||
echo 正在标记镜像到阿里云仓库...
|
echo 正在标记镜像到阿里云仓库...
|
||||||
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
docker tag quanyoumi-app:%VERSION% registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
|
||||||
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
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:%VERSION%
|
||||||
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||||
|
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
@@ -58,12 +81,17 @@ echo ==========================================
|
|||||||
echo 推送完成!
|
echo 推送完成!
|
||||||
echo ==========================================
|
echo ==========================================
|
||||||
echo.
|
echo.
|
||||||
|
echo 当前版本: %VERSION%
|
||||||
|
echo 下次版本: %MAJOR%.%MINOR%.%NEXT_PATCH%
|
||||||
|
echo.
|
||||||
echo 镜像地址:
|
echo 镜像地址:
|
||||||
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
|
||||||
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||||
echo.
|
echo.
|
||||||
echo 在 NAS 上使用:
|
echo 在 NAS 上使用:
|
||||||
echo docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
echo docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
|
||||||
echo docker-compose up -d
|
echo docker-compose up -d
|
||||||
echo.
|
echo.
|
||||||
|
echo 提示: 如需修改主版本号或次版本号,请编辑 build.bat 文件顶部的 MAJOR 和 MINOR 变量
|
||||||
|
echo.
|
||||||
pause
|
pause
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# 泉友米车友会 - Docker 构建脚本
|
# 泉友米车友会 - Docker 构建脚本
|
||||||
# 版本: 0.0.1
|
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# ==========================================
|
||||||
|
# 版本号配置(修改这里即可)
|
||||||
|
# ==========================================
|
||||||
|
VERSION_FILE=".version"
|
||||||
|
MAJOR=0
|
||||||
|
MINOR=0
|
||||||
|
|
||||||
|
# 读取或初始化补丁版本号
|
||||||
|
if [ -f "$VERSION_FILE" ]; then
|
||||||
|
PATCH=$(cat "$VERSION_FILE")
|
||||||
|
else
|
||||||
|
PATCH=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 组合完整版本号
|
||||||
|
VERSION="$MAJOR.$MINOR.$PATCH"
|
||||||
|
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "泉友米车友会 Docker 镜像构建"
|
echo "泉友米车友会 Docker 镜像构建"
|
||||||
echo "版本: 0.0.1"
|
echo "版本: $VERSION"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|
||||||
# 检查 Docker 是否安装
|
# 检查 Docker 是否安装
|
||||||
@@ -19,25 +35,32 @@ fi
|
|||||||
# 构建镜像
|
# 构建镜像
|
||||||
echo ""
|
echo ""
|
||||||
echo "开始构建 Docker 镜像..."
|
echo "开始构建 Docker 镜像..."
|
||||||
docker build -t quanyoumi-app:0.0.1 -t quanyoumi-app:latest .
|
docker build -t quanyoumi-app:$VERSION -t quanyoumi-app:latest .
|
||||||
|
|
||||||
|
# 构建成功后自动递增版本号
|
||||||
|
NEXT_PATCH=$((PATCH + 1))
|
||||||
|
echo $NEXT_PATCH > "$VERSION_FILE"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo "构建完成!"
|
echo "构建完成!版本号已更新"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "当前版本: $VERSION"
|
||||||
|
echo "下次版本: $MAJOR.$MINOR.$NEXT_PATCH"
|
||||||
|
echo ""
|
||||||
echo "镜像信息:"
|
echo "镜像信息:"
|
||||||
docker images | grep quanyoumi-app
|
docker images | grep quanyoumi-app
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 标记镜像到阿里云仓库
|
# 标记镜像到阿里云仓库
|
||||||
echo "正在标记镜像到阿里云仓库..."
|
echo "正在标记镜像到阿里云仓库..."
|
||||||
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
docker tag quanyoumi-app:$VERSION registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION
|
||||||
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
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:$VERSION
|
||||||
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -45,11 +68,16 @@ echo "=========================================="
|
|||||||
echo "推送完成!"
|
echo "推送完成!"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "当前版本: $VERSION"
|
||||||
|
echo "下次版本: $MAJOR.$MINOR.$NEXT_PATCH"
|
||||||
|
echo ""
|
||||||
echo "镜像地址:"
|
echo "镜像地址:"
|
||||||
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1"
|
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION"
|
||||||
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest"
|
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest"
|
||||||
echo ""
|
echo ""
|
||||||
echo "在 NAS 上使用:"
|
echo "在 NAS 上使用:"
|
||||||
echo " docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1"
|
echo " docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION"
|
||||||
echo " docker-compose up -d"
|
echo " docker-compose up -d"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo "提示: 如需修改主版本号或次版本号,请编辑 build.sh 文件顶部的 MAJOR 和 MINOR 变量"
|
||||||
|
echo ""
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
@echo off
|
||||||
|
echo ==========================================
|
||||||
|
echo 清理数据库文件
|
||||||
|
echo ==========================================
|
||||||
|
|
||||||
|
echo 停止容器...
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo 删除数据库文件...
|
||||||
|
if exist backend\data\database.sqlite (
|
||||||
|
del /f backend\data\database.sqlite
|
||||||
|
echo 数据库文件已删除
|
||||||
|
) else (
|
||||||
|
echo 数据库文件不存在
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo 清理完成!现在可以运行 start.bat 重新启动
|
||||||
|
pause
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
-- 添加签到功能字段
|
|
||||||
ALTER TABLE Registrations
|
|
||||||
ADD COLUMN checkInStatus BOOLEAN DEFAULT false COMMENT '是否已签到',
|
|
||||||
ADD COLUMN checkInTime DATETIME NULL COMMENT '签到时间';
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
-- 创建数据库
|
|
||||||
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
|
|
||||||
);
|
|
||||||
+3
-63
@@ -3,7 +3,7 @@ version: '3.8'
|
|||||||
services:
|
services:
|
||||||
# 应用服务(前端+后端)
|
# 应用服务(前端+后端)
|
||||||
app:
|
app:
|
||||||
image: registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
image: registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.2
|
||||||
container_name: quanyoumi-app
|
container_name: quanyoumi-app
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
@@ -11,28 +11,14 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=3000
|
- PORT=3000
|
||||||
# 数据库配置
|
|
||||||
- DB_HOST=db
|
|
||||||
- DB_PORT=3306
|
|
||||||
- DB_USER=root
|
|
||||||
- DB_PASSWORD=quanyoumi2024
|
|
||||||
- DB_NAME=quanyoumi
|
|
||||||
# JWT 密钥(请修改为随机字符串)
|
# JWT 密钥(请修改为随机字符串)
|
||||||
- JWT_SECRET=quanyoumi_jwt_secret_change_this_in_production_2024
|
- JWT_SECRET=quanyoumi_jwt_secret_change_this_in_production_2024
|
||||||
# 高德地图配置(必填)
|
# 高德地图配置(必填)
|
||||||
- AMAP_KEY=fd5b77136ba34fe6b4f0f86dca7782d9
|
- AMAP_KEY=fd5b77136ba34fe6b4f0f86dca7782d9
|
||||||
- AMAP_SECURITY_CODE=7f61638e5635d944a534ff747dc615ac
|
- AMAP_SECURITY_CODE=7f61638e5635d944a534ff747dc615ac
|
||||||
# MinIO 配置
|
|
||||||
- MINIO_ENDPOINT=minio
|
|
||||||
- MINIO_PORT=9000
|
|
||||||
- MINIO_ACCESS_KEY=minioadmin
|
|
||||||
- MINIO_SECRET_KEY=minioadmin123
|
|
||||||
- MINIO_BUCKET=quanyoumi
|
|
||||||
volumes:
|
volumes:
|
||||||
|
- app-data:/app/data
|
||||||
- app-uploads:/app/uploads
|
- app-uploads:/app/uploads
|
||||||
depends_on:
|
|
||||||
- db
|
|
||||||
- minio
|
|
||||||
networks:
|
networks:
|
||||||
- quanyoumi-network
|
- quanyoumi-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -42,54 +28,8 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
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:
|
volumes:
|
||||||
mysql-data:
|
app-data:
|
||||||
driver: local
|
|
||||||
minio-data:
|
|
||||||
driver: local
|
driver: local
|
||||||
app-uploads:
|
app-uploads:
|
||||||
driver: local
|
driver: local
|
||||||
|
|||||||
@@ -5,6 +5,28 @@ echo "=========================================="
|
|||||||
echo "泉友米车友会 - 启动中..."
|
echo "泉友米车友会 - 启动中..."
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
|
|
||||||
|
# 检查数据库文件
|
||||||
|
DB_FILE="/app/data/database.sqlite"
|
||||||
|
if [ -f "$DB_FILE" ]; then
|
||||||
|
echo "✓ 发现已存在的数据库文件"
|
||||||
|
# 检查是否有备份表残留(这会导致sync失败)
|
||||||
|
if sqlite3 "$DB_FILE" "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_backup%';" 2>/dev/null | grep -q "_backup"; then
|
||||||
|
echo "⚠ 检测到备份表残留,清理中..."
|
||||||
|
sqlite3 "$DB_FILE" "SELECT 'DROP TABLE IF EXISTS ' || name || ';' FROM sqlite_master WHERE type='table' AND name LIKE '%_backup%';" | sqlite3 "$DB_FILE" 2>/dev/null || true
|
||||||
|
echo "✓ 备份表已清理"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "✓ 首次启动,将创建新数据库"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 初始化数据库
|
||||||
|
echo "初始化数据库..."
|
||||||
|
echo "[1/2] 运行数据库迁移..."
|
||||||
|
node migrations/index.js
|
||||||
|
|
||||||
|
echo "[2/2] 初始化管理员账号..."
|
||||||
|
node init-admin.js
|
||||||
|
|
||||||
# 替换前端 index.html 中的环境变量占位符
|
# 替换前端 index.html 中的环境变量占位符
|
||||||
if [ -f "/app/public/index.html" ]; then
|
if [ -f "/app/public/index.html" ]; then
|
||||||
echo "配置高德地图 Key..."
|
echo "配置高德地图 Key..."
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
@echo off
|
||||||
|
echo ==========================================
|
||||||
|
echo 修复经费媒体数据
|
||||||
|
echo ==========================================
|
||||||
|
cd backend
|
||||||
|
node fix-expense-media.js
|
||||||
|
cd ..
|
||||||
|
pause
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
<!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>
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
+127
-3
@@ -1,22 +1,118 @@
|
|||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api',
|
baseURL: 'https://mi.xyvan.cn/api',
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
});
|
});
|
||||||
|
|
||||||
api.interceptors.request.use(config => {
|
// Token续期状态管理
|
||||||
|
let isRefreshing = false;
|
||||||
|
let refreshSubscribers = [];
|
||||||
|
|
||||||
|
// 订阅token续期完成事件
|
||||||
|
const subscribeTokenRefresh = (cb) => {
|
||||||
|
refreshSubscribers.push(cb);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 通知所有订阅者token已更新
|
||||||
|
const onTokenRefreshed = (token) => {
|
||||||
|
refreshSubscribers.forEach(cb => cb(token));
|
||||||
|
refreshSubscribers = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
// 检查token是否即将过期(剩余时间小于1天)
|
||||||
|
const shouldRefreshToken = (token) => {
|
||||||
|
if (!token) return false;
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(atob(token.split('.')[1]));
|
||||||
|
const exp = payload.exp * 1000;
|
||||||
|
const now = Date.now();
|
||||||
|
const oneDay = 24 * 60 * 60 * 1000;
|
||||||
|
return (exp - now) < oneDay && (exp - now) > 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 续期token
|
||||||
|
const refreshToken = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.post('/admin/refresh-token', {}, {
|
||||||
|
_skipRefresh: true // 标记跳过续期检查
|
||||||
|
});
|
||||||
|
localStorage.setItem('token', data.token);
|
||||||
|
return data.token;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Token续期失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
api.interceptors.request.use(async config => {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
if (token) {
|
if (token) {
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
|
||||||
|
// 跳过续期请求本身的续期检查
|
||||||
|
if (!config._skipRefresh && shouldRefreshToken(token)) {
|
||||||
|
if (!isRefreshing) {
|
||||||
|
isRefreshing = true;
|
||||||
|
const newToken = await refreshToken();
|
||||||
|
isRefreshing = false;
|
||||||
|
if (newToken) {
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
onTokenRefreshed(newToken);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 等待续期完成
|
||||||
|
return new Promise(resolve => {
|
||||||
|
subscribeTokenRefresh(newToken => {
|
||||||
|
config.headers.Authorization = `Bearer ${newToken}`;
|
||||||
|
resolve(config);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 响应拦截器:确保所有错误都能被正确捕获
|
||||||
|
api.interceptors.response.use(
|
||||||
|
response => response,
|
||||||
|
error => {
|
||||||
|
// 401错误:token过期或无效,跳转登录页
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('admin');
|
||||||
|
// 避免在登录页重复跳转
|
||||||
|
if (!window.location.pathname.includes('/admin/login')) {
|
||||||
|
window.location.href = '/admin/login';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('API请求错误:', error);
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取完整的图片URL
|
||||||
|
export const getImageUrl = (path) => {
|
||||||
|
if (!path) return '';
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
// 开发环境使用代理,生产环境使用完整域名
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
return `http://localhost:3000${path}`;
|
||||||
|
}
|
||||||
|
return `https://mi.xyvan.cn${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
// 活动相关
|
// 活动相关
|
||||||
getActivities: (params) => api.get('/activities', { params }),
|
getActivities: (params) => api.get('/activities', { params }),
|
||||||
getActivity: (id) => api.get(`/activities/${id}`),
|
getActivity: (id) => api.get(`/activities/${id}`),
|
||||||
|
getVenueImages: (id) => api.get(`/activities/${id}/venue-images`),
|
||||||
|
|
||||||
// 报名相关
|
// 报名相关
|
||||||
submitRegistration: (data) => api.post('/registrations', data),
|
submitRegistration: (data) => api.post('/registrations', data),
|
||||||
@@ -34,11 +130,14 @@ export default {
|
|||||||
|
|
||||||
// 管理员相关
|
// 管理员相关
|
||||||
adminLogin: (data) => api.post('/admin/login', data),
|
adminLogin: (data) => api.post('/admin/login', data),
|
||||||
|
refreshToken: () => api.post('/admin/refresh-token'),
|
||||||
createActivity: (data) => api.post('/admin/activities', data),
|
createActivity: (data) => api.post('/admin/activities', data),
|
||||||
updateActivity: (id, data) => api.put(`/admin/activities/${id}`, data),
|
updateActivity: (id, data) => api.put(`/admin/activities/${id}`, data),
|
||||||
approveRegistration: (id, status) => api.put(`/admin/registrations/${id}`, { status }),
|
approveRegistration: (id, status) => api.put(`/admin/registrations/${id}`, { status }),
|
||||||
updateRegistration: (id, data) => api.put(`/admin/registrations/${id}`, data),
|
updateRegistration: (id, data) => api.put(`/admin/registrations/${id}`, data),
|
||||||
checkInRegistration: (id) => api.put(`/admin/registrations/${id}/checkin`),
|
checkInRegistration: (id) => api.put(`/admin/registrations/${id}/checkin`),
|
||||||
|
groupRegistration: (id) => api.put(`/admin/registrations/${id}/group`),
|
||||||
|
deleteRegistration: (id) => api.delete(`/admin/registrations/${id}`),
|
||||||
getAllRegistrations: (params) => api.get('/admin/registrations', { params }),
|
getAllRegistrations: (params) => api.get('/admin/registrations', { params }),
|
||||||
addExpense: (data) => api.post('/admin/expenses', data, {
|
addExpense: (data) => api.post('/admin/expenses', data, {
|
||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
@@ -65,5 +164,30 @@ export default {
|
|||||||
headers: { 'Content-Type': 'multipart/form-data' },
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
timeout: 60000
|
timeout: 60000
|
||||||
}),
|
}),
|
||||||
deleteSponsor: (id) => api.delete(`/admin/sponsors/${id}`)
|
deleteSponsor: (id) => api.delete(`/admin/sponsors/${id}`),
|
||||||
|
addPrize: (data) => api.post('/admin/prizes', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 60000
|
||||||
|
}),
|
||||||
|
updatePrize: (id, data) => api.put(`/admin/prizes/${id}`, data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 60000
|
||||||
|
}),
|
||||||
|
deletePrize: (id) => api.delete(`/admin/prizes/${id}`),
|
||||||
|
|
||||||
|
// 管理员账号管理(仅超级管理员)
|
||||||
|
getAdmins: () => api.get('/admin/admins'),
|
||||||
|
createAdmin: (data) => api.post('/admin/admins', data),
|
||||||
|
updateAdmin: (id, data) => api.put(`/admin/admins/${id}`, data),
|
||||||
|
updateAdminPassword: (id, data) => api.put(`/admin/admins/${id}/password`, data),
|
||||||
|
deleteAdmin: (id) => api.delete(`/admin/admins/${id}`),
|
||||||
|
|
||||||
|
// 场地图片管理
|
||||||
|
getVenueImages: (activityId) => api.get('/admin/venue-images', { params: { activityId } }),
|
||||||
|
uploadVenueImage: (data) => api.post('/admin/venue-images', data, {
|
||||||
|
headers: { 'Content-Type': 'multipart/form-data' },
|
||||||
|
timeout: 60000
|
||||||
|
}),
|
||||||
|
deleteVenueImage: (id) => api.delete(`/admin/venue-images/${id}`),
|
||||||
|
updateVenueImageOrder: (id, sortOrder) => api.put(`/admin/venue-images/${id}`, { sortOrder })
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ const routes = [
|
|||||||
{
|
{
|
||||||
path: '/activity/:id',
|
path: '/activity/:id',
|
||||||
name: 'ActivityDetail',
|
name: 'ActivityDetail',
|
||||||
component: () => import('../views/ActivityDetail.vue')
|
component: () => import('../views/ActivityDetail/index.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/danmaku-demo',
|
||||||
|
name: 'DanmakuDemo',
|
||||||
|
component: () => import('../views/ActivityDetail/DanmakuDemo.vue')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/register/:id',
|
path: '/register/:id',
|
||||||
@@ -42,6 +47,12 @@ const routes = [
|
|||||||
name: 'ActivityManage',
|
name: 'ActivityManage',
|
||||||
component: () => import('../views/admin/ActivityManage.vue'),
|
component: () => import('../views/admin/ActivityManage.vue'),
|
||||||
meta: { requiresAuth: true }
|
meta: { requiresAuth: true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin/admins',
|
||||||
|
name: 'AdminManage',
|
||||||
|
component: () => import('../views/admin/AdminManage.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresSuper: true }
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -53,6 +64,13 @@ const router = createRouter({
|
|||||||
router.beforeEach((to, from, next) => {
|
router.beforeEach((to, from, next) => {
|
||||||
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
|
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
|
||||||
next('/admin/login');
|
next('/admin/login');
|
||||||
|
} else if (to.meta.requiresSuper) {
|
||||||
|
const admin = JSON.parse(localStorage.getItem('admin') || '{}');
|
||||||
|
if (admin.role !== 'super') {
|
||||||
|
next('/admin');
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,292 @@
|
|||||||
|
<template>
|
||||||
|
<div class="danmaku-demo">
|
||||||
|
<h2>弹幕效果演示</h2>
|
||||||
|
<p class="demo-tip">鼠标悬停可暂停弹幕</p>
|
||||||
|
|
||||||
|
<div class="danmaku-container">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in activeDanmaku"
|
||||||
|
:key="item.id"
|
||||||
|
class="danmaku-item"
|
||||||
|
:class="[`track-${item.track}`, `speed-${item.speed}`, item.type]"
|
||||||
|
:style="{ animationDelay: `${item.delay}s` }"
|
||||||
|
>
|
||||||
|
<div class="danmaku-card">
|
||||||
|
<div class="danmaku-logo">
|
||||||
|
<img v-if="item.logo" :src="item.logo" :alt="item.name" />
|
||||||
|
<span v-else class="logo-placeholder">{{ item.name.charAt(0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="danmaku-content">
|
||||||
|
<span class="sponsor-name">{{ item.name }}</span>
|
||||||
|
<span class="sponsor-action">{{ item.type === 'title' ? '冠名赞助' : '赞助了' }}</span>
|
||||||
|
<span class="sponsor-item">{{ item.item }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
// 模拟赞助数据
|
||||||
|
const mockSponsors = [
|
||||||
|
{ id: 1, name: '小米汽车', logo: '', type: 'title', item: '本次活动' },
|
||||||
|
{ id: 2, name: '泉州欧乐堡', logo: '', type: 'sponsor', item: '游乐园门票' },
|
||||||
|
{ id: 3, name: '德劲新能源', logo: '', type: 'sponsor', item: '充电服务' },
|
||||||
|
{ id: 4, name: '泉友米车友会', logo: '', type: 'sponsor', item: '活动组织' },
|
||||||
|
{ id: 5, name: '本地商家A', logo: '', type: 'sponsor', item: '饮料零食' },
|
||||||
|
{ id: 6, name: '本地商家B', logo: '', type: 'sponsor', item: '精美礼品' },
|
||||||
|
{ id: 7, name: '本地商家C', logo: '', type: 'title', item: '特别赞助' },
|
||||||
|
{ id: 8, name: '本地商家D', logo: '', type: 'sponsor', item: '活动道具' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeDanmaku = ref([]);
|
||||||
|
const speeds = ['slow', 'medium', 'fast'];
|
||||||
|
const tracks = [0, 1, 2, 3];
|
||||||
|
let intervalId = null;
|
||||||
|
|
||||||
|
const generateDanmaku = () => {
|
||||||
|
const sponsor = mockSponsors[Math.floor(Math.random() * mockSponsors.length)];
|
||||||
|
const track = tracks[Math.floor(Math.random() * tracks.length)];
|
||||||
|
const speed = speeds[Math.floor(Math.random() * speeds.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sponsor,
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
track,
|
||||||
|
speed,
|
||||||
|
delay: 0
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDanmaku = () => {
|
||||||
|
if (activeDanmaku.value.length < 12) {
|
||||||
|
activeDanmaku.value.push(generateDanmaku());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理已完成的弹幕
|
||||||
|
if (activeDanmaku.value.length > 15) {
|
||||||
|
activeDanmaku.value = activeDanmaku.value.slice(-12);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始添加几条弹幕
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
setTimeout(() => addDanmaku(), i * 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定期添加新弹幕
|
||||||
|
intervalId = setInterval(addDanmaku, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.danmaku-demo {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 32px;
|
||||||
|
margin: 40px 0 10px;
|
||||||
|
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-tip {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item {
|
||||||
|
position: absolute;
|
||||||
|
right: -100%;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 轨道位置 */
|
||||||
|
.track-0 { top: 20px; }
|
||||||
|
.track-1 { top: 110px; }
|
||||||
|
.track-2 { top: 200px; }
|
||||||
|
.track-3 { top: 290px; }
|
||||||
|
|
||||||
|
/* 速度变化 */
|
||||||
|
.speed-slow {
|
||||||
|
animation: danmaku-move 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-medium {
|
||||||
|
animation: danmaku-move 15s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-fast {
|
||||||
|
animation: danmaku-move 10s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes danmaku-move {
|
||||||
|
0% {
|
||||||
|
right: -100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
5% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
right: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 50px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item:hover .danmaku-card {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 30px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 冠名赞助样式 */
|
||||||
|
.danmaku-item.title .danmaku-card {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 215, 0, 0.9) 0%, rgba(255, 165, 0, 0.9) 100%);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item.title .danmaku-content {
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 普通赞助样式 */
|
||||||
|
.danmaku-item.sponsor .danmaku-card {
|
||||||
|
background: linear-gradient(135deg, rgba(74, 144, 226, 0.9) 0%, rgba(53, 122, 189, 0.9) 100%);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item.sponsor .danmaku-content {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-placeholder {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-action {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-item {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.danmaku-container {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-0 { top: 15px; }
|
||||||
|
.track-1 { top: 85px; }
|
||||||
|
.track-2 { top: 155px; }
|
||||||
|
.track-3 { top: 225px; }
|
||||||
|
|
||||||
|
.danmaku-card {
|
||||||
|
padding: 8px 16px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-content {
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-action {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-item {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sponsor-danmaku" v-if="memberPrizes.length > 0">
|
||||||
|
<transition name="danmaku-fade">
|
||||||
|
<div
|
||||||
|
v-if="currentIndex >= 0 && currentIndex < memberPrizes.length"
|
||||||
|
:key="memberPrizes[currentIndex].id"
|
||||||
|
class="danmaku-item"
|
||||||
|
>
|
||||||
|
<div class="danmaku-card">
|
||||||
|
<div class="danmaku-avatar">
|
||||||
|
<span class="avatar-placeholder">{{ memberPrizes[currentIndex].sponsor.charAt(0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="danmaku-content">
|
||||||
|
<span class="thank-text">感谢</span>
|
||||||
|
<span class="sponsor-name">{{ memberPrizes[currentIndex].sponsor }}</span>
|
||||||
|
<span class="sponsor-action">赞助的</span>
|
||||||
|
<span class="sponsor-item">{{ memberPrizes[currentIndex].name }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
prizes: {
|
||||||
|
type: Array,
|
||||||
|
default: () => []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const memberPrizes = computed(() => {
|
||||||
|
return props.prizes.filter(p => p.source === 'member' && p.sponsor);
|
||||||
|
});
|
||||||
|
|
||||||
|
const currentIndex = ref(-1);
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
const startDanmaku = () => {
|
||||||
|
if (memberPrizes.value.length === 0) return;
|
||||||
|
|
||||||
|
currentIndex.value = 0;
|
||||||
|
|
||||||
|
timer = setInterval(() => {
|
||||||
|
currentIndex.value = (currentIndex.value + 1) % memberPrizes.value.length;
|
||||||
|
}, 15000); // 每15秒切换一条
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopDanmaku = () => {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(() => memberPrizes.value.length, (newLen) => {
|
||||||
|
stopDanmaku();
|
||||||
|
if (newLen > 0) {
|
||||||
|
startDanmaku();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (memberPrizes.value.length > 0) {
|
||||||
|
startDanmaku();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
stopDanmaku();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sponsor-danmaku {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 70px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: -100%;
|
||||||
|
will-change: transform;
|
||||||
|
pointer-events: auto;
|
||||||
|
filter: drop-shadow(0 8px 30px rgba(212, 175, 55, 0.6));
|
||||||
|
animation: danmaku-move 15s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-fade-enter-active {
|
||||||
|
animation: danmaku-move 15s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-fade-leave-active {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes danmaku-move {
|
||||||
|
0% {
|
||||||
|
right: -100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
5% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
right: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 35px;
|
||||||
|
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%);
|
||||||
|
border: 2px solid #d4af37;
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(212, 175, 55, 0.5),
|
||||||
|
0 0 40px rgba(212, 175, 55, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.3), transparent);
|
||||||
|
animation: shine 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
50%, 100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item:hover .danmaku-card {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow:
|
||||||
|
0 0 30px rgba(212, 175, 55, 0.8),
|
||||||
|
0 0 60px rgba(212, 175, 55, 0.5),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 3px solid #d4af37;
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(212, 175, 55, 0.8),
|
||||||
|
inset 0 0 10px rgba(0, 0, 0, 0.3);
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-avatar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.3) 50%, transparent 70%);
|
||||||
|
animation: avatar-shine 4s infinite;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes avatar-shine {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #1a1a1a;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thank-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #d4af37;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 0 10px rgba(212, 175, 55, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-name {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 17px;
|
||||||
|
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
text-shadow: 0 0 20px rgba(212, 175, 55, 0.8);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-action {
|
||||||
|
color: #c0c0c0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-item {
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(212, 175, 55, 0.6),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
max-width: 300px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sponsor-danmaku {
|
||||||
|
top: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item {
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-card {
|
||||||
|
padding: 8px 18px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2.5px solid #d4af37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.danmaku-content {
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thank-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-name {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-action {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-item {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
}</style>
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
<template>
|
||||||
|
<div class="detail">
|
||||||
|
<div class="detail-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.back()">
|
||||||
|
<van-icon name="arrow-left" />
|
||||||
|
</div>
|
||||||
|
<div class="header-content">
|
||||||
|
<h1 class="activity-title">{{ activity.title }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-item">
|
||||||
|
<van-icon name="clock-o" class="info-icon" />
|
||||||
|
<div class="info-text">
|
||||||
|
<div class="info-label">活动时间</div>
|
||||||
|
<div class="info-value">{{ formatDate(activity.startTime) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" @click="showMap">
|
||||||
|
<van-icon name="location-o" class="info-icon" />
|
||||||
|
<div class="info-text">
|
||||||
|
<div class="info-label">活动地点</div>
|
||||||
|
<div class="info-value">{{ activity.location }}</div>
|
||||||
|
</div>
|
||||||
|
<van-icon name="arrow" class="info-arrow" />
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<van-icon name="calendar-o" class="info-icon" />
|
||||||
|
<div class="info-text">
|
||||||
|
<div class="info-label">报名截止</div>
|
||||||
|
<div class="info-value">{{ formatDate(activity.registrationDeadline) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<van-icon name="gold-coin-o" class="info-icon" />
|
||||||
|
<div class="info-text">
|
||||||
|
<div class="info-label">人均费用</div>
|
||||||
|
<div class="info-value price-value">{{ activity.price > 0 ? `¥${activity.price}` : '免费' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">活动介绍</h3>
|
||||||
|
<p class="description">{{ activity.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 赞助商弹幕 -->
|
||||||
|
<SponsorDanmaku v-if="activity.danmakuEnabled" :prizes="prizes" />
|
||||||
|
|
||||||
|
<!-- 活动流程 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">活动流程</h3>
|
||||||
|
<div class="timeline">
|
||||||
|
<!-- 签到与集结 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(0)" :class="{ expanded: expandedTimelineIndex === 0 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">14:00 - 14:30</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 0 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">签到与集结</div>
|
||||||
|
<div class="timeline-brief">车辆有序到场,签到领卡,开启精彩活动之旅</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 0 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step-item">🚗 车辆到场 → 指引至临时停车点</div>
|
||||||
|
<div class="step-item">✍️ 签到处签名确认参与</div>
|
||||||
|
<div class="step-item">📱 扫码入群,加入活动大家庭</div>
|
||||||
|
<div class="step-item">🎫 领取专属积分卡</div>
|
||||||
|
</div>
|
||||||
|
<div class="game-rule">
|
||||||
|
<div class="rule-title">📋 积分卡说明</div>
|
||||||
|
<div class="rule-content">
|
||||||
|
• 每人领取一张积分卡(红绿双色章)<br>
|
||||||
|
• 初始积分:0分<br>
|
||||||
|
• 游戏得分范围:+1至+5分<br>
|
||||||
|
• 游戏扣分范围:-1至-5分<br>
|
||||||
|
• 最终积分可兑换精美礼品<br>
|
||||||
|
• 积分越高,礼品越丰厚
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 观赏素材拍摄 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(1)" :class="{ expanded: expandedTimelineIndex === 1 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">14:30 - 16:30</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 1 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">观赏素材拍摄</div>
|
||||||
|
<div class="timeline-brief">专业摄影团队记录精彩瞬间,留下珍贵回忆</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 1 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step-item">📸 全员车前合影 - 展示车队阵容</div>
|
||||||
|
<div class="step-item">🎯 签到墙合照 - 打卡留念</div>
|
||||||
|
<div class="step-item">🏛️ 地标合影 - 记录活动地点</div>
|
||||||
|
<div class="step-item">🎬 动态全员上车 - 拍摄车队启动瞬间</div>
|
||||||
|
<div class="step-item">✨ 拍摄"泉州"字样 - 城市印记</div>
|
||||||
|
</div>
|
||||||
|
<div class="tip-box">
|
||||||
|
💡 提示:这些素材将用于活动回顾视频制作,请积极配合拍摄
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 互动游戏时间 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(2)" :class="{ expanded: expandedTimelineIndex === 2 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">16:30 - 18:00</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 2 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">互动游戏时间</div>
|
||||||
|
<div class="timeline-brief">10+趣味游戏项目,赢取积分兑换精美礼品</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 2 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="game-list">
|
||||||
|
<div
|
||||||
|
v-for="(game, index) in gameItems"
|
||||||
|
:key="'game' + index"
|
||||||
|
class="game-card"
|
||||||
|
@click.stop="toggleGame('game' + index)"
|
||||||
|
:class="{ expanded: expandedGameId === 'game' + index, 'highlight-game': game.name.includes('最终复活') }"
|
||||||
|
>
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">{{ game.icon }} {{ game.name }}</div>
|
||||||
|
<van-icon :name="expandedGameId === 'game' + index ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'game' + index }">
|
||||||
|
<div class="game-desc" v-html="game.detail"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 落日BBQ -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(3)" :class="{ expanded: expandedTimelineIndex === 3 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">18:00 - 19:30</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 3 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">落日BBQ</div>
|
||||||
|
<div class="timeline-brief">落日余晖下品尝美食,拍摄浪漫氛围大片</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 3 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step-item">🌅 落日余晖下车辆排成一排</div>
|
||||||
|
<div class="step-item">💡 齐开大灯营造氛围</div>
|
||||||
|
<div class="step-item">📷 拍摄氛围感大片</div>
|
||||||
|
<div class="step-item">🍖 边烤肉边欣赏晚霞</div>
|
||||||
|
<div class="step-item">🎵 轻松音乐伴随美食时光</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 篝火歌舞会 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(4)" :class="{ expanded: expandedTimelineIndex === 4 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">19:00 - 21:00</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 4 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">篝火歌舞会</div>
|
||||||
|
<div class="timeline-brief">互动游戏、璀璨烟花、积分兑奖,欢乐不停歇</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 4 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="game-list">
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire1')" :class="{ expanded: expandedGameId === 'bonfire1' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🥁 击鼓传花(10轮)</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire1' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire1' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>音乐响起时传递道具,音乐停止时持花者上台<br>
|
||||||
|
<strong>惩罚:</strong>吃柠檬或苦瓜(二选一)<br>
|
||||||
|
<strong>得分:</strong>勇敢完成+2分,拒绝挑战-3分<br>
|
||||||
|
<strong>轮数:</strong>共10轮,气氛逐渐升温
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire2')" :class="{ expanded: expandedGameId === 'bonfire2' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🤝 喊数抱团(10轮)</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire2' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire2' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>主持人喊数字,参与者按数字抱团<br>
|
||||||
|
<strong>规则:</strong>落单者淘汰,上台自我介绍(1分钟)<br>
|
||||||
|
<strong>得分:</strong>成功抱团+1分,落单但完成自我介绍+2分,拒绝-2分<br>
|
||||||
|
<strong>互动:</strong>认识新朋友的好机会!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire3')" :class="{ expanded: expandedGameId === 'bonfire3' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🎵 大合唱</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire3' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire3' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>全员参与,齐唱经典歌曲<br>
|
||||||
|
<strong>氛围:</strong>点燃现场,凝聚团队
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire4')" :class="{ expanded: expandedGameId === 'bonfire4' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🎭 个人才艺表演</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire4' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire4' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>自愿上台展示才艺(唱歌/跳舞/表演)<br>
|
||||||
|
<strong>准备:</strong>需提前到后台准备BGM<br>
|
||||||
|
<strong>鼓励:</strong>展现自我,收获掌声!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire5')" :class="{ expanded: expandedGameId === 'bonfire5' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">👶 小朋友互动</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire5' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire5' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>主持人邀请小朋友上台互动<br>
|
||||||
|
<strong>内容:</strong>简单游戏/才艺展示/问答<br>
|
||||||
|
<strong>礼品:</strong>参与即有小礼物<br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire6')" :class="{ expanded: expandedGameId === 'bonfire6' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🎆 璀璨烟火秀</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire6' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire6' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>环节:</strong>三重烟花盛宴,点亮夜空绽放精彩<br>
|
||||||
|
<strong>米兰之夜:</strong>大型专业烟火表演,绚丽夺目<br>
|
||||||
|
<strong>仙女棒:</strong>每车配发手持烟花,拍照打卡<br>
|
||||||
|
<strong>加特林压轴:</strong>震撼收尾,全场欢呼<br>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire7')" :class="{ expanded: expandedGameId === 'bonfire7' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🏆 积分兑换礼品</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire7' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire7' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>奖项设置:</strong><br>
|
||||||
|
🥇 一等奖 - 神秘大奖等你拿<br>
|
||||||
|
🥈 二等奖 - 精美礼品超值享<br>
|
||||||
|
🥉 三等奖 - 实用好物带回家<br>
|
||||||
|
🎖️ 四等奖 - 纪念礼品留回忆<br>
|
||||||
|
🎊 参与奖 - 人人有份不落空<br>
|
||||||
|
<br>
|
||||||
|
✨ 积分越高奖品越丰厚,还可能有惊喜等着你!<br>
|
||||||
|
📍 凭积分卡到兑奖处领取对应礼品
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 自由交流 & 返程 -->
|
||||||
|
<div class="timeline-item timeline-item-last">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(5)" :class="{ expanded: expandedTimelineIndex === 5 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">21:00 以后</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 5 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">自由交流 & 返程</div>
|
||||||
|
<div class="timeline-brief">自由合影留念,整理现场,安全返程</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 5 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step-item">📸 自由合影留念</div>
|
||||||
|
<div class="step-item">🧹 整理现场垃圾</div>
|
||||||
|
<div class="step-item">🚗 提醒安全驾驶返程</div>
|
||||||
|
<div class="step-item">👋 期待下次相聚</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 场地展示 -->
|
||||||
|
<div v-if="venueImages.length" class="section">
|
||||||
|
<h3 class="section-title">场地展示</h3>
|
||||||
|
<van-swipe class="venue-swipe" :autoplay="3000" indicator-color="white">
|
||||||
|
<van-swipe-item v-for="(image, index) in venueImages" :key="index">
|
||||||
|
<div class="venue-image" @click="previewImage(getImageUrl(image))">
|
||||||
|
<img :src="getImageUrl(image)" :alt="`场地图片${index + 1}`" />
|
||||||
|
</div>
|
||||||
|
</van-swipe-item>
|
||||||
|
</van-swipe>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 活动经费 -->
|
||||||
|
<div v-if="expenses.length && isApproved" class="section">
|
||||||
|
<h3 class="section-title">活动经费 <span class="count">¥{{ totalExpense }}</span></h3>
|
||||||
|
<div class="expense-list">
|
||||||
|
<div v-for="expense in expenses" :key="expense.id" class="expense-item">
|
||||||
|
<div class="expense-info">
|
||||||
|
<div class="expense-name">{{ expense.item }}</div>
|
||||||
|
<div class="expense-category">{{ expense.category }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expense.media && expense.media.length" class="expense-media">
|
||||||
|
<div v-for="(media, index) in expense.media" :key="index" class="expense-media-item">
|
||||||
|
<van-image
|
||||||
|
v-if="media.type === 'image'"
|
||||||
|
:src="media.url"
|
||||||
|
fit="cover"
|
||||||
|
@click="previewImage(media.url)"
|
||||||
|
/>
|
||||||
|
<div v-else class="expense-video" @click="playExpenseVideo(media.url)">
|
||||||
|
<video :src="media.url"></video>
|
||||||
|
<van-icon name="play-circle-o" class="play-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="expense-amount">金额:¥{{ expense.amount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isApproved && hasRegistered" class="section">
|
||||||
|
<div class="permission-tip">
|
||||||
|
<van-icon name="info-o" />
|
||||||
|
<span v-if="userRegistration.status === 'pending'">您的报名正在审核中,审核通过后可查看活动经费</span>
|
||||||
|
<span v-else-if="userRegistration.status === 'rejected'">您的报名未通过审核</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 赞助商 -->
|
||||||
|
<div v-if="sponsors.length" class="section">
|
||||||
|
<h3 class="section-title">赞助商</h3>
|
||||||
|
<div class="sponsor-grid">
|
||||||
|
<div v-for="sponsor in sponsors" :key="sponsor.id" class="sponsor-card">
|
||||||
|
<div class="sponsor-logo" :style="{ backgroundImage: `url(${getImageUrl(sponsor.logo)})` }"></div>
|
||||||
|
<div class="sponsor-name">{{ sponsor.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 奖品礼品 -->
|
||||||
|
<div v-if="prizes.length" class="section">
|
||||||
|
<h3 class="section-title">🏆 奖品礼品</h3>
|
||||||
|
<div class="prize-list">
|
||||||
|
<div v-for="prize in prizes" :key="prize.id" class="prize-card">
|
||||||
|
<div class="prize-image" :style="prize.image ? { backgroundImage: `url(${getImageUrl(prize.image)})` } : {}">
|
||||||
|
<van-icon v-if="!prize.image" name="gift-o" size="40" color="#ccc" />
|
||||||
|
</div>
|
||||||
|
<div class="prize-content">
|
||||||
|
<div class="prize-header">
|
||||||
|
<span class="prize-level" :class="`level-${prize.level}`">{{ prize.level }}</span>
|
||||||
|
<span class="prize-name">{{ prize.name }}</span>
|
||||||
|
<van-icon
|
||||||
|
v-if="prize.sponsor"
|
||||||
|
name="info-o"
|
||||||
|
class="prize-sponsor-icon"
|
||||||
|
@click="showSponsorInfo(prize)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="prize.description" class="prize-desc">{{ prize.description }}</div>
|
||||||
|
<div class="prize-footer">
|
||||||
|
<span class="prize-quantity">数量: {{ prize.quantity }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 活动照片与视频 -->
|
||||||
|
<div v-if="photos.length || videos.length" class="section">
|
||||||
|
<h3 class="section-title">活动照片与视频</h3>
|
||||||
|
|
||||||
|
<div v-if="photos.length" class="media-section">
|
||||||
|
<div class="media-label">照片</div>
|
||||||
|
<div class="photo-grid">
|
||||||
|
<div v-for="photo in photos" :key="photo.id" class="photo-item" @click="previewImage(photo.url)">
|
||||||
|
<div class="photo-img" :style="{ backgroundImage: `url(${photo.url})` }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="videos.length" class="media-section">
|
||||||
|
<div class="media-label">视频</div>
|
||||||
|
<div class="video-list">
|
||||||
|
<div v-for="video in videos" :key="video.id" class="video-item">
|
||||||
|
<video :src="video.url" controls class="video-player"></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasRegistered && isApproved" class="upload-section">
|
||||||
|
<van-uploader
|
||||||
|
v-model="userMedia"
|
||||||
|
multiple
|
||||||
|
:max-count="5"
|
||||||
|
:after-read="uploadUserMedia"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
>
|
||||||
|
<van-button icon="photograph" type="primary" size="small">上传照片/视频</van-button>
|
||||||
|
</van-uploader>
|
||||||
|
<div class="upload-tip">您可以上传活动照片或视频,审核后将展示给所有人</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasRegistered && !isApproved" class="upload-section">
|
||||||
|
<div class="permission-tip">
|
||||||
|
<van-icon name="info-o" />
|
||||||
|
<span v-if="userRegistration.status === 'pending'">审核通过后可上传照片/视频</span>
|
||||||
|
<span v-else-if="userRegistration.status === 'rejected'">您的报名未通过审核</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 报名名单 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">报名名单 <span class="count">({{ totalPeople }}人)</span></h3>
|
||||||
|
<div class="member-list">
|
||||||
|
<div v-for="reg in registrations" :key="reg.id" class="member-item">
|
||||||
|
<div class="member-avatar">{{ reg.name.charAt(0) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作按钮 -->
|
||||||
|
<div v-if="!hasRegistered && activity.registrationEnabled" class="footer-action">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
@click="$router.push(`/register/${activity.id}`)"
|
||||||
|
class="action-button"
|
||||||
|
>
|
||||||
|
<van-icon name="add-o" /> 立即报名
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
plain
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
@click="handleLogin"
|
||||||
|
class="action-button login-button"
|
||||||
|
>
|
||||||
|
<van-icon name="user-o" /> 已报名?点击登录
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!hasRegistered && !activity.registrationEnabled" class="footer-action">
|
||||||
|
<van-button type="default" block round size="large" disabled class="action-button">
|
||||||
|
<van-icon name="warning-o" /> 报名已结束
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
plain
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
@click="handleLogin"
|
||||||
|
class="action-button login-button"
|
||||||
|
>
|
||||||
|
<van-icon name="user-o" /> 已报名?点击登录
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!isApproved" class="footer-action">
|
||||||
|
<van-button type="default" block round size="large" disabled class="action-button">
|
||||||
|
<van-icon name="clock-o" />
|
||||||
|
<span v-if="userRegistration.status === 'pending'">等待审核中</span>
|
||||||
|
<span v-else>报名未通过</span>
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="footer-action">
|
||||||
|
<van-button type="success" block round size="large" disabled class="action-button">
|
||||||
|
<van-icon name="success" /> 已报名
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分享按钮 -->
|
||||||
|
<div class="share-btn" @click="handleShare">
|
||||||
|
<van-icon name="share-o" />
|
||||||
|
<span>分享</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 地图弹窗 -->
|
||||||
|
<van-popup v-model:show="showMapPopup" position="bottom" :style="{ height: '70%' }">
|
||||||
|
<div class="map-container">
|
||||||
|
<div class="map-header">
|
||||||
|
<h3>{{ activity.location }}</h3>
|
||||||
|
<van-icon name="cross" @click="showMapPopup = false" />
|
||||||
|
</div>
|
||||||
|
<div id="amap-container" style="width: 100%; height: calc(100% - 50px);"></div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 登录弹窗 -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showLoginDialog"
|
||||||
|
title="验证报名信息"
|
||||||
|
show-cancel-button
|
||||||
|
@confirm="submitLogin"
|
||||||
|
>
|
||||||
|
<div class="login-form">
|
||||||
|
<van-field
|
||||||
|
v-model="loginForm.phone"
|
||||||
|
label="手机号"
|
||||||
|
placeholder="请输入报名时的手机号"
|
||||||
|
type="tel"
|
||||||
|
maxlength="11"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
v-model="loginForm.carNumber"
|
||||||
|
label="车牌号"
|
||||||
|
>
|
||||||
|
<template #input>
|
||||||
|
<div class="license-plate-input">
|
||||||
|
<span class="plate-prefix">闽</span>
|
||||||
|
<input
|
||||||
|
v-model="carLetter"
|
||||||
|
@input="handleLetterInput"
|
||||||
|
placeholder="_"
|
||||||
|
maxlength="1"
|
||||||
|
class="plate-letter"
|
||||||
|
/>
|
||||||
|
<span class="plate-dot">·</span>
|
||||||
|
<input
|
||||||
|
v-model="carDigits"
|
||||||
|
@input="handleDigitsInput"
|
||||||
|
placeholder="______"
|
||||||
|
maxlength="6"
|
||||||
|
class="plate-digits"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
</div>
|
||||||
|
</van-dialog>
|
||||||
|
|
||||||
|
<!-- 经费视频播放弹窗 -->
|
||||||
|
<van-popup v-model:show="showExpenseVideoPopup" position="center" round :style="{ width: '90%' }">
|
||||||
|
<div class="video-player-popup">
|
||||||
|
<video :src="currentExpenseVideo" controls autoplay class="popup-video"></video>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useActivityDetail } from './useActivityDetail';
|
||||||
|
import SponsorDanmaku from './SponsorDanmaku.vue';
|
||||||
|
import { gameItems } from '../timeline-data.js';
|
||||||
|
|
||||||
|
// 获取完整图片URL的辅助函数
|
||||||
|
const getImageUrl = (path) => {
|
||||||
|
if (!path || typeof path !== 'string') return '';
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return import.meta.env.DEV ? `http://localhost:3000${path}` : `https://mi.xyvan.cn${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
activity,
|
||||||
|
photos,
|
||||||
|
videos,
|
||||||
|
sponsors,
|
||||||
|
prizes,
|
||||||
|
registrations,
|
||||||
|
expenses,
|
||||||
|
venueImages,
|
||||||
|
userMedia,
|
||||||
|
showMapPopup,
|
||||||
|
showLoginDialog,
|
||||||
|
showExpenseVideoPopup,
|
||||||
|
currentExpenseVideo,
|
||||||
|
loginForm,
|
||||||
|
carLetter,
|
||||||
|
carDigits,
|
||||||
|
userRegistration,
|
||||||
|
expandedTimelineIndex,
|
||||||
|
expandedGameId,
|
||||||
|
totalExpense,
|
||||||
|
totalPeople,
|
||||||
|
isApproved,
|
||||||
|
hasRegistered,
|
||||||
|
formatDate,
|
||||||
|
previewImage,
|
||||||
|
playExpenseVideo,
|
||||||
|
showMap,
|
||||||
|
uploadUserMedia,
|
||||||
|
handleLogin,
|
||||||
|
handleLetterInput,
|
||||||
|
handleDigitsInput,
|
||||||
|
submitLogin,
|
||||||
|
handleShare,
|
||||||
|
toggleTimeline,
|
||||||
|
toggleGame,
|
||||||
|
showSponsorInfo
|
||||||
|
} = useActivityDetail();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style src="./styles.css" scoped></style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,350 @@
|
|||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { showImagePreview, showToast, showDialog } from 'vant';
|
||||||
|
import api from '../../api';
|
||||||
|
|
||||||
|
export function useActivityDetail() {
|
||||||
|
const route = useRoute();
|
||||||
|
const activity = ref({});
|
||||||
|
const photos = ref([]);
|
||||||
|
const videos = ref([]);
|
||||||
|
const sponsors = ref([]);
|
||||||
|
const prizes = ref([]);
|
||||||
|
const registrations = ref([]);
|
||||||
|
const expenses = ref([]);
|
||||||
|
const venueImages = ref([]);
|
||||||
|
const userMedia = ref([]);
|
||||||
|
const showMapPopup = ref(false);
|
||||||
|
const showLoginDialog = ref(false);
|
||||||
|
const showExpenseVideoPopup = ref(false);
|
||||||
|
const currentExpenseVideo = ref('');
|
||||||
|
const loginForm = ref({ phone: '', carNumber: '' });
|
||||||
|
const expandedTimelineIndex = ref(null);
|
||||||
|
const expandedGameId = ref(null);
|
||||||
|
const carLetter = ref('');
|
||||||
|
const carDigits = ref('');
|
||||||
|
const userRegistration = ref(null);
|
||||||
|
const showPrizeSourcePopup = ref(false);
|
||||||
|
const currentPrize = ref(null);
|
||||||
|
let map = null;
|
||||||
|
|
||||||
|
const handleLetterInput = (e) => {
|
||||||
|
const value = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
|
||||||
|
carLetter.value = value;
|
||||||
|
loginForm.value.carNumber = value + carDigits.value;
|
||||||
|
|
||||||
|
if (value.length === 1) {
|
||||||
|
e.target.nextElementSibling?.nextElementSibling?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDigitsInput = (e) => {
|
||||||
|
const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
carDigits.value = value;
|
||||||
|
loginForm.value.carNumber = carLetter.value + value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTimeline = (index) => {
|
||||||
|
expandedTimelineIndex.value = expandedTimelineIndex.value === index ? null : index;
|
||||||
|
expandedGameId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGame = (gameId) => {
|
||||||
|
expandedGameId.value = expandedGameId.value === gameId ? null : gameId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalExpense = computed(() => {
|
||||||
|
return expenses.value.reduce((sum, e) => sum + parseFloat(e.amount), 0).toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalPeople = computed(() => {
|
||||||
|
return registrations.value
|
||||||
|
.filter(r => r.status === 'approved')
|
||||||
|
.reduce((sum, r) => sum + (r.peopleCount || 1), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isApproved = computed(() => {
|
||||||
|
return userRegistration.value && userRegistration.value.status === 'approved';
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasRegistered = computed(() => {
|
||||||
|
return userRegistration.value !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const prizesByLevel = computed(() => {
|
||||||
|
const levels = ['特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼'];
|
||||||
|
return levels.map(level => ({
|
||||||
|
level,
|
||||||
|
items: prizes.value.filter(p => p.level === level).sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
})).filter(group => group.items.length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const showPrizeSource = (prize) => {
|
||||||
|
if (prize.source === 'purchase') return;
|
||||||
|
currentPrize.value = prize;
|
||||||
|
showPrizeSourcePopup.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSponsorInfo = (prize) => {
|
||||||
|
if (!prize.sponsor) return;
|
||||||
|
|
||||||
|
let sourceText = '';
|
||||||
|
if (prize.source === 'member') {
|
||||||
|
sourceText = '群友赞助';
|
||||||
|
} else if (prize.source === 'sponsor') {
|
||||||
|
sourceText = '赞助商提供';
|
||||||
|
} else {
|
||||||
|
sourceText = '礼品来源';
|
||||||
|
}
|
||||||
|
|
||||||
|
showDialog({
|
||||||
|
title: sourceText,
|
||||||
|
message: prize.sponsor,
|
||||||
|
confirmButtonText: '知道了',
|
||||||
|
confirmButtonColor: '#FF6700',
|
||||||
|
className: 'sponsor-dialog'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const { data } = await api.getActivity(route.params.id);
|
||||||
|
activity.value = data.activity;
|
||||||
|
|
||||||
|
const allMedia = data.photos || [];
|
||||||
|
photos.value = allMedia.filter(m => m.type === 'image');
|
||||||
|
videos.value = allMedia.filter(m => m.type === 'video');
|
||||||
|
|
||||||
|
sponsors.value = data.sponsors;
|
||||||
|
prizes.value = data.prizes || [];
|
||||||
|
expenses.value = data.expenses || [];
|
||||||
|
venueImages.value = data.venueImages || [];
|
||||||
|
|
||||||
|
const { data: regs } = await api.getActivityRegistrations(route.params.id);
|
||||||
|
registrations.value = regs;
|
||||||
|
|
||||||
|
// 检查本地存储的报名信息
|
||||||
|
const savedReg = localStorage.getItem(`user_reg_${route.params.id}`);
|
||||||
|
if (savedReg) {
|
||||||
|
const regData = JSON.parse(savedReg);
|
||||||
|
// 验证报名是否还存在
|
||||||
|
try {
|
||||||
|
const { data: verifyData } = await api.getRegistration(regData.id);
|
||||||
|
userRegistration.value = verifyData;
|
||||||
|
// 更新本地存储
|
||||||
|
localStorage.setItem(`user_reg_${route.params.id}`, JSON.stringify(verifyData));
|
||||||
|
} catch (error) {
|
||||||
|
// 报名不存在,清理本地存储
|
||||||
|
if (error.response?.status === 404) {
|
||||||
|
localStorage.removeItem(`user_reg_${route.params.id}`);
|
||||||
|
userRegistration.value = null;
|
||||||
|
} else {
|
||||||
|
// 其他错误,保留本地数据
|
||||||
|
userRegistration.value = regData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
// 处理带时区的时间格式 "2026-04-19 06:00:00.000 +00:00"
|
||||||
|
let dateStr = date;
|
||||||
|
if (typeof date === 'string') {
|
||||||
|
// 移除时区部分,只保留日期时间
|
||||||
|
dateStr = date.replace(/\s*\+\d{2}:\d{2}$/, '').replace(/\.\d{3}/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
if (isNaN(d.getTime())) return '';
|
||||||
|
|
||||||
|
return d.toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewImage = (url) => {
|
||||||
|
showImagePreview([url]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playExpenseVideo = (url) => {
|
||||||
|
currentExpenseVideo.value = url;
|
||||||
|
showExpenseVideoPopup.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showMap = () => {
|
||||||
|
if (!activity.value.locationLat || !activity.value.locationLng) {
|
||||||
|
showToast('暂无地图信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMapPopup.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
initMap();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initMap = async () => {
|
||||||
|
try {
|
||||||
|
const AMap = await window.loadAMap();
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = [activity.value.locationLng, activity.value.locationLat];
|
||||||
|
map = new AMap.Map('amap-container', { zoom: 15, center });
|
||||||
|
new AMap.Marker({ position: center, map, title: activity.value.location });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('地图加载失败:', e);
|
||||||
|
showToast('地图加载失败: ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadUserMedia = async (files) => {
|
||||||
|
if (!isApproved.value) {
|
||||||
|
showToast('请先登录并等待审核通过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingToast = showToast({
|
||||||
|
type: 'loading',
|
||||||
|
message: '上传中...',
|
||||||
|
forbidClick: true,
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('uploadedBy', userRegistration.value.name);
|
||||||
|
|
||||||
|
if (Array.isArray(files)) {
|
||||||
|
files.forEach(file => formData.append('photos', file.file));
|
||||||
|
} else {
|
||||||
|
formData.append('photos', files.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.uploadUserPhotos(route.params.id, formData);
|
||||||
|
showToast('上传成功,等待审核');
|
||||||
|
userMedia.value = [];
|
||||||
|
} catch (error) {
|
||||||
|
showToast('上传失败');
|
||||||
|
} finally {
|
||||||
|
loadingToast.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
showLoginDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitLogin = async () => {
|
||||||
|
if (!loginForm.value.phone || !loginForm.value.carNumber) {
|
||||||
|
showToast('请输入手机号和车牌号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const carNumberWithPrefix = `闽${loginForm.value.carNumber}`;
|
||||||
|
|
||||||
|
const { data } = await api.verifyUser(route.params.id, {
|
||||||
|
phone: loginForm.value.phone,
|
||||||
|
carNumber: carNumberWithPrefix
|
||||||
|
});
|
||||||
|
|
||||||
|
userRegistration.value = data.registration;
|
||||||
|
localStorage.setItem(`user_reg_${route.params.id}`, JSON.stringify(data.registration));
|
||||||
|
|
||||||
|
showLoginDialog.value = false;
|
||||||
|
|
||||||
|
if (data.registration.status === 'approved') {
|
||||||
|
showToast('登录成功');
|
||||||
|
} else if (data.registration.status === 'pending') {
|
||||||
|
showToast('您的报名正在审核中');
|
||||||
|
} else {
|
||||||
|
showToast('您的报名未通过审核');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 如果报名不存在(被删除),清理本地存储
|
||||||
|
if (error.response?.status === 404 || error.response?.data?.error?.includes('不存在')) {
|
||||||
|
localStorage.removeItem(`user_reg_${route.params.id}`);
|
||||||
|
userRegistration.value = null;
|
||||||
|
showToast('报名信息不存在,请重新报名');
|
||||||
|
} else {
|
||||||
|
showToast(error.response?.data?.error || '验证失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
const shareUrl = `${window.location.origin}/share/${route.params.id}`;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: activity.value.title,
|
||||||
|
text: `${activity.value.title} - 泉友米车友会`,
|
||||||
|
url: shareUrl
|
||||||
|
}).catch(() => {
|
||||||
|
copyShareLink(shareUrl);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
copyShareLink(shareUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShareLink = (url) => {
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
showToast('分享链接已复制');
|
||||||
|
}).catch(() => {
|
||||||
|
showToast('复制失败,请手动复制:' + url);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
photos,
|
||||||
|
videos,
|
||||||
|
sponsors,
|
||||||
|
prizes,
|
||||||
|
prizesByLevel,
|
||||||
|
registrations,
|
||||||
|
expenses,
|
||||||
|
venueImages,
|
||||||
|
userMedia,
|
||||||
|
showMapPopup,
|
||||||
|
showLoginDialog,
|
||||||
|
showExpenseVideoPopup,
|
||||||
|
showPrizeSourcePopup,
|
||||||
|
currentExpenseVideo,
|
||||||
|
currentPrize,
|
||||||
|
loginForm,
|
||||||
|
carLetter,
|
||||||
|
carDigits,
|
||||||
|
userRegistration,
|
||||||
|
expandedTimelineIndex,
|
||||||
|
expandedGameId,
|
||||||
|
totalExpense,
|
||||||
|
totalPeople,
|
||||||
|
isApproved,
|
||||||
|
hasRegistered,
|
||||||
|
formatDate,
|
||||||
|
previewImage,
|
||||||
|
playExpenseVideo,
|
||||||
|
showMap,
|
||||||
|
uploadUserMedia,
|
||||||
|
handleLogin,
|
||||||
|
handleLetterInput,
|
||||||
|
handleDigitsInput,
|
||||||
|
submitLogin,
|
||||||
|
handleShare,
|
||||||
|
toggleTimeline,
|
||||||
|
toggleGame,
|
||||||
|
showPrizeSource,
|
||||||
|
showSponsorInfo
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -136,7 +136,7 @@
|
|||||||
<div class="sponsor-logo xiaomi-logo">
|
<div class="sponsor-logo xiaomi-logo">
|
||||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
<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"/>
|
<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>
|
<text x="50" y="58" font-family="Microsoft YaHei, Arial, sans-serif" font-size="24" font-weight="bold" fill="white" text-anchor="middle">洛江店</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="sponsor-info">
|
<div class="sponsor-info">
|
||||||
@@ -145,24 +145,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -713,27 +696,25 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sponsors-grid {
|
.sponsors-grid {
|
||||||
display: flex;
|
display: grid;
|
||||||
justify-content: center;
|
grid-template-columns: repeat(3, 1fr);
|
||||||
align-items: stretch;
|
gap: 20px;
|
||||||
gap: 16px;
|
|
||||||
margin-top: 32px;
|
margin-top: 32px;
|
||||||
flex-wrap: wrap;
|
max-width: 800px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card {
|
.sponsor-card {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||||
transition: transform 0.3s, box-shadow 0.3s;
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
flex: 0 1 140px;
|
|
||||||
max-width: 150px;
|
|
||||||
min-width: 120px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card:hover {
|
.sponsor-card:hover {
|
||||||
@@ -742,12 +723,12 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-logo {
|
.sponsor-logo {
|
||||||
width: 45px;
|
width: 60px;
|
||||||
height: 45px;
|
height: 60px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-logo svg {
|
.sponsor-logo svg {
|
||||||
@@ -768,20 +749,30 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-name {
|
.sponsor-name {
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #333;
|
color: #333;
|
||||||
margin: 0 0 4px;
|
margin: 0 0 6px;
|
||||||
line-height: 1.3;
|
line-height: 1.4;
|
||||||
word-break: break-all;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-desc {
|
.sponsor-desc {
|
||||||
font-size: 10px;
|
font-size: 11px;
|
||||||
color: #999;
|
color: #999;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
word-break: break-all;
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CTA区域 */
|
/* CTA区域 */
|
||||||
@@ -930,28 +921,26 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sponsors-grid {
|
.sponsors-grid {
|
||||||
gap: 12px;
|
grid-template-columns: 1fr;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-card {
|
.sponsor-card {
|
||||||
flex: 0 1 calc(20% - 10px);
|
padding: 16px;
|
||||||
min-width: 100px;
|
|
||||||
max-width: 130px;
|
|
||||||
padding: 10px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-logo {
|
.sponsor-logo {
|
||||||
width: 40px;
|
width: 50px;
|
||||||
height: 40px;
|
height: 50px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-name {
|
.sponsor-name {
|
||||||
font-size: 10px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsor-desc {
|
.sponsor-desc {
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@
|
|||||||
@click="showCarModelPicker = true"
|
@click="showCarModelPicker = true"
|
||||||
/>
|
/>
|
||||||
<van-field
|
<van-field
|
||||||
|
v-model="form.carNumber"
|
||||||
label="车牌号"
|
label="车牌号"
|
||||||
:rules="[{ required: true, message: '请输入车牌号' }]"
|
:rules="[{ required: true, message: '请输入车牌号' }]"
|
||||||
class="car-number-field"
|
class="car-number-field"
|
||||||
@@ -108,6 +109,46 @@
|
|||||||
@cancel="showCarModelPicker = false"
|
@cancel="showCarModelPicker = false"
|
||||||
/>
|
/>
|
||||||
</van-popup>
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 群二维码弹窗 -->
|
||||||
|
<van-popup
|
||||||
|
v-model:show="showQrcodeDialog"
|
||||||
|
:close-on-click-overlay="false"
|
||||||
|
:overlay-style="{ background: 'rgba(0, 0, 0, 0.8)' }"
|
||||||
|
>
|
||||||
|
<div class="qrcode-card">
|
||||||
|
<div class="qrcode-title">
|
||||||
|
<van-icon name="success" color="#FF6700" size="32" />
|
||||||
|
<h2>🎉 报名成功!</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qrcode-important">
|
||||||
|
<van-icon name="warning-o" color="#FF6700" />
|
||||||
|
<span>重要!请扫码加入活动群</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qrcode-image-wrapper">
|
||||||
|
<img src="/qun.jpg" alt="群二维码" class="qrcode-image" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="qrcode-tip">
|
||||||
|
<van-icon name="info-o" />
|
||||||
|
<span>长按二维码保存图片</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
color="#FF6700"
|
||||||
|
@click="closeQrcodeDialog"
|
||||||
|
class="qrcode-confirm-btn"
|
||||||
|
>
|
||||||
|
我知道了
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -121,6 +162,7 @@ const route = useRoute();
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const showCarModelPicker = ref(false);
|
const showCarModelPicker = ref(false);
|
||||||
|
const showQrcodeDialog = ref(false);
|
||||||
const carModels = ref([
|
const carModels = ref([
|
||||||
{ text: '小米SU7', value: '小米SU7' },
|
{ text: '小米SU7', value: '小米SU7' },
|
||||||
{ text: '小米YU7', value: '小米YU7' },
|
{ text: '小米YU7', value: '小米YU7' },
|
||||||
@@ -225,11 +267,18 @@ const onSubmit = async () => {
|
|||||||
message: `您的报名ID: ${data.id}\n已自动登录,请等待管理员审核`
|
message: `您的报名ID: ${data.id}\n已自动登录,请等待管理员审核`
|
||||||
});
|
});
|
||||||
|
|
||||||
router.back();
|
// 显示醒目的群二维码弹窗
|
||||||
|
showQrcodeDialog.value = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showToast('报名失败');
|
showToast('报名失败');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 关闭二维码弹窗
|
||||||
|
const closeQrcodeDialog = () => {
|
||||||
|
showQrcodeDialog.value = false;
|
||||||
|
router.back();
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -347,7 +396,7 @@ const onSubmit = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.plate-digits {
|
.plate-digits {
|
||||||
width: 110px;
|
width: 95px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
border: 2px solid rgba(255,255,255,0.9);
|
border: 2px solid rgba(255,255,255,0.9);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
@@ -367,4 +416,104 @@ const onSubmit = async () => {
|
|||||||
color: #ddd;
|
color: #ddd;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 群二维码弹窗样式 */
|
||||||
|
.qrcode-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
max-width: 400px;
|
||||||
|
width: 90vw;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
animation: slideUp 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-title {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-title h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 12px 0 0 0;
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-important {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: linear-gradient(135deg, #FFF5E6 0%, #FFE8CC 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
border: 2px solid #FF6700;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 0 0 0 rgba(255, 103, 0, 0.4);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.02);
|
||||||
|
box-shadow: 0 0 0 8px rgba(255, 103, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-important span {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #FF6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-image-wrapper {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #f8f8f8;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-image {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
height: auto;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrcode-confirm-btn {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,606 @@
|
|||||||
|
<template>
|
||||||
|
<div class="admin-manage">
|
||||||
|
<div class="header">
|
||||||
|
<h2>管理员账号管理</h2>
|
||||||
|
<button @click="showCreateDialog = true" class="btn-primary">
|
||||||
|
<span>➕</span> 添加管理员
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-cards">
|
||||||
|
<div v-for="admin in admins" :key="admin.id" class="admin-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<div class="admin-info">
|
||||||
|
<div class="admin-avatar">
|
||||||
|
{{ admin.username.charAt(0).toUpperCase() }}
|
||||||
|
</div>
|
||||||
|
<div class="admin-details">
|
||||||
|
<div class="admin-name">{{ admin.name }}</div>
|
||||||
|
<div class="admin-username">@{{ admin.username }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span :class="['role-badge', admin.role]">
|
||||||
|
{{ admin.role === 'super' ? '超级管理员' : '管理员' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-meta">
|
||||||
|
<span class="meta-item">
|
||||||
|
<span class="meta-icon">📅</span>
|
||||||
|
创建于 {{ formatDate(admin.createdAt) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-actions">
|
||||||
|
<button @click="editAdmin(admin)" class="btn-action btn-edit">
|
||||||
|
<span>✏️</span> 编辑
|
||||||
|
</button>
|
||||||
|
<button @click="changePassword(admin)" class="btn-action btn-password">
|
||||||
|
<span>🔑</span> 改密码
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="admin.role !== 'super'"
|
||||||
|
@click="deleteAdmin(admin)"
|
||||||
|
class="btn-action btn-delete"
|
||||||
|
>
|
||||||
|
<span>🗑️</span> 删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!admins.length" class="empty-state">
|
||||||
|
<div class="empty-icon">👥</div>
|
||||||
|
<div class="empty-text">暂无管理员</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 创建管理员对话框 -->
|
||||||
|
<div v-if="showCreateDialog" class="dialog-overlay" @click.self="showCreateDialog = false">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>添加管理员</h3>
|
||||||
|
<form @submit.prevent="createAdmin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input v-model="newAdmin.username" required placeholder="请输入用户名" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>姓名</label>
|
||||||
|
<input v-model="newAdmin.name" required placeholder="请输入姓名" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>密码</label>
|
||||||
|
<input v-model="newAdmin.password" type="password" required placeholder="请输入密码" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>角色</label>
|
||||||
|
<select v-model="newAdmin.role">
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
<option value="super">超级管理员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" @click="showCreateDialog = false" class="btn-cancel">取消</button>
|
||||||
|
<button type="submit" class="btn-primary">创建</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 编辑管理员对话框 -->
|
||||||
|
<div v-if="showEditDialog" class="dialog-overlay" @click.self="showEditDialog = false">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>编辑管理员</h3>
|
||||||
|
<form @submit.prevent="updateAdmin">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input :value="editingAdmin.username" disabled />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>姓名</label>
|
||||||
|
<input v-model="editingAdmin.name" required placeholder="请输入姓名" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>角色</label>
|
||||||
|
<select v-model="editingAdmin.role">
|
||||||
|
<option value="admin">管理员</option>
|
||||||
|
<option value="super">超级管理员</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" @click="showEditDialog = false" class="btn-cancel">取消</button>
|
||||||
|
<button type="submit" class="btn-primary">保存</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 修改密码对话框 -->
|
||||||
|
<div v-if="showPasswordDialog" class="dialog-overlay" @click.self="showPasswordDialog = false">
|
||||||
|
<div class="dialog">
|
||||||
|
<h3>修改密码</h3>
|
||||||
|
<form @submit.prevent="updatePassword">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>用户名</label>
|
||||||
|
<input :value="passwordAdmin.username" disabled />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>新密码</label>
|
||||||
|
<input v-model="newPassword" type="password" required placeholder="请输入新密码" />
|
||||||
|
</div>
|
||||||
|
<div class="dialog-actions">
|
||||||
|
<button type="button" @click="showPasswordDialog = false" class="btn-cancel">取消</button>
|
||||||
|
<button type="submit" class="btn-primary">确认</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue';
|
||||||
|
import api from '../../api';
|
||||||
|
import { showToast } from 'vant';
|
||||||
|
|
||||||
|
const admins = ref([]);
|
||||||
|
const showCreateDialog = ref(false);
|
||||||
|
const showEditDialog = ref(false);
|
||||||
|
const showPasswordDialog = ref(false);
|
||||||
|
|
||||||
|
const newAdmin = ref({
|
||||||
|
username: '',
|
||||||
|
name: '',
|
||||||
|
password: '',
|
||||||
|
role: 'admin'
|
||||||
|
});
|
||||||
|
|
||||||
|
const editingAdmin = ref({});
|
||||||
|
const passwordAdmin = ref({});
|
||||||
|
const newPassword = ref('');
|
||||||
|
|
||||||
|
const loadAdmins = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.getAdmins();
|
||||||
|
console.log('管理员数据:', data);
|
||||||
|
admins.value = data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载管理员列表失败:', error);
|
||||||
|
console.error('错误详情:', error.response?.data);
|
||||||
|
if (error.response?.status === 403) {
|
||||||
|
showToast('需要超级管理员权限');
|
||||||
|
} else if (error.response?.status === 401) {
|
||||||
|
showToast('登录已过期,请重新登录');
|
||||||
|
} else {
|
||||||
|
showToast(error.response?.data?.error || '加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const createAdmin = async () => {
|
||||||
|
try {
|
||||||
|
await api.createAdmin(newAdmin.value);
|
||||||
|
showToast('创建成功');
|
||||||
|
showCreateDialog.value = false;
|
||||||
|
newAdmin.value = { username: '', name: '', password: '', role: 'admin' };
|
||||||
|
loadAdmins();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.response?.data?.error || '创建失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editAdmin = (admin) => {
|
||||||
|
editingAdmin.value = { ...admin };
|
||||||
|
showEditDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAdmin = async () => {
|
||||||
|
try {
|
||||||
|
await api.updateAdmin(editingAdmin.value.id, {
|
||||||
|
name: editingAdmin.value.name,
|
||||||
|
role: editingAdmin.value.role
|
||||||
|
});
|
||||||
|
showToast('更新成功');
|
||||||
|
showEditDialog.value = false;
|
||||||
|
loadAdmins();
|
||||||
|
} catch (error) {
|
||||||
|
showToast('更新失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = (admin) => {
|
||||||
|
passwordAdmin.value = admin;
|
||||||
|
newPassword.value = '';
|
||||||
|
showPasswordDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePassword = async () => {
|
||||||
|
try {
|
||||||
|
await api.updateAdminPassword(passwordAdmin.value.id, {
|
||||||
|
newPassword: newPassword.value
|
||||||
|
});
|
||||||
|
showToast('密码修改成功');
|
||||||
|
showPasswordDialog.value = false;
|
||||||
|
} catch (error) {
|
||||||
|
showToast('修改失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAdmin = async (admin) => {
|
||||||
|
if (!confirm(`确定删除管理员 ${admin.username} 吗?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.deleteAdmin(admin.id);
|
||||||
|
showToast('删除成功');
|
||||||
|
loadAdmins();
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.response?.data?.error || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
if (!date) return '-';
|
||||||
|
try {
|
||||||
|
return new Date(date).toLocaleDateString('zh-CN');
|
||||||
|
} catch (error) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadAdmins);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.admin-manage {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 4px 12px rgba(77, 171, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 16px rgba(77, 171, 247, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary span {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cards {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
transition: all 0.3s;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-avatar {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-details {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-name {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-username {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge {
|
||||||
|
padding: 6px 14px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.super {
|
||||||
|
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.role-badge.admin {
|
||||||
|
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 2px 8px rgba(77, 171, 247, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-top: 1px solid #f5f5f5;
|
||||||
|
border-bottom: 1px solid #f5f5f5;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 80px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit:hover {
|
||||||
|
background: #bbdefb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-password {
|
||||||
|
background: #fff3e0;
|
||||||
|
color: #f57c00;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-password:hover {
|
||||||
|
background: #ffe0b2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete {
|
||||||
|
background: #ffebee;
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete:hover {
|
||||||
|
background: #ffcdd2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 64px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 20px;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: white;
|
||||||
|
padding: 28px;
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 440px;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog h3 {
|
||||||
|
margin: 0 0 24px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 2px solid #e8e8e8;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4dabf7;
|
||||||
|
box-shadow: 0 0 0 3px rgba(77, 171, 247, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:disabled {
|
||||||
|
background: #f5f5f5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input::placeholder {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-actions button {
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-cancel:hover {
|
||||||
|
background: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.admin-manage {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-cards {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-action {
|
||||||
|
min-width: 70px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,9 +5,20 @@
|
|||||||
<h1>管理后台</h1>
|
<h1>管理后台</h1>
|
||||||
<p>泉友米车友会</p>
|
<p>泉友米车友会</p>
|
||||||
</div>
|
</div>
|
||||||
<van-button class="logout-btn" size="small" round @click="logout">
|
<div class="header-actions">
|
||||||
<van-icon name="logout" /> 退出
|
<van-button
|
||||||
</van-button>
|
v-if="isSuper"
|
||||||
|
class="admin-btn"
|
||||||
|
size="small"
|
||||||
|
round
|
||||||
|
@click="goToAdminManage"
|
||||||
|
>
|
||||||
|
<van-icon name="manager-o" /> 管理员
|
||||||
|
</van-button>
|
||||||
|
<van-button class="logout-btn" size="small" round @click="logout">
|
||||||
|
<van-icon name="logout" /> 退出
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="dashboard-content">
|
<div class="dashboard-content">
|
||||||
@@ -230,6 +241,7 @@ import api from '../../api';
|
|||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const activities = ref([]);
|
const activities = ref([]);
|
||||||
|
const isSuper = ref(false);
|
||||||
|
|
||||||
const showActivityForm = ref(false);
|
const showActivityForm = ref(false);
|
||||||
const showMapPicker = ref(false);
|
const showMapPicker = ref(false);
|
||||||
@@ -290,14 +302,29 @@ const formatDateTime = (dateTime) => {
|
|||||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 安全解析datetime-local格式,确保以本地时区创建Date对象
|
||||||
|
const parseDateTimeLocal = (dateTimeStr) => {
|
||||||
|
console.log('[时区转换] 输入的datetime-local字符串:', dateTimeStr);
|
||||||
|
const [datePart, timePart] = dateTimeStr.split('T');
|
||||||
|
const [year, month, day] = datePart.split('-').map(Number);
|
||||||
|
const [hours, minutes] = timePart.split(':').map(Number);
|
||||||
|
const localDate = new Date(year, month - 1, day, hours, minutes);
|
||||||
|
console.log('[时区转换] 创建的本地Date对象:', localDate.toString());
|
||||||
|
console.log('[时区转换] 转换为UTC (ISO):', localDate.toISOString());
|
||||||
|
console.log('[时区转换] 时区偏移(分钟):', localDate.getTimezoneOffset());
|
||||||
|
return localDate;
|
||||||
|
};
|
||||||
|
|
||||||
// 开始时间确认
|
// 开始时间确认
|
||||||
const confirmStartTime = () => {
|
const confirmStartTime = () => {
|
||||||
if (!startTimeInput.value) {
|
if (!startTimeInput.value) {
|
||||||
showToast('请选择开始时间');
|
showToast('请选择开始时间');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedDate = new Date(startTimeInput.value);
|
console.log('[开始时间] 用户选择的时间:', startTimeInput.value);
|
||||||
|
const selectedDate = parseDateTimeLocal(startTimeInput.value);
|
||||||
activityForm.value.startTime = selectedDate.toISOString();
|
activityForm.value.startTime = selectedDate.toISOString();
|
||||||
|
console.log('[开始时间] 保存到表单的UTC时间:', activityForm.value.startTime);
|
||||||
showStartTimePicker.value = false;
|
showStartTimePicker.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -307,12 +334,14 @@ const confirmDeadline = () => {
|
|||||||
showToast('请选择报名截止时间');
|
showToast('请选择报名截止时间');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const selectedDate = new Date(deadlineInput.value);
|
console.log('[截止时间] 用户选择的时间:', deadlineInput.value);
|
||||||
|
const selectedDate = parseDateTimeLocal(deadlineInput.value);
|
||||||
if (activityForm.value.startTime && selectedDate > new Date(activityForm.value.startTime)) {
|
if (activityForm.value.startTime && selectedDate > new Date(activityForm.value.startTime)) {
|
||||||
showToast('报名截止时间不能晚于活动开始时间');
|
showToast('报名截止时间不能晚于活动开始时间');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
activityForm.value.registrationDeadline = selectedDate.toISOString();
|
activityForm.value.registrationDeadline = selectedDate.toISOString();
|
||||||
|
console.log('[截止时间] 保存到表单的UTC时间:', activityForm.value.registrationDeadline);
|
||||||
showDeadlinePicker.value = false;
|
showDeadlinePicker.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -573,10 +602,17 @@ const submitActivity = async () => {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
|
localStorage.removeItem('admin');
|
||||||
router.push('/admin/login');
|
router.push('/admin/login');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const goToAdminManage = () => {
|
||||||
|
router.push('/admin/admins');
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
const admin = JSON.parse(localStorage.getItem('admin') || '{}');
|
||||||
|
isSuper.value = admin.role === 'super';
|
||||||
loadActivities();
|
loadActivities();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -609,6 +645,22 @@ onMounted(() => {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn {
|
||||||
|
background: #4dabf7;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-btn:active {
|
||||||
|
background: #339af0;
|
||||||
|
}
|
||||||
|
|
||||||
.logout-btn {
|
.logout-btn {
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const onSubmit = async () => {
|
|||||||
try {
|
try {
|
||||||
const { data } = await api.adminLogin(form.value);
|
const { data } = await api.adminLogin(form.value);
|
||||||
localStorage.setItem('token', data.token);
|
localStorage.setItem('token', data.token);
|
||||||
|
localStorage.setItem('admin', JSON.stringify(data.admin));
|
||||||
showToast('登录成功');
|
showToast('登录成功');
|
||||||
router.push('/admin');
|
router.push('/admin');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/* 奖品管理样式 */
|
||||||
|
.prize-manage-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-manage-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-image-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-manage-card .prize-image {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-details {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-details .prize-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-特等奖 {
|
||||||
|
background: linear-gradient(135deg, #FF0000 0%, #FF6B6B 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-一等奖 {
|
||||||
|
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-二等奖 {
|
||||||
|
background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-三等奖 {
|
||||||
|
background: linear-gradient(135deg, #CD7F32 0%, #B8733E 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-四等奖 {
|
||||||
|
background: linear-gradient(135deg, #8B7355 0%, #A0826D 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-参与奖 {
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-伴手礼 {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-details .prize-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-desc-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-quantity-text {
|
||||||
|
color: #FF6700;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-ops {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-ops .van-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-ops .van-icon:active {
|
||||||
|
color: #FF6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等级选择器 */
|
||||||
|
.level-picker {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-picker .picker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-picker .picker-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-picker .picker-header .van-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-item:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
background: #fff7e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-item .level-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { showToast, showConfirmDialog } from 'vant';
|
||||||
|
import api from '../../api';
|
||||||
|
|
||||||
|
export function usePrizeManage(activityId) {
|
||||||
|
const prizes = ref([]);
|
||||||
|
const showPrizeForm = ref(false);
|
||||||
|
const showLevelPicker = ref(false);
|
||||||
|
const showSourcePicker = ref(false);
|
||||||
|
const prizeImage = ref([]);
|
||||||
|
const prizeForm = ref({
|
||||||
|
id: null,
|
||||||
|
activityId: activityId.value,
|
||||||
|
name: '',
|
||||||
|
level: '',
|
||||||
|
description: '',
|
||||||
|
quantity: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
source: 'purchase',
|
||||||
|
sponsor: '',
|
||||||
|
image: null
|
||||||
|
});
|
||||||
|
|
||||||
|
const prizeLevels = ['特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼'];
|
||||||
|
|
||||||
|
const loadPrizes = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await api.getActivity(activityId.value);
|
||||||
|
prizes.value = data.prizes || [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载奖品失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editPrize = (prize) => {
|
||||||
|
prizeForm.value = {
|
||||||
|
id: prize.id,
|
||||||
|
activityId: activityId.value,
|
||||||
|
name: prize.name,
|
||||||
|
level: prize.level,
|
||||||
|
description: prize.description || '',
|
||||||
|
quantity: prize.quantity,
|
||||||
|
sortOrder: prize.sortOrder || 0,
|
||||||
|
source: prize.source || 'purchase',
|
||||||
|
sponsor: prize.sponsor || '',
|
||||||
|
image: prize.image
|
||||||
|
};
|
||||||
|
// 回显图片 - 需要使用完整URL
|
||||||
|
if (prize.image) {
|
||||||
|
const imageUrl = prize.image.startsWith('http') ? prize.image :
|
||||||
|
(import.meta.env.DEV ? `http://localhost:3000${prize.image}` : `https://mi.xyvan.cn${prize.image}`);
|
||||||
|
prizeImage.value = [{ url: imageUrl }];
|
||||||
|
} else {
|
||||||
|
prizeImage.value = [];
|
||||||
|
}
|
||||||
|
showPrizeForm.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onPrizeImageRead = (file) => {
|
||||||
|
prizeForm.value.imageFile = file.file;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectLevel = (level) => {
|
||||||
|
prizeForm.value.level = level;
|
||||||
|
showLevelPicker.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSource = (source) => {
|
||||||
|
prizeForm.value.source = source;
|
||||||
|
if (source === 'purchase') {
|
||||||
|
prizeForm.value.sponsor = '';
|
||||||
|
}
|
||||||
|
showSourcePicker.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitPrize = async () => {
|
||||||
|
if (!prizeForm.value.name || !prizeForm.value.level || !prizeForm.value.source) {
|
||||||
|
showToast('请填写必填项');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prizeForm.value.source !== 'purchase' && !prizeForm.value.sponsor) {
|
||||||
|
showToast('请填写赞助者名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingToast = showToast({
|
||||||
|
type: 'loading',
|
||||||
|
message: '保存中...',
|
||||||
|
forbidClick: true,
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('activityId', activityId.value);
|
||||||
|
formData.append('name', prizeForm.value.name);
|
||||||
|
formData.append('level', prizeForm.value.level);
|
||||||
|
formData.append('description', prizeForm.value.description);
|
||||||
|
formData.append('quantity', prizeForm.value.quantity);
|
||||||
|
formData.append('sortOrder', prizeForm.value.sortOrder);
|
||||||
|
formData.append('source', prizeForm.value.source);
|
||||||
|
if (prizeForm.value.sponsor) {
|
||||||
|
formData.append('sponsor', prizeForm.value.sponsor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prizeForm.value.imageFile) {
|
||||||
|
formData.append('image', prizeForm.value.imageFile);
|
||||||
|
} else if (prizeImage.value.length === 0 && prizeForm.value.image) {
|
||||||
|
// 用户删除了图片
|
||||||
|
formData.append('removeImage', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prizeForm.value.id) {
|
||||||
|
await api.updatePrize(prizeForm.value.id, formData);
|
||||||
|
} else {
|
||||||
|
await api.addPrize(formData);
|
||||||
|
}
|
||||||
|
|
||||||
|
showToast('保存成功');
|
||||||
|
showPrizeForm.value = false;
|
||||||
|
resetPrizeForm();
|
||||||
|
loadPrizes();
|
||||||
|
} catch (error) {
|
||||||
|
showToast('保存失败');
|
||||||
|
console.error('保存奖品失败:', error);
|
||||||
|
} finally {
|
||||||
|
loadingToast.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deletePrizeItem = async (id) => {
|
||||||
|
try {
|
||||||
|
await showConfirmDialog({
|
||||||
|
title: '确认删除',
|
||||||
|
message: '确定要删除这个奖品吗?'
|
||||||
|
});
|
||||||
|
|
||||||
|
await api.deletePrize(id);
|
||||||
|
showToast('删除成功');
|
||||||
|
await loadPrizes();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel') {
|
||||||
|
showToast('删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetPrizeForm = () => {
|
||||||
|
prizeForm.value = {
|
||||||
|
id: null,
|
||||||
|
activityId: activityId.value,
|
||||||
|
name: '',
|
||||||
|
level: '',
|
||||||
|
description: '',
|
||||||
|
quantity: 1,
|
||||||
|
sortOrder: 0,
|
||||||
|
source: 'purchase',
|
||||||
|
sponsor: '',
|
||||||
|
image: null
|
||||||
|
};
|
||||||
|
prizeImage.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
prizes,
|
||||||
|
showPrizeForm,
|
||||||
|
showLevelPicker,
|
||||||
|
showSourcePicker,
|
||||||
|
prizeImage,
|
||||||
|
prizeForm,
|
||||||
|
prizeLevels,
|
||||||
|
loadPrizes,
|
||||||
|
editPrize,
|
||||||
|
onPrizeImageRead,
|
||||||
|
selectLevel,
|
||||||
|
selectSource,
|
||||||
|
submitPrize,
|
||||||
|
deletePrizeItem,
|
||||||
|
resetPrizeForm
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
// 游戏项目数据
|
||||||
|
export const gameItems = [
|
||||||
|
{
|
||||||
|
icon: '🎯',
|
||||||
|
name: '套圈圈',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>站在指定位置,用圈圈套中目标物品</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 套中1个:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 套中3个:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 套中5个:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🏺',
|
||||||
|
name: '投壶',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>将箭矢投入壶中,考验准确度</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 投中3个:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 投中5个:<span class="score-badge">+4分</span></p>
|
||||||
|
<p>• 全部投中:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎲',
|
||||||
|
name: '摇骰子(067玩法)',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>玩家与庄家对战,比拼骰子点数大小</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 玩家赢庄家:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 玩家输庄家:<span class="score-badge">-1分</span></p>
|
||||||
|
<p>• 摇出5个正豹子:<span class="score-badge">+5分</span>(直接判赢)</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎲',
|
||||||
|
name: '摇骰子(单骰比大小)',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>单个骰子比大小,玩家与庄家对战</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 玩家赢庄家:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 玩家输庄家:<span class="score-badge">-1分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🐂',
|
||||||
|
name: '牛牛',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>扑克牌牛牛游戏,与对手比拼牌型大小</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 赢一局:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 连赢3局:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 连赢5局:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '✊',
|
||||||
|
name: '闽南划拳(两拳倒)',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>闽南传统划拳游戏,两拳倒玩法</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 玩家赢:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 玩家输:<span class="score-badge">-1分</span></p>
|
||||||
|
<p>• 一人一拳投资,赢的玩家:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 最高叠加投资到划5拳,赢的玩家:<span class="score-badge">+5分</span></p>
|
||||||
|
<p class="highlight-tip">💡 投资机制:可叠加投资,赢得越多分数越高!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '⚽',
|
||||||
|
name: '足球射门',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>踢足球射门,进球数决定得分</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 进1球:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 进3球:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 进5球:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎮',
|
||||||
|
name: '王者荣耀',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>组队进行王者荣耀对战</p>
|
||||||
|
<p><strong>5V5模式(凑齐10人):</strong></p>
|
||||||
|
<p>• 胜方队员(4人):<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 胜方MVP(评分最高):<span class="score-badge">+5分</span></p>
|
||||||
|
<p>• 败方MVP(评分最高):<span class="score-badge">+1分</span></p>
|
||||||
|
<p><strong>SOLO模式(1V1):</strong></p>
|
||||||
|
<p>• 胜者(一血或一塔):<span class="score-badge">+2分</span></p>
|
||||||
|
<p class="highlight-tip">💡 团队配合最重要,选好英雄!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '💧',
|
||||||
|
name: '抛水瓶',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>抛水瓶使其立住,考验技巧</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 立住1个:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 立住3个:<span class="score-badge">+4分</span></p>
|
||||||
|
<p>• 立住5个:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎴',
|
||||||
|
name: '扑克游戏',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>多种扑克玩法,赢家得分</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 赢家:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 输家:<span class="score-badge">-1分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🐊',
|
||||||
|
name: '鳄鱼牙齿/插剑玩具',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>按压鳄鱼牙齿或插剑,避免触发机关</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 按/插过半数:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 每多按/插一个:<span class="score-badge">+1分</span>(累加)</p>
|
||||||
|
<p>• 最高封顶:<span class="score-badge">+5分</span></p>
|
||||||
|
<p>• 触发机关(爆了):<span class="score-badge">积分清0</span></p>
|
||||||
|
<p class="highlight-tip">💡 胆大心细,步步为营!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🪑',
|
||||||
|
name: '最终复活挑战 - 抢椅子',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail highlight-sub">
|
||||||
|
<p><strong>游戏规则:</strong>音乐停止时抢座位,未抢到者淘汰</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 冠军:<span class="score-badge">+5分</span></p>
|
||||||
|
<p>• 亚军:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 季军:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 淘汰:<span class="score-badge">-1分</span></p>
|
||||||
|
<p class="highlight-tip">⚡ 这是翻盘的最佳机会!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
@echo off
|
||||||
|
echo ========================================
|
||||||
|
echo 泉友米系统初始化
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
echo [1/2] 运行数据库迁移...
|
||||||
|
node backend/migrations/index.js
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo ✗ 迁移失败,请检查错误信息
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo [2/2] 初始化管理员账号...
|
||||||
|
node backend/init-admin.js
|
||||||
|
if %errorlevel% neq 0 (
|
||||||
|
echo.
|
||||||
|
echo ✗ 初始化失败,请检查错误信息
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo ✓ 初始化完成!
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
echo 管理员账号信息:
|
||||||
|
echo 超级管理员: yvan / Xu950329.
|
||||||
|
echo 普通管理员: admin / admin123
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "========================================"
|
||||||
|
echo "泉友米系统初始化"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
echo "[1/2] 运行数据库迁移..."
|
||||||
|
node backend/migrations/index.js
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "✗ 迁移失败,请检查错误信息"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "[2/2] 初始化管理员账号..."
|
||||||
|
node backend/init-admin.js
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "✗ 初始化失败,请检查错误信息"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "========================================"
|
||||||
|
echo "✓ 初始化完成!"
|
||||||
|
echo "========================================"
|
||||||
|
echo ""
|
||||||
|
echo "管理员账号信息:"
|
||||||
|
echo " 超级管理员: yvan / Xu950329."
|
||||||
|
echo " 普通管理员: admin / admin123"
|
||||||
|
echo ""
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
echo Running migration: Add group status field...
|
||||||
|
cd backend
|
||||||
|
node migrations/index.js
|
||||||
|
cd ..
|
||||||
|
echo Migration completed!
|
||||||
|
pause
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
@echo off
|
||||||
|
echo 正在运行数据库迁移...
|
||||||
|
cd backend
|
||||||
|
node migrations/index.js
|
||||||
|
cd ..
|
||||||
|
echo 迁移完成!
|
||||||
|
pause
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
@echo off
|
||||||
|
echo ==========================================
|
||||||
|
echo 数据库迁移 - 添加弹幕字段
|
||||||
|
echo ==========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 检查容器是否运行
|
||||||
|
docker ps | findstr quanyoumi-app >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo 错误: quanyoumi-app 容器未运行
|
||||||
|
echo 请先启动容器: docker-compose up -d
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo 正在容器内执行数据库迁移...
|
||||||
|
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||||
|
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo 迁移失败!
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==========================================
|
||||||
|
echo 迁移完成!请重启容器使更改生效
|
||||||
|
echo ==========================================
|
||||||
|
echo.
|
||||||
|
echo 重启命令: docker-compose restart
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "数据库迁移 - 添加弹幕字段"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查容器是否运行
|
||||||
|
if ! docker ps | grep -q quanyoumi-app; then
|
||||||
|
echo "错误: quanyoumi-app 容器未运行"
|
||||||
|
echo "请先启动容器: docker-compose up -d"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "正在容器内执行数据库迁移..."
|
||||||
|
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "迁移失败!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "迁移完成!请重启容器使更改生效"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "重启命令: docker-compose restart"
|
||||||
|
echo ""
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
# 签到功能实施说明
|
|
||||||
|
|
||||||
## 已完成的修改
|
|
||||||
|
|
||||||
### 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