优化验证报名信息对话框车牌号输入布局,使标签和输入框在同一行显示
This commit is contained in:
parent
89960b1cb8
commit
4920620d2c
10
Dockerfile
10
Dockerfile
|
|
@ -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/
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
/**
|
||||||
|
* 迁移: 初始化数据库结构(确保所有表和字段存在)
|
||||||
|
* 版本: 0000
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 检查并创建所有必需的表和字段...');
|
||||||
|
|
||||||
|
// Activities 表的额外字段
|
||||||
|
const activityFields = [
|
||||||
|
{ name: 'registrationEnabled', sql: 'ALTER TABLE Activities ADD COLUMN registrationEnabled INTEGER DEFAULT 1;' },
|
||||||
|
{ name: 'coverImage', sql: 'ALTER TABLE Activities ADD COLUMN coverImage TEXT;' },
|
||||||
|
{ name: 'price', sql: 'ALTER TABLE Activities ADD COLUMN price DECIMAL(10, 2) DEFAULT 0;' },
|
||||||
|
{ name: 'budget', sql: 'ALTER TABLE Activities ADD COLUMN budget DECIMAL(10, 2) DEFAULT 0;' },
|
||||||
|
{ name: 'locationLat', sql: 'ALTER TABLE Activities ADD COLUMN locationLat DECIMAL(10, 7);' },
|
||||||
|
{ name: 'locationLng', sql: 'ALTER TABLE Activities ADD COLUMN locationLng DECIMAL(10, 7);' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of activityFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrations 表的额外字段
|
||||||
|
const registrationFields = [
|
||||||
|
{ name: 'peopleCount', sql: 'ALTER TABLE Registrations ADD COLUMN peopleCount INTEGER DEFAULT 1;' },
|
||||||
|
{ name: 'note', sql: 'ALTER TABLE Registrations ADD COLUMN note TEXT;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of registrationFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expenses 表的额外字段
|
||||||
|
const expenseFields = [
|
||||||
|
{ name: 'category', sql: 'ALTER TABLE Expenses ADD COLUMN category TEXT;' },
|
||||||
|
{ name: 'receipt', sql: 'ALTER TABLE Expenses ADD COLUMN receipt TEXT;' },
|
||||||
|
{ name: 'note', sql: 'ALTER TABLE Expenses ADD COLUMN note TEXT;' },
|
||||||
|
{ name: 'media', sql: 'ALTER TABLE Expenses ADD COLUMN media TEXT;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of expenseFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Photos 表的额外字段
|
||||||
|
const photoFields = [
|
||||||
|
{ name: 'description', sql: 'ALTER TABLE Photos ADD COLUMN description TEXT;' },
|
||||||
|
{ name: 'uploadedBy', sql: 'ALTER TABLE Photos ADD COLUMN uploadedBy TEXT;' },
|
||||||
|
{ name: 'status', sql: 'ALTER TABLE Photos ADD COLUMN status TEXT DEFAULT "approved";' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of photoFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sponsors 表的额外字段
|
||||||
|
const sponsorFields = [
|
||||||
|
{ name: 'logo', sql: 'ALTER TABLE Sponsors ADD COLUMN logo TEXT;' },
|
||||||
|
{ name: 'type', sql: 'ALTER TABLE Sponsors ADD COLUMN type TEXT DEFAULT "sponsor";' },
|
||||||
|
{ name: 'link', sql: 'ALTER TABLE Sponsors ADD COLUMN link TEXT;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of sponsorFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admins 表的额外字段
|
||||||
|
const adminFields = [
|
||||||
|
{ name: 'name', sql: 'ALTER TABLE Admins ADD COLUMN name TEXT;' }
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const field of adminFields) {
|
||||||
|
await manager.executeSql(field.sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(' - 初始化完成');
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 回滚不支持');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
/**
|
||||||
|
* 迁移: 添加弹幕功能字段
|
||||||
|
* 版本: 0001
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 danmakuEnabled 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Activities ADD COLUMN danmakuEnabled INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(' - 添加 danmakuContent 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Activities ADD COLUMN danmakuContent TEXT;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
// SQLite 不支持 DROP COLUMN,需要重建表
|
||||||
|
console.log(' - 回滚不支持,请手动处理');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
/**
|
||||||
|
* 迁移: 添加赞助商排序字段
|
||||||
|
* 版本: 0002
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 Sponsors.sortOrder 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Sponsors ADD COLUMN sortOrder INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 回滚不支持');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
/**
|
||||||
|
* 迁移: 添加签到功能字段
|
||||||
|
* 版本: 0003
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 Registrations.checkInStatus 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Registrations ADD COLUMN checkInStatus INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log(' - 添加 Registrations.checkInTime 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Registrations ADD COLUMN checkInTime DATETIME;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 回滚不支持');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
/**
|
||||||
|
* 迁移: 添加奖品表
|
||||||
|
* 版本: 0004
|
||||||
|
* 日期: 2025-01-10
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 创建 Prizes 表...');
|
||||||
|
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS Prizes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activityId INTEGER NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
level TEXT NOT NULL CHECK(level IN ('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼')),
|
||||||
|
description TEXT,
|
||||||
|
image TEXT,
|
||||||
|
quantity INTEGER DEFAULT 1,
|
||||||
|
sortOrder INTEGER DEFAULT 0,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updatedAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
console.log(' - 删除 Prizes 表...');
|
||||||
|
await manager.executeSql('DROP TABLE IF EXISTS Prizes;');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,190 @@
|
||||||
|
# 数据库迁移系统
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本项目使用版本化的数据库迁移系统,支持增量更新和自动同步。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- ✅ 版本管理:每个迁移有唯一版本号
|
||||||
|
- ✅ 增量执行:只执行未执行的迁移
|
||||||
|
- ✅ 自动运行:服务器启动时自动执行迁移
|
||||||
|
- ✅ 幂等性:重复执行不会出错
|
||||||
|
- ✅ 迁移记录:记录所有已执行的迁移
|
||||||
|
|
||||||
|
## 迁移文件命名规范
|
||||||
|
|
||||||
|
```
|
||||||
|
XXXX-description.js
|
||||||
|
```
|
||||||
|
|
||||||
|
- `XXXX`: 4位数字版本号(如 0001, 0002)
|
||||||
|
- `description`: 迁移描述(使用短横线分隔)
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- `0001-add-danmaku-fields.js`
|
||||||
|
- `0002-add-user-avatar.js`
|
||||||
|
|
||||||
|
## 创建新迁移
|
||||||
|
|
||||||
|
### 1. 创建迁移文件
|
||||||
|
|
||||||
|
在 `backend/migrations/` 目录下创建新文件:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
/**
|
||||||
|
* 迁移: 添加用户头像字段
|
||||||
|
* 版本: 0002
|
||||||
|
* 日期: 2025-01-09
|
||||||
|
*/
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
console.log(' - 添加 avatar 字段...');
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Users ADD COLUMN avatar TEXT;
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
async down(manager) {
|
||||||
|
// 回滚逻辑(可选)
|
||||||
|
console.log(' - 回滚不支持');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 迁移方法
|
||||||
|
|
||||||
|
#### `up(manager)` - 升级
|
||||||
|
执行数据库变更的方法。
|
||||||
|
|
||||||
|
可用的 manager 方法:
|
||||||
|
- `executeSql(sql)`: 执行SQL语句
|
||||||
|
- `db`: 直接访问数据库连接
|
||||||
|
|
||||||
|
#### `down(manager)` - 回滚(可选)
|
||||||
|
回滚数据库变更的方法。
|
||||||
|
|
||||||
|
注意:SQLite 不支持 DROP COLUMN,回滚可能需要重建表。
|
||||||
|
|
||||||
|
## 执行迁移
|
||||||
|
|
||||||
|
### 自动执行(推荐)
|
||||||
|
|
||||||
|
服务器启动时会自动执行所有未执行的迁移:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发环境
|
||||||
|
cd backend
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# 生产环境(Docker)
|
||||||
|
docker-compose up -d
|
||||||
|
# 或
|
||||||
|
docker-compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
### 手动执行
|
||||||
|
|
||||||
|
#### 在容器内执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Windows
|
||||||
|
migrate.bat
|
||||||
|
|
||||||
|
# Linux/Mac
|
||||||
|
chmod +x migrate.sh
|
||||||
|
./migrate.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 直接在容器中执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## 迁移状态
|
||||||
|
|
||||||
|
迁移执行记录保存在 `migrations` 表中:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT * FROM migrations;
|
||||||
|
```
|
||||||
|
|
||||||
|
输出示例:
|
||||||
|
```
|
||||||
|
id | version | name | executed_at
|
||||||
|
---+---------+-----------------------+---------------------
|
||||||
|
1 | 1 | add-danmaku-fields | 2025-01-09 10:30:00
|
||||||
|
2 | 2 | add-user-avatar | 2025-01-09 11:00:00
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q: 迁移失败怎么办?
|
||||||
|
|
||||||
|
A: 检查错误日志,修复问题后重新启动服务器。已执行的迁移不会重复执行。
|
||||||
|
|
||||||
|
### Q: 如何回滚迁移?
|
||||||
|
|
||||||
|
A: 目前不支持自动回滚。如需回滚,请手动修改数据库或创建新的迁移来撤销更改。
|
||||||
|
|
||||||
|
### Q: 可以修改已执行的迁移吗?
|
||||||
|
|
||||||
|
A: 不建议。已执行的迁移不应修改。如需更改,请创建新的迁移。
|
||||||
|
|
||||||
|
### Q: 迁移在什么时候执行?
|
||||||
|
|
||||||
|
A: 服务器启动时自动执行。也可以手动运行 `migrate.bat` 或 `migrate.sh`。
|
||||||
|
|
||||||
|
## 最佳实践
|
||||||
|
|
||||||
|
1. **版本号递增**:新迁移的版本号应大于所有现有迁移
|
||||||
|
2. **描述清晰**:文件名和注释应清楚说明迁移目的
|
||||||
|
3. **测试迁移**:在开发环境测试后再部署到生产环境
|
||||||
|
4. **幂等性**:迁移应该可以安全地重复执行
|
||||||
|
5. **小步迭代**:每个迁移只做一件事,便于追踪和回滚
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### 添加新字段
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
await manager.executeSql(`
|
||||||
|
ALTER TABLE Activities ADD COLUMN featured INTEGER DEFAULT 0;
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建新表
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
await manager.executeSql(`
|
||||||
|
CREATE TABLE IF NOT EXISTS Comments (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
activityId INTEGER NOT NULL,
|
||||||
|
userId INTEGER NOT NULL,
|
||||||
|
content TEXT NOT NULL,
|
||||||
|
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 修改数据
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
async up(manager) {
|
||||||
|
await manager.executeSql(`
|
||||||
|
UPDATE Activities SET status = 'completed' WHERE endTime < datetime('now');
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
@ -0,0 +1,141 @@
|
||||||
|
const sqlite3 = require('sqlite3').verbose();
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, '..', 'data', 'database.sqlite');
|
||||||
|
const migrationsDir = __dirname;
|
||||||
|
|
||||||
|
class MigrationManager {
|
||||||
|
constructor() {
|
||||||
|
this.db = new sqlite3.Database(dbPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化迁移表
|
||||||
|
async initMigrationTable() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS migrations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
version INTEGER NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
executed_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`, (err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取已执行的迁移版本
|
||||||
|
async getExecutedMigrations() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.all('SELECT version FROM migrations ORDER BY version', (err, rows) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(rows.map(r => r.version));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录迁移执行
|
||||||
|
async recordMigration(version, name) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.run(
|
||||||
|
'INSERT INTO migrations (version, name) VALUES (?, ?)',
|
||||||
|
[version, name],
|
||||||
|
(err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行SQL语句
|
||||||
|
async executeSql(sql) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.db.run(sql, (err) => {
|
||||||
|
if (err) {
|
||||||
|
// 忽略字段已存在的错误
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载所有迁移文件
|
||||||
|
loadMigrations() {
|
||||||
|
const files = fs.readdirSync(migrationsDir)
|
||||||
|
.filter(f => f.match(/^\d{4}-.*\.js$/) && f !== 'index.js')
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
return files.map(file => {
|
||||||
|
const version = parseInt(file.split('-')[0]);
|
||||||
|
const name = file.replace(/^\d{4}-/, '').replace('.js', '');
|
||||||
|
const migration = require(path.join(migrationsDir, file));
|
||||||
|
return { version, name, file, migration };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行迁移
|
||||||
|
async runMigrations() {
|
||||||
|
try {
|
||||||
|
console.log('🔄 开始数据库迁移...\n');
|
||||||
|
|
||||||
|
// 初始化迁移表
|
||||||
|
await this.initMigrationTable();
|
||||||
|
|
||||||
|
// 获取已执行的迁移
|
||||||
|
const executed = await this.getExecutedMigrations();
|
||||||
|
console.log(`📊 已执行迁移: ${executed.length} 个`);
|
||||||
|
|
||||||
|
// 加载所有迁移文件
|
||||||
|
const migrations = this.loadMigrations();
|
||||||
|
console.log(`📁 发现迁移文件: ${migrations.length} 个\n`);
|
||||||
|
|
||||||
|
// 执行未执行的迁移
|
||||||
|
let count = 0;
|
||||||
|
for (const { version, name, file, migration } of migrations) {
|
||||||
|
if (!executed.includes(version)) {
|
||||||
|
console.log(`⏳ 执行迁移 [${version}] ${name}...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migration.up(this);
|
||||||
|
await this.recordMigration(version, name);
|
||||||
|
console.log(`✅ 迁移 [${version}] ${name} 完成\n`);
|
||||||
|
count++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`❌ 迁移 [${version}] ${name} 失败:`, error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log(`⏭️ 跳过已执行的迁移 [${version}] ${name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n✨ 迁移完成!共执行 ${count} 个新迁移`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ 迁移失败:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.db.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果直接运行此文件
|
||||||
|
if (require.main === module) {
|
||||||
|
const manager = new MigrationManager();
|
||||||
|
manager.runMigrations()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch(() => process.exit(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = MigrationManager;
|
||||||
|
|
@ -38,6 +38,15 @@ const Activity = sequelize.define('Activity', {
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
defaultValue: 0,
|
defaultValue: 0,
|
||||||
comment: '活动预算'
|
comment: '活动预算'
|
||||||
|
},
|
||||||
|
danmakuEnabled: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: false,
|
||||||
|
comment: '弹幕开关'
|
||||||
|
},
|
||||||
|
danmakuContent: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
comment: '弹幕内容JSON'
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const sequelize = require('./db');
|
||||||
|
|
||||||
|
const Prize = sequelize.define('Prize', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
primaryKey: true,
|
||||||
|
autoIncrement: true
|
||||||
|
},
|
||||||
|
activityId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
level: {
|
||||||
|
type: DataTypes.ENUM('一等奖', '二等奖', '三等奖', '参与奖', '伴手礼'),
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
description: DataTypes.TEXT,
|
||||||
|
image: DataTypes.STRING,
|
||||||
|
quantity: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 1
|
||||||
|
},
|
||||||
|
sortOrder: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
comment: '排序值,数字越小越靠前'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = Prize;
|
||||||
|
|
@ -20,7 +20,12 @@ const Sponsor = sequelize.define('Sponsor', {
|
||||||
type: DataTypes.ENUM('title', 'sponsor'),
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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)');
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
44
build.bat
44
build.bat
|
|
@ -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
|
||||||
|
|
|
||||||
44
build.sh
44
build.sh
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
@echo off
|
||||||
|
echo ==========================================
|
||||||
|
echo 清理数据库文件
|
||||||
|
echo ==========================================
|
||||||
|
|
||||||
|
echo 停止容器...
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
echo 删除数据库文件...
|
||||||
|
if exist backend\data\database.sqlite (
|
||||||
|
del /f backend\data\database.sqlite
|
||||||
|
echo 数据库文件已删除
|
||||||
|
) else (
|
||||||
|
echo 数据库文件不存在
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo 清理完成!现在可以运行 start.bat 重新启动
|
||||||
|
pause
|
||||||
|
|
@ -3,7 +3,7 @@ version: '3.8'
|
||||||
services:
|
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
|
||||||
|
|
|
||||||
|
|
@ -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..."
|
||||||
|
|
|
||||||
|
|
@ -1,201 +0,0 @@
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>高德地图搜索测试</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
padding: 20px;
|
|
||||||
max-width: 800px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
#container {
|
|
||||||
width: 100%;
|
|
||||||
height: 400px;
|
|
||||||
margin: 20px 0;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
}
|
|
||||||
.search-box {
|
|
||||||
margin: 20px 0;
|
|
||||||
}
|
|
||||||
input {
|
|
||||||
padding: 10px;
|
|
||||||
width: 300px;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
button {
|
|
||||||
padding: 10px 20px;
|
|
||||||
font-size: 14px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.results {
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
.result-item {
|
|
||||||
padding: 10px;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.result-item:hover {
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
.log {
|
|
||||||
background: #f5f5f5;
|
|
||||||
padding: 10px;
|
|
||||||
margin-top: 20px;
|
|
||||||
max-height: 300px;
|
|
||||||
overflow-y: auto;
|
|
||||||
font-family: monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>高德地图搜索功能测试</h1>
|
|
||||||
|
|
||||||
<div class="search-box">
|
|
||||||
<input type="text" id="keyword" placeholder="输入地点名称,如:泉州市政府" />
|
|
||||||
<button onclick="searchPlace()">搜索</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="container"></div>
|
|
||||||
|
|
||||||
<div class="results" id="results"></div>
|
|
||||||
|
|
||||||
<div class="log" id="log"></div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// 替换为你的高德地图Key和安全密钥
|
|
||||||
const AMAP_KEY = 'fd5b77136ba34fe6b4f0f86dca7782d9';
|
|
||||||
const SECURITY_CODE = 'b2e67bb4d5eed8848f0b8aae6ca667fc';
|
|
||||||
|
|
||||||
let map = null;
|
|
||||||
let placeSearch = null;
|
|
||||||
let marker = null;
|
|
||||||
|
|
||||||
function log(message) {
|
|
||||||
const logDiv = document.getElementById('log');
|
|
||||||
const time = new Date().toLocaleTimeString();
|
|
||||||
logDiv.innerHTML += `[${time}] ${message}<br>`;
|
|
||||||
logDiv.scrollTop = logDiv.scrollHeight;
|
|
||||||
console.log(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initMap() {
|
|
||||||
log('开始初始化地图...');
|
|
||||||
|
|
||||||
map = new AMap.Map('container', {
|
|
||||||
zoom: 13,
|
|
||||||
center: [118.6762, 24.8742] // 泉州市中心
|
|
||||||
});
|
|
||||||
|
|
||||||
log('地图初始化成功');
|
|
||||||
|
|
||||||
// 初始化PlaceSearch
|
|
||||||
AMap.plugin(['AMap.PlaceSearch'], function() {
|
|
||||||
placeSearch = new AMap.PlaceSearch({
|
|
||||||
pageSize: 10,
|
|
||||||
pageIndex: 1,
|
|
||||||
city: '泉州',
|
|
||||||
citylimit: false,
|
|
||||||
extensions: 'base'
|
|
||||||
});
|
|
||||||
log('PlaceSearch插件加载成功');
|
|
||||||
log('配置信息:city=泉州, citylimit=false, extensions=base');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function searchPlace() {
|
|
||||||
const keyword = document.getElementById('keyword').value.trim();
|
|
||||||
|
|
||||||
if (!keyword) {
|
|
||||||
alert('请输入搜索关键词');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!placeSearch) {
|
|
||||||
log('错误:PlaceSearch未初始化');
|
|
||||||
alert('地图搜索功能未就绪,请刷新页面重试');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log(`开始搜索:${keyword}`);
|
|
||||||
|
|
||||||
placeSearch.search(keyword, function(status, result) {
|
|
||||||
log(`搜索状态:${status}`);
|
|
||||||
|
|
||||||
const resultsDiv = document.getElementById('results');
|
|
||||||
resultsDiv.innerHTML = '';
|
|
||||||
|
|
||||||
if (status === 'complete' && result.info === 'OK') {
|
|
||||||
if (result.poiList && result.poiList.pois && result.poiList.pois.length > 0) {
|
|
||||||
log(`找到 ${result.poiList.pois.length} 个结果`);
|
|
||||||
|
|
||||||
result.poiList.pois.forEach((poi, index) => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.className = 'result-item';
|
|
||||||
div.innerHTML = `
|
|
||||||
<strong>${index + 1}. ${poi.name}</strong><br>
|
|
||||||
地址:${poi.address || '无'}<br>
|
|
||||||
坐标:${poi.location.lng}, ${poi.location.lat}
|
|
||||||
`;
|
|
||||||
div.onclick = () => showOnMap(poi);
|
|
||||||
resultsDiv.appendChild(div);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log('搜索结果为空');
|
|
||||||
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
|
|
||||||
}
|
|
||||||
} else if (status === 'no_data') {
|
|
||||||
log('无搜索结果');
|
|
||||||
resultsDiv.innerHTML = '<p>未找到相关地点</p>';
|
|
||||||
} else {
|
|
||||||
log(`搜索失败:${status} - ${result.info || '未知错误'}`);
|
|
||||||
log(`完整结果:${JSON.stringify(result)}`);
|
|
||||||
resultsDiv.innerHTML = `<p style="color: red;">搜索失败:${result.info || status}</p>`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function showOnMap(poi) {
|
|
||||||
const position = [poi.location.lng, poi.location.lat];
|
|
||||||
|
|
||||||
if (marker) {
|
|
||||||
marker.setPosition(position);
|
|
||||||
} else {
|
|
||||||
marker = new AMap.Marker({
|
|
||||||
position: position,
|
|
||||||
map: map
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
map.setCenter(position);
|
|
||||||
map.setZoom(16);
|
|
||||||
|
|
||||||
log(`在地图上显示:${poi.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 配置安全密钥(必须在加载地图前配置)
|
|
||||||
window._AMapSecurityConfig = {
|
|
||||||
securityJsCode: SECURITY_CODE
|
|
||||||
};
|
|
||||||
|
|
||||||
log(`配置安全密钥:${SECURITY_CODE}`);
|
|
||||||
|
|
||||||
// 加载高德地图脚本
|
|
||||||
const script = document.createElement('script');
|
|
||||||
script.src = `https://webapi.amap.com/maps?v=2.0&key=${AMAP_KEY}`;
|
|
||||||
script.onload = () => {
|
|
||||||
log('高德地图脚本加载成功');
|
|
||||||
initMap();
|
|
||||||
};
|
|
||||||
script.onerror = () => {
|
|
||||||
log('错误:高德地图脚本加载失败');
|
|
||||||
alert('地图加载失败,请检查网络连接或API Key配置');
|
|
||||||
};
|
|
||||||
document.head.appendChild(script);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
|
|
@ -1,7 +1,7 @@
|
||||||
import axios from 'axios';
|
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}`)
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -0,0 +1,292 @@
|
||||||
|
<template>
|
||||||
|
<div class="danmaku-demo">
|
||||||
|
<h2>弹幕效果演示</h2>
|
||||||
|
<p class="demo-tip">鼠标悬停可暂停弹幕</p>
|
||||||
|
|
||||||
|
<div class="danmaku-container">
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in activeDanmaku"
|
||||||
|
:key="item.id"
|
||||||
|
class="danmaku-item"
|
||||||
|
:class="[`track-${item.track}`, `speed-${item.speed}`, item.type]"
|
||||||
|
:style="{ animationDelay: `${item.delay}s` }"
|
||||||
|
>
|
||||||
|
<div class="danmaku-card">
|
||||||
|
<div class="danmaku-logo">
|
||||||
|
<img v-if="item.logo" :src="item.logo" :alt="item.name" />
|
||||||
|
<span v-else class="logo-placeholder">{{ item.name.charAt(0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="danmaku-content">
|
||||||
|
<span class="sponsor-name">{{ item.name }}</span>
|
||||||
|
<span class="sponsor-action">{{ item.type === 'title' ? '冠名赞助' : '赞助了' }}</span>
|
||||||
|
<span class="sponsor-item">{{ item.item }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue';
|
||||||
|
|
||||||
|
// 模拟赞助数据
|
||||||
|
const mockSponsors = [
|
||||||
|
{ id: 1, name: '小米汽车', logo: '', type: 'title', item: '本次活动' },
|
||||||
|
{ id: 2, name: '泉州欧乐堡', logo: '', type: 'sponsor', item: '游乐园门票' },
|
||||||
|
{ id: 3, name: '德劲新能源', logo: '', type: 'sponsor', item: '充电服务' },
|
||||||
|
{ id: 4, name: '泉友米车友会', logo: '', type: 'sponsor', item: '活动组织' },
|
||||||
|
{ id: 5, name: '本地商家A', logo: '', type: 'sponsor', item: '饮料零食' },
|
||||||
|
{ id: 6, name: '本地商家B', logo: '', type: 'sponsor', item: '精美礼品' },
|
||||||
|
{ id: 7, name: '本地商家C', logo: '', type: 'title', item: '特别赞助' },
|
||||||
|
{ id: 8, name: '本地商家D', logo: '', type: 'sponsor', item: '活动道具' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeDanmaku = ref([]);
|
||||||
|
const speeds = ['slow', 'medium', 'fast'];
|
||||||
|
const tracks = [0, 1, 2, 3];
|
||||||
|
let intervalId = null;
|
||||||
|
|
||||||
|
const generateDanmaku = () => {
|
||||||
|
const sponsor = mockSponsors[Math.floor(Math.random() * mockSponsors.length)];
|
||||||
|
const track = tracks[Math.floor(Math.random() * tracks.length)];
|
||||||
|
const speed = speeds[Math.floor(Math.random() * speeds.length)];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...sponsor,
|
||||||
|
id: Date.now() + Math.random(),
|
||||||
|
track,
|
||||||
|
speed,
|
||||||
|
delay: 0
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addDanmaku = () => {
|
||||||
|
if (activeDanmaku.value.length < 12) {
|
||||||
|
activeDanmaku.value.push(generateDanmaku());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理已完成的弹幕
|
||||||
|
if (activeDanmaku.value.length > 15) {
|
||||||
|
activeDanmaku.value = activeDanmaku.value.slice(-12);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 初始添加几条弹幕
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
setTimeout(() => addDanmaku(), i * 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 定期添加新弹幕
|
||||||
|
intervalId = setInterval(addDanmaku, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (intervalId) clearInterval(intervalId);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.danmaku-demo {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
text-align: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 32px;
|
||||||
|
margin: 40px 0 10px;
|
||||||
|
text-shadow: 0 2px 10px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.demo-tip {
|
||||||
|
text-align: center;
|
||||||
|
color: rgba(255,255,255,0.9);
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-container {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 400px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item {
|
||||||
|
position: absolute;
|
||||||
|
right: -100%;
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 轨道位置 */
|
||||||
|
.track-0 { top: 20px; }
|
||||||
|
.track-1 { top: 110px; }
|
||||||
|
.track-2 { top: 200px; }
|
||||||
|
.track-3 { top: 290px; }
|
||||||
|
|
||||||
|
/* 速度变化 */
|
||||||
|
.speed-slow {
|
||||||
|
animation: danmaku-move 20s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-medium {
|
||||||
|
animation: danmaku-move 15s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.speed-fast {
|
||||||
|
animation: danmaku-move 10s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes danmaku-move {
|
||||||
|
0% {
|
||||||
|
right: -100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
5% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
right: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 50px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||||
|
transition: transform 0.3s, box-shadow 0.3s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item:hover .danmaku-card {
|
||||||
|
transform: scale(1.05);
|
||||||
|
box-shadow: 0 6px 30px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 冠名赞助样式 */
|
||||||
|
.danmaku-item.title .danmaku-card {
|
||||||
|
background: linear-gradient(135deg, rgba(255, 215, 0, 0.9) 0%, rgba(255, 165, 0, 0.9) 100%);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item.title .danmaku-content {
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 普通赞助样式 */
|
||||||
|
.danmaku-item.sponsor .danmaku-card {
|
||||||
|
background: linear-gradient(135deg, rgba(74, 144, 226, 0.9) 0%, rgba(53, 122, 189, 0.9) 100%);
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item.sponsor .danmaku-content {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-logo img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-placeholder {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-action {
|
||||||
|
opacity: 0.9;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-item {
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端适配 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.danmaku-container {
|
||||||
|
height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-0 { top: 15px; }
|
||||||
|
.track-1 { top: 85px; }
|
||||||
|
.track-2 { top: 155px; }
|
||||||
|
.track-3 { top: 225px; }
|
||||||
|
|
||||||
|
.danmaku-card {
|
||||||
|
padding: 8px 16px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-content {
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-action {
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-item {
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
<template>
|
||||||
|
<div class="sponsor-danmaku">
|
||||||
|
<div class="danmaku-item">
|
||||||
|
<div class="danmaku-card">
|
||||||
|
<div class="danmaku-avatar">
|
||||||
|
<img src="/avatars/wang.png" alt="王总" @error="handleImageError" />
|
||||||
|
</div>
|
||||||
|
<div class="danmaku-content">
|
||||||
|
<span class="thank-text">感谢</span>
|
||||||
|
<span class="sponsor-name">王总</span>
|
||||||
|
<span class="sponsor-action">赞助的</span>
|
||||||
|
<span class="sponsor-item">小米商城价值3990积分的物料</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
// 单条固定弹幕,无需额外逻辑
|
||||||
|
const handleImageError = (e) => {
|
||||||
|
// 如果图片加载失败,显示文字占位
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
e.target.parentElement.innerHTML = '<span class="avatar-placeholder">王</span>';
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sponsor-danmaku {
|
||||||
|
position: fixed;
|
||||||
|
top: 80px;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 70px;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: -100%;
|
||||||
|
animation: danmaku-move 15s linear infinite;
|
||||||
|
will-change: transform;
|
||||||
|
pointer-events: auto;
|
||||||
|
filter: drop-shadow(0 8px 30px rgba(212, 175, 55, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item:hover {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes danmaku-move {
|
||||||
|
0% {
|
||||||
|
right: -100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
5% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
95% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
right: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 35px;
|
||||||
|
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 50%, #1a1a1a 100%);
|
||||||
|
border: 2px solid #d4af37;
|
||||||
|
box-shadow:
|
||||||
|
0 0 20px rgba(212, 175, 55, 0.5),
|
||||||
|
0 0 40px rgba(212, 175, 55, 0.3),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.1),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-card::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -100%;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(212, 175, 55, 0.3), transparent);
|
||||||
|
animation: shine 3s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shine {
|
||||||
|
0% {
|
||||||
|
left: -100%;
|
||||||
|
}
|
||||||
|
50%, 100% {
|
||||||
|
left: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item:hover .danmaku-card {
|
||||||
|
transform: scale(1.1);
|
||||||
|
box-shadow:
|
||||||
|
0 0 30px rgba(212, 175, 55, 0.8),
|
||||||
|
0 0 60px rgba(212, 175, 55, 0.5),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.2),
|
||||||
|
inset 0 -1px 0 rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 3px solid #d4af37;
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(212, 175, 55, 0.8),
|
||||||
|
inset 0 0 10px rgba(0, 0, 0, 0.3);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-avatar::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -50%;
|
||||||
|
left: -50%;
|
||||||
|
width: 200%;
|
||||||
|
height: 200%;
|
||||||
|
background: linear-gradient(45deg, transparent 30%, rgba(255, 255, 255, 0.3) 50%, transparent 70%);
|
||||||
|
animation: avatar-shine 4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes avatar-shine {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-avatar img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: bold;
|
||||||
|
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thank-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #d4af37;
|
||||||
|
font-weight: 500;
|
||||||
|
text-shadow: 0 0 10px rgba(212, 175, 55, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-name {
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 17px;
|
||||||
|
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
text-shadow: 0 0 20px rgba(212, 175, 55, 0.8);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-action {
|
||||||
|
color: #c0c0c0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-item {
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: linear-gradient(135deg, #d4af37 0%, #f4e5a1 50%, #d4af37 100%);
|
||||||
|
border-radius: 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #1a1a1a;
|
||||||
|
box-shadow:
|
||||||
|
0 0 15px rgba(212, 175, 55, 0.6),
|
||||||
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sponsor-danmaku {
|
||||||
|
top: 60px;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-item {
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-card {
|
||||||
|
padding: 8px 18px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-avatar {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 2.5px solid #d4af37;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danmaku-content {
|
||||||
|
font-size: 13px;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thank-text {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-name {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-action {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-item {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,638 @@
|
||||||
|
<template>
|
||||||
|
<div class="detail">
|
||||||
|
<div class="detail-header" :style="{ backgroundImage: `url(${activity.coverImage || 'https://s1.xiaomiev.com/activity-outer-assets/0328/images/su7_ultra_20250227/pc/5.jpg'})` }">
|
||||||
|
<div class="header-overlay"></div>
|
||||||
|
<div class="header-back" @click="$router.back()">
|
||||||
|
<van-icon name="arrow-left" />
|
||||||
|
</div>
|
||||||
|
<div class="header-content">
|
||||||
|
<h1 class="activity-title">{{ activity.title }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-item">
|
||||||
|
<van-icon name="clock-o" class="info-icon" />
|
||||||
|
<div class="info-text">
|
||||||
|
<div class="info-label">活动时间</div>
|
||||||
|
<div class="info-value">{{ formatDate(activity.startTime) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item" @click="showMap">
|
||||||
|
<van-icon name="location-o" class="info-icon" />
|
||||||
|
<div class="info-text">
|
||||||
|
<div class="info-label">活动地点</div>
|
||||||
|
<div class="info-value">{{ activity.location }}</div>
|
||||||
|
</div>
|
||||||
|
<van-icon name="arrow" class="info-arrow" />
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<van-icon name="calendar-o" class="info-icon" />
|
||||||
|
<div class="info-text">
|
||||||
|
<div class="info-label">报名截止</div>
|
||||||
|
<div class="info-value">{{ formatDate(activity.registrationDeadline) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<van-icon name="gold-coin-o" class="info-icon" />
|
||||||
|
<div class="info-text">
|
||||||
|
<div class="info-label">人均费用</div>
|
||||||
|
<div class="info-value price-value">{{ activity.price > 0 ? `¥${activity.price}` : '免费' }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">活动介绍</h3>
|
||||||
|
<p class="description">{{ activity.description }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 赞助商弹幕 -->
|
||||||
|
<SponsorDanmaku v-if="activity.danmakuEnabled" />
|
||||||
|
|
||||||
|
<!-- 活动流程 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">活动流程</h3>
|
||||||
|
<div class="timeline">
|
||||||
|
<!-- 签到与集结 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(0)" :class="{ expanded: expandedTimelineIndex === 0 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">14:00 - 14:30</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 0 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">签到与集结</div>
|
||||||
|
<div class="timeline-brief">车辆有序到场,签到领卡,开启精彩活动之旅</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 0 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step-item">🚗 车辆到场 → 指引至临时停车点</div>
|
||||||
|
<div class="step-item">✍️ 签到处签名确认参与</div>
|
||||||
|
<div class="step-item">📱 扫码入群,加入活动大家庭</div>
|
||||||
|
<div class="step-item">🎫 领取专属积分卡</div>
|
||||||
|
</div>
|
||||||
|
<div class="game-rule">
|
||||||
|
<div class="rule-title">📋 积分卡说明</div>
|
||||||
|
<div class="rule-content">
|
||||||
|
• 每人领取一张积分卡(红绿双色章)<br>
|
||||||
|
• 初始积分:0分<br>
|
||||||
|
• 游戏得分范围:+1至+5分<br>
|
||||||
|
• 游戏扣分范围:-1至-5分<br>
|
||||||
|
• 最终积分可兑换精美礼品<br>
|
||||||
|
• 积分越高,礼品越丰厚
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 观赏素材拍摄 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(1)" :class="{ expanded: expandedTimelineIndex === 1 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">14:30 - 16:30</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 1 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">观赏素材拍摄</div>
|
||||||
|
<div class="timeline-brief">专业摄影团队记录精彩瞬间,留下珍贵回忆</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 1 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step-item">📸 全员车前合影 - 展示车队阵容</div>
|
||||||
|
<div class="step-item">🎯 签到墙合照 - 打卡留念</div>
|
||||||
|
<div class="step-item">🏛️ 地标合影 - 记录活动地点</div>
|
||||||
|
<div class="step-item">🎬 动态全员上车 - 拍摄车队启动瞬间</div>
|
||||||
|
<div class="step-item">🔥 点火仪式 - 引擎轰鸣</div>
|
||||||
|
<div class="step-item">✨ 拍摄"泉州"字样 - 城市印记</div>
|
||||||
|
</div>
|
||||||
|
<div class="tip-box">
|
||||||
|
💡 提示:这些素材将用于活动回顾视频制作,请积极配合拍摄
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 互动游戏时间 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(2)" :class="{ expanded: expandedTimelineIndex === 2 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">16:30 - 18:00</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 2 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">互动游戏时间</div>
|
||||||
|
<div class="timeline-brief">10+趣味游戏项目,赢取积分兑换精美礼品</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 2 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="game-list">
|
||||||
|
<div
|
||||||
|
v-for="(game, index) in timelineData[2].items"
|
||||||
|
:key="'game' + index"
|
||||||
|
class="game-card"
|
||||||
|
@click.stop="toggleGame('game' + index)"
|
||||||
|
:class="{ expanded: expandedGameId === 'game' + index, 'highlight-game': game.name.includes('最终复活') }"
|
||||||
|
>
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">{{ game.icon }} {{ game.name }}</div>
|
||||||
|
<van-icon :name="expandedGameId === 'game' + index ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'game' + index }">
|
||||||
|
<div class="game-desc" v-html="game.detail"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 落日BBQ -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(3)" :class="{ expanded: expandedTimelineIndex === 3 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">18:00 - 19:30</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 3 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">落日BBQ</div>
|
||||||
|
<div class="timeline-brief">落日余晖下品尝美食,拍摄浪漫氛围大片</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 3 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step-item">🌅 落日余晖下车辆排成一排</div>
|
||||||
|
<div class="step-item">💡 齐开大灯营造氛围</div>
|
||||||
|
<div class="step-item">📷 拍摄氛围感大片</div>
|
||||||
|
<div class="step-item">🍖 边烤肉边欣赏晚霞</div>
|
||||||
|
<div class="step-item">🎵 轻松音乐伴随美食时光</div>
|
||||||
|
</div>
|
||||||
|
<div class="tip-box">
|
||||||
|
💡 提示:分享美食照片到朋友圈并@活动官方账号可获得 <span class="score-badge">+1分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 篝火歌舞会 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(4)" :class="{ expanded: expandedTimelineIndex === 4 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">19:00 - 20:00</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 4 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">篝火歌舞会</div>
|
||||||
|
<div class="timeline-brief">5个精彩互动游戏,点燃现场欢乐气氛</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 4 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="game-list">
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire1')" :class="{ expanded: expandedGameId === 'bonfire1' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🥁 击鼓传花(10轮)</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire1' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire1' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>音乐响起时传递道具,音乐停止时持花者上台<br>
|
||||||
|
<strong>惩罚:</strong>吃柠檬或苦瓜(二选一)<br>
|
||||||
|
<strong>得分:</strong>勇敢完成+2分,拒绝挑战-3分<br>
|
||||||
|
<strong>轮数:</strong>共10轮,气氛逐渐升温
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire2')" :class="{ expanded: expandedGameId === 'bonfire2' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🤝 喊数抱团(10轮)</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire2' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire2' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>主持人喊数字,参与者按数字抱团<br>
|
||||||
|
<strong>规则:</strong>落单者淘汰,上台自我介绍(1分钟)<br>
|
||||||
|
<strong>得分:</strong>成功抱团+1分,落单但完成自我介绍+2分,拒绝-2分<br>
|
||||||
|
<strong>互动:</strong>认识新朋友的好机会!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire3')" :class="{ expanded: expandedGameId === 'bonfire3' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🎵 大合唱</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire3' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire3' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>全员参与,齐唱经典歌曲<br>
|
||||||
|
<strong>曲目:</strong>《朋友》《真心英雄》等<br>
|
||||||
|
<strong>得分:</strong>参与即可获得+1分<br>
|
||||||
|
<strong>氛围:</strong>点燃现场,凝聚团队
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire4')" :class="{ expanded: expandedGameId === 'bonfire4' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">🎭 个人才艺表演</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire4' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire4' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>自愿上台展示才艺(唱歌/跳舞/表演)<br>
|
||||||
|
<strong>准备:</strong>需提前到后台准备BGM<br>
|
||||||
|
<strong>得分:</strong>参与表演+3分,精彩表演+5分<br>
|
||||||
|
<strong>鼓励:</strong>展现自我,收获掌声!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="game-card" @click.stop="toggleGame('bonfire5')" :class="{ expanded: expandedGameId === 'bonfire5' }">
|
||||||
|
<div class="game-header">
|
||||||
|
<div class="game-title">👶 小朋友互动</div>
|
||||||
|
<van-icon :name="expandedGameId === 'bonfire5' ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="game-detail" :class="{ expanded: expandedGameId === 'bonfire5' }">
|
||||||
|
<div class="game-desc">
|
||||||
|
<strong>玩法:</strong>主持人邀请小朋友上台互动<br>
|
||||||
|
<strong>内容:</strong>简单游戏/才艺展示/问答<br>
|
||||||
|
<strong>礼品:</strong>参与即有小礼物<br>
|
||||||
|
<strong>得分:</strong>家长陪同参与+2分
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 璀璨烟火秀 -->
|
||||||
|
<div class="timeline-item">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(5)" :class="{ expanded: expandedTimelineIndex === 5 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">20:00 - 20:30</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 5 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">璀璨烟火秀</div>
|
||||||
|
<div class="timeline-brief">三重烟花盛宴,点亮夜空绽放精彩</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 5 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="flow-steps">
|
||||||
|
<div class="step-item">🎆 米兰之夜 - 大型专业烟火表演,绚丽夺目</div>
|
||||||
|
<div class="step-item">✨ 仙女棒 - 每车配发手持烟花,拍照打卡</div>
|
||||||
|
<div class="step-item">🔥 加特林压轴 - 震撼收尾,全场欢呼</div>
|
||||||
|
<div class="step-item">📸 最佳拍摄时机 - 记录璀璨瞬间</div>
|
||||||
|
</div>
|
||||||
|
<div class="tip-box">
|
||||||
|
💡 提示:拍摄烟花视频并分享可获得 <span class="score-badge">+2分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 积分兑奖 & 自由交流 -->
|
||||||
|
<div class="timeline-item timeline-item-last">
|
||||||
|
<div class="timeline-dot"></div>
|
||||||
|
<div class="timeline-content" @click="toggleTimeline(6)" :class="{ expanded: expandedTimelineIndex === 6 }">
|
||||||
|
<div class="timeline-time">
|
||||||
|
<span class="time-text">20:30 以后</span>
|
||||||
|
<van-icon :name="expandedTimelineIndex === 6 ? 'arrow-up' : 'arrow-down'" class="expand-icon" />
|
||||||
|
</div>
|
||||||
|
<div class="timeline-title">积分兑奖 & 自由交流</div>
|
||||||
|
<div class="timeline-brief">凭积分兑换精美礼品,自由合影欢乐交流</div>
|
||||||
|
<div class="timeline-detail" :class="{ expanded: expandedTimelineIndex === 6 }">
|
||||||
|
<div class="detail-content">
|
||||||
|
<div class="game-rule">
|
||||||
|
<div class="rule-title">🏆 积分兑换礼品</div>
|
||||||
|
<div class="rule-content">
|
||||||
|
<strong>积分排行榜:</strong><br>
|
||||||
|
• 第1名(20分以上):神秘大奖 🎁<br>
|
||||||
|
• 第2-3名(15-19分):精美礼品 🎀<br>
|
||||||
|
• 第4-10名(10-14分):纪念品 🎈<br>
|
||||||
|
• 参与奖(5-9分):小礼物 🎊<br>
|
||||||
|
<br>
|
||||||
|
<strong>活动收尾:</strong><br>
|
||||||
|
• 自由合影留念<br>
|
||||||
|
• 整理现场垃圾<br>
|
||||||
|
• 提醒安全驾驶返程<br>
|
||||||
|
• 期待下次相聚
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 活动经费 -->
|
||||||
|
<div v-if="expenses.length && isApproved" class="section">
|
||||||
|
<h3 class="section-title">活动经费 <span class="count">¥{{ totalExpense }}</span></h3>
|
||||||
|
<div class="expense-list">
|
||||||
|
<div v-for="expense in expenses" :key="expense.id" class="expense-item">
|
||||||
|
<div class="expense-info">
|
||||||
|
<div class="expense-name">{{ expense.item }}</div>
|
||||||
|
<div class="expense-category">{{ expense.category }}</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="expense.media && expense.media.length" class="expense-media">
|
||||||
|
<div v-for="(media, index) in expense.media" :key="index" class="expense-media-item">
|
||||||
|
<van-image
|
||||||
|
v-if="media.type === 'image'"
|
||||||
|
:src="media.url"
|
||||||
|
fit="cover"
|
||||||
|
@click="previewImage(media.url)"
|
||||||
|
/>
|
||||||
|
<div v-else class="expense-video" @click="playExpenseVideo(media.url)">
|
||||||
|
<video :src="media.url"></video>
|
||||||
|
<van-icon name="play-circle-o" class="play-icon" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="expense-amount">金额:¥{{ expense.amount }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!isApproved && hasRegistered" class="section">
|
||||||
|
<div class="permission-tip">
|
||||||
|
<van-icon name="info-o" />
|
||||||
|
<span v-if="userRegistration.status === 'pending'">您的报名正在审核中,审核通过后可查看活动经费</span>
|
||||||
|
<span v-else-if="userRegistration.status === 'rejected'">您的报名未通过审核</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 赞助商 -->
|
||||||
|
<div v-if="sponsors.length" class="section">
|
||||||
|
<h3 class="section-title">赞助商</h3>
|
||||||
|
<div class="sponsor-grid">
|
||||||
|
<div v-for="sponsor in sponsors" :key="sponsor.id" class="sponsor-card">
|
||||||
|
<div class="sponsor-logo" :style="{ backgroundImage: `url(${getImageUrl(sponsor.logo)})` }"></div>
|
||||||
|
<div class="sponsor-name">{{ sponsor.name }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 奖品礼品 -->
|
||||||
|
<div v-if="prizes.length" class="section">
|
||||||
|
<h3 class="section-title">🏆 奖品礼品</h3>
|
||||||
|
<div class="prize-list">
|
||||||
|
<div v-for="prize in prizes" :key="prize.id" class="prize-card">
|
||||||
|
<div class="prize-image" :style="prize.image ? { backgroundImage: `url(${getImageUrl(prize.image)})` } : {}">
|
||||||
|
<van-icon v-if="!prize.image" name="gift-o" size="40" color="#ccc" />
|
||||||
|
</div>
|
||||||
|
<div class="prize-content">
|
||||||
|
<div class="prize-header">
|
||||||
|
<span class="prize-level" :class="`level-${prize.level}`">{{ prize.level }}</span>
|
||||||
|
<span class="prize-name">{{ prize.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="prize.description" class="prize-desc">{{ prize.description }}</div>
|
||||||
|
<div class="prize-footer">
|
||||||
|
<span class="prize-quantity">数量: {{ prize.quantity }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 活动照片与视频 -->
|
||||||
|
<div v-if="photos.length || videos.length" class="section">
|
||||||
|
<h3 class="section-title">活动照片与视频</h3>
|
||||||
|
|
||||||
|
<div v-if="photos.length" class="media-section">
|
||||||
|
<div class="media-label">照片</div>
|
||||||
|
<div class="photo-grid">
|
||||||
|
<div v-for="photo in photos" :key="photo.id" class="photo-item" @click="previewImage(photo.url)">
|
||||||
|
<div class="photo-img" :style="{ backgroundImage: `url(${photo.url})` }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="videos.length" class="media-section">
|
||||||
|
<div class="media-label">视频</div>
|
||||||
|
<div class="video-list">
|
||||||
|
<div v-for="video in videos" :key="video.id" class="video-item">
|
||||||
|
<video :src="video.url" controls class="video-player"></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasRegistered && isApproved" class="upload-section">
|
||||||
|
<van-uploader
|
||||||
|
v-model="userMedia"
|
||||||
|
multiple
|
||||||
|
:max-count="5"
|
||||||
|
:after-read="uploadUserMedia"
|
||||||
|
accept="image/*,video/*"
|
||||||
|
>
|
||||||
|
<van-button icon="photograph" type="primary" size="small">上传照片/视频</van-button>
|
||||||
|
</van-uploader>
|
||||||
|
<div class="upload-tip">您可以上传活动照片或视频,审核后将展示给所有人</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasRegistered && !isApproved" class="upload-section">
|
||||||
|
<div class="permission-tip">
|
||||||
|
<van-icon name="info-o" />
|
||||||
|
<span v-if="userRegistration.status === 'pending'">审核通过后可上传照片/视频</span>
|
||||||
|
<span v-else-if="userRegistration.status === 'rejected'">您的报名未通过审核</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 报名名单 -->
|
||||||
|
<div class="section">
|
||||||
|
<h3 class="section-title">报名名单 <span class="count">({{ registrations.length }}人)</span></h3>
|
||||||
|
<div class="member-list">
|
||||||
|
<div v-for="reg in registrations" :key="reg.id" class="member-item">
|
||||||
|
<div class="member-avatar">{{ reg.name.charAt(0) }}</div>
|
||||||
|
<div class="member-info">
|
||||||
|
<div class="member-name">{{ reg.name }}</div>
|
||||||
|
<div class="member-car">{{ reg.carModel }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 底部操作按钮 -->
|
||||||
|
<div v-if="!hasRegistered && activity.registrationEnabled" class="footer-action">
|
||||||
|
<van-button
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
@click="$router.push(`/register/${activity.id}`)"
|
||||||
|
class="action-button"
|
||||||
|
>
|
||||||
|
<van-icon name="add-o" /> 立即报名
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
plain
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
@click="handleLogin"
|
||||||
|
class="action-button login-button"
|
||||||
|
>
|
||||||
|
<van-icon name="user-o" /> 已报名?点击登录
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!hasRegistered && !activity.registrationEnabled" class="footer-action">
|
||||||
|
<van-button type="default" block round size="large" disabled class="action-button">
|
||||||
|
<van-icon name="warning-o" /> 报名已结束
|
||||||
|
</van-button>
|
||||||
|
<van-button
|
||||||
|
plain
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
round
|
||||||
|
size="large"
|
||||||
|
@click="handleLogin"
|
||||||
|
class="action-button login-button"
|
||||||
|
>
|
||||||
|
<van-icon name="user-o" /> 已报名?点击登录
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!isApproved" class="footer-action">
|
||||||
|
<van-button type="default" block round size="large" disabled class="action-button">
|
||||||
|
<van-icon name="clock-o" />
|
||||||
|
<span v-if="userRegistration.status === 'pending'">等待审核中</span>
|
||||||
|
<span v-else>报名未通过</span>
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="footer-action">
|
||||||
|
<van-button type="success" block round size="large" disabled class="action-button">
|
||||||
|
<van-icon name="success" /> 已报名
|
||||||
|
</van-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 分享按钮 -->
|
||||||
|
<div class="share-btn" @click="handleShare">
|
||||||
|
<van-icon name="share-o" />
|
||||||
|
<span>分享</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 地图弹窗 -->
|
||||||
|
<van-popup v-model:show="showMapPopup" position="bottom" :style="{ height: '70%' }">
|
||||||
|
<div class="map-container">
|
||||||
|
<div class="map-header">
|
||||||
|
<h3>{{ activity.location }}</h3>
|
||||||
|
<van-icon name="cross" @click="showMapPopup = false" />
|
||||||
|
</div>
|
||||||
|
<div id="amap-container" style="width: 100%; height: calc(100% - 50px);"></div>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
|
||||||
|
<!-- 登录弹窗 -->
|
||||||
|
<van-dialog
|
||||||
|
v-model:show="showLoginDialog"
|
||||||
|
title="验证报名信息"
|
||||||
|
show-cancel-button
|
||||||
|
@confirm="submitLogin"
|
||||||
|
>
|
||||||
|
<div class="login-form">
|
||||||
|
<van-field
|
||||||
|
v-model="loginForm.phone"
|
||||||
|
label="手机号"
|
||||||
|
placeholder="请输入报名时的手机号"
|
||||||
|
type="tel"
|
||||||
|
maxlength="11"
|
||||||
|
/>
|
||||||
|
<van-field
|
||||||
|
label="车牌号"
|
||||||
|
>
|
||||||
|
<template #input>
|
||||||
|
<div class="license-plate-input">
|
||||||
|
<span class="plate-prefix">闽</span>
|
||||||
|
<input
|
||||||
|
v-model="carLetter"
|
||||||
|
@input="handleLetterInput"
|
||||||
|
placeholder="_"
|
||||||
|
maxlength="1"
|
||||||
|
class="plate-letter"
|
||||||
|
/>
|
||||||
|
<span class="plate-dot">·</span>
|
||||||
|
<input
|
||||||
|
v-model="carDigits"
|
||||||
|
@input="handleDigitsInput"
|
||||||
|
placeholder="______"
|
||||||
|
maxlength="6"
|
||||||
|
class="plate-digits"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</van-field>
|
||||||
|
</div>
|
||||||
|
</van-dialog>
|
||||||
|
|
||||||
|
<!-- 经费视频播放弹窗 -->
|
||||||
|
<van-popup v-model:show="showExpenseVideoPopup" position="center" round :style="{ width: '90%' }">
|
||||||
|
<div class="video-player-popup">
|
||||||
|
<video :src="currentExpenseVideo" controls autoplay class="popup-video"></video>
|
||||||
|
</div>
|
||||||
|
</van-popup>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { useActivityDetail } from './useActivityDetail';
|
||||||
|
import SponsorDanmaku from './SponsorDanmaku.vue';
|
||||||
|
import { timelineData } from '../timeline-data.js';
|
||||||
|
|
||||||
|
// 获取完整图片URL的辅助函数
|
||||||
|
const getImageUrl = (path) => {
|
||||||
|
if (!path) return '';
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
return import.meta.env.DEV ? `http://localhost:3000${path}` : `https://mi.xyvan.cn${path}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
activity,
|
||||||
|
photos,
|
||||||
|
videos,
|
||||||
|
sponsors,
|
||||||
|
prizes,
|
||||||
|
registrations,
|
||||||
|
expenses,
|
||||||
|
userMedia,
|
||||||
|
showMapPopup,
|
||||||
|
showLoginDialog,
|
||||||
|
showExpenseVideoPopup,
|
||||||
|
currentExpenseVideo,
|
||||||
|
loginForm,
|
||||||
|
carLetter,
|
||||||
|
carDigits,
|
||||||
|
userRegistration,
|
||||||
|
expandedTimelineIndex,
|
||||||
|
expandedGameId,
|
||||||
|
totalExpense,
|
||||||
|
isApproved,
|
||||||
|
hasRegistered,
|
||||||
|
formatDate,
|
||||||
|
previewImage,
|
||||||
|
playExpenseVideo,
|
||||||
|
showMap,
|
||||||
|
uploadUserMedia,
|
||||||
|
handleLogin,
|
||||||
|
handleLetterInput,
|
||||||
|
handleDigitsInput,
|
||||||
|
submitLogin,
|
||||||
|
handleShare,
|
||||||
|
toggleTimeline,
|
||||||
|
toggleGame
|
||||||
|
} = useActivityDetail();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style src="./styles.css" scoped></style>
|
||||||
|
|
@ -0,0 +1,936 @@
|
||||||
|
.detail {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f9fa;
|
||||||
|
padding-bottom: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
position: relative;
|
||||||
|
height: 280px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.6) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-back {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255,255,255,0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 20px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-content {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity-title {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: bold;
|
||||||
|
color: #fff;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
margin-top: -20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-arrow {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #999;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #FF6700;
|
||||||
|
margin-right: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-text {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-value {
|
||||||
|
color: #FF6700;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
box-shadow: 0 2px 12px rgba(0,0,0,0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title .count {
|
||||||
|
color: #FF6700;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #666;
|
||||||
|
white-space: pre-line;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline样式 */
|
||||||
|
.timeline {
|
||||||
|
position: relative;
|
||||||
|
padding-left: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 8px;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(180deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
position: relative;
|
||||||
|
padding-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item-last {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
position: absolute;
|
||||||
|
left: -28px;
|
||||||
|
top: 4px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
border: 3px solid #FF6700;
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 103, 0, 0.1);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content.expanded {
|
||||||
|
box-shadow: 0 4px 12px rgba(255, 103, 0, 0.15);
|
||||||
|
border-color: #FF6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #FF6700;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time::before {
|
||||||
|
content: '🕐';
|
||||||
|
margin-right: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-text {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expand-icon {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #999;
|
||||||
|
transition: transform 0.3s;
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-brief {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-detail {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-detail.expanded {
|
||||||
|
max-height: 3000px;
|
||||||
|
transition: max-height 0.5s ease-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-content {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flow-steps {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
padding: 8px 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 3px solid #FF6700;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip-box {
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: #fff7e6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #ff9800;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-rule {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
border-left: 3px solid #FF6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rule-content {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 游戏卡片样式 */
|
||||||
|
.game-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card {
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card.expanded {
|
||||||
|
border-color: #FF6700;
|
||||||
|
background: #fff7e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-card.highlight-game {
|
||||||
|
background: linear-gradient(135deg, #fff7e6 0%, #fff 100%);
|
||||||
|
border-color: #FF6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-detail {
|
||||||
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-out;
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-detail.expanded {
|
||||||
|
max-height: 500px;
|
||||||
|
transition: max-height 0.5s ease-in;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.8;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 其他组件样式 */
|
||||||
|
.sponsor-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-logo {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin: 0 auto 10px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-item {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-label {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #666;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-item {
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 300px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-avatar {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-name {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.member-car {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-name {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-category {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #FF6700;
|
||||||
|
background: #fff7e6;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-media {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-media-item {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f5f5f5;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-media-item:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-video {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-video video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-video .play-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-size: 28px;
|
||||||
|
color: #fff;
|
||||||
|
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
.expense-amount {
|
||||||
|
font-size: 15px;
|
||||||
|
color: #FF6700;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: right;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px dashed #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-tip {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 底部按钮和弹窗样式 */
|
||||||
|
.footer-action {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 -2px 12px rgba(0,0,0,0.08);
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button {
|
||||||
|
height: 48px;
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-button .van-icon {
|
||||||
|
margin-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-button {
|
||||||
|
margin-top: 0;
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%) !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 16px;
|
||||||
|
right: 16px;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: rgba(255,255,255,0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
color: #FF6700;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-container {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-header .van-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 车牌号字段样式优化 - 标签和输入框同行显示 */
|
||||||
|
.login-form .van-field {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .van-field__label {
|
||||||
|
width: 60px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .van-field__control {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-form .van-field__control--custom {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-tip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: #fff7e6;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #ff9800;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.permission-tip .van-icon {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-player-popup {
|
||||||
|
padding: 20px;
|
||||||
|
background: #000;
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-video {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 车牌输入样式 */
|
||||||
|
.license-plate-input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.3);
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate-prefix {
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 15px;
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate-letter {
|
||||||
|
width: 20px;
|
||||||
|
height: 26px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.9);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
outline: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate-dot {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate-digits {
|
||||||
|
width: 95px;
|
||||||
|
height: 26px;
|
||||||
|
border: 2px solid rgba(255,255,255,0.9);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #333;
|
||||||
|
outline: none;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plate-letter::placeholder,
|
||||||
|
.plate-digits::placeholder {
|
||||||
|
color: #ddd;
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 奖品礼品样式 */
|
||||||
|
.prize-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
background: linear-gradient(135deg, #fff 0%, #fffbf5 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #ffe8cc;
|
||||||
|
box-shadow: 0 2px 8px rgba(255, 103, 0, 0.08);
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-image {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 2px solid #ffe8cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-一等奖 {
|
||||||
|
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(255, 215, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-二等奖 {
|
||||||
|
background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(192, 192, 192, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-三等奖 {
|
||||||
|
background: linear-gradient(135deg, #CD7F32 0%, #B8733E 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(205, 127, 50, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-参与奖 {
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(255, 103, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-伴手礼 {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(240, 147, 251, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.4;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
overflow: hidden;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 6px;
|
||||||
|
border-top: 1px dashed #ffe8cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-quantity {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #FF6700;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,256 @@
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
import { showImagePreview, showToast } from 'vant';
|
||||||
|
import api from '../../api';
|
||||||
|
|
||||||
|
export function useActivityDetail() {
|
||||||
|
const route = useRoute();
|
||||||
|
const activity = ref({});
|
||||||
|
const photos = ref([]);
|
||||||
|
const videos = ref([]);
|
||||||
|
const sponsors = ref([]);
|
||||||
|
const prizes = ref([]);
|
||||||
|
const registrations = ref([]);
|
||||||
|
const expenses = ref([]);
|
||||||
|
const userMedia = ref([]);
|
||||||
|
const showMapPopup = ref(false);
|
||||||
|
const showLoginDialog = ref(false);
|
||||||
|
const showExpenseVideoPopup = ref(false);
|
||||||
|
const currentExpenseVideo = ref('');
|
||||||
|
const loginForm = ref({ phone: '', carNumber: '' });
|
||||||
|
const expandedTimelineIndex = ref(null);
|
||||||
|
const expandedGameId = ref(null);
|
||||||
|
const carLetter = ref('');
|
||||||
|
const carDigits = ref('');
|
||||||
|
const userRegistration = ref(null);
|
||||||
|
let map = null;
|
||||||
|
|
||||||
|
const handleLetterInput = (e) => {
|
||||||
|
const value = e.target.value.toUpperCase().replace(/[^A-Z]/g, '');
|
||||||
|
carLetter.value = value;
|
||||||
|
loginForm.value.carNumber = value + carDigits.value;
|
||||||
|
|
||||||
|
if (value.length === 1) {
|
||||||
|
e.target.nextElementSibling?.nextElementSibling?.focus();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDigitsInput = (e) => {
|
||||||
|
const value = e.target.value.toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||||
|
carDigits.value = value;
|
||||||
|
loginForm.value.carNumber = carLetter.value + value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleTimeline = (index) => {
|
||||||
|
expandedTimelineIndex.value = expandedTimelineIndex.value === index ? null : index;
|
||||||
|
expandedGameId.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleGame = (gameId) => {
|
||||||
|
expandedGameId.value = expandedGameId.value === gameId ? null : gameId;
|
||||||
|
};
|
||||||
|
|
||||||
|
const totalExpense = computed(() => {
|
||||||
|
return expenses.value.reduce((sum, e) => sum + parseFloat(e.amount), 0).toFixed(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isApproved = computed(() => {
|
||||||
|
return userRegistration.value && userRegistration.value.status === 'approved';
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasRegistered = computed(() => {
|
||||||
|
return userRegistration.value !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
const { data } = await api.getActivity(route.params.id);
|
||||||
|
activity.value = data.activity;
|
||||||
|
|
||||||
|
const allMedia = data.photos || [];
|
||||||
|
photos.value = allMedia.filter(m => m.type === 'image');
|
||||||
|
videos.value = allMedia.filter(m => m.type === 'video');
|
||||||
|
|
||||||
|
sponsors.value = data.sponsors;
|
||||||
|
prizes.value = data.prizes || [];
|
||||||
|
expenses.value = data.expenses || [];
|
||||||
|
|
||||||
|
const { data: regs } = await api.getActivityRegistrations(route.params.id);
|
||||||
|
registrations.value = regs;
|
||||||
|
|
||||||
|
const savedReg = localStorage.getItem(`user_reg_${route.params.id}`);
|
||||||
|
if (savedReg) {
|
||||||
|
userRegistration.value = JSON.parse(savedReg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (date) => {
|
||||||
|
return new Date(date).toLocaleString('zh-CN');
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewImage = (url) => {
|
||||||
|
showImagePreview([url]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const playExpenseVideo = (url) => {
|
||||||
|
currentExpenseVideo.value = url;
|
||||||
|
showExpenseVideoPopup.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showMap = () => {
|
||||||
|
if (!activity.value.locationLat || !activity.value.locationLng) {
|
||||||
|
showToast('暂无地图信息');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showMapPopup.value = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
initMap();
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const initMap = async () => {
|
||||||
|
try {
|
||||||
|
const AMap = await window.loadAMap();
|
||||||
|
|
||||||
|
if (map) {
|
||||||
|
map.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = [activity.value.locationLng, activity.value.locationLat];
|
||||||
|
map = new AMap.Map('amap-container', { zoom: 15, center });
|
||||||
|
new AMap.Marker({ position: center, map, title: activity.value.location });
|
||||||
|
} catch (e) {
|
||||||
|
console.error('地图加载失败:', e);
|
||||||
|
showToast('地图加载失败: ' + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const uploadUserMedia = async (files) => {
|
||||||
|
if (!isApproved.value) {
|
||||||
|
showToast('请先登录并等待审核通过');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadingToast = showToast({
|
||||||
|
type: 'loading',
|
||||||
|
message: '上传中...',
|
||||||
|
forbidClick: true,
|
||||||
|
duration: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('uploadedBy', userRegistration.value.name);
|
||||||
|
|
||||||
|
if (Array.isArray(files)) {
|
||||||
|
files.forEach(file => formData.append('photos', file.file));
|
||||||
|
} else {
|
||||||
|
formData.append('photos', files.file);
|
||||||
|
}
|
||||||
|
|
||||||
|
await api.uploadUserPhotos(route.params.id, formData);
|
||||||
|
showToast('上传成功,等待审核');
|
||||||
|
userMedia.value = [];
|
||||||
|
} catch (error) {
|
||||||
|
showToast('上传失败');
|
||||||
|
} finally {
|
||||||
|
loadingToast.clear();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogin = () => {
|
||||||
|
showLoginDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitLogin = async () => {
|
||||||
|
if (!loginForm.value.phone || !loginForm.value.carNumber) {
|
||||||
|
showToast('请输入手机号和车牌号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const carNumberWithPrefix = `闽${loginForm.value.carNumber}`;
|
||||||
|
|
||||||
|
const { data } = await api.verifyUser(route.params.id, {
|
||||||
|
phone: loginForm.value.phone,
|
||||||
|
carNumber: carNumberWithPrefix
|
||||||
|
});
|
||||||
|
|
||||||
|
userRegistration.value = data.registration;
|
||||||
|
localStorage.setItem(`user_reg_${route.params.id}`, JSON.stringify(data.registration));
|
||||||
|
|
||||||
|
showLoginDialog.value = false;
|
||||||
|
|
||||||
|
if (data.registration.status === 'approved') {
|
||||||
|
showToast('登录成功');
|
||||||
|
} else if (data.registration.status === 'pending') {
|
||||||
|
showToast('您的报名正在审核中');
|
||||||
|
} else {
|
||||||
|
showToast('您的报名未通过审核');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast(error.response?.data?.error || '验证失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShare = () => {
|
||||||
|
const shareUrl = `${window.location.origin}/share/${route.params.id}`;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
navigator.share({
|
||||||
|
title: activity.value.title,
|
||||||
|
text: `${activity.value.title} - 泉友米车友会`,
|
||||||
|
url: shareUrl
|
||||||
|
}).catch(() => {
|
||||||
|
copyShareLink(shareUrl);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
copyShareLink(shareUrl);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyShareLink = (url) => {
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
showToast('分享链接已复制');
|
||||||
|
}).catch(() => {
|
||||||
|
showToast('复制失败,请手动复制:' + url);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadData);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activity,
|
||||||
|
photos,
|
||||||
|
videos,
|
||||||
|
sponsors,
|
||||||
|
prizes,
|
||||||
|
registrations,
|
||||||
|
expenses,
|
||||||
|
userMedia,
|
||||||
|
showMapPopup,
|
||||||
|
showLoginDialog,
|
||||||
|
showExpenseVideoPopup,
|
||||||
|
currentExpenseVideo,
|
||||||
|
loginForm,
|
||||||
|
carLetter,
|
||||||
|
carDigits,
|
||||||
|
userRegistration,
|
||||||
|
expandedTimelineIndex,
|
||||||
|
expandedGameId,
|
||||||
|
totalExpense,
|
||||||
|
isApproved,
|
||||||
|
hasRegistered,
|
||||||
|
formatDate,
|
||||||
|
previewImage,
|
||||||
|
playExpenseVideo,
|
||||||
|
showMap,
|
||||||
|
uploadUserMedia,
|
||||||
|
handleLogin,
|
||||||
|
handleLetterInput,
|
||||||
|
handleDigitsInput,
|
||||||
|
submitLogin,
|
||||||
|
handleShare,
|
||||||
|
toggleTimeline,
|
||||||
|
toggleGame
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -713,27 +713,25 @@ onUnmounted(() => {
|
||||||
}
|
}
|
||||||
|
|
||||||
.sponsors-grid {
|
.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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,179 @@
|
||||||
|
/* 奖品管理样式 */
|
||||||
|
.prize-manage-card {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid #f0f0f0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-manage-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-image-wrapper {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-manage-card .prize-image {
|
||||||
|
width: 70px;
|
||||||
|
height: 70px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid #e8e8e8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-details {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-details .prize-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-一等奖 {
|
||||||
|
background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-二等奖 {
|
||||||
|
background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-三等奖 {
|
||||||
|
background: linear-gradient(135deg, #CD7F32 0%, #B8733E 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-参与奖 {
|
||||||
|
background: linear-gradient(135deg, #FF6700 0%, #FF8533 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-level-badge.level-伴手礼 {
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-details .prize-name {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-desc-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-quantity-text {
|
||||||
|
color: #FF6700;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-ops {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-ops .van-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prize-ops .van-icon:active {
|
||||||
|
color: #FF6700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 等级选择器 */
|
||||||
|
.level-picker {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-picker .picker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-picker .picker-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-picker .picker-header .van-icon {
|
||||||
|
font-size: 20px;
|
||||||
|
color: #999;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-item {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-item:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
background: #fff7e6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.level-item .level-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 20px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,417 @@
|
||||||
|
// 活动流程数据
|
||||||
|
export const timelineData = [
|
||||||
|
{
|
||||||
|
title: '签到与集结',
|
||||||
|
time: '14:00 - 14:30',
|
||||||
|
brief: '车辆到场、签到登记、领取积分卡',
|
||||||
|
overview: `
|
||||||
|
<div class="overview-text">
|
||||||
|
<p>📍 车辆到达指定地点后,工作人员将引导至临时停车点</p>
|
||||||
|
<p>✍️ 前往签到处签名确认,扫码加入活动群</p>
|
||||||
|
<p>🎫 领取专属积分卡,开启精彩旅程</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
content: `
|
||||||
|
<div class="game-rule">
|
||||||
|
<div class="rule-title">📋 积分卡说明</div>
|
||||||
|
<div class="rule-content">
|
||||||
|
• 每人领取一张积分卡(红绿双色章)<br>
|
||||||
|
• 初始积分:0分<br>
|
||||||
|
• 游戏得分范围:1~5分<br>
|
||||||
|
• 游戏扣分范围:1~5分<br>
|
||||||
|
• 最终积分可兑换礼品
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '观赏素材拍摄',
|
||||||
|
time: '14:30 - 16:30',
|
||||||
|
brief: '多点位打卡拍照,记录精彩瞬间',
|
||||||
|
overview: `
|
||||||
|
<div class="overview-text">
|
||||||
|
<p>📸 专业摄影师带队,前往多个精选点位</p>
|
||||||
|
<p>🚗 全员车前合影、签到墙打卡、地标合照</p>
|
||||||
|
<p>✨ 完成所有点位拍照可获得 <span class="score-badge">+2分</span></p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: '🏛️',
|
||||||
|
name: '宝船门口',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>拍摄内容:</strong></p>
|
||||||
|
<p>• 全员车前合影</p>
|
||||||
|
<p>• 签到墙合照</p>
|
||||||
|
<p>• 个人打卡照</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🌉',
|
||||||
|
name: '隧道三点位',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>拍摄内容:</strong></p>
|
||||||
|
<p>• 地标合影</p>
|
||||||
|
<p>• 动态全员上车</p>
|
||||||
|
<p>• 点火瞬间</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎨',
|
||||||
|
name: '特色造型',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>拍摄内容:</strong></p>
|
||||||
|
<p>• 摆"泉州"两个字造型</p>
|
||||||
|
<p>• 创意合影</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '互动游戏时间',
|
||||||
|
time: '16:30 - 18:00',
|
||||||
|
brief: '10+趣味游戏项目,赢取积分',
|
||||||
|
overview: `
|
||||||
|
<div class="overview-text">
|
||||||
|
<p>🎮 精心准备10+款趣味游戏</p>
|
||||||
|
<p>🏆 每个游戏都有积分奖励,表现越好得分越高</p>
|
||||||
|
<p>⚡ 特别设置复活挑战赛,给你翻盘机会</p>
|
||||||
|
<p class="tip-text">💡 点击下方游戏查看详细规则和得分标准</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: '🎯',
|
||||||
|
name: '套圈圈',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>站在指定位置,用圈圈套中目标物品</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 套中1个:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 套中3个:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 套中5个:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🏺',
|
||||||
|
name: '投壶',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>将箭矢投入壶中,考验准确度</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 投中3个:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 投中5个:<span class="score-badge">+4分</span></p>
|
||||||
|
<p>• 全部投中:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎲',
|
||||||
|
name: '摇骰子(067玩法)',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>玩家与庄家对战,比拼骰子点数大小</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 玩家赢庄家:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 玩家输庄家:<span class="score-badge">-1分</span></p>
|
||||||
|
<p>• 摇出5个正豹子:<span class="score-badge">+5分</span>(直接判赢)</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎲',
|
||||||
|
name: '摇骰子(单骰比大小)',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>单个骰子比大小,玩家与庄家对战</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 玩家赢庄家:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 玩家输庄家:<span class="score-badge">-1分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🐂',
|
||||||
|
name: '牛牛',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>扑克牌牛牛游戏,与对手比拼牌型大小</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 赢一局:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 连赢3局:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 连赢5局:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '✊',
|
||||||
|
name: '闽南划拳(两拳倒)',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>闽南传统划拳游戏,两拳倒玩法</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 玩家赢:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 玩家输:<span class="score-badge">-1分</span></p>
|
||||||
|
<p>• 一人一拳投资,赢的玩家:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 最高叠加投资到划5拳,赢的玩家:<span class="score-badge">+5分</span></p>
|
||||||
|
<p class="highlight-tip">💡 投资机制:可叠加投资,赢得越多分数越高!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '⚽',
|
||||||
|
name: '足球射门',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>踢足球射门,进球数决定得分</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 进1球:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 进3球:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 进5球:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎮',
|
||||||
|
name: '王者荣耀',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>组队进行王者荣耀对战</p>
|
||||||
|
<p><strong>5V5模式(凑齐10人):</strong></p>
|
||||||
|
<p>• 胜方队员(4人):<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 胜方MVP(评分最高):<span class="score-badge">+5分</span></p>
|
||||||
|
<p>• 败方MVP(评分最高):<span class="score-badge">+1分</span></p>
|
||||||
|
<p><strong>SOLO模式(1V1):</strong></p>
|
||||||
|
<p>• 胜者(一血或一塔):<span class="score-badge">+2分</span></p>
|
||||||
|
<p class="highlight-tip">💡 团队配合最重要,选好英雄!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '💧',
|
||||||
|
name: '抛水瓶',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>抛水瓶使其立住,考验技巧</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 立住1个:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 立住3个:<span class="score-badge">+4分</span></p>
|
||||||
|
<p>• 立住5个:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎴',
|
||||||
|
name: '扑克游戏',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>多种扑克玩法,赢家得分</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 赢家:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 输家:<span class="score-badge">-1分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🐊',
|
||||||
|
name: '鳄鱼牙齿/插剑玩具',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>按压鳄鱼牙齿或插剑,避免触发机关</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 按/插过半数:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 每多按/插一个:<span class="score-badge">+1分</span>(累加)</p>
|
||||||
|
<p>• 最高封顶:<span class="score-badge">+5分</span></p>
|
||||||
|
<p>• 触发机关(爆了):<span class="score-badge">积分清0</span></p>
|
||||||
|
<p class="highlight-tip">💡 胆大心细,步步为营!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🪑',
|
||||||
|
name: '最终复活挑战 - 抢椅子',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail highlight-sub">
|
||||||
|
<p><strong>游戏规则:</strong>音乐停止时抢座位,未抢到者淘汰</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 冠军:<span class="score-badge">+5分</span></p>
|
||||||
|
<p>• 亚军:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 季军:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 淘汰:<span class="score-badge">-1分</span></p>
|
||||||
|
<p class="highlight-tip">⚡ 这是翻盘的最佳机会!</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '落日BBQ',
|
||||||
|
time: '18:00 - 19:30',
|
||||||
|
brief: '落日余晖下享受美食,拍摄氛围大片',
|
||||||
|
overview: `
|
||||||
|
<div class="overview-text">
|
||||||
|
<p>🌅 在落日余晖下,车辆排成一排</p>
|
||||||
|
<p>🚗 齐开大灯,营造浪漫氛围</p>
|
||||||
|
<p>🍖 边烤肉边欣赏晚霞,拍摄氛围感大片</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
content: `
|
||||||
|
<div class="game-rule">
|
||||||
|
<div class="rule-content">
|
||||||
|
💡 提示:分享美食照片到朋友圈并@活动官方账号可获得 <span class="score-badge">+1分</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '篝火歌舞会',
|
||||||
|
time: '19:00 - 20:00',
|
||||||
|
brief: '围炉夜话,互动游戏,才艺表演',
|
||||||
|
overview: `
|
||||||
|
<div class="overview-text">
|
||||||
|
<p>🔥 点燃篝火,围坐一圈</p>
|
||||||
|
<p>🎤 击鼓传花、喊数抱团等互动游戏</p>
|
||||||
|
<p>🎭 才艺表演,展示个人魅力</p>
|
||||||
|
<p class="tip-text">💡 点击下方查看各个游戏详细规则</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: '🥁',
|
||||||
|
name: '击鼓传花(10轮)',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>音乐停止时持花者上台,吃柠檬或苦瓜</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 勇敢完成:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 拒绝挑战:<span class="score-badge">-3分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🤝',
|
||||||
|
name: '喊数抱团(10轮)',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>游戏规则:</strong>主持人喊数字,参与者按数字抱团,落单者淘汰</p>
|
||||||
|
<p><strong>惩罚:</strong>上台自我介绍(1分钟)</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 成功抱团:<span class="score-badge">+1分</span></p>
|
||||||
|
<p>• 落单但完成自我介绍:<span class="score-badge">+2分</span></p>
|
||||||
|
<p>• 拒绝:<span class="score-badge">-2分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎵',
|
||||||
|
name: '大合唱',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>活动内容:</strong>全员参与,气氛热烈</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 每人:<span class="score-badge">+1分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🎭',
|
||||||
|
name: '个人才艺表演',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>活动内容:</strong>自愿上台唱歌、跳舞、表演才艺</p>
|
||||||
|
<p><strong>提示:</strong>需提前到后台准备BGM</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 参与表演:<span class="score-badge">+3分</span></p>
|
||||||
|
<p>• 精彩表演:<span class="score-badge">+5分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '👶',
|
||||||
|
name: '小朋友互动',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>活动内容:</strong>主持人邀请小朋友上台互动,参与即有礼物</p>
|
||||||
|
<p><strong>得分标准:</strong></p>
|
||||||
|
<p>• 家长陪同参与:<span class="score-badge">+2分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '璀璨烟火秀',
|
||||||
|
time: '20:00 - 20:30',
|
||||||
|
brief: '专业烟火表演,点亮夜空',
|
||||||
|
overview: `
|
||||||
|
<div class="overview-text">
|
||||||
|
<p>🎆 大型专业烟火表演</p>
|
||||||
|
<p>✨ 每车配发手持仙女棒</p>
|
||||||
|
<p>🔥 加特林压轴,震撼收尾</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
icon: '🌃',
|
||||||
|
name: '米兰之夜',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>表演内容:</strong>大型专业烟火表演,点亮夜空</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '✨',
|
||||||
|
name: '仙女棒',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>活动内容:</strong>每车配发手持烟花,拍照打卡</p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: '🔥',
|
||||||
|
name: '加特林压轴',
|
||||||
|
detail: `
|
||||||
|
<div class="sub-detail">
|
||||||
|
<p><strong>表演内容:</strong>震撼收尾,全场欢呼</p>
|
||||||
|
<p class="highlight-tip">💡 拍摄烟花视频并分享可获得 <span class="score-badge">+2分</span></p>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '积分兑奖 & 自由交流',
|
||||||
|
time: '20:30 以后',
|
||||||
|
brief: '积分兑换礼品,自由合影交流',
|
||||||
|
overview: `
|
||||||
|
<div class="overview-text">
|
||||||
|
<p>🏆 根据积分排行榜兑换礼品</p>
|
||||||
|
<p>📸 自由合影,交流心得</p>
|
||||||
|
<p>🚗 整理垃圾,安全驾驶返程</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
content: `
|
||||||
|
<div class="game-rule">
|
||||||
|
<div class="rule-title">🏆 积分兑换礼品</div>
|
||||||
|
<div class="rule-content">
|
||||||
|
<strong>积分排行榜:</strong><br>
|
||||||
|
• 第1名(20分以上):神秘大奖<br>
|
||||||
|
• 第2-3名(15-19分):精美礼品<br>
|
||||||
|
• 第4-10名(10-14分):纪念品<br>
|
||||||
|
• 参与奖(5-9分):小礼物<br>
|
||||||
|
<br>
|
||||||
|
随后自由合影、整理垃圾、提醒安全驾驶返程
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
@echo off
|
||||||
|
echo ==========================================
|
||||||
|
echo 数据库迁移 - 添加弹幕字段
|
||||||
|
echo ==========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
REM 检查容器是否运行
|
||||||
|
docker ps | findstr quanyoumi-app >nul 2>&1
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo 错误: quanyoumi-app 容器未运行
|
||||||
|
echo 请先启动容器: docker-compose up -d
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo 正在容器内执行数据库迁移...
|
||||||
|
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||||
|
|
||||||
|
if errorlevel 1 (
|
||||||
|
echo.
|
||||||
|
echo 迁移失败!
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ==========================================
|
||||||
|
echo 迁移完成!请重启容器使更改生效
|
||||||
|
echo ==========================================
|
||||||
|
echo.
|
||||||
|
echo 重启命令: docker-compose restart
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
echo "=========================================="
|
||||||
|
echo "数据库迁移 - 添加弹幕字段"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# 检查容器是否运行
|
||||||
|
if ! docker ps | grep -q quanyoumi-app; then
|
||||||
|
echo "错误: quanyoumi-app 容器未运行"
|
||||||
|
echo "请先启动容器: docker-compose up -d"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "正在容器内执行数据库迁移..."
|
||||||
|
docker exec quanyoumi-app node /app/backend/migrations/index.js
|
||||||
|
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "迁移失败!"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=========================================="
|
||||||
|
echo "迁移完成!请重启容器使更改生效"
|
||||||
|
echo "=========================================="
|
||||||
|
echo ""
|
||||||
|
echo "重启命令: docker-compose restart"
|
||||||
|
echo ""
|
||||||
53
签到功能说明.md
53
签到功能说明.md
|
|
@ -1,53 +0,0 @@
|
||||||
# 签到功能实施说明
|
|
||||||
|
|
||||||
## 已完成的修改
|
|
||||||
|
|
||||||
### 1. 数据库修改
|
|
||||||
- 创建了迁移脚本 `database-migration-checkin.sql`
|
|
||||||
- 添加了两个字段:
|
|
||||||
- `checkInStatus` (BOOLEAN) - 签到状态
|
|
||||||
- `checkInTime` (DATETIME) - 签到时间
|
|
||||||
|
|
||||||
### 2. 后端修改
|
|
||||||
- **模型更新** (`backend/models/Registration.js`)
|
|
||||||
- 添加签到字段定义
|
|
||||||
|
|
||||||
- **API接口** (`backend/routes/admin.js`)
|
|
||||||
- 新增 `PUT /admin/registrations/:id/checkin` - 签到/取消签到接口
|
|
||||||
|
|
||||||
- **前端API** (`frontend/src/api/index.js`)
|
|
||||||
- 添加 `checkInRegistration` 方法
|
|
||||||
|
|
||||||
### 3. 前端修改
|
|
||||||
- **后台管理页面** (`frontend/src/views/admin/ActivityManage.vue`)
|
|
||||||
- 统计栏新增:已签到/未签到统计(一行三个布局)
|
|
||||||
- 报名列表显示签到状态标签
|
|
||||||
- 报名列表显示签到时间
|
|
||||||
- 添加签到/取消签到按钮(仅已通过审核的用户可签到)
|
|
||||||
- 手机号改为可点击拨打的链接
|
|
||||||
- 车牌号可点击复制
|
|
||||||
|
|
||||||
## 使用步骤
|
|
||||||
|
|
||||||
### 1. 执行数据库迁移
|
|
||||||
```bash
|
|
||||||
mysql -u root -p quanyoumi < database-migration-checkin.sql
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 重启后端服务
|
|
||||||
```bash
|
|
||||||
cd backend
|
|
||||||
npm start
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 功能使用
|
|
||||||
- 进入后台管理 -> 活动管理 -> 报名情况
|
|
||||||
- 查看签到统计(已签到/未签到人数)
|
|
||||||
- 点击"签到"按钮为用户签到
|
|
||||||
- 点击手机号直接拨打电话
|
|
||||||
- 点击车牌号复制到剪贴板
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
- 只有审核通过的用户才能签到
|
|
||||||
- 签到后可以取消签到
|
|
||||||
- 签到时间会自动记录
|
|
||||||
Loading…
Reference in New Issue