From fcf5d3fee4e084bd0dd48ba3605b474757f47272 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Mon, 17 Aug 2026 13:19:36 +0800 Subject: [PATCH] =?UTF-8?q?v1.1.0=20=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0+?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E5=A2=9E=E5=BC=BA=EF=BC=9A/admin=E5=85=A8?= =?UTF-8?q?=E6=95=B0=E6=8D=AECRUD=E4=B8=8E=E7=AB=99=E7=82=B9=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=EF=BC=9B=E5=BF=AB=E6=8D=B7=E8=AF=AD=E5=8F=A5=E7=82=B9?= =?UTF-8?q?=E5=87=BB=E8=87=AA=E5=8A=A8=E6=8F=90=E4=BA=A4=EF=BC=9BMarkdown?= =?UTF-8?q?=E5=9B=9E=E7=AD=94=E6=B8=B2=E6=9F=93=EF=BC=9B=E5=8F=82=E8=80=83?= =?UTF-8?q?=E8=B5=84=E8=AE=AF=E6=8A=98=E5=8F=A0=E9=93=BE=E6=8E=A5=EF=BC=9B?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E8=AF=86=E5=88=AB=E6=A0=87=E8=AE=B0=E4=B8=8E?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E6=9F=A5=E7=9C=8B=E5=8D=A1=E7=89=87(entity?= =?UTF-8?q?=5Flinker)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 ++- admin.py | 296 +++++++++++++++++++++++++++++++++++++++ api.py | 92 +++++++++++- chat.py | 93 +++++++++--- db.py | 6 + entity_linker.py | 221 +++++++++++++++++++++++++++++ llm.py | 10 +- static/admin.html | 188 +++++++++++++++++++++++++ static/admin.js | 245 ++++++++++++++++++++++++++++++++ static/app.js | 106 +++++++++++++- static/index.html | 10 +- static/lib/marked.min.js | 6 + static/style.css | 51 +++++++ 13 files changed, 1299 insertions(+), 43 deletions(-) create mode 100644 admin.py create mode 100644 entity_linker.py create mode 100644 static/admin.html create mode 100644 static/admin.js create mode 100644 static/lib/marked.min.js diff --git a/README.md b/README.md index 6bc5dce..90ebd1a 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ - 🧠 **RAG 检索**:新闻与百科向量化存储,语义检索 + Rerank 精排 - 🔧 **Function Calling**:8 个查询工具(球队/球员/比赛/单场详情/排名/新闻/人物/百科),多轮工具调用 + 实体覆盖补全 - 🧵 **多轮对话**:支持上下文指代(「他拿过几个MVP?」→ 上一话题对象) +- 🎛️ **管理后台**(`/admin`,默认密码 admin123):球队/球员/比赛/技术统计/排名/新闻百科/人物/运动/联赛 全量 CRUD + 站点配置(开场白、快捷问题、站点名、页脚、密码) +- 📰 **参考资讯折叠块**:回答用到新闻/百科时,回答上方默认折叠展示资讯链接,点击直达详情 +- ✨ **实体识别标记**:回答中的球队/球员/人物/比赛自动高亮(含简称:雷霆/湖人/库里/字母哥…),点击弹出详情;回答下方生成快速查看卡片 +- 📝 **Markdown 回答**:大模型回答以 Markdown 渲染(标题/列表/表格/代码块/引用),本地 marked 库无外部依赖 ## 🏗️ 系统架构 @@ -42,15 +46,17 @@ ``` nba-fan-hub/ -├── api.py # Flask 服务(REST API + 静态页) +├── api.py # Flask 服务(REST API + 静态页 + 管理后台路由) ├── chat.py # 对话管线(工具路由 + 兜底 + 覆盖补全) ├── tools.py # 8 个查询工具(SQL + 向量),含绰号别名/模糊匹配 ├── llm.py # DeepSeek 调用(思考模式 reasoning_content 回传) +├── entity_linker.py # 实体识别程序(回答中球队/球员/人物/比赛标记 + 卡片) +├── admin.py # 管理后台 API(登录认证 + 通用 CRUD + 站点配置) ├── vector_store.py # Embedding + Chroma + Rerank(纯 REST,零额外依赖) -├── db.py # SQLite 连接与建表 +├── db.py # SQLite 连接与建表(含 site_config 配置表) ├── seed.py # 种子数据入口(幂等,可 --rebuild-vector) ├── seed_*.py # 模拟数据(30队/154球员/54比赛/46新闻百科/38人物) -├── static/ # 前端页面 +├── static/ # 前端页面(index.html 前台 + admin.html 后台 + lib/marked.min.js) ├── data/ # nba_fan.db(自动生成) └── start.sh # 启停脚本(start/stop/restart/status/seed) ``` @@ -75,7 +81,8 @@ nba-fan-hub/ | 接口 | 说明 | |------|------| -| `POST /api/chat` | 对话 `{message, history}` → `{reply, sources, used_tools}` | +| `POST /api/chat` | 对话 `{message, history}` → `{reply, sources, used_tools, news_refs, entities, cards}` | +| `GET /api/boot` | 对话启动信息(开场白 + 快捷问题,后台可配置) | | `GET /api/teams[?q=]` `GET /api/teams/` | 球队列表/详情(含阵容+近期比赛) | | `GET /api/players[?q=]` `GET /api/players/` | 球员列表/详情 | | `GET /api/games[?q=&status=]` `GET /api/games/` | 比赛列表/详情(含技术统计) | @@ -83,6 +90,9 @@ nba-fan-hub/ | `GET /api/news[?q=&kind=]` `GET /api/news/` | 新闻/百科 | | `GET /api/persons[?q=&role=]` | 人物(coach/commentator/host/agent/gm/legend) | | `GET /api/health` | 健康检查 + 数据统计 | +| `POST /api/admin/login` | 管理后台登录(默认密码 admin123) | +| `GET/POST/PUT/DELETE /api/admin/[/]` | 通用数据 CRUD(表白名单防注入) | +| `GET/PUT /api/admin/config` | 站点配置读写(开场白/快捷问题/密码等) | ## 🧩 扩展设计(重点) diff --git a/admin.py b/admin.py new file mode 100644 index 0000000..75aeb38 --- /dev/null +++ b/admin.py @@ -0,0 +1,296 @@ +# -*- coding: utf-8 -*- +""" +管理后台 API(/api/admin/*) +============================ +功能: + 1. 登录认证(Bearer Token,密码存 site_config,默认 admin123) + 2. 通用数据 CRUD:teams / players / games / game_player_stats / standings / news / persons / sports / leagues + 3. 站点配置管理:开场白、快捷问题、站点名称、页脚等(site_config 表) +安全: + - 表名白名单 + 字段名白名单(PRAGMA 校验),防 SQL 注入 + - 登录失败限速(10 分钟 5 次锁定) + - Token 有效期 12 小时 +""" +import json +import logging +import secrets +import time +from functools import wraps + +from flask import jsonify, request + +from db import query, query_one, execute, executemany + +log = logging.getLogger("admin") + +# ------------------------------------------------------------------ 常量 +TABLES = { + "teams": "球队", "players": "球员", "games": "比赛", + "game_player_stats": "比赛技术统计", "standings": "排名", + "news": "新闻/百科", "persons": "人物", + "sports": "运动项目", "leagues": "联赛", +} +# 不允许前端直接改的字段(系统维护) +RO_FIELDS = {"id", "created_at"} + +_tokens = {} # token -> expire_ts +TOKEN_TTL = 12 * 3600 +MAX_FAIL = 5 +FAIL_WINDOW = 600 # 10 分钟 +_fails = [] # [(ip, ts), ...] + +DEFAULT_CONFIG = { + "site_name": "NBA球迷大全", + "site_subtitle": "比赛 · 球员 · 球队 · 资讯 · 人物 · 百科", + "welcome_text": "你好,我是**NBA球迷大全**助手!可以问我任何关于比赛、球员、球队、新闻、人物的问题,我会基于数据库给你准确答案~", + "welcome_hint": "试试:", + "suggestions": json.dumps([ + "最近一场比赛结果", "湖人本赛季战绩怎么样", "库里本赛季场均数据", + "2026年总决赛谁赢了", "SGA拿了什么荣誉", "NBA工资帽是什么", + "介绍一下波波维奇", "今天有什么新闻", "西部排名", + "雷霆和凯尔特人总决赛G6数据", + ], ensure_ascii=False), + "footer_text": "NBA球迷大全 · 数据为模拟演示数据(2025-26 赛季) · LLM: DeepSeek · 向量: Chroma + bge-large-zh", + "admin_password": "admin123", +} + +SEARCHABLE = { # 每个表可搜索的 TEXT 字段 + "teams": ("name", "name_en", "code", "city"), + "players": ("name", "name_en", "country", "bio"), + "games": ("round_name", "game_time", "venue"), + "game_player_stats": (), + "standings": ("conference",), + "news": ("title", "author", "source", "tags", "content"), + "persons": ("name", "name_en", "role", "role_cn", "title"), + "sports": ("code", "name"), + "leagues": ("code", "name", "country"), +} + + +# ------------------------------------------------------------------ 初始化 +def init_defaults(): + """写入默认配置(已存在的跳过)""" + for k, v in DEFAULT_CONFIG.items(): + if not query_one("SELECT 1 FROM site_config WHERE key=?", (k,)): + execute("INSERT INTO site_config(key, value) VALUES(?,?)", (k, v)) + + +def get_config(): + rows = query("SELECT key, value FROM site_config") + return {r["key"]: r["value"] for r in rows} + + +def set_config(key, value): + execute("""INSERT INTO site_config(key, value, updated_at) VALUES(?,?,datetime('now','localtime')) + ON CONFLICT(key) DO UPDATE SET value=excluded.value, + updated_at=datetime('now','localtime')""", (key, value)) + + +# ------------------------------------------------------------------ 认证 +def _check_pw(pw): + cfg = get_config() + return bool(pw) and pw == cfg.get("admin_password", "") + + +def _too_many_fails(ip): + now = time.time() + _fails[:] = [(i, t) for i, t in _fails if now - t < FAIL_WINDOW] + return sum(1 for i, _ in _fails if i == ip) >= MAX_FAIL + + +def login(): + body = request.get_json(force=True, silent=True) or {} + pw = body.get("password") or "" + ip = request.remote_addr or "?" + if _too_many_fails(ip): + return jsonify({"error": "失败次数过多,请 10 分钟后再试"}), 429 + if not _check_pw(pw): + _fails.append((ip, time.time())) + return jsonify({"error": "密码错误"}), 401 + token = secrets.token_hex(24) + _tokens[token] = time.time() + TOKEN_TTL + return jsonify({"token": token, "expires_in": TOKEN_TTL, + "site_name": get_config().get("site_name", "NBA球迷大全")}) + + +def logout(): + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + _tokens.pop(token, None) + return jsonify({"ok": True}) + + +def require_admin(fn): + @wraps(fn) + def wrapper(*a, **kw): + auth = request.headers.get("Authorization", "") + token = auth.replace("Bearer ", "").strip() + if token not in _tokens or _tokens[token] < time.time(): + return jsonify({"error": "未登录或登录已过期"}), 401 + return fn(*a, **kw) + return wrapper + + +# ------------------------------------------------------------------ 工具 +def _check_table(table): + if table not in TABLES: + raise ValueError(f"未知数据表: {table}") + + +def _columns(table): + """返回表字段 [{name, type, pk}](白名单)""" + _check_table(table) + rows = query(f"PRAGMA table_info({table})") + return [{"name": r["name"], "type": r["type"], "pk": bool(r["pk"])} for r in rows] + + +def _valid_field(table, field): + return field in {c["name"] for c in _columns(table)} + + +def _clean_payload(table, data): + """过滤非法字段、剔除系统字段;TEXT 空字符串 → None 可留空""" + cols = {c["name"]: c["type"] for c in _columns(table)} + out = {} + for k, v in (data or {}).items(): + if k in RO_FIELDS or k not in cols: + continue + if v == "": + v = None + if cols[k] in ("INTEGER", "REAL") and v is not None: + try: + v = int(v) if cols[k] == "INTEGER" else float(v) + except (TypeError, ValueError): + v = None + out[k] = v + return out + + +def _fmt_row(table, row): + """JSON 友好化:TEXT 字段里的 JSON 字符串(如 news.tags)原样返回,前端解析""" + return row + + +# ------------------------------------------------------------------ 统计 +def stats(): + return jsonify({ + "tables": {t: {"cn": cn, "count": query_one(f"SELECT COUNT(*) AS c FROM {t}")["c"]} + for t, cn in TABLES.items()}, + "config": len(query("SELECT 1 FROM site_config")), + }) + + +# ------------------------------------------------------------------ 通用 CRUD +def list_rows(table): + try: + _check_table(table) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + page = max(1, int(request.args.get("page", 1))) + size = min(100, max(1, int(request.args.get("size", 20)))) + q = (request.args.get("q") or "").strip() + cols = _columns(table) + where, args = "", [] + if q: + fields = SEARCHABLE.get(table) or () + if fields: + like = f"%{q}%" + where = "WHERE " + " OR ".join(f"{f} LIKE ? ESCAPE '\\'" for f in fields) + args = [like] * len(fields) + total = query_one(f"SELECT COUNT(*) AS c FROM {table} {where}", args)["c"] + rows = query(f"SELECT * FROM {table} {where} ORDER BY id DESC LIMIT ? OFFSET ?", + args + [size, (page - 1) * size]) + return jsonify({"table": table, "cn": TABLES[table], "columns": cols, + "total": total, "page": page, "size": size, "rows": rows}) + + +def get_row(table, rid): + try: + _check_table(table) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + row = query_one(f"SELECT * FROM {table} WHERE id=?", (rid,)) + if not row: + return jsonify({"error": "记录不存在"}), 404 + return jsonify(row) + + +def create_row(table): + try: + _check_table(table) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + data = _clean_payload(table, request.get_json(force=True, silent=True) or {}) + if not data: + return jsonify({"error": "没有可写入的字段"}), 400 + cols = list(data.keys()) + marks = ",".join("?" for _ in cols) + nid = execute(f"INSERT INTO {table}({','.join(cols)}) VALUES({marks})", + [data[c] for c in cols]) + _after_write(table, nid, data) + return jsonify({"ok": True, "id": nid}) + + +def update_row(table, rid): + try: + _check_table(table) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + if not query_one(f"SELECT 1 FROM {table} WHERE id=?", (rid,)): + return jsonify({"error": "记录不存在"}), 404 + data = _clean_payload(table, request.get_json(force=True, silent=True) or {}) + if not data: + return jsonify({"error": "没有可更新的字段"}), 400 + sets = ",".join(f"{c}=?" for c in data) + execute(f"UPDATE {table} SET {sets} WHERE id=?", [data[c] for c in data] + [rid]) + _after_write(table, rid, data) + return jsonify({"ok": True, "id": rid}) + + +def delete_row(table, rid): + try: + _check_table(table) + except ValueError as e: + return jsonify({"error": str(e)}), 400 + if not query_one(f"SELECT 1 FROM {table} WHERE id=?", (rid,)): + return jsonify({"error": "记录不存在"}), 404 + # 级联清理:删除比赛时清理技术统计;删除球队时清理其球员/新闻归属 + if table == "games": + execute("DELETE FROM game_player_stats WHERE game_id=?", (rid,)) + if table == "teams": + execute("UPDATE players SET team_id=NULL WHERE team_id=?", (rid,)) + execute("UPDATE news SET team_id=NULL WHERE team_id=?", (rid,)) + execute(f"DELETE FROM {table} WHERE id=?", (rid,)) + return jsonify({"ok": True}) + + +def _after_write(table, rid, data): + """写入后的联动维护:比赛改比分/球队时同步球队得分(可选扩展)""" + pass + + +# ------------------------------------------------------------------ 配置 +def get_config_api(): + cfg = get_config() + out = dict(cfg) + out["_protected"] = ["admin_password"] + return jsonify(out) + + +def update_config(): + body = request.get_json(force=True, silent=True) or {} + if not isinstance(body, dict): + return jsonify({"error": "参数格式错误"}), 400 + for k, v in body.items(): + if k.startswith("_") or not isinstance(v, str): + continue + if k == "admin_password" and not v.strip(): + continue # 空密码 = 不修改 + set_config(k, v) + return jsonify({"ok": True, "config": get_config()}) + + +# ------------------------------------------------------------------ 恢复默认快捷问题 +def reset_suggestions(): + set_config("suggestions", DEFAULT_CONFIG["suggestions"]) + return jsonify({"ok": True, "suggestions": json.loads(DEFAULT_CONFIG["suggestions"])}) diff --git a/api.py b/api.py index dc8d023..49da25d 100644 --- a/api.py +++ b/api.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -"""NBA球迷大全 - Flask 服务入口(API + 前端静态页)""" +"""NBA球迷大全 - Flask 服务入口(API + 前端静态页 + 管理后台)""" import logging import os @@ -10,6 +10,8 @@ from db import init_db, table_count, query_one import tools import chat import vector_store +import entity_linker +import admin as admin_mod logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger("app") @@ -24,6 +26,11 @@ def index(): return send_from_directory(STATIC_DIR, "index.html") +@app.route("/admin") +def admin_page(): + return send_from_directory(STATIC_DIR, "admin.html") + + @app.route("/static/") def static_files(path): return send_from_directory(STATIC_DIR, path) @@ -42,6 +49,12 @@ def suggestions(): return jsonify(chat.suggest_questions()) +@app.route("/api/boot") +def boot(): + """对话界面启动信息:开场白 + 快捷问题(管理后台可配置)""" + return jsonify(chat.boot_info()) + + # ------------------------------------------------------------------ 对话 @app.route("/api/chat", methods=["POST"]) def api_chat(): @@ -51,8 +64,14 @@ def api_chat(): if not message: return jsonify({"error": "消息不能为空"}), 400 try: - reply, sources, used_tools = chat.chat_once(message, history) - return jsonify({"reply": reply, "sources": sources, "used_tools": used_tools}) + reply, sources, used_tools, news_refs = chat.chat_once(message, history) + # 实体识别程序:扫描回答,标记球队/球员/人物/比赛 + 快速查看卡片 + game_refs = [it for s in sources + if s.get("tool") in ("search_games", "get_game_detail") + for it in s.get("items", [])] + spans, cards = entity_linker.link_entities(reply, game_refs=game_refs) + return jsonify({"reply": reply, "sources": sources, "used_tools": used_tools, + "news_refs": news_refs, "entities": spans, "cards": cards}) except Exception as e: log.exception("chat error") return jsonify({"error": f"服务异常: {e}"}), 500 @@ -168,7 +187,74 @@ def api_person(pid): return jsonify(p) +# ================================================================== 管理后台 +@app.route("/api/admin/login", methods=["POST"]) +def admin_login(): + return admin_mod.login() + + +@app.route("/api/admin/logout", methods=["POST"]) +@admin_mod.require_admin +def admin_logout(): + return admin_mod.logout() + + +@app.route("/api/admin/stats") +@admin_mod.require_admin +def admin_stats(): + return admin_mod.stats() + + +@app.route("/api/admin/config") +@admin_mod.require_admin +def admin_config_get(): + return admin_mod.get_config_api() + + +@app.route("/api/admin/config", methods=["PUT"]) +@admin_mod.require_admin +def admin_config_put(): + return admin_mod.update_config() + + +@app.route("/api/admin/suggestions/reset", methods=["POST"]) +@admin_mod.require_admin +def admin_suggestions_reset(): + return admin_mod.reset_suggestions() + + +@app.route("/api/admin/
") +@admin_mod.require_admin +def admin_list(table): + return admin_mod.list_rows(table) + + +@app.route("/api/admin/
", methods=["POST"]) +@admin_mod.require_admin +def admin_create(table): + return admin_mod.create_row(table) + + +@app.route("/api/admin/
/") +@admin_mod.require_admin +def admin_get(table, rid): + return admin_mod.get_row(table, rid) + + +@app.route("/api/admin/
/", methods=["PUT"]) +@admin_mod.require_admin +def admin_update(table, rid): + return admin_mod.update_row(table, rid) + + +@app.route("/api/admin/
/", methods=["DELETE"]) +@admin_mod.require_admin +def admin_delete(table, rid): + return admin_mod.delete_row(table, rid) + + if __name__ == "__main__": init_db() + admin_mod.init_defaults() log.info("%s 启动于 http://%s:%s", SERVICE_NAME, SERVICE_HOST, SERVICE_PORT) app.run(host=SERVICE_HOST, port=SERVICE_PORT, threaded=True) diff --git a/chat.py b/chat.py index 8c0342d..e2229a7 100644 --- a/chat.py +++ b/chat.py @@ -57,9 +57,14 @@ def _grounding_context(user_msg): def chat_once(user_msg, history=None): - """单轮对话。返回 (reply, sources, used_tools) - sources: 供前端展示的信息来源卡片 + """单轮对话。返回 (reply, sources, used_tools, news_refs) + sources : 供前端展示的信息来源卡片 + news_refs : 本次回答用到的新闻/百科资讯列表(前端折叠展示为链接) """ + return _chat_impl(user_msg, history) + + +def _chat_impl(user_msg, history=None): history = history or [] messages = [{"role": "system", "content": SYSTEM_PROMPT}] for h in history[-10:]: @@ -68,7 +73,7 @@ def chat_once(user_msg, history=None): messages.append({"role": "assistant", "content": h["assistant"]}) messages.append({"role": "user", "content": user_msg}) - used_tools, sources = [], [] + used_tools, sources, news_refs = [], [], [] # ---- 第 1 轮:带工具 try: @@ -79,9 +84,9 @@ def chat_once(user_msg, history=None): msgs = messages + ([{"role": "system", "content": f"以下是数据库检索到的可能相关信息(未直接命中时勿强行引用):\n{ctx}"}] if ctx else []) try: resp2 = llm.chat(msgs) - return llm.parse_content(resp2), _mk_sources(ctx), [t for t in ("grounding",) if ctx] + return llm.parse_content(resp2), _mk_sources(ctx), [t for t in ("grounding",) if ctx], [] except Exception as e2: - return (f"抱歉,大模型服务暂时不可用({e2})。你可以稍后再试,或直接浏览下方数据页面。", [], []) + return (f"抱歉,大模型服务暂时不可用({e2})。你可以稍后再试,或直接浏览下方数据页面。", [], [], []) # ---- 工具执行(单轮)+ 实体覆盖补全 → 最终无工具作答 all_executed = [] @@ -89,7 +94,7 @@ def chat_once(user_msg, history=None): if not calls: calls = _parse_text_tool_calls(llm.parse_content(resp), user_msg) if not calls: - return llm.parse_content(resp) or "(模型未返回内容)", [], [] + return llm.parse_content(resp) or "(模型未返回内容)", [], [], [] executed = [] for c in calls: @@ -102,6 +107,14 @@ def chat_once(user_msg, history=None): used_tools.append(c["name"]) if result.get("results"): sources.append({"tool": c["name"], "items": result["results"][:3]}) + if c["name"] == "search_news": + for it in result["results"][:5]: + if it.get("id"): + news_refs.append({"id": it["id"], "title": it.get("title", ""), + "source": it.get("source", ""), + "publish_time": it.get("publish_time", "")}) + elif c["name"] == "get_game_detail" and isinstance(result, dict) and result.get("id"): + sources.append({"tool": "get_game_detail", "items": [result]}) all_executed += executed # 覆盖补全:用户问题里提到的其他实体(多球员/多球队对比)自动补查,避免模型漏调 @@ -126,6 +139,14 @@ def chat_once(user_msg, history=None): sources.append({"tool": "search_players" if key == "name" else "search_teams", "items": r["results"][:3]}) break + # 新闻引用去重(按 id) + seen_nid, news_refs_u = set(), [] + for n in news_refs: + if n["id"] not in seen_nid: + seen_nid.add(n["id"]) + news_refs_u.append(n) + news_refs = news_refs_u + messages.append({"role": "assistant", "content": None, "reasoning_content": llm.extract_reasoning(resp), "tool_calls": [ @@ -139,13 +160,13 @@ def chat_once(user_msg, history=None): resp = llm.chat(messages) # 最终轮:不带工具,让模型基于数据作答 except Exception as e: log.warning("LLM 最终轮失败(%s),返回工具结果摘要", e) - return _finalize(all_executed, user_msg, messages, sources, used_tools), sources, used_tools + return _finalize(all_executed, user_msg, messages, sources, used_tools), sources, used_tools, news_refs reply = llm.parse_content(resp) or "" if not reply or "" in reply or "search_" in reply: # 模型又输出工具调用文本 → 注入上下文再答一次 - return _finalize(all_executed, user_msg, messages, sources, used_tools), sources, used_tools - return reply, sources, used_tools + return _finalize(all_executed, user_msg, messages, sources, used_tools), sources, used_tools, news_refs + return reply, sources, used_tools, news_refs def _finalize(executed, user_msg, messages, sources, used_tools): @@ -210,16 +231,44 @@ def _mk_sources(ctx): def suggest_questions(): - """快捷问题(前端展示用)""" - return [ - "最近一场比赛结果", - "湖人本赛季战绩怎么样", - "库里本赛季场均数据", - "2026年总决赛谁赢了", - "SGA拿了什么荣誉", - "NBA工资帽是什么", - "介绍一下波波维奇", - "今天有什么新闻", - "西部排名", - "雷霆和凯尔特人总决赛G6数据", - ] + """快捷问题(前端展示用,从站点配置读取,管理后台可编辑)""" + try: + from admin import get_config + raw = get_config().get("suggestions") or "" + arr = json.loads(raw) + if isinstance(arr, list) and arr: + return [str(x).strip() for x in arr if str(x).strip()] + except Exception: + pass + return DEFAULT_SUGGESTIONS + + +DEFAULT_SUGGESTIONS = [ + "最近一场比赛结果", + "湖人本赛季战绩怎么样", + "库里本赛季场均数据", + "2026年总决赛谁赢了", + "SGA拿了什么荣誉", + "NBA工资帽是什么", + "介绍一下波波维奇", + "今天有什么新闻", + "西部排名", + "雷霆和凯尔特人总决赛G6数据", +] + + +def boot_info(): + """对话界面启动信息:开场白 + 快捷问题(均可后台配置)""" + try: + from admin import get_config + cfg = get_config() + except Exception: + cfg = {} + return { + "site_name": cfg.get("site_name", "NBA球迷大全"), + "site_subtitle": cfg.get("site_subtitle", "比赛 · 球员 · 球队 · 资讯 · 人物 · 百科"), + "welcome_text": cfg.get("welcome_text", "你好,我是**NBA球迷大全**助手!可以问我任何关于比赛、球员、球队、新闻、人物的问题,我会基于数据库给你准确答案~"), + "welcome_hint": cfg.get("welcome_hint", "试试:"), + "suggestions": suggest_questions(), + "footer_text": cfg.get("footer_text", "NBA球迷大全 · 数据为模拟演示数据(2025-26 赛季)"), + } diff --git a/db.py b/db.py index 7a91b80..03d0d2c 100644 --- a/db.py +++ b/db.py @@ -138,6 +138,12 @@ CREATE TABLE IF NOT EXISTS persons ( created_at TEXT DEFAULT (datetime('now','localtime')) ); +CREATE TABLE IF NOT EXISTS site_config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now','localtime')) +); + CREATE INDEX IF NOT EXISTS idx_players_team ON players(team_id); CREATE INDEX IF NOT EXISTS idx_players_name ON players(name); CREATE INDEX IF NOT EXISTS idx_games_time ON games(game_time); diff --git a/entity_linker.py b/entity_linker.py new file mode 100644 index 0000000..80d5208 --- /dev/null +++ b/entity_linker.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +""" +实体链接程序(Entity Linker)—— 需求5专用程序 +============================================== +作用:大模型回答生成后,用数据库实体库对回答文本做扫描匹配, + 找出其中提到的 球队 / 球员 / 人物 / 比赛 实体,供前端: + a) 特殊标记(高亮 + 点击查看详情) + b) 快速查看入口(回答下方卡片) +设计: + - 纯规则 + 数据库匹配,不额外调用大模型(快、稳、零成本) + - 最长匹配优先("洛杉矶湖人" 优先于 "湖人") + - 重叠区间去重,只保留最长的命中 + - 比赛实体优先取对话中工具实际查到的比赛(game_refs),避免误匹配 + - 别名(字母哥/SGA 等)复用 tools.ALIASES +""" +import os +import re + +from db import query, query_one +from tools import ALIASES + +_cache = {"names": None, "games_seen": set()} + + +def _load_names(): + """加载实体名库:[(名字, 实体信息), ...](按名字长度降序) + 自动生成简称:球队去掉城市前缀(波士顿凯尔特人→凯尔特人); + 球员中文名取最后一段(谢伊·吉尔杰斯-亚历山大→亚历山大),歧义名跳过。""" + if _cache["names"] is not None: + return _cache["names"] + names = [] + for t in query("SELECT id, name, name_en, code, city FROM teams"): + info = {"type": "team", "id": t["id"], "name": t["name"]} + for n in {t["name"], t["name_en"], t["code"]}: + if n and len(str(n)) >= 2: + names.append((str(n), info)) + # 简称:去掉与城市名的公共前缀('波士顿凯尔特人'→'凯尔特人';'俄克拉荷马雷霆'→'雷霆') + short = t["name"] + if t["city"]: + common = os.path.commonprefix([t["name"], t["city"]]) + if len(common) >= 2: + short = t["name"][len(common):] + if len(short) >= 2 and short != t["name"]: + names.append((short, info)) + for p in query("SELECT id, name, name_en FROM players"): + info = {"type": "player", "id": p["id"], "name": p["name"]} + for n in {p["name"], p["name_en"]}: + if n and len(str(n)) >= 2: + names.append((str(n), info)) + # 简称:中文名按 · 分割取最后一段,再按 - 分割取最后一段 + # ('谢伊·吉尔杰斯-亚历山大' → '亚历山大';'斯蒂芬·库里' → '库里') + for short in _short_names(p["name"]): + if short: + names.append((short, info)) + for p in query("SELECT id, name, name_en FROM persons"): + info = {"type": "person", "id": p["id"], "name": p["name"]} + for n in {p["name"], p["name_en"]}: + if n and len(str(n)) >= 2: + names.append((str(n), info)) + for short in _short_names(p["name"]): + if short: + names.append((short, info)) + # 别名 → 指向正式实体(球队/球员名里查找) + for nick, real in ALIASES.items(): + target = None + for n, info in names: + if info["name"] == real and info["type"] in ("player", "team"): + target = info + break + if target: + names.append((nick, target)) + # 去歧义:同一简称指向多个不同实体时,全部移除(避免错误标记) + by_name = {} + for n, info in names: + by_name.setdefault(n, set()).add((info["type"], info["id"])) + names = [(n, info) for n, info in names if len(by_name[n]) == 1] + # 按名字长度降序,保证最长匹配优先 + names.sort(key=lambda x: -len(x[0])) + _cache["names"] = names + return names + + +_ASCII_RE = re.compile(r"[A-Za-z0-9 ._'\-]+") + + +def _short_names(full): + """生成中文名简称候选:按 · 和 - 逐级取最后一段 + '谢伊·吉尔杰斯-亚历山大' → ['吉尔杰斯-亚历山大', '亚历山大'] + '格雷格·波波维奇' → ['波波维奇'] + '斯蒂芬·库里' → ['库里']""" + if not full or "·" not in full: + return [] + seg = full.split("·")[-1].strip() + out = [] + if len(seg) >= 2: + out.append(seg) + if "-" in seg: + tail = seg.split("-")[-1].strip() + if len(tail) >= 2 and tail != seg: + out.append(tail) + return out + + +def _find_spans(text): + """在 text 中找出所有实体命中区间。 + 返回 [{start, end, type, id, name}],已去重叠(保留最长),按 start 升序。""" + if not text: + return [] + spans = [] + for n, info in _load_names(): + # 中文/混合名:直接子串查找(两边不接中文字符,避免部分词误匹配) + if re.search(r"[\u4e00-\u9fff]", n): + for m in re.finditer(re.escape(n), text): + spans.append((m.start(), m.end(), info)) + else: + # 英文/缩写:需要词边界 + for m in re.finditer(r"(?= last_end: + out.append({"start": st, "end": en, "type": info["type"], + "id": info["id"], "name": info["name"]}) + last_end = en + return out + + +def _match_games(text, game_refs): + """从工具实际查到的比赛里,找出文本中提到的比赛(A队 vs B队 相邻出现)。 + 返回卡片数据列表(按球队组合去重,避免系列赛多场重复)。""" + cards, seen, seen_pair = [], set(), set() + for g in game_refs or []: + gid = g.get("id") + if not gid or gid in seen: + continue + away, home = g.get("away_team") or "", g.get("home_team") or "" + if not away or not home: + continue + pair = frozenset((away, home)) + if pair in seen_pair: # 同一对球队只留一个卡片(优先已结束/最新) + continue + # 两种顺序都试:'雷霆 4-2 凯尔特人' / '凯尔特人不敌雷霆' + hit = None + for a, b in ((away, home), (home, away)): + pat = re.compile(re.escape(a) + r"[^。\n,,;;]{0,12}?" + re.escape(b)) + m = pat.search(text) + if m: + hit = m + break + if hit: + seen.add(gid) + seen_pair.add(pair) + cards.append({ + "type": "game", "id": gid, + "home_team": g.get("home_team"), "away_team": g.get("away_team"), + "home_score": g.get("home_score"), "away_score": g.get("away_score"), + "game_time": g.get("game_time"), "status": g.get("status"), + "round_name": g.get("round_name"), + }) + return cards + + +def link_entities(text, game_refs=None): + """主入口:扫描回答文本。 + 返回 (spans, cards) + spans: 实体命中区间(前端高亮标记用),按 start 升序、互不重叠 + cards: 快速查看卡片数据(每类限量,避免刷屏) + """ + spans = _find_spans(text or "") + cards = [] + seen_cards = set() + for sp in spans: + key = (sp["type"], sp["id"]) + if key in seen_cards: + continue + seen_cards.add(key) + info = _card_info(sp["type"], sp["id"]) + if info: + cards.append(info) + cards += _match_games(text, game_refs) + # 每类最多 4 个卡片 + by_type = {} + for c in cards: + by_type.setdefault(c["type"], []).append(c) + cards = [] + for t in ("team", "player", "person", "game"): + cards.extend(by_type.get(t, [])[:4]) + return spans, cards + + +def _card_info(etype, eid): + """取实体摘要数据(前端卡片直接渲染)""" + try: + if etype == "team": + t = query_one("SELECT * FROM teams WHERE id=?", (eid,)) + if not t: + return None + return {"type": "team", "id": t["id"], "name": t["name"], "name_en": t["name_en"], + "city": t["city"], "arena": t["arena"], "champion_count": t["champion_count"], + "head_coach": t["head_coach"]} + if etype == "player": + p = query_one("""SELECT p.*, t.name AS team_name FROM players p + LEFT JOIN teams t ON p.team_id=t.id WHERE p.id=?""", (eid,)) + if not p: + return None + return {"type": "player", "id": p["id"], "name": p["name"], "name_en": p["name_en"], + "team": p["team_name"], "position": p["position"], "number": p["number"], + "season": {"pts": p["season_pts"], "reb": p["season_reb"], "ast": p["season_ast"]}} + if etype == "person": + p = query_one("SELECT * FROM persons WHERE id=?", (eid,)) + if not p: + return None + return {"type": "person", "id": p["id"], "name": p["name"], "role_cn": p["role_cn"], + "title": p["title"]} + except Exception: + return None + return None diff --git a/llm.py b/llm.py index 88fb0bb..a3d0b9f 100644 --- a/llm.py +++ b/llm.py @@ -33,13 +33,17 @@ def chat(messages, tools=None, temperature=LLM_TEMPERATURE, max_tokens=LLM_MAX_T if tools: payload["tools"] = tools payload["tool_choice"] = "auto" - for attempt in range(2): + for attempt in range(3): try: return _post(payload) except requests.exceptions.HTTPError as e: - if e.response is not None and e.response.status_code in (429, 500, 502, 503) and attempt == 0: - time.sleep(2) + body = e.response.text[:400] if e.response is not None else "" + code = e.response.status_code if e.response is not None else 0 + if code in (400, 429, 500, 502, 503) and attempt < 2: + log.warning("LLM HTTP %s(第%s次重试): %s", code, attempt + 1, body) + time.sleep(2 * (attempt + 1)) continue + log.error("LLM HTTP %s: %s", code, body) raise raise RuntimeError("LLM 请求失败") diff --git a/static/admin.html b/static/admin.html new file mode 100644 index 0000000..4bb6b51 --- /dev/null +++ b/static/admin.html @@ -0,0 +1,188 @@ + + + + + +管理后台 - NBA球迷大全 + + + + + + + + + + + + + + + + diff --git a/static/admin.js b/static/admin.js new file mode 100644 index 0000000..69c5602 --- /dev/null +++ b/static/admin.js @@ -0,0 +1,245 @@ +/* NBA球迷大全 管理后台逻辑(原生 JS) */ +const $ = (s) => document.querySelector(s); +const $$ = (s) => [...document.querySelectorAll(s)]; +const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); + +let TOKEN = localStorage.getItem("nba_admin_token") || ""; +let CUR = { table: "", page: 1, q: "" }; +let LOOKUPS = {}; // teams/players/leagues/sports → {id: name} + +/* 字段中文名 */ +const FIELD_CN = { + teams: { league_id: "联赛ID", code: "缩写", name: "球队名", name_en: "英文名", city: "城市", arena: "主场球馆", founded: "建队年份", champion_count: "总冠军数", head_coach: "主教练", logo_url: "Logo地址", intro: "简介" }, + players: { team_id: "所属球队", name: "姓名", name_en: "英文名", position: "位置", number: "球衣号", height_cm: "身高(cm)", weight_kg: "体重(kg)", birth_date: "出生日期", country: "国家", draft_year: "选秀年份", draft_pick: "选秀顺位(0=落选)", salary_m: "年薪(万美元)", season_pts: "本季得分", season_reb: "本季篮板", season_ast: "本季助攻", season_stl: "本季抢断", season_blk: "本季盖帽", season_min: "本季时间", career_pts: "生涯得分", career_reb: "生涯篮板", career_ast: "生涯助攻", career_games: "生涯场次", career_steals: "生涯抢断", career_blocks: "生涯盖帽", awards: "主要荣誉", bio: "简介" }, + games: { league_id: "联赛ID", season: "赛季", round_name: "轮次", game_time: "开赛时间", status: "状态", home_team_id: "主队", away_team_id: "客队", home_score: "主队得分", away_score: "客队得分", venue: "球馆", broadcast: "转播" }, + game_player_stats: { game_id: "比赛ID", player_id: "球员", team_id: "球队", points: "得分", rebounds: "篮板", assists: "助攻", steals: "抢断", blocks: "盖帽", minutes: "分钟" }, + standings: { league_id: "联赛ID", season: "赛季", team_id: "球队", wins: "胜", losses: "负", win_pct: "胜率", conference: "分区", rank: "排名" }, + news: { sport_id: "运动ID", league_id: "联赛ID", team_id: "球队", title: "标题", content: "正文", author: "作者", source: "来源", publish_time: "发布时间", tags: "标签(逗号分隔)", kind: "类型" }, + persons: { sport_id: "运动ID", league_id: "联赛ID", team_id: "球队", name: "姓名", name_en: "英文名", role: "角色代码", role_cn: "角色中文", title: "头衔", bio: "简介", achievements: "成就" }, + sports: { code: "代码", name: "名称", name_en: "英文名" }, + leagues: { sport_id: "运动ID", code: "代码", name: "名称", name_en: "英文名", country: "国家", season: "赛季" }, +}; +const LONG_TEXT = new Set(["intro", "bio", "awards", "content", "achievements", "tags", "logo_url"]); +const ENUM_FIELDS = { + games: { status: [["scheduled", "未开始"], ["live", "进行中"], ["finished", "已结束"]] }, + news: { kind: [["news", "新闻"], ["wiki", "百科词条"]] }, + persons: { role: [["coach", "教练"], ["agent", "经纪人"], ["commentator", "评论员"], ["host", "主持人"], ["gm", "总经理"], ["legend", "传奇"]] }, +}; + +/* ================= 请求 ================= */ +async function api(url, opt = {}) { + const headers = { "Content-Type": "application/json" }; + if (TOKEN) headers["Authorization"] = "Bearer " + TOKEN; + const r = await fetch(url, { ...opt, headers }); + if (r.status === 401) { + const d = await r.json().catch(() => ({})); + if (url.includes("/login")) throw new Error(d.error || "密码错误"); + logout(); + throw new Error(d.error || "登录过期"); + } + const d = await r.json().catch(() => ({})); + if (!r.ok) throw new Error(d.error || `请求失败(${r.status})`); + return d; +} + +/* ================= 登录 ================= */ +async function doLogin() { + const pw = $("#login-pw").value; + try { + const d = await api("/api/admin/login", { method: "POST", body: JSON.stringify({ password: pw }) }); + TOKEN = d.token; + localStorage.setItem("nba_admin_token", TOKEN); + $("#login-err").textContent = ""; + enterMain(); + } catch (e) { + $("#login-err").textContent = e.message; + } +} +function logout() { + TOKEN = ""; + localStorage.removeItem("nba_admin_token"); + $("#main-view").classList.add("hidden"); + $("#login-view").classList.remove("hidden"); +} +function enterMain() { + $("#login-view").classList.add("hidden"); + $("#main-view").classList.remove("hidden"); + switchPage("stats"); +} +$("#login-btn").addEventListener("click", doLogin); +$("#login-pw").addEventListener("keydown", (e) => { if (e.key === "Enter") doLogin(); }); +$("#logout-btn").addEventListener("click", () => { api("/api/admin/logout", { method: "POST" }).catch(() => {}); logout(); }); + +/* ================= 导航 ================= */ +$$(".side nav button[data-page]").forEach((b) => b.addEventListener("click", () => switchPage(b.dataset.page))); +function switchPage(p) { + $$(".side nav button").forEach((x) => x.classList.toggle("active", x.dataset.page === p)); + $("#page-stats").classList.toggle("hidden", p !== "stats"); + $("#page-table").classList.toggle("hidden", !["teams", "players", "games", "game_player_stats", "standings", "news", "persons", "sports", "leagues"].includes(p)); + $("#page-config").classList.toggle("hidden", p !== "config"); + if (p === "stats") loadStats(); + else if (p === "config") loadConfig(); + else { CUR = { table: p, page: 1, q: "" }; $("#tbl-search").value = ""; loadTable(); } +} + +/* ================= 仪表盘 ================= */ +async function loadStats() { + const d = await api("/api/admin/stats"); + $("#stats-grid").innerHTML = Object.entries(d.tables).map(([k, v]) => + `
${v.count}
${esc(v.cn)}
`).join("") + + `
${d.config}
配置项
`; +} + +/* ================= 数据表 CRUD ================= */ +const TITLE_CN = { teams: "🏟️ 球队管理", players: "⭐ 球员管理", games: "📅 比赛管理", game_player_stats: "📊 比赛技术统计管理", standings: "🏆 排名管理", news: "📰 新闻/百科管理", persons: "👥 人物管理", sports: "🏈 运动项目管理", leagues: "🏀 联赛管理" }; + +async function loadLookups() { + const jobs = {}; + jobs.teams = api("/api/teams?limit=50"); + jobs.players = api("/api/players?limit=200"); + jobs.leagues = api("/api/admin/leagues?size=50").catch(() => ({ rows: [] })); + jobs.sports = api("/api/admin/sports?size=10").catch(() => ({ rows: [] })); + const [teams, players, leagues, sports] = await Promise.all([jobs.teams, jobs.players, jobs.leagues, jobs.sports]); + LOOKUPS.teams = Object.fromEntries(teams.map((t) => [t.id, t.name])); + LOOKUPS.players = Object.fromEntries(players.map((p) => [p.id, p.name])); + LOOKUPS.leagues = Object.fromEntries((leagues.rows || []).map((l) => [l.id, `${l.name}(${l.code})`])); + LOOKUPS.sports = Object.fromEntries((sports.rows || []).map((s) => [s.id, s.name])); +} + +async function loadTable() { + $("#table-title").textContent = TITLE_CN[CUR.table] || CUR.table; + const d = await api(`/api/admin/${CUR.table}?page=${CUR.page}&size=20&q=${encodeURIComponent(CUR.q)}`); + const cols = d.columns.filter((c) => c.name !== "created_at"); + const rows = d.rows; + if (!Object.keys(LOOKUPS).length) await loadLookups().catch(() => {}); + const thead = `
${cols.map((c) => ``).join("")}`; + const tbody = rows.map((r) => { + const tds = cols.map((c) => { + let v = r[c.name]; + if (v === null || v === undefined) v = ""; + if (c.name === "id") v = `#${v}`; + else if (["team_id", "home_team_id", "away_team_id"].includes(c.name)) v = (LOOKUPS.teams || {})[v] || v; + else if (c.name === "player_id") v = (LOOKUPS.players || {})[v] || v; + else if (c.name === "league_id") v = (LOOKUPS.leagues || {})[v] || v; + else if (c.name === "sport_id") v = (LOOKUPS.sports || {})[v] || v; + else if (c.name === "status") v = ({ scheduled: "未开始", live: "进行中", finished: "已结束" })[v] || v; + else if (c.name === "kind") v = v === "wiki" ? "百科" : v === "news" ? "新闻" : v; + else if (typeof v === "string" && v.length > 60) v = v.slice(0, 60) + "…"; + else if (c.name === "win_pct" && v !== "") v = (v * 100).toFixed(1) + "%"; + return ``; + }).join(""); + return `${tds}`; + }).join(""); + $("#tbl-wrap").innerHTML = `
${esc(FIELD_CN[CUR.table]?.[c.name] || c.name)}操作
${esc(String(v))}
${thead}${tbody || ``}
暂无数据
`; + const pages = Math.max(1, Math.ceil(d.total / d.size)); + $("#tbl-pager").innerHTML = ` + 第 ${CUR.page}/${pages} 页 · 共 ${d.total} 条 + `; +} +function goPage(p) { CUR.page = p; loadTable(); } +$("#tbl-search-btn").addEventListener("click", () => { CUR.q = $("#tbl-search").value.trim(); CUR.page = 1; loadTable(); }); +$("#tbl-search").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#tbl-search-btn").click(); }); +$("#tbl-add").addEventListener("click", () => openEdit(CUR.table, null)); + +/* ================= 编辑弹窗 ================= */ +let EDIT = { table: "", id: null }; + +async function openEdit(table, id) { + EDIT = { table, id }; + const d = await api(`/api/admin/${table}?size=1`); + const cols = d.columns.filter((c) => c.name !== "created_at"); + let row = {}; + if (id) row = await api(`/api/admin/${table}/${id}`); + $("#modal-title").textContent = (id ? "编辑" : "新增") + " · " + (TITLE_CN[table] || table); + $("#modal-form").innerHTML = cols.map((c) => { + const cn = FIELD_CN[table]?.[c.name] || c.name; + let input = ""; + if (c.name === "id") input = ``; + else { + let val = row[c.name] ?? ""; + if (val === null) val = ""; + const opts = ENUM_FIELDS[table]?.[c.name]; + if (opts) { + input = ``; + } else if (["team_id", "home_team_id", "away_team_id"].includes(c.name)) { + const opts2 = Object.entries(LOOKUPS.teams || {}); + input = ``; + } else if (c.name === "player_id") { + const opts3 = Object.entries(LOOKUPS.players || {}); + input = ``; + } else if (LONG_TEXT.has(c.name)) { + input = ``; + } else { + input = ``; + } + } + return `
${input}
`; + }).join(""); + $("#modal").classList.remove("hidden"); +} +$("#modal-close").addEventListener("click", () => $("#modal").classList.add("hidden")); +$("#modal-cancel").addEventListener("click", () => $("#modal").classList.add("hidden")); +$("#modal").addEventListener("click", (e) => { if (e.target.id === "modal") $("#modal").classList.add("hidden"); }); +$("#modal-save").addEventListener("click", async () => { + const payload = {}; + $$("#modal-form [data-f]").forEach((el) => { payload[el.dataset.f] = el.value; }); + try { + if (EDIT.id) await api(`/api/admin/${EDIT.table}/${EDIT.id}`, { method: "PUT", body: JSON.stringify(payload) }); + else await api(`/api/admin/${EDIT.table}`, { method: "POST", body: JSON.stringify(payload) }); + $("#modal").classList.add("hidden"); + loadTable(); + } catch (e) { alert(e.message); } +}); +async function delRow(table, id) { + if (!confirm(`确认删除 ID=${id} 的记录?此操作不可恢复!`)) return; + try { + await api(`/api/admin/${table}/${id}`, { method: "DELETE" }); + loadTable(); + } catch (e) { alert(e.message); } +} + +/* ================= 站点配置 ================= */ +async function loadConfig() { + const d = await api("/api/admin/config"); + $("#cfg-site_name").value = d.site_name || ""; + $("#cfg-site_subtitle").value = d.site_subtitle || ""; + $("#cfg-welcome_text").value = d.welcome_text || ""; + $("#cfg-welcome_hint").value = d.welcome_hint || ""; + $("#cfg-footer_text").value = d.footer_text || ""; + $("#cfg-admin_password").value = ""; + try { $("#cfg-suggestions").value = JSON.parse(d.suggestions || "[]").join("\n"); } + catch (e) { $("#cfg-suggestions").value = ""; } + $("#cfg-msg").textContent = ""; +} +$("#cfg-save").addEventListener("click", async () => { + const payload = { + site_name: $("#cfg-site_name").value.trim(), + site_subtitle: $("#cfg-site_subtitle").value.trim(), + welcome_text: $("#cfg-welcome_text").value, + welcome_hint: $("#cfg-welcome_hint").value.trim(), + footer_text: $("#cfg-footer_text").value.trim(), + suggestions: JSON.stringify($("#cfg-suggestions").value.split("\n").map((s) => s.trim()).filter(Boolean), null, 0), + }; + const pw = $("#cfg-admin_password").value; + if (pw) payload.admin_password = pw; + try { + await api("/api/admin/config", { method: "PUT", body: JSON.stringify(payload) }); + $("#cfg-msg").textContent = "✅ 保存成功,前台刷新后生效"; + $("#cfg-msg").style.color = "var(--green)"; + $("#cfg-admin_password").value = ""; + } catch (e) { $("#cfg-msg").textContent = "❌ " + e.message; $("#cfg-msg").style.color = "var(--red)"; } +}); +$("#cfg-reset-sug").addEventListener("click", async () => { + const d = await api("/api/admin/suggestions/reset", { method: "POST" }); + $("#cfg-suggestions").value = d.suggestions.join("\n"); + $("#cfg-msg").textContent = "↺ 已恢复默认快捷问题,记得保存"; + $("#cfg-msg").style.color = "var(--orange2)"; +}); + +/* ================= 启动 ================= */ +(async function init() { + if (TOKEN) { + try { await api("/api/admin/stats"); enterMain(); } + catch (e) { /* 未登录,停留在登录页 */ } + } +})(); diff --git a/static/app.js b/static/app.js index 3c30c7b..5fc4263 100644 --- a/static/app.js +++ b/static/app.js @@ -6,6 +6,39 @@ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", " const chatHistory = []; let chatBusy = false; +/* ================= Markdown 渲染(marked 本地库,先转义防 XSS) ================= */ +function md(text) { + if (window.marked) { + marked.setOptions({ breaks: true, gfm: true }); + return marked.parse(esc(text)); + } + return esc(text).replace(/\n/g, "
"); +} + +/* 实体高亮:先用占位符替换实体区间(避免 markdown 转换后偏移错位),渲染后再恢复为可点击标签 */ +function renderMdWithEntities(reply, entities) { + let text = reply || ""; + const phs = []; + if (entities && entities.length) { + const sorted = [...entities].sort((a, b) => b.start - a.start); + for (const sp of sorted) { + if (sp.start < 0 || sp.end > text.length || sp.start >= sp.end) continue; + const ph = `\u27E6E${phs.length}\u27E7`; + phs.push(sp); + text = text.slice(0, sp.start) + ph + text.slice(sp.end); + } + } + let html = md(text); + phs.forEach((sp, i) => { + const ph = `\u27E6E${i}\u27E7`; + if (html.includes(ph)) { + const tag = `${esc(sp.name)}`; + html = html.split(ph).join(tag); + } + }); + return html; +} + /* ================= 标签页切换 ================= */ $$(".tab").forEach((t) => t.addEventListener("click", () => { $$(".tab").forEach((x) => x.classList.remove("active")); @@ -41,6 +74,19 @@ function showTyping() { $("#chat-list").scrollTop = $("#chat-list").scrollHeight; return div; } + +/* 实体快速查看卡片 HTML */ +function cardHtml(c) { + if (c.type === "team") return `
🏟️
${esc(c.name)}${esc(c.city || "")} · 总冠军×${c.champion_count ?? "?"}
`; + if (c.type === "player") return `
${esc(c.name)}${esc(c.team || "自由球员")}${c.season ? " · " + (c.season.pts ?? "?") + "分" : ""}
`; + if (c.type === "person") return `
👤
${esc(c.name)}${esc(c.role_cn || "")}
`; + if (c.type === "game") { + const score = c.status === "finished" ? `${c.away_score ?? "?"} : ${c.home_score ?? "?"}` : "VS"; + return `
🏀
${esc(c.away_team || "")} ${score} ${esc(c.home_team || "")}${esc(c.round_name || "")} · ${esc((c.game_time || "").slice(0, 16))}
`; + } + return ""; +} + async function sendChat(text) { if (chatBusy) return; chatBusy = true; @@ -53,9 +99,22 @@ async function sendChat(text) { const d = await r.json(); typing.remove(); if (d.error) { addMsg("bot", `⚠️ ${esc(d.error)}`); return; } - let html = esc(d.reply).replace(/\n/g, "
"); + let html = ""; + // 需求4:参考资讯(新闻/百科链接),默认折叠,位于回答块上方 + if (d.news_refs && d.news_refs.length) { + html += `
📰 参考资讯(${d.news_refs.length})
    ` + + d.news_refs.map((n) => `
  • ${esc(n.title)}` + + (n.source ? `${esc(n.source)}${n.publish_time ? " · " + esc(n.publish_time.slice(0, 10)) : ""}` : "") + `
  • `).join("") + + `
`; + } + // 需求3:markdown 渲染 + 需求5:实体特殊标记 + html += `
${renderMdWithEntities(d.reply, d.entities)}
`; + // 需求5:快速查看入口卡片 + if (d.cards && d.cards.length) { + html += `
${d.cards.map(cardHtml).join("")}
`; + } if (d.sources && d.sources.length) { - html += "
" + d.sources.map((s) => `来源:${esc(s.tool)}`).join(""); + html += "
" + d.sources.map((s) => `来源:${esc(s.tool)}`).join("") + "
"; } addMsg("bot", html); chatHistory.push({ user: text, assistant: d.reply }); @@ -71,15 +130,48 @@ async function sendChat(text) { $("#send-btn").addEventListener("click", () => { const v = $("#chat-input").value.trim(); if (v) { $("#chat-input").value = ""; sendChat(v); } }); $("#chat-input").addEventListener("keydown", (e) => { if (e.key === "Enter") $("#send-btn").click(); }); -/* 快捷问题 */ -async function loadChips() { +/* 快捷问题:点击 → 自动填入输入框并自动提交(需求2) */ +function askQuick(q) { + $("#chat-input").value = q; + sendChat(q); +} + +/* 聊天区事件委托:快捷语句 / 实体标记 / 新闻链接 / 卡片 */ +$("#chat-list").addEventListener("click", (e) => { + const q = e.target.closest(".quick-q"); + if (q) { e.preventDefault(); askQuick(q.textContent); return; } + const ent = e.target.closest(".entity, .ecard"); + if (ent && ent.dataset.type) { openEntity(ent.dataset.type, parseInt(ent.dataset.id)); return; } + const news = e.target.closest("a[data-news]"); + if (news) { e.preventDefault(); openNews(parseInt(news.dataset.news)); return; } +}); + +function openEntity(type, id) { try { - const qs = await (await fetch("/api/suggestions")).json(); + if (type === "team") openTeam(id); + else if (type === "player") openPlayer(id); + else if (type === "person") openPerson(id); + else if (type === "game") openGame(id); + } catch (e) { + openModal(`

⚠️ 打开失败

该${type}记录可能已被删除(ID=${id})。

`); + } +} + +/* 启动信息:开场白 + 快捷问题(从配置加载,管理后台可编辑) */ +async function loadBoot() { + try { + const b = await (await fetch("/api/boot")).json(); + if (b.welcome_text) $("#welcome-text").innerHTML = md(b.welcome_text); + const qs = b.suggestions || []; + const hint = (b.welcome_hint || "试试:") + " "; + $("#welcome-hint").innerHTML = hint + qs.map((q) => `${esc(q)}`).join(" / "); $("#chips").innerHTML = qs.map((q) => ``).join(""); - $$("#chips button").forEach((b) => b.addEventListener("click", () => sendChat(b.textContent))); + $$("#chips button").forEach((b2) => b2.addEventListener("click", () => askQuick(b2.textContent))); + if (b.site_name) document.title = b.site_name; + if (b.footer_text) $("#footer-text").textContent = b.footer_text; } catch (e) {} } -loadChips(); +loadBoot(); /* ================= 通用请求 ================= */ async function getJSON(url) { const r = await fetch(url); if (!r.ok) throw new Error(r.status); return r.json(); } diff --git a/static/index.html b/static/index.html index f969d0a..519f366 100644 --- a/static/index.html +++ b/static/index.html @@ -5,6 +5,7 @@ NBA球迷大全 +
@@ -24,6 +25,7 @@ + ⚙️
@@ -31,11 +33,11 @@
-
+
🤖
-

你好,我是NBA球迷大全助手!可以问我任何关于比赛、球员、球队、新闻、人物的问题,我会基于数据库给你准确答案~

-

试试:2026年总决赛谁赢了? / 库里本赛季场均多少分? / 介绍一下杨毅

+

你好,我是NBA球迷大全助手!

+

@@ -98,7 +100,7 @@
-
NBA球迷大全 v1.0 · 数据为模拟演示数据(2025-26 赛季) · LLM: DeepSeek · 向量: Chroma + bge-large-zh
+
NBA球迷大全 · 数据为模拟演示数据(2025-26 赛季) · LLM: DeepSeek · 向量: Chroma + bge-large-zh
diff --git a/static/lib/marked.min.js b/static/lib/marked.min.js new file mode 100644 index 0000000..a91afe7 --- /dev/null +++ b/static/lib/marked.min.js @@ -0,0 +1,6 @@ +/** + * marked v12.0.2 - a markdown parser + * Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ +!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&","<":"<",">":">",'"':""","'":"'"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/gi;function p(e){return e.replace(h,((e,t)=>"colon"===(t=t.toLowerCase())?":":"#"===t.charAt(0)?"x"===t.charAt(1)?String.fromCharCode(parseInt(t.substring(2),16)):String.fromCharCode(+t.substring(1)):""))}const u=/(^|[^\[])\^/g;function k(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(u,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function g(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch(e){return null}return e}const f={exec:()=>null};function d(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^ {1,4}/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:x(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=x(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:t[0]}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=t[0].replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1");e=x(e.replace(/^ *>[ \t]?/gm,""),"\n");const n=this.lexer.state.top;this.lexer.state.top=!0;const s=this.lexer.blockTokens(e);return this.lexer.state.top=n,{type:"blockquote",raw:t[0],tokens:s,text:e}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l="",o="",a=!1;for(;e;){let n=!1;if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;l=t[0],e=e.substring(l.length);let s=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=0;this.options.pedantic?(h=2,o=s.trimStart()):(h=t[2].search(/[^ ]/),h=h>4?1:h,o=s.slice(h),h+=t[1].length);let p=!1;if(!s&&/^ *$/.test(c)&&(l+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`),n=new RegExp(`^ {0,${Math.min(3,h-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),r=new RegExp(`^ {0,${Math.min(3,h-1)}}(?:\`\`\`|~~~)`),i=new RegExp(`^ {0,${Math.min(3,h-1)}}#`);for(;e;){const a=e.split("\n",1)[0];if(c=a,this.options.pedantic&&(c=c.replace(/^ {1,4}(?=( {4})*[^ ])/g," ")),r.test(c))break;if(i.test(c))break;if(t.test(c))break;if(n.test(e))break;if(c.search(/[^ ]/)>=h||!c.trim())o+="\n"+c.slice(h);else{if(p)break;if(s.search(/[^ ]/)>=4)break;if(r.test(s))break;if(i.test(s))break;if(n.test(s))break;o+="\n"+c}p||c.trim()||(p=!0),l+=a+"\n",e=e.substring(a.length+1),s=c.slice(h)}}r.loose||(a?r.loose=!0:/\n *\n *$/.test(l)&&(a=!0));let u,k=null;this.options.gfm&&(k=/^\[[ xX]\] /.exec(o),k&&(u="[ ] "!==k[0],o=o.replace(/^\[[ xX]\] +/,""))),r.items.push({type:"list_item",raw:l,task:!!k,checked:u,loose:!1,text:o,tokens:[]}),r.raw+=l}r.items[r.items.length-1].raw=l.trimEnd(),r.items[r.items.length-1].text=o.trimEnd(),r.raw=r.raw.trimEnd();for(let e=0;e"space"===e.type)),n=t.length>0&&t.some((e=>/\n.*\n/.test(e.raw)));r.loose=n}if(r.loose)for(let e=0;e$/,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",s=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:t[0],href:n,title:s}}}table(e){const t=this.rules.block.table.exec(e);if(!t)return;if(!/[:|]/.test(t[2]))return;const n=d(t[1]),s=t[2].replace(/^\||\| *$/g,"").split("|"),r=t[3]&&t[3].trim()?t[3].replace(/\n[ \t]*$/,"").split("\n"):[],i={type:"table",raw:t[0],header:[],align:[],rows:[]};if(n.length===s.length){for(const e of s)/^ *-+: *$/.test(e)?i.align.push("right"):/^ *:-+: *$/.test(e)?i.align.push("center"):/^ *:-+ *$/.test(e)?i.align.push("left"):i.align.push(null);for(const e of n)i.header.push({text:e,tokens:this.lexer.inline(e)});for(const e of r)i.rows.push(d(e,i.header.length).map((e=>({text:e,tokens:this.lexer.inline(e)}))));return i}}lheading(e){const t=this.rules.block.lheading.exec(e);if(t)return{type:"heading",raw:t[0],depth:"="===t[2].charAt(0)?1:2,text:t[1],tokens:this.lexer.inline(t[1])}}paragraph(e){const t=this.rules.block.paragraph.exec(e);if(t){const e="\n"===t[1].charAt(t[1].length-1)?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(e){const t=this.rules.block.text.exec(e);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(e){const t=this.rules.inline.escape.exec(e);if(t)return{type:"escape",raw:t[0],text:c(t[1])}}tag(e){const t=this.rules.inline.tag.exec(e);if(t)return!this.lexer.state.inLink&&/^/i.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&/^<(pre|code|kbd|script)(\s|>)/i.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&/^<\/(pre|code|kbd|script)(\s|>)/i.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(e){const t=this.rules.inline.link.exec(e);if(t){const e=t[2].trim();if(!this.options.pedantic&&/^$/.test(e))return;const t=x(e.slice(0,-1),"\\");if((e.length-t.length)%2==0)return}else{const e=function(e,t){if(-1===e.indexOf(t[1]))return-1;let n=0;for(let s=0;s-1){const n=(0===t[0].indexOf("!")?5:4)+t[1].length+e;t[2]=t[2].substring(0,e),t[0]=t[0].substring(0,n).trim(),t[3]=""}}let n=t[2],s="";if(this.options.pedantic){const e=/^([^'"]*[^\s])\s+(['"])(.*)\2/.exec(n);e&&(n=e[1],s=e[3])}else s=t[3]?t[3].slice(1,-1):"";return n=n.trim(),/^$/.test(e)?n.slice(1):n.slice(1,-1)),b(t,{href:n?n.replace(this.rules.inline.anyPunctuation,"$1"):n,title:s?s.replace(this.rules.inline.anyPunctuation,"$1"):s},t[0],this.lexer)}}reflink(e,t){let n;if((n=this.rules.inline.reflink.exec(e))||(n=this.rules.inline.nolink.exec(e))){const e=t[(n[2]||n[1]).replace(/\s+/g," ").toLowerCase()];if(!e){const e=n[0].charAt(0);return{type:"text",raw:e,text:e}}return b(n,e,n[0],this.lexer)}}emStrong(e,t,n=""){let s=this.rules.inline.emStrongLDelim.exec(e);if(!s)return;if(s[3]&&n.match(/[\p{L}\p{N}]/u))return;if(!(s[1]||s[2]||"")||!n||this.rules.inline.punctuation.exec(n)){const n=[...s[0]].length-1;let r,i,l=n,o=0;const a="*"===s[0][0]?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(a.lastIndex=0,t=t.slice(-1*e.length+n);null!=(s=a.exec(t));){if(r=s[1]||s[2]||s[3]||s[4]||s[5]||s[6],!r)continue;if(i=[...r].length,s[3]||s[4]){l+=i;continue}if((s[5]||s[6])&&n%3&&!((n+i)%3)){o+=i;continue}if(l-=i,l>0)continue;i=Math.min(i,i+l+o);const t=[...s[0]][0].length,a=e.slice(0,n+s.index+t+i);if(Math.min(n,i)%2){const e=a.slice(1,-1);return{type:"em",raw:a,text:e,tokens:this.lexer.inlineTokens(e)}}const c=a.slice(2,-2);return{type:"strong",raw:a,text:c,tokens:this.lexer.inlineTokens(c)}}}}codespan(e){const t=this.rules.inline.code.exec(e);if(t){let e=t[2].replace(/\n/g," ");const n=/[^ ]/.test(e),s=/^ /.test(e)&&/ $/.test(e);return n&&s&&(e=e.substring(1,e.length-1)),e=c(e,!0),{type:"codespan",raw:t[0],text:e}}}br(e){const t=this.rules.inline.br.exec(e);if(t)return{type:"br",raw:t[0]}}del(e){const t=this.rules.inline.del.exec(e);if(t)return{type:"del",raw:t[0],text:t[2],tokens:this.lexer.inlineTokens(t[2])}}autolink(e){const t=this.rules.inline.autolink.exec(e);if(t){let e,n;return"@"===t[2]?(e=c(t[1]),n="mailto:"+e):(e=c(t[1]),n=e),{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}url(e){let t;if(t=this.rules.inline.url.exec(e)){let e,n;if("@"===t[2])e=c(t[0]),n="mailto:"+e;else{let s;do{s=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??""}while(s!==t[0]);e=c(t[0]),n="www."===t[1]?"http://"+t[0]:t[0]}return{type:"link",raw:t[0],text:e,href:n,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(e){const t=this.rules.inline.text.exec(e);if(t){let e;return e=this.lexer.state.inRawBlock?t[0]:c(t[0]),{type:"text",raw:t[0],text:e}}}}const m=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,y=/(?:[*+-]|\d{1,9}[.)])/,$=k(/^(?!bull |blockCode|fences|blockquote|heading|html)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html))+?)\n {0,3}(=+|-+) *(?:\n+|$)/).replace(/bull/g,y).replace(/blockCode/g,/ {4}/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).getRegex(),z=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,T=/(?!\s*\])(?:\\.|[^\[\]\\])+/,R=k(/^ {0,3}\[(label)\]: *(?:\n *)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n *)?| *\n *)(title))? *(?:\n+|$)/).replace("label",T).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),_=k(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,y).getRegex(),A="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",S=/|$))/,I=k("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n *)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n *)+\\n|$))","i").replace("comment",S).replace("tag",A).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),E=k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),q={blockquote:k(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",E).getRegex(),code:/^( {4}[^\n]+(?:\n(?: *(?:\n|$))*)?)+/,def:R,fences:/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,heading:/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,hr:m,html:I,lheading:$,list:_,newline:/^(?: *(?:\n|$))+/,paragraph:E,table:f,text:/^[^\n]+/},Z=k("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code"," {4}[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex(),L={...q,table:Z,paragraph:k(z).replace("hr",m).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",Z).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)]) ").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",A).getRegex()},P={...q,html:k("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))").replace("comment",S).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:f,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:k(z).replace("hr",m).replace("heading"," *#{1,6} *[^\n]").replace("lheading",$).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},Q=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,v=/^( {2,}|\\)\n(?!\s*$)/,B="\\p{P}\\p{S}",C=k(/^((?![*_])[\spunctuation])/,"u").replace(/punctuation/g,B).getRegex(),M=k(/^(?:\*+(?:((?!\*)[punct])|[^\s*]))|^_+(?:((?!_)[punct])|([^\s_]))/,"u").replace(/punct/g,B).getRegex(),O=k("^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)[punct](\\*+)(?=[\\s]|$)|[^punct\\s](\\*+)(?!\\*)(?=[punct\\s]|$)|(?!\\*)[punct\\s](\\*+)(?=[^punct\\s])|[\\s](\\*+)(?!\\*)(?=[punct])|(?!\\*)[punct](\\*+)(?!\\*)(?=[punct])|[^punct\\s](\\*+)(?=[^punct\\s])","gu").replace(/punct/g,B).getRegex(),D=k("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)[punct](_+)(?=[\\s]|$)|[^punct\\s](_+)(?!_)(?=[punct\\s]|$)|(?!_)[punct\\s](_+)(?=[^punct\\s])|[\\s](_+)(?!_)(?=[punct])|(?!_)[punct](_+)(?!_)(?=[punct])","gu").replace(/punct/g,B).getRegex(),j=k(/\\([punct])/,"gu").replace(/punct/g,B).getRegex(),H=k(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),U=k(S).replace("(?:--\x3e|$)","--\x3e").getRegex(),X=k("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",U).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),F=/(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/,N=k(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/).replace("label",F).replace("href",/<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),G=k(/^!?\[(label)\]\[(ref)\]/).replace("label",F).replace("ref",T).getRegex(),J=k(/^!?\[(ref)\](?:\[\])?/).replace("ref",T).getRegex(),K={_backpedal:f,anyPunctuation:j,autolink:H,blockSkip:/\[[^[\]]*?\]\([^\(\)]*?\)|`[^`]*?`|<[^<>]*?>/g,br:v,code:/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,del:f,emStrongLDelim:M,emStrongRDelimAst:O,emStrongRDelimUnd:D,escape:Q,link:N,nolink:J,punctuation:C,reflink:G,reflinkSearch:k("reflink|nolink(?!\\()","g").replace("reflink",G).replace("nolink",J).getRegex(),tag:X,text:/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\t+" ".repeat(n.length)));e;)if(!(this.options.extensions&&this.options.extensions.block&&this.options.extensions.block.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.space(e))e=e.substring(n.raw.length),1===n.raw.length&&t.length>0?t[t.length-1].raw+="\n":t.push(n);else if(n=this.tokenizer.code(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?t.push(n):(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.fences(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.heading(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.hr(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.blockquote(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.list(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.html(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.def(e))e=e.substring(n.raw.length),s=t[t.length-1],!s||"paragraph"!==s.type&&"text"!==s.type?this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title}):(s.raw+="\n"+n.raw,s.text+="\n"+n.raw,this.inlineQueue[this.inlineQueue.length-1].src=s.text);else if(n=this.tokenizer.table(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.lheading(e))e=e.substring(n.raw.length),t.push(n);else{if(r=e,this.options.extensions&&this.options.extensions.startBlock){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startBlock.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(this.state.top&&(n=this.tokenizer.paragraph(r)))s=t[t.length-1],i&&"paragraph"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n),i=r.length!==e.length,e=e.substring(n.raw.length);else if(n=this.tokenizer.text(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===s.type?(s.raw+="\n"+n.raw,s.text+="\n"+n.text,this.inlineQueue.pop(),this.inlineQueue[this.inlineQueue.length-1].src=s.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}return this.state.top=!0,t}inline(e,t=[]){return this.inlineQueue.push({src:e,tokens:t}),t}inlineTokens(e,t=[]){let n,s,r,i,l,o,a=e;if(this.tokens.links){const e=Object.keys(this.tokens.links);if(e.length>0)for(;null!=(i=this.tokenizer.rules.inline.reflinkSearch.exec(a));)e.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;null!=(i=this.tokenizer.rules.inline.blockSkip.exec(a));)a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);for(;null!=(i=this.tokenizer.rules.inline.anyPunctuation.exec(a));)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);for(;e;)if(l||(o=""),l=!1,!(this.options.extensions&&this.options.extensions.inline&&this.options.extensions.inline.some((s=>!!(n=s.call({lexer:this},e,t))&&(e=e.substring(n.raw.length),t.push(n),!0)))))if(n=this.tokenizer.escape(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.tag(e))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.link(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.reflink(e,this.tokens.links))e=e.substring(n.raw.length),s=t[t.length-1],s&&"text"===n.type&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(n=this.tokenizer.emStrong(e,a,o))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.codespan(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.br(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.del(e))e=e.substring(n.raw.length),t.push(n);else if(n=this.tokenizer.autolink(e))e=e.substring(n.raw.length),t.push(n);else if(this.state.inLink||!(n=this.tokenizer.url(e))){if(r=e,this.options.extensions&&this.options.extensions.startInline){let t=1/0;const n=e.slice(1);let s;this.options.extensions.startInline.forEach((e=>{s=e.call({lexer:this},n),"number"==typeof s&&s>=0&&(t=Math.min(t,s))})),t<1/0&&t>=0&&(r=e.substring(0,t+1))}if(n=this.tokenizer.inlineText(r))e=e.substring(n.raw.length),"_"!==n.raw.slice(-1)&&(o=n.raw.slice(-1)),l=!0,s=t[t.length-1],s&&"text"===s.type?(s.raw+=n.raw,s.text+=n.text):t.push(n);else if(e){const t="Infinite loop on byte: "+e.charCodeAt(0);if(this.options.silent){console.error(t);break}throw new Error(t)}}else e=e.substring(n.raw.length),t.push(n);return t}}class se{options;constructor(t){this.options=t||e.defaults}code(e,t,n){const s=(t||"").match(/^\S*/)?.[0];return e=e.replace(/\n$/,"")+"\n",s?'
'+(n?e:c(e,!0))+"
\n":"
"+(n?e:c(e,!0))+"
\n"}blockquote(e){return`
\n${e}
\n`}html(e,t){return e}heading(e,t,n){return`${e}\n`}hr(){return"
\n"}list(e,t,n){const s=t?"ol":"ul";return"<"+s+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+"\n"}listitem(e,t,n){return`
  • ${e}
  • \n`}checkbox(e){return"'}paragraph(e){return`

    ${e}

    \n`}table(e,t){return t&&(t=`${t}`),"\n\n"+e+"\n"+t+"
    \n"}tablerow(e){return`\n${e}\n`}tablecell(e,t){const n=t.header?"th":"td";return(t.align?`<${n} align="${t.align}">`:`<${n}>`)+e+`\n`}strong(e){return`${e}`}em(e){return`${e}`}codespan(e){return`${e}`}br(){return"
    "}del(e){return`${e}`}link(e,t,n){const s=g(e);if(null===s)return n;let r='
    ",r}image(e,t,n){const s=g(e);if(null===s)return n;let r=`${n}0&&"paragraph"===n.tokens[0].type?(n.tokens[0].text=e+" "+n.tokens[0].text,n.tokens[0].tokens&&n.tokens[0].tokens.length>0&&"text"===n.tokens[0].tokens[0].type&&(n.tokens[0].tokens[0].text=e+" "+n.tokens[0].tokens[0].text)):n.tokens.unshift({type:"text",text:e+" "}):o+=e+" "}o+=this.parse(n.tokens,i),l+=this.renderer.listitem(o,r,!!s)}n+=this.renderer.list(l,t,s);continue}case"html":{const e=r;n+=this.renderer.html(e.text,e.block);continue}case"paragraph":{const e=r;n+=this.renderer.paragraph(this.parseInline(e.tokens));continue}case"text":{let i=r,l=i.tokens?this.parseInline(i.tokens):i.text;for(;s+1{const r=e[s].flat(1/0);n=n.concat(this.walkTokens(r,t))})):e.tokens&&(n=n.concat(this.walkTokens(e.tokens,t)))}}return n}use(...e){const t=this.defaults.extensions||{renderers:{},childTokens:{}};return e.forEach((e=>{const n={...e};if(n.async=this.defaults.async||n.async||!1,e.extensions&&(e.extensions.forEach((e=>{if(!e.name)throw new Error("extension name required");if("renderer"in e){const n=t.renderers[e.name];t.renderers[e.name]=n?function(...t){let s=e.renderer.apply(this,t);return!1===s&&(s=n.apply(this,t)),s}:e.renderer}if("tokenizer"in e){if(!e.level||"block"!==e.level&&"inline"!==e.level)throw new Error("extension level must be 'block' or 'inline'");const n=t[e.level];n?n.unshift(e.tokenizer):t[e.level]=[e.tokenizer],e.start&&("block"===e.level?t.startBlock?t.startBlock.push(e.start):t.startBlock=[e.start]:"inline"===e.level&&(t.startInline?t.startInline.push(e.start):t.startInline=[e.start]))}"childTokens"in e&&e.childTokens&&(t.childTokens[e.name]=e.childTokens)})),n.extensions=t),e.renderer){const t=this.defaults.renderer||new se(this.defaults);for(const n in e.renderer){if(!(n in t))throw new Error(`renderer '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.renderer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n||""}}n.renderer=t}if(e.tokenizer){const t=this.defaults.tokenizer||new w(this.defaults);for(const n in e.tokenizer){if(!(n in t))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;const s=n,r=e.tokenizer[s],i=t[s];t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.tokenizer=t}if(e.hooks){const t=this.defaults.hooks||new le;for(const n in e.hooks){if(!(n in t))throw new Error(`hook '${n}' does not exist`);if("options"===n)continue;const s=n,r=e.hooks[s],i=t[s];le.passThroughHooks.has(n)?t[s]=e=>{if(this.defaults.async)return Promise.resolve(r.call(t,e)).then((e=>i.call(t,e)));const n=r.call(t,e);return i.call(t,n)}:t[s]=(...e)=>{let n=r.apply(t,e);return!1===n&&(n=i.apply(t,e)),n}}n.hooks=t}if(e.walkTokens){const t=this.defaults.walkTokens,s=e.walkTokens;n.walkTokens=function(e){let n=[];return n.push(s.call(this,e)),t&&(n=n.concat(t.call(this,e))),n}}this.defaults={...this.defaults,...n}})),this}setOptions(e){return this.defaults={...this.defaults,...e},this}lexer(e,t){return ne.lex(e,t??this.defaults)}parser(e,t){return ie.parse(e,t??this.defaults)}#e(e,t){return(n,s)=>{const r={...s},i={...this.defaults,...r};!0===this.defaults.async&&!1===r.async&&(i.silent||console.warn("marked(): The async option was set to true by an extension. The async: false option sent to parse will be ignored."),i.async=!0);const l=this.#t(!!i.silent,!!i.async);if(null==n)return l(new Error("marked(): input parameter is undefined or null"));if("string"!=typeof n)return l(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(n)+", string expected"));if(i.hooks&&(i.hooks.options=i),i.async)return Promise.resolve(i.hooks?i.hooks.preprocess(n):n).then((t=>e(t,i))).then((e=>i.hooks?i.hooks.processAllTokens(e):e)).then((e=>i.walkTokens?Promise.all(this.walkTokens(e,i.walkTokens)).then((()=>e)):e)).then((e=>t(e,i))).then((e=>i.hooks?i.hooks.postprocess(e):e)).catch(l);try{i.hooks&&(n=i.hooks.preprocess(n));let s=e(n,i);i.hooks&&(s=i.hooks.processAllTokens(s)),i.walkTokens&&this.walkTokens(s,i.walkTokens);let r=t(s,i);return i.hooks&&(r=i.hooks.postprocess(r)),r}catch(e){return l(e)}}}#t(e,t){return n=>{if(n.message+="\nPlease report this to https://github.com/markedjs/marked.",e){const e="

    An error occurred:

    "+c(n.message+"",!0)+"
    ";return t?Promise.resolve(e):e}if(t)return Promise.reject(n);throw n}}}const ae=new oe;function ce(e,t){return ae.parse(e,t)}ce.options=ce.setOptions=function(e){return ae.setOptions(e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.getDefaults=t,ce.defaults=e.defaults,ce.use=function(...e){return ae.use(...e),ce.defaults=ae.defaults,n(ce.defaults),ce},ce.walkTokens=function(e,t){return ae.walkTokens(e,t)},ce.parseInline=ae.parseInline,ce.Parser=ie,ce.parser=ie.parse,ce.Renderer=se,ce.TextRenderer=re,ce.Lexer=ne,ce.lexer=ne.lex,ce.Tokenizer=w,ce.Hooks=le,ce.parse=ce;const he=ce.options,pe=ce.setOptions,ue=ce.use,ke=ce.walkTokens,ge=ce.parseInline,fe=ce,de=ie.parse,xe=ne.lex;e.Hooks=le,e.Lexer=ne,e.Marked=oe,e.Parser=ie,e.Renderer=se,e.TextRenderer=re,e.Tokenizer=w,e.getDefaults=t,e.lexer=xe,e.marked=ce,e.options=he,e.parse=fe,e.parseInline=ge,e.parser=de,e.setOptions=pe,e.use=ue,e.walkTokens=ke})); diff --git a/static/style.css b/static/style.css index f4792cf..de7980e 100644 --- a/static/style.css +++ b/static/style.css @@ -13,6 +13,8 @@ body { background: var(--bg); color: var(--txt); font-family: "PingFang SC", "Mi .logo h1 { font-size: 22px; letter-spacing: 1px; background: linear-gradient(90deg, #fb923c, #f97316, #fbbf24); -webkit-background-clip: text; -webkit-text-fill-color: transparent; } .logo .sub { font-size: 12px; color: var(--sub); margin-top: 2px; } .tabs { display: flex; gap: 6px; flex-wrap: wrap; } +.admin-link { font-size: 18px; color: var(--sub); text-decoration: none; padding: 6px 10px; border: 1px solid var(--line); border-radius: 999px; transition: .2s; } +.admin-link:hover { color: var(--orange2); border-color: var(--orange2); } .tab { background: transparent; border: 1px solid var(--line); color: var(--sub); padding: 8px 14px; border-radius: 999px; cursor: pointer; font-size: 14px; transition: .2s; } .tab:hover { color: var(--txt); border-color: var(--orange2); } .tab.active { background: linear-gradient(135deg, #f97316, #ea580c); color: #fff; border-color: transparent; box-shadow: 0 2px 12px rgba(249,115,22,.35); } @@ -33,8 +35,57 @@ main { flex: 1; width: 100%; max-width: 1200px; margin: 0 auto; padding: 20px 16 .bubble b { color: var(--orange2); } .bubble em { color: var(--blue); font-style: normal; } .bubble .hint { color: var(--sub); font-size: 13px; margin-top: 6px; } +.bubble .quick-q { color: var(--blue); cursor: pointer; text-decoration: none; border-bottom: 1px dashed var(--blue); } +.bubble .quick-q:hover { color: var(--orange2); border-bottom-color: var(--orange2); } .bubble ul { margin: 6px 0 6px 18px; } .bubble li { margin: 3px 0; } + +/* ---------- Markdown 渲染(需求3) ---------- */ +.md { line-height: 1.8; } +.md h1, .md h2, .md h3, .md h4 { margin: 12px 0 6px; line-height: 1.4; } +.md h1 { font-size: 17px; } .md h2 { font-size: 16px; } .md h3, .md h4 { font-size: 15px; } +.md h1::before, .md h2::before { content: ""; } +.md p { margin: 6px 0; } +.md ul, .md ol { margin: 6px 0 6px 20px; } +.md li { margin: 3px 0; } +.md code { background: #0d1117; border: 1px solid var(--line); border-radius: 5px; padding: 1px 6px; font-size: 12.5px; font-family: Consolas, monospace; color: var(--green); } +.md pre { background: #0d1117; border: 1px solid var(--line); border-radius: 10px; padding: 12px; overflow-x: auto; margin: 8px 0; } +.md pre code { background: transparent; border: none; padding: 0; color: var(--txt); } +.md blockquote { border-left: 3px solid var(--orange2); padding: 2px 12px; margin: 8px 0; color: var(--sub); background: rgba(249,115,22,.06); border-radius: 0 8px 8px 0; } +.md table { display: block; width: 100%; overflow-x: auto; margin: 10px 0; } +.md table th, .md table td { padding: 7px 10px; font-size: 12.5px; } +.md table th { background: var(--bg2); color: var(--orange2); } +.md hr { border: none; border-top: 1px solid var(--line); margin: 10px 0; } +.md a { color: var(--blue); text-decoration: none; } +.md a:hover { text-decoration: underline; } + +/* ---------- 参考资讯折叠块(需求4) ---------- */ +.refs { background: #101826; border: 1px solid #1e3a5f; border-radius: 10px; padding: 8px 12px; margin-bottom: 10px; font-size: 13px; } +.refs summary { cursor: pointer; color: var(--blue); font-weight: 600; list-style: none; user-select: none; } +.refs summary::-webkit-details-marker { display: none; } +.refs summary::before { content: "▸ "; color: var(--sub); } +.refs[open] summary::before { content: "▾ "; } +.refs ul { margin: 8px 0 2px 4px; list-style: none; } +.refs li { margin: 6px 0; padding-left: 12px; border-left: 2px solid #1e3a5f; } +.refs li a { color: var(--txt); text-decoration: none; } +.refs li a:hover { color: var(--blue); } +.ref-src { color: var(--sub); font-size: 11.5px; margin-left: 8px; } + +/* ---------- 实体特殊标记(需求5) ---------- */ +.entity { border-radius: 6px; padding: 0 4px; margin: 0 1px; cursor: pointer; font-weight: 600; transition: .15s; border-bottom: 2px solid transparent; } +.entity:hover { filter: brightness(1.25); } +.entity-team { color: var(--orange2); background: rgba(249,115,22,.12); border-bottom-color: var(--orange2); } +.entity-player { color: var(--blue); background: rgba(56,189,248,.10); border-bottom-color: var(--blue); } +.entity-person { color: var(--green); background: rgba(52,211,153,.10); border-bottom-color: var(--green); } +.entity-game { color: #fbbf24; background: rgba(251,191,36,.10); border-bottom-color: #fbbf24; } + +.entity-cards { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.ecard { display: flex; align-items: center; gap: 8px; background: var(--bg2); border: 1px solid var(--line); border-radius: 10px; padding: 7px 12px 7px 9px; cursor: pointer; transition: .15s; max-width: 100%; } +.ecard:hover { border-color: var(--orange2); transform: translateY(-1px); } +.ecard .eico { font-size: 16px; } +.ecard b { display: block; font-size: 13px; line-height: 1.3; } +.ecard small { color: var(--sub); font-size: 11px; } +.src-line { margin-top: 8px; } .src-tag { display: inline-block; font-size: 11px; color: var(--green); border: 1px solid #1f5c43; background: #0f2a1e; border-radius: 999px; padding: 1px 8px; margin-top: 8px; margin-right: 6px; } .typing { display: inline-flex; gap: 4px; padding: 6px 2px; } .typing i { width: 7px; height: 7px; border-radius: 50%; background: var(--sub); animation: blink 1.2s infinite; }