first commit
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
97
.trae/documents/抽奖系统产品需求文档.md
Normal file
97
.trae/documents/抽奖系统产品需求文档.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# 抽奖系统产品需求文档
|
||||
|
||||
## 1. Product Overview
|
||||
|
||||
本产品是一个基于H5技术的学号抽奖系统,为学校活动提供公平、透明的抽奖服务。系统支持多级奖项设置、实时数据统计和离线缓存功能,确保在网络异常情况下也能正常运行。
|
||||
|
||||
- 解决传统抽奖方式不够透明、数据难以统计的问题,为学校师生提供现代化的抽奖解决方案。
|
||||
- 目标是成为校园活动的标准抽奖工具,提升活动参与度和公信力。
|
||||
|
||||
## 2. Core Features
|
||||
|
||||
### 2.1 User Roles
|
||||
|
||||
| Role | Registration Method | Core Permissions |
|
||||
|------|---------------------|------------------|
|
||||
| 管理员 | 密码登录 | 系统配置、奖项设置、数据查看、背景管理 |
|
||||
| 学生用户 | 学号输入 | 参与抽奖、查看中奖结果 |
|
||||
|
||||
### 2.2 Feature Module
|
||||
|
||||
我们的抽奖系统包含以下主要页面:
|
||||
|
||||
1. **登录页面**:密码验证、系统准入控制
|
||||
2. **抽奖主界面**:三列布局展示、学号输入、屏幕键盘、抽奖操作
|
||||
3. **后台管理页面**:奖项配置、抽奖次数设置、数据统计
|
||||
4. **数据查询页面**:中奖记录查看、学号隐私保护
|
||||
5. **系统设置页面**:背景图片管理、界面配置
|
||||
|
||||
### 2.3 Page Details
|
||||
|
||||
| Page Name | Module Name | Feature description |
|
||||
|-----------|-------------|---------------------|
|
||||
| 登录页面 | 密码验证模块 | 输入系统密码,验证通过后进入抽奖系统 |
|
||||
| 抽奖主界面 | 奖项展示区 | 显示各级奖品名称、中奖概率、已中出数量(左侧2/9宽度) |
|
||||
| 抽奖主界面 | 学号输入区 | 12位数字学号输入框、屏幕数字键盘(140px*140px,间距10px)、退格清空按钮(中间5/9宽度) |
|
||||
| 抽奖主界面 | 开奖结果区 | 实时显示中奖情况、历史记录(右侧2/9宽度) |
|
||||
| 抽奖主界面 | 抽奖动画模块 | 弹出浮窗、3秒滚动摇奖效果、中奖信息展示 |
|
||||
| 后台管理页面 | 奖项配置模块 | 设置总奖数、一二三等奖名称数量、抽奖次数限制 |
|
||||
| 后台管理页面 | 数据统计模块 | 查看所有中奖数据、学号记录、抽奖时间 |
|
||||
| 数据查询页面 | 查询接口模块 | 实时查看出奖情况、学号隐私保护(*号替换) |
|
||||
| 系统设置页面 | 背景管理模块 | 本地图片选择、背景大小位置调整 |
|
||||
| 系统设置页面 | 缓存同步模块 | 本地缓存管理、网络恢复后数据同步 |
|
||||
|
||||
## 3. Core Process
|
||||
|
||||
**管理员流程:**
|
||||
系统启动 → 密码登录 → 奖项配置 → 背景设置 → 启动抽奖 → 实时监控 → 数据导出
|
||||
|
||||
**学生用户流程:**
|
||||
进入抽奖页面 → 输入12位学号 → 点击抽奖按钮 → 观看摇奖动画 → 查看中奖结果 → 结束或继续抽奖
|
||||
|
||||
**系统内部流程:**
|
||||
学号验证 → 抽奖次数检查 → 本地缓存记录 → 概率计算 → 结果生成 → 数据同步
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[系统启动] --> B[密码登录]
|
||||
B --> C[抽奖主界面]
|
||||
C --> D[学号输入]
|
||||
D --> E[抽奖次数验证]
|
||||
E --> F[抽奖动画]
|
||||
F --> G[结果展示]
|
||||
G --> H[数据记录]
|
||||
H --> I[本地缓存]
|
||||
I --> J[网络同步]
|
||||
|
||||
C --> K[后台管理]
|
||||
K --> L[奖项配置]
|
||||
K --> M[数据查询]
|
||||
K --> N[系统设置]
|
||||
```
|
||||
|
||||
## 4. User Interface Design
|
||||
|
||||
### 4.1 Design Style
|
||||
|
||||
- **主色调**:#1890FF(蓝色主题),#52C41A(成功绿色)
|
||||
- **辅助色**:#FAAD14(警告黄色),#F5222D(错误红色)
|
||||
- **按钮样式**:圆角矩形,3D立体效果,悬停渐变动画
|
||||
- **字体**:微软雅黑 16px-24px,数字使用等宽字体
|
||||
- **布局风格**:卡片式设计,顶部导航,响应式网格布局
|
||||
- **图标风格**:线性图标配合实心填充,统一视觉风格
|
||||
|
||||
### 4.2 Page Design Overview
|
||||
|
||||
| Page Name | Module Name | UI Elements |
|
||||
|-----------|-------------|-------------|
|
||||
| 登录页面 | 密码输入框 | 居中卡片布局,深色背景,白色输入框,蓝色登录按钮 |
|
||||
| 抽奖主界面 | 三列布局 | 1920*1080全屏,左中右2:5:2比例,无滚动条设计 |
|
||||
| 抽奖主界面 | 屏幕键盘 | 3*4数字矩阵,140px*140px按钮,10px间距,触摸反馈 |
|
||||
| 抽奖主界面 | 抽奖动画 | 模态弹窗,旋转滚动效果,3秒倒计时,结果高亮显示 |
|
||||
| 后台管理页面 | 配置表单 | 表格布局,输入框验证,实时预览,保存确认 |
|
||||
| 数据查询页面 | 数据表格 | 分页显示,搜索过滤,学号脱敏,导出功能 |
|
||||
|
||||
### 4.3 Responsiveness
|
||||
|
||||
系统主要针对1920*1080分辨率设计,桌面优先适配。支持触摸交互优化,屏幕键盘具备触觉反馈效果。在较小屏幕上自动调整布局比例,确保核心功能可用性。
|
||||
247
.trae/documents/抽奖系统技术架构文档.md
Normal file
247
.trae/documents/抽奖系统技术架构文档.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# 抽奖系统技术架构文档
|
||||
|
||||
## 1. Architecture design
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户浏览器] --> B[React前端应用]
|
||||
B --> C[SQLite数据库接口]
|
||||
C --> D[SQLite本地数据库]
|
||||
B --> E[本地缓存 LocalStorage]
|
||||
B --> F[文件系统存储]
|
||||
|
||||
subgraph "前端层"
|
||||
B
|
||||
E
|
||||
end
|
||||
|
||||
subgraph "数据层"
|
||||
C
|
||||
D
|
||||
F
|
||||
end
|
||||
```
|
||||
|
||||
## 2. Technology Description
|
||||
|
||||
- Frontend: React@18 + TypeScript + Tailwind CSS@3 + Vite + Framer Motion
|
||||
- Database: SQLite + better-sqlite3
|
||||
- 状态管理: Zustand
|
||||
- 本地存储: LocalStorage + SQLite
|
||||
- 动画库: Framer Motion + CSS3 Animations
|
||||
- 文件处理: Node.js File System API
|
||||
|
||||
## 3. Route definitions
|
||||
|
||||
| Route | Purpose |
|
||||
|-------|---------|
|
||||
| / | 登录页面,密码验证和系统准入 |
|
||||
| /lottery | 抽奖主界面,三列布局的核心抽奖功能 |
|
||||
| /admin | 后台管理页面,奖项配置和系统设置 |
|
||||
| /admin/prizes | 奖项管理子页面,设置各级奖品信息 |
|
||||
| /admin/records | 数据查询子页面,查看中奖记录 |
|
||||
| /admin/settings | 系统设置子页面,背景和参数配置 |
|
||||
|
||||
## 4. API definitions
|
||||
|
||||
### 4.1 Core API
|
||||
|
||||
**身份认证相关**
|
||||
```
|
||||
POST /api/auth/login
|
||||
```
|
||||
|
||||
Request:
|
||||
| Param Name | Param Type | isRequired | Description |
|
||||
|------------|------------|------------|-------------|
|
||||
| password | string | true | 系统管理密码 |
|
||||
|
||||
Response:
|
||||
| Param Name | Param Type | Description |
|
||||
|------------|------------|-------------|
|
||||
| success | boolean | 登录是否成功 |
|
||||
| session | string | 会话标识 |
|
||||
|
||||
**抽奖相关**
|
||||
```
|
||||
POST /api/lottery/draw
|
||||
```
|
||||
|
||||
Request:
|
||||
| Param Name | Param Type | isRequired | Description |
|
||||
|------------|------------|------------|-------------|
|
||||
| studentId | string | true | 12位学号 |
|
||||
| timestamp | number | true | 抽奖时间戳 |
|
||||
|
||||
Response:
|
||||
| Param Name | Param Type | Description |
|
||||
|------------|------------|-------------|
|
||||
| success | boolean | 抽奖是否成功 |
|
||||
| prize | object | 中奖信息 |
|
||||
| remaining | number | 剩余抽奖次数 |
|
||||
|
||||
**奖项配置**
|
||||
```
|
||||
GET/POST /api/prizes
|
||||
```
|
||||
|
||||
**中奖记录查询**
|
||||
```
|
||||
GET /api/records
|
||||
```
|
||||
|
||||
Request:
|
||||
| Param Name | Param Type | isRequired | Description |
|
||||
|------------|------------|------------|-------------|
|
||||
| page | number | false | 页码 |
|
||||
| limit | number | false | 每页数量 |
|
||||
| hidePositions | array | false | 学号隐藏位置 |
|
||||
|
||||
## 5. Server architecture diagram
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[客户端/前端] --> B[路由层]
|
||||
B --> C[组件层]
|
||||
C --> D[状态管理层]
|
||||
D --> E[数据服务层]
|
||||
E --> F[缓存层]
|
||||
E --> G[(SQLite数据库)]
|
||||
|
||||
subgraph 前端架构
|
||||
B
|
||||
C
|
||||
D
|
||||
E
|
||||
F
|
||||
end
|
||||
|
||||
subgraph 数据层
|
||||
G
|
||||
H[本地存储]
|
||||
I[文件系统]
|
||||
end
|
||||
|
||||
F --> H
|
||||
F --> I
|
||||
```
|
||||
|
||||
## 6. Data model
|
||||
|
||||
### 6.1 Data model definition
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
SYSTEM_CONFIG ||--o{ PRIZE_CONFIG : contains
|
||||
PRIZE_CONFIG ||--o{ LOTTERY_RECORD : generates
|
||||
STUDENT ||--o{ LOTTERY_RECORD : participates
|
||||
|
||||
SYSTEM_CONFIG {
|
||||
uuid id PK
|
||||
string admin_password
|
||||
int max_draw_times
|
||||
json background_config
|
||||
json hide_positions
|
||||
timestamp created_at
|
||||
timestamp updated_at
|
||||
}
|
||||
|
||||
PRIZE_CONFIG {
|
||||
uuid id PK
|
||||
string prize_name
|
||||
int prize_level
|
||||
int total_quantity
|
||||
int remaining_quantity
|
||||
decimal probability
|
||||
boolean is_active
|
||||
timestamp created_at
|
||||
}
|
||||
|
||||
STUDENT {
|
||||
string student_id PK
|
||||
int draw_count
|
||||
timestamp first_draw_at
|
||||
timestamp last_draw_at
|
||||
}
|
||||
|
||||
LOTTERY_RECORD {
|
||||
uuid id PK
|
||||
string student_id FK
|
||||
uuid prize_id FK
|
||||
string prize_name
|
||||
int prize_level
|
||||
timestamp draw_time
|
||||
boolean is_synced
|
||||
json cache_data
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 Data Definition Language
|
||||
|
||||
**系统配置表 (system_config)**
|
||||
```sql
|
||||
-- 创建系统配置表
|
||||
CREATE TABLE system_config (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
admin_password TEXT NOT NULL DEFAULT 'admin123',
|
||||
max_draw_times INTEGER DEFAULT 1,
|
||||
background_config TEXT DEFAULT '{}',
|
||||
hide_positions TEXT DEFAULT '3,4,5,6',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建奖项配置表
|
||||
CREATE TABLE prize_config (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
prize_name TEXT NOT NULL,
|
||||
prize_level INTEGER NOT NULL CHECK (prize_level IN (1,2,3,4)),
|
||||
total_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
remaining_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
probability REAL DEFAULT 0.0000,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建学生表
|
||||
CREATE TABLE students (
|
||||
student_id TEXT PRIMARY KEY,
|
||||
draw_count INTEGER DEFAULT 0,
|
||||
first_draw_at DATETIME,
|
||||
last_draw_at DATETIME
|
||||
);
|
||||
|
||||
-- 创建抽奖记录表
|
||||
CREATE TABLE lottery_records (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
student_id TEXT NOT NULL,
|
||||
prize_id TEXT REFERENCES prize_config(id),
|
||||
prize_name TEXT NOT NULL,
|
||||
prize_level INTEGER NOT NULL,
|
||||
draw_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_synced INTEGER DEFAULT 0,
|
||||
cache_data TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_lottery_records_student_id ON lottery_records(student_id);
|
||||
CREATE INDEX idx_lottery_records_draw_time ON lottery_records(draw_time DESC);
|
||||
CREATE INDEX idx_lottery_records_prize_level ON lottery_records(prize_level);
|
||||
CREATE INDEX idx_students_draw_count ON students(draw_count);
|
||||
|
||||
-- 创建触发器用于更新时间戳
|
||||
CREATE TRIGGER update_system_config_timestamp
|
||||
AFTER UPDATE ON system_config
|
||||
BEGIN
|
||||
UPDATE system_config SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- 初始化数据
|
||||
INSERT INTO system_config (admin_password, max_draw_times) VALUES ('admin123', 1);
|
||||
|
||||
INSERT INTO prize_config (prize_name, prize_level, total_quantity, remaining_quantity, probability) VALUES
|
||||
('一等奖-iPad', 1, 1, 1, 0.0010),
|
||||
('二等奖-蓝牙耳机', 2, 5, 5, 0.0050),
|
||||
('三等奖-保温杯', 3, 20, 20, 0.0200),
|
||||
('谢谢参与', 4, 9974, 9974, 0.9740);
|
||||
```
|
||||
61
.vite/deps/_metadata.json
Normal file
61
.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"hash": "e77040a4",
|
||||
"configHash": "7cee043e",
|
||||
"lockfileHash": "0a7bdaab",
|
||||
"browserHash": "98b692f4",
|
||||
"optimized": {
|
||||
"react/jsx-dev-runtime": {
|
||||
"src": "../../node_modules/react/jsx-dev-runtime.js",
|
||||
"file": "react_jsx-dev-runtime.js",
|
||||
"fileHash": "c400bd07",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react": {
|
||||
"src": "../../node_modules/react/index.js",
|
||||
"file": "react.js",
|
||||
"fileHash": "eaccd8a1",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-dom/client": {
|
||||
"src": "../../node_modules/react-dom/client.js",
|
||||
"file": "react-dom_client.js",
|
||||
"fileHash": "8c209677",
|
||||
"needsInterop": true
|
||||
},
|
||||
"react-router-dom": {
|
||||
"src": "../../node_modules/react-router-dom/dist/index.mjs",
|
||||
"file": "react-router-dom.js",
|
||||
"fileHash": "c1275fad",
|
||||
"needsInterop": false
|
||||
},
|
||||
"framer-motion": {
|
||||
"src": "../../node_modules/framer-motion/dist/es/index.mjs",
|
||||
"file": "framer-motion.js",
|
||||
"fileHash": "e6ddb510",
|
||||
"needsInterop": false
|
||||
},
|
||||
"lucide-react": {
|
||||
"src": "../../node_modules/lucide-react/dist/esm/lucide-react.js",
|
||||
"file": "lucide-react.js",
|
||||
"fileHash": "49c0e6e0",
|
||||
"needsInterop": false
|
||||
},
|
||||
"better-sqlite3": {
|
||||
"src": "../../node_modules/better-sqlite3/lib/index.js",
|
||||
"file": "better-sqlite3.js",
|
||||
"fileHash": "3977a274",
|
||||
"needsInterop": true
|
||||
}
|
||||
},
|
||||
"chunks": {
|
||||
"chunk-QMYD5KZB": {
|
||||
"file": "chunk-QMYD5KZB.js"
|
||||
},
|
||||
"chunk-QTVD6AVW": {
|
||||
"file": "chunk-QTVD6AVW.js"
|
||||
},
|
||||
"chunk-PR4QN5HX": {
|
||||
"file": "chunk-PR4QN5HX.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
816
.vite/deps/better-sqlite3.js
Normal file
816
.vite/deps/better-sqlite3.js
Normal file
@@ -0,0 +1,816 @@
|
||||
import {
|
||||
__commonJS,
|
||||
__require
|
||||
} from "./chunk-PR4QN5HX.js";
|
||||
|
||||
// browser-external:fs
|
||||
var require_fs = __commonJS({
|
||||
"browser-external:fs"(exports, module) {
|
||||
module.exports = Object.create(new Proxy({}, {
|
||||
get(_, key) {
|
||||
if (key !== "__esModule" && key !== "__proto__" && key !== "constructor" && key !== "splice") {
|
||||
console.warn(`Module "fs" has been externalized for browser compatibility. Cannot access "fs.${key}" in client code. See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// browser-external:path
|
||||
var require_path = __commonJS({
|
||||
"browser-external:path"(exports, module) {
|
||||
module.exports = Object.create(new Proxy({}, {
|
||||
get(_, key) {
|
||||
if (key !== "__esModule" && key !== "__proto__" && key !== "constructor" && key !== "splice") {
|
||||
console.warn(`Module "path" has been externalized for browser compatibility. Cannot access "path.${key}" in client code. See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/util.js
|
||||
var require_util = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/util.js"(exports) {
|
||||
"use strict";
|
||||
exports.getBooleanOption = (options, key) => {
|
||||
let value = false;
|
||||
if (key in options && typeof (value = options[key]) !== "boolean") {
|
||||
throw new TypeError(`Expected the "${key}" option to be a boolean`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
exports.cppdb = Symbol();
|
||||
exports.inspect = Symbol.for("nodejs.util.inspect.custom");
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/sqlite-error.js
|
||||
var require_sqlite_error = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/sqlite-error.js"(exports, module) {
|
||||
"use strict";
|
||||
var descriptor = { value: "SqliteError", writable: true, enumerable: false, configurable: true };
|
||||
function SqliteError(message, code) {
|
||||
if (new.target !== SqliteError) {
|
||||
return new SqliteError(message, code);
|
||||
}
|
||||
if (typeof code !== "string") {
|
||||
throw new TypeError("Expected second argument to be a string");
|
||||
}
|
||||
Error.call(this, message);
|
||||
descriptor.value = "" + message;
|
||||
Object.defineProperty(this, "message", descriptor);
|
||||
Error.captureStackTrace(this, SqliteError);
|
||||
this.code = code;
|
||||
}
|
||||
Object.setPrototypeOf(SqliteError, Error);
|
||||
Object.setPrototypeOf(SqliteError.prototype, Error.prototype);
|
||||
Object.defineProperty(SqliteError.prototype, "name", descriptor);
|
||||
module.exports = SqliteError;
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/file-uri-to-path/index.js
|
||||
var require_file_uri_to_path = __commonJS({
|
||||
"node_modules/file-uri-to-path/index.js"(exports, module) {
|
||||
var sep = require_path().sep || "/";
|
||||
module.exports = fileUriToPath;
|
||||
function fileUriToPath(uri) {
|
||||
if ("string" != typeof uri || uri.length <= 7 || "file://" != uri.substring(0, 7)) {
|
||||
throw new TypeError("must pass in a file:// URI to convert to a file path");
|
||||
}
|
||||
var rest = decodeURI(uri.substring(7));
|
||||
var firstSlash = rest.indexOf("/");
|
||||
var host = rest.substring(0, firstSlash);
|
||||
var path = rest.substring(firstSlash + 1);
|
||||
if ("localhost" == host) host = "";
|
||||
if (host) {
|
||||
host = sep + sep + host;
|
||||
}
|
||||
path = path.replace(/^(.+)\|/, "$1:");
|
||||
if (sep == "\\") {
|
||||
path = path.replace(/\//g, "\\");
|
||||
}
|
||||
if (/^.+\:/.test(path)) {
|
||||
} else {
|
||||
path = sep + path;
|
||||
}
|
||||
return host + path;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/bindings/bindings.js
|
||||
var require_bindings = __commonJS({
|
||||
"node_modules/bindings/bindings.js"(exports, module) {
|
||||
var fs = require_fs();
|
||||
var path = require_path();
|
||||
var fileURLToPath = require_file_uri_to_path();
|
||||
var join = path.join;
|
||||
var dirname = path.dirname;
|
||||
var exists = fs.accessSync && function(path2) {
|
||||
try {
|
||||
fs.accessSync(path2);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} || fs.existsSync || path.existsSync;
|
||||
var defaults = {
|
||||
arrow: process.env.NODE_BINDINGS_ARROW || " → ",
|
||||
compiled: process.env.NODE_BINDINGS_COMPILED_DIR || "compiled",
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
nodePreGyp: "node-v" + process.versions.modules + "-" + process.platform + "-" + process.arch,
|
||||
version: process.versions.node,
|
||||
bindings: "bindings.node",
|
||||
try: [
|
||||
// node-gyp's linked version in the "build" dir
|
||||
["module_root", "build", "bindings"],
|
||||
// node-waf and gyp_addon (a.k.a node-gyp)
|
||||
["module_root", "build", "Debug", "bindings"],
|
||||
["module_root", "build", "Release", "bindings"],
|
||||
// Debug files, for development (legacy behavior, remove for node v0.9)
|
||||
["module_root", "out", "Debug", "bindings"],
|
||||
["module_root", "Debug", "bindings"],
|
||||
// Release files, but manually compiled (legacy behavior, remove for node v0.9)
|
||||
["module_root", "out", "Release", "bindings"],
|
||||
["module_root", "Release", "bindings"],
|
||||
// Legacy from node-waf, node <= 0.4.x
|
||||
["module_root", "build", "default", "bindings"],
|
||||
// Production "Release" buildtype binary (meh...)
|
||||
["module_root", "compiled", "version", "platform", "arch", "bindings"],
|
||||
// node-qbs builds
|
||||
["module_root", "addon-build", "release", "install-root", "bindings"],
|
||||
["module_root", "addon-build", "debug", "install-root", "bindings"],
|
||||
["module_root", "addon-build", "default", "install-root", "bindings"],
|
||||
// node-pre-gyp path ./lib/binding/{node_abi}-{platform}-{arch}
|
||||
["module_root", "lib", "binding", "nodePreGyp", "bindings"]
|
||||
]
|
||||
};
|
||||
function bindings(opts) {
|
||||
if (typeof opts == "string") {
|
||||
opts = { bindings: opts };
|
||||
} else if (!opts) {
|
||||
opts = {};
|
||||
}
|
||||
Object.keys(defaults).map(function(i2) {
|
||||
if (!(i2 in opts)) opts[i2] = defaults[i2];
|
||||
});
|
||||
if (!opts.module_root) {
|
||||
opts.module_root = exports.getRoot(exports.getFileName());
|
||||
}
|
||||
if (path.extname(opts.bindings) != ".node") {
|
||||
opts.bindings += ".node";
|
||||
}
|
||||
var requireFunc = typeof __webpack_require__ === "function" ? __non_webpack_require__ : __require;
|
||||
var tries = [], i = 0, l = opts.try.length, n, b, err;
|
||||
for (; i < l; i++) {
|
||||
n = join.apply(
|
||||
null,
|
||||
opts.try[i].map(function(p) {
|
||||
return opts[p] || p;
|
||||
})
|
||||
);
|
||||
tries.push(n);
|
||||
try {
|
||||
b = opts.path ? requireFunc.resolve(n) : requireFunc(n);
|
||||
if (!opts.path) {
|
||||
b.path = n;
|
||||
}
|
||||
return b;
|
||||
} catch (e) {
|
||||
if (e.code !== "MODULE_NOT_FOUND" && e.code !== "QUALIFIED_PATH_RESOLUTION_FAILED" && !/not find/i.test(e.message)) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
err = new Error(
|
||||
"Could not locate the bindings file. Tried:\n" + tries.map(function(a) {
|
||||
return opts.arrow + a;
|
||||
}).join("\n")
|
||||
);
|
||||
err.tries = tries;
|
||||
throw err;
|
||||
}
|
||||
module.exports = exports = bindings;
|
||||
exports.getFileName = function getFileName(calling_file) {
|
||||
var origPST = Error.prepareStackTrace, origSTL = Error.stackTraceLimit, dummy = {}, fileName;
|
||||
Error.stackTraceLimit = 10;
|
||||
Error.prepareStackTrace = function(e, st) {
|
||||
for (var i = 0, l = st.length; i < l; i++) {
|
||||
fileName = st[i].getFileName();
|
||||
if (fileName !== __filename) {
|
||||
if (calling_file) {
|
||||
if (fileName !== calling_file) {
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Error.captureStackTrace(dummy);
|
||||
dummy.stack;
|
||||
Error.prepareStackTrace = origPST;
|
||||
Error.stackTraceLimit = origSTL;
|
||||
var fileSchema = "file://";
|
||||
if (fileName.indexOf(fileSchema) === 0) {
|
||||
fileName = fileURLToPath(fileName);
|
||||
}
|
||||
return fileName;
|
||||
};
|
||||
exports.getRoot = function getRoot(file) {
|
||||
var dir = dirname(file), prev;
|
||||
while (true) {
|
||||
if (dir === ".") {
|
||||
dir = process.cwd();
|
||||
}
|
||||
if (exists(join(dir, "package.json")) || exists(join(dir, "node_modules"))) {
|
||||
return dir;
|
||||
}
|
||||
if (prev === dir) {
|
||||
throw new Error(
|
||||
'Could not find module root given file: "' + file + '". Do you have a `package.json` file? '
|
||||
);
|
||||
}
|
||||
prev = dir;
|
||||
dir = join(dir, "..");
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/wrappers.js
|
||||
var require_wrappers = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/wrappers.js"(exports) {
|
||||
"use strict";
|
||||
var { cppdb } = require_util();
|
||||
exports.prepare = function prepare(sql) {
|
||||
return this[cppdb].prepare(sql, this, false);
|
||||
};
|
||||
exports.exec = function exec(sql) {
|
||||
this[cppdb].exec(sql);
|
||||
return this;
|
||||
};
|
||||
exports.close = function close() {
|
||||
this[cppdb].close();
|
||||
return this;
|
||||
};
|
||||
exports.loadExtension = function loadExtension(...args) {
|
||||
this[cppdb].loadExtension(...args);
|
||||
return this;
|
||||
};
|
||||
exports.defaultSafeIntegers = function defaultSafeIntegers(...args) {
|
||||
this[cppdb].defaultSafeIntegers(...args);
|
||||
return this;
|
||||
};
|
||||
exports.unsafeMode = function unsafeMode(...args) {
|
||||
this[cppdb].unsafeMode(...args);
|
||||
return this;
|
||||
};
|
||||
exports.getters = {
|
||||
name: {
|
||||
get: function name() {
|
||||
return this[cppdb].name;
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
open: {
|
||||
get: function open() {
|
||||
return this[cppdb].open;
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
inTransaction: {
|
||||
get: function inTransaction() {
|
||||
return this[cppdb].inTransaction;
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
readonly: {
|
||||
get: function readonly() {
|
||||
return this[cppdb].readonly;
|
||||
},
|
||||
enumerable: true
|
||||
},
|
||||
memory: {
|
||||
get: function memory() {
|
||||
return this[cppdb].memory;
|
||||
},
|
||||
enumerable: true
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/transaction.js
|
||||
var require_transaction = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/transaction.js"(exports, module) {
|
||||
"use strict";
|
||||
var { cppdb } = require_util();
|
||||
var controllers = /* @__PURE__ */ new WeakMap();
|
||||
module.exports = function transaction(fn) {
|
||||
if (typeof fn !== "function") throw new TypeError("Expected first argument to be a function");
|
||||
const db = this[cppdb];
|
||||
const controller = getController(db, this);
|
||||
const { apply } = Function.prototype;
|
||||
const properties = {
|
||||
default: { value: wrapTransaction(apply, fn, db, controller.default) },
|
||||
deferred: { value: wrapTransaction(apply, fn, db, controller.deferred) },
|
||||
immediate: { value: wrapTransaction(apply, fn, db, controller.immediate) },
|
||||
exclusive: { value: wrapTransaction(apply, fn, db, controller.exclusive) },
|
||||
database: { value: this, enumerable: true }
|
||||
};
|
||||
Object.defineProperties(properties.default.value, properties);
|
||||
Object.defineProperties(properties.deferred.value, properties);
|
||||
Object.defineProperties(properties.immediate.value, properties);
|
||||
Object.defineProperties(properties.exclusive.value, properties);
|
||||
return properties.default.value;
|
||||
};
|
||||
var getController = (db, self) => {
|
||||
let controller = controllers.get(db);
|
||||
if (!controller) {
|
||||
const shared = {
|
||||
commit: db.prepare("COMMIT", self, false),
|
||||
rollback: db.prepare("ROLLBACK", self, false),
|
||||
savepoint: db.prepare("SAVEPOINT ` _bs3. `", self, false),
|
||||
release: db.prepare("RELEASE ` _bs3. `", self, false),
|
||||
rollbackTo: db.prepare("ROLLBACK TO ` _bs3. `", self, false)
|
||||
};
|
||||
controllers.set(db, controller = {
|
||||
default: Object.assign({ begin: db.prepare("BEGIN", self, false) }, shared),
|
||||
deferred: Object.assign({ begin: db.prepare("BEGIN DEFERRED", self, false) }, shared),
|
||||
immediate: Object.assign({ begin: db.prepare("BEGIN IMMEDIATE", self, false) }, shared),
|
||||
exclusive: Object.assign({ begin: db.prepare("BEGIN EXCLUSIVE", self, false) }, shared)
|
||||
});
|
||||
}
|
||||
return controller;
|
||||
};
|
||||
var wrapTransaction = (apply, fn, db, { begin, commit, rollback, savepoint, release, rollbackTo }) => function sqliteTransaction() {
|
||||
let before, after, undo;
|
||||
if (db.inTransaction) {
|
||||
before = savepoint;
|
||||
after = release;
|
||||
undo = rollbackTo;
|
||||
} else {
|
||||
before = begin;
|
||||
after = commit;
|
||||
undo = rollback;
|
||||
}
|
||||
before.run();
|
||||
try {
|
||||
const result = apply.call(fn, this, arguments);
|
||||
if (result && typeof result.then === "function") {
|
||||
throw new TypeError("Transaction function cannot return a promise");
|
||||
}
|
||||
after.run();
|
||||
return result;
|
||||
} catch (ex) {
|
||||
if (db.inTransaction) {
|
||||
undo.run();
|
||||
if (undo !== rollback) after.run();
|
||||
}
|
||||
throw ex;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/pragma.js
|
||||
var require_pragma = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/pragma.js"(exports, module) {
|
||||
"use strict";
|
||||
var { getBooleanOption, cppdb } = require_util();
|
||||
module.exports = function pragma(source, options) {
|
||||
if (options == null) options = {};
|
||||
if (typeof source !== "string") throw new TypeError("Expected first argument to be a string");
|
||||
if (typeof options !== "object") throw new TypeError("Expected second argument to be an options object");
|
||||
const simple = getBooleanOption(options, "simple");
|
||||
const stmt = this[cppdb].prepare(`PRAGMA ${source}`, this, true);
|
||||
return simple ? stmt.pluck().get() : stmt.all();
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// browser-external:util
|
||||
var require_util2 = __commonJS({
|
||||
"browser-external:util"(exports, module) {
|
||||
module.exports = Object.create(new Proxy({}, {
|
||||
get(_, key) {
|
||||
if (key !== "__esModule" && key !== "__proto__" && key !== "constructor" && key !== "splice") {
|
||||
console.warn(`Module "util" has been externalized for browser compatibility. Cannot access "util.${key}" in client code. See https://vite.dev/guide/troubleshooting.html#module-externalized-for-browser-compatibility for more details.`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/backup.js
|
||||
var require_backup = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/backup.js"(exports, module) {
|
||||
"use strict";
|
||||
var fs = require_fs();
|
||||
var path = require_path();
|
||||
var { promisify } = require_util2();
|
||||
var { cppdb } = require_util();
|
||||
var fsAccess = promisify(fs.access);
|
||||
module.exports = async function backup(filename, options) {
|
||||
if (options == null) options = {};
|
||||
if (typeof filename !== "string") throw new TypeError("Expected first argument to be a string");
|
||||
if (typeof options !== "object") throw new TypeError("Expected second argument to be an options object");
|
||||
filename = filename.trim();
|
||||
const attachedName = "attached" in options ? options.attached : "main";
|
||||
const handler = "progress" in options ? options.progress : null;
|
||||
if (!filename) throw new TypeError("Backup filename cannot be an empty string");
|
||||
if (filename === ":memory:") throw new TypeError('Invalid backup filename ":memory:"');
|
||||
if (typeof attachedName !== "string") throw new TypeError('Expected the "attached" option to be a string');
|
||||
if (!attachedName) throw new TypeError('The "attached" option cannot be an empty string');
|
||||
if (handler != null && typeof handler !== "function") throw new TypeError('Expected the "progress" option to be a function');
|
||||
await fsAccess(path.dirname(filename)).catch(() => {
|
||||
throw new TypeError("Cannot save backup because the directory does not exist");
|
||||
});
|
||||
const isNewFile = await fsAccess(filename).then(() => false, () => true);
|
||||
return runBackup(this[cppdb].backup(this, attachedName, filename, isNewFile), handler || null);
|
||||
};
|
||||
var runBackup = (backup, handler) => {
|
||||
let rate = 0;
|
||||
let useDefault = true;
|
||||
return new Promise((resolve, reject) => {
|
||||
setImmediate(function step() {
|
||||
try {
|
||||
const progress = backup.transfer(rate);
|
||||
if (!progress.remainingPages) {
|
||||
backup.close();
|
||||
resolve(progress);
|
||||
return;
|
||||
}
|
||||
if (useDefault) {
|
||||
useDefault = false;
|
||||
rate = 100;
|
||||
}
|
||||
if (handler) {
|
||||
const ret = handler(progress);
|
||||
if (ret !== void 0) {
|
||||
if (typeof ret === "number" && ret === ret) rate = Math.max(0, Math.min(2147483647, Math.round(ret)));
|
||||
else throw new TypeError("Expected progress callback to return a number or undefined");
|
||||
}
|
||||
}
|
||||
setImmediate(step);
|
||||
} catch (err) {
|
||||
backup.close();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/serialize.js
|
||||
var require_serialize = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/serialize.js"(exports, module) {
|
||||
"use strict";
|
||||
var { cppdb } = require_util();
|
||||
module.exports = function serialize(options) {
|
||||
if (options == null) options = {};
|
||||
if (typeof options !== "object") throw new TypeError("Expected first argument to be an options object");
|
||||
const attachedName = "attached" in options ? options.attached : "main";
|
||||
if (typeof attachedName !== "string") throw new TypeError('Expected the "attached" option to be a string');
|
||||
if (!attachedName) throw new TypeError('The "attached" option cannot be an empty string');
|
||||
return this[cppdb].serialize(attachedName);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/function.js
|
||||
var require_function = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/function.js"(exports, module) {
|
||||
"use strict";
|
||||
var { getBooleanOption, cppdb } = require_util();
|
||||
module.exports = function defineFunction(name, options, fn) {
|
||||
if (options == null) options = {};
|
||||
if (typeof options === "function") {
|
||||
fn = options;
|
||||
options = {};
|
||||
}
|
||||
if (typeof name !== "string") throw new TypeError("Expected first argument to be a string");
|
||||
if (typeof fn !== "function") throw new TypeError("Expected last argument to be a function");
|
||||
if (typeof options !== "object") throw new TypeError("Expected second argument to be an options object");
|
||||
if (!name) throw new TypeError("User-defined function name cannot be an empty string");
|
||||
const safeIntegers = "safeIntegers" in options ? +getBooleanOption(options, "safeIntegers") : 2;
|
||||
const deterministic = getBooleanOption(options, "deterministic");
|
||||
const directOnly = getBooleanOption(options, "directOnly");
|
||||
const varargs = getBooleanOption(options, "varargs");
|
||||
let argCount = -1;
|
||||
if (!varargs) {
|
||||
argCount = fn.length;
|
||||
if (!Number.isInteger(argCount) || argCount < 0) throw new TypeError("Expected function.length to be a positive integer");
|
||||
if (argCount > 100) throw new RangeError("User-defined functions cannot have more than 100 arguments");
|
||||
}
|
||||
this[cppdb].function(fn, name, argCount, safeIntegers, deterministic, directOnly);
|
||||
return this;
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/aggregate.js
|
||||
var require_aggregate = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/aggregate.js"(exports, module) {
|
||||
"use strict";
|
||||
var { getBooleanOption, cppdb } = require_util();
|
||||
module.exports = function defineAggregate(name, options) {
|
||||
if (typeof name !== "string") throw new TypeError("Expected first argument to be a string");
|
||||
if (typeof options !== "object" || options === null) throw new TypeError("Expected second argument to be an options object");
|
||||
if (!name) throw new TypeError("User-defined function name cannot be an empty string");
|
||||
const start = "start" in options ? options.start : null;
|
||||
const step = getFunctionOption(options, "step", true);
|
||||
const inverse = getFunctionOption(options, "inverse", false);
|
||||
const result = getFunctionOption(options, "result", false);
|
||||
const safeIntegers = "safeIntegers" in options ? +getBooleanOption(options, "safeIntegers") : 2;
|
||||
const deterministic = getBooleanOption(options, "deterministic");
|
||||
const directOnly = getBooleanOption(options, "directOnly");
|
||||
const varargs = getBooleanOption(options, "varargs");
|
||||
let argCount = -1;
|
||||
if (!varargs) {
|
||||
argCount = Math.max(getLength(step), inverse ? getLength(inverse) : 0);
|
||||
if (argCount > 0) argCount -= 1;
|
||||
if (argCount > 100) throw new RangeError("User-defined functions cannot have more than 100 arguments");
|
||||
}
|
||||
this[cppdb].aggregate(start, step, inverse, result, name, argCount, safeIntegers, deterministic, directOnly);
|
||||
return this;
|
||||
};
|
||||
var getFunctionOption = (options, key, required) => {
|
||||
const value = key in options ? options[key] : null;
|
||||
if (typeof value === "function") return value;
|
||||
if (value != null) throw new TypeError(`Expected the "${key}" option to be a function`);
|
||||
if (required) throw new TypeError(`Missing required option "${key}"`);
|
||||
return null;
|
||||
};
|
||||
var getLength = ({ length }) => {
|
||||
if (Number.isInteger(length) && length >= 0) return length;
|
||||
throw new TypeError("Expected function.length to be a positive integer");
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/table.js
|
||||
var require_table = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/table.js"(exports, module) {
|
||||
"use strict";
|
||||
var { cppdb } = require_util();
|
||||
module.exports = function defineTable(name, factory) {
|
||||
if (typeof name !== "string") throw new TypeError("Expected first argument to be a string");
|
||||
if (!name) throw new TypeError("Virtual table module name cannot be an empty string");
|
||||
let eponymous = false;
|
||||
if (typeof factory === "object" && factory !== null) {
|
||||
eponymous = true;
|
||||
factory = defer(parseTableDefinition(factory, "used", name));
|
||||
} else {
|
||||
if (typeof factory !== "function") throw new TypeError("Expected second argument to be a function or a table definition object");
|
||||
factory = wrapFactory(factory);
|
||||
}
|
||||
this[cppdb].table(factory, name, eponymous);
|
||||
return this;
|
||||
};
|
||||
function wrapFactory(factory) {
|
||||
return function virtualTableFactory(moduleName, databaseName, tableName, ...args) {
|
||||
const thisObject = {
|
||||
module: moduleName,
|
||||
database: databaseName,
|
||||
table: tableName
|
||||
};
|
||||
const def = apply.call(factory, thisObject, args);
|
||||
if (typeof def !== "object" || def === null) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" did not return a table definition object`);
|
||||
}
|
||||
return parseTableDefinition(def, "returned", moduleName);
|
||||
};
|
||||
}
|
||||
function parseTableDefinition(def, verb, moduleName) {
|
||||
if (!hasOwnProperty.call(def, "rows")) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition without a "rows" property`);
|
||||
}
|
||||
if (!hasOwnProperty.call(def, "columns")) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition without a "columns" property`);
|
||||
}
|
||||
const rows = def.rows;
|
||||
if (typeof rows !== "function" || Object.getPrototypeOf(rows) !== GeneratorFunctionPrototype) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition with an invalid "rows" property (should be a generator function)`);
|
||||
}
|
||||
let columns = def.columns;
|
||||
if (!Array.isArray(columns) || !(columns = [...columns]).every((x) => typeof x === "string")) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition with an invalid "columns" property (should be an array of strings)`);
|
||||
}
|
||||
if (columns.length !== new Set(columns).size) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition with duplicate column names`);
|
||||
}
|
||||
if (!columns.length) {
|
||||
throw new RangeError(`Virtual table module "${moduleName}" ${verb} a table definition with zero columns`);
|
||||
}
|
||||
let parameters;
|
||||
if (hasOwnProperty.call(def, "parameters")) {
|
||||
parameters = def.parameters;
|
||||
if (!Array.isArray(parameters) || !(parameters = [...parameters]).every((x) => typeof x === "string")) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition with an invalid "parameters" property (should be an array of strings)`);
|
||||
}
|
||||
} else {
|
||||
parameters = inferParameters(rows);
|
||||
}
|
||||
if (parameters.length !== new Set(parameters).size) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition with duplicate parameter names`);
|
||||
}
|
||||
if (parameters.length > 32) {
|
||||
throw new RangeError(`Virtual table module "${moduleName}" ${verb} a table definition with more than the maximum number of 32 parameters`);
|
||||
}
|
||||
for (const parameter of parameters) {
|
||||
if (columns.includes(parameter)) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition with column "${parameter}" which was ambiguously defined as both a column and parameter`);
|
||||
}
|
||||
}
|
||||
let safeIntegers = 2;
|
||||
if (hasOwnProperty.call(def, "safeIntegers")) {
|
||||
const bool = def.safeIntegers;
|
||||
if (typeof bool !== "boolean") {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition with an invalid "safeIntegers" property (should be a boolean)`);
|
||||
}
|
||||
safeIntegers = +bool;
|
||||
}
|
||||
let directOnly = false;
|
||||
if (hasOwnProperty.call(def, "directOnly")) {
|
||||
directOnly = def.directOnly;
|
||||
if (typeof directOnly !== "boolean") {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" ${verb} a table definition with an invalid "directOnly" property (should be a boolean)`);
|
||||
}
|
||||
}
|
||||
const columnDefinitions = [
|
||||
...parameters.map(identifier).map((str) => `${str} HIDDEN`),
|
||||
...columns.map(identifier)
|
||||
];
|
||||
return [
|
||||
`CREATE TABLE x(${columnDefinitions.join(", ")});`,
|
||||
wrapGenerator(rows, new Map(columns.map((x, i) => [x, parameters.length + i])), moduleName),
|
||||
parameters,
|
||||
safeIntegers,
|
||||
directOnly
|
||||
];
|
||||
}
|
||||
function wrapGenerator(generator, columnMap, moduleName) {
|
||||
return function* virtualTable(...args) {
|
||||
const output = args.map((x) => Buffer.isBuffer(x) ? Buffer.from(x) : x);
|
||||
for (let i = 0; i < columnMap.size; ++i) {
|
||||
output.push(null);
|
||||
}
|
||||
for (const row of generator(...args)) {
|
||||
if (Array.isArray(row)) {
|
||||
extractRowArray(row, output, columnMap.size, moduleName);
|
||||
yield output;
|
||||
} else if (typeof row === "object" && row !== null) {
|
||||
extractRowObject(row, output, columnMap, moduleName);
|
||||
yield output;
|
||||
} else {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" yielded something that isn't a valid row object`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
function extractRowArray(row, output, columnCount, moduleName) {
|
||||
if (row.length !== columnCount) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" yielded a row with an incorrect number of columns`);
|
||||
}
|
||||
const offset = output.length - columnCount;
|
||||
for (let i = 0; i < columnCount; ++i) {
|
||||
output[i + offset] = row[i];
|
||||
}
|
||||
}
|
||||
function extractRowObject(row, output, columnMap, moduleName) {
|
||||
let count = 0;
|
||||
for (const key of Object.keys(row)) {
|
||||
const index = columnMap.get(key);
|
||||
if (index === void 0) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" yielded a row with an undeclared column "${key}"`);
|
||||
}
|
||||
output[index] = row[key];
|
||||
count += 1;
|
||||
}
|
||||
if (count !== columnMap.size) {
|
||||
throw new TypeError(`Virtual table module "${moduleName}" yielded a row with missing columns`);
|
||||
}
|
||||
}
|
||||
function inferParameters({ length }) {
|
||||
if (!Number.isInteger(length) || length < 0) {
|
||||
throw new TypeError("Expected function.length to be a positive integer");
|
||||
}
|
||||
const params = [];
|
||||
for (let i = 0; i < length; ++i) {
|
||||
params.push(`$${i + 1}`);
|
||||
}
|
||||
return params;
|
||||
}
|
||||
var { hasOwnProperty } = Object.prototype;
|
||||
var { apply } = Function.prototype;
|
||||
var GeneratorFunctionPrototype = Object.getPrototypeOf(function* () {
|
||||
});
|
||||
var identifier = (str) => `"${str.replace(/"/g, '""')}"`;
|
||||
var defer = (x) => () => x;
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/methods/inspect.js
|
||||
var require_inspect = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/methods/inspect.js"(exports, module) {
|
||||
"use strict";
|
||||
var DatabaseInspection = function Database() {
|
||||
};
|
||||
module.exports = function inspect(depth, opts) {
|
||||
return Object.assign(new DatabaseInspection(), this);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/database.js
|
||||
var require_database = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/database.js"(exports, module) {
|
||||
"use strict";
|
||||
var fs = require_fs();
|
||||
var path = require_path();
|
||||
var util = require_util();
|
||||
var SqliteError = require_sqlite_error();
|
||||
var DEFAULT_ADDON;
|
||||
function Database(filenameGiven, options) {
|
||||
if (new.target == null) {
|
||||
return new Database(filenameGiven, options);
|
||||
}
|
||||
let buffer;
|
||||
if (Buffer.isBuffer(filenameGiven)) {
|
||||
buffer = filenameGiven;
|
||||
filenameGiven = ":memory:";
|
||||
}
|
||||
if (filenameGiven == null) filenameGiven = "";
|
||||
if (options == null) options = {};
|
||||
if (typeof filenameGiven !== "string") throw new TypeError("Expected first argument to be a string");
|
||||
if (typeof options !== "object") throw new TypeError("Expected second argument to be an options object");
|
||||
if ("readOnly" in options) throw new TypeError('Misspelled option "readOnly" should be "readonly"');
|
||||
if ("memory" in options) throw new TypeError('Option "memory" was removed in v7.0.0 (use ":memory:" filename instead)');
|
||||
const filename = filenameGiven.trim();
|
||||
const anonymous = filename === "" || filename === ":memory:";
|
||||
const readonly = util.getBooleanOption(options, "readonly");
|
||||
const fileMustExist = util.getBooleanOption(options, "fileMustExist");
|
||||
const timeout = "timeout" in options ? options.timeout : 5e3;
|
||||
const verbose = "verbose" in options ? options.verbose : null;
|
||||
const nativeBinding = "nativeBinding" in options ? options.nativeBinding : null;
|
||||
if (readonly && anonymous && !buffer) throw new TypeError("In-memory/temporary databases cannot be readonly");
|
||||
if (!Number.isInteger(timeout) || timeout < 0) throw new TypeError('Expected the "timeout" option to be a positive integer');
|
||||
if (timeout > 2147483647) throw new RangeError('Option "timeout" cannot be greater than 2147483647');
|
||||
if (verbose != null && typeof verbose !== "function") throw new TypeError('Expected the "verbose" option to be a function');
|
||||
if (nativeBinding != null && typeof nativeBinding !== "string" && typeof nativeBinding !== "object") throw new TypeError('Expected the "nativeBinding" option to be a string or addon object');
|
||||
let addon;
|
||||
if (nativeBinding == null) {
|
||||
addon = DEFAULT_ADDON || (DEFAULT_ADDON = require_bindings()("better_sqlite3.node"));
|
||||
} else if (typeof nativeBinding === "string") {
|
||||
const requireFunc = typeof __non_webpack_require__ === "function" ? __non_webpack_require__ : __require;
|
||||
addon = requireFunc(path.resolve(nativeBinding).replace(/(\.node)?$/, ".node"));
|
||||
} else {
|
||||
addon = nativeBinding;
|
||||
}
|
||||
if (!addon.isInitialized) {
|
||||
addon.setErrorConstructor(SqliteError);
|
||||
addon.isInitialized = true;
|
||||
}
|
||||
if (!anonymous && !fs.existsSync(path.dirname(filename))) {
|
||||
throw new TypeError("Cannot open database because the directory does not exist");
|
||||
}
|
||||
Object.defineProperties(this, {
|
||||
[util.cppdb]: { value: new addon.Database(filename, filenameGiven, anonymous, readonly, fileMustExist, timeout, verbose || null, buffer || null) },
|
||||
...wrappers.getters
|
||||
});
|
||||
}
|
||||
var wrappers = require_wrappers();
|
||||
Database.prototype.prepare = wrappers.prepare;
|
||||
Database.prototype.transaction = require_transaction();
|
||||
Database.prototype.pragma = require_pragma();
|
||||
Database.prototype.backup = require_backup();
|
||||
Database.prototype.serialize = require_serialize();
|
||||
Database.prototype.function = require_function();
|
||||
Database.prototype.aggregate = require_aggregate();
|
||||
Database.prototype.table = require_table();
|
||||
Database.prototype.loadExtension = wrappers.loadExtension;
|
||||
Database.prototype.exec = wrappers.exec;
|
||||
Database.prototype.close = wrappers.close;
|
||||
Database.prototype.defaultSafeIntegers = wrappers.defaultSafeIntegers;
|
||||
Database.prototype.unsafeMode = wrappers.unsafeMode;
|
||||
Database.prototype[util.inspect] = require_inspect();
|
||||
module.exports = Database;
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/better-sqlite3/lib/index.js
|
||||
var require_lib = __commonJS({
|
||||
"node_modules/better-sqlite3/lib/index.js"(exports, module) {
|
||||
module.exports = require_database();
|
||||
module.exports.SqliteError = require_sqlite_error();
|
||||
}
|
||||
});
|
||||
export default require_lib();
|
||||
//# sourceMappingURL=better-sqlite3.js.map
|
||||
7
.vite/deps/better-sqlite3.js.map
Normal file
7
.vite/deps/better-sqlite3.js.map
Normal file
File diff suppressed because one or more lines are too long
42
.vite/deps/chunk-PR4QN5HX.js
Normal file
42
.vite/deps/chunk-PR4QN5HX.js
Normal file
@@ -0,0 +1,42 @@
|
||||
var __create = Object.create;
|
||||
var __defProp = Object.defineProperty;
|
||||
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
||||
var __getOwnPropNames = Object.getOwnPropertyNames;
|
||||
var __getProtoOf = Object.getPrototypeOf;
|
||||
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
||||
var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
|
||||
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
|
||||
}) : x)(function(x) {
|
||||
if (typeof require !== "undefined") return require.apply(this, arguments);
|
||||
throw Error('Dynamic require of "' + x + '" is not supported');
|
||||
});
|
||||
var __commonJS = (cb, mod) => function __require2() {
|
||||
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
|
||||
};
|
||||
var __export = (target, all) => {
|
||||
for (var name in all)
|
||||
__defProp(target, name, { get: all[name], enumerable: true });
|
||||
};
|
||||
var __copyProps = (to, from, except, desc) => {
|
||||
if (from && typeof from === "object" || typeof from === "function") {
|
||||
for (let key of __getOwnPropNames(from))
|
||||
if (!__hasOwnProp.call(to, key) && key !== except)
|
||||
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
||||
}
|
||||
return to;
|
||||
};
|
||||
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
||||
// If the importer is in node compatibility mode or this is not an ESM
|
||||
// file that has been converted to a CommonJS file using a Babel-
|
||||
// compatible transform (i.e. "__esModule" has not been set), then set
|
||||
// "default" to the CommonJS "module.exports" for node compatibility.
|
||||
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
||||
mod
|
||||
));
|
||||
|
||||
export {
|
||||
__require,
|
||||
__commonJS,
|
||||
__export,
|
||||
__toESM
|
||||
};
|
||||
7
.vite/deps/chunk-PR4QN5HX.js.map
Normal file
7
.vite/deps/chunk-PR4QN5HX.js.map
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
21687
.vite/deps/chunk-QMYD5KZB.js
Normal file
21687
.vite/deps/chunk-QMYD5KZB.js
Normal file
File diff suppressed because it is too large
Load Diff
7
.vite/deps/chunk-QMYD5KZB.js.map
Normal file
7
.vite/deps/chunk-QMYD5KZB.js.map
Normal file
File diff suppressed because one or more lines are too long
1906
.vite/deps/chunk-QTVD6AVW.js
Normal file
1906
.vite/deps/chunk-QTVD6AVW.js
Normal file
File diff suppressed because it is too large
Load Diff
7
.vite/deps/chunk-QTVD6AVW.js.map
Normal file
7
.vite/deps/chunk-QTVD6AVW.js.map
Normal file
File diff suppressed because one or more lines are too long
11179
.vite/deps/framer-motion.js
Normal file
11179
.vite/deps/framer-motion.js
Normal file
File diff suppressed because it is too large
Load Diff
7
.vite/deps/framer-motion.js.map
Normal file
7
.vite/deps/framer-motion.js.map
Normal file
File diff suppressed because one or more lines are too long
26825
.vite/deps/lucide-react.js
Normal file
26825
.vite/deps/lucide-react.js
Normal file
File diff suppressed because it is too large
Load Diff
7
.vite/deps/lucide-react.js.map
Normal file
7
.vite/deps/lucide-react.js.map
Normal file
File diff suppressed because one or more lines are too long
3
.vite/deps/package.json
Normal file
3
.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
39
.vite/deps/react-dom_client.js
vendored
Normal file
39
.vite/deps/react-dom_client.js
vendored
Normal file
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
require_react_dom
|
||||
} from "./chunk-QMYD5KZB.js";
|
||||
import "./chunk-QTVD6AVW.js";
|
||||
import {
|
||||
__commonJS
|
||||
} from "./chunk-PR4QN5HX.js";
|
||||
|
||||
// node_modules/react-dom/client.js
|
||||
var require_client = __commonJS({
|
||||
"node_modules/react-dom/client.js"(exports) {
|
||||
var m = require_react_dom();
|
||||
if (false) {
|
||||
exports.createRoot = m.createRoot;
|
||||
exports.hydrateRoot = m.hydrateRoot;
|
||||
} else {
|
||||
i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
|
||||
exports.createRoot = function(c, o) {
|
||||
i.usingClientEntryPoint = true;
|
||||
try {
|
||||
return m.createRoot(c, o);
|
||||
} finally {
|
||||
i.usingClientEntryPoint = false;
|
||||
}
|
||||
};
|
||||
exports.hydrateRoot = function(c, h, o) {
|
||||
i.usingClientEntryPoint = true;
|
||||
try {
|
||||
return m.hydrateRoot(c, h, o);
|
||||
} finally {
|
||||
i.usingClientEntryPoint = false;
|
||||
}
|
||||
};
|
||||
}
|
||||
var i;
|
||||
}
|
||||
});
|
||||
export default require_client();
|
||||
//# sourceMappingURL=react-dom_client.js.map
|
||||
7
.vite/deps/react-dom_client.js.map
Normal file
7
.vite/deps/react-dom_client.js.map
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": ["../../node_modules/react-dom/client.js"],
|
||||
"sourcesContent": ["'use strict';\n\nvar m = require('react-dom');\nif (process.env.NODE_ENV === 'production') {\n exports.createRoot = m.createRoot;\n exports.hydrateRoot = m.hydrateRoot;\n} else {\n var i = m.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;\n exports.createRoot = function(c, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.createRoot(c, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n exports.hydrateRoot = function(c, h, o) {\n i.usingClientEntryPoint = true;\n try {\n return m.hydrateRoot(c, h, o);\n } finally {\n i.usingClientEntryPoint = false;\n }\n };\n}\n"],
|
||||
"mappings": ";;;;;;;;;AAAA;AAAA;AAEA,QAAI,IAAI;AACR,QAAI,OAAuC;AACzC,cAAQ,aAAa,EAAE;AACvB,cAAQ,cAAc,EAAE;AAAA,IAC1B,OAAO;AACD,UAAI,EAAE;AACV,cAAQ,aAAa,SAAS,GAAG,GAAG;AAClC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,WAAW,GAAG,CAAC;AAAA,QAC1B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AACA,cAAQ,cAAc,SAAS,GAAG,GAAG,GAAG;AACtC,UAAE,wBAAwB;AAC1B,YAAI;AACF,iBAAO,EAAE,YAAY,GAAG,GAAG,CAAC;AAAA,QAC9B,UAAE;AACA,YAAE,wBAAwB;AAAA,QAC5B;AAAA,MACF;AAAA,IACF;AAjBM;AAAA;AAAA;",
|
||||
"names": []
|
||||
}
|
||||
13502
.vite/deps/react-router-dom.js
vendored
Normal file
13502
.vite/deps/react-router-dom.js
vendored
Normal file
File diff suppressed because it is too large
Load Diff
7
.vite/deps/react-router-dom.js.map
Normal file
7
.vite/deps/react-router-dom.js.map
Normal file
File diff suppressed because one or more lines are too long
5
.vite/deps/react.js
vendored
Normal file
5
.vite/deps/react.js
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-QTVD6AVW.js";
|
||||
import "./chunk-PR4QN5HX.js";
|
||||
export default require_react();
|
||||
7
.vite/deps/react.js.map
Normal file
7
.vite/deps/react.js.map
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"version": 3,
|
||||
"sources": [],
|
||||
"sourcesContent": [],
|
||||
"mappings": "",
|
||||
"names": []
|
||||
}
|
||||
913
.vite/deps/react_jsx-dev-runtime.js
Normal file
913
.vite/deps/react_jsx-dev-runtime.js
Normal file
@@ -0,0 +1,913 @@
|
||||
import {
|
||||
require_react
|
||||
} from "./chunk-QTVD6AVW.js";
|
||||
import {
|
||||
__commonJS
|
||||
} from "./chunk-PR4QN5HX.js";
|
||||
|
||||
// node_modules/react/cjs/react-jsx-dev-runtime.development.js
|
||||
var require_react_jsx_dev_runtime_development = __commonJS({
|
||||
"node_modules/react/cjs/react-jsx-dev-runtime.development.js"(exports) {
|
||||
"use strict";
|
||||
if (true) {
|
||||
(function() {
|
||||
"use strict";
|
||||
var React = require_react();
|
||||
var REACT_ELEMENT_TYPE = Symbol.for("react.element");
|
||||
var REACT_PORTAL_TYPE = Symbol.for("react.portal");
|
||||
var REACT_FRAGMENT_TYPE = Symbol.for("react.fragment");
|
||||
var REACT_STRICT_MODE_TYPE = Symbol.for("react.strict_mode");
|
||||
var REACT_PROFILER_TYPE = Symbol.for("react.profiler");
|
||||
var REACT_PROVIDER_TYPE = Symbol.for("react.provider");
|
||||
var REACT_CONTEXT_TYPE = Symbol.for("react.context");
|
||||
var REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref");
|
||||
var REACT_SUSPENSE_TYPE = Symbol.for("react.suspense");
|
||||
var REACT_SUSPENSE_LIST_TYPE = Symbol.for("react.suspense_list");
|
||||
var REACT_MEMO_TYPE = Symbol.for("react.memo");
|
||||
var REACT_LAZY_TYPE = Symbol.for("react.lazy");
|
||||
var REACT_OFFSCREEN_TYPE = Symbol.for("react.offscreen");
|
||||
var MAYBE_ITERATOR_SYMBOL = Symbol.iterator;
|
||||
var FAUX_ITERATOR_SYMBOL = "@@iterator";
|
||||
function getIteratorFn(maybeIterable) {
|
||||
if (maybeIterable === null || typeof maybeIterable !== "object") {
|
||||
return null;
|
||||
}
|
||||
var maybeIterator = MAYBE_ITERATOR_SYMBOL && maybeIterable[MAYBE_ITERATOR_SYMBOL] || maybeIterable[FAUX_ITERATOR_SYMBOL];
|
||||
if (typeof maybeIterator === "function") {
|
||||
return maybeIterator;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var ReactSharedInternals = React.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED;
|
||||
function error(format) {
|
||||
{
|
||||
{
|
||||
for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) {
|
||||
args[_key2 - 1] = arguments[_key2];
|
||||
}
|
||||
printWarning("error", format, args);
|
||||
}
|
||||
}
|
||||
}
|
||||
function printWarning(level, format, args) {
|
||||
{
|
||||
var ReactDebugCurrentFrame2 = ReactSharedInternals.ReactDebugCurrentFrame;
|
||||
var stack = ReactDebugCurrentFrame2.getStackAddendum();
|
||||
if (stack !== "") {
|
||||
format += "%s";
|
||||
args = args.concat([stack]);
|
||||
}
|
||||
var argsWithFormat = args.map(function(item) {
|
||||
return String(item);
|
||||
});
|
||||
argsWithFormat.unshift("Warning: " + format);
|
||||
Function.prototype.apply.call(console[level], console, argsWithFormat);
|
||||
}
|
||||
}
|
||||
var enableScopeAPI = false;
|
||||
var enableCacheElement = false;
|
||||
var enableTransitionTracing = false;
|
||||
var enableLegacyHidden = false;
|
||||
var enableDebugTracing = false;
|
||||
var REACT_MODULE_REFERENCE;
|
||||
{
|
||||
REACT_MODULE_REFERENCE = Symbol.for("react.module.reference");
|
||||
}
|
||||
function isValidElementType(type) {
|
||||
if (typeof type === "string" || typeof type === "function") {
|
||||
return true;
|
||||
}
|
||||
if (type === REACT_FRAGMENT_TYPE || type === REACT_PROFILER_TYPE || enableDebugTracing || type === REACT_STRICT_MODE_TYPE || type === REACT_SUSPENSE_TYPE || type === REACT_SUSPENSE_LIST_TYPE || enableLegacyHidden || type === REACT_OFFSCREEN_TYPE || enableScopeAPI || enableCacheElement || enableTransitionTracing) {
|
||||
return true;
|
||||
}
|
||||
if (typeof type === "object" && type !== null) {
|
||||
if (type.$$typeof === REACT_LAZY_TYPE || type.$$typeof === REACT_MEMO_TYPE || type.$$typeof === REACT_PROVIDER_TYPE || type.$$typeof === REACT_CONTEXT_TYPE || type.$$typeof === REACT_FORWARD_REF_TYPE || // This needs to include all possible module reference object
|
||||
// types supported by any Flight configuration anywhere since
|
||||
// we don't know which Flight build this will end up being used
|
||||
// with.
|
||||
type.$$typeof === REACT_MODULE_REFERENCE || type.getModuleId !== void 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
function getWrappedName(outerType, innerType, wrapperName) {
|
||||
var displayName = outerType.displayName;
|
||||
if (displayName) {
|
||||
return displayName;
|
||||
}
|
||||
var functionName = innerType.displayName || innerType.name || "";
|
||||
return functionName !== "" ? wrapperName + "(" + functionName + ")" : wrapperName;
|
||||
}
|
||||
function getContextName(type) {
|
||||
return type.displayName || "Context";
|
||||
}
|
||||
function getComponentNameFromType(type) {
|
||||
if (type == null) {
|
||||
return null;
|
||||
}
|
||||
{
|
||||
if (typeof type.tag === "number") {
|
||||
error("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue.");
|
||||
}
|
||||
}
|
||||
if (typeof type === "function") {
|
||||
return type.displayName || type.name || null;
|
||||
}
|
||||
if (typeof type === "string") {
|
||||
return type;
|
||||
}
|
||||
switch (type) {
|
||||
case REACT_FRAGMENT_TYPE:
|
||||
return "Fragment";
|
||||
case REACT_PORTAL_TYPE:
|
||||
return "Portal";
|
||||
case REACT_PROFILER_TYPE:
|
||||
return "Profiler";
|
||||
case REACT_STRICT_MODE_TYPE:
|
||||
return "StrictMode";
|
||||
case REACT_SUSPENSE_TYPE:
|
||||
return "Suspense";
|
||||
case REACT_SUSPENSE_LIST_TYPE:
|
||||
return "SuspenseList";
|
||||
}
|
||||
if (typeof type === "object") {
|
||||
switch (type.$$typeof) {
|
||||
case REACT_CONTEXT_TYPE:
|
||||
var context = type;
|
||||
return getContextName(context) + ".Consumer";
|
||||
case REACT_PROVIDER_TYPE:
|
||||
var provider = type;
|
||||
return getContextName(provider._context) + ".Provider";
|
||||
case REACT_FORWARD_REF_TYPE:
|
||||
return getWrappedName(type, type.render, "ForwardRef");
|
||||
case REACT_MEMO_TYPE:
|
||||
var outerName = type.displayName || null;
|
||||
if (outerName !== null) {
|
||||
return outerName;
|
||||
}
|
||||
return getComponentNameFromType(type.type) || "Memo";
|
||||
case REACT_LAZY_TYPE: {
|
||||
var lazyComponent = type;
|
||||
var payload = lazyComponent._payload;
|
||||
var init = lazyComponent._init;
|
||||
try {
|
||||
return getComponentNameFromType(init(payload));
|
||||
} catch (x) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var assign = Object.assign;
|
||||
var disabledDepth = 0;
|
||||
var prevLog;
|
||||
var prevInfo;
|
||||
var prevWarn;
|
||||
var prevError;
|
||||
var prevGroup;
|
||||
var prevGroupCollapsed;
|
||||
var prevGroupEnd;
|
||||
function disabledLog() {
|
||||
}
|
||||
disabledLog.__reactDisabledLog = true;
|
||||
function disableLogs() {
|
||||
{
|
||||
if (disabledDepth === 0) {
|
||||
prevLog = console.log;
|
||||
prevInfo = console.info;
|
||||
prevWarn = console.warn;
|
||||
prevError = console.error;
|
||||
prevGroup = console.group;
|
||||
prevGroupCollapsed = console.groupCollapsed;
|
||||
prevGroupEnd = console.groupEnd;
|
||||
var props = {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
value: disabledLog,
|
||||
writable: true
|
||||
};
|
||||
Object.defineProperties(console, {
|
||||
info: props,
|
||||
log: props,
|
||||
warn: props,
|
||||
error: props,
|
||||
group: props,
|
||||
groupCollapsed: props,
|
||||
groupEnd: props
|
||||
});
|
||||
}
|
||||
disabledDepth++;
|
||||
}
|
||||
}
|
||||
function reenableLogs() {
|
||||
{
|
||||
disabledDepth--;
|
||||
if (disabledDepth === 0) {
|
||||
var props = {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true
|
||||
};
|
||||
Object.defineProperties(console, {
|
||||
log: assign({}, props, {
|
||||
value: prevLog
|
||||
}),
|
||||
info: assign({}, props, {
|
||||
value: prevInfo
|
||||
}),
|
||||
warn: assign({}, props, {
|
||||
value: prevWarn
|
||||
}),
|
||||
error: assign({}, props, {
|
||||
value: prevError
|
||||
}),
|
||||
group: assign({}, props, {
|
||||
value: prevGroup
|
||||
}),
|
||||
groupCollapsed: assign({}, props, {
|
||||
value: prevGroupCollapsed
|
||||
}),
|
||||
groupEnd: assign({}, props, {
|
||||
value: prevGroupEnd
|
||||
})
|
||||
});
|
||||
}
|
||||
if (disabledDepth < 0) {
|
||||
error("disabledDepth fell below zero. This is a bug in React. Please file an issue.");
|
||||
}
|
||||
}
|
||||
}
|
||||
var ReactCurrentDispatcher = ReactSharedInternals.ReactCurrentDispatcher;
|
||||
var prefix;
|
||||
function describeBuiltInComponentFrame(name, source, ownerFn) {
|
||||
{
|
||||
if (prefix === void 0) {
|
||||
try {
|
||||
throw Error();
|
||||
} catch (x) {
|
||||
var match = x.stack.trim().match(/\n( *(at )?)/);
|
||||
prefix = match && match[1] || "";
|
||||
}
|
||||
}
|
||||
return "\n" + prefix + name;
|
||||
}
|
||||
}
|
||||
var reentry = false;
|
||||
var componentFrameCache;
|
||||
{
|
||||
var PossiblyWeakMap = typeof WeakMap === "function" ? WeakMap : Map;
|
||||
componentFrameCache = new PossiblyWeakMap();
|
||||
}
|
||||
function describeNativeComponentFrame(fn, construct) {
|
||||
if (!fn || reentry) {
|
||||
return "";
|
||||
}
|
||||
{
|
||||
var frame = componentFrameCache.get(fn);
|
||||
if (frame !== void 0) {
|
||||
return frame;
|
||||
}
|
||||
}
|
||||
var control;
|
||||
reentry = true;
|
||||
var previousPrepareStackTrace = Error.prepareStackTrace;
|
||||
Error.prepareStackTrace = void 0;
|
||||
var previousDispatcher;
|
||||
{
|
||||
previousDispatcher = ReactCurrentDispatcher.current;
|
||||
ReactCurrentDispatcher.current = null;
|
||||
disableLogs();
|
||||
}
|
||||
try {
|
||||
if (construct) {
|
||||
var Fake = function() {
|
||||
throw Error();
|
||||
};
|
||||
Object.defineProperty(Fake.prototype, "props", {
|
||||
set: function() {
|
||||
throw Error();
|
||||
}
|
||||
});
|
||||
if (typeof Reflect === "object" && Reflect.construct) {
|
||||
try {
|
||||
Reflect.construct(Fake, []);
|
||||
} catch (x) {
|
||||
control = x;
|
||||
}
|
||||
Reflect.construct(fn, [], Fake);
|
||||
} else {
|
||||
try {
|
||||
Fake.call();
|
||||
} catch (x) {
|
||||
control = x;
|
||||
}
|
||||
fn.call(Fake.prototype);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
throw Error();
|
||||
} catch (x) {
|
||||
control = x;
|
||||
}
|
||||
fn();
|
||||
}
|
||||
} catch (sample) {
|
||||
if (sample && control && typeof sample.stack === "string") {
|
||||
var sampleLines = sample.stack.split("\n");
|
||||
var controlLines = control.stack.split("\n");
|
||||
var s = sampleLines.length - 1;
|
||||
var c = controlLines.length - 1;
|
||||
while (s >= 1 && c >= 0 && sampleLines[s] !== controlLines[c]) {
|
||||
c--;
|
||||
}
|
||||
for (; s >= 1 && c >= 0; s--, c--) {
|
||||
if (sampleLines[s] !== controlLines[c]) {
|
||||
if (s !== 1 || c !== 1) {
|
||||
do {
|
||||
s--;
|
||||
c--;
|
||||
if (c < 0 || sampleLines[s] !== controlLines[c]) {
|
||||
var _frame = "\n" + sampleLines[s].replace(" at new ", " at ");
|
||||
if (fn.displayName && _frame.includes("<anonymous>")) {
|
||||
_frame = _frame.replace("<anonymous>", fn.displayName);
|
||||
}
|
||||
{
|
||||
if (typeof fn === "function") {
|
||||
componentFrameCache.set(fn, _frame);
|
||||
}
|
||||
}
|
||||
return _frame;
|
||||
}
|
||||
} while (s >= 1 && c >= 0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reentry = false;
|
||||
{
|
||||
ReactCurrentDispatcher.current = previousDispatcher;
|
||||
reenableLogs();
|
||||
}
|
||||
Error.prepareStackTrace = previousPrepareStackTrace;
|
||||
}
|
||||
var name = fn ? fn.displayName || fn.name : "";
|
||||
var syntheticFrame = name ? describeBuiltInComponentFrame(name) : "";
|
||||
{
|
||||
if (typeof fn === "function") {
|
||||
componentFrameCache.set(fn, syntheticFrame);
|
||||
}
|
||||
}
|
||||
return syntheticFrame;
|
||||
}
|
||||
function describeFunctionComponentFrame(fn, source, ownerFn) {
|
||||
{
|
||||
return describeNativeComponentFrame(fn, false);
|
||||
}
|
||||
}
|
||||
function shouldConstruct(Component) {
|
||||
var prototype = Component.prototype;
|
||||
return !!(prototype && prototype.isReactComponent);
|
||||
}
|
||||
function describeUnknownElementTypeFrameInDEV(type, source, ownerFn) {
|
||||
if (type == null) {
|
||||
return "";
|
||||
}
|
||||
if (typeof type === "function") {
|
||||
{
|
||||
return describeNativeComponentFrame(type, shouldConstruct(type));
|
||||
}
|
||||
}
|
||||
if (typeof type === "string") {
|
||||
return describeBuiltInComponentFrame(type);
|
||||
}
|
||||
switch (type) {
|
||||
case REACT_SUSPENSE_TYPE:
|
||||
return describeBuiltInComponentFrame("Suspense");
|
||||
case REACT_SUSPENSE_LIST_TYPE:
|
||||
return describeBuiltInComponentFrame("SuspenseList");
|
||||
}
|
||||
if (typeof type === "object") {
|
||||
switch (type.$$typeof) {
|
||||
case REACT_FORWARD_REF_TYPE:
|
||||
return describeFunctionComponentFrame(type.render);
|
||||
case REACT_MEMO_TYPE:
|
||||
return describeUnknownElementTypeFrameInDEV(type.type, source, ownerFn);
|
||||
case REACT_LAZY_TYPE: {
|
||||
var lazyComponent = type;
|
||||
var payload = lazyComponent._payload;
|
||||
var init = lazyComponent._init;
|
||||
try {
|
||||
return describeUnknownElementTypeFrameInDEV(init(payload), source, ownerFn);
|
||||
} catch (x) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
var hasOwnProperty = Object.prototype.hasOwnProperty;
|
||||
var loggedTypeFailures = {};
|
||||
var ReactDebugCurrentFrame = ReactSharedInternals.ReactDebugCurrentFrame;
|
||||
function setCurrentlyValidatingElement(element) {
|
||||
{
|
||||
if (element) {
|
||||
var owner = element._owner;
|
||||
var stack = describeUnknownElementTypeFrameInDEV(element.type, element._source, owner ? owner.type : null);
|
||||
ReactDebugCurrentFrame.setExtraStackFrame(stack);
|
||||
} else {
|
||||
ReactDebugCurrentFrame.setExtraStackFrame(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
function checkPropTypes(typeSpecs, values, location, componentName, element) {
|
||||
{
|
||||
var has = Function.call.bind(hasOwnProperty);
|
||||
for (var typeSpecName in typeSpecs) {
|
||||
if (has(typeSpecs, typeSpecName)) {
|
||||
var error$1 = void 0;
|
||||
try {
|
||||
if (typeof typeSpecs[typeSpecName] !== "function") {
|
||||
var err = Error((componentName || "React class") + ": " + location + " type `" + typeSpecName + "` is invalid; it must be a function, usually from the `prop-types` package, but received `" + typeof typeSpecs[typeSpecName] + "`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.");
|
||||
err.name = "Invariant Violation";
|
||||
throw err;
|
||||
}
|
||||
error$1 = typeSpecs[typeSpecName](values, typeSpecName, componentName, location, null, "SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED");
|
||||
} catch (ex) {
|
||||
error$1 = ex;
|
||||
}
|
||||
if (error$1 && !(error$1 instanceof Error)) {
|
||||
setCurrentlyValidatingElement(element);
|
||||
error("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).", componentName || "React class", location, typeSpecName, typeof error$1);
|
||||
setCurrentlyValidatingElement(null);
|
||||
}
|
||||
if (error$1 instanceof Error && !(error$1.message in loggedTypeFailures)) {
|
||||
loggedTypeFailures[error$1.message] = true;
|
||||
setCurrentlyValidatingElement(element);
|
||||
error("Failed %s type: %s", location, error$1.message);
|
||||
setCurrentlyValidatingElement(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var isArrayImpl = Array.isArray;
|
||||
function isArray(a) {
|
||||
return isArrayImpl(a);
|
||||
}
|
||||
function typeName(value) {
|
||||
{
|
||||
var hasToStringTag = typeof Symbol === "function" && Symbol.toStringTag;
|
||||
var type = hasToStringTag && value[Symbol.toStringTag] || value.constructor.name || "Object";
|
||||
return type;
|
||||
}
|
||||
}
|
||||
function willCoercionThrow(value) {
|
||||
{
|
||||
try {
|
||||
testStringCoercion(value);
|
||||
return false;
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
function testStringCoercion(value) {
|
||||
return "" + value;
|
||||
}
|
||||
function checkKeyStringCoercion(value) {
|
||||
{
|
||||
if (willCoercionThrow(value)) {
|
||||
error("The provided key is an unsupported type %s. This value must be coerced to a string before before using it here.", typeName(value));
|
||||
return testStringCoercion(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
var ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
|
||||
var RESERVED_PROPS = {
|
||||
key: true,
|
||||
ref: true,
|
||||
__self: true,
|
||||
__source: true
|
||||
};
|
||||
var specialPropKeyWarningShown;
|
||||
var specialPropRefWarningShown;
|
||||
var didWarnAboutStringRefs;
|
||||
{
|
||||
didWarnAboutStringRefs = {};
|
||||
}
|
||||
function hasValidRef(config) {
|
||||
{
|
||||
if (hasOwnProperty.call(config, "ref")) {
|
||||
var getter = Object.getOwnPropertyDescriptor(config, "ref").get;
|
||||
if (getter && getter.isReactWarning) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return config.ref !== void 0;
|
||||
}
|
||||
function hasValidKey(config) {
|
||||
{
|
||||
if (hasOwnProperty.call(config, "key")) {
|
||||
var getter = Object.getOwnPropertyDescriptor(config, "key").get;
|
||||
if (getter && getter.isReactWarning) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return config.key !== void 0;
|
||||
}
|
||||
function warnIfStringRefCannotBeAutoConverted(config, self) {
|
||||
{
|
||||
if (typeof config.ref === "string" && ReactCurrentOwner.current && self && ReactCurrentOwner.current.stateNode !== self) {
|
||||
var componentName = getComponentNameFromType(ReactCurrentOwner.current.type);
|
||||
if (!didWarnAboutStringRefs[componentName]) {
|
||||
error('Component "%s" contains the string ref "%s". Support for string refs will be removed in a future major release. This case cannot be automatically converted to an arrow function. We ask you to manually fix this case by using useRef() or createRef() instead. Learn more about using refs safely here: https://reactjs.org/link/strict-mode-string-ref', getComponentNameFromType(ReactCurrentOwner.current.type), config.ref);
|
||||
didWarnAboutStringRefs[componentName] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function defineKeyPropWarningGetter(props, displayName) {
|
||||
{
|
||||
var warnAboutAccessingKey = function() {
|
||||
if (!specialPropKeyWarningShown) {
|
||||
specialPropKeyWarningShown = true;
|
||||
error("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)", displayName);
|
||||
}
|
||||
};
|
||||
warnAboutAccessingKey.isReactWarning = true;
|
||||
Object.defineProperty(props, "key", {
|
||||
get: warnAboutAccessingKey,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
function defineRefPropWarningGetter(props, displayName) {
|
||||
{
|
||||
var warnAboutAccessingRef = function() {
|
||||
if (!specialPropRefWarningShown) {
|
||||
specialPropRefWarningShown = true;
|
||||
error("%s: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)", displayName);
|
||||
}
|
||||
};
|
||||
warnAboutAccessingRef.isReactWarning = true;
|
||||
Object.defineProperty(props, "ref", {
|
||||
get: warnAboutAccessingRef,
|
||||
configurable: true
|
||||
});
|
||||
}
|
||||
}
|
||||
var ReactElement = function(type, key, ref, self, source, owner, props) {
|
||||
var element = {
|
||||
// This tag allows us to uniquely identify this as a React Element
|
||||
$$typeof: REACT_ELEMENT_TYPE,
|
||||
// Built-in properties that belong on the element
|
||||
type,
|
||||
key,
|
||||
ref,
|
||||
props,
|
||||
// Record the component responsible for creating this element.
|
||||
_owner: owner
|
||||
};
|
||||
{
|
||||
element._store = {};
|
||||
Object.defineProperty(element._store, "validated", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: true,
|
||||
value: false
|
||||
});
|
||||
Object.defineProperty(element, "_self", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: self
|
||||
});
|
||||
Object.defineProperty(element, "_source", {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: source
|
||||
});
|
||||
if (Object.freeze) {
|
||||
Object.freeze(element.props);
|
||||
Object.freeze(element);
|
||||
}
|
||||
}
|
||||
return element;
|
||||
};
|
||||
function jsxDEV(type, config, maybeKey, source, self) {
|
||||
{
|
||||
var propName;
|
||||
var props = {};
|
||||
var key = null;
|
||||
var ref = null;
|
||||
if (maybeKey !== void 0) {
|
||||
{
|
||||
checkKeyStringCoercion(maybeKey);
|
||||
}
|
||||
key = "" + maybeKey;
|
||||
}
|
||||
if (hasValidKey(config)) {
|
||||
{
|
||||
checkKeyStringCoercion(config.key);
|
||||
}
|
||||
key = "" + config.key;
|
||||
}
|
||||
if (hasValidRef(config)) {
|
||||
ref = config.ref;
|
||||
warnIfStringRefCannotBeAutoConverted(config, self);
|
||||
}
|
||||
for (propName in config) {
|
||||
if (hasOwnProperty.call(config, propName) && !RESERVED_PROPS.hasOwnProperty(propName)) {
|
||||
props[propName] = config[propName];
|
||||
}
|
||||
}
|
||||
if (type && type.defaultProps) {
|
||||
var defaultProps = type.defaultProps;
|
||||
for (propName in defaultProps) {
|
||||
if (props[propName] === void 0) {
|
||||
props[propName] = defaultProps[propName];
|
||||
}
|
||||
}
|
||||
}
|
||||
if (key || ref) {
|
||||
var displayName = typeof type === "function" ? type.displayName || type.name || "Unknown" : type;
|
||||
if (key) {
|
||||
defineKeyPropWarningGetter(props, displayName);
|
||||
}
|
||||
if (ref) {
|
||||
defineRefPropWarningGetter(props, displayName);
|
||||
}
|
||||
}
|
||||
return ReactElement(type, key, ref, self, source, ReactCurrentOwner.current, props);
|
||||
}
|
||||
}
|
||||
var ReactCurrentOwner$1 = ReactSharedInternals.ReactCurrentOwner;
|
||||
var ReactDebugCurrentFrame$1 = ReactSharedInternals.ReactDebugCurrentFrame;
|
||||
function setCurrentlyValidatingElement$1(element) {
|
||||
{
|
||||
if (element) {
|
||||
var owner = element._owner;
|
||||
var stack = describeUnknownElementTypeFrameInDEV(element.type, element._source, owner ? owner.type : null);
|
||||
ReactDebugCurrentFrame$1.setExtraStackFrame(stack);
|
||||
} else {
|
||||
ReactDebugCurrentFrame$1.setExtraStackFrame(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
var propTypesMisspellWarningShown;
|
||||
{
|
||||
propTypesMisspellWarningShown = false;
|
||||
}
|
||||
function isValidElement(object) {
|
||||
{
|
||||
return typeof object === "object" && object !== null && object.$$typeof === REACT_ELEMENT_TYPE;
|
||||
}
|
||||
}
|
||||
function getDeclarationErrorAddendum() {
|
||||
{
|
||||
if (ReactCurrentOwner$1.current) {
|
||||
var name = getComponentNameFromType(ReactCurrentOwner$1.current.type);
|
||||
if (name) {
|
||||
return "\n\nCheck the render method of `" + name + "`.";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
function getSourceInfoErrorAddendum(source) {
|
||||
{
|
||||
if (source !== void 0) {
|
||||
var fileName = source.fileName.replace(/^.*[\\\/]/, "");
|
||||
var lineNumber = source.lineNumber;
|
||||
return "\n\nCheck your code at " + fileName + ":" + lineNumber + ".";
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
var ownerHasKeyUseWarning = {};
|
||||
function getCurrentComponentErrorInfo(parentType) {
|
||||
{
|
||||
var info = getDeclarationErrorAddendum();
|
||||
if (!info) {
|
||||
var parentName = typeof parentType === "string" ? parentType : parentType.displayName || parentType.name;
|
||||
if (parentName) {
|
||||
info = "\n\nCheck the top-level render call using <" + parentName + ">.";
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
}
|
||||
function validateExplicitKey(element, parentType) {
|
||||
{
|
||||
if (!element._store || element._store.validated || element.key != null) {
|
||||
return;
|
||||
}
|
||||
element._store.validated = true;
|
||||
var currentComponentErrorInfo = getCurrentComponentErrorInfo(parentType);
|
||||
if (ownerHasKeyUseWarning[currentComponentErrorInfo]) {
|
||||
return;
|
||||
}
|
||||
ownerHasKeyUseWarning[currentComponentErrorInfo] = true;
|
||||
var childOwner = "";
|
||||
if (element && element._owner && element._owner !== ReactCurrentOwner$1.current) {
|
||||
childOwner = " It was passed a child from " + getComponentNameFromType(element._owner.type) + ".";
|
||||
}
|
||||
setCurrentlyValidatingElement$1(element);
|
||||
error('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.', currentComponentErrorInfo, childOwner);
|
||||
setCurrentlyValidatingElement$1(null);
|
||||
}
|
||||
}
|
||||
function validateChildKeys(node, parentType) {
|
||||
{
|
||||
if (typeof node !== "object") {
|
||||
return;
|
||||
}
|
||||
if (isArray(node)) {
|
||||
for (var i = 0; i < node.length; i++) {
|
||||
var child = node[i];
|
||||
if (isValidElement(child)) {
|
||||
validateExplicitKey(child, parentType);
|
||||
}
|
||||
}
|
||||
} else if (isValidElement(node)) {
|
||||
if (node._store) {
|
||||
node._store.validated = true;
|
||||
}
|
||||
} else if (node) {
|
||||
var iteratorFn = getIteratorFn(node);
|
||||
if (typeof iteratorFn === "function") {
|
||||
if (iteratorFn !== node.entries) {
|
||||
var iterator = iteratorFn.call(node);
|
||||
var step;
|
||||
while (!(step = iterator.next()).done) {
|
||||
if (isValidElement(step.value)) {
|
||||
validateExplicitKey(step.value, parentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function validatePropTypes(element) {
|
||||
{
|
||||
var type = element.type;
|
||||
if (type === null || type === void 0 || typeof type === "string") {
|
||||
return;
|
||||
}
|
||||
var propTypes;
|
||||
if (typeof type === "function") {
|
||||
propTypes = type.propTypes;
|
||||
} else if (typeof type === "object" && (type.$$typeof === REACT_FORWARD_REF_TYPE || // Note: Memo only checks outer props here.
|
||||
// Inner props are checked in the reconciler.
|
||||
type.$$typeof === REACT_MEMO_TYPE)) {
|
||||
propTypes = type.propTypes;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (propTypes) {
|
||||
var name = getComponentNameFromType(type);
|
||||
checkPropTypes(propTypes, element.props, "prop", name, element);
|
||||
} else if (type.PropTypes !== void 0 && !propTypesMisspellWarningShown) {
|
||||
propTypesMisspellWarningShown = true;
|
||||
var _name = getComponentNameFromType(type);
|
||||
error("Component %s declared `PropTypes` instead of `propTypes`. Did you misspell the property assignment?", _name || "Unknown");
|
||||
}
|
||||
if (typeof type.getDefaultProps === "function" && !type.getDefaultProps.isReactClassApproved) {
|
||||
error("getDefaultProps is only used on classic React.createClass definitions. Use a static property named `defaultProps` instead.");
|
||||
}
|
||||
}
|
||||
}
|
||||
function validateFragmentProps(fragment) {
|
||||
{
|
||||
var keys = Object.keys(fragment.props);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (key !== "children" && key !== "key") {
|
||||
setCurrentlyValidatingElement$1(fragment);
|
||||
error("Invalid prop `%s` supplied to `React.Fragment`. React.Fragment can only have `key` and `children` props.", key);
|
||||
setCurrentlyValidatingElement$1(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (fragment.ref !== null) {
|
||||
setCurrentlyValidatingElement$1(fragment);
|
||||
error("Invalid attribute `ref` supplied to `React.Fragment`.");
|
||||
setCurrentlyValidatingElement$1(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
var didWarnAboutKeySpread = {};
|
||||
function jsxWithValidation(type, props, key, isStaticChildren, source, self) {
|
||||
{
|
||||
var validType = isValidElementType(type);
|
||||
if (!validType) {
|
||||
var info = "";
|
||||
if (type === void 0 || typeof type === "object" && type !== null && Object.keys(type).length === 0) {
|
||||
info += " You likely forgot to export your component from the file it's defined in, or you might have mixed up default and named imports.";
|
||||
}
|
||||
var sourceInfo = getSourceInfoErrorAddendum(source);
|
||||
if (sourceInfo) {
|
||||
info += sourceInfo;
|
||||
} else {
|
||||
info += getDeclarationErrorAddendum();
|
||||
}
|
||||
var typeString;
|
||||
if (type === null) {
|
||||
typeString = "null";
|
||||
} else if (isArray(type)) {
|
||||
typeString = "array";
|
||||
} else if (type !== void 0 && type.$$typeof === REACT_ELEMENT_TYPE) {
|
||||
typeString = "<" + (getComponentNameFromType(type.type) || "Unknown") + " />";
|
||||
info = " Did you accidentally export a JSX literal instead of a component?";
|
||||
} else {
|
||||
typeString = typeof type;
|
||||
}
|
||||
error("React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s", typeString, info);
|
||||
}
|
||||
var element = jsxDEV(type, props, key, source, self);
|
||||
if (element == null) {
|
||||
return element;
|
||||
}
|
||||
if (validType) {
|
||||
var children = props.children;
|
||||
if (children !== void 0) {
|
||||
if (isStaticChildren) {
|
||||
if (isArray(children)) {
|
||||
for (var i = 0; i < children.length; i++) {
|
||||
validateChildKeys(children[i], type);
|
||||
}
|
||||
if (Object.freeze) {
|
||||
Object.freeze(children);
|
||||
}
|
||||
} else {
|
||||
error("React.jsx: Static children should always be an array. You are likely explicitly calling React.jsxs or React.jsxDEV. Use the Babel transform instead.");
|
||||
}
|
||||
} else {
|
||||
validateChildKeys(children, type);
|
||||
}
|
||||
}
|
||||
}
|
||||
{
|
||||
if (hasOwnProperty.call(props, "key")) {
|
||||
var componentName = getComponentNameFromType(type);
|
||||
var keys = Object.keys(props).filter(function(k) {
|
||||
return k !== "key";
|
||||
});
|
||||
var beforeExample = keys.length > 0 ? "{key: someKey, " + keys.join(": ..., ") + ": ...}" : "{key: someKey}";
|
||||
if (!didWarnAboutKeySpread[componentName + beforeExample]) {
|
||||
var afterExample = keys.length > 0 ? "{" + keys.join(": ..., ") + ": ...}" : "{}";
|
||||
error('A props object containing a "key" prop is being spread into JSX:\n let props = %s;\n <%s {...props} />\nReact keys must be passed directly to JSX without using spread:\n let props = %s;\n <%s key={someKey} {...props} />', beforeExample, componentName, afterExample, componentName);
|
||||
didWarnAboutKeySpread[componentName + beforeExample] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === REACT_FRAGMENT_TYPE) {
|
||||
validateFragmentProps(element);
|
||||
} else {
|
||||
validatePropTypes(element);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
}
|
||||
var jsxDEV$1 = jsxWithValidation;
|
||||
exports.Fragment = REACT_FRAGMENT_TYPE;
|
||||
exports.jsxDEV = jsxDEV$1;
|
||||
})();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// node_modules/react/jsx-dev-runtime.js
|
||||
var require_jsx_dev_runtime = __commonJS({
|
||||
"node_modules/react/jsx-dev-runtime.js"(exports, module) {
|
||||
if (false) {
|
||||
module.exports = null;
|
||||
} else {
|
||||
module.exports = require_react_jsx_dev_runtime_development();
|
||||
}
|
||||
}
|
||||
});
|
||||
export default require_jsx_dev_runtime();
|
||||
/*! Bundled license information:
|
||||
|
||||
react/cjs/react-jsx-dev-runtime.development.js:
|
||||
(**
|
||||
* @license React
|
||||
* react-jsx-dev-runtime.development.js
|
||||
*
|
||||
* Copyright (c) Facebook, Inc. and its affiliates.
|
||||
*
|
||||
* This source code is licensed under the MIT license found in the
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*)
|
||||
*/
|
||||
//# sourceMappingURL=react_jsx-dev-runtime.js.map
|
||||
7
.vite/deps/react_jsx-dev-runtime.js.map
Normal file
7
.vite/deps/react_jsx-dev-runtime.js.map
Normal file
File diff suppressed because one or more lines are too long
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
||||
# 抽奖系统
|
||||
|
||||
一个基于React + Node.js + SQLite的抽奖系统,支持多浏览器数据同步。
|
||||
|
||||
## 功能特点
|
||||
|
||||
- ✅ **统一数据库**: 使用SQLite数据库,确保所有浏览器访问相同数据
|
||||
- ✅ **实时同步**: 前后端分离架构,数据实时同步
|
||||
- ✅ **多浏览器支持**: 不同浏览器访问显示相同的抽奖数据
|
||||
- ✅ **管理功能**: 完整的后台管理系统
|
||||
- ✅ **抽奖记录**: 完整的抽奖历史记录
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 方式一:使用启动脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 一键启动前后端服务
|
||||
./start.sh
|
||||
```
|
||||
|
||||
### 方式二:手动启动
|
||||
|
||||
1. 安装依赖:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
2. 启动后端服务器:
|
||||
```bash
|
||||
node server/index.js
|
||||
```
|
||||
|
||||
3. 启动前端开发服务器:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 访问地址
|
||||
|
||||
- **前端页面**: http://localhost:5173
|
||||
- **后端API**: http://localhost:3001
|
||||
- **管理页面**: http://localhost:5173/admin
|
||||
|
||||
## 数据库说明
|
||||
|
||||
系统使用SQLite数据库存储所有数据,数据库文件位于:`/server/lottery.db`
|
||||
|
||||
### 数据表结构
|
||||
|
||||
- `system_config`: 系统配置
|
||||
- `prizes`: 奖项信息
|
||||
- `students`: 学生抽奖记录
|
||||
- `records`: 抽奖历史记录
|
||||
|
||||
## 多浏览器测试
|
||||
|
||||
1. 在Chrome中打开: http://localhost:5173
|
||||
2. 在Firefox中打开: http://localhost:5173
|
||||
3. 在Safari中打开: http://localhost:5173
|
||||
|
||||
所有浏览器将显示相同的数据,确保数据一致性。
|
||||
|
||||
## 默认配置
|
||||
|
||||
- 管理员密码: `123456`
|
||||
- 登录密码: `123456`
|
||||
- 最大抽奖次数: `1`
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端**: React 18 + TypeScript + Vite + Tailwind CSS
|
||||
- **后端**: Node.js + Express + SQLite3
|
||||
- **状态管理**: Zustand
|
||||
- **数据库**: SQLite
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 前端无法连接后端
|
||||
|
||||
1. 确保后端服务器正在运行(端口3001)
|
||||
2. 检查CORS配置
|
||||
3. 确认防火墙设置
|
||||
|
||||
### 数据不同步
|
||||
|
||||
1. 确认所有浏览器都访问同一个前端地址
|
||||
2. 检查后端数据库文件是否存在
|
||||
3. 清除浏览器缓存后重试
|
||||
|
||||
### 端口冲突
|
||||
|
||||
- 前端默认端口: 5173
|
||||
- 后端默认端口: 3001
|
||||
|
||||
如需修改端口,请编辑相应的配置文件。
|
||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
25
index.html
Normal file
25
index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>抽奖系统 - 四喜科技</title>
|
||||
<script type="module">
|
||||
if (import.meta.hot?.on) {
|
||||
import.meta.hot.on('vite:error', (error) => {
|
||||
if (error.err) {
|
||||
console.error(
|
||||
[error.err.message, error.err.frame].filter(Boolean).join('\n'),
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
0
lottery.db
Normal file
0
lottery.db
Normal file
7777
package-lock.json
generated
Normal file
7777
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
57
package.json
Normal file
57
package.json
Normal file
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "draw",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"check": "tsc -b --noEmit",
|
||||
"start": "./start.sh",
|
||||
"server": "node server/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.39.0",
|
||||
"@types/cors": "^2.8.19",
|
||||
"@types/express": "^5.0.3",
|
||||
"clsx": "^2.1.1",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2",
|
||||
"framer-motion": "^11.0.0",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^7.3.0",
|
||||
"socket.io": "^4.8.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"sqlite3": "^5.1.7",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"zustand": "^5.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.4.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"babel-plugin-react-dev-locator": "^1.0.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"jsdom": "^22.1.0",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"terser": "^5.44.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5",
|
||||
"vite-plugin-trae-solo-badge": "^1.0.0",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
10
postcss.config.js
Normal file
10
postcss.config.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
/** WARNING: DON'T EDIT THIS FILE */
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
13
public/favicon.svg
Normal file
13
public/favicon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Background circle -->
|
||||
<circle cx="16" cy="16" r="15" fill="#1a1a1a" stroke="#00d4aa" stroke-width="2"/>
|
||||
|
||||
<!-- NGX Text -->
|
||||
<text x="16" y="20" font-family="Arial, sans-serif" font-size="10" font-weight="bold" text-anchor="middle" fill="#00d4aa">NGX</text>
|
||||
|
||||
<!-- Decorative elements -->
|
||||
<circle cx="8" cy="8" r="1.5" fill="#00d4aa" opacity="0.8"/>
|
||||
<circle cx="24" cy="8" r="1.5" fill="#00d4aa" opacity="0.8"/>
|
||||
<circle cx="8" cy="24" r="1.5" fill="#00d4aa" opacity="0.6"/>
|
||||
<circle cx="24" cy="24" r="1.5" fill="#00d4aa" opacity="0.6"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 664 B |
1062
server/index.js
Normal file
1062
server/index.js
Normal file
File diff suppressed because it is too large
Load Diff
BIN
server/lottery.db
Normal file
BIN
server/lottery.db
Normal file
Binary file not shown.
151
src/App.tsx
Normal file
151
src/App.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAppStore } from './store'
|
||||
import { ToastProvider } from './contexts/ToastContext'
|
||||
import AuthService from './services/authService'
|
||||
|
||||
// 懒加载页面组件
|
||||
const Login = React.lazy(() => import('./pages/Login'))
|
||||
const Lottery = React.lazy(() => import('./pages/Lottery'))
|
||||
const Home = React.lazy(() => import('./pages/Home'))
|
||||
const Admin = React.lazy(() => import('./pages/Admin'))
|
||||
const Records = React.lazy(() => import('./pages/Records'))
|
||||
const ClearRecords = React.lazy(() => import('./pages/ClearRecords'))
|
||||
const ConfettiDemo = React.lazy(() => import('./pages/ConfettiDemo'))
|
||||
const CrudTest = React.lazy(() => import('./pages/CrudTest'))
|
||||
const NetworkTest = React.lazy(() => import('./pages/NetworkTest'))
|
||||
const SimpleNetworkTest = React.lazy(() => import('./pages/SimpleNetworkTest'))
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, initializeApp, checkAuthStatus } = useAppStore()
|
||||
const [isInitializing, setIsInitializing] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const initApp = async () => {
|
||||
try {
|
||||
// 首先检查认证状态
|
||||
checkAuthStatus()
|
||||
|
||||
// 然后初始化应用
|
||||
await initializeApp()
|
||||
} catch (error) {
|
||||
console.error('应用初始化失败:', error)
|
||||
} finally {
|
||||
setIsInitializing(false)
|
||||
}
|
||||
}
|
||||
|
||||
initApp()
|
||||
}, [initializeApp, checkAuthStatus])
|
||||
|
||||
// 监听页面可见性变化,恢复时检查认证状态
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (!document.hidden) {
|
||||
checkAuthStatus()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
}
|
||||
}, [checkAuthStatus])
|
||||
|
||||
// 应用卸载时清理资源
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const { cleanup } = useAppStore.getState()
|
||||
cleanup()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 应用初始化中的加载状态
|
||||
if (isInitializing) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-white text-lg">正在初始化应用...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900">
|
||||
|
||||
|
||||
{/* 背景装饰 */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute top-3/4 right-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 left-1/2 w-96 h-96 bg-indigo-500/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
{/* 路由配置 */}
|
||||
<React.Suspense fallback={
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-4"></div>
|
||||
<p className="text-white text-lg">正在加载页面...</p>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={isAuthenticated ? <Lottery /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/home"
|
||||
element={isAuthenticated ? <Home /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={isAuthenticated ? <Admin /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/records"
|
||||
element={isAuthenticated ? <Records /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/clear-records"
|
||||
element={isAuthenticated ? <ClearRecords /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/confetti-demo"
|
||||
element={isAuthenticated ? <ConfettiDemo /> : <Navigate to="/login" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/crud-test"
|
||||
element={<CrudTest />}
|
||||
/>
|
||||
<Route
|
||||
path="/network-test"
|
||||
element={<NetworkTest />}
|
||||
/>
|
||||
<Route
|
||||
path="/simple-test"
|
||||
element={<SimpleNetworkTest />}
|
||||
/>
|
||||
<Route
|
||||
path="*"
|
||||
element={<Navigate to={isAuthenticated ? "/" : "/login"} replace />}
|
||||
/>
|
||||
</Routes>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</Router>
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
151
src/components/ConfettiEffect.tsx
Normal file
151
src/components/ConfettiEffect.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ConfettiPiece {
|
||||
id: number;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
size: number;
|
||||
rotation: number;
|
||||
delay: number;
|
||||
}
|
||||
|
||||
interface ConfettiEffectProps {
|
||||
isActive: boolean;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const ConfettiEffect: React.FC<ConfettiEffectProps> = ({
|
||||
isActive,
|
||||
duration = 3000
|
||||
}) => {
|
||||
const [confetti, setConfetti] = useState<ConfettiPiece[]>([]);
|
||||
const [showEffect, setShowEffect] = useState(false);
|
||||
|
||||
const colors = [
|
||||
'#FFD700', // 金色
|
||||
'#FF6B6B', // 红色
|
||||
'#4ECDC4', // 青色
|
||||
'#45B7D1', // 蓝色
|
||||
'#96CEB4', // 绿色
|
||||
'#FFEAA7', // 黄色
|
||||
'#DDA0DD', // 紫色
|
||||
'#FFA07A', // 橙色
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setShowEffect(true);
|
||||
|
||||
// 生成撒花粒子
|
||||
const pieces: ConfettiPiece[] = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
pieces.push({
|
||||
id: i,
|
||||
x: Math.random() * window.innerWidth,
|
||||
y: -20,
|
||||
color: colors[Math.floor(Math.random() * colors.length)],
|
||||
size: Math.random() * 8 + 4,
|
||||
rotation: Math.random() * 360,
|
||||
delay: Math.random() * 1000,
|
||||
});
|
||||
}
|
||||
setConfetti(pieces);
|
||||
|
||||
// 设置效果持续时间
|
||||
const timer = setTimeout(() => {
|
||||
setShowEffect(false);
|
||||
setConfetti([]);
|
||||
}, duration);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isActive, duration]);
|
||||
|
||||
if (!showEffect) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
|
||||
{confetti.map((piece) => (
|
||||
<motion.div
|
||||
key={piece.id}
|
||||
className="absolute"
|
||||
style={{
|
||||
left: piece.x,
|
||||
top: piece.y,
|
||||
width: piece.size,
|
||||
height: piece.size,
|
||||
backgroundColor: piece.color,
|
||||
borderRadius: Math.random() > 0.5 ? '50%' : '0%',
|
||||
}}
|
||||
initial={{
|
||||
y: -20,
|
||||
rotate: piece.rotation,
|
||||
opacity: 1,
|
||||
}}
|
||||
animate={{
|
||||
y: window.innerHeight + 100,
|
||||
rotate: piece.rotation + 720,
|
||||
opacity: 0,
|
||||
x: piece.x + (Math.random() - 0.5) * 200,
|
||||
}}
|
||||
transition={{
|
||||
duration: 3,
|
||||
delay: piece.delay / 1000,
|
||||
ease: 'easeOut',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 额外的星星特效 */}
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={`star-${i}`}
|
||||
className="absolute text-yellow-400 text-2xl"
|
||||
style={{
|
||||
left: Math.random() * window.innerWidth,
|
||||
top: Math.random() * 100,
|
||||
}}
|
||||
initial={{
|
||||
scale: 0,
|
||||
rotate: 0,
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
scale: [0, 1.5, 0],
|
||||
rotate: 360,
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
delay: Math.random() * 1000 / 1000,
|
||||
repeat: 1,
|
||||
}}
|
||||
>
|
||||
⭐
|
||||
</motion.div>
|
||||
))}
|
||||
|
||||
{/* 烟花爆炸效果 */}
|
||||
<motion.div
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{
|
||||
scale: [0, 2, 0],
|
||||
opacity: [0, 1, 0],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
times: [0, 0.3, 1],
|
||||
}}
|
||||
>
|
||||
<div className="text-6xl">🎉</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ConfettiEffect);
|
||||
217
src/components/DrawAnimation.tsx
Normal file
217
src/components/DrawAnimation.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Trophy, Sparkles } from 'lucide-react'
|
||||
import { Prize } from '../store'
|
||||
|
||||
interface DrawAnimationProps {
|
||||
prizes: Prize[]
|
||||
duration: number
|
||||
}
|
||||
|
||||
const DrawAnimation: React.FC<DrawAnimationProps> = ({ prizes, duration }) => {
|
||||
const [currentPrizeIndex, setCurrentPrizeIndex] = useState(0)
|
||||
const [isRolling, setIsRolling] = useState(true)
|
||||
const [showSparkles, setShowSparkles] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (prizes.length === 0) return
|
||||
|
||||
// 滚动动画
|
||||
const rollInterval = setInterval(() => {
|
||||
setCurrentPrizeIndex(prev => (prev + 1) % prizes.length)
|
||||
}, 100) // 每100ms切换一次
|
||||
|
||||
// 停止滚动并显示结果
|
||||
const stopTimer = setTimeout(() => {
|
||||
clearInterval(rollInterval)
|
||||
setIsRolling(false)
|
||||
setShowSparkles(true)
|
||||
}, duration - 500) // 提前500ms停止滚动
|
||||
|
||||
return () => {
|
||||
clearInterval(rollInterval)
|
||||
clearTimeout(stopTimer)
|
||||
}
|
||||
}, [prizes, duration])
|
||||
|
||||
if (prizes.length === 0) return null
|
||||
|
||||
const currentPrize = prizes[currentPrizeIndex]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm"
|
||||
>
|
||||
{/* 背景粒子效果 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
{Array.from({ length: 20 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute w-2 h-2 bg-yellow-400 rounded-full"
|
||||
initial={{
|
||||
x: Math.random() * window.innerWidth,
|
||||
y: Math.random() * window.innerHeight,
|
||||
scale: 0
|
||||
}}
|
||||
animate={{
|
||||
scale: [0, 1, 0],
|
||||
rotate: 360,
|
||||
x: Math.random() * window.innerWidth,
|
||||
y: Math.random() * window.innerHeight
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
delay: Math.random() * 2
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 主要动画容器 */}
|
||||
<motion.div
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
exit={{ scale: 0, rotate: 180 }}
|
||||
transition={{ type: "spring", stiffness: 200, damping: 20 }}
|
||||
className="relative z-10 bg-gradient-to-br from-purple-600 via-blue-600 to-indigo-600 rounded-3xl p-12 shadow-2xl border-4 border-white/20 max-w-2xl w-full mx-4"
|
||||
>
|
||||
{/* 装饰性光环 */}
|
||||
<div className="absolute -inset-4 bg-gradient-to-r from-yellow-400 via-pink-500 to-purple-500 rounded-3xl blur-xl opacity-30 animate-pulse"></div>
|
||||
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-8 relative z-10">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
className="w-20 h-20 bg-gradient-to-r from-yellow-400 to-orange-500 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<Trophy className="w-10 h-10 text-white" />
|
||||
</motion.div>
|
||||
|
||||
<h2 className="text-4xl font-bold text-white mb-2">
|
||||
{isRolling ? '正在抽奖...' : '恭喜中奖!'}
|
||||
</h2>
|
||||
|
||||
{isRolling && (
|
||||
<p className="text-white/80 text-lg">请稍候,正在为您抽取奖品</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 奖品展示区 */}
|
||||
<div className="relative">
|
||||
{/* 滚动框架 */}
|
||||
<div className="bg-white/10 backdrop-blur-lg rounded-2xl p-8 border-2 border-white/20 relative overflow-hidden">
|
||||
{/* 滚动指示器 */}
|
||||
{isRolling && (
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-yellow-400 to-transparent animate-pulse"></div>
|
||||
)}
|
||||
|
||||
{/* 奖品信息 */}
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentPrizeIndex}
|
||||
initial={{ y: 50, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
exit={{ y: -50, opacity: 0 }}
|
||||
transition={{ duration: 0.1 }}
|
||||
className="text-center"
|
||||
>
|
||||
{/* 奖品颜色指示器 */}
|
||||
<div
|
||||
className="w-24 h-24 rounded-full mx-auto mb-6 flex items-center justify-center shadow-lg bg-gradient-to-br from-purple-500 to-blue-500"
|
||||
>
|
||||
<Trophy className="w-12 h-12 text-white" />
|
||||
</div>
|
||||
|
||||
{/* 奖品名称 */}
|
||||
<h3 className="text-3xl font-bold text-white mb-2">
|
||||
{currentPrize.name}
|
||||
</h3>
|
||||
|
||||
{/* 剩余数量 */}
|
||||
<div className="flex items-center justify-center space-x-4 text-white/60">
|
||||
<span>剩余: {currentPrize.remainingQuantity} 个</span>
|
||||
<span>•</span>
|
||||
<span>等级: {currentPrize.level}</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
{/* 滚动指示器底部 */}
|
||||
{isRolling && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-yellow-400 to-transparent animate-pulse"></div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 火花效果 */}
|
||||
<AnimatePresence>
|
||||
{showSparkles && (
|
||||
<>
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
initial={{ scale: 0, rotate: 0 }}
|
||||
animate={{
|
||||
scale: [0, 1, 0],
|
||||
rotate: 360,
|
||||
x: [0, (Math.random() - 0.5) * 200],
|
||||
y: [0, (Math.random() - 0.5) * 200]
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
delay: i * 0.1,
|
||||
ease: "easeOut"
|
||||
}}
|
||||
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<Sparkles className="w-8 h-8 text-yellow-400" />
|
||||
</motion.div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<div className="mt-8 relative z-10">
|
||||
<div className="bg-white/20 rounded-full h-2 overflow-hidden">
|
||||
<motion.div
|
||||
className="bg-gradient-to-r from-yellow-400 to-orange-500 h-full rounded-full"
|
||||
initial={{ width: '0%' }}
|
||||
animate={{ width: '100%' }}
|
||||
transition={{ duration: duration / 1000, ease: "linear" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center mt-2 text-white/60 text-sm">
|
||||
<span>抽奖进行中</span>
|
||||
<span>{Math.round(duration / 1000)}秒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 装饰性元素 */}
|
||||
<div className="absolute top-4 right-4">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 3, repeat: Infinity, ease: "linear" }}
|
||||
className="w-8 h-8 border-2 border-yellow-400 border-t-transparent rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 left-4">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.2, 1] }}
|
||||
transition={{ duration: 1, repeat: Infinity }}
|
||||
className="w-6 h-6 bg-yellow-400 rounded-full opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(DrawAnimation)
|
||||
45
src/components/Empty.tsx
Normal file
45
src/components/Empty.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { FileX, Package } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface EmptyProps {
|
||||
icon?: React.ReactNode;
|
||||
title?: string;
|
||||
description?: string;
|
||||
action?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Empty component with better UX
|
||||
export default function Empty({
|
||||
icon,
|
||||
title = '暂无数据',
|
||||
description = '当前没有可显示的内容',
|
||||
action,
|
||||
className
|
||||
}: EmptyProps) {
|
||||
const defaultIcon = icon || <Package className="w-12 h-12 text-gray-300" />;
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col items-center justify-center py-12 px-4 text-center',
|
||||
'min-h-[200px]',
|
||||
className
|
||||
)}>
|
||||
<div className="mb-4">
|
||||
{defaultIcon}
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{title}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mb-6 max-w-sm">
|
||||
{description}
|
||||
</p>
|
||||
{action && (
|
||||
<div className="mt-4">
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
src/components/EnhancedUX.tsx
Normal file
223
src/components/EnhancedUX.tsx
Normal file
@@ -0,0 +1,223 @@
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { AlertTriangle, RefreshCw, Wifi, WifiOff, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
// 增强的加载状态组件
|
||||
interface EnhancedLoadingProps {
|
||||
message?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
showProgress?: boolean;
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
export const EnhancedLoading: React.FC<EnhancedLoadingProps> = ({
|
||||
message = '加载中...',
|
||||
size = 'md',
|
||||
showProgress = false,
|
||||
progress = 0
|
||||
}) => {
|
||||
const sizeClasses = {
|
||||
sm: 'w-6 h-6',
|
||||
md: 'w-8 h-8',
|
||||
lg: 'w-12 h-12'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
|
||||
className={`${sizeClasses[size]} text-blue-500 mb-4`}
|
||||
>
|
||||
<RefreshCw className="w-full h-full" />
|
||||
</motion.div>
|
||||
|
||||
<p className="text-gray-600 text-center mb-4">{message}</p>
|
||||
|
||||
{showProgress && (
|
||||
<div className="w-64 bg-gray-200 rounded-full h-2 mb-2">
|
||||
<motion.div
|
||||
className="bg-blue-500 h-2 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 增强的错误状态组件
|
||||
interface EnhancedErrorProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
onRetry?: () => void;
|
||||
retryText?: string;
|
||||
showDetails?: boolean;
|
||||
details?: string;
|
||||
}
|
||||
|
||||
export const EnhancedError: React.FC<EnhancedErrorProps> = ({
|
||||
title = '出现错误',
|
||||
message = '请稍后重试',
|
||||
onRetry,
|
||||
retryText = '重试',
|
||||
showDetails = false,
|
||||
details
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||
<XCircle className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md">{message}</p>
|
||||
|
||||
{onRetry && (
|
||||
<button
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
{retryText}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{showDetails && details && (
|
||||
<details className="mt-4 text-left">
|
||||
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-700">
|
||||
查看详细信息
|
||||
</summary>
|
||||
<pre className="mt-2 p-3 bg-gray-100 rounded text-xs text-gray-700 overflow-auto max-w-md">
|
||||
{details}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 网络状态指示器
|
||||
interface NetworkIndicatorProps {
|
||||
isOnline: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const NetworkIndicator: React.FC<NetworkIndicatorProps> = ({
|
||||
isOnline,
|
||||
className = ''
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className={`flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium ${
|
||||
isOnline
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
} ${className}`}
|
||||
>
|
||||
{isOnline ? (
|
||||
<Wifi className="w-4 h-4" />
|
||||
) : (
|
||||
<WifiOff className="w-4 h-4" />
|
||||
)}
|
||||
{isOnline ? '在线' : '离线'}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// 成功状态组件
|
||||
interface SuccessStateProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
onContinue?: () => void;
|
||||
continueText?: string;
|
||||
}
|
||||
|
||||
export const SuccessState: React.FC<SuccessStateProps> = ({
|
||||
title = '操作成功',
|
||||
message = '操作已完成',
|
||||
onContinue,
|
||||
continueText = '继续'
|
||||
}) => {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="flex flex-col items-center justify-center p-8 text-center"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: 'spring', stiffness: 200 }}
|
||||
className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4"
|
||||
>
|
||||
<CheckCircle className="w-8 h-8 text-green-500" />
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md">{message}</p>
|
||||
|
||||
{onContinue && (
|
||||
<button
|
||||
onClick={onContinue}
|
||||
className="px-6 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
{continueText}
|
||||
</button>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
// 警告状态组件
|
||||
interface WarningStateProps {
|
||||
title?: string;
|
||||
message?: string;
|
||||
onConfirm?: () => void;
|
||||
onCancel?: () => void;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
}
|
||||
|
||||
export const WarningState: React.FC<WarningStateProps> = ({
|
||||
title = '请确认',
|
||||
message = '此操作需要确认',
|
||||
onConfirm,
|
||||
onCancel,
|
||||
confirmText = '确认',
|
||||
cancelText = '取消'
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<div className="w-16 h-16 bg-yellow-100 rounded-full flex items-center justify-center mb-4">
|
||||
<AlertTriangle className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{title}</h3>
|
||||
<p className="text-gray-600 mb-6 max-w-md">{message}</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
)}
|
||||
{onConfirm && (
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className="px-4 py-2 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors"
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
49
src/components/ErrorBoundary.tsx
Normal file
49
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900">
|
||||
<div className="text-center p-8 bg-black/20 backdrop-blur-sm rounded-lg border border-white/10">
|
||||
<div className="text-red-400 text-xl mb-4">⚠️ 组件出现错误</div>
|
||||
<div className="text-white/60 mb-4">请刷新页面重试</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
63
src/components/LoadingSpinner.tsx
Normal file
63
src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface LoadingSpinnerProps {
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
text?: string;
|
||||
className?: string;
|
||||
fullScreen?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-6 h-6',
|
||||
lg: 'w-8 h-8'
|
||||
};
|
||||
|
||||
export default function LoadingSpinner({
|
||||
size = 'md',
|
||||
text,
|
||||
className,
|
||||
fullScreen = false
|
||||
}: LoadingSpinnerProps) {
|
||||
const content = (
|
||||
<div className={cn(
|
||||
'flex flex-col items-center justify-center gap-3',
|
||||
fullScreen ? 'fixed inset-0 bg-white/80 backdrop-blur-sm z-50' : 'py-8',
|
||||
className
|
||||
)}>
|
||||
<Loader2 className={cn(
|
||||
'animate-spin text-blue-600',
|
||||
sizeClasses[size]
|
||||
)} />
|
||||
{text && (
|
||||
<p className="text-sm text-gray-600 font-medium">
|
||||
{text}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
// 简化的内联加载器
|
||||
export function InlineLoader({ className }: { className?: string }) {
|
||||
return (
|
||||
<Loader2 className={cn(
|
||||
'w-4 h-4 animate-spin text-gray-400',
|
||||
className
|
||||
)} />
|
||||
);
|
||||
}
|
||||
|
||||
// 按钮加载状态
|
||||
export function ButtonLoader({ className }: { className?: string }) {
|
||||
return (
|
||||
<Loader2 className={cn(
|
||||
'w-4 h-4 animate-spin',
|
||||
className
|
||||
)} />
|
||||
);
|
||||
}
|
||||
330
src/components/LotteryAnimation.tsx
Normal file
330
src/components/LotteryAnimation.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { DrawResult, useAppStore } from '../store';
|
||||
import ConfettiEffect from './ConfettiEffect';
|
||||
|
||||
interface LotteryAnimationProps {
|
||||
isVisible: boolean;
|
||||
onComplete: () => void;
|
||||
onCountdownComplete?: () => void;
|
||||
result?: DrawResult;
|
||||
}
|
||||
|
||||
const LotteryAnimation: React.FC<LotteryAnimationProps> = ({
|
||||
isVisible,
|
||||
onComplete,
|
||||
onCountdownComplete,
|
||||
result
|
||||
}) => {
|
||||
const [animationPhase, setAnimationPhase] = useState<'countdown' | 'loading' | 'result' | 'hidden'>('hidden');
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [forceRender, setForceRender] = useState(0);
|
||||
const [countdownNumber, setCountdownNumber] = useState(3);
|
||||
const [scrollingIndex, setScrollingIndex] = useState(0);
|
||||
const [isCountdownRunning, setIsCountdownRunning] = useState(false); // 添加倒计时执行标志
|
||||
|
||||
// 调试日志:状态变化追踪
|
||||
useEffect(() => {
|
||||
console.log('🎲📊 状态更新 - animationPhase:', animationPhase, 'countdownNumber:', countdownNumber, 'isVisible:', isVisible);
|
||||
}, [animationPhase, countdownNumber, isVisible]);
|
||||
|
||||
const scrollTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const onCountdownCompleteRef = useRef(onCountdownComplete);
|
||||
const onCompleteRef = useRef(onComplete);
|
||||
const animationPhaseRef = useRef(animationPhase);
|
||||
const resultRef = useRef(result);
|
||||
|
||||
// 从store获取奖项数据
|
||||
const { prizes } = useAppStore();
|
||||
|
||||
// 倒数数字列表
|
||||
const countdownNumbers = [3, 2, 1];
|
||||
|
||||
// 更新ref引用
|
||||
useEffect(() => {
|
||||
onCountdownCompleteRef.current = onCountdownComplete;
|
||||
onCompleteRef.current = onComplete;
|
||||
animationPhaseRef.current = animationPhase;
|
||||
resultRef.current = result;
|
||||
});
|
||||
|
||||
// 清理定时器函数
|
||||
const clearTimer = useCallback(() => {
|
||||
if (scrollTimerRef.current) {
|
||||
clearTimeout(scrollTimerRef.current);
|
||||
scrollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 开始倒计时效果 - 使用更稳定的实现
|
||||
const startCountdownEffect = useCallback(() => {
|
||||
// 防止重复执行倒计时
|
||||
if (isCountdownRunning) {
|
||||
console.log('🎲⚠️ 倒计时正在执行中,跳过重复调用');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎲🎲🎲 ===== 开始倒计时流程 =====');
|
||||
console.log('🎲 启动倒计时效果');
|
||||
setIsCountdownRunning(true); // 设置执行标志
|
||||
setAnimationPhase('countdown');
|
||||
setCountdownNumber(3);
|
||||
console.log('🚀 开始倒计时动画,初始数字: 3');
|
||||
window.postMessage({ type: 'COUNTDOWN_START', number: 3 }, '*');
|
||||
|
||||
// 清理之前的定时器
|
||||
console.log('🎲 清理之前的定时器');
|
||||
clearTimer();
|
||||
|
||||
// 使用简单的定时器序列,避免递归和闭包问题
|
||||
console.log('🎲🎯 显示数字 3');
|
||||
|
||||
// 1秒后显示2
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
console.log('🎲🎯 显示数字 2');
|
||||
setCountdownNumber(2);
|
||||
console.log('🔢 倒计时: 2');
|
||||
window.postMessage({ type: 'COUNTDOWN_NUMBER', number: 2 }, '*');
|
||||
|
||||
// 再1秒后显示1
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
console.log('🎲🎯 显示数字 1');
|
||||
setCountdownNumber(1);
|
||||
console.log('🔢 倒计时: 1');
|
||||
window.postMessage({ type: 'COUNTDOWN_NUMBER', number: 1 }, '*');
|
||||
|
||||
// 再1秒后触发抽奖
|
||||
scrollTimerRef.current = setTimeout(() => {
|
||||
console.log('🎲🚀 倒计时完成,触发抽奖回调');
|
||||
|
||||
// 倒计时完成,切换到loading状态
|
||||
console.log('🎯 倒计时321完成!切换到loading状态');
|
||||
setAnimationPhase('loading');
|
||||
setCountdownNumber(null);
|
||||
setIsCountdownRunning(false); // 重置执行标志
|
||||
|
||||
// 触发倒计时完成回调
|
||||
if (onCountdownCompleteRef.current) {
|
||||
console.log('🎯 触发倒计时完成回调');
|
||||
onCountdownCompleteRef.current();
|
||||
}
|
||||
|
||||
console.log('🎲🎲🎲 ===== 倒计时流程完成 =====');
|
||||
|
||||
// 5秒后如果还没有结果,则隐藏动画框
|
||||
setTimeout(() => {
|
||||
// 使用ref获取最新状态,检查当前状态,如果还在loading且没有结果,则隐藏
|
||||
console.log('🎲🔍 检查当前状态 - phase:', animationPhaseRef.current, 'hasResult:', !!resultRef.current);
|
||||
if (animationPhaseRef.current === 'loading' && !resultRef.current) {
|
||||
console.log('🚫 超时隐藏动画框 - 倒计时完成后无结果');
|
||||
window.postMessage({ type: 'ANIMATION_HIDDEN', reason: 'timeout' }, '*');
|
||||
setAnimationPhase('hidden');
|
||||
if (onCompleteRef.current) {
|
||||
onCompleteRef.current();
|
||||
}
|
||||
} else {
|
||||
console.log('🎲✅ 状态已变化或已有结果,无需隐藏');
|
||||
}
|
||||
}, 5000); // 5秒后检查
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
}, [clearTimer, isCountdownRunning]); // 添加isCountdownRunning依赖
|
||||
|
||||
|
||||
// 重置动画状态
|
||||
const resetAnimation = useCallback(() => {
|
||||
console.log('🎲 重置动画状态');
|
||||
clearTimer(); // 确保清理定时器
|
||||
setAnimationPhase('hidden');
|
||||
setShowConfetti(false);
|
||||
setCountdownNumber(3);
|
||||
setScrollingIndex(0);
|
||||
setIsCountdownRunning(false); // 重置执行标志
|
||||
setForceRender(prev => prev + 1);
|
||||
}, [clearTimer]);
|
||||
|
||||
|
||||
|
||||
// 主要状态管理useEffect - 只监听isVisible的变化
|
||||
useEffect(() => {
|
||||
console.log('🎲🔄 LotteryAnimation状态变化:', { isVisible, result: !!result, animationPhase });
|
||||
|
||||
if (isVisible) {
|
||||
// 只有在隐藏状态时才启动倒计时,避免重复触发
|
||||
if (animationPhase === 'hidden') {
|
||||
console.log('🎲🚀 准备启动倒计时');
|
||||
startCountdownEffect();
|
||||
}
|
||||
} else if (!isVisible && animationPhase !== 'hidden') {
|
||||
console.log('🎲🔒 隐藏动画,重置状态');
|
||||
resetAnimation();
|
||||
}
|
||||
}, [isVisible]); // 修复:只监听isVisible,不监听animationPhase,避免重复触发
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
clearTimer();
|
||||
};
|
||||
}, [clearTimer]);
|
||||
|
||||
// 监听结果变化 - 当有结果时切换到结果显示阶段
|
||||
useEffect(() => {
|
||||
if (result && (animationPhase === 'loading' || animationPhase === 'countdown')) {
|
||||
// 如果正在倒数,让倒数完成后再显示结果
|
||||
if (animationPhase === 'countdown') {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🎲📋 收到抽奖结果,切换到结果显示阶段:', result);
|
||||
// 直接显示结果
|
||||
setAnimationPhase('result');
|
||||
|
||||
// 只有1等奖才显示撒花效果
|
||||
const shouldShowConfetti = result.success && result.prize && result.prize.level === 1;
|
||||
setShowConfetti(shouldShowConfetti);
|
||||
|
||||
// 3秒后隐藏撒花效果
|
||||
if (shouldShowConfetti) {
|
||||
const confettiTimer = setTimeout(() => {
|
||||
setShowConfetti(false);
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(confettiTimer);
|
||||
}
|
||||
}
|
||||
|
||||
// 添加超时机制:如果在loading状态超过10秒没有结果,自动隐藏
|
||||
if (animationPhase === 'loading' && !result) {
|
||||
console.log('🎲⏰ 设置loading状态超时检查');
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
console.log('🎲⏰ Loading状态超时,自动隐藏动画框');
|
||||
setAnimationPhase('hidden');
|
||||
if (onCompleteRef.current) {
|
||||
onCompleteRef.current();
|
||||
}
|
||||
}, 10000); // 10秒超时
|
||||
|
||||
return () => clearTimeout(timeoutTimer);
|
||||
}
|
||||
}, [result, animationPhase]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
clearTimer();
|
||||
setAnimationPhase('hidden');
|
||||
setShowConfetti(false);
|
||||
setCountdownNumber(3);
|
||||
setScrollingIndex(0);
|
||||
setIsCountdownRunning(false); // 重置执行标志
|
||||
setForceRender(prev => prev + 1);
|
||||
if (onCompleteRef.current) {
|
||||
onCompleteRef.current();
|
||||
}
|
||||
}, [clearTimer]);
|
||||
|
||||
if (!isVisible && animationPhase === 'hidden') {
|
||||
console.log('🚫 LotteryAnimation组件已隐藏 - 倒计时修复成功!');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 渲染不同阶段的内容
|
||||
const renderContent = () => {
|
||||
switch (animationPhase) {
|
||||
case 'countdown':
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="mb-8">
|
||||
<div className="text-4xl text-white mb-6 font-bold">抽奖即将开始</div>
|
||||
<div className="bg-gradient-to-r from-red-500 to-pink-500 rounded-full w-40 h-40 mx-auto flex items-center justify-center shadow-2xl">
|
||||
<div className="text-8xl font-bold text-white animate-bounce" key={`countdown-${countdownNumber}`}>
|
||||
{countdownNumber}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg text-gray-300 mt-6">
|
||||
请准备好,抽奖马上开始...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'loading':
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-24 w-24 border-b-4 border-yellow-400 mx-auto mb-6"></div>
|
||||
<div className="text-3xl text-white mb-4 font-bold">
|
||||
正在抽奖中...
|
||||
</div>
|
||||
<div className="text-lg text-gray-300">
|
||||
请稍候,系统正在为您抽取奖品
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'result':
|
||||
if (!result) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-3xl text-white mb-6 font-bold">
|
||||
抽奖完成,但未获取到结果
|
||||
</div>
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="px-8 py-3 bg-blue-500 text-white text-xl rounded-lg hover:bg-blue-600 font-bold"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
{result.success && result.prize ? (
|
||||
<>
|
||||
<div className="text-8xl font-bold text-yellow-400 mb-6 animate-bounce">🎉 恭喜中奖!🎉</div>
|
||||
<div className="text-6xl text-white mb-4 font-bold">
|
||||
您获得了:{result.prize.name}
|
||||
</div>
|
||||
<div className="text-4xl text-gray-300 mb-6">
|
||||
{result.message}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-4xl font-bold text-gray-400 mb-6">很遗憾,未中奖</div>
|
||||
<div className="text-2xl text-gray-300 mb-6">
|
||||
{result.message || '感谢您的参与,下次再来!'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onComplete}
|
||||
className="px-8 py-3 bg-blue-500 text-white text-xl rounded-lg hover:bg-blue-600 transition-colors font-bold"
|
||||
>确定</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-80 flex items-center justify-center z-50">
|
||||
<div className="w-4/5 h-1/2 flex items-center justify-center p-8">
|
||||
{renderContent()}
|
||||
</div>
|
||||
|
||||
|
||||
{/* 撒花特效 */}
|
||||
<ConfettiEffect
|
||||
isActive={showConfetti}
|
||||
duration={4000}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LotteryAnimation;
|
||||
68
src/components/NetworkStatus.tsx
Normal file
68
src/components/NetworkStatus.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Wifi, WifiOff } from 'lucide-react';
|
||||
import { NetworkService } from '../services/networkService';
|
||||
|
||||
interface NetworkStatusProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const NetworkStatus: React.FC<NetworkStatusProps> = ({ className = '' }) => {
|
||||
const [networkStatus, setNetworkStatus] = useState({
|
||||
isOnline: true,
|
||||
lastCheck: new Date()
|
||||
});
|
||||
|
||||
const networkService = NetworkService.getInstance();
|
||||
|
||||
useEffect(() => {
|
||||
// 初始化网络状态
|
||||
setNetworkStatus(networkService.getStatus());
|
||||
|
||||
// 订阅网络状态变化
|
||||
const unsubscribe = networkService.subscribe((status) => {
|
||||
setNetworkStatus(status);
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const getStatusColor = () => {
|
||||
return networkStatus.isOnline ? 'text-green-500' : 'text-orange-500';
|
||||
};
|
||||
|
||||
const getStatusText = () => {
|
||||
return networkStatus.isOnline ? '在线' : '离线';
|
||||
};
|
||||
|
||||
const getStatusIcon = () => {
|
||||
return networkStatus.isOnline ? <Wifi className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-2 ${className}`}>
|
||||
{/* 状态指示器 */}
|
||||
<div className={`flex items-center space-x-1 ${getStatusColor()}`}>
|
||||
{getStatusIcon()}
|
||||
<span className="text-sm font-medium">{getStatusText()}</span>
|
||||
</div>
|
||||
|
||||
{/* 详细信息提示 */}
|
||||
<div className="relative group">
|
||||
<div className="w-2 h-2 bg-blue-400 rounded-full cursor-help"></div>
|
||||
<div className="absolute bottom-full right-0 mb-2 w-48 p-3 bg-gray-800 text-white text-xs rounded-lg shadow-lg opacity-0 group-hover:opacity-100 transition-opacity z-50">
|
||||
<div className="space-y-1">
|
||||
<div>网络状态: {networkStatus.isOnline ? '在线' : '离线'}</div>
|
||||
<div>最后检查: {networkStatus.lastCheck ? networkStatus.lastCheck.toLocaleTimeString() : '未知'}</div>
|
||||
</div>
|
||||
<div className="absolute top-full right-4 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-gray-800"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkStatus;
|
||||
158
src/components/NumberKeyboard.tsx
Normal file
158
src/components/NumberKeyboard.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import React from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Delete, RotateCcw, Check } from 'lucide-react'
|
||||
|
||||
interface NumberKeyboardProps {
|
||||
onNumberClick: (number: string) => void
|
||||
onDeleteClick: () => void
|
||||
onClearClick: () => void
|
||||
onConfirmClick: () => void
|
||||
disabled?: boolean
|
||||
canConfirm?: boolean
|
||||
studentIdLength?: number
|
||||
}
|
||||
|
||||
const NumberKeyboard: React.FC<NumberKeyboardProps> = ({
|
||||
onNumberClick,
|
||||
onDeleteClick,
|
||||
onClearClick,
|
||||
onConfirmClick,
|
||||
disabled = false,
|
||||
canConfirm = false,
|
||||
studentIdLength = 0
|
||||
}) => {
|
||||
// 数字键盘布局
|
||||
const keyboardLayout = [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9'],
|
||||
['clear', '0', 'delete']
|
||||
]
|
||||
|
||||
// 按钮点击动画变体
|
||||
const buttonVariants = {
|
||||
initial: { scale: 1 },
|
||||
tap: { scale: 0.95 },
|
||||
hover: { scale: 1.05 }
|
||||
}
|
||||
|
||||
// 渲染按钮内容
|
||||
const renderButtonContent = (key: string) => {
|
||||
switch (key) {
|
||||
case 'delete':
|
||||
return <Delete className="w-8 h-8" />
|
||||
case 'clear':
|
||||
return <RotateCcw className="w-8 h-8" />
|
||||
default:
|
||||
return <span className="text-3xl font-bold">{key}</span>
|
||||
}
|
||||
}
|
||||
|
||||
// 检查数字键是否应该被禁用
|
||||
const isNumberKeyDisabled = (key: string) => {
|
||||
// 如果是数字键且学号已达到12位,则禁用
|
||||
return /^[0-9]$/.test(key) && studentIdLength >= 12
|
||||
}
|
||||
|
||||
// 获取按钮样式
|
||||
const getButtonStyle = (key: string) => {
|
||||
const baseStyle = "w-[140px] h-[140px] rounded-2xl font-bold transition-all duration-200 flex items-center justify-center shadow-lg"
|
||||
|
||||
if (disabled) {
|
||||
return `${baseStyle} bg-gray-500/20 text-gray-400 cursor-not-allowed`
|
||||
}
|
||||
|
||||
// 检查数字键是否被禁用
|
||||
if (isNumberKeyDisabled(key)) {
|
||||
return `${baseStyle} bg-gray-500/20 text-gray-400 cursor-not-allowed`
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'delete':
|
||||
return `${baseStyle} bg-red-500/20 hover:bg-red-500/30 text-red-400 hover:text-red-300 border border-red-500/30`
|
||||
case 'clear':
|
||||
return `${baseStyle} bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-400 hover:text-yellow-300 border border-yellow-500/30`
|
||||
default:
|
||||
return `${baseStyle} bg-blue-500/20 hover:bg-blue-500/30 text-white border border-blue-500/30 hover:border-blue-400`
|
||||
}
|
||||
}
|
||||
|
||||
// 处理按钮点击
|
||||
const handleButtonClick = (key: string) => {
|
||||
if (disabled) return
|
||||
|
||||
// 如果是数字键且被禁用,则不处理点击
|
||||
if (isNumberKeyDisabled(key)) return
|
||||
|
||||
switch (key) {
|
||||
case 'delete':
|
||||
onDeleteClick()
|
||||
break
|
||||
case 'clear':
|
||||
onClearClick()
|
||||
break
|
||||
default:
|
||||
onNumberClick(key)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
{/* 数字键盘网格 */}
|
||||
<div className="grid grid-cols-3 gap-[10px]">
|
||||
{keyboardLayout.map((row, rowIndex) =>
|
||||
row.map((key, keyIndex) => (
|
||||
<motion.button
|
||||
key={`${rowIndex}-${keyIndex}`}
|
||||
variants={buttonVariants}
|
||||
initial="initial"
|
||||
whileHover={disabled || isNumberKeyDisabled(key) ? "initial" : "hover"}
|
||||
whileTap={disabled || isNumberKeyDisabled(key) ? "initial" : "tap"}
|
||||
onClick={() => handleButtonClick(key)}
|
||||
className={getButtonStyle(key)}
|
||||
disabled={disabled || isNumberKeyDisabled(key)}
|
||||
>
|
||||
{renderButtonContent(key)}
|
||||
</motion.button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 确认按钮 */}
|
||||
<motion.button
|
||||
variants={buttonVariants}
|
||||
initial="initial"
|
||||
whileHover={(!disabled && canConfirm) ? "hover" : "initial"}
|
||||
whileTap={(!disabled && canConfirm) ? "tap" : "initial"}
|
||||
onClick={onConfirmClick}
|
||||
disabled={disabled || !canConfirm}
|
||||
className={`w-full max-w-[450px] h-[80px] rounded-2xl font-bold text-xl transition-all duration-200 flex items-center justify-center space-x-3 shadow-lg ${
|
||||
disabled || !canConfirm
|
||||
? 'bg-gray-500/20 text-gray-400 cursor-not-allowed border border-gray-500/30'
|
||||
: 'bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white shadow-green-500/25 hover:shadow-green-500/40'
|
||||
}`}
|
||||
>
|
||||
<Check className="w-6 h-6" />
|
||||
<span>开始抽奖</span>
|
||||
</motion.button>
|
||||
|
||||
{/* 键盘说明 */}
|
||||
<div className="text-center text-white/50 text-sm space-y-1">
|
||||
<p>点击数字输入学号</p>
|
||||
<div className="flex items-center justify-center space-x-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
<span>清空</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Delete className="w-4 h-4" />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(NumberKeyboard)
|
||||
661
src/components/PrizeDisplay.tsx
Normal file
661
src/components/PrizeDisplay.tsx
Normal file
@@ -0,0 +1,661 @@
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Trophy, Gift, Award, Star, Clock, User } from 'lucide-react'
|
||||
import { Prize } from '../store'
|
||||
import { useAppStore } from '../store'
|
||||
import ApiService from '../services/apiService'
|
||||
import EventService, { EVENT_TYPES } from '../services/eventService'
|
||||
|
||||
interface PrizeDisplayProps {
|
||||
prizes: Prize[]
|
||||
}
|
||||
|
||||
// 中奖记录接口
|
||||
interface DrawRecord {
|
||||
id: string
|
||||
student_id: string
|
||||
prize_id: string
|
||||
prize_name: string
|
||||
draw_time: string
|
||||
}
|
||||
|
||||
const PrizeDisplay: React.FC<PrizeDisplayProps> = ({ prizes: propsPrizes }) => {
|
||||
const { loadPrizes, prizes: storePrizes } = useAppStore();
|
||||
const [drawRecords, setDrawRecords] = useState<DrawRecord[]>([]);
|
||||
const [isLoadingRecords, setIsLoadingRecords] = useState(false);
|
||||
const [isAutoRefreshing, setIsAutoRefreshing] = useState(true);
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(null);
|
||||
const [refreshError, setRefreshError] = useState<string | null>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
const [isPageVisible, setIsPageVisible] = useState(true);
|
||||
const [refreshInterval, setRefreshInterval] = useState(5000); // 动态刷新间隔
|
||||
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isComponentMountedRef = useRef(true);
|
||||
const lastRequestTimeRef = useRef<number>(0);
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// 使用store中的prizes数据,如果没有则使用props传入的数据
|
||||
const prizes = storePrizes && storePrizes.length > 0 ? storePrizes : propsPrizes;
|
||||
|
||||
// 调试日志已移除以优化控制台输出
|
||||
|
||||
// 检测网络连接状态
|
||||
const checkNetworkStatus = useCallback(() => {
|
||||
return navigator.onLine;
|
||||
}, []);
|
||||
|
||||
// 获取错误类型和用户友好的错误消息
|
||||
const getErrorInfo = useCallback((error: any) => {
|
||||
if (!checkNetworkStatus()) {
|
||||
return {
|
||||
type: 'network',
|
||||
message: '网络连接已断开,请检查网络设置',
|
||||
canRetry: true
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
return {
|
||||
type: 'connection',
|
||||
message: '无法连接到服务器,请稍后重试',
|
||||
canRetry: true
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('404')) {
|
||||
return {
|
||||
type: 'not_found',
|
||||
message: 'API接口不存在,请联系管理员',
|
||||
canRetry: false
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message.includes('500')) {
|
||||
return {
|
||||
type: 'server_error',
|
||||
message: '服务器内部错误,请稍后重试',
|
||||
canRetry: true
|
||||
};
|
||||
}
|
||||
|
||||
if (error.message.includes('timeout')) {
|
||||
return {
|
||||
type: 'timeout',
|
||||
message: '请求超时,请检查网络连接',
|
||||
canRetry: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unknown',
|
||||
message: error instanceof Error ? error.message : '未知错误',
|
||||
canRetry: true
|
||||
};
|
||||
}, [checkNetworkStatus]);
|
||||
|
||||
// 加载中奖记录(带错误处理和重试机制)
|
||||
const loadDrawRecords = useCallback(async (isBackground = false) => {
|
||||
if (!isComponentMountedRef.current) return;
|
||||
|
||||
try {
|
||||
if (!isBackground) {
|
||||
setIsLoadingRecords(true);
|
||||
}
|
||||
setRefreshError(null);
|
||||
setRetryCount(0); // 重置重试计数
|
||||
|
||||
const apiService = ApiService.getInstance();
|
||||
const records = await apiService.getAllRecords();
|
||||
|
||||
if (isComponentMountedRef.current) {
|
||||
setDrawRecords(records || []);
|
||||
setLastRefreshTime(new Date());
|
||||
console.log('✅ 中奖记录加载成功,共', records?.length || 0, '条记录');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ 中奖记录加载失败:', error);
|
||||
if (isComponentMountedRef.current) {
|
||||
const errorInfo = getErrorInfo(error);
|
||||
setRefreshError(errorInfo.message);
|
||||
|
||||
// 增加重试计数
|
||||
setRetryCount(prev => prev + 1);
|
||||
|
||||
// 只有在可以重试且是后台刷新且重试次数不超过3次时才进行自动重试
|
||||
if (errorInfo.canRetry && isBackground && isAutoRefreshing && retryCount < 3) {
|
||||
const retryDelay = Math.min(3000 * Math.pow(2, retryCount), 30000); // 指数退避,最大30秒
|
||||
console.log(`🔄 将在 ${retryDelay/1000} 秒后重试加载中奖记录 (第${retryCount + 1}次重试)`);
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
loadDrawRecords(true);
|
||||
}, retryDelay);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isComponentMountedRef.current && !isBackground) {
|
||||
setIsLoadingRecords(false);
|
||||
}
|
||||
}
|
||||
}, [isAutoRefreshing, getErrorInfo, retryCount]);
|
||||
|
||||
// 防抖加载函数
|
||||
const debouncedLoadRecords = useCallback((isBackground = false) => {
|
||||
const now = Date.now();
|
||||
const timeSinceLastRequest = now - lastRequestTimeRef.current;
|
||||
|
||||
// 清除之前的防抖定时器
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// 防止过于频繁的请求(最小间隔2秒)
|
||||
if (timeSinceLastRequest < 2000) {
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
if (isComponentMountedRef.current) {
|
||||
lastRequestTimeRef.current = Date.now();
|
||||
loadDrawRecords(isBackground);
|
||||
}
|
||||
}, 2000 - timeSinceLastRequest);
|
||||
return;
|
||||
}
|
||||
|
||||
lastRequestTimeRef.current = now;
|
||||
loadDrawRecords(isBackground);
|
||||
}, [loadDrawRecords]);
|
||||
|
||||
// 动态调整刷新间隔
|
||||
const adjustRefreshInterval = useCallback(() => {
|
||||
if (!isPageVisible) {
|
||||
// 页面不可见时,降低刷新频率到30秒
|
||||
setRefreshInterval(30000);
|
||||
} else if (refreshError) {
|
||||
// 有错误时,增加刷新间隔到10秒
|
||||
setRefreshInterval(10000);
|
||||
} else {
|
||||
// 正常情况下5秒刷新
|
||||
setRefreshInterval(5000);
|
||||
}
|
||||
}, [isPageVisible, refreshError]);
|
||||
|
||||
// 启动自动刷新定时器
|
||||
const startAutoRefresh = useCallback(() => {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
}
|
||||
|
||||
if (isAutoRefreshing && isPageVisible) {
|
||||
refreshIntervalRef.current = setInterval(() => {
|
||||
debouncedLoadRecords(true); // 后台刷新
|
||||
}, refreshInterval);
|
||||
// 自动刷新已启动
|
||||
}
|
||||
}, [isAutoRefreshing, isPageVisible, refreshInterval, debouncedLoadRecords]);
|
||||
|
||||
// 停止自动刷新
|
||||
const stopAutoRefresh = useCallback(() => {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
refreshIntervalRef.current = null;
|
||||
// 自动刷新已停止
|
||||
}
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
debounceTimeoutRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 页面可见性监听
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
const isVisible = !document.hidden;
|
||||
const wasVisible = isPageVisible;
|
||||
setIsPageVisible(isVisible);
|
||||
|
||||
if (isVisible && !wasVisible) {
|
||||
// 只有从不可见变为可见时才刷新数据,避免重复请求
|
||||
console.log('📱 页面变为可见,刷新数据');
|
||||
// 延迟一下再刷新,避免与其他请求冲突
|
||||
setTimeout(() => {
|
||||
if (isComponentMountedRef.current) {
|
||||
debouncedLoadRecords(true);
|
||||
}
|
||||
}, 500);
|
||||
} else if (!isVisible) {
|
||||
console.log('📱 页面变为不可见');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [debouncedLoadRecords, isPageVisible]);
|
||||
|
||||
// 动态调整刷新间隔
|
||||
useEffect(() => {
|
||||
adjustRefreshInterval();
|
||||
}, [adjustRefreshInterval]);
|
||||
|
||||
// 合并初始化和自动刷新逻辑,减少useEffect数量
|
||||
useEffect(() => {
|
||||
const initializeData = async () => {
|
||||
try {
|
||||
console.log('🎯 PrizeDisplay: 开始加载奖项数据');
|
||||
await loadPrizes();
|
||||
console.log('✅ PrizeDisplay: 奖项数据加载成功');
|
||||
} catch (error) {
|
||||
console.error('❌ PrizeDisplay: 奖项数据加载失败:', error);
|
||||
const errorInfo = getErrorInfo(error);
|
||||
setRefreshError(errorInfo.message);
|
||||
}
|
||||
|
||||
// 延迟加载中奖记录,避免并发请求
|
||||
setTimeout(() => {
|
||||
if (isComponentMountedRef.current) {
|
||||
loadDrawRecords();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
// 延迟启动自动刷新
|
||||
setTimeout(() => {
|
||||
if (isComponentMountedRef.current && refreshInterval > 0) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
initializeData();
|
||||
|
||||
// 组件卸载时清理
|
||||
return () => {
|
||||
isComponentMountedRef.current = false;
|
||||
stopAutoRefresh();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 监听刷新间隔变化,单独处理
|
||||
useEffect(() => {
|
||||
if (refreshInterval > 0 && !isAutoRefreshing && isComponentMountedRef.current) {
|
||||
startAutoRefresh();
|
||||
}
|
||||
}, [refreshInterval, startAutoRefresh, isAutoRefreshing]);
|
||||
|
||||
// WebSocket监听中奖记录更新
|
||||
useEffect(() => {
|
||||
const eventService = EventService.getInstance();
|
||||
|
||||
// 监听新增中奖记录
|
||||
const handleNewRecord = (event: CustomEvent) => {
|
||||
const { record } = event.detail;
|
||||
if (record && isComponentMountedRef.current) {
|
||||
setDrawRecords(prev => [record, ...prev]);
|
||||
setLastRefreshTime(new Date());
|
||||
setRefreshError(null);
|
||||
// 收到新中奖记录
|
||||
}
|
||||
};
|
||||
|
||||
// 监听中奖记录更新
|
||||
const handleRecordsUpdated = (event: CustomEvent) => {
|
||||
const records = event.detail;
|
||||
if (Array.isArray(records) && isComponentMountedRef.current) {
|
||||
setDrawRecords(records);
|
||||
setLastRefreshTime(new Date());
|
||||
setRefreshError(null);
|
||||
// 中奖记录已更新
|
||||
}
|
||||
};
|
||||
|
||||
// 监听数据清空
|
||||
const handleDataClear = () => {
|
||||
if (isComponentMountedRef.current) {
|
||||
setDrawRecords([]);
|
||||
setLastRefreshTime(new Date());
|
||||
setRefreshError(null);
|
||||
// 中奖记录已清空
|
||||
}
|
||||
};
|
||||
|
||||
// 监听奖项更新事件
|
||||
const handlePrizesUpdated = (event: CustomEvent) => {
|
||||
console.log('PrizeDisplay: 收到奖项更新事件', event.detail);
|
||||
if (isComponentMountedRef.current) {
|
||||
// 重新加载奖项数据
|
||||
loadPrizes().catch(error => {
|
||||
console.error('PrizeDisplay: 奖项更新后重新加载失败:', error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
eventService.subscribe('new_record', handleNewRecord);
|
||||
eventService.subscribe('records_updated', handleRecordsUpdated);
|
||||
eventService.subscribe('data_cleared', handleDataClear);
|
||||
eventService.subscribe('system_reset', handleDataClear);
|
||||
eventService.subscribe('force_reset', handleDataClear);
|
||||
eventService.subscribe('prizes_updated', handlePrizesUpdated);
|
||||
|
||||
return () => {
|
||||
eventService.unsubscribe('new_record', handleNewRecord);
|
||||
eventService.unsubscribe('records_updated', handleRecordsUpdated);
|
||||
eventService.unsubscribe('data_cleared', handleDataClear);
|
||||
eventService.unsubscribe('system_reset', handleDataClear);
|
||||
eventService.unsubscribe('force_reset', handleDataClear);
|
||||
eventService.unsubscribe('prizes_updated', handlePrizesUpdated);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 过滤活跃奖项
|
||||
const activePrizes = prizes.filter(prize => prize.is_active);
|
||||
|
||||
// 获取奖项图标
|
||||
const getPrizeIcon = (level: number) => {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return <Trophy className="w-8 h-8" />
|
||||
case 2:
|
||||
return <Award className="w-8 h-8" />
|
||||
case 3:
|
||||
return <Star className="w-8 h-8" />
|
||||
default:
|
||||
return <Gift className="w-8 h-8" />
|
||||
}
|
||||
}
|
||||
|
||||
// 获取奖项等级文字
|
||||
const getLevelText = (level: number) => {
|
||||
const levelMap: { [key: number]: string } = {
|
||||
1: '特等奖',
|
||||
2: '一等奖',
|
||||
3: '二等奖',
|
||||
4: '三等奖',
|
||||
5: '参与奖'
|
||||
}
|
||||
return levelMap[level] || `${level}等奖`
|
||||
}
|
||||
|
||||
// 计算剩余比例
|
||||
const getRemainingRatio = (prize: Prize) => {
|
||||
return prize.totalQuantity > 0 ? (prize.remainingQuantity / prize.totalQuantity) * 100 : 0
|
||||
}
|
||||
|
||||
// 获取状态颜色
|
||||
const getStatusColor = (ratio: number) => {
|
||||
if (ratio > 50) return 'text-green-400'
|
||||
if (ratio > 20) return 'text-yellow-400'
|
||||
if (ratio > 0) return 'text-orange-400'
|
||||
return 'text-red-400'
|
||||
}
|
||||
|
||||
// 根据奖项等级获取字体大小
|
||||
const getFontSizeClass = (prizeName: string) => {
|
||||
if (prizeName.includes('一等奖') || prizeName.includes('特等奖')) {
|
||||
return 'text-lg' // 一等奖最大
|
||||
} else if (prizeName.includes('二等奖')) {
|
||||
return 'text-base' // 二等奖次之
|
||||
} else if (prizeName.includes('三等奖')) {
|
||||
return 'text-sm' // 三等奖再次
|
||||
} else if (prizeName.includes('参与') || prizeName.includes('重在参与')) {
|
||||
return 'text-xs' // 重在参与最小
|
||||
}
|
||||
return 'text-sm' // 默认大小
|
||||
}
|
||||
|
||||
// 格式化学号显示(每4位一个空格)
|
||||
const formatStudentId = (studentId: string) => {
|
||||
return studentId.replace(/(\d{4})(?=\d)/g, '$1 ');
|
||||
};
|
||||
|
||||
// 格式化时间显示
|
||||
const formatTime = (timeString: string) => {
|
||||
const date = new Date(timeString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// 判断是否为一等奖
|
||||
const isFirstPrize = (prizeName: string) => {
|
||||
return prizeName.includes('一等奖') || prizeName.includes('特等奖');
|
||||
};
|
||||
|
||||
// 如果没有奖项数据,显示加载状态或错误信息
|
||||
if (!prizes || prizes.length === 0) {
|
||||
console.log('PrizeDisplay 渲染 - 没有奖项数据,显示加载状态');
|
||||
return (
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 h-full flex flex-col">
|
||||
<h2 className="text-xl font-bold text-white mb-4 text-center">
|
||||
奖项设置
|
||||
</h2>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-white/60">
|
||||
{refreshError ? (
|
||||
<div className="text-center">
|
||||
<div className="w-16 h-16 bg-red-500/20 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||
<Gift className="w-8 h-8 text-red-400" />
|
||||
</div>
|
||||
<div className="text-lg mb-2 text-red-300">加载失败</div>
|
||||
<div className="text-sm mb-4 text-red-200 max-w-xs mx-auto">
|
||||
{refreshError}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRefreshError(null);
|
||||
loadPrizes();
|
||||
}}
|
||||
className="px-4 py-2 bg-red-500/80 text-white rounded-lg hover:bg-red-500 transition-colors"
|
||||
>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="animate-pulse">
|
||||
<div className="w-16 h-16 bg-white/20 rounded-full mx-auto mb-4 flex items-center justify-center">
|
||||
<Gift className="w-8 h-8 text-white/40" />
|
||||
</div>
|
||||
<div className="text-lg mb-2">加载中...</div>
|
||||
<button
|
||||
onClick={() => loadPrizes()}
|
||||
className="mt-4 px-4 py-2 bg-blue-500/80 text-white rounded-lg hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
刷新数据
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 如果没有活跃奖项,显示提示信息
|
||||
if (activePrizes.length === 0) {
|
||||
console.log('PrizeDisplay 渲染 - 没有活跃奖项,显示提示信息');
|
||||
return (
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 h-full flex flex-col">
|
||||
<h2 className="text-xl font-bold text-white mb-4 text-center">
|
||||
奖项设置
|
||||
</h2>
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-white/60">
|
||||
<div className="text-lg mb-2">暂无活跃奖项</div>
|
||||
<div className="text-sm">所有奖项均已停用</div>
|
||||
<button
|
||||
onClick={() => loadPrizes()}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 h-full flex flex-col">
|
||||
<h2 className="text-xl font-bold text-white mb-4 text-center">
|
||||
奖项设置
|
||||
</h2>
|
||||
|
||||
<div className="flex-1 space-y-3 overflow-y-auto scrollbar-hide">
|
||||
{prizes.filter(prize => prize.is_active).map((prize) => (
|
||||
<div
|
||||
key={prize.id}
|
||||
className="bg-white/5 rounded-xl p-3 border border-white/20"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className={`${getFontSizeClass(prize.name)} font-semibold text-white truncate`}>
|
||||
{prize.name}
|
||||
</h3>
|
||||
<span className="text-xs text-white/60">
|
||||
{(prize.probability * 1000).toFixed(1)}‰
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-xs text-white/80 mb-2">
|
||||
<span>剩余: {prize.remainingQuantity}</span>
|
||||
<span>总数: {prize.totalQuantity}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(prize.remainingQuantity / prize.totalQuantity) * 100}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 中奖记录显示区域 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="mt-8 flex-1 flex flex-col"
|
||||
>
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20 flex-1 flex flex-col">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Trophy className="w-6 h-6 text-yellow-400" />
|
||||
<h3 className="text-xl font-bold text-white">中奖记录</h3>
|
||||
<div className="flex-1 h-px bg-gradient-to-r from-yellow-400/50 to-transparent"></div>
|
||||
</div>
|
||||
|
||||
{lastRefreshTime && (
|
||||
<div className="mb-3 text-xs text-white/40 text-center">
|
||||
最后更新: {lastRefreshTime.toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoadingRecords ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-yellow-400"></div>
|
||||
<span className="ml-3 text-white/70">加载中奖记录...</span>
|
||||
</div>
|
||||
) : drawRecords.length === 0 ? (
|
||||
<div className="text-center py-8 text-white/50">
|
||||
<Award className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>暂无中奖记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className={`flex-1 overflow-y-auto overflow-x-hidden space-y-1 scrollbar-thin scrollbar-thumb-white/20 scrollbar-track-transparent transition-opacity duration-500 ${isLoadingRecords ? 'opacity-50' : 'opacity-100'}`}>
|
||||
{drawRecords.slice(0, 4).map((record, index) => {
|
||||
const isFirst = isFirstPrize(record.prize_name);
|
||||
return (
|
||||
<motion.div
|
||||
key={record.id}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className={`
|
||||
relative p-2 rounded-lg border transition-all duration-500 hover:shadow-md transform hover:scale-[1.02]
|
||||
${isFirst
|
||||
? 'bg-gradient-to-r from-yellow-500/20 to-orange-500/20 border-yellow-400/50 shadow-lg shadow-yellow-400/20'
|
||||
: 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${index * 100}ms`,
|
||||
animationFillMode: 'both'
|
||||
}}
|
||||
>
|
||||
{/* 一等奖发光效果 */}
|
||||
{isFirst && (
|
||||
<>
|
||||
<div className="absolute inset-0 rounded-xl bg-gradient-to-r from-yellow-400/10 to-orange-400/10 animate-pulse"></div>
|
||||
<div className="absolute -inset-1 rounded-xl bg-gradient-to-r from-yellow-400/20 to-orange-400/20 blur-sm animate-pulse"></div>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Star className="w-5 h-5 text-yellow-400 animate-pulse" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 flex flex-wrap items-center gap-2 sm:gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-blue-400" />
|
||||
<span className={`font-mono ${isFirst ? 'text-yellow-100 font-bold' : 'text-white'}`}>
|
||||
{formatStudentId(record.student_id)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isFirst ? (
|
||||
<Trophy className="w-4 h-4 text-yellow-400" />
|
||||
) : record.prize_name.includes('二等奖') ? (
|
||||
<Award className="w-4 h-4 text-gray-300" />
|
||||
) : record.prize_name.includes('三等奖') ? (
|
||||
<Gift className="w-4 h-4 text-orange-400" />
|
||||
) : (
|
||||
<Star className="w-4 h-4 text-blue-400" />
|
||||
)}
|
||||
<span className={`font-semibold ${
|
||||
isFirst
|
||||
? 'text-yellow-100 text-lg'
|
||||
: record.prize_name.includes('二等奖')
|
||||
? 'text-gray-100'
|
||||
: record.prize_name.includes('三等奖')
|
||||
? 'text-orange-100'
|
||||
: 'text-blue-100'
|
||||
}`}>
|
||||
{record.prize_name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-sm text-white/70 ml-auto">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>{formatTime(record.draw_time)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 一等奖额外的装饰效果 */}
|
||||
{isFirst && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-yellow-400 to-orange-400 rounded-b-xl animate-pulse"></div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(PrizeDisplay);
|
||||
323
src/components/ResultDisplay.tsx
Normal file
323
src/components/ResultDisplay.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Trophy, RefreshCw, Clock, User, Gift, Sparkles } from 'lucide-react'
|
||||
import { DrawResult } from '../store'
|
||||
import NetworkStatus from './NetworkStatus'
|
||||
import ApiService from '../services/apiService'
|
||||
|
||||
interface ResultDisplayProps {
|
||||
drawResult: DrawResult | null
|
||||
onRestart: () => void
|
||||
isDrawing: boolean
|
||||
showCountdown?: boolean
|
||||
onCountdownComplete?: () => void
|
||||
}
|
||||
|
||||
const ResultDisplay: React.FC<ResultDisplayProps> = ({
|
||||
drawResult,
|
||||
onRestart,
|
||||
isDrawing,
|
||||
showCountdown = false,
|
||||
onCountdownComplete
|
||||
}) => {
|
||||
// 移除调试日志以优化控制台输出
|
||||
|
||||
const [maxDrawTimes, setMaxDrawTimes] = useState<number>(3) // 默认3次
|
||||
// 倒计时相关状态已移除,由浮窗LotteryAnimation组件处理
|
||||
|
||||
// 获取系统配置中的抽奖次数上限
|
||||
useEffect(() => {
|
||||
const loadMaxDrawTimes = async () => {
|
||||
try {
|
||||
const apiService = ApiService.getInstance();
|
||||
const config = await apiService.getSystemConfig();
|
||||
if (config && config.max_draw_times) {
|
||||
setMaxDrawTimes(config.max_draw_times);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取抽奖次数上限失败:', error);
|
||||
}
|
||||
};
|
||||
loadMaxDrawTimes();
|
||||
}, []);
|
||||
|
||||
// Props变化监听
|
||||
useEffect(() => {
|
||||
// 移除调试日志以优化控制台输出
|
||||
}, [isDrawing, drawResult])
|
||||
const [isCountdownActive, setIsCountdownActive] = useState(false)
|
||||
|
||||
// Props变化追踪
|
||||
useEffect(() => {
|
||||
// 移除调试日志以优化控制台输出
|
||||
}, [drawResult, isDrawing, showCountdown]);
|
||||
|
||||
// 倒计时逻辑已完全移除,由浮窗LotteryAnimation组件处理
|
||||
// 不再在ResultDisplay中显示倒计时,避免与浮窗倒计时重复显示数字3
|
||||
|
||||
// 倒计时状态重置逻辑已移除
|
||||
// 格式化时间
|
||||
const formatTime = (timestamp?: number) => {
|
||||
if (!timestamp || typeof timestamp !== 'number') {
|
||||
return new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
const date = new Date(timestamp)
|
||||
if (isNaN(date.getTime())) {
|
||||
return new Date().toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 格式化学号显示
|
||||
const formatStudentId = (studentId?: string) => {
|
||||
if (!studentId || typeof studentId !== 'string') return '未知学号'
|
||||
if (studentId.length !== 12) return studentId
|
||||
return `${studentId.slice(0, 4)}-${studentId.slice(4, 8)}-${studentId.slice(8)}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-4 h-full flex flex-col">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-white">抽奖结果</h2>
|
||||
<NetworkStatus className="bg-black/20 backdrop-blur-sm rounded-lg px-3 py-2" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col justify-center">
|
||||
<AnimatePresence mode="wait">
|
||||
{/* 倒计时状态已移除 - 由浮窗LotteryAnimation组件处理 */}
|
||||
{/* 在浮窗倒计时期间,此处不显示任何倒计时内容,避免重复显示 */}
|
||||
|
||||
{/* 抽奖中状态 */}
|
||||
{isDrawing && !isCountdownActive && (
|
||||
<motion.div
|
||||
key="drawing"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="text-center space-y-6"
|
||||
>
|
||||
{/* 旋转圆环 */}
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
className="w-20 h-20 border-4 border-blue-500/30 border-t-blue-500 rounded-full mx-auto"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Trophy className="w-8 h-8 text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-white mb-2">抽奖进行中</h3>
|
||||
<p className="text-white/70">请稍候,正在为您抽取奖品...</p>
|
||||
</div>
|
||||
|
||||
{/* 动态点 */}
|
||||
<div className="flex justify-center space-x-2">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-3 h-3 bg-blue-400 rounded-full"
|
||||
animate={{ scale: [1, 1.5, 1], opacity: [0.5, 1, 0.5] }}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 有结果状态 */}
|
||||
{!isDrawing && drawResult && (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* 恭喜标题 */}
|
||||
<div className="text-center">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 200, delay: 0.2 }}
|
||||
className="relative inline-block"
|
||||
>
|
||||
<div className="text-6xl mb-2">🎉</div>
|
||||
{/* 闪烁效果 */}
|
||||
<motion.div
|
||||
animate={{ scale: [1.5, 2, 1.5], opacity: [0.5, 1, 0.5] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
className="absolute -inset-4 bg-yellow-400/20 rounded-full blur-xl"
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="text-2xl font-bold text-white mb-1">恭喜中奖!</h3>
|
||||
<p className="text-white/70">Congratulations!</p>
|
||||
</div>
|
||||
|
||||
{/* 奖品信息卡片 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="bg-gradient-to-br from-yellow-500/20 to-orange-500/20 rounded-xl p-6 border border-yellow-500/30 relative overflow-hidden"
|
||||
>
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute top-0 right-0 w-20 h-20 bg-yellow-400/10 rounded-full -translate-y-10 translate-x-10" />
|
||||
<div className="absolute bottom-0 left-0 w-16 h-16 bg-orange-400/10 rounded-full translate-y-8 -translate-x-8" />
|
||||
|
||||
<div className="relative z-10 text-center space-y-4">
|
||||
{/* 奖品名称 */}
|
||||
<div>
|
||||
<div className="text-3xl font-bold text-yellow-400 mb-1">
|
||||
{drawResult.prize?.name || drawResult.prize?.prize_name || '未知奖品'}
|
||||
</div>
|
||||
<div className="text-white/60 text-sm">获得奖品</div>
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex-1 h-px bg-white/20" />
|
||||
<Sparkles className="w-5 h-5 text-yellow-400" />
|
||||
<div className="flex-1 h-px bg-white/20" />
|
||||
</div>
|
||||
|
||||
{/* 详细信息 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-white/70">
|
||||
<User className="w-4 h-4" />
|
||||
<span>学号</span>
|
||||
</div>
|
||||
<div className="text-white font-mono">
|
||||
{formatStudentId(drawResult.studentId)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-white/70">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>时间</span>
|
||||
</div>
|
||||
<div className="text-white text-xs">
|
||||
{formatTime(drawResult.timestamp || Date.now())}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 重新开始按钮 */}
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
onClick={onRestart}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<RefreshCw className="w-5 h-5" />
|
||||
<span>重新开始</span>
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 等待状态 */}
|
||||
{!isDrawing && !drawResult && (
|
||||
<motion.div
|
||||
key="waiting"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-center space-y-6"
|
||||
>
|
||||
{/* 等待图标 */}
|
||||
<div className="relative">
|
||||
<div className="w-20 h-20 bg-white/5 rounded-full flex items-center justify-center mx-auto border-2 border-white/10">
|
||||
<Trophy className="w-10 h-10 text-white/30" />
|
||||
</div>
|
||||
|
||||
{/* 呼吸效果 */}
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1], opacity: [0.3, 0.6, 0.3] }}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
className="absolute inset-0 bg-white/5 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-bold text-white/70 mb-2">等待抽奖</h3>
|
||||
<p className="text-white/50 text-sm leading-relaxed">
|
||||
请输入12位学号<br />
|
||||
然后点击开始抽奖
|
||||
</p>
|
||||
<p className="text-white/40 text-xs mt-3">
|
||||
每个学号可以抽奖{maxDrawTimes}次
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 装饰性元素 */}
|
||||
<div className="flex justify-center space-x-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="w-2 h-2 bg-white/20 rounded-full"
|
||||
animate={{
|
||||
scale: [1, 1.5, 1],
|
||||
opacity: [0.3, 0.8, 0.3]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.3
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 底部提示 */}
|
||||
{!isDrawing && !drawResult && (
|
||||
<div className="mt-6 pt-4 border-t border-white/10">
|
||||
<div className="text-center text-white/40 text-xs space-y-1">
|
||||
<p>🎯 输入学号开始抽奖</p>
|
||||
<p>🏆 祝您好运连连</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultDisplay
|
||||
317
src/components/ScreenKeyboard.tsx
Normal file
317
src/components/ScreenKeyboard.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Play, RotateCcw, Delete, AlertCircle } from 'lucide-react'
|
||||
import { useAppStore } from '../store'
|
||||
import LotteryService from '../services/lotteryService'
|
||||
import type { DrawResult } from '../types'
|
||||
|
||||
interface ScreenKeyboardProps {
|
||||
onStudentIdChange: (studentId: string) => void
|
||||
onDrawStart?: (result: DrawResult) => void
|
||||
onCountdownStart?: () => Promise<boolean>
|
||||
onClearResult?: () => void
|
||||
}
|
||||
|
||||
const ScreenKeyboard: React.FC<ScreenKeyboardProps> = ({ onStudentIdChange, onDrawStart, onCountdownStart, onClearResult }) => {
|
||||
const [studentId, setStudentId] = useState('');
|
||||
const [studentDrawCount, setStudentDrawCount] = useState(0);
|
||||
const [maxDrawTimes, setMaxDrawTimes] = useState(1);
|
||||
const [isCheckingLimit, setIsCheckingLimit] = useState(false);
|
||||
const { isDrawing, drawResult, systemConfig } = useAppStore();
|
||||
const lotteryService = LotteryService.getInstance();
|
||||
|
||||
// 当抽奖结果出现时,清空输入框但不重置抽奖次数状态
|
||||
useEffect(() => {
|
||||
if (drawResult) {
|
||||
setStudentId('');
|
||||
onStudentIdChange('');
|
||||
// 抽奖完成后重置防抖状态,允许下次抽奖
|
||||
setIsDebouncing(false);
|
||||
console.log('🎯 抽奖结果出现,重置防抖状态');
|
||||
// 不重置抽奖次数状态,让下次输入时重新从API获取最新数据
|
||||
// setStudentDrawCount(0); // 移除这行,避免状态不一致
|
||||
}
|
||||
}, [drawResult, onStudentIdChange]);
|
||||
|
||||
// 监听系统配置变化
|
||||
useEffect(() => {
|
||||
if (systemConfig) {
|
||||
setMaxDrawTimes(systemConfig.max_draw_times || 1);
|
||||
}
|
||||
}, [systemConfig]);
|
||||
|
||||
// 监听学号变化,检查抽奖次数
|
||||
useEffect(() => {
|
||||
const checkStudentDrawCount = async () => {
|
||||
if (studentId.length === 12 && /^\d{12}$/.test(studentId)) {
|
||||
setIsCheckingLimit(true);
|
||||
try {
|
||||
// 直接获取学生的实际抽奖次数,而不是依赖canStudentDraw的验证结果
|
||||
const apiService = (lotteryService as any).api;
|
||||
const student = await apiService.getStudent(studentId);
|
||||
const currentDrawCount = student ? student.draw_count : 0;
|
||||
setStudentDrawCount(currentDrawCount);
|
||||
|
||||
console.log('🔍 检查抽奖次数:', {
|
||||
studentId,
|
||||
currentDrawCount,
|
||||
maxDrawTimes,
|
||||
canDraw: currentDrawCount < maxDrawTimes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('检查抽奖次数失败:', error);
|
||||
setStudentDrawCount(0);
|
||||
} finally {
|
||||
setIsCheckingLimit(false);
|
||||
}
|
||||
} else {
|
||||
// 只有当学号不完整时才重置抽奖次数
|
||||
if (studentId.length === 0) {
|
||||
setStudentDrawCount(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
checkStudentDrawCount();
|
||||
}, [studentId, maxDrawTimes, lotteryService]);
|
||||
|
||||
const handleKeyPress = (key: string) => {
|
||||
// 点击数字时清空抽奖结果
|
||||
if (onClearResult) {
|
||||
onClearResult();
|
||||
}
|
||||
|
||||
// 如果学号已达到12位,禁用数字键输入
|
||||
if (studentId.length >= 12) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (studentId.length < 12) {
|
||||
const newStudentId = studentId + key;
|
||||
setStudentId(newStudentId);
|
||||
onStudentIdChange(newStudentId);
|
||||
// 实时更新store中的currentStudentId
|
||||
useAppStore.getState().setStudentId(newStudentId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
const newStudentId = studentId.slice(0, -1);
|
||||
setStudentId(newStudentId);
|
||||
onStudentIdChange(newStudentId);
|
||||
// 实时更新store中的currentStudentId
|
||||
useAppStore.getState().setStudentId(newStudentId);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setStudentId('');
|
||||
onStudentIdChange('');
|
||||
// 实时更新store中的currentStudentId
|
||||
useAppStore.getState().setStudentId('');
|
||||
};
|
||||
|
||||
// 防抖机制,防止重复点击
|
||||
const [isDebouncing, setIsDebouncing] = useState(false);
|
||||
|
||||
const handleDraw = useCallback(async () => {
|
||||
console.log('🎯 ScreenKeyboard: handleDraw被调用', {
|
||||
isDebouncing,
|
||||
studentIdLength: studentId.length,
|
||||
isDrawing,
|
||||
isCheckingLimit,
|
||||
studentDrawCount,
|
||||
maxDrawTimes,
|
||||
onDrawStart: !!onDrawStart,
|
||||
onCountdownStart: !!onCountdownStart
|
||||
});
|
||||
|
||||
// 强化防重复点击检查
|
||||
if (isDebouncing || studentId.length !== 12 || isDrawing || isCheckingLimit) {
|
||||
console.log('❌ 抽奖条件不满足:', { isDebouncing, studentIdLength: studentId.length, isDrawing, isCheckingLimit });
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查抽奖次数限制
|
||||
if (studentDrawCount >= maxDrawTimes) {
|
||||
console.log('❌ 已达到抽奖上限:', { studentDrawCount, maxDrawTimes });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ ScreenKeyboard: 开始抽奖流程', { studentId, studentDrawCount, maxDrawTimes });
|
||||
|
||||
// 验证学号格式
|
||||
if (!/^\d{12}$/.test(studentId)) {
|
||||
console.log('❌ 学号格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 立即设置防抖状态,防止重复点击
|
||||
console.log('🔒 设置防抖状态');
|
||||
setIsDebouncing(true);
|
||||
|
||||
// 触发抽奖开始事件
|
||||
if (onDrawStart) {
|
||||
console.log('📢 触发onDrawStart事件');
|
||||
onDrawStart({
|
||||
success: true,
|
||||
message: '',
|
||||
student_id: studentId,
|
||||
draw_time: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
console.log('⚠️ onDrawStart回调不存在');
|
||||
}
|
||||
|
||||
// 启动倒计时
|
||||
if (onCountdownStart) {
|
||||
console.log('⏰ 调用onCountdownStart');
|
||||
const result = await onCountdownStart();
|
||||
console.log('⏰ onCountdownStart返回结果:', result);
|
||||
|
||||
// 只有在倒计时成功启动后才重置防抖状态
|
||||
if (result) {
|
||||
// 延长防抖时间到5秒,确保整个抽奖流程完成
|
||||
setTimeout(() => {
|
||||
console.log('🔓 重置防抖状态');
|
||||
setIsDebouncing(false);
|
||||
}, 5000);
|
||||
} else {
|
||||
// 如果倒计时启动失败,立即重置防抖状态
|
||||
console.log('🔓 倒计时启动失败,立即重置防抖状态');
|
||||
setIsDebouncing(false);
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ onCountdownStart回调不存在');
|
||||
setIsDebouncing(false);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ ScreenKeyboard: 抽奖启动失败:', error);
|
||||
console.error('❌ 错误堆栈:', error.stack);
|
||||
setIsDebouncing(false);
|
||||
|
||||
// 可以在这里添加用户友好的错误提示
|
||||
// 例如通过toast显示错误信息
|
||||
}
|
||||
}, [studentId, isDrawing, isDebouncing, isCheckingLimit, studentDrawCount, maxDrawTimes, onDrawStart, onCountdownStart]);
|
||||
|
||||
|
||||
const keys = [
|
||||
['1', '2', '3'],
|
||||
['4', '5', '6'],
|
||||
['7', '8', '9'],
|
||||
['clear', '0', 'delete']
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center space-y-6">
|
||||
{/* 学号显示区域 */}
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 w-full max-w-md">
|
||||
<div className="bg-black/20 rounded-xl p-4 mb-4">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl font-mono text-white mb-2 leading-tight">
|
||||
{(() => {
|
||||
const paddedId = studentId.padEnd(12, '_');
|
||||
const groups = [
|
||||
paddedId.slice(0, 4),
|
||||
paddedId.slice(4, 8),
|
||||
paddedId.slice(8, 12)
|
||||
];
|
||||
return groups.map((group, groupIndex) => (
|
||||
<span key={groupIndex} className="inline-block">
|
||||
{group.split('').map((char, charIndex) => (
|
||||
<span key={charIndex} className={char === '_' ? 'text-white/30' : 'text-white'}>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
{groupIndex < 2 && <span className="text-white/50 mx-1"> </span>}
|
||||
</span>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-sm text-white/60">
|
||||
{studentId.length}/12
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 抽奖次数显示已移除 */}
|
||||
|
||||
{/* 抽奖按钮 */}
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
onClick={handleDraw}
|
||||
disabled={studentId.length !== 12 || isDrawing || isCheckingLimit || studentDrawCount >= maxDrawTimes || isDebouncing}
|
||||
className="w-full py-6 bg-gradient-to-r from-green-500 to-blue-600 hover:from-green-600 hover:to-blue-700 disabled:from-gray-500 disabled:to-gray-600 disabled:cursor-not-allowed text-white font-bold text-xl rounded-xl shadow-lg transition-all duration-200 flex items-center justify-center space-x-2"
|
||||
>
|
||||
<Play className="w-6 h-6" />
|
||||
<span>{isDrawing ? '抽奖中...' :
|
||||
isCheckingLimit ? '检查中...' :
|
||||
isDebouncing ? '请稍候...' :
|
||||
studentDrawCount >= maxDrawTimes ? '已达上限' :
|
||||
'开始抽奖'}</span>
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* 屏幕键盘 - 140px*140px */}
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6">
|
||||
<div className="grid grid-cols-3 gap-[10px]" style={{ width: '450px', height: '600px' }}>
|
||||
{keys.flat().map((key, index) => {
|
||||
let content;
|
||||
|
||||
// 检查数字键是否应该被禁用
|
||||
const isNumberKeyDisabled = /^[0-9]$/.test(key) && studentId.length >= 12;
|
||||
|
||||
const className = `
|
||||
rounded-lg font-bold text-sm transition-all duration-200 flex items-center justify-center
|
||||
${isDrawing || isNumberKeyDisabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
|
||||
${key === 'clear' || key === 'delete'
|
||||
? 'bg-red-500/20 hover:bg-red-500/30 text-red-300 border border-red-500/30'
|
||||
: isNumberKeyDisabled
|
||||
? 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
||||
: 'bg-white/10 hover:bg-white/20 text-white border border-white/20'
|
||||
}
|
||||
${typeof key === 'number' ? '' : 'text-lg'}
|
||||
`;
|
||||
|
||||
const numberStyle = /^[0-9]$/.test(key) ? { fontSize: '112px', lineHeight: '140px' } : {};
|
||||
|
||||
if (key === 'clear') {
|
||||
content = <RotateCcw className="w-8 h-8" />;
|
||||
} else if (key === 'delete') {
|
||||
content = <Delete className="w-8 h-8" />;
|
||||
} else {
|
||||
content = key;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={index}
|
||||
whileHover={isDrawing || isNumberKeyDisabled ? {} : { scale: 1.05 }}
|
||||
whileTap={isDrawing || isNumberKeyDisabled ? {} : { scale: 0.95 }}
|
||||
className={className}
|
||||
style={{ width: '140px', height: '140px', ...numberStyle }}
|
||||
disabled={isDrawing || isNumberKeyDisabled}
|
||||
onClick={() => {
|
||||
if (key === 'clear') {
|
||||
handleClear();
|
||||
} else if (key === 'delete') {
|
||||
handleDelete();
|
||||
} else if (!isNumberKeyDisabled) {
|
||||
handleKeyPress(key);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</motion.button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default React.memo(ScreenKeyboard)
|
||||
120
src/components/Toast.tsx
Normal file
120
src/components/Toast.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { AlertCircle, CheckCircle, Info, X } from 'lucide-react'
|
||||
|
||||
export interface ToastProps {
|
||||
id: string
|
||||
type: 'success' | 'error' | 'warning' | 'info'
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
onClose: (id: string) => void
|
||||
}
|
||||
|
||||
const Toast: React.FC<ToastProps> = ({
|
||||
id,
|
||||
type,
|
||||
title,
|
||||
message,
|
||||
duration = 3000,
|
||||
onClose
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
// 调试输出
|
||||
console.log('Toast props:', { id, type, title, message, duration })
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
setTimeout(() => onClose(id), 300) // 等待动画完成
|
||||
}, duration)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}, [duration, id, onClose])
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false)
|
||||
setTimeout(() => onClose(id), 300)
|
||||
}
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle className="w-5 h-5 text-green-400" />
|
||||
case 'error':
|
||||
return <AlertCircle className="w-5 h-5 text-red-400" />
|
||||
case 'warning':
|
||||
return <AlertCircle className="w-5 h-5 text-yellow-400" />
|
||||
case 'info':
|
||||
default:
|
||||
return <Info className="w-5 h-5 text-blue-400" />
|
||||
}
|
||||
}
|
||||
|
||||
const getColorClasses = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'bg-gradient-to-br from-green-500/25 to-green-600/30 border-green-400/50 text-green-50 shadow-green-500/20'
|
||||
case 'error':
|
||||
return 'bg-gradient-to-br from-red-500/25 to-red-600/30 border-red-400/50 text-red-50 shadow-red-500/20'
|
||||
case 'warning':
|
||||
return 'bg-gradient-to-br from-yellow-500/25 to-yellow-600/30 border-yellow-400/50 text-yellow-50 shadow-yellow-500/20'
|
||||
case 'info':
|
||||
default:
|
||||
return 'bg-gradient-to-br from-blue-500/25 to-blue-600/30 border-blue-400/50 text-blue-50 shadow-blue-500/20'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -50, scale: 0.9 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 30 }}
|
||||
className={`
|
||||
relative p-5 rounded-2xl border-2 backdrop-blur-md
|
||||
shadow-2xl shadow-black/30 drop-shadow-lg
|
||||
${getColorClasses()}
|
||||
min-w-[320px] max-w-[420px]
|
||||
before:content-[''] before:absolute before:bottom-[-8px] before:left-6
|
||||
before:w-4 before:h-4 before:rotate-45 before:border-r-2 before:border-b-2
|
||||
before:border-inherit before:bg-inherit before:backdrop-blur-md
|
||||
`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{getIcon()}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm">{title}</h4>
|
||||
{message && message.trim() && (
|
||||
<p className="mt-1 text-sm opacity-90">{message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="flex-shrink-0 ml-2 p-1 rounded-md hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 进度条 */}
|
||||
<motion.div
|
||||
initial={{ width: '100%' }}
|
||||
animate={{ width: '0%' }}
|
||||
transition={{ duration: duration / 1000, ease: 'linear' }}
|
||||
className="absolute bottom-0 left-0 h-1 bg-white/30 rounded-b-lg"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toast
|
||||
31
src/components/ToastContainer.tsx
Normal file
31
src/components/ToastContainer.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import Toast, { ToastProps } from './Toast'
|
||||
|
||||
export interface ToastItem extends Omit<ToastProps, 'onClose'> {
|
||||
id: string
|
||||
}
|
||||
|
||||
interface ToastContainerProps {
|
||||
toasts: ToastItem[]
|
||||
onRemoveToast: (id: string) => void
|
||||
}
|
||||
|
||||
const ToastContainer: React.FC<ToastContainerProps> = ({ toasts, onRemoveToast }) => {
|
||||
const portalRoot = document.getElementById('toast-root') || document.body
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed top-4 right-4 z-[9999] space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
{...toast}
|
||||
onClose={onRemoveToast}
|
||||
/>
|
||||
))}
|
||||
</div>,
|
||||
portalRoot
|
||||
)
|
||||
}
|
||||
|
||||
export default ToastContainer
|
||||
62
src/contexts/ToastContext.tsx
Normal file
62
src/contexts/ToastContext.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React, { createContext, useContext, ReactNode } from 'react'
|
||||
import { useToast } from '../hooks/useToast'
|
||||
import ToastContainer from '../components/ToastContainer'
|
||||
|
||||
interface ToastContextType {
|
||||
showToast: (options: {
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
}) => string
|
||||
showSuccess: (title: string, message?: string, duration?: number) => string
|
||||
showError: (title: string, message?: string, duration?: number) => string
|
||||
showWarning: (title: string, message?: string, duration?: number) => string
|
||||
showInfo: (title: string, message?: string, duration?: number) => string
|
||||
removeToast: (id: string) => void
|
||||
clearAllToasts: () => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined)
|
||||
|
||||
export const useToastContext = () => {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToastContext must be used within a ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
interface ToastProviderProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const ToastProvider: React.FC<ToastProviderProps> = ({ children }) => {
|
||||
const {
|
||||
toasts,
|
||||
showToast,
|
||||
removeToast,
|
||||
clearAllToasts,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo
|
||||
} = useToast()
|
||||
|
||||
const contextValue: ToastContextType = {
|
||||
showToast,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo,
|
||||
removeToast,
|
||||
clearAllToasts
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} onRemoveToast={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
435
src/database/database.ts
Normal file
435
src/database/database.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
// 数据库服务 - 直接调用服务器API
|
||||
// 移除所有本地存储,统一使用服务器数据库
|
||||
|
||||
import type { LotteryRecord } from '../types';
|
||||
|
||||
// 数据库接口定义
|
||||
export interface SystemConfig {
|
||||
id: string;
|
||||
admin_password: string;
|
||||
login_password: string;
|
||||
max_draw_times: number;
|
||||
background_config: string;
|
||||
hide_positions: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface PrizeConfig {
|
||||
id: string;
|
||||
prize_name: string;
|
||||
prize_level: number;
|
||||
total_quantity: number;
|
||||
remaining_quantity: number;
|
||||
probability: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Student {
|
||||
student_id: string;
|
||||
draw_count: number;
|
||||
first_draw_at?: string;
|
||||
last_draw_at?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
class DatabaseService {
|
||||
private static instance: DatabaseService;
|
||||
private baseUrl: string = 'http://localhost:3001/api';
|
||||
|
||||
private constructor() {
|
||||
// 直接使用服务器API,无需初始化本地数据库
|
||||
}
|
||||
|
||||
public static getInstance(): DatabaseService {
|
||||
if (!DatabaseService.instance) {
|
||||
DatabaseService.instance = new DatabaseService();
|
||||
}
|
||||
return DatabaseService.instance;
|
||||
}
|
||||
|
||||
// HTTP请求辅助方法
|
||||
private async request(endpoint: string, options: RequestInit = {}): Promise<any> {
|
||||
const url = `${this.baseUrl}${endpoint}`;
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
// 系统配置相关方法
|
||||
public async getSystemConfig(): Promise<SystemConfig | null> {
|
||||
try {
|
||||
return await this.request('/system/config');
|
||||
} catch (error) {
|
||||
console.error('获取系统配置失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async updateSystemConfig(config: Partial<SystemConfig>): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/system/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('更新系统配置失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async changeAdminPassword(newPassword: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/system/admin-password', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ password: newPassword }),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('修改管理员密码失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async verifyAdminPassword(password: string): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.request('/system/verify-admin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
return result.valid;
|
||||
} catch (error) {
|
||||
console.error('验证管理员密码失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新管理员密码
|
||||
public async updateAdminPassword(newPassword: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.changeAdminPassword(newPassword);
|
||||
} catch (error) {
|
||||
console.error('Update admin password failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证当前管理员密码
|
||||
public async validateCurrentAdminPassword(password: string): Promise<boolean> {
|
||||
try {
|
||||
return await this.verifyAdminPassword(password);
|
||||
} catch (error) {
|
||||
console.error('Validate admin password failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置管理员密码为默认值
|
||||
public async resetAdminPasswordToDefault(): Promise<boolean> {
|
||||
try {
|
||||
console.log('重置管理员密码为默认值: 123456');
|
||||
return await this.changeAdminPassword('123456');
|
||||
} catch (error) {
|
||||
console.error('Reset admin password failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新首页登录密码
|
||||
public async updateLoginPassword(newPassword: string): Promise<boolean> {
|
||||
try {
|
||||
console.log('更新首页登录密码');
|
||||
return await this.updateSystemConfig({ login_password: newPassword });
|
||||
} catch (error) {
|
||||
console.error('Update login password failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 验证首页登录密码
|
||||
public async validateLoginPassword(password: string): Promise<boolean> {
|
||||
try {
|
||||
const config = await this.getSystemConfig();
|
||||
return config ? config.login_password === password : false;
|
||||
} catch (error) {
|
||||
console.error('Validate login password failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 重置首页登录密码为默认值
|
||||
public async resetLoginPasswordToDefault(): Promise<boolean> {
|
||||
try {
|
||||
console.log('重置首页登录密码为默认值: 123456');
|
||||
return await this.updateSystemConfig({ login_password: '123456' });
|
||||
} catch (error) {
|
||||
console.error('Reset login password failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 奖项配置相关操作
|
||||
public async getAllPrizes(): Promise<PrizeConfig[]> {
|
||||
try {
|
||||
return await this.request('/prizes');
|
||||
} catch (error) {
|
||||
console.error('获取奖项列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 添加奖项
|
||||
public async addPrize(prize: Omit<PrizeConfig, 'id' | 'remaining_quantity' | 'created_at'>): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/prizes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(prize),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('添加奖项失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 更新奖项
|
||||
public async updatePrize(id: string, prize: Partial<Omit<PrizeConfig, 'id' | 'created_at'>>): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/prizes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(prize),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('更新奖项失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 删除奖项
|
||||
public async deletePrize(id: string): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/prizes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('删除奖项失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有抽奖记录
|
||||
public async getAllDrawRecords(): Promise<LotteryRecord[]> {
|
||||
try {
|
||||
return await this.request('/records');
|
||||
} catch (error) {
|
||||
console.error('获取抽奖记录失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async updatePrizeQuantity(prizeId: string, remainingQuantity: number): Promise<boolean> {
|
||||
try {
|
||||
await this.request(`/prizes/${prizeId}/quantity`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ remaining_quantity: remainingQuantity }),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Update prize quantity failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 学生相关操作
|
||||
public async getStudent(studentId: string): Promise<Student | null> {
|
||||
try {
|
||||
return await this.request(`/students/${studentId}`);
|
||||
} catch (error) {
|
||||
console.error('Get student failed:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async createOrUpdateStudent(studentId: string): Promise<Student> {
|
||||
try {
|
||||
return await this.request('/students', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ student_id: studentId }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create or update student failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽奖记录相关操作
|
||||
public async createLotteryRecord(studentId: string, prizeId: string, prizeName: string, prizeLevel: number): Promise<LotteryRecord> {
|
||||
try {
|
||||
const record = {
|
||||
student_id: studentId,
|
||||
prize_id: prizeId,
|
||||
prize_name: prizeName,
|
||||
prize_level: prizeLevel
|
||||
};
|
||||
return await this.request('/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Create lottery record failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async createDrawRecord(record: Omit<LotteryRecord, 'id' | 'draw_time' | 'is_synced' | 'cache_data'>): Promise<LotteryRecord> {
|
||||
try {
|
||||
return await this.request('/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('创建抽奖记录失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public async getLotteryRecords(limit?: number): Promise<LotteryRecord[]> {
|
||||
try {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
return await this.request(`/records${params}`);
|
||||
} catch (error) {
|
||||
console.error('Get lottery records failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
public async getStudentRecords(studentId: string): Promise<LotteryRecord[]> {
|
||||
try {
|
||||
return await this.request(`/students/${studentId}/records`);
|
||||
} catch (error) {
|
||||
console.error('Get student records failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 清空所有抽奖记录
|
||||
public async clearAllLotteryRecords(): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/records', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Clear lottery records failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async clearAllDrawRecords(): Promise<boolean> {
|
||||
try {
|
||||
return await this.clearAllLotteryRecords();
|
||||
} catch (error) {
|
||||
console.error('清空抽奖记录失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 一键重置系统:清空记录、重置奖项数量和重新计算概率
|
||||
public async resetSystemCompletely(): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/system/reset', {
|
||||
method: 'POST',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('一键重置系统失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async resetSystem(): Promise<boolean> {
|
||||
try {
|
||||
return await this.resetSystemCompletely();
|
||||
} catch (error) {
|
||||
console.error('系统重置失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 强制重置数据库
|
||||
public async forceResetDatabase(): Promise<boolean> {
|
||||
try {
|
||||
console.log('DatabaseService: 开始强制重置数据库...');
|
||||
return await this.resetSystemCompletely();
|
||||
} catch (error) {
|
||||
console.error('强制重置数据库失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 模拟抽奖(用于测试)
|
||||
public async simulateLottery(studentId: string, prizeId: string): Promise<LotteryRecord | null> {
|
||||
try {
|
||||
return await this.request('/lottery/simulate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ studentId, prizeId }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('模拟抽奖失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 抽奖逻辑现在由服务器处理
|
||||
// 客户端不再需要本地抽奖逻辑
|
||||
|
||||
// 保存数据库(用于数据持久化)
|
||||
public async saveDatabase(): Promise<boolean> {
|
||||
try {
|
||||
await this.request('/system/save', {
|
||||
method: 'POST',
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('保存数据库失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取数据库统计信息
|
||||
public async getDatabaseStats(): Promise<any> {
|
||||
try {
|
||||
return await this.request('/system/stats');
|
||||
} catch (error) {
|
||||
console.error('获取数据库统计信息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭数据库连接
|
||||
public close(): void {
|
||||
// 服务器端数据库连接由服务器管理,客户端无需关闭
|
||||
console.log('使用服务器数据库,无需关闭连接');
|
||||
}
|
||||
}
|
||||
|
||||
export default DatabaseService;
|
||||
66
src/database/init.sql
Normal file
66
src/database/init.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- 抽奖系统数据库初始化脚本
|
||||
-- 创建系统配置表
|
||||
CREATE TABLE system_config (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
admin_password TEXT NOT NULL DEFAULT 'admin123',
|
||||
login_password TEXT NOT NULL DEFAULT 'admin123',
|
||||
max_draw_times INTEGER DEFAULT 1,
|
||||
background_config TEXT DEFAULT '{}',
|
||||
hide_positions TEXT DEFAULT '3,4,5,6',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建奖项配置表
|
||||
CREATE TABLE prize_config (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
prize_name TEXT NOT NULL,
|
||||
prize_level INTEGER NOT NULL CHECK (prize_level IN (1,2,3,4)),
|
||||
total_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
remaining_quantity INTEGER NOT NULL DEFAULT 0,
|
||||
probability REAL DEFAULT 0.0000,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- 创建学生表
|
||||
CREATE TABLE students (
|
||||
student_id TEXT PRIMARY KEY,
|
||||
draw_count INTEGER DEFAULT 0,
|
||||
first_draw_at DATETIME,
|
||||
last_draw_at DATETIME
|
||||
);
|
||||
|
||||
-- 创建抽奖记录表
|
||||
CREATE TABLE lottery_records (
|
||||
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
||||
student_id TEXT NOT NULL,
|
||||
prize_id TEXT REFERENCES prize_config(id),
|
||||
prize_name TEXT NOT NULL,
|
||||
prize_level INTEGER NOT NULL,
|
||||
draw_time DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_synced INTEGER DEFAULT 0,
|
||||
cache_data TEXT DEFAULT '{}'
|
||||
);
|
||||
|
||||
-- 创建索引
|
||||
CREATE INDEX idx_lottery_records_student_id ON lottery_records(student_id);
|
||||
CREATE INDEX idx_lottery_records_draw_time ON lottery_records(draw_time DESC);
|
||||
CREATE INDEX idx_lottery_records_prize_level ON lottery_records(prize_level);
|
||||
CREATE INDEX idx_students_draw_count ON students(draw_count);
|
||||
|
||||
-- 创建触发器用于更新时间戳
|
||||
CREATE TRIGGER update_system_config_timestamp
|
||||
AFTER UPDATE ON system_config
|
||||
BEGIN
|
||||
UPDATE system_config SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
|
||||
END;
|
||||
|
||||
-- 初始化数据
|
||||
INSERT INTO system_config (admin_password, login_password, max_draw_times) VALUES ('admin123', 'admin123', 1);
|
||||
|
||||
INSERT INTO prize_config (prize_name, prize_level, total_quantity, remaining_quantity, probability) VALUES
|
||||
('一等奖-iPad', 1, 1, 1, 0.0010),
|
||||
('二等奖-蓝牙耳机', 2, 5, 5, 0.0050),
|
||||
('三等奖-保温杯', 3, 20, 20, 0.0200),
|
||||
('谢谢参与', 4, 9974, 9974, 0.9740);
|
||||
29
src/hooks/useTheme.ts
Normal file
29
src/hooks/useTheme.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark';
|
||||
|
||||
export function useTheme() {
|
||||
const [theme, setTheme] = useState<Theme>(() => {
|
||||
const savedTheme = localStorage.getItem('theme') as Theme;
|
||||
if (savedTheme) {
|
||||
return savedTheme;
|
||||
}
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.remove('light', 'dark');
|
||||
document.documentElement.classList.add(theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
return {
|
||||
theme,
|
||||
toggleTheme,
|
||||
isDark: theme === 'dark'
|
||||
};
|
||||
}
|
||||
64
src/hooks/useToast.ts
Normal file
64
src/hooks/useToast.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { ToastItem } from '../components/ToastContainer'
|
||||
|
||||
export interface ShowToastOptions {
|
||||
type?: 'success' | 'error' | 'warning' | 'info'
|
||||
title: string
|
||||
message?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export const useToast = () => {
|
||||
const [toasts, setToasts] = useState<ToastItem[]>([])
|
||||
|
||||
const showToast = useCallback((options: ShowToastOptions) => {
|
||||
const id = Date.now().toString() + Math.random().toString(36).substr(2, 9)
|
||||
const newToast: ToastItem = {
|
||||
id,
|
||||
type: options.type || 'info',
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
duration: options.duration || 3000
|
||||
}
|
||||
|
||||
setToasts(prev => [...prev, newToast])
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id))
|
||||
}, [])
|
||||
|
||||
const clearAllToasts = useCallback(() => {
|
||||
setToasts([])
|
||||
}, [])
|
||||
|
||||
// 便捷方法
|
||||
const showSuccess = useCallback((title: string, message?: string, duration?: number) => {
|
||||
return showToast({ type: 'success', title, message, duration })
|
||||
}, [showToast])
|
||||
|
||||
const showError = useCallback((title: string, message?: string, duration?: number) => {
|
||||
return showToast({ type: 'error', title, message, duration })
|
||||
}, [showToast])
|
||||
|
||||
const showWarning = useCallback((title: string, message?: string, duration?: number) => {
|
||||
console.log('showWarning调用:', { title, message, duration })
|
||||
return showToast({ type: 'warning', title, message, duration })
|
||||
}, [showToast])
|
||||
|
||||
const showInfo = useCallback((title: string, message?: string, duration?: number) => {
|
||||
return showToast({ type: 'info', title, message, duration })
|
||||
}, [showToast])
|
||||
|
||||
return {
|
||||
toasts,
|
||||
showToast,
|
||||
removeToast,
|
||||
clearAllToasts,
|
||||
showSuccess,
|
||||
showError,
|
||||
showWarning,
|
||||
showInfo
|
||||
}
|
||||
}
|
||||
92
src/index.css
Normal file
92
src/index.css
Normal file
@@ -0,0 +1,92 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji";
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* 深色模式全局样式 */
|
||||
html {
|
||||
background-color: #111827;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gray-900 text-gray-100 transition-colors duration-300;
|
||||
}
|
||||
|
||||
/* 深色模式下的滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-gray-800;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-600 rounded-md;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
@apply bg-gray-500;
|
||||
}
|
||||
|
||||
/* 淡入上升动画 */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeInUp {
|
||||
animation: fadeInUp 0.6s ease-out;
|
||||
}
|
||||
|
||||
/* 平滑过渡效果 */
|
||||
.smooth-refresh {
|
||||
transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.smooth-refresh.loading {
|
||||
opacity: 0.7;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保持滚动功能 */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* Internet Explorer 10+ */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Safari and Chrome */
|
||||
}
|
||||
|
||||
/* 隐藏所有闪烁光标 */
|
||||
* {
|
||||
caret-color: transparent !important;
|
||||
}
|
||||
|
||||
input, textarea, [contenteditable] {
|
||||
caret-color: transparent !important;
|
||||
cursor: default !important;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, [contenteditable]:focus {
|
||||
caret-color: transparent !important;
|
||||
outline: none;
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
14
src/main.tsx
Normal file
14
src/main.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App'
|
||||
import ErrorBoundary from './components/ErrorBoundary'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<ErrorBoundary>
|
||||
{/* 暂时禁用StrictMode以排查倒计时问题 */}
|
||||
{/* <StrictMode> */}
|
||||
<App />
|
||||
{/* </StrictMode> */}
|
||||
</ErrorBoundary>
|
||||
)
|
||||
1399
src/pages/Admin.tsx
Normal file
1399
src/pages/Admin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
184
src/pages/ClearRecords.tsx
Normal file
184
src/pages/ClearRecords.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, Trash2, RefreshCw, CheckCircle } from 'lucide-react';
|
||||
import ApiService from "../services/apiService";
|
||||
import { useToast } from '../hooks/useToast';
|
||||
|
||||
const ClearRecords: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { showSuccess, showError, showWarning } = useToast();
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [clearResult, setClearResult] = useState<string | null>(null);
|
||||
const [currentData, setCurrentData] = useState<any>(null);
|
||||
|
||||
const api = ApiService.getInstance();
|
||||
|
||||
const checkCurrentData = async () => {
|
||||
try {
|
||||
const records = await api.getLotteryRecords();
|
||||
const prizes = await api.getAllPrizes();
|
||||
const students = JSON.parse(localStorage.getItem('lottery_students') || '[]');
|
||||
|
||||
setCurrentData({
|
||||
recordsCount: records.length,
|
||||
studentsCount: students.length,
|
||||
prizes: prizes.map(p => ({
|
||||
name: p.prize_name,
|
||||
total: p.total_quantity,
|
||||
remaining: p.remaining_quantity
|
||||
}))
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('ClearRecords页面 - 获取当前数据失败:', error);
|
||||
showError('获取数据失败,请检查网络连接');
|
||||
setCurrentData({
|
||||
recordsCount: 0,
|
||||
studentsCount: 0,
|
||||
prizes: []
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearRecords = async () => {
|
||||
if (!window.confirm('确定要清空所有抽奖记录吗?此操作不可撤销!')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsClearing(true);
|
||||
setClearResult(null);
|
||||
|
||||
try {
|
||||
const success = await api.clearAllLotteryRecords();
|
||||
|
||||
if (success) {
|
||||
setClearResult('✅ 抽奖记录清空成功!');
|
||||
// 重新检查数据
|
||||
setTimeout(async () => {
|
||||
await checkCurrentData();
|
||||
}, 500);
|
||||
} else {
|
||||
setClearResult('❌ 清空操作失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
setClearResult(`❌ 清空操作出错: ${error}`);
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
checkCurrentData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 头部 */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => navigate('/admin')}
|
||||
className="flex items-center space-x-2 text-gray-600 hover:text-gray-800 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
<span>返回管理</span>
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold text-gray-800">清空抽奖记录</h1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 当前数据状态 */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold text-gray-800">当前数据状态</h2>
|
||||
<button
|
||||
onClick={checkCurrentData}
|
||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
<span>刷新数据</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentData && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-blue-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-blue-800 mb-2">抽奖记录</h3>
|
||||
<p className="text-2xl font-bold text-blue-600">{currentData.recordsCount}</p>
|
||||
<p className="text-sm text-blue-600">条记录</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-green-800 mb-2">学生记录</h3>
|
||||
<p className="text-2xl font-bold text-green-600">{currentData.studentsCount}</p>
|
||||
<p className="text-sm text-green-600">个学生</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 p-4 rounded-lg">
|
||||
<h3 className="font-semibold text-purple-800 mb-2">奖品状态</h3>
|
||||
<div className="space-y-1">
|
||||
{currentData.prizes.map((prize: any, index: number) => (
|
||||
<div key={index} className="text-sm">
|
||||
<span className="text-purple-700">{prize.name}:</span>
|
||||
<span className="text-purple-600 ml-1">
|
||||
{prize.remaining}/{prize.total}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 清空操作 */}
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h2 className="text-xl font-semibold text-gray-800 mb-4">清空操作</h2>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-semibold text-red-800 mb-2">⚠️ 警告</h3>
|
||||
<p className="text-red-700 mb-2">此操作将会:</p>
|
||||
<ul className="list-disc list-inside text-red-700 space-y-1">
|
||||
<li>删除所有抽奖记录</li>
|
||||
<li>重置所有学生的抽奖次数</li>
|
||||
<li>恢复所有奖品的剩余数量到初始值</li>
|
||||
</ul>
|
||||
<p className="text-red-800 font-semibold mt-2">此操作不可恢复,请谨慎操作!</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={handleClearRecords}
|
||||
disabled={isClearing}
|
||||
className="flex items-center space-x-2 px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isClearing ? (
|
||||
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-5 h-5" />
|
||||
)}
|
||||
<span>{isClearing ? '清空中...' : '清空所有记录'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{clearResult && (
|
||||
<div className={`mt-4 p-4 rounded-lg ${
|
||||
clearResult.includes('✅')
|
||||
? 'bg-green-50 border border-green-200 text-green-800'
|
||||
: 'bg-red-50 border border-red-200 text-red-800'
|
||||
}`}>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span>{clearResult}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClearRecords;
|
||||
161
src/pages/ConfettiDemo.tsx
Normal file
161
src/pages/ConfettiDemo.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Sparkles, Trophy, ArrowLeft } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import ConfettiEffect from '../components/ConfettiEffect';
|
||||
import LotteryAnimation from '../components/LotteryAnimation';
|
||||
import { DrawResult } from '../store';
|
||||
|
||||
const ConfettiDemo: React.FC = () => {
|
||||
const [showConfetti, setShowConfetti] = useState(false);
|
||||
const [showLotteryAnimation, setShowLotteryAnimation] = useState(false);
|
||||
const [demoResult, setDemoResult] = useState<DrawResult | undefined>();
|
||||
|
||||
// 模拟1等奖结果
|
||||
const mockFirstPrizeResult: DrawResult = {
|
||||
success: true,
|
||||
message: '恭喜中奖!',
|
||||
studentId: '2021001',
|
||||
formattedStudentId: '2021001',
|
||||
prize: {
|
||||
id: '1',
|
||||
name: '一等奖',
|
||||
level: 1
|
||||
},
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// 触发单独的撒花特效
|
||||
const triggerConfetti = () => {
|
||||
setShowConfetti(true);
|
||||
setTimeout(() => {
|
||||
setShowConfetti(false);
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
// 触发完整的1等奖抽奖动画
|
||||
const triggerFullAnimation = () => {
|
||||
setDemoResult(mockFirstPrizeResult);
|
||||
setShowLotteryAnimation(true);
|
||||
};
|
||||
|
||||
const handleAnimationComplete = () => {
|
||||
setShowLotteryAnimation(false);
|
||||
setDemoResult(undefined);
|
||||
};
|
||||
|
||||
const handleCountdownComplete = () => {
|
||||
// 模拟抽奖完成后设置结果
|
||||
setTimeout(() => {
|
||||
setDemoResult(mockFirstPrizeResult);
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* 头部导航 */}
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center space-x-2 text-white hover:text-yellow-400 transition-colors"
|
||||
>
|
||||
<ArrowLeft size={24} />
|
||||
<span>返回首页</span>
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold text-white flex items-center space-x-2">
|
||||
<Sparkles className="text-yellow-400" />
|
||||
<span>撒花特效演示</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* 演示区域 */}
|
||||
<div className="bg-white/10 backdrop-blur-sm rounded-2xl p-8 mb-8">
|
||||
<div className="text-center">
|
||||
<div className="mb-8">
|
||||
<Trophy className="w-24 h-24 text-yellow-400 mx-auto mb-4" />
|
||||
<h2 className="text-2xl font-bold text-white mb-2">一等奖撒花特效演示</h2>
|
||||
<p className="text-gray-300">点击下方按钮体验不同的撒花效果</p>
|
||||
</div>
|
||||
|
||||
{/* 演示按钮 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 单独撒花特效 */}
|
||||
<motion.button
|
||||
onClick={triggerConfetti}
|
||||
className="bg-gradient-to-r from-yellow-400 to-orange-500 text-black font-bold py-4 px-8 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Sparkles size={24} />
|
||||
<span>触发撒花特效</span>
|
||||
</div>
|
||||
<div className="text-sm mt-1 opacity-80">
|
||||
仅显示撒花动画
|
||||
</div>
|
||||
</motion.button>
|
||||
|
||||
{/* 完整抽奖动画 */}
|
||||
<motion.button
|
||||
onClick={triggerFullAnimation}
|
||||
className="bg-gradient-to-r from-purple-500 to-pink-500 text-white font-bold py-4 px-8 rounded-xl shadow-lg hover:shadow-xl transition-all duration-300"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<Trophy size={24} />
|
||||
<span>完整抽奖动画</span>
|
||||
</div>
|
||||
<div className="text-sm mt-1 opacity-80">
|
||||
包含倒计时和撒花
|
||||
</div>
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 说明文档 */}
|
||||
<div className="bg-white/5 backdrop-blur-sm rounded-2xl p-6">
|
||||
<h3 className="text-xl font-bold text-white mb-4">特效说明</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 text-gray-300">
|
||||
<div>
|
||||
<h4 className="font-semibold text-yellow-400 mb-2">撒花特效包含:</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 50个彩色粒子从顶部飘落</li>
|
||||
<li>• 20个星星闪烁动画</li>
|
||||
<li>• 中央烟花爆炸效果</li>
|
||||
<li>• 持续时间:4秒</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-yellow-400 mb-2">触发条件:</h4>
|
||||
<ul className="space-y-1 text-sm">
|
||||
<li>• 抽中1等奖时自动触发</li>
|
||||
<li>• 奖品等级 level === 1</li>
|
||||
<li>• 在抽奖结果显示时播放</li>
|
||||
<li>• 全屏覆盖,不影响交互</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 撒花特效组件 */}
|
||||
<ConfettiEffect
|
||||
isActive={showConfetti}
|
||||
duration={4000}
|
||||
/>
|
||||
|
||||
{/* 完整抽奖动画 */}
|
||||
<LotteryAnimation
|
||||
isVisible={showLotteryAnimation}
|
||||
onComplete={handleAnimationComplete}
|
||||
onCountdownComplete={handleCountdownComplete}
|
||||
result={demoResult}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfettiDemo;
|
||||
284
src/pages/CrudTest.tsx
Normal file
284
src/pages/CrudTest.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import DatabaseService from '../database/database';
|
||||
import { Prize, Student, DrawRecord, SystemConfig } from '../types';
|
||||
import { PrizeConfig } from '../database/database';
|
||||
|
||||
interface TestResult {
|
||||
operation: string;
|
||||
success: boolean;
|
||||
message: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
const CrudTest: React.FC = () => {
|
||||
const [testResults, setTestResults] = useState<TestResult[]>([]);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [dbService, setDbService] = useState<DatabaseService | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const initDb = async () => {
|
||||
try {
|
||||
const service = DatabaseService.getInstance();
|
||||
// 使用公共方法ensureInitialized替代私有方法initDatabase
|
||||
// 不再需要初始化本地数据库
|
||||
setDbService(service);
|
||||
addResult('数据库初始化', true, '数据库服务初始化成功');
|
||||
} catch (error) {
|
||||
addResult('数据库初始化', false, `初始化失败: ${error}`);
|
||||
}
|
||||
};
|
||||
initDb();
|
||||
}, []);
|
||||
|
||||
const addResult = (operation: string, success: boolean, message: string, data?: any) => {
|
||||
setTestResults(prev => [...prev, { operation, success, message, data }]);
|
||||
};
|
||||
|
||||
const clearResults = () => {
|
||||
setTestResults([]);
|
||||
};
|
||||
|
||||
const runAllTests = async () => {
|
||||
if (!dbService) {
|
||||
addResult('测试准备', false, '数据库服务未初始化');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
clearResults();
|
||||
|
||||
try {
|
||||
// 测试系统配置CRUD
|
||||
await testSystemConfig();
|
||||
|
||||
// 测试奖项CRUD
|
||||
await testPrizes();
|
||||
|
||||
// 测试学生CRUD
|
||||
await testStudents();
|
||||
|
||||
// 测试抽奖记录CRUD
|
||||
await testDrawRecords();
|
||||
|
||||
// 测试密码管理
|
||||
await testPasswordManagement();
|
||||
|
||||
addResult('所有测试', true, '所有CRUD接口测试完成');
|
||||
} catch (error) {
|
||||
addResult('测试执行', false, `测试过程中发生错误: ${error}`);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testSystemConfig = async () => {
|
||||
try {
|
||||
// 测试获取系统配置
|
||||
const config = await dbService!.getSystemConfig();
|
||||
addResult('获取系统配置', true, '成功获取系统配置', config);
|
||||
|
||||
// 测试更新系统配置
|
||||
const newConfig: Partial<SystemConfig> = {
|
||||
max_draw_times: 3,
|
||||
admin_password: 'test123',
|
||||
login_password: 'draw123'
|
||||
};
|
||||
const updateResult = await dbService!.updateSystemConfig(newConfig);
|
||||
addResult('更新系统配置', updateResult, updateResult ? '系统配置更新成功' : '系统配置更新失败');
|
||||
|
||||
// 验证更新后的配置
|
||||
const updatedConfig = await dbService!.getSystemConfig();
|
||||
addResult('验证配置更新', true, '配置更新验证完成', updatedConfig);
|
||||
} catch (error) {
|
||||
addResult('系统配置测试', false, `系统配置测试失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const testPrizes = async () => {
|
||||
try {
|
||||
// 测试获取所有奖项
|
||||
const prizes = await dbService!.getAllPrizes();
|
||||
addResult('获取所有奖项', true, `成功获取${prizes?.length || 0}个奖项`, prizes);
|
||||
|
||||
// 测试添加奖项
|
||||
const newPrize = {
|
||||
prize_name: '测试奖品',
|
||||
prize_level: 1,
|
||||
probability: 0.1,
|
||||
total_quantity: 10,
|
||||
is_active: true
|
||||
};
|
||||
const addPrizeResult = await dbService!.addPrize(newPrize);
|
||||
addResult('添加奖项', addPrizeResult, addPrizeResult ? '奖项添加成功' : '奖项添加失败', newPrize);
|
||||
|
||||
if (addPrizeResult) {
|
||||
// 获取添加的奖项以获取ID
|
||||
const allPrizes = await dbService!.getAllPrizes();
|
||||
const addedPrize = allPrizes.find(p => p.prize_name === '测试奖品');
|
||||
|
||||
if (addedPrize) {
|
||||
// 测试更新奖项
|
||||
const updatedPrize = { prize_name: '更新后的测试奖品', remaining_quantity: 8 };
|
||||
const updateResult = await dbService!.updatePrize(addedPrize.id, updatedPrize);
|
||||
addResult('更新奖项', updateResult, updateResult ? '奖项更新成功' : '奖项更新失败', updatedPrize);
|
||||
|
||||
// 测试删除奖项
|
||||
const deleteResult = await dbService!.deletePrize(addedPrize.id);
|
||||
addResult('删除奖项', deleteResult, deleteResult ? '奖项删除成功' : '奖项删除失败');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
addResult('奖项测试', false, `奖项测试失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const testStudents = async () => {
|
||||
try {
|
||||
const testStudentId = 'TEST001';
|
||||
|
||||
// 测试获取学生
|
||||
const student = await dbService!.getStudent(testStudentId);
|
||||
addResult('获取学生', true, student ? '学生已存在' : '学生不存在', student);
|
||||
|
||||
// 测试创建/更新学生
|
||||
const createResult = await dbService!.createOrUpdateStudent(testStudentId);
|
||||
addResult('创建/更新学生', !!createResult, createResult ? '学生操作成功' : '学生操作失败', createResult);
|
||||
|
||||
// 验证学生创建
|
||||
const createdStudent = await dbService!.getStudent(testStudentId);
|
||||
addResult('验证学生创建', !!createdStudent, createdStudent ? '学生创建验证成功' : '学生创建验证失败', createdStudent);
|
||||
} catch (error) {
|
||||
addResult('学生测试', false, `学生测试失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const testDrawRecords = async () => {
|
||||
try {
|
||||
// 测试获取所有抽奖记录
|
||||
const records = await dbService!.getAllDrawRecords();
|
||||
addResult('获取抽奖记录', true, `成功获取${records?.length || 0}条抽奖记录`, records);
|
||||
|
||||
// 测试创建抽奖记录
|
||||
const newRecord = {
|
||||
student_id: 'TEST001',
|
||||
prize_id: 'test-prize',
|
||||
prize_name: '测试奖品',
|
||||
prize_level: 1
|
||||
};
|
||||
const createResult = await dbService!.createDrawRecord(newRecord);
|
||||
addResult('创建抽奖记录', !!createResult, createResult ? '抽奖记录创建成功' : '抽奖记录创建失败', createResult);
|
||||
|
||||
// 测试清空抽奖记录
|
||||
const clearResult = await dbService!.clearAllDrawRecords();
|
||||
addResult('清空抽奖记录', clearResult, clearResult ? '抽奖记录清空成功' : '抽奖记录清空失败');
|
||||
} catch (error) {
|
||||
addResult('抽奖记录测试', false, `抽奖记录测试失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
const testPasswordManagement = async () => {
|
||||
try {
|
||||
// 测试管理员密码验证
|
||||
const adminVerify = await dbService!.verifyAdminPassword('123456');
|
||||
addResult('验证管理员密码', true, adminVerify ? '管理员密码验证成功' : '管理员密码验证失败');
|
||||
|
||||
// 测试登录密码验证
|
||||
const loginVerify = await dbService!.validateLoginPassword('123456');
|
||||
addResult('验证登录密码', true, loginVerify ? '登录密码验证成功' : '登录密码验证失败');
|
||||
|
||||
// 测试修改管理员密码
|
||||
const changeAdminResult = await dbService!.changeAdminPassword('newadmin123');
|
||||
addResult('修改管理员密码', changeAdminResult, changeAdminResult ? '管理员密码修改成功' : '管理员密码修改失败');
|
||||
|
||||
// 恢复原密码
|
||||
if (changeAdminResult) {
|
||||
await dbService!.changeAdminPassword('123456');
|
||||
addResult('恢复管理员密码', true, '管理员密码已恢复');
|
||||
}
|
||||
} catch (error) {
|
||||
addResult('密码管理测试', false, `密码管理测试失败: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-100 p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-800 mb-6">CRUD接口兼容性测试</h1>
|
||||
|
||||
<div className="mb-6">
|
||||
<button
|
||||
onClick={runAllTests}
|
||||
disabled={isRunning || !dbService}
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
{isRunning ? '测试进行中...' : '运行所有测试'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={clearResults}
|
||||
disabled={isRunning}
|
||||
className="ml-4 bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white px-6 py-2 rounded-lg font-medium transition-colors"
|
||||
>
|
||||
清空结果
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-xl font-semibold text-gray-700">测试结果</h2>
|
||||
|
||||
{testResults.length === 0 ? (
|
||||
<p className="text-gray-500">暂无测试结果</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{testResults.map((result, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`p-4 rounded-lg border-l-4 ${
|
||||
result.success
|
||||
? 'bg-green-50 border-green-400 text-green-800'
|
||||
: 'bg-red-50 border-red-400 text-red-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="font-medium">{result.operation}</span>
|
||||
<span className={`ml-2 px-2 py-1 rounded text-xs ${
|
||||
result.success ? 'bg-green-200 text-green-800' : 'bg-red-200 text-red-800'
|
||||
}`}>
|
||||
{result.success ? '成功' : '失败'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-1 text-sm">{result.message}</p>
|
||||
{result.data && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-sm font-medium">查看数据</summary>
|
||||
<pre className="mt-1 p-2 bg-gray-100 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(result.data, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 p-4 bg-blue-50 rounded-lg">
|
||||
<h3 className="font-semibold text-blue-800 mb-2">测试说明</h3>
|
||||
<ul className="text-sm text-blue-700 space-y-1">
|
||||
<li>• 系统配置:测试获取和更新系统配置</li>
|
||||
<li>• 奖项管理:测试奖项的增删改查操作</li>
|
||||
<li>• 学生管理:测试学生信息的创建和查询</li>
|
||||
<li>• 抽奖记录:测试抽奖记录的创建、查询和清空</li>
|
||||
<li>• 密码管理:测试管理员和登录密码的验证和修改</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CrudTest;
|
||||
443
src/pages/Home.tsx
Normal file
443
src/pages/Home.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Trophy, Award, Star, Gift, RefreshCw, Clock, Sparkles, Wifi, WifiOff } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAppStore } from '../store';
|
||||
import { Prize } from '../store';
|
||||
import EventService, { EVENT_TYPES } from '../services/eventService';
|
||||
import DatabaseService, { SystemConfig } from '../database/database';
|
||||
import { websocketService } from '../services/websocketService';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
import Empty from '../components/Empty';
|
||||
|
||||
interface ProbabilityInfo {
|
||||
prize: Prize;
|
||||
dynamicProbability: number;
|
||||
remainingRatio: number;
|
||||
adjustedForDrawLimit: boolean;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const { prizes, loadPrizes } = useAppStore();
|
||||
const [probabilityData, setProbabilityData] = useState<ProbabilityInfo[]>([]);
|
||||
const [lastUpdateTime, setLastUpdateTime] = useState<Date>(new Date());
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [systemConfig, setSystemConfig] = useState<SystemConfig | null>(null);
|
||||
const [isWebSocketConnected, setIsWebSocketConnected] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// 计算动态概率 - 结合抽奖次数上限和剩余数量
|
||||
const calculateDynamicProbabilities = (prizeList: Prize[]): ProbabilityInfo[] => {
|
||||
// 只显示启用且有剩余数量的奖项
|
||||
const activePrizes = prizeList.filter(p => p.is_active && p.remainingQuantity > 0);
|
||||
|
||||
if (activePrizes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxDrawTimes = systemConfig?.max_draw_times || 1;
|
||||
|
||||
// 计算总权重(与抽奖服务的weightedRandomDraw方法一致)
|
||||
const totalWeight = activePrizes.reduce((sum, prize) => sum + prize.probability, 0);
|
||||
|
||||
return activePrizes.map(prize => {
|
||||
// 基础概率计算
|
||||
let dynamicProbability = totalWeight > 0 ? (prize.probability / totalWeight) : 0;
|
||||
let adjustedForDrawLimit = false;
|
||||
|
||||
// 结合抽奖次数上限调整概率
|
||||
if (maxDrawTimes > 0) {
|
||||
// 计算剩余奖品与最大抽奖次数的比例
|
||||
const remainingDrawOpportunities = prize.remainingQuantity;
|
||||
const maxPossibleDraws = maxDrawTimes;
|
||||
|
||||
// 如果剩余奖品数量远小于可能的抽奖总次数,降低显示概率
|
||||
if (remainingDrawOpportunities < maxPossibleDraws * 0.1) {
|
||||
dynamicProbability *= (remainingDrawOpportunities / (maxPossibleDraws * 0.1));
|
||||
adjustedForDrawLimit = true;
|
||||
}
|
||||
|
||||
// 如果接近抽奖次数上限,进一步调整概率显示
|
||||
if (remainingDrawOpportunities <= maxPossibleDraws * 0.05) {
|
||||
dynamicProbability *= 0.5; // 进一步降低显示概率
|
||||
adjustedForDrawLimit = true;
|
||||
}
|
||||
}
|
||||
|
||||
const remainingRatio = prize.totalQuantity > 0 ? (prize.remainingQuantity / prize.totalQuantity) * 100 : 0;
|
||||
|
||||
return {
|
||||
prize,
|
||||
dynamicProbability,
|
||||
remainingRatio,
|
||||
adjustedForDrawLimit
|
||||
};
|
||||
}).sort((a, b) => a.prize.level - b.prize.level);
|
||||
};
|
||||
|
||||
// 获取奖项图标
|
||||
const getPrizeIcon = (level: number) => {
|
||||
switch (level) {
|
||||
case 1:
|
||||
return <Trophy className="w-6 h-6" />;
|
||||
case 2:
|
||||
return <Award className="w-6 h-6" />;
|
||||
case 3:
|
||||
return <Star className="w-6 h-6" />;
|
||||
default:
|
||||
return <Gift className="w-6 h-6" />;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取奖项等级文字
|
||||
const getLevelText = (level: number) => {
|
||||
const levelMap: { [key: number]: string } = {
|
||||
1: '特等奖',
|
||||
2: '一等奖',
|
||||
3: '二等奖',
|
||||
4: '三等奖',
|
||||
5: '参与奖'
|
||||
};
|
||||
return levelMap[level] || `${level}等奖`;
|
||||
};
|
||||
|
||||
// 获取概率颜色
|
||||
const getProbabilityColor = (probability: number) => {
|
||||
if (probability >= 0.3) return 'text-green-400';
|
||||
if (probability >= 0.15) return 'text-yellow-400';
|
||||
if (probability >= 0.05) return 'text-orange-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
// 加载系统配置
|
||||
const loadSystemConfig = async () => {
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const config = await db.getSystemConfig();
|
||||
setSystemConfig(config);
|
||||
} catch (error) {
|
||||
console.error('加载系统配置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 手动刷新概率
|
||||
const handleRefresh = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
await loadPrizes();
|
||||
await loadSystemConfig();
|
||||
setLastUpdateTime(new Date());
|
||||
console.log('🔄 手动刷新完成,当前概率数据:', probabilityData);
|
||||
} catch (error) {
|
||||
console.error('刷新失败:', error);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 调试信息
|
||||
useEffect(() => {
|
||||
console.log('🏠 首页概率数据更新:', probabilityData);
|
||||
console.log('📊 最后更新时间:', lastUpdateTime);
|
||||
}, [probabilityData, lastUpdateTime]);
|
||||
|
||||
// 初始化和监听数据变化
|
||||
useEffect(() => {
|
||||
const initializeData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
await Promise.all([
|
||||
loadPrizes(),
|
||||
loadSystemConfig()
|
||||
]);
|
||||
} catch (err) {
|
||||
console.error('初始化数据失败:', err);
|
||||
setError('加载数据失败,请刷新页面重试');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeData();
|
||||
|
||||
// 监听事件服务的奖品更新事件,实现智能无感刷新
|
||||
const eventService = EventService.getInstance();
|
||||
const unsubscribeEvent = eventService.subscribe(EVENT_TYPES.PRIZES_UPDATED, () => {
|
||||
console.log('WebSocket事件:奖品数据已更新');
|
||||
setLastUpdateTime(new Date());
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeEvent();
|
||||
};
|
||||
}, []); // 空依赖数组,避免重复执行
|
||||
|
||||
// 使用useMemo优化概率计算,避免不必要的重新计算
|
||||
const memoizedProbabilityData = React.useMemo(() => {
|
||||
if (!prizes || prizes.length === 0) return [];
|
||||
return calculateDynamicProbabilities(prizes);
|
||||
}, [prizes, systemConfig?.max_draw_times]);
|
||||
|
||||
// 只在计算结果真正变化时更新状态
|
||||
useEffect(() => {
|
||||
const hasChanges = JSON.stringify(memoizedProbabilityData) !== JSON.stringify(probabilityData);
|
||||
if (hasChanges) {
|
||||
console.log('检测到概率数据变化,更新显示');
|
||||
setProbabilityData(memoizedProbabilityData);
|
||||
setLastUpdateTime(new Date());
|
||||
}
|
||||
}, [memoizedProbabilityData, probabilityData]);
|
||||
|
||||
// WebSocket连接状态监控
|
||||
useEffect(() => {
|
||||
const checkConnection = () => {
|
||||
setIsWebSocketConnected(websocketService.isConnected());
|
||||
};
|
||||
|
||||
// 初始检查
|
||||
checkConnection();
|
||||
|
||||
// 定期检查连接状态
|
||||
const interval = setInterval(checkConnection, 5000);
|
||||
|
||||
// 监听WebSocket事件
|
||||
const eventService = EventService.getInstance();
|
||||
const unsubscribePrizes = eventService.subscribe(EVENT_TYPES.PRIZES_UPDATED, () => {
|
||||
setLastUpdateTime(new Date());
|
||||
console.log('🔄 WebSocket实时更新: 奖项数据已更新');
|
||||
});
|
||||
|
||||
const unsubscribeRecords = eventService.subscribe(EVENT_TYPES.RECORDS_UPDATED, () => {
|
||||
setLastUpdateTime(new Date());
|
||||
console.log('🔄 WebSocket实时更新: 抽奖记录已更新');
|
||||
});
|
||||
|
||||
const unsubscribeSystemConfig = eventService.subscribe(EVENT_TYPES.SYSTEM_CONFIG_UPDATED, () => {
|
||||
setLastUpdateTime(new Date());
|
||||
console.log('🔄 WebSocket实时更新: 系统配置已更新');
|
||||
});
|
||||
|
||||
const unsubscribeDataClear = eventService.subscribe(EVENT_TYPES.DATA_CLEARED, () => {
|
||||
setLastUpdateTime(new Date());
|
||||
console.log('🔄 WebSocket实时更新: 数据已清空');
|
||||
});
|
||||
|
||||
const unsubscribeSystemReset = eventService.subscribe(EVENT_TYPES.SYSTEM_RESET, () => {
|
||||
setLastUpdateTime(new Date());
|
||||
console.log('🔄 WebSocket实时更新: 系统已重置');
|
||||
});
|
||||
|
||||
const unsubscribeForceReset = eventService.subscribe(EVENT_TYPES.FORCE_RESET, () => {
|
||||
setLastUpdateTime(new Date());
|
||||
console.log('🔄 WebSocket实时更新: 强制重置完成');
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
unsubscribePrizes();
|
||||
unsubscribeRecords();
|
||||
unsubscribeSystemConfig();
|
||||
unsubscribeDataClear();
|
||||
unsubscribeSystemReset();
|
||||
unsubscribeForceReset();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 加载状态
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<LoadingSpinner size="lg" text="正在加载数据..." fullScreen />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 错误状态
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<Empty
|
||||
title="加载失败"
|
||||
description={error}
|
||||
action={
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* 标题区域 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-8"
|
||||
>
|
||||
<h1 className="text-4xl font-bold text-white mb-4">
|
||||
抽奖系统 - 实时中奖概率
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-4 text-gray-300 mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
{isWebSocketConnected ? (
|
||||
<Wifi className="w-4 h-4 text-green-400" />
|
||||
) : (
|
||||
<WifiOff className="w-4 h-4 text-red-400" />
|
||||
)}
|
||||
<span className={isWebSocketConnected ? 'text-green-400' : 'text-red-400'}>
|
||||
{isWebSocketConnected ? '实时连接' : '连接断开'}
|
||||
</span>
|
||||
</div>
|
||||
<span>最后更新: {lastUpdateTime.toLocaleTimeString()}</span>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-2 px-3 py-1 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '刷新中...' : '手动刷新'}
|
||||
</button>
|
||||
<Link
|
||||
to="/confetti-demo"
|
||||
className="flex items-center gap-2 px-3 py-1 bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600 text-white rounded-lg transition-all duration-300 transform hover:scale-105"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
撒花演示
|
||||
</Link>
|
||||
</div>
|
||||
{systemConfig && (
|
||||
<div className="flex items-center justify-center gap-2 text-yellow-400 text-sm">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span>每个学号最多可抽奖 {systemConfig.max_draw_times} 次</span>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* 概率展示区域 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{probabilityData.map((item, index) => (
|
||||
<motion.div
|
||||
key={item.prize.id}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="bg-white/10 backdrop-blur-md rounded-2xl p-6 border border-white/20 hover:border-white/40 transition-all duration-300"
|
||||
>
|
||||
{/* 奖项标题 */}
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="text-yellow-400">
|
||||
{getPrizeIcon(item.prize.level)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
{getLevelText(item.prize.level)}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-300">
|
||||
{item.prize.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 概率信息 */}
|
||||
<div className="space-y-3">
|
||||
{/* 动态概率 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-300">当前中奖概率:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-bold text-lg ${getProbabilityColor(item.dynamicProbability)}`}>
|
||||
{(item.dynamicProbability * 100).toFixed(2)}%
|
||||
</span>
|
||||
{item.adjustedForDrawLimit && (
|
||||
<span className="text-xs bg-orange-500 text-white px-2 py-1 rounded-full">
|
||||
已调整
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 原始概率 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-400 text-sm">配置概率:</span>
|
||||
<span className="text-gray-400 text-sm">
|
||||
{(item.prize.probability * 1000).toFixed(1)}‰
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 剩余数量 */}
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-gray-300">剩余数量:</span>
|
||||
<span className="text-white font-semibold">
|
||||
{item.prize.remainingQuantity} / {item.prize.totalQuantity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 剩余比例进度条 */}
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between text-xs text-gray-400 mb-1">
|
||||
<span>剩余比例</span>
|
||||
<span>{item.remainingRatio.toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-gradient-to-r from-blue-500 to-purple-600 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${item.remainingRatio}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 无奖项提示 */}
|
||||
{probabilityData.length === 0 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="py-12"
|
||||
>
|
||||
<Empty
|
||||
title="暂无奖项"
|
||||
description="当前没有可抽取的奖项,请联系管理员添加奖项"
|
||||
action={
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '刷新中...' : '刷新数据'}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 说明文字 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
className="mt-8 text-center text-gray-400 text-sm"
|
||||
>
|
||||
<p>* 当前中奖概率根据各奖项剩余数量实时计算</p>
|
||||
<p>* 系统通过WebSocket实时推送数据更新,无需手动刷新</p>
|
||||
<p>* 数据变化时自动更新显示,确保信息准确性</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
156
src/pages/Login.tsx
Normal file
156
src/pages/Login.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { Lock, Eye, EyeOff } from 'lucide-react'
|
||||
import { useAppStore } from '../store'
|
||||
import LotteryService from '../services/lotteryService'
|
||||
import { AuthService } from '../services/authService'
|
||||
|
||||
const Login: React.FC = () => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { login } = useAppStore()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError('')
|
||||
|
||||
try {
|
||||
const lotteryService = LotteryService.getInstance()
|
||||
const isValid = await lotteryService.validateLoginPassword(password)
|
||||
|
||||
if (isValid) {
|
||||
// 使用AuthService进行登录,生成并保存JWT token
|
||||
const authService = AuthService.getInstance()
|
||||
const success = authService.login(password)
|
||||
|
||||
if (success) {
|
||||
// 更新应用状态
|
||||
login(password)
|
||||
console.log('登录成功,JWT token已保存')
|
||||
} else {
|
||||
setError('登录失败,请稍后重试')
|
||||
}
|
||||
} else {
|
||||
setError('首页登录密码错误,请重新输入')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err)
|
||||
setError('登录失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSubmit(e as React.FormEvent)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 via-gray-800 to-black flex items-center justify-center p-4">
|
||||
{/* 背景装饰 */}
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-blue-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-purple-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-2000"></div>
|
||||
<div className="absolute top-40 left-40 w-80 h-80 bg-indigo-600 rounded-full mix-blend-multiply filter blur-xl opacity-30 animate-blob animation-delay-4000"></div>
|
||||
</div>
|
||||
|
||||
{/* 登录卡片 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="relative z-10 w-full max-w-md"
|
||||
>
|
||||
<div className="bg-gray-800/80 backdrop-blur-lg rounded-2xl shadow-2xl border border-gray-700/50 p-8">
|
||||
{/* 标题 */}
|
||||
<div className="text-center mb-8">
|
||||
<motion.div
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
|
||||
className="w-16 h-16 bg-gradient-to-r from-blue-600 to-indigo-600 rounded-full flex items-center justify-center mx-auto mb-4"
|
||||
>
|
||||
<Lock className="w-8 h-8 text-white" />
|
||||
</motion.div>
|
||||
<h1 className="text-3xl font-bold text-gray-100 mb-2">抽 奖 系 统</h1>
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setError('')
|
||||
}}
|
||||
onKeyPress={handleKeyPress}
|
||||
placeholder="请输入密码"
|
||||
className="w-full px-4 py-3 pr-12 bg-gray-700/50 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-200 transition-colors"
|
||||
disabled={loading}
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-red-300 text-sm text-center bg-red-900/30 border border-red-700/50 rounded-lg p-3"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className="w-full py-3 bg-gradient-to-r from-blue-600 to-indigo-600 text-white font-semibold rounded-lg shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center space-x-2"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
|
||||
<span>登录中...</span>
|
||||
</>
|
||||
) : (
|
||||
<span>登录</span>
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<div className="mt-6 text-center">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 底部版权信息 */}
|
||||
<div className="absolute bottom-4 left-1/2 transform -translate-x-1/2">
|
||||
<p className="text-gray-500 text-sm">
|
||||
© 2025 抽奖系统 - 专为活动设计 - 通化市四喜科技有限公司
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
||||
364
src/pages/Lottery.tsx
Normal file
364
src/pages/Lottery.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Settings, FileText, LogOut, X } from 'lucide-react'
|
||||
import PrizeDisplay from '../components/PrizeDisplay'
|
||||
import ResultDisplay from '../components/ResultDisplay'
|
||||
import ScreenKeyboard from '../components/ScreenKeyboard'
|
||||
import LotteryAnimation from '../components/LotteryAnimation'
|
||||
import ErrorBoundary from '../components/ErrorBoundary'
|
||||
import { useAppStore } from '../store'
|
||||
import { useToastContext } from '../contexts/ToastContext'
|
||||
import LotteryService from '../services/lotteryService'
|
||||
import EventService, { EVENT_TYPES } from '../services/eventService'
|
||||
|
||||
const Lottery: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { logout, prizes, drawResult, isDrawing, resetDraw, startDraw, currentStudentId, setStudentId } = useAppStore()
|
||||
const { showWarning } = useToastContext()
|
||||
const [showCountdown, setShowCountdown] = useState(false)
|
||||
const [showAnimation, setShowAnimation] = useState(false)
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [passwordError, setPasswordError] = useState('')
|
||||
|
||||
// 验证抽奖次数并开始倒计时
|
||||
const handleDrawStart = async () => {
|
||||
const lotteryService = LotteryService.getInstance()
|
||||
const validation = await lotteryService.canStudentDraw(currentStudentId)
|
||||
|
||||
console.log('=== 抽奖验证调试 ===')
|
||||
console.log('当前学号:', currentStudentId)
|
||||
console.log('验证结果:', validation)
|
||||
console.log('是否可以抽奖:', validation.canDraw)
|
||||
console.log('提示消息:', validation.message)
|
||||
|
||||
if (!validation.canDraw) {
|
||||
// 显示气泡提示
|
||||
console.log('显示Toast提示:', validation.message)
|
||||
// 根据不同的错误类型使用不同的标题
|
||||
const title = validation.message.includes('格式不正确') ? '学号格式错误' :
|
||||
validation.message.includes('抽奖上限') ? '抽奖次数限制' :
|
||||
validation.message.includes('系统配置') ? '系统错误' : '抽奖验证'
|
||||
showWarning(title, validation.message, 5000)
|
||||
return false
|
||||
}
|
||||
|
||||
console.log('🎲 验证通过,准备开始抽奖,学号:', currentStudentId)
|
||||
console.log('🎯 抽奖前奖品状态:', prizes.map(p => ({ name: p.name, remaining: p.remainingQuantity })))
|
||||
|
||||
setErrorMessage('')
|
||||
// 不在这里设置showAnimation,应该在倒计时完成后设置
|
||||
return true
|
||||
}
|
||||
|
||||
// 防止重复调用的引用
|
||||
const isStartingDrawRef = useRef(false)
|
||||
const isCountdownCompleteRef = useRef(false)
|
||||
|
||||
// 倒计时完成处理
|
||||
const handleCountdownComplete = useCallback(async () => {
|
||||
console.log('⏰ ===== 倒计时完成处理开始 =====');
|
||||
console.log('⏰ 当前时间戳:', Date.now());
|
||||
console.log('⏰ 当前状态 - isDrawing:', isDrawing);
|
||||
console.log('⏰ 当前学号:', currentStudentId);
|
||||
console.log('⏰ 当前drawResult:', drawResult);
|
||||
console.log('⏰ startDraw函数类型:', typeof startDraw);
|
||||
|
||||
// 防止重复调用倒计时完成处理
|
||||
if (isCountdownCompleteRef.current) {
|
||||
console.log('⏰ 倒计时完成处理正在进行中,跳过重复调用');
|
||||
return;
|
||||
}
|
||||
|
||||
isCountdownCompleteRef.current = true;
|
||||
|
||||
console.log('⏰ 倒计时完成,开始执行抽奖逻辑');
|
||||
|
||||
if (!currentStudentId) {
|
||||
console.error('⏰ 学号为空,无法进行抽奖');
|
||||
isCountdownCompleteRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// 防止重复调用
|
||||
if (isDrawing) {
|
||||
console.log('⏰ 抽奖正在进行中,跳过重复调用');
|
||||
isCountdownCompleteRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('⏰ 准备调用startDraw,参数检查:');
|
||||
console.log('⏰ - 学号:', currentStudentId);
|
||||
console.log('⏰ - 学号类型:', typeof currentStudentId);
|
||||
console.log('⏰ - 学号长度:', currentStudentId.length);
|
||||
|
||||
try {
|
||||
console.log('⏰ 开始调用startDraw函数...');
|
||||
const result = await startDraw(currentStudentId);
|
||||
console.log('⏰ startDraw调用完成,返回结果:', result);
|
||||
console.log('⏰ 结果类型:', typeof result);
|
||||
console.log('⏰ 结果详情:', JSON.stringify(result, null, 2));
|
||||
console.log('⏰ 抽奖完成后的状态 - isDrawing:', isDrawing, 'drawResult:', drawResult);
|
||||
} catch (error) {
|
||||
console.error('⏰ 抽奖过程中发生错误:', error);
|
||||
console.error('⏰ 错误堆栈:', error.stack);
|
||||
} finally {
|
||||
// 重置防重复调用标志
|
||||
isCountdownCompleteRef.current = false;
|
||||
}
|
||||
|
||||
console.log('⏰ ===== 倒计时完成处理结束 =====');
|
||||
}, [currentStudentId, startDraw, isDrawing, drawResult]) // 修复:移除showCountdown依赖,避免重复触发
|
||||
|
||||
// 处理学号输入变化
|
||||
const handleStudentIdChange = (studentId: string) => {
|
||||
console.log('学号输入变化:', studentId)
|
||||
// 学号变化时的处理逻辑,如果需要的话
|
||||
}
|
||||
|
||||
// 处理倒计时开始
|
||||
const handleCountdownStart = async () => {
|
||||
console.log('🎯 倒计时开始')
|
||||
console.log('当前状态 - showCountdown:', showCountdown, 'isDrawing:', isDrawing)
|
||||
|
||||
try {
|
||||
const canStart = await handleDrawStart()
|
||||
console.log('🎯 handleDrawStart返回结果:', canStart)
|
||||
|
||||
if (canStart) {
|
||||
console.log('✅ 验证通过,只设置showAnimation为true,不设置showCountdown')
|
||||
// 修复:只设置showAnimation,不设置showCountdown,避免重复倒计时
|
||||
setShowAnimation(true) // 显示抽奖动画,动画内部会处理倒计时
|
||||
console.log('✅ 状态更新后 - showAnimation: true')
|
||||
return true
|
||||
} else {
|
||||
console.log('❌ 验证失败,无法开始倒计时')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ handleCountdownStart出错:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理清空结果
|
||||
const handleClearResult = () => {
|
||||
console.log('清空抽奖结果')
|
||||
resetDraw()
|
||||
}
|
||||
|
||||
// 处理密码提交
|
||||
const handlePasswordSubmit = async () => {
|
||||
console.log('验证后台管理密码:', password);
|
||||
try {
|
||||
const lotteryService = LotteryService.getInstance();
|
||||
const isValid = await lotteryService.validateAdminPassword(password);
|
||||
|
||||
if (isValid) {
|
||||
setShowPasswordModal(false)
|
||||
setPassword('')
|
||||
setPasswordError('')
|
||||
navigate('/admin')
|
||||
} else {
|
||||
setPasswordError('后台管理密码错误,请重试!')
|
||||
setPassword('')
|
||||
}
|
||||
} catch (error) {
|
||||
setPasswordError('验证失败,请重试!')
|
||||
setPassword('')
|
||||
}
|
||||
}
|
||||
|
||||
// 合并抽奖结果监听和事件订阅,减少useEffect数量
|
||||
React.useEffect(() => {
|
||||
// 监听抽奖结果变化,向测试页面发送结果
|
||||
if (drawResult) {
|
||||
try {
|
||||
window.parent.postMessage({
|
||||
type: 'LOTTERY_RESULT',
|
||||
result: drawResult
|
||||
}, '*');
|
||||
} catch (error) {
|
||||
// 静默处理错误
|
||||
}
|
||||
}
|
||||
|
||||
// 设置事件监听
|
||||
const eventService = EventService.getInstance();
|
||||
|
||||
// 监听奖品数据更新
|
||||
const unsubscribePrizes = eventService.subscribe(EVENT_TYPES.PRIZES_UPDATED, () => {
|
||||
// 奖品数据通过store自动更新,无需手动处理
|
||||
});
|
||||
|
||||
// 监听抽奖记录更新
|
||||
const unsubscribeRecords = eventService.subscribe(EVENT_TYPES.RECORDS_UPDATED, () => {
|
||||
// 记录更新可能影响用户抽奖次数限制,但这里主要用于日志记录
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribePrizes();
|
||||
unsubscribeRecords();
|
||||
};
|
||||
}, [drawResult]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 relative">
|
||||
{/* 顶部导航栏 */}
|
||||
<div className="absolute top-4 right-4 flex gap-2 z-10">
|
||||
|
||||
<button
|
||||
onClick={() => setShowPasswordModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 text-gray-200 rounded-lg shadow-md hover:shadow-lg hover:bg-gray-600 transition-all duration-200"
|
||||
>
|
||||
<Settings size={18} />
|
||||
后台管理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => navigate('/records')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-700 text-gray-200 rounded-lg shadow-md hover:shadow-lg hover:bg-gray-600 transition-all duration-200"
|
||||
>
|
||||
<FileText size={18} />
|
||||
记录查询
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
logout()
|
||||
navigate('/login')
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-600 text-white rounded-lg shadow-md hover:shadow-lg hover:bg-red-700 transition-all duration-200"
|
||||
>
|
||||
<LogOut size={18} />
|
||||
退出登录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="flex h-screen">
|
||||
{/* 左侧区域 - 奖项展示 */}
|
||||
<div className="w-[22.22%] p-4">
|
||||
<PrizeDisplay prizes={prizes || []} />
|
||||
</div>
|
||||
|
||||
{/* 中间结果展示 - 增大到45% */}
|
||||
<div className="w-[45%] h-full p-4">
|
||||
<ErrorBoundary>
|
||||
<ResultDisplay
|
||||
drawResult={drawResult}
|
||||
onRestart={resetDraw}
|
||||
isDrawing={isDrawing}
|
||||
showCountdown={showCountdown}
|
||||
onCountdownComplete={handleCountdownComplete}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
|
||||
{/* 右侧抽奖区域 - 缩小到32.78% */}
|
||||
<div className="w-[32.78%] h-full p-4">
|
||||
<div className="bg-gray-800/50 backdrop-blur-md rounded-2xl p-8 h-full flex flex-col border border-gray-700/50">
|
||||
<div className="flex-1 flex flex-col justify-center items-center">
|
||||
<ScreenKeyboard
|
||||
onStudentIdChange={handleStudentIdChange}
|
||||
onDrawStart={handleDrawStart}
|
||||
onCountdownStart={handleCountdownStart}
|
||||
onClearResult={handleClearResult}
|
||||
/>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{errorMessage && (
|
||||
<div className="mt-4 p-4 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-300 text-center font-medium">{errorMessage}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 抽奖动画浮窗 */}
|
||||
<LotteryAnimation
|
||||
isVisible={showAnimation}
|
||||
onComplete={() => {
|
||||
console.log('🎲 LotteryAnimation onComplete 被调用,隐藏动画框');
|
||||
setShowAnimation(false);
|
||||
|
||||
// 抽奖完成后,触发奖项数据更新
|
||||
console.log('🎲 抽奖完成,触发奖项数据更新');
|
||||
const eventService = EventService.getInstance();
|
||||
eventService.emit(EVENT_TYPES.PRIZES_UPDATED, {
|
||||
updatedPrizes: null, // 触发重新加载
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}}
|
||||
onCountdownComplete={handleCountdownComplete}
|
||||
result={drawResult}
|
||||
/>
|
||||
|
||||
{/* 密码输入弹窗 */}
|
||||
{showPasswordModal && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
<div className="bg-gray-800 rounded-2xl p-6 w-96 border border-gray-700 shadow-2xl">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-semibold text-white">后台管理验证</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordModal(false)
|
||||
setPassword('')
|
||||
setPasswordError('')
|
||||
}}
|
||||
className="text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-300 text-sm font-medium mb-2">
|
||||
请输入后台管理密码:
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
setPasswordError('')
|
||||
}}
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handlePasswordSubmit()
|
||||
}
|
||||
}}
|
||||
className="w-full px-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
placeholder="输入密码"
|
||||
autoFocus
|
||||
/>
|
||||
{passwordError && (
|
||||
<p className="text-red-400 text-sm mt-2">{passwordError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPasswordModal(false)
|
||||
setPassword('')
|
||||
setPasswordError('')
|
||||
}}
|
||||
className="flex-1 px-4 py-2 bg-gray-600 text-gray-200 rounded-lg hover:bg-gray-500 transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePasswordSubmit}
|
||||
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Lottery
|
||||
120
src/pages/NetworkTest.tsx
Normal file
120
src/pages/NetworkTest.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import PrizeDisplay from '../components/PrizeDisplay';
|
||||
|
||||
const NetworkTest: React.FC = () => {
|
||||
const { prizes, loadPrizes } = useAppStore();
|
||||
const [networkInfo, setNetworkInfo] = useState({
|
||||
url: '',
|
||||
hostname: '',
|
||||
protocol: '',
|
||||
port: '',
|
||||
userAgent: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 获取网络信息
|
||||
setNetworkInfo({
|
||||
url: window.location.href,
|
||||
hostname: window.location.hostname,
|
||||
protocol: window.location.protocol,
|
||||
port: window.location.port,
|
||||
userAgent: navigator.userAgent
|
||||
});
|
||||
|
||||
// 加载奖项数据
|
||||
console.log('NetworkTest - 开始加载奖项数据');
|
||||
loadPrizes();
|
||||
}, [loadPrizes]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 p-4">
|
||||
<div className="max-w-6xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-white text-center mb-8">
|
||||
网络环境测试页面
|
||||
</h1>
|
||||
|
||||
{/* 网络信息显示 */}
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-8">
|
||||
<h2 className="text-xl font-bold text-white mb-4">网络环境信息</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div className="text-white/80">
|
||||
<strong>完整URL:</strong> {networkInfo.url}
|
||||
</div>
|
||||
<div className="text-white/80">
|
||||
<strong>主机名:</strong> {networkInfo.hostname}
|
||||
</div>
|
||||
<div className="text-white/80">
|
||||
<strong>协议:</strong> {networkInfo.protocol}
|
||||
</div>
|
||||
<div className="text-white/80">
|
||||
<strong>端口:</strong> {networkInfo.port || '默认'}
|
||||
</div>
|
||||
<div className="text-white/80 col-span-full">
|
||||
<strong>用户代理:</strong> {networkInfo.userAgent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 奖项数据状态 */}
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-8">
|
||||
<h2 className="text-xl font-bold text-white mb-4">奖项数据状态</h2>
|
||||
<div className="text-white/80">
|
||||
<p><strong>奖项总数:</strong> {prizes.length}</p>
|
||||
<p><strong>活跃奖项:</strong> {prizes.filter(p => p.is_active).length}</p>
|
||||
<p><strong>数据加载状态:</strong> {prizes.length > 0 ? '✅ 已加载' : '❌ 未加载或为空'}</p>
|
||||
</div>
|
||||
|
||||
{/* 详细奖项信息 */}
|
||||
{prizes.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<h3 className="text-lg font-semibold text-white mb-2">奖项详情:</h3>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{prizes.map((prize, index) => (
|
||||
<div key={prize.id} className="text-xs text-white/70 bg-white/5 p-2 rounded">
|
||||
<span className="font-medium">{index + 1}. {prize.name}</span>
|
||||
<span className="ml-2">({prize.is_active ? '活跃' : '非活跃'})</span>
|
||||
<span className="ml-2">剩余: {prize.remainingQuantity}/{prize.totalQuantity}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* PrizeDisplay组件测试 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-4">PrizeDisplay 组件渲染测试</h2>
|
||||
<PrizeDisplay prizes={prizes} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">调试信息</h2>
|
||||
<div className="text-sm text-white/80 space-y-2">
|
||||
<p>请打开浏览器开发者工具查看控制台日志</p>
|
||||
<p>关键日志前缀:</p>
|
||||
<ul className="list-disc list-inside ml-4 space-y-1">
|
||||
<li>PrizeDisplay 渲染 - 组件渲染信息</li>
|
||||
<li>Store loadPrizes - 状态管理信息</li>
|
||||
<li>SharedDatabase getAllPrizes - 数据库操作信息</li>
|
||||
</ul>
|
||||
|
||||
<div className="mt-4 p-3 bg-white/5 rounded">
|
||||
<p className="font-medium">测试步骤:</p>
|
||||
<ol className="list-decimal list-inside ml-4 space-y-1 mt-2">
|
||||
<li>在localhost:5173访问此页面</li>
|
||||
<li>在192.168.1.42:5173访问此页面</li>
|
||||
<li>对比两个环境下的显示效果和控制台日志</li>
|
||||
<li>检查PrizeDisplay组件是否正常显示</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkTest;
|
||||
307
src/pages/Records.tsx
Normal file
307
src/pages/Records.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { Search, Download, ArrowLeft } from 'lucide-react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import ApiService from '../services/apiService'
|
||||
import EventService, { EVENT_TYPES } from '../services/eventService'
|
||||
import type { PrizeConfig } from '../database/database'
|
||||
import type { LotteryRecord } from '../types'
|
||||
|
||||
// 为了兼容现有代码,创建DrawRecord类型别名
|
||||
type DrawRecord = LotteryRecord & { is_winner: boolean }
|
||||
|
||||
// 页面组件属性接口 - 使用Record类型避免空接口警告
|
||||
type RecordsPageProps = Record<string, never>
|
||||
|
||||
const Records: React.FC<RecordsPageProps> = () => {
|
||||
const navigate = useNavigate()
|
||||
const [records, setRecords] = useState<DrawRecord[]>([])
|
||||
const [prizes, setPrizes] = useState<PrizeConfig[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [selectedPrize, setSelectedPrize] = useState<string>('')
|
||||
const [dateRange, setDateRange] = useState({ start: '', end: '' })
|
||||
const [stats, setStats] = useState({
|
||||
totalDraws: 0,
|
||||
totalWins: 0,
|
||||
winRate: 0,
|
||||
prizeStats: [] as { prize_name: string; count: number }[]
|
||||
})
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const api = ApiService.getInstance()
|
||||
const [recordsData, prizesData] = await Promise.all([
|
||||
api.getAllRecords(),
|
||||
api.getAllPrizes()
|
||||
])
|
||||
|
||||
// 转换记录数据格式
|
||||
const formattedRecords: DrawRecord[] = recordsData.map(record => ({
|
||||
...record,
|
||||
is_winner: record.prize_id !== null
|
||||
}))
|
||||
|
||||
setRecords(formattedRecords)
|
||||
setPrizes(prizesData)
|
||||
calculateStats(formattedRecords)
|
||||
} catch (error) {
|
||||
console.error('Records页面 - 加载数据失败:', error)
|
||||
// 设置空数据以避免界面错误
|
||||
setRecords([])
|
||||
setPrizes([])
|
||||
setStats({
|
||||
totalDraws: 0,
|
||||
totalWins: 0,
|
||||
winRate: 0,
|
||||
prizeStats: []
|
||||
})
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
|
||||
// 监听记录更新事件,实现智能刷新
|
||||
const eventService = EventService.getInstance();
|
||||
const unsubscribeRecords = eventService.subscribe(EVENT_TYPES.RECORDS_UPDATED, () => {
|
||||
console.log('Records页面 - 收到记录更新事件,重新加载数据');
|
||||
loadData();
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribeRecords();
|
||||
};
|
||||
}, [loadData])
|
||||
|
||||
const calculateStats = (recordsData: DrawRecord[]) => {
|
||||
const totalDraws = recordsData.length
|
||||
const totalWins = recordsData.filter(r => r.is_winner).length
|
||||
const winRate = totalDraws > 0 ? (totalWins / totalDraws) * 100 : 0
|
||||
|
||||
const prizeStats = recordsData
|
||||
.filter(r => r.is_winner && r.prize_name)
|
||||
.reduce((acc, record) => {
|
||||
const existing = acc.find(p => p.prize_name === record.prize_name)
|
||||
if (existing) {
|
||||
existing.count++
|
||||
} else {
|
||||
acc.push({ prize_name: record.prize_name!, count: 1 })
|
||||
}
|
||||
return acc
|
||||
}, [] as { prize_name: string; count: number }[])
|
||||
|
||||
setStats({ totalDraws, totalWins, winRate, prizeStats })
|
||||
}
|
||||
|
||||
// 使用useMemo优化过滤逻辑,避免重复计算
|
||||
const filteredRecords = useMemo(() => {
|
||||
return records.filter(record => {
|
||||
const matchesSearch = searchTerm === '' ||
|
||||
record.student_id.includes(searchTerm) ||
|
||||
(record.prize_name && record.prize_name.toLowerCase().includes(searchTerm.toLowerCase()))
|
||||
|
||||
const matchesPrize = selectedPrize === '' || record.prize_name === selectedPrize
|
||||
|
||||
const matchesDate = (!dateRange.start || new Date(record.draw_time) >= new Date(dateRange.start)) &&
|
||||
(!dateRange.end || new Date(record.draw_time) <= new Date(dateRange.end))
|
||||
|
||||
return matchesSearch && matchesPrize && matchesDate
|
||||
})
|
||||
}, [records, searchTerm, selectedPrize, dateRange])
|
||||
|
||||
const exportRecords = () => {
|
||||
const csvContent = [
|
||||
['学号', '抽奖时间', '是否中奖', '奖项名称'].join(','),
|
||||
...filteredRecords.map(record => [
|
||||
record.student_id,
|
||||
new Date(record.draw_time).toLocaleString(),
|
||||
record.is_winner ? '是' : '否',
|
||||
record.prize_name || '未中奖'
|
||||
].join(','))
|
||||
].join('\n')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const link = document.createElement('a')
|
||||
const url = URL.createObjectURL(blob)
|
||||
link.href = url
|
||||
link.download = `抽奖记录_${new Date().toISOString().split('T')[0]}.csv`
|
||||
link.click()
|
||||
|
||||
// 清理URL对象,防止内存泄漏
|
||||
setTimeout(() => {
|
||||
URL.revokeObjectURL(url)
|
||||
}, 100)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-gray-300">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gray-900 to-gray-800 p-6">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* 头部 */}
|
||||
<div className="bg-gray-800 rounded-lg shadow-md p-6 mb-6 border border-gray-700">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-2 px-3 py-2 text-gray-300 hover:text-gray-100 transition-colors rounded-lg hover:bg-gray-700"
|
||||
>
|
||||
<ArrowLeft size={20} />
|
||||
返回首页
|
||||
</button>
|
||||
<h1 className="text-3xl font-bold text-gray-100">抽奖记录查询</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={exportRecords}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<Download size={20} />
|
||||
导出记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 统计信息 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-gray-700 p-4 rounded-lg border border-gray-600">
|
||||
<h3 className="text-sm font-medium text-blue-400 mb-1">总抽奖次数</h3>
|
||||
<p className="text-2xl font-bold text-blue-300">{stats.totalDraws}</p>
|
||||
</div>
|
||||
<div className="bg-gray-700 p-4 rounded-lg border border-gray-600">
|
||||
<h3 className="text-sm font-medium text-green-400 mb-1">中奖次数</h3>
|
||||
<p className="text-2xl font-bold text-green-300">{stats.totalWins}</p>
|
||||
</div>
|
||||
<div className="bg-gray-700 p-4 rounded-lg border border-gray-600">
|
||||
<h3 className="text-sm font-medium text-purple-400 mb-1">中奖率</h3>
|
||||
<p className="text-2xl font-bold text-purple-300">{stats.winRate.toFixed(1)}%</p>
|
||||
</div>
|
||||
<div className="bg-gray-700 p-4 rounded-lg border border-gray-600">
|
||||
<h3 className="text-sm font-medium text-orange-400 mb-1">奖项种类</h3>
|
||||
<p className="text-2xl font-bold text-orange-300">{stats.prizeStats.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 筛选条件 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索学号或奖项名称"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={selectedPrize}
|
||||
onChange={(e) => setSelectedPrize(e.target.value)}
|
||||
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">所有奖项</option>
|
||||
{prizes.map(prize => (
|
||||
<option key={prize.id} value={prize.prize_name}>{prize.prize_name}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.start}
|
||||
onChange={(e) => setDateRange({ ...dateRange, start: e.target.value })}
|
||||
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={dateRange.end}
|
||||
onChange={(e) => setDateRange({ ...dateRange, end: e.target.value })}
|
||||
className="px-3 py-2 bg-gray-700 border border-gray-600 rounded-lg text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 记录列表 */}
|
||||
<div className="bg-gray-800 rounded-lg shadow-md overflow-hidden border border-gray-700">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-600">
|
||||
<thead className="bg-gray-700">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
学号
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
抽奖时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
结果
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-300 uppercase tracking-wider">
|
||||
奖项名称
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-gray-800 divide-y divide-gray-600">
|
||||
{filteredRecords.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-6 py-12 text-center text-gray-400">
|
||||
暂无记录
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredRecords.map((record) => (
|
||||
<tr key={record.id} className="hover:bg-gray-700">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-100">
|
||||
{record.student_id}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
{new Date(record.draw_time).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
<span className={`px-2 py-1 text-xs rounded-full ${
|
||||
record.is_winner
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{record.is_winner ? '中奖' : '未中奖'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-300">
|
||||
{record.prize_name || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 奖项统计 */}
|
||||
{stats.prizeStats.length > 0 && (
|
||||
<div className="bg-gray-800 rounded-lg shadow-md p-6 mt-6 border border-gray-700">
|
||||
<h2 className="text-xl font-bold text-gray-100 mb-4">奖项统计</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{stats.prizeStats.map((stat, index) => (
|
||||
<div key={index} className="bg-gray-700 p-4 rounded-lg border border-gray-600">
|
||||
<h3 className="text-sm font-medium text-gray-300 mb-1">{stat.prize_name}</h3>
|
||||
<p className="text-xl font-bold text-gray-100">{stat.count} 次</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Records
|
||||
105
src/pages/SimpleNetworkTest.tsx
Normal file
105
src/pages/SimpleNetworkTest.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAppStore } from '../store';
|
||||
import PrizeDisplay from '../components/PrizeDisplay';
|
||||
|
||||
const SimpleNetworkTest: React.FC = () => {
|
||||
const { prizes, loadPrizes } = useAppStore();
|
||||
const [networkInfo, setNetworkInfo] = useState({
|
||||
url: '',
|
||||
host: '',
|
||||
userAgent: '',
|
||||
online: navigator.onLine
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// 获取网络信息
|
||||
setNetworkInfo({
|
||||
url: window.location.href,
|
||||
host: window.location.host,
|
||||
userAgent: navigator.userAgent,
|
||||
online: navigator.onLine
|
||||
});
|
||||
|
||||
// 强制输出调试信息到控制台
|
||||
console.log('=== 网络测试页面 ===');
|
||||
console.log('当前URL:', window.location.href);
|
||||
console.log('当前主机:', window.location.host);
|
||||
console.log('网络状态:', navigator.onLine ? '在线' : '离线');
|
||||
console.log('用户代理:', navigator.userAgent);
|
||||
|
||||
// 加载奖项数据
|
||||
console.log('开始加载奖项数据...');
|
||||
loadPrizes().then(() => {
|
||||
console.log('奖项数据加载完成');
|
||||
}).catch(error => {
|
||||
console.error('奖项数据加载失败:', error);
|
||||
});
|
||||
}, [loadPrizes]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('奖项数据更新:', prizes);
|
||||
console.log('活跃奖项数量:', prizes.filter(p => p.is_active).length);
|
||||
}, [prizes]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-purple-900 via-blue-900 to-indigo-900 p-4">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<h1 className="text-3xl font-bold text-white text-center mb-8">
|
||||
网络环境测试页面
|
||||
</h1>
|
||||
|
||||
{/* 网络信息显示 */}
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">网络环境信息</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-white">
|
||||
<div>
|
||||
<strong>当前URL:</strong>
|
||||
<div className="text-sm text-white/80 break-all">{networkInfo.url}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>主机地址:</strong>
|
||||
<div className="text-sm text-white/80">{networkInfo.host}</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>网络状态:</strong>
|
||||
<div className={`text-sm ${networkInfo.online ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{networkInfo.online ? '在线' : '离线'}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<strong>奖项数量:</strong>
|
||||
<div className="text-sm text-white/80">
|
||||
总计: {prizes.length}, 活跃: {prizes.filter(p => p.is_active).length}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PrizeDisplay 组件测试 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-white mb-4">PrizeDisplay 组件渲染测试</h2>
|
||||
<PrizeDisplay prizes={prizes} />
|
||||
</div>
|
||||
|
||||
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6">
|
||||
<h2 className="text-xl font-bold text-white mb-4">调试信息</h2>
|
||||
<div className="text-white text-sm space-y-2">
|
||||
<div>请打开浏览器开发者工具查看控制台输出</div>
|
||||
<div>检查是否有错误或警告信息</div>
|
||||
<div>验证奖项数据是否正确加载</div>
|
||||
<div className="mt-4 p-3 bg-white/5 rounded">
|
||||
<strong>原始奖项数据:</strong>
|
||||
<pre className="text-xs mt-2 overflow-auto max-h-40">
|
||||
{JSON.stringify(prizes, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SimpleNetworkTest;
|
||||
453
src/services/apiService.ts
Normal file
453
src/services/apiService.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
// API服务 - 统一的后端数据接口
|
||||
// 替换IndexedDB,使用后端SQLite数据库确保数据一致性
|
||||
|
||||
import type { SystemConfig, PrizeConfig, Student } from '../database/database';
|
||||
import type { LotteryRecord } from '../types';
|
||||
|
||||
// 动态获取API基础URL,支持不同访问方式
|
||||
const getApiBaseUrl = () => {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
return '';
|
||||
}
|
||||
// 开发环境下,使用相对路径,通过Vite代理转发到后端
|
||||
const apiUrl = '';
|
||||
console.log('API Base URL:', apiUrl || 'relative path (via proxy)');
|
||||
return apiUrl;
|
||||
};
|
||||
|
||||
const API_BASE_URL = getApiBaseUrl();
|
||||
|
||||
class ApiService {
|
||||
private static instance: ApiService;
|
||||
|
||||
private constructor() {
|
||||
// 单例模式,构造函数私有
|
||||
}
|
||||
|
||||
public static getInstance(): ApiService {
|
||||
if (!ApiService.instance) {
|
||||
ApiService.instance = new ApiService();
|
||||
}
|
||||
return ApiService.instance;
|
||||
}
|
||||
|
||||
private activeRequests = new Map<string, AbortController>();
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const url = `/api${endpoint}`;
|
||||
console.log(`🌐 API请求: ${url}`);
|
||||
|
||||
// 只在请求方法不同时才取消之前的请求,避免取消相同的GET请求
|
||||
const requestKey = `${endpoint}_${options.method || 'GET'}`;
|
||||
if (this.activeRequests.has(requestKey)) {
|
||||
const prevController = this.activeRequests.get(requestKey);
|
||||
if (prevController && (options.method && options.method !== 'GET')) {
|
||||
prevController.abort();
|
||||
console.log('🔄 取消之前的请求:', requestKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建新的 AbortController
|
||||
const controller = new AbortController();
|
||||
this.activeRequests.set(requestKey, controller);
|
||||
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10秒超时
|
||||
|
||||
const config: RequestInit = {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
signal: controller.signal,
|
||||
...options,
|
||||
};
|
||||
|
||||
try {
|
||||
// 检查网络连接状态
|
||||
if (!navigator.onLine) {
|
||||
clearTimeout(timeoutId);
|
||||
this.activeRequests.delete(endpoint);
|
||||
throw new Error('网络连接已断开,请检查网络设置');
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
clearTimeout(timeoutId);
|
||||
this.activeRequests.delete(requestKey);
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = `HTTP ${response.status}`;
|
||||
|
||||
switch (response.status) {
|
||||
case 404:
|
||||
errorMessage = 'API接口不存在,请检查后端服务器配置';
|
||||
break;
|
||||
case 500:
|
||||
errorMessage = '服务器内部错误,请稍后重试';
|
||||
break;
|
||||
case 503:
|
||||
errorMessage = '服务器暂时不可用,请稍后重试';
|
||||
break;
|
||||
case 0:
|
||||
errorMessage = '无法连接到服务器,请检查后端服务器是否运行';
|
||||
break;
|
||||
default:
|
||||
errorMessage = `请求失败 (${response.status}): ${response.statusText}`;
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data && data.error) {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
|
||||
console.log(`✅ API请求成功: ${endpoint}`);
|
||||
return data;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
this.activeRequests.delete(requestKey);
|
||||
console.error(`❌ API请求失败 ${endpoint}:`, error);
|
||||
|
||||
// 处理取消的请求(不抛出错误)
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
console.log('🚫 API请求已取消:', endpoint);
|
||||
throw new Error('请求已取消');
|
||||
}
|
||||
|
||||
// 处理网络错误
|
||||
if (error instanceof TypeError && (error.message.includes('fetch') || error.message.includes('Failed to fetch'))) {
|
||||
throw new Error('无法连接到服务器,请确认后端服务器正在运行');
|
||||
}
|
||||
|
||||
// 处理ERR_NETWORK错误
|
||||
if (error instanceof Error && error.message.includes('ERR_NETWORK')) {
|
||||
throw new Error('网络连接错误,请检查网络设置');
|
||||
}
|
||||
|
||||
// 处理超时错误
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
throw new Error('请求超时,请检查网络连接或稍后重试');
|
||||
}
|
||||
|
||||
// 处理CORS错误
|
||||
if (error instanceof TypeError && error.message.includes('CORS')) {
|
||||
throw new Error('跨域请求被阻止,请检查服务器CORS配置');
|
||||
}
|
||||
|
||||
// 确保错误对象有正确的结构
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new Error(String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 系统配置相关方法
|
||||
public async getSystemConfig(): Promise<SystemConfig | null> {
|
||||
const response = await this.request<{ success: boolean; data: SystemConfig | null }>('/system-config');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
public async updateSystemConfig(config: Partial<SystemConfig>): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>('/system-config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(config),
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
public async verifyAdminPassword(password: string): Promise<boolean> {
|
||||
try {
|
||||
const config = await this.getSystemConfig();
|
||||
return config ? config.admin_password === password : false;
|
||||
} catch (error) {
|
||||
console.error('验证管理员密码失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async changeAdminPassword(newPassword: string): Promise<boolean> {
|
||||
return this.updateSystemConfig({ admin_password: newPassword });
|
||||
}
|
||||
|
||||
public async validateLoginPassword(password: string): Promise<boolean> {
|
||||
try {
|
||||
const config = await this.getSystemConfig();
|
||||
return config ? config.login_password === password : false;
|
||||
} catch (error) {
|
||||
console.error('验证登录密码失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async updateLoginPassword(newPassword: string): Promise<boolean> {
|
||||
return this.updateSystemConfig({ login_password: newPassword });
|
||||
}
|
||||
|
||||
public async validateCurrentAdminPassword(password: string): Promise<boolean> {
|
||||
try {
|
||||
const config = await this.getSystemConfig();
|
||||
return config ? config.admin_password === password : false;
|
||||
} catch (error) {
|
||||
console.error('验证管理员密码失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async updateAdminPassword(newPassword: string): Promise<boolean> {
|
||||
return this.updateSystemConfig({ admin_password: newPassword });
|
||||
}
|
||||
|
||||
// 奖项相关方法
|
||||
public async getAllPrizes(): Promise<PrizeConfig[]> {
|
||||
const response = await this.request<{ success: boolean; data: PrizeConfig[] }>('/prizes');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
public async addPrize(prize: Omit<PrizeConfig, 'id' | 'created_at'>): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>('/prizes', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(prize),
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
public async updatePrize(id: string, prize: Partial<PrizeConfig>): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>(`/prizes/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(prize),
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
public async deletePrize(id: string): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>(`/prizes/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
// 学生相关方法
|
||||
public async getStudent(studentId: string): Promise<Student | null> {
|
||||
return this.request<Student | null>(`/students/${studentId}`);
|
||||
}
|
||||
|
||||
public async updateStudentDrawCount(studentId: string, drawCount: number): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>(`/students/${studentId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ draw_count: drawCount }),
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
public async createOrUpdateStudent(studentId: string): Promise<boolean> {
|
||||
// 通过更新抽奖次数来创建或更新学生记录
|
||||
const student = await this.getStudent(studentId);
|
||||
const currentDrawCount = student ? student.draw_count + 1 : 1;
|
||||
return this.updateStudentDrawCount(studentId, currentDrawCount);
|
||||
}
|
||||
|
||||
public async getStudentRecords(studentId: string): Promise<LotteryRecord[]> {
|
||||
// 从抽奖记录中筛选特定学生的记录
|
||||
const allRecords = await this.getAllRecords();
|
||||
return allRecords.filter(record => record.student_id === studentId);
|
||||
}
|
||||
|
||||
public async updatePrizeQuantity(prizeId: string, quantity: number): Promise<boolean> {
|
||||
return this.updatePrize(prizeId, { remaining_quantity: quantity });
|
||||
}
|
||||
|
||||
public async createLotteryRecord(studentId: string, prizeId: string, prizeName: string, prizeLevel: number): Promise<LotteryRecord> {
|
||||
const record = {
|
||||
student_id: studentId,
|
||||
prize_id: prizeId,
|
||||
prize_name: prizeName,
|
||||
prize_level: prizeLevel,
|
||||
is_synced: 1,
|
||||
cache_data: null
|
||||
};
|
||||
|
||||
const result = await this.request<LotteryRecord>('/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 抽奖记录相关方法
|
||||
public async getAllRecords(): Promise<LotteryRecord[]> {
|
||||
const response = await this.request<{ success: boolean; data: LotteryRecord[] }>('/records');
|
||||
return response.data || [];
|
||||
}
|
||||
|
||||
public async getLotteryRecords(limit?: number): Promise<LotteryRecord[]> {
|
||||
const params = limit ? `?limit=${limit}` : '';
|
||||
return this.request<LotteryRecord[]>(`/records${params}`);
|
||||
}
|
||||
|
||||
public async addRecord(record: Omit<LotteryRecord, 'id' | 'draw_time'>): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>('/records', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(record),
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
public async clearAllRecords(): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>('/records', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
public async clearAllLotteryRecords(): Promise<boolean> {
|
||||
return this.clearAllRecords();
|
||||
}
|
||||
|
||||
// 系统重置
|
||||
public async resetSystem(): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>('/reset', {
|
||||
method: 'POST',
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
public async resetSystemCompletely(): Promise<boolean> {
|
||||
return this.resetSystem();
|
||||
}
|
||||
|
||||
// 密码重置方法
|
||||
public async resetAdminPasswordToDefault(): Promise<boolean> {
|
||||
return this.updateSystemConfig({ admin_password: '123456' });
|
||||
}
|
||||
|
||||
public async resetLoginPasswordToDefault(): Promise<boolean> {
|
||||
return this.updateSystemConfig({ login_password: '123456' });
|
||||
}
|
||||
|
||||
// 数据库重置
|
||||
public async forceResetDatabase(): Promise<boolean> {
|
||||
const result = await this.request<{ success: boolean }>('/force-reset', {
|
||||
method: 'POST',
|
||||
});
|
||||
return result.success;
|
||||
}
|
||||
|
||||
// 模拟抽奖
|
||||
public async simulateLottery(times: number): Promise<any> {
|
||||
const result = await this.request<any>('/simulate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ times }),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 抽奖逻辑
|
||||
public async drawPrize(studentId: string): Promise<{ prize: PrizeConfig; record: LotteryRecord } | null> {
|
||||
try {
|
||||
// 获取学生信息
|
||||
const student = await this.getStudent(studentId);
|
||||
const config = await this.getSystemConfig();
|
||||
|
||||
if (!config) {
|
||||
throw new Error('系统配置未找到');
|
||||
}
|
||||
|
||||
// 检查抽奖次数限制
|
||||
const currentDrawCount = student ? student.draw_count : 0;
|
||||
if (currentDrawCount >= config.max_draw_times) {
|
||||
throw new Error('已达到最大抽奖次数');
|
||||
}
|
||||
|
||||
// 获取可用奖项
|
||||
const prizes = await this.getAllPrizes();
|
||||
const availablePrizes = prizes.filter(p => p.is_active && p.remaining_quantity > 0);
|
||||
|
||||
if (availablePrizes.length === 0) {
|
||||
throw new Error('没有可用的奖项');
|
||||
}
|
||||
|
||||
// 抽奖算法
|
||||
const random = Math.random();
|
||||
let cumulativeProbability = 0;
|
||||
let selectedPrize: PrizeConfig | null = null;
|
||||
|
||||
for (const prize of availablePrizes) {
|
||||
cumulativeProbability += prize.probability;
|
||||
if (random <= cumulativeProbability) {
|
||||
selectedPrize = prize;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有选中任何奖项,选择最后一个(通常是谢谢参与)
|
||||
if (!selectedPrize) {
|
||||
selectedPrize = availablePrizes[availablePrizes.length - 1];
|
||||
}
|
||||
|
||||
// 更新奖项剩余数量
|
||||
await this.updatePrize(selectedPrize.id, {
|
||||
remaining_quantity: selectedPrize.remaining_quantity - 1
|
||||
});
|
||||
|
||||
// 更新学生抽奖次数
|
||||
await this.updateStudentDrawCount(studentId, currentDrawCount + 1);
|
||||
|
||||
// 创建抽奖记录
|
||||
const record: Omit<LotteryRecord, 'id' | 'draw_time'> = {
|
||||
student_id: studentId,
|
||||
prize_id: selectedPrize.id,
|
||||
prize_name: selectedPrize.prize_name,
|
||||
prize_level: selectedPrize.prize_level,
|
||||
is_synced: 1,
|
||||
cache_data: null
|
||||
};
|
||||
|
||||
await this.addRecord(record);
|
||||
|
||||
// 返回完整的记录对象
|
||||
const fullRecord: LotteryRecord = {
|
||||
...record,
|
||||
id: this.generateId(),
|
||||
draw_time: new Date().toISOString()
|
||||
};
|
||||
|
||||
return {
|
||||
prize: {
|
||||
...selectedPrize,
|
||||
remaining_quantity: selectedPrize.remaining_quantity - 1
|
||||
},
|
||||
record: fullRecord
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('抽奖失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private generateId(): string {
|
||||
return Math.random().toString(36).substr(2, 9) + Date.now().toString(36);
|
||||
}
|
||||
|
||||
// 清理方法,防止内存泄漏
|
||||
public cleanup(): void {
|
||||
// 取消所有活跃的请求
|
||||
this.activeRequests.forEach((controller, requestKey) => {
|
||||
controller.abort();
|
||||
console.log('🧹 清理请求:', requestKey);
|
||||
});
|
||||
this.activeRequests.clear();
|
||||
}
|
||||
|
||||
// 获取活跃请求数量(用于调试)
|
||||
public getActiveRequestsCount(): number {
|
||||
return this.activeRequests.size;
|
||||
}
|
||||
}
|
||||
|
||||
export default ApiService;
|
||||
export { PrizeConfig, SystemConfig, LotteryRecord, Student };
|
||||
268
src/services/authService.ts
Normal file
268
src/services/authService.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
|
||||
// JWT payload 接口
|
||||
interface JWTPayload {
|
||||
userId: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
}
|
||||
|
||||
// 认证服务类
|
||||
class AuthService {
|
||||
private static instance: AuthService;
|
||||
private readonly TOKEN_KEY = 'lottery_auth_token';
|
||||
private readonly SECRET_KEY = 'lottery_secret_2025'; // 在生产环境中应该使用环境变量
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): AuthService {
|
||||
if (!AuthService.instance) {
|
||||
AuthService.instance = new AuthService();
|
||||
}
|
||||
return AuthService.instance;
|
||||
}
|
||||
|
||||
// 简单的JWT生成(客户端实现,仅用于演示)
|
||||
private generateToken(userId: string): string {
|
||||
const header = {
|
||||
alg: 'HS256',
|
||||
typ: 'JWT'
|
||||
};
|
||||
|
||||
const payload = {
|
||||
userId,
|
||||
exp: Math.floor(Date.now() / 1000) + (24 * 60 * 60), // 24小时过期
|
||||
iat: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
// 简化的JWT实现(生产环境应使用专业库)
|
||||
const encodedHeader = btoa(JSON.stringify(header));
|
||||
const encodedPayload = btoa(JSON.stringify(payload));
|
||||
const signature = btoa(`${encodedHeader}.${encodedPayload}.${this.SECRET_KEY}`);
|
||||
|
||||
return `${encodedHeader}.${encodedPayload}.${signature}`;
|
||||
}
|
||||
|
||||
// 验证token
|
||||
private verifyToken(token: string): JWTPayload | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(atob(parts[1]));
|
||||
|
||||
// 检查过期时间
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
console.log('Token已过期');
|
||||
return null;
|
||||
}
|
||||
|
||||
return payload as JWTPayload;
|
||||
} catch (error) {
|
||||
console.error('Token验证失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 登录并生成token
|
||||
public async login(password: string): Promise<{ success: boolean; token?: string }> {
|
||||
try {
|
||||
// 调用API服务验证密码
|
||||
const apiServiceModule = await import('./apiService');
|
||||
const ApiService = apiServiceModule.default;
|
||||
const apiService = ApiService.getInstance();
|
||||
const isValid = await apiService.validateLoginPassword(password);
|
||||
|
||||
if (isValid) {
|
||||
const userId = 'admin';
|
||||
const token = this.generateToken(userId);
|
||||
|
||||
// 存储token到localStorage
|
||||
localStorage.setItem(this.TOKEN_KEY, token);
|
||||
|
||||
console.log('登录成功,token已生成并存储');
|
||||
return { success: true, token };
|
||||
}
|
||||
|
||||
console.log('登录失败:密码不正确');
|
||||
return { success: false };
|
||||
} catch (error) {
|
||||
console.error('登录验证过程中发生错误:', error);
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否已登录
|
||||
public isAuthenticated(): boolean {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = this.verifyToken(token);
|
||||
return payload !== null;
|
||||
}
|
||||
|
||||
// 获取存储的token
|
||||
public getToken(): string | null {
|
||||
return localStorage.getItem(this.TOKEN_KEY);
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
public getUserInfo(): { userId: string } | null {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = this.verifyToken(token);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { userId: payload.userId };
|
||||
}
|
||||
|
||||
// 登出
|
||||
public logout(): void {
|
||||
localStorage.removeItem(this.TOKEN_KEY);
|
||||
console.log('已登出,token已清除');
|
||||
}
|
||||
|
||||
// 刷新token(延长有效期)
|
||||
public refreshToken(): boolean {
|
||||
const currentToken = this.getToken();
|
||||
if (!currentToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = this.verifyToken(currentToken);
|
||||
if (!payload) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果token还有效,生成新的token
|
||||
const newToken = this.generateToken(payload.userId);
|
||||
localStorage.setItem(this.TOKEN_KEY, newToken);
|
||||
|
||||
console.log('Token已刷新');
|
||||
return true;
|
||||
}
|
||||
|
||||
// 检查token是否即将过期(1小时内)
|
||||
public isTokenExpiringSoon(): boolean {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = this.verifyToken(token);
|
||||
if (!payload) {
|
||||
return true; // token无效,视为即将过期
|
||||
}
|
||||
|
||||
const oneHourFromNow = Math.floor(Date.now() / 1000) + (60 * 60);
|
||||
return payload.exp < oneHourFromNow;
|
||||
}
|
||||
|
||||
// 自动刷新token(如果即将过期)
|
||||
public autoRefreshToken(): void {
|
||||
if (this.isTokenExpiringSoon() && this.isAuthenticated()) {
|
||||
this.refreshToken();
|
||||
}
|
||||
}
|
||||
|
||||
// 处理token过期的回调
|
||||
private onTokenExpired?: () => void;
|
||||
private tokenMonitorInterval?: NodeJS.Timeout;
|
||||
|
||||
// 设置token过期回调
|
||||
public setTokenExpiredCallback(callback: () => void): void {
|
||||
this.onTokenExpired = callback;
|
||||
}
|
||||
|
||||
// 检查token并处理过期
|
||||
public checkTokenAndHandleExpiry(): boolean {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
this.handleTokenExpired();
|
||||
return false;
|
||||
}
|
||||
|
||||
const payload = this.verifyToken(token);
|
||||
if (!payload) {
|
||||
this.handleTokenExpired();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 如果token即将过期,尝试刷新
|
||||
if (this.isTokenExpiringSoon()) {
|
||||
const refreshed = this.refreshToken();
|
||||
if (!refreshed) {
|
||||
this.handleTokenExpired();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 处理token过期
|
||||
private handleTokenExpired(): void {
|
||||
console.log('Token已过期,清除认证状态');
|
||||
this.logout();
|
||||
|
||||
if (this.onTokenExpired) {
|
||||
this.onTokenExpired();
|
||||
}
|
||||
}
|
||||
|
||||
// 启动token监控
|
||||
public startTokenMonitoring(): void {
|
||||
// 清理之前的定时器
|
||||
this.stopTokenMonitoring();
|
||||
|
||||
// 每分钟检查一次token状态
|
||||
this.tokenMonitorInterval = setInterval(() => {
|
||||
if (this.getToken()) {
|
||||
this.checkTokenAndHandleExpiry();
|
||||
}
|
||||
}, 60 * 1000); // 1分钟
|
||||
}
|
||||
|
||||
// 停止token监控
|
||||
public stopTokenMonitoring(): void {
|
||||
if (this.tokenMonitorInterval) {
|
||||
clearInterval(this.tokenMonitorInterval);
|
||||
this.tokenMonitorInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取token剩余有效时间(秒)
|
||||
public getTokenRemainingTime(): number {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const payload = this.verifyToken(token);
|
||||
if (!payload) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
return Math.max(0, payload.exp - now);
|
||||
}
|
||||
|
||||
// 清理方法,防止内存泄漏
|
||||
public cleanup(): void {
|
||||
this.stopTokenMonitoring();
|
||||
this.onTokenExpired = undefined;
|
||||
console.log('🧹 AuthService 已清理');
|
||||
}
|
||||
}
|
||||
|
||||
export { AuthService };
|
||||
export default AuthService;
|
||||
105
src/services/eventService.ts
Normal file
105
src/services/eventService.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// 事件服务 - 用于全站无感刷新
|
||||
class EventService {
|
||||
private static instance: EventService;
|
||||
private eventTarget: EventTarget;
|
||||
private listeners: Map<string, Set<EventListener>>;
|
||||
|
||||
private constructor() {
|
||||
this.eventTarget = new EventTarget();
|
||||
this.listeners = new Map();
|
||||
}
|
||||
|
||||
public static getInstance(): EventService {
|
||||
if (!EventService.instance) {
|
||||
EventService.instance = new EventService();
|
||||
}
|
||||
return EventService.instance;
|
||||
}
|
||||
|
||||
// 订阅事件
|
||||
public subscribe(eventType: string, callback: EventListener): () => void {
|
||||
if (!this.listeners.has(eventType)) {
|
||||
this.listeners.set(eventType, new Set());
|
||||
}
|
||||
|
||||
this.listeners.get(eventType)!.add(callback);
|
||||
this.eventTarget.addEventListener(eventType, callback);
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
this.unsubscribe(eventType, callback);
|
||||
};
|
||||
}
|
||||
|
||||
// 取消订阅
|
||||
public unsubscribe(eventType: string, callback: EventListener): void {
|
||||
const listeners = this.listeners.get(eventType);
|
||||
if (listeners) {
|
||||
listeners.delete(callback);
|
||||
if (listeners.size === 0) {
|
||||
this.listeners.delete(eventType);
|
||||
}
|
||||
}
|
||||
this.eventTarget.removeEventListener(eventType, callback);
|
||||
}
|
||||
|
||||
// 发布事件
|
||||
public emit(eventType: string, data?: unknown): void {
|
||||
const event = new CustomEvent(eventType, { detail: data });
|
||||
this.eventTarget.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// 清理所有监听器
|
||||
public cleanup(): void {
|
||||
for (const [eventType, listeners] of this.listeners) {
|
||||
for (const listener of listeners) {
|
||||
this.eventTarget.removeEventListener(eventType, listener);
|
||||
}
|
||||
}
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// 事件类型常量
|
||||
export const EVENT_TYPES = {
|
||||
PRIZE_DRAWN: 'prize_drawn', // 奖品被抽中
|
||||
PRIZES_UPDATED: 'prizes_updated', // 奖品数据更新
|
||||
RECORDS_UPDATED: 'records_updated', // 抽奖记录更新
|
||||
PROBABILITY_CHANGED: 'probability_changed', // 概率发生变化
|
||||
STUDENT_DRAWN: 'student_drawn', // 学生完成抽奖
|
||||
STUDENT_UPDATED: 'student_updated', // 学生数据更新
|
||||
SYSTEM_CONFIG_UPDATED: 'system_config_updated', // 系统配置更新
|
||||
DATA_CLEARED: 'data_cleared', // 数据清空
|
||||
SYSTEM_RESET: 'system_reset', // 系统重置
|
||||
FORCE_RESET: 'force_reset' // 强制重置
|
||||
} as const;
|
||||
|
||||
export type EventType = typeof EVENT_TYPES[keyof typeof EVENT_TYPES];
|
||||
|
||||
// 事件数据接口
|
||||
export interface PrizeDrawnEventData {
|
||||
studentId: string;
|
||||
prizeId: string;
|
||||
prizeName: string;
|
||||
prizeLevel: number;
|
||||
remainingQuantity: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface PrizesUpdatedEventData {
|
||||
updatedPrizes: Array<{
|
||||
id: string;
|
||||
remainingQuantity: number;
|
||||
totalQuantity: number;
|
||||
}>;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface ProbabilityChangedEventData {
|
||||
prizeId: string;
|
||||
oldProbability: number;
|
||||
newProbability: number;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export default EventService;
|
||||
279
src/services/lotteryService.ts
Normal file
279
src/services/lotteryService.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import ApiService from '../services/apiService';
|
||||
import type { PrizeConfig } from '../database/database';
|
||||
import EventService, { EVENT_TYPES, type PrizeDrawnEventData } from './eventService';
|
||||
import type { LotteryRecord } from '../types';
|
||||
|
||||
export interface DrawResult {
|
||||
success: boolean;
|
||||
prize?: {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
};
|
||||
message: string;
|
||||
remaining?: number;
|
||||
}
|
||||
|
||||
export interface LotteryStats {
|
||||
totalDraws: number;
|
||||
prizeDistribution: Record<number, number>;
|
||||
remainingPrizes: PrizeConfig[];
|
||||
}
|
||||
|
||||
class LotteryService {
|
||||
private api: ApiService;
|
||||
private static instance: LotteryService;
|
||||
|
||||
private constructor() {
|
||||
this.api = ApiService.getInstance();
|
||||
}
|
||||
|
||||
public static getInstance(): LotteryService {
|
||||
if (!LotteryService.instance) {
|
||||
LotteryService.instance = new LotteryService();
|
||||
}
|
||||
return LotteryService.instance;
|
||||
}
|
||||
|
||||
// 验证学号格式
|
||||
public validateStudentId(studentId: string): boolean {
|
||||
// 12位数字学号验证
|
||||
const regex = /^\d{12}$/;
|
||||
return regex.test(studentId);
|
||||
}
|
||||
|
||||
// 检查学生是否可以抽奖
|
||||
public async canStudentDraw(studentId: string): Promise<{ canDraw: boolean; message: string }> {
|
||||
console.log('=== canStudentDraw 验证开始 ===');
|
||||
console.log('输入学号:', studentId);
|
||||
console.log('学号长度:', studentId.length);
|
||||
console.log('学号类型:', typeof studentId);
|
||||
|
||||
// 验证学号格式
|
||||
const isValidFormat = this.validateStudentId(studentId);
|
||||
console.log('学号格式验证结果:', isValidFormat);
|
||||
|
||||
if (!isValidFormat) {
|
||||
console.log('学号格式验证失败,返回格式错误提示');
|
||||
return { canDraw: false, message: '学号格式不正确,请输入12位数字学号' };
|
||||
}
|
||||
|
||||
const systemConfig = await this.api.getSystemConfig();
|
||||
console.log('系统配置:', systemConfig);
|
||||
|
||||
if (!systemConfig) {
|
||||
console.log('系统配置获取失败');
|
||||
return { canDraw: false, message: '系统配置错误,请联系管理员' };
|
||||
}
|
||||
|
||||
const student = await this.api.getStudent(studentId);
|
||||
console.log('学生信息:', student);
|
||||
console.log('最大抽奖次数:', systemConfig.max_draw_times);
|
||||
|
||||
if (student && student.draw_count >= systemConfig.max_draw_times) {
|
||||
console.log('抽奖次数验证失败,当前次数:', student.draw_count, '最大次数:', systemConfig.max_draw_times);
|
||||
return { canDraw: false, message: `已达到抽奖上限!每人最多只能抽奖${systemConfig.max_draw_times}次` };
|
||||
}
|
||||
|
||||
console.log('所有验证通过,可以开始抽奖');
|
||||
return { canDraw: true, message: '验证通过,可以开始抽奖' };
|
||||
}
|
||||
|
||||
// 执行抽奖
|
||||
public async drawLottery(studentId: string): Promise<DrawResult> {
|
||||
// 验证是否可以抽奖
|
||||
const validation = await this.canStudentDraw(studentId);
|
||||
if (!validation.canDraw) {
|
||||
return {
|
||||
success: false,
|
||||
message: validation.message
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 获取所有奖项,只考虑启用且有剩余数量的奖项
|
||||
const prizes = await this.api.getAllPrizes();
|
||||
const availablePrizes = prizes.filter(p => p.is_active && p.remaining_quantity > 0);
|
||||
|
||||
if (availablePrizes.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
message: '暂无可抽取的奖品'
|
||||
};
|
||||
}
|
||||
|
||||
// 执行加权随机抽奖
|
||||
const selectedPrize = this.weightedRandomDraw(availablePrizes);
|
||||
|
||||
if (!selectedPrize) {
|
||||
return {
|
||||
success: false,
|
||||
message: '抽奖失败,请重试'
|
||||
};
|
||||
}
|
||||
|
||||
// 更新数据库 - 确保原子性操作,防止重复计数
|
||||
const newRemainingQuantity = selectedPrize.remaining_quantity - 1;
|
||||
|
||||
// 使用 await 确保所有数据库操作按顺序执行
|
||||
// 先更新奖品数量
|
||||
await this.api.updatePrizeQuantity(selectedPrize.id, newRemainingQuantity);
|
||||
|
||||
// 创建抽奖记录
|
||||
await this.api.createLotteryRecord(
|
||||
studentId,
|
||||
selectedPrize.id,
|
||||
selectedPrize.prize_name,
|
||||
selectedPrize.prize_level
|
||||
);
|
||||
|
||||
// 最后更新学生抽奖次数(只在成功抽奖后更新)
|
||||
await this.api.createOrUpdateStudent(studentId);
|
||||
|
||||
// 触发奖品抽中事件,用于全站无感刷新
|
||||
const eventService = EventService.getInstance();
|
||||
const eventData: PrizeDrawnEventData = {
|
||||
studentId,
|
||||
prizeId: selectedPrize.id,
|
||||
prizeName: selectedPrize.prize_name,
|
||||
prizeLevel: selectedPrize.prize_level,
|
||||
remainingQuantity: newRemainingQuantity,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
eventService.emit(EVENT_TYPES.PRIZE_DRAWN, eventData);
|
||||
eventService.emit(EVENT_TYPES.PRIZES_UPDATED, {
|
||||
updatedPrizes: [{
|
||||
id: selectedPrize.id,
|
||||
remainingQuantity: newRemainingQuantity,
|
||||
totalQuantity: selectedPrize.total_quantity
|
||||
}],
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
console.log('=== 构建抽奖返回结果 ===');
|
||||
console.log('selectedPrize:', selectedPrize);
|
||||
|
||||
const result = {
|
||||
success: true,
|
||||
prize: {
|
||||
id: selectedPrize.id,
|
||||
name: selectedPrize.prize_name,
|
||||
level: selectedPrize.prize_level
|
||||
},
|
||||
message: '恭喜中奖!',
|
||||
remaining: newRemainingQuantity,
|
||||
studentId: studentId,
|
||||
timestamp: Date.now(),
|
||||
formattedStudentId: this.formatStudentId(studentId)
|
||||
};
|
||||
|
||||
console.log('返回结果:', JSON.stringify(result, null, 2));
|
||||
return result;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Draw lottery error:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: '抽奖过程中发生错误,请重试'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 加权随机抽奖算法
|
||||
private weightedRandomDraw(prizes: PrizeConfig[]): PrizeConfig | null {
|
||||
// 只考虑启用且有剩余数量的奖项
|
||||
const enabledPrizes = prizes.filter(p => p.is_active && p.remaining_quantity > 0);
|
||||
|
||||
if (enabledPrizes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 计算启用奖项的总权重
|
||||
const totalWeight = enabledPrizes.reduce((sum, prize) => sum + prize.probability, 0);
|
||||
|
||||
if (totalWeight === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 生成随机数
|
||||
const random = Math.random() * totalWeight;
|
||||
|
||||
// 根据权重选择奖品
|
||||
let currentWeight = 0;
|
||||
for (const prize of enabledPrizes) {
|
||||
currentWeight += prize.probability;
|
||||
if (random <= currentWeight) {
|
||||
return prize;
|
||||
}
|
||||
}
|
||||
|
||||
// 兜底返回最后一个启用的奖品
|
||||
return enabledPrizes[enabledPrizes.length - 1];
|
||||
}
|
||||
|
||||
// 获取抽奖统计信息
|
||||
public async getLotteryStats(): Promise<LotteryStats> {
|
||||
const records = await this.api.getLotteryRecords(1000);
|
||||
const prizes = await this.api.getAllPrizes();
|
||||
|
||||
const prizeDistribution: Record<number, number> = {};
|
||||
|
||||
records.forEach(record => {
|
||||
prizeDistribution[record.prize_level] = (prizeDistribution[record.prize_level] || 0) + 1;
|
||||
});
|
||||
|
||||
return {
|
||||
totalDraws: records.length,
|
||||
prizeDistribution,
|
||||
remainingPrizes: prizes.filter(p => p.remaining_quantity > 0)
|
||||
};
|
||||
}
|
||||
|
||||
// 获取学生抽奖记录
|
||||
public async getStudentHistory(studentId: string): Promise<LotteryRecord[]> {
|
||||
return this.api.getStudentRecords(studentId);
|
||||
}
|
||||
|
||||
// 验证首页登录密码
|
||||
public async validateLoginPassword(password: string): Promise<boolean> {
|
||||
const config = await this.api.getSystemConfig();
|
||||
return config ? config.login_password === password : false;
|
||||
}
|
||||
|
||||
// 验证后台管理密码
|
||||
public async validateAdminPassword(password: string): Promise<boolean> {
|
||||
const config = await this.api.getSystemConfig();
|
||||
return config ? config.admin_password === password : false;
|
||||
}
|
||||
|
||||
// 获取隐藏位置配置
|
||||
public async getHidePositions(): Promise<number[]> {
|
||||
const config = await this.api.getSystemConfig();
|
||||
if (!config || !config.hide_positions) {
|
||||
return [3, 4, 5, 6]; // 默认隐藏位置
|
||||
}
|
||||
|
||||
try {
|
||||
return config.hide_positions.split(',').map(pos => parseInt(pos.trim()));
|
||||
} catch {
|
||||
return [3, 4, 5, 6];
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化学号显示
|
||||
public formatStudentId(studentId: string): string {
|
||||
// 不隐藏任何数字,每四位用空格分隔
|
||||
return studentId.replace(/(\d{4})(?=\d)/g, '$1 ');
|
||||
}
|
||||
|
||||
// 清理方法,防止内存泄漏
|
||||
public cleanup(): void {
|
||||
// 清理API服务
|
||||
this.api.cleanup();
|
||||
console.log('🧹 LotteryService 已清理');
|
||||
}
|
||||
}
|
||||
|
||||
export default LotteryService;
|
||||
159
src/services/networkService.ts
Normal file
159
src/services/networkService.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
export interface NetworkStatus {
|
||||
isOnline: boolean;
|
||||
lastOnlineTime?: Date;
|
||||
lastOfflineTime?: Date;
|
||||
lastCheck: Date;
|
||||
}
|
||||
|
||||
export type NetworkStatusCallback = (status: NetworkStatus) => void;
|
||||
|
||||
export class NetworkService {
|
||||
private static instance: NetworkService;
|
||||
private status: NetworkStatus;
|
||||
private callbacks: Set<NetworkStatusCallback> = new Set();
|
||||
private checkInterval?: NodeJS.Timeout;
|
||||
|
||||
private constructor() {
|
||||
const now = new Date();
|
||||
this.status = {
|
||||
isOnline: navigator.onLine,
|
||||
lastOnlineTime: navigator.onLine ? now : undefined,
|
||||
lastOfflineTime: !navigator.onLine ? now : undefined,
|
||||
lastCheck: now
|
||||
};
|
||||
|
||||
this.initializeListeners();
|
||||
this.startPeriodicCheck();
|
||||
}
|
||||
|
||||
public static getInstance(): NetworkService {
|
||||
if (!NetworkService.instance) {
|
||||
NetworkService.instance = new NetworkService();
|
||||
}
|
||||
return NetworkService.instance;
|
||||
}
|
||||
|
||||
private initializeListeners(): void {
|
||||
window.addEventListener('online', this.handleOnline.bind(this));
|
||||
window.addEventListener('offline', this.handleOffline.bind(this));
|
||||
}
|
||||
|
||||
private handleOnline(): void {
|
||||
console.log('网络连接已恢复');
|
||||
this.updateStatus(true);
|
||||
}
|
||||
|
||||
private handleOffline(): void {
|
||||
console.log('网络连接已断开');
|
||||
this.updateStatus(false);
|
||||
}
|
||||
|
||||
private updateStatus(isOnline: boolean): void {
|
||||
const now = new Date();
|
||||
this.status = {
|
||||
...this.status,
|
||||
isOnline,
|
||||
lastOnlineTime: isOnline ? now : this.status.lastOnlineTime,
|
||||
lastOfflineTime: !isOnline ? now : this.status.lastOfflineTime,
|
||||
lastCheck: now
|
||||
};
|
||||
|
||||
// 通知所有监听器
|
||||
this.callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(this.status);
|
||||
} catch (error) {
|
||||
console.error('网络状态回调执行错误:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startPeriodicCheck(): void {
|
||||
// 每30秒检查一次网络连接
|
||||
this.checkInterval = setInterval(() => {
|
||||
this.checkNetworkConnectivity();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
private async checkNetworkConnectivity(): Promise<void> {
|
||||
try {
|
||||
// 使用浏览器的在线状态作为主要判断依据
|
||||
const browserOnline = navigator.onLine;
|
||||
|
||||
if (!browserOnline) {
|
||||
if (this.status.isOnline) {
|
||||
console.log('浏览器检测到网络断开');
|
||||
this.updateStatus(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果浏览器认为在线,直接信任浏览器的判断
|
||||
// 避免额外的网络请求导致不必要的错误日志
|
||||
if (browserOnline !== this.status.isOnline) {
|
||||
this.updateStatus(browserOnline);
|
||||
} else {
|
||||
// 即使状态没变,也要更新检查时间
|
||||
this.status.lastCheck = new Date();
|
||||
}
|
||||
} catch (error) {
|
||||
// 检测过程出错,保持当前状态
|
||||
console.warn('网络状态检测出错:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public getStatus(): NetworkStatus {
|
||||
return { ...this.status };
|
||||
}
|
||||
|
||||
public isOnline(): boolean {
|
||||
return this.status.isOnline;
|
||||
}
|
||||
|
||||
public subscribe(callback: NetworkStatusCallback): () => void {
|
||||
this.callbacks.add(callback);
|
||||
|
||||
// 立即调用一次回调,提供当前状态
|
||||
callback(this.status);
|
||||
|
||||
// 返回取消订阅函数
|
||||
return () => {
|
||||
this.callbacks.delete(callback);
|
||||
};
|
||||
}
|
||||
|
||||
public async waitForOnline(timeout: number = 30000): Promise<boolean> {
|
||||
if (this.status.isOnline) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
unsubscribe();
|
||||
resolve(false);
|
||||
}, timeout);
|
||||
|
||||
const unsubscribe = this.subscribe((status) => {
|
||||
if (status.isOnline) {
|
||||
clearTimeout(timeoutId);
|
||||
unsubscribe();
|
||||
resolve(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public destroy(): void {
|
||||
window.removeEventListener('online', this.handleOnline.bind(this));
|
||||
window.removeEventListener('offline', this.handleOffline.bind(this));
|
||||
|
||||
if (this.checkInterval) {
|
||||
clearInterval(this.checkInterval);
|
||||
}
|
||||
|
||||
this.callbacks.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const networkService = NetworkService.getInstance();
|
||||
export default NetworkService;
|
||||
393
src/services/websocketService.ts
Normal file
393
src/services/websocketService.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
import { useAppStore } from '../store';
|
||||
import EventService, { EVENT_TYPES } from './eventService';
|
||||
|
||||
class WebSocketService {
|
||||
private socket: Socket | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private maxReconnectAttempts = 5;
|
||||
private reconnectDelay = 1000;
|
||||
private isConnecting = false;
|
||||
private updateDebounceTimer: NodeJS.Timeout | null = null;
|
||||
private readonly debounceDelay = 300; // 防抖延迟300ms
|
||||
private networkStatusTimer: NodeJS.Timeout | null = null;
|
||||
private lastPingTime = 0;
|
||||
private connectionQuality: 'good' | 'poor' | 'disconnected' = 'disconnected';
|
||||
private eventService: EventService;
|
||||
|
||||
constructor() {
|
||||
this.eventService = EventService.getInstance();
|
||||
this.connect();
|
||||
}
|
||||
|
||||
private connect() {
|
||||
if (this.isConnecting || this.socket?.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnecting = true;
|
||||
|
||||
// 获取当前主机地址和端口配置
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const host = window.location.hostname === 'localhost' ? 'localhost' : window.location.hostname;
|
||||
const port = process.env.NODE_ENV === 'production' ? window.location.port || '80' : '3001';
|
||||
const socketUrl = process.env.NODE_ENV === 'production'
|
||||
? `${protocol}//${host}${port !== '80' && port !== '443' ? ':' + port : ''}`
|
||||
: `${protocol}//${host}:3001`;
|
||||
|
||||
console.log('🔌 连接WebSocket服务器:', socketUrl, '(环境:', process.env.NODE_ENV, ')');
|
||||
|
||||
this.socket = io(socketUrl, {
|
||||
transports: ['websocket', 'polling'],
|
||||
timeout: 10000,
|
||||
reconnection: true,
|
||||
reconnectionAttempts: this.maxReconnectAttempts,
|
||||
reconnectionDelay: this.reconnectDelay,
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
if (!this.socket) return;
|
||||
|
||||
// 连接成功
|
||||
this.socket.on('connect', () => {
|
||||
console.log('✅ WebSocket连接成功:', this.socket?.id);
|
||||
this.isConnecting = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.connectionQuality = 'good';
|
||||
|
||||
// 开始网络状态监控
|
||||
this.startNetworkMonitoring();
|
||||
|
||||
// 延迟请求最新数据,避免连接刚建立时的不稳定
|
||||
setTimeout(() => {
|
||||
this.requestDataUpdate();
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// 连接失败
|
||||
this.socket.on('connect_error', (error) => {
|
||||
console.error('❌ WebSocket连接失败:', error.message || error);
|
||||
this.isConnecting = false;
|
||||
this.connectionQuality = 'disconnected';
|
||||
|
||||
// 根据错误类型决定是否重连
|
||||
if (error.message?.includes('ECONNREFUSED') || error.message?.includes('timeout')) {
|
||||
console.log('🔄 服务器连接被拒绝或超时,将尝试重连');
|
||||
this.handleReconnect();
|
||||
} else {
|
||||
console.log('⚠️ 连接错误,暂停重连尝试');
|
||||
}
|
||||
});
|
||||
|
||||
// 断开连接
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('🔌 WebSocket断开连接:', reason);
|
||||
this.isConnecting = false;
|
||||
this.connectionQuality = 'disconnected';
|
||||
|
||||
// 停止网络监控
|
||||
this.stopNetworkMonitoring();
|
||||
|
||||
// 根据断开原因决定重连策略
|
||||
if (reason === 'io server disconnect') {
|
||||
// 服务器主动断开,需要重新连接
|
||||
console.log('🔄 服务器主动断开,准备重连');
|
||||
this.handleReconnect();
|
||||
} else if (reason === 'transport close' || reason === 'transport error') {
|
||||
// 传输层错误,尝试重连
|
||||
console.log('🔄 传输层错误,准备重连');
|
||||
this.handleReconnect();
|
||||
} else {
|
||||
console.log('ℹ️ 客户端主动断开或其他原因,不自动重连');
|
||||
}
|
||||
});
|
||||
|
||||
// 监听奖项数据更新(带防抖)
|
||||
this.socket.on('prizes_updated', (data) => {
|
||||
// 确保传递正确的数据格式
|
||||
const prizes = data.updatedPrizes || data;
|
||||
this.debouncedUpdate('prizes', prizes);
|
||||
});
|
||||
|
||||
// 监听单个奖项更新(优化版)
|
||||
this.socket.on('prize_updated', (data) => {
|
||||
// 发射奖项更新事件
|
||||
this.eventService.emit(EVENT_TYPES.PRIZES_UPDATED, {
|
||||
updatedPrizes: [data.prize],
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// 监听抽奖记录更新
|
||||
this.socket.on('records_updated', (data) => {
|
||||
// 发射记录更新事件
|
||||
this.eventService.emit(EVENT_TYPES.RECORDS_UPDATED, {
|
||||
records: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// 监听新增抽奖记录(优化版)
|
||||
this.socket.on('new_record', (data) => {
|
||||
console.log('收到新增抽奖记录:', data);
|
||||
|
||||
// 发射新记录事件
|
||||
this.eventService.emit(EVENT_TYPES.RECORDS_UPDATED, {
|
||||
records: [data.record],
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 触发记录列表刷新
|
||||
this.requestDataUpdate();
|
||||
});
|
||||
|
||||
// 监听系统配置更新
|
||||
this.socket.on('system_config_updated', (config) => {
|
||||
console.log('收到系统配置更新:', config);
|
||||
|
||||
// 发射系统配置更新事件
|
||||
this.eventService.emit(EVENT_TYPES.SYSTEM_CONFIG_UPDATED, {
|
||||
config: config,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 请求数据更新
|
||||
this.requestDataUpdate();
|
||||
});
|
||||
|
||||
// 监听学生数据更新
|
||||
this.socket.on('student_updated', (data) => {
|
||||
console.log('收到学生数据更新:', data);
|
||||
|
||||
// 发射学生数据更新事件
|
||||
this.eventService.emit(EVENT_TYPES.STUDENT_UPDATED, {
|
||||
student: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
});
|
||||
|
||||
// 监听数据清空事件
|
||||
this.socket.on('data_cleared', (data) => {
|
||||
console.log('收到数据清空事件:', data);
|
||||
|
||||
// 发射数据清空事件
|
||||
this.eventService.emit(EVENT_TYPES.DATA_CLEARED, {
|
||||
message: data.message || '数据已清空',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 请求数据更新
|
||||
this.requestDataUpdate();
|
||||
});
|
||||
|
||||
// 监听系统重置事件
|
||||
this.socket.on('system_reset', (data) => {
|
||||
console.log('收到系统重置事件:', data);
|
||||
|
||||
// 发射系统重置事件
|
||||
this.eventService.emit(EVENT_TYPES.SYSTEM_RESET, {
|
||||
message: data.message || '系统已重置',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 重新请求最新数据
|
||||
this.requestDataUpdate();
|
||||
});
|
||||
|
||||
// 监听强制重置事件
|
||||
this.socket.on('force_reset', (data) => {
|
||||
console.log('收到强制重置事件:', data);
|
||||
|
||||
// 发射强制重置事件
|
||||
this.eventService.emit(EVENT_TYPES.FORCE_RESET, {
|
||||
message: data.message || '系统已强制重置',
|
||||
timestamp: Date.now()
|
||||
});
|
||||
|
||||
// 重新请求所有数据
|
||||
this.requestDataUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
// 防抖更新数据
|
||||
private debouncedUpdate(type: 'prizes' | 'records', data: any) {
|
||||
// 清除之前的定时器
|
||||
if (this.updateDebounceTimer) {
|
||||
clearTimeout(this.updateDebounceTimer);
|
||||
}
|
||||
|
||||
// 设置新的防抖定时器
|
||||
this.updateDebounceTimer = setTimeout(() => {
|
||||
console.log(`收到${type === 'prizes' ? '奖项' : '记录'}数据更新:`, data);
|
||||
|
||||
if (type === 'prizes') {
|
||||
// 发射奖项更新事件
|
||||
this.eventService.emit(EVENT_TYPES.PRIZES_UPDATED, {
|
||||
updatedPrizes: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
} else {
|
||||
// 发射记录更新事件
|
||||
this.eventService.emit(EVENT_TYPES.RECORDS_UPDATED, {
|
||||
records: data,
|
||||
timestamp: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
this.updateDebounceTimer = null;
|
||||
}, this.debounceDelay);
|
||||
}
|
||||
|
||||
private handleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error('WebSocket重连次数已达上限');
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
||||
|
||||
console.log(`WebSocket将在${delay}ms后尝试第${this.reconnectAttempts}次重连`);
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.socket?.connected) {
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// 开始网络状态监控
|
||||
private startNetworkMonitoring() {
|
||||
if (this.networkStatusTimer) {
|
||||
clearInterval(this.networkStatusTimer);
|
||||
}
|
||||
|
||||
this.networkStatusTimer = setInterval(() => {
|
||||
if (this.socket?.connected) {
|
||||
const now = Date.now();
|
||||
this.lastPingTime = now;
|
||||
|
||||
// 发送ping测试连接质量
|
||||
this.socket.emit('ping', now);
|
||||
|
||||
// 设置超时检测
|
||||
setTimeout(() => {
|
||||
const responseTime = Date.now() - this.lastPingTime;
|
||||
if (responseTime > 3000) {
|
||||
this.connectionQuality = 'poor';
|
||||
console.warn('网络连接质量较差,响应时间:', responseTime + 'ms');
|
||||
} else if (responseTime > 1000) {
|
||||
this.connectionQuality = 'poor';
|
||||
} else {
|
||||
this.connectionQuality = 'good';
|
||||
}
|
||||
}, 1000);
|
||||
} else {
|
||||
this.connectionQuality = 'disconnected';
|
||||
}
|
||||
}, 10000); // 每10秒检测一次
|
||||
|
||||
// 监听pong响应
|
||||
this.socket?.on('pong', (timestamp) => {
|
||||
const responseTime = Date.now() - timestamp;
|
||||
if (responseTime < 500) {
|
||||
this.connectionQuality = 'good';
|
||||
} else if (responseTime < 1500) {
|
||||
this.connectionQuality = 'poor';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 停止网络监控
|
||||
private stopNetworkMonitoring() {
|
||||
if (this.networkStatusTimer) {
|
||||
clearInterval(this.networkStatusTimer);
|
||||
this.networkStatusTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取连接质量
|
||||
public getConnectionQuality(): 'good' | 'poor' | 'disconnected' {
|
||||
return this.connectionQuality;
|
||||
}
|
||||
|
||||
// 请求数据更新
|
||||
public requestDataUpdate() {
|
||||
if (this.socket?.connected) {
|
||||
this.socket.emit('request_data_update');
|
||||
}
|
||||
}
|
||||
|
||||
// 检查连接状态
|
||||
public isConnected(): boolean {
|
||||
return this.socket?.connected || false;
|
||||
}
|
||||
|
||||
// 手动重连
|
||||
public reconnect() {
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
this.connect();
|
||||
}
|
||||
|
||||
// 断开连接
|
||||
public disconnect() {
|
||||
// 停止网络监控
|
||||
this.stopNetworkMonitoring();
|
||||
|
||||
// 清理防抖定时器
|
||||
if (this.updateDebounceTimer) {
|
||||
clearTimeout(this.updateDebounceTimer);
|
||||
this.updateDebounceTimer = null;
|
||||
}
|
||||
|
||||
if (this.socket) {
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
this.connectionQuality = 'disconnected';
|
||||
}
|
||||
|
||||
// 清理所有资源
|
||||
public cleanup() {
|
||||
console.log('WebSocketService: 开始清理资源');
|
||||
|
||||
// 停止网络监控
|
||||
this.stopNetworkMonitoring();
|
||||
|
||||
// 清理防抖定时器
|
||||
if (this.updateDebounceTimer) {
|
||||
clearTimeout(this.updateDebounceTimer);
|
||||
this.updateDebounceTimer = null;
|
||||
}
|
||||
|
||||
// 断开WebSocket连接
|
||||
if (this.socket) {
|
||||
this.socket.removeAllListeners();
|
||||
this.socket.disconnect();
|
||||
this.socket = null;
|
||||
}
|
||||
|
||||
// 重置状态
|
||||
this.reconnectAttempts = 0;
|
||||
this.isConnecting = false;
|
||||
this.connectionQuality = 'disconnected';
|
||||
this.lastPingTime = 0;
|
||||
|
||||
console.log('WebSocketService: 资源清理完成');
|
||||
}
|
||||
}
|
||||
|
||||
// 创建单例实例
|
||||
export const websocketService = new WebSocketService();
|
||||
export default websocketService;
|
||||
|
||||
// 导出类型
|
||||
export type { WebSocketService };
|
||||
404
src/store/index.ts
Normal file
404
src/store/index.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { create } from 'zustand';
|
||||
import LotteryService from '../services/lotteryService';
|
||||
import ApiService from '../services/apiService';
|
||||
import AuthService from '../services/authService';
|
||||
import type { PrizeConfig, SystemConfig } from '../database/database';
|
||||
import EventService, { EVENT_TYPES, type PrizesUpdatedEventData } from '../services/eventService';
|
||||
|
||||
// 定义概率信息接口
|
||||
interface ProbabilityInfo {
|
||||
prize: Prize;
|
||||
dynamicProbability: number;
|
||||
remainingRatio: number;
|
||||
adjustedForDrawLimit?: boolean;
|
||||
}
|
||||
|
||||
// 奖项配置接口
|
||||
export interface Prize {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
totalQuantity: number;
|
||||
remainingQuantity: number;
|
||||
probability: number;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
// 抽奖结果接口
|
||||
export interface DrawResult {
|
||||
success: boolean;
|
||||
prize?: {
|
||||
id: string;
|
||||
name: string;
|
||||
level: number;
|
||||
prize_name?: string; // 兼容数据库字段
|
||||
};
|
||||
message: string;
|
||||
remaining?: number;
|
||||
studentId?: string;
|
||||
timestamp?: number;
|
||||
formattedStudentId?: string;
|
||||
}
|
||||
|
||||
// 应用状态接口
|
||||
export interface AppState {
|
||||
// 用户认证
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// 奖项配置
|
||||
prizes: Prize[];
|
||||
|
||||
// 抽奖相关
|
||||
currentStudentId: string;
|
||||
drawResult: DrawResult | null;
|
||||
isDrawing: boolean;
|
||||
drawMessage: string;
|
||||
|
||||
// 系统配置
|
||||
maxDrawTimes: number;
|
||||
hidePositions: number[];
|
||||
systemConfig: SystemConfig | null;
|
||||
|
||||
// 操作方法
|
||||
login: (password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
setStudentId: (id: string) => void;
|
||||
startDraw: (studentId: string) => Promise<DrawResult>;
|
||||
resetDraw: () => void;
|
||||
loadPrizes: () => Promise<void>;
|
||||
initializeApp: () => Promise<void>;
|
||||
loadSystemConfig: () => Promise<void>;
|
||||
setSystemConfig: (config: SystemConfig | null) => void;
|
||||
calculateDynamicProbabilities: (prizeList: Prize[]) => ProbabilityInfo[];
|
||||
checkAuthStatus: () => void;
|
||||
autoRefreshAuth: () => void;
|
||||
getTokenRemainingTime: () => number;
|
||||
cleanup: () => void;
|
||||
}
|
||||
|
||||
// 转换数据库奖项到应用奖项格式
|
||||
const convertPrizeConfig = (dbPrize: PrizeConfig): Prize => ({
|
||||
id: dbPrize.id,
|
||||
name: dbPrize.prize_name,
|
||||
level: dbPrize.prize_level,
|
||||
totalQuantity: dbPrize.total_quantity,
|
||||
remainingQuantity: dbPrize.remaining_quantity,
|
||||
probability: dbPrize.probability,
|
||||
is_active: dbPrize.is_active
|
||||
});
|
||||
|
||||
export const useAppStore = create<AppState>()(
|
||||
(set, get) => {
|
||||
const lotteryService = LotteryService.getInstance();
|
||||
const authService = AuthService.getInstance();
|
||||
|
||||
// 初始化事件监听
|
||||
const eventService = EventService.getInstance();
|
||||
|
||||
// 监听奖品更新事件,实现智能无感刷新
|
||||
eventService.subscribe(EVENT_TYPES.PRIZES_UPDATED, ((event: CustomEvent<PrizesUpdatedEventData>) => {
|
||||
const { updatedPrizes } = event.detail;
|
||||
const currentPrizes = get().prizes;
|
||||
|
||||
// 将数据库格式转换为应用格式
|
||||
const convertedPrizes = (updatedPrizes as unknown as PrizeConfig[]).map(convertPrizeConfig);
|
||||
|
||||
// 智能比较:检查是否有任何字段发生变化
|
||||
const hasChanges = JSON.stringify(convertedPrizes) !== JSON.stringify(currentPrizes);
|
||||
|
||||
// 只有在数据真正变化时才更新状态,避免不必要的重新渲染
|
||||
if (hasChanges) {
|
||||
console.log('检测到奖品数据变化,更新状态');
|
||||
set({ prizes: convertedPrizes });
|
||||
}
|
||||
}) as EventListener);
|
||||
|
||||
return {
|
||||
// 初始状态 - 从AuthService检查登录状态
|
||||
isAuthenticated: authService.isAuthenticated(),
|
||||
prizes: [],
|
||||
currentStudentId: '',
|
||||
drawResult: null,
|
||||
isDrawing: false,
|
||||
drawMessage: '',
|
||||
maxDrawTimes: 1,
|
||||
hidePositions: [3, 4, 5, 6],
|
||||
systemConfig: null,
|
||||
|
||||
// 初始化应用
|
||||
initializeApp: async () => {
|
||||
try {
|
||||
// 检查登录状态
|
||||
get().checkAuthStatus();
|
||||
|
||||
// 设置token过期回调
|
||||
authService.setTokenExpiredCallback(() => {
|
||||
console.log('Token过期,自动登出');
|
||||
set({ isAuthenticated: false });
|
||||
});
|
||||
|
||||
// 启动token监控
|
||||
authService.startTokenMonitoring();
|
||||
|
||||
// 加载系统配置
|
||||
await get().loadSystemConfig();
|
||||
|
||||
await get().loadPrizes();
|
||||
const hidePositions = await lotteryService.getHidePositions();
|
||||
set({ hidePositions });
|
||||
|
||||
// 设置自动刷新token的定时器
|
||||
setInterval(() => {
|
||||
get().autoRefreshAuth();
|
||||
}, 5 * 60 * 1000); // 每5分钟检查一次
|
||||
} catch (error) {
|
||||
console.error('Initialize app failed:', error);
|
||||
}
|
||||
},
|
||||
|
||||
// 登录
|
||||
login: async (inputPassword: string) => {
|
||||
try {
|
||||
const result = await authService.login(inputPassword);
|
||||
if (result.success) {
|
||||
set({ isAuthenticated: true });
|
||||
console.log('登录成功,状态已更新');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// 登出方法
|
||||
logout: () => {
|
||||
authService.logout();
|
||||
set({
|
||||
isAuthenticated: false,
|
||||
drawResult: null,
|
||||
drawMessage: ''
|
||||
});
|
||||
console.log('已登出');
|
||||
},
|
||||
|
||||
// 设置学号
|
||||
setStudentId: (id: string) => {
|
||||
set({ currentStudentId: id, drawMessage: '' });
|
||||
},
|
||||
|
||||
// 加载奖项配置
|
||||
loadPrizes: async () => {
|
||||
try {
|
||||
console.log('Store loadPrizes - 开始加载奖项数据');
|
||||
console.log('Store loadPrizes - 当前URL:', window.location.href);
|
||||
|
||||
const apiService = ApiService.getInstance();
|
||||
const dbPrizes = await apiService.getAllPrizes();
|
||||
console.log('Store loadPrizes - 从数据库获取的原始奖项:', dbPrizes);
|
||||
|
||||
const prizes = (dbPrizes || []).map(convertPrizeConfig);
|
||||
console.log('Store loadPrizes - 处理后的奖项数据:', prizes);
|
||||
|
||||
set({ prizes });
|
||||
|
||||
console.log('Store loadPrizes - 奖项数据已更新到状态');
|
||||
|
||||
// 注意:不在这里触发PRIZES_UPDATED事件,避免与WebSocket事件形成循环
|
||||
// WebSocket事件应该是数据变化的唯一触发源
|
||||
} catch (error) {
|
||||
console.error('Store loadPrizes - 加载奖项失败:', error);
|
||||
console.error('Store loadPrizes - 错误详情:', error.stack);
|
||||
|
||||
// 提供更详细的错误信息
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
console.error('Store loadPrizes - 网络连接错误,请检查后端服务是否正常运行');
|
||||
} else if (error.message?.includes('Failed to fetch')) {
|
||||
console.error('Store loadPrizes - API请求失败,可能是网络问题或服务器未响应');
|
||||
}
|
||||
|
||||
// 设置空数组避免组件渲染错误
|
||||
set({ prizes: [] });
|
||||
|
||||
// 重新抛出错误,让调用方知道加载失败
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// 开始抽奖
|
||||
startDraw: async (studentId: string) => {
|
||||
console.log('🎲 Store: 开始抽奖');
|
||||
console.log('Store: 当前状态 - isDrawing:', get().isDrawing, 'drawResult:', get().drawResult);
|
||||
|
||||
// 防止并发调用
|
||||
if (get().isDrawing) {
|
||||
console.log('Store: 抽奖正在进行中,拒绝重复调用');
|
||||
return {
|
||||
success: false,
|
||||
message: '抽奖正在进行中,请稍候'
|
||||
};
|
||||
}
|
||||
|
||||
set({ isDrawing: true, drawResult: null });
|
||||
console.log('Store: 状态更新 - isDrawing: true, drawResult: null');
|
||||
|
||||
try {
|
||||
console.log('Store: 调用 lotteryService.drawLottery()');
|
||||
const result = await lotteryService.drawLottery(studentId);
|
||||
console.log('Store: 抽奖结果:', result);
|
||||
|
||||
set({
|
||||
drawResult: result,
|
||||
isDrawing: false
|
||||
});
|
||||
console.log('Store: 最终状态更新 - drawResult:', result, 'isDrawing: false');
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Store: 抽奖失败:', error);
|
||||
const errorResult = {
|
||||
success: false,
|
||||
message: '抽奖失败,请重试'
|
||||
};
|
||||
set({ drawResult: errorResult, isDrawing: false });
|
||||
console.log('Store: 错误状态更新 - isDrawing: false');
|
||||
return errorResult;
|
||||
}
|
||||
},
|
||||
|
||||
// 重置抽奖
|
||||
resetDraw: () => {
|
||||
set({
|
||||
drawResult: null,
|
||||
currentStudentId: '',
|
||||
isDrawing: false,
|
||||
drawMessage: ''
|
||||
});
|
||||
},
|
||||
|
||||
// 加载系统配置
|
||||
loadSystemConfig: async () => {
|
||||
try {
|
||||
const apiService = ApiService.getInstance();
|
||||
const config = await apiService.getSystemConfig();
|
||||
set({
|
||||
systemConfig: config,
|
||||
maxDrawTimes: config?.max_draw_times || 1
|
||||
});
|
||||
console.log('系统配置已加载:', config);
|
||||
console.log('最大抽奖次数已更新为:', config?.max_draw_times || 1);
|
||||
} catch (error) {
|
||||
console.error('Load system config failed:', error);
|
||||
set({
|
||||
systemConfig: null,
|
||||
maxDrawTimes: 1
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
// 设置系统配置
|
||||
setSystemConfig: (config: SystemConfig | null) => {
|
||||
set({
|
||||
systemConfig: config,
|
||||
maxDrawTimes: config?.max_draw_times || 1
|
||||
});
|
||||
console.log('系统配置已更新:', config);
|
||||
console.log('最大抽奖次数已同步更新为:', config?.max_draw_times || 1);
|
||||
},
|
||||
|
||||
// 动态概率计算函数 - 结合抽奖次数上限和剩余数量
|
||||
calculateDynamicProbabilities: (prizeList: Prize[]): ProbabilityInfo[] => {
|
||||
// 只考虑启用且有剩余数量的奖项
|
||||
const activePrizes = prizeList.filter(p => p.is_active && p.remainingQuantity > 0);
|
||||
|
||||
if (activePrizes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const state = get();
|
||||
const maxDrawTimes = state.systemConfig?.max_draw_times || 1;
|
||||
|
||||
// 计算启用奖项的总权重(只考虑启用的奖项)
|
||||
const totalWeight = activePrizes.reduce((sum, prize) => sum + prize.probability, 0);
|
||||
|
||||
return activePrizes.map(prize => {
|
||||
// 基础概率计算
|
||||
let dynamicProbability = totalWeight > 0 ? (prize.probability / totalWeight) : 0;
|
||||
let adjustedForDrawLimit = false;
|
||||
|
||||
// 结合抽奖次数上限调整概率
|
||||
if (maxDrawTimes > 0) {
|
||||
// 计算剩余奖品与最大抽奖次数的比例
|
||||
const remainingDrawOpportunities = prize.remainingQuantity;
|
||||
const maxPossibleDraws = maxDrawTimes;
|
||||
|
||||
// 如果剩余奖品数量远小于可能的抽奖总次数,降低显示概率
|
||||
if (remainingDrawOpportunities < maxPossibleDraws * 0.1) {
|
||||
dynamicProbability *= (remainingDrawOpportunities / (maxPossibleDraws * 0.1));
|
||||
adjustedForDrawLimit = true;
|
||||
}
|
||||
|
||||
// 如果接近抽奖次数上限,进一步调整概率显示
|
||||
if (remainingDrawOpportunities <= maxPossibleDraws * 0.05) {
|
||||
dynamicProbability *= 0.5; // 进一步降低显示概率
|
||||
adjustedForDrawLimit = true;
|
||||
}
|
||||
}
|
||||
|
||||
const remainingRatio = prize.totalQuantity > 0 ? (prize.remainingQuantity / prize.totalQuantity) * 100 : 0;
|
||||
|
||||
return {
|
||||
prize,
|
||||
dynamicProbability,
|
||||
remainingRatio,
|
||||
adjustedForDrawLimit
|
||||
};
|
||||
}).sort((a, b) => a.prize.level - b.prize.level);
|
||||
},
|
||||
|
||||
// 检查认证状态
|
||||
checkAuthStatus: () => {
|
||||
const isAuth = authService.checkTokenAndHandleExpiry();
|
||||
const currentAuth = get().isAuthenticated;
|
||||
|
||||
if (isAuth !== currentAuth) {
|
||||
set({ isAuthenticated: isAuth });
|
||||
console.log('认证状态已更新:', isAuth);
|
||||
}
|
||||
},
|
||||
|
||||
// 自动刷新认证
|
||||
autoRefreshAuth: () => {
|
||||
if (authService.isAuthenticated()) {
|
||||
authService.autoRefreshToken();
|
||||
}
|
||||
},
|
||||
|
||||
// 获取token剩余时间
|
||||
getTokenRemainingTime: () => {
|
||||
return authService.getTokenRemainingTime();
|
||||
},
|
||||
|
||||
// 清理方法 - 防止内存泄漏
|
||||
cleanup: () => {
|
||||
console.log('Store cleanup - 开始清理资源');
|
||||
|
||||
// 清理认证服务
|
||||
authService.cleanup();
|
||||
|
||||
// 清理抽奖服务
|
||||
lotteryService.cleanup();
|
||||
|
||||
// 清理事件服务
|
||||
eventService.cleanup();
|
||||
|
||||
// 清理WebSocket服务
|
||||
const { websocketService } = require('../services/websocketService');
|
||||
websocketService.cleanup();
|
||||
|
||||
console.log('Store cleanup - 资源清理完成');
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
61
src/types.ts
Normal file
61
src/types.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
// 类型定义文件
|
||||
|
||||
export interface DrawRecord {
|
||||
id: string;
|
||||
student_id: string;
|
||||
prize_id: string;
|
||||
prize_name: string;
|
||||
prize_level: number;
|
||||
draw_time: string;
|
||||
is_winner: boolean;
|
||||
is_synced: number;
|
||||
cache_data: string;
|
||||
}
|
||||
|
||||
export interface Prize {
|
||||
id: string;
|
||||
prize_name: string;
|
||||
prize_level: number;
|
||||
total_quantity: number;
|
||||
remaining_quantity: number;
|
||||
probability: number;
|
||||
is_active: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface SystemConfig {
|
||||
id: string;
|
||||
admin_password: string;
|
||||
login_password: string;
|
||||
max_draw_times: number;
|
||||
background_config: string;
|
||||
hide_positions: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Student {
|
||||
student_id: string;
|
||||
draw_count: number;
|
||||
first_draw_at?: string;
|
||||
last_draw_at?: string;
|
||||
}
|
||||
|
||||
export interface LotteryRecord {
|
||||
id: string;
|
||||
student_id: string;
|
||||
prize_id: string;
|
||||
prize_name: string;
|
||||
prize_level: number;
|
||||
draw_time: string;
|
||||
is_synced: number;
|
||||
cache_data: string;
|
||||
}
|
||||
|
||||
export interface DrawResult {
|
||||
success: boolean;
|
||||
prize?: Prize;
|
||||
message: string;
|
||||
student_id: string;
|
||||
draw_time: string;
|
||||
}
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
34
start.sh
Normal file
34
start.sh
Normal file
@@ -0,0 +1,34 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 抽奖系统启动脚本
|
||||
echo "正在启动抽奖系统..."
|
||||
|
||||
# 检查依赖是否安装
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "正在安装依赖..."
|
||||
npm install
|
||||
fi
|
||||
|
||||
# 启动后端服务器
|
||||
echo "启动后端服务器 (端口 3001)..."
|
||||
node server/index.js &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# 等待后端启动
|
||||
sleep 3
|
||||
|
||||
# 启动前端开发服务器
|
||||
echo "启动前端开发服务器 (端口 5173)..."
|
||||
npm run dev &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo "系统启动完成!"
|
||||
echo "前端地址: http://localhost:5173"
|
||||
echo "后端API: http://localhost:3001"
|
||||
echo "按 Ctrl+C 停止所有服务"
|
||||
|
||||
# 捕获退出信号,清理进程
|
||||
trap 'echo "正在停止服务..."; kill $BACKEND_PID $FRONTEND_PID; exit' INT TERM
|
||||
|
||||
# 等待进程结束
|
||||
wait
|
||||
13
tailwind.config.js
Normal file
13
tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
},
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
36
tsconfig.json
Normal file
36
tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": [
|
||||
"ES2020",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": false,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": false,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"noUncheckedSideEffectImports": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src",
|
||||
"api"
|
||||
]
|
||||
}
|
||||
70
vite.config.ts
Normal file
70
vite.config.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { traeBadgePlugin } from 'vite-plugin-trae-solo-badge';
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
build: {
|
||||
sourcemap: 'hidden',
|
||||
// 代码分割优化
|
||||
rollupOptions: {
|
||||
output: {
|
||||
manualChunks: {
|
||||
vendor: ['react', 'react-dom', 'react-router-dom'],
|
||||
ui: ['lucide-react'],
|
||||
utils: ['zustand', 'socket.io-client']
|
||||
}
|
||||
}
|
||||
},
|
||||
// 压缩优化
|
||||
minify: 'terser',
|
||||
terserOptions: {
|
||||
compress: {
|
||||
drop_console: true,
|
||||
drop_debugger: true
|
||||
}
|
||||
},
|
||||
// 构建性能优化
|
||||
chunkSizeWarningLimit: 1000
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
configure: (proxy) => {
|
||||
proxy.on('error', (err) => {
|
||||
console.log('proxy error', err);
|
||||
});
|
||||
proxy.on('proxyReq', (proxyReq, req) => {
|
||||
console.log('Sending Request to the Target:', req.method, req.url);
|
||||
});
|
||||
proxy.on('proxyRes', (proxyRes, req) => {
|
||||
console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
react({
|
||||
babel: {
|
||||
plugins: [
|
||||
'react-dev-locator',
|
||||
],
|
||||
},
|
||||
}),
|
||||
traeBadgePlugin({
|
||||
variant: 'dark',
|
||||
position: 'bottom-right',
|
||||
prodOnly: true,
|
||||
clickable: true,
|
||||
clickUrl: 'https://www.trae.ai/solo?showJoin=1',
|
||||
autoTheme: true,
|
||||
autoThemeTarget: '#root'
|
||||
}),
|
||||
tsconfigPaths()
|
||||
],
|
||||
})
|
||||
16
原始需求.md
Normal file
16
原始需求.md
Normal file
@@ -0,0 +1,16 @@
|
||||
- 写一个Windows端有界面的抽奖程序
|
||||
- 需要一个输入框,一个输入键盘.
|
||||
- 输入框只输入数字,为一个12位的纯数字学号
|
||||
- 设置一个退格键,一个清空键,功能是退格输入框内容或清空输入框内容
|
||||
- 后台可以设置总奖数,可以设置一等奖的奖品名称/数量,二等奖的奖品名称/数量,三等奖的奖品名称/数量,总奖数减去一等奖二等将三等将的数量为谢谢参与奖.
|
||||
- 抽奖页按1920*1080无滚动条布局.纵向分布为3列,左边一列为奖项设置及开出情况和概率.中间一列为学号输入框和屏幕键盘,屏幕键盘采用140px*140px间距10px为矩阵布局.右边一列为开奖情况.宽度比例分别为2:5:2.
|
||||
- 后台可以设置所有学号可以抽奖几次.默认是1次.
|
||||
- 学生输入学号点击抽奖以后,软件自动记录学生学号并记录抽奖时间及所中奖项.并根据后台设置的学号抽奖次数进行判断是否已抽奖或继续抽奖.
|
||||
- 抽奖界面要显示出各级奖品名称以及中奖概率和已中出数量.不显示谢谢参与奖的数量.
|
||||
- 所有中奖数据可以在后台随时查看.
|
||||
- 每次打开抽奖软件,均需要输入一个密码,才可以使用抽奖系统.
|
||||
- 使用H5实现无感刷新
|
||||
- 为避免网络异常导致的延迟,在抽奖过程中,优先使用本地缓存,待网络恢复正常以后再同步到数据服务器.
|
||||
- 网站端提供数据查询接口,可以实时查看出奖情况,后台可以设置隐藏学号的哪几个位置的数据.以*号代替.
|
||||
- 页面简单,清晰,可以替换背景图片,并支持背景图片大小位置调整.背景图片要可以从本地选择.
|
||||
- 点击抽奖后,弹出浮窗,给出滚动效果,3秒摇奖效果以后,再显示中奖信息.
|
||||
408
部署文档.md
Normal file
408
部署文档.md
Normal file
@@ -0,0 +1,408 @@
|
||||
# 抽奖系统部署文档
|
||||
|
||||
## 1. 项目简介
|
||||
|
||||
本项目是一个基于 React + TypeScript + Vite 技术栈开发的学号抽奖系统,为学校活动提供公平、透明的抽奖服务。系统采用前端单页应用架构,使用 SQLite 本地数据库存储数据,支持离线缓存和实时同步功能。
|
||||
|
||||
### 主要功能特性
|
||||
|
||||
- **多级奖项设置**:支持一、二、三等奖及特别奖的灵活配置
|
||||
- **学号抽奖**:基于12位学号的公平抽奖机制
|
||||
- **实时动画**:炫酷的抽奖动画效果和撒花庆祝
|
||||
- **数据统计**:完整的中奖记录和数据分析
|
||||
- **离线缓存**:网络异常时仍可正常运行
|
||||
- **管理后台**:奖项配置、数据查看、系统设置
|
||||
- **响应式设计**:适配不同屏幕尺寸
|
||||
|
||||
## 2. 环境要求
|
||||
|
||||
### 系统要求
|
||||
- **操作系统**:Windows 10+、macOS 10.15+、Linux (Ubuntu 18.04+)
|
||||
- **浏览器**:Chrome 90+、Firefox 88+、Safari 14+、Edge 90+
|
||||
- **屏幕分辨率**:推荐 1920×1080 或更高
|
||||
|
||||
### 软件依赖
|
||||
- **Node.js**:版本 18.19.1 或更高 (推荐使用 LTS 版本)
|
||||
- **npm**:版本 9.0+ (随 Node.js 安装)
|
||||
- **Git**:用于代码克隆和版本管理
|
||||
|
||||
### 硬件要求
|
||||
- **内存**:最低 4GB RAM,推荐 8GB+
|
||||
- **存储空间**:至少 1GB 可用空间
|
||||
- **网络**:开发时需要网络连接下载依赖
|
||||
|
||||
## 3. 安装步骤
|
||||
|
||||
### 3.1 克隆项目代码
|
||||
|
||||
```bash
|
||||
# 克隆项目到本地
|
||||
git clone <项目仓库地址>
|
||||
cd draw
|
||||
|
||||
# 或者直接下载项目压缩包并解压
|
||||
```
|
||||
|
||||
### 3.2 安装项目依赖
|
||||
|
||||
```bash
|
||||
# 安装所有依赖包
|
||||
npm install
|
||||
|
||||
# 如果安装速度慢,可以使用国内镜像
|
||||
npm install --registry=https://registry.npmmirror.com
|
||||
```
|
||||
|
||||
### 3.3 数据库初始化
|
||||
|
||||
项目使用 SQLite 数据库,首次运行时会自动创建数据库文件:
|
||||
|
||||
```bash
|
||||
# 数据库文件位置:./lottery.db
|
||||
# 初始化脚本位置:./src/database/init.sql
|
||||
```
|
||||
|
||||
数据库包含以下表结构:
|
||||
- `system_config`:系统配置表
|
||||
- `prize_config`:奖项配置表
|
||||
- `lottery_record`:抽奖记录表
|
||||
- `student`:学生信息表
|
||||
|
||||
### 3.4 环境配置检查
|
||||
|
||||
```bash
|
||||
# 检查 Node.js 版本
|
||||
node --version
|
||||
|
||||
# 检查 npm 版本
|
||||
npm --version
|
||||
|
||||
# 检查 TypeScript 编译
|
||||
npm run check
|
||||
```
|
||||
|
||||
## 4. 配置说明
|
||||
|
||||
### 4.1 端口配置
|
||||
|
||||
默认开发服务器端口为 `5173`,可在 `vite.config.ts` 中修改:
|
||||
|
||||
```typescript
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
host: true, // 允许外部访问
|
||||
open: true // 自动打开浏览器
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4.2 数据库配置
|
||||
|
||||
数据库配置位于 `src/database/database.ts`:
|
||||
|
||||
```typescript
|
||||
// SQLite 数据库文件路径
|
||||
const DB_PATH = './lottery.db'
|
||||
|
||||
// 数据库连接选项
|
||||
const options = {
|
||||
verbose: console.log, // 开发环境日志
|
||||
fileMustExist: false // 自动创建数据库
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 系统默认配置
|
||||
|
||||
- **管理员密码**:`admin123` (首次登录后可修改)
|
||||
- **最大抽奖次数**:每个学号限制 1 次
|
||||
- **学号隐私保护**:默认隐藏第 3-6 位数字
|
||||
- **奖项等级**:支持 1-4 级奖项设置
|
||||
|
||||
## 5. 运行和启动
|
||||
|
||||
### 5.1 开发环境启动
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
npm run dev
|
||||
|
||||
# 服务器启动后访问地址
|
||||
# 本地访问:http://localhost:5173
|
||||
# 局域网访问:http://[本机IP]:5173
|
||||
```
|
||||
|
||||
### 5.2 生产环境构建
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
npm run build
|
||||
|
||||
# 构建文件输出到 dist 目录
|
||||
# 可以部署到任何静态文件服务器
|
||||
```
|
||||
|
||||
### 5.3 预览生产构建
|
||||
|
||||
```bash
|
||||
# 预览生产构建结果
|
||||
npm run preview
|
||||
|
||||
# 访问地址:http://localhost:4173
|
||||
```
|
||||
|
||||
### 5.4 代码检查和格式化
|
||||
|
||||
```bash
|
||||
# TypeScript 类型检查
|
||||
npm run check
|
||||
|
||||
# ESLint 代码检查
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## 6. 生产环境部署
|
||||
|
||||
### 6.1 静态文件部署
|
||||
|
||||
1. **构建项目**:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
2. **部署 dist 目录**到以下任一服务器:
|
||||
- **Nginx**:配置静态文件服务和 SPA 路由
|
||||
- **Apache**:启用 mod_rewrite 支持 SPA
|
||||
- **Vercel**:直接部署,自动配置
|
||||
- **Netlify**:拖拽部署,零配置
|
||||
|
||||
### 6.2 Nginx 配置示例
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
root /path/to/dist;
|
||||
index index.html;
|
||||
|
||||
# SPA 路由支持
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# 静态资源缓存
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 安全头设置
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-Content-Type-Options "nosniff";
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 Docker 部署
|
||||
|
||||
创建 `Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
FROM node:18-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:alpine
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
```
|
||||
|
||||
构建和运行:
|
||||
|
||||
```bash
|
||||
# 构建镜像
|
||||
docker build -t lottery-system .
|
||||
|
||||
# 运行容器
|
||||
docker run -d -p 80:80 --name lottery lottery-system
|
||||
```
|
||||
|
||||
### 6.4 宝塔面板部署
|
||||
|
||||
1. **上传项目文件**到网站根目录
|
||||
2. **安装 Node.js**:在宝塔面板安装 Node.js 环境
|
||||
3. **构建项目**:
|
||||
```bash
|
||||
cd /www/wwwroot/your-site
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
4. **配置网站**:将网站根目录指向 `dist` 文件夹
|
||||
5. **设置伪静态**:添加 SPA 路由规则
|
||||
|
||||
## 7. 常见问题和解决方案
|
||||
|
||||
### 7.1 安装依赖问题
|
||||
|
||||
**问题**:`npm install` 失败或速度慢
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 清理缓存
|
||||
npm cache clean --force
|
||||
|
||||
# 使用国内镜像
|
||||
npm config set registry https://registry.npmmirror.com
|
||||
|
||||
# 或使用 cnpm
|
||||
npm install -g cnpm
|
||||
cnpm install
|
||||
```
|
||||
|
||||
### 7.2 端口占用问题
|
||||
|
||||
**问题**:端口 5173 被占用
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 查看端口占用
|
||||
netstat -ano | findstr :5173
|
||||
|
||||
# 杀死占用进程
|
||||
taskkill /PID <进程ID> /F
|
||||
|
||||
# 或修改端口
|
||||
npm run dev -- --port 3000
|
||||
```
|
||||
|
||||
### 7.3 数据库权限问题
|
||||
|
||||
**问题**:SQLite 数据库创建失败
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查目录权限
|
||||
ls -la lottery.db
|
||||
|
||||
# 修改权限 (Linux/macOS)
|
||||
chmod 666 lottery.db
|
||||
chmod 755 .
|
||||
```
|
||||
|
||||
### 7.4 构建失败问题
|
||||
|
||||
**问题**:TypeScript 编译错误
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 检查类型错误
|
||||
npm run check
|
||||
|
||||
# 更新依赖
|
||||
npm update
|
||||
|
||||
# 重新安装
|
||||
rm -rf node_modules package-lock.json
|
||||
npm install
|
||||
```
|
||||
|
||||
### 7.5 浏览器兼容性问题
|
||||
|
||||
**问题**:在旧版浏览器中功能异常
|
||||
|
||||
**解决方案**:
|
||||
- 升级浏览器到支持的版本
|
||||
- 检查控制台错误信息
|
||||
- 确保启用 JavaScript
|
||||
|
||||
### 7.6 网络连接问题
|
||||
|
||||
**问题**:局域网无法访问
|
||||
|
||||
**解决方案**:
|
||||
```bash
|
||||
# 启动时绑定所有网络接口
|
||||
npm run dev -- --host 0.0.0.0
|
||||
|
||||
# 检查防火墙设置
|
||||
# Windows: 允许 Node.js 通过防火墙
|
||||
# Linux: sudo ufw allow 5173
|
||||
```
|
||||
|
||||
## 8. 项目结构说明
|
||||
|
||||
```
|
||||
draw/
|
||||
├── public/ # 静态资源目录
|
||||
│ └── favicon.svg # 网站图标
|
||||
├── src/ # 源代码目录
|
||||
│ ├── components/ # React 组件
|
||||
│ │ ├── ConfettiEffect.tsx # 撒花效果组件
|
||||
│ │ ├── LotteryAnimation.tsx # 抽奖动画组件
|
||||
│ │ ├── PrizeDisplay.tsx # 奖项展示组件
|
||||
│ │ └── ... # 其他组件
|
||||
│ ├── pages/ # 页面组件
|
||||
│ │ ├── Home.tsx # 首页/登录页
|
||||
│ │ ├── Lottery.tsx # 抽奖主界面
|
||||
│ │ ├── Admin.tsx # 管理后台
|
||||
│ │ └── ... # 其他页面
|
||||
│ ├── database/ # 数据库相关
|
||||
│ │ ├── database.ts # 数据库连接
|
||||
│ │ └── init.sql # 初始化脚本
|
||||
│ ├── services/ # 业务服务
|
||||
│ │ ├── lotteryService.ts # 抽奖逻辑
|
||||
│ │ ├── cacheService.ts # 缓存服务
|
||||
│ │ └── ... # 其他服务
|
||||
│ ├── store/ # 状态管理
|
||||
│ │ └── index.ts # Zustand 状态
|
||||
│ ├── hooks/ # 自定义 Hooks
|
||||
│ ├── contexts/ # React Context
|
||||
│ ├── types.ts # TypeScript 类型定义
|
||||
│ └── main.tsx # 应用入口
|
||||
├── .trae/ # 项目文档
|
||||
│ └── documents/ # 需求和架构文档
|
||||
├── lottery.db # SQLite 数据库文件
|
||||
├── package.json # 项目配置和依赖
|
||||
├── vite.config.ts # Vite 构建配置
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── tailwind.config.js # Tailwind CSS 配置
|
||||
└── README.md # 项目说明
|
||||
```
|
||||
|
||||
### 核心文件说明
|
||||
|
||||
- **App.tsx**:应用主组件,包含路由配置
|
||||
- **main.tsx**:应用入口,挂载 React 应用
|
||||
- **database.ts**:SQLite 数据库操作封装
|
||||
- **lotteryService.ts**:抽奖核心业务逻辑
|
||||
- **types.ts**:全局 TypeScript 类型定义
|
||||
- **index.css**:全局样式和 Tailwind CSS 导入
|
||||
|
||||
### 重要配置文件
|
||||
|
||||
- **vite.config.ts**:开发服务器和构建配置
|
||||
- **tsconfig.json**:TypeScript 编译选项
|
||||
- **tailwind.config.js**:CSS 框架配置
|
||||
- **package.json**:项目元信息和脚本命令
|
||||
|
||||
---
|
||||
|
||||
## 技术支持
|
||||
|
||||
如遇到部署问题,请检查:
|
||||
|
||||
1. **环境版本**:确保 Node.js 版本符合要求
|
||||
2. **依赖安装**:确保所有依赖正确安装
|
||||
3. **端口占用**:确保端口未被其他程序占用
|
||||
4. **权限设置**:确保有足够的文件读写权限
|
||||
5. **网络连接**:确保网络连接正常
|
||||
|
||||
更多技术细节请参考项目文档目录下的需求文档和架构文档。
|
||||
|
||||
**项目版本**:v1.0.0
|
||||
**最后更新**:2024年12月
|
||||
**技术栈**:React 18 + TypeScript + Vite + SQLite
|
||||
Reference in New Issue
Block a user