初始化抽奖程序

This commit is contained in:
zhugaoliang 2025-04-07 16:56:13 +08:00
commit 8c846f860e
36 changed files with 5704 additions and 0 deletions

83
README.md Normal file
View File

@ -0,0 +1,83 @@
# 抽奖系统
基于 Vue 3 + Vite + TailwindCSS + Element Plus 的前端抽奖系统,后端使用 Python + FastAPI + SQLite 数据库。
## 项目结构
- `frontend/`: Vue 3 + Vite + TailwindCSS + Element Plus 前端
- `backend/`: Python + FastAPI + SQLite 后端
## 功能特性
根据流程图实现的抽奖系统,包含以下功能:
1. 主题活动首页
2. 扫描/小卡参与活动门票抽奖
3. 抽奖中奖/用户填写个人资料
4. 抽奖未中奖/可重复扫描步骤
5. 用户收集一套5张小卡/后台自动判定可获得一份奖品
6. 用户填写寄送地址/寄送奖品
## 技术栈
### 前端
- Vue 3
- TypeScript
- Vite
- TailwindCSS
- Element Plus UI 组件库
- Font Awesome 图标
- Chart.js 图表库
### 后端
- Python
- FastAPI
- SQLite 数据库
- SQLAlchemy ORM
## 安装与运行
### 前端
```bash
# 进入前端目录
cd frontend
# 安装依赖
npm install
# 启动开发服务器
npm run dev
```
### 后端
```bash
# 进入后端目录
cd backend
# 创建并激活虚拟环境 (可选)
python -m venv venv
# Windows
venv\Scripts\activate
# Linux/Mac
source venv/bin/activate
# 安装依赖
pip install -r requirements.txt
# 启动后端服务
uvicorn main:app --reload
```
## API 文档
启动后端服务后,访问 http://localhost:8000/docs 查看 API 文档。
## 系统截图
![抽奖系统流程图](流程图.png)
## 开发者
项目开发于 2025 年 4 月

3
backend/.env Normal file
View File

@ -0,0 +1,3 @@
DATABASE_URL=sqlite:///./lottery.db
DEBUG=True
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173

Binary file not shown.

Binary file not shown.

Binary file not shown.

266
backend/admin.py Normal file
View File

@ -0,0 +1,266 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from passlib.context import CryptContext
from typing import List, Optional
from datetime import datetime, timedelta
from pydantic import BaseModel
import jwt
from jose import JWTError, jwt
from models import User, Prize, Card, UserPrize
from sqlalchemy import desc
# Database dependency
def get_db():
from sqlalchemy.orm import sessionmaker
from sqlalchemy import create_engine
DATABASE_URL = "sqlite:///./lottery.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
db = SessionLocal()
try:
yield db
finally:
db.close()
# Admin router
router = APIRouter(prefix="/admin", tags=["admin"])
# Password hashing
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# JWT settings
SECRET_KEY = "YOUR_SECRET_KEY_HERE" # In production, use a secure secret key
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# Token model
class Token(BaseModel):
access_token: str
token_type: str
# TokenData model
class TokenData(BaseModel):
username: Optional[str] = None
# Admin model
class AdminUser(BaseModel):
username: str
email: Optional[str] = None
is_active: bool = True
# Admin in DB
class AdminUserInDB(AdminUser):
hashed_password: str
# Admin list (this would typically come from a database)
fake_admin_db = {
"admin": {
"username": "admin",
"email": "admin@example.com",
"hashed_password": pwd_context.hash("adminpassword"), # In production, use a secure password
"is_active": True,
}
}
# Functions for authentication
def verify_password(plain_password, hashed_password):
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password):
return pwd_context.hash(password)
def get_admin_user(db, username: str):
if username in fake_admin_db:
user_dict = fake_admin_db[username]
return AdminUserInDB(**user_dict)
return None
def authenticate_admin(username: str, password: str):
user = get_admin_user(None, username)
if not user:
return False
if not verify_password(password, user.hashed_password):
return False
return user
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=15)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
# OAuth2 scheme
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/admin/token")
# Dependency to get current admin
async def get_current_admin(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except JWTError:
raise credentials_exception
user = get_admin_user(None, username=token_data.username)
if user is None:
raise credentials_exception
return user
# Login route
@router.post("/token", response_model=Token)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends()):
user = authenticate_admin(form_data.username, form_data.password)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
)
return {"access_token": access_token, "token_type": "bearer"}
# Protected route to test authentication
@router.get("/me", response_model=AdminUser)
async def read_admin_me(current_user: AdminUserInDB = Depends(get_current_admin)):
return current_user
# Order model for responses
class OrderResponse(BaseModel):
id: int
user_id: int
user_name: Optional[str] = None
user_phone: Optional[str] = None
user_address: Optional[str] = None
prize_id: int
prize_name: str
prize_description: Optional[str] = None
awarded_at: datetime
is_shipped: bool
shipped_at: Optional[datetime] = None
# Get all orders
@router.get("/orders", response_model=List[OrderResponse])
async def get_orders(
current_user: AdminUserInDB = Depends(get_current_admin),
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100,
):
# Join query to get orders with user and prize information
orders = (
db.query(
UserPrize.id,
UserPrize.user_id,
User.name.label("user_name"),
User.phone.label("user_phone"),
User.address.label("user_address"),
UserPrize.prize_id,
Prize.name.label("prize_name"),
Prize.description.label("prize_description"),
UserPrize.awarded_at,
UserPrize.is_shipped,
UserPrize.shipped_at,
)
.join(User, UserPrize.user_id == User.id)
.join(Prize, UserPrize.prize_id == Prize.id)
.order_by(desc(UserPrize.awarded_at))
.offset(skip)
.limit(limit)
.all()
)
return [
OrderResponse(
id=order.id,
user_id=order.user_id,
user_name=order.user_name,
user_phone=order.user_phone,
user_address=order.user_address,
prize_id=order.prize_id,
prize_name=order.prize_name,
prize_description=order.prize_description,
awarded_at=order.awarded_at,
is_shipped=order.is_shipped,
shipped_at=order.shipped_at,
)
for order in orders
]
# Update order shipping status
@router.put("/orders/{order_id}/ship", response_model=dict)
async def update_order_status(
order_id: int,
current_user: AdminUserInDB = Depends(get_current_admin),
db: Session = Depends(get_db),
):
order = db.query(UserPrize).filter(UserPrize.id == order_id).first()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
order.is_shipped = True
order.shipped_at = datetime.now()
db.commit()
return {"success": True, "message": "Order marked as shipped"}
# Get order details
@router.get("/orders/{order_id}", response_model=OrderResponse)
async def get_order_details(
order_id: int,
current_user: AdminUserInDB = Depends(get_current_admin),
db: Session = Depends(get_db),
):
order = (
db.query(
UserPrize.id,
UserPrize.user_id,
User.name.label("user_name"),
User.phone.label("user_phone"),
User.address.label("user_address"),
UserPrize.prize_id,
Prize.name.label("prize_name"),
Prize.description.label("prize_description"),
UserPrize.awarded_at,
UserPrize.is_shipped,
UserPrize.shipped_at,
)
.join(User, UserPrize.user_id == User.id)
.join(Prize, UserPrize.prize_id == Prize.id)
.filter(UserPrize.id == order_id)
.first()
)
if not order:
raise HTTPException(status_code=404, detail="Order not found")
return OrderResponse(
id=order.id,
user_id=order.user_id,
user_name=order.user_name,
user_phone=order.user_phone,
user_address=order.user_address,
prize_id=order.prize_id,
prize_name=order.prize_name,
prize_description=order.prize_description,
awarded_at=order.awarded_at,
is_shipped=order.is_shipped,
shipped_at=order.shipped_at,
)

BIN
backend/lottery.db Normal file

Binary file not shown.

373
backend/main.py Normal file
View File

@ -0,0 +1,373 @@
from fastapi import FastAPI, Depends, HTTPException, UploadFile, File, Form, BackgroundTasks
import admin
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from sqlalchemy import create_engine, Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker, Session
from sqlalchemy.sql import func
from pydantic import BaseModel
from typing import List, Optional, Dict, Any
import random
import datetime
import os
import uuid
from pathlib import Path
# Create the FastAPI app
app = FastAPI(
title="Lottery System API",
description="Backend API for lottery system with SQLite database",
version="1.0.0"
)
# Include admin router
app.include_router(admin.router)
# Add CORS middleware to allow frontend requests
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, change this to your frontend domain
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Database setup
DATABASE_URL = "sqlite:///./lottery.db"
engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
# Database dependency
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
# Models
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=True)
phone = Column(String(20), nullable=True)
address = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
class Prize(Base):
__tablename__ = "prizes"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100))
description = Column(Text, nullable=True)
probability = Column(Float, default=0)
available_quantity = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
class Card(Base):
__tablename__ = "cards"
id = Column(Integer, primary_key=True, index=True)
card_id = Column(String(50), unique=True)
card_type = Column(String(20))
is_collected = Column(Boolean, default=False)
collected_by = Column(Integer, ForeignKey("users.id"), nullable=True)
collected_at = Column(DateTime, nullable=True)
class UserPrize(Base):
__tablename__ = "user_prizes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
prize_id = Column(Integer, ForeignKey("prizes.id"))
awarded_at = Column(DateTime, default=func.now())
is_shipped = Column(Boolean, default=False)
shipped_at = Column(DateTime, nullable=True)
# Create tables
Base.metadata.create_all(bind=engine)
# Pydantic Models for API
class UserCreate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
class UserUpdate(BaseModel):
name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
class PrizeCreate(BaseModel):
name: str
description: Optional[str] = None
probability: float
available_quantity: int = 0
is_active: bool = True
class CardCreate(BaseModel):
card_type: str
class CardCollect(BaseModel):
card_id: str
user_id: int
class DrawResult(BaseModel):
success: bool
prize: Optional[Dict[str, Any]] = None
message: Optional[str] = None
class ShippingUpdate(BaseModel):
user_id: int
address: str
# Initialize default prizes if none exist
def initialize_prizes(db: Session):
# Check if prizes already exist
existing_prizes = db.query(Prize).count()
if existing_prizes == 0:
default_prizes = [
{"name": "一等奖", "description": "豪华大礼包", "probability": 0.01, "available_quantity": 5},
{"name": "二等奖", "description": "精美礼品", "probability": 0.05, "available_quantity": 20},
{"name": "三等奖", "description": "纪念品", "probability": 0.2, "available_quantity": 50},
{"name": "鼓励奖", "description": "小礼品", "probability": 0.3, "available_quantity": 100},
{"name": "谢谢参与", "description": "下次再来", "probability": 0.44, "available_quantity": 999},
]
for prize_data in default_prizes:
db_prize = Prize(**prize_data)
db.add(db_prize)
db.commit()
# API Routes
@app.on_event("startup")
async def startup_event():
db = SessionLocal()
initialize_prizes(db)
db.close()
@app.get("/")
def read_root():
return {"message": "Welcome to the Lottery System API"}
# User routes
@app.post("/users/", response_model=dict)
def create_user(user: UserCreate, db: Session = Depends(get_db)):
db_user = User(**user.dict())
db.add(db_user)
db.commit()
db.refresh(db_user)
return {"success": True, "user_id": db_user.id}
@app.put("/users/{user_id}", response_model=dict)
def update_user(user_id: int, user: UserUpdate, db: Session = Depends(get_db)):
db_user = db.query(User).filter(User.id == user_id).first()
if not db_user:
raise HTTPException(status_code=404, detail="User not found")
for key, value in user.dict(exclude_unset=True).items():
setattr(db_user, key, value)
db.commit()
db.refresh(db_user)
return {"success": True, "message": "User updated successfully"}
# Draw lottery route
@app.post("/draw/{user_id}", response_model=DrawResult)
def draw_lottery(user_id: int, db: Session = Depends(get_db)):
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get active prizes
prizes = db.query(Prize).filter(Prize.is_active == True, Prize.available_quantity > 0).all()
# No prizes available
if not prizes:
return DrawResult(success=False, message="No prizes available")
# Calculate draw result based on probability
random_num = random.random()
cumulative_prob = 0
selected_prize = None
for prize in prizes:
cumulative_prob += prize.probability
if random_num <= cumulative_prob:
selected_prize = prize
break
if not selected_prize:
selected_prize = prizes[-1] # Default to last prize if no match (should be "谢谢参与")
# Record prize win if not "谢谢参与"
if selected_prize.name != "谢谢参与":
# Decrement available quantity
selected_prize.available_quantity -= 1
# Record user win
user_prize = UserPrize(user_id=user_id, prize_id=selected_prize.id)
db.add(user_prize)
db.commit()
# Return prize info
return DrawResult(
success=True,
prize={
"id": selected_prize.id,
"name": selected_prize.name,
"description": selected_prize.description
}
)
# Card routes
@app.post("/cards/generate", response_model=dict)
def generate_card(card: CardCreate, db: Session = Depends(get_db)):
card_id = f"CARD-{uuid.uuid4().hex[:8].upper()}"
db_card = Card(card_id=card_id, card_type=card.card_type)
db.add(db_card)
db.commit()
db.refresh(db_card)
return {"success": True, "card_id": card_id}
@app.post("/cards/collect", response_model=dict)
def collect_card(card_data: CardCollect, db: Session = Depends(get_db)):
# Check if card exists
card = db.query(Card).filter(Card.card_id == card_data.card_id).first()
if not card:
raise HTTPException(status_code=404, detail="Card not found")
# Check if card is already collected
if card.is_collected:
return {"success": False, "message": "Card is already collected"}
# Collect the card
card.is_collected = True
card.collected_by = card_data.user_id
card.collected_at = datetime.datetime.now()
db.commit()
# Check if user has collected 5 cards
collected_cards = db.query(Card).filter(
Card.collected_by == card_data.user_id,
Card.is_collected == True
).count()
return {
"success": True,
"card_id": card.card_id,
"collected_count": collected_cards,
"has_complete_set": collected_cards >= 5
}
@app.get("/users/{user_id}/cards", response_model=dict)
def get_user_cards(user_id: int, db: Session = Depends(get_db)):
cards = db.query(Card).filter(Card.collected_by == user_id, Card.is_collected == True).all()
return {
"success": True,
"cards": [{"card_id": card.card_id, "card_type": card.card_type} for card in cards],
"count": len(cards),
"has_complete_set": len(cards) >= 5
}
# Prize claiming for card collection
@app.post("/cards/claim-prize/{user_id}", response_model=DrawResult)
def claim_prize_for_cards(user_id: int, db: Session = Depends(get_db)):
# Check if user has 5 or more cards
card_count = db.query(Card).filter(Card.collected_by == user_id, Card.is_collected == True).count()
if card_count < 5:
return DrawResult(success=False, message="Not enough cards collected. Need at least 5 cards.")
# Get a random prize (excluding "谢谢参与")
prizes = db.query(Prize).filter(
Prize.is_active == True,
Prize.available_quantity > 0,
Prize.name != "谢谢参与"
).all()
if not prizes:
return DrawResult(success=False, message="No prizes available")
# Select a random prize from available ones
selected_prize = random.choice(prizes)
# Decrement available quantity
selected_prize.available_quantity -= 1
# Record user win
user_prize = UserPrize(user_id=user_id, prize_id=selected_prize.id)
db.add(user_prize)
db.commit()
# Return prize info
return DrawResult(
success=True,
prize={
"id": selected_prize.id,
"name": selected_prize.name,
"description": selected_prize.description
}
)
# Update shipping address
@app.put("/shipping/update", response_model=dict)
def update_shipping(shipping_data: ShippingUpdate, db: Session = Depends(get_db)):
# Find user
user = db.query(User).filter(User.id == shipping_data.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Update address
user.address = shipping_data.address
db.commit()
# Mark prizes as shipped
prizes = db.query(UserPrize).filter(
UserPrize.user_id == shipping_data.user_id,
UserPrize.is_shipped == False
).all()
for prize in prizes:
prize.is_shipped = True
prize.shipped_at = datetime.datetime.now()
db.commit()
return {"success": True, "message": "Shipping information updated"}
# Stats routes for dashboard
@app.get("/stats", response_model=dict)
def get_stats(db: Session = Depends(get_db)):
user_count = db.query(User).count()
prizes_awarded = db.query(UserPrize).count()
prizes_shipped = db.query(UserPrize).filter(UserPrize.is_shipped == True).count()
cards_collected = db.query(Card).filter(Card.is_collected == True).count()
# Prize distribution
prize_distribution = db.query(
Prize.name, func.count(UserPrize.id)
).join(
UserPrize, UserPrize.prize_id == Prize.id, isouter=True
).group_by(
Prize.id
).all()
return {
"user_count": user_count,
"prizes_awarded": prizes_awarded,
"prizes_shipped": prizes_shipped,
"cards_collected": cards_collected,
"prize_distribution": [{"name": name, "count": count} for name, count in prize_distribution]
}
# Run with: uvicorn main:app --reload
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

56
backend/models.py Normal file
View File

@ -0,0 +1,56 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, ForeignKey, DateTime, Text
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.sql import func
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), nullable=True)
phone = Column(String(20), nullable=True)
address = Column(Text, nullable=True)
created_at = Column(DateTime, default=func.now())
def __repr__(self):
return f"<User(id={self.id}, name={self.name})>"
class Prize(Base):
__tablename__ = "prizes"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100))
description = Column(Text, nullable=True)
probability = Column(Float, default=0)
available_quantity = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
def __repr__(self):
return f"<Prize(id={self.id}, name={self.name}, probability={self.probability})>"
class Card(Base):
__tablename__ = "cards"
id = Column(Integer, primary_key=True, index=True)
card_id = Column(String(50), unique=True)
card_type = Column(String(20))
is_collected = Column(Boolean, default=False)
collected_by = Column(Integer, ForeignKey("users.id"), nullable=True)
collected_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<Card(id={self.id}, card_id={self.card_id}, collected={self.is_collected})>"
class UserPrize(Base):
__tablename__ = "user_prizes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"))
prize_id = Column(Integer, ForeignKey("prizes.id"))
awarded_at = Column(DateTime, default=func.now())
is_shipped = Column(Boolean, default=False)
shipped_at = Column(DateTime, nullable=True)
def __repr__(self):
return f"<UserPrize(id={self.id}, user_id={self.user_id}, prize_id={self.prize_id})>"

6
backend/requirements.txt Normal file
View File

@ -0,0 +1,6 @@
fastapi>=0.95.0
uvicorn>=0.21.1
pydantic>=2.0.0
sqlalchemy>=2.0.0
aiosqlite>=0.18.0
python-multipart>=0.0.6

24
frontend/.gitignore vendored Normal file
View 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?

3
frontend/.vscode/extensions.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

5
frontend/README.md Normal file
View File

@ -0,0 +1,5 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + Vue + TS</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

3357
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.7.2",
"autoprefixer": "^10.4.21",
"axios": "^1.8.4",
"chart.js": "^4.4.8",
"element-plus": "^2.9.7",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"vue": "^3.5.13",
"vue-chartjs": "^5.3.2",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.2.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.7.2",
"vite": "^6.2.0",
"vue-tsc": "^2.2.4"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
frontend/public/vite.svg Normal file
View 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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

19
frontend/src/App.vue Normal file
View File

@ -0,0 +1,19 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
// Router instance
const router = useRouter()
//
const goToAdmin = () => {
router.push('/admin')
}
</script>
<template>
<router-view></router-view>
</template>
<style scoped>
/* Global styles can be added here */
</style>

View 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="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

View File

@ -0,0 +1,208 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { statsAPI } from '../services/api'
import { Bar, Pie, Doughnut } from 'vue-chartjs'
import { Chart as ChartJS, Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement } from 'chart.js'
// Register ChartJS components
ChartJS.register(Title, Tooltip, Legend, BarElement, CategoryScale, LinearScale, ArcElement)
// Stats data
const stats = ref({
user_count: 0,
prizes_awarded: 0,
prizes_shipped: 0,
cards_collected: 0,
prize_distribution: [] as { name: string, count: number }[]
})
// Load stats
const loadStats = async () => {
try {
const data = await statsAPI.getStats()
stats.value = data
} catch (error) {
console.error('Failed to load stats:', error)
}
}
// Chart data
const prizeDistributionData = computed(() => {
return {
labels: stats.value.prize_distribution.map(item => item.name),
datasets: [
{
label: '奖品分布',
backgroundColor: [
'#6A5ACD', // primary
'#FF6B6B', // secondary
'#4ECDC4', // accent
'#2ECC71', // success
'#FFD700', // warning
],
data: stats.value.prize_distribution.map(item => item.count),
}
]
}
})
const prizesStatusData = computed(() => {
return {
labels: ['已发放', '待发放'],
datasets: [
{
backgroundColor: ['#2ECC71', '#FFD700'],
data: [stats.value.prizes_shipped, stats.value.prizes_awarded - stats.value.prizes_shipped],
}
]
}
})
const userActivityData = computed(() => {
return {
labels: ['用户数', '收集卡片数'],
datasets: [
{
label: '用户活动统计',
backgroundColor: ['#6A5ACD', '#4ECDC4'],
data: [stats.value.user_count, stats.value.cards_collected],
}
]
}
})
// Chart options
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
},
title: {
display: true,
text: '抽奖系统数据分析'
}
}
}
// Initialize
onMounted(() => {
loadStats()
})
</script>
<template>
<div class="dashboard">
<h1 class="text-2xl font-bold text-primary mb-6">系统数据看板</h1>
<!-- Stat cards -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<div class="card bg-primary/10">
<div class="flex items-center">
<div class="rounded-full bg-primary w-12 h-12 flex items-center justify-center text-white">
<i class="fas fa-users"></i>
</div>
<div class="ml-4">
<div class="text-sm text-gray-500">用户总数</div>
<div class="text-2xl font-bold">{{ stats.user_count }}</div>
</div>
</div>
</div>
<div class="card bg-secondary/10">
<div class="flex items-center">
<div class="rounded-full bg-secondary w-12 h-12 flex items-center justify-center text-white">
<i class="fas fa-gift"></i>
</div>
<div class="ml-4">
<div class="text-sm text-gray-500">奖品发放</div>
<div class="text-2xl font-bold">{{ stats.prizes_awarded }}</div>
</div>
</div>
</div>
<div class="card bg-success/10">
<div class="flex items-center">
<div class="rounded-full bg-success w-12 h-12 flex items-center justify-center text-white">
<i class="fas fa-shipping-fast"></i>
</div>
<div class="ml-4">
<div class="text-sm text-gray-500">已发货</div>
<div class="text-2xl font-bold">{{ stats.prizes_shipped }}</div>
</div>
</div>
</div>
<div class="card bg-accent/10">
<div class="flex items-center">
<div class="rounded-full bg-accent w-12 h-12 flex items-center justify-center text-white">
<i class="fas fa-layer-group"></i>
</div>
<div class="ml-4">
<div class="text-sm text-gray-500">卡片收集</div>
<div class="text-2xl font-bold">{{ stats.cards_collected }}</div>
</div>
</div>
</div>
</div>
<!-- Charts -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div class="card">
<h2 class="text-lg font-bold mb-4">奖品分布情况</h2>
<div class="h-80">
<Pie
:data="prizeDistributionData"
:options="chartOptions"
/>
</div>
</div>
<div class="card">
<h2 class="text-lg font-bold mb-4">奖品发放状态</h2>
<div class="h-80">
<Doughnut
:data="prizesStatusData"
:options="chartOptions"
/>
</div>
</div>
<div class="card">
<h2 class="text-lg font-bold mb-4">用户活动统计</h2>
<div class="h-80">
<Bar
:data="userActivityData"
:options="chartOptions"
/>
</div>
</div>
</div>
<!-- Prize distribution table -->
<div class="card">
<h2 class="text-lg font-bold mb-4">奖品发放明细</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">奖品名称</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">发放数量</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">百分比</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="item in stats.prize_distribution" :key="item.name">
<td class="px-6 py-4 whitespace-nowrap">{{ item.name }}</td>
<td class="px-6 py-4 whitespace-nowrap">{{ item.count }}</td>
<td class="px-6 py-4 whitespace-nowrap">
{{ stats.prizes_awarded > 0 ? Math.round((item.count / stats.prizes_awarded) * 100) : 0 }}%
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,41 @@
<script setup lang="ts">
import { ref } from 'vue'
defineProps<{ msg: string }>()
const count = ref(0)
</script>
<template>
<h1>{{ msg }}</h1>
<div class="card">
<button type="button" @click="count++">count is {{ count }}</button>
<p>
Edit
<code>components/HelloWorld.vue</code> to test HMR
</p>
</div>
<p>
Check out
<a href="https://vuejs.org/guide/quick-start.html#local" target="_blank"
>create-vue</a
>, the official Vue + Vite starter
</p>
<p>
Learn more about IDE Support for Vue in the
<a
href="https://vuejs.org/guide/scaling-up/tooling.html#ide-support"
target="_blank"
>Vue Docs Scaling up Guide</a
>.
</p>
<p class="read-the-docs">Click on the Vite and Vue logos to learn more</p>
</template>
<style scoped>
.read-the-docs {
color: #888;
}
</style>

View File

@ -0,0 +1,376 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { adminAPI } from '../../services/api'
import Dashboard from '../Dashboard.vue'
// Define types
interface AdminUser {
username: string
email: string
is_active: boolean
}
interface Order {
id: number
user_id: number
user_name: string
user_phone: string
user_address: string
prize_id: number
prize_name: string
prize_description: string
awarded_at: string
shipped_at: string | null
is_shipped: boolean
}
// Define emits
const emit = defineEmits(['logout'])
// Props
const props = defineProps({
token: {
type: String,
required: true
}
})
// Admin user
const admin = ref<AdminUser>({
username: '',
email: '',
is_active: true
})
// Orders
const orders = ref<Order[]>([])
const currentOrder = ref<Order | null>(null)
const loading = ref(false)
const detailsLoading = ref(false)
const showOrderDetails = ref(false)
// Get admin profile
const getProfile = async () => {
try {
const data = await adminAPI.getProfile(props.token)
admin.value = data
} catch (error) {
console.error('Failed to get admin profile:', error)
handleLogout()
}
}
// Get all orders
const getOrders = async () => {
try {
loading.value = true
const data = await adminAPI.getOrders(props.token)
orders.value = data
} catch (error) {
console.error('Failed to get orders:', error)
ElMessage.error('获取订单列表失败')
} finally {
loading.value = false
}
}
// Get order details
const getOrderDetails = async (orderId: number) => {
try {
detailsLoading.value = true
const data = await adminAPI.getOrderDetails(props.token, orderId)
currentOrder.value = data
showOrderDetails.value = true
} catch (error) {
console.error('Failed to get order details:', error)
ElMessage.error('获取订单详情失败')
} finally {
detailsLoading.value = false
}
}
// Mark order as shipped
const shipOrder = async (orderId: number) => {
try {
await ElMessageBox.confirm(
'确认将此订单标记为已发货?',
'确认操作',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
await adminAPI.shipOrder(props.token, orderId)
ElMessage.success('订单已标记为已发货')
// Refresh order list and current order
getOrders()
if (currentOrder.value && currentOrder.value.id === orderId) {
getOrderDetails(orderId)
}
} catch (error) {
if (error !== 'cancel') {
console.error('Failed to ship order:', error)
ElMessage.error('操作失败')
}
}
}
// Logout
const handleLogout = () => {
localStorage.removeItem('admin_token')
emit('logout')
}
// Format date string
const formatDate = (dateString: string | null): string => {
if (!dateString) return '未设置'
const date = new Date(dateString)
return new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(date)
}
// Initialize
onMounted(() => {
getProfile()
getOrders()
})
</script>
<template>
<div class="admin-dashboard min-h-screen bg-gray-100">
<!-- Header -->
<header class="bg-dark text-white shadow">
<div class="container mx-auto px-4 py-4 flex justify-between items-center">
<div class="flex items-center space-x-4">
<i class="fas fa-user-shield text-2xl"></i>
<div>
<h1 class="text-xl font-bold">抽奖系统管理后台</h1>
<p class="text-sm opacity-75">欢迎回来, {{ admin.username }}</p>
</div>
</div>
<button @click="handleLogout" class="btn-secondary">
<i class="fas fa-sign-out-alt mr-2"></i>
退出登录
</button>
</div>
</header>
<!-- Main content -->
<div class="container mx-auto px-4 py-8">
<div class="flex flex-col lg:flex-row gap-8">
<!-- Sidebar -->
<div class="w-full lg:w-64 space-y-4">
<div class="card bg-primary text-white">
<div class="p-4 flex items-center space-x-3">
<div class="rounded-full bg-white text-primary w-10 h-10 flex items-center justify-center">
<i class="fas fa-user"></i>
</div>
<div>
<p class="font-bold">{{ admin.username }}</p>
<p class="text-xs">{{ admin.email || '无邮箱' }}</p>
</div>
</div>
</div>
<div class="card">
<h3 class="font-bold mb-4">快捷操作</h3>
<div class="space-y-2">
<button @click="getOrders" class="w-full text-left px-4 py-2 rounded hover:bg-gray-100">
<i class="fas fa-sync-alt mr-2"></i> 刷新订单
</button>
</div>
</div>
</div>
<!-- Main content area -->
<div class="flex-1">
<div class="card mb-6">
<h2 class="text-xl font-bold mb-4 flex items-center">
<i class="fas fa-clipboard-list mr-2 text-primary"></i>
订单管理
</h2>
<div v-if="loading" class="flex justify-center items-center py-8">
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
<div v-else-if="orders.length === 0" class="py-8 text-center text-gray-500">
<i class="fas fa-inbox text-4xl mb-2"></i>
<p>暂无订单数据</p>
</div>
<div v-else class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">订单ID</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">用户</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">奖品</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">领取时间</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">状态</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="order in orders" :key="order.id" class="hover:bg-gray-50">
<td class="px-4 py-4 whitespace-nowrap text-sm font-medium">{{ order.id }}</td>
<td class="px-4 py-4 whitespace-nowrap text-sm">{{ order.user_name || '未填写名称' }}</td>
<td class="px-4 py-4 whitespace-nowrap text-sm">{{ order.prize_name }}</td>
<td class="px-4 py-4 whitespace-nowrap text-sm">{{ formatDate(order.awarded_at) }}</td>
<td class="px-4 py-4 whitespace-nowrap text-sm">
<span
:class="[
'px-2 py-1 rounded-full text-xs',
order.is_shipped ? 'bg-success/20 text-success' : 'bg-warning/20 text-warning'
]"
>
{{ order.is_shipped ? '已发货' : '待发货' }}
</span>
</td>
<td class="px-4 py-4 whitespace-nowrap text-sm">
<div class="flex space-x-2">
<button
@click="getOrderDetails(order.id)"
class="text-info hover:text-info-dark"
title="查看详情"
>
<i class="fas fa-eye"></i>
</button>
<button
v-if="!order.is_shipped"
@click="shipOrder(order.id)"
class="text-success hover:text-success-dark"
title="标记为已发货"
>
<i class="fas fa-shipping-fast"></i>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Order statistics -->
<div class="card">
<h2 class="text-xl font-bold mb-4">系统统计</h2>
<Dashboard />
</div>
</div>
</div>
</div>
<!-- Order details modal -->
<div v-if="showOrderDetails" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold">订单详情</h2>
<button @click="showOrderDetails = false" class="text-gray-500 hover:text-gray-700">
<i class="fas fa-times"></i>
</button>
</div>
<div v-if="detailsLoading" class="flex justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary"></div>
</div>
<div v-else-if="currentOrder" class="space-y-6">
<!-- Order info -->
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-500">订单ID</p>
<p class="font-bold">{{ currentOrder.id }}</p>
</div>
<div>
<p class="text-sm text-gray-500">状态</p>
<p>
<span
:class="[
'px-2 py-1 rounded-full text-xs',
currentOrder.is_shipped ? 'bg-success/20 text-success' : 'bg-warning/20 text-warning'
]"
>
{{ currentOrder.is_shipped ? '已发货' : '待发货' }}
</span>
</p>
</div>
</div>
<hr />
<!-- Prize info -->
<div>
<h3 class="font-bold mb-2">奖品信息</h3>
<div class="bg-accent/10 p-4 rounded-lg">
<p class="font-bold text-lg">{{ currentOrder.prize_name }}</p>
<p class="text-gray-600">{{ currentOrder.prize_description }}</p>
</div>
</div>
<!-- User info -->
<div>
<h3 class="font-bold mb-2">用户信息</h3>
<div class="space-y-2">
<div class="grid grid-cols-2">
<p class="text-gray-500">用户ID:</p>
<p>{{ currentOrder.user_id }}</p>
</div>
<div class="grid grid-cols-2">
<p class="text-gray-500">姓名:</p>
<p>{{ currentOrder.user_name || '未填写' }}</p>
</div>
<div class="grid grid-cols-2">
<p class="text-gray-500">电话:</p>
<p>{{ currentOrder.user_phone || '未填写' }}</p>
</div>
<div>
<p class="text-gray-500">收货地址:</p>
<p class="bg-gray-50 p-2 rounded mt-1">{{ currentOrder.user_address || '未填写' }}</p>
</div>
</div>
</div>
<!-- Dates -->
<div>
<h3 class="font-bold mb-2">时间信息</h3>
<div class="space-y-2">
<div class="grid grid-cols-2">
<p class="text-gray-500">领取时间:</p>
<p>{{ formatDate(currentOrder.awarded_at) }}</p>
</div>
<div class="grid grid-cols-2">
<p class="text-gray-500">发货时间:</p>
<p>{{ currentOrder.shipped_at ? formatDate(currentOrder.shipped_at) : '未发货' }}</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="mt-6 flex justify-end" v-if="!currentOrder.is_shipped">
<button @click="shipOrder(currentOrder.id)" class="btn-success">
<i class="fas fa-shipping-fast mr-2"></i>
标记为已发货
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,96 @@
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { ElMessage } from 'element-plus'
import { adminAPI } from '../../services/api'
// Define emits
const emit = defineEmits(['login-success'])
// Form data
const loginForm = reactive({
username: '',
password: '',
})
// Loading state
const loading = ref(false)
// Login function
const handleLogin = async () => {
if (!loginForm.username || !loginForm.password) {
ElMessage.warning('请输入用户名和密码')
return
}
try {
loading.value = true
// Call login API
const response = await adminAPI.login(loginForm.username, loginForm.password)
// Save token to localStorage
localStorage.setItem('admin_token', response.access_token)
// Emit success event
emit('login-success', response.access_token)
ElMessage.success('登录成功')
} catch (error) {
console.error('Login error:', error)
ElMessage.error('登录失败,请检查用户名和密码')
} finally {
loading.value = false
}
}
</script>
<template>
<div class="flex items-center justify-center min-h-screen bg-gray-100">
<div class="max-w-md w-full p-8 bg-white rounded-lg shadow-lg">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-primary">管理员登录</h1>
<p class="text-gray-600 mt-2">请输入管理员账号和密码登录</p>
</div>
<form @submit.prevent="handleLogin" class="space-y-6">
<div>
<label for="username" class="block text-sm font-medium text-gray-700 mb-1">用户名</label>
<input
v-model="loginForm.username"
id="username"
type="text"
class="input-field w-full"
placeholder="请输入用户名"
autocomplete="username"
/>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700 mb-1">密码</label>
<input
v-model="loginForm.password"
id="password"
type="password"
class="input-field w-full"
placeholder="请输入密码"
autocomplete="current-password"
/>
</div>
<button
type="submit"
class="btn-primary w-full flex items-center justify-center"
:disabled="loading"
>
<i class="fas fa-spinner fa-spin mr-2" v-if="loading"></i>
{{ loading ? '登录中...' : '登录' }}
</button>
</form>
<div class="mt-6 text-center text-sm text-gray-500">
<p>默认管理员账号: admin</p>
<p>默认管理员密码: adminpassword</p>
</div>
</div>
</div>
</template>

25
frontend/src/main.ts Normal file
View File

@ -0,0 +1,25 @@
import { createApp } from 'vue'
import './style.css'
// Import App component
import App from './App.vue'
// Import router
import router from './router'
// Import Element Plus
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
// Import Font Awesome
import '@fortawesome/fontawesome-free/css/all.css'
// Create app
const app = createApp(App)
// Use plugins
app.use(ElementPlus)
app.use(router)
// Mount the app
app.mount('#app')

View File

@ -0,0 +1,23 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import Admin from '../views/Admin.vue'
const routes = [
{
path: '/',
name: 'Home',
component: Home
},
{
path: '/admin',
name: 'Admin',
component: Admin
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

View File

@ -0,0 +1,120 @@
import axios from 'axios';
// Create Axios instance with base URL
const api = axios.create({
baseURL: 'http://localhost:8000',
headers: {
'Content-Type': 'application/json',
},
});
// User API
export const userAPI = {
createUser: async (userData: any) => {
const response = await api.post('/users/', userData);
return response.data;
},
updateUser: async (userId: number, userData: any) => {
const response = await api.put(`/users/${userId}`, userData);
return response.data;
},
};
// Lottery API
export const lotteryAPI = {
drawLottery: async (userId: number) => {
const response = await api.post(`/draw/${userId}`);
return response.data;
},
};
// Card API
export const cardAPI = {
generateCard: async (cardType: string) => {
const response = await api.post('/cards/generate', { card_type: cardType });
return response.data;
},
collectCard: async (cardId: string, userId: number) => {
const response = await api.post('/cards/collect', { card_id: cardId, user_id: userId });
return response.data;
},
getUserCards: async (userId: number) => {
const response = await api.get(`/users/${userId}/cards`);
return response.data;
},
claimPrize: async (userId: number) => {
const response = await api.post(`/cards/claim-prize/${userId}`);
return response.data;
},
};
// Shipping API
export const shippingAPI = {
updateShipping: async (userId: number, address: string) => {
const response = await api.put('/shipping/update', { user_id: userId, address });
return response.data;
},
};
// Stats API
export const statsAPI = {
getStats: async () => {
const response = await api.get('/stats');
return response.data;
},
};
// Admin API
export const adminAPI = {
// Authentication
login: async (username: string, password: string) => {
const formData = new FormData();
formData.append('username', username);
formData.append('password', password);
const response = await axios.post('http://localhost:8000/admin/token', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
// Get admin profile
getProfile: async (token: string) => {
const response = await api.get('/admin/me', {
headers: { Authorization: `Bearer ${token}` }
});
return response.data;
},
// Get all orders
getOrders: async (token: string) => {
const response = await api.get('/admin/orders', {
headers: { Authorization: `Bearer ${token}` }
});
return response.data;
},
// Get order details
getOrderDetails: async (token: string, orderId: number) => {
const response = await api.get(`/admin/orders/${orderId}`, {
headers: { Authorization: `Bearer ${token}` }
});
return response.data;
},
// Update order shipping status
shipOrder: async (token: string, orderId: number) => {
const response = await api.put(`/admin/orders/${orderId}/ship`, {}, {
headers: { Authorization: `Bearer ${token}` }
});
return response.data;
},
};
export default api;

31
frontend/src/style.css Normal file
View File

@ -0,0 +1,31 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Custom styles for the lottery system */
body {
@apply bg-primary/10 min-h-screen;
}
/* Custom component classes */
@layer components {
.btn-primary {
@apply bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/80 transition-colors;
}
.btn-secondary {
@apply bg-secondary text-white px-4 py-2 rounded-lg hover:bg-secondary/80 transition-colors;
}
.card {
@apply bg-white rounded-xl shadow-lg p-6 transition-all;
}
.input-field {
@apply border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary;
}
.lottery-card {
@apply bg-gradient-to-br from-accent to-secondary text-white rounded-xl shadow-lg p-4;
}
}

View File

@ -0,0 +1,42 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import AdminLogin from '../components/admin/AdminLogin.vue'
import AdminDashboard from '../components/admin/AdminDashboard.vue'
// Authentication state
const isAuthenticated = ref(false)
const token = ref('')
// Check if user is authenticated
const checkAuth = () => {
const storedToken = localStorage.getItem('admin_token')
if (storedToken) {
token.value = storedToken
isAuthenticated.value = true
}
}
// Handle login success
const handleLoginSuccess = (accessToken: string) => {
token.value = accessToken
isAuthenticated.value = true
}
// Handle logout
const handleLogout = () => {
token.value = ''
isAuthenticated.value = false
}
// Check authentication status on component mount
onMounted(() => {
checkAuth()
})
</script>
<template>
<div class="admin-page">
<AdminLogin v-if="!isAuthenticated" @login-success="handleLoginSuccess" />
<AdminDashboard v-else :token="token" @logout="handleLogout" />
</div>
</template>

421
frontend/src/views/Home.vue Normal file
View File

@ -0,0 +1,421 @@
<script setup lang="ts">
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useRouter } from 'vue-router'
import { userAPI, shippingAPI, lotteryAPI, cardAPI } from '../services/api'
// Router instance
const router = useRouter()
// Current page state (based on flowchart)
const currentPage = ref(1)
// User data
const userData = ref({
id: 0,
name: '',
phone: '',
address: '',
collectedCards: [] as string[],
prize: null as any
})
// Move to next page
const nextPage = () => {
if (currentPage.value < 6) {
currentPage.value++
}
}
// Move to previous page
const prevPage = () => {
if (currentPage.value > 1) {
currentPage.value--
}
}
// Draw lottery
const drawLottery = async () => {
try {
// If user is already created, use the API
if (userData.value.id !== 0) {
const result = await lotteryAPI.drawLottery(userData.value.id)
userData.value.prize = result
ElMessage.success(`恭喜您抽中了${result.name}`)
return result
}
// Otherwise use simulated logic for now
const prizes = [
{ id: 1, name: '一等奖', description: '豪华大礼包', probability: 0.01 },
{ id: 2, name: '二等奖', description: '精美礼品', probability: 0.05 },
{ id: 3, name: '三等奖', description: '纪念品', probability: 0.2 },
{ id: 4, name: '鼓励奖', description: '小礼品', probability: 0.3 },
{ id: 5, name: '谢谢参与', description: '下次再来', probability: 0.44 }
]
// Simple random draw logic
const random = Math.random()
let cumulative = 0
let selectedPrize = prizes[prizes.length - 1]
for (const prize of prizes) {
cumulative += prize.probability
if (random <= cumulative) {
selectedPrize = prize
break
}
}
userData.value.prize = selectedPrize
ElMessage.success(`恭喜您抽中了${selectedPrize.name}`)
return selectedPrize
} catch (error) {
ElMessage.error('抽奖失败,请重试')
console.error('Draw lottery error:', error)
return null
}
}
// Redraw logic
const redraw = async () => {
await drawLottery()
}
// Add collected card
const addCollectedCard = (cardId: string) => {
if (!userData.value.collectedCards.includes(cardId)) {
userData.value.collectedCards.push(cardId)
ElMessage.success(`成功收集卡片: ${cardId}`)
}
}
// Check if user has a complete set of cards
const hasCompleteSet = () => {
return userData.value.collectedCards.length >= 5
}
// Submit user information
const submitUserInfo = async () => {
try {
// Call API to create/update user
const userInfo = {
name: userData.value.name,
phone: userData.value.phone
}
let response;
if (userData.value.id === 0) {
// Create new user
response = await userAPI.createUser(userInfo)
userData.value.id = response.id // Save user ID for future updates
} else {
// Update existing user
response = await userAPI.updateUser(userData.value.id, userInfo)
}
ElMessage.success('个人信息提交成功!')
nextPage()
} catch (error) {
ElMessage.error('提交失败,请重试')
console.error('Submit user info error:', error)
}
}
// Submit shipping address
const submitShippingAddress = async () => {
try {
if (userData.value.id === 0) {
ElMessage.error('请先提交个人信息')
currentPage.value = 4
return
}
// Call API to update shipping address
await shippingAPI.updateShipping(userData.value.id, userData.value.address)
// If user has a prize, save it to the database
if (userData.value.prize) {
// Assuming we have a claimPrize API that takes user ID and prize ID
await cardAPI.claimPrize(userData.value.id)
}
ElMessage.success('收货地址提交成功!奖品即将派送')
nextPage()
} catch (error) {
ElMessage.error('提交失败,请重试')
console.error('Submit shipping address error:', error)
}
}
</script>
<template>
<div class="min-h-screen">
<!-- Header with admin link -->
<div class="bg-dark text-white py-2 px-4">
<div class="container mx-auto flex justify-end">
<button @click="router.push('/admin')" class="text-white hover:text-secondary transition-all text-sm">
<i class="fas fa-user-shield mr-1"></i> 管理员入口
</button>
</div>
</div>
<!-- Progress indicator -->
<div class="bg-primary py-4 text-white">
<div class="container mx-auto px-4">
<div class="flex justify-center items-center space-x-2">
<div v-for="page in 6" :key="page"
class="flex items-center">
<div
:class="[
'w-8 h-8 rounded-full flex items-center justify-center transition-all',
currentPage === page ? 'bg-secondary text-white' : 'bg-white text-primary'
]">
{{ page }}
</div>
<div v-if="page < 6" class="h-1 w-4 mx-1"
:class="currentPage > page ? 'bg-secondary' : 'bg-white'"></div>
</div>
</div>
</div>
</div>
<!-- Main content area -->
<div class="container mx-auto p-4">
<!-- Page 1: Welcome -->
<div v-if="currentPage === 1" class="max-w-lg mx-auto bg-white shadow-lg rounded-lg p-6">
<h1 class="text-2xl font-bold text-center text-primary mb-4">欢迎参与抽奖活动</h1>
<p class="text-gray-700 mb-4">参与我们的集卡抽奖活动有机会赢取丰厚奖品</p>
<div class="bg-gray-100 p-4 rounded-lg mb-4">
<h2 class="font-bold text-lg text-primary mb-2">活动规则</h2>
<ol class="list-decimal pl-5 space-y-1">
<li>收集不同的卡片</li>
<li>集齐一套卡片后参与抽奖</li>
<li>中奖后填写个人信息</li>
<li>等待奖品寄送</li>
</ol>
</div>
<div class="flex justify-center">
<button @click="nextPage" class="bg-primary hover:bg-primary-dark text-white py-2 px-6 rounded-lg transition-all">
开始收集卡片
</button>
</div>
</div>
<!-- Page 2: Collect Cards -->
<div v-if="currentPage === 2" class="max-w-lg mx-auto bg-white shadow-lg rounded-lg p-6">
<h1 class="text-2xl font-bold text-center text-primary mb-4">收集卡片</h1>
<p class="text-gray-700 mb-4">点击下方按钮获取随机卡片集齐一套卡片即可参与抽奖</p>
<div class="grid grid-cols-5 gap-2 mb-4">
<div v-for="(collected, index) in 5" :key="index"
class="bg-gray-100 rounded-lg aspect-square flex items-center justify-center">
<div v-if="userData.collectedCards.includes(`Card${index + 1}`)" class="text-3xl text-primary">
<i class="fas fa-check-circle"></i>
</div>
<div v-else class="text-3xl text-gray-300">
<i class="fas fa-question-circle"></i>
</div>
</div>
</div>
<div class="text-center mb-4">
<p class="text-sm text-gray-600">已收集 {{ userData.collectedCards.length }} / 5 张卡片</p>
<div class="w-full bg-gray-200 h-2 rounded-full mt-1">
<div class="bg-primary h-2 rounded-full" :style="{width: `${(userData.collectedCards.length / 5) * 100}%`}"></div>
</div>
</div>
<div class="flex justify-center space-x-3">
<button @click="addCollectedCard(`Card${Math.floor(Math.random() * 5) + 1}`)"
class="bg-secondary hover:bg-secondary-dark text-white py-2 px-6 rounded-lg transition-all">
获取随机卡片
</button>
<button @click="nextPage" :disabled="userData.collectedCards.length < 5"
:class="[
'py-2 px-6 rounded-lg transition-all',
userData.collectedCards.length >= 5
? 'bg-primary hover:bg-primary-dark text-white'
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
]">
继续
</button>
</div>
<div class="flex justify-center mt-4">
<button @click="prevPage" class="text-primary hover:text-primary-dark transition-all">
<i class="fas fa-arrow-left mr-1"></i> 返回
</button>
</div>
</div>
<!-- Page 3: Draw Lottery -->
<div v-if="currentPage === 3" class="max-w-lg mx-auto bg-white shadow-lg rounded-lg p-6">
<h1 class="text-2xl font-bold text-center text-primary mb-4">抽奖环节</h1>
<p class="text-gray-700 mb-4">恭喜您集齐了一套卡片现在可以参与抽奖了</p>
<div class="bg-gray-100 p-6 rounded-lg mb-6 text-center">
<div v-if="!userData.prize" class="flex flex-col items-center">
<div class="text-6xl text-primary mb-4">
<i class="fas fa-gift"></i>
</div>
<p class="text-gray-600">点击下方按钮开始抽奖</p>
</div>
<div v-else class="flex flex-col items-center animate-bounce">
<div class="text-6xl text-secondary mb-4">
<i class="fas fa-trophy"></i>
</div>
<h2 class="text-xl font-bold text-primary mb-1">{{ userData.prize.name }}</h2>
<p class="text-gray-600">{{ userData.prize.description }}</p>
</div>
</div>
<div class="flex justify-center space-x-3">
<button v-if="!userData.prize" @click="drawLottery"
class="bg-secondary hover:bg-secondary-dark text-white py-2 px-6 rounded-lg transition-all">
抽奖
</button>
<button v-else-if="userData.prize.id <= 4" @click="nextPage"
class="bg-primary hover:bg-primary-dark text-white py-2 px-6 rounded-lg transition-all">
领取奖品
</button>
<button v-else @click="redraw"
class="bg-secondary hover:bg-secondary-dark text-white py-2 px-6 rounded-lg transition-all">
再抽一次
</button>
</div>
<div class="flex justify-center mt-4">
<button @click="prevPage" class="text-primary hover:text-primary-dark transition-all">
<i class="fas fa-arrow-left mr-1"></i> 返回
</button>
</div>
</div>
<!-- Page 4: User Information -->
<div v-if="currentPage === 4" class="max-w-lg mx-auto bg-white shadow-lg rounded-lg p-6">
<h1 class="text-2xl font-bold text-center text-primary mb-4">个人信息</h1>
<p class="text-gray-700 mb-4">请填写您的个人信息以便我们能够联系到您</p>
<div class="space-y-4 mb-6">
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="name">
姓名
</label>
<input v-model="userData.name" id="name" type="text"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder="请输入您的姓名">
</div>
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="phone">
手机号码
</label>
<input v-model="userData.phone" id="phone" type="tel"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
placeholder="请输入您的手机号码">
</div>
</div>
<div class="flex justify-center">
<button @click="submitUserInfo"
:disabled="!userData.name || !userData.phone"
:class="[
'py-2 px-6 rounded-lg transition-all',
userData.name && userData.phone
? 'bg-primary hover:bg-primary-dark text-white'
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
]">
提交
</button>
</div>
<div class="flex justify-center mt-4">
<button @click="prevPage" class="text-primary hover:text-primary-dark transition-all">
<i class="fas fa-arrow-left mr-1"></i> 返回
</button>
</div>
</div>
<!-- Page 5: Shipping Address -->
<div v-if="currentPage === 5" class="max-w-lg mx-auto bg-white shadow-lg rounded-lg p-6">
<h1 class="text-2xl font-bold text-center text-primary mb-4">收货地址</h1>
<p class="text-gray-700 mb-4">请填写您的收货地址我们将尽快为您寄送奖品</p>
<div class="space-y-4 mb-6">
<div>
<label class="block text-gray-700 text-sm font-bold mb-2" for="address">
详细地址
</label>
<textarea v-model="userData.address" id="address"
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
rows="3" placeholder="请输入您的详细地址"></textarea>
</div>
</div>
<div class="flex justify-center">
<button @click="submitShippingAddress"
:disabled="!userData.address"
:class="[
'py-2 px-6 rounded-lg transition-all',
userData.address
? 'bg-primary hover:bg-primary-dark text-white'
: 'bg-gray-200 text-gray-500 cursor-not-allowed'
]">
提交
</button>
</div>
<div class="flex justify-center mt-4">
<button @click="prevPage" class="text-primary hover:text-primary-dark transition-all">
<i class="fas fa-arrow-left mr-1"></i> 返回
</button>
</div>
</div>
<!-- Page 6: Confirmation -->
<div v-if="currentPage === 6" class="max-w-lg mx-auto bg-white shadow-lg rounded-lg p-6">
<div class="text-center">
<div class="text-6xl text-secondary mb-4">
<i class="fas fa-check-circle"></i>
</div>
<h1 class="text-2xl font-bold text-primary mb-2">提交成功</h1>
<p class="text-gray-700 mb-6">感谢您参与我们的活动我们将尽快为您寄送奖品</p>
<div class="bg-gray-100 p-4 rounded-lg mb-6 text-left">
<h2 class="font-bold text-lg text-primary mb-2">订单信息</h2>
<div class="space-y-1">
<p><span class="font-medium">姓名</span>{{ userData.name }}</p>
<p><span class="font-medium">手机</span>{{ userData.phone }}</p>
<p><span class="font-medium">地址</span>{{ userData.address }}</p>
<p><span class="font-medium">奖品</span>{{ userData.prize?.name }} - {{ userData.prize?.description }}</p>
</div>
</div>
<button @click="currentPage = 1" class="bg-primary hover:bg-primary-dark text-white py-2 px-6 rounded-lg transition-all">
返回首页
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
/* Additional styles if needed */
.animate-bounce {
animation: bounce 1s ease infinite;
}
@keyframes bounce {
0%, 100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
</style>

1
frontend/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,22 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: '#6A5ACD', // SlateBlue - main color based on the image background
secondary: '#FF6B6B', // Vibrant red
accent: '#4ECDC4', // Turquoise
success: '#2ECC71', // Emerald green
warning: '#FFD700', // Gold
info: '#3498DB', // Blue
dark: '#2C3E50', // Dark blue
light: '#ECF0F1', // Light gray
},
},
},
plugins: [],
}

View File

@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/
export default defineConfig({
plugins: [vue()],
})