Compare commits
2 Commits
89960b1cb8
...
2530ceb6b4
| Author | SHA1 | Date |
|---|---|---|
|
|
2530ceb6b4 | |
|
|
4920620d2c |
10
Dockerfile
10
Dockerfile
|
|
@ -20,14 +20,14 @@ FROM node:18-alpine
|
|||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 dumb-init 和 envsubst
|
||||
RUN apk add --no-cache dumb-init gettext
|
||||
# 安装 dumb-init、envsubst 和 SQLite 编译依赖
|
||||
RUN apk add --no-cache dumb-init gettext python3 make g++ sqlite-dev
|
||||
|
||||
# 复制后端依赖文件
|
||||
COPY backend/package*.json ./
|
||||
|
||||
# 安装后端依赖(仅生产依赖)
|
||||
RUN npm ci --only=production
|
||||
RUN npm install --only=production
|
||||
|
||||
# 复制后端源码
|
||||
COPY backend/ ./
|
||||
|
|
@ -35,8 +35,8 @@ COPY backend/ ./
|
|||
# 从前端构建阶段复制构建产物
|
||||
COPY --from=frontend-builder /app/frontend/dist ./public
|
||||
|
||||
# 创建上传目录
|
||||
RUN mkdir -p uploads
|
||||
# 创建上传和数据目录
|
||||
RUN mkdir -p uploads data
|
||||
|
||||
# 复制启动脚本
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
|
|
|
|||
|
|
@ -1,17 +1,6 @@
|
|||
PORT=3000
|
||||
DB_HOST=localhost
|
||||
DB_USER=root
|
||||
DB_PASSWORD=your-db-password
|
||||
DB_NAME=quanyoumi
|
||||
JWT_SECRET=your-secret-key-change-in-production
|
||||
|
||||
# MinIO 配置
|
||||
MINIO_ENDPOINT=localhost # 后端连接 MinIO 的地址
|
||||
MINIO_PORT=9000
|
||||
MINIO_USE_SSL=false
|
||||
MINIO_ACCESS_KEY=jFywUsaCb4zNr2LV7hTP
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET=quanyoumi
|
||||
# MINIO_PUBLIC_URL(可选):前端访问文件的公开地址,不填则自动用 MINIO_ENDPOINT 拼接
|
||||
# 生产环境如果用了反向代理(如 Nginx),填写外网地址,例如:https://cdn.yourdomain.com
|
||||
# MINIO_PUBLIC_URL=http://localhost:9000
|
||||
# 高德地图配置
|
||||
AMAP_KEY=your-amap-key
|
||||
AMAP_SECURITY_CODE=your-amap-security-code
|
||||
|
|
|
|||
|
|
@ -5,8 +5,21 @@ 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' } });
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log('✓ 管理员账号已存在');
|
||||
console.log(' 用户名: admin');
|
||||
console.log(' 提示: 如需重置密码,请删除数据库文件后重新初始化');
|
||||
process.exit(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const hashedPassword = bcrypt.hashSync('admin123', 10);
|
||||
await Admin.create({
|
||||
username: 'admin',
|
||||
|
|
@ -14,12 +27,17 @@ async function initAdmin() {
|
|||
name: '管理员'
|
||||
});
|
||||
|
||||
console.log('管理员账号创建成功');
|
||||
console.log('用户名: admin');
|
||||
console.log('密码: admin123');
|
||||
console.log('✓ 管理员账号创建成功');
|
||||
console.log(' 用户名: admin');
|
||||
console.log(' 密码: admin123');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('创建失败:', error.message);
|
||||
console.error('✗ 初始化失败:', error.message);
|
||||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
console.error(' 原因: 管理员账号已存在');
|
||||
} else {
|
||||
console.error(' 详细错误:', error);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,27 @@
|
|||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { uploadBuffer } = require('../utils/minio');
|
||||
const fs = require('fs');
|
||||
|
||||
// 确保上传目录存在
|
||||
const uploadDir = path.join(__dirname, '../uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
|
||||
// 配置本地存储
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueName = `${Date.now()}-${Math.random().toString(36).substring(7)}${path.extname(file.originalname)}`;
|
||||
cb(null, uniqueName);
|
||||
}
|
||||
});
|
||||
|
||||
// 使用内存存储,文件不落盘,不限制大小
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
storage,
|
||||
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const extname = /jpeg|jpg|png|gif|mp4|mov|avi/.test(
|
||||
path.extname(file.originalname).toLowerCase()
|
||||
|
|
@ -16,15 +32,14 @@ const upload = multer({
|
|||
}
|
||||
});
|
||||
|
||||
// 将 req.files 或 req.file 上传到 MinIO,返回 url 数组或单个 url
|
||||
const uploadFilesToMinio = async (files) => {
|
||||
// 返回文件访问URL
|
||||
const getFileUrls = (files) => {
|
||||
const list = Array.isArray(files) ? files : [files];
|
||||
return Promise.all(list.map(async (file) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const filename = `${Date.now()}-${uuidv4()}${ext}`;
|
||||
const url = await uploadBuffer(file.buffer, filename, file.mimetype);
|
||||
return { ...file, filename, url };
|
||||
return list.map(file => ({
|
||||
url: `/uploads/${file.filename}`,
|
||||
filename: file.filename,
|
||||
mimetype: file.mimetype
|
||||
}));
|
||||
};
|
||||
|
||||
module.exports = { upload, uploadFilesToMinio };
|
||||
module.exports = { upload, getFileUrls };
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* 迁移: 初始化数据库结构(确保所有表和字段存在)
|
||||
* 版本: 0000
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 检查并创建所有必需的表和字段...');
|
||||
|
||||
// Activities 表的额外字段
|
||||
const activityFields = [
|
||||
{ name: 'registrationEnabled', sql: 'ALTER TABLE Activities ADD COLUMN registrationEnabled INTEGER DEFAULT 1;' },
|
||||
{ name: 'coverImage', sql: 'ALTER TABLE Activities ADD COLUMN coverImage TEXT;' },
|
||||
{ name: 'price', sql: 'ALTER TABLE Activities ADD COLUMN price DECIMAL(10, 2) DEFAULT 0;' },
|
||||
{ name: 'budget', sql: 'ALTER TABLE Activities ADD COLUMN budget DECIMAL(10, 2) DEFAULT 0;' },
|
||||
{ name: 'locationLat', sql: 'ALTER TABLE Activities ADD COLUMN locationLat DECIMAL(10, 7);' },
|
||||
{ name: 'locationLng', sql: 'ALTER TABLE Activities ADD COLUMN locationLng DECIMAL(10, 7);' }
|
||||
];
|
||||
|
||||
for (const field of activityFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Registrations 表的额外字段
|
||||
const registrationFields = [
|
||||
{ name: 'peopleCount', sql: 'ALTER TABLE Registrations ADD COLUMN peopleCount INTEGER DEFAULT 1;' },
|
||||
{ name: 'note', sql: 'ALTER TABLE Registrations ADD COLUMN note TEXT;' }
|
||||
];
|
||||
|
||||
for (const field of registrationFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Expenses 表的额外字段
|
||||
const expenseFields = [
|
||||
{ name: 'category', sql: 'ALTER TABLE Expenses ADD COLUMN category TEXT;' },
|
||||
{ name: 'receipt', sql: 'ALTER TABLE Expenses ADD COLUMN receipt TEXT;' },
|
||||
{ name: 'note', sql: 'ALTER TABLE Expenses ADD COLUMN note TEXT;' },
|
||||
{ name: 'media', sql: 'ALTER TABLE Expenses ADD COLUMN media TEXT;' }
|
||||
];
|
||||
|
||||
for (const field of expenseFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Photos 表的额外字段
|
||||
const photoFields = [
|
||||
{ name: 'description', sql: 'ALTER TABLE Photos ADD COLUMN description TEXT;' },
|
||||
{ name: 'uploadedBy', sql: 'ALTER TABLE Photos ADD COLUMN uploadedBy TEXT;' },
|
||||
{ name: 'status', sql: 'ALTER TABLE Photos ADD COLUMN status TEXT DEFAULT "approved";' }
|
||||
];
|
||||
|
||||
for (const field of photoFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Sponsors 表的额外字段
|
||||
const sponsorFields = [
|
||||
{ name: 'logo', sql: 'ALTER TABLE Sponsors ADD COLUMN logo TEXT;' },
|
||||
{ name: 'type', sql: 'ALTER TABLE Sponsors ADD COLUMN type TEXT DEFAULT "sponsor";' },
|
||||
{ name: 'link', sql: 'ALTER TABLE Sponsors ADD COLUMN link TEXT;' }
|
||||
];
|
||||
|
||||
for (const field of sponsorFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
// Admins 表的额外字段
|
||||
const adminFields = [
|
||||
{ name: 'name', sql: 'ALTER TABLE Admins ADD COLUMN name TEXT;' }
|
||||
];
|
||||
|
||||
for (const field of adminFields) {
|
||||
await manager.executeSql(field.sql);
|
||||
}
|
||||
|
||||
console.log(' - 初始化完成');
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 回滚不支持');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/**
|
||||
* 迁移: 添加弹幕功能字段
|
||||
* 版本: 0001
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 danmakuEnabled 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Activities ADD COLUMN danmakuEnabled INTEGER DEFAULT 0;
|
||||
`);
|
||||
|
||||
console.log(' - 添加 danmakuContent 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Activities ADD COLUMN danmakuContent TEXT;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
// SQLite 不支持 DROP COLUMN,需要重建表
|
||||
console.log(' - 回滚不支持,请手动处理');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
/**
|
||||
* 迁移: 添加赞助商排序字段
|
||||
* 版本: 0002
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 Sponsors.sortOrder 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Sponsors ADD COLUMN sortOrder INTEGER DEFAULT 0;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 回滚不支持');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* 迁移: 添加签到功能字段
|
||||
* 版本: 0003
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 Registrations.checkInStatus 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Registrations ADD COLUMN checkInStatus INTEGER DEFAULT 0;
|
||||
`);
|
||||
|
||||
console.log(' - 添加 Registrations.checkInTime 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Registrations ADD COLUMN checkInTime DATETIME;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 回滚不支持');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* 迁移: 添加奖品表
|
||||
* 版本: 0004
|
||||
* 日期: 2025-01-10
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 创建 Prizes 表...');
|
||||
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE IF NOT EXISTS Prizes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activityId INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
level TEXT NOT NULL CHECK(level IN ('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||
description TEXT,
|
||||
image TEXT,
|
||||
quantity INTEGER DEFAULT 1,
|
||||
sortOrder INTEGER DEFAULT 0,
|
||||
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
console.log(' - 删除 Prizes 表...');
|
||||
await manager.executeSql('DROP TABLE IF EXISTS Prizes;');
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,190 @@
|
|||
# 数据库迁移系统
|
||||
|
||||
## 概述
|
||||
|
||||
本项目使用版本化的数据库迁移系统,支持增量更新和自动同步。
|
||||
|
||||
## 特性
|
||||
|
||||
- ✅ 版本管理:每个迁移有唯一版本号
|
||||
- ✅ 增量执行:只执行未执行的迁移
|
||||
- ✅ 自动运行:服务器启动时自动执行迁移
|
||||
- ✅ 幂等性:重复执行不会出错
|
||||
- ✅ 迁移记录:记录所有已执行的迁移
|
||||
|
||||
## 迁移文件命名规范
|
||||
|
||||
```
|
||||
XXXX-description.js
|
||||
```
|
||||
|
||||
- `XXXX`: 4位数字版本号(如 0001, 0002)
|
||||
- `description`: 迁移描述(使用短横线分隔)
|
||||
|
||||
示例:
|
||||
- `0001-add-danmaku-fields.js`
|
||||
- `0002-add-user-avatar.js`
|
||||
|
||||
## 创建新迁移
|
||||
|
||||
### 1. 创建迁移文件
|
||||
|
||||
在 `backend/migrations/` 目录下创建新文件:
|
||||
|
||||
```javascript
|
||||
/**
|
||||
* 迁移: 添加用户头像字段
|
||||
* 版本: 0002
|
||||
* 日期: 2025-01-09
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
console.log(' - 添加 avatar 字段...');
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Users ADD COLUMN avatar TEXT;
|
||||
`);
|
||||
},
|
||||
|
||||
async down(manager) {
|
||||
// 回滚逻辑(可选)
|
||||
console.log(' - 回滚不支持');
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 迁移方法
|
||||
|
||||
#### `up(manager)` - 升级
|
||||
执行数据库变更的方法。
|
||||
|
||||
可用的 manager 方法:
|
||||
- `executeSql(sql)`: 执行SQL语句
|
||||
- `db`: 直接访问数据库连接
|
||||
|
||||
#### `down(manager)` - 回滚(可选)
|
||||
回滚数据库变更的方法。
|
||||
|
||||
注意:SQLite 不支持 DROP COLUMN,回滚可能需要重建表。
|
||||
|
||||
## 执行迁移
|
||||
|
||||
### 自动执行(推荐)
|
||||
|
||||
服务器启动时会自动执行所有未执行的迁移:
|
||||
|
||||
```bash
|
||||
# 开发环境
|
||||
cd backend
|
||||
npm start
|
||||
|
||||
# 生产环境(Docker)
|
||||
docker-compose up -d
|
||||
# 或
|
||||
docker-compose restart
|
||||
```
|
||||
|
||||
### 手动执行
|
||||
|
||||
#### 在容器内执行:
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
migrate.bat
|
||||
|
||||
# Linux/Mac
|
||||
chmod +x migrate.sh
|
||||
./migrate.sh
|
||||
```
|
||||
|
||||
#### 直接在容器中执行:
|
||||
|
||||
```bash
|
||||
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||
```
|
||||
|
||||
## 迁移状态
|
||||
|
||||
迁移执行记录保存在 `migrations` 表中:
|
||||
|
||||
```sql
|
||||
SELECT * FROM migrations;
|
||||
```
|
||||
|
||||
输出示例:
|
||||
```
|
||||
id | version | name | executed_at
|
||||
---+---------+-----------------------+---------------------
|
||||
1 | 1 | add-danmaku-fields | 2025-01-09 10:30:00
|
||||
2 | 2 | add-user-avatar | 2025-01-09 11:00:00
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 迁移失败怎么办?
|
||||
|
||||
A: 检查错误日志,修复问题后重新启动服务器。已执行的迁移不会重复执行。
|
||||
|
||||
### Q: 如何回滚迁移?
|
||||
|
||||
A: 目前不支持自动回滚。如需回滚,请手动修改数据库或创建新的迁移来撤销更改。
|
||||
|
||||
### Q: 可以修改已执行的迁移吗?
|
||||
|
||||
A: 不建议。已执行的迁移不应修改。如需更改,请创建新的迁移。
|
||||
|
||||
### Q: 迁移在什么时候执行?
|
||||
|
||||
A: 服务器启动时自动执行。也可以手动运行 `migrate.bat` 或 `migrate.sh`。
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **版本号递增**:新迁移的版本号应大于所有现有迁移
|
||||
2. **描述清晰**:文件名和注释应清楚说明迁移目的
|
||||
3. **测试迁移**:在开发环境测试后再部署到生产环境
|
||||
4. **幂等性**:迁移应该可以安全地重复执行
|
||||
5. **小步迭代**:每个迁移只做一件事,便于追踪和回滚
|
||||
|
||||
## 示例
|
||||
|
||||
### 添加新字段
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
await manager.executeSql(`
|
||||
ALTER TABLE Activities ADD COLUMN featured INTEGER DEFAULT 0;
|
||||
`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 创建新表
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
await manager.executeSql(`
|
||||
CREATE TABLE IF NOT EXISTS Comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activityId INTEGER NOT NULL,
|
||||
userId INTEGER NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 修改数据
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
async up(manager) {
|
||||
await manager.executeSql(`
|
||||
UPDATE Activities SET status = 'completed' WHERE endTime < datetime('now');
|
||||
`);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const dbPath = path.join(__dirname, '..', 'data', 'database.sqlite');
|
||||
const migrationsDir = __dirname;
|
||||
|
||||
class MigrationManager {
|
||||
constructor() {
|
||||
this.db = new sqlite3.Database(dbPath);
|
||||
}
|
||||
|
||||
// 初始化迁移表
|
||||
async initMigrationTable() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(`
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
version INTEGER NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`, (err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 获取已执行的迁移版本
|
||||
async getExecutedMigrations() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all('SELECT version FROM migrations ORDER BY version', (err, rows) => {
|
||||
if (err) reject(err);
|
||||
else resolve(rows.map(r => r.version));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 记录迁移执行
|
||||
async recordMigration(version, name) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'INSERT INTO migrations (version, name) VALUES (?, ?)',
|
||||
[version, name],
|
||||
(err) => {
|
||||
if (err) reject(err);
|
||||
else resolve();
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 执行SQL语句
|
||||
async executeSql(sql) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(sql, (err) => {
|
||||
if (err) {
|
||||
// 忽略字段已存在的错误
|
||||
if (err.message.includes('duplicate column name')) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 加载所有迁移文件
|
||||
loadMigrations() {
|
||||
const files = fs.readdirSync(migrationsDir)
|
||||
.filter(f => f.match(/^\d{4}-.*\.js$/) && f !== 'index.js')
|
||||
.sort();
|
||||
|
||||
return files.map(file => {
|
||||
const version = parseInt(file.split('-')[0]);
|
||||
const name = file.replace(/^\d{4}-/, '').replace('.js', '');
|
||||
const migration = require(path.join(migrationsDir, file));
|
||||
return { version, name, file, migration };
|
||||
});
|
||||
}
|
||||
|
||||
// 执行迁移
|
||||
async runMigrations() {
|
||||
try {
|
||||
console.log('🔄 开始数据库迁移...\n');
|
||||
|
||||
// 初始化迁移表
|
||||
await this.initMigrationTable();
|
||||
|
||||
// 获取已执行的迁移
|
||||
const executed = await this.getExecutedMigrations();
|
||||
console.log(`📊 已执行迁移: ${executed.length} 个`);
|
||||
|
||||
// 加载所有迁移文件
|
||||
const migrations = this.loadMigrations();
|
||||
console.log(`📁 发现迁移文件: ${migrations.length} 个\n`);
|
||||
|
||||
// 执行未执行的迁移
|
||||
let count = 0;
|
||||
for (const { version, name, file, migration } of migrations) {
|
||||
if (!executed.includes(version)) {
|
||||
console.log(`⏳ 执行迁移 [${version}] ${name}...`);
|
||||
|
||||
try {
|
||||
await migration.up(this);
|
||||
await this.recordMigration(version, name);
|
||||
console.log(`✅ 迁移 [${version}] ${name} 完成\n`);
|
||||
count++;
|
||||
} catch (error) {
|
||||
console.error(`❌ 迁移 [${version}] ${name} 失败:`, error.message);
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
console.log(`⏭️ 跳过已执行的迁移 [${version}] ${name}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n✨ 迁移完成!共执行 ${count} 个新迁移`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ 迁移失败:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.db.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
const manager = new MigrationManager();
|
||||
manager.runMigrations()
|
||||
.then(() => process.exit(0))
|
||||
.catch(() => process.exit(1));
|
||||
}
|
||||
|
||||
module.exports = MigrationManager;
|
||||
|
|
@ -38,6 +38,15 @@ const Activity = sequelize.define('Activity', {
|
|||
type: DataTypes.DECIMAL(10, 2),
|
||||
defaultValue: 0,
|
||||
comment: '活动预算'
|
||||
},
|
||||
danmakuEnabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: '弹幕开关'
|
||||
},
|
||||
danmakuContent: {
|
||||
type: DataTypes.TEXT,
|
||||
comment: '弹幕内容JSON'
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
const sequelize = require('./db');
|
||||
|
||||
const Prize = sequelize.define('Prize', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true
|
||||
},
|
||||
activityId: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
level: {
|
||||
type: DataTypes.ENUM('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼'),
|
||||
allowNull: false
|
||||
},
|
||||
description: DataTypes.TEXT,
|
||||
image: DataTypes.STRING,
|
||||
quantity: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1
|
||||
},
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '排序值,数字越小越靠前'
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = Prize;
|
||||
|
|
@ -20,7 +20,12 @@ const Sponsor = sequelize.define('Sponsor', {
|
|||
type: DataTypes.ENUM('title', 'sponsor'),
|
||||
defaultValue: 'sponsor'
|
||||
},
|
||||
link: DataTypes.STRING
|
||||
link: DataTypes.STRING,
|
||||
sortOrder: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: '排序值,数字越小越靠前'
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = Sponsor;
|
||||
|
|
|
|||
|
|
@ -1,14 +1,17 @@
|
|||
const { Sequelize } = require('sequelize');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'quanyoumi',
|
||||
process.env.DB_USER || 'root',
|
||||
process.env.DB_PASSWORD || '',
|
||||
{
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
dialect: 'mysql',
|
||||
logging: false
|
||||
}
|
||||
);
|
||||
// 确保data目录存在
|
||||
const dataDir = path.join(__dirname, '../data');
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
const sequelize = new Sequelize({
|
||||
dialect: 'sqlite',
|
||||
storage: path.join(dataDir, 'database.sqlite'),
|
||||
logging: false
|
||||
});
|
||||
|
||||
module.exports = sequelize;
|
||||
|
|
|
|||
|
|
@ -4,11 +4,15 @@ const Registration = require('./Registration');
|
|||
const Expense = require('./Expense');
|
||||
const Photo = require('./Photo');
|
||||
const Sponsor = require('./Sponsor');
|
||||
const Prize = require('./Prize');
|
||||
const Admin = require('./Admin');
|
||||
|
||||
// 同步数据库
|
||||
sequelize.sync({ alter: true }).then(() => {
|
||||
console.log('数据库同步完成');
|
||||
// 使用 force: false 避免 SQLite alter 表时的约束冲突问题
|
||||
sequelize.sync({ force: false }).then(() => {
|
||||
console.log('✓ 数据库同步完成');
|
||||
}).catch(err => {
|
||||
console.error('✗ 数据库同步失败:', err.message);
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
|
|
@ -18,5 +22,6 @@ module.exports = {
|
|||
Expense,
|
||||
Photo,
|
||||
Sponsor,
|
||||
Prize,
|
||||
Admin
|
||||
};
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -9,15 +9,15 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"express": "^4.18.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"minio": "^8.0.7",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.6.0",
|
||||
"sequelize": "^6.33.0",
|
||||
"uuid": "^9.0.1"
|
||||
"sql.js": "^1.14.1",
|
||||
"sqlite3": "^5.1.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { Activity, Photo, Sponsor, Expense, Registration } = require('../models');
|
||||
const { upload, uploadFilesToMinio } = require('../middleware/upload');
|
||||
const { Activity, Photo, Sponsor, Expense, Registration, Prize } = require('../models');
|
||||
const { upload, getFileUrls } = require('../middleware/upload');
|
||||
|
||||
// 获取活动列表
|
||||
router.get('/activities', async (req, res) => {
|
||||
|
|
@ -23,10 +23,17 @@ router.get('/activities/:id', async (req, res) => {
|
|||
const photos = await Photo.findAll({
|
||||
where: { activityId: req.params.id, status: 'approved' }
|
||||
});
|
||||
const sponsors = await Sponsor.findAll({ where: { activityId: req.params.id } });
|
||||
const sponsors = await Sponsor.findAll({
|
||||
where: { activityId: req.params.id },
|
||||
order: [['sortOrder', 'ASC'], ['id', 'ASC']]
|
||||
});
|
||||
const expenses = await Expense.findAll({ where: { activityId: req.params.id } });
|
||||
const prizes = await Prize.findAll({
|
||||
where: { activityId: req.params.id },
|
||||
order: [['sortOrder', 'ASC'], ['id', 'ASC']]
|
||||
});
|
||||
|
||||
res.json({ activity, photos, sponsors, expenses });
|
||||
res.json({ activity, photos, sponsors, expenses, prizes });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
|
@ -46,7 +53,7 @@ router.get('/activities/:id/expenses', async (req, res) => {
|
|||
router.post('/activities/:id/photos', upload.array('photos', 10), async (req, res) => {
|
||||
try {
|
||||
const { uploadedBy } = req.body;
|
||||
const uploaded = await uploadFilesToMinio(req.files);
|
||||
const uploaded = getFileUrls(req.files);
|
||||
const photos = uploaded.map(file => ({
|
||||
activityId: req.params.id,
|
||||
url: file.url,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ const router = express.Router();
|
|||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const auth = require('../middleware/auth');
|
||||
const { upload, uploadFilesToMinio } = require('../middleware/upload');
|
||||
const { deleteFile } = require('../utils/minio');
|
||||
const { Admin, Activity, Registration, Expense, Photo, Sponsor } = require('../models');
|
||||
const authSuper = require('../middleware/authSuper');
|
||||
const { upload, getFileUrls } = require('../middleware/upload');
|
||||
const { Admin, Activity, Registration, Expense, Photo, Sponsor, Prize } = require('../models');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 管理员登录
|
||||
router.post('/login', async (req, res) => {
|
||||
|
|
@ -16,7 +18,132 @@ router.post('/login', async (req, res) => {
|
|||
return res.status(401).json({ error: '用户名或密码错误' });
|
||||
}
|
||||
const token = jwt.sign({ id: admin.id }, process.env.JWT_SECRET, { expiresIn: '7d' });
|
||||
res.json({ token, admin: { id: admin.id, username: admin.username, name: admin.name } });
|
||||
res.json({
|
||||
token,
|
||||
admin: {
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
name: admin.name,
|
||||
role: admin.role
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 管理员管理API(仅超级管理员) ==========
|
||||
|
||||
// 获取管理员列表
|
||||
router.get('/admins', authSuper, async (req, res) => {
|
||||
try {
|
||||
const admins = await Admin.findAll({
|
||||
attributes: ['id', 'username', 'name', 'role', 'createdAt'],
|
||||
order: [['createdAt', 'ASC']],
|
||||
raw: true
|
||||
});
|
||||
res.json(admins);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 创建管理员
|
||||
router.post('/admins', authSuper, async (req, res) => {
|
||||
try {
|
||||
const { username, password, name, role } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
return res.status(400).json({ error: '用户名和密码不能为空' });
|
||||
}
|
||||
|
||||
const existing = await Admin.findOne({ where: { username } });
|
||||
if (existing) {
|
||||
return res.status(400).json({ error: '用户名已存在' });
|
||||
}
|
||||
|
||||
const hashedPassword = bcrypt.hashSync(password, 10);
|
||||
const admin = await Admin.create({
|
||||
username,
|
||||
password: hashedPassword,
|
||||
name: name || username,
|
||||
role: role || 'admin'
|
||||
});
|
||||
|
||||
res.json({
|
||||
id: admin.id,
|
||||
username: admin.username,
|
||||
name: admin.name,
|
||||
role: admin.role
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 修改管理员信息
|
||||
router.put('/admins/:id', authSuper, async (req, res) => {
|
||||
try {
|
||||
const { name, role } = req.body;
|
||||
const admin = await Admin.findByPk(req.params.id);
|
||||
|
||||
if (!admin) {
|
||||
return res.status(404).json({ error: '管理员不存在' });
|
||||
}
|
||||
|
||||
const updateData = {};
|
||||
if (name !== undefined) updateData.name = name;
|
||||
if (role !== undefined) updateData.role = role;
|
||||
|
||||
await Admin.update(updateData, { where: { id: req.params.id } });
|
||||
const updated = await Admin.findByPk(req.params.id, {
|
||||
attributes: ['id', 'username', 'name', 'role']
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 修改管理员密码
|
||||
router.put('/admins/:id/password', authSuper, async (req, res) => {
|
||||
try {
|
||||
const { newPassword } = req.body;
|
||||
|
||||
if (!newPassword) {
|
||||
return res.status(400).json({ error: '新密码不能为空' });
|
||||
}
|
||||
|
||||
const admin = await Admin.findByPk(req.params.id);
|
||||
if (!admin) {
|
||||
return res.status(404).json({ error: '管理员不存在' });
|
||||
}
|
||||
|
||||
const hashedPassword = bcrypt.hashSync(newPassword, 10);
|
||||
await Admin.update({ password: hashedPassword }, { where: { id: req.params.id } });
|
||||
|
||||
res.json({ message: '密码修改成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除管理员
|
||||
router.delete('/admins/:id', authSuper, async (req, res) => {
|
||||
try {
|
||||
const admin = await Admin.findByPk(req.params.id);
|
||||
if (!admin) {
|
||||
return res.status(404).json({ error: '管理员不存在' });
|
||||
}
|
||||
|
||||
// 防止删除超级管理员
|
||||
if (admin.role === 'super') {
|
||||
return res.status(403).json({ error: '不能删除超级管理员' });
|
||||
}
|
||||
|
||||
await Admin.destroy({ where: { id: req.params.id } });
|
||||
res.json({ message: '删除成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
|
|
@ -92,6 +219,16 @@ router.get('/registrations', auth, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 删除报名
|
||||
router.delete('/registrations/:id', auth, async (req, res) => {
|
||||
try {
|
||||
await Registration.destroy({ where: { id: req.params.id } });
|
||||
res.json({ message: '删除成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 上传经费明细
|
||||
router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
|
||||
try {
|
||||
|
|
@ -100,7 +237,7 @@ router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
|
|||
let media = [];
|
||||
|
||||
if (req.files && req.files.length > 0) {
|
||||
const uploaded = await uploadFilesToMinio(req.files);
|
||||
const uploaded = getFileUrls(req.files);
|
||||
media = uploaded.map(file => ({
|
||||
url: file.url,
|
||||
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
|
||||
|
|
@ -152,7 +289,7 @@ router.put('/expenses/:id', auth, upload.array('media', 5), async (req, res) =>
|
|||
|
||||
// 处理新上传的媒体
|
||||
if (req.files && req.files.length > 0) {
|
||||
const uploaded = await uploadFilesToMinio(req.files);
|
||||
const uploaded = getFileUrls(req.files);
|
||||
const newMedia = uploaded.map(file => ({
|
||||
url: file.url,
|
||||
type: file.mimetype.startsWith('video/') ? 'video' : 'image'
|
||||
|
|
@ -184,7 +321,7 @@ router.delete('/expenses/:id', auth, async (req, res) => {
|
|||
router.post('/photos', auth, upload.array('photos', 10), async (req, res) => {
|
||||
try {
|
||||
const { activityId } = req.body;
|
||||
const uploaded = await uploadFilesToMinio(req.files);
|
||||
const uploaded = getFileUrls(req.files);
|
||||
const photos = uploaded.map(file => ({
|
||||
activityId,
|
||||
url: file.url,
|
||||
|
|
@ -231,11 +368,12 @@ router.delete('/photos/:id', auth, async (req, res) => {
|
|||
return res.status(404).json({ error: '照片不存在' });
|
||||
}
|
||||
|
||||
// 从URL提取文件名
|
||||
// 删除本地文件
|
||||
const filename = photo.url.split('/').pop();
|
||||
|
||||
// 从MinIO删除文件
|
||||
await deleteFile(filename);
|
||||
const filePath = path.join(__dirname, '../uploads', filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
// 从数据库删除记录
|
||||
await photo.destroy();
|
||||
|
|
@ -249,13 +387,20 @@ router.delete('/photos/:id', auth, async (req, res) => {
|
|||
// 添加赞助商
|
||||
router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { activityId, name, type, link } = req.body;
|
||||
const { activityId, name, type, link, sortOrder } = req.body;
|
||||
let logo = null;
|
||||
if (req.file) {
|
||||
const [uploaded] = await uploadFilesToMinio(req.file);
|
||||
const [uploaded] = getFileUrls(req.file);
|
||||
logo = uploaded.url;
|
||||
}
|
||||
const sponsor = await Sponsor.create({ activityId, name, logo, type, link });
|
||||
const sponsor = await Sponsor.create({
|
||||
activityId,
|
||||
name,
|
||||
logo,
|
||||
type,
|
||||
link,
|
||||
sortOrder: sortOrder || 0
|
||||
});
|
||||
res.json(sponsor);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
|
|
@ -265,12 +410,18 @@ router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
|
|||
// 更新赞助商
|
||||
router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { name, type, link } = req.body;
|
||||
const { name, type, link, sortOrder, removeLogo } = req.body;
|
||||
const updateData = { name, type, link };
|
||||
|
||||
if (sortOrder !== undefined) {
|
||||
updateData.sortOrder = sortOrder;
|
||||
}
|
||||
|
||||
if (req.file) {
|
||||
const [uploaded] = await uploadFilesToMinio(req.file);
|
||||
const [uploaded] = getFileUrls(req.file);
|
||||
updateData.logo = uploaded.url;
|
||||
} else if (removeLogo === 'true') {
|
||||
updateData.logo = null;
|
||||
}
|
||||
|
||||
await Sponsor.update(updateData, { where: { id: req.params.id } });
|
||||
|
|
@ -291,4 +442,61 @@ router.delete('/sponsors/:id', auth, async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// 添加奖品
|
||||
router.post('/prizes', auth, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { activityId, name, level, description, quantity, sortOrder, source, sponsor } = req.body;
|
||||
let image = null;
|
||||
if (req.file) {
|
||||
const [uploaded] = getFileUrls([req.file]);
|
||||
image = uploaded.url;
|
||||
}
|
||||
const prize = await Prize.create({
|
||||
activityId,
|
||||
name,
|
||||
level,
|
||||
description,
|
||||
image,
|
||||
quantity: quantity || 1,
|
||||
sortOrder: sortOrder || 0,
|
||||
source: source || 'purchase',
|
||||
sponsor: sponsor || null
|
||||
});
|
||||
res.json(prize);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 更新奖品
|
||||
router.put('/prizes/:id', auth, upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { name, level, description, quantity, sortOrder, source, sponsor, removeImage } = req.body;
|
||||
const updateData = { name, level, description, quantity, sortOrder, source, sponsor };
|
||||
|
||||
if (req.file) {
|
||||
const [uploaded] = getFileUrls([req.file]);
|
||||
updateData.image = uploaded.url;
|
||||
} else if (removeImage === 'true') {
|
||||
updateData.image = null;
|
||||
}
|
||||
|
||||
await Prize.update(updateData, { where: { id: req.params.id } });
|
||||
const prize = await Prize.findByPk(req.params.id);
|
||||
res.json(prize);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 删除奖品
|
||||
router.delete('/prizes/:id', auth, async (req, res) => {
|
||||
try {
|
||||
await Prize.destroy({ where: { id: req.params.id } });
|
||||
res.json({ message: '删除成功' });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ const app = express();
|
|||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// 静态文件服务 - 提供上传的文件
|
||||
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
|
||||
|
||||
// API 路由
|
||||
app.use('/api', require('./routes/activity'));
|
||||
app.use('/api', require('./routes/registration'));
|
||||
|
|
@ -26,11 +29,25 @@ if (process.env.NODE_ENV === 'production') {
|
|||
}
|
||||
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`服务器运行在 http://localhost:${PORT}`);
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log('运行模式: 生产环境(前后端一体)');
|
||||
} else {
|
||||
console.log('运行模式: 开发环境(仅后端API)');
|
||||
|
||||
// 启动服务器前运行数据库迁移
|
||||
const MigrationManager = require('./migrations/index');
|
||||
const runMigrations = async () => {
|
||||
try {
|
||||
const manager = new MigrationManager();
|
||||
await manager.runMigrations();
|
||||
} catch (error) {
|
||||
console.error('数据库迁移失败,但服务器将继续启动');
|
||||
}
|
||||
};
|
||||
|
||||
runMigrations().then(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`服务器运行在 http://localhost:${PORT}`);
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.log('运行模式: 生产环境(前后端一体)');
|
||||
} else {
|
||||
console.log('运行模式: 开发环境(仅后端API)');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
44
build.bat
44
build.bat
|
|
@ -1,10 +1,26 @@
|
|||
@echo off
|
||||
REM 泉友米车友会 - Docker 构建脚本 (Windows)
|
||||
REM 版本: 0.0.1
|
||||
|
||||
REM ==========================================
|
||||
REM 版本号配置(修改这里即可)
|
||||
REM ==========================================
|
||||
set VERSION_FILE=.version
|
||||
set MAJOR=0
|
||||
set MINOR=0
|
||||
|
||||
REM 读取或初始化补丁版本号
|
||||
if exist %VERSION_FILE% (
|
||||
set /p PATCH=<%VERSION_FILE%
|
||||
) else (
|
||||
set PATCH=1
|
||||
)
|
||||
|
||||
REM 组合完整版本号
|
||||
set VERSION=%MAJOR%.%MINOR%.%PATCH%
|
||||
|
||||
echo ==========================================
|
||||
echo 泉友米车友会 Docker 镜像构建
|
||||
echo 版本: 0.0.1
|
||||
echo 版本: %VERSION%
|
||||
echo ==========================================
|
||||
|
||||
REM 检查 Docker 是否安装
|
||||
|
|
@ -17,7 +33,7 @@ if errorlevel 1 (
|
|||
|
||||
echo.
|
||||
echo 开始构建 Docker 镜像...
|
||||
docker build -t quanyoumi-app:0.0.1 -t quanyoumi-app:latest .
|
||||
docker build -t quanyoumi-app:%VERSION% -t quanyoumi-app:latest .
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
|
|
@ -26,23 +42,30 @@ if errorlevel 1 (
|
|||
exit /b 1
|
||||
)
|
||||
|
||||
REM 构建成功后自动递增版本号
|
||||
set /a NEXT_PATCH=%PATCH%+1
|
||||
echo %NEXT_PATCH%>%VERSION_FILE%
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo 构建完成!
|
||||
echo 构建完成!版本号已更新
|
||||
echo ==========================================
|
||||
echo.
|
||||
echo 当前版本: %VERSION%
|
||||
echo 下次版本: %MAJOR%.%MINOR%.%NEXT_PATCH%
|
||||
echo.
|
||||
echo 镜像信息:
|
||||
docker images | findstr quanyoumi-app
|
||||
echo.
|
||||
|
||||
REM 标记镜像到阿里云仓库
|
||||
echo 正在标记镜像到阿里云仓库...
|
||||
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
||||
docker tag quanyoumi-app:%VERSION% registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
|
||||
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||
|
||||
echo.
|
||||
echo 正在推送镜像到阿里云仓库...
|
||||
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
||||
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
|
||||
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||
|
||||
if errorlevel 1 (
|
||||
|
|
@ -58,12 +81,17 @@ echo ==========================================
|
|||
echo 推送完成!
|
||||
echo ==========================================
|
||||
echo.
|
||||
echo 当前版本: %VERSION%
|
||||
echo 下次版本: %MAJOR%.%MINOR%.%NEXT_PATCH%
|
||||
echo.
|
||||
echo 镜像地址:
|
||||
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
||||
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
|
||||
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||
echo.
|
||||
echo 在 NAS 上使用:
|
||||
echo docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
||||
echo docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
|
||||
echo docker-compose up -d
|
||||
echo.
|
||||
echo 提示: 如需修改主版本号或次版本号,请编辑 build.bat 文件顶部的 MAJOR 和 MINOR 变量
|
||||
echo.
|
||||
pause
|
||||
|
|
|
|||
44
build.sh
44
build.sh
|
|
@ -1,13 +1,29 @@
|
|||
#!/bin/bash
|
||||
|
||||
# 泉友米车友会 - Docker 构建脚本
|
||||
# 版本: 0.0.1
|
||||
|
||||
set -e
|
||||
|
||||
# ==========================================
|
||||
# 版本号配置(修改这里即可)
|
||||
# ==========================================
|
||||
VERSION_FILE=".version"
|
||||
MAJOR=0
|
||||
MINOR=0
|
||||
|
||||
# 读取或初始化补丁版本号
|
||||
if [ -f "$VERSION_FILE" ]; then
|
||||
PATCH=$(cat "$VERSION_FILE")
|
||||
else
|
||||
PATCH=1
|
||||
fi
|
||||
|
||||
# 组合完整版本号
|
||||
VERSION="$MAJOR.$MINOR.$PATCH"
|
||||
|
||||
echo "=========================================="
|
||||
echo "泉友米车友会 Docker 镜像构建"
|
||||
echo "版本: 0.0.1"
|
||||
echo "版本: $VERSION"
|
||||
echo "=========================================="
|
||||
|
||||
# 检查 Docker 是否安装
|
||||
|
|
@ -19,25 +35,32 @@ fi
|
|||
# 构建镜像
|
||||
echo ""
|
||||
echo "开始构建 Docker 镜像..."
|
||||
docker build -t quanyoumi-app:0.0.1 -t quanyoumi-app:latest .
|
||||
docker build -t quanyoumi-app:$VERSION -t quanyoumi-app:latest .
|
||||
|
||||
# 构建成功后自动递增版本号
|
||||
NEXT_PATCH=$((PATCH + 1))
|
||||
echo $NEXT_PATCH > "$VERSION_FILE"
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "构建完成!"
|
||||
echo "构建完成!版本号已更新"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "当前版本: $VERSION"
|
||||
echo "下次版本: $MAJOR.$MINOR.$NEXT_PATCH"
|
||||
echo ""
|
||||
echo "镜像信息:"
|
||||
docker images | grep quanyoumi-app
|
||||
echo ""
|
||||
|
||||
# 标记镜像到阿里云仓库
|
||||
echo "正在标记镜像到阿里云仓库..."
|
||||
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
||||
docker tag quanyoumi-app:$VERSION registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION
|
||||
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||
|
||||
echo ""
|
||||
echo "正在推送镜像到阿里云仓库..."
|
||||
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
||||
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION
|
||||
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
|
||||
|
||||
echo ""
|
||||
|
|
@ -45,11 +68,16 @@ echo "=========================================="
|
|||
echo "推送完成!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "当前版本: $VERSION"
|
||||
echo "下次版本: $MAJOR.$MINOR.$NEXT_PATCH"
|
||||
echo ""
|
||||
echo "镜像地址:"
|
||||
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1"
|
||||
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION"
|
||||
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest"
|
||||
echo ""
|
||||
echo "在 NAS 上使用:"
|
||||
echo " docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1"
|
||||
echo " docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION"
|
||||
echo " docker-compose up -d"
|
||||
echo ""
|
||||
echo "提示: 如需修改主版本号或次版本号,请编辑 build.sh 文件顶部的 MAJOR 和 MINOR 变量"
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
@echo off
|
||||
echo ==========================================
|
||||
echo 清理数据库文件
|
||||
echo ==========================================
|
||||
|
||||
echo 停止容器...
|
||||
docker-compose down
|
||||
|
||||
echo 删除数据库文件...
|
||||
if exist backend\data\database.sqlite (
|
||||
del /f backend\data\database.sqlite
|
||||
echo 数据库文件已删除
|
||||
) else (
|
||||
echo 数据库文件不存在
|
||||
)
|
||||
|
||||
echo.
|
||||
echo 清理完成!现在可以运行 start.bat 重新启动
|
||||
pause
|
||||
|
|
@ -3,7 +3,7 @@ version: '3.8'
|
|||
services:
|
||||
# 应用服务(前端+后端)
|
||||
app:
|
||||
image: registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1
|
||||
image: registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.2
|
||||
container_name: quanyoumi-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
|
@ -11,28 +11,14 @@ services:
|
|||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
# 数据库配置
|
||||
- DB_HOST=db
|
||||
- DB_PORT=3306
|
||||
- DB_USER=root
|
||||
- DB_PASSWORD=quanyoumi2024
|
||||
- DB_NAME=quanyoumi
|
||||
# JWT 密钥(请修改为随机字符串)
|
||||
- JWT_SECRET=quanyoumi_jwt_secret_change_this_in_production_2024
|
||||
# 高德地图配置(必填)
|
||||
- AMAP_KEY=fd5b77136ba34fe6b4f0f86dca7782d9
|
||||
- AMAP_SECURITY_CODE=7f61638e5635d944a534ff747dc615ac
|
||||
# MinIO 配置
|
||||
- MINIO_ENDPOINT=minio
|
||||
- MINIO_PORT=9000
|
||||
- MINIO_ACCESS_KEY=minioadmin
|
||||
- MINIO_SECRET_KEY=minioadmin123
|
||||
- MINIO_BUCKET=quanyoumi
|
||||
volumes:
|
||||
- app-data:/app/data
|
||||
- app-uploads:/app/uploads
|
||||
depends_on:
|
||||
- db
|
||||
- minio
|
||||
networks:
|
||||
- quanyoumi-network
|
||||
healthcheck:
|
||||
|
|
@ -42,54 +28,8 @@ services:
|
|||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
# MySQL 数据库
|
||||
db:
|
||||
image: mysql:8.0
|
||||
container_name: quanyoumi-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- MYSQL_ROOT_PASSWORD=quanyoumi2024
|
||||
- MYSQL_DATABASE=quanyoumi
|
||||
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
|
||||
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
- ./database.sql:/docker-entrypoint-initdb.d/init.sql
|
||||
networks:
|
||||
- quanyoumi-network
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pquanyoumi2024"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
|
||||
# MinIO 对象存储
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
container_name: quanyoumi-minio
|
||||
restart: unless-stopped
|
||||
command: server /data --console-address ":9001"
|
||||
environment:
|
||||
- MINIO_ROOT_USER=minioadmin
|
||||
- MINIO_ROOT_PASSWORD=minioadmin123
|
||||
volumes:
|
||||
- minio-data:/data
|
||||
ports:
|
||||
- "9001:9001"
|
||||
networks:
|
||||
- quanyoumi-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||
interval: 30s
|
||||
timeout: 20s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
driver: local
|
||||
minio-data:
|
||||
app-data:
|
||||
driver: local
|
||||
app-uploads:
|
||||
driver: local
|
||||
|
|
|
|||
|
|
@ -5,6 +5,24 @@ echo "=========================================="
|
|||
echo "泉友米车友会 - 启动中..."
|
||||
echo "=========================================="
|
||||
|
||||
# 检查数据库文件
|
||||
DB_FILE="/app/data/database.sqlite"
|
||||
if [ -f "$DB_FILE" ]; then
|
||||
echo "✓ 发现已存在的数据库文件"
|
||||
# 检查是否有备份表残留(这会导致sync失败)
|
||||
if sqlite3 "$DB_FILE" "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_backup%';" 2>/dev/null | grep -q "_backup"; then
|
||||
echo "⚠ 检测到备份表残留,清理中..."
|
||||
sqlite3 "$DB_FILE" "SELECT 'DROP TABLE IF EXISTS ' || name || ';' FROM sqlite_master WHERE type='table' AND name LIKE '%_backup%';" | sqlite3 "$DB_FILE" 2>/dev/null || true
|
||||
echo "✓ 备份表已清理"
|
||||
fi
|
||||
else
|
||||
echo "✓ 首次启动,将创建新数据库"
|
||||
fi
|
||||
|
||||
# 初始化数据库
|
||||
echo "初始化数据库..."
|
||||
node init-admin.js
|
||||
|
||||
# 替换前端 index.html 中的环境变量占位符
|
||||
if [ -f "/app/public/index.html" ]; then
|
||||
echo "配置高德地图 Key..."
|
||||
|
|
|
|||
|
|
@ -1,201 +0,0 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>高德地图搜索测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
padding: 20px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
#container {
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.search-box {
|
||||
margin: 20px 0;
|
||||
}
|
||||
input {
|
||||
padding: 10px;
|
||||
width: 300px;
|
||||
font-size: 14px;
|
||||
}
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.results {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.result-item {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
cursor: pointer;
|
||||
}
|
||||
.result-item:hover {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.log {
|
||||
background: #f5f5f5;
|
||||
padding: 10px;
|
||||
margin-top: 20px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>高德地图搜索功能测试</h1>
|
||||
|
||||
<div class="search-box">
|
||||
<input type="text" id="keyword" placeholder="输入地点名称,如:泉州市政府" />
|
||||
<button onclick="searchPlace()">搜索</button>
|
||||
</div>
|
||||
|
||||
<div id="container"></div>
|
||||
|
||||
<div class="results" id="results"></div>
|
||||
|
||||
<div class="log" id="log"></div>
|
||||
|
||||
<script>
|
||||
// 替换为你的高德地图Key和安全密钥
|
||||
const AMAP_KEY = 'fd5b77136ba34fe6b4f0f86dca7782d9';
|
||||
const SECURITY_CODE = 'b2e67bb4d5eed8848f0b8aae6ca667fc';
|
||||
|
||||
let map = null;
|
||||
let placeSearch = null;
|
||||
let marker = null;
|
||||
|
||||
function log(message) {
|
||||
const logDiv = document.getElementById('log');
|
||||
const time = new Date().toLocaleTimeString();
|
||||
logDiv.innerHTML += `[${time}] ${message}<br>`;
|
||||
logDiv.scrollTop = logDiv.scrollHeight;
|
||||
console.log(message);
|
||||
}
|
||||
|
||||
function initMap() {
|
||||
log('开始初始化地图...');
|
||||
|
||||
map = new AMap.Map('container', {
|
||||
zoom: 13,
|
||||
center: [118.6762, 24.8742] // 泉州市中心
|
||||
});
|
||||
|
||||
log('地图初始化成功');
|
||||
|
||||
// 初始化PlaceSearch
|
||||
AMap.plugin(['AMap.PlaceSearch'], function() {
|
||||
placeSearch = new AMap.PlaceSearch({
|
||||
pageSize: 10,
|
||||
pageIndex: 1,
|
||||
city: '泉州',
|
||||
citylimit: false,
|
||||
extensions: 'base'
|
||||
});
|
||||
log('PlaceSearch插件加载成功');
|
||||
log('配置信息:city=泉州, citylimit=false, extensions=base');
|
||||
});
|
||||
}
|
||||
|
||||
function searchPlace() {
|
||||
const keyword = document.getElementById('keyword').value.trim();
|
||||
|
||||
if (!keyword) {
|
||||
alert('请输入搜索关键词');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!placeSearch) {
|
||||
log('错误:PlaceSearch未初始化');
|
||||
alert('地图搜索功能未就绪,请刷新页面重试');
|
||||
return;
|
||||
}
|
||||
|
||||
log(`开始搜索:${keyword}`);
|
||||
|
||||
placeSearch.search(keyword, function(status, result) {
|
||||
log(`搜索状态:${status}`);
|
||||
|
||||
const resultsDiv = document.getElementById('results');
|
||||
resultsDiv.innerHTML = '';
|
||||
|
||||
if (status === 'complete' && result.info === 'OK') {
|
||||
if (result.poiList && result.poiList.pois && result.poiList.pois.length > 0) {
|
||||
log(`找到 ${result.poiList.pois.length} 个结果`);
|
||||
|
||||
result.poiList.pois.forEach((poi, index) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'result-item';
|
||||
div.innerHTML = `
|
||||
<strong>${index + 1}. ${poi.name}</strong><br>
|
||||
地址:${poi.address || '无'}<br>
|
||||
坐标:${poi.location.lng}, ${poi.location.lat}
|
||||
`;
|
||||
div.onclick = () => showOnMap(poi);
|
||||
resultsDiv.appendChild(div);
|
||||
});
|
||||
} else {
|
||||
log('搜索结果为空');
|
||||
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
|
||||
}
|
||||
} else if (status === 'no_data') {
|
||||
log('无搜索结果');
|
||||
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
|
||||
} else {
|
||||
log(`搜索失败:${status} - ${result.info || '未知错误'}`);
|
||||
log(`完整结果:${JSON.stringify(result)}`);
|
||||
resultsDiv.innerHTML = `<p style="color: red;">搜索失败:${result.info || status}</p>`;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showOnMap(poi) {
|
||||
const position = [poi.location.lng, poi.location.lat];
|
||||
|
||||
if (marker) {
|
||||
marker.setPosition(position);
|
||||
} else {
|
||||
marker = new AMap.Marker({
|
||||
position: position,
|
||||
map: map
|
||||
});
|
||||
}
|
||||
|
||||
map.setCenter(position);
|
||||
map.setZoom(16);
|
||||
|
||||
log(`在地图上显示:${poi.name}`);
|
||||
}
|
||||
|
||||
// 配置安全密钥(必须在加载地图前配置)
|
||||
window._AMapSecurityConfig = {
|
||||
securityJsCode: SECURITY_CODE
|
||||
};
|
||||
|
||||
log(`配置安全密钥:${SECURITY_CODE}`);
|
||||
|
||||
// 加载高德地图脚本
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://webapi.amap.com/maps?v=2.0&key=${AMAP_KEY}`;
|
||||
script.onload = () => {
|
||||
log('高德地图脚本加载成功');
|
||||
initMap();
|
||||
};
|
||||
script.onerror = () => {
|
||||
log('错误:高德地图脚本加载失败');
|
||||
alert('地图加载失败,请检查网络连接或API Key配置');
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
|
|
@ -1,7 +1,7 @@
|
|||
import axios from 'axios';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
baseURL: 'https://mi.xyvan.cn/api',
|
||||
timeout: 10000
|
||||
});
|
||||
|
||||
|
|
@ -13,6 +13,29 @@ api.interceptors.request.use(config => {
|
|||
return config;
|
||||
});
|
||||
|
||||
// 响应拦截器:确保所有错误都能被正确捕获
|
||||
api.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
// 统一错误处理
|
||||
console.error('API请求错误:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 获取完整的图片URL
|
||||
export const getImageUrl = (path) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
// 开发环境使用代理,生产环境使用完整域名
|
||||
if (import.meta.env.DEV) {
|
||||
return `http://localhost:3000${path}`;
|
||||
}
|
||||
return `https://mi.xyvan.cn${path}`;
|
||||
};
|
||||
|
||||
export default {
|
||||
// 活动相关
|
||||
getActivities: (params) => api.get('/activities', { params }),
|
||||
|
|
@ -65,5 +88,14 @@ export default {
|
|||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000
|
||||
}),
|
||||
deleteSponsor: (id) => api.delete(`/admin/sponsors/${id}`)
|
||||
deleteSponsor: (id) => api.delete(`/admin/sponsors/${id}`),
|
||||
addPrize: (data) => api.post('/admin/prizes', data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000
|
||||
}),
|
||||
updatePrize: (id, data) => api.put(`/admin/prizes/${id}`, data, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000
|
||||
}),
|
||||
deletePrize: (id) => api.delete(`/admin/prizes/${id}`)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,12 @@ const routes = [
|
|||
{
|
||||
path: '/activity/:id',
|
||||
name: 'ActivityDetail',
|
||||
component: () => import('../views/ActivityDetail.vue')
|
||||
component: () => import('../views/ActivityDetail/index.vue')
|
||||
},
|
||||
{
|
||||
path: '/danmaku-demo',
|
||||
name: 'DanmakuDemo',
|
||||
component: () => import('../views/ActivityDetail/DanmakuDemo.vue')
|
||||
},
|
||||
{
|
||||
path: '/register/:id',
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,292 @@
|
|||
<template>
|
||||
<div class="danmaku-demo">
|
||||
<h2>弹幕效果演示</h2>
|
||||
<p class="demo-tip">鼠标悬停可暂停弹幕</p>
|
||||
|
||||
<div class="danmaku-container">
|
||||
<div
|
||||
v-for="(item, index) in activeDanmaku"
|
||||
:key="item.id"
|
||||
class="danmaku-item"
|
||||
:class="[`track-${item.track}`, `speed-${item.speed}`, item.type]"
|
||||
:style="{ animationDelay: `${item.delay}s` }"
|
||||
>
|
||||
<div class="danmaku-card">
|
||||
<div class="danmaku-logo">
|
||||
<img v-if="item.logo" :src="item.logo" :alt="item.name" />
|
||||
<span v-else class="logo-placeholder">{{ item.name.charAt(0) }}</span>
|
||||
</div>
|
||||
<div class="danmaku-content">
|
||||
<span class="sponsor-name">{{ item.name }}</span>
|
||||
<span class="sponsor-action">{{ item.type === 'title' ? '冠名赞助' : '赞助了' }}</span>
|
||||
<span class="sponsor-item">{{ item.item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
// 模拟赞助数据
|
||||
const mockSponsors = [
|
||||
{ id: 1, name: '小米汽车', logo: '', type: 'title', item: '本次活动' },
|
||||
{ id: 2, name: '泉州欧乐堡', logo: '', type: 'sponsor', item: '游乐园门票' },
|
||||
{ id: 3, name: '德劲新能源', logo: '', type: 'sponsor', item: '充电服务' },
|
||||
{ id: 4, name: '泉友米车友会', logo: '', type: 'sponsor', item: '活动组织' },
|
||||
{ id: 5, name: '本地商家A', logo: '', type: 'sponsor', item: '饮料零食' },
|
||||
{ id: 6, name: '本地商家B', logo: '', type: 'sponsor', item: '精美礼品' },
|
||||
{ id: 7, name: '本地商家C', logo: '', type: 'title', item: '特别赞助' },
|
||||
{ id: 8, name: '本地商家D', logo: '', type: 'sponsor', item: '活动道具' },
|
||||
];
|
||||
|
||||
const activeDanmaku = ref([]);
|
||||
const speeds = ['slow', 'medium', 'fast'];
|
||||
const tracks = [0, 1, 2, 3];
|
||||
let intervalId = null;
|
||||
|
||||
const generateDanmaku = () => {
|
||||
const sponsor = mockSponsors[Math.floor(Math.random() * mockSponsors.length)];
|
||||
const track = tracks[Math.floor(Math.random() * tracks.length)];
|
||||
const speed = speeds[Math.floor(Math.random() * speeds.length)];
|
||||
|
||||
return {
|
||||
...sponsor,
|
||||
id: Date.now() + Math.random(),
|
||||
track,
|
||||
speed,
|
||||
delay: 0
|
||||
};
|
||||
};
|
||||
|
||||
const addDanmaku = () => {
|
||||
if (activeDanmaku.value.length < 12) {
|
||||
activeDanmaku.value.push(generateDanmaku());
|
||||
}
|
||||
|
||||
// 清理已完成的弹幕
|
||||
if (activeDanmaku.value.length > 15) {
|
||||
activeDanmaku.value = activeDanmaku.value.slice(-12);
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
// 初始添加几条弹幕
|
||||
for (let i = 0; i < 6; i++) {
|
||||
setTimeout(() => addDanmaku(), i * 800);
|
||||
}
|
||||
|
||||
// 定期添加新弹幕
|
||||
intervalId = setInterval(addDanmaku, 2000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId) clearInterval(intervalId);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.danmaku-demo {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
text-align: center;
|
||||
color: white;
|
||||
font-size: 32px;
|
||||
margin: 40px 0 10px;
|
||||
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.demo-tip {
|
||||
text-align: center;
|
||||
color: rgba(255,255,255,0.9);
|
||||
font-size: 14px;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
|
||||
.danmaku-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
overflow: hidden;
|
||||
background: rgba(0,0,0,0.1);
|
||||
border-radius: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.danmaku-item {
|
||||
position: absolute;
|
||||
right: -100%;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.danmaku-item:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
/* 轨道位置 */
|
||||
.track-0 { top: 20px; }
|
||||
.track-1 { top: 110px; }
|
||||
.track-2 { top: 200px; }
|
||||
.track-3 { top: 290px; }
|
||||
|
||||
/* 速度变化 */
|
||||
.speed-slow {
|
||||
animation: danmaku-move 20s linear infinite;
|
||||
}
|
||||
|
||||
.speed-medium {
|
||||
animation: danmaku-move 15s linear infinite;
|
||||
}
|
||||
|
||||
.speed-fast {
|
||||
animation: danmaku-move 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes danmaku-move {
|
||||
0% {
|
||||
right: -100%;
|
||||
opacity: 0;
|
||||
}
|
||||
5% {
|
||||
opacity: 1;
|
||||
}
|
||||
95% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
right: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.danmaku-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 50px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.danmaku-item:hover .danmaku-card {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 6px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* 冠名赞助样式 */
|
||||
.danmaku-item.title .danmaku-card {
|
||||
background: linear-gradient(135deg, rgba(255, 215, 0, 0.9) 0%, rgba(255, 165, 0, 0.9) 100%);
|
||||
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.danmaku-item.title .danmaku-content {
|
||||
color: #fff;
|
||||
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* 普通赞助样式 */
|
||||
.danmaku-item.sponsor .danmaku-card {
|
||||
background: linear-gradient(135deg, rgba(74, 144, 226, 0.9) 0%, rgba(53, 122, 189, 0.9) 100%);
|
||||
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.danmaku-item.sponsor .danmaku-content {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.danmaku-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.danmaku-logo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.logo-placeholder {
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.danmaku-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sponsor-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sponsor-action {
|
||||
opacity: 0.9;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sponsor-item {
|
||||
font-weight: 600;
|
||||
padding: 2px 8px;
|
||||
background: rgba(255,255,255,0.2);
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 768px) {
|
||||
.danmaku-container {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.track-0 { top: 15px; }
|
||||
.track-1 { top: 85px; }
|
||||
.track-2 { top: 155px; }
|
||||
.track-3 { top: 225px; }
|
||||
|
||||
.danmaku-card {
|
||||
padding: 8px 16px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.danmaku-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.danmaku-content {
|
||||
font-size: 13px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.sponsor-action {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sponsor-item {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,266 @@
|
|||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// 单条固定弹幕,无需额外逻辑
|
||||
const handleImageError = (e) => {
|
||||
// 如果图片加载失败,显示文字占位
|
||||
e.target.style.display = 'none';
|
||||
e.target.parentElement.innerHTML = '<span class="avatar-placeholder">王</span>';
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sponsor-danmaku {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 70px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.danmaku-item {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: -100%;
|
||||
animation: danmaku-move 15s linear infinite;
|
||||
will-change: transform;
|
||||
pointer-events: auto;
|
||||
filter: drop-shadow(0 8px 30px rgba(212, 175, 55, 0.6));
|
||||
}
|
||||
|
||||
.danmaku-item:hover {
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes danmaku-move {
|
||||
0% {
|
||||
right: -100%;
|
||||
opacity: 0;
|
||||
}
|
||||
5% {
|
||||
opacity: 1;
|
||||
}
|
||||
95% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
right: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.danmaku-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 24px;
|
||||
border-radius: 35px;
|
||||
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%);
|
||||
border: 2px solid #d4af37;
|
||||
box-shadow:
|
||||
0 0 20px rgba(212, 175, 55, 0.5),
|
||||
0 0 40px rgba(212, 175, 55, 0.3),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||
transition: all 0.3s ease;
|
||||
white-space: nowrap;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.danmaku-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.3), transparent);
|
||||
animation: shine 3s infinite;
|
||||
}
|
||||
|
||||
@keyframes shine {
|
||||
0% {
|
||||
left: -100%;
|
||||
}
|
||||
50%, 100% {
|
||||
left: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.danmaku-item:hover .danmaku-card {
|
||||
transform: scale(1.1);
|
||||
box-shadow:
|
||||
0 0 30px rgba(212, 175, 55, 0.8),
|
||||
0 0 60px rgba(212, 175, 55, 0.5),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.danmaku-avatar {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||
flex-shrink: 0;
|
||||
border: 3px solid #d4af37;
|
||||
box-shadow:
|
||||
0 0 15px rgba(212, 175, 55, 0.8),
|
||||
inset 0 0 10px rgba(0, 0, 0, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.danmaku-avatar::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -50%;
|
||||
left: -50%;
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.3) 50%, transparent 70%);
|
||||
animation: avatar-shine 4s infinite;
|
||||
}
|
||||
|
||||
@keyframes avatar-shine {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.danmaku-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.thank-text {
|
||||
font-size: 14px;
|
||||
color: #d4af37;
|
||||
font-weight: 500;
|
||||
text-shadow: 0 0 10px rgba(212, 175, 55, 0.8);
|
||||
}
|
||||
|
||||
.sponsor-name {
|
||||
font-weight: 800;
|
||||
font-size: 17px;
|
||||
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-shadow: 0 0 20px rgba(212, 175, 55, 0.8);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.sponsor-action {
|
||||
color: #c0c0c0;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sponsor-item {
|
||||
font-weight: 700;
|
||||
padding: 4px 12px;
|
||||
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||
border-radius: 15px;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
box-shadow:
|
||||
0 0 15px rgba(212, 175, 55, 0.6),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sponsor-danmaku {
|
||||
top: 60px;
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.danmaku-item {
|
||||
top: 5px;
|
||||
}
|
||||
|
||||
.danmaku-card {
|
||||
padding: 8px 18px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.danmaku-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 2.5px solid #d4af37;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.danmaku-content {
|
||||
font-size: 13px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.thank-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sponsor-name {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.sponsor-action {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sponsor-item {
|
||||
font-size: 12px;
|
||||
padding: 3px 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,638 @@
|
|||
<template>
|
||||
<div class="detail">
|
||||
<div class="detail-header" :style="{ backgroundImage: `url(${activity.coverImage || 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/5.jpg'})` }">
|
||||
<div class="header-overlay"></div>
|
||||
<div class="header-back" @click="$router.back()">
|
||||
<van-icon name="arrow-left" />
|
||||
</div>
|
||||
<div class="header-content">
|
||||
<h1 class="activity-title">{{ activity.title }}</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="info-card">
|
||||
<div class="info-item">
|
||||
<van-icon name="clock-o" class="info-icon" />
|
||||
<div class="info-text">
|
||||
<div class="info-label">活动时间</div>
|
||||
<div class="info-value">{{ formatDate(activity.startTime) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item" @click="showMap">
|
||||
<van-icon name="location-o" class="info-icon" />
|
||||
<div class="info-text">
|
||||
<div class="info-label">活动地点</div>
|
||||
<div class="info-value">{{ activity.location }}</div>
|
||||
</div>
|
||||
<van-icon name="arrow" class="info-arrow" />
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<van-icon name="calendar-o" class="info-icon" />
|
||||
<div class="info-text">
|
||||
<div class="info-label">报名截止</div>
|
||||
<div class="info-value">{{ formatDate(activity.registrationDeadline) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-item">
|
||||
<van-icon name="gold-coin-o" class="info-icon" />
|
||||
<div class="info-text">
|
||||
<div class="info-label">人均费用</div>
|
||||
<div class="info-value price-value">{{ activity.price > 0 ? `¥${activity.price}` : '免费' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h3 class="section-title">活动介绍</h3>
|
||||
<p class="description">{{ activity.description }}</p>
|
||||
</div>
|
||||
|
||||
<!-- 赞助商弹幕 -->
|
||||
<SponsorDanmaku v-if="activity.danmakuEnabled" />
|
||||
|
||||
<!-- 活动流程 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">活动流程</h3>
|
||||
<div class="timeline">
|
||||
<!-- 签到与集结 -->
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content" @click="toggleTimeline(0)" :class="{ expanded: expandedTimelineIndex === 0 }">
|
||||
<div class="timeline-time">
|
||||
<span class="time-text">14:00 - 14:30</span>
|
||||
<van-icon :name="expandedTimelineIndex === 0 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="timeline-title">签到与集结</div>
|
||||
<div class="timeline-brief">车辆有序到场,签到领卡,开启精彩活动之旅</div>
|
||||
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 0 }">
|
||||
<div class="detail-content">
|
||||
<div class="flow-steps">
|
||||
<div class="step-item">🚗 车辆到场 → 指引至临时停车点</div>
|
||||
<div class="step-item">✍️ 签到处签名确认参与</div>
|
||||
<div class="step-item">📱 扫码入群,加入活动大家庭</div>
|
||||
<div class="step-item">🎫 领取专属积分卡</div>
|
||||
</div>
|
||||
<div class="game-rule">
|
||||
<div class="rule-title">📋 积分卡说明</div>
|
||||
<div class="rule-content">
|
||||
• 每人领取一张积分卡(红绿双色章)<br>
|
||||
• 初始积分:0分<br>
|
||||
• 游戏得分范围:+1至+5分<br>
|
||||
• 游戏扣分范围:-1至-5分<br>
|
||||
• 最终积分可兑换精美礼品<br>
|
||||
• 积分越高,礼品越丰厚
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 观赏素材拍摄 -->
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content" @click="toggleTimeline(1)" :class="{ expanded: expandedTimelineIndex === 1 }">
|
||||
<div class="timeline-time">
|
||||
<span class="time-text">14:30 - 16:30</span>
|
||||
<van-icon :name="expandedTimelineIndex === 1 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="timeline-title">观赏素材拍摄</div>
|
||||
<div class="timeline-brief">专业摄影团队记录精彩瞬间,留下珍贵回忆</div>
|
||||
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 1 }">
|
||||
<div class="detail-content">
|
||||
<div class="flow-steps">
|
||||
<div class="step-item">📸 全员车前合影 - 展示车队阵容</div>
|
||||
<div class="step-item">🎯 签到墙合照 - 打卡留念</div>
|
||||
<div class="step-item">🏛️ 地标合影 - 记录活动地点</div>
|
||||
<div class="step-item">🎬 动态全员上车 - 拍摄车队启动瞬间</div>
|
||||
<div class="step-item">🔥 点火仪式 - 引擎轰鸣</div>
|
||||
<div class="step-item">✨ 拍摄"泉州"字样 - 城市印记</div>
|
||||
</div>
|
||||
<div class="tip-box">
|
||||
💡 提示:这些素材将用于活动回顾视频制作,请积极配合拍摄
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 互动游戏时间 -->
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content" @click="toggleTimeline(2)" :class="{ expanded: expandedTimelineIndex === 2 }">
|
||||
<div class="timeline-time">
|
||||
<span class="time-text">16:30 - 18:00</span>
|
||||
<van-icon :name="expandedTimelineIndex === 2 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="timeline-title">互动游戏时间</div>
|
||||
<div class="timeline-brief">10+趣味游戏项目,赢取积分兑换精美礼品</div>
|
||||
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 2 }">
|
||||
<div class="detail-content">
|
||||
<div class="game-list">
|
||||
<div
|
||||
v-for="(game, index) in timelineData[2].items"
|
||||
:key="'game' + index"
|
||||
class="game-card"
|
||||
@click.stop="toggleGame('game' + index)"
|
||||
:class="{ expanded: expandedGameId === 'game' + index, 'highlight-game': game.name.includes('最终复活') }"
|
||||
>
|
||||
<div class="game-header">
|
||||
<div class="game-title">{{ game.icon }} {{ game.name }}</div>
|
||||
<van-icon :name="expandedGameId === 'game' + index ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="game-detail" :class="{ expanded: expandedGameId === 'game' + index }">
|
||||
<div class="game-desc" v-html="game.detail"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 落日BBQ -->
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content" @click="toggleTimeline(3)" :class="{ expanded: expandedTimelineIndex === 3 }">
|
||||
<div class="timeline-time">
|
||||
<span class="time-text">18:00 - 19:30</span>
|
||||
<van-icon :name="expandedTimelineIndex === 3 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="timeline-title">落日BBQ</div>
|
||||
<div class="timeline-brief">落日余晖下品尝美食,拍摄浪漫氛围大片</div>
|
||||
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 3 }">
|
||||
<div class="detail-content">
|
||||
<div class="flow-steps">
|
||||
<div class="step-item">🌅 落日余晖下车辆排成一排</div>
|
||||
<div class="step-item">💡 齐开大灯营造氛围</div>
|
||||
<div class="step-item">📷 拍摄氛围感大片</div>
|
||||
<div class="step-item">🍖 边烤肉边欣赏晚霞</div>
|
||||
<div class="step-item">🎵 轻松音乐伴随美食时光</div>
|
||||
</div>
|
||||
<div class="tip-box">
|
||||
💡 提示:分享美食照片到朋友圈并@活动官方账号可获得 <span class="score-badge">+1分</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 篝火歌舞会 -->
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content" @click="toggleTimeline(4)" :class="{ expanded: expandedTimelineIndex === 4 }">
|
||||
<div class="timeline-time">
|
||||
<span class="time-text">19:00 - 20: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-detail" :class="{ expanded: expandedTimelineIndex === 4 }">
|
||||
<div class="detail-content">
|
||||
<div class="game-list">
|
||||
<div class="game-card" @click.stop="toggleGame('bonfire1')" :class="{ expanded: expandedGameId === 'bonfire1' }">
|
||||
<div class="game-header">
|
||||
<div class="game-title">🥁 击鼓传花(10轮)</div>
|
||||
<van-icon :name="expandedGameId === 'bonfire1' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire1' }">
|
||||
<div class="game-desc">
|
||||
<strong>玩法:</strong>音乐响起时传递道具,音乐停止时持花者上台<br>
|
||||
<strong>惩罚:</strong>吃柠檬或苦瓜(二选一)<br>
|
||||
<strong>得分:</strong>勇敢完成+2分,拒绝挑战-3分<br>
|
||||
<strong>轮数:</strong>共10轮,气氛逐渐升温
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-card" @click.stop="toggleGame('bonfire2')" :class="{ expanded: expandedGameId === 'bonfire2' }">
|
||||
<div class="game-header">
|
||||
<div class="game-title">🤝 喊数抱团(10轮)</div>
|
||||
<van-icon :name="expandedGameId === 'bonfire2' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire2' }">
|
||||
<div class="game-desc">
|
||||
<strong>玩法:</strong>主持人喊数字,参与者按数字抱团<br>
|
||||
<strong>规则:</strong>落单者淘汰,上台自我介绍(1分钟)<br>
|
||||
<strong>得分:</strong>成功抱团+1分,落单但完成自我介绍+2分,拒绝-2分<br>
|
||||
<strong>互动:</strong>认识新朋友的好机会!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-card" @click.stop="toggleGame('bonfire3')" :class="{ expanded: expandedGameId === 'bonfire3' }">
|
||||
<div class="game-header">
|
||||
<div class="game-title">🎵 大合唱</div>
|
||||
<van-icon :name="expandedGameId === 'bonfire3' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire3' }">
|
||||
<div class="game-desc">
|
||||
<strong>玩法:</strong>全员参与,齐唱经典歌曲<br>
|
||||
<strong>曲目:</strong>《朋友》《真心英雄》等<br>
|
||||
<strong>得分:</strong>参与即可获得+1分<br>
|
||||
<strong>氛围:</strong>点燃现场,凝聚团队
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-card" @click.stop="toggleGame('bonfire4')" :class="{ expanded: expandedGameId === 'bonfire4' }">
|
||||
<div class="game-header">
|
||||
<div class="game-title">🎭 个人才艺表演</div>
|
||||
<van-icon :name="expandedGameId === 'bonfire4' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire4' }">
|
||||
<div class="game-desc">
|
||||
<strong>玩法:</strong>自愿上台展示才艺(唱歌/跳舞/表演)<br>
|
||||
<strong>准备:</strong>需提前到后台准备BGM<br>
|
||||
<strong>得分:</strong>参与表演+3分,精彩表演+5分<br>
|
||||
<strong>鼓励:</strong>展现自我,收获掌声!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-card" @click.stop="toggleGame('bonfire5')" :class="{ expanded: expandedGameId === 'bonfire5' }">
|
||||
<div class="game-header">
|
||||
<div class="game-title">👶 小朋友互动</div>
|
||||
<van-icon :name="expandedGameId === 'bonfire5' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire5' }">
|
||||
<div class="game-desc">
|
||||
<strong>玩法:</strong>主持人邀请小朋友上台互动<br>
|
||||
<strong>内容:</strong>简单游戏/才艺展示/问答<br>
|
||||
<strong>礼品:</strong>参与即有小礼物<br>
|
||||
<strong>得分:</strong>家长陪同参与+2分
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 璀璨烟火秀 -->
|
||||
<div class="timeline-item">
|
||||
<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>
|
||||
<van-icon :name="expandedTimelineIndex === 5 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||
</div>
|
||||
<div class="timeline-title">璀璨烟火秀</div>
|
||||
<div class="timeline-brief">三重烟花盛宴,点亮夜空绽放精彩</div>
|
||||
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 5 }">
|
||||
<div class="detail-content">
|
||||
<div class="flow-steps">
|
||||
<div class="step-item">🎆 米兰之夜 - 大型专业烟火表演,绚丽夺目</div>
|
||||
<div class="step-item">✨ 仙女棒 - 每车配发手持烟花,拍照打卡</div>
|
||||
<div class="step-item">🔥 加特林压轴 - 震撼收尾,全场欢呼</div>
|
||||
<div class="step-item">📸 最佳拍摄时机 - 记录璀璨瞬间</div>
|
||||
</div>
|
||||
<div 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活动经费 -->
|
||||
<div v-if="expenses.length && isApproved" class="section">
|
||||
<h3 class="section-title">活动经费 <span class="count">¥{{ totalExpense }}</span></h3>
|
||||
<div class="expense-list">
|
||||
<div v-for="expense in expenses" :key="expense.id" class="expense-item">
|
||||
<div class="expense-info">
|
||||
<div class="expense-name">{{ expense.item }}</div>
|
||||
<div class="expense-category">{{ expense.category }}</div>
|
||||
</div>
|
||||
<div v-if="expense.media && expense.media.length" class="expense-media">
|
||||
<div v-for="(media, index) in expense.media" :key="index" class="expense-media-item">
|
||||
<van-image
|
||||
v-if="media.type === 'image'"
|
||||
:src="media.url"
|
||||
fit="cover"
|
||||
@click="previewImage(media.url)"
|
||||
/>
|
||||
<div v-else class="expense-video" @click="playExpenseVideo(media.url)">
|
||||
<video :src="media.url"></video>
|
||||
<van-icon name="play-circle-o" class="play-icon" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="expense-amount">金额:¥{{ expense.amount }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isApproved && hasRegistered" class="section">
|
||||
<div class="permission-tip">
|
||||
<van-icon name="info-o" />
|
||||
<span v-if="userRegistration.status === 'pending'">您的报名正在审核中,审核通过后可查看活动经费</span>
|
||||
<span v-else-if="userRegistration.status === 'rejected'">您的报名未通过审核</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 赞助商 -->
|
||||
<div v-if="sponsors.length" class="section">
|
||||
<h3 class="section-title">赞助商</h3>
|
||||
<div class="sponsor-grid">
|
||||
<div v-for="sponsor in sponsors" :key="sponsor.id" class="sponsor-card">
|
||||
<div class="sponsor-logo" :style="{ backgroundImage: `url(${getImageUrl(sponsor.logo)})` }"></div>
|
||||
<div class="sponsor-name">{{ sponsor.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 奖品礼品 -->
|
||||
<div v-if="prizes.length" class="section">
|
||||
<h3 class="section-title">🏆 奖品礼品</h3>
|
||||
<div class="prize-list">
|
||||
<div v-for="prize in prizes" :key="prize.id" class="prize-card">
|
||||
<div class="prize-image" :style="prize.image ? { backgroundImage: `url(${getImageUrl(prize.image)})` } : {}">
|
||||
<van-icon v-if="!prize.image" name="gift-o" size="40" color="#ccc" />
|
||||
</div>
|
||||
<div class="prize-content">
|
||||
<div class="prize-header">
|
||||
<span class="prize-level" :class="`level-${prize.level}`">{{ prize.level }}</span>
|
||||
<span class="prize-name">{{ prize.name }}</span>
|
||||
</div>
|
||||
<div v-if="prize.description" class="prize-desc">{{ prize.description }}</div>
|
||||
<div class="prize-footer">
|
||||
<span class="prize-quantity">数量: {{ prize.quantity }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 活动照片与视频 -->
|
||||
<div v-if="photos.length || videos.length" class="section">
|
||||
<h3 class="section-title">活动照片与视频</h3>
|
||||
|
||||
<div v-if="photos.length" class="media-section">
|
||||
<div class="media-label">照片</div>
|
||||
<div class="photo-grid">
|
||||
<div v-for="photo in photos" :key="photo.id" class="photo-item" @click="previewImage(photo.url)">
|
||||
<div class="photo-img" :style="{ backgroundImage: `url(${photo.url})` }"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="videos.length" class="media-section">
|
||||
<div class="media-label">视频</div>
|
||||
<div class="video-list">
|
||||
<div v-for="video in videos" :key="video.id" class="video-item">
|
||||
<video :src="video.url" controls class="video-player"></video>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasRegistered && isApproved" class="upload-section">
|
||||
<van-uploader
|
||||
v-model="userMedia"
|
||||
multiple
|
||||
:max-count="5"
|
||||
:after-read="uploadUserMedia"
|
||||
accept="image/*,video/*"
|
||||
>
|
||||
<van-button icon="photograph" type="primary" size="small">上传照片/视频</van-button>
|
||||
</van-uploader>
|
||||
<div class="upload-tip">您可以上传活动照片或视频,审核后将展示给所有人</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasRegistered && !isApproved" class="upload-section">
|
||||
<div class="permission-tip">
|
||||
<van-icon name="info-o" />
|
||||
<span v-if="userRegistration.status === 'pending'">审核通过后可上传照片/视频</span>
|
||||
<span v-else-if="userRegistration.status === 'rejected'">您的报名未通过审核</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 报名名单 -->
|
||||
<div class="section">
|
||||
<h3 class="section-title">报名名单 <span class="count">({{ registrations.length }}人)</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>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div v-if="!hasRegistered && activity.registrationEnabled" class="footer-action">
|
||||
<van-button
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
size="large"
|
||||
@click="$router.push(`/register/${activity.id}`)"
|
||||
class="action-button"
|
||||
>
|
||||
<van-icon name="add-o" /> 立即报名
|
||||
</van-button>
|
||||
<van-button
|
||||
plain
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
size="large"
|
||||
@click="handleLogin"
|
||||
class="action-button login-button"
|
||||
>
|
||||
<van-icon name="user-o" /> 已报名?点击登录
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!hasRegistered && !activity.registrationEnabled" class="footer-action">
|
||||
<van-button type="default" block round size="large" disabled class="action-button">
|
||||
<van-icon name="warning-o" /> 报名已结束
|
||||
</van-button>
|
||||
<van-button
|
||||
plain
|
||||
type="primary"
|
||||
block
|
||||
round
|
||||
size="large"
|
||||
@click="handleLogin"
|
||||
class="action-button login-button"
|
||||
>
|
||||
<van-icon name="user-o" /> 已报名?点击登录
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!isApproved" class="footer-action">
|
||||
<van-button type="default" block round size="large" disabled class="action-button">
|
||||
<van-icon name="clock-o" />
|
||||
<span v-if="userRegistration.status === 'pending'">等待审核中</span>
|
||||
<span v-else>报名未通过</span>
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<div v-else class="footer-action">
|
||||
<van-button type="success" block round size="large" disabled class="action-button">
|
||||
<van-icon name="success" /> 已报名
|
||||
</van-button>
|
||||
</div>
|
||||
|
||||
<!-- 分享按钮 -->
|
||||
<div class="share-btn" @click="handleShare">
|
||||
<van-icon name="share-o" />
|
||||
<span>分享</span>
|
||||
</div>
|
||||
|
||||
<!-- 地图弹窗 -->
|
||||
<van-popup v-model:show="showMapPopup" position="bottom" :style="{ height: '70%' }">
|
||||
<div class="map-container">
|
||||
<div class="map-header">
|
||||
<h3>{{ activity.location }}</h3>
|
||||
<van-icon name="cross" @click="showMapPopup = false" />
|
||||
</div>
|
||||
<div id="amap-container" style="width: 100%; height: calc(100% - 50px);"></div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 登录弹窗 -->
|
||||
<van-dialog
|
||||
v-model:show="showLoginDialog"
|
||||
title="验证报名信息"
|
||||
show-cancel-button
|
||||
@confirm="submitLogin"
|
||||
>
|
||||
<div class="login-form">
|
||||
<van-field
|
||||
v-model="loginForm.phone"
|
||||
label="手机号"
|
||||
placeholder="请输入报名时的手机号"
|
||||
type="tel"
|
||||
maxlength="11"
|
||||
/>
|
||||
<van-field
|
||||
label="车牌号"
|
||||
>
|
||||
<template #input>
|
||||
<div class="license-plate-input">
|
||||
<span class="plate-prefix">闽</span>
|
||||
<input
|
||||
v-model="carLetter"
|
||||
@input="handleLetterInput"
|
||||
placeholder="_"
|
||||
maxlength="1"
|
||||
class="plate-letter"
|
||||
/>
|
||||
<span class="plate-dot">·</span>
|
||||
<input
|
||||
v-model="carDigits"
|
||||
@input="handleDigitsInput"
|
||||
placeholder="______"
|
||||
maxlength="6"
|
||||
class="plate-digits"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</van-field>
|
||||
</div>
|
||||
</van-dialog>
|
||||
|
||||
<!-- 经费视频播放弹窗 -->
|
||||
<van-popup v-model:show="showExpenseVideoPopup" position="center" round :style="{ width: '90%' }">
|
||||
<div class="video-player-popup">
|
||||
<video :src="currentExpenseVideo" controls autoplay class="popup-video"></video>
|
||||
</div>
|
||||
</van-popup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useActivityDetail } from './useActivityDetail';
|
||||
import SponsorDanmaku from './SponsorDanmaku.vue';
|
||||
import { timelineData } from '../timeline-data.js';
|
||||
|
||||
// 获取完整图片URL的辅助函数
|
||||
const getImageUrl = (path) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
return import.meta.env.DEV ? `http://localhost:3000${path}` : `https://mi.xyvan.cn${path}`;
|
||||
};
|
||||
|
||||
const {
|
||||
activity,
|
||||
photos,
|
||||
videos,
|
||||
sponsors,
|
||||
prizes,
|
||||
registrations,
|
||||
expenses,
|
||||
userMedia,
|
||||
showMapPopup,
|
||||
showLoginDialog,
|
||||
showExpenseVideoPopup,
|
||||
currentExpenseVideo,
|
||||
loginForm,
|
||||
carLetter,
|
||||
carDigits,
|
||||
userRegistration,
|
||||
expandedTimelineIndex,
|
||||
expandedGameId,
|
||||
totalExpense,
|
||||
isApproved,
|
||||
hasRegistered,
|
||||
formatDate,
|
||||
previewImage,
|
||||
playExpenseVideo,
|
||||
showMap,
|
||||
uploadUserMedia,
|
||||
handleLogin,
|
||||
handleLetterInput,
|
||||
handleDigitsInput,
|
||||
submitLogin,
|
||||
handleShare,
|
||||
toggleTimeline,
|
||||
toggleGame
|
||||
} = useActivityDetail();
|
||||
</script>
|
||||
|
||||
<style src="./styles.css" scoped></style>
|
||||
|
|
@ -0,0 +1,936 @@
|
|||
.detail {
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
padding-bottom: 140px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
position: relative;
|
||||
height: 280px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #e8e8e8;
|
||||
}
|
||||
|
||||
.header-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.6) 100%);
|
||||
}
|
||||
|
||||
.header-back {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 2;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255,255,255,0.3);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.header-content {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin: 0;
|
||||
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 16px;
|
||||
margin-top: -20px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.info-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-arrow {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
font-size: 24px;
|
||||
color: #FF6700;
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
color: #FF6700;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin: 0 0 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.section-title .count {
|
||||
color: #FF6700;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
color: #666;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* Timeline样式 */
|
||||
.timeline {
|
||||
position: relative;
|
||||
padding-left: 32px;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 2px;
|
||||
background: linear-gradient(180deg, #FF6700 0%, #FF8533 100%);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.timeline-item-last {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
left: -28px;
|
||||
top: 4px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
border: 3px solid #FF6700;
|
||||
box-shadow: 0 0 0 3px rgba(255, 103, 0, 0.1);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
transition: all 0.3s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.timeline-content:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.timeline-content.expanded {
|
||||
box-shadow: 0 4px 12px rgba(255, 103, 0, 0.15);
|
||||
border-color: #FF6700;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
font-size: 13px;
|
||||
color: #FF6700;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.timeline-time::before {
|
||||
content: '🕐';
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
font-size: 16px;
|
||||
color: #999;
|
||||
transition: transform 0.3s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.timeline-brief {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-detail {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
|
||||
.timeline-detail.expanded {
|
||||
max-height: 3000px;
|
||||
transition: max-height 0.5s ease-in;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.flow-steps {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 8px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #FF6700;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.step-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tip-box {
|
||||
padding: 10px 12px;
|
||||
background: #fff7e6;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
color: #ff9800;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.game-rule {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid #FF6700;
|
||||
}
|
||||
|
||||
.rule-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.rule-content {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.score-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||
color: #fff;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
/* 游戏卡片样式 */
|
||||
.game-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e8e8e8;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.game-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.game-card.expanded {
|
||||
border-color: #FF6700;
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.game-card.highlight-game {
|
||||
background: linear-gradient(135deg, #fff7e6 0%, #fff 100%);
|
||||
border-color: #FF6700;
|
||||
}
|
||||
|
||||
.game-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.game-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.game-detail {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.game-detail.expanded {
|
||||
max-height: 500px;
|
||||
transition: max-height 0.5s ease-in;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.game-desc {
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
line-height: 1.8;
|
||||
padding: 10px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* 其他组件样式 */
|
||||
.sponsor-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.sponsor-card {
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.sponsor-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.sponsor-logo {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 10px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #f5f5f5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sponsor-name {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.photo-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.photo-item {
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.media-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.media-label {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.video-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.video-item {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.video-player {
|
||||
width: 100%;
|
||||
max-height: 300px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.member-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.member-car {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.expense-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.expense-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
.expense-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.expense-name {
|
||||
font-size: 15px;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.expense-category {
|
||||
display: inline-block;
|
||||
font-size: 11px;
|
||||
color: #FF6700;
|
||||
background: #fff7e6;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.expense-media {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.expense-media-item {
|
||||
width: 100%;
|
||||
aspect-ratio: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #f5f5f5;
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.expense-media-item:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.expense-video {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.expense-video video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.expense-video .play-icon {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 28px;
|
||||
color: #fff;
|
||||
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
|
||||
}
|
||||
|
||||
.expense-amount {
|
||||
font-size: 15px;
|
||||
color: #FF6700;
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px dashed #f0f0f0;
|
||||
}
|
||||
|
||||
.upload-section {
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.upload-tip {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* 底部按钮和弹窗样式 */
|
||||
.footer-action {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 12px 16px;
|
||||
background: #fff;
|
||||
box-shadow: 0 -2px 12px rgba(0,0,0,0.08);
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
height: 48px;
|
||||
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-button .van-icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.login-button {
|
||||
margin-top: 0;
|
||||
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 8px 16px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 20px;
|
||||
color: #FF6700;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.map-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.map-header .van-icon {
|
||||
font-size: 20px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 车牌号字段样式优化 - 标签和输入框同行显示 */
|
||||
.login-form .van-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.login-form .van-field__label {
|
||||
width: 60px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.login-form .van-field__control {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.login-form .van-field__control--custom {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.permission-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: #fff7e6;
|
||||
border-radius: 8px;
|
||||
color: #ff9800;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.permission-tip .van-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.video-player-popup {
|
||||
padding: 20px;
|
||||
background: #000;
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.popup-video {
|
||||
width: 100%;
|
||||
max-height: 60vh;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* 车牌输入样式 */
|
||||
.license-plate-input {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
gap: 3px;
|
||||
padding: 5px 8px;
|
||||
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.3);
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.plate-prefix {
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.plate-letter {
|
||||
width: 20px;
|
||||
height: 26px;
|
||||
border: 2px solid rgba(255,255,255,0.9);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
outline: none;
|
||||
text-transform: uppercase;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.plate-dot {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.plate-digits {
|
||||
width: 95px;
|
||||
height: 26px;
|
||||
border: 2px solid rgba(255,255,255,0.9);
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
outline: none;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.plate-letter::placeholder,
|
||||
.plate-digits::placeholder {
|
||||
color: #ddd;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* 奖品礼品样式 */
|
||||
.prize-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.prize-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: linear-gradient(135deg, #fff 0%, #fffbf5 100%);
|
||||
border-radius: 12px;
|
||||
border: 1px solid #ffe8cc;
|
||||
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.08);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.prize-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.prize-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 8px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid #ffe8cc;
|
||||
}
|
||||
|
||||
.prize-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.prize-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.prize-level {
|
||||
display: inline-block;
|
||||
padding: 3px 10px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.level-一等奖 {
|
||||
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(255, 215, 0, 0.4);
|
||||
}
|
||||
|
||||
.level-二等奖 {
|
||||
background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(192, 192, 192, 0.4);
|
||||
}
|
||||
|
||||
.level-三等奖 {
|
||||
background: linear-gradient(135deg, #CD7F32 0%, #B8733E 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(205, 127, 50, 0.4);
|
||||
}
|
||||
|
||||
.level-参与奖 {
|
||||
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(255, 103, 0, 0.3);
|
||||
}
|
||||
|
||||
.level-伴手礼 {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgba(240, 147, 251, 0.3);
|
||||
}
|
||||
|
||||
.prize-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.prize-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.prize-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin-top: auto;
|
||||
padding-top: 6px;
|
||||
border-top: 1px dashed #ffe8cc;
|
||||
}
|
||||
|
||||
.prize-quantity {
|
||||
font-size: 12px;
|
||||
color: #FF6700;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { showImagePreview, showToast } from 'vant';
|
||||
import api from '../../api';
|
||||
|
||||
export function useActivityDetail() {
|
||||
const route = useRoute();
|
||||
const activity = ref({});
|
||||
const photos = ref([]);
|
||||
const videos = ref([]);
|
||||
const sponsors = ref([]);
|
||||
const prizes = ref([]);
|
||||
const registrations = ref([]);
|
||||
const expenses = ref([]);
|
||||
const userMedia = ref([]);
|
||||
const showMapPopup = ref(false);
|
||||
const showLoginDialog = ref(false);
|
||||
const showExpenseVideoPopup = ref(false);
|
||||
const currentExpenseVideo = ref('');
|
||||
const loginForm = ref({ phone: '', carNumber: '' });
|
||||
const expandedTimelineIndex = ref(null);
|
||||
const expandedGameId = ref(null);
|
||||
const carLetter = ref('');
|
||||
const carDigits = ref('');
|
||||
const userRegistration = ref(null);
|
||||
let map = null;
|
||||
|
||||
const handleLetterInput = (e) => {
|
||||
const value = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
|
||||
carLetter.value = value;
|
||||
loginForm.value.carNumber = value + carDigits.value;
|
||||
|
||||
if (value.length === 1) {
|
||||
e.target.nextElementSibling?.nextElementSibling?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDigitsInput = (e) => {
|
||||
const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
carDigits.value = value;
|
||||
loginForm.value.carNumber = carLetter.value + value;
|
||||
};
|
||||
|
||||
const toggleTimeline = (index) => {
|
||||
expandedTimelineIndex.value = expandedTimelineIndex.value === index ? null : index;
|
||||
expandedGameId.value = null;
|
||||
};
|
||||
|
||||
const toggleGame = (gameId) => {
|
||||
expandedGameId.value = expandedGameId.value === gameId ? null : gameId;
|
||||
};
|
||||
|
||||
const totalExpense = computed(() => {
|
||||
return expenses.value.reduce((sum, e) => sum + parseFloat(e.amount), 0).toFixed(2);
|
||||
});
|
||||
|
||||
const isApproved = computed(() => {
|
||||
return userRegistration.value && userRegistration.value.status === 'approved';
|
||||
});
|
||||
|
||||
const hasRegistered = computed(() => {
|
||||
return userRegistration.value !== null;
|
||||
});
|
||||
|
||||
const loadData = async () => {
|
||||
const { data } = await api.getActivity(route.params.id);
|
||||
activity.value = data.activity;
|
||||
|
||||
const allMedia = data.photos || [];
|
||||
photos.value = allMedia.filter(m => m.type === 'image');
|
||||
videos.value = allMedia.filter(m => m.type === 'video');
|
||||
|
||||
sponsors.value = data.sponsors;
|
||||
prizes.value = data.prizes || [];
|
||||
expenses.value = data.expenses || [];
|
||||
|
||||
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 formatDate = (date) => {
|
||||
return new Date(date).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
const previewImage = (url) => {
|
||||
showImagePreview([url]);
|
||||
};
|
||||
|
||||
const playExpenseVideo = (url) => {
|
||||
currentExpenseVideo.value = url;
|
||||
showExpenseVideoPopup.value = true;
|
||||
};
|
||||
|
||||
const showMap = () => {
|
||||
if (!activity.value.locationLat || !activity.value.locationLng) {
|
||||
showToast('暂无地图信息');
|
||||
return;
|
||||
}
|
||||
showMapPopup.value = true;
|
||||
setTimeout(() => {
|
||||
initMap();
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const initMap = async () => {
|
||||
try {
|
||||
const AMap = await window.loadAMap();
|
||||
|
||||
if (map) {
|
||||
map.destroy();
|
||||
}
|
||||
|
||||
const center = [activity.value.locationLng, activity.value.locationLat];
|
||||
map = new AMap.Map('amap-container', { zoom: 15, center });
|
||||
new AMap.Marker({ position: center, map, title: activity.value.location });
|
||||
} catch (e) {
|
||||
console.error('地图加载失败:', e);
|
||||
showToast('地图加载失败: ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadUserMedia = async (files) => {
|
||||
if (!isApproved.value) {
|
||||
showToast('请先登录并等待审核通过');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingToast = showToast({
|
||||
type: 'loading',
|
||||
message: '上传中...',
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
});
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('uploadedBy', userRegistration.value.name);
|
||||
|
||||
if (Array.isArray(files)) {
|
||||
files.forEach(file => formData.append('photos', file.file));
|
||||
} else {
|
||||
formData.append('photos', files.file);
|
||||
}
|
||||
|
||||
await api.uploadUserPhotos(route.params.id, formData);
|
||||
showToast('上传成功,等待审核');
|
||||
userMedia.value = [];
|
||||
} catch (error) {
|
||||
showToast('上传失败');
|
||||
} finally {
|
||||
loadingToast.clear();
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogin = () => {
|
||||
showLoginDialog.value = true;
|
||||
};
|
||||
|
||||
const submitLogin = async () => {
|
||||
if (!loginForm.value.phone || !loginForm.value.carNumber) {
|
||||
showToast('请输入手机号和车牌号');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const carNumberWithPrefix = `闽${loginForm.value.carNumber}`;
|
||||
|
||||
const { data } = await api.verifyUser(route.params.id, {
|
||||
phone: loginForm.value.phone,
|
||||
carNumber: carNumberWithPrefix
|
||||
});
|
||||
|
||||
userRegistration.value = data.registration;
|
||||
localStorage.setItem(`user_reg_${route.params.id}`, JSON.stringify(data.registration));
|
||||
|
||||
showLoginDialog.value = false;
|
||||
|
||||
if (data.registration.status === 'approved') {
|
||||
showToast('登录成功');
|
||||
} else if (data.registration.status === 'pending') {
|
||||
showToast('您的报名正在审核中');
|
||||
} else {
|
||||
showToast('您的报名未通过审核');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.response?.data?.error || '验证失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = () => {
|
||||
const shareUrl = `${window.location.origin}/share/${route.params.id}`;
|
||||
|
||||
if (navigator.share) {
|
||||
navigator.share({
|
||||
title: activity.value.title,
|
||||
text: `${activity.value.title} - 泉友米车友会`,
|
||||
url: shareUrl
|
||||
}).catch(() => {
|
||||
copyShareLink(shareUrl);
|
||||
});
|
||||
} else {
|
||||
copyShareLink(shareUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const copyShareLink = (url) => {
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
showToast('分享链接已复制');
|
||||
}).catch(() => {
|
||||
showToast('复制失败,请手动复制:' + url);
|
||||
});
|
||||
};
|
||||
|
||||
onMounted(loadData);
|
||||
|
||||
return {
|
||||
activity,
|
||||
photos,
|
||||
videos,
|
||||
sponsors,
|
||||
prizes,
|
||||
registrations,
|
||||
expenses,
|
||||
userMedia,
|
||||
showMapPopup,
|
||||
showLoginDialog,
|
||||
showExpenseVideoPopup,
|
||||
currentExpenseVideo,
|
||||
loginForm,
|
||||
carLetter,
|
||||
carDigits,
|
||||
userRegistration,
|
||||
expandedTimelineIndex,
|
||||
expandedGameId,
|
||||
totalExpense,
|
||||
isApproved,
|
||||
hasRegistered,
|
||||
formatDate,
|
||||
previewImage,
|
||||
playExpenseVideo,
|
||||
showMap,
|
||||
uploadUserMedia,
|
||||
handleLogin,
|
||||
handleLetterInput,
|
||||
handleDigitsInput,
|
||||
submitLogin,
|
||||
handleShare,
|
||||
toggleTimeline,
|
||||
toggleGame
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -713,27 +713,25 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.sponsors-grid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
gap: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-top: 32px;
|
||||
flex-wrap: wrap;
|
||||
max-width: 800px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.sponsor-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,0.06);
|
||||
transition: transform 0.3s, box-shadow 0.3s;
|
||||
flex: 0 1 140px;
|
||||
max-width: 150px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.sponsor-card:hover {
|
||||
|
|
@ -742,12 +740,12 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.sponsor-logo {
|
||||
width: 45px;
|
||||
height: 45px;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sponsor-logo svg {
|
||||
|
|
@ -768,20 +766,30 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.sponsor-name {
|
||||
font-size: 11px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin: 0 0 4px;
|
||||
line-height: 1.3;
|
||||
word-break: break-all;
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.4;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.sponsor-desc {
|
||||
font-size: 10px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
word-break: break-all;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* CTA区域 */
|
||||
|
|
@ -930,28 +938,26 @@ onUnmounted(() => {
|
|||
}
|
||||
|
||||
.sponsors-grid {
|
||||
gap: 12px;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sponsor-card {
|
||||
flex: 0 1 calc(20% - 10px);
|
||||
min-width: 100px;
|
||||
max-width: 130px;
|
||||
padding: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sponsor-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-bottom: 6px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.sponsor-name {
|
||||
font-size: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sponsor-desc {
|
||||
font-size: 9px;
|
||||
font-size: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -347,7 +347,7 @@ const onSubmit = async () => {
|
|||
}
|
||||
|
||||
.plate-digits {
|
||||
width: 110px;
|
||||
width: 95px;
|
||||
height: 28px;
|
||||
border: 2px solid rgba(255,255,255,0.9);
|
||||
border-radius: 3px;
|
||||
|
|
|
|||
|
|
@ -64,6 +64,16 @@
|
|||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="弹幕开关" center>
|
||||
<template #right-icon>
|
||||
<van-switch
|
||||
v-model="activity.danmakuEnabled"
|
||||
size="24px"
|
||||
@change="toggleDanmaku"
|
||||
active-color="#d4af37"
|
||||
/>
|
||||
</template>
|
||||
</van-cell>
|
||||
<van-cell title="活动描述" :label="activity.description" />
|
||||
</van-cell-group>
|
||||
</div>
|
||||
|
|
@ -128,7 +138,7 @@
|
|||
</div>
|
||||
|
||||
<!-- 悬浮添加按钮 -->
|
||||
<div class="float-add-btn" @click="showExpenseForm = true">
|
||||
<div class="float-add-btn" @click="addNewExpense">
|
||||
<van-icon name="plus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -139,7 +149,7 @@
|
|||
<div class="sponsor-list">
|
||||
<div v-for="sponsor in sponsors" :key="sponsor.id" class="sponsor-card">
|
||||
<div class="sponsor-logo-wrapper">
|
||||
<div class="sponsor-logo" :style="sponsor.logo ? { backgroundImage: `url(${sponsor.logo})` } : {}">
|
||||
<div class="sponsor-logo" :style="sponsor.logo ? { backgroundImage: `url(${getImageUrl(sponsor.logo)})` } : {}">
|
||||
<van-icon v-if="!sponsor.logo" name="shop-o" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -150,9 +160,12 @@
|
|||
{{ sponsor.type === 'title' ? '冠名' : '赞助' }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="sponsor.link" class="sponsor-link">
|
||||
<van-icon name="link-o" />
|
||||
<span>{{ sponsor.link }}</span>
|
||||
<div class="sponsor-meta">
|
||||
<span class="sponsor-sort">排序: {{ sponsor.sortOrder || 0 }}</span>
|
||||
<div v-if="sponsor.link" class="sponsor-link">
|
||||
<van-icon name="link-o" />
|
||||
<span>{{ sponsor.link }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sponsor-ops">
|
||||
|
|
@ -165,7 +178,43 @@
|
|||
<van-empty v-if="!sponsors.length" description="暂无赞助商" />
|
||||
|
||||
<!-- 悬浮添加按钮 -->
|
||||
<div class="float-add-btn" @click="showSponsorForm = true">
|
||||
<div class="float-add-btn" @click="addNewSponsor">
|
||||
<van-icon name="plus" />
|
||||
</div>
|
||||
</div>
|
||||
</van-tab>
|
||||
|
||||
<van-tab title="奖品管理" name="prizes">
|
||||
<div class="tab-content">
|
||||
<div class="prize-list">
|
||||
<div v-for="prize in prizes" :key="prize.id" class="prize-manage-card">
|
||||
<div class="prize-image-wrapper">
|
||||
<div class="prize-image" :style="prize.image ? { backgroundImage: `url(${getImageUrl(prize.image)})` } : {}">
|
||||
<van-icon v-if="!prize.image" name="gift-o" size="30" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-details">
|
||||
<div class="prize-header">
|
||||
<span class="prize-level-badge" :class="`level-${prize.level}`">{{ prize.level }}</span>
|
||||
<span class="prize-name">{{ prize.name }}</span>
|
||||
</div>
|
||||
<div v-if="prize.description" class="prize-desc-text">{{ prize.description }}</div>
|
||||
<div class="prize-meta">
|
||||
<span class="prize-quantity-text">数量: {{ prize.quantity }}</span>
|
||||
<span class="prize-sort">排序: {{ prize.sortOrder || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="prize-ops">
|
||||
<van-icon name="edit" @click="editPrize(prize)" />
|
||||
<van-icon name="delete-o" @click="deletePrizeItem(prize.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<van-empty v-if="!prizes.length" description="暂无奖品" />
|
||||
|
||||
<!-- 悬浮添加按钮 -->
|
||||
<div class="float-add-btn" @click="addNewPrize">
|
||||
<van-icon name="plus" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -636,6 +685,12 @@
|
|||
</van-radio-group>
|
||||
</template>
|
||||
</van-field>
|
||||
<van-field
|
||||
v-model.number="sponsorForm.sortOrder"
|
||||
type="number"
|
||||
label="排序"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
<van-field v-model="sponsorForm.link" label="链接" placeholder="官网链接(选填)" />
|
||||
<van-field name="logo" label="Logo">
|
||||
<template #input>
|
||||
|
|
@ -657,6 +712,119 @@
|
|||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 奖品表单弹窗 -->
|
||||
<van-popup v-model:show="showPrizeForm" position="bottom" round>
|
||||
<div class="form-popup">
|
||||
<div class="popup-header">
|
||||
<h3>{{ prizeForm.id ? '编辑奖品' : '添加奖品' }}</h3>
|
||||
<van-icon name="cross" @click="showPrizeForm = false" />
|
||||
</div>
|
||||
<van-form @submit="submitPrize" class="popup-form">
|
||||
<van-cell-group>
|
||||
<van-field v-model="prizeForm.name" label="名称" placeholder="请输入奖品名称" required />
|
||||
<van-field
|
||||
v-model="prizeForm.level"
|
||||
label="等级"
|
||||
placeholder="选择等级"
|
||||
is-link
|
||||
readonly
|
||||
@click="showLevelPicker = true"
|
||||
required
|
||||
/>
|
||||
<van-field v-model="prizeForm.description" label="描述" type="textarea" placeholder="奖品描述(选填)" rows="2" autosize />
|
||||
<van-field
|
||||
v-model="prizeForm.source"
|
||||
label="来源"
|
||||
placeholder="选择来源"
|
||||
is-link
|
||||
readonly
|
||||
@click="showSourcePicker = true"
|
||||
required
|
||||
/>
|
||||
<van-field
|
||||
v-if="prizeForm.source !== 'purchase'"
|
||||
v-model="prizeForm.sponsor"
|
||||
label="赞助者"
|
||||
placeholder="请输入赞助者名称"
|
||||
required
|
||||
/>
|
||||
<van-field
|
||||
v-model.number="prizeForm.quantity"
|
||||
type="number"
|
||||
label="数量"
|
||||
placeholder="请输入数量"
|
||||
required
|
||||
/>
|
||||
<van-field
|
||||
v-model.number="prizeForm.sortOrder"
|
||||
type="number"
|
||||
label="排序"
|
||||
placeholder="数字越小越靠前"
|
||||
/>
|
||||
<van-field name="image" label="图片">
|
||||
<template #input>
|
||||
<van-uploader
|
||||
v-model="prizeImage"
|
||||
:max-count="1"
|
||||
:after-read="onPrizeImageRead"
|
||||
accept="image/*"
|
||||
>
|
||||
<van-button icon="plus" type="primary" size="small">选择图片</van-button>
|
||||
</van-uploader>
|
||||
</template>
|
||||
</van-field>
|
||||
</van-cell-group>
|
||||
<div class="form-actions">
|
||||
<van-button round block type="primary" native-type="submit" size="large">保存</van-button>
|
||||
</div>
|
||||
</van-form>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 等级选择器 -->
|
||||
<van-popup v-model:show="showLevelPicker" position="bottom" round>
|
||||
<div class="level-picker">
|
||||
<div class="picker-header">
|
||||
<h3>选择等级</h3>
|
||||
<van-icon name="cross" @click="showLevelPicker = false" />
|
||||
</div>
|
||||
<div class="level-list">
|
||||
<div
|
||||
v-for="level in prizeLevels"
|
||||
:key="level"
|
||||
class="level-item"
|
||||
@click="selectLevel(level)"
|
||||
>
|
||||
<span class="prize-level-badge" :class="`level-${level}`">{{ level }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 来源选择器 -->
|
||||
<van-popup v-model:show="showSourcePicker" position="bottom" round>
|
||||
<div class="level-picker">
|
||||
<div class="picker-header">
|
||||
<h3>选择来源</h3>
|
||||
<van-icon name="cross" @click="showSourcePicker = false" />
|
||||
</div>
|
||||
<div class="level-list">
|
||||
<div class="level-item" @click="selectSource('member')">
|
||||
<van-icon name="friends-o" color="#FF6700" />
|
||||
<span>群友赞助</span>
|
||||
</div>
|
||||
<div class="level-item" @click="selectSource('sponsor')">
|
||||
<van-icon name="shop-o" color="#FF6700" />
|
||||
<span>赞助商提供</span>
|
||||
</div>
|
||||
<div class="level-item" @click="selectSource('purchase')">
|
||||
<van-icon name="shopping-cart-o" color="#999" />
|
||||
<span>采购</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
||||
<!-- 视频播放弹窗 -->
|
||||
<van-popup v-model:show="showVideoPlayer" position="center" round :style="{ width: '90%' }">
|
||||
<div class="video-player-popup">
|
||||
|
|
@ -752,6 +920,16 @@
|
|||
<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
|
||||
size="large"
|
||||
type="danger"
|
||||
plain
|
||||
@click="deleteRegistration"
|
||||
style="margin-top: 12px;"
|
||||
>
|
||||
删除报名
|
||||
</van-button>
|
||||
</div>
|
||||
</div>
|
||||
</van-popup>
|
||||
|
|
@ -761,13 +939,23 @@
|
|||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { showToast } from 'vant';
|
||||
import { showToast, closeToast, showConfirmDialog, ImagePreview } from 'vant';
|
||||
import api from '../../api';
|
||||
import { usePrizeManage } from './usePrizeManage';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const activeTab = ref('info');
|
||||
|
||||
// 获取完整图片URL的辅助函数
|
||||
const getImageUrl = (path) => {
|
||||
if (!path) return '';
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
return path;
|
||||
}
|
||||
return import.meta.env.DEV ? `http://localhost:3000${path}` : `https://mi.xyvan.cn${path}`;
|
||||
};
|
||||
|
||||
const activity = ref({});
|
||||
const expenses = ref([]);
|
||||
const sponsors = ref([]);
|
||||
|
|
@ -790,6 +978,27 @@ const activeFilter = ref('all'); // 过滤器状态
|
|||
const showActivityForm = ref(false);
|
||||
const showExpenseForm = ref(false);
|
||||
const showSponsorForm = ref(false);
|
||||
|
||||
// 奖品管理
|
||||
const activityId = computed(() => route.params.id);
|
||||
const {
|
||||
prizes,
|
||||
showPrizeForm,
|
||||
showLevelPicker,
|
||||
showSourcePicker,
|
||||
prizeImage,
|
||||
prizeForm,
|
||||
prizeLevels,
|
||||
loadPrizes,
|
||||
editPrize,
|
||||
onPrizeImageRead,
|
||||
selectLevel,
|
||||
selectSource,
|
||||
submitPrize,
|
||||
deletePrizeItem,
|
||||
resetPrizeForm
|
||||
} = usePrizeManage(activityId);
|
||||
|
||||
const showMapPicker = ref(false);
|
||||
const showStartTimePicker = ref(false);
|
||||
const showDeadlinePicker = ref(false);
|
||||
|
|
@ -852,7 +1061,8 @@ const sponsorForm = ref({
|
|||
id: null,
|
||||
name: '',
|
||||
type: 'sponsor',
|
||||
link: ''
|
||||
link: '',
|
||||
sortOrder: 0
|
||||
});
|
||||
|
||||
const totalExpense = computed(() => {
|
||||
|
|
@ -926,14 +1136,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;
|
||||
};
|
||||
|
||||
|
|
@ -943,12 +1168,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;
|
||||
};
|
||||
|
||||
|
|
@ -996,22 +1223,39 @@ const loadData = async () => {
|
|||
|
||||
const { data: regs } = await api.getAllRegistrations({ activityId: route.params.id });
|
||||
registrations.value = regs;
|
||||
|
||||
// 加载奖品
|
||||
await loadPrizes();
|
||||
} catch (error) {
|
||||
showToast('加载失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 添加新奖品
|
||||
const addNewPrize = () => {
|
||||
resetPrizeForm();
|
||||
showPrizeForm.value = true;
|
||||
};
|
||||
|
||||
const editActivity = () => {
|
||||
activityForm.value = { ...activity.value };
|
||||
|
||||
console.log('[编辑活动] 原始活动数据:', activity.value);
|
||||
|
||||
// 设置日期时间选择器的值
|
||||
if (activity.value.startTime) {
|
||||
console.log('[编辑活动] 数据库的开始时间(UTC):', activity.value.startTime);
|
||||
const startDate = new Date(activity.value.startTime);
|
||||
console.log('[编辑活动] 转换为本地Date对象:', startDate.toString());
|
||||
startTimeInput.value = formatDateTimeLocal(startDate);
|
||||
console.log('[编辑活动] 显示在选择器的时间:', startTimeInput.value);
|
||||
}
|
||||
if (activity.value.registrationDeadline) {
|
||||
console.log('[编辑活动] 数据库的截止时间(UTC):', activity.value.registrationDeadline);
|
||||
const deadlineDate = new Date(activity.value.registrationDeadline);
|
||||
console.log('[编辑活动] 转换为本地Date对象:', deadlineDate.toString());
|
||||
deadlineInput.value = formatDateTimeLocal(deadlineDate);
|
||||
console.log('[编辑活动] 显示在选择器的时间:', deadlineInput.value);
|
||||
}
|
||||
|
||||
showActivityForm.value = true;
|
||||
|
|
@ -1057,43 +1301,62 @@ const toggleRegistration = async (value) => {
|
|||
}
|
||||
};
|
||||
|
||||
const submitExpense = async () => {
|
||||
const toggleDanmaku = async (value) => {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('activityId', activity.value.id);
|
||||
formData.append('item', expenseForm.value.item);
|
||||
formData.append('amount', expenseForm.value.amount);
|
||||
formData.append('category', expenseForm.value.category);
|
||||
formData.append('note', expenseForm.value.note);
|
||||
|
||||
// 添加现有媒体
|
||||
if (expenseForm.value.media && expenseForm.value.media.length > 0) {
|
||||
formData.append('existingMedia', JSON.stringify(expenseForm.value.media));
|
||||
}
|
||||
|
||||
// 添加新上传的媒体文件
|
||||
expenseMediaFiles.value.forEach(item => {
|
||||
if (item.file) {
|
||||
formData.append('media', item.file);
|
||||
}
|
||||
});
|
||||
|
||||
if (expenseForm.value.id) {
|
||||
await api.updateExpense(expenseForm.value.id, formData);
|
||||
showToast('更新成功');
|
||||
} else {
|
||||
await api.addExpense(formData);
|
||||
showToast('添加成功');
|
||||
}
|
||||
showExpenseForm.value = false;
|
||||
expenseForm.value = { id: null, item: '', amount: '', category: '', note: '', media: [] };
|
||||
expenseMediaFiles.value = [];
|
||||
loadData();
|
||||
await api.updateActivity(activity.value.id, { danmakuEnabled: value });
|
||||
showToast(value ? '弹幕已开启' : '弹幕已关闭');
|
||||
} catch (error) {
|
||||
showToast(expenseForm.value.id ? '更新失败' : '添加失败');
|
||||
showToast('操作失败');
|
||||
activity.value.danmakuEnabled = !value;
|
||||
}
|
||||
};
|
||||
|
||||
const submitExpense = async () => {
|
||||
const loadingToast = showToast({
|
||||
type: 'loading',
|
||||
message: '保存中...',
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
});
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('activityId', activity.value.id);
|
||||
formData.append('item', expenseForm.value.item);
|
||||
formData.append('amount', expenseForm.value.amount);
|
||||
formData.append('category', expenseForm.value.category);
|
||||
formData.append('note', expenseForm.value.note);
|
||||
|
||||
// 添加现有媒体
|
||||
if (expenseForm.value.media && expenseForm.value.media.length > 0) {
|
||||
formData.append('existingMedia', JSON.stringify(expenseForm.value.media));
|
||||
}
|
||||
|
||||
// 添加新上传的媒体文件
|
||||
expenseMediaFiles.value.forEach(item => {
|
||||
if (item.file) {
|
||||
formData.append('media', item.file);
|
||||
}
|
||||
});
|
||||
|
||||
if (expenseForm.value.id) {
|
||||
await api.updateExpense(expenseForm.value.id, formData);
|
||||
showToast('更新成功');
|
||||
} else {
|
||||
await api.addExpense(formData);
|
||||
showToast('添加成功');
|
||||
}
|
||||
showExpenseForm.value = false;
|
||||
expenseForm.value = { id: null, item: '', amount: '', category: '', note: '', media: [] };
|
||||
expenseMediaFiles.value = [];
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast(expenseForm.value.id ? '更新失败' : '添加失败');
|
||||
} finally {
|
||||
loadingToast.clear();
|
||||
}
|
||||
};
|
||||
|
||||
const editExpense = (expense) => {
|
||||
expenseForm.value = {
|
||||
...expense,
|
||||
|
|
@ -1103,6 +1366,13 @@ const editExpense = (expense) => {
|
|||
showExpenseForm.value = true;
|
||||
};
|
||||
|
||||
// 添加新经费
|
||||
const addNewExpense = () => {
|
||||
expenseForm.value = { id: null, item: '', amount: '', category: '', note: '', media: [] };
|
||||
expenseMediaFiles.value = [];
|
||||
showExpenseForm.value = true;
|
||||
};
|
||||
|
||||
const onExpenseMediaRead = (file) => {
|
||||
// 文件读取后的处理,Vant会自动处理
|
||||
};
|
||||
|
|
@ -1113,18 +1383,18 @@ const removeExpenseMedia = (index) => {
|
|||
|
||||
const deleteExpenseItem = async (id) => {
|
||||
try {
|
||||
await import('vant').then(({ showConfirmDialog }) => {
|
||||
showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '确定要删除这条经费记录吗?',
|
||||
}).then(async () => {
|
||||
await api.deleteExpense(id);
|
||||
showToast('删除成功');
|
||||
loadData();
|
||||
}).catch(() => {});
|
||||
await showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '确定要删除这条经费记录吗?',
|
||||
});
|
||||
await api.deleteExpense(id);
|
||||
showToast('删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast('删除失败');
|
||||
// 用户取消或删除失败
|
||||
if (error !== 'cancel') {
|
||||
showToast('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1139,6 +1409,8 @@ const submitSponsor = async () => {
|
|||
console.log('表单数据:', sponsorForm.value);
|
||||
console.log('Logo 文件:', sponsorLogo.value);
|
||||
|
||||
let loadingToast = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
if (!sponsorForm.value.id) {
|
||||
|
|
@ -1147,6 +1419,7 @@ const submitSponsor = async () => {
|
|||
formData.append('name', sponsorForm.value.name);
|
||||
formData.append('type', sponsorForm.value.type);
|
||||
formData.append('link', sponsorForm.value.link || '');
|
||||
formData.append('sortOrder', sponsorForm.value.sortOrder || 0);
|
||||
|
||||
// 检查是否有 Logo 需要上传
|
||||
const hasLogo = sponsorLogo.value.length > 0 && sponsorLogo.value[0].file;
|
||||
|
|
@ -1154,16 +1427,26 @@ const submitSponsor = async () => {
|
|||
if (hasLogo) {
|
||||
const logoSize = (sponsorLogo.value[0].file.size / 1024 / 1024).toFixed(2);
|
||||
console.log('准备上传 Logo,大小:', logoSize, 'MB');
|
||||
showToast({
|
||||
loadingToast = showToast({
|
||||
type: 'loading',
|
||||
message: `正在上传 Logo (${logoSize}MB)...`,
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
});
|
||||
formData.append('logo', sponsorLogo.value[0].file);
|
||||
} else if (sponsorLogo.value.length === 0 && sponsorForm.value.logo) {
|
||||
// 用户删除了logo
|
||||
formData.append('removeLogo', 'true');
|
||||
console.log('用户删除了 Logo');
|
||||
loadingToast = showToast({
|
||||
type: 'loading',
|
||||
message: '正在保存...',
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
});
|
||||
} else {
|
||||
console.log('没有 Logo,仅保存数据');
|
||||
showToast({
|
||||
loadingToast = showToast({
|
||||
type: 'loading',
|
||||
message: '正在保存...',
|
||||
forbidClick: true,
|
||||
|
|
@ -1183,10 +1466,6 @@ const submitSponsor = async () => {
|
|||
const endTime = Date.now();
|
||||
console.log('API 调用完成,耗时:', (endTime - startTime) / 1000, '秒');
|
||||
|
||||
// 使用 closeToast 关闭所有 Toast
|
||||
const { closeToast } = await import('vant');
|
||||
closeToast();
|
||||
|
||||
// 显示成功提示
|
||||
showToast({
|
||||
type: 'success',
|
||||
|
|
@ -1196,18 +1475,14 @@ const submitSponsor = async () => {
|
|||
|
||||
// 关闭弹窗并重置表单
|
||||
showSponsorForm.value = false;
|
||||
sponsorForm.value = { id: null, name: '', type: 'sponsor', link: '' };
|
||||
sponsorForm.value = { id: null, name: '', type: 'sponsor', link: '', sortOrder: 0 };
|
||||
sponsorLogo.value = [];
|
||||
|
||||
console.log('开始重新加载数据...');
|
||||
// 重新加载数据
|
||||
await loadData();
|
||||
// 重新加载数据(不等待,避免阻塞)
|
||||
loadData();
|
||||
console.log('数据加载完成');
|
||||
} catch (error) {
|
||||
// 使用 closeToast 关闭所有 Toast
|
||||
const { closeToast } = await import('vant');
|
||||
closeToast();
|
||||
|
||||
console.error('赞助商提交失败:', error);
|
||||
console.error('错误详情:', error.response);
|
||||
const errorMsg = error.response?.data?.error || error.message || '操作失败';
|
||||
|
|
@ -1216,11 +1491,30 @@ const submitSponsor = async () => {
|
|||
message: errorMsg,
|
||||
duration: 3000
|
||||
});
|
||||
} finally {
|
||||
if (loadingToast) {
|
||||
loadingToast.clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const editSponsor = (sponsor) => {
|
||||
sponsorForm.value = { ...sponsor };
|
||||
// 回显Logo - 需要使用完整URL
|
||||
if (sponsor.logo) {
|
||||
const logoUrl = sponsor.logo.startsWith('http') ? sponsor.logo :
|
||||
(import.meta.env.DEV ? `http://localhost:3000${sponsor.logo}` : `https://mi.xyvan.cn${sponsor.logo}`);
|
||||
sponsorLogo.value = [{ url: logoUrl }];
|
||||
} else {
|
||||
sponsorLogo.value = [];
|
||||
}
|
||||
showSponsorForm.value = true;
|
||||
};
|
||||
|
||||
// 添加新赞助商
|
||||
const addNewSponsor = () => {
|
||||
sponsorForm.value = { id: null, name: '', type: 'sponsor', link: '', sortOrder: 0 };
|
||||
sponsorLogo.value = [];
|
||||
showSponsorForm.value = true;
|
||||
};
|
||||
|
||||
|
|
@ -1234,18 +1528,18 @@ const onLogoRead = (file) => {
|
|||
|
||||
const deleteSponsorItem = async (id) => {
|
||||
try {
|
||||
await import('vant').then(({ showConfirmDialog }) => {
|
||||
showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '确定要删除这个赞助商吗?',
|
||||
}).then(async () => {
|
||||
await api.deleteSponsor(id);
|
||||
showToast('删除成功');
|
||||
loadData();
|
||||
}).catch(() => {});
|
||||
await showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '确定要删除这个赞助商吗?',
|
||||
});
|
||||
await api.deleteSponsor(id);
|
||||
showToast('删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
showToast('删除失败');
|
||||
// 用户取消或删除失败
|
||||
if (error !== 'cancel') {
|
||||
showToast('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1328,6 +1622,24 @@ const approveFromDetail = async (status) => {
|
|||
}
|
||||
};
|
||||
|
||||
// 删除报名
|
||||
const deleteRegistration = async () => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '删除后用户可以重新报名,确定要删除这条报名信息吗?'
|
||||
});
|
||||
await api.deleteRegistration(currentReg.value.id);
|
||||
showToast('删除成功');
|
||||
showRegDetailDialog.value = false;
|
||||
loadData();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
showToast('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 媒体管理相关方法
|
||||
const uploadPhotos = async (files) => {
|
||||
const fileArray = Array.isArray(files) ? files : [files];
|
||||
|
|
@ -1335,7 +1647,7 @@ const uploadPhotos = async (files) => {
|
|||
const totalSizeMB = (totalSize / 1024 / 1024).toFixed(2);
|
||||
const fileCount = fileArray.length;
|
||||
|
||||
showToast({
|
||||
const loadingToast = showToast({
|
||||
type: 'loading',
|
||||
message: `正在上传 ${fileCount} 个文件 (${totalSizeMB}MB)...`,
|
||||
forbidClick: true,
|
||||
|
|
@ -1350,10 +1662,6 @@ const uploadPhotos = async (files) => {
|
|||
|
||||
await api.uploadPhotos(formData);
|
||||
|
||||
// 使用 closeToast 关闭所有 Toast
|
||||
const { closeToast } = await import('vant');
|
||||
closeToast();
|
||||
|
||||
// 显示成功提示
|
||||
showToast({
|
||||
type: 'success',
|
||||
|
|
@ -1364,19 +1672,17 @@ const uploadPhotos = async (files) => {
|
|||
// 清空文件列表
|
||||
photoFiles.value = [];
|
||||
|
||||
// 重新加载数据
|
||||
await loadData();
|
||||
// 重新加载数据(不等待,避免阻塞)
|
||||
loadData();
|
||||
} catch (error) {
|
||||
// 使用 closeToast 关闭所有 Toast
|
||||
const { closeToast } = await import('vant');
|
||||
closeToast();
|
||||
|
||||
console.error('上传失败:', error);
|
||||
showToast({
|
||||
type: 'fail',
|
||||
message: error.response?.data?.error || '上传失败',
|
||||
duration: 3000
|
||||
});
|
||||
} finally {
|
||||
loadingToast.clear();
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1393,26 +1699,24 @@ const approvePhoto = async (id, status) => {
|
|||
|
||||
const deletePhoto = async (id) => {
|
||||
try {
|
||||
await import('vant').then(({ showConfirmDialog }) => {
|
||||
showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '删除后无法恢复,确定要删除吗?',
|
||||
}).then(async () => {
|
||||
await api.deletePhoto(id);
|
||||
showToast('删除成功');
|
||||
loadData();
|
||||
}).catch(() => {});
|
||||
await showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '删除后无法恢复,确定要删除吗?',
|
||||
});
|
||||
await api.deletePhoto(id);
|
||||
showToast('删除成功');
|
||||
loadData();
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
showToast('删除失败');
|
||||
// 用户取消或删除失败
|
||||
if (error !== 'cancel') {
|
||||
console.error('删除失败:', error);
|
||||
showToast('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const previewImage = (url) => {
|
||||
import('vant').then(({ ImagePreview }) => {
|
||||
ImagePreview([url]);
|
||||
});
|
||||
ImagePreview([url]);
|
||||
};
|
||||
|
||||
const playVideo = (url) => {
|
||||
|
|
@ -1612,6 +1916,8 @@ onMounted(loadData);
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './prize-manage-styles.css';
|
||||
|
||||
.activity-manage {
|
||||
min-height: 100vh;
|
||||
background: #f8f9fa;
|
||||
|
|
@ -1934,6 +2240,22 @@ onMounted(loadData);
|
|||
background: linear-gradient(135deg, #07c160 0%, #00d68f 100%);
|
||||
}
|
||||
|
||||
.sponsor-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.sponsor-sort {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
padding: 2px 8px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sponsor-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1941,6 +2263,7 @@ onMounted(loadData);
|
|||
font-size: 12px;
|
||||
color: #FF6700;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sponsor-link .van-icon {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,179 @@
|
|||
/* 奖品管理样式 */
|
||||
.prize-manage-card {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #f0f0f0;
|
||||
margin-bottom: 12px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.prize-manage-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.prize-image-wrapper {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.prize-manage-card .prize-image {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 8px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-color: #f5f5f5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #e8e8e8;
|
||||
}
|
||||
|
||||
.prize-details {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.prize-details .prize-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.prize-level-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.prize-level-badge.level-一等奖 {
|
||||
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prize-level-badge.level-二等奖 {
|
||||
background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prize-level-badge.level-三等奖 {
|
||||
background: linear-gradient(135deg, #CD7F32 0%, #B8733E 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prize-level-badge.level-参与奖 {
|
||||
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prize-level-badge.level-伴手礼 {
|
||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.prize-details .prize-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.prize-desc-text {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prize-meta {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.prize-quantity-text {
|
||||
color: #FF6700;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prize-ops {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.prize-ops .van-icon {
|
||||
font-size: 20px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.prize-ops .van-icon:active {
|
||||
color: #FF6700;
|
||||
}
|
||||
|
||||
/* 等级选择器 */
|
||||
.level-picker {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.level-picker .picker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.level-picker .picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.level-picker .picker-header .van-icon {
|
||||
font-size: 20px;
|
||||
color: #999;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.level-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.level-item {
|
||||
padding: 12px 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.level-item:active {
|
||||
transform: scale(0.98);
|
||||
background: #fff7e6;
|
||||
}
|
||||
|
||||
.level-item .level-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 20px;
|
||||
border-radius: 16px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { ref } from 'vue';
|
||||
import { showToast, showConfirmDialog } from 'vant';
|
||||
import api from '../../api';
|
||||
|
||||
export function usePrizeManage(activityId) {
|
||||
const prizes = ref([]);
|
||||
const showPrizeForm = ref(false);
|
||||
const showLevelPicker = ref(false);
|
||||
const showSourcePicker = ref(false);
|
||||
const prizeImage = ref([]);
|
||||
const prizeForm = ref({
|
||||
id: null,
|
||||
activityId: activityId.value,
|
||||
name: '',
|
||||
level: '',
|
||||
description: '',
|
||||
quantity: 1,
|
||||
sortOrder: 0,
|
||||
source: 'purchase',
|
||||
sponsor: '',
|
||||
image: null
|
||||
});
|
||||
|
||||
const prizeLevels = ['特等奖', '一等奖', '二等奖', '三等奖', '四等奖', '参与奖', '伴手礼'];
|
||||
|
||||
const loadPrizes = async () => {
|
||||
try {
|
||||
const { data } = await api.getActivity(activityId.value);
|
||||
prizes.value = data.prizes || [];
|
||||
} catch (error) {
|
||||
console.error('加载奖品失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const editPrize = (prize) => {
|
||||
prizeForm.value = {
|
||||
id: prize.id,
|
||||
activityId: activityId.value,
|
||||
name: prize.name,
|
||||
level: prize.level,
|
||||
description: prize.description || '',
|
||||
quantity: prize.quantity,
|
||||
sortOrder: prize.sortOrder || 0,
|
||||
source: prize.source || 'purchase',
|
||||
sponsor: prize.sponsor || '',
|
||||
image: prize.image
|
||||
};
|
||||
// 回显图片 - 需要使用完整URL
|
||||
if (prize.image) {
|
||||
const imageUrl = prize.image.startsWith('http') ? prize.image :
|
||||
(import.meta.env.DEV ? `http://localhost:3000${prize.image}` : `https://mi.xyvan.cn${prize.image}`);
|
||||
prizeImage.value = [{ url: imageUrl }];
|
||||
} else {
|
||||
prizeImage.value = [];
|
||||
}
|
||||
showPrizeForm.value = true;
|
||||
};
|
||||
|
||||
const onPrizeImageRead = (file) => {
|
||||
prizeForm.value.imageFile = file.file;
|
||||
};
|
||||
|
||||
const selectLevel = (level) => {
|
||||
prizeForm.value.level = level;
|
||||
showLevelPicker.value = false;
|
||||
};
|
||||
|
||||
const selectSource = (source) => {
|
||||
prizeForm.value.source = source;
|
||||
if (source === 'purchase') {
|
||||
prizeForm.value.sponsor = '';
|
||||
}
|
||||
showSourcePicker.value = false;
|
||||
};
|
||||
|
||||
const submitPrize = async () => {
|
||||
if (!prizeForm.value.name || !prizeForm.value.level || !prizeForm.value.source) {
|
||||
showToast('请填写必填项');
|
||||
return;
|
||||
}
|
||||
|
||||
if (prizeForm.value.source !== 'purchase' && !prizeForm.value.sponsor) {
|
||||
showToast('请填写赞助者名称');
|
||||
return;
|
||||
}
|
||||
|
||||
const loadingToast = showToast({
|
||||
type: 'loading',
|
||||
message: '保存中...',
|
||||
forbidClick: true,
|
||||
duration: 0
|
||||
});
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('activityId', activityId.value);
|
||||
formData.append('name', prizeForm.value.name);
|
||||
formData.append('level', prizeForm.value.level);
|
||||
formData.append('description', prizeForm.value.description);
|
||||
formData.append('quantity', prizeForm.value.quantity);
|
||||
formData.append('sortOrder', prizeForm.value.sortOrder);
|
||||
formData.append('source', prizeForm.value.source);
|
||||
if (prizeForm.value.sponsor) {
|
||||
formData.append('sponsor', prizeForm.value.sponsor);
|
||||
}
|
||||
|
||||
if (prizeForm.value.imageFile) {
|
||||
formData.append('image', prizeForm.value.imageFile);
|
||||
} else if (prizeImage.value.length === 0 && prizeForm.value.image) {
|
||||
// 用户删除了图片
|
||||
formData.append('removeImage', 'true');
|
||||
}
|
||||
|
||||
if (prizeForm.value.id) {
|
||||
await api.updatePrize(prizeForm.value.id, formData);
|
||||
} else {
|
||||
await api.addPrize(formData);
|
||||
}
|
||||
|
||||
showToast('保存成功');
|
||||
showPrizeForm.value = false;
|
||||
resetPrizeForm();
|
||||
loadPrizes();
|
||||
} catch (error) {
|
||||
showToast('保存失败');
|
||||
console.error('保存奖品失败:', error);
|
||||
} finally {
|
||||
loadingToast.clear();
|
||||
}
|
||||
};
|
||||
|
||||
const deletePrizeItem = async (id) => {
|
||||
try {
|
||||
await showConfirmDialog({
|
||||
title: '确认删除',
|
||||
message: '确定要删除这个奖品吗?'
|
||||
});
|
||||
|
||||
await api.deletePrize(id);
|
||||
showToast('删除成功');
|
||||
await loadPrizes();
|
||||
} catch (error) {
|
||||
if (error !== 'cancel') {
|
||||
showToast('删除失败');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const resetPrizeForm = () => {
|
||||
prizeForm.value = {
|
||||
id: null,
|
||||
activityId: activityId.value,
|
||||
name: '',
|
||||
level: '',
|
||||
description: '',
|
||||
quantity: 1,
|
||||
sortOrder: 0,
|
||||
source: 'purchase',
|
||||
sponsor: '',
|
||||
image: null
|
||||
};
|
||||
prizeImage.value = [];
|
||||
};
|
||||
|
||||
return {
|
||||
prizes,
|
||||
showPrizeForm,
|
||||
showLevelPicker,
|
||||
showSourcePicker,
|
||||
prizeImage,
|
||||
prizeForm,
|
||||
prizeLevels,
|
||||
loadPrizes,
|
||||
editPrize,
|
||||
onPrizeImageRead,
|
||||
selectLevel,
|
||||
selectSource,
|
||||
submitPrize,
|
||||
deletePrizeItem,
|
||||
resetPrizeForm
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
// 活动流程数据
|
||||
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: [
|
||||
{
|
||||
icon: '🎯',
|
||||
name: '套圈圈',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>站在指定位置,用圈圈套中目标物品</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 套中1个:<span class="score-badge">+1分</span></p>
|
||||
<p>• 套中3个:<span class="score-badge">+3分</span></p>
|
||||
<p>• 套中5个:<span class="score-badge">+5分</span></p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '🏺',
|
||||
name: '投壶',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>将箭矢投入壶中,考验准确度</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 投中3个:<span class="score-badge">+2分</span></p>
|
||||
<p>• 投中5个:<span class="score-badge">+4分</span></p>
|
||||
<p>• 全部投中:<span class="score-badge">+5分</span></p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '🎲',
|
||||
name: '摇骰子(067玩法)',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>玩家与庄家对战,比拼骰子点数大小</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 玩家赢庄家:<span class="score-badge">+1分</span></p>
|
||||
<p>• 玩家输庄家:<span class="score-badge">-1分</span></p>
|
||||
<p>• 摇出5个正豹子:<span class="score-badge">+5分</span>(直接判赢)</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '🎲',
|
||||
name: '摇骰子(单骰比大小)',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>单个骰子比大小,玩家与庄家对战</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 玩家赢庄家:<span class="score-badge">+1分</span></p>
|
||||
<p>• 玩家输庄家:<span class="score-badge">-1分</span></p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '🐂',
|
||||
name: '牛牛',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>扑克牌牛牛游戏,与对手比拼牌型大小</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 赢一局:<span class="score-badge">+1分</span></p>
|
||||
<p>• 连赢3局:<span class="score-badge">+3分</span></p>
|
||||
<p>• 连赢5局:<span class="score-badge">+5分</span></p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '✊',
|
||||
name: '闽南划拳(两拳倒)',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>闽南传统划拳游戏,两拳倒玩法</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 玩家赢:<span class="score-badge">+1分</span></p>
|
||||
<p>• 玩家输:<span class="score-badge">-1分</span></p>
|
||||
<p>• 一人一拳投资,赢的玩家:<span class="score-badge">+2分</span></p>
|
||||
<p>• 最高叠加投资到划5拳,赢的玩家:<span class="score-badge">+5分</span></p>
|
||||
<p class="highlight-tip">💡 投资机制:可叠加投资,赢得越多分数越高!</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '⚽',
|
||||
name: '足球射门',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>踢足球射门,进球数决定得分</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 进1球:<span class="score-badge">+1分</span></p>
|
||||
<p>• 进3球:<span class="score-badge">+3分</span></p>
|
||||
<p>• 进5球:<span class="score-badge">+5分</span></p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '🎮',
|
||||
name: '王者荣耀',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>组队进行王者荣耀对战</p>
|
||||
<p><strong>5V5模式(凑齐10人):</strong></p>
|
||||
<p>• 胜方队员(4人):<span class="score-badge">+3分</span></p>
|
||||
<p>• 胜方MVP(评分最高):<span class="score-badge">+5分</span></p>
|
||||
<p>• 败方MVP(评分最高):<span class="score-badge">+1分</span></p>
|
||||
<p><strong>SOLO模式(1V1):</strong></p>
|
||||
<p>• 胜者(一血或一塔):<span class="score-badge">+2分</span></p>
|
||||
<p class="highlight-tip">💡 团队配合最重要,选好英雄!</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '💧',
|
||||
name: '抛水瓶',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>抛水瓶使其立住,考验技巧</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 立住1个:<span class="score-badge">+2分</span></p>
|
||||
<p>• 立住3个:<span class="score-badge">+4分</span></p>
|
||||
<p>• 立住5个:<span class="score-badge">+5分</span></p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '🎴',
|
||||
name: '扑克游戏',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>多种扑克玩法,赢家得分</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 赢家:<span class="score-badge">+2分</span></p>
|
||||
<p>• 输家:<span class="score-badge">-1分</span></p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '🐊',
|
||||
name: '鳄鱼牙齿/插剑玩具',
|
||||
detail: `
|
||||
<div class="sub-detail">
|
||||
<p><strong>游戏规则:</strong>按压鳄鱼牙齿或插剑,避免触发机关</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 按/插过半数:<span class="score-badge">+1分</span></p>
|
||||
<p>• 每多按/插一个:<span class="score-badge">+1分</span>(累加)</p>
|
||||
<p>• 最高封顶:<span class="score-badge">+5分</span></p>
|
||||
<p>• 触发机关(爆了):<span class="score-badge">积分清0</span></p>
|
||||
<p class="highlight-tip">💡 胆大心细,步步为营!</p>
|
||||
</div>
|
||||
`
|
||||
},
|
||||
{
|
||||
icon: '🪑',
|
||||
name: '最终复活挑战 - 抢椅子',
|
||||
detail: `
|
||||
<div class="sub-detail highlight-sub">
|
||||
<p><strong>游戏规则:</strong>音乐停止时抢座位,未抢到者淘汰</p>
|
||||
<p><strong>得分标准:</strong></p>
|
||||
<p>• 冠军:<span class="score-badge">+5分</span></p>
|
||||
<p>• 亚军:<span class="score-badge">+3分</span></p>
|
||||
<p>• 季军:<span class="score-badge">+2分</span></p>
|
||||
<p>• 淘汰:<span class="score-badge">-1分</span></p>
|
||||
<p class="highlight-tip">⚡ 这是翻盘的最佳机会!</p>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
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,33 @@
|
|||
@echo off
|
||||
echo ==========================================
|
||||
echo 数据库迁移 - 添加弹幕字段
|
||||
echo ==========================================
|
||||
echo.
|
||||
|
||||
REM 检查容器是否运行
|
||||
docker ps | findstr quanyoumi-app >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo 错误: quanyoumi-app 容器未运行
|
||||
echo 请先启动容器: docker-compose up -d
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo 正在容器内执行数据库迁移...
|
||||
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo 迁移失败!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ==========================================
|
||||
echo 迁移完成!请重启容器使更改生效
|
||||
echo ==========================================
|
||||
echo.
|
||||
echo 重启命令: docker-compose restart
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
#!/bin/bash
|
||||
|
||||
echo "=========================================="
|
||||
echo "数据库迁移 - 添加弹幕字段"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 检查容器是否运行
|
||||
if ! docker ps | grep -q quanyoumi-app; then
|
||||
echo "错误: quanyoumi-app 容器未运行"
|
||||
echo "请先启动容器: docker-compose up -d"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "正在容器内执行数据库迁移..."
|
||||
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "迁移失败!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "迁移完成!请重启容器使更改生效"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "重启命令: docker-compose restart"
|
||||
echo ""
|
||||
53
签到功能说明.md
53
签到功能说明.md
|
|
@ -1,53 +0,0 @@
|
|||
# 签到功能实施说明
|
||||
|
||||
## 已完成的修改
|
||||
|
||||
### 1. 数据库修改
|
||||
- 创建了迁移脚本 `database-migration-checkin.sql`
|
||||
- 添加了两个字段:
|
||||
- `checkInStatus` (BOOLEAN) - 签到状态
|
||||
- `checkInTime` (DATETIME) - 签到时间
|
||||
|
||||
### 2. 后端修改
|
||||
- **模型更新** (`backend/models/Registration.js`)
|
||||
- 添加签到字段定义
|
||||
|
||||
- **API接口** (`backend/routes/admin.js`)
|
||||
- 新增 `PUT /admin/registrations/:id/checkin` - 签到/取消签到接口
|
||||
|
||||
- **前端API** (`frontend/src/api/index.js`)
|
||||
- 添加 `checkInRegistration` 方法
|
||||
|
||||
### 3. 前端修改
|
||||
- **后台管理页面** (`frontend/src/views/admin/ActivityManage.vue`)
|
||||
- 统计栏新增:已签到/未签到统计(一行三个布局)
|
||||
- 报名列表显示签到状态标签
|
||||
- 报名列表显示签到时间
|
||||
- 添加签到/取消签到按钮(仅已通过审核的用户可签到)
|
||||
- 手机号改为可点击拨打的链接
|
||||
- 车牌号可点击复制
|
||||
|
||||
## 使用步骤
|
||||
|
||||
### 1. 执行数据库迁移
|
||||
```bash
|
||||
mysql -u root -p quanyoumi < database-migration-checkin.sql
|
||||
```
|
||||
|
||||
### 2. 重启后端服务
|
||||
```bash
|
||||
cd backend
|
||||
npm start
|
||||
```
|
||||
|
||||
### 3. 功能使用
|
||||
- 进入后台管理 -> 活动管理 -> 报名情况
|
||||
- 查看签到统计(已签到/未签到人数)
|
||||
- 点击"签到"按钮为用户签到
|
||||
- 点击手机号直接拨打电话
|
||||
- 点击车牌号复制到剪贴板
|
||||
|
||||
## 注意事项
|
||||
- 只有审核通过的用户才能签到
|
||||
- 签到后可以取消签到
|
||||
- 签到时间会自动记录
|
||||
Loading…
Reference in New Issue