From 82c0d5167158299f4caae3bc59bba85ea9efd138 Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Thu, 27 Aug 2026 15:59:12 +0800 Subject: [PATCH] =?UTF-8?q?v1.1.0:=20=E8=AE=BE=E7=BD=AE=E9=9D=A2=E6=9D=BF+?= =?UTF-8?q?=E5=8F=8C=E8=A7=A6=E5=8F=91=E8=87=AA=E5=8A=A8=E5=A4=87=E4=BB=BD?= =?UTF-8?q?+=E6=81=A2=E5=A4=8D=20+=20=E6=A0=87=E7=AD=BE=E5=A4=9A=E9=80=89?= =?UTF-8?q?=20+=20=E6=91=98=E8=A6=81=E5=8E=86=E5=8F=B2=20+=20=E6=96=87?= =?UTF-8?q?=E6=9C=AC=E7=BC=96=E8=BE=91/=E4=B8=80=E9=94=AE=E5=A4=8D?= =?UTF-8?q?=E5=88=B6=20+=20=E7=B2=98=E8=B4=B4=E5=9B=BE=E7=89=87=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=20+=20Logo=E5=9B=9E=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 27 +++- app.py | 373 ++++++++++++++++++++++++++++++++++++++++++---- static/index.html | 221 +++++++++++++++++++++++++-- 3 files changed, 581 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 4b8709d..ca19881 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,24 @@ Flask + SQLite + FTS5 全文检索 + DeepSeek/豆包大模型分析 的素材管 ### 3. 索引与查询 - **SQLite FTS5 全文索引**(jieba 分词):素材名 + 抽取文本 + AI摘要 + 关键词 全部入库 - 站内搜索:项目匹配 + 素材匹配(带 AI 摘要预览) -- 分类 / 标签过滤 +- 分类过滤 / **标签多选过滤**(同时勾选多个标签 = 同时满足) + +### 4. 系统设置(⚙️ 右上角) +- **大模型配置**:分析大模型(OpenAI 兼容接口)+ 图片视觉模型,地址/Key/模型名 均可改,支持“测试连接” +- **自动备份(双触发)**: + - 时间间隔(小时,0=关闭) + - 变更量阈值(项目/素材改动次数,0=关闭) + - 后台调度器 30s 检查一次,命中任一条件自动打包备份 + - 保留份数可调,自动清理旧备份 +- **手动备份 / 导出 / 删除** 备份文件 +- **导入恢复**:上传备份 zip 一键还原(覆盖当前数据,有确认) + +### 5. 其他便捷功能 +- **粘贴图片上传**:在项目页直接 Ctrl+V 粘贴剪贴板截图/图片 +- **文本素材编辑**:新增的文本素材可再次编辑名称与内容(内容改动后需重新 AI 分析) +- **一键复制**:文本素材可一键复制全文(表格行 + 详情弹窗均有入口) +- **摘要历史**:每次生成的“项目 AI 摘要”全部保留,可展开查看历史版本 +- **Logo 点击**:左上角 Logo 一键返回首页 ## 技术栈 - 后端:Flask 3 (Python 3.12, openclaw conda 环境) @@ -53,8 +70,14 @@ material-library/ ## 数据库表 - `projects` — 项目(名称/描述/类别/标签) - `materials` — 素材(类型/文件/抽取文本/状态) -- `analyses` — 分析结果(素材级 + 项目级,JSON 字段) +- `analyses` — 分析结果(素材级 + 项目级,JSON 字段,项目摘要保留全部历史) - `materials_fts` — FTS5 全文索引 +- `settings` — 系统设置(大模型配置 / 备份参数,KV 存储) + +## 备份说明 +- 备份目录:`data/backups/backup_YYYYMMDD_HHMMSS[_manual].zip`(含完整数据库 + 上传目录) +- 自动备份由后台线程触发(时间间隔 / 变更量双条件) +- 恢复 = 上传 zip → 校验 → 覆盖数据库与上传目录 → 自动重建索引 ## API 一览 | 方法 | 路径 | 说明 | diff --git a/app.py b/app.py index 027484d..75813fa 100644 --- a/app.py +++ b/app.py @@ -12,6 +12,8 @@ import re import io import json import uuid +import time +import zipfile import shutil import threading import datetime @@ -28,8 +30,10 @@ DATA_DIR = BASE_DIR / "data" UPLOAD_DIR = DATA_DIR / "uploads" DB_PATH = DATA_DIR / "material.db" STATIC_DIR = BASE_DIR / "static" +BACKUP_DIR = DATA_DIR / "backups" os.makedirs(UPLOAD_DIR, exist_ok=True) os.makedirs(STATIC_DIR, exist_ok=True) +os.makedirs(BACKUP_DIR, exist_ok=True) MAX_FILE_MB = 500 # 单文件上限 MAX_TEXT_LEN = 30000 # 抽取文本送入 LLM 的最大长度 @@ -128,9 +132,23 @@ def init_db(): ); CREATE INDEX IF NOT EXISTS idx_mat_project ON materials(project_id); CREATE INDEX IF NOT EXISTS idx_ana_project ON analyses(project_id); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); CREATE VIRTUAL TABLE IF NOT EXISTS materials_fts USING fts5(term); """) db.commit() + # 写入默认设置(不覆盖已有) + defaults = { + "llm_base_url": LLM_BASE_URL, "llm_api_key": LLM_API_KEY, "llm_model": LLM_MODEL, + "vision_base_url": VISION_BASE_URL, "vision_api_key": VISION_API_KEY, "vision_model": VISION_MODEL, + "backup_interval_hours": "24", "backup_change_threshold": "50", "backup_max_keep": "10", + "last_backup_time": "", "change_counter": "0", + } + for k, v in defaults.items(): + db.execute("INSERT OR IGNORE INTO settings(key,value) VALUES(?,?)", (k, v)) + db.commit() db.close() @@ -241,20 +259,158 @@ def _safe_filename(name): return name[:120] or "unnamed" +# ---------------- 设置 & 备份机制 ---------------- +def get_settings(db=None): + if db is None: + db = sqlite3.connect(str(DB_PATH)) + db.row_factory = sqlite3.Row + close = True + else: + close = False + rows = db.execute("SELECT key,value FROM settings").fetchall() + s = {r["key"]: r["value"] for r in rows} + if close: + db.close() + return s + + +def get_setting(key, default=""): + s = get_settings() + return s.get(key) if s.get(key) not in (None, "") else default + + +def set_setting(db, key, value): + db.execute("INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value", (key, str(value))) + + +def bump_changes(db): + """变更计数+1(用于按变更量触发备份)""" + cur = db.execute("SELECT value FROM settings WHERE key='change_counter'").fetchone() + cnt = int(cur["value"]) if cur and cur["value"] else 0 + set_setting(db, "change_counter", str(cnt + 1)) + + +def llm_config(): + return { + "base_url": get_setting("llm_base_url", LLM_BASE_URL), + "api_key": get_setting("llm_api_key", LLM_API_KEY), + "model": get_setting("llm_model", LLM_MODEL), + } + + +def vision_config(): + return { + "base_url": get_setting("vision_base_url", VISION_BASE_URL), + "api_key": get_setting("vision_api_key", VISION_API_KEY), + "model": get_setting("vision_model", VISION_MODEL), + } + + +def make_backup(manual=False): + """生成备份 zip(数据库 + 上传目录)""" + with _lock: + c = sqlite3.connect(str(DB_PATH)) + try: + c.execute("PRAGMA wal_checkpoint(TRUNCATE)") + finally: + c.close() + ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + name = f"backup_{ts}{'_manual' if manual else ''}.zip" + zpath = BACKUP_DIR / name + with zipfile.ZipFile(zpath, "w", zipfile.ZIP_DEFLATED) as z: + z.write(DB_PATH, "data/material.db") + if UPLOAD_DIR.exists(): + for p in sorted(UPLOAD_DIR.rglob("*")): + if p.is_file(): + z.write(p, f"data/uploads/{p.relative_to(UPLOAD_DIR)}") + db = sqlite3.connect(str(DB_PATH)) + set_setting(db, "last_backup_time", _ts()) + set_setting(db, "change_counter", "0") + db.commit() + db.close() + _prune_backups() + return name + + +def _prune_backups(): + try: + max_keep = int(get_setting("backup_max_keep", "10") or 10) + except Exception: + max_keep = 10 + files = sorted(BACKUP_DIR.glob("backup_*.zip"), key=lambda p: p.stat().st_mtime, reverse=True) + for f in files[max_keep:]: + try: + f.unlink() + except Exception: + pass + + +def list_backups(): + out = [] + for p in sorted(BACKUP_DIR.glob("backup_*.zip"), key=lambda x: x.stat().st_mtime, reverse=True): + out.append({"name": p.name, "size": p.stat().st_size, + "time": datetime.datetime.fromtimestamp(p.stat().st_mtime).strftime("%Y-%m-%d %H:%M:%S")}) + return out + + +def _backup_scheduler(): + """后台调度:按时间间隔 / 变更量 自动备份""" + while True: + try: + time.sleep(30) + s = get_settings() + try: + interval = float(s.get("backup_interval_hours", "24") or 0) + except Exception: + interval = 24 + try: + threshold = int(s.get("backup_change_threshold", "50") or 0) + except Exception: + threshold = 50 + try: + cnt = int(s.get("change_counter", "0") or 0) + except Exception: + cnt = 0 + last = s.get("last_backup_time", "") + due_time = False + if interval > 0: + if not last: + due_time = True + else: + try: + lt = datetime.datetime.strptime(last, "%Y-%m-%d %H:%M:%S") + due_time = (datetime.datetime.now() - lt).total_seconds() >= interval * 3600 + except Exception: + due_time = True + if (due_time or (threshold > 0 and cnt >= threshold)): + make_backup() + except Exception: + pass + + # ---------------- 大模型调用 ---------------- def _llm_chat(messages, model=None, temperature=0.3, max_tokens=1500): - url = f"{LLM_BASE_URL.rstrip('/')}/chat/completions" - payload = { - "model": model or LLM_MODEL, - "messages": messages, - "temperature": temperature, - "max_tokens": max_tokens, - "response_format": {"type": "json_object"}, - } - r = requests.post(url, headers={"Authorization": f"Bearer {LLM_API_KEY}"}, - json=payload, timeout=120) - r.raise_for_status() - return r.json()["choices"][0]["message"]["content"] + cfg = llm_config() + url = f"{cfg['base_url'].rstrip('/')}/chat/completions" + headers = {"Authorization": f"Bearer {cfg['api_key']}"} + payload_base = {"model": model or cfg["model"], "messages": messages, + "temperature": temperature, "max_tokens": max_tokens} + # 部分模型不支持 response_format=json_object,失败时自动降级重试 + for use_json in (True, False): + payload = dict(payload_base) + if use_json: + payload["response_format"] = {"type": "json_object"} + try: + r = requests.post(url, headers=headers, json=payload, timeout=120) + r.raise_for_status() + return r.json()["choices"][0]["message"]["content"] + except requests.HTTPError: + if not use_json: + raise + continue + except Exception: + raise + raise RuntimeError("LLM 调用失败") ANALYZE_PROMPT = """你是专业素材库分析助手。请对下面这个素材做深度分析,提取关键信息。 @@ -283,13 +439,14 @@ def _parse_json(content): def _vision_describe(path): - """用 doubao 视觉模型描述图片""" + """用视觉大模型描述图片""" try: import base64 + cfg = vision_config() b64 = base64.b64encode(Path(path).read_bytes()).decode() - url = f"{VISION_BASE_URL.rstrip('/')}/chat/completions" + url = f"{cfg['base_url'].rstrip('/')}/chat/completions" payload = { - "model": VISION_MODEL, + "model": cfg["model"], "messages": [{ "role": "user", "content": [ @@ -300,7 +457,7 @@ def _vision_describe(path): "temperature": 0.3, "max_tokens": 300, } - r = requests.post(url, headers={"Authorization": f"Bearer {VISION_API_KEY}"}, + r = requests.post(url, headers={"Authorization": f"Bearer {cfg['api_key']}"}, json=payload, timeout=120) r.raise_for_status() return r.json()["choices"][0]["message"]["content"].strip() @@ -332,16 +489,16 @@ def analyze_material(mat): prompt = ANALYZE_PROMPT.format(name=mat["name"], mtype=mtype, extra=extra, content=text) out = _llm_chat([{"role": "user", "content": prompt}]) data = _parse_json(out) + model = llm_config()["model"] detail["raw"] = out[:3000] - detail["model"] = LLM_MODEL - return data, LLM_MODEL, detail + detail["model"] = model + return data, model, detail def _store_analysis(db, material_id, project_id, scope, data, model, detail): if scope == "material": db.execute("DELETE FROM analyses WHERE material_id=? AND scope='material'", (material_id,)) - else: - db.execute("DELETE FROM analyses WHERE project_id=? AND scope='project'", (project_id,)) + # project 摘要:保留全部历史记录,不做删除 cur = db.execute( "INSERT INTO analyses (material_id, project_id, scope, summary, key_points, keywords, tags, category, detail, model, created_at) " "VALUES (?,?,?,?,?,?,?,?,?,?,?)", @@ -441,7 +598,8 @@ def _run_project_summary(project_id): """ out = _llm_chat([{"role": "user", "content": prompt}]) data = _parse_json(out) - _store_analysis(db, None, project_id, "project", data, LLM_MODEL, {"model": LLM_MODEL, "raw": out[:3000]}) + model = llm_config()["model"] + _store_analysis(db, None, project_id, "project", data, model, {"model": model, "raw": out[:3000]}) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), project_id)) db.commit() _tasks[key]["done"] = 1 @@ -517,7 +675,7 @@ def list_projects(): db = get_db() q = (request.args.get("q") or "").strip() category = (request.args.get("category") or "").strip() - tag = (request.args.get("tag") or "").strip() + tags_param = (request.args.get("tags") or request.args.get("tag") or "").strip() sql = "SELECT * FROM projects WHERE 1=1" args = [] if q: @@ -527,9 +685,11 @@ def list_projects(): if category: sql += " AND category=?" args.append(category) - if tag: - sql += " AND tags LIKE ?" - args.append(f"%{tag}%") + if tags_param: + # 多选标签:项目需同时包含所有选中标签(AND) + for t in [x.strip() for x in tags_param.split(",") if x.strip()]: + sql += " AND tags LIKE ?" + args.append(f"%{t}%") sql += " ORDER BY updated_at DESC, id DESC" rows = db.execute(sql, args).fetchall() return jsonify([project_json(r, db) for r in rows]) @@ -547,6 +707,8 @@ def create_project(): (d.get("category") or "未分类").strip(), (d.get("tags") or "").strip(), _ts())) db.commit() + bump_changes(db) + db.commit() row = db.execute("SELECT * FROM projects WHERE id=?", (cur.lastrowid,)).fetchone() return jsonify(project_json(row, db)) @@ -575,6 +737,7 @@ def update_project(pid): (name, (d.get("description", row["description"]) or "").strip(), (d.get("category", row["category"]) or "未分类").strip(), (d.get("tags", row["tags"]) or "").strip(), _ts(), pid)) + bump_changes(db) db.commit() return jsonify(project_json(db.execute("SELECT * FROM projects WHERE id=?", (pid,)).fetchone(), db)) @@ -595,6 +758,7 @@ def delete_project(pid): db.execute("DELETE FROM analyses WHERE project_id=?", (pid,)) db.execute("DELETE FROM materials WHERE project_id=?", (pid,)) db.execute("DELETE FROM projects WHERE id=?", (pid,)) + bump_changes(db) db.commit() return jsonify({"ok": True}) @@ -658,6 +822,7 @@ def upload_materials(pid): mid = cur.lastrowid _index_material(db, mid, safe, text) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), pid)) + bump_changes(db) db.commit() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() results.append(material_json(row, db)) @@ -680,6 +845,7 @@ def add_text_material(pid): mid = cur.lastrowid _index_material(db, mid, name, content) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), pid)) + bump_changes(db) db.commit() row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() return jsonify(material_json(row, db)) @@ -703,12 +869,21 @@ def update_material(mid): return jsonify({"error": "素材不存在"}), 404 name = (d.get("name") or row["name"]).strip() mtype = (d.get("mtype") or row["mtype"]).strip() + # 支持编辑文本内容:内容变了则旧分析作废,需重新分析 + if "content" in d: + new_content = d.get("content") or "" + if new_content != (row["extracted_text"] or ""): + db.execute("UPDATE materials SET extracted_text=?, text_status=?, status='uploaded' WHERE id=?", + (new_content, "extracted" if new_content.strip() else "empty", mid)) + db.execute("DELETE FROM analyses WHERE material_id=? AND scope='material'", (mid,)) db.execute("UPDATE materials SET name=?, mtype=? WHERE id=?", (name, mtype, mid)) # 更新索引 + row = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() ana = db.execute("SELECT * FROM analyses WHERE material_id=? AND scope='material' ORDER BY id DESC LIMIT 1", (mid,)).fetchone() kw = ",".join(json.loads(ana["keywords"] or "[]")) if ana else "" _index_material(db, mid, name, row["extracted_text"] or "", (ana["summary"] if ana else "") or "", kw) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), row["project_id"])) + bump_changes(db) db.commit() row2 = db.execute("SELECT * FROM materials WHERE id=?", (mid,)).fetchone() return jsonify(material_json(row2, db)) @@ -731,6 +906,7 @@ def delete_material(mid): db.execute("DELETE FROM analyses WHERE material_id=?", (mid,)) db.execute("DELETE FROM materials WHERE id=?", (mid,)) db.execute("UPDATE projects SET updated_at=? WHERE id=?", (_ts(), row["project_id"])) + bump_changes(db) db.commit() return jsonify({"ok": True}) @@ -812,11 +988,158 @@ def search(): }) +# ---------------- 路由:设置 & 备份 ---------------- +@app.get("/api/settings") +def api_get_settings(): + return jsonify(get_settings()) + + +@app.put("/api/settings") +def api_put_settings(): + d = request.get_json(force=True, silent=True) or {} + db = get_db() + keys = ["llm_base_url", "llm_api_key", "llm_model", + "vision_base_url", "vision_api_key", "vision_model", + "backup_interval_hours", "backup_change_threshold", "backup_max_keep"] + for k in keys: + if k in d: + set_setting(db, k, d[k]) + db.commit() + return jsonify(get_settings()) + + +@app.post("/api/settings/test") +def api_test_settings(): + d = request.get_json(force=True, silent=True) or {} + kind = d.get("kind", "llm") + base_url = (d.get("base_url") or "").strip() + api_key = (d.get("api_key") or "").strip() + model = (d.get("model") or "").strip() + if not base_url or not api_key or not model: + return jsonify({"ok": False, "error": "请填写完整的接口地址 / Key / 模型名"}), 400 + try: + payload = {"model": model, "messages": [{"role": "user", "content": "请用 json 格式回复:{\"status\":\"ok\"}"}], "max_tokens": 30} + if kind == "llm": + payload["response_format"] = {"type": "json_object"} + r = requests.post(f"{base_url.rstrip('/')}/chat/completions", + headers={"Authorization": f"Bearer {api_key}"}, + json=payload, timeout=60) + r.raise_for_status() + msg = r.json()["choices"][0]["message"] + reply = msg.get("content") or "" + if not reply.strip() and msg.get("reasoning_content"): + reply = "(推理过程) " + msg["reasoning_content"] + return jsonify({"ok": True, "reply": str(reply)[:200]}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)[:300]}) + + +@app.get("/api/backups") +def api_list_backups(): + return jsonify({"backups": list_backups(), + "last_backup_time": get_setting("last_backup_time", ""), + "change_counter": get_setting("change_counter", "0")}) + + +@app.post("/api/backup") +def api_make_backup(): + try: + name = make_backup(manual=True) + return jsonify({"ok": True, "name": name}) + except Exception as e: + return jsonify({"ok": False, "error": str(e)}), 500 + + +@app.get("/api/backup/download/") +def api_download_backup(name): + safe = Path(name).name + if not re.fullmatch(r"backup_\d{8}_\d{6}(_manual)?\.zip", safe): + abort(400) + p = BACKUP_DIR / safe + if not p.exists(): + abort(404) + return send_file(str(p), as_attachment=True, download_name=safe) + + +@app.delete("/api/backup/") +def api_delete_backup(name): + safe = Path(name).name + if not re.fullmatch(r"backup_\d{8}_\d{6}(_manual)?\.zip", safe): + abort(400) + p = BACKUP_DIR / safe + if p.exists(): + p.unlink() + return jsonify({"ok": True}) + + +@app.post("/api/backup/restore") +def api_restore_backup(): + for k, t in _tasks.items(): + if t.get("running"): + return jsonify({"ok": False, "error": "当前有后台任务运行中,请稍后再恢复"}), 409 + f = request.files.get("file") + if not f or not f.filename: + return jsonify({"ok": False, "error": "请选择备份文件"}), 400 + tmp = DATA_DIR / "_restore_tmp" + if tmp.exists(): + shutil.rmtree(str(tmp)) + tmp.mkdir() + try: + with zipfile.ZipFile(f.stream) as z: + if "data/material.db" not in z.namelist(): + shutil.rmtree(str(tmp)) + return jsonify({"ok": False, "error": "备份文件不包含数据库"}), 400 + z.extractall(str(tmp)) + except Exception as e: + shutil.rmtree(str(tmp)) + return jsonify({"ok": False, "error": f"备份文件解析失败: {e}"}), 400 + try: + c = sqlite3.connect(str(DB_PATH)) + c.execute("PRAGMA wal_checkpoint(TRUNCATE)") + c.close() + db_tmp = tmp / "data" / "material.db" + shutil.copy2(str(db_tmp), str(DB_PATH)) + for suffix in ("-wal", "-shm"): + p = Path(str(DB_PATH) + suffix) + if p.exists(): + p.unlink() + if UPLOAD_DIR.exists(): + shutil.rmtree(str(UPLOAD_DIR)) + up_tmp = tmp / "data" / "uploads" + if up_tmp.exists(): + shutil.move(str(up_tmp), str(UPLOAD_DIR)) + else: + UPLOAD_DIR.mkdir(parents=True, exist_ok=True) + init_db() + shutil.rmtree(str(tmp)) + return jsonify({"ok": True, "msg": "恢复成功"}) + except Exception as e: + shutil.rmtree(str(tmp)) + return jsonify({"ok": False, "error": f"恢复失败: {e}"}), 500 + + +# ---------------- 路由:摘要历史 ---------------- +@app.get("/api/projects//summaries") +def project_summaries(pid): + db = get_db() + rows = db.execute("SELECT * FROM analyses WHERE project_id=? AND scope='project' ORDER BY id DESC", (pid,)).fetchall() + out = [] + for r in rows: + d = dict(r) + d["key_points"] = json.loads(d["key_points"] or "[]") + d["keywords"] = json.loads(d["keywords"] or "[]") + d["tags"] = json.loads(d["tags"] or "[]") + d["detail"] = json.loads(d["detail"] or "{}") + out.append(d) + return jsonify(out) + + app.teardown_appcontext(close_db) if __name__ == "__main__": init_db() + threading.Thread(target=_backup_scheduler, daemon=True).start() port = int(os.environ.get("PORT", "16091")) print(f"素材库系统启动: http://0.0.0.0:{port}") app.run(host="0.0.0.0", port=port, debug=False, threaded=True) diff --git a/static/index.html b/static/index.html index ffe5a44..9be5288 100644 --- a/static/index.html +++ b/static/index.html @@ -166,8 +166,9 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord
- + +
0项目
0素材
@@ -210,7 +211,7 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord