初始化抽奖程序
This commit is contained in:
commit
8c846f860e
83
README.md
Normal file
83
README.md
Normal 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 文档。
|
||||
|
||||
## 系统截图
|
||||
|
||||

|
||||
|
||||
## 开发者
|
||||
|
||||
项目开发于 2025 年 4 月
|
3
backend/.env
Normal file
3
backend/.env
Normal file
@ -0,0 +1,3 @@
|
||||
DATABASE_URL=sqlite:///./lottery.db
|
||||
DEBUG=True
|
||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
BIN
backend/__pycache__/admin.cpython-311.pyc
Normal file
BIN
backend/__pycache__/admin.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
BIN
backend/__pycache__/main.cpython-311.pyc
Normal file
Binary file not shown.
BIN
backend/__pycache__/models.cpython-311.pyc
Normal file
BIN
backend/__pycache__/models.cpython-311.pyc
Normal file
Binary file not shown.
266
backend/admin.py
Normal file
266
backend/admin.py
Normal 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
BIN
backend/lottery.db
Normal file
Binary file not shown.
373
backend/main.py
Normal file
373
backend/main.py
Normal 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
56
backend/models.py
Normal 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
6
backend/requirements.txt
Normal 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
24
frontend/.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?
|
3
frontend/.vscode/extensions.json
vendored
Normal file
3
frontend/.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
5
frontend/README.md
Normal file
5
frontend/README.md
Normal 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
13
frontend/index.html
Normal 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
3357
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.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="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
19
frontend/src/App.vue
Normal 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>
|
1
frontend/src/assets/vue.svg
Normal file
1
frontend/src/assets/vue.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="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 |
208
frontend/src/components/Dashboard.vue
Normal file
208
frontend/src/components/Dashboard.vue
Normal 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>
|
41
frontend/src/components/HelloWorld.vue
Normal file
41
frontend/src/components/HelloWorld.vue
Normal 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>
|
376
frontend/src/components/admin/AdminDashboard.vue
Normal file
376
frontend/src/components/admin/AdminDashboard.vue
Normal 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>
|
96
frontend/src/components/admin/AdminLogin.vue
Normal file
96
frontend/src/components/admin/AdminLogin.vue
Normal 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
25
frontend/src/main.ts
Normal 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')
|
23
frontend/src/router/index.ts
Normal file
23
frontend/src/router/index.ts
Normal 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
|
120
frontend/src/services/api.ts
Normal file
120
frontend/src/services/api.ts
Normal 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
31
frontend/src/style.css
Normal 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;
|
||||
}
|
||||
}
|
42
frontend/src/views/Admin.vue
Normal file
42
frontend/src/views/Admin.vue
Normal 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
421
frontend/src/views/Home.vue
Normal 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
1
frontend/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
22
frontend/tailwind.config.js
Normal file
22
frontend/tailwind.config.js
Normal 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: [],
|
||||
}
|
14
frontend/tsconfig.app.json
Normal file
14
frontend/tsconfig.app.json
Normal 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
7
frontend/tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
24
frontend/tsconfig.node.json
Normal file
24
frontend/tsconfig.node.json
Normal 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
7
frontend/vite.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
})
|
Loading…
x
Reference in New Issue
Block a user