feat: 添加管理员管理和场地图片功能
This commit is contained in:
parent
2530ceb6b4
commit
fd49a9de88
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
279
DEPLOY.md
279
DEPLOY.md
|
|
@ -1,279 +0,0 @@
|
|||
# 泉友米车友会 - 部署说明
|
||||
|
||||
版本: 0.0.1
|
||||
|
||||
## 🚀 快速部署(推荐)
|
||||
|
||||
### Windows 用户
|
||||
|
||||
```cmd
|
||||
# 1. 构建镜像
|
||||
build.bat
|
||||
|
||||
# 2. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 初始化管理员
|
||||
docker exec -it quanyoumi-app sh
|
||||
node init-admin.js
|
||||
```
|
||||
|
||||
### Linux/Mac 用户
|
||||
|
||||
```bash
|
||||
# 1. 构建镜像
|
||||
chmod +x build.sh
|
||||
./build.sh
|
||||
|
||||
# 2. 启动服务
|
||||
docker-compose up -d
|
||||
|
||||
# 3. 初始化管理员
|
||||
docker exec -it quanyoumi-app sh
|
||||
node init-admin.js
|
||||
```
|
||||
|
||||
## 📝 部署前准备
|
||||
|
||||
### 1. 修改配置文件
|
||||
|
||||
编辑 `docker-compose.yml`,修改以下配置:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- DB_PASSWORD=your_mysql_password # 修改数据库密码
|
||||
- JWT_SECRET=your_jwt_secret_key # 修改JWT密钥(随机字符串)
|
||||
- AMAP_KEY=your_amap_key # 填入高德地图Key
|
||||
```
|
||||
|
||||
同时修改 MySQL 服务的密码:
|
||||
|
||||
```yaml
|
||||
db:
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=your_mysql_password # 与上面保持一致
|
||||
```
|
||||
|
||||
### 2. 获取高德地图 Key
|
||||
|
||||
1. 访问 https://console.amap.com/
|
||||
2. 注册/登录账号
|
||||
3. 创建应用,选择 Web 端(JS API)
|
||||
4. 复制 Key 填入配置
|
||||
|
||||
## 🔧 详细步骤
|
||||
|
||||
### 步骤 1: 构建 Docker 镜像
|
||||
|
||||
镜像包含前端(Vue3)和后端(Node.js),版本号 0.0.1。
|
||||
|
||||
**Windows:**
|
||||
```cmd
|
||||
build.bat
|
||||
```
|
||||
|
||||
**Linux/Mac:**
|
||||
```bash
|
||||
chmod +x build.sh
|
||||
./build.sh
|
||||
```
|
||||
|
||||
### 步骤 2: 启动服务
|
||||
|
||||
使用 docker-compose 启动完整环境(应用+MySQL+MinIO):
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
查看启动状态:
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
查看日志:
|
||||
```bash
|
||||
docker-compose logs -f app
|
||||
```
|
||||
|
||||
### 步骤 3: 初始化数据库
|
||||
|
||||
数据库会自动初始化(使用 database.sql),等待约 30 秒。
|
||||
|
||||
检查数据库是否就绪:
|
||||
```bash
|
||||
docker-compose logs db | grep "ready for connections"
|
||||
```
|
||||
|
||||
### 步骤 4: 创建管理员账号
|
||||
|
||||
进入应用容器:
|
||||
```bash
|
||||
docker exec -it quanyoumi-app sh
|
||||
```
|
||||
|
||||
运行初始化脚本:
|
||||
```bash
|
||||
node init-admin.js
|
||||
```
|
||||
|
||||
按提示输入:
|
||||
- 用户名(例如:admin)
|
||||
- 密码(至少6位)
|
||||
- 确认密码
|
||||
|
||||
退出容器:
|
||||
```bash
|
||||
exit
|
||||
```
|
||||
|
||||
### 步骤 5: 访问应用
|
||||
|
||||
- **前端页面**: http://localhost:3000
|
||||
- **后台管理**: http://localhost:3000/admin/login
|
||||
- **MinIO 控制台**: http://localhost:9001 (minioadmin/minioadmin)
|
||||
|
||||
## 📦 镜像信息
|
||||
|
||||
- **镜像名称**: quanyoumi-app
|
||||
- **版本**: 0.0.1
|
||||
- **基础镜像**: node:18-alpine
|
||||
- **大小**: 约 200MB
|
||||
- **包含内容**:
|
||||
- 前端构建产物(Vue3 + Vant4)
|
||||
- 后端服务(Node.js + Express)
|
||||
- 依赖包(仅生产环境)
|
||||
|
||||
## 🔍 验证部署
|
||||
|
||||
### 1. 检查服务状态
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
应该看到 3 个服务都是 Up 状态:
|
||||
- quanyoumi-app
|
||||
- quanyoumi-db
|
||||
- quanyoumi-minio
|
||||
|
||||
### 2. 检查应用日志
|
||||
|
||||
```bash
|
||||
docker-compose logs app
|
||||
```
|
||||
|
||||
应该看到:
|
||||
```
|
||||
服务器运行在 http://localhost:3000
|
||||
运行模式: 生产环境(前后端一体)
|
||||
```
|
||||
|
||||
### 3. 测试访问
|
||||
|
||||
浏览器访问 http://localhost:3000,应该能看到首页。
|
||||
|
||||
## 🛠️ 常见问题
|
||||
|
||||
### 问题 1: 端口被占用
|
||||
|
||||
错误信息:`port is already allocated`
|
||||
|
||||
解决方法:
|
||||
```bash
|
||||
# 修改 docker-compose.yml 中的端口映射
|
||||
ports:
|
||||
- "3001:3000" # 改为其他端口
|
||||
```
|
||||
|
||||
### 问题 2: 数据库连接失败
|
||||
|
||||
错误信息:`ECONNREFUSED` 或 `Access denied`
|
||||
|
||||
解决方法:
|
||||
1. 检查数据库是否启动:`docker-compose ps db`
|
||||
2. 检查密码是否一致(app 和 db 的密码要相同)
|
||||
3. 等待数据库完全启动(约 30 秒)
|
||||
|
||||
### 问题 3: 前端页面空白
|
||||
|
||||
解决方法:
|
||||
1. 检查浏览器控制台是否有错误
|
||||
2. 确认后端 API 是否正常:访问 http://localhost:3000/api/activities
|
||||
3. 重新构建镜像:`docker-compose build --no-cache`
|
||||
|
||||
### 问题 4: 图片上传失败
|
||||
|
||||
解决方法:
|
||||
1. 检查 MinIO 是否运行:`docker-compose ps minio`
|
||||
2. 访问 MinIO 控制台创建 bucket:http://localhost:9001
|
||||
3. 检查环境变量配置是否正确
|
||||
|
||||
## 🔄 更新应用
|
||||
|
||||
```bash
|
||||
# 1. 停止服务
|
||||
docker-compose down
|
||||
|
||||
# 2. 拉取最新代码
|
||||
git pull
|
||||
|
||||
# 3. 重新构建
|
||||
docker-compose build --no-cache
|
||||
|
||||
# 4. 启动服务
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## 🗑️ 清理
|
||||
|
||||
### 停止服务(保留数据)
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### 完全清理(删除数据)
|
||||
```bash
|
||||
docker-compose down -v
|
||||
```
|
||||
|
||||
### 删除镜像
|
||||
```bash
|
||||
docker rmi quanyoumi-app:0.0.1
|
||||
```
|
||||
|
||||
## 📊 生产环境建议
|
||||
|
||||
1. **使用反向代理**
|
||||
- 推荐使用 Nginx 或 Caddy
|
||||
- 配置 HTTPS 证书
|
||||
- 设置域名
|
||||
|
||||
2. **修改默认密码**
|
||||
- MySQL root 密码
|
||||
- MinIO 访问密钥
|
||||
- JWT 密钥(长随机字符串)
|
||||
|
||||
3. **数据备份**
|
||||
- 定期备份 MySQL 数据
|
||||
- 备份 MinIO 存储
|
||||
- 备份上传文件
|
||||
|
||||
4. **监控告警**
|
||||
- 配置日志收集
|
||||
- 设置资源监控
|
||||
- 配置告警通知
|
||||
|
||||
5. **安全加固**
|
||||
- 限制数据库外部访问
|
||||
- 使用防火墙规则
|
||||
- 定期更新镜像
|
||||
|
||||
## 📞 技术支持
|
||||
|
||||
如有问题,请查看:
|
||||
1. 应用日志:`docker-compose logs app`
|
||||
2. 数据库日志:`docker-compose logs db`
|
||||
3. MinIO 日志:`docker-compose logs minio`
|
||||
|
||||
或联系技术支持团队。
|
||||
Binary file not shown.
|
|
@ -0,0 +1,49 @@
|
|||
const { Expense } = require('./models');
|
||||
|
||||
async function fixExpenseMedia() {
|
||||
try {
|
||||
console.log('开始修复经费媒体数据...');
|
||||
|
||||
const expenses = await Expense.findAll();
|
||||
console.log(`找到 ${expenses.length} 条经费记录`);
|
||||
|
||||
let fixedCount = 0;
|
||||
|
||||
for (const expense of expenses) {
|
||||
let needUpdate = false;
|
||||
let media = expense.media || [];
|
||||
|
||||
// 检查是否有异常数据
|
||||
if (Array.isArray(media) && media.length > 5) {
|
||||
console.log(`经费 #${expense.id} 有 ${media.length} 个媒体文件,超过限制`);
|
||||
media = media.slice(0, 5);
|
||||
needUpdate = true;
|
||||
}
|
||||
|
||||
// 检查是否有无效的媒体项
|
||||
const validMedia = media.filter(item => {
|
||||
return item && item.url && (item.type === 'image' || item.type === 'video');
|
||||
});
|
||||
|
||||
if (validMedia.length !== media.length) {
|
||||
console.log(`经费 #${expense.id} 有无效媒体项,从 ${media.length} 清理到 ${validMedia.length}`);
|
||||
media = validMedia;
|
||||
needUpdate = true;
|
||||
}
|
||||
|
||||
if (needUpdate) {
|
||||
await expense.update({ media });
|
||||
fixedCount++;
|
||||
console.log(`✓ 修复经费 #${expense.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n修复完成!共修复 ${fixedCount} 条记录`);
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('修复失败:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fixExpenseMedia();
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加奖品来源字段...');
|
||||
|
||||
// 添加source字段,默认值为purchase
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Prizes ADD COLUMN source TEXT DEFAULT 'purchase';
|
||||
`);
|
||||
|
||||
// 添加sponsor字段
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Prizes ADD COLUMN sponsor TEXT;
|
||||
`);
|
||||
|
||||
// 为现有数据设置默认值
|
||||
await manager.executeSql(`
|
||||
UPDATE Prizes SET source = 'purchase' WHERE source IS NULL;
|
||||
`);
|
||||
|
||||
console.log(' - 奖品来源字段添加完成');
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 移除奖品来源字段...');
|
||||
|
||||
// SQLite不支持DROP COLUMN,需要重建表
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE Prizes_backup AS SELECT
|
||||
id, activityId, name, level, description, image, quantity, sortOrder, createdAt, updatedAt
|
||||
FROM Prizes;
|
||||
`);
|
||||
|
||||
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||
|
||||
await manager.executeSql(`ALTER TABLE Prizes_backup RENAME TO Prizes;`);
|
||||
|
||||
console.log(' - 奖品来源字段移除完成');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加特等奖等级...');
|
||||
|
||||
// SQLite不支持直接修改CHECK约束,需要重建表
|
||||
// 1. 创建新表结构(包含特等奖)
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE Prizes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activityId INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
sortOrder INTEGER DEFAULT 0,
|
||||
source TEXT DEFAULT 'purchase',
|
||||
sponsor TEXT,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// 2. 复制数据
|
||||
await manager.executeSql(`
|
||||
INSERT INTO Prizes_new
|
||||
SELECT * FROM Prizes;
|
||||
`);
|
||||
|
||||
// 3. 删除旧表
|
||||
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||
|
||||
// 4. 重命名新表
|
||||
await manager.executeSql(`ALTER TABLE Prizes_new RENAME TO Prizes;`);
|
||||
|
||||
console.log(' - 特等奖等级添加完成');
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 移除特等奖等级...');
|
||||
|
||||
// 1. 创建旧表结构(不包含特等奖)
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE Prizes_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activityId INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK(level IN ('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
sortOrder INTEGER DEFAULT 0,
|
||||
source TEXT DEFAULT 'purchase',
|
||||
sponsor TEXT,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// 2. 复制数据(排除特等奖)
|
||||
await manager.executeSql(`
|
||||
INSERT INTO Prizes_old
|
||||
SELECT * FROM Prizes WHERE level != '特等奖';
|
||||
`);
|
||||
|
||||
// 3. 删除新表
|
||||
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||
|
||||
// 4. 重命名旧表
|
||||
await manager.executeSql(`ALTER TABLE Prizes_old RENAME TO Prizes;`);
|
||||
|
||||
console.log(' - 特等奖等级移除完成');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
// 添加管理员角色字段
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 Admins.role 字段...');
|
||||
await manager.executeSql('ALTER TABLE Admins ADD COLUMN role TEXT DEFAULT "admin";');
|
||||
console.log(' - 角色字段添加完成');
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
await manager.executeSql('ALTER TABLE Admins DROP COLUMN role;');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
// 添加四等奖等级
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加四等奖等级...');
|
||||
|
||||
// SQLite不支持直接修改CHECK约束,需要重建表
|
||||
// 1. 创建新表结构(包含四等奖)
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE Prizes_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activityId INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼')),
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
sortOrder INTEGER DEFAULT 0,
|
||||
source TEXT DEFAULT 'purchase',
|
||||
sponsor TEXT,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// 2. 复制数据
|
||||
await manager.executeSql(`
|
||||
INSERT INTO Prizes_new
|
||||
SELECT * FROM Prizes;
|
||||
`);
|
||||
|
||||
// 3. 删除旧表
|
||||
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||
|
||||
// 4. 重命名新表
|
||||
await manager.executeSql(`ALTER TABLE Prizes_new RENAME TO Prizes;`);
|
||||
|
||||
console.log(' - 四等奖等级添加完成');
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 移除四等奖等级...');
|
||||
|
||||
// 1. 创建旧表结构(不包含四等奖)
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE Prizes_old (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activityId INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK(level IN ('特等奖', '一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
sortOrder INTEGER DEFAULT 0,
|
||||
source TEXT DEFAULT 'purchase',
|
||||
sponsor TEXT,
|
||||
createdAt DATETIME NOT NULL,
|
||||
updatedAt DATETIME NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// 2. 复制数据(排除四等奖)
|
||||
await manager.executeSql(`
|
||||
INSERT INTO Prizes_old
|
||||
SELECT * FROM Prizes WHERE level != '四等奖';
|
||||
`);
|
||||
|
||||
// 3. 删除新表
|
||||
await manager.executeSql(`DROP TABLE Prizes;`);
|
||||
|
||||
// 4. 重命名旧表
|
||||
await manager.executeSql(`ALTER TABLE Prizes_old RENAME TO Prizes;`);
|
||||
|
||||
console.log(' - 四等奖等级移除完成');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
/**
|
||||
* 迁移 0009: 添加进群状态字段
|
||||
* 为 Registrations 表添加 groupStatus 字段
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 Registrations.groupStatus 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Registrations ADD COLUMN groupStatus INTEGER DEFAULT 0;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 移除 Registrations.groupStatus 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Registrations DROP COLUMN groupStatus;
|
||||
`);
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: '赞助者名称(群友或赞助商)'
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -1,4 +0,0 @@
|
|||
-- 添加签到功能字段
|
||||
ALTER TABLE Registrations
|
||||
ADD COLUMN checkInStatus BOOLEAN DEFAULT false COMMENT '是否已签到',
|
||||
ADD COLUMN checkInTime DATETIME NULL COMMENT '签到时间';
|
||||
90
database.sql
90
database.sql
|
|
@ -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
|
||||
);
|
||||
|
|
@ -21,6 +21,10 @@ fi
|
|||
|
||||
# 初始化数据库
|
||||
echo "初始化数据库..."
|
||||
echo "[1/2] 运行数据库迁移..."
|
||||
node migrations/index.js
|
||||
|
||||
echo "[2/2] 初始化管理员账号..."
|
||||
node init-admin.js
|
||||
|
||||
# 替换前端 index.html 中的环境变量占位符
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
@echo off
|
||||
echo ==========================================
|
||||
echo 修复经费媒体数据
|
||||
echo ==========================================
|
||||
cd backend
|
||||
node fix-expense-media.js
|
||||
cd ..
|
||||
pause
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 428 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 401 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 162 KiB |
|
|
@ -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 })
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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-3名(15-19分):精美礼品 🎀<br>
|
||||
• 第4-10名(10-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
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,606 @@
|
|||
<template>
|
||||
<div class="admin-manage">
|
||||
<div class="header">
|
||||
<h2>管理员账号管理</h2>
|
||||
<button @click="showCreateDialog = true" class="btn-primary">
|
||||
<span>➕</span> 添加管理员
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-cards">
|
||||
<div v-for="admin in admins" :key="admin.id" class="admin-card">
|
||||
<div class="card-header">
|
||||
<div class="admin-info">
|
||||
<div class="admin-avatar">
|
||||
{{ admin.username.charAt(0).toUpperCase() }}
|
||||
</div>
|
||||
<div class="admin-details">
|
||||
<div class="admin-name">{{ admin.name }}</div>
|
||||
<div class="admin-username">@{{ admin.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span :class="['role-badge', admin.role]">
|
||||
{{ admin.role === 'super' ? '超级管理员' : '管理员' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
<span class="meta-item">
|
||||
<span class="meta-icon">📅</span>
|
||||
创建于 {{ formatDate(admin.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="card-actions">
|
||||
<button @click="editAdmin(admin)" class="btn-action btn-edit">
|
||||
<span>✏️</span> 编辑
|
||||
</button>
|
||||
<button @click="changePassword(admin)" class="btn-action btn-password">
|
||||
<span>🔑</span> 改密码
|
||||
</button>
|
||||
<button
|
||||
v-if="admin.role !== 'super'"
|
||||
@click="deleteAdmin(admin)"
|
||||
class="btn-action btn-delete"
|
||||
>
|
||||
<span>🗑️</span> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!admins.length" class="empty-state">
|
||||
<div class="empty-icon">👥</div>
|
||||
<div class="empty-text">暂无管理员</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建管理员对话框 -->
|
||||
<div v-if="showCreateDialog" class="dialog-overlay" @click.self="showCreateDialog = false">
|
||||
<div class="dialog">
|
||||
<h3>添加管理员</h3>
|
||||
<form @submit.prevent="createAdmin">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input v-model="newAdmin.username" required placeholder="请输入用户名" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>姓名</label>
|
||||
<input v-model="newAdmin.name" required placeholder="请输入姓名" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>密码</label>
|
||||
<input v-model="newAdmin.password" type="password" required placeholder="请输入密码" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>角色</label>
|
||||
<select v-model="newAdmin.role">
|
||||
<option value="admin">管理员</option>
|
||||
<option value="super">超级管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" @click="showCreateDialog = false" class="btn-cancel">取消</button>
|
||||
<button type="submit" class="btn-primary">创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 编辑管理员对话框 -->
|
||||
<div v-if="showEditDialog" class="dialog-overlay" @click.self="showEditDialog = false">
|
||||
<div class="dialog">
|
||||
<h3>编辑管理员</h3>
|
||||
<form @submit.prevent="updateAdmin">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input :value="editingAdmin.username" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>姓名</label>
|
||||
<input v-model="editingAdmin.name" required placeholder="请输入姓名" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>角色</label>
|
||||
<select v-model="editingAdmin.role">
|
||||
<option value="admin">管理员</option>
|
||||
<option value="super">超级管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" @click="showEditDialog = false" class="btn-cancel">取消</button>
|
||||
<button type="submit" class="btn-primary">保存</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 修改密码对话框 -->
|
||||
<div v-if="showPasswordDialog" class="dialog-overlay" @click.self="showPasswordDialog = false">
|
||||
<div class="dialog">
|
||||
<h3>修改密码</h3>
|
||||
<form @submit.prevent="updatePassword">
|
||||
<div class="form-group">
|
||||
<label>用户名</label>
|
||||
<input :value="passwordAdmin.username" disabled />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>新密码</label>
|
||||
<input v-model="newPassword" type="password" required placeholder="请输入新密码" />
|
||||
</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" @click="showPasswordDialog = false" class="btn-cancel">取消</button>
|
||||
<button type="submit" class="btn-primary">确认</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import api from '../../api';
|
||||
import { showToast } from 'vant';
|
||||
|
||||
const admins = ref([]);
|
||||
const showCreateDialog = ref(false);
|
||||
const showEditDialog = ref(false);
|
||||
const showPasswordDialog = ref(false);
|
||||
|
||||
const newAdmin = ref({
|
||||
username: '',
|
||||
name: '',
|
||||
password: '',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
const editingAdmin = ref({});
|
||||
const passwordAdmin = ref({});
|
||||
const newPassword = ref('');
|
||||
|
||||
const loadAdmins = async () => {
|
||||
try {
|
||||
const { data } = await api.getAdmins();
|
||||
console.log('管理员数据:', data);
|
||||
admins.value = data;
|
||||
} catch (error) {
|
||||
console.error('加载管理员列表失败:', error);
|
||||
console.error('错误详情:', error.response?.data);
|
||||
if (error.response?.status === 403) {
|
||||
showToast('需要超级管理员权限');
|
||||
} else if (error.response?.status === 401) {
|
||||
showToast('登录已过期,请重新登录');
|
||||
} else {
|
||||
showToast(error.response?.data?.error || '加载失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const createAdmin = async () => {
|
||||
try {
|
||||
await api.createAdmin(newAdmin.value);
|
||||
showToast('创建成功');
|
||||
showCreateDialog.value = false;
|
||||
newAdmin.value = { username: '', name: '', password: '', role: 'admin' };
|
||||
loadAdmins();
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '创建失败');
|
||||
}
|
||||
};
|
||||
|
||||
const editAdmin = (admin) => {
|
||||
editingAdmin.value = { ...admin };
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const updateAdmin = async () => {
|
||||
try {
|
||||
await api.updateAdmin(editingAdmin.value.id, {
|
||||
name: editingAdmin.value.name,
|
||||
role: editingAdmin.value.role
|
||||
});
|
||||
showToast('更新成功');
|
||||
showEditDialog.value = false;
|
||||
loadAdmins();
|
||||
} catch (error) {
|
||||
showToast('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const changePassword = (admin) => {
|
||||
passwordAdmin.value = admin;
|
||||
newPassword.value = '';
|
||||
showPasswordDialog.value = true;
|
||||
};
|
||||
|
||||
const updatePassword = async () => {
|
||||
try {
|
||||
await api.updateAdminPassword(passwordAdmin.value.id, {
|
||||
newPassword: newPassword.value
|
||||
});
|
||||
showToast('密码修改成功');
|
||||
showPasswordDialog.value = false;
|
||||
} catch (error) {
|
||||
showToast('修改失败');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAdmin = async (admin) => {
|
||||
if (!confirm(`确定删除管理员 ${admin.username} 吗?`)) return;
|
||||
|
||||
try {
|
||||
await api.deleteAdmin(admin.id);
|
||||
showToast('删除成功');
|
||||
loadAdmins();
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (date) => {
|
||||
if (!date) return '-';
|
||||
try {
|
||||
return new Date(date).toLocaleDateString('zh-CN');
|
||||
} catch (error) {
|
||||
return '-';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadAdmins);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.admin-manage {
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 12px 24px;
|
||||
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 12px rgba(77, 171, 247, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(77, 171, 247, 0.4);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-primary span {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.admin-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.admin-card {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: all 0.3s;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.admin-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.admin-avatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-details {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-username {
|
||||
font-size: 13px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.role-badge {
|
||||
padding: 6px 14px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.role-badge.super {
|
||||
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(255, 107, 107, 0.3);
|
||||
}
|
||||
|
||||
.role-badge.admin {
|
||||
background: linear-gradient(135deg, #4dabf7 0%, #339af0 100%);
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(77, 171, 247, 0.3);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid #f5f5f5;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: 10px 16px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.btn-action:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.btn-edit {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
.btn-edit:hover {
|
||||
background: #bbdefb;
|
||||
}
|
||||
|
||||
.btn-password {
|
||||
background: #fff3e0;
|
||||
color: #f57c00;
|
||||
}
|
||||
|
||||
.btn-password:hover {
|
||||
background: #ffe0b2;
|
||||
}
|
||||
|
||||
.btn-delete {
|
||||
background: #ffebee;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
.btn-delete:hover {
|
||||
background: #ffcdd2;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.dialog-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
background: white;
|
||||
padding: 28px;
|
||||
border-radius: 16px;
|
||||
width: 100%;
|
||||
max-width: 440px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.dialog h3 {
|
||||
margin: 0 0 24px 0;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e8e8e8;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #4dabf7;
|
||||
box-shadow: 0 0 0 3px rgba(77, 171, 247, 0.1);
|
||||
}
|
||||
|
||||
.form-group input:disabled {
|
||||
background: #f5f5f5;
|
||||
cursor: not-allowed;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.form-group input::placeholder {
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.dialog-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.dialog-actions button {
|
||||
padding: 12px 24px;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
background: #f5f5f5;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: #e8e8e8;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-manage {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.header h2 {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.admin-cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
min-width: 70px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dialog {
|
||||
padding: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -5,9 +5,20 @@
|
|||
<h1>管理后台</h1>
|
||||
<p>泉友米车友会</p>
|
||||
</div>
|
||||
<van-button class="logout-btn" size="small" round @click="logout">
|
||||
<van-icon name="logout" /> 退出
|
||||
</van-button>
|
||||
<div class="header-actions">
|
||||
<van-button
|
||||
v-if="isSuper"
|
||||
class="admin-btn"
|
||||
size="small"
|
||||
round
|
||||
@click="goToAdminManage"
|
||||
>
|
||||
<van-icon name="manager-o" /> 管理员
|
||||
</van-button>
|
||||
<van-button class="logout-btn" size="small" round @click="logout">
|
||||
<van-icon name="logout" /> 退出
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-content">
|
||||
|
|
@ -230,6 +241,7 @@ import api from '../../api';
|
|||
|
||||
const router = useRouter();
|
||||
const activities = ref([]);
|
||||
const isSuper = ref(false);
|
||||
|
||||
const showActivityForm = ref(false);
|
||||
const showMapPicker = ref(false);
|
||||
|
|
@ -290,14 +302,29 @@ const formatDateTime = (dateTime) => {
|
|||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
// 安全解析datetime-local格式,确保以本地时区创建Date对象
|
||||
const parseDateTimeLocal = (dateTimeStr) => {
|
||||
console.log('[时区转换] 输入的datetime-local字符串:', dateTimeStr);
|
||||
const [datePart, timePart] = dateTimeStr.split('T');
|
||||
const [year, month, day] = datePart.split('-').map(Number);
|
||||
const [hours, minutes] = timePart.split(':').map(Number);
|
||||
const localDate = new Date(year, month - 1, day, hours, minutes);
|
||||
console.log('[时区转换] 创建的本地Date对象:', localDate.toString());
|
||||
console.log('[时区转换] 转换为UTC (ISO):', localDate.toISOString());
|
||||
console.log('[时区转换] 时区偏移(分钟):', localDate.getTimezoneOffset());
|
||||
return localDate;
|
||||
};
|
||||
|
||||
// 开始时间确认
|
||||
const confirmStartTime = () => {
|
||||
if (!startTimeInput.value) {
|
||||
showToast('请选择开始时间');
|
||||
return;
|
||||
}
|
||||
const selectedDate = new Date(startTimeInput.value);
|
||||
console.log('[开始时间] 用户选择的时间:', startTimeInput.value);
|
||||
const selectedDate = parseDateTimeLocal(startTimeInput.value);
|
||||
activityForm.value.startTime = selectedDate.toISOString();
|
||||
console.log('[开始时间] 保存到表单的UTC时间:', activityForm.value.startTime);
|
||||
showStartTimePicker.value = false;
|
||||
};
|
||||
|
||||
|
|
@ -307,12 +334,14 @@ const confirmDeadline = () => {
|
|||
showToast('请选择报名截止时间');
|
||||
return;
|
||||
}
|
||||
const selectedDate = new Date(deadlineInput.value);
|
||||
console.log('[截止时间] 用户选择的时间:', deadlineInput.value);
|
||||
const selectedDate = parseDateTimeLocal(deadlineInput.value);
|
||||
if (activityForm.value.startTime && selectedDate > new Date(activityForm.value.startTime)) {
|
||||
showToast('报名截止时间不能晚于活动开始时间');
|
||||
return;
|
||||
}
|
||||
activityForm.value.registrationDeadline = selectedDate.toISOString();
|
||||
console.log('[截止时间] 保存到表单的UTC时间:', activityForm.value.registrationDeadline);
|
||||
showDeadlinePicker.value = false;
|
||||
};
|
||||
|
||||
|
|
@ -573,10 +602,17 @@ const submitActivity = async () => {
|
|||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('admin');
|
||||
router.push('/admin/login');
|
||||
};
|
||||
|
||||
const goToAdminManage = () => {
|
||||
router.push('/admin/admins');
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
const admin = JSON.parse(localStorage.getItem('admin') || '{}');
|
||||
isSuper.value = admin.role === 'super';
|
||||
loadActivities();
|
||||
});
|
||||
</script>
|
||||
|
|
@ -609,6 +645,22 @@ onMounted(() => {
|
|||
color: #666;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
background: #4dabf7;
|
||||
border: none;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-btn:active {
|
||||
background: #339af0;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
background: #f5f5f5;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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-3名(15-19分):精美礼品<br>
|
||||
• 第4-10名(10-14分):纪念品<br>
|
||||
• 参与奖(5-9分):小礼物<br>
|
||||
<br>
|
||||
随后自由合影、整理垃圾、提醒安全驾驶返程
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
@echo off
|
||||
echo ========================================
|
||||
echo 泉友米系统初始化
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
echo [1/2] 运行数据库迁移...
|
||||
node backend/migrations/index.js
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ✗ 迁移失败,请检查错误信息
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [2/2] 初始化管理员账号...
|
||||
node backend/init-admin.js
|
||||
if %errorlevel% neq 0 (
|
||||
echo.
|
||||
echo ✗ 初始化失败,请检查错误信息
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ========================================
|
||||
echo ✓ 初始化完成!
|
||||
echo ========================================
|
||||
echo.
|
||||
echo 管理员账号信息:
|
||||
echo 超级管理员: yvan / Xu950329.
|
||||
echo 普通管理员: admin / admin123
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "========================================"
|
||||
echo "泉友米系统初始化"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
|
||||
echo "[1/2] 运行数据库迁移..."
|
||||
node backend/migrations/index.js
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "✗ 迁移失败,请检查错误信息"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "[2/2] 初始化管理员账号..."
|
||||
node backend/init-admin.js
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "✗ 初始化失败,请检查错误信息"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "========================================"
|
||||
echo "✓ 初始化完成!"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "管理员账号信息:"
|
||||
echo " 超级管理员: yvan / Xu950329."
|
||||
echo " 普通管理员: admin / admin123"
|
||||
echo ""
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@echo off
|
||||
echo Running migration: Add group status field...
|
||||
cd backend
|
||||
node migrations/index.js
|
||||
cd ..
|
||||
echo Migration completed!
|
||||
pause
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
@echo off
|
||||
echo 正在运行数据库迁移...
|
||||
cd backend
|
||||
node migrations/index.js
|
||||
cd ..
|
||||
echo 迁移完成!
|
||||
pause
|
||||
Loading…
Reference in New Issue