Compare commits
12 Commits
65e6d94f00
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8c9e80002 | ||
|
|
e2a9fef1d0 | ||
|
|
2e35f219e7 | ||
|
|
fba18fc32c | ||
|
|
23e2a4512b | ||
|
|
2a45e5e6be | ||
|
|
6ba943ff32 | ||
|
|
c4b642006f | ||
|
|
ddb4092ebf | ||
|
|
fcfb0b3c71 | ||
|
|
dace987070 | ||
|
|
cac99b5c1d |
@@ -2,7 +2,8 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Bash(git add:*)",
|
"Bash(git add:*)",
|
||||||
"Bash(git commit:*)"
|
"Bash(git commit:*)",
|
||||||
|
"WebFetch(domain:docs.claude.com)"
|
||||||
],
|
],
|
||||||
"deny": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
# Vue SPA 部署指南
|
|
||||||
|
|
||||||
## 问题描述
|
|
||||||
|
|
||||||
当直接访问 `http://uzi1.cn/play?code=973F2YTE` 时出现404错误,但其他页面正常。这是典型的单页应用(SPA)部署问题。
|
|
||||||
|
|
||||||
## 原因分析
|
|
||||||
|
|
||||||
1. **开发环境**:Vite开发服务器自动处理路由回退
|
|
||||||
2. **生产环境**:Web服务器尝试查找实际的 `/play` 文件,但这只是前端路由
|
|
||||||
|
|
||||||
## 解决方案
|
|
||||||
|
|
||||||
### 方案一:Nginx 配置(推荐)
|
|
||||||
|
|
||||||
1. 使用项目根目录的 `nginx.conf` 文件
|
|
||||||
2. 修改其中的 `root` 路径为实际部署路径
|
|
||||||
3. 重新加载 Nginx 配置:
|
|
||||||
```bash
|
|
||||||
sudo nginx -t # 测试配置
|
|
||||||
sudo nginx -s reload # 重新加载
|
|
||||||
```
|
|
||||||
|
|
||||||
### 方案二:Apache 配置
|
|
||||||
|
|
||||||
1. 将 `apache.htaccess` 文件复制到 `dist` 目录下,重命名为 `.htaccess`
|
|
||||||
2. 确保 Apache 启用了 `mod_rewrite` 模块
|
|
||||||
|
|
||||||
### 方案三:Netlify 部署
|
|
||||||
|
|
||||||
1. 将 `_redirects` 文件复制到 `dist` 目录下
|
|
||||||
2. 重新部署到 Netlify
|
|
||||||
|
|
||||||
### 方案四:其他静态托管服务
|
|
||||||
|
|
||||||
对于其他静态托管服务,需要配置:
|
|
||||||
- 所有路由都回退到 `index.html`
|
|
||||||
- API 请求代理到 `http://192.140.164.137:18080`
|
|
||||||
|
|
||||||
## 重新构建和部署
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 重新构建项目
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 2. 将构建文件部署到服务器
|
|
||||||
# 确保 dist 目录下的所有文件都已上传
|
|
||||||
|
|
||||||
# 3. 配置 Web 服务器(选择上述方案之一)
|
|
||||||
|
|
||||||
# 4. 测试访问
|
|
||||||
# http://uzi1.cn/play?code=973F2YTE 应该能正常访问
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验证步骤
|
|
||||||
|
|
||||||
1. 直接访问:`http://uzi1.cn/play?code=973F2YTE`
|
|
||||||
2. 应该能看到游戏页面,而不是404错误
|
|
||||||
3. 刷新页面应该仍然正常
|
|
||||||
4. API 请求应该正常工作(无CORS错误)
|
|
||||||
|
|
||||||
## 常见问题
|
|
||||||
|
|
||||||
### Q: 仍然出现404错误
|
|
||||||
A: 检查Web服务器配置是否正确应用,确认配置文件路径和语法
|
|
||||||
|
|
||||||
### Q: API请求失败
|
|
||||||
A: 检查代理配置,确认后端服务 `http://192.140.164.137:18080` 可访问
|
|
||||||
|
|
||||||
### Q: 静态资源加载失败
|
|
||||||
A: 检查资源路径配置,确认 `base` 配置正确
|
|
||||||
182
README_LINKS.md
182
README_LINKS.md
@@ -1,182 +0,0 @@
|
|||||||
# 链接管理功能
|
|
||||||
|
|
||||||
## 功能概述
|
|
||||||
|
|
||||||
链接管理页面提供了批量生成链接的功能,支持设置生成次数和每次生成的链接数量。根据您提供的API接口,系统会调用 `POST /api/link/generate` 接口来生成链接。
|
|
||||||
|
|
||||||
## 主要功能
|
|
||||||
|
|
||||||
### 1. 批量生成链接
|
|
||||||
- **生成次数**: 1-100次
|
|
||||||
- **每次链接数量**: 1-50个
|
|
||||||
- 支持表单验证
|
|
||||||
- 生成成功后自动刷新列表
|
|
||||||
|
|
||||||
### 2. 链接列表管理
|
|
||||||
- 分页显示已生成的链接批次
|
|
||||||
- 显示批次ID、机器编号、扣除积分、过期时间
|
|
||||||
- 支持查看、删除操作
|
|
||||||
- 根据过期时间自动判断链接状态
|
|
||||||
- 支持导出CSV数据
|
|
||||||
|
|
||||||
### 3. 二维码功能
|
|
||||||
- 根据机器编号生成对应的链接二维码
|
|
||||||
- 支持下载二维码图片
|
|
||||||
- 使用在线二维码生成服务
|
|
||||||
- 自动生成游戏链接地址
|
|
||||||
|
|
||||||
### 4. 权限控制
|
|
||||||
- 管理员:拥有所有权限(生成、查看、删除、导出)
|
|
||||||
- 代理商:只有查看权限
|
|
||||||
|
|
||||||
## API接口
|
|
||||||
|
|
||||||
### 生成链接
|
|
||||||
```http
|
|
||||||
POST /api/link/generate
|
|
||||||
Authorization: Bearer {token}
|
|
||||||
Content-Type: application/json
|
|
||||||
|
|
||||||
{
|
|
||||||
"times": 10, // 生成次数
|
|
||||||
"linkCount": 5 // 每次链接数量
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**返回数据示例:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"batchId": 6,
|
|
||||||
"deductPoints": 50,
|
|
||||||
"expireAt": "2025-08-26T12:29:13.63955",
|
|
||||||
"codeNos": [
|
|
||||||
"X3T9ND84"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 获取链接列表
|
|
||||||
```http
|
|
||||||
GET /api/link/list?page=1&pageSize=20
|
|
||||||
Authorization: Bearer {token}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 删除链接
|
|
||||||
```http
|
|
||||||
DELETE /api/link/{id}
|
|
||||||
Authorization: Bearer {token}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 页面路由
|
|
||||||
|
|
||||||
- **路径**: `/links`
|
|
||||||
- **名称**: `Links`
|
|
||||||
- **权限**: `LINK_VIEW`
|
|
||||||
|
|
||||||
## 文件结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── api/
|
|
||||||
│ └── links.js # 链接相关API接口
|
|
||||||
├── views/links/
|
|
||||||
│ └── LinkGenerate.vue # 链接生成页面
|
|
||||||
├── utils/
|
|
||||||
│ └── links.js # 链接管理工具函数
|
|
||||||
├── config/
|
|
||||||
│ └── links.js # 链接管理配置文件
|
|
||||||
├── router/
|
|
||||||
│ └── index.js # 路由配置(已更新)
|
|
||||||
├── layouts/
|
|
||||||
│ └── AdminLayout.vue # 导航菜单(已更新)
|
|
||||||
└── utils/
|
|
||||||
└── permission.js # 权限配置(已更新)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 使用说明
|
|
||||||
|
|
||||||
### 1. 生成链接
|
|
||||||
1. 在"生成次数"输入框中输入要生成的次数(1-100)
|
|
||||||
2. 在"每次链接数量"输入框中输入每次生成的链接数量(1-50)
|
|
||||||
3. 点击"开始生成"按钮
|
|
||||||
4. 系统会调用API生成链接,成功后显示提示信息
|
|
||||||
|
|
||||||
### 2. 管理链接
|
|
||||||
- 查看已生成的链接列表
|
|
||||||
- 点击"查看二维码"查看链接对应的二维码
|
|
||||||
- 点击"删除"删除不需要的链接
|
|
||||||
- 使用"导出CSV"功能导出链接数据
|
|
||||||
|
|
||||||
### 3. 权限说明
|
|
||||||
- 管理员可以执行所有操作
|
|
||||||
- 代理商只能查看链接列表,无法生成或删除链接
|
|
||||||
|
|
||||||
## 注意事项
|
|
||||||
|
|
||||||
1. **API地址**: 确保后端API地址配置正确(当前配置为 `http://localhost:18080`)
|
|
||||||
2. **认证**: 需要有效的Bearer Token才能访问API
|
|
||||||
3. **二维码**: 使用在线二维码生成服务,确保网络连接正常
|
|
||||||
4. **批量限制**: 单次最多生成50个链接,避免API压力过大
|
|
||||||
5. **链接地址**: 系统会自动根据机器编号生成游戏链接地址,格式为 `https://yourdomain.com/play?code={机器编号}`
|
|
||||||
6. **状态判断**: 链接状态根据过期时间自动判断(正常/即将过期/已过期)
|
|
||||||
7. **配置自定义**: 可以在 `src/config/links.js` 中自定义链接地址生成规则和状态配置
|
|
||||||
|
|
||||||
## 配置说明
|
|
||||||
|
|
||||||
### 链接地址配置 (`src/config/links.js`)
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export const LINK_CONFIG = {
|
|
||||||
// 基础域名
|
|
||||||
BASE_URL: 'https://yourdomain.com',
|
|
||||||
|
|
||||||
// 游戏页面路径
|
|
||||||
GAME_PATH: '/play',
|
|
||||||
|
|
||||||
// 机器编号参数名
|
|
||||||
CODE_PARAM: 'code',
|
|
||||||
|
|
||||||
// 链接地址模板
|
|
||||||
getLinkUrl: (codeNo) => {
|
|
||||||
return `${LINK_CONFIG.BASE_URL}${LINK_CONFIG.GAME_PATH}?${LINK_CONFIG.CODE_PARAM}=${codeNo}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 状态配置
|
|
||||||
|
|
||||||
```javascript
|
|
||||||
export const STATUS_CONFIG = {
|
|
||||||
// 状态标签类型
|
|
||||||
LABEL_TYPES: {
|
|
||||||
NORMAL: 'success', // 正常
|
|
||||||
EXPIRING: 'warning', // 即将过期
|
|
||||||
EXPIRED: 'danger', // 已过期
|
|
||||||
UNKNOWN: 'info' // 未知
|
|
||||||
},
|
|
||||||
|
|
||||||
// 过期时间阈值(毫秒)
|
|
||||||
EXPIRING_THRESHOLD: 24 * 60 * 60 * 1000, // 24小时
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 扩展功能
|
|
||||||
|
|
||||||
可以根据需要添加以下功能:
|
|
||||||
- 链接状态切换(启用/禁用)
|
|
||||||
- 链接过期时间设置
|
|
||||||
- 链接使用统计
|
|
||||||
- 自定义链接模板
|
|
||||||
- 批量操作(批量删除、批量导出等)
|
|
||||||
- 链接访问统计和监控
|
|
||||||
|
|
||||||
## 技术特点
|
|
||||||
|
|
||||||
- 使用Vue 3 Composition API
|
|
||||||
- Element Plus UI组件库
|
|
||||||
- 响应式设计
|
|
||||||
- 权限控制集成
|
|
||||||
- 错误处理和用户提示
|
|
||||||
- 支持CSV导出
|
|
||||||
|
|
||||||
|
|
||||||
193
docs/权限系统配置总结.md
193
docs/权限系统配置总结.md
@@ -1,193 +0,0 @@
|
|||||||
# 权限系统配置总结
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
本系统采用基于角色的权限控制(RBAC)机制,通过用户类型(ADMIN/AGENT)来控制用户的功能访问权限。
|
|
||||||
|
|
||||||
## 用户类型权限配置
|
|
||||||
|
|
||||||
### ADMIN(管理员)
|
|
||||||
- **权限范围**:拥有系统所有功能的访问权限
|
|
||||||
- **路由访问**:可以访问所有页面和路由
|
|
||||||
- **功能权限**:
|
|
||||||
- 用户管理(创建、编辑、删除、启用/禁用)
|
|
||||||
- 游戏管理(查看、创建、编辑、删除)
|
|
||||||
- 订单管理(查看、管理)
|
|
||||||
- 报表分析(查看)
|
|
||||||
- 系统设置(管理)
|
|
||||||
|
|
||||||
### AGENT(代理商)
|
|
||||||
- **权限范围**:只能查看基础信息,无管理权限
|
|
||||||
- **路由访问**:只能访问部分页面
|
|
||||||
- **功能权限**:
|
|
||||||
- 游戏查看
|
|
||||||
- 订单查看
|
|
||||||
- 报表查看
|
|
||||||
|
|
||||||
## 权限检查机制
|
|
||||||
|
|
||||||
### 1. 路由权限检查
|
|
||||||
```javascript
|
|
||||||
// 管理员可以访问所有路由
|
|
||||||
export function canAccessRoute(routeName) {
|
|
||||||
if (isAdmin()) return true
|
|
||||||
|
|
||||||
const requiredPermissions = ROUTE_PERMISSIONS[routeName] || []
|
|
||||||
if (requiredPermissions.length === 0) return true
|
|
||||||
|
|
||||||
return hasAnyPermission(requiredPermissions)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 功能权限检查
|
|
||||||
```javascript
|
|
||||||
// 检查单个权限
|
|
||||||
const canCreateUser = hasPermission('user:create')
|
|
||||||
|
|
||||||
// 检查多个权限(任一)
|
|
||||||
const canManageUsers = hasAnyPermission(['user:create', 'user:update', 'user:delete'])
|
|
||||||
|
|
||||||
// 检查多个权限(全部)
|
|
||||||
const canFullManage = hasAllPermissions(['user:create', 'user:update', 'user:delete'])
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 权限指令使用
|
|
||||||
```vue
|
|
||||||
<!-- 单个权限 -->
|
|
||||||
<el-button v-permission="'user:create'">新增用户</el-button>
|
|
||||||
|
|
||||||
<!-- 多个权限(OR逻辑) -->
|
|
||||||
<el-button v-permission="['user:create', 'user:update']">操作</el-button>
|
|
||||||
|
|
||||||
<!-- 多个权限(AND逻辑) -->
|
|
||||||
<el-button v-permission="['user:create', 'user:update', 'AND']">操作</el-button>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 权限配置详情
|
|
||||||
|
|
||||||
### 权限定义
|
|
||||||
```javascript
|
|
||||||
export const PERMISSIONS = {
|
|
||||||
// 用户管理权限
|
|
||||||
USER_MANAGE: 'user:manage',
|
|
||||||
USER_CREATE: 'user:create',
|
|
||||||
USER_UPDATE: 'user:update',
|
|
||||||
USER_DELETE: 'user:delete',
|
|
||||||
USER_VIEW: 'user:view',
|
|
||||||
|
|
||||||
// 游戏管理权限
|
|
||||||
GAME_MANAGE: 'game:manage',
|
|
||||||
GAME_CREATE: 'game:create',
|
|
||||||
GAME_UPDATE: 'game:update',
|
|
||||||
GAME_DELETE: 'game:delete',
|
|
||||||
GAME_VIEW: 'game:view',
|
|
||||||
|
|
||||||
// 订单管理权限
|
|
||||||
ORDER_MANAGE: 'order:manage',
|
|
||||||
ORDER_VIEW: 'order:view',
|
|
||||||
|
|
||||||
// 报表分析权限
|
|
||||||
REPORT_VIEW: 'report:view',
|
|
||||||
|
|
||||||
// 系统设置权限
|
|
||||||
SETTING_MANAGE: 'setting:manage',
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 角色权限映射
|
|
||||||
```javascript
|
|
||||||
export const ROLE_PERMISSIONS = {
|
|
||||||
ADMIN: [
|
|
||||||
// 管理员拥有所有权限
|
|
||||||
PERMISSIONS.USER_MANAGE, PERMISSIONS.USER_CREATE, PERMISSIONS.USER_UPDATE,
|
|
||||||
PERMISSIONS.USER_DELETE, PERMISSIONS.USER_VIEW,
|
|
||||||
PERMISSIONS.GAME_MANAGE, PERMISSIONS.GAME_CREATE, PERMISSIONS.GAME_UPDATE,
|
|
||||||
PERMISSIONS.GAME_DELETE, PERMISSIONS.GAME_VIEW,
|
|
||||||
PERMISSIONS.ORDER_MANAGE, PERMISSIONS.ORDER_VIEW,
|
|
||||||
PERMISSIONS.REPORT_VIEW, PERMISSIONS.SETTING_MANAGE,
|
|
||||||
],
|
|
||||||
AGENT: [
|
|
||||||
// 代理商只有查看权限
|
|
||||||
PERMISSIONS.GAME_VIEW, PERMISSIONS.ORDER_VIEW, PERMISSIONS.REPORT_VIEW,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 路由权限映射
|
|
||||||
```javascript
|
|
||||||
export const ROUTE_PERMISSIONS = {
|
|
||||||
'Users': [PERMISSIONS.USER_VIEW],
|
|
||||||
'Games': [PERMISSIONS.GAME_VIEW],
|
|
||||||
'Orders': [PERMISSIONS.ORDER_VIEW],
|
|
||||||
'Reports': [PERMISSIONS.REPORT_VIEW],
|
|
||||||
'Settings': [PERMISSIONS.SETTING_MANAGE],
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 权限控制层级
|
|
||||||
|
|
||||||
### 1. 路由级权限控制
|
|
||||||
- 在路由守卫中检查用户是否有访问权限
|
|
||||||
- 管理员可以访问所有路由
|
|
||||||
- 代理商只能访问有权限的路由
|
|
||||||
- 无权限时自动跳转到仪表盘页面
|
|
||||||
|
|
||||||
### 2. 菜单级权限控制
|
|
||||||
- 根据用户权限动态显示/隐藏菜单项
|
|
||||||
- 代理商用户看不到"用户管理"和"系统设置"菜单
|
|
||||||
- 管理员可以看到所有菜单项
|
|
||||||
|
|
||||||
### 3. 页面级权限控制
|
|
||||||
- 在页面组件中检查用户权限
|
|
||||||
- 无权限时显示权限不足提示
|
|
||||||
- 提供友好的用户体验
|
|
||||||
|
|
||||||
### 4. 操作级权限控制
|
|
||||||
- 根据用户权限显示/隐藏操作按钮
|
|
||||||
- 代理商用户无法进行用户管理操作
|
|
||||||
- 使用权限指令控制按钮显示
|
|
||||||
|
|
||||||
## 安全注意事项
|
|
||||||
|
|
||||||
1. **前端权限控制仅用于用户体验**,不能作为安全依据
|
|
||||||
2. **后端必须进行权限验证**,确保数据安全
|
|
||||||
3. **敏感操作需要二次确认**,如删除用户
|
|
||||||
4. **权限变更需要重新登录**,确保权限生效
|
|
||||||
5. **定期审查权限配置**,避免权限泄露
|
|
||||||
|
|
||||||
## 测试验证
|
|
||||||
|
|
||||||
### 权限测试页面
|
|
||||||
- 访问 `/permission-test` 页面
|
|
||||||
- 查看当前用户的权限状态
|
|
||||||
- 测试各种权限检查功能
|
|
||||||
- 验证路由访问权限
|
|
||||||
|
|
||||||
### 测试建议
|
|
||||||
1. 使用不同用户类型登录,验证权限控制
|
|
||||||
2. 测试无权限访问时的跳转逻辑
|
|
||||||
3. 验证菜单和按钮的显示/隐藏
|
|
||||||
4. 测试后端接口的权限验证
|
|
||||||
5. 检查权限变更后的效果
|
|
||||||
|
|
||||||
## 扩展说明
|
|
||||||
|
|
||||||
### 添加新权限
|
|
||||||
1. 在 `PERMISSIONS` 对象中添加新权限
|
|
||||||
2. 在 `ROLE_PERMISSIONS` 中为不同角色分配权限
|
|
||||||
3. 在 `ROUTE_PERMISSIONS` 中配置路由权限要求
|
|
||||||
4. 更新相关的前端权限检查逻辑
|
|
||||||
|
|
||||||
### 修改角色权限
|
|
||||||
1. 修改 `ROLE_PERMISSIONS` 中对应角色的权限数组
|
|
||||||
2. 更新相关的前端权限检查逻辑
|
|
||||||
3. 测试权限变更后的效果
|
|
||||||
|
|
||||||
## 总结
|
|
||||||
|
|
||||||
当前的权限系统配置确保了:
|
|
||||||
- **管理员拥有所有权限**,可以访问所有页面和功能
|
|
||||||
- **代理商只有查看权限**,无法进行管理操作
|
|
||||||
- **权限检查机制完善**,包括路由、菜单、页面、操作四个层级
|
|
||||||
- **用户体验友好**,无权限时提供清晰的提示和跳转
|
|
||||||
- **安全性保障**,前后端都有权限验证机制
|
|
||||||
288
docs/需求文档.md
288
docs/需求文档.md
@@ -1,288 +0,0 @@
|
|||||||
# 上号系统需求文档(PRD/SRS)v0.9
|
|
||||||
|
|
||||||
> 基于《安装步骤.docx》与用户确认的补充说明整理。本文作为首版“可评审草案”,确认后进入详细设计与开发。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 0. 版本信息
|
|
||||||
|
|
||||||
* 文档版本:v0.9(草案)
|
|
||||||
* 更新时间:2025-08-23
|
|
||||||
* 负责人:待定
|
|
||||||
* 评审人:待定
|
|
||||||
* 目标里程碑:首版上线(TBD)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 项目概述
|
|
||||||
|
|
||||||
### 1.1 背景
|
|
||||||
|
|
||||||
提供“上号任务”的一站式系统,生成带签名与加密的用户链接,用户进入后进行选区、扫码上号、实时进度与二界面跳转;面向商家(管理员、代理商)提供链接批量生成与额度(点数)管理、退单与公告等功能。
|
|
||||||
|
|
||||||
### 1.2 目标
|
|
||||||
|
|
||||||
* 终端用户侧:通过单个加密链接完成选区 → 扫码上号 → 进入二界面。
|
|
||||||
* 商家侧:
|
|
||||||
|
|
||||||
* 管理员:创建代理商账号、为代理商添加点数(余额)、批量生成链接、退单、公告管理、数据导出。
|
|
||||||
* 代理商:查看自身额度、批量生成链接、退单、数据导出、查看公告。
|
|
||||||
|
|
||||||
### 1.3 不在范围(本期)
|
|
||||||
|
|
||||||
* 对账、统计、风控(均不做)。
|
|
||||||
* 富文本公告、多语言、公告定时上线下线。
|
|
||||||
* 数据导入(仅导出)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 角色与权限
|
|
||||||
|
|
||||||
* **终端用户**:仅访问加密链接完成流程。
|
|
||||||
* **管理员**:代理商管理(创建/启停/加点)、批量生成链接、退单、公告发布、导出。
|
|
||||||
* **代理商**:批量生成链接(前提是额度充足)、退单、导出、查看公告。
|
|
||||||
|
|
||||||
### 2.1 权限矩阵(简表)
|
|
||||||
|
|
||||||
| 功能 | 终端用户 | 代理商 | 管理员 |
|
|
||||||
| ---------- | ---- | ------- | ------ |
|
|
||||||
| 访问加密链接进行上号 | ✔️ | — | — |
|
|
||||||
| 批量生成链接 | — | ✔️ | ✔️ |
|
|
||||||
| 查看/导出链接 | — | ✔️(仅自己) | ✔️(全量) |
|
|
||||||
| 退单 | — | ✔️(仅自己) | ✔️(全量) |
|
|
||||||
| 公告查看 | — | ✔️ | ✔️ |
|
|
||||||
| 公告发布 | — | — | ✔️ |
|
|
||||||
| 代理商账号管理/加点 | — | — | ✔️ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 术语与规则
|
|
||||||
|
|
||||||
* **链接(Link)**:一次任务的入口地址,带签名与加密;默认有效期 **24 小时**(可配)。
|
|
||||||
* **编号(CodeNo)**:由后端自动生成,**全局唯一**、可追踪;用于日志与二界面模板替换。
|
|
||||||
* **数量(Quantity)**:一次执行产生的奖励数,例如 50。
|
|
||||||
* **次数(Times)**:重复执行次数,例如 20。
|
|
||||||
* **总点数(TotalPoints)**:`Times × Quantity`,例如 20×50=1000。
|
|
||||||
* **批量生成**:同一组设置一次生成 N 个链接(默认 10 个,可配)。
|
|
||||||
* **二界面**:外部提供的页面或资源,系统在上号成功后 **用编号动态替换**后跳转。
|
|
||||||
* **脚本端**:运行在手机上的脚本与相关资源(例如二维码图片、进度截图、上号判断等),由系统通过适配器服务进行对接。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 业务流程
|
|
||||||
|
|
||||||
### 4.1 链接生命周期(简述)
|
|
||||||
|
|
||||||
1. 商家侧生成链接:校验额度 → 扣减点数(`Times×Quantity`)→ 生成编号 → 生成**加密+签名**链接 → 设置过期时间(24h)。
|
|
||||||
2. 终端用户访问链接:若未过期,进入“选区→扫码上号”流程;成功则跳转二界面;若退单或过期则提示不可用。
|
|
||||||
3. 退单:释放机器(不回滚额度),记录日志。
|
|
||||||
|
|
||||||
### 4.2 终端用户流程(含二次识别/刷新约束)
|
|
||||||
|
|
||||||
* **首次进入**:
|
|
||||||
|
|
||||||
1. 选区(安卓QQ/安卓WX)。
|
|
||||||
2. 触发脚本端生成二维码;前端按返回的 `qrCreatedAt/qrExpireAt` 本地计算倒计时并轮询上号状态。
|
|
||||||
3. 上号成功 → 跳转二界面(按模板替换编号)。
|
|
||||||
* **未进入二界面即退出/刷新/返回**:
|
|
||||||
|
|
||||||
* 规则:**必须先调用“刷新”接口** → 等待 **Y 秒(默认 10s,可配)** → 才允许再次选区与扫码。
|
|
||||||
|
|
||||||
### 4.3 退单流程
|
|
||||||
|
|
||||||
* 商家提交编号 → 系统释放关联机器(若有)→ 标记退单 → 记录日志;额度**不回滚**。
|
|
||||||
|
|
||||||
### 4.4 扣费时机与计算
|
|
||||||
|
|
||||||
* **生成链接时即扣费**:`扣减点数 = Times × Quantity × 批量个数`。
|
|
||||||
* 额度不足:拒绝生成。
|
|
||||||
|
|
||||||
### 4.5 公告
|
|
||||||
|
|
||||||
* 简单文本公告(可配置是否展示、展示位置),无定时/富文本/多语言。
|
|
||||||
|
|
||||||
### 4.6 用户端(扫码登录)规范(单链接模式 · 定稿)
|
|
||||||
|
|
||||||
> 本节**仅**描述用户访问与后端处理逻辑;不涉及商家功能。**一切界面都在同一 URL**:`https://yourdomain.com/play?code={codeNo}`。前端通过该 URL 内的 `code` 调用后端查询状态并渲染对应页面(首界面/扫码界面/二界面)。
|
|
||||||
|
|
||||||
#### 4.6.1 URL 与安全
|
|
||||||
|
|
||||||
* 入口:`GET https://yourdomain.com/play?code={opaque}`,`code` 为**加密+签名**的**不透明**令牌(包含 linkId/iat/exp 等),不可被推断。
|
|
||||||
* 后端在**每次**接口调用时校验:解密→签名→有效期(默认 24h)→状态;失败返回 401/403/410(过期)。
|
|
||||||
|
|
||||||
#### 4.6.2 状态模型(后端对前端暴露)
|
|
||||||
|
|
||||||
* `NEW`:未进入扫码流程(未选区)。
|
|
||||||
* `USING`:已进入扫码流程(已选区,二维码有效期内)。
|
|
||||||
* `LOGGED_IN`:已上号(展示二界面)。
|
|
||||||
* `REFUNDED`:已退单(不可用)。
|
|
||||||
* `EXPIRED`:已过期(24h)。
|
|
||||||
* `needRefresh: boolean`:是否必须先刷新→等待 10 秒→再允许选区与扫码(只要进入过扫码但未到二界面时生效)。
|
|
||||||
|
|
||||||
#### 4.6.3 用户端 API(仅用户用)
|
|
||||||
|
|
||||||
> 路径示例以 `/api/link/*` 说明;实际以最终路由为准。
|
|
||||||
|
|
||||||
1. **获取状态**
|
|
||||||
`GET /api/link/status?code={code}` →
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "NEW | USING | LOGGED_IN | REFUNDED | EXPIRED",
|
|
||||||
"needRefresh": false,
|
|
||||||
"region": "Q | V | null",
|
|
||||||
"qr": { "url": "http://ip:12345/{编号}/二维码.png", "createdAt": 1730000000000, "expireAt": 1730000060000 },
|
|
||||||
"view": "FIRST | SCAN | SECOND",
|
|
||||||
"assets": {
|
|
||||||
"base": "http://ip:12345/{编号}/",
|
|
||||||
"firstHome": "首次主页.png",
|
|
||||||
"firstBonus": "首次赏金.png",
|
|
||||||
"midBonus": "中途赏金.png",
|
|
||||||
"endBonus": "结束赏金.png"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* 说明:
|
|
||||||
|
|
||||||
* `qr` 仅在 `status=USING` 时返回;若脚本端无 TTL,后端以默认 **60s** 回填。
|
|
||||||
* `view` 是前端渲染建议:`FIRST(选区) / SCAN(扫码) / SECOND(二界面)`;实际以 `status/needRefresh` 为准。
|
|
||||||
* `assets` 为二界面所需的静态资源信息(路径中需要自动替换编号)。
|
|
||||||
|
|
||||||
2. **选区**
|
|
||||||
`POST /api/link/select-region`
|
|
||||||
Body: `{ code: string, region: "Q" | "V" }` → 返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "USING",
|
|
||||||
"needRefresh": false,
|
|
||||||
"qr": { "url": "http://ip:12345/{编号}/二维码.png", "createdAt": 1730000000000, "expireAt": 1730000060000 }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
* 后端行为:按需分配机器、下发任务参数、请求脚本端生成二维码;将状态置为 `USING`。
|
|
||||||
|
|
||||||
3. **刷新**
|
|
||||||
`POST /api/link/refresh`
|
|
||||||
Body: `{ code: string }` → 返回:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "needRefresh": true, "waitSeconds": 10 }
|
|
||||||
```
|
|
||||||
|
|
||||||
* 规则:**未到二界面前**,再次尝试必须先刷新并在前端**等待 10 秒**后才允许重新选区与扫码。
|
|
||||||
|
|
||||||
4. **轮询上号**
|
|
||||||
`GET /api/link/poll-login?code={code}` →
|
|
||||||
|
|
||||||
* 未上号:`{ "success": false, "status": "USING" }`
|
|
||||||
* 已上号:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{ "success": true, "status": "LOGGED_IN", "view": "SECOND", "assets": { "base": "http://ip:12345/{编号}/", "endBonus": "结束赏金.png" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
* 前端据返回改为渲染**二界面**(同一个 URL,不跳转)。
|
|
||||||
|
|
||||||
> 可选:`GET /api/link/qr.png?code={code}` 由后端代理二维码,确保整链路 HTTPS,避免浏览器混合内容拦截。
|
|
||||||
|
|
||||||
#### 4.6.4 前端渲染流程(同一路由)
|
|
||||||
|
|
||||||
1. **首访**:调用 `/api/link/status` 渲染:
|
|
||||||
|
|
||||||
* `NEW` → 显示选区页(两个按钮 Q/V)。
|
|
||||||
* `USING` → 显示扫码页(二维码+倒计时),并开始轮询 `/poll-login`(每 1s)。
|
|
||||||
* `LOGGED_IN` → 显示二界面(依 `assets` 取图/信息)。
|
|
||||||
* `REFUNDED/EXPIRED` → 显示不可用页。
|
|
||||||
2. **选区**:`POST /select-region` 成功后进入扫码页,开始轮询。
|
|
||||||
3. **二维码过期**:倒计时到 0 或轮询返回需刷新 → 显示“请刷新”按钮;调用 `/refresh` 并禁用按钮 **10s**,倒计时结束后恢复“选区”按钮。
|
|
||||||
4. **上号成功**:轮询返回 `LOGGED_IN` → 在**同一 URL** 切换到二界面视图;前端可停止所有轮询与倒计时。
|
|
||||||
|
|
||||||
#### 4.6.5 后端内部处理要点
|
|
||||||
|
|
||||||
* **校验**:对所有接口按 `code` 做鉴权(签名/解密/过期/撤销/退单)。
|
|
||||||
* **状态流转**:
|
|
||||||
|
|
||||||
* `NEW → USING`:选区成功并生成二维码。
|
|
||||||
* `USING → LOGGED_IN`:脚本端返回“已上号”。
|
|
||||||
* `NEW/USING → REFUNDED | EXPIRED`:退单或超时达 24h。
|
|
||||||
* **刷新约束**:对进入过扫码但未上号的会话,标记 `needRefresh=true`;`/refresh` 后写入“刷新时间”,后续 **10s** 内拒绝 `select-region`。
|
|
||||||
* **脚本端适配**:分配/释放机器、生成二维码、查询“是否上号”、可选的分数读取;超时重试与错误兜底(不把脚本端错误直接暴露给前端)。
|
|
||||||
* **幂等**:`select-region`/`refresh`/`poll-login` 按 `code` 幂等处理,避免重复侧效应。
|
|
||||||
* **日志**:记录编号、选区、二维码下发/失效、上号结果、刷新与错误(保留 90 天)。
|
|
||||||
|
|
||||||
#### 4.6.6 错误与前端文案(示例)
|
|
||||||
|
|
||||||
* 401/403:链接无效或已篡改 → “链接无效,请联系商家”。
|
|
||||||
* 410:链接已过期 → “链接已过期,请联系商家重新获取”。
|
|
||||||
* 423:需刷新等待 → “请先刷新并等待 10 秒后再试”。
|
|
||||||
* 504:脚本端超时 → “当前排队人数较多,请稍后重试”。
|
|
||||||
|
|
||||||
> 以上约束确保“同一链接承载首界面/扫码/二界面”的产品体验,前端仅根据后端状态渲染视图,无需路由跳转。
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
次数链接
|
|
||||||
http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=总次数&编号=次数
|
|
||||||
|
|
||||||
后台操作流程: 后台管理员在后台输入框里面填写了一个次数5, 然后点击了生成, 平台会自动生成一个链接,格式如同:域名/加密
|
|
||||||
|
|
||||||
批量生成可以搞成下载文件或者复制粘贴都可以
|
|
||||||
|
|
||||||
后台功能:
|
|
||||||
1.后台可以直接单生成链接或者批量生成链接给用户使用如图
|
|
||||||
2.代理系统, 代理也可单独生成链接或批量生成, 后台可帮他设置金额, 代理使用多少,自动扣除多少
|
|
||||||
3.退单: 后台只需填写正确的编号运行退单链接,即可直接退单
|
|
||||||
4.用户选区之后等待几秒 出现二维码到期时间 刷新时间 用户使用有效期 设置
|
|
||||||
5.次数等于数量 设置
|
|
||||||
6.可以设置公告弹窗, 代理也可以自己生成自己的, 不会互通! 只有确定和取消按钮,点确定会跳转某网页
|
|
||||||
|
|
||||||
退单链接()
|
|
||||||
http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断退单&编号=T
|
|
||||||
|
|
||||||
第一步;用户界面 1和2
|
|
||||||
2
|
|
||||||
判断空的设备链接
|
|
||||||
http://36.138.184.60:1234/yijianwan_netfile/readAllMsg?文件名=判断分数
|
|
||||||
|
|
||||||
选区链接
|
|
||||||
http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断系统&编号=Q或V
|
|
||||||
|
|
||||||
刷新链接
|
|
||||||
http://36.138.184.60:1234/yijianwan_netfile/saveMsg?文件名=判断刷新&编号=刷新
|
|
||||||
|
|
||||||
二维码的http://36.138.184.60:12345/编号/二维码.png
|
|
||||||
|
|
||||||
判断是否上号
|
|
||||||
http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断上号&对象名=编号 状态: 未上号 已上号 【已上号跳转第二个界面】
|
|
||||||
|
|
||||||
讲解:后台生成了一个链接,填写了次数, 用户拿到链接之后,在浏览器运行, 后台会自动获取一个空的编号, 然后用户可以自己选QQ区或微信区, 如果用户选择QQ区,那么直接执行选区链接执行QQ的, 微信也一样, 根据后台设置的等待,几秒后获取二维码链接 如同第三张图, 点击之后你可以观看第三张图,有一个倒数时间,也可在后台设置时间, 如果用户在我设置的60秒内没扫码,自动会跳出扫码超时,如第二张图, 用户可以继续选区, 后台会首先执行刷新链接, 然后根据后台设置的刷新时间,等待多少秒后再执行选择QQ区或微信区的链接, 根据用户选择, 作者可以写一个日志或者其他把把这个些链接的使用流程记录下来,方便第二次识别,
|
|
||||||
|
|
||||||
假如:用户他第一次已经选过了区,但是他直接第二次刷新了浏览器, 导致没办法确认我后台对接的脚本是否在首页, 所以作者必须写个记录, 来判断这个链接是否被使用过一次,如果被使用过一次, 那么用户不管是刷新浏览器,还是等待超时扫码, 都判定为第二次! 所以必须先执行刷新根据后台设计的时间等待 再选区!
|
|
||||||
|
|
||||||
用户只要选区了,出现了二维码,那么后台只需要一直识别判断它是否上号那条链接,如果出现已上号,那么直接跳转第二个界面,未上号就一直在第一个界面!
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
用户扫码后跳转的:第二个界面
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
②http://36.138.184.60:12345/编号/首次主页.png
|
|
||||||
③http://36.138.184.60:12345/编号/首次赏金.png
|
|
||||||
④http://36.138.184.60:12345/编号/中途赏金.png
|
|
||||||
⑤http://36.138.184.60:12345/编号/结束赏金.png
|
|
||||||
|
|
||||||
讲解:
|
|
||||||
1.选择大厅这个就是用户自己选的区域显示
|
|
||||||
2.状态=已打完 空闲, 只要显示这两个状态,余地都是已打完,其他都是代练中
|
|
||||||
3.目标点数0/1000如图1000就是后台设置的次数x次数等于数量=1000 [假如后台设置的次数等于总量为50,那么必须设置20次才等于1000
|
|
||||||
|
|
||||||
使用过的所有链接总有效期为24小时 可设置
|
|
||||||
|
|
||||||
目标点数
|
|
||||||
http://36.138.184.60:1234/yijianwan_netfile/readMsg?文件名=判断分数&对象名=编号
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,11 +1,25 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="error-page">
|
<div class="error-page">
|
||||||
|
<div class="page-header">
|
||||||
|
<h1 class="title">出现错误</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="error-container">
|
<div class="error-container">
|
||||||
<div class="error-icon">❌</div>
|
<div class="icon-wrapper">
|
||||||
|
<svg class="error-svg" viewBox="0 0 24 24" width="80" height="80">
|
||||||
|
<circle cx="12" cy="12" r="11" fill="#f44336" opacity="0.1"/>
|
||||||
|
<path fill="#f44336" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<h2 class="error-title">{{ errorTitle }}</h2>
|
<h2 class="error-title">{{ errorTitle }}</h2>
|
||||||
<p class="error-message">{{ errorMessage }}</p>
|
<p class="error-message">{{ errorMessage }}</p>
|
||||||
<button @click="$emit('retry')" class="retry-btn">重新尝试</button>
|
<button @click="$emit('retry')" class="retry-btn">重新尝试</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="notice-text">
|
||||||
|
<p>遇到问题?请联系客服</p>
|
||||||
|
<p>我们会尽快为您解决</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -30,54 +44,110 @@ export default {
|
|||||||
.error-page {
|
.error-page {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
flex-direction: column;
|
||||||
align-items: center;
|
background: white;
|
||||||
padding: 40px 20px;
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 3px solid #f44336;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #f44336 0%, #e91e63 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-container {
|
.error-container {
|
||||||
background: white;
|
flex: 1;
|
||||||
padding: 40px;
|
display: flex;
|
||||||
border-radius: 20px;
|
flex-direction: column;
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 40px 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
max-width: 400px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-icon {
|
.icon-wrapper {
|
||||||
font-size: 48px;
|
margin-bottom: 24px;
|
||||||
margin-bottom: 16px;
|
animation: shake 0.5s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shake {
|
||||||
|
0%, 100% { transform: translateX(0); }
|
||||||
|
10%, 30%, 50%, 70%, 90% { transform: translateX(-5px); }
|
||||||
|
20%, 40%, 60%, 80% { transform: translateX(5px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-svg {
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(244, 67, 54, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-title {
|
.error-title {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-message {
|
.error-message {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
color: #666;
|
color: #666;
|
||||||
margin: 0 0 24px 0;
|
margin: 0 0 32px 0;
|
||||||
line-height: 1.5;
|
line-height: 1.6;
|
||||||
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn {
|
.retry-btn {
|
||||||
background: #667eea;
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 32px;
|
padding: 12px 32px;
|
||||||
border-radius: 25px;
|
border-radius: 8px;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn:hover {
|
.retry-btn:hover {
|
||||||
background: #5a6fd8;
|
opacity: 0.9;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-text {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-top: 2px solid #e9ecef;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-text p {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.error-svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-title {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -63,10 +63,23 @@
|
|||||||
</div>
|
</div>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="announcement-footer">
|
<div class="announcement-footer">
|
||||||
<el-checkbox v-model="dontShowToday" class="dont-show-checkbox">
|
<template v-if="announcement && announcement.jumpUrl">
|
||||||
今日不再弹出
|
<!-- 有链接时显示确定和取消按钮 -->
|
||||||
</el-checkbox>
|
<el-checkbox v-model="dontShowToday" class="dont-show-checkbox">
|
||||||
<el-button type="primary" @click="handleAnnouncementClose">确定</el-button>
|
今日不再弹出
|
||||||
|
</el-checkbox>
|
||||||
|
<div class="button-group">
|
||||||
|
<el-button @click="handleAnnouncementCancel">取消</el-button>
|
||||||
|
<el-button type="primary" @click="handleAnnouncementConfirm">确定</el-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<!-- 无链接时显示原有的样式 -->
|
||||||
|
<el-checkbox v-model="dontShowToday" class="dont-show-checkbox">
|
||||||
|
今日不再弹出
|
||||||
|
</el-checkbox>
|
||||||
|
<el-button type="primary" @click="handleAnnouncementClose">确定</el-button>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -200,6 +213,38 @@ export default {
|
|||||||
dontShowToday.value = false
|
dontShowToday.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 点击确定按钮:跳转到链接
|
||||||
|
const handleAnnouncementConfirm = () => {
|
||||||
|
const codeNo = props.codeNo
|
||||||
|
const announcementData = announcement.value
|
||||||
|
if (dontShowToday.value && codeNo) {
|
||||||
|
markDontShowToday(codeNo)
|
||||||
|
}
|
||||||
|
if (announcementData && codeNo) {
|
||||||
|
markDismissedPermanently(codeNo, announcementData.id)
|
||||||
|
}
|
||||||
|
// 跳转到链接
|
||||||
|
if (announcementData && announcementData.jumpUrl) {
|
||||||
|
window.open(announcementData.jumpUrl, '_blank')
|
||||||
|
}
|
||||||
|
announcementVisible.value = false
|
||||||
|
dontShowToday.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 点击取消按钮:仅关闭弹窗
|
||||||
|
const handleAnnouncementCancel = () => {
|
||||||
|
const codeNo = props.codeNo
|
||||||
|
const announcementData = announcement.value
|
||||||
|
if (dontShowToday.value && codeNo) {
|
||||||
|
markDontShowToday(codeNo)
|
||||||
|
}
|
||||||
|
if (announcementData && codeNo) {
|
||||||
|
markDismissedPermanently(codeNo, announcementData.id)
|
||||||
|
}
|
||||||
|
announcementVisible.value = false
|
||||||
|
dontShowToday.value = false
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
// 仅在进入 GamePage 时检查公告
|
// 仅在进入 GamePage 时检查公告
|
||||||
setTimeout(() => checkAnnouncement(props.codeNo), 1000)
|
setTimeout(() => checkAnnouncement(props.codeNo), 1000)
|
||||||
@@ -218,7 +263,9 @@ export default {
|
|||||||
announcementVisible,
|
announcementVisible,
|
||||||
announcement,
|
announcement,
|
||||||
dontShowToday,
|
dontShowToday,
|
||||||
handleAnnouncementClose
|
handleAnnouncementClose,
|
||||||
|
handleAnnouncementConfirm,
|
||||||
|
handleAnnouncementCancel
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -394,6 +441,11 @@ export default {
|
|||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.tab-item {
|
.tab-item {
|
||||||
padding: 10px 8px;
|
padding: 10px 8px;
|
||||||
@@ -422,5 +474,10 @@ export default {
|
|||||||
.image-label {
|
.image-label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 移动端按钮组调整 */
|
||||||
|
.button-group {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,7 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="loading-overlay">
|
<div class="loading-overlay">
|
||||||
<div class="loading-spinner"></div>
|
<div class="page-header">
|
||||||
<p>加载中...</p>
|
<h1 class="title">加载中</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading-container">
|
||||||
|
<div class="spinner-wrapper">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<div class="loading-pulse"></div>
|
||||||
|
</div>
|
||||||
|
<p class="loading-text">正在加载订单信息...</p>
|
||||||
|
<p class="loading-subtext">请稍候</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="notice-text">
|
||||||
|
<p>代理技术代练平台</p>
|
||||||
|
<p>安全 · 专业 · 高效</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -18,26 +33,140 @@ export default {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
background: rgba(255, 255, 255, 0.9);
|
background: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border-bottom: 3px solid #4776e6;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-container {
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
z-index: 1000;
|
padding: 40px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-wrapper {
|
||||||
|
position: relative;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 40px;
|
position: absolute;
|
||||||
height: 40px;
|
top: 50%;
|
||||||
border: 4px solid #f3f3f3;
|
left: 50%;
|
||||||
border-top: 4px solid #667eea;
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
margin-top: -30px;
|
||||||
|
margin-left: -30px;
|
||||||
|
border: 4px solid #e9ecef;
|
||||||
|
border-top: 4px solid #4776e6;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
margin-bottom: 16px;
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
margin-top: -40px;
|
||||||
|
margin-left: -40px;
|
||||||
|
border: 2px solid #4776e6;
|
||||||
|
border-radius: 50%;
|
||||||
|
opacity: 0.3;
|
||||||
|
animation: pulse 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
@keyframes spin {
|
||||||
0% { transform: rotate(0deg); }
|
0% { transform: rotate(0deg); }
|
||||||
100% { transform: rotate(360deg); }
|
100% { transform: rotate(360deg); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(0.8);
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-subtext {
|
||||||
|
font-size: 14px;
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-text {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-top: 2px solid #e9ecef;
|
||||||
|
margin-top: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-text p {
|
||||||
|
margin: 4px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.spinner-wrapper {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-top: -24px;
|
||||||
|
margin-left: -24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
margin-top: -32px;
|
||||||
|
margin-left: -32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,26 +1,54 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="refresh-wait-page">
|
<div class="refresh-wait-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="title">请选择您的账号类型</h1>
|
<h1 class="title">需要刷新</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="refresh-container">
|
<div class="refresh-container">
|
||||||
<div class="warning-icon">⚠️</div>
|
<div class="icon-wrapper">
|
||||||
|
<svg class="warning-svg" viewBox="0 0 24 24" width="80" height="80">
|
||||||
|
<path fill="#ff9800" d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
<p class="refresh-text">页面需要刷新</p>
|
<p class="refresh-text">页面需要刷新</p>
|
||||||
<p class="refresh-desc">请等待后重新选择区域</p>
|
<p class="refresh-desc">请等待冷却时间后重新选择区域</p>
|
||||||
|
|
||||||
|
<div v-if="refreshCooldown > 0" class="cooldown-info">
|
||||||
|
<div class="cooldown-circle">
|
||||||
|
<svg class="cooldown-svg" viewBox="0 0 100 100" width="120" height="120">
|
||||||
|
<circle cx="50" cy="50" r="45" fill="none" stroke="#e9ecef" stroke-width="8"/>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r="45"
|
||||||
|
fill="none"
|
||||||
|
stroke="#4776e6"
|
||||||
|
stroke-width="8"
|
||||||
|
:stroke-dasharray="circumference"
|
||||||
|
:stroke-dashoffset="dashOffset"
|
||||||
|
transform="rotate(-90 50 50)"
|
||||||
|
style="transition: stroke-dashoffset 1s linear;"
|
||||||
|
/>
|
||||||
|
<text x="50" y="50" text-anchor="middle" dy="0.3em" font-size="24" font-weight="bold" fill="#4776e6">
|
||||||
|
{{ refreshCooldown }}s
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="$emit('refresh')"
|
@click="$emit('refresh')"
|
||||||
class="refresh-btn"
|
class="refresh-btn"
|
||||||
:disabled="refreshCooldown > 0"
|
:disabled="refreshCooldown > 0"
|
||||||
>
|
>
|
||||||
{{ refreshCooldown > 0 ? `请等待 ${refreshCooldown}s` : '确定' }}
|
{{ refreshCooldown > 0 ? `请等待 ${refreshCooldown}秒` : '立即刷新' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="notice-text">
|
<div class="notice-text">
|
||||||
<p>代理技术代练平台操作中</p>
|
<p>代理技术代练平台操作中</p>
|
||||||
<p>绝对安全保障!请耐心等待</p>
|
<p>绝对安全保障!请耐心等待</p>
|
||||||
<p>温馨提示: 请选择正确区域</p>
|
<p>温馨提示: 刷新后需重新选择区域</p>
|
||||||
<p v-if="mecmachineId" class="machine-id-info">ID: {{ mecmachineId }}</p>
|
<p v-if="mecmachineId" class="machine-id-info">ID: {{ mecmachineId }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +67,27 @@ export default {
|
|||||||
default: null
|
default: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['refresh']
|
emits: ['refresh'],
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
maxCooldown: 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
circumference() {
|
||||||
|
return 2 * Math.PI * 45
|
||||||
|
},
|
||||||
|
dashOffset() {
|
||||||
|
if (this.refreshCooldown === 0) return this.circumference
|
||||||
|
const progress = this.refreshCooldown / this.maxCooldown
|
||||||
|
return this.circumference * (1 - progress)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
if (this.refreshCooldown > 0) {
|
||||||
|
this.maxCooldown = this.refreshCooldown
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,20 +96,27 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
background: white;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 20px 20px;
|
padding: 20px;
|
||||||
color: white;
|
background: white;
|
||||||
|
border-bottom: 3px solid #4776e6;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 16px 0;
|
margin: 0;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-container {
|
.refresh-container {
|
||||||
@@ -70,63 +125,93 @@ export default {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 40px 20px;
|
||||||
color: white;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning-icon {
|
.icon-wrapper {
|
||||||
font-size: 48px;
|
margin-bottom: 24px;
|
||||||
margin-bottom: 16px;
|
animation: bounce 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 20%, 50%, 80%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning-svg {
|
||||||
|
filter: drop-shadow(0 4px 8px rgba(255, 152, 0, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-text {
|
.refresh-text {
|
||||||
font-size: 20px;
|
font-size: 22px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-desc {
|
.refresh-desc {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
margin: 0 0 24px 0;
|
margin: 0 0 32px 0;
|
||||||
opacity: 0.8;
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooldown-info {
|
||||||
|
margin: 24px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooldown-circle {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooldown-svg {
|
||||||
|
filter: drop-shadow(0 4px 12px rgba(71, 118, 230, 0.3));
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn {
|
.refresh-btn {
|
||||||
background: white;
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
color: #667eea;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 32px;
|
padding: 12px 32px;
|
||||||
border-radius: 25px;
|
border-radius: 8px;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
min-width: 120px;
|
min-width: 140px;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:hover:not(:disabled) {
|
.refresh-btn:hover:not(:disabled) {
|
||||||
background: #f0f0f0;
|
opacity: 0.9;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:disabled {
|
.refresh-btn:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-text {
|
.notice-text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: white;
|
background: #f8f9fa;
|
||||||
background: rgba(0, 0, 0, 0.1);
|
border-top: 2px solid #e9ecef;
|
||||||
backdrop-filter: blur(10px);
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-text p {
|
.notice-text p {
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
opacity: 0.9;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.machine-id-info {
|
.machine-id-info {
|
||||||
@@ -134,4 +219,20 @@ export default {
|
|||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
opacity: 1 !important;
|
opacity: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.warning-svg {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cooldown-svg {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-text {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="scan-page">
|
<div class="scan-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="title">请选择您的账号类型</h1>
|
<h1 class="title">扫码登录</h1>
|
||||||
<div class="selected-region">{{ regionName }}</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 选中的区域信息 -->
|
||||||
|
<div class="region-info">
|
||||||
|
<div class="info-label">已选择大区</div>
|
||||||
|
<div class="info-value">{{ regionName }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 等待二维码 -->
|
<!-- 等待二维码 -->
|
||||||
@@ -15,10 +20,18 @@
|
|||||||
|
|
||||||
<!-- 二维码区域 -->
|
<!-- 二维码区域 -->
|
||||||
<div v-else-if="qrInfo && countdown > 0" class="qr-container">
|
<div v-else-if="qrInfo && countdown > 0" class="qr-container">
|
||||||
<div class="qr-wrapper">
|
<div class="qr-section">
|
||||||
<img :src="qrInfo.url" class="qr-code" alt="扫码登录" @error="$emit('qrImageError', $event)" @load="$emit('qrImageLoad', $event)" />
|
<div class="qr-title">请尽快完成扫码!</div>
|
||||||
|
<div class="qr-wrapper">
|
||||||
|
<img :src="qrInfo.url" class="qr-code" alt="扫码登录" @error="$emit('qrImageError', $event)" @load="$emit('qrImageLoad', $event)" />
|
||||||
|
</div>
|
||||||
|
<div class="countdown-timer">
|
||||||
|
<svg class="timer-icon" viewBox="0 0 24 24" width="20" height="20">
|
||||||
|
<path fill="currentColor" d="M15,1H9V3H15M19,8H17V14H19M15,21H9V23H15M11,17H13V7H11M17,1.01L17,3.01C19.75,3.55 21.9,5.84 22.28,8.65C22.35,9.15 22.39,9.65 22.39,10.16C22.39,13.65 20.14,16.63 16.97,17.67C16.64,17.77 16.31,17.85 15.97,17.91V19.93C16.39,19.87 16.8,19.78 17.2,19.67C21.15,18.42 24,14.61 24,10.16C24,9.56 23.94,8.96 23.84,8.38C23.31,4.85 20.66,2.04 17.21,1.13C17.14,1.11 17.07,1.09 17,1.01M7,3.01V1.01C6.93,1.09 6.86,1.11 6.79,1.13C3.34,2.04 0.69,4.85 0.16,8.38C0.0600000000000001,8.96 0,9.56 0,10.16C0,14.61 2.85,18.42 6.8,19.67C7.2,19.78 7.61,19.87 8.03,19.93V17.91C7.69,17.85 7.36,17.77 7.03,17.67C3.86,16.63 1.61,13.65 1.61,10.16C1.61,9.65 1.65,9.15 1.72,8.65C2.1,5.84 4.25,3.55 7,3.01Z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ formatTime(countdown) }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="countdown-timer">{{ formatTime(countdown) }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 二维码获取失败 -->
|
<!-- 二维码获取失败 -->
|
||||||
@@ -38,9 +51,11 @@
|
|||||||
<!-- 二维码过期 -->
|
<!-- 二维码过期 -->
|
||||||
<div v-else class="qr-expired">
|
<div v-else class="qr-expired">
|
||||||
<div class="warning-icon">⚠️</div>
|
<div class="warning-icon">⚠️</div>
|
||||||
<p class="expired-text">扫码超时{{ qrInfo?.url }}</p>
|
<p class="expired-text">扫码超时</p>
|
||||||
<p class="expired-desc">请手动刷新页面重新获取二维码</p>
|
<p class="expired-desc">请手动刷新页面重新获取二维码</p>
|
||||||
<img :src="qrInfo?.url" class="qr-code" alt="扫码登录" />
|
<div class="qr-wrapper expired">
|
||||||
|
<img :src="qrInfo?.url" class="qr-code" alt="扫码登录" />
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
@click="$emit('pageRefresh')"
|
@click="$emit('pageRefresh')"
|
||||||
class="refresh-btn"
|
class="refresh-btn"
|
||||||
@@ -52,8 +67,8 @@
|
|||||||
<div class="notice-text">
|
<div class="notice-text">
|
||||||
<p>代理技术代练平台操作中</p>
|
<p>代理技术代练平台操作中</p>
|
||||||
<p>绝对安全保障!请耐心等待</p>
|
<p>绝对安全保障!请耐心等待</p>
|
||||||
<p>温馨提示: 请选择正确区域</p>
|
<p>温馨提示: 请耐心等待扫码登录</p>
|
||||||
<p v-if="mecmachineId" class="machine-id-info">机器ID: {{ mecmachineId }}</p>
|
<p v-if="mecmachineId" class="machine-id-info">ID: {{ mecmachineId }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -110,23 +125,10 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
isWaitingQr(newVal) {
|
// 移除二维码探测逻辑,确保必须等待指定时间
|
||||||
if (newVal && this.mecmachineId) {
|
|
||||||
this.startQrProbe()
|
|
||||||
} else {
|
|
||||||
this.stopQrProbe()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
mecmachineId(newVal) {
|
|
||||||
if (this.isWaitingQr && newVal) {
|
|
||||||
this.startQrProbe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
if (this.isWaitingQr && this.mecmachineId) {
|
// 移除二维码探测逻辑,确保必须等待指定时间
|
||||||
this.startQrProbe()
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
beforeUnmount() {
|
beforeUnmount() {
|
||||||
this.stopQrProbe()
|
this.stopQrProbe()
|
||||||
@@ -137,21 +139,7 @@ export default {
|
|||||||
const secs = seconds % 60
|
const secs = seconds % 60
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||||
},
|
},
|
||||||
startQrProbe() {
|
// 移除二维码探测方法,确保必须等待指定时间后才显示二维码
|
||||||
this.stopQrProbe()
|
|
||||||
const attemptLoad = () => {
|
|
||||||
if (!this.mecmachineId) return
|
|
||||||
const testImg = new Image()
|
|
||||||
testImg.onload = () => {
|
|
||||||
this.$emit('qrImageLoad')
|
|
||||||
this.stopQrProbe()
|
|
||||||
}
|
|
||||||
testImg.onerror = () => {}
|
|
||||||
testImg.src = `https://uzi1.cn/image/${this.mecmachineId}/二维码.png?t=${Date.now()}`
|
|
||||||
}
|
|
||||||
attemptLoad()
|
|
||||||
this.probeTimer = setInterval(attemptLoad, 1000)
|
|
||||||
},
|
|
||||||
stopQrProbe() {
|
stopQrProbe() {
|
||||||
if (this.probeTimer) {
|
if (this.probeTimer) {
|
||||||
clearInterval(this.probeTimer)
|
clearInterval(this.probeTimer)
|
||||||
@@ -167,29 +155,50 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
background: white;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 20px 20px;
|
padding: 20px;
|
||||||
color: white;
|
background: white;
|
||||||
|
border-bottom: 3px solid #4776e6;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 16px 0;
|
margin: 0;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-region {
|
.region-info {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: #f8f9fa;
|
||||||
padding: 8px 16px;
|
padding: 16px;
|
||||||
border-radius: 20px;
|
margin: 16px;
|
||||||
display: inline-block;
|
border-radius: 8px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
backdrop-filter: blur(10px);
|
color: #4CAF50;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-container {
|
.qr-container {
|
||||||
@@ -198,7 +207,24 @@ export default {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section {
|
||||||
|
background: white;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-waiting {
|
.qr-waiting {
|
||||||
@@ -207,50 +233,65 @@ export default {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 40px 20px;
|
||||||
color: white;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting-text {
|
.waiting-text {
|
||||||
font-size: 20px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 16px 0 8px 0;
|
margin: 16px 0 8px 0;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waiting-desc {
|
.waiting-desc {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.8;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.retry-info {
|
.retry-info {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin: 8px 0 0 0;
|
margin: 8px 0 0 0;
|
||||||
opacity: 0.9;
|
color: #ff9800;
|
||||||
color: #ffd700;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-wrapper {
|
.qr-wrapper {
|
||||||
background: white;
|
background: white;
|
||||||
padding: 20px;
|
padding: 16px;
|
||||||
border-radius: 20px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
border: 2px solid #e9ecef;
|
||||||
margin-bottom: 20px;
|
display: inline-block;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-wrapper.expired {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-code {
|
.qr-code {
|
||||||
width: 200px;
|
width: 220px;
|
||||||
height: 200px;
|
height: 220px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
.countdown-timer {
|
.countdown-timer {
|
||||||
color: white;
|
display: flex;
|
||||||
font-size: 18px;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: #4776e6;
|
||||||
|
font-size: 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
padding: 8px 16px;
|
||||||
|
background: #f3f4ff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-icon {
|
||||||
|
color: #4776e6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qr-expired, .qr-error {
|
.qr-expired, .qr-error {
|
||||||
@@ -259,13 +300,12 @@ export default {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 0 20px;
|
padding: 40px 20px;
|
||||||
color: white;
|
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.warning-icon, .error-icon {
|
.warning-icon, .error-icon {
|
||||||
font-size: 48px;
|
font-size: 64px;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,42 +313,46 @@ export default {
|
|||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.expired-desc, .error-desc {
|
.expired-desc, .error-desc {
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
margin: 0 0 24px 0;
|
margin: 0 0 24px 0;
|
||||||
opacity: 0.8;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn, .retry-btn {
|
.refresh-btn, .retry-btn {
|
||||||
background: white;
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
color: #667eea;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 32px;
|
padding: 12px 32px;
|
||||||
border-radius: 25px;
|
border-radius: 8px;
|
||||||
font-size: 16px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
min-width: 120px;
|
min-width: 120px;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:hover:not(:disabled) {
|
.refresh-btn:hover:not(:disabled),
|
||||||
background: #f0f0f0;
|
.retry-btn:hover:not(:disabled) {
|
||||||
transform: translateY(-2px);
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.refresh-btn:disabled {
|
.refresh-btn:disabled,
|
||||||
opacity: 0.6;
|
.retry-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 40px;
|
width: 48px;
|
||||||
height: 40px;
|
height: 48px;
|
||||||
border: 4px solid #f3f3f3;
|
border: 4px solid #f3f3f3;
|
||||||
border-top: 4px solid #667eea;
|
border-top: 4px solid #4776e6;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -322,15 +366,15 @@ export default {
|
|||||||
.notice-text {
|
.notice-text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: white;
|
background: #f8f9fa;
|
||||||
background: rgba(0, 0, 0, 0.1);
|
border-top: 2px solid #e9ecef;
|
||||||
backdrop-filter: blur(10px);
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-text p {
|
.notice-text p {
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
opacity: 0.9;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.machine-id-info {
|
.machine-id-info {
|
||||||
@@ -341,8 +385,17 @@ export default {
|
|||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.qr-code {
|
.qr-code {
|
||||||
width: 150px;
|
width: 180px;
|
||||||
height: 150px;
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-section {
|
||||||
|
padding: 20px;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qr-title {
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,30 +1,74 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="select-region-page">
|
<div class="select-region-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h1 class="title">请选择您的账号类型</h1>
|
<h1 class="title">订单详情</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="region-buttons">
|
<!-- 订单详情表格 -->
|
||||||
|
<div class="order-details">
|
||||||
|
<div class="detail-row">
|
||||||
|
<div class="detail-label">代练大区</div>
|
||||||
|
<div class="detail-label">状态</div>
|
||||||
|
<div class="detail-label">打手信息</div>
|
||||||
|
<div class="detail-label">目标点数</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-row detail-values">
|
||||||
|
<div class="detail-value region-value">{{ selectedRegion || '未选择' }}</div>
|
||||||
|
<div class="detail-value">待选区</div>
|
||||||
|
<div class="detail-value">{{ mecmachineId || '待分配' }}</div>
|
||||||
|
<div class="detail-value points-value">{{ displayTotalPoints }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选择大区输入框 -->
|
||||||
|
<div class="region-selector">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="regionInput"
|
||||||
|
placeholder="请选择大区"
|
||||||
|
readonly
|
||||||
|
class="region-input"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
@click="$emit('selectRegion', 'Q')"
|
@click="confirmSelection"
|
||||||
class="region-btn qq-btn"
|
class="confirm-btn"
|
||||||
:disabled="submitting"
|
:disabled="!selectedRegion || submitting"
|
||||||
:class="{ 'loading': submitting }"
|
:class="{ 'loading': submitting }"
|
||||||
>
|
>
|
||||||
<div v-if="submitting" class="loading-spinner small"></div>
|
<span v-if="submitting" class="loading-spinner"></span>
|
||||||
<div v-else class="btn-icon">Q</div>
|
<span>{{ submitting ? '正在连接...' : '确认选择' }}</span>
|
||||||
<span class="btn-text">{{ submitting ? '正在连接...' : 'QQ区' }}</span>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 选择区域按钮 -->
|
||||||
|
<div class="region-options">
|
||||||
|
<button
|
||||||
|
@click="selectRegionOption('Q')"
|
||||||
|
class="option-btn qq-option"
|
||||||
|
:class="{ 'selected': selectedRegion === 'QQ区' }"
|
||||||
|
:disabled="submitting"
|
||||||
|
>
|
||||||
|
<div class="option-icon qq-icon">
|
||||||
|
<svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span>QQ区</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@click="$emit('selectRegion', 'V')"
|
@click="selectRegionOption('V')"
|
||||||
class="region-btn wx-btn"
|
class="option-btn wx-option"
|
||||||
|
:class="{ 'selected': selectedRegion === '微信区' }"
|
||||||
:disabled="submitting"
|
:disabled="submitting"
|
||||||
:class="{ 'loading': submitting }"
|
|
||||||
>
|
>
|
||||||
<div v-if="submitting" class="loading-spinner small"></div>
|
<div class="option-icon wx-icon">
|
||||||
<div v-else class="btn-icon">V</div>
|
<svg viewBox="0 0 24 24" width="32" height="32" fill="currentColor">
|
||||||
<span class="btn-text">{{ submitting ? '正在连接...' : '微信区' }}</span>
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
|
||||||
|
<path d="M12 6c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<span>微信区</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -49,9 +93,47 @@ export default {
|
|||||||
mecmachineId: {
|
mecmachineId: {
|
||||||
type: String,
|
type: String,
|
||||||
default: null
|
default: null
|
||||||
|
},
|
||||||
|
totalPoints: {
|
||||||
|
type: Number,
|
||||||
|
default: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['selectRegion']
|
data() {
|
||||||
|
return {
|
||||||
|
selectedRegion: '',
|
||||||
|
regionInput: '',
|
||||||
|
regionCode: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['selectRegion'],
|
||||||
|
computed: {
|
||||||
|
displayTotalPoints() {
|
||||||
|
// 如果 totalPoints 为 null 或 undefined,显示"待分配"
|
||||||
|
// 注意:0 是有效值,应该显示
|
||||||
|
if (this.totalPoints === null || this.totalPoints === undefined) {
|
||||||
|
return '待分配'
|
||||||
|
}
|
||||||
|
return this.totalPoints
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
selectRegionOption(code) {
|
||||||
|
this.regionCode = code
|
||||||
|
if (code === 'Q') {
|
||||||
|
this.selectedRegion = 'QQ区'
|
||||||
|
this.regionInput = 'QQ区'
|
||||||
|
} else if (code === 'V') {
|
||||||
|
this.selectedRegion = '微信区'
|
||||||
|
this.regionInput = '微信区'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmSelection() {
|
||||||
|
if (this.regionCode && !this.submitting) {
|
||||||
|
this.$emit('selectRegion', this.regionCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -60,131 +142,243 @@ export default {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
background: white;
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 40px 20px 20px;
|
padding: 20px;
|
||||||
color: white;
|
background: white;
|
||||||
|
border-bottom: 3px solid #4776e6;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.title {
|
||||||
font-size: 24px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin: 0 0 16px 0;
|
margin: 0;
|
||||||
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-buttons {
|
/* 订单详情表格 */
|
||||||
|
.order-details {
|
||||||
|
background: white;
|
||||||
|
margin: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-values {
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
padding: 12px 8px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-top: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-value {
|
||||||
|
color: #4CAF50;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.points-value {
|
||||||
|
color: #f44336;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选择大区区域 */
|
||||||
|
.region-selector {
|
||||||
|
margin: 16px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: white;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-input::placeholder {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn {
|
||||||
|
padding: 12px 24px;
|
||||||
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.confirm-btn.loading {
|
||||||
|
opacity: 0.8;
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Spinner */
|
||||||
|
.loading-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-top-color: white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 选择区域按钮 */
|
||||||
|
.region-options {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
padding: 0 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-btn {
|
.option-btn {
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 20px;
|
|
||||||
border: none;
|
|
||||||
background: white;
|
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
background: white;
|
||||||
|
border: 3px solid transparent;
|
||||||
|
border-radius: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
font-size: 16px;
|
min-width: 120px;
|
||||||
font-weight: 600;
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-btn:hover {
|
.option-btn:hover:not(:disabled) {
|
||||||
transform: translateY(-5px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 12px 35px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-btn:disabled {
|
.option-btn:disabled {
|
||||||
opacity: 0.6;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
transform: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-btn.loading {
|
.option-icon {
|
||||||
opacity: 0.8;
|
width: 60px;
|
||||||
cursor: not-allowed;
|
height: 60px;
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.region-btn.loading:hover {
|
|
||||||
transform: none;
|
|
||||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-icon {
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 24px;
|
|
||||||
font-weight: bold;
|
|
||||||
color: white;
|
color: white;
|
||||||
margin-bottom: 12px;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qq-btn .btn-icon {
|
.qq-icon {
|
||||||
background: #12B7F5;
|
background: #12B7F5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wx-btn .btn-icon {
|
.wx-icon {
|
||||||
background: #07C160;
|
background: #07C160;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-text {
|
.option-btn span {
|
||||||
color: #333;
|
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner {
|
.option-btn.selected {
|
||||||
width: 40px;
|
border-color: #4776e6;
|
||||||
height: 40px;
|
background: #f3f4ff;
|
||||||
border: 4px solid #f3f3f3;
|
|
||||||
border-top: 4px solid #667eea;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: spin 1s linear infinite;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-spinner.small {
|
.qq-option.selected {
|
||||||
width: 20px;
|
border-color: #12B7F5;
|
||||||
height: 20px;
|
background: #e3f2fd;
|
||||||
border: 2px solid #f3f3f3;
|
|
||||||
border-top: 2px solid #667eea;
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes spin {
|
.qq-option.selected span {
|
||||||
0% { transform: rotate(0deg); }
|
color: #12B7F5;
|
||||||
100% { transform: rotate(360deg); }
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wx-option.selected {
|
||||||
|
border-color: #07C160;
|
||||||
|
background: #e8f5e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wx-option.selected span {
|
||||||
|
color: #07C160;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-text {
|
.notice-text {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
color: white;
|
background: #f8f9fa;
|
||||||
background: rgba(0, 0, 0, 0.1);
|
border-top: 2px solid #e9ecef;
|
||||||
backdrop-filter: blur(10px);
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.notice-text p {
|
.notice-text p {
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
font-size: 14px;
|
font-size: 12px;
|
||||||
opacity: 0.9;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.machine-id-info {
|
.machine-id-info {
|
||||||
@@ -194,22 +388,37 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.region-buttons {
|
.detail-label,
|
||||||
|
.detail-value {
|
||||||
|
padding: 10px 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-selector {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-input,
|
||||||
|
.confirm-btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-options {
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-btn {
|
.option-btn {
|
||||||
width: 100px;
|
min-width: 100px;
|
||||||
height: 100px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.option-icon {
|
||||||
width: 40px;
|
width: 50px;
|
||||||
height: 40px;
|
height: 50px;
|
||||||
font-size: 20px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-text {
|
.option-btn span {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function usePlayState() {
|
|||||||
qrInfo: null,
|
qrInfo: null,
|
||||||
assets: null,
|
assets: null,
|
||||||
currentPoints: 0,
|
currentPoints: 0,
|
||||||
totalPoints: 1000,
|
totalPoints: null, // 修改:初始值改为null,等待从API获取真实数据
|
||||||
completedPoints: 0,
|
completedPoints: 0,
|
||||||
progressDisplayFormat: '1',
|
progressDisplayFormat: '1',
|
||||||
error: null,
|
error: null,
|
||||||
@@ -37,7 +37,9 @@ export function usePlayState() {
|
|||||||
machineId: null,
|
machineId: null,
|
||||||
qrCreatedAt: null,
|
qrCreatedAt: null,
|
||||||
qrExpireAt: null,
|
qrExpireAt: null,
|
||||||
qrDelayTimeoutId: null
|
qrDelayTimeoutId: null,
|
||||||
|
completedAt: null,
|
||||||
|
isCompletedExpired: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const initializePage = async () => {
|
const initializePage = async () => {
|
||||||
@@ -55,6 +57,32 @@ export function usePlayState() {
|
|||||||
const response = await getLinkStatus(state.code)
|
const response = await getLinkStatus(state.code)
|
||||||
const data = response.data
|
const data = response.data
|
||||||
await updateStateFromResponse(data)
|
await updateStateFromResponse(data)
|
||||||
|
|
||||||
|
// 如果是NEW状态,尝试获取游戏界面数据以获取totalPoints
|
||||||
|
if (data.status === 'NEW') {
|
||||||
|
try {
|
||||||
|
const gameResponse = await getGameInterfaceAPI(state.code)
|
||||||
|
const gameData = gameResponse.data
|
||||||
|
console.log('NEW状态 - 游戏界面数据:', gameData)
|
||||||
|
|
||||||
|
// 更新totalPoints和其他可用数据
|
||||||
|
if (gameData.totalPoints !== undefined && gameData.totalPoints !== null) {
|
||||||
|
state.totalPoints = gameData.totalPoints
|
||||||
|
console.log('从游戏界面获取到totalPoints:', state.totalPoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameData.completedPoints !== undefined) {
|
||||||
|
state.completedPoints = gameData.completedPoints
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameData.progressDisplayFormat) {
|
||||||
|
state.progressDisplayFormat = String(gameData.progressDisplayFormat)
|
||||||
|
}
|
||||||
|
} catch (gameError) {
|
||||||
|
// 游戏界面数据获取失败不影响主流程,只记录日志
|
||||||
|
console.log('NEW状态获取游戏界面数据失败(这是正常的):', gameError.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
@@ -84,7 +112,7 @@ export function usePlayState() {
|
|||||||
state.status = 'LOGGED_IN'
|
state.status = 'LOGGED_IN'
|
||||||
|
|
||||||
state.assets = {
|
state.assets = {
|
||||||
qrCodeUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/二维码.png?t=${new Date().getTime()}` : null,
|
qrCodeUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/二维码.png?t=${Date.now()}` : null,
|
||||||
...(gameData.assets || {})
|
...(gameData.assets || {})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +176,25 @@ export function usePlayState() {
|
|||||||
|
|
||||||
state.status = 'COMPLETED'
|
state.status = 'COMPLETED'
|
||||||
|
|
||||||
|
// 保存完成时间戳
|
||||||
|
if (gameData.completedAt) {
|
||||||
|
state.completedAt = gameData.completedAt
|
||||||
|
|
||||||
|
// 判断是否超过24小时
|
||||||
|
const now = Math.floor(Date.now() / 1000) // 当前时间戳(秒)
|
||||||
|
const completedTime = gameData.completedAt
|
||||||
|
const hoursPassed = (now - completedTime) / 3600 // 转换为小时
|
||||||
|
|
||||||
|
state.isCompletedExpired = hoursPassed > 24
|
||||||
|
|
||||||
|
console.log('完成时间判断:', {
|
||||||
|
completedAt: completedTime,
|
||||||
|
now: now,
|
||||||
|
hoursPassed: hoursPassed.toFixed(2),
|
||||||
|
isExpired: state.isCompletedExpired
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 更新区域和机器信息
|
// 更新区域和机器信息
|
||||||
if (gameData.region) {
|
if (gameData.region) {
|
||||||
state.region = gameData.region
|
state.region = gameData.region
|
||||||
@@ -181,6 +228,8 @@ export function usePlayState() {
|
|||||||
totalPoints: state.totalPoints,
|
totalPoints: state.totalPoints,
|
||||||
completedPoints: state.completedPoints,
|
completedPoints: state.completedPoints,
|
||||||
currentPoints: state.currentPoints,
|
currentPoints: state.currentPoints,
|
||||||
|
completedAt: state.completedAt,
|
||||||
|
isCompletedExpired: state.isCompletedExpired,
|
||||||
assets: !!state.assets
|
assets: !!state.assets
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -216,7 +265,8 @@ export function usePlayState() {
|
|||||||
state.qrExpireAt = data.qrExpireAt
|
state.qrExpireAt = data.qrExpireAt
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.totalPoints) {
|
// 优先使用 data.totalPoints,确保在NEW状态也能获取到目标点数
|
||||||
|
if (data.totalPoints !== undefined && data.totalPoints !== null) {
|
||||||
state.totalPoints = data.totalPoints
|
state.totalPoints = data.totalPoints
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,7 +278,8 @@ export function usePlayState() {
|
|||||||
state.progressDisplayFormat = String(data.progressDisplayFormat)
|
state.progressDisplayFormat = String(data.progressDisplayFormat)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.assets && data.assets.totalPoints) {
|
// 如果assets中有totalPoints,也更新
|
||||||
|
if (data.assets && data.assets.totalPoints !== undefined && data.assets.totalPoints !== null) {
|
||||||
state.totalPoints = data.assets.totalPoints
|
state.totalPoints = data.assets.totalPoints
|
||||||
if (state.currentPoints === undefined) {
|
if (state.currentPoints === undefined) {
|
||||||
state.currentPoints = 0
|
state.currentPoints = 0
|
||||||
@@ -240,7 +291,8 @@ export function usePlayState() {
|
|||||||
dataRegion: data.region,
|
dataRegion: data.region,
|
||||||
stateRegion: state.region,
|
stateRegion: state.region,
|
||||||
mecmachineId: data.mecmachineId,
|
mecmachineId: data.mecmachineId,
|
||||||
totalPoints: state.totalPoints,
|
dataTotalPoints: data.totalPoints,
|
||||||
|
stateTotalPoints: state.totalPoints,
|
||||||
completedPoints: state.completedPoints,
|
completedPoints: state.completedPoints,
|
||||||
skipQrProcessing
|
skipQrProcessing
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export function useTimers() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('轮询错误:', error)
|
console.error('轮询错误:', error)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 3000) // 修改:轮询间隔从1秒改为3秒
|
||||||
}
|
}
|
||||||
|
|
||||||
const startProgressPolling = (code, onProgressUpdate) => {
|
const startProgressPolling = (code, onProgressUpdate) => {
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ export const LINK_CONFIG = {
|
|||||||
// 基础域名 - 生产环境需要修改为实际域名
|
// 基础域名 - 生产环境需要修改为实际域名
|
||||||
BASE_URL: import.meta.env.VITE_BASE_URL || 'http://localhost:5173',
|
BASE_URL: import.meta.env.VITE_BASE_URL || 'http://localhost:5173',
|
||||||
|
|
||||||
// 游戏页面路径
|
// 游戏页面路径(已缩短为空,新生成的链接使用短格式,旧链接 /play/:code 仍然兼容)
|
||||||
GAME_PATH: '/play',
|
GAME_PATH: '',
|
||||||
|
|
||||||
// 机器编号参数名
|
// 机器编号参数名
|
||||||
CODE_PARAM: 'code',
|
CODE_PARAM: 'code',
|
||||||
|
|
||||||
// 链接地址模板
|
// 链接地址模板
|
||||||
getLinkUrl: (codeNo) => {
|
getLinkUrl: (codeNo) => {
|
||||||
return `${LINK_CONFIG.BASE_URL}${LINK_CONFIG.GAME_PATH}?${LINK_CONFIG.CODE_PARAM}=${codeNo}`
|
// 如果 GAME_PATH 为空,直接拼接;否则添加斜杠
|
||||||
|
const path = LINK_CONFIG.GAME_PATH ? `${LINK_CONFIG.GAME_PATH}/${codeNo}` : `/${codeNo}`
|
||||||
|
return `${LINK_CONFIG.BASE_URL}${path}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,6 @@
|
|||||||
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></i>
|
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg></i>
|
||||||
<span>公告管理</span>
|
<span>公告管理</span>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
<el-menu-item v-if="canAccessDeviceStatus" index="DeviceStatus" :route="{ name: 'DeviceStatus' }">
|
|
||||||
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="M21 6h-7V4h-4v2H3c-1.1 0-2 .9-2 2v10a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2m0 12H3V8h18v10M8 10h2v6H8v-6m3 0h2v6h-2v-6m3 0h2v6h-2v-6Z"/></svg></i>
|
|
||||||
<span>设备状态</span>
|
|
||||||
</el-menu-item>
|
|
||||||
<el-menu-item v-if="canAccessSettings" index="Settings" :route="{ name: 'Settings' }">
|
<el-menu-item v-if="canAccessSettings" index="Settings" :route="{ name: 'Settings' }">
|
||||||
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="m12 8l-2 4h4l-2 4"/></svg></i>
|
<i class="el-icon"><svg viewBox="0 0 24 24"><path fill="currentColor" d="m12 8l-2 4h4l-2 4"/></svg></i>
|
||||||
<span>系统设置</span>
|
<span>系统设置</span>
|
||||||
@@ -118,7 +114,6 @@ const pageTitleMap = {
|
|||||||
'Links': '链接管理',
|
'Links': '链接管理',
|
||||||
'Refund': '退单管理',
|
'Refund': '退单管理',
|
||||||
'Announcements': '公告管理',
|
'Announcements': '公告管理',
|
||||||
'DeviceStatus': '设备状态',
|
|
||||||
'Settings': '系统设置'
|
'Settings': '系统设置'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,7 +130,6 @@ const canAccessUsers = computed(() => canAccessRoute('Users'))
|
|||||||
const canAccessLinks = computed(() => canAccessRoute('Links'))
|
const canAccessLinks = computed(() => canAccessRoute('Links'))
|
||||||
const canAccessRefund = computed(() => canAccessRoute('Refund'))
|
const canAccessRefund = computed(() => canAccessRoute('Refund'))
|
||||||
const canAccessAnnouncements = computed(() => canAccessRoute('Announcements'))
|
const canAccessAnnouncements = computed(() => canAccessRoute('Announcements'))
|
||||||
const canAccessDeviceStatus = computed(() => canAccessRoute('DeviceStatus'))
|
|
||||||
const canAccessSettings = computed(() => canAccessRoute('Settings'))
|
const canAccessSettings = computed(() => canAccessRoute('Settings'))
|
||||||
|
|
||||||
// 获取积分余额
|
// 获取积分余额
|
||||||
|
|||||||
@@ -12,11 +12,12 @@ const AnnouncementList = () => import('@/views/announcements/AnnouncementList.vu
|
|||||||
const RefundManagement = () => import('@/views/refund/RefundManagement.vue')
|
const RefundManagement = () => import('@/views/refund/RefundManagement.vue')
|
||||||
const Play = () => import('@/views/Play.vue')
|
const Play = () => import('@/views/Play.vue')
|
||||||
const NotFound = () => import('@/views/NotFound.vue')
|
const NotFound = () => import('@/views/NotFound.vue')
|
||||||
const DeviceStatus = () => import('@/views/devices/DeviceStatus.vue')
|
|
||||||
|
|
||||||
export const routes = [
|
export const routes = [
|
||||||
{ path: '/login', name: 'Login', component: Login, meta: { public: true, title: '登录' } },
|
{ path: '/login', name: 'Login', component: Login, meta: { public: true, title: '登录' } },
|
||||||
{ path: '/play', name: 'Play', component: Play, meta: { public: true, title: '上号任务' } },
|
// 保留旧的 /play/:code 路由用于兼容旧链接
|
||||||
|
{ path: '/play/:code', name: 'PlayLegacy', component: Play, meta: { public: true, title: '上号任务' } },
|
||||||
|
{ path: '/play', name: 'PlayLegacyNoCode', component: Play, meta: { public: true, title: '上号任务' } },
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
component: AdminLayout,
|
component: AdminLayout,
|
||||||
@@ -39,9 +40,10 @@ export const routes = [
|
|||||||
{ path: 'links', name: 'Links', component: LinkGenerate, meta: { title: '链接管理' } },
|
{ path: 'links', name: 'Links', component: LinkGenerate, meta: { title: '链接管理' } },
|
||||||
{ path: 'refund', name: 'Refund', component: RefundManagement, meta: { title: '退单管理' } },
|
{ path: 'refund', name: 'Refund', component: RefundManagement, meta: { title: '退单管理' } },
|
||||||
{ path: 'announcements', name: 'Announcements', component: AnnouncementList, meta: { title: '公告管理' } },
|
{ path: 'announcements', name: 'Announcements', component: AnnouncementList, meta: { title: '公告管理' } },
|
||||||
{ path: 'devices', name: 'DeviceStatus', component: DeviceStatus, meta: { title: '设备状态' } },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// 新的短链接路由(放在最后,NotFound之前),用于新生成的链接
|
||||||
|
{ path: '/:code', name: 'Play', component: Play, meta: { public: true, title: '上号任务' } },
|
||||||
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { public: true, title: '未找到' } },
|
{ path: '/:pathMatch(.*)*', name: 'NotFound', component: NotFound, meta: { public: true, title: '未找到' } },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ export const PERMISSIONS = {
|
|||||||
REFUND_EXECUTE: 'refund:execute',
|
REFUND_EXECUTE: 'refund:execute',
|
||||||
REFUND_VIEW: 'refund:view',
|
REFUND_VIEW: 'refund:view',
|
||||||
|
|
||||||
// 设备状态查看
|
|
||||||
DEVICE_VIEW: 'device:view',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角色权限映射
|
// 角色权限映射
|
||||||
@@ -96,7 +94,6 @@ export const ROUTE_PERMISSIONS = {
|
|||||||
'Links': [PERMISSIONS.LINK_VIEW],
|
'Links': [PERMISSIONS.LINK_VIEW],
|
||||||
'Refund': [PERMISSIONS.REFUND_VIEW],
|
'Refund': [PERMISSIONS.REFUND_VIEW],
|
||||||
'Announcements': [PERMISSIONS.ANNOUNCEMENT_VIEW],
|
'Announcements': [PERMISSIONS.ANNOUNCEMENT_VIEW],
|
||||||
'DeviceStatus': [PERMISSIONS.DEVICE_VIEW],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取当前用户信息
|
// 获取当前用户信息
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
v-else-if="state.status === 'NEW' && !state.needRefresh"
|
v-else-if="state.status === 'NEW' && !state.needRefresh"
|
||||||
:submitting="state.submitting"
|
:submitting="state.submitting"
|
||||||
:mecmachine-id="state.mecmachineId || state.machineId"
|
:mecmachine-id="state.mecmachineId || state.machineId"
|
||||||
|
:total-points="state.totalPoints"
|
||||||
@select-region="handleSelectRegion"
|
@select-region="handleSelectRegion"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -141,7 +142,8 @@ export default {
|
|||||||
} = useQrCode()
|
} = useQrCode()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const code = route.query.code
|
// 兼容两种方式:路径参数和查询参数
|
||||||
|
const code = route.params.code || route.query.code
|
||||||
if (!code) {
|
if (!code) {
|
||||||
state.error = 'INVALID_CODE'
|
state.error = 'INVALID_CODE'
|
||||||
state.loading = false
|
state.loading = false
|
||||||
@@ -197,38 +199,8 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleQrReadyEarly = async () => {
|
const handleQrReadyEarly = async () => {
|
||||||
try {
|
// 移除提前显示二维码的逻辑,确保必须等待指定时间后才显示
|
||||||
if (!state.isWaitingQr) return
|
console.log('二维码提前就绪事件被忽略,必须等待指定时间后才显示')
|
||||||
if (!state.mecmachineId) return
|
|
||||||
if (state.qrInfo && state.qrInfo.url) return
|
|
||||||
// 结束等待并清理相关定时器
|
|
||||||
state.isWaitingQr = false
|
|
||||||
clearQrDelayCountdown()
|
|
||||||
if (typeof clearQrDelayTimeout === 'function') {
|
|
||||||
clearQrDelayTimeout(state)
|
|
||||||
}
|
|
||||||
// 立即拉取二维码并开始倒计时与登录轮询
|
|
||||||
await fetchQrCodeAfterDelay(
|
|
||||||
state,
|
|
||||||
countdown,
|
|
||||||
state.mecmachineId,
|
|
||||||
state.qrCreatedAt,
|
|
||||||
state.qrExpireAt
|
|
||||||
)
|
|
||||||
if (state.status === 'USING') {
|
|
||||||
startCountdown()
|
|
||||||
startLoginPolling(state.code, handleLoggedInStatus, handleCompletedStatus)
|
|
||||||
startProgressPolling(state.code, (progressData) => {
|
|
||||||
state.currentPoints = progressData.currentPoints || state.currentPoints
|
|
||||||
state.totalPoints = progressData.totalPoints || state.totalPoints
|
|
||||||
if (progressData.completedPoints !== undefined) {
|
|
||||||
state.completedPoints = progressData.completedPoints
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('提前获取二维码失败:', error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -260,7 +232,7 @@ export default {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.play-container {
|
.play-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="device-status-view">
|
|
||||||
<el-card class="header-card">
|
|
||||||
<template #header>
|
|
||||||
<div class="header">
|
|
||||||
<h3 class="title">设备状态</h3>
|
|
||||||
<div class="actions">
|
|
||||||
<el-button type="primary" :loading="loading" @click="fetchData">
|
|
||||||
<el-icon><Refresh /></el-icon>
|
|
||||||
刷新
|
|
||||||
</el-button>
|
|
||||||
<el-switch v-model="autoRefresh" active-text="自动刷新" @change="onToggleAuto" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<div class="stats">
|
|
||||||
<el-statistic title="设备总数" :value="stats.totalDevices" />
|
|
||||||
<el-statistic title="已占用" :value="stats.runningCount" />
|
|
||||||
<el-statistic title="使用中" :value="stats.usingCount" />
|
|
||||||
<el-statistic title="冷却空闲" :value="stats.idleCooldownCount" />
|
|
||||||
<el-statistic title="空闲" :value="stats.idleFreeCount" />
|
|
||||||
</div>
|
|
||||||
</el-card>
|
|
||||||
|
|
||||||
<el-card class="table-card">
|
|
||||||
<el-table :data="categoryRows" v-loading="loading" border style="width: 100%">
|
|
||||||
<el-table-column prop="label" label="类别" width="140" />
|
|
||||||
<el-table-column prop="count" label="数量" width="100" />
|
|
||||||
<el-table-column label="设备列表" min-width="300">
|
|
||||||
<template #default="{ row }">
|
|
||||||
<div class="devices-list" :title="row.devices.join(', ')">{{ row.preview }}</div>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</el-card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { ref, reactive, onMounted, onUnmounted, computed } from 'vue'
|
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import { getAllDeviceStatus } from '@/api/devices'
|
|
||||||
import { isAdmin, canAccessRoute } from '@/utils/permission'
|
|
||||||
|
|
||||||
// 仅管理员可访问(路由守卫已做拦截,这里再次防御)
|
|
||||||
const allowed = computed(() => isAdmin() || canAccessRoute('DeviceStatus'))
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const autoRefresh = ref(true)
|
|
||||||
const timer = ref(null)
|
|
||||||
|
|
||||||
const stats = reactive({ totalDevices: 0, runningCount: 0, usingCount: 0, idleCooldownCount: 0, idleFreeCount: 0 })
|
|
||||||
const categoryToDevices = ref({})
|
|
||||||
|
|
||||||
const categoryOrder = ['RUNNING', 'USING', 'IDLE_COOLDOWN', 'IDLE_FREE']
|
|
||||||
const categoryLabelMap = { RUNNING: '已占用', USING: '使用中', IDLE_COOLDOWN: '冷却空闲', IDLE_FREE: '空闲' }
|
|
||||||
const categoryRows = computed(() => {
|
|
||||||
const map = categoryToDevices.value || {}
|
|
||||||
return categoryOrder.map(key => {
|
|
||||||
const devices = Array.isArray(map[key]) ? map[key] : []
|
|
||||||
const preview = devices.join(', ')
|
|
||||||
return { key, label: categoryLabelMap[key] || key, count: devices.length, devices, preview }
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
async function fetchData() {
|
|
||||||
if (!allowed.value) return
|
|
||||||
try {
|
|
||||||
loading.value = true
|
|
||||||
const data = await getAllDeviceStatus()
|
|
||||||
categoryToDevices.value = data?.categoryToDevices || {}
|
|
||||||
stats.totalDevices = data?.totalDevices ?? 0
|
|
||||||
stats.runningCount = data?.runningCount ?? (categoryToDevices.value['RUNNING']?.length || 0)
|
|
||||||
stats.usingCount = data?.usingCount ?? (categoryToDevices.value['USING']?.length || 0)
|
|
||||||
stats.idleCooldownCount = data?.idleCooldownCount ?? (categoryToDevices.value['IDLE_COOLDOWN']?.length || 0)
|
|
||||||
stats.idleFreeCount = data?.idleFreeCount ?? (categoryToDevices.value['IDLE_FREE']?.length || 0)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onToggleAuto(val) {
|
|
||||||
if (val) startAuto()
|
|
||||||
else stopAuto()
|
|
||||||
}
|
|
||||||
|
|
||||||
function startAuto() {
|
|
||||||
stopAuto()
|
|
||||||
timer.value = setInterval(fetchData, 2000)
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopAuto() {
|
|
||||||
if (timer.value) {
|
|
||||||
clearInterval(timer.value)
|
|
||||||
timer.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchData()
|
|
||||||
if (autoRefresh.value) startAuto()
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
stopAuto()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.device-status-view { padding: 0; }
|
|
||||||
.header-card { margin-bottom: 16px; }
|
|
||||||
.header { display: flex; align-items: center; justify-content: space-between; }
|
|
||||||
.title { margin: 0; font-size: 18px; font-weight: 600; }
|
|
||||||
.actions { display: flex; gap: 12px; align-items: center; }
|
|
||||||
.stats { display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
|
||||||
.inline-tag { height: 24px; align-items: center; }
|
|
||||||
.devices-list { white-space: normal; word-break: break-all; line-height: 1.6; }
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.title { font-size: 20px; }
|
|
||||||
.actions { gap: 8px; }
|
|
||||||
.stats { gap: 12px; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,5 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="link-generate" :class="{ 'mobile-layout': isMobile }">
|
<div class="link-generate" :class="{ 'mobile-layout': isMobile }">
|
||||||
|
<!-- 设备状态提示 -->
|
||||||
|
<div class="device-status-alert" v-if="deviceStatusLoaded">
|
||||||
|
<el-alert
|
||||||
|
:title="deviceStatusText"
|
||||||
|
:type="deviceStatusType"
|
||||||
|
show-icon
|
||||||
|
:closable="false"
|
||||||
|
class="status-alert"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 生成表单 -->
|
<!-- 生成表单 -->
|
||||||
<el-card class="generate-form-card">
|
<el-card class="generate-form-card">
|
||||||
<template #header>
|
<template #header>
|
||||||
@@ -525,6 +536,7 @@ import {
|
|||||||
import { generateLinks, fetchLinks, deleteLink, batchDeleteLinks, batchDeleteByStatus } from '@/api/links'
|
import { generateLinks, fetchLinks, deleteLink, batchDeleteLinks, batchDeleteByStatus } from '@/api/links'
|
||||||
import { copyToClipboard as copyText, exportToExcel as exportExcelUtil } from '@/utils/links'
|
import { copyToClipboard as copyText, exportToExcel as exportExcelUtil } from '@/utils/links'
|
||||||
import { LINK_CONFIG, STATUS_CONFIG, EXPORT_CONFIG } from '@/config/links'
|
import { LINK_CONFIG, STATUS_CONFIG, EXPORT_CONFIG } from '@/config/links'
|
||||||
|
import { getAllDeviceStatus } from '@/api/devices'
|
||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const generateFormRef = ref()
|
const generateFormRef = ref()
|
||||||
@@ -534,6 +546,11 @@ const linkList = ref([])
|
|||||||
|
|
||||||
const isMobile = ref(false)
|
const isMobile = ref(false)
|
||||||
|
|
||||||
|
// 设备状态相关
|
||||||
|
const deviceStatusLoaded = ref(false)
|
||||||
|
const deviceStatusText = ref('')
|
||||||
|
const deviceStatusType = ref('info')
|
||||||
|
|
||||||
// 检测移动端
|
// 检测移动端
|
||||||
const checkMobile = () => {
|
const checkMobile = () => {
|
||||||
isMobile.value = window.innerWidth <= 768
|
isMobile.value = window.innerWidth <= 768
|
||||||
@@ -692,6 +709,33 @@ const refreshList = () => {
|
|||||||
return getLinkList()
|
return getLinkList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取设备状态
|
||||||
|
const getDeviceStatus = async () => {
|
||||||
|
try {
|
||||||
|
const data = await getAllDeviceStatus()
|
||||||
|
|
||||||
|
// 计算空闲设备数量(冷却空闲 + 完全空闲)
|
||||||
|
const idleCooldownCount = data?.idleCooldownCount || 0
|
||||||
|
const idleFreeCount = data?.idleFreeCount || 0
|
||||||
|
const totalIdleCount = idleCooldownCount + idleFreeCount
|
||||||
|
|
||||||
|
deviceStatusLoaded.value = true
|
||||||
|
|
||||||
|
if (totalIdleCount > 0) {
|
||||||
|
deviceStatusText.value = `当前有空闲设备可用`
|
||||||
|
deviceStatusType.value = 'success'
|
||||||
|
} else {
|
||||||
|
deviceStatusText.value = '当前暂无空闲设备,请稍后再试'
|
||||||
|
deviceStatusType.value = 'warning'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取设备状态失败:', error)
|
||||||
|
deviceStatusLoaded.value = true
|
||||||
|
deviceStatusText.value = '无法获取设备状态'
|
||||||
|
deviceStatusType.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 分页处理
|
// 分页处理
|
||||||
const handleSizeChange = (size) => {
|
const handleSizeChange = (size) => {
|
||||||
pagination.pageSize = size
|
pagination.pageSize = size
|
||||||
@@ -1001,13 +1045,17 @@ const downloadCurrentBatch = async () => {
|
|||||||
try {
|
try {
|
||||||
batchDownloadDialog.downloading = true
|
batchDownloadDialog.downloading = true
|
||||||
|
|
||||||
// 根据批次ID筛选当前批次的数据
|
// 通过API获取该批次的所有数据(不受分页限制)
|
||||||
const currentBatchData = linkList.value.filter(item =>
|
const response = await fetchLinks({
|
||||||
item.batchId === batchDownloadDialog.batchId
|
batchId: batchDownloadDialog.batchId,
|
||||||
)
|
page: 1,
|
||||||
|
pageSize: 1000 // 设置一个足够大的值以获取所有数据
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentBatchData = response.data.items || []
|
||||||
|
|
||||||
if (currentBatchData.length === 0) {
|
if (currentBatchData.length === 0) {
|
||||||
ElMessage.warning('未找到当前批次的数据,请刷新列表后重试')
|
ElMessage.warning('未找到当前批次的数据,请重试')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1055,6 +1103,8 @@ const copyCurrentBatchSingleLink = async () => {
|
|||||||
if (batchDownloadDialog.count !== 1 || !batchDownloadDialog.singleCodeNo) return
|
if (batchDownloadDialog.count !== 1 || !batchDownloadDialog.singleCodeNo) return
|
||||||
const url = generateLinkUrl(batchDownloadDialog.singleCodeNo)
|
const url = generateLinkUrl(batchDownloadDialog.singleCodeNo)
|
||||||
await copyToClipboard(url)
|
await copyToClipboard(url)
|
||||||
|
// 复制完成后关闭弹窗
|
||||||
|
closeBatchDownloadDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBatchDeleteByStatus = async () => {
|
const handleBatchDeleteByStatus = async () => {
|
||||||
@@ -1118,6 +1168,7 @@ onMounted(() => {
|
|||||||
checkMobile()
|
checkMobile()
|
||||||
window.addEventListener('resize', checkMobile)
|
window.addEventListener('resize', checkMobile)
|
||||||
getLinkList()
|
getLinkList()
|
||||||
|
getDeviceStatus()
|
||||||
|
|
||||||
// 初始化表单默认值
|
// 初始化表单默认值
|
||||||
generateForm.times = 1
|
generateForm.times = 1
|
||||||
@@ -1136,6 +1187,15 @@ onUnmounted(() => {
|
|||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 设备状态提示样式 */
|
||||||
|
.device-status-alert {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-alert {
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.generate-form-card,
|
.generate-form-card,
|
||||||
.link-list-card {
|
.link-list-card {
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
@@ -1458,6 +1518,14 @@ onUnmounted(() => {
|
|||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-status-alert {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-alert {
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.mobile-layout {
|
.mobile-layout {
|
||||||
background: #f5f7fa;
|
background: #f5f7fa;
|
||||||
}
|
}
|
||||||
@@ -1511,9 +1579,9 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.primary-action-btn {
|
.primary-action-btn {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
|
box-shadow: 0 4px 15px rgba(71, 118, 230, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pagination-wrapper {
|
.pagination-wrapper {
|
||||||
|
|||||||
@@ -325,18 +325,18 @@ export default {
|
|||||||
midRewardUrl: gameData.midRewardUrl,
|
midRewardUrl: gameData.midRewardUrl,
|
||||||
endRewardUrl: gameData.endRewardUrl,
|
endRewardUrl: gameData.endRewardUrl,
|
||||||
|
|
||||||
qrCodeUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/二维码.png?t=${new Date().getTime()}` : null,
|
qrCodeUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/二维码.png?t=${Date.now()}` : null,
|
||||||
// 保留原有的assets数据(如果存在)
|
// 保留原有的assets数据(如果存在)
|
||||||
...(gameData.assets || {})
|
...(gameData.assets || {})
|
||||||
}
|
}
|
||||||
}else{
|
}else{
|
||||||
state.assets = {
|
state.assets = {
|
||||||
homepageUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/首次主页.png?t=${new Date().getTime()}` : null,
|
homepageUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/首次主页.png?t=${Date.now()}` : null,
|
||||||
firstRewardUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/首次赏金.png?t=${new Date().getTime()}` : null,
|
firstRewardUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/首次赏金.png?t=${Date.now()}` : null,
|
||||||
midRewardUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/中途赏金.png?t=${new Date().getTime()}` : null,
|
midRewardUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/中途赏金.png?t=${Date.now()}` : null,
|
||||||
endRewardUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/结束赏金.png?t=${new Date().getTime()}` : null,
|
endRewardUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/结束赏金.png?t=${Date.now()}` : null,
|
||||||
|
|
||||||
qrCodeUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/二维码.png?t=${new Date().getTime()}` : null,
|
qrCodeUrl: gameData.mecmachineId ? `https://uzi1.cn/image/${gameData.mecmachineId}/二维码.png?t=${Date.now()}` : null,
|
||||||
// 保留原有的assets数据(如果存在)
|
// 保留原有的assets数据(如果存在)
|
||||||
...(gameData.assets || {})
|
...(gameData.assets || {})
|
||||||
|
|
||||||
@@ -791,7 +791,7 @@ export default {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('轮询错误:', error)
|
console.error('轮询错误:', error)
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 3000) // 修改:轮询间隔从1秒改为3秒
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开始进度轮询
|
// 开始进度轮询
|
||||||
@@ -1024,7 +1024,7 @@ export default {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.play-container {
|
.play-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #4776e6 0%, #8e54e9 100%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1050,7 +1050,7 @@ export default {
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
border: 4px solid #f3f3f3;
|
border: 4px solid #f3f3f3;
|
||||||
border-top: 4px solid #667eea;
|
border-top: 4px solid #4776e6;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
@@ -1060,7 +1060,7 @@ export default {
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
border: 2px solid #f3f3f3;
|
border: 2px solid #f3f3f3;
|
||||||
border-top: 2px solid #667eea;
|
border-top: 2px solid #4776e6;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1273,7 +1273,7 @@ export default {
|
|||||||
|
|
||||||
.refresh-btn, .retry-btn {
|
.refresh-btn, .retry-btn {
|
||||||
background: white;
|
background: white;
|
||||||
color: #667eea;
|
color: #4776e6;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 32px;
|
padding: 12px 32px;
|
||||||
border-radius: 25px;
|
border-radius: 25px;
|
||||||
@@ -1574,7 +1574,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn {
|
.retry-btn {
|
||||||
background: #667eea;
|
background: #4776e6;
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 32px;
|
padding: 12px 32px;
|
||||||
@@ -1586,7 +1586,7 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.retry-btn:hover {
|
.retry-btn:hover {
|
||||||
background: #5a6fd8;
|
background: #3d6ad6;
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,9 +187,8 @@
|
|||||||
|
|
||||||
<el-collapse-item title="输入格式说明" name="5">
|
<el-collapse-item title="输入格式说明" name="5">
|
||||||
<ul class="help-list">
|
<ul class="help-list">
|
||||||
<li>支持直接输入链接编号,如:<code class="mono">RUC74NCV</code></li>
|
<li>支持直接输入链接编号,如:<code class="mono">6DJWZ4MA</code></li>
|
||||||
<li>支持输入完整链接URL,如:<code class="mono">http://localhost:5173/play?code=RUC74NCV</code></li>
|
<li>支持粘贴完整链接URL,系统会自动识别并提取编号</li>
|
||||||
<li>系统会自动从URL中提取链接编号</li>
|
|
||||||
<li>支持使用粘贴按钮或Ctrl+V快速粘贴</li>
|
<li>支持使用粘贴按钮或Ctrl+V快速粘贴</li>
|
||||||
</ul>
|
</ul>
|
||||||
</el-collapse-item>
|
</el-collapse-item>
|
||||||
@@ -343,7 +342,7 @@ export default {
|
|||||||
// 移除首尾空格
|
// 移除首尾空格
|
||||||
const trimmed = input.trim()
|
const trimmed = input.trim()
|
||||||
|
|
||||||
// 如果是URL,尝试提取code参数
|
// 方式1: 尝试从旧格式URL提取 (play?code=xxx)
|
||||||
try {
|
try {
|
||||||
if (trimmed.includes('play?code=')) {
|
if (trimmed.includes('play?code=')) {
|
||||||
const url = new URL(trimmed)
|
const url = new URL(trimmed)
|
||||||
@@ -354,23 +353,48 @@ export default {
|
|||||||
// 如果URL解析失败,继续下面的处理
|
// 如果URL解析失败,继续下面的处理
|
||||||
}
|
}
|
||||||
|
|
||||||
// 尝试从各种URL格式中提取代码
|
// 方式2: 尝试从查询参数中提取 code=
|
||||||
if (trimmed.includes('code=')) {
|
if (trimmed.includes('code=')) {
|
||||||
const match = trimmed.match(/code=([^&\s]+)/)
|
const match = trimmed.match(/code=([^&\s]+)/)
|
||||||
if (match) return match[1]
|
if (match) return match[1]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果包含路径分隔符,取最后一个部分
|
// 方式3: 尝试从新格式URL提取 (play/CODE 或 play/CODE?t=xxx)
|
||||||
|
// 匹配 /play/CODE 格式,CODE后面可能跟查询参数
|
||||||
|
const playPathMatch = trimmed.match(/\/play\/([A-Z0-9]+)(?:\?|$|\/)/i)
|
||||||
|
if (playPathMatch) {
|
||||||
|
return playPathMatch[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方式4: 如果包含路径分隔符,取最后一个部分(去除查询参数)
|
||||||
if (trimmed.includes('/')) {
|
if (trimmed.includes('/')) {
|
||||||
const parts = trimmed.split('/')
|
const parts = trimmed.split('/')
|
||||||
const lastPart = parts[parts.length - 1]
|
let lastPart = parts[parts.length - 1]
|
||||||
|
|
||||||
|
// 去除查询参数部分
|
||||||
|
if (lastPart.includes('?')) {
|
||||||
|
lastPart = lastPart.split('?')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 去除hash部分
|
||||||
|
if (lastPart.includes('#')) {
|
||||||
|
lastPart = lastPart.split('#')[0]
|
||||||
|
}
|
||||||
|
|
||||||
if (lastPart && lastPart.length >= 6) {
|
if (lastPart && lastPart.length >= 6) {
|
||||||
return lastPart
|
return lastPart
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 直接返回输入(假设就是代码)
|
// 方式5: 直接返回输入(假设就是代码,去除可能的查询参数)
|
||||||
return trimmed
|
let result = trimmed
|
||||||
|
if (result.includes('?')) {
|
||||||
|
result = result.split('?')[0]
|
||||||
|
}
|
||||||
|
if (result.includes('#')) {
|
||||||
|
result = result.split('#')[0]
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查询
|
// 查询
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://127.0.0.1:18080',
|
target: 'https://uzi1.cn/api',
|
||||||
|
// target: 'http://localhost:18080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
rewrite: (p) => p.replace(/^\/api/, ''),
|
rewrite: (p) => p.replace(/^\/api/, ''),
|
||||||
},
|
},
|
||||||
|
|||||||
282
部署指南.md
Normal file
282
部署指南.md
Normal file
@@ -0,0 +1,282 @@
|
|||||||
|
# 游戏平台前端项目部署指南
|
||||||
|
|
||||||
|
## 项目概述
|
||||||
|
|
||||||
|
本项目是一个基于 Vue 3 + Vite 的单页应用(SPA),包含管理后台和游戏功能。项目使用 Element Plus 作为 UI 组件库,Axios 进行 HTTP 请求。
|
||||||
|
|
||||||
|
## 📋 环境要求
|
||||||
|
|
||||||
|
### 开发环境
|
||||||
|
- Node.js 版本:>= 16.0.0
|
||||||
|
- npm 版本:>= 8.0.0 或 yarn >= 1.22.0
|
||||||
|
|
||||||
|
### 生产环境
|
||||||
|
- Web 服务器:Nginx(推荐)或 Apache
|
||||||
|
- 后端 API 服务:需要运行在 `http://192.140.164.137:18080`
|
||||||
|
|
||||||
|
## 🚀 快速部署
|
||||||
|
|
||||||
|
### 1. 准备工作
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 克隆项目(如果需要)
|
||||||
|
git clone <repository-url>
|
||||||
|
cd login_task_web
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 验证开发环境
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 构建生产版本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建项目
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 构建完成后,dist 目录包含所有静态文件
|
||||||
|
```
|
||||||
|
|
||||||
|
构建产物说明:
|
||||||
|
- `dist/index.html` - 主页面文件
|
||||||
|
- `dist/assets/` - 静态资源(JS、CSS、图片等)
|
||||||
|
|
||||||
|
### 3. 部署到服务器
|
||||||
|
|
||||||
|
将 `dist` 目录下的所有文件上传到 Web 服务器的网站根目录。
|
||||||
|
|
||||||
|
## 🔧 服务器配置
|
||||||
|
|
||||||
|
由于本项目是单页应用(SPA),需要正确配置服务器以支持:
|
||||||
|
1. **路由回退**:所有前端路由都应返回 `index.html`
|
||||||
|
2. **API 代理**:代理 `/api/*` 请求到后端服务器
|
||||||
|
3. **CORS 处理**:解决跨域问题
|
||||||
|
|
||||||
|
### 🌟 方式一:Nginx 配置(推荐)
|
||||||
|
|
||||||
|
#### 使用项目提供的配置文件
|
||||||
|
|
||||||
|
1. **复制 Nginx 配置**
|
||||||
|
```bash
|
||||||
|
cp nginx.conf /etc/nginx/sites-available/your-site.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **修改配置文件中的路径**
|
||||||
|
```nginx
|
||||||
|
# 修改为您的实际部署路径
|
||||||
|
root /var/www/your-site/dist;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **启用站点并重载**
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/your-site.conf /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 完整 Nginx 配置示例
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
root /var/www/your-site/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# 静态资源缓存
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# API 代理
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://192.140.164.137:18080/;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# CORS 设置
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
|
||||||
|
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
|
||||||
|
|
||||||
|
# 处理预检请求
|
||||||
|
if ($request_method = 'OPTIONS') {
|
||||||
|
add_header Access-Control-Allow-Origin *;
|
||||||
|
add_header Access-Control-Allow-Methods 'GET, POST, PUT, DELETE, OPTIONS';
|
||||||
|
add_header Access-Control-Allow-Headers 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range,Authorization';
|
||||||
|
add_header Access-Control-Max-Age 1728000;
|
||||||
|
add_header Content-Type 'text/plain; charset=utf-8';
|
||||||
|
add_header Content-Length 0;
|
||||||
|
return 204;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA 路由配置(关键!)
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ @fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
location @fallback {
|
||||||
|
rewrite ^.*$ /index.html last;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 错误页面
|
||||||
|
error_page 404 /index.html;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🛠️ 方式二:宝塔面板部署
|
||||||
|
|
||||||
|
参考项目中的 `宝塔面板操作步骤.md` 文件,关键步骤:
|
||||||
|
|
||||||
|
1. **上传文件**
|
||||||
|
- 将 `dist` 目录下所有文件上传到网站根目录
|
||||||
|
|
||||||
|
2. **修改 Nginx 配置**
|
||||||
|
- 登录宝塔面板 → 网站 → 设置 → 配置文件
|
||||||
|
- 在 `#REWRITE-END` 后添加 API 代理配置
|
||||||
|
- 添加 SPA 路由回退配置
|
||||||
|
|
||||||
|
3. **保存并重载配置**
|
||||||
|
|
||||||
|
### 🔨 方式三:Apache 配置
|
||||||
|
|
||||||
|
1. **复制 .htaccess 文件**
|
||||||
|
```bash
|
||||||
|
cp apache.htaccess dist/.htaccess
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **确保 Apache 模块已启用**
|
||||||
|
```bash
|
||||||
|
sudo a2enmod rewrite
|
||||||
|
sudo a2enmod headers
|
||||||
|
sudo a2enmod proxy
|
||||||
|
sudo a2enmod proxy_http
|
||||||
|
sudo systemctl restart apache2
|
||||||
|
```
|
||||||
|
|
||||||
|
### ☁️ 方式四:Netlify 部署
|
||||||
|
|
||||||
|
1. **复制重定向文件**
|
||||||
|
```bash
|
||||||
|
cp _redirects dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **部署到 Netlify**
|
||||||
|
- 上传 `dist` 目录到 Netlify
|
||||||
|
- 或连接 Git 仓库自动部署
|
||||||
|
|
||||||
|
## 🧪 部署验证
|
||||||
|
|
||||||
|
### 功能测试清单
|
||||||
|
|
||||||
|
- [ ] 主页访问:`http://your-domain.com/`
|
||||||
|
- [ ] 直接路由访问:`http://your-domain.com/play?code=xxx`
|
||||||
|
- [ ] 页面刷新:在任意页面刷新不应出现 404
|
||||||
|
- [ ] API 请求:检查网络面板,确认 API 请求正常
|
||||||
|
- [ ] 登录功能:测试用户登录流程
|
||||||
|
- [ ] 游戏功能:测试游戏相关功能
|
||||||
|
|
||||||
|
### 验证命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 测试 Nginx 配置
|
||||||
|
sudo nginx -t
|
||||||
|
|
||||||
|
# 查看错误日志
|
||||||
|
sudo tail -f /var/log/nginx/error.log
|
||||||
|
|
||||||
|
# 测试 API 连通性
|
||||||
|
curl -I http://your-domain.com/api/health
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚨 常见问题及解决方案
|
||||||
|
|
||||||
|
### 问题 1:直接访问路由出现 404
|
||||||
|
**原因**:Web 服务器未配置 SPA 路由回退
|
||||||
|
**解决**:按照上述配置添加路由回退规则
|
||||||
|
|
||||||
|
### 问题 2:API 请求失败 (CORS 错误)
|
||||||
|
**原因**:跨域配置不正确
|
||||||
|
**解决**:
|
||||||
|
1. 检查 API 代理配置
|
||||||
|
2. 确认后端服务 `http://192.140.164.137:18080` 可访问
|
||||||
|
3. 检查 CORS 头部设置
|
||||||
|
|
||||||
|
### 问题 3:静态资源加载失败
|
||||||
|
**原因**:资源路径配置问题
|
||||||
|
**解决**:
|
||||||
|
1. 检查 `vite.config.js` 中的 `base` 配置
|
||||||
|
2. 确认所有文件都已正确上传
|
||||||
|
|
||||||
|
### 问题 4:页面空白
|
||||||
|
**原因**:通常是 JavaScript 错误
|
||||||
|
**解决**:
|
||||||
|
1. 打开浏览器开发者工具查看控制台错误
|
||||||
|
2. 检查网络请求是否正常
|
||||||
|
3. 确认构建过程无错误
|
||||||
|
|
||||||
|
## 📊 性能优化建议
|
||||||
|
|
||||||
|
### 1. 启用 Gzip 压缩
|
||||||
|
```nginx
|
||||||
|
# 在 Nginx 配置中添加
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 配置缓存策略
|
||||||
|
- 静态资源(JS、CSS、图片):长期缓存
|
||||||
|
- HTML 文件:短期缓存或无缓存
|
||||||
|
|
||||||
|
### 3. CDN 加速
|
||||||
|
考虑使用 CDN 服务加速静态资源访问
|
||||||
|
|
||||||
|
## 🔒 安全建议
|
||||||
|
|
||||||
|
1. **HTTPS 配置**:生产环境应启用 HTTPS
|
||||||
|
2. **API 安全**:确保后端 API 有适当的安全验证
|
||||||
|
3. **访问控制**:配置防火墙规则
|
||||||
|
4. **定期更新**:保持依赖库和服务器软件更新
|
||||||
|
|
||||||
|
## 📱 移动端适配
|
||||||
|
|
||||||
|
项目已包含移动端检测,确保在移动设备上访问体验良好。
|
||||||
|
|
||||||
|
## 🔄 更新部署
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 拉取最新代码
|
||||||
|
git pull origin main
|
||||||
|
|
||||||
|
# 2. 安装/更新依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 3. 重新构建
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# 4. 上传新的构建文件
|
||||||
|
# 替换服务器上的 dist 目录内容
|
||||||
|
|
||||||
|
# 5. 重载服务器配置(如有必要)
|
||||||
|
sudo nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📞 技术支持
|
||||||
|
|
||||||
|
如果在部署过程中遇到问题:
|
||||||
|
|
||||||
|
1. 首先查看上述常见问题
|
||||||
|
2. 检查服务器错误日志
|
||||||
|
3. 确认网络和防火墙配置
|
||||||
|
4. 验证后端服务可用性
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**注意**:本文档基于当前项目配置编写,如果项目配置有变更,请相应更新此文档。
|
||||||
Reference in New Issue
Block a user