主播后台添加模板页面

This commit is contained in:
taiyi 2025-12-18 15:03:25 +08:00
parent 4927420266
commit e72df24934
8 changed files with 632 additions and 9 deletions

View File

@ -56,3 +56,18 @@ class Bet(Base):
# 关联关系
chest = relationship("Chest", back_populates="bets", foreign_keys=[chest_id])
class ChestTemplate(Base):
"""宝箱模板表"""
__tablename__ = "chest_templates"
id = Column(Integer, primary_key=True, autoincrement=True, index=True)
streamer_id = Column(Integer, nullable=False, index=True, comment="主播ID")
name = Column(String(100), nullable=False, comment="模板名称")
title = Column(String(200), nullable=False, comment="宝箱标题")
option_a = Column(String(100), nullable=False, comment="选项A")
option_b = Column(String(100), nullable=False, comment="选项B")
countdown_seconds = Column(Integer, default=300, comment="倒计时(秒)")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")

View File

@ -8,7 +8,8 @@ from datetime import datetime
from ..core.database import get_db
from ..schemas.game import (
ChestCreate, ChestResponse, BetCreate, BetResponse,
ChestSettle, ChestLock
ChestSettle, ChestLock,
ChestTemplateCreate, ChestTemplateUpdate, ChestTemplateResponse
)
from ..services.game_service import GameService
from ..models.game import ChestStatus
@ -252,3 +253,66 @@ def get_chest_bets(
获取宝箱下注记录
"""
return GameService.get_chest_bets(db, chest_id)
# ==================== 宝箱模板相关路由 ====================
@router.get("/templates", response_model=List[ChestTemplateResponse])
def get_templates(
db: Session = Depends(get_db),
current_user: get_current_streamer = Depends(get_current_streamer)
):
"""
获取当前主播的宝箱模板列表
"""
templates = GameService.get_templates_by_streamer(db, current_user.id)
return templates
@router.post("/templates", response_model=ChestTemplateResponse)
def create_template(
template_data: ChestTemplateCreate,
db: Session = Depends(get_db),
current_user: get_current_streamer = Depends(get_current_streamer)
):
"""
创建宝箱模板
"""
try:
template = GameService.create_template(db, current_user.id, template_data)
return template
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/templates/{template_id}", response_model=ChestTemplateResponse)
def update_template(
template_id: int,
template_data: ChestTemplateUpdate,
db: Session = Depends(get_db),
current_user: get_current_streamer = Depends(get_current_streamer)
):
"""
更新宝箱模板
"""
try:
template = GameService.update_template(db, template_id, current_user.id, template_data)
return template
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.delete("/templates/{template_id}")
def delete_template(
template_id: int,
db: Session = Depends(get_db),
current_user: get_current_streamer = Depends(get_current_streamer)
):
"""
删除宝箱模板
"""
try:
GameService.delete_template(db, template_id, current_user.id)
return {"message": "删除成功"}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))

View File

@ -80,3 +80,39 @@ class ChestSettle(BaseModel):
class ChestLock(BaseModel):
"""宝箱封盘"""
pass
# ==================== 宝箱模板相关 schemas ====================
class ChestTemplateBase(BaseModel):
"""宝箱模板基础模型"""
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
title: str = Field(..., min_length=1, max_length=200, description="宝箱标题")
option_a: str = Field(..., min_length=1, max_length=100, description="选项A")
option_b: str = Field(..., min_length=1, max_length=100, description="选项B")
countdown_seconds: int = Field(default=300, ge=10, le=3600, description="倒计时(秒)")
class ChestTemplateCreate(ChestTemplateBase):
"""创建宝箱模板"""
pass
class ChestTemplateUpdate(BaseModel):
"""更新宝箱模板"""
name: Optional[str] = Field(None, min_length=1, max_length=100, description="模板名称")
title: Optional[str] = Field(None, min_length=1, max_length=200, description="宝箱标题")
option_a: Optional[str] = Field(None, min_length=1, max_length=100, description="选项A")
option_b: Optional[str] = Field(None, min_length=1, max_length=100, description="选项B")
countdown_seconds: Optional[int] = Field(None, ge=10, le=3600, description="倒计时(秒)")
class ChestTemplateResponse(ChestTemplateBase):
"""宝箱模板响应模型"""
id: int
streamer_id: int
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True

View File

@ -615,3 +615,104 @@ class GameService:
获取宝箱下注记录
"""
return db.query(Bet).filter(Bet.chest_id == chest_id).all()
# ==================== 宝箱模板相关方法 ====================
@staticmethod
def get_templates_by_streamer(db: Session, streamer_id: int) -> List:
"""
获取指定主播的所有宝箱模板
"""
from ..models.game import ChestTemplate
return db.query(ChestTemplate).filter(
ChestTemplate.streamer_id == streamer_id
).order_by(ChestTemplate.created_at.desc()).all()
@staticmethod
def get_template_by_id(db: Session, template_id: int, streamer_id: int):
"""
根据ID获取宝箱模板验证所有权
"""
from ..models.game import ChestTemplate
return db.query(ChestTemplate).filter(
and_(
ChestTemplate.id == template_id,
ChestTemplate.streamer_id == streamer_id
)
).first()
@staticmethod
def create_template(db: Session, streamer_id: int, template_data) -> 'ChestTemplate':
"""
创建宝箱模板
"""
from ..models.game import ChestTemplate
# 检查模板数量限制最多3个
existing_count = db.query(ChestTemplate).filter(
ChestTemplate.streamer_id == streamer_id
).count()
if existing_count >= 3:
raise ValueError("模板数量已达上限3个")
# 创建模板
db_template = ChestTemplate(
streamer_id=streamer_id,
name=template_data.name,
title=template_data.title,
option_a=template_data.option_a,
option_b=template_data.option_b,
countdown_seconds=template_data.countdown_seconds
)
db.add(db_template)
db.commit()
db.refresh(db_template)
return db_template
@staticmethod
def update_template(db: Session, template_id: int, streamer_id: int, template_data) -> 'ChestTemplate':
"""
更新宝箱模板
"""
from ..models.game import ChestTemplate
# 查找模板并验证所有权
db_template = GameService.get_template_by_id(db, template_id, streamer_id)
if not db_template:
raise ValueError("模板不存在或无权操作")
# 更新字段
if template_data.name is not None:
db_template.name = template_data.name
if template_data.title is not None:
db_template.title = template_data.title
if template_data.option_a is not None:
db_template.option_a = template_data.option_a
if template_data.option_b is not None:
db_template.option_b = template_data.option_b
if template_data.countdown_seconds is not None:
db_template.countdown_seconds = template_data.countdown_seconds
db.commit()
db.refresh(db_template)
return db_template
@staticmethod
def delete_template(db: Session, template_id: int, streamer_id: int) -> bool:
"""
删除宝箱模板
"""
from ..models.game import ChestTemplate
# 查找模板并验证所有权
db_template = GameService.get_template_by_id(db, template_id, streamer_id)
if not db_template:
raise ValueError("模板不存在或无权操作")
db.delete(db_template)
db.commit()
return True

View File

@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>互动竞猜开宝箱系统</title>
<script type="module" crossorigin src="/assets/index-ByW5lIiv.js"></script>
<script type="module" crossorigin src="/assets/index-DDg-nT0I.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D9Z2CB14.css">
</head>
<body>

View File

@ -5,7 +5,7 @@ import { useAuth } from '../contexts/AuthContext';
import { websocketService } from '../services/websocket';
import Card from '../components/Card';
import Loading from '../components/Loading';
import type { Chest } from '../types';
import type { Chest, ChestTemplate } from '../types';
const StreamerConsole = () => {
const { user } = useAuth();
@ -34,6 +34,18 @@ const StreamerConsole = () => {
const [countdowns, setCountdowns] = useState<{[key: number]: number}>({});
const countdownIntervalsRef = useRef<{[key: number]: number}>({});
// 宝箱模板相关状态
const [templates, setTemplates] = useState<ChestTemplate[]>([]);
const [showTemplateForm, setShowTemplateForm] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<ChestTemplate | null>(null);
const [templateName, setTemplateName] = useState('');
const [templateTitle, setTemplateTitle] = useState('');
const [templateOptionA, setTemplateOptionA] = useState('');
const [templateOptionB, setTemplateOptionB] = useState('');
const [templateCountdown, setTemplateCountdown] = useState(300);
const [selectedTemplateId, setSelectedTemplateId] = useState<number | null>(null);
const [templateLoading, setTemplateLoading] = useState(false);
// ============ 计算值在Hook之前===========
const activeChests = myChests.filter(c => c.status === 0 || c.status === 1);
const finishedChests = myChests.filter(c => c.status === 3 || c.status === 4);
@ -57,8 +69,29 @@ const StreamerConsole = () => {
}
loadMyChests();
loadTemplates();
}, [user, navigate]);
// ============ 加载宝箱模板 ============
const loadTemplates = async () => {
try {
console.log('正在加载模板...');
const token = localStorage.getItem('token');
console.log('Token存在:', !!token);
const data = await gameApi.getTemplates();
console.log('模板加载成功:', data);
setTemplates(data);
} catch (err: any) {
console.error('加载模板失败:', err);
console.error('错误详情:', err.response?.data);
console.error('状态码:', err.response?.status);
if (err.response?.status === 401) {
console.error('认证失败,请检查登录状态');
// 可以在这里添加重新登录的逻辑
}
}
};
// 监听WebSocket连接状态
useEffect(() => {
const interval = setInterval(() => {
@ -186,6 +219,126 @@ const StreamerConsole = () => {
}
};
// ============ 宝箱模板管理函数 ============
const handleSaveTemplate = async (e: React.FormEvent) => {
e.preventDefault();
if (!templateName || !templateTitle || !templateOptionA || !templateOptionB) {
setError('请填写完整信息');
return;
}
try {
setTemplateLoading(true);
setError('');
console.log('保存模板:', { templateName, templateTitle, templateOptionA, templateOptionB, templateCountdown });
if (editingTemplate) {
// 更新模板
console.log('更新模板 ID:', editingTemplate.id);
await gameApi.updateTemplate(editingTemplate.id, {
name: templateName,
title: templateTitle,
option_a: templateOptionA,
option_b: templateOptionB,
countdown_seconds: templateCountdown,
});
alert('模板更新成功!');
} else {
// 创建模板
console.log('创建新模板');
await gameApi.createTemplate({
name: templateName,
title: templateTitle,
option_a: templateOptionA,
option_b: templateOptionB,
countdown_seconds: templateCountdown,
});
alert('模板创建成功!');
}
// 重置表单
resetTemplateForm();
await loadTemplates();
} catch (err: any) {
console.error('保存模板失败:', err);
console.error('错误详情:', err.response?.data);
console.error('状态码:', err.response?.status);
setError(err.response?.data?.detail || '保存模板失败');
} finally {
setTemplateLoading(false);
}
};
const handleDeleteTemplate = async (templateId: number) => {
if (!confirm('确定要删除这个模板吗?')) {
return;
}
try {
setTemplateLoading(true);
await gameApi.deleteTemplate(templateId);
alert('模板删除成功!');
await loadTemplates();
} catch (err: any) {
setError(err.response?.data?.detail || '删除模板失败');
} finally {
setTemplateLoading(false);
}
};
const handleEditTemplate = (template: ChestTemplate) => {
setEditingTemplate(template);
setTemplateName(template.name);
setTemplateTitle(template.title);
setTemplateOptionA(template.option_a);
setTemplateOptionB(template.option_b);
setTemplateCountdown(template.countdown_seconds);
setShowTemplateForm(true);
};
const resetTemplateForm = () => {
setEditingTemplate(null);
setTemplateName('');
setTemplateTitle('');
setTemplateOptionA('');
setTemplateOptionB('');
setTemplateCountdown(300);
setShowTemplateForm(false);
};
const handleUseTemplate = async (template: ChestTemplate) => {
// 弹出确认框
const confirmMessage = `确定要使用模板"${template.name}"创建宝箱吗?
${template.title}
${template.option_a} vs ${template.option_b}
${template.countdown_seconds}`;
if (!confirm(confirmMessage)) {
return; // 用户取消,不执行任何操作
}
try {
// 用户确认后,直接创建宝箱
await gameApi.createChest({
title: template.title,
option_a: template.option_a,
option_b: template.option_b,
countdown_seconds: template.countdown_seconds,
});
// 重新加载宝箱列表
await loadMyChests();
alert(`宝箱创建成功!\n模板${template.name}`);
} catch (err: any) {
console.error('创建宝箱失败:', err);
alert(err.response?.data?.detail || '创建宝箱失败');
}
};
const handleLockChest = async (chestId: number) => {
if (!confirm('确定要封盘吗?封盘后将无法再下注。')) {
return;
@ -261,9 +414,68 @@ const StreamerConsole = () => {
return <Loading text="加载控制台中..." />;
}
// ============ 样式 ============
const templateStyles = `
.template-form {
background: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
}
.templates-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.template-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
border: 1px solid #dee2e6;
border-radius: 8px;
background: #fff;
transition: all 0.3s;
}
.template-item:hover {
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.template-info h4 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.1rem;
}
.template-info p {
margin: 0 0 0.25rem 0;
color: #666;
}
.template-info small {
color: #999;
font-size: 0.85rem;
}
.template-actions {
display: flex;
gap: 0.5rem;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
`;
// ============ 渲染 ============
return (
<div className="streamer-console">
<>
<style>{templateStyles}</style>
<div className="streamer-console">
<div className="console-header">
<div style={{ display: 'flex', alignItems: 'center', gap: '20px' }}>
<h1 className="page-title">🎮 </h1>
@ -295,6 +507,143 @@ const StreamerConsole = () => {
{error && <div className="alert alert-danger">{error}</div>}
{/* 宝箱模板管理卡片 */}
<Card title="📋 宝箱模板管理" className="mb-3">
<div className="mb-3">
<button
className="btn btn-primary"
onClick={() => setShowTemplateForm(!showTemplateForm)}
>
{showTemplateForm ? '取消' : '+ 创建模板'}
</button>
<span className="ml-2 text-secondary">3</span>
</div>
{showTemplateForm && (
<form onSubmit={handleSaveTemplate} className="template-form mb-3">
<div className="form-group">
<label className="form-label"></label>
<input
type="text"
className="form-input"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
placeholder="例如: 游戏类宝箱"
required
/>
</div>
<div className="form-group">
<label className="form-label"></label>
<input
type="text"
className="form-input"
value={templateTitle}
onChange={(e) => setTemplateTitle(e.target.value)}
placeholder="例如: 这把能吃鸡吗?"
required
/>
</div>
<div className="form-row">
<div className="form-group">
<label className="form-label">A</label>
<input
type="text"
className="form-input"
value={templateOptionA}
onChange={(e) => setTemplateOptionA(e.target.value)}
placeholder="例如: 能"
required
/>
</div>
<div className="form-group">
<label className="form-label">B</label>
<input
type="text"
className="form-input"
value={templateOptionB}
onChange={(e) => setTemplateOptionB(e.target.value)}
placeholder="例如: 不能"
required
/>
</div>
</div>
<div className="form-group">
<label className="form-label"></label>
<input
type="number"
className="form-input"
value={templateCountdown}
onChange={(e) => setTemplateCountdown(parseInt(e.target.value))}
min="60"
max="3600"
required
/>
</div>
<div className="form-actions">
<button
type="button"
className="btn btn-secondary"
onClick={resetTemplateForm}
>
</button>
<button
type="submit"
className="btn btn-primary"
disabled={templateLoading}
>
{templateLoading ? '保存中...' : (editingTemplate ? '更新模板' : '保存模板')}
</button>
</div>
</form>
)}
{/* 模板列表 */}
{templates.length === 0 ? (
<p className="text-center text-secondary"></p>
) : (
<div className="templates-list">
{templates.map((template) => (
<div key={template.id} className="template-item">
<div className="template-info">
<h4>{template.name}</h4>
<p>{template.title}</p>
<small>
{template.option_a} vs {template.option_b} | {template.countdown_seconds}
</small>
</div>
<div className="template-actions">
<button
className="btn btn-success btn-sm"
onClick={() => handleUseTemplate(template)}
>
使
</button>
<button
className="btn btn-secondary btn-sm"
onClick={() => handleEditTemplate(template)}
>
</button>
<button
className="btn btn-danger btn-sm"
onClick={() => handleDeleteTemplate(template.id)}
disabled={templateLoading}
>
</button>
</div>
</div>
))}
</div>
)}
</Card>
{/* 全局提醒:如果有封盘状态的宝箱 */}
{lockedChests.length > 0 && (
<div className="locked-chests-alert">
@ -320,7 +669,12 @@ const StreamerConsole = () => {
)}
{showCreateForm && (
<Card title="创建新宝箱" className="mb-3">
<Card title="创建新宝箱" className="mb-3 create-form">
{selectedTemplateId && (
<div className="alert alert-info mb-3">
</div>
)}
<form onSubmit={handleCreateChest}>
<div className="form-group">
<label className="form-label"></label>
@ -547,7 +901,8 @@ const StreamerConsole = () => {
</div>
</div>
)}
</div>
</div>
</>
);
};

View File

@ -1,5 +1,5 @@
import axios from 'axios';
import type { User, LoginResponse, Transaction, Chest, ChestCreate, Bet, BetCreate, ChestSettle, SystemConfig, StreamerProfile, StreamerStatistics, Announcement } from '../types';
import type { User, LoginResponse, Transaction, Chest, ChestCreate, Bet, BetCreate, ChestSettle, SystemConfig, StreamerProfile, StreamerStatistics, Announcement, ChestTemplate, ChestTemplateCreate, ChestTemplateUpdate } from '../types';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
@ -174,6 +174,27 @@ export const gameApi = {
const response = await api.get<Bet[]>(`/api/chests/${chestId}/bets`);
return response.data;
},
// 宝箱模板相关API
getTemplates: async (): Promise<ChestTemplate[]> => {
const response = await api.get<ChestTemplate[]>('/api/templates');
return response.data;
},
createTemplate: async (data: ChestTemplateCreate): Promise<ChestTemplate> => {
const response = await api.post<ChestTemplate>('/api/templates', data);
return response.data;
},
updateTemplate: async (templateId: number, data: ChestTemplateUpdate): Promise<ChestTemplate> => {
const response = await api.put<ChestTemplate>(`/api/templates/${templateId}`, data);
return response.data;
},
deleteTemplate: async (templateId: number): Promise<{ message: string }> => {
const response = await api.delete(`/api/templates/${templateId}`);
return response.data;
},
};
// 系统配置相关

View File

@ -178,3 +178,34 @@ export interface StreamerStatistics {
average_chest_value: number;
success_rate: number;
}
// 宝箱模板
export interface ChestTemplate {
id: number;
streamer_id: number;
name: string;
title: string;
option_a: string;
option_b: string;
countdown_seconds: number;
created_at: string;
updated_at: string;
}
// 创建宝箱模板
export interface ChestTemplateCreate {
name: string;
title: string;
option_a: string;
option_b: string;
countdown_seconds: number;
}
// 更新宝箱模板
export interface ChestTemplateUpdate {
name?: string;
title?: string;
option_a?: string;
option_b?: string;
countdown_seconds?: number;
}