feat: 添加管理员管理和场地图片功能

This commit is contained in:
Yvan 2026-04-17 18:09:02 +08:00
parent 2530ceb6b4
commit fd49a9de88
53 changed files with 4225 additions and 1424 deletions

8
.idea/.gitignore vendored Normal file
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
.idea/modules.xml Normal file
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>

9
.idea/qym.iml Normal file
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>

6
.idea/vcs.xml Normal file
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>

View File

@ -1 +1 @@
5
47

279
DEPLOY.md
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 控制台创建 buckethttp://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`
或联系技术支持团队。

View File

Binary file not shown.

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();

View File

@ -5,39 +5,66 @@ const { Admin, sequelize } = require('./models');
async function initAdmin() {
try {
// 使用 force: false 避免 alter 表时的问题
// 如果表不存在则创建,存在则跳过
await sequelize.sync({ force: false });
// 检查是否已存在管理员账号
const existingAdmin = await Admin.findOne({ where: { username: 'admin' } });
// 检查role字段是否存在
const [results] = await sequelize.query("PRAGMA table_info(Admins);");
const hasRoleColumn = results.some(col => col.name === 'role');
if (existingAdmin) {
console.log('✓ 管理员账号已存在');
console.log(' 用户名: admin');
console.log(' 提示: 如需重置密码,请删除数据库文件后重新初始化');
process.exit(0);
if (!hasRoleColumn) {
console.log('⚠️ role字段不存在请先运行迁移: node backend/migrations/index.js');
console.log(' 或运行: npm run migrate (在backend目录)');
process.exit(1);
return;
}
const hashedPassword = bcrypt.hashSync('admin123', 10);
await Admin.create({
username: 'admin',
password: hashedPassword,
name: '管理员'
});
// 检查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已存在');
}
}
console.log('✓ 管理员账号创建成功');
console.log(' 用户名: admin');
console.log(' 密码: admin123');
// 检查默认管理员
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);
if (error.name === 'SequelizeUniqueConstraintError') {
console.error(' 原因: 管理员账号已存在');
} else {
console.error(' 详细错误:', error);
}
console.error(' 详细错误:', error);
process.exit(1);
}
}

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;

View File

@ -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(' - 奖品来源字段移除完成');
}
};

View File

@ -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(' - 特等奖等级移除完成');
}
};

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

View File

@ -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(' - 四等奖等级移除完成');
}
};

View File

@ -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;
`);
}
};

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;

View File

@ -16,7 +16,7 @@ const Prize = sequelize.define('Prize', {
allowNull: false
},
level: {
type: DataTypes.ENUM('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼'),
type: DataTypes.ENUM('特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼'),
allowNull: false
},
description: DataTypes.TEXT,
@ -29,6 +29,15 @@ const Prize = sequelize.define('Prize', {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '排序值,数字越小越靠前'
},
source: {
type: DataTypes.ENUM('member', 'sponsor', 'purchase'),
defaultValue: 'purchase',
comment: '来源: member-群友赞助, sponsor-赞助商提供, purchase-采购'
},
sponsor: {
type: DataTypes.STRING,
comment: '赞助者名称(群友或赞助商)'
}
});

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;

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;

View File

@ -6,6 +6,7 @@ const Photo = require('./Photo');
const Sponsor = require('./Sponsor');
const Prize = require('./Prize');
const Admin = require('./Admin');
const VenueImage = require('./VenueImage');
// 同步数据库
// 使用 force: false 避免 SQLite alter 表时的约束冲突问题
@ -23,5 +24,6 @@ module.exports = {
Photo,
Sponsor,
Prize,
Admin
Admin,
VenueImage
};

1103
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
const express = require('express');
const router = express.Router();
const { Activity, Photo, Sponsor, Expense, Registration, Prize } = require('../models');
const { Activity, Photo, Sponsor, Expense, Registration, Prize, VenueImage } = require('../models');
const { upload, getFileUrls } = require('../middleware/upload');
// 获取活动列表
@ -8,7 +8,11 @@ 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,22 +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']]
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true
});
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
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']]
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true
});
const venueImages = await VenueImage.findAll({
where: { activityId: req.params.id },
order: [['sortOrder', 'ASC'], ['id', 'ASC']],
raw: true
});
res.json({ activity, photos, sponsors, expenses, prizes });
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 });
}
@ -42,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 });
}

View File

@ -32,6 +32,20 @@ router.post('/login', async (req, res) => {
}
});
// 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仅超级管理员 ==========
// 获取管理员列表
@ -173,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);
@ -207,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 {
@ -264,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 });
}

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
backend/routes/venue.js Normal file
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;

View File

@ -16,6 +16,7 @@ app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
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') {

View File

@ -1,4 +0,0 @@
-- 添加签到功能字段
ALTER TABLE Registrations
ADD COLUMN checkInStatus BOOLEAN DEFAULT false COMMENT '是否已签到',
ADD COLUMN checkInTime DATETIME NULL COMMENT '签到时间';

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

View File

@ -21,6 +21,10 @@ fi
# 初始化数据库
echo "初始化数据库..."
echo "[1/2] 运行数据库迁移..."
node migrations/index.js
echo "[2/2] 初始化管理员账号..."
node init-admin.js
# 替换前端 index.html 中的环境变量占位符

8
fix-expense.bat Normal file
View File

@ -0,0 +1,8 @@
@echo off
echo ==========================================
echo 修复经费媒体数据
echo ==========================================
cd backend
node fix-expense-media.js
cd ..
pause

BIN
frontend/public/cd/cd1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 428 KiB

BIN
frontend/public/cd/cd2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 331 KiB

BIN
frontend/public/cd/cd3.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

BIN
frontend/public/qun.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

@ -5,10 +5,74 @@ const api = axios.create({
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;
});
@ -17,7 +81,15 @@ api.interceptors.request.use(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);
}
@ -40,6 +112,7 @@ 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),
@ -57,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' },
@ -97,5 +173,21 @@ export default {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
deletePrize: (id) => api.delete(`/admin/prizes/${id}`)
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 })
};

View File

@ -47,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 }
}
];
@ -58,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();
}

View File

@ -1,28 +1,77 @@
<template>
<div class="sponsor-danmaku">
<div class="danmaku-item">
<div class="danmaku-card">
<div class="danmaku-avatar">
<img src="/avatars/wang.png" alt="王总" @error="handleImageError" />
</div>
<div class="danmaku-content">
<span class="thank-text">感谢</span>
<span class="sponsor-name">王总</span>
<span class="sponsor-action">赞助的</span>
<span class="sponsor-item">小米商城价值3990积分的物料</span>
<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>
</div>
</transition>
</div>
</template>
<script setup>
//
const handleImageError = (e) => {
//
e.target.style.display = 'none';
e.target.parentElement.innerHTML = '<span class="avatar-placeholder">王</span>';
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>
@ -41,10 +90,19 @@ const handleImageError = (e) => {
position: absolute;
top: 10px;
right: -100%;
animation: danmaku-move 15s linear infinite;
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 {
@ -128,6 +186,9 @@ const handleImageError = (e) => {
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 {
@ -139,6 +200,7 @@ const handleImageError = (e) => {
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 {
@ -150,21 +212,10 @@ const handleImageError = (e) => {
}
}
.danmaku-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
position: relative;
z-index: 1;
}
.avatar-placeholder {
font-size: 20px;
font-weight: bold;
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
color: #1a1a1a;
position: relative;
z-index: 1;
}
@ -214,6 +265,10 @@ const handleImageError = (e) => {
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) {
@ -240,7 +295,6 @@ const handleImageError = (e) => {
.avatar-placeholder {
font-size: 16px;
}
.danmaku-content {
font-size: 13px;
gap: 6px;
@ -261,6 +315,6 @@ const handleImageError = (e) => {
.sponsor-item {
font-size: 12px;
padding: 3px 10px;
max-width: 200px;
}
}
</style>
}</style>

View File

@ -49,7 +49,7 @@
</div>
<!-- 赞助商弹幕 -->
<SponsorDanmaku v-if="activity.danmakuEnabled" />
<SponsorDanmaku v-if="activity.danmakuEnabled" :prizes="prizes" />
<!-- 活动流程 -->
<div class="section">
@ -106,7 +106,6 @@
<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">
@ -131,7 +130,7 @@
<div class="detail-content">
<div class="game-list">
<div
v-for="(game, index) in timelineData[2].items"
v-for="(game, index) in gameItems"
:key="'game' + index"
class="game-card"
@click.stop="toggleGame('game' + index)"
@ -170,9 +169,6 @@
<div class="step-item">🍖 边烤肉边欣赏晚霞</div>
<div class="step-item">🎵 轻松音乐伴随美食时光</div>
</div>
<div class="tip-box">
💡 提示分享美食照片到朋友圈并@活动官方账号可获得 <span class="score-badge">+1</span>
</div>
</div>
</div>
</div>
@ -183,11 +179,11 @@
<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 - 20:00</span>
<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">5个精彩互动游戏,点燃现场欢乐气氛</div>
<div class="timeline-brief">互动游戏璀璨烟花积分兑奖,欢乐不停歇</div>
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 4 }">
<div class="detail-content">
<div class="game-list">
@ -229,8 +225,6 @@
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire3' }">
<div class="game-desc">
<strong>玩法</strong>全员参与,齐唱经典歌曲<br>
<strong>曲目</strong>朋友真心英雄<br>
<strong>得分</strong>参与即可获得+1<br>
<strong>氛围</strong>点燃现场,凝聚团队
</div>
</div>
@ -245,7 +239,6 @@
<div class="game-desc">
<strong>玩法</strong>自愿上台展示才艺(唱歌/跳舞/表演)<br>
<strong>准备</strong>需提前到后台准备BGM<br>
<strong>得分</strong>参与表演+3,精彩表演+5<br>
<strong>鼓励</strong>展现自我,收获掌声!
</div>
</div>
@ -261,7 +254,41 @@
<strong>玩法</strong>主持人邀请小朋友上台互动<br>
<strong>内容</strong>简单游戏/才艺展示/问答<br>
<strong>礼品</strong>参与即有小礼物<br>
<strong>得分</strong>家长陪同参与+2
</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>
@ -271,59 +298,23 @@
</div>
</div>
<!-- 璀璨烟火秀 -->
<div class="timeline-item">
<!-- 自由交流 & 返程 -->
<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">20:00 - 20:30</span>
<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-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 class="tip-box">
💡 提示拍摄烟花视频并分享可获得 <span class="score-badge">+2</span>
</div>
</div>
</div>
</div>
</div>
<!-- 积分兑奖 & 自由交流 -->
<div class="timeline-item timeline-item-last">
<div class="timeline-dot"></div>
<div class="timeline-content" @click="toggleTimeline(6)" :class="{ expanded: expandedTimelineIndex === 6 }">
<div class="timeline-time">
<span class="time-text">20:30 以后</span>
<van-icon :name="expandedTimelineIndex === 6 ? '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 === 6 }">
<div class="detail-content">
<div class="game-rule">
<div class="rule-title">🏆 积分兑换礼品</div>
<div class="rule-content">
<strong>积分排行榜</strong><br>
第1名20分以上神秘大奖 🎁<br>
第2-315-19精美礼品 🎀<br>
第4-1010-14纪念品 🎈<br>
参与奖5-9小礼物 🎊<br>
<br>
<strong>活动收尾</strong><br>
自由合影留念<br>
整理现场垃圾<br>
提醒安全驾驶返程<br>
期待下次相聚
</div>
<div class="step-item">📸 自由合影留念</div>
<div class="step-item">🧹 整理现场垃圾</div>
<div class="step-item">🚗 提醒安全驾驶返程</div>
<div class="step-item">👋 期待下次相聚</div>
</div>
</div>
</div>
@ -332,6 +323,18 @@
</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>
@ -391,6 +394,12 @@
<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">
@ -447,14 +456,10 @@
<!-- 报名名单 -->
<div class="section">
<h3 class="section-title">报名名单 <span class="count">({{ registrations.length }})</span></h3>
<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 class="member-info">
<div class="member-name">{{ reg.name }}</div>
<div class="member-car">{{ reg.carModel }}</div>
</div>
</div>
</div>
</div>
@ -549,6 +554,7 @@
maxlength="11"
/>
<van-field
v-model="loginForm.carNumber"
label="车牌号"
>
<template #input>
@ -587,11 +593,11 @@
<script setup>
import { useActivityDetail } from './useActivityDetail';
import SponsorDanmaku from './SponsorDanmaku.vue';
import { timelineData } from '../timeline-data.js';
import { gameItems } from '../timeline-data.js';
// URL
const getImageUrl = (path) => {
if (!path) return '';
if (!path || typeof path !== 'string') return '';
if (path.startsWith('http://') || path.startsWith('https://')) {
return path;
}
@ -606,6 +612,7 @@ const {
prizes,
registrations,
expenses,
venueImages,
userMedia,
showMapPopup,
showLoginDialog,
@ -618,6 +625,7 @@ const {
expandedTimelineIndex,
expandedGameId,
totalExpense,
totalPeople,
isApproved,
hasRegistered,
formatDate,
@ -631,7 +639,8 @@ const {
submitLogin,
handleShare,
toggleTimeline,
toggleGame
toggleGame,
showSponsorInfo
} = useActivityDetail();
</script>

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
import { ref, computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { showImagePreview, showToast } from 'vant';
import { showImagePreview, showToast, showDialog } from 'vant';
import api from '../../api';
export function useActivityDetail() {
@ -12,6 +12,7 @@ export function useActivityDetail() {
const prizes = ref([]);
const registrations = ref([]);
const expenses = ref([]);
const venueImages = ref([]);
const userMedia = ref([]);
const showMapPopup = ref(false);
const showLoginDialog = ref(false);
@ -23,6 +24,8 @@ export function useActivityDetail() {
const carLetter = ref('');
const carDigits = ref('');
const userRegistration = ref(null);
const showPrizeSourcePopup = ref(false);
const currentPrize = ref(null);
let map = null;
const handleLetterInput = (e) => {
@ -54,6 +57,12 @@ export function useActivityDetail() {
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';
});
@ -62,6 +71,41 @@ export function useActivityDetail() {
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;
@ -73,18 +117,54 @@ export function useActivityDetail() {
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) {
userRegistration.value = JSON.parse(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) => {
return new Date(date).toLocaleString('zh-CN');
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) => {
@ -188,7 +268,14 @@ export function useActivityDetail() {
showToast('您的报名未通过审核');
}
} catch (error) {
showToast(error.response?.data?.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 || '验证失败');
}
}
};
@ -224,13 +311,17 @@ export function useActivityDetail() {
videos,
sponsors,
prizes,
prizesByLevel,
registrations,
expenses,
venueImages,
userMedia,
showMapPopup,
showLoginDialog,
showExpenseVideoPopup,
showPrizeSourcePopup,
currentExpenseVideo,
currentPrize,
loginForm,
carLetter,
carDigits,
@ -238,6 +329,7 @@ export function useActivityDetail() {
expandedTimelineIndex,
expandedGameId,
totalExpense,
totalPeople,
isApproved,
hasRegistered,
formatDate,
@ -251,6 +343,8 @@ export function useActivityDetail() {
submitLogin,
handleShare,
toggleTimeline,
toggleGame
toggleGame,
showPrizeSource,
showSponsorInfo
};
}

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>

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

View File

@ -238,6 +238,13 @@
<div class="stat-desc">已通过</div>
</div>
</div>
<div class="stat-box">
<van-icon name="friends" color="#1989fa" />
<div class="stat-text">
<div class="stat-num">{{ approvedTotalPeople }}</div>
<div class="stat-desc">参与人数</div>
</div>
</div>
<div class="stat-box" :class="{ active: activeFilter === 'rejected' }" @click="setFilter('rejected')">
<van-icon name="close" color="#ee0a24" />
<div class="stat-text">
@ -266,6 +273,20 @@
<div class="stat-desc">未签到</div>
</div>
</div>
<div class="stat-box" :class="{ active: activeFilter === 'grouped' }" @click="setFilter('grouped')">
<van-icon name="chat-o" color="#1989fa" />
<div class="stat-text">
<div class="stat-num">{{ registrations.filter(r => r.groupStatus).length }}</div>
<div class="stat-desc">已进群</div>
</div>
</div>
<div class="stat-box" :class="{ active: activeFilter === 'notGrouped' }" @click="setFilter('notGrouped')">
<van-icon name="comment-o" color="#999" />
<div class="stat-text">
<div class="stat-num">{{ registrations.filter(r => !r.groupStatus).length }}</div>
<div class="stat-desc">未进群</div>
</div>
</div>
</div>
<!-- 搜索框 -->
@ -285,11 +306,6 @@
<div class="reg-main">
<div class="reg-top">
<span class="reg-name">{{ reg.name || '未知' }}</span>
<div class="reg-tags">
<van-tag v-if="reg.checkInStatus" type="success" size="medium">
<van-icon name="success" /> 已签到
</van-tag>
</div>
</div>
<div class="reg-info">
<a :href="`tel:${reg.phone}`" class="info-link" @click.stop>
@ -305,12 +321,46 @@
</div>
</div>
<div class="reg-right">
<div class="status-indicators">
<div
class="status-dot"
:class="{
'status-approved': reg.status === 'approved',
'status-rejected': reg.status === 'rejected',
'status-pending': reg.status === 'pending'
}"
:title="reg.status === 'approved' ? '已审核' : reg.status === 'rejected' ? '已拒绝' : '待审核'"
>
<van-icon v-if="reg.status === 'approved'" name="passed" />
<van-icon v-else-if="reg.status === 'rejected'" name="close" />
<van-icon v-else name="clock-o" />
</div>
<div
class="status-dot"
:class="{ 'status-active': reg.checkInStatus }"
:title="reg.checkInStatus ? '已签到' : '未签到'"
>
<van-icon name="location" />
</div>
<div
class="status-dot"
:class="{ 'status-active': reg.groupStatus }"
:title="reg.groupStatus ? '已进群' : '未进群'"
>
<van-icon name="chat-o" />
</div>
</div>
<van-icon name="arrow" color="#ccc" />
</div>
</div>
</div>
<van-empty v-if="filteredRegistrations.length === 0" :description="regSearchKeyword ? '未找到匹配的报名' : '暂无报名'" />
<!-- 悬浮添加按钮 -->
<div class="float-add-btn" @click="addNewRegistration">
<van-icon name="plus" />
</div>
</div>
</van-tab>
@ -414,6 +464,47 @@
</van-uploader>
</div>
</van-tab>
<van-tab title="场地管理" name="venue">
<div class="tab-content">
<div class="venue-list">
<div v-for="image in venueImages" :key="image.id" class="venue-item">
<div class="venue-thumb" @click="previewImage(getImageUrl(image.image))">
<van-image :src="getImageUrl(image.image)" fit="cover" />
</div>
<div class="venue-actions">
<van-field
v-model.number="image.sortOrder"
type="number"
label="排序"
placeholder="数字越小越靠前"
@blur="updateVenueOrder(image.id, image.sortOrder)"
/>
<van-button
size="mini"
type="danger"
icon="delete-o"
@click="deleteVenueImage(image.id)"
>删除</van-button>
</div>
</div>
</div>
<van-empty v-if="!venueImages.length" description="暂无场地图片" />
<!-- 悬浮上传按钮 -->
<van-uploader
v-model="venueFiles"
:max-count="1"
:after-read="uploadVenueImage"
accept="image/*"
>
<div class="float-add-btn">
<van-icon name="photograph" />
</div>
</van-uploader>
</div>
</van-tab>
</van-tabs>
<!-- 编辑活动弹窗 -->
@ -579,7 +670,7 @@
<div class="form-popup">
<div class="popup-header">
<h3>{{ expenseForm.id ? '编辑经费' : '添加经费' }}</h3>
<van-icon name="cross" @click="showExpenseForm = false" />
<van-icon name="cross" @click="closeExpenseForm" />
</div>
<van-form @submit="submitExpense" class="popup-form">
<van-cell-group>
@ -603,16 +694,33 @@
:max-count="5"
accept="image/*,video/*"
:after-read="onExpenseMediaRead"
:disabled="expenseForm.media.length + expenseMediaFiles.length >= 5"
>
<van-button icon="photograph" type="primary" size="small">
上传 ({{ expenseForm.media.length + expenseMediaFiles.length }}/5)
</van-button>
</van-uploader>
<!-- 已有媒体 -->
<div v-if="expenseForm.media.length" class="existing-media">
<div v-for="(item, index) in expenseForm.media" :key="index" class="media-thumb">
<van-image v-if="item.type === 'image'" :src="item.url" fit="cover" />
<video v-else :src="item.url" class="video-thumb"></video>
<van-icon name="cross" class="remove-icon" @click="removeExpenseMedia(index)" />
<div class="media-section-title">已有媒体</div>
<div class="media-list">
<div v-for="(item, index) in expenseForm.media" :key="'existing-' + index" class="media-thumb">
<van-image v-if="item.type === 'image'" :src="item.url" fit="cover" />
<video v-else :src="item.url" class="video-thumb"></video>
<van-icon name="cross" class="remove-icon" @click="removeExpenseMedia(index)" />
</div>
</div>
</div>
<!-- 新上传媒体 -->
<div v-if="expenseMediaFiles.length" class="existing-media">
<div class="media-section-title">新上传</div>
<div class="media-list">
<div v-for="(item, index) in expenseMediaFiles" :key="'new-' + index" class="media-thumb">
<van-image v-if="item.content" :src="item.content" fit="cover" />
<van-icon name="cross" class="remove-icon" @click="removeNewExpenseMedia(index)" />
</div>
</div>
</div>
</div>
@ -852,6 +960,82 @@
</div>
</van-popup>
<!-- 修改车牌号弹窗 -->
<van-popup v-model:show="showCarNumberDialog" position="center" round :style="{ width: '80%' }">
<div class="people-count-dialog">
<div class="dialog-header">
<h3>修改车牌号</h3>
<van-icon name="cross" @click="showCarNumberDialog = false" />
</div>
<div class="dialog-content">
<div class="count-info">
<span class="info-label">{{ currentEditReg.name }}</span>
<span class="info-desc">当前车牌号{{ currentEditReg.carNumber || '未填写' }}</span>
</div>
<van-field v-model="newCarNumber" placeholder="请输入车牌号闽A12345" />
</div>
<div class="dialog-actions">
<van-button block type="primary" @click="confirmCarNumber">确定</van-button>
</div>
</div>
</van-popup>
<!-- 手动录入报名弹窗 -->
<van-popup v-model:show="showRegForm" position="bottom" round>
<div class="form-popup">
<div class="popup-header">
<h3>手动录入报名</h3>
<van-icon name="cross" @click="closeRegForm" />
</div>
<van-form @submit="submitRegistration" class="popup-form">
<van-cell-group>
<van-field
v-model="regForm.name"
label="姓名"
placeholder="请输入姓名"
required
:rules="[{ required: true, message: '请输入姓名' }]"
/>
<van-field
v-model="regForm.phone"
label="手机号"
type="tel"
placeholder="请输入手机号"
required
:rules="[{ required: true, message: '请输入手机号' }]"
/>
<van-field
v-model="regForm.carModel"
label="车型"
placeholder="请输入车型(选填)"
/>
<van-field
v-model="regForm.carNumber"
label="车牌号"
placeholder="请输入车牌号(选填)"
/>
<van-field
v-model.number="regForm.peopleCount"
type="number"
label="人数"
placeholder="请输入人数"
/>
<van-field
v-model="regForm.note"
label="备注"
type="textarea"
placeholder="备注信息(选填)"
rows="2"
autosize
/>
</van-cell-group>
<div class="form-actions">
<van-button round block type="primary" native-type="submit" size="large">提交报名</van-button>
</div>
</van-form>
</div>
</van-popup>
<!-- 报名详情弹窗 -->
<van-popup v-model:show="showRegDetailDialog" position="bottom" round>
<div class="reg-detail-popup">
@ -873,8 +1057,9 @@
</div>
<div class="detail-row" v-if="currentReg.carNumber">
<span class="detail-label">车牌号</span>
<span class="detail-value detail-link" @click="copyCarNumber(currentReg.carNumber)">
<span class="detail-value detail-link" @click="editCarNumber(currentReg)">
<van-icon name="certificate" />{{ currentReg.carNumber }}
<van-icon name="edit" size="14" />
</span>
</div>
<div class="detail-row" v-if="currentReg.carModel">
@ -892,33 +1077,41 @@
<span class="detail-label">签到时间</span>
<span class="detail-value">{{ formatDate(currentReg.checkInTime) }}</span>
</div>
<div class="detail-row">
<span class="detail-label">审核状态</span>
<van-tag :type="currentReg.status === 'approved' ? 'success' : currentReg.status === 'rejected' ? 'danger' : 'warning'">
{{ currentReg.status === 'approved' ? '已通过' : currentReg.status === 'rejected' ? '已拒绝' : '待审核' }}
</van-tag>
</div>
<div class="detail-row">
<span class="detail-label">签到状态</span>
<van-tag :type="currentReg.checkInStatus ? 'success' : 'default'">
{{ currentReg.checkInStatus ? '已签到' : '未签到' }}
</van-tag>
<div class="detail-row status-row">
<span class="detail-label">状态管理</span>
<div class="status-controls">
<div class="status-item">
<span class="status-name">审核是否交钱</span>
<van-tag :type="currentReg.status === 'approved' ? 'success' : currentReg.status === 'rejected' ? 'danger' : 'warning'">
{{ currentReg.status === 'approved' ? '已通过' : currentReg.status === 'rejected' ? '已拒绝' : '待审核' }}
</van-tag>
</div>
<div class="status-item">
<span class="status-name">签到是否到现场</span>
<van-switch
v-model="currentReg.checkInStatus"
size="20px"
@change="toggleCheckInFromDetail"
active-color="#07c160"
/>
</div>
<div class="status-item">
<span class="status-name">进群是否加微信群</span>
<van-switch
v-model="currentReg.groupStatus"
size="20px"
@change="toggleGroupStatusFromDetail"
active-color="#1989fa"
/>
</div>
</div>
</div>
</div>
</div>
<div class="detail-actions">
<van-button
v-if="currentReg.status === 'approved'"
block
size="large"
:type="currentReg.checkInStatus ? 'default' : 'success'"
@click="toggleCheckInFromDetail"
>
{{ currentReg.checkInStatus ? '取消签到' : '签到' }}
</van-button>
<div v-if="currentReg.status === 'pending'" class="action-buttons">
<van-button size="large" type="success" @click="approveFromDetail('approved')">通过</van-button>
<van-button size="large" type="danger" @click="approveFromDetail('rejected')">拒绝</van-button>
<van-button size="large" type="success" @click="approveFromDetail('approved')">通过审核</van-button>
<van-button size="large" type="danger" @click="approveFromDetail('rejected')">拒绝审核</van-button>
</div>
<van-button
block
@ -926,7 +1119,7 @@
type="danger"
plain
@click="deleteRegistration"
style="margin-top: 12px;"
:style="{ marginTop: currentReg.status === 'pending' ? '12px' : '0' }"
>
删除报名
</van-button>
@ -965,12 +1158,16 @@ const photoFiles = ref([]);
const pendingPhotos = ref([]);
const expenseMediaFiles = ref([]);
const approvedPhotos = ref([]);
const venueImages = ref([]);
const venueFiles = ref([]);
const showVideoPlayer = ref(false);
const currentVideoUrl = ref('');
const regSearchKeyword = ref('');
const showPeopleCountDialog = ref(false);
const currentEditReg = ref({});
const newPeopleCount = ref(1);
const showCarNumberDialog = ref(false);
const newCarNumber = ref('');
const showRegDetailDialog = ref(false);
const currentReg = ref({});
const activeFilter = ref('all'); //
@ -978,6 +1175,16 @@ const activeFilter = ref('all'); // 过滤器状态
const showActivityForm = ref(false);
const showExpenseForm = ref(false);
const showSponsorForm = ref(false);
const showRegForm = ref(false);
const regForm = ref({
name: '',
phone: '',
carModel: '',
carNumber: '',
peopleCount: 1,
note: ''
});
//
const activityId = computed(() => route.params.id);
@ -1069,6 +1276,13 @@ const totalExpense = computed(() => {
return expenses.value.reduce((sum, e) => sum + parseFloat(e.amount), 0).toFixed(2);
});
// +
const approvedTotalPeople = computed(() => {
return registrations.value
.filter(r => r.status === 'approved')
.reduce((sum, r) => sum + (r.peopleCount || 1), 0);
});
//
const filteredRegistrations = computed(() => {
let result = registrations.value;
@ -1084,6 +1298,10 @@ const filteredRegistrations = computed(() => {
result = result.filter(r => r.checkInStatus);
} else if (activeFilter.value === 'notCheckedIn') {
result = result.filter(r => !r.checkInStatus);
} else if (activeFilter.value === 'grouped') {
result = result.filter(r => r.groupStatus);
} else if (activeFilter.value === 'notGrouped') {
result = result.filter(r => !r.groupStatus);
}
//
@ -1212,7 +1430,11 @@ const confirmCustomCategory = () => {
const loadData = async () => {
try {
const { data } = await api.getActivity(route.params.id);
activity.value = data.activity;
activity.value = {
...data.activity,
danmakuEnabled: Boolean(data.activity.danmakuEnabled),
registrationEnabled: Boolean(data.activity.registrationEnabled)
};
expenses.value = data.expenses || [];
sponsors.value = data.sponsors || [];
@ -1226,6 +1448,9 @@ const loadData = async () => {
//
await loadPrizes();
//
await loadVenueImages();
} catch (error) {
showToast('加载失败');
}
@ -1327,8 +1552,8 @@ const toggleDanmaku = async (value) => {
formData.append('category', expenseForm.value.category);
formData.append('note', expenseForm.value.note);
//
if (expenseForm.value.media && expenseForm.value.media.length > 0) {
//
if (expenseForm.value.id && expenseForm.value.media && expenseForm.value.media.length > 0) {
formData.append('existingMedia', JSON.stringify(expenseForm.value.media));
}
@ -1346,9 +1571,7 @@ const toggleDanmaku = async (value) => {
await api.addExpense(formData);
showToast('添加成功');
}
showExpenseForm.value = false;
expenseForm.value = { id: null, item: '', amount: '', category: '', note: '', media: [] };
expenseMediaFiles.value = [];
closeExpenseForm();
loadData();
} catch (error) {
showToast(expenseForm.value.id ? '更新失败' : '添加失败');
@ -1374,13 +1597,76 @@ const addNewExpense = () => {
};
const onExpenseMediaRead = (file) => {
// ,Vant
//
const fileItem = Array.isArray(file) ? file[0] : file;
const maxSize = 50 * 1024 * 1024; // 50MB
if (fileItem.file.size > maxSize) {
showToast('文件大小不能超过50MB');
//
const index = expenseMediaFiles.value.findIndex(f => f.file === fileItem.file);
if (index > -1) {
expenseMediaFiles.value.splice(index, 1);
}
return;
}
};
const removeExpenseMedia = (index) => {
expenseForm.value.media.splice(index, 1);
};
//
const addNewRegistration = () => {
regForm.value = {
name: '',
phone: '',
carModel: '',
carNumber: '',
peopleCount: 1,
note: ''
};
showRegForm.value = true;
};
//
const closeRegForm = () => {
showRegForm.value = false;
regForm.value = {
name: '',
phone: '',
carModel: '',
carNumber: '',
peopleCount: 1,
note: ''
};
};
//
const submitRegistration = async () => {
try {
await api.submitRegistration({
activityId: activity.value.id,
...regForm.value
});
showToast('报名成功');
closeRegForm();
loadData();
} catch (error) {
showToast('报名失败');
}
};;
const removeNewExpenseMedia = (index) => {
expenseMediaFiles.value.splice(index, 1);
};
const closeExpenseForm = () => {
showExpenseForm.value = false;
expenseForm.value = { id: null, item: '', amount: '', category: '', note: '', media: [] };
expenseMediaFiles.value = [];
};
const deleteExpenseItem = async (id) => {
try {
await showConfirmDialog({
@ -1592,6 +1878,24 @@ const confirmPeopleCount = async () => {
}
};
//
const editCarNumber = (reg) => {
currentEditReg.value = reg;
newCarNumber.value = reg.carNumber || '';
showCarNumberDialog.value = true;
};
const confirmCarNumber = async () => {
try {
await api.updateRegistration(currentEditReg.value.id, { carNumber: newCarNumber.value });
showToast('修改成功');
showCarNumberDialog.value = false;
loadData();
} catch (error) {
showToast('修改失败');
}
};
//
const showRegDetail = (reg) => {
currentReg.value = reg;
@ -1600,13 +1904,19 @@ const showRegDetail = (reg) => {
//
const toggleCheckInFromDetail = async () => {
const originalStatus = currentReg.value.checkInStatus;
try {
await api.checkInRegistration(currentReg.value.id);
showToast('操作成功');
showRegDetailDialog.value = false;
loadData();
await loadData();
//
const updatedReg = registrations.value.find(r => r.id === currentReg.value.id);
if (updatedReg) {
currentReg.value = updatedReg;
}
} catch (error) {
showToast('操作失败');
currentReg.value.checkInStatus = originalStatus;
}
};
@ -1622,6 +1932,22 @@ const approveFromDetail = async (status) => {
}
};
//
const toggleGroupStatusFromDetail = async () => {
try {
await api.groupRegistration(currentReg.value.id);
showToast('操作成功');
await loadData();
//
const updatedReg = registrations.value.find(r => r.id === currentReg.value.id);
if (updatedReg) {
currentReg.value = updatedReg;
}
} catch (error) {
showToast('操作失败');
}
};
//
const deleteRegistration = async () => {
try {
@ -1724,6 +2050,70 @@ const playVideo = (url) => {
showVideoPlayer.value = true;
};
//
const loadVenueImages = async () => {
try {
const { data } = await api.getVenueImages(route.params.id);
venueImages.value = data;
} catch (error) {
console.error('加载场地图片失败:', error);
}
};
const uploadVenueImage = async (file) => {
const loadingToast = showToast({
type: 'loading',
message: '正在上传...',
forbidClick: true,
duration: 0
});
try {
const formData = new FormData();
formData.append('image', file.file);
formData.append('activityId', route.params.id);
formData.append('sortOrder', venueImages.value.length);
await api.uploadVenueImage(formData);
showToast('上传成功');
venueFiles.value = [];
await loadVenueImages();
} catch (error) {
console.error('上传失败:', error);
showToast('上传失败');
} finally {
loadingToast.clear();
}
};
const deleteVenueImage = async (id) => {
try {
await showConfirmDialog({
title: '确认删除',
message: '删除后无法恢复,确定要删除吗?',
});
await api.deleteVenueImage(id);
showToast('删除成功');
await loadVenueImages();
} catch (error) {
if (error !== 'cancel') {
console.error('删除失败:', error);
showToast('删除失败');
}
}
};
const updateVenueOrder = async (id, sortOrder) => {
try {
await api.updateVenueImageOrder(id, sortOrder);
showToast('排序已更新');
await loadVenueImages();
} catch (error) {
console.error('更新失败:', error);
showToast('更新失败');
}
};
// Dashboard
const initMapPicker = async () => {
try {
@ -2400,6 +2790,71 @@ onMounted(loadData);
font-size: 13px;
}
.reg-right {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.reg-right .van-icon {
font-size: 16px;
}
.status-indicators {
display: flex;
gap: 6px;
align-items: center;
}
.status-dot {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: #f5f5f5;
transition: all 0.3s ease;
}
.status-dot .van-icon {
font-size: 14px;
color: #999;
}
.status-dot.status-approved {
background: #e8f5e9;
}
.status-dot.status-approved .van-icon {
color: #07c160;
}
.status-dot.status-rejected {
background: #ffebee;
}
.status-dot.status-rejected .van-icon {
color: #ee0a24;
}
.status-dot.status-pending {
background: #fff3e0;
}
.status-dot.status-pending .van-icon {
color: #ffa940;
}
.status-dot.status-active {
background: #e3f2fd;
}
.status-dot.status-active .van-icon {
color: #1989fa;
}
.info-link,
.info-text {
display: inline-flex;
@ -2441,15 +2896,7 @@ onMounted(loadData);
opacity: 0.7;
}
.reg-right {
display: flex;
align-items: center;
flex-shrink: 0;
}
.reg-right .van-icon {
font-size: 16px;
}
/* 移除旧的 reg-right 样式,已在上面重新定义 */
/* 弹窗 */
.form-popup {
@ -2759,6 +3206,48 @@ onMounted(loadData);
border-radius: 12px;
}
/* 场地管理 */
.venue-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.venue-item {
background: #fff;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
border: 1px solid #f0f0f0;
}
.venue-thumb {
width: 100%;
height: 200px;
background: #f5f5f5;
cursor: pointer;
}
.venue-thumb .van-image {
width: 100%;
height: 100%;
}
.venue-actions {
padding: 12px;
display: flex;
gap: 8px;
align-items: center;
}
.venue-actions .van-field {
flex: 1;
}
.venue-actions .van-button {
flex-shrink: 0;
}
/* 分类选择器 */
.category-picker {
padding: 20px;
@ -2887,9 +3376,19 @@ onMounted(loadData);
}
.existing-media {
margin-top: 12px;
}
.media-section-title {
font-size: 13px;
color: #666;
margin-bottom: 8px;
font-weight: 500;
}
.media-list {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
@ -3026,6 +3525,33 @@ onMounted(loadData);
border-bottom: 1px solid #f5f5f5;
}
.detail-row.status-row {
flex-direction: column;
align-items: stretch;
gap: 12px;
}
.status-controls {
display: flex;
flex-direction: column;
gap: 12px;
background: #f7f8fa;
padding: 12px;
border-radius: 8px;
}
.status-item {
display: flex;
justify-content: space-between;
align-items: center;
}
.status-name {
font-size: 14px;
color: #333;
font-weight: 500;
}
.detail-row:last-child {
border-bottom: none;
}

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>

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

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) {

View File

@ -54,6 +54,11 @@
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;
@ -69,6 +74,11 @@
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;
@ -163,6 +173,10 @@
cursor: pointer;
transition: all 0.2s;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.level-item:active {

View File

@ -1,92 +1,6 @@
// 活动流程数据
export const timelineData = [
{
title: '签到与集结',
time: '14:00 - 14:30',
brief: '车辆到场、签到登记、领取积分卡',
overview: `
<div class="overview-text">
<p>📍 车辆到达指定地点后工作人员将引导至临时停车点</p>
<p> 前往签到处签名确认扫码加入活动群</p>
<p>🎫 领取专属积分卡开启精彩旅程</p>
</div>
`,
content: `
<div class="game-rule">
<div class="rule-title">📋 积分卡说明</div>
<div class="rule-content">
每人领取一张积分卡红绿双色章<br>
初始积分0<br>
游戏得分范围1~5<br>
游戏扣分范围1~5<br>
最终积分可兑换礼品
</div>
</div>
`
},
{
title: '观赏素材拍摄',
time: '14:30 - 16:30',
brief: '多点位打卡拍照,记录精彩瞬间',
overview: `
<div class="overview-text">
<p>📸 专业摄影师带队前往多个精选点位</p>
<p>🚗 全员车前合影签到墙打卡地标合照</p>
<p> 完成所有点位拍照可获得 <span class="score-badge">+2</span></p>
</div>
`,
items: [
{
icon: '🏛️',
name: '宝船门口',
detail: `
<div class="sub-detail">
<p><strong>拍摄内容</strong></p>
<p> 全员车前合影</p>
<p> 签到墙合照</p>
<p> 个人打卡照</p>
</div>
`
},
{
icon: '🌉',
name: '隧道三点位',
detail: `
<div class="sub-detail">
<p><strong>拍摄内容</strong></p>
<p> 地标合影</p>
<p> 动态全员上车</p>
<p> 点火瞬间</p>
</div>
`
},
{
icon: '🎨',
name: '特色造型',
detail: `
<div class="sub-detail">
<p><strong>拍摄内容</strong></p>
<p> "泉州"两个字造型</p>
<p> 创意合影</p>
</div>
`
}
]
},
{
title: '互动游戏时间',
time: '16:30 - 18:00',
brief: '10+趣味游戏项目,赢取积分',
overview: `
<div class="overview-text">
<p>🎮 精心准备10+款趣味游戏</p>
<p>🏆 每个游戏都有积分奖励表现越好得分越高</p>
<p> 特别设置复活挑战赛给你翻盘机会</p>
<p class="tip-text">💡 点击下方游戏查看详细规则和得分标准</p>
</div>
`,
items: [
{
// 游戏项目数据
export const gameItems = [
{
icon: '🎯',
name: '套圈圈',
detail: `
@ -98,8 +12,8 @@ export const timelineData = [
<p> 套中5个<span class="score-badge">+5</span></p>
</div>
`
},
{
},
{
icon: '🏺',
name: '投壶',
detail: `
@ -111,8 +25,8 @@ export const timelineData = [
<p> 全部投中<span class="score-badge">+5</span></p>
</div>
`
},
{
},
{
icon: '🎲',
name: '摇骰子067玩法',
detail: `
@ -124,8 +38,8 @@ export const timelineData = [
<p> 摇出5个正豹子<span class="score-badge">+5</span></p>
</div>
`
},
{
},
{
icon: '🎲',
name: '摇骰子(单骰比大小)',
detail: `
@ -136,8 +50,8 @@ export const timelineData = [
<p> 玩家输庄家<span class="score-badge">-1</span></p>
</div>
`
},
{
},
{
icon: '🐂',
name: '牛牛',
detail: `
@ -149,8 +63,8 @@ export const timelineData = [
<p> 连赢5局<span class="score-badge">+5</span></p>
</div>
`
},
{
},
{
icon: '✊',
name: '闽南划拳(两拳倒)',
detail: `
@ -164,8 +78,8 @@ export const timelineData = [
<p class="highlight-tip">💡 投资机制可叠加投资赢得越多分数越高</p>
</div>
`
},
{
},
{
icon: '⚽',
name: '足球射门',
detail: `
@ -177,8 +91,8 @@ export const timelineData = [
<p> 进5球<span class="score-badge">+5</span></p>
</div>
`
},
{
},
{
icon: '🎮',
name: '王者荣耀',
detail: `
@ -193,8 +107,8 @@ export const timelineData = [
<p class="highlight-tip">💡 团队配合最重要选好英雄</p>
</div>
`
},
{
},
{
icon: '💧',
name: '抛水瓶',
detail: `
@ -206,8 +120,8 @@ export const timelineData = [
<p> 立住5个<span class="score-badge">+5</span></p>
</div>
`
},
{
},
{
icon: '🎴',
name: '扑克游戏',
detail: `
@ -218,8 +132,8 @@ export const timelineData = [
<p> 输家<span class="score-badge">-1</span></p>
</div>
`
},
{
},
{
icon: '🐊',
name: '鳄鱼牙齿/插剑玩具',
detail: `
@ -233,8 +147,8 @@ export const timelineData = [
<p class="highlight-tip">💡 胆大心细步步为营</p>
</div>
`
},
{
},
{
icon: '🪑',
name: '最终复活挑战 - 抢椅子',
detail: `
@ -248,170 +162,5 @@ export const timelineData = [
<p class="highlight-tip"> 这是翻盘的最佳机会</p>
</div>
`
}
]
},
{
title: '落日BBQ',
time: '18:00 - 19:30',
brief: '落日余晖下享受美食,拍摄氛围大片',
overview: `
<div class="overview-text">
<p>🌅 在落日余晖下车辆排成一排</p>
<p>🚗 齐开大灯营造浪漫氛围</p>
<p>🍖 边烤肉边欣赏晚霞拍摄氛围感大片</p>
</div>
`,
content: `
<div class="game-rule">
<div class="rule-content">
💡 提示分享美食照片到朋友圈并@活动官方账号可获得 <span class="score-badge">+1</span>
</div>
</div>
`
},
{
title: '篝火歌舞会',
time: '19:00 - 20:00',
brief: '围炉夜话,互动游戏,才艺表演',
overview: `
<div class="overview-text">
<p>🔥 点燃篝火围坐一圈</p>
<p>🎤 击鼓传花喊数抱团等互动游戏</p>
<p>🎭 才艺表演展示个人魅力</p>
<p class="tip-text">💡 点击下方查看各个游戏详细规则</p>
</div>
`,
items: [
{
icon: '🥁',
name: '击鼓传花10轮',
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">-3</span></p>
</div>
`
},
{
icon: '🤝',
name: '喊数抱团10轮',
detail: `
<div class="sub-detail">
<p><strong>游戏规则</strong></p>
<p><strong>惩罚</strong>1</p>
<p><strong>得分标准</strong></p>
<p> 成功抱团<span class="score-badge">+1</span></p>
<p> 落单但完成自我介绍<span class="score-badge">+2</span></p>
<p> 拒绝<span class="score-badge">-2</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>
</div>
`
},
{
icon: '🎭',
name: '个人才艺表演',
detail: `
<div class="sub-detail">
<p><strong>活动内容</strong></p>
<p><strong>提示</strong>BGM</p>
<p><strong>得分标准</strong></p>
<p> 参与表演<span class="score-badge">+3</span></p>
<p> 精彩表演<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>
</div>
`
}
]
},
{
title: '璀璨烟火秀',
time: '20:00 - 20:30',
brief: '专业烟火表演,点亮夜空',
overview: `
<div class="overview-text">
<p>🎆 大型专业烟火表演</p>
<p> 每车配发手持仙女棒</p>
<p>🔥 加特林压轴震撼收尾</p>
</div>
`,
items: [
{
icon: '🌃',
name: '米兰之夜',
detail: `
<div class="sub-detail">
<p><strong>表演内容</strong></p>
</div>
`
},
{
icon: '✨',
name: '仙女棒',
detail: `
<div class="sub-detail">
<p><strong>活动内容</strong></p>
</div>
`
},
{
icon: '🔥',
name: '加特林压轴',
detail: `
<div class="sub-detail">
<p><strong>表演内容</strong></p>
<p class="highlight-tip">💡 拍摄烟花视频并分享可获得 <span class="score-badge">+2</span></p>
</div>
`
}
]
},
{
title: '积分兑奖 & 自由交流',
time: '20:30 以后',
brief: '积分兑换礼品,自由合影交流',
overview: `
<div class="overview-text">
<p>🏆 根据积分排行榜兑换礼品</p>
<p>📸 自由合影交流心得</p>
<p>🚗 整理垃圾安全驾驶返程</p>
</div>
`,
content: `
<div class="game-rule">
<div class="rule-title">🏆 积分兑换礼品</div>
<div class="rule-content">
<strong>积分排行榜</strong><br>
第1名20分以上神秘大奖<br>
第2-315-19精美礼品<br>
第4-1010-14纪念品<br>
参与奖5-9小礼物<br>
<br>
随后自由合影整理垃圾提醒安全驾驶返程
</div>
</div>
`
}
}
];

35
init-all.bat Normal file
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
init-all.sh Normal file
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
migrate-group-status.bat Normal file
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
migrate-prize-source.bat Normal file
View File

@ -0,0 +1,7 @@
@echo off
echo 正在运行数据库迁移...
cd backend
node migrations/index.js
cd ..
echo 迁移完成!
pause