优化验证报名信息对话框车牌号输入布局,使标签和输入框在同一行显示

This commit is contained in:
Yvan 2026-04-09 15:37:40 +08:00
parent 89960b1cb8
commit 4920620d2c
48 changed files with 4804 additions and 3916 deletions

1
.version Normal file
View File

@ -0,0 +1 @@
5

View File

@ -20,14 +20,14 @@ FROM node:18-alpine
WORKDIR /app WORKDIR /app
# 安装 dumb-init 和 envsubst # 安装 dumb-init、envsubst 和 SQLite 编译依赖
RUN apk add --no-cache dumb-init gettext RUN apk add --no-cache dumb-init gettext python3 make g++ sqlite-dev
# 复制后端依赖文件 # 复制后端依赖文件
COPY backend/package*.json ./ COPY backend/package*.json ./
# 安装后端依赖(仅生产依赖) # 安装后端依赖(仅生产依赖)
RUN npm ci --only=production RUN npm install --only=production
# 复制后端源码 # 复制后端源码
COPY backend/ ./ COPY backend/ ./
@ -35,8 +35,8 @@ COPY backend/ ./
# 从前端构建阶段复制构建产物 # 从前端构建阶段复制构建产物
COPY --from=frontend-builder /app/frontend/dist ./public COPY --from=frontend-builder /app/frontend/dist ./public
# 创建上传目录 # 创建上传和数据目录
RUN mkdir -p uploads RUN mkdir -p uploads data
# 复制启动脚本 # 复制启动脚本
COPY docker-entrypoint.sh /usr/local/bin/ COPY docker-entrypoint.sh /usr/local/bin/

0
PRIZE-FEATURE.md Normal file
View File

View File

@ -1,17 +1,6 @@
PORT=3000 PORT=3000
DB_HOST=localhost
DB_USER=root
DB_PASSWORD=your-db-password
DB_NAME=quanyoumi
JWT_SECRET=your-secret-key-change-in-production JWT_SECRET=your-secret-key-change-in-production
# MinIO 配置 # 高德地图配置
MINIO_ENDPOINT=localhost # 后端连接 MinIO 的地址 AMAP_KEY=your-amap-key
MINIO_PORT=9000 AMAP_SECURITY_CODE=your-amap-security-code
MINIO_USE_SSL=false
MINIO_ACCESS_KEY=jFywUsaCb4zNr2LV7hTP
MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET=quanyoumi
# MINIO_PUBLIC_URL可选前端访问文件的公开地址不填则自动用 MINIO_ENDPOINT 拼接
# 生产环境如果用了反向代理(如 Nginx填写外网地址例如https://cdn.yourdomain.com
# MINIO_PUBLIC_URL=http://localhost:9000

View File

@ -5,8 +5,21 @@ const { Admin, sequelize } = require('./models');
async function initAdmin() { async function initAdmin() {
try { try {
// 使用 force: false 避免 alter 表时的问题
// 如果表不存在则创建,存在则跳过
await sequelize.sync({ force: false }); 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); const hashedPassword = bcrypt.hashSync('admin123', 10);
await Admin.create({ await Admin.create({
username: 'admin', username: 'admin',
@ -14,12 +27,17 @@ async function initAdmin() {
name: '管理员' name: '管理员'
}); });
console.log('管理员账号创建成功'); console.log('管理员账号创建成功');
console.log('用户名: admin'); console.log(' 用户名: admin');
console.log('密码: admin123'); console.log(' 密码: admin123');
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
console.error('创建失败:', error.message); console.error('✗ 初始化失败:', error.message);
if (error.name === 'SequelizeUniqueConstraintError') {
console.error(' 原因: 管理员账号已存在');
} else {
console.error(' 详细错误:', error);
}
process.exit(1); process.exit(1);
} }
} }

View File

@ -1,11 +1,27 @@
const multer = require('multer'); const multer = require('multer');
const path = require('path'); const path = require('path');
const { v4: uuidv4 } = require('uuid'); const fs = require('fs');
const { uploadBuffer } = require('../utils/minio');
// 确保上传目录存在
const uploadDir = path.join(__dirname, '../uploads');
if (!fs.existsSync(uploadDir)) {
fs.mkdirSync(uploadDir, { recursive: true });
}
// 配置本地存储
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const uniqueName = `${Date.now()}-${Math.random().toString(36).substring(7)}${path.extname(file.originalname)}`;
cb(null, uniqueName);
}
});
// 使用内存存储,文件不落盘,不限制大小
const upload = multer({ const upload = multer({
storage: multer.memoryStorage(), storage,
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB
fileFilter: (req, file, cb) => { fileFilter: (req, file, cb) => {
const extname = /jpeg|jpg|png|gif|mp4|mov|avi/.test( const extname = /jpeg|jpg|png|gif|mp4|mov|avi/.test(
path.extname(file.originalname).toLowerCase() path.extname(file.originalname).toLowerCase()
@ -16,15 +32,14 @@ const upload = multer({
} }
}); });
// 将 req.files 或 req.file 上传到 MinIO返回 url 数组或单个 url // 返回文件访问URL
const uploadFilesToMinio = async (files) => { const getFileUrls = (files) => {
const list = Array.isArray(files) ? files : [files]; const list = Array.isArray(files) ? files : [files];
return Promise.all(list.map(async (file) => { return list.map(file => ({
const ext = path.extname(file.originalname).toLowerCase(); url: `/uploads/${file.filename}`,
const filename = `${Date.now()}-${uuidv4()}${ext}`; filename: file.filename,
const url = await uploadBuffer(file.buffer, filename, file.mimetype); mimetype: file.mimetype
return { ...file, filename, url };
})); }));
}; };
module.exports = { upload, uploadFilesToMinio }; module.exports = { upload, getFileUrls };

View File

@ -0,0 +1,84 @@
/**
* 迁移: 初始化数据库结构确保所有表和字段存在
* 版本: 0000
* 日期: 2025-01-09
*/
module.exports = {
async up(manager) {
console.log(' - 检查并创建所有必需的表和字段...');
// Activities 表的额外字段
const activityFields = [
{ name: 'registrationEnabled', sql: 'ALTER TABLE Activities ADD COLUMN registrationEnabled INTEGER DEFAULT 1;' },
{ name: 'coverImage', sql: 'ALTER TABLE Activities ADD COLUMN coverImage TEXT;' },
{ name: 'price', sql: 'ALTER TABLE Activities ADD COLUMN price DECIMAL(10, 2) DEFAULT 0;' },
{ name: 'budget', sql: 'ALTER TABLE Activities ADD COLUMN budget DECIMAL(10, 2) DEFAULT 0;' },
{ name: 'locationLat', sql: 'ALTER TABLE Activities ADD COLUMN locationLat DECIMAL(10, 7);' },
{ name: 'locationLng', sql: 'ALTER TABLE Activities ADD COLUMN locationLng DECIMAL(10, 7);' }
];
for (const field of activityFields) {
await manager.executeSql(field.sql);
}
// Registrations 表的额外字段
const registrationFields = [
{ name: 'peopleCount', sql: 'ALTER TABLE Registrations ADD COLUMN peopleCount INTEGER DEFAULT 1;' },
{ name: 'note', sql: 'ALTER TABLE Registrations ADD COLUMN note TEXT;' }
];
for (const field of registrationFields) {
await manager.executeSql(field.sql);
}
// Expenses 表的额外字段
const expenseFields = [
{ name: 'category', sql: 'ALTER TABLE Expenses ADD COLUMN category TEXT;' },
{ name: 'receipt', sql: 'ALTER TABLE Expenses ADD COLUMN receipt TEXT;' },
{ name: 'note', sql: 'ALTER TABLE Expenses ADD COLUMN note TEXT;' },
{ name: 'media', sql: 'ALTER TABLE Expenses ADD COLUMN media TEXT;' }
];
for (const field of expenseFields) {
await manager.executeSql(field.sql);
}
// Photos 表的额外字段
const photoFields = [
{ name: 'description', sql: 'ALTER TABLE Photos ADD COLUMN description TEXT;' },
{ name: 'uploadedBy', sql: 'ALTER TABLE Photos ADD COLUMN uploadedBy TEXT;' },
{ name: 'status', sql: 'ALTER TABLE Photos ADD COLUMN status TEXT DEFAULT "approved";' }
];
for (const field of photoFields) {
await manager.executeSql(field.sql);
}
// Sponsors 表的额外字段
const sponsorFields = [
{ name: 'logo', sql: 'ALTER TABLE Sponsors ADD COLUMN logo TEXT;' },
{ name: 'type', sql: 'ALTER TABLE Sponsors ADD COLUMN type TEXT DEFAULT "sponsor";' },
{ name: 'link', sql: 'ALTER TABLE Sponsors ADD COLUMN link TEXT;' }
];
for (const field of sponsorFields) {
await manager.executeSql(field.sql);
}
// Admins 表的额外字段
const adminFields = [
{ name: 'name', sql: 'ALTER TABLE Admins ADD COLUMN name TEXT;' }
];
for (const field of adminFields) {
await manager.executeSql(field.sql);
}
console.log(' - 初始化完成');
},
async down(manager) {
console.log(' - 回滚不支持');
}
};

View File

@ -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(' - 回滚不支持,请手动处理');
}
};

View File

@ -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(' - 回滚不支持');
}
};

View File

@ -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(' - 回滚不支持');
}
};

View File

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

View File

@ -0,0 +1,190 @@
# 数据库迁移系统
## 概述
本项目使用版本化的数据库迁移系统,支持增量更新和自动同步。
## 特性
- ✅ 版本管理:每个迁移有唯一版本号
- ✅ 增量执行:只执行未执行的迁移
- ✅ 自动运行:服务器启动时自动执行迁移
- ✅ 幂等性:重复执行不会出错
- ✅ 迁移记录:记录所有已执行的迁移
## 迁移文件命名规范
```
XXXX-description.js
```
- `XXXX`: 4位数字版本号如 0001, 0002
- `description`: 迁移描述(使用短横线分隔)
示例:
- `0001-add-danmaku-fields.js`
- `0002-add-user-avatar.js`
## 创建新迁移
### 1. 创建迁移文件
`backend/migrations/` 目录下创建新文件:
```javascript
/**
* 迁移: 添加用户头像字段
* 版本: 0002
* 日期: 2025-01-09
*/
module.exports = {
async up(manager) {
console.log(' - 添加 avatar 字段...');
await manager.executeSql(`
ALTER TABLE Users ADD COLUMN avatar TEXT;
`);
},
async down(manager) {
// 回滚逻辑(可选)
console.log(' - 回滚不支持');
}
};
```
### 2. 迁移方法
#### `up(manager)` - 升级
执行数据库变更的方法。
可用的 manager 方法:
- `executeSql(sql)`: 执行SQL语句
- `db`: 直接访问数据库连接
#### `down(manager)` - 回滚(可选)
回滚数据库变更的方法。
注意SQLite 不支持 DROP COLUMN回滚可能需要重建表。
## 执行迁移
### 自动执行(推荐)
服务器启动时会自动执行所有未执行的迁移:
```bash
# 开发环境
cd backend
npm start
# 生产环境Docker
docker-compose up -d
# 或
docker-compose restart
```
### 手动执行
#### 在容器内执行:
```bash
# Windows
migrate.bat
# Linux/Mac
chmod +x migrate.sh
./migrate.sh
```
#### 直接在容器中执行:
```bash
docker exec quanyoumi-app node /app/backend/migrations/index.js
```
## 迁移状态
迁移执行记录保存在 `migrations` 表中:
```sql
SELECT * FROM migrations;
```
输出示例:
```
id | version | name | executed_at
---+---------+-----------------------+---------------------
1 | 1 | add-danmaku-fields | 2025-01-09 10:30:00
2 | 2 | add-user-avatar | 2025-01-09 11:00:00
```
## 常见问题
### Q: 迁移失败怎么办?
A: 检查错误日志,修复问题后重新启动服务器。已执行的迁移不会重复执行。
### Q: 如何回滚迁移?
A: 目前不支持自动回滚。如需回滚,请手动修改数据库或创建新的迁移来撤销更改。
### Q: 可以修改已执行的迁移吗?
A: 不建议。已执行的迁移不应修改。如需更改,请创建新的迁移。
### Q: 迁移在什么时候执行?
A: 服务器启动时自动执行。也可以手动运行 `migrate.bat``migrate.sh`
## 最佳实践
1. **版本号递增**:新迁移的版本号应大于所有现有迁移
2. **描述清晰**:文件名和注释应清楚说明迁移目的
3. **测试迁移**:在开发环境测试后再部署到生产环境
4. **幂等性**:迁移应该可以安全地重复执行
5. **小步迭代**:每个迁移只做一件事,便于追踪和回滚
## 示例
### 添加新字段
```javascript
module.exports = {
async up(manager) {
await manager.executeSql(`
ALTER TABLE Activities ADD COLUMN featured INTEGER DEFAULT 0;
`);
}
};
```
### 创建新表
```javascript
module.exports = {
async up(manager) {
await manager.executeSql(`
CREATE TABLE IF NOT EXISTS Comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
activityId INTEGER NOT NULL,
userId INTEGER NOT NULL,
content TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
}
};
```
### 修改数据
```javascript
module.exports = {
async up(manager) {
await manager.executeSql(`
UPDATE Activities SET status = 'completed' WHERE endTime < datetime('now');
`);
}
};
```

141
backend/migrations/index.js Normal file
View File

@ -0,0 +1,141 @@
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const fs = require('fs');
const dbPath = path.join(__dirname, '..', 'data', 'database.sqlite');
const migrationsDir = __dirname;
class MigrationManager {
constructor() {
this.db = new sqlite3.Database(dbPath);
}
// 初始化迁移表
async initMigrationTable() {
return new Promise((resolve, reject) => {
this.db.run(`
CREATE TABLE IF NOT EXISTS migrations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version INTEGER NOT NULL UNIQUE,
name TEXT NOT NULL,
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
`, (err) => {
if (err) reject(err);
else resolve();
});
});
}
// 获取已执行的迁移版本
async getExecutedMigrations() {
return new Promise((resolve, reject) => {
this.db.all('SELECT version FROM migrations ORDER BY version', (err, rows) => {
if (err) reject(err);
else resolve(rows.map(r => r.version));
});
});
}
// 记录迁移执行
async recordMigration(version, name) {
return new Promise((resolve, reject) => {
this.db.run(
'INSERT INTO migrations (version, name) VALUES (?, ?)',
[version, name],
(err) => {
if (err) reject(err);
else resolve();
}
);
});
}
// 执行SQL语句
async executeSql(sql) {
return new Promise((resolve, reject) => {
this.db.run(sql, (err) => {
if (err) {
// 忽略字段已存在的错误
if (err.message.includes('duplicate column name')) {
resolve();
} else {
reject(err);
}
} else {
resolve();
}
});
});
}
// 加载所有迁移文件
loadMigrations() {
const files = fs.readdirSync(migrationsDir)
.filter(f => f.match(/^\d{4}-.*\.js$/) && f !== 'index.js')
.sort();
return files.map(file => {
const version = parseInt(file.split('-')[0]);
const name = file.replace(/^\d{4}-/, '').replace('.js', '');
const migration = require(path.join(migrationsDir, file));
return { version, name, file, migration };
});
}
// 执行迁移
async runMigrations() {
try {
console.log('🔄 开始数据库迁移...\n');
// 初始化迁移表
await this.initMigrationTable();
// 获取已执行的迁移
const executed = await this.getExecutedMigrations();
console.log(`📊 已执行迁移: ${executed.length}`);
// 加载所有迁移文件
const migrations = this.loadMigrations();
console.log(`📁 发现迁移文件: ${migrations.length}\n`);
// 执行未执行的迁移
let count = 0;
for (const { version, name, file, migration } of migrations) {
if (!executed.includes(version)) {
console.log(`⏳ 执行迁移 [${version}] ${name}...`);
try {
await migration.up(this);
await this.recordMigration(version, name);
console.log(`✅ 迁移 [${version}] ${name} 完成\n`);
count++;
} catch (error) {
console.error(`❌ 迁移 [${version}] ${name} 失败:`, error.message);
throw error;
}
} else {
console.log(`⏭️ 跳过已执行的迁移 [${version}] ${name}`);
}
}
console.log(`\n✨ 迁移完成!共执行 ${count} 个新迁移`);
} catch (error) {
console.error('\n❌ 迁移失败:', error);
throw error;
} finally {
this.db.close();
}
}
}
// 如果直接运行此文件
if (require.main === module) {
const manager = new MigrationManager();
manager.runMigrations()
.then(() => process.exit(0))
.catch(() => process.exit(1));
}
module.exports = MigrationManager;

View File

@ -38,6 +38,15 @@ const Activity = sequelize.define('Activity', {
type: DataTypes.DECIMAL(10, 2), type: DataTypes.DECIMAL(10, 2),
defaultValue: 0, defaultValue: 0,
comment: '活动预算' comment: '活动预算'
},
danmakuEnabled: {
type: DataTypes.BOOLEAN,
defaultValue: false,
comment: '弹幕开关'
},
danmakuContent: {
type: DataTypes.TEXT,
comment: '弹幕内容JSON'
} }
}); });

35
backend/models/Prize.js Normal file
View File

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

View File

@ -20,7 +20,12 @@ const Sponsor = sequelize.define('Sponsor', {
type: DataTypes.ENUM('title', 'sponsor'), type: DataTypes.ENUM('title', 'sponsor'),
defaultValue: 'sponsor' defaultValue: 'sponsor'
}, },
link: DataTypes.STRING link: DataTypes.STRING,
sortOrder: {
type: DataTypes.INTEGER,
defaultValue: 0,
comment: '排序值,数字越小越靠前'
}
}); });
module.exports = Sponsor; module.exports = Sponsor;

View File

@ -1,14 +1,17 @@
const { Sequelize } = require('sequelize'); const { Sequelize } = require('sequelize');
const path = require('path');
const fs = require('fs');
const sequelize = new Sequelize( // 确保data目录存在
process.env.DB_NAME || 'quanyoumi', const dataDir = path.join(__dirname, '../data');
process.env.DB_USER || 'root', if (!fs.existsSync(dataDir)) {
process.env.DB_PASSWORD || '', fs.mkdirSync(dataDir, { recursive: true });
{ }
host: process.env.DB_HOST || 'localhost',
dialect: 'mysql', const sequelize = new Sequelize({
logging: false dialect: 'sqlite',
} storage: path.join(dataDir, 'database.sqlite'),
); logging: false
});
module.exports = sequelize; module.exports = sequelize;

View File

@ -4,11 +4,15 @@ const Registration = require('./Registration');
const Expense = require('./Expense'); const Expense = require('./Expense');
const Photo = require('./Photo'); const Photo = require('./Photo');
const Sponsor = require('./Sponsor'); const Sponsor = require('./Sponsor');
const Prize = require('./Prize');
const Admin = require('./Admin'); const Admin = require('./Admin');
// 同步数据库 // 同步数据库
sequelize.sync({ alter: true }).then(() => { // 使用 force: false 避免 SQLite alter 表时的约束冲突问题
console.log('数据库同步完成'); sequelize.sync({ force: false }).then(() => {
console.log('✓ 数据库同步完成');
}).catch(err => {
console.error('✗ 数据库同步失败:', err.message);
}); });
module.exports = { module.exports = {
@ -18,5 +22,6 @@ module.exports = {
Expense, Expense,
Photo, Photo,
Sponsor, Sponsor,
Prize,
Admin Admin
}; };

File diff suppressed because it is too large Load Diff

View File

@ -9,15 +9,15 @@
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"better-sqlite3": "^11.10.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.3.1", "dotenv": "^16.3.1",
"express": "^4.18.2", "express": "^4.18.2",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"minio": "^8.0.7",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"mysql2": "^3.6.0",
"sequelize": "^6.33.0", "sequelize": "^6.33.0",
"uuid": "^9.0.1" "sql.js": "^1.14.1",
"sqlite3": "^5.1.7"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.1" "nodemon": "^3.0.1"

View File

@ -1,7 +1,7 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { Activity, Photo, Sponsor, Expense, Registration } = require('../models'); const { Activity, Photo, Sponsor, Expense, Registration, Prize } = require('../models');
const { upload, uploadFilesToMinio } = require('../middleware/upload'); const { upload, getFileUrls } = require('../middleware/upload');
// 获取活动列表 // 获取活动列表
router.get('/activities', async (req, res) => { router.get('/activities', async (req, res) => {
@ -23,10 +23,17 @@ router.get('/activities/:id', async (req, res) => {
const photos = await Photo.findAll({ const photos = await Photo.findAll({
where: { activityId: req.params.id, status: 'approved' } 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 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) { } catch (error) {
res.status(500).json({ error: error.message }); 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) => { router.post('/activities/:id/photos', upload.array('photos', 10), async (req, res) => {
try { try {
const { uploadedBy } = req.body; const { uploadedBy } = req.body;
const uploaded = await uploadFilesToMinio(req.files); const uploaded = getFileUrls(req.files);
const photos = uploaded.map(file => ({ const photos = uploaded.map(file => ({
activityId: req.params.id, activityId: req.params.id,
url: file.url, url: file.url,

View File

@ -3,9 +3,10 @@ const router = express.Router();
const bcrypt = require('bcryptjs'); const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken'); const jwt = require('jsonwebtoken');
const auth = require('../middleware/auth'); const auth = require('../middleware/auth');
const { upload, uploadFilesToMinio } = require('../middleware/upload'); const { upload, getFileUrls } = require('../middleware/upload');
const { deleteFile } = require('../utils/minio'); const { Admin, Activity, Registration, Expense, Photo, Sponsor, Prize } = require('../models');
const { Admin, Activity, Registration, Expense, Photo, Sponsor } = require('../models'); const fs = require('fs');
const path = require('path');
// 管理员登录 // 管理员登录
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
@ -100,7 +101,7 @@ router.post('/expenses', auth, upload.array('media', 5), async (req, res) => {
let media = []; let media = [];
if (req.files && req.files.length > 0) { if (req.files && req.files.length > 0) {
const uploaded = await uploadFilesToMinio(req.files); const uploaded = getFileUrls(req.files);
media = uploaded.map(file => ({ media = uploaded.map(file => ({
url: file.url, url: file.url,
type: file.mimetype.startsWith('video/') ? 'video' : 'image' type: file.mimetype.startsWith('video/') ? 'video' : 'image'
@ -152,7 +153,7 @@ router.put('/expenses/:id', auth, upload.array('media', 5), async (req, res) =>
// 处理新上传的媒体 // 处理新上传的媒体
if (req.files && req.files.length > 0) { if (req.files && req.files.length > 0) {
const uploaded = await uploadFilesToMinio(req.files); const uploaded = getFileUrls(req.files);
const newMedia = uploaded.map(file => ({ const newMedia = uploaded.map(file => ({
url: file.url, url: file.url,
type: file.mimetype.startsWith('video/') ? 'video' : 'image' type: file.mimetype.startsWith('video/') ? 'video' : 'image'
@ -184,7 +185,7 @@ router.delete('/expenses/:id', auth, async (req, res) => {
router.post('/photos', auth, upload.array('photos', 10), async (req, res) => { router.post('/photos', auth, upload.array('photos', 10), async (req, res) => {
try { try {
const { activityId } = req.body; const { activityId } = req.body;
const uploaded = await uploadFilesToMinio(req.files); const uploaded = getFileUrls(req.files);
const photos = uploaded.map(file => ({ const photos = uploaded.map(file => ({
activityId, activityId,
url: file.url, url: file.url,
@ -231,11 +232,12 @@ router.delete('/photos/:id', auth, async (req, res) => {
return res.status(404).json({ error: '照片不存在' }); return res.status(404).json({ error: '照片不存在' });
} }
// 从URL提取文件名 // 删除本地文件
const filename = photo.url.split('/').pop(); const filename = photo.url.split('/').pop();
const filePath = path.join(__dirname, '../uploads', filename);
// 从MinIO删除文件 if (fs.existsSync(filePath)) {
await deleteFile(filename); fs.unlinkSync(filePath);
}
// 从数据库删除记录 // 从数据库删除记录
await photo.destroy(); await photo.destroy();
@ -249,13 +251,20 @@ router.delete('/photos/:id', auth, async (req, res) => {
// 添加赞助商 // 添加赞助商
router.post('/sponsors', auth, upload.single('logo'), async (req, res) => { router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
try { try {
const { activityId, name, type, link } = req.body; const { activityId, name, type, link, sortOrder } = req.body;
let logo = null; let logo = null;
if (req.file) { if (req.file) {
const [uploaded] = await uploadFilesToMinio(req.file); const [uploaded] = getFileUrls(req.file);
logo = uploaded.url; logo = uploaded.url;
} }
const sponsor = await Sponsor.create({ activityId, name, logo, type, link }); const sponsor = await Sponsor.create({
activityId,
name,
logo,
type,
link,
sortOrder: sortOrder || 0
});
res.json(sponsor); res.json(sponsor);
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
@ -265,11 +274,15 @@ router.post('/sponsors', auth, upload.single('logo'), async (req, res) => {
// 更新赞助商 // 更新赞助商
router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => { router.put('/sponsors/:id', auth, upload.single('logo'), async (req, res) => {
try { try {
const { name, type, link } = req.body; const { name, type, link, sortOrder } = req.body;
const updateData = { name, type, link }; const updateData = { name, type, link };
if (sortOrder !== undefined) {
updateData.sortOrder = sortOrder;
}
if (req.file) { if (req.file) {
const [uploaded] = await uploadFilesToMinio(req.file); const [uploaded] = getFileUrls(req.file);
updateData.logo = uploaded.url; updateData.logo = uploaded.url;
} }
@ -291,4 +304,57 @@ 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 } = 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
});
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 } = req.body;
const updateData = { name, level, description, quantity, sortOrder };
if (req.file) {
const [uploaded] = getFileUrls([req.file]);
updateData.image = uploaded.url;
}
await Prize.update(updateData, { where: { id: req.params.id } });
const prize = await Prize.findByPk(req.params.id);
res.json(prize);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// 删除奖品
router.delete('/prizes/:id', auth, async (req, res) => {
try {
await Prize.destroy({ where: { id: req.params.id } });
res.json({ message: '删除成功' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router; module.exports = router;

View File

@ -9,6 +9,9 @@ const app = express();
app.use(cors()); app.use(cors());
app.use(express.json()); app.use(express.json());
// 静态文件服务 - 提供上传的文件
app.use('/uploads', express.static(path.join(__dirname, 'uploads')));
// API 路由 // API 路由
app.use('/api', require('./routes/activity')); app.use('/api', require('./routes/activity'));
app.use('/api', require('./routes/registration')); app.use('/api', require('./routes/registration'));
@ -26,11 +29,25 @@ if (process.env.NODE_ENV === 'production') {
} }
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`); // 启动服务器前运行数据库迁移
if (process.env.NODE_ENV === 'production') { const MigrationManager = require('./migrations/index');
console.log('运行模式: 生产环境(前后端一体)'); const runMigrations = async () => {
} else { try {
console.log('运行模式: 开发环境仅后端API'); const manager = new MigrationManager();
await manager.runMigrations();
} catch (error) {
console.error('数据库迁移失败,但服务器将继续启动');
} }
};
runMigrations().then(() => {
app.listen(PORT, () => {
console.log(`服务器运行在 http://localhost:${PORT}`);
if (process.env.NODE_ENV === 'production') {
console.log('运行模式: 生产环境(前后端一体)');
} else {
console.log('运行模式: 开发环境仅后端API');
}
});
}); });

View File

@ -1,10 +1,26 @@
@echo off @echo off
REM 泉友米车友会 - Docker 构建脚本 (Windows) REM 泉友米车友会 - Docker 构建脚本 (Windows)
REM 版本: 0.0.1
REM ==========================================
REM 版本号配置(修改这里即可)
REM ==========================================
set VERSION_FILE=.version
set MAJOR=0
set MINOR=0
REM 读取或初始化补丁版本号
if exist %VERSION_FILE% (
set /p PATCH=<%VERSION_FILE%
) else (
set PATCH=1
)
REM 组合完整版本号
set VERSION=%MAJOR%.%MINOR%.%PATCH%
echo ========================================== echo ==========================================
echo 泉友米车友会 Docker 镜像构建 echo 泉友米车友会 Docker 镜像构建
echo 版本: 0.0.1 echo 版本: %VERSION%
echo ========================================== echo ==========================================
REM 检查 Docker 是否安装 REM 检查 Docker 是否安装
@ -17,7 +33,7 @@ if errorlevel 1 (
echo. echo.
echo 开始构建 Docker 镜像... echo 开始构建 Docker 镜像...
docker build -t quanyoumi-app:0.0.1 -t quanyoumi-app:latest . docker build -t quanyoumi-app:%VERSION% -t quanyoumi-app:latest .
if errorlevel 1 ( if errorlevel 1 (
echo. echo.
@ -26,23 +42,30 @@ if errorlevel 1 (
exit /b 1 exit /b 1
) )
REM 构建成功后自动递增版本号
set /a NEXT_PATCH=%PATCH%+1
echo %NEXT_PATCH%>%VERSION_FILE%
echo. echo.
echo ========================================== echo ==========================================
echo 构建完成! echo 构建完成!版本号已更新
echo ========================================== echo ==========================================
echo. echo.
echo 当前版本: %VERSION%
echo 下次版本: %MAJOR%.%MINOR%.%NEXT_PATCH%
echo.
echo 镜像信息: echo 镜像信息:
docker images | findstr quanyoumi-app docker images | findstr quanyoumi-app
echo. echo.
REM 标记镜像到阿里云仓库 REM 标记镜像到阿里云仓库
echo 正在标记镜像到阿里云仓库... echo 正在标记镜像到阿里云仓库...
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1 docker tag quanyoumi-app:%VERSION% registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo. echo.
echo 正在推送镜像到阿里云仓库... echo 正在推送镜像到阿里云仓库...
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1 docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
if errorlevel 1 ( if errorlevel 1 (
@ -58,12 +81,17 @@ echo ==========================================
echo 推送完成! echo 推送完成!
echo ========================================== echo ==========================================
echo. echo.
echo 当前版本: %VERSION%
echo 下次版本: %MAJOR%.%MINOR%.%NEXT_PATCH%
echo.
echo 镜像地址: echo 镜像地址:
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1 echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest echo registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo. echo.
echo 在 NAS 上使用: echo 在 NAS 上使用:
echo docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1 echo docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:%VERSION%
echo docker-compose up -d echo docker-compose up -d
echo. echo.
echo 提示: 如需修改主版本号或次版本号,请编辑 build.bat 文件顶部的 MAJOR 和 MINOR 变量
echo.
pause pause

View File

@ -1,13 +1,29 @@
#!/bin/bash #!/bin/bash
# 泉友米车友会 - Docker 构建脚本 # 泉友米车友会 - Docker 构建脚本
# 版本: 0.0.1
set -e set -e
# ==========================================
# 版本号配置(修改这里即可)
# ==========================================
VERSION_FILE=".version"
MAJOR=0
MINOR=0
# 读取或初始化补丁版本号
if [ -f "$VERSION_FILE" ]; then
PATCH=$(cat "$VERSION_FILE")
else
PATCH=1
fi
# 组合完整版本号
VERSION="$MAJOR.$MINOR.$PATCH"
echo "==========================================" echo "=========================================="
echo "泉友米车友会 Docker 镜像构建" echo "泉友米车友会 Docker 镜像构建"
echo "版本: 0.0.1" echo "版本: $VERSION"
echo "==========================================" echo "=========================================="
# 检查 Docker 是否安装 # 检查 Docker 是否安装
@ -19,25 +35,32 @@ fi
# 构建镜像 # 构建镜像
echo "" echo ""
echo "开始构建 Docker 镜像..." echo "开始构建 Docker 镜像..."
docker build -t quanyoumi-app:0.0.1 -t quanyoumi-app:latest . docker build -t quanyoumi-app:$VERSION -t quanyoumi-app:latest .
# 构建成功后自动递增版本号
NEXT_PATCH=$((PATCH + 1))
echo $NEXT_PATCH > "$VERSION_FILE"
echo "" echo ""
echo "==========================================" echo "=========================================="
echo "构建完成!" echo "构建完成!版本号已更新"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "当前版本: $VERSION"
echo "下次版本: $MAJOR.$MINOR.$NEXT_PATCH"
echo ""
echo "镜像信息:" echo "镜像信息:"
docker images | grep quanyoumi-app docker images | grep quanyoumi-app
echo "" echo ""
# 标记镜像到阿里云仓库 # 标记镜像到阿里云仓库
echo "正在标记镜像到阿里云仓库..." echo "正在标记镜像到阿里云仓库..."
docker tag quanyoumi-app:0.0.1 registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1 docker tag quanyoumi-app:$VERSION registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION
docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest docker tag quanyoumi-app:latest registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo "" echo ""
echo "正在推送镜像到阿里云仓库..." echo "正在推送镜像到阿里云仓库..."
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1 docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION
docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest docker push registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest
echo "" echo ""
@ -45,11 +68,16 @@ echo "=========================================="
echo "推送完成!" echo "推送完成!"
echo "==========================================" echo "=========================================="
echo "" echo ""
echo "当前版本: $VERSION"
echo "下次版本: $MAJOR.$MINOR.$NEXT_PATCH"
echo ""
echo "镜像地址:" echo "镜像地址:"
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1" echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION"
echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest" echo " registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:latest"
echo "" echo ""
echo "在 NAS 上使用:" echo "在 NAS 上使用:"
echo " docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1" echo " docker pull registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:$VERSION"
echo " docker-compose up -d" echo " docker-compose up -d"
echo "" echo ""
echo "提示: 如需修改主版本号或次版本号,请编辑 build.sh 文件顶部的 MAJOR 和 MINOR 变量"
echo ""

19
clean-db.bat Normal file
View File

@ -0,0 +1,19 @@
@echo off
echo ==========================================
echo 清理数据库文件
echo ==========================================
echo 停止容器...
docker-compose down
echo 删除数据库文件...
if exist backend\data\database.sqlite (
del /f backend\data\database.sqlite
echo 数据库文件已删除
) else (
echo 数据库文件不存在
)
echo.
echo 清理完成!现在可以运行 start.bat 重新启动
pause

View File

@ -3,7 +3,7 @@ version: '3.8'
services: services:
# 应用服务(前端+后端) # 应用服务(前端+后端)
app: app:
image: registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.1 image: registry.cn-hangzhou.aliyuncs.com/yvan329/quanyoumi-app:0.0.2
container_name: quanyoumi-app container_name: quanyoumi-app
restart: unless-stopped restart: unless-stopped
ports: ports:
@ -11,28 +11,14 @@ services:
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- PORT=3000 - PORT=3000
# 数据库配置
- DB_HOST=db
- DB_PORT=3306
- DB_USER=root
- DB_PASSWORD=quanyoumi2024
- DB_NAME=quanyoumi
# JWT 密钥(请修改为随机字符串) # JWT 密钥(请修改为随机字符串)
- JWT_SECRET=quanyoumi_jwt_secret_change_this_in_production_2024 - JWT_SECRET=quanyoumi_jwt_secret_change_this_in_production_2024
# 高德地图配置(必填) # 高德地图配置(必填)
- AMAP_KEY=fd5b77136ba34fe6b4f0f86dca7782d9 - AMAP_KEY=fd5b77136ba34fe6b4f0f86dca7782d9
- AMAP_SECURITY_CODE=7f61638e5635d944a534ff747dc615ac - AMAP_SECURITY_CODE=7f61638e5635d944a534ff747dc615ac
# MinIO 配置
- MINIO_ENDPOINT=minio
- MINIO_PORT=9000
- MINIO_ACCESS_KEY=minioadmin
- MINIO_SECRET_KEY=minioadmin123
- MINIO_BUCKET=quanyoumi
volumes: volumes:
- app-data:/app/data
- app-uploads:/app/uploads - app-uploads:/app/uploads
depends_on:
- db
- minio
networks: networks:
- quanyoumi-network - quanyoumi-network
healthcheck: healthcheck:
@ -42,54 +28,8 @@ services:
retries: 3 retries: 3
start_period: 10s start_period: 10s
# MySQL 数据库
db:
image: mysql:8.0
container_name: quanyoumi-db
restart: unless-stopped
environment:
- MYSQL_ROOT_PASSWORD=quanyoumi2024
- MYSQL_DATABASE=quanyoumi
- MYSQL_CHARACTER_SET_SERVER=utf8mb4
- MYSQL_COLLATION_SERVER=utf8mb4_unicode_ci
volumes:
- mysql-data:/var/lib/mysql
- ./database.sql:/docker-entrypoint-initdb.d/init.sql
networks:
- quanyoumi-network
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pquanyoumi2024"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
# MinIO 对象存储
minio:
image: minio/minio:latest
container_name: quanyoumi-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
- MINIO_ROOT_USER=minioadmin
- MINIO_ROOT_PASSWORD=minioadmin123
volumes:
- minio-data:/data
ports:
- "9001:9001"
networks:
- quanyoumi-network
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 20s
retries: 3
start_period: 10s
volumes: volumes:
mysql-data: app-data:
driver: local
minio-data:
driver: local driver: local
app-uploads: app-uploads:
driver: local driver: local

View File

@ -5,6 +5,24 @@ echo "=========================================="
echo "泉友米车友会 - 启动中..." echo "泉友米车友会 - 启动中..."
echo "==========================================" echo "=========================================="
# 检查数据库文件
DB_FILE="/app/data/database.sqlite"
if [ -f "$DB_FILE" ]; then
echo "✓ 发现已存在的数据库文件"
# 检查是否有备份表残留(这会导致sync失败)
if sqlite3 "$DB_FILE" "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '%_backup%';" 2>/dev/null | grep -q "_backup"; then
echo "⚠ 检测到备份表残留,清理中..."
sqlite3 "$DB_FILE" "SELECT 'DROP TABLE IF EXISTS ' || name || ';' FROM sqlite_master WHERE type='table' AND name LIKE '%_backup%';" | sqlite3 "$DB_FILE" 2>/dev/null || true
echo "✓ 备份表已清理"
fi
else
echo "✓ 首次启动,将创建新数据库"
fi
# 初始化数据库
echo "初始化数据库..."
node init-admin.js
# 替换前端 index.html 中的环境变量占位符 # 替换前端 index.html 中的环境变量占位符
if [ -f "/app/public/index.html" ]; then if [ -f "/app/public/index.html" ]; then
echo "配置高德地图 Key..." echo "配置高德地图 Key..."

View File

@ -1,201 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>高德地图搜索测试</title>
<style>
body {
font-family: Arial, sans-serif;
padding: 20px;
max-width: 800px;
margin: 0 auto;
}
#container {
width: 100%;
height: 400px;
margin: 20px 0;
border: 1px solid #ccc;
}
.search-box {
margin: 20px 0;
}
input {
padding: 10px;
width: 300px;
font-size: 14px;
}
button {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
}
.results {
margin-top: 20px;
}
.result-item {
padding: 10px;
border-bottom: 1px solid #eee;
cursor: pointer;
}
.result-item:hover {
background: #f5f5f5;
}
.log {
background: #f5f5f5;
padding: 10px;
margin-top: 20px;
max-height: 300px;
overflow-y: auto;
font-family: monospace;
font-size: 12px;
}
</style>
</head>
<body>
<h1>高德地图搜索功能测试</h1>
<div class="search-box">
<input type="text" id="keyword" placeholder="输入地点名称,如:泉州市政府" />
<button onclick="searchPlace()">搜索</button>
</div>
<div id="container"></div>
<div class="results" id="results"></div>
<div class="log" id="log"></div>
<script>
// 替换为你的高德地图Key和安全密钥
const AMAP_KEY = 'fd5b77136ba34fe6b4f0f86dca7782d9';
const SECURITY_CODE = 'b2e67bb4d5eed8848f0b8aae6ca667fc';
let map = null;
let placeSearch = null;
let marker = null;
function log(message) {
const logDiv = document.getElementById('log');
const time = new Date().toLocaleTimeString();
logDiv.innerHTML += `[${time}] ${message}<br>`;
logDiv.scrollTop = logDiv.scrollHeight;
console.log(message);
}
function initMap() {
log('开始初始化地图...');
map = new AMap.Map('container', {
zoom: 13,
center: [118.6762, 24.8742] // 泉州市中心
});
log('地图初始化成功');
// 初始化PlaceSearch
AMap.plugin(['AMap.PlaceSearch'], function() {
placeSearch = new AMap.PlaceSearch({
pageSize: 10,
pageIndex: 1,
city: '泉州',
citylimit: false,
extensions: 'base'
});
log('PlaceSearch插件加载成功');
log('配置信息city=泉州, citylimit=false, extensions=base');
});
}
function searchPlace() {
const keyword = document.getElementById('keyword').value.trim();
if (!keyword) {
alert('请输入搜索关键词');
return;
}
if (!placeSearch) {
log('错误PlaceSearch未初始化');
alert('地图搜索功能未就绪,请刷新页面重试');
return;
}
log(`开始搜索:${keyword}`);
placeSearch.search(keyword, function(status, result) {
log(`搜索状态:${status}`);
const resultsDiv = document.getElementById('results');
resultsDiv.innerHTML = '';
if (status === 'complete' && result.info === 'OK') {
if (result.poiList && result.poiList.pois && result.poiList.pois.length > 0) {
log(`找到 ${result.poiList.pois.length} 个结果`);
result.poiList.pois.forEach((poi, index) => {
const div = document.createElement('div');
div.className = 'result-item';
div.innerHTML = `
<strong>${index + 1}. ${poi.name}</strong><br>
地址:${poi.address || '无'}<br>
坐标:${poi.location.lng}, ${poi.location.lat}
`;
div.onclick = () => showOnMap(poi);
resultsDiv.appendChild(div);
});
} else {
log('搜索结果为空');
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
}
} else if (status === 'no_data') {
log('无搜索结果');
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
} else {
log(`搜索失败:${status} - ${result.info || '未知错误'}`);
log(`完整结果:${JSON.stringify(result)}`);
resultsDiv.innerHTML = `<p style="color: red;">搜索失败:${result.info || status}</p>`;
}
});
}
function showOnMap(poi) {
const position = [poi.location.lng, poi.location.lat];
if (marker) {
marker.setPosition(position);
} else {
marker = new AMap.Marker({
position: position,
map: map
});
}
map.setCenter(position);
map.setZoom(16);
log(`在地图上显示:${poi.name}`);
}
// 配置安全密钥(必须在加载地图前配置)
window._AMapSecurityConfig = {
securityJsCode: SECURITY_CODE
};
log(`配置安全密钥:${SECURITY_CODE}`);
// 加载高德地图脚本
const script = document.createElement('script');
script.src = `https://webapi.amap.com/maps?v=2.0&key=${AMAP_KEY}`;
script.onload = () => {
log('高德地图脚本加载成功');
initMap();
};
script.onerror = () => {
log('错误:高德地图脚本加载失败');
alert('地图加载失败请检查网络连接或API Key配置');
};
document.head.appendChild(script);
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

View File

@ -1,7 +1,7 @@
import axios from 'axios'; import axios from 'axios';
const api = axios.create({ const api = axios.create({
baseURL: '/api', baseURL: 'https://mi.xyvan.cn/api',
timeout: 10000 timeout: 10000
}); });
@ -13,6 +13,29 @@ api.interceptors.request.use(config => {
return 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 { export default {
// 活动相关 // 活动相关
getActivities: (params) => api.get('/activities', { params }), getActivities: (params) => api.get('/activities', { params }),
@ -65,5 +88,14 @@ export default {
headers: { 'Content-Type': 'multipart/form-data' }, headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000 timeout: 60000
}), }),
deleteSponsor: (id) => api.delete(`/admin/sponsors/${id}`) deleteSponsor: (id) => api.delete(`/admin/sponsors/${id}`),
addPrize: (data) => api.post('/admin/prizes', data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
updatePrize: (id, data) => api.put(`/admin/prizes/${id}`, data, {
headers: { 'Content-Type': 'multipart/form-data' },
timeout: 60000
}),
deletePrize: (id) => api.delete(`/admin/prizes/${id}`)
}; };

View File

@ -14,7 +14,12 @@ const routes = [
{ {
path: '/activity/:id', path: '/activity/:id',
name: 'ActivityDetail', name: 'ActivityDetail',
component: () => import('../views/ActivityDetail.vue') component: () => import('../views/ActivityDetail/index.vue')
},
{
path: '/danmaku-demo',
name: 'DanmakuDemo',
component: () => import('../views/ActivityDetail/DanmakuDemo.vue')
}, },
{ {
path: '/register/:id', path: '/register/:id',

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -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-315-19精美礼品 🎀<br>
第4-1010-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>

View File

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

View File

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

View File

@ -713,27 +713,25 @@ onUnmounted(() => {
} }
.sponsors-grid { .sponsors-grid {
display: flex; display: grid;
justify-content: center; grid-template-columns: repeat(3, 1fr);
align-items: stretch; gap: 20px;
gap: 16px;
margin-top: 32px; margin-top: 32px;
flex-wrap: wrap; max-width: 800px;
margin-left: auto;
margin-right: auto;
} }
.sponsor-card { .sponsor-card {
background: #fff; background: #fff;
border-radius: 12px; border-radius: 12px;
padding: 12px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
box-shadow: 0 2px 12px rgba(0,0,0,0.06); box-shadow: 0 2px 12px rgba(0,0,0,0.06);
transition: transform 0.3s, box-shadow 0.3s; transition: transform 0.3s, box-shadow 0.3s;
flex: 0 1 140px;
max-width: 150px;
min-width: 120px;
} }
.sponsor-card:hover { .sponsor-card:hover {
@ -742,12 +740,12 @@ onUnmounted(() => {
} }
.sponsor-logo { .sponsor-logo {
width: 45px; width: 60px;
height: 45px; height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 8px; margin-bottom: 12px;
} }
.sponsor-logo svg { .sponsor-logo svg {
@ -768,20 +766,30 @@ onUnmounted(() => {
} }
.sponsor-name { .sponsor-name {
font-size: 11px; font-size: 13px;
font-weight: 600; font-weight: 600;
color: #333; color: #333;
margin: 0 0 4px; margin: 0 0 6px;
line-height: 1.3; line-height: 1.4;
word-break: break-all; width: 100%;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
word-break: break-word;
} }
.sponsor-desc { .sponsor-desc {
font-size: 10px; font-size: 11px;
color: #999; color: #999;
margin: 0; margin: 0;
line-height: 1.4; line-height: 1.4;
word-break: break-all; width: 100%;
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
word-break: break-word;
} }
/* CTA区域 */ /* CTA区域 */
@ -930,28 +938,26 @@ onUnmounted(() => {
} }
.sponsors-grid { .sponsors-grid {
gap: 12px; grid-template-columns: 1fr;
gap: 16px;
} }
.sponsor-card { .sponsor-card {
flex: 0 1 calc(20% - 10px); padding: 16px;
min-width: 100px;
max-width: 130px;
padding: 10px;
} }
.sponsor-logo { .sponsor-logo {
width: 40px; width: 50px;
height: 40px; height: 50px;
margin-bottom: 6px; margin-bottom: 10px;
} }
.sponsor-name { .sponsor-name {
font-size: 10px; font-size: 12px;
} }
.sponsor-desc { .sponsor-desc {
font-size: 9px; font-size: 10px;
} }
} }
</style> </style>

View File

@ -347,7 +347,7 @@ const onSubmit = async () => {
} }
.plate-digits { .plate-digits {
width: 110px; width: 95px;
height: 28px; height: 28px;
border: 2px solid rgba(255,255,255,0.9); border: 2px solid rgba(255,255,255,0.9);
border-radius: 3px; border-radius: 3px;

View File

@ -64,6 +64,16 @@
/> />
</template> </template>
</van-cell> </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 title="活动描述" :label="activity.description" />
</van-cell-group> </van-cell-group>
</div> </div>
@ -139,7 +149,7 @@
<div class="sponsor-list"> <div class="sponsor-list">
<div v-for="sponsor in sponsors" :key="sponsor.id" class="sponsor-card"> <div v-for="sponsor in sponsors" :key="sponsor.id" class="sponsor-card">
<div class="sponsor-logo-wrapper"> <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" /> <van-icon v-if="!sponsor.logo" name="shop-o" />
</div> </div>
</div> </div>
@ -150,9 +160,12 @@
{{ sponsor.type === 'title' ? '冠名' : '赞助' }} {{ sponsor.type === 'title' ? '冠名' : '赞助' }}
</span> </span>
</div> </div>
<div v-if="sponsor.link" class="sponsor-link"> <div class="sponsor-meta">
<van-icon name="link-o" /> <span class="sponsor-sort">排序: {{ sponsor.sortOrder || 0 }}</span>
<span>{{ sponsor.link }}</span> <div v-if="sponsor.link" class="sponsor-link">
<van-icon name="link-o" />
<span>{{ sponsor.link }}</span>
</div>
</div> </div>
</div> </div>
<div class="sponsor-ops"> <div class="sponsor-ops">
@ -171,6 +184,42 @@
</div> </div>
</van-tab> </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="showPrizeForm = true">
<van-icon name="plus" />
</div>
</div>
</van-tab>
<van-tab title="报名情况" name="registrations"> <van-tab title="报名情况" name="registrations">
<div class="tab-content"> <div class="tab-content">
<!-- 统计栏 --> <!-- 统计栏 -->
@ -636,6 +685,12 @@
</van-radio-group> </van-radio-group>
</template> </template>
</van-field> </van-field>
<van-field
v-model.number="sponsorForm.sortOrder"
type="number"
label="排序"
placeholder="数字越小越靠前"
/>
<van-field v-model="sponsorForm.link" label="链接" placeholder="官网链接(选填)" /> <van-field v-model="sponsorForm.link" label="链接" placeholder="官网链接(选填)" />
<van-field name="logo" label="Logo"> <van-field name="logo" label="Logo">
<template #input> <template #input>
@ -657,6 +712,79 @@
</div> </div>
</van-popup> </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.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="level-badge" :class="`level-${level}`">{{ level }}</span>
</div>
</div>
</div>
</van-popup>
<!-- 视频播放弹窗 --> <!-- 视频播放弹窗 -->
<van-popup v-model:show="showVideoPlayer" position="center" round :style="{ width: '90%' }"> <van-popup v-model:show="showVideoPlayer" position="center" round :style="{ width: '90%' }">
<div class="video-player-popup"> <div class="video-player-popup">
@ -761,13 +889,23 @@
<script setup> <script setup>
import { ref, computed, onMounted, watch } from 'vue'; import { ref, computed, onMounted, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { showToast } from 'vant'; import { showToast, closeToast, showConfirmDialog, ImagePreview } from 'vant';
import api from '../../api'; import api from '../../api';
import { usePrizeManage } from './usePrizeManage';
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const activeTab = ref('info'); 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 activity = ref({});
const expenses = ref([]); const expenses = ref([]);
const sponsors = ref([]); const sponsors = ref([]);
@ -790,6 +928,25 @@ const activeFilter = ref('all'); // 过滤器状态
const showActivityForm = ref(false); const showActivityForm = ref(false);
const showExpenseForm = ref(false); const showExpenseForm = ref(false);
const showSponsorForm = ref(false); const showSponsorForm = ref(false);
//
const activityId = computed(() => route.params.id);
const {
prizes,
showPrizeForm,
showLevelPicker,
prizeImage,
prizeForm,
prizeLevels,
loadPrizes,
editPrize,
onPrizeImageRead,
selectLevel,
submitPrize,
deletePrizeItem,
resetPrizeForm
} = usePrizeManage(activityId);
const showMapPicker = ref(false); const showMapPicker = ref(false);
const showStartTimePicker = ref(false); const showStartTimePicker = ref(false);
const showDeadlinePicker = ref(false); const showDeadlinePicker = ref(false);
@ -852,7 +1009,8 @@ const sponsorForm = ref({
id: null, id: null,
name: '', name: '',
type: 'sponsor', type: 'sponsor',
link: '' link: '',
sortOrder: 0
}); });
const totalExpense = computed(() => { const totalExpense = computed(() => {
@ -996,6 +1154,9 @@ const loadData = async () => {
const { data: regs } = await api.getAllRegistrations({ activityId: route.params.id }); const { data: regs } = await api.getAllRegistrations({ activityId: route.params.id });
registrations.value = regs; registrations.value = regs;
//
await loadPrizes();
} catch (error) { } catch (error) {
showToast('加载失败'); showToast('加载失败');
} }
@ -1057,43 +1218,62 @@ const toggleRegistration = async (value) => {
} }
}; };
const submitExpense = async () => { const toggleDanmaku = async (value) => {
try { try {
const formData = new FormData(); await api.updateActivity(activity.value.id, { danmakuEnabled: value });
formData.append('activityId', activity.value.id); showToast(value ? '弹幕已开启' : '弹幕已关闭');
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) { } 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) => { const editExpense = (expense) => {
expenseForm.value = { expenseForm.value = {
...expense, ...expense,
@ -1113,18 +1293,18 @@ const removeExpenseMedia = (index) => {
const deleteExpenseItem = async (id) => { const deleteExpenseItem = async (id) => {
try { try {
await import('vant').then(({ showConfirmDialog }) => { await showConfirmDialog({
showConfirmDialog({ title: '确认删除',
title: '确认删除', message: '确定要删除这条经费记录吗?',
message: '确定要删除这条经费记录吗?',
}).then(async () => {
await api.deleteExpense(id);
showToast('删除成功');
loadData();
}).catch(() => {});
}); });
await api.deleteExpense(id);
showToast('删除成功');
loadData();
} catch (error) { } catch (error) {
showToast('删除失败'); //
if (error !== 'cancel') {
showToast('删除失败');
}
} }
}; };
@ -1139,6 +1319,8 @@ const submitSponsor = async () => {
console.log('表单数据:', sponsorForm.value); console.log('表单数据:', sponsorForm.value);
console.log('Logo 文件:', sponsorLogo.value); console.log('Logo 文件:', sponsorLogo.value);
let loadingToast = null;
try { try {
const formData = new FormData(); const formData = new FormData();
if (!sponsorForm.value.id) { if (!sponsorForm.value.id) {
@ -1147,6 +1329,7 @@ const submitSponsor = async () => {
formData.append('name', sponsorForm.value.name); formData.append('name', sponsorForm.value.name);
formData.append('type', sponsorForm.value.type); formData.append('type', sponsorForm.value.type);
formData.append('link', sponsorForm.value.link || ''); formData.append('link', sponsorForm.value.link || '');
formData.append('sortOrder', sponsorForm.value.sortOrder || 0);
// Logo // Logo
const hasLogo = sponsorLogo.value.length > 0 && sponsorLogo.value[0].file; const hasLogo = sponsorLogo.value.length > 0 && sponsorLogo.value[0].file;
@ -1154,7 +1337,7 @@ const submitSponsor = async () => {
if (hasLogo) { if (hasLogo) {
const logoSize = (sponsorLogo.value[0].file.size / 1024 / 1024).toFixed(2); const logoSize = (sponsorLogo.value[0].file.size / 1024 / 1024).toFixed(2);
console.log('准备上传 Logo大小:', logoSize, 'MB'); console.log('准备上传 Logo大小:', logoSize, 'MB');
showToast({ loadingToast = showToast({
type: 'loading', type: 'loading',
message: `正在上传 Logo (${logoSize}MB)...`, message: `正在上传 Logo (${logoSize}MB)...`,
forbidClick: true, forbidClick: true,
@ -1163,7 +1346,7 @@ const submitSponsor = async () => {
formData.append('logo', sponsorLogo.value[0].file); formData.append('logo', sponsorLogo.value[0].file);
} else { } else {
console.log('没有 Logo仅保存数据'); console.log('没有 Logo仅保存数据');
showToast({ loadingToast = showToast({
type: 'loading', type: 'loading',
message: '正在保存...', message: '正在保存...',
forbidClick: true, forbidClick: true,
@ -1183,10 +1366,6 @@ const submitSponsor = async () => {
const endTime = Date.now(); const endTime = Date.now();
console.log('API 调用完成,耗时:', (endTime - startTime) / 1000, '秒'); console.log('API 调用完成,耗时:', (endTime - startTime) / 1000, '秒');
// 使 closeToast Toast
const { closeToast } = await import('vant');
closeToast();
// //
showToast({ showToast({
type: 'success', type: 'success',
@ -1196,18 +1375,14 @@ const submitSponsor = async () => {
// //
showSponsorForm.value = false; showSponsorForm.value = false;
sponsorForm.value = { id: null, name: '', type: 'sponsor', link: '' }; sponsorForm.value = { id: null, name: '', type: 'sponsor', link: '', sortOrder: 0 };
sponsorLogo.value = []; sponsorLogo.value = [];
console.log('开始重新加载数据...'); console.log('开始重新加载数据...');
// //
await loadData(); loadData();
console.log('数据加载完成'); console.log('数据加载完成');
} catch (error) { } catch (error) {
// 使 closeToast Toast
const { closeToast } = await import('vant');
closeToast();
console.error('赞助商提交失败:', error); console.error('赞助商提交失败:', error);
console.error('错误详情:', error.response); console.error('错误详情:', error.response);
const errorMsg = error.response?.data?.error || error.message || '操作失败'; const errorMsg = error.response?.data?.error || error.message || '操作失败';
@ -1216,11 +1391,23 @@ const submitSponsor = async () => {
message: errorMsg, message: errorMsg,
duration: 3000 duration: 3000
}); });
} finally {
if (loadingToast) {
loadingToast.clear();
}
} }
}; };
const editSponsor = (sponsor) => { const editSponsor = (sponsor) => {
sponsorForm.value = { ...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; showSponsorForm.value = true;
}; };
@ -1234,18 +1421,18 @@ const onLogoRead = (file) => {
const deleteSponsorItem = async (id) => { const deleteSponsorItem = async (id) => {
try { try {
await import('vant').then(({ showConfirmDialog }) => { await showConfirmDialog({
showConfirmDialog({ title: '确认删除',
title: '确认删除', message: '确定要删除这个赞助商吗?',
message: '确定要删除这个赞助商吗?',
}).then(async () => {
await api.deleteSponsor(id);
showToast('删除成功');
loadData();
}).catch(() => {});
}); });
await api.deleteSponsor(id);
showToast('删除成功');
loadData();
} catch (error) { } catch (error) {
showToast('删除失败'); //
if (error !== 'cancel') {
showToast('删除失败');
}
} }
}; };
@ -1335,7 +1522,7 @@ const uploadPhotos = async (files) => {
const totalSizeMB = (totalSize / 1024 / 1024).toFixed(2); const totalSizeMB = (totalSize / 1024 / 1024).toFixed(2);
const fileCount = fileArray.length; const fileCount = fileArray.length;
showToast({ const loadingToast = showToast({
type: 'loading', type: 'loading',
message: `正在上传 ${fileCount} 个文件 (${totalSizeMB}MB)...`, message: `正在上传 ${fileCount} 个文件 (${totalSizeMB}MB)...`,
forbidClick: true, forbidClick: true,
@ -1350,10 +1537,6 @@ const uploadPhotos = async (files) => {
await api.uploadPhotos(formData); await api.uploadPhotos(formData);
// 使 closeToast Toast
const { closeToast } = await import('vant');
closeToast();
// //
showToast({ showToast({
type: 'success', type: 'success',
@ -1364,19 +1547,17 @@ const uploadPhotos = async (files) => {
// //
photoFiles.value = []; photoFiles.value = [];
// //
await loadData(); loadData();
} catch (error) { } catch (error) {
// 使 closeToast Toast
const { closeToast } = await import('vant');
closeToast();
console.error('上传失败:', error); console.error('上传失败:', error);
showToast({ showToast({
type: 'fail', type: 'fail',
message: error.response?.data?.error || '上传失败', message: error.response?.data?.error || '上传失败',
duration: 3000 duration: 3000
}); });
} finally {
loadingToast.clear();
} }
}; };
@ -1393,26 +1574,24 @@ const approvePhoto = async (id, status) => {
const deletePhoto = async (id) => { const deletePhoto = async (id) => {
try { try {
await import('vant').then(({ showConfirmDialog }) => { await showConfirmDialog({
showConfirmDialog({ title: '确认删除',
title: '确认删除', message: '删除后无法恢复,确定要删除吗?',
message: '删除后无法恢复,确定要删除吗?',
}).then(async () => {
await api.deletePhoto(id);
showToast('删除成功');
loadData();
}).catch(() => {});
}); });
await api.deletePhoto(id);
showToast('删除成功');
loadData();
} catch (error) { } catch (error) {
console.error('删除失败:', error); //
showToast('删除失败'); if (error !== 'cancel') {
console.error('删除失败:', error);
showToast('删除失败');
}
} }
}; };
const previewImage = (url) => { const previewImage = (url) => {
import('vant').then(({ ImagePreview }) => { ImagePreview([url]);
ImagePreview([url]);
});
}; };
const playVideo = (url) => { const playVideo = (url) => {
@ -1612,6 +1791,8 @@ onMounted(loadData);
</script> </script>
<style scoped> <style scoped>
@import './prize-manage-styles.css';
.activity-manage { .activity-manage {
min-height: 100vh; min-height: 100vh;
background: #f8f9fa; background: #f8f9fa;
@ -1934,6 +2115,22 @@ onMounted(loadData);
background: linear-gradient(135deg, #07c160 0%, #00d68f 100%); 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 { .sponsor-link {
display: flex; display: flex;
align-items: center; align-items: center;
@ -1941,6 +2138,7 @@ onMounted(loadData);
font-size: 12px; font-size: 12px;
color: #FF6700; color: #FF6700;
overflow: hidden; overflow: hidden;
flex: 1;
} }
.sponsor-link .van-icon { .sponsor-link .van-icon {

View File

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

View File

@ -0,0 +1,153 @@
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 prizeImage = ref([]);
const prizeForm = ref({
id: null,
activityId: activityId.value,
name: '',
level: '',
description: '',
quantity: 1,
sortOrder: 0,
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,
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 submitPrize = async () => {
if (!prizeForm.value.name || !prizeForm.value.level) {
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);
if (prizeForm.value.imageFile) {
formData.append('image', prizeForm.value.imageFile);
}
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,
image: null
};
prizeImage.value = [];
};
return {
prizes,
showPrizeForm,
showLevelPicker,
prizeImage,
prizeForm,
prizeLevels,
loadPrizes,
editPrize,
onPrizeImageRead,
selectLevel,
submitPrize,
deletePrizeItem,
resetPrizeForm
};
}

View File

@ -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-315-19精美礼品<br>
第4-1010-14纪念品<br>
参与奖5-9小礼物<br>
<br>
随后自由合影整理垃圾提醒安全驾驶返程
</div>
</div>
`
}
];

33
migrate.bat Normal file
View File

@ -0,0 +1,33 @@
@echo off
echo ==========================================
echo 数据库迁移 - 添加弹幕字段
echo ==========================================
echo.
REM 检查容器是否运行
docker ps | findstr quanyoumi-app >nul 2>&1
if errorlevel 1 (
echo 错误: quanyoumi-app 容器未运行
echo 请先启动容器: docker-compose up -d
pause
exit /b 1
)
echo 正在容器内执行数据库迁移...
docker exec quanyoumi-app node /app/backend/migrations/index.js
if errorlevel 1 (
echo.
echo 迁移失败!
pause
exit /b 1
)
echo.
echo ==========================================
echo 迁移完成!请重启容器使更改生效
echo ==========================================
echo.
echo 重启命令: docker-compose restart
echo.
pause

30
migrate.sh Normal file
View File

@ -0,0 +1,30 @@
#!/bin/bash
echo "=========================================="
echo "数据库迁移 - 添加弹幕字段"
echo "=========================================="
echo ""
# 检查容器是否运行
if ! docker ps | grep -q quanyoumi-app; then
echo "错误: quanyoumi-app 容器未运行"
echo "请先启动容器: docker-compose up -d"
exit 1
fi
echo "正在容器内执行数据库迁移..."
docker exec quanyoumi-app node /app/backend/migrations/index.js
if [ $? -ne 0 ]; then
echo ""
echo "迁移失败!"
exit 1
fi
echo ""
echo "=========================================="
echo "迁移完成!请重启容器使更改生效"
echo "=========================================="
echo ""
echo "重启命令: docker-compose restart"
echo ""

View File

@ -1,53 +0,0 @@
# 签到功能实施说明
## 已完成的修改
### 1. 数据库修改
- 创建了迁移脚本 `database-migration-checkin.sql`
- 添加了两个字段:
- `checkInStatus` (BOOLEAN) - 签到状态
- `checkInTime` (DATETIME) - 签到时间
### 2. 后端修改
- **模型更新** (`backend/models/Registration.js`)
- 添加签到字段定义
- **API接口** (`backend/routes/admin.js`)
- 新增 `PUT /admin/registrations/:id/checkin` - 签到/取消签到接口
- **前端API** (`frontend/src/api/index.js`)
- 添加 `checkInRegistration` 方法
### 3. 前端修改
- **后台管理页面** (`frontend/src/views/admin/ActivityManage.vue`)
- 统计栏新增:已签到/未签到统计(一行三个布局)
- 报名列表显示签到状态标签
- 报名列表显示签到时间
- 添加签到/取消签到按钮(仅已通过审核的用户可签到)
- 手机号改为可点击拨打的链接
- 车牌号可点击复制
## 使用步骤
### 1. 执行数据库迁移
```bash
mysql -u root -p quanyoumi < database-migration-checkin.sql
```
### 2. 重启后端服务
```bash
cd backend
npm start
```
### 3. 功能使用
- 进入后台管理 -> 活动管理 -> 报名情况
- 查看签到统计(已签到/未签到人数)
- 点击"签到"按钮为用户签到
- 点击手机号直接拨打电话
- 点击车牌号复制到剪贴板
## 注意事项
- 只有审核通过的用户才能签到
- 签到后可以取消签到
- 签到时间会自动记录