Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82c0d51671 |
@@ -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 一览
|
||||
| 方法 | 路径 | 说明 |
|
||||
|
||||
@@ -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/<path:name>")
|
||||
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/<path:name>")
|
||||
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/<int:pid>/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)
|
||||
+208
-13
@@ -166,8 +166,9 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo"><div class="ic">🗂️</div>素材库系统</div>
|
||||
<div class="logo" onclick="renderDash()" title="回到首页" style="cursor:pointer"><div class="ic">🗂️</div>素材库系统</div>
|
||||
<div class="search"><span class="s-ic">🔍</span><input id="searchInput" placeholder="搜索项目、素材内容、关键词… (回车搜索)" value=""></div>
|
||||
<button class="btn ghost" onclick="openSettings()" title="设置">⚙️ 设置</button>
|
||||
<div class="chips">
|
||||
<div class="chip"><b id="stProj">0</b>项目</div>
|
||||
<div class="chip"><b id="stMat">0</b>素材</div>
|
||||
@@ -210,7 +211,7 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord
|
||||
|
||||
<div class="overlay" id="textModal" hidden>
|
||||
<div class="modal">
|
||||
<div class="m-head"><h3>新增文本素材</h3><button class="x" onclick="closeModal('textModal')">✕</button></div>
|
||||
<div class="m-head"><h3 id="tmTitle">新增文本素材</h3><button class="x" onclick="closeModal('textModal')">✕</button></div>
|
||||
<div class="m-body">
|
||||
<label>素材名称</label>
|
||||
<input type="text" id="tmName" placeholder="如:政策解读笔记">
|
||||
@@ -229,6 +230,8 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord
|
||||
<div class="m-head"><h3 id="mmTitle">素材详情</h3><button class="x" onclick="closeModal('matModal')">✕</button></div>
|
||||
<div class="m-body" id="mmBody"></div>
|
||||
<div class="m-foot">
|
||||
<button class="btn ghost" id="mmCopy" onclick="copyCurMat()" style="display:none">📋 复制文本</button>
|
||||
<button class="btn ghost" id="mmEdit" onclick="openTextModal(curMat.id)" style="display:none">✏️ 编辑文本</button>
|
||||
<button class="btn danger" onclick="deleteMaterial()">删除</button>
|
||||
<button class="btn green" id="mmAnalyze" onclick="analyzeOne()">🤖 AI 分析</button>
|
||||
<button class="btn primary" onclick="closeModal('matModal')">关闭</button>
|
||||
@@ -238,11 +241,53 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<!-- 设置弹窗 -->
|
||||
<div class="overlay" id="settingsModal" hidden>
|
||||
<div class="modal wide">
|
||||
<div class="m-head"><h3>⚙️ 系统设置</h3><button class="x" onclick="closeModal('settingsModal')">✕</button></div>
|
||||
<div class="m-body">
|
||||
<div class="section"><h5>🧠 分析大模型(OpenAI 兼容接口)</h5>
|
||||
<label>接口地址 Base URL</label><input type="text" id="stLlmUrl" placeholder="https://api.deepseek.com">
|
||||
<div class="two">
|
||||
<div><label>API Key</label><input type="text" id="stLlmKey" placeholder="sk-..."></div>
|
||||
<div><label>模型名</label><input type="text" id="stLlmModel" placeholder="deepseek-v4-flash"></div>
|
||||
</div>
|
||||
<div style="margin-top:10px"><button class="btn ghost sm" onclick="testConn('llm')">🔌 测试连接</button> <span id="stLlmTest" style="font-size:12px;color:var(--dim)"></span></div>
|
||||
</div>
|
||||
<div class="section"><h5>🖼️ 图片视觉模型(用于识别图片内容)</h5>
|
||||
<label>接口地址 Base URL</label><input type="text" id="stVisUrl" placeholder="https://ark.cn-beijing.volces.com/api/plan/v3">
|
||||
<div class="two">
|
||||
<div><label>API Key</label><input type="text" id="stVisKey" placeholder="ark-..."></div>
|
||||
<div><label>模型名</label><input type="text" id="stVisModel" placeholder="doubao-seed-evolving"></div>
|
||||
</div>
|
||||
<div style="margin-top:10px"><button class="btn ghost sm" onclick="testConn('vision')">🔌 测试连接</button> <span id="stVisTest" style="font-size:12px;color:var(--dim)"></span></div>
|
||||
</div>
|
||||
<div class="section"><h5>💾 自动备份(两种触发方式:时间间隔 / 变更量)</h5>
|
||||
<div class="two">
|
||||
<div><label>时间间隔(小时,0=关闭)</label><input type="number" id="stBkInterval" min="0" step="1"></div>
|
||||
<div><label>变更量阈值(改动多少次触发,0=关闭)</label><input type="number" id="stBkThreshold" min="0" step="1"></div>
|
||||
</div>
|
||||
<label>最多保留备份数</label><input type="number" id="stBkKeep" min="1" step="1" style="max-width:200px">
|
||||
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
|
||||
<button class="btn primary sm" onclick="saveSettings()">💾 保存设置</button>
|
||||
<button class="btn green sm" onclick="doBackup()">📦 立即备份</button>
|
||||
<button class="btn ghost sm" onclick="exportBackup()">⬇️ 导出最新备份</button>
|
||||
<button class="btn ghost sm" onclick="$('restoreInput').click()">⬆️ 导入恢复备份</button>
|
||||
<input type="file" id="restoreInput" hidden accept=".zip" onchange="restoreBackup(this.files)">
|
||||
</div>
|
||||
<div id="bkInfo" style="font-size:12px;color:var(--dim);margin-top:10px"></div>
|
||||
<div id="bkList" style="margin-top:6px"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-foot"><button class="btn ghost" onclick="closeModal('settingsModal')">关闭</button></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $=id=>document.getElementById(id);
|
||||
const TYPE_IC={text:'📝',doc:'📄',image:'🖼️',video:'🎬',audio:'🎵',other:'📦'};
|
||||
const TYPE_CLS={text:'t-text',doc:'t-doc',image:'t-image',video:'t-video',audio:'t-audio',other:'t-other'};
|
||||
let state={view:'dash', project:null, cat:'', tag:'', projects:[], meta:null};
|
||||
let state={view:'dash', project:null, cat:'', selectedTags:[], projects:[], meta:null};
|
||||
let polling={};
|
||||
|
||||
function toast(msg,err){const t=$('toast');t.textContent=msg;t.className='toast'+(err?' err':'');t.style.display='block';clearTimeout(t._h);t._h=setTimeout(()=>t.style.display='none',2600);}
|
||||
@@ -270,14 +315,14 @@ async function loadMeta(){
|
||||
$('catList').innerHTML='<div class="filter-item '+(state.cat===''?'active':'')+'" onclick="setCat(\'\')"><span>📂</span>全部项目<span class="n">'+state.meta.stats.projects+'</span></div>'+
|
||||
state.meta.categories.map(c=>'<div class="filter-item '+(state.cat===c.name?'active':'')+'" onclick="setCat('+JSON.stringify(c.name)+')"><span>🏷️</span>'+esc(c.name)+'<span class="n">'+c.count+'</span></div>').join('');
|
||||
// 标签
|
||||
$('tagCloud').innerHTML=state.meta.tags.map(t=>'<span class="tag-chip '+(state.tag===t.name?'active':'')+'" onclick="setTag(\''+escQ(t.name)+'\')">'+esc(t.name)+' ('+t.count+')</span>').join('');
|
||||
$('tagCloud').innerHTML=state.meta.tags.map(t=>'<span class="tag-chip '+(state.selectedTags.includes(t.name)?'active':'')+'" onclick="setTag(\''+escQ(t.name)+'\')">'+esc(t.name)+' ('+t.count+')</span>').join('');
|
||||
// datalist
|
||||
$('catDatalist').innerHTML=state.meta.categories.map(c=>'<option value="'+escQ(c.name)+'">').join('');
|
||||
}
|
||||
async function loadProjects(){
|
||||
let u='/api/projects?';
|
||||
if(state.cat)u+='category='+encodeURIComponent(state.cat)+'&';
|
||||
if(state.tag)u+='tag='+encodeURIComponent(state.tag);
|
||||
if(state.selectedTags.length)u+='tags='+encodeURIComponent(state.selectedTags.join(','));
|
||||
state.projects=await api(u);
|
||||
renderProjList();
|
||||
}
|
||||
@@ -289,8 +334,13 @@ function renderProjList(){
|
||||
}
|
||||
|
||||
/* ---------- 视图 ---------- */
|
||||
function setCat(c){state.cat=c;state.view='dash';loadProjects().then(renderDash);}
|
||||
function setTag(t){state.tag=t;state.view='dash';loadProjects().then(renderDash);}
|
||||
function setCat(c){state.cat=c;state.view='dash';loadMeta().then(()=>loadProjects().then(renderDash));}
|
||||
function setTag(t){
|
||||
const i=state.selectedTags.indexOf(t);
|
||||
if(i>=0)state.selectedTags.splice(i,1);else state.selectedTags.push(t);
|
||||
state.view='dash';
|
||||
loadMeta().then(()=>loadProjects().then(renderDash));
|
||||
}
|
||||
|
||||
async function renderDash(){
|
||||
state.view='dash';
|
||||
@@ -315,7 +365,7 @@ async function renderDash(){
|
||||
</div>`;
|
||||
}).join('');
|
||||
$('main').innerHTML=`
|
||||
<div class="dash-head"><h2>📊 素材库概览${state.cat?' · '+esc(state.cat):''}${state.tag?' · #'+esc(state.tag):''}</h2>
|
||||
<div class="dash-head"><h2>📊 素材库概览${state.cat?' · '+esc(state.cat):''}${state.selectedTags.length?' · '+state.selectedTags.map(t=>'#'+esc(t)).join(' '):''}</h2>
|
||||
<button class="btn primary" onclick="openProjectModal()">+ 新建项目</button></div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat"><div class="v">${s.projects}</div><div class="l">项目总数</div></div>
|
||||
@@ -328,6 +378,7 @@ async function renderDash(){
|
||||
|
||||
async function openProject(id){
|
||||
state.project=await api('/api/projects/'+id);
|
||||
state.project.summaries=await api('/api/projects/'+id+'/summaries');
|
||||
state.view='proj';
|
||||
renderProj();
|
||||
}
|
||||
@@ -350,6 +401,7 @@ function renderProj(){
|
||||
<td><span class="badge ${stCls}">${stTxt}</span></td>
|
||||
<td style="white-space:nowrap">
|
||||
<button class="btn ghost sm" onclick="openMat(${m.id})">查看</button>
|
||||
${(m.extracted_text||m.mtype==='text')?`<button class="btn ghost sm" onclick="copyMat(${m.id})">复制</button>`:''}
|
||||
${m.file_path?`<button class="btn ghost sm" onclick="download(${m.id})">下载</button>`:''}
|
||||
${m.status!=='analyzed'?`<button class="btn green sm" onclick="analyzeOne(${m.id})">AI分析</button>`:''}
|
||||
<button class="btn danger sm" onclick="delMat(${m.id})">删除</button>
|
||||
@@ -384,6 +436,15 @@ function renderProj(){
|
||||
<div class="lab" style="margin-top:10px">项目标签</div><div class="kw-tags">${(sum.tags||[]).map(k=>`<span style="color:var(--purple)">${esc(k)}</span>`).join('')}</div></div>
|
||||
</div>
|
||||
</div>`:''}
|
||||
${(p.summaries&&p.summaries.length>1)?`<div class="summary-card">
|
||||
<h4>📜 摘要历史(${p.summaries.length-1} 份旧版本)</h4>
|
||||
${p.summaries.slice(1).map(h=>`
|
||||
<details style="margin:8px 0;border:1px solid var(--line);border-radius:10px;padding:10px">
|
||||
<summary style="cursor:pointer;font-size:13px;color:var(--acc)">${esc(h.created_at)} · ${esc(h.category||'')}</summary>
|
||||
<div style="margin-top:8px;font-size:13px;line-height:1.7">${esc(h.summary)}</div>
|
||||
<div style="margin-top:8px"><span class="lab">关键词</span><div class="kw-tags" style="margin-top:4px">${(h.keywords||[]).map(k=>`<span>${esc(k)}</span>`).join('')}</div></div>
|
||||
</details>`).join('')}
|
||||
</div>`:''}
|
||||
<div class="upload-zone" onclick="$('fileInput').click()" ondragover="event.preventDefault();this.classList.add('drag')" ondragleave="this.classList.remove('drag')" ondrop="dropFiles(event)">
|
||||
<div class="big">📂</div><p>拖拽文件到此处,或点击选择文件上传</p>
|
||||
<div class="hint">支持:文本(txt/md/csv/json…) · 文档(pdf/docx/pptx) · 图片 · 视频 · 音频</div>
|
||||
@@ -425,18 +486,46 @@ async function delProject(id){
|
||||
}
|
||||
|
||||
/* ---------- 文本素材 ---------- */
|
||||
function openTextModal(){$('tmName').value='';$('tmContent').value='';openModal('textModal');}
|
||||
let editTextId=null;
|
||||
function openTextModal(id){
|
||||
editTextId=id||null;
|
||||
$('tmTitle').textContent=id?'编辑文本素材':'新增文本素材';
|
||||
$('tmName').value='';$('tmContent').value='';
|
||||
if(id){
|
||||
const m=(curMat&&curMat.id===id)?curMat:(state.project.materials||[]).find(x=>x.id===id);
|
||||
if(m){$('tmName').value=m.name;$('tmContent').value=m.extracted_text||'';}
|
||||
}
|
||||
openModal('textModal');
|
||||
}
|
||||
async function saveTextMaterial(){
|
||||
const pid=state.project.id;
|
||||
const body={name:$('tmName').value.trim(),content:$('tmContent').value};
|
||||
try{
|
||||
await api(`/api/projects/${pid}/materials`,{method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:$('tmName').value.trim(),content:$('tmContent').value})});
|
||||
toast('文本素材已添加');closeModal('textModal');await openProject(pid);await loadMeta();
|
||||
if(editTextId){
|
||||
await api('/api/materials/'+editTextId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
toast('文本素材已更新');
|
||||
}else{
|
||||
await api(`/api/projects/${pid}/materials`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||||
toast('文本素材已添加');
|
||||
}
|
||||
closeModal('textModal');
|
||||
await openProject(pid);await loadMeta();
|
||||
}catch(e){toast(e.message,true);}
|
||||
}
|
||||
|
||||
/* ---------- 上传 ---------- */
|
||||
/* ---------- 上传 & 粘贴 ---------- */
|
||||
function dropFiles(e){e.preventDefault();e.target.classList.remove('drag');uploadFiles(e.dataTransfer.files);}
|
||||
// 支持在项目中直接粘贴(截图/剪贴板)上传图片
|
||||
function setupPaste(){
|
||||
document.addEventListener('paste',e=>{
|
||||
if(state.view!=='proj'||!state.project)return;
|
||||
const items=e.clipboardData&&e.clipboardData.items;
|
||||
if(!items)return;
|
||||
const files=[];
|
||||
for(const it of items){if(it.kind==='file'&&it.type.startsWith('image/'))files.push(it.getAsFile());}
|
||||
if(files.length){e.preventDefault();toast('粘贴图片上传中…');uploadFiles(files);}
|
||||
});
|
||||
}
|
||||
async function uploadFiles(files){
|
||||
const pid=state.project.id;
|
||||
const fd=new FormData();
|
||||
@@ -464,6 +553,8 @@ async function openMat(id){
|
||||
:m.mtype==='audio'?`<audio controls src="/files/${m.id}" style="width:100%;margin-bottom:10px"></audio>`
|
||||
:''):'';
|
||||
$('mmTitle').textContent=m.name;
|
||||
$('mmCopy').style.display=(m.extracted_text||m.mtype==='text')?'inline-flex':'none';
|
||||
$('mmEdit').style.display=(m.mtype==='text')?'inline-flex':'none';
|
||||
$('mmBody').innerHTML=`
|
||||
${media}
|
||||
<div class="mdl-row">
|
||||
@@ -516,6 +607,109 @@ async function delMat(id){
|
||||
catch(e){toast(e.message,true);}
|
||||
}
|
||||
|
||||
/* ---------- 一键复制 ---------- */
|
||||
function copyToClipboard(text){
|
||||
if(navigator.clipboard&&window.isSecureContext){
|
||||
navigator.clipboard.writeText(text).then(()=>toast('已复制到剪贴板'),()=>fallbackCopy(text));
|
||||
}else fallbackCopy(text);
|
||||
}
|
||||
function fallbackCopy(text){
|
||||
const ta=document.createElement('textarea');
|
||||
ta.value=text;ta.style.position='fixed';ta.style.opacity='0';
|
||||
document.body.appendChild(ta);ta.select();
|
||||
try{document.execCommand('copy');toast('已复制到剪贴板');}catch(e){toast('复制失败',true);}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
function copyCurMat(){if(!curMat||!curMat.extracted_text){toast('没有可复制的文本',true);return;}copyToClipboard(curMat.extracted_text);}
|
||||
async function copyMat(id){
|
||||
try{
|
||||
const m=await api('/api/materials/'+id);
|
||||
if(!m.extracted_text){toast('该素材没有可复制的文本',true);return;}
|
||||
copyToClipboard(m.extracted_text);
|
||||
}catch(e){toast(e.message,true);}
|
||||
}
|
||||
|
||||
/* ---------- 设置 & 备份 ---------- */
|
||||
let settings=null;
|
||||
async function openSettings(){
|
||||
settings=await api('/api/settings');
|
||||
$('stLlmUrl').value=settings.llm_base_url||'';$('stLlmKey').value=settings.llm_api_key||'';$('stLlmModel').value=settings.llm_model||'';
|
||||
$('stVisUrl').value=settings.vision_base_url||'';$('stVisKey').value=settings.vision_api_key||'';$('stVisModel').value=settings.vision_model||'';
|
||||
$('stBkInterval').value=settings.backup_interval_hours||'24';
|
||||
$('stBkThreshold').value=settings.backup_change_threshold||'50';
|
||||
$('stBkKeep').value=settings.backup_max_keep||'10';
|
||||
await refreshBackupList();
|
||||
openModal('settingsModal');
|
||||
}
|
||||
async function saveSettings(){
|
||||
try{
|
||||
await api('/api/settings',{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({
|
||||
llm_base_url:$('stLlmUrl').value.trim(),llm_api_key:$('stLlmKey').value.trim(),llm_model:$('stLlmModel').value.trim(),
|
||||
vision_base_url:$('stVisUrl').value.trim(),vision_api_key:$('stVisKey').value.trim(),vision_model:$('stVisModel').value.trim(),
|
||||
backup_interval_hours:$('stBkInterval').value,backup_change_threshold:$('stBkThreshold').value,backup_max_keep:$('stBkKeep').value
|
||||
})});
|
||||
toast('设置已保存');
|
||||
await refreshBackupList();
|
||||
}catch(e){toast(e.message,true);}
|
||||
}
|
||||
async function testConn(kind){
|
||||
const el=kind==='llm'?{u:'stLlmUrl',k:'stLlmKey',m:'stLlmModel',t:'stLlmTest'}:{u:'stVisUrl',k:'stVisKey',m:'stVisModel',t:'stVisTest'};
|
||||
$(el.t).textContent='测试中…';$(el.t).style.color='var(--dim)';
|
||||
try{
|
||||
const r=await api('/api/settings/test',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({kind,base_url:$(el.u).value.trim(),api_key:$(el.k).value.trim(),model:$(el.m).value.trim()})});
|
||||
$(el.t).textContent=r.ok?('✅ '+esc(r.reply||'连接成功')):('❌ '+esc(r.error||'失败'));
|
||||
$(el.t).style.color=r.ok?'var(--acc2)':'var(--danger)';
|
||||
}catch(e){$(el.t).textContent='❌ '+esc(e.message);$(el.t).style.color='var(--danger)';}
|
||||
}
|
||||
async function refreshBackupList(){
|
||||
const d=await api('/api/backups');
|
||||
$('bkInfo').innerHTML='上次备份:'+(d.last_backup_time||'从未')+' · 当前累计变更:'+d.change_counter+' 次';
|
||||
$('bkList').innerHTML=d.backups.length?d.backups.map(b=>
|
||||
`<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--line);border-radius:9px;margin-top:6px;font-size:12px">
|
||||
<span>📦</span><span>${esc(b.name)}</span><span style="color:var(--dim)">${fmtSize(b.size)} · ${esc(b.time)}</span>
|
||||
<span style="margin-left:auto;display:flex;gap:6px">
|
||||
<button class="btn ghost sm" onclick="downloadBackup('${escQ(b.name)}')">下载</button>
|
||||
<button class="btn danger sm" onclick="delBackup('${escQ(b.name)}')">删除</button>
|
||||
</span></div>`).join('')
|
||||
:'<div style="color:var(--dim);font-size:12px;margin-top:6px">暂无备份</div>';
|
||||
}
|
||||
async function doBackup(){
|
||||
try{
|
||||
const r=await api('/api/backup',{method:'POST'});
|
||||
if(r.ok){toast('备份完成:'+r.name);await refreshBackupList();}else toast(r.error||'备份失败',true);
|
||||
}catch(e){toast(e.message,true);}
|
||||
}
|
||||
async function exportBackup(){
|
||||
try{
|
||||
let d=await api('/api/backups');
|
||||
if(!d.backups.length){await api('/api/backup',{method:'POST'});d=await api('/api/backups');}
|
||||
if(!d.backups.length){toast('备份失败',true);return;}
|
||||
window.open('/api/backup/download/'+encodeURIComponent(d.backups[0].name),'_blank');
|
||||
await refreshBackupList();
|
||||
}catch(e){toast(e.message,true);}
|
||||
}
|
||||
async function downloadBackup(name){window.open('/api/backup/download/'+encodeURIComponent(name),'_blank');}
|
||||
async function delBackup(name){
|
||||
if(!confirm('删除该备份文件?'))return;
|
||||
try{await api('/api/backup/'+encodeURIComponent(name),{method:'DELETE'});toast('已删除');await refreshBackupList();}
|
||||
catch(e){toast(e.message,true);}
|
||||
}
|
||||
async function restoreBackup(files){
|
||||
if(!files||!files.length){toast('请选择备份文件',true);return;}
|
||||
if(!confirm('恢复备份将覆盖当前全部项目与素材数据,确定继续?')){return;}
|
||||
const fd=new FormData();fd.append('file',files[0]);
|
||||
toast('恢复中…');
|
||||
try{
|
||||
const r=await api('/api/backup/restore',{method:'POST',body:fd});
|
||||
if(r.ok){
|
||||
toast('恢复成功');
|
||||
await loadMeta();await loadProjects();renderDash();
|
||||
closeModal('settingsModal');
|
||||
}else toast(r.error||'恢复失败',true);
|
||||
}catch(e){toast(e.message,true);}
|
||||
finally{$('restoreInput').value='';}
|
||||
}
|
||||
|
||||
/* ---------- 任务轮询 ---------- */
|
||||
function startPoll(key,onDone){
|
||||
if(polling[key])return;
|
||||
@@ -562,6 +756,7 @@ $('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')doSearch(e.ta
|
||||
$('searchInput').addEventListener('input',e=>{if(!e.target.value.trim())renderDash();});
|
||||
|
||||
/* 初始化 */
|
||||
setupPaste();
|
||||
(async()=>{await loadMeta();await loadProjects();renderDash();})();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
Reference in New Issue
Block a user