Compare commits

...
3 Commits
Author SHA1 Message Date
Yvan fd49a9de88 feat: 添加管理员管理和场地图片功能 2026-04-17 18:09:02 +08:00
Yvan 2530ceb6b4 fix: 修复奖品图片和赞助商logo删除bug
- 修复编辑奖品时删除图片后保存,图片仍存在的问题
- 修复编辑赞助商时删除logo后保存,logo仍存在的问题
- 前端添加删除检测逻辑,传递removeImage/removeLogo标记
- 后端处理删除标记,将对应字段设为null
2026-04-10 09:32:29 +08:00
Yvan 4920620d2c 优化验证报名信息对话框车牌号输入布局,使标签和输入框在同一行显示 2026-04-09 15:37:40 +08:00
79 changed files with 8346 additions and 4361 deletions
+8
View File
@@ -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
+8
View File
@@ -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
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+1
View File
@@ -0,0 +1 @@
47
-279
View File
@@ -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
View File
@@ -20,14 +20,14 @@ FROM node:18-alpine
WORKDIR /app
# 安装 dumb-init 和 envsubst
RUN apk add --no-cache dumb-init gettext
# 安装 dumb-init、envsubst 和 SQLite 编译依赖
RUN apk add --no-cache dumb-init gettext python3 make g++ sqlite-dev
# 复制后端依赖文件
COPY backend/package*.json ./
# 安装后端依赖(仅生产依赖)
RUN npm ci --only=production
RUN npm install --only=production
# 复制后端源码
COPY backend/ ./
@@ -35,8 +35,8 @@ COPY backend/ ./
# 从前端构建阶段复制构建产物
COPY --from=frontend-builder /app/frontend/dist ./public
# 创建上传目录
RUN mkdir -p uploads
# 创建上传和数据目录
RUN mkdir -p uploads data
# 复制启动脚本
COPY docker-entrypoint.sh /usr/local/bin/
+3 -14
View File
@@ -1,17 +1,6 @@
PORT=3000
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your-db-password
DB_NAME=quanyoumi
JWT_SECRET=your-secret-key-change-in-production
# MinIO 配置
MINIO_ENDPOINT=localhost # 后端连接 MinIO 的地址
MINIO_PORT=9000
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=jFywUsaCb4zNr2LV7hTP
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=quanyoumi
# MINIO_PUBLIC_URL(可选):前端访问文件的公开地址,不填则自动用 MINIO_ENDPOINT 拼接
# 生产环境如果用了反向代理(如 Nginx),填写外网地址,例如:https://cdn.yourdomain.com
# MINIO_PUBLIC_URL=http://localhost:9000
# 高德地图配置
AMAP_KEY=your-amap-key
AMAP_SECURITY_CODE=your-amap-security-code
Binary file not shown.
+49
View File
@@ -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
View File
@@ -7,19 +7,64 @@ async function initAdmin() {
try {
await sequelize.sync({ force: false });
const hashedPassword = bcrypt.hashSync('admin123', 10);
await Admin.create({
username: 'admin',
password: hashedPassword,
name: '管理员'
});
// 检查role字段是否存在
const [results] = await sequelize.query("PRAGMA table_info(Admins);");
const hasRoleColumn = results.some(col => col.name === 'role');
console.log('管理员账号创建成功');
console.log('用户名: admin');
console.log('密码: admin123');
if (!hasRoleColumn) {
console.log('⚠️ role字段不存在,请先运行迁移: node backend/migrations/index.js');
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);
} catch (error) {
console.error('创建失败:', error.message);
console.error('✗ 初始化失败:', error.message);
console.error(' 详细错误:', error);
process.exit(1);
}
}
+27
View File
@@ -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;
+27 -12
View File
@@ -1,11 +1,27 @@
const multer = require('multer');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { uploadBuffer } = require('../utils/minio');
const fs = require('fs');
// 确保上传目录存在
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({
storage: multer.memoryStorage(),
storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
fileFilter: (req, file, cb) => {
const extname = /jpeg|jpg|png|gif|mp4|mov|avi/.test(
path.extname(file.originalname).toLowerCase()
@@ -16,15 +32,14 @@ const upload = multer({
}
});
// 将 req.files 或 req.file 上传到 MinIO,返回 url 数组或单个 url
const uploadFilesToMinio = async (files) => {
// 返回文件访问URL
const getFileUrls = (files) => {
const list = Array.isArray(files) ? files : [files];
return Promise.all(list.map(async (file) => {
const ext = path.extname(file.originalname).toLowerCase();
const filename = `${Date.now()}-${uuidv4()}${ext}`;
const url = await uploadBuffer(file.buffer, filename, file.mimetype);
return { ...file, filename, url };
return list.map(file => ({
url: `/uploads/${file.filename}`,
filename: file.filename,
mimetype: file.mimetype
}));
};
module.exports = { upload, uploadFilesToMinio };
module.exports = { upload, getFileUrls };
+84
View File
@@ -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(' - 特等奖等级移除完成');
}
};
+12
View File
@@ -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;
`);
}
};
+190
View File
@@ -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');
`);
}
};
```
+141
View File
@@ -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;
+9
View File
@@ -38,6 +38,15 @@ const Activity = sequelize.define('Activity', {
type: DataTypes.DECIMAL(10, 2),
defaultValue: 0,
comment: '活动预算'
},
danmakuEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
comment: '弹幕开关'
},
danmakuContent: {
type: DataTypes.TEXT,
comment: '弹幕内容JSON'
}
});
+6 -1
View File
@@ -16,7 +16,12 @@ const Admin = sequelize.define('Admin', {
type: DataTypes.STRING,
allowNull: false
},
name: DataTypes.STRING
name: DataTypes.STRING,
role: {
type: DataTypes.STRING,
allowNull: false,
defaultValue: 'admin'
}
});
module.exports = Admin;
+44
View File
@@ -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;
+5 -1
View File
@@ -34,7 +34,11 @@ const Registration = sequelize.define('Registration', {
type: DataTypes.BOOLEAN,
defaultValue: false
},
checkInTime: DataTypes.DATE
checkInTime: DataTypes.DATE,
groupStatus: {
type: DataTypes.BOOLEAN,
defaultValue: false
}
});
module.exports = Registration;
+6 -1
View File
@@ -20,7 +20,12 @@ const Sponsor = sequelize.define('Sponsor', {
type: DataTypes.ENUM('title', 'sponsor'),
defaultValue: 'sponsor'
},
link: DataTypes.STRING
link: DataTypes.STRING,
sortOrder: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '排序值,数字越小越靠前'
}
});
module.exports = Sponsor;
+25
View File
@@ -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
View File
@@ -1,14 +1,17 @@
const { Sequelize } = require('sequelize');
const path = require('path');
const fs = require('fs');
const sequelize = new Sequelize(
process.env.DB_NAME || 'quanyoumi',
process.env.DB_USER || 'root',
process.env.DB_PASSWORD || '',
{
host: process.env.DB_HOST || 'localhost',
dialect: 'mysql',
logging: false
}
);
// 确保data目录存在
const dataDir = path.join(__dirname, '../data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: path.join(dataDir, 'database.sqlite'),
logging: false
});
module.exports = sequelize;
+10 -3
View File
@@ -4,11 +4,16 @@ const Registration = require('./Registration');
const Expense = require('./Expense');
const Photo = require('./Photo');
const Sponsor = require('./Sponsor');
const Prize = require('./Prize');
const Admin = require('./Admin');
const VenueImage = require('./VenueImage');
// 同步数据库
sequelize.sync({ alter: true }).then(() => {
console.log('数据库同步完成');
// 使用 force: false 避免 SQLite alter 表时的约束冲突问题
sequelize.sync({ force: false }).then(() => {
console.log('✓ 数据库同步完成');
}).catch(err => {
console.error('✗ 数据库同步失败:', err.message);
});
module.exports = {
@@ -18,5 +23,7 @@ module.exports = {
Expense,
Photo,
Sponsor,
Admin
Prize,
Admin,
VenueImage
};
+1442 -335
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -9,15 +9,15 @@
},
"dependencies": {
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.10.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.2",
"minio": "^8.0.7",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.0",
"sequelize": "^6.33.0",
"uuid": "^9.0.1"
"sql.js": "^1.14.1",
"sqlite3": "^5.1.7"
},
"devDependencies": {
"nodemon": "^3.0.1"
+64 -11
View File
@@ -1,14 +1,18 @@
const express = require('express');
const router = express.Router();
const { Activity, Photo, Sponsor, Expense, Registration } = require('../models');
const { upload, uploadFilesToMinio } = require('../middleware/upload');
const { Activity, Photo, Sponsor, Expense, Registration, Prize, VenueImage } = require('../models');
const { upload, getFileUrls } = require('../middleware/upload');
// 获取活动列表
router.get('/activities', async (req, res) => {
try {
const { status } = req.query;
const where = status ? { status } : {};
const activities = await Activity.findAll({ where, order: [['startTime', 'DESC']] });
const activities = await Activity.findAll({
where,
order: [['startTime', 'DESC']],
raw: true
});
res.json(activities);
} catch (error) {
res.status(500).json({ error: error.message });
@@ -18,15 +22,42 @@ router.get('/activities', async (req, res) => {
// 获取活动详情
router.get('/activities/:id', async (req, res) => {
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({
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) {
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) => {
try {
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
res.json(expenses);
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 || [])
}));
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) {
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) => {
try {
const { uploadedBy } = req.body;
const uploaded = await uploadFilesToMinio(req.files);
const uploaded = getFileUrls(req.files);
const photos = uploaded.map(file => ({
activityId: req.params.id,
url: file.url,
+268 -19
View File
@@ -3,9 +3,11 @@ const router = express.Router();
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const auth = require('../middleware/auth');
const { upload, uploadFilesToMinio } = require('../middleware/upload');
const { deleteFile } = require('../utils/minio');
const { Admin, Activity, Registration, Expense, Photo, Sponsor } = require('../models');
const authSuper = require('../middleware/authSuper');
const { upload, getFileUrls } = require('../middleware/upload');
const { Admin, Activity, Registration, Expense, Photo, Sponsor, Prize } = require('../models');
const fs = require('fs');
const path = require('path');
// 管理员登录
router.post('/login', async (req, res) => {
@@ -16,7 +18,146 @@ router.post('/login', async (req, res) => {
return res.status(401).json({ error: '用户名或密码错误' });
}
const token = jwt.sign({ id: admin.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
res.json({ token, admin: { id: admin.id, username: admin.username, name: admin.name } });
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) {
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) => {
try {
const { status, peopleCount } = req.body;
const { status, peopleCount, carNumber, groupStatus } = req.body;
const updateData = {};
if (status) updateData.status = status;
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 } });
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) => {
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) => {
try {
@@ -100,7 +273,7 @@ router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
let media = [];
if (req.files && req.files.length > 0) {
const uploaded = await uploadFilesToMinio(req.files);
const uploaded = getFileUrls(req.files);
media = uploaded.map(file => ({
url: file.url,
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
@@ -127,8 +300,13 @@ router.get('/expenses', auth, async (req, res) => {
try {
const { activityId } = req.query;
const where = activityId ? { activityId } : {};
const expenses = await Expense.findAll({ where });
res.json(expenses);
const expenses = await Expense.findAll({ where, 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 });
}
@@ -152,7 +330,7 @@ router.put('/expenses/:id', auth, upload.array('media', 5), async (req, res) =>
// 处理新上传的媒体
if (req.files && req.files.length > 0) {
const uploaded = await uploadFilesToMinio(req.files);
const uploaded = getFileUrls(req.files);
const newMedia = uploaded.map(file => ({
url: file.url,
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) => {
try {
const { activityId } = req.body;
const uploaded = await uploadFilesToMinio(req.files);
const uploaded = getFileUrls(req.files);
const photos = uploaded.map(file => ({
activityId,
url: file.url,
@@ -231,11 +409,12 @@ router.delete('/photos/:id', auth, async (req, res) => {
return res.status(404).json({ error: '照片不存在' });
}
// 从URL提取文件名
// 删除本地文件
const filename = photo.url.split('/').pop();
// 从MinIO删除文件
await deleteFile(filename);
const filePath = path.join(__dirname, '../uploads', filename);
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
// 从数据库删除记录
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) => {
try {
const { activityId, name, type, link } = req.body;
const { activityId, name, type, link, sortOrder } = req.body;
let logo = null;
if (req.file) {
const [uploaded] = await uploadFilesToMinio(req.file);
const [uploaded] = getFileUrls(req.file);
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);
} catch (error) {
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) => {
try {
const { name, type, link } = req.body;
const { name, type, link, sortOrder, removeLogo } = req.body;
const updateData = { name, type, link };
if (sortOrder !== undefined) {
updateData.sortOrder = sortOrder;
}
if (req.file) {
const [uploaded] = await uploadFilesToMinio(req.file);
const [uploaded] = getFileUrls(req.file);
updateData.logo = uploaded.url;
} else if (removeLogo === 'true') {
updateData.logo = null;
}
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;
+4 -3
View File
@@ -16,7 +16,7 @@ router.post('/registrations', async (req, res) => {
activityId, name, phone, carModel, carNumber, note
});
res.json(registration);
res.json(registration.toJSON());
} catch (error) {
res.status(500).json({ error: error.message });
}
@@ -25,7 +25,7 @@ router.post('/registrations', async (req, res) => {
// 查询报名状态
router.get('/registrations/:id', async (req, res) => {
try {
const registration = await Registration.findByPk(req.params.id);
const registration = await Registration.findByPk(req.params.id, { raw: true });
res.json(registration);
} catch (error) {
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) => {
try {
const registrations = await Registration.findAll({
where: { activityId: req.params.id, status: 'approved' }
where: { activityId: req.params.id, status: 'approved' },
raw: true
});
res.json(registrations);
} catch (error) {
+66
View File
@@ -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
View File
@@ -9,10 +9,14 @@ const app = express();
app.use(cors());
app.use(express.json());
// 静态文件服务 - 提供上传的文件
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// API 路由
app.use('/api', require('./routes/activity'));
app.use('/api', require('./routes/registration'));
app.use('/api/admin', require('./routes/admin'));
app.use('/api/admin', require('./routes/venue'));
// 生产环境下提供前端静态文件
if (process.env.NODE_ENV === 'production') {
@@ -26,11 +30,25 @@ if (process.env.NODE_ENV === 'production') {
}
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
if (process.env.NODE_ENV === 'production') {
console.log('运行模式: 生产环境(前后端一体)');
} else {
console.log('运行模式: 开发环境(仅后端API)');
// 启动服务器前运行数据库迁移
const MigrationManager = require('./migrations/index');
const runMigrations = async () => {
try {
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)');
}
});
});
+36 -8
View File
@@ -1,10 +1,26 @@
@echo off
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 泉友米车友会 Docker 镜像构建
echo 版本: 0.0.1
echo 版本: %VERSION%
echo ==========================================
REM 检查 Docker 是否安装
@@ -17,7 +33,7 @@ if errorlevel 1 (
echo.
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 (
echo.
@@ -26,23 +42,30 @@ if errorlevel 1 (
exit /b 1
)
REM 构建成功后自动递增版本号
set /a NEXT_PATCH=%PATCH%+1
echo %NEXT_PATCH%>%VERSION_FILE%
echo.
echo ==========================================
echo 构建完成!
echo 构建完成!版本号已更新
echo ==========================================
echo.
echo 当前版本: %VERSION%
echo 下次版本: %MAJOR%.%MINOR%.%NEXT_PATCH%
echo.
echo 镜像信息:
docker images | findstr quanyoumi-app
echo.
REM 标记镜像到阿里云仓库
echo 正在标记镜像到阿里云仓库...
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
docker tag quanyoumi-app:%VERSION% registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo.
echo 正在推送镜像到阿里云仓库...
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
if errorlevel 1 (
@@ -58,12 +81,17 @@ echo ==========================================
echo 推送完成!
echo ==========================================
echo.
echo 当前版本: %VERSION%
echo 下次版本: %MAJOR%.%MINOR%.%NEXT_PATCH%
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.
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.
echo 提示: 如需修改主版本号或次版本号,请编辑 build.bat 文件顶部的 MAJOR 和 MINOR 变量
echo.
pause
+36 -8
View File
@@ -1,13 +1,29 @@
#!/bin/bash
# 泉友米车友会 - Docker 构建脚本
# 版本: 0.0.1
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 "泉友米车友会 Docker 镜像构建"
echo "版本: 0.0.1"
echo "版本: $VERSION"
echo "=========================================="
# 检查 Docker 是否安装
@@ -19,25 +35,32 @@ fi
# 构建镜像
echo ""
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 "当前版本: $VERSION"
echo "下次版本: $MAJOR.$MINOR.$NEXT_PATCH"
echo ""
echo "镜像信息:"
docker images | grep quanyoumi-app
echo ""
# 标记镜像到阿里云仓库
echo "正在标记镜像到阿里云仓库..."
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
docker tag quanyoumi-app:$VERSION registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo ""
echo "正在推送镜像到阿里云仓库..."
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo ""
@@ -45,11 +68,16 @@ echo "=========================================="
echo "推送完成!"
echo "=========================================="
echo ""
echo "当前版本: $VERSION"
echo "下次版本: $MAJOR.$MINOR.$NEXT_PATCH"
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 ""
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 ""
echo "提示: 如需修改主版本号或次版本号,请编辑 build.sh 文件顶部的 MAJOR 和 MINOR 变量"
echo ""
+19
View File
@@ -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
-4
View File
@@ -1,4 +0,0 @@
-- 添加签到功能字段
ALTER TABLE Registrations
ADD COLUMN checkInStatus BOOLEAN DEFAULT false COMMENT '是否已签到',
ADD COLUMN checkInTime DATETIME NULL COMMENT '签到时间';
-90
View File
@@ -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
View File
@@ -3,7 +3,7 @@ version: '3.8'
services:
# 应用服务(前端+后端)
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
restart: unless-stopped
ports:
@@ -11,28 +11,14 @@ services:
environment:
- NODE_ENV=production
- PORT=3000
# 数据库配置
- DB_HOST=db
- DB_PORT=3306
- DB_USER=root
- DB_PASSWORD=quanyoumi2024
- DB_NAME=quanyoumi
# JWT 密钥(请修改为随机字符串)
- JWT_SECRET=quanyoumi_jwt_secret_change_this_in_production_2024
# 高德地图配置(必填)
- AMAP_KEY=fd5b77136ba34fe6b4f0f86dca7782d9
- AMAP_SECURITY_CODE=7f61638e5635d944a534ff747dc615ac
# MinIO 配置
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin123
- MINIO_BUCKET=quanyoumi
volumes:
- app-data:/app/data
- app-uploads:/app/uploads
depends_on:
- db
- minio
networks:
- quanyoumi-network
healthcheck:
@@ -42,54 +28,8 @@ services:
retries: 3
start_period: 10s
# MySQL 数据库
db:
image: mysql:8.0
container_name: quanyoumi-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=quanyoumi2024
- MYSQL_DATABASE=quanyoumi
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
- ./database.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- quanyoumi-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pquanyoumi2024"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: quanyoumi-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin123
volumes:
- minio-data:/data
ports:
- "9001:9001"
networks:
- quanyoumi-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
start_period: 10s
volumes:
mysql-data:
driver: local
minio-data:
app-data:
driver: local
app-uploads:
driver: local
+22
View File
@@ -5,6 +5,28 @@ 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 中的环境变量占位符
if [ -f "/app/public/index.html" ]; then
echo "配置高德地图 Key..."
+8
View File
@@ -0,0 +1,8 @@
@echo off
echo ==========================================
echo 修复经费媒体数据
echo ==========================================
cd backend
node fix-expense-media.js
cd ..
pause
-201
View File
@@ -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
View File
@@ -1,22 +1,118 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
baseURL: 'https://mi.xyvan.cn/api',
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');
if (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;
});
// 响应拦截器:确保所有错误都能被正确捕获
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 {
// 活动相关
getActivities: (params) => api.get('/activities', { params }),
getActivity: (id) => api.get(`/activities/${id}`),
getVenueImages: (id) => api.get(`/activities/${id}/venue-images`),
// 报名相关
submitRegistration: (data) => api.post('/registrations', data),
@@ -34,11 +130,14 @@ export default {
// 管理员相关
adminLogin: (data) => api.post('/admin/login', data),
refreshToken: () => api.post('/admin/refresh-token'),
createActivity: (data) => api.post('/admin/activities', data),
updateActivity: (id, data) => api.put(`/admin/activities/${id}`, data),
approveRegistration: (id, status) => api.put(`/admin/registrations/${id}`, { status }),
updateRegistration: (id, data) => api.put(`/admin/registrations/${id}`, data),
checkInRegistration: (id) => api.put(`/admin/registrations/${id}/checkin`),
groupRegistration: (id) => api.put(`/admin/registrations/${id}/group`),
deleteRegistration: (id) => api.delete(`/admin/registrations/${id}`),
getAllRegistrations: (params) => api.get('/admin/registrations', { params }),
addExpense: (data) => api.post('/admin/expenses', data, {
headers: { 'Content-Type': 'multipart/form-data' },
@@ -65,5 +164,30 @@ export default {
headers: { 'Content-Type': 'multipart/form-data' },
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 })
};
+19 -1
View File
@@ -14,7 +14,12 @@ const routes = [
{
path: '/activity/:id',
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',
@@ -42,6 +47,12 @@ const routes = [
name: 'ActivityManage',
component: () => import('../views/admin/ActivityManage.vue'),
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) => {
if (to.meta.requiresAuth && !localStorage.getItem('token')) {
next('/admin/login');
} else if (to.meta.requiresSuper) {
const admin = JSON.parse(localStorage.getItem('admin') || '{}');
if (admin.role !== 'super') {
next('/admin');
} else {
next();
}
} else {
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>
+647
View File
@@ -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
+36 -47
View File
@@ -136,7 +136,7 @@
<div class="sponsor-logo xiaomi-logo">
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
<rect x="10" y="10" width="80" height="80" rx="20" fill="#FF6700"/>
<text x="50" y="65" font-family="Arial, sans-serif" font-size="40" font-weight="bold" fill="white" text-anchor="middle">MI</text>
<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>
</div>
<div class="sponsor-info">
@@ -145,24 +145,7 @@
</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>
@@ -713,27 +696,25 @@ onUnmounted(() => {
}
.sponsors-grid {
display: flex;
justify-content: center;
align-items: stretch;
gap: 16px;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
margin-top: 32px;
flex-wrap: wrap;
max-width: 800px;
margin-left: auto;
margin-right: auto;
}
.sponsor-card {
background: #fff;
border-radius: 12px;
padding: 12px;
padding: 16px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
transition: transform 0.3s, box-shadow 0.3s;
flex: 0 1 140px;
max-width: 150px;
min-width: 120px;
}
.sponsor-card:hover {
@@ -742,12 +723,12 @@ onUnmounted(() => {
}
.sponsor-logo {
width: 45px;
height: 45px;
width: 60px;
height: 60px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
margin-bottom: 12px;
}
.sponsor-logo svg {
@@ -768,20 +749,30 @@ onUnmounted(() => {
}
.sponsor-name {
font-size: 11px;
font-size: 13px;
font-weight: 600;
color: #333;
margin: 0 0 4px;
line-height: 1.3;
word-break: break-all;
margin: 0 0 6px;
line-height: 1.4;
width: 100%;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
word-break: break-word;
}
.sponsor-desc {
font-size: 10px;
font-size: 11px;
color: #999;
margin: 0;
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区域 */
@@ -930,28 +921,26 @@ onUnmounted(() => {
}
.sponsors-grid {
gap: 12px;
grid-template-columns: 1fr;
gap: 16px;
}
.sponsor-card {
flex: 0 1 calc(20% - 10px);
min-width: 100px;
max-width: 130px;
padding: 10px;
padding: 16px;
}
.sponsor-logo {
width: 40px;
height: 40px;
margin-bottom: 6px;
width: 50px;
height: 50px;
margin-bottom: 10px;
}
.sponsor-name {
font-size: 10px;
font-size: 12px;
}
.sponsor-desc {
font-size: 9px;
font-size: 10px;
}
}
</style>
+151 -2
View File
@@ -49,6 +49,7 @@
@click="showCarModelPicker = true"
/>
<van-field
v-model="form.carNumber"
label="车牌号"
:rules="[{ required: true, message: '请输入车牌号' }]"
class="car-number-field"
@@ -108,6 +109,46 @@
@cancel="showCarModelPicker = false"
/>
</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>
</template>
@@ -121,6 +162,7 @@ const route = useRoute();
const router = useRouter();
const showCarModelPicker = ref(false);
const showQrcodeDialog = ref(false);
const carModels = ref([
{ text: '小米SU7', value: '小米SU7' },
{ text: '小米YU7', value: '小米YU7' },
@@ -225,11 +267,18 @@ const onSubmit = async () => {
message: `您的报名ID: ${data.id}\n已自动登录,请等待管理员审核`
});
router.back();
// 显示醒目的群二维码弹窗
showQrcodeDialog.value = true;
} catch (error) {
showToast('报名失败');
}
};
// 关闭二维码弹窗
const closeQrcodeDialog = () => {
showQrcodeDialog.value = false;
router.back();
};
</script>
<style scoped>
@@ -347,7 +396,7 @@ const onSubmit = async () => {
}
.plate-digits {
width: 110px;
width: 95px;
height: 28px;
border: 2px solid rgba(255,255,255,0.9);
border-radius: 3px;
@@ -367,4 +416,104 @@ const onSubmit = async () => {
color: #ddd;
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>
File diff suppressed because it is too large Load Diff
+606
View File
@@ -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>
+57 -5
View File
@@ -5,9 +5,20 @@
<h1>管理后台</h1>
<p>泉友米车友会</p>
</div>
<van-button class="logout-btn" size="small" round @click="logout">
<van-icon name="logout" /> 退出
</van-button>
<div class="header-actions">
<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 class="dashboard-content">
@@ -230,6 +241,7 @@ import api from '../../api';
const router = useRouter();
const activities = ref([]);
const isSuper = ref(false);
const showActivityForm = ref(false);
const showMapPicker = ref(false);
@@ -290,14 +302,29 @@ const formatDateTime = (dateTime) => {
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 = () => {
if (!startTimeInput.value) {
showToast('请选择开始时间');
return;
}
const selectedDate = new Date(startTimeInput.value);
console.log('[开始时间] 用户选择的时间:', startTimeInput.value);
const selectedDate = parseDateTimeLocal(startTimeInput.value);
activityForm.value.startTime = selectedDate.toISOString();
console.log('[开始时间] 保存到表单的UTC时间:', activityForm.value.startTime);
showStartTimePicker.value = false;
};
@@ -307,12 +334,14 @@ const confirmDeadline = () => {
showToast('请选择报名截止时间');
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)) {
showToast('报名截止时间不能晚于活动开始时间');
return;
}
activityForm.value.registrationDeadline = selectedDate.toISOString();
console.log('[截止时间] 保存到表单的UTC时间:', activityForm.value.registrationDeadline);
showDeadlinePicker.value = false;
};
@@ -573,10 +602,17 @@ const submitActivity = async () => {
const logout = () => {
localStorage.removeItem('token');
localStorage.removeItem('admin');
router.push('/admin/login');
};
const goToAdminManage = () => {
router.push('/admin/admins');
};
onMounted(() => {
const admin = JSON.parse(localStorage.getItem('admin') || '{}');
isSuper.value = admin.role === 'super';
loadActivities();
});
</script>
@@ -609,6 +645,22 @@ onMounted(() => {
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 {
background: #f5f5f5;
border: none;
+1
View File
@@ -62,6 +62,7 @@ const onSubmit = async () => {
try {
const { data } = await api.adminLogin(form.value);
localStorage.setItem('token', data.token);
localStorage.setItem('admin', JSON.stringify(data.admin));
showToast('登录成功');
router.push('/admin');
} 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;
}
+182
View File
@@ -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
};
}
+166
View File
@@ -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>
`
}
];
+35
View File
@@ -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
View File
@@ -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 ""
+7
View File
@@ -0,0 +1,7 @@
@echo off
echo Running migration: Add group status field...
cd backend
node migrations/index.js
cd ..
echo Migration completed!
pause
+7
View File
@@ -0,0 +1,7 @@
@echo off
echo 正在运行数据库迁移...
cd backend
node migrations/index.js
cd ..
echo 迁移完成!
pause
+33
View File
@@ -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
View File
@@ -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 ""
-53
View File
@@ -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. 功能使用
- 进入后台管理 -> 活动管理 -> 报名情况
- 查看签到统计(已签到/未签到人数)
- 点击"签到"按钮为用户签到
- 点击手机号直接拨打电话
- 点击车牌号复制到剪贴板
## 注意事项
- 只有审核通过的用户才能签到
- 签到后可以取消签到
- 签到时间会自动记录