From 2e9fc28d2c4de185fd240a93ddbfe53b0181e76d Mon Sep 17 00:00:00 2001 From: LEEE Date: Sun, 8 Mar 2026 17:06:02 +0800 Subject: [PATCH] feat: add social hub pages, invites, and generic LLM compatibility --- app.py | 381 +++++++++++++++++++++++++++------- config.py | 20 ++ game/room_manager.py | 33 ++- services/deepseek_client.py | 162 ++++++++++++--- services/persistence.py | 394 +++++++++++++++++++++++++++++++++++- static/css/style.css | 81 +++++++- static/js/invite_notify.js | 138 +++++++++++++ static/js/leaderboard.js | 129 ++++++++++++ static/js/lobby.js | 30 +-- static/js/room.js | 20 +- static/js/rooms.js | 156 ++++++++++++++ static/js/user_profile.js | 195 ++++++++++++++++++ templates/admin.html | 1 + templates/card_editor.html | 1 + templates/index.html | 14 +- templates/leaderboard.html | 42 ++++ templates/plaza.html | 3 + templates/room.html | 1 + templates/rooms.html | 50 +++++ templates/user_profile.html | 45 ++++ 20 files changed, 1736 insertions(+), 160 deletions(-) create mode 100644 static/js/invite_notify.js create mode 100644 static/js/leaderboard.js create mode 100644 static/js/rooms.js create mode 100644 static/js/user_profile.js create mode 100644 templates/leaderboard.html create mode 100644 templates/rooms.html create mode 100644 templates/user_profile.html diff --git a/app.py b/app.py index ab6f740..48ac5d0 100644 --- a/app.py +++ b/app.py @@ -63,6 +63,14 @@ ERROR_TEXTS = { "cannot_kick_host": "不能踢出房主。", "spectator_readonly": "观战模式下不能执行该操作。", "ai_invalid_json": "AI 返回了无效 JSON,本轮未结算,请稍后重试。", + "invite_invalid": "邀请参数无效。", + "invite_self_forbidden": "不能邀请自己。", + "invite_room_required": "请先选择要邀请进入的房间。", + "invite_room_unavailable": "该房间当前不可邀请。", + "invite_sender_not_in_room": "你不在该房间中,无法发起邀请。", + "invite_not_found": "邀请不存在或已处理。", + "invite_status_invalid": "邀请状态无效。", + "oauth_identity_invalid": "OAuth 绑定参数无效。", } ALLOWED_THEMES = {"minimal", "dopamine", "midnight", "mint", "sunset", "aurora", "graphite"} @@ -452,13 +460,19 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: state_manager = StateManager(persistence, story_engine) action_resolver = ActionResolver() deepseek_client = DeepSeekClient( - api_key=app.config.get("DEEPSEEK_API_KEY", ""), - base_url=app.config.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com"), - model=app.config.get("DEEPSEEK_MODEL", "deepseek-chat"), - timeout=float(app.config.get("DEEPSEEK_TIMEOUT", 25)), - connect_timeout=float(app.config.get("DEEPSEEK_CONNECT_TIMEOUT", 8)), - read_timeout=float(app.config.get("DEEPSEEK_READ_TIMEOUT", app.config.get("DEEPSEEK_TIMEOUT", 25))), - retries=int(app.config.get("DEEPSEEK_RETRIES", 2)), + api_key=app.config.get("LLM_API_KEY", app.config.get("DEEPSEEK_API_KEY", "")), + base_url=app.config.get("LLM_BASE_URL", app.config.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com")), + model=app.config.get("LLM_MODEL", app.config.get("DEEPSEEK_MODEL", "deepseek-chat")), + timeout=float(app.config.get("LLM_TIMEOUT", app.config.get("DEEPSEEK_TIMEOUT", 25))), + connect_timeout=float( + app.config.get("LLM_CONNECT_TIMEOUT", app.config.get("DEEPSEEK_CONNECT_TIMEOUT", 8)) + ), + read_timeout=float( + app.config.get("LLM_READ_TIMEOUT", app.config.get("DEEPSEEK_READ_TIMEOUT", app.config.get("DEEPSEEK_TIMEOUT", 25))) + ), + retries=int(app.config.get("LLM_RETRIES", app.config.get("DEEPSEEK_RETRIES", 2))), + extra_headers=app.config.get("LLM_EXTRA_HEADERS", {}), + force_json_mode=bool(app.config.get("LLM_FORCE_JSON_MODE", True)), ) room_manager = RoomManager( persistence, @@ -549,6 +563,51 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: "bio": user.get("bio", ""), } + def serialize_public_user(user: dict[str, Any] | None) -> dict[str, Any] | None: + if user is None: + return None + return { + "user_id": user["user_id"], + "username": user["username"], + "display_name": user.get("display_name", user["username"]), + "avatar_color": user.get("avatar_color", "#0070f2"), + "avatar_url": user.get("avatar_url", ""), + "theme": normalize_theme(user.get("theme", "dopamine")), + "bio": user.get("bio", ""), + "created_at": user.get("created_at", ""), + "updated_at": user.get("updated_at", ""), + } + + def build_user_profile_payload(user: dict[str, Any]) -> dict[str, Any]: + presence = persistence.get_user_presence(user["user_id"]) + stats = persistence.get_user_game_stats(user["user_id"]) + recent_rooms = persistence.list_user_rooms(user["user_id"], limit=8) + for row in recent_rooms: + card = persistence.get_game_card(row.get("game_card_id", "")) + row["card_title"] = card["title"] if card else "未知剧本" + row["card_cover_url"] = card.get("cover_url", "") if card else "" + return { + "user": serialize_public_user(user), + "presence": presence, + "stats": stats, + "recent_rooms": recent_rooms, + } + + def pick_unique_username(base_username: str, provider_user_id: str) -> str: + base = normalize_account_username(base_username) or "linuxdo_user" + if persistence.get_user_by_username(base) is None: + return base + suffix_seed = re.sub(r"[^a-z0-9]+", "", str(provider_user_id or "").lower())[-6:] or "id" + for seq in range(1, 80): + suffix = f"_{suffix_seed}{seq}" if seq > 1 else f"_{suffix_seed}" + candidate = (base[: max(3, 20 - len(suffix))] + suffix)[:20] + candidate = normalize_account_username(candidate) + if not candidate: + continue + if persistence.get_user_by_username(candidate) is None: + return candidate + return f"linuxdo_{secrets.token_hex(3)}"[:20] + def require_admin_json() -> tuple[dict[str, Any] | None, Any | None]: user = current_user() if user is None: @@ -561,6 +620,23 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: def index() -> str: return render_template("index.html", user=serialize_user(current_user())) + @app.get("/rooms") + def rooms_page() -> str: + return render_template("rooms.html", user=serialize_user(current_user())) + + @app.get("/leaderboard") + def leaderboard_page() -> str: + return render_template("leaderboard.html", user=serialize_user(current_user())) + + @app.get("/users/") + def user_profile_page(username: str) -> str: + safe_username = normalize_account_username(username) + return render_template( + "user_profile.html", + user=serialize_user(current_user()), + profile_username=safe_username or "", + ) + @app.get("/plaza") def plaza_page() -> str: return render_template("plaza.html", user=serialize_user(current_user())) @@ -726,17 +802,37 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: except ValueError: return oauth_error_redirect("用户信息缺少 id") - user = persistence.get_user_by_username(username) + nested_user = userinfo.get("user") + user_payload = nested_user if isinstance(nested_user, dict) else {} + provider_user_id = str(userinfo.get("id") or user_payload.get("id") or "").strip() + if not provider_user_id: + return oauth_error_redirect("用户信息缺少 id") + + user = None + identity = persistence.get_oauth_identity("linuxdo", provider_user_id) + if identity: + user = persistence.get_user_by_id(identity.get("user_id")) + if user is None: + final_username = pick_unique_username(username, provider_user_id) random_password = secrets.token_urlsafe(32) try: user = persistence.create_user( - username=username, + username=final_username, password_hash=generate_password_hash(random_password, method="pbkdf2:sha256"), display_name=display_name, ) except ValueError as exc: return oauth_error_redirect(f"创建本地账号失败: {readable_error(str(exc))}") + try: + persistence.bind_oauth_identity( + "linuxdo", + provider_user_id, + user["user_id"], + username_snapshot=username, + ) + except ValueError as exc: + return oauth_error_redirect(f"绑定 OAuth 身份失败: {readable_error(str(exc))}") session["user_id"] = user["user_id"] return redirect(url_for("index")) @@ -1645,6 +1741,52 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: row["card_title"] = card["title"] if card else "未知剧本" return jsonify({"ok": True, "rooms": rows}) + @app.get("/api/users/search") + def users_search_api() -> Any: + q = str(request.args.get("q", "")).strip() + limit_raw = request.args.get("limit", "20") + try: + limit = max(1, min(100, int(str(limit_raw)))) + except ValueError: + limit = 20 + rows = persistence.search_users(q, limit=limit) + users = [] + for row in rows: + profile = serialize_public_user(row) + if profile is None: + continue + profile["presence"] = persistence.get_user_presence(row["user_id"]) + profile["stats"] = persistence.get_user_game_stats(row["user_id"]) + users.append(profile) + return jsonify({"ok": True, "q": q, "users": users}) + + @app.get("/api/users//profile") + def user_profile_api(username: str) -> Any: + safe_username = normalize_account_username(username) + if not safe_username: + return jsonify({"ok": False, "error": readable_error("user_not_found")}), 404 + target = persistence.get_user_by_username(safe_username) + if target is None: + return jsonify({"ok": False, "error": readable_error("user_not_found")}), 404 + return jsonify({"ok": True, "profile": build_user_profile_payload(target)}) + + @app.get("/api/invites") + def list_invites_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + status = str(request.args.get("status", "pending")).strip().lower() or "pending" + limit_raw = request.args.get("limit", "20") + try: + limit = max(1, min(100, int(str(limit_raw)))) + except ValueError: + limit = 20 + rows = persistence.list_user_invitations(user["user_id"], status=status, limit=limit) + for row in rows: + card = persistence.get_game_card(row.get("room_game_card_id", "")) + row["card_title"] = card["title"] if card else "未知剧本" + return jsonify({"ok": True, "invitations": rows}) + @app.get("/api/leaderboard") def leaderboard_api() -> Any: limit_raw = request.args.get("limit", "20") @@ -1670,6 +1812,157 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: row["card_subtitle"] = card.get("subtitle", "") if card else "" return jsonify({"ok": True, "rooms": rows}) + def join_room_for_user( + room_id: str, + user: dict[str, Any] | None, + *, + player_id: str | None = None, + ) -> tuple[dict[str, Any], int]: + safe_room_id = str(room_id or "").strip().upper() + safe_player_id = str(player_id or "").strip() or None + if not safe_room_id: + return {"ok": False, "error": "请输入房间码"}, 400 + + room = persistence.get_room(safe_room_id) + if room is None: + return {"ok": False, "error": readable_error("room_not_found")}, 400 + + if room["status"] == "lobby": + if user is None: + return {"ok": False, "error": readable_error("auth_required")}, 401 + name = str(user["username"]).strip() + user_id = user["user_id"] + if not name: + return {"ok": False, "error": "用户名无效"}, 400 + try: + join_info, _ = room_manager.join_room(safe_room_id, name, safe_player_id, user_id=user_id) + return ( + { + "ok": True, + "room_id": safe_room_id, + "player_id": join_info["player_id"], + "player_name": join_info["player_name"], + "is_spectator": False, + "redirect": url_for( + "room_page", + room_id=safe_room_id, + player_id=join_info["player_id"], + ), + }, + 200, + ) + except ValueError as exc: + return {"ok": False, "error": readable_error(str(exc))}, 400 + + if user: + existing = persistence.get_player_by_user(safe_room_id, user["user_id"]) + if existing: + try: + join_info, _ = room_manager.join_room( + safe_room_id, + str(user["username"]).strip(), + existing["player_id"], + user_id=user["user_id"], + ) + return ( + { + "ok": True, + "room_id": safe_room_id, + "player_id": join_info["player_id"], + "player_name": join_info["player_name"], + "is_spectator": False, + "redirect": url_for( + "room_page", + room_id=safe_room_id, + player_id=join_info["player_id"], + ), + }, + 200, + ) + except ValueError: + pass + + return ( + { + "ok": True, + "room_id": safe_room_id, + "is_spectator": True, + "redirect": url_for("room_page", room_id=safe_room_id, spectator=1), + }, + 200, + ) + + @app.post("/api/invites") + def create_invite_api() -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + + payload = request.get_json(silent=True) or {} + to_user_id = str(payload.get("to_user_id", "")).strip() + to_username = normalize_account_username(payload.get("to_username")) + room_id = str(payload.get("room_id", "")).strip().upper() + message = str(payload.get("message", "")).strip()[:200] + + if not room_id: + return jsonify({"ok": False, "error": readable_error("invite_room_required")}), 400 + if not to_user_id and not to_username: + return jsonify({"ok": False, "error": readable_error("invite_invalid")}), 400 + + target = None + if to_user_id: + target = persistence.get_user_by_id(to_user_id) + if target is None and to_username: + target = persistence.get_user_by_username(to_username) + if target is None: + return jsonify({"ok": False, "error": readable_error("user_not_found")}), 404 + if str(target["user_id"]) == str(user["user_id"]): + return jsonify({"ok": False, "error": readable_error("invite_self_forbidden")}), 400 + + room = persistence.get_room(room_id) + if room is None: + return jsonify({"ok": False, "error": readable_error("room_not_found")}), 400 + if room["status"] not in {"lobby", "running"}: + return jsonify({"ok": False, "error": readable_error("invite_room_unavailable")}), 400 + + sender_player = persistence.get_player_by_user(room_id, user["user_id"]) + if sender_player is None: + return jsonify({"ok": False, "error": readable_error("invite_sender_not_in_room")}), 400 + + invite = persistence.create_invitation( + from_user_id=user["user_id"], + to_user_id=target["user_id"], + room_id=room_id, + message=message, + ) + invite["from_username"] = user["username"] + invite["to_username"] = target["username"] + return jsonify({"ok": True, "invitation": invite}) + + @app.post("/api/invites//accept") + def accept_invite_api(invite_id: int) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + invite = persistence.get_pending_invitation(invite_id, user["user_id"]) + if invite is None: + return jsonify({"ok": False, "error": readable_error("invite_not_found")}), 404 + persistence.update_invitation_status(invite_id, user["user_id"], "accepted") + payload, status = join_room_for_user(invite.get("room_id", ""), user) + payload["invite_id"] = invite_id + return jsonify(payload), status + + @app.post("/api/invites//dismiss") + def dismiss_invite_api(invite_id: int) -> Any: + user = current_user() + if user is None: + return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 + try: + invite = persistence.update_invitation_status(invite_id, user["user_id"], "dismissed") + return jsonify({"ok": True, "invitation": invite}) + except ValueError as exc: + return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 + @app.post("/api/rooms/create") def create_room_api() -> Any: payload = request.get_json(silent=True) or {} @@ -1723,74 +2016,8 @@ def create_app(config_object: type[Config] = Config) -> tuple[Flask, SocketIO]: room_id = str(payload.get("room_id", "")).strip().upper() player_id = str(payload.get("player_id", "")).strip() or None user = current_user() - - if not room_id: - return jsonify({"ok": False, "error": "请输入房间码"}), 400 - - room = persistence.get_room(room_id) - if room is None: - return jsonify({"ok": False, "error": readable_error("room_not_found")}), 400 - - if room["status"] == "lobby": - if user is None: - return jsonify({"ok": False, "error": readable_error("auth_required")}), 401 - name = str(user["username"]).strip() - user_id = user["user_id"] - try: - join_info, _ = room_manager.join_room(room_id, name, player_id, user_id=user_id) - return jsonify( - { - "ok": True, - "room_id": room_id, - "player_id": join_info["player_id"], - "player_name": join_info["player_name"], - "is_spectator": False, - "redirect": url_for( - "room_page", - room_id=room_id, - player_id=join_info["player_id"], - ), - } - ) - except ValueError as exc: - return jsonify({"ok": False, "error": readable_error(str(exc))}), 400 - - # running / ended: existing player can reconnect, others进入观战 - if user: - existing = persistence.get_player_by_user(room_id, user["user_id"]) - if existing: - try: - join_info, _ = room_manager.join_room( - room_id, - str(user["username"]).strip(), - existing["player_id"], - user_id=user["user_id"], - ) - return jsonify( - { - "ok": True, - "room_id": room_id, - "player_id": join_info["player_id"], - "player_name": join_info["player_name"], - "is_spectator": False, - "redirect": url_for( - "room_page", - room_id=room_id, - player_id=join_info["player_id"], - ), - } - ) - except ValueError: - pass - - return jsonify( - { - "ok": True, - "room_id": room_id, - "is_spectator": True, - "redirect": url_for("room_page", room_id=room_id, spectator=1), - } - ) + payload_out, status = join_room_for_user(room_id, user, player_id=player_id) + return jsonify(payload_out), status @app.get("/invite/") def invite_link_page(room_id: str) -> Any: diff --git a/config.py b/config.py index e5eaac2..fe21814 100644 --- a/config.py +++ b/config.py @@ -17,6 +17,15 @@ DEFAULT_SETTINGS: dict[str, Any] = { "deepseek_connect_timeout": 8, "deepseek_read_timeout": 90, "deepseek_retries": 2, + "llm_api_key": "", + "llm_base_url": "", + "llm_model": "", + "llm_timeout": 25, + "llm_connect_timeout": 8, + "llm_read_timeout": 90, + "llm_retries": 2, + "llm_extra_headers": {}, + "llm_force_json_mode": True, "max_room_players": 8, "max_action_length": 280, "app_host": "0.0.0.0", @@ -88,6 +97,16 @@ class Config: SETTINGS.get("deepseek_read_timeout", DEFAULT_SETTINGS["deepseek_read_timeout"]) ) DEEPSEEK_RETRIES = int(SETTINGS.get("deepseek_retries", DEFAULT_SETTINGS["deepseek_retries"])) + LLM_API_KEY = str(SETTINGS.get("llm_api_key") or DEEPSEEK_API_KEY) + LLM_BASE_URL = str(SETTINGS.get("llm_base_url") or DEEPSEEK_BASE_URL) + LLM_MODEL = str(SETTINGS.get("llm_model") or DEEPSEEK_MODEL) + LLM_TIMEOUT = float(SETTINGS.get("llm_timeout", DEEPSEEK_TIMEOUT)) + LLM_CONNECT_TIMEOUT = float(SETTINGS.get("llm_connect_timeout", DEEPSEEK_CONNECT_TIMEOUT)) + LLM_READ_TIMEOUT = float(SETTINGS.get("llm_read_timeout", DEEPSEEK_READ_TIMEOUT)) + LLM_RETRIES = int(SETTINGS.get("llm_retries", DEEPSEEK_RETRIES)) + LLM_FORCE_JSON_MODE = bool(SETTINGS.get("llm_force_json_mode", True)) + _llm_extra_headers_raw = SETTINGS.get("llm_extra_headers", {}) + LLM_EXTRA_HEADERS = _llm_extra_headers_raw if isinstance(_llm_extra_headers_raw, dict) else {} MAX_ROOM_PLAYERS = int( SETTINGS.get("max_room_players", DEFAULT_SETTINGS["max_room_players"]) ) @@ -104,3 +123,4 @@ class TestConfig(Config): TESTING = True SECRET_KEY = "test-secret" DEEPSEEK_API_KEY = "" + LLM_API_KEY = "" diff --git a/game/room_manager.py b/game/room_manager.py index 6c891ca..2d69a4e 100644 --- a/game/room_manager.py +++ b/game/room_manager.py @@ -11,7 +11,7 @@ from typing import Any from .action_resolver import ActionResolver from .models import PlayerAction from .prompts import build_messages -from .schemas import parse_json_payload, validate_ai_payload +from .schemas import fallback_ai_payload, parse_json_payload, validate_ai_payload from .state_manager import StateManager from .story_engine import StoryEngine from services.deepseek_client import DeepSeekClient @@ -71,6 +71,14 @@ class RoomManager: normalized = normalized[: self.max_action_length] return html.escape(normalized) + def _normalize_ai_narration(self, content: Any, default: str) -> str: + text = " ".join(str(content or "").strip().split()) + if not text: + return default + if len(text) > 420: + return f"{text[:417]}..." + return text + def _ensure_unique_name(self, name: str, existing_names: set[str]) -> str: if name not in existing_names: return name @@ -468,19 +476,30 @@ class RoomManager: } ai_payload = None + ai_notice = "" ai_call = self.deepseek_client.chat_json(build_messages(prompt_context)) if ai_call.get("ok"): - parsed, parse_error = parse_json_payload(ai_call.get("content") or "") + raw_content = str(ai_call.get("content") or "") + parsed, parse_error = parse_json_payload(raw_content) if parse_error: - raise ValueError("ai_invalid_json") + ai_payload = fallback_ai_payload( + reason=self._normalize_ai_narration( + raw_content, + "AI 输出了非 JSON 文本,系统已自动兼容并继续推进剧情。", + ) + ) + ai_notice = "AI 返回非结构化文本,已自动兼容。" else: ok, errors, normalized = validate_ai_payload(parsed or {}) ai_payload = normalized if not ok: - raise ValueError(f"ai_invalid_schema:{','.join(errors[:3])}") + ai_notice = f"AI 输出字段不完整({','.join(errors[:3])}),已自动修复。" else: ai_error = str(ai_call.get("error", "ai_call_failed")).strip() or "ai_call_failed" - raise ValueError(f"ai_call_failed:{ai_error}") + ai_payload = fallback_ai_payload( + reason=f"AI 暂时不可用({ai_error}),本轮由主持规则接管推进。" + ) + ai_notice = f"AI 调用异常({ai_error}),已切换规则主持。" if ai_payload is None: raise ValueError("ai_call_failed:empty_ai_payload") @@ -489,6 +508,8 @@ class RoomManager: next_status = "ended" if next_state.get("game_phase") == "ended" else "running" self.state_manager.set_room_state(room_id, next_status, next_state) + if ai_notice: + self.persistence.append_message(room_id, "system", ai_notice) if round_result.get("merge_summary"): self.persistence.append_message(room_id, "system", round_result["merge_summary"]) if round_result.get("narration"): @@ -564,6 +585,8 @@ class RoomManager: "player_id": player["player_id"], "name": player["name"], "user_id": player.get("user_id"), + "username": user_cache.get(str(player.get("user_id") or ""), {}).get("username", ""), + "display_name": user_cache.get(str(player.get("user_id") or ""), {}).get("display_name", ""), "avatar_color": user_cache.get(str(player.get("user_id") or ""), {}).get("avatar_color", "#0070f2"), "avatar_url": user_cache.get(str(player.get("user_id") or ""), {}).get("avatar_url", ""), "is_host": player["is_host"], diff --git a/services/deepseek_client.py b/services/deepseek_client.py index cb71a85..a344852 100644 --- a/services/deepseek_client.py +++ b/services/deepseek_client.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import time from typing import Any @@ -16,6 +17,8 @@ class DeepSeekClient: connect_timeout: float | None = None, read_timeout: float | None = None, retries: int = 2, + extra_headers: dict[str, Any] | None = None, + force_json_mode: bool = True, ): self.api_key = api_key.strip() self.base_url = base_url.rstrip("/") @@ -24,6 +27,107 @@ class DeepSeekClient: self.connect_timeout = float(connect_timeout) if connect_timeout is not None else 8.0 self.read_timeout = float(read_timeout) if read_timeout is not None else float(timeout) self.retries = retries + self.force_json_mode = bool(force_json_mode) + self.extra_headers = self._normalize_headers(extra_headers) + + def _normalize_headers(self, headers: dict[str, Any] | None) -> dict[str, str]: + if not isinstance(headers, dict): + return {} + normalized: dict[str, str] = {} + for key, value in headers.items(): + safe_key = str(key or "").strip() + safe_value = str(value or "").strip() + if safe_key and safe_value: + normalized[safe_key] = safe_value + return normalized + + def _chat_url(self) -> str: + if self.base_url.endswith("/chat/completions"): + return self.base_url + if self.base_url.endswith("/v1"): + return f"{self.base_url}/chat/completions" + return f"{self.base_url}/v1/chat/completions" + + def _request_headers(self) -> dict[str, str]: + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + headers.update(self.extra_headers) + if "openrouter.ai" in self.base_url: + headers.setdefault("HTTP-Referer", "http://localhost") + headers.setdefault("X-Title", "Echo Tavern") + return headers + + def _payload(self, messages: list[dict[str, str]], temperature: float, max_tokens: int, use_json_mode: bool) -> dict[str, Any]: + payload: dict[str, Any] = { + "model": self.model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + } + if use_json_mode: + payload["response_format"] = {"type": "json_object"} + return payload + + def _is_response_format_unsupported(self, status_code: int, raw_text: str) -> bool: + if status_code not in {400, 404, 415, 422}: + return False + lower = str(raw_text or "").lower() + if "response_format" in lower: + return True + if "json_object" in lower and "support" in lower: + return True + return "json schema" in lower and "support" in lower + + def _content_to_text(self, value: Any) -> str: + if isinstance(value, str): + return value.strip() + if isinstance(value, dict): + for key in ("text", "content", "message"): + text = value.get(key) + if isinstance(text, str) and text.strip(): + return text.strip() + return "" + if isinstance(value, list): + parts: list[str] = [] + for row in value: + if isinstance(row, str): + text = row.strip() + if text: + parts.append(text) + continue + if not isinstance(row, dict): + continue + text = row.get("text") + if not isinstance(text, str): + nested = row.get("content") + if isinstance(nested, str): + text = nested + if isinstance(text, str): + clean = text.strip() + if clean: + parts.append(clean) + return "\n".join(parts).strip() + return "" + + def _extract_content(self, body: dict[str, Any]) -> str: + choices = body.get("choices", []) + if isinstance(choices, list) and choices: + first = choices[0] if isinstance(choices[0], dict) else {} + message = first.get("message", {}) + content = "" + if isinstance(message, dict): + content = self._content_to_text(message.get("content")) + if not content: + content = self._content_to_text(first.get("text")) + if content: + return content + + output_text = self._content_to_text(body.get("output_text")) + if output_text: + return output_text + return "" def chat_json( self, @@ -35,52 +139,61 @@ class DeepSeekClient: if not self.api_key: return {"ok": False, "error": "missing_api_key", "content": None, "detail": "api_key_empty"} - url = f"{self.base_url}/v1/chat/completions" - headers = { - "Authorization": f"Bearer {self.api_key}", - "Content-Type": "application/json", - } - payload = { - "model": self.model, - "messages": messages, - "temperature": temperature, - "max_tokens": max_tokens, - "response_format": {"type": "json_object"}, - } + url = self._chat_url() + headers = self._request_headers() last_error = "request_failed" last_detail = "" - for attempt in range(self.retries + 1): + attempt = 0 + use_json_mode = self.force_json_mode + while attempt <= self.retries: try: response = requests.post( url, headers=headers, - json=payload, + json=self._payload(messages, temperature, max_tokens, use_json_mode), timeout=(self.connect_timeout, self.read_timeout), ) + raw_text = response.text or "" if response.status_code >= 500: last_error = f"server_error_{response.status_code}" raise requests.HTTPError(last_error) if response.status_code >= 400: + if use_json_mode and self._is_response_format_unsupported(response.status_code, raw_text): + use_json_mode = False + continue return { "ok": False, "error": f"http_{response.status_code}", "content": None, "detail": "", - "raw": response.text, + "raw": raw_text, } - body = response.json() - choices = body.get("choices", []) - if not choices: - return {"ok": False, "error": "empty_choices", "content": None, "detail": "", "raw": body} + try: + body = response.json() + except json.JSONDecodeError: + return { + "ok": False, + "error": "invalid_json_response", + "content": None, + "detail": "", + "raw": raw_text, + } - content = choices[0].get("message", {}).get("content", "") - if not isinstance(content, str) or not content.strip(): + content = self._extract_content(body) + if not content: return {"ok": False, "error": "empty_content", "content": None, "detail": "", "raw": body} - return {"ok": True, "error": None, "content": content, "detail": "", "raw": body} + return { + "ok": True, + "error": None, + "content": content, + "detail": "", + "raw": body, + "usage": body.get("usage"), + } except requests.Timeout as exc: last_error = "request_timeout" @@ -102,7 +215,8 @@ class DeepSeekClient: last_error = "invalid_response" last_detail = "" - if attempt < self.retries: - time.sleep(0.4 * (attempt + 1)) + attempt += 1 + if attempt <= self.retries: + time.sleep(0.4 * attempt) return {"ok": False, "error": last_error, "content": None, "detail": last_detail} diff --git a/services/persistence.py b/services/persistence.py index ce05eef..26f7494 100644 --- a/services/persistence.py +++ b/services/persistence.py @@ -109,6 +109,33 @@ class SQLitePersistence: created_at TEXT NOT NULL, FOREIGN KEY(room_id) REFERENCES rooms(room_id) ); + + CREATE TABLE IF NOT EXISTS oauth_identities ( + provider TEXT NOT NULL, + provider_user_id TEXT NOT NULL, + user_id TEXT NOT NULL, + username_snapshot TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY(provider, provider_user_id), + UNIQUE(provider, user_id), + FOREIGN KEY(user_id) REFERENCES users(user_id) + ); + + CREATE TABLE IF NOT EXISTS invitations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_user_id TEXT NOT NULL, + to_user_id TEXT NOT NULL, + room_id TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'pending', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + responded_at TEXT, + FOREIGN KEY(from_user_id) REFERENCES users(user_id), + FOREIGN KEY(to_user_id) REFERENCES users(user_id), + FOREIGN KEY(room_id) REFERENCES rooms(room_id) + ); """ ) @@ -120,6 +147,12 @@ class SQLitePersistence: self._ensure_column(conn, "users", "role", "TEXT NOT NULL DEFAULT 'player'") self._ensure_column(conn, "users", "avatar_url", "TEXT NOT NULL DEFAULT ''") self._ensure_column(conn, "users", "theme", "TEXT NOT NULL DEFAULT 'dopamine'") + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_invitations_to_status ON invitations(to_user_id, status, created_at DESC)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_players_user_id_room ON players(user_id, room_id)" + ) conn.commit() self._seed_default_cards(conn) @@ -876,6 +909,115 @@ class SQLitePersistence: ).fetchall() return [dict(row) for row in rows] + def search_users(self, query: str, limit: int = 20) -> list[dict[str, Any]]: + text = str(query or "").strip() + try: + safe_limit = max(1, min(100, int(limit))) + except (TypeError, ValueError): + safe_limit = 20 + with self._lock, self._connect() as conn: + if text: + like = f"%{text}%" + rows = conn.execute( + """ + SELECT + user_id, + username, + display_name, + role, + avatar_color, + avatar_url, + theme, + bio, + created_at, + updated_at + FROM users + WHERE username LIKE ? OR display_name LIKE ? + ORDER BY CASE WHEN username = ? THEN 0 ELSE 1 END ASC, updated_at DESC + LIMIT ? + """, + (like, like, text.lower(), safe_limit), + ).fetchall() + else: + rows = conn.execute( + """ + SELECT + user_id, + username, + display_name, + role, + avatar_color, + avatar_url, + theme, + bio, + created_at, + updated_at + FROM users + ORDER BY updated_at DESC + LIMIT ? + """, + (safe_limit,), + ).fetchall() + return [dict(row) for row in rows] + + def get_user_presence(self, user_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + if not safe_user_id: + return { + "is_online": False, + "active_room_id": "", + "active_room_status": "", + "active_player_id": "", + "active_player_name": "", + "last_seen": "", + } + with self._lock, self._connect() as conn: + online_row = conn.execute( + """ + SELECT + p.player_id, + p.name, + p.room_id, + p.last_seen, + r.status + FROM players p + JOIN rooms r ON r.room_id = p.room_id + WHERE p.user_id=? AND p.is_online=1 AND r.status IN ('lobby', 'running') + ORDER BY p.last_seen DESC + LIMIT 1 + """, + (safe_user_id,), + ).fetchone() + latest_row = conn.execute( + """ + SELECT last_seen + FROM players + WHERE user_id=? + ORDER BY last_seen DESC + LIMIT 1 + """, + (safe_user_id,), + ).fetchone() + + if online_row: + row = dict(online_row) + return { + "is_online": True, + "active_room_id": str(row.get("room_id", "")).strip(), + "active_room_status": str(row.get("status", "")).strip(), + "active_player_id": str(row.get("player_id", "")).strip(), + "active_player_name": str(row.get("name", "")).strip(), + "last_seen": str(row.get("last_seen", "")).strip(), + } + return { + "is_online": False, + "active_room_id": "", + "active_room_status": "", + "active_player_id": "", + "active_player_name": "", + "last_seen": str((dict(latest_row) if latest_row else {}).get("last_seen", "")).strip(), + } + def _is_winning_ending(self, ending_state: Any) -> bool: ending = str(ending_state or "").strip().lower() if not ending: @@ -890,11 +1032,15 @@ class SQLitePersistence: "custom_end", } - def list_player_leaderboard(self, limit: int = 20) -> list[dict[str, Any]]: - try: - safe_limit = max(1, min(100, int(limit))) - except (TypeError, ValueError): - safe_limit = 20 + def list_player_leaderboard(self, limit: int | None = 20) -> list[dict[str, Any]]: + safe_limit: int | None + if limit is None: + safe_limit = None + else: + try: + safe_limit = max(1, min(100, int(limit))) + except (TypeError, ValueError): + safe_limit = 20 with self._lock, self._connect() as conn: rows = conn.execute( @@ -991,11 +1137,247 @@ class SQLitePersistence: reverse=True, ) - ranked = results[:safe_limit] + ranked = results[:safe_limit] if safe_limit is not None else results for idx, row in enumerate(ranked, start=1): row["rank"] = idx return ranked + def get_user_game_stats(self, user_id: str) -> dict[str, Any]: + safe_user_id = str(user_id or "").strip() + if not safe_user_id: + return { + "played_games": 0, + "wins": 0, + "win_rate": 0.0, + "total_rounds": 0, + "score": 0, + "rank": None, + "last_played_at": "", + } + leaderboard = self.list_player_leaderboard(limit=None) + row = next((item for item in leaderboard if str(item.get("user_id", "")) == safe_user_id), None) + if row is None: + return { + "played_games": 0, + "wins": 0, + "win_rate": 0.0, + "total_rounds": 0, + "score": 0, + "rank": None, + "last_played_at": "", + } + return { + "played_games": int(row.get("played_games", 0)), + "wins": int(row.get("wins", 0)), + "win_rate": float(row.get("win_rate", 0.0)), + "total_rounds": int(row.get("total_rounds", 0)), + "score": int(row.get("score", 0)), + "rank": int(row.get("rank", 0)) if row.get("rank") is not None else None, + "last_played_at": str(row.get("last_played_at", "")).strip(), + } + + def get_oauth_identity(self, provider: str, provider_user_id: str) -> dict[str, Any] | None: + safe_provider = str(provider or "").strip().lower() + safe_provider_user_id = str(provider_user_id or "").strip() + if not safe_provider or not safe_provider_user_id: + return None + with self._lock, self._connect() as conn: + row = conn.execute( + """ + SELECT provider, provider_user_id, user_id, username_snapshot, created_at, updated_at + FROM oauth_identities + WHERE provider=? AND provider_user_id=? + """, + (safe_provider, safe_provider_user_id), + ).fetchone() + return dict(row) if row else None + + def bind_oauth_identity( + self, + provider: str, + provider_user_id: str, + user_id: str, + *, + username_snapshot: str = "", + ) -> None: + safe_provider = str(provider or "").strip().lower() + safe_provider_user_id = str(provider_user_id or "").strip() + safe_user_id = str(user_id or "").strip() + safe_snapshot = str(username_snapshot or "").strip() + if not safe_provider or not safe_provider_user_id or not safe_user_id: + raise ValueError("oauth_identity_invalid") + now = utc_now_iso() + with self._lock, self._connect() as conn: + conn.execute( + """ + INSERT INTO oauth_identities(provider, provider_user_id, user_id, username_snapshot, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT(provider, provider_user_id) DO UPDATE SET + user_id=excluded.user_id, + username_snapshot=excluded.username_snapshot, + updated_at=excluded.updated_at + """, + (safe_provider, safe_provider_user_id, safe_user_id, safe_snapshot, now, now), + ) + conn.commit() + + def create_invitation( + self, + *, + from_user_id: str, + to_user_id: str, + room_id: str, + message: str = "", + ) -> dict[str, Any]: + safe_from = str(from_user_id or "").strip() + safe_to = str(to_user_id or "").strip() + safe_room_id = str(room_id or "").strip().upper() + safe_message = str(message or "").strip()[:200] + if not safe_from or not safe_to or not safe_room_id: + raise ValueError("invite_invalid") + now = utc_now_iso() + with self._lock, self._connect() as conn: + existing = conn.execute( + """ + SELECT id FROM invitations + WHERE from_user_id=? AND to_user_id=? AND room_id=? AND status='pending' + ORDER BY id DESC + LIMIT 1 + """, + (safe_from, safe_to, safe_room_id), + ).fetchone() + if existing: + conn.execute( + """ + UPDATE invitations + SET message=?, updated_at=? + WHERE id=? + """, + (safe_message, now, int(existing["id"])), + ) + conn.commit() + row = conn.execute( + "SELECT * FROM invitations WHERE id=?", + (int(existing["id"]),), + ).fetchone() + return dict(row) if row else {} + + conn.execute( + """ + INSERT INTO invitations( + from_user_id, to_user_id, room_id, message, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, 'pending', ?, ?) + """, + (safe_from, safe_to, safe_room_id, safe_message, now, now), + ) + invite_id = int(conn.execute("SELECT last_insert_rowid()").fetchone()[0]) + conn.commit() + row = conn.execute("SELECT * FROM invitations WHERE id=?", (invite_id,)).fetchone() + return dict(row) if row else {} + + def list_user_invitations( + self, + to_user_id: str, + *, + status: str = "pending", + limit: int = 20, + ) -> list[dict[str, Any]]: + safe_to = str(to_user_id or "").strip() + safe_status = str(status or "").strip().lower() or "pending" + try: + safe_limit = max(1, min(100, int(limit))) + except (TypeError, ValueError): + safe_limit = 20 + if not safe_to: + return [] + with self._lock, self._connect() as conn: + rows = conn.execute( + """ + SELECT + i.id, + i.from_user_id, + i.to_user_id, + i.room_id, + i.message, + i.status, + i.created_at, + i.updated_at, + i.responded_at, + fu.username AS from_username, + fu.display_name AS from_display_name, + fu.avatar_color AS from_avatar_color, + fu.avatar_url AS from_avatar_url, + r.status AS room_status, + r.is_public AS room_is_public, + r.game_card_id AS room_game_card_id + FROM invitations i + JOIN users fu ON fu.user_id = i.from_user_id + LEFT JOIN rooms r ON r.room_id = i.room_id + WHERE i.to_user_id=? AND i.status=? + ORDER BY i.created_at DESC + LIMIT ? + """, + (safe_to, safe_status, safe_limit), + ).fetchall() + result = [] + for row in rows: + item = dict(row) + item["room_is_public"] = bool(item.get("room_is_public", 0)) + result.append(item) + return result + + def get_pending_invitation(self, invite_id: int, to_user_id: str) -> dict[str, Any] | None: + safe_to = str(to_user_id or "").strip() + try: + safe_id = int(invite_id) + except (TypeError, ValueError): + return None + with self._lock, self._connect() as conn: + row = conn.execute( + """ + SELECT * + FROM invitations + WHERE id=? AND to_user_id=? AND status='pending' + """, + (safe_id, safe_to), + ).fetchone() + return dict(row) if row else None + + def update_invitation_status(self, invite_id: int, to_user_id: str, status: str) -> dict[str, Any]: + safe_to = str(to_user_id or "").strip() + safe_status = str(status or "").strip().lower() + if safe_status not in {"accepted", "dismissed"}: + raise ValueError("invite_status_invalid") + try: + safe_id = int(invite_id) + except (TypeError, ValueError): + raise ValueError("invite_not_found") + + with self._lock, self._connect() as conn: + row = conn.execute( + """ + SELECT * + FROM invitations + WHERE id=? AND to_user_id=? AND status='pending' + """, + (safe_id, safe_to), + ).fetchone() + if row is None: + raise ValueError("invite_not_found") + + now = utc_now_iso() + conn.execute( + """ + UPDATE invitations + SET status=?, responded_at=?, updated_at=? + WHERE id=? + """, + (safe_status, now, now, safe_id), + ) + conn.commit() + out = conn.execute("SELECT * FROM invitations WHERE id=?", (safe_id,)).fetchone() + return dict(out) if out else {} + def list_users(self, limit: int = 500) -> list[dict[str, Any]]: with self._lock, self._connect() as conn: rows = conn.execute( diff --git a/static/css/style.css b/static/css/style.css index 64ab178..5309049 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1526,12 +1526,6 @@ body.room-page .meta-panel li { font-size: 1.02rem; } -.player-host::after { - content: " 房主"; - color: var(--n-red); - font-weight: 800; -} - .player-row { display: grid; gap: 0.35rem; @@ -1543,6 +1537,16 @@ body.room-page .meta-panel li { gap: 0.42rem; } +.player-name-link { + color: inherit; + text-decoration: none; + font-weight: 700; +} + +.player-name-link:hover { + text-decoration: underline; +} + .player-profile img { width: 22px; height: 22px; @@ -1591,6 +1595,55 @@ body.room-page .meta-panel li { color: #9f1f2f; } +.profile-hero { + display: grid; + gap: 0.3rem; +} + +.invite-toast-stack { + position: fixed; + top: 0.9rem; + right: 0.9rem; + z-index: 1400; + display: grid; + gap: 0.56rem; + width: min(360px, calc(100vw - 1.2rem)); +} + +.invite-toast { + border-radius: 14px; + border: 1px solid rgba(124, 167, 247, 0.45); + background: rgba(247, 251, 255, 0.96); + box-shadow: 0 10px 24px rgba(15, 36, 78, 0.18); + padding: 0.72rem; + display: grid; + gap: 0.42rem; +} + +.invite-toast h4 { + margin: 0; + font-size: 0.96rem; +} + +.invite-toast p { + margin: 0; + font-size: 0.87rem; + line-height: 1.4; +} + +.invite-toast-actions { + display: flex; + gap: 0.4rem; + flex-wrap: wrap; +} + +.invite-toast-actions > * { + width: auto; + min-height: 34px; + padding: 0.38rem 0.6rem; + border-radius: 10px; +} + /* ===== Theme Overrides (Strong Visual Separation) ===== */ /* Minimal: flat, restrained, almost monochrome */ @@ -1848,6 +1901,14 @@ body[data-theme="midnight"] .choice-chip { border-color: rgba(176, 206, 255, 0.65) !important; } +body[data-theme="midnight"] .invite-toast, +body[data-theme="graphite"] .invite-toast { + background: rgba(18, 28, 52, 0.96); + border-color: rgba(114, 149, 232, 0.48); + color: #e8f0ff; + box-shadow: 0 14px 28px rgba(2, 8, 22, 0.45); +} + /* Mint: airy, calm, high readability */ body[data-theme="mint"] { background: @@ -2221,4 +2282,12 @@ body[data-theme="sunset"].room-page .meta-panel li { .invite-qr { width: min(190px, 100%); } + + .invite-toast-stack { + top: auto; + bottom: 0.66rem; + right: 0.6rem; + left: 0.6rem; + width: auto; + } } diff --git a/static/js/invite_notify.js b/static/js/invite_notify.js new file mode 100644 index 0000000..3bc21c1 --- /dev/null +++ b/static/js/invite_notify.js @@ -0,0 +1,138 @@ +(function () { + const body = document.body; + if (!body) return; + + const currentUser = (() => { + try { + return JSON.parse(body.dataset.user || 'null'); + } catch { + return null; + } + })(); + if (!currentUser || !currentUser.user_id) return; + + const setStatus = (text) => { + const statusEl = document.getElementById('status'); + if (statusEl) statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const postJson = async (url, payload) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload || {}), + }); + return res.json(); + }; + + let stack = document.getElementById('invite-toast-stack'); + if (!stack) { + stack = document.createElement('div'); + stack.id = 'invite-toast-stack'; + stack.className = 'invite-toast-stack'; + body.appendChild(stack); + } + + const renderInvites = (rows) => { + stack.textContent = ''; + rows.forEach((row) => { + const card = document.createElement('section'); + card.className = 'invite-toast'; + + const title = document.createElement('h4'); + title.textContent = '房间邀请'; + card.appendChild(title); + + const text = document.createElement('p'); + const fromUser = row.from_username || '玩家'; + const roomId = row.room_id || '-'; + const roomState = row.room_status === 'running' ? '进行中' : '准备中'; + text.textContent = `${fromUser} 邀请你加入房间 ${roomId}(${roomState})`; + card.appendChild(text); + + if (row.message) { + const message = document.createElement('p'); + message.className = 'helper-text'; + message.textContent = `留言:${row.message}`; + card.appendChild(message); + } + + const actions = document.createElement('div'); + actions.className = 'invite-toast-actions'; + + const profileLink = document.createElement('a'); + profileLink.className = 'ghost-btn'; + profileLink.href = `/users/${encodeURIComponent(fromUser)}`; + profileLink.textContent = '查看主页'; + actions.appendChild(profileLink); + + const acceptBtn = document.createElement('button'); + acceptBtn.type = 'button'; + acceptBtn.className = 'secondary-btn'; + acceptBtn.textContent = '接受'; + acceptBtn.addEventListener('click', async () => { + acceptBtn.disabled = true; + try { + const data = await postJson(`/api/invites/${row.id}/accept`, {}); + if (!data.ok) { + setStatus(data.error || '接受邀请失败'); + acceptBtn.disabled = false; + return; + } + if (data.redirect) { + window.location.href = data.redirect; + return; + } + setStatus('邀请已接受。'); + pollInvites(); + } catch (error) { + setStatus(`网络错误: ${error}`); + acceptBtn.disabled = false; + } + }); + actions.appendChild(acceptBtn); + + const dismissBtn = document.createElement('button'); + dismissBtn.type = 'button'; + dismissBtn.className = 'ghost-btn'; + dismissBtn.textContent = '稍后'; + dismissBtn.addEventListener('click', async () => { + dismissBtn.disabled = true; + try { + const data = await postJson(`/api/invites/${row.id}/dismiss`, {}); + if (!data.ok) { + setStatus(data.error || '操作失败'); + dismissBtn.disabled = false; + return; + } + pollInvites(); + } catch (error) { + setStatus(`网络错误: ${error}`); + dismissBtn.disabled = false; + } + }); + actions.appendChild(dismissBtn); + + card.appendChild(actions); + stack.appendChild(card); + }); + }; + + const pollInvites = async () => { + try { + const data = await getJson('/api/invites?status=pending&limit=6'); + if (!data.ok) return; + renderInvites(data.invitations || []); + } catch { + // ignore polling failures + } + }; + + pollInvites(); + window.setInterval(pollInvites, 9000); +})(); diff --git a/static/js/leaderboard.js b/static/js/leaderboard.js new file mode 100644 index 0000000..7ebe1f8 --- /dev/null +++ b/static/js/leaderboard.js @@ -0,0 +1,129 @@ +(function () { + const body = document.body; + const statusEl = document.getElementById('status'); + const leaderboardList = document.getElementById('leaderboard-list'); + const searchForm = document.getElementById('search-form'); + const searchInput = document.getElementById('search-input'); + const searchResults = document.getElementById('search-results'); + + const setStatus = (text) => { + statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const renderLeaderboard = (rows) => { + if (!leaderboardList) return; + if (!rows.length) { + leaderboardList.textContent = '暂无排行数据'; + return; + } + const table = document.createElement('table'); + table.className = 'admin-table'; + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + ['排名', '用户名', '胜场', '参与局', '胜率', '总回合'].forEach((text) => { + const th = document.createElement('th'); + th.textContent = text; + headRow.appendChild(th); + }); + thead.appendChild(headRow); + table.appendChild(thead); + const tbody = document.createElement('tbody'); + + rows.forEach((row) => { + const tr = document.createElement('tr'); + + const tdRank = document.createElement('td'); + tdRank.textContent = `#${row.rank || '-'}`; + tr.appendChild(tdRank); + + const tdUser = document.createElement('td'); + const link = document.createElement('a'); + link.href = `/users/${encodeURIComponent(row.username || '')}`; + link.textContent = row.username || '匿名'; + tdUser.appendChild(link); + tr.appendChild(tdUser); + + const tdWins = document.createElement('td'); + tdWins.textContent = String(row.wins || 0); + tr.appendChild(tdWins); + + const tdGames = document.createElement('td'); + tdGames.textContent = String(row.played_games || 0); + tr.appendChild(tdGames); + + const tdRate = document.createElement('td'); + tdRate.textContent = `${Number(row.win_rate || 0).toFixed(1)}%`; + tr.appendChild(tdRate); + + const tdRounds = document.createElement('td'); + tdRounds.textContent = String(row.total_rounds || 0); + tr.appendChild(tdRounds); + + tbody.appendChild(tr); + }); + table.appendChild(tbody); + leaderboardList.textContent = ''; + leaderboardList.appendChild(table); + }; + + const renderSearch = (rows) => { + searchResults.textContent = ''; + if (!rows.length) { + const li = document.createElement('li'); + li.textContent = '没有匹配用户'; + searchResults.appendChild(li); + return; + } + rows.forEach((row) => { + const li = document.createElement('li'); + const online = row.presence?.is_online ? '在线' : '离线'; + const rank = row.stats?.rank ? `#${row.stats.rank}` : '-'; + const link = document.createElement('a'); + link.href = `/users/${encodeURIComponent(row.username || '')}`; + link.textContent = row.username || '匿名'; + li.appendChild(link); + const text = document.createElement('span'); + text.textContent = ` · ${online} · 排名 ${rank} · 胜场 ${row.stats?.wins || 0}`; + li.appendChild(text); + searchResults.appendChild(li); + }); + }; + + const loadLeaderboard = async () => { + const data = await getJson('/api/leaderboard?limit=100'); + if (!data.ok) { + setStatus(data.error || '加载排行榜失败'); + return; + } + renderLeaderboard(data.leaderboard || []); + }; + + const searchUsers = async (q) => { + const data = await getJson(`/api/users/search?q=${encodeURIComponent(q || '')}&limit=20`); + if (!data.ok) { + setStatus(data.error || '搜索失败'); + return; + } + renderSearch(data.users || []); + }; + + if (searchForm) { + searchForm.addEventListener('submit', async (event) => { + event.preventDefault(); + try { + await searchUsers(searchInput?.value || ''); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + } + + loadLeaderboard() + .then(() => searchUsers('')) + .catch((error) => setStatus(`网络错误: ${error}`)); +})(); diff --git a/static/js/lobby.js b/static/js/lobby.js index 976ea89..e2b3f51 100644 --- a/static/js/lobby.js +++ b/static/js/lobby.js @@ -16,7 +16,6 @@ const createCardGallery = document.getElementById('create-card-gallery'); const selectedCardDetail = document.getElementById('selected-card-detail'); const rejoinList = document.getElementById('rejoin-list'); - const leaderboardList = document.getElementById('leaderboard-list'); const publicRoomsList = document.getElementById('public-rooms-list'); const statusEl = document.getElementById('status'); const logoutBtn = document.getElementById('logout-btn'); @@ -282,25 +281,6 @@ }); }; - const renderLeaderboard = (rows) => { - if (!leaderboardList) return; - leaderboardList.textContent = ''; - if (!rows.length) { - const li = document.createElement('li'); - li.textContent = '暂无已完成对局数据'; - leaderboardList.appendChild(li); - return; - } - rows.forEach((row) => { - const playedGames = Number(row.played_games || 0); - const wins = Number(row.wins || 0); - const winRate = Number(row.win_rate || 0).toFixed(1); - const li = document.createElement('li'); - li.textContent = `#${row.rank || '-'} ${row.username || '匿名玩家'} · 胜场 ${wins}/${playedGames} · 胜率 ${winRate}% · 回合 ${row.total_rounds || 0}`; - leaderboardList.appendChild(li); - }); - }; - const renderPublicRooms = (rooms) => { if (!publicRoomsList) return; publicRoomsList.textContent = ''; @@ -334,11 +314,9 @@ const loadBootstrap = async () => { try { - const [cardsResp, roomsResp, leaderboardResp, publicResp] = await Promise.all([ + const [cardsResp, roomsResp] = await Promise.all([ getJson('/api/cards'), getJson('/api/me/rooms'), - getJson('/api/leaderboard?limit=10'), - getJson('/api/rooms/public?limit=30'), ]); if (cardsResp.ok) { renderCards(cardsResp.cards || []); @@ -346,12 +324,6 @@ if (roomsResp.ok) { renderRejoinRooms(roomsResp.rooms || []); } - if (leaderboardResp.ok) { - renderLeaderboard(leaderboardResp.leaderboard || []); - } - if (publicResp.ok) { - renderPublicRooms(publicResp.rooms || []); - } } catch (error) { setStatus(`加载大厅信息失败: ${error}`); } diff --git a/static/js/room.js b/static/js/room.js index 6be6b53..a89aa55 100644 --- a/static/js/room.js +++ b/static/js/room.js @@ -476,11 +476,25 @@ dot.style.background = p.avatar_color || '#0070f2'; profile.appendChild(dot); } - const name = document.createElement('span'); - name.textContent = p.name; - profile.appendChild(name); + if (p.username) { + const nameLink = document.createElement('a'); + nameLink.className = 'player-name-link'; + nameLink.href = `/users/${encodeURIComponent(p.username)}`; + nameLink.textContent = p.name; + profile.appendChild(nameLink); + } else { + const name = document.createElement('span'); + name.textContent = p.name; + profile.appendChild(name); + } li.appendChild(profile); + if (p.is_host) { + const hostBadge = document.createElement('span'); + hostBadge.className = 'badge'; + hostBadge.textContent = '房主'; + li.appendChild(hostBadge); + } if (p.is_ready) { const badge = document.createElement('span'); badge.className = 'badge ready'; diff --git a/static/js/rooms.js b/static/js/rooms.js new file mode 100644 index 0000000..744859f --- /dev/null +++ b/static/js/rooms.js @@ -0,0 +1,156 @@ +(function () { + const body = document.body; + const user = (() => { + try { + return JSON.parse(body.dataset.user || 'null'); + } catch { + return null; + } + })(); + + const joinForm = document.getElementById('join-form'); + const rejoinList = document.getElementById('rejoin-list'); + const publicRoomsList = document.getElementById('public-rooms-list'); + const statusEl = document.getElementById('status'); + + const setStatus = (text) => { + if (statusEl) statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const postJson = async (url, payload) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const savePlayerRef = (roomId, playerId, playerName) => { + localStorage.setItem(`echo-player-${roomId}`, playerId); + localStorage.setItem('echo-last-name', playerName); + }; + + const enterRoom = (data) => { + if (data.is_spectator) { + window.location.href = data.redirect; + return; + } + savePlayerRef(data.room_id, data.player_id, data.player_name); + window.location.href = data.redirect; + }; + + const joinRoom = async (roomId) => { + const safeRoomId = String(roomId || '').trim().toUpperCase(); + if (!safeRoomId) { + setStatus('请输入房间码'); + return; + } + setStatus('正在加入房间...'); + const cachedPlayerId = localStorage.getItem(`echo-player-${safeRoomId}`) || ''; + const data = await postJson('/api/rooms/join', { + room_id: safeRoomId, + player_id: cachedPlayerId, + }); + if (!data.ok) { + setStatus(`加入失败: ${data.error || '未知错误'}`); + return; + } + enterRoom(data); + }; + + const renderRejoinRooms = (rooms) => { + if (!rejoinList) return; + rejoinList.textContent = ''; + if (!rooms.length) { + const li = document.createElement('li'); + li.textContent = user ? '暂无可重连房间' : '登录后可查看你的重连房间'; + rejoinList.appendChild(li); + return; + } + + rooms.forEach((room) => { + const li = document.createElement('li'); + const statusLabel = room.status === 'running' + ? '进行中' + : room.status === 'ended' + ? '已结束' + : '准备中'; + const link = document.createElement('a'); + link.href = `/room/${room.room_id}?player_id=${room.player_id}`; + link.textContent = `${room.room_id} · ${room.card_title || '未知剧本'} · ${statusLabel}`; + li.appendChild(link); + rejoinList.appendChild(li); + }); + }; + + const renderPublicRooms = (rooms) => { + if (!publicRoomsList) return; + publicRoomsList.textContent = ''; + if (!rooms.length) { + const li = document.createElement('li'); + li.textContent = '当前暂无公开房间'; + publicRoomsList.appendChild(li); + return; + } + rooms.forEach((room) => { + const li = document.createElement('li'); + const status = room.status === 'running' ? '进行中' : '准备中'; + const title = room.card_title || '未知剧本'; + const host = room.host_name || '未知房主'; + li.textContent = `${room.room_id} · ${title} · ${status} · 房主:${host} · 在线:${room.online_count}/${room.player_count}`; + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'secondary-btn'; + btn.style.width = 'auto'; + btn.style.marginTop = '0.4rem'; + btn.textContent = room.status === 'running' ? '观战/重连' : '加入房间'; + btn.addEventListener('click', () => { + joinRoom(room.room_id).catch((error) => setStatus(`网络错误: ${error}`)); + }); + li.appendChild(btn); + publicRoomsList.appendChild(li); + }); + }; + + const loadData = async () => { + try { + const [roomsResp, publicResp] = await Promise.all([ + getJson('/api/me/rooms'), + getJson('/api/rooms/public?limit=60'), + ]); + if (roomsResp.ok) renderRejoinRooms(roomsResp.rooms || []); + if (publicResp.ok) renderPublicRooms(publicResp.rooms || []); + } catch (error) { + setStatus(`加载联机大厅失败: ${error}`); + } + }; + + if (joinForm) { + joinForm.addEventListener('submit', async (event) => { + event.preventDefault(); + const roomInput = document.getElementById('join-room'); + try { + await joinRoom(roomInput?.value || ''); + } catch (error) { + setStatus(`网络错误: ${error}`); + } + }); + } + + const inviteCode = new URLSearchParams(window.location.search).get('invite'); + if (inviteCode) { + const joinInput = document.getElementById('join-room'); + if (joinInput) joinInput.value = String(inviteCode).trim().toUpperCase(); + setStatus('检测到邀请链接,已自动填入房间码。'); + } + + loadData(); + window.setInterval(loadData, 12000); +})(); diff --git a/static/js/user_profile.js b/static/js/user_profile.js new file mode 100644 index 0000000..d5c16d7 --- /dev/null +++ b/static/js/user_profile.js @@ -0,0 +1,195 @@ +(function () { + const body = document.body; + const currentUser = (() => { + try { + return JSON.parse(body.dataset.user || 'null'); + } catch { + return null; + } + })(); + + const profileUsername = String(body.dataset.profileUsername || '').trim().toLowerCase(); + const subtitleEl = document.getElementById('profile-subtitle'); + const heroEl = document.getElementById('profile-hero'); + const metaEl = document.getElementById('profile-meta'); + const recentRoomsEl = document.getElementById('recent-rooms'); + const invitePanelEl = document.getElementById('invite-panel'); + const statusEl = document.getElementById('status'); + + const setStatus = (text) => { + statusEl.textContent = text || ''; + }; + + const getJson = async (url) => { + const res = await fetch(url); + return res.json(); + }; + + const postJson = async (url, payload) => { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + return res.json(); + }; + + const renderRecentRooms = (rooms) => { + recentRoomsEl.textContent = ''; + if (!rooms.length) { + const li = document.createElement('li'); + li.textContent = '暂无对局记录'; + recentRoomsEl.appendChild(li); + return; + } + rooms.forEach((row) => { + const li = document.createElement('li'); + const statusLabel = row.status === 'running' ? '进行中' : row.status === 'lobby' ? '准备中' : '已结束'; + li.textContent = `${row.room_id} · ${row.card_title || '未知剧本'} · ${statusLabel}`; + if (row.status !== 'ended') { + const action = document.createElement('a'); + action.className = 'link-btn'; + action.style.marginLeft = '0.5rem'; + action.href = `/room/${row.room_id}?player_id=${row.player_id}`; + action.textContent = '进入'; + li.appendChild(action); + } + recentRoomsEl.appendChild(li); + }); + }; + + const renderInvitePanel = async (profile) => { + invitePanelEl.textContent = ''; + const target = profile.user || {}; + const isSelf = Boolean(currentUser && String(currentUser.username || '') === String(target.username || '')); + if (!currentUser) { + invitePanelEl.textContent = '登录后可邀请该用户一起游戏。'; + return; + } + if (isSelf) { + invitePanelEl.textContent = '这是你自己的主页。'; + return; + } + + const myRoomsResp = await getJson('/api/me/rooms'); + const availableRooms = (myRoomsResp.rooms || []).filter((row) => row.status === 'lobby' || row.status === 'running'); + if (!availableRooms.length) { + invitePanelEl.textContent = '你当前没有可邀请的活跃房间,请先在大厅创建或加入房间。'; + return; + } + + const wrap = document.createElement('div'); + wrap.className = 'editor-form-grid'; + + const roomLabel = document.createElement('label'); + roomLabel.textContent = '选择你的房间'; + roomLabel.htmlFor = 'invite-room-select'; + wrap.appendChild(roomLabel); + + const select = document.createElement('select'); + select.id = 'invite-room-select'; + availableRooms.forEach((row) => { + const option = document.createElement('option'); + option.value = row.room_id; + const statusLabel = row.status === 'running' ? '进行中' : '准备中'; + option.textContent = `${row.room_id} · ${row.card_title || '未知剧本'} · ${statusLabel}`; + select.appendChild(option); + }); + wrap.appendChild(select); + + const messageInput = document.createElement('input'); + messageInput.maxLength = 120; + messageInput.placeholder = '可选留言,例如:来一起开黑'; + wrap.appendChild(messageInput); + + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'secondary-btn'; + btn.textContent = `邀请 @${target.username || ''}`; + btn.addEventListener('click', async () => { + btn.disabled = true; + setStatus('正在发送邀请...'); + try { + const data = await postJson('/api/invites', { + to_username: target.username, + room_id: select.value, + message: String(messageInput.value || '').trim(), + }); + if (!data.ok) { + setStatus(data.error || '邀请失败'); + return; + } + setStatus('邀请已发送。'); + } catch (error) { + setStatus(`网络错误: ${error}`); + } finally { + btn.disabled = false; + } + }); + wrap.appendChild(btn); + invitePanelEl.appendChild(wrap); + }; + + const renderProfile = async (profile) => { + const user = profile.user || {}; + const presence = profile.presence || {}; + const stats = profile.stats || {}; + const onlineText = presence.is_online ? '在线' : '离线'; + subtitleEl.textContent = `@${user.username || ''} · ${onlineText}`; + + heroEl.textContent = ''; + const badge = document.createElement('div'); + badge.className = `user-badge ${user.avatar_url ? 'has-avatar' : ''}`; + badge.style.setProperty('--avatar-color', user.avatar_color || '#0070f2'); + if (user.avatar_url) { + badge.style.setProperty('--avatar-url', `url('${String(user.avatar_url).replace(/'/g, "\\'")}')`); + } + const icon = document.createElement('i'); + badge.appendChild(icon); + const text = document.createElement('span'); + text.textContent = `@${user.username || '未知用户'}`; + badge.appendChild(text); + heroEl.appendChild(badge); + + const bio = document.createElement('p'); + bio.className = 'helper-text'; + bio.style.marginTop = '0.6rem'; + bio.textContent = user.bio || '这个用户还没有填写个性签名。'; + heroEl.appendChild(bio); + + const rows = [ + `在线状态:${onlineText}`, + `当前房间:${presence.active_room_id || '-'}`, + `最后活跃:${presence.last_seen || '-'}`, + `排行榜名次:${stats.rank || '-'}`, + `总胜场:${stats.wins || 0}`, + `参与局数:${stats.played_games || 0}`, + `胜率:${Number(stats.win_rate || 0).toFixed(1)}%`, + `总回合:${stats.total_rounds || 0}`, + ]; + metaEl.textContent = ''; + rows.forEach((row) => { + const div = document.createElement('div'); + div.textContent = row; + metaEl.appendChild(div); + }); + + renderRecentRooms(profile.recent_rooms || []); + await renderInvitePanel(profile); + }; + + const loadProfile = async () => { + if (!profileUsername) { + setStatus('用户不存在'); + return; + } + const data = await getJson(`/api/users/${encodeURIComponent(profileUsername)}/profile`); + if (!data.ok) { + setStatus(data.error || '加载用户主页失败'); + return; + } + await renderProfile(data.profile || {}); + }; + + loadProfile().catch((error) => setStatus(`网络错误: ${error}`)); +})(); diff --git a/templates/admin.html b/templates/admin.html index 4fe68cb..58f2b43 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -70,6 +70,7 @@
+ diff --git a/templates/card_editor.html b/templates/card_editor.html index 34a7607..21cc246 100644 --- a/templates/card_editor.html +++ b/templates/card_editor.html @@ -230,6 +230,7 @@
+ diff --git a/templates/index.html b/templates/index.html index 7cf258f..8335ee7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -20,6 +20,8 @@ {% if user.role == 'admin' %} 管理后台 {% endif %} + 联机大厅 + 玩家排行 游戏卡广场 {% else %} @@ -51,21 +53,12 @@ -

将使用用户名加入。支持断线重连:输入同房间码会优先恢复你在该房间的身份。

+

将使用用户名加入。支持断线重连:输入同房间码会优先恢复你在该房间的身份。公开房间列表请前往“联机大厅”。

快速重连

    -

    玩家排行榜

    -

    按胜场、参与局数和回合数综合排序。

    -
      -
      - -
      -

      联机大厅

      -

      房主公开后的房间会显示在这里,可直接加入或观战。

      -
        @@ -104,6 +97,7 @@
        + diff --git a/templates/leaderboard.html b/templates/leaderboard.html new file mode 100644 index 0000000..e4f0bc6 --- /dev/null +++ b/templates/leaderboard.html @@ -0,0 +1,42 @@ + + + + + + 玩家排行榜 - Echo Tavern + + + +
        +
        +

        玩家排行榜

        +

        按胜场、参与局数和总回合综合排序,点击用户名可查看个人主页。

        +
        + +
        + +
        +
        +

        排行榜

        +
        +
        +
        +

        搜索用户

        +
        + + + +
        +
          +
          +
          + +
          + + + + + diff --git a/templates/plaza.html b/templates/plaza.html index 2afccc5..a42fed9 100644 --- a/templates/plaza.html +++ b/templates/plaza.html @@ -16,6 +16,8 @@ {% if user and user.role == 'admin' %} 管理后台 {% endif %} + 玩家排行 + 联机大厅 {% if user %} 新建游戏卡 {% endif %} @@ -47,6 +49,7 @@
          + diff --git a/templates/room.html b/templates/room.html index 6d3af2a..93e7586 100644 --- a/templates/room.html +++ b/templates/room.html @@ -156,6 +156,7 @@ + diff --git a/templates/rooms.html b/templates/rooms.html new file mode 100644 index 0000000..4d1bcac --- /dev/null +++ b/templates/rooms.html @@ -0,0 +1,50 @@ + + + + + + 联机大厅 - Echo Tavern + + + +
          +
          +

          联机大厅

          +

          查看公开房间、输入房间码加入、快速重连你参与过的对局。

          +
          + +
          + +
          +
          +

          加入房间

          +
          + + + +
          +

          进行中的房间会自动进入观战或身份恢复。

          +
          + +
          +

          快速重连

          +
            +
            + +
            +

            公开房间

            +

            房主设为公开后会显示在这里,可直接加入。

            +
              +
              +
              + +
              + + + + + diff --git a/templates/user_profile.html b/templates/user_profile.html new file mode 100644 index 0000000..0d03d5c --- /dev/null +++ b/templates/user_profile.html @@ -0,0 +1,45 @@ + + + + + + 个人主页 - Echo Tavern + + + +
              +
              +

              个人主页

              +

              加载中...

              +
              + +
              + +
              +
              +

              用户信息

              +
              +
              +
              + +
              +

              邀请互动

              +
              +

              最近参与房间

              +
                +
                +
                + +
                + + + + +