From e72df2493420addd9a97ab21821f76fedc25014e Mon Sep 17 00:00:00 2001 From: taiyi Date: Thu, 18 Dec 2025 15:03:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BB=E6=92=AD=E5=90=8E=E5=8F=B0=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E6=A8=A1=E6=9D=BF=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/models/game.py | 15 + backend/app/routers/game.py | 66 ++++- backend/app/schemas/game.py | 38 ++- backend/app/services/game_service.py | 103 ++++++- frontend/dist/index.html | 2 +- frontend/src/pages/StreamerConsole.tsx | 363 ++++++++++++++++++++++++- frontend/src/services/api.ts | 23 +- frontend/src/types/index.ts | 31 +++ 8 files changed, 632 insertions(+), 9 deletions(-) diff --git a/backend/app/models/game.py b/backend/app/models/game.py index ad9c222..02e0f2b 100644 --- a/backend/app/models/game.py +++ b/backend/app/models/game.py @@ -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="更新时间") diff --git a/backend/app/routers/game.py b/backend/app/routers/game.py index f526fd4..83f7e6b 100644 --- a/backend/app/routers/game.py +++ b/backend/app/routers/game.py @@ -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)) diff --git a/backend/app/schemas/game.py b/backend/app/schemas/game.py index 89a4c5a..f27a01b 100644 --- a/backend/app/schemas/game.py +++ b/backend/app/schemas/game.py @@ -79,4 +79,40 @@ class ChestSettle(BaseModel): class ChestLock(BaseModel): """宝箱封盘""" - pass \ No newline at end of file + 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 \ No newline at end of file diff --git a/backend/app/services/game_service.py b/backend/app/services/game_service.py index 751fa72..db130c5 100644 --- a/backend/app/services/game_service.py +++ b/backend/app/services/game_service.py @@ -614,4 +614,105 @@ class GameService: """ 获取宝箱下注记录 """ - return db.query(Bet).filter(Bet.chest_id == chest_id).all() \ No newline at end of file + 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 \ No newline at end of file diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 3687c8e..24efc3c 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -5,7 +5,7 @@ 互动竞猜开宝箱系统 - + diff --git a/frontend/src/pages/StreamerConsole.tsx b/frontend/src/pages/StreamerConsole.tsx index 91deeed..38f4e8f 100644 --- a/frontend/src/pages/StreamerConsole.tsx +++ b/frontend/src/pages/StreamerConsole.tsx @@ -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([]); + const [showTemplateForm, setShowTemplateForm] = useState(false); + const [editingTemplate, setEditingTemplate] = useState(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(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 ; } + // ============ 样式 ============ + 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 ( -
+ <> + +

🎮 主播控制台

@@ -295,6 +507,143 @@ const StreamerConsole = () => { {error &&
{error}
} + {/* 宝箱模板管理卡片 */} + +
+ + 最多可创建3个模板 +
+ + {showTemplateForm && ( +
+
+ + setTemplateName(e.target.value)} + placeholder="例如: 游戏类宝箱" + required + /> +
+ +
+ + setTemplateTitle(e.target.value)} + placeholder="例如: 这把能吃鸡吗?" + required + /> +
+ +
+
+ + setTemplateOptionA(e.target.value)} + placeholder="例如: 能" + required + /> +
+ +
+ + setTemplateOptionB(e.target.value)} + placeholder="例如: 不能" + required + /> +
+
+ +
+ + setTemplateCountdown(parseInt(e.target.value))} + min="60" + max="3600" + required + /> +
+ +
+ + +
+
+ )} + + {/* 模板列表 */} + {templates.length === 0 ? ( +

暂无模板,点击上方按钮创建

+ ) : ( +
+ {templates.map((template) => ( +
+
+

{template.name}

+

{template.title}

+ + {template.option_a} vs {template.option_b} | {template.countdown_seconds}秒 + +
+
+ + + +
+
+ ))} +
+ )} +
+ {/* 全局提醒:如果有封盘状态的宝箱 */} {lockedChests.length > 0 && (
@@ -320,7 +669,12 @@ const StreamerConsole = () => { )} {showCreateForm && ( - + + {selectedTemplateId && ( +
+ ✓ 已从模板加载配置,您可以直接创建或修改后创建 +
+ )}
@@ -547,7 +901,8 @@ const StreamerConsole = () => {
)} -
+
+ ); }; diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index b59cdc9..8ed3e1c 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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(`/api/chests/${chestId}/bets`); return response.data; }, + + // 宝箱模板相关API + getTemplates: async (): Promise => { + const response = await api.get('/api/templates'); + return response.data; + }, + + createTemplate: async (data: ChestTemplateCreate): Promise => { + const response = await api.post('/api/templates', data); + return response.data; + }, + + updateTemplate: async (templateId: number, data: ChestTemplateUpdate): Promise => { + const response = await api.put(`/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; + }, }; // 系统配置相关 diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index ba4999d..1880aa4 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -177,4 +177,35 @@ export interface StreamerStatistics { total_commission: number; 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; } \ No newline at end of file