Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
222564f0da | ||
|
|
cc6735dfe9 |
@@ -0,0 +1,232 @@
|
||||
# 素材库系统 — 完整 API 文档
|
||||
|
||||
- 基础地址:`http://<主机>:16091`
|
||||
- 数据格式:JSON(`Content-Type: application/json`)
|
||||
- 文件上传:`multipart/form-data`
|
||||
- 所有接口均为无鉴权访问(内网工具);如需鉴权可自行在前面加网关
|
||||
|
||||
---
|
||||
|
||||
## 一、项目 Projects
|
||||
|
||||
### 1.1 项目列表
|
||||
`GET /api/projects`
|
||||
|
||||
查询参数:
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `q` | 关键词(匹配名称/描述/标签) |
|
||||
| `category` | 按类别过滤(精确) |
|
||||
| `tags` | 按标签过滤(逗号分隔,多标签=同时满足 AND) |
|
||||
|
||||
响应:项目数组,每项含 `id, name, description, category, tags, created_at, updated_at, material_count, analyzed_count, analysis`(最新项目摘要)
|
||||
|
||||
### 1.2 新建项目
|
||||
`POST /api/projects`
|
||||
|
||||
```json
|
||||
{"name": "项目名", "description": "描述", "category": "市场资讯", "tags": "AI,行业"}
|
||||
```
|
||||
|
||||
### 1.3 项目详情(含全部素材+分析)
|
||||
`GET /api/projects/<id>`
|
||||
|
||||
响应:项目对象 + `materials` 数组(每个素材含抽取文本与最新 AI 分析)
|
||||
|
||||
### 1.4 修改项目
|
||||
`PUT /api/projects/<id>`
|
||||
```json
|
||||
{"name": "新名", "description": "新描述", "category": "新类别", "tags": "新标签"}
|
||||
```
|
||||
(缺省字段保持原值)
|
||||
|
||||
### 1.5 删除项目(级联删素材+分析+文件)
|
||||
`DELETE /api/projects/<id>`
|
||||
|
||||
### 1.6 项目摘要历史
|
||||
`GET /api/projects/<id>/summaries`
|
||||
|
||||
响应:该项目全部项目级 AI 摘要(按时间倒序),每份含 `id, summary, key_points[], keywords[], tags[], category, created_at, model`
|
||||
|
||||
---
|
||||
|
||||
## 二、素材 Materials
|
||||
|
||||
### 2.1 批量上传文件
|
||||
`POST /api/projects/<id>/materials/upload`
|
||||
`multipart/form-data`,字段 `files`(可多个)
|
||||
|
||||
支持类型:文本(txt/md/csv/json/xml/srt/log/html) · 文档(pdf/docx/pptx) · 图片 · 视频 · 音频
|
||||
|
||||
响应:`{"results": [素材对象], "count": n}`;不支持的扩展名返回 `{"ok":false,"error":"..."}`
|
||||
|
||||
### 2.2 新增文本素材(粘贴内容)
|
||||
`POST /api/projects/<id>/materials`
|
||||
```json
|
||||
{"name": "素材名", "content": "文本内容"}
|
||||
```
|
||||
|
||||
### 2.3 素材详情
|
||||
`GET /api/materials/<id>`
|
||||
|
||||
响应含:`name, mtype, file_path, file_size, ext, meta(宽高/格式), extracted_text, text_status, status, created_at, analysis`(最新素材级分析)
|
||||
|
||||
### 2.4 修改素材(改名 / 编辑文本内容)
|
||||
`PUT /api/materials/<id>`
|
||||
```json
|
||||
{"name": "新名字", "content": "新内容", "mtype": "text"}
|
||||
```
|
||||
- `content` 有值时更新正文并**作废旧分析**(需重新分析)
|
||||
- 缺省字段保持原值
|
||||
|
||||
### 2.5 删除素材
|
||||
`DELETE /api/materials/<id>`
|
||||
|
||||
### 2.6 下载/预览素材文件
|
||||
`GET /files/<material_id>`
|
||||
- 图片/音视频:浏览器内预览;其它:下载
|
||||
|
||||
---
|
||||
|
||||
## 三、AI 分析 Analysis
|
||||
|
||||
### 3.1 分析单个素材
|
||||
`POST /api/materials/<id>/analyze`
|
||||
→ `{"ok": true, "task": "material:<project_id>"}`
|
||||
后台执行,用任务接口轮询进度。
|
||||
|
||||
### 3.2 分析项目全部素材
|
||||
`POST /api/projects/<id>/analyze`
|
||||
→ `{"ok": true, "task": "material:<project_id>"}`
|
||||
|
||||
### 3.3 生成项目级 AI 摘要
|
||||
`POST /api/projects/<id>/summary`
|
||||
→ `{"ok": true, "task": "summary:<project_id>"}`
|
||||
每次生成都会保留为一条历史记录。
|
||||
|
||||
### 3.4 任务进度查询
|
||||
`GET /api/tasks/<key>`
|
||||
→ `{"running": bool, "done": n, "total": n, "msg": "已分析 2/4", "error": null}`
|
||||
|
||||
---
|
||||
|
||||
## 四、搜索 Search
|
||||
|
||||
### 4.1 全文搜索
|
||||
`GET /api/search?q=关键词`
|
||||
- 项目匹配(名称/描述/标签 LIKE)
|
||||
- 素材匹配(SQLite FTS5 + jieba 中文分词,索引含素材名/正文/AI摘要/关键词)
|
||||
|
||||
响应:`{"projects": [...], "materials": [...]}`
|
||||
|
||||
### 4.2 元数据/统计
|
||||
`GET /api/meta`
|
||||
响应:`{"categories": [{name,count}], "tags": [{name,count}], "stats": {projects, materials, analyzed}}`
|
||||
|
||||
---
|
||||
|
||||
## 五、设置 Settings
|
||||
|
||||
### 5.1 读取设置
|
||||
`GET /api/settings`
|
||||
响应(KV):
|
||||
```json
|
||||
{
|
||||
"llm_base_url": "https://api.deepseek.com",
|
||||
"llm_api_key": "sk-...",
|
||||
"llm_model": "deepseek-v4-flash",
|
||||
"vision_base_url": "https://ark.cn-beijing.volces.com/api/plan/v3",
|
||||
"vision_api_key": "ark-...",
|
||||
"vision_model": "doubao-seed-evolving",
|
||||
"backup_interval_hours": "24",
|
||||
"backup_change_threshold": "50",
|
||||
"backup_max_keep": "10",
|
||||
"last_backup_time": "...",
|
||||
"change_counter": "0"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 保存设置
|
||||
`PUT /api/settings`
|
||||
传需要修改的字段即可(部分更新)。大模型/视觉接口改动**立即生效**,无需重启。
|
||||
|
||||
### 5.3 测试接口连接
|
||||
`POST /api/settings/test`
|
||||
```json
|
||||
{"kind": "llm|vision", "base_url": "...", "api_key": "...", "model": "..."}
|
||||
```
|
||||
→ `{"ok": true, "reply": "..."}` 或 `{"ok": false, "error": "..."}`
|
||||
|
||||
---
|
||||
|
||||
## 六、备份 Backups
|
||||
|
||||
### 6.1 备份列表
|
||||
`GET /api/backups`
|
||||
→ `{"backups": [{name,size,time}], "last_backup_time": "...", "change_counter": "5"}`
|
||||
|
||||
### 6.2 手动立即备份
|
||||
`POST /api/backup`
|
||||
→ `{"ok": true, "name": "backup_20260827_100000_manual.zip"}`
|
||||
备份包含完整数据库 + 上传目录,打包为 zip 存于 `data/backups/`
|
||||
|
||||
### 6.3 下载备份
|
||||
`GET /api/backup/download/<文件名>`
|
||||
|
||||
### 6.4 删除备份
|
||||
`DELETE /api/backup/<文件名>`
|
||||
|
||||
### 6.5 导入恢复备份
|
||||
`POST /api/backup/restore`
|
||||
`multipart/form-data`,字段 `file`(上传备份 zip)
|
||||
|
||||
⚠️ 会**覆盖当前全部数据**(数据库 + 上传目录),后台有任务运行时返回 409 拒绝。
|
||||
|
||||
---
|
||||
|
||||
## 七、自动备份触发规则
|
||||
|
||||
后台调度器每 30 秒检查一次,满足以下任一条件即自动备份:
|
||||
1. **时间间隔**:距上次备份 ≥ `backup_interval_hours` 小时(0=关闭)
|
||||
2. **变更量**:`change_counter`(项目/素材增删改次数)≥ `backup_change_threshold`(0=关闭)
|
||||
|
||||
备份后 `change_counter` 清零、`last_backup_time` 更新。保留份数 `backup_max_keep`,超出自动删除最旧备份。
|
||||
|
||||
---
|
||||
|
||||
## 八、错误码约定
|
||||
|
||||
| 情况 | 状态码 |
|
||||
|------|--------|
|
||||
| 参数/校验错误 | 400(`{"error": "..."}`) |
|
||||
| 资源不存在 | 404 |
|
||||
| 后台任务运行中(恢复备份) | 409 |
|
||||
| 服务器内部错误 | 500 |
|
||||
|
||||
## 九、调用示例(curl)
|
||||
|
||||
```bash
|
||||
# 新建项目
|
||||
curl -X POST http://127.0.0.1:16091/api/projects \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"调研","category":"研究","tags":"AI"}'
|
||||
|
||||
# 上传文件
|
||||
curl -X POST http://127.0.0.1:16091/api/projects/1/materials/upload \
|
||||
-F "files=@report.pdf" -F "files=@pic.png"
|
||||
|
||||
# 新增文本素材
|
||||
curl -X POST http://127.0.0.1:16091/api/projects/1/materials \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"笔记","content":"这是内容"}'
|
||||
|
||||
# 全文搜索
|
||||
curl "http://127.0.0.1:16091/api/search?q=大模型"
|
||||
|
||||
# 分析项目全部素材 → 轮询任务
|
||||
curl -X POST http://127.0.0.1:16091/api/projects/1/analyze
|
||||
curl http://127.0.0.1:16091/api/tasks/material:1
|
||||
|
||||
# 立即备份
|
||||
curl -X POST http://127.0.0.1:16091/api/backup
|
||||
```
|
||||
@@ -35,11 +35,14 @@ Flask + SQLite + FTS5 全文检索 + DeepSeek/豆包大模型分析 的素材管
|
||||
- **导入恢复**:上传备份 zip 一键还原(覆盖当前数据,有确认)
|
||||
|
||||
### 5. 其他便捷功能
|
||||
- **文本素材直接编辑**:查看详情时文本区域默认可直接编辑,点「💾 保存」才真正落库;有未保存修改时关闭弹窗/点外部区域会提示是否保存,防误点
|
||||
- **Markdown 切换**:文本查看支持「纯文本 / Markdown」切换,默认纯文本,Markdown 模式渲染预览(只读)
|
||||
- **粘贴图片上传**:在项目页直接 Ctrl+V 粘贴剪贴板截图/图片
|
||||
- **文本素材编辑**:新增的文本素材可再次编辑名称与内容(内容改动后需重新 AI 分析)
|
||||
- **一键复制**:文本素材可一键复制全文(表格行 + 详情弹窗均有入口)
|
||||
- **摘要历史**:每次生成的“项目 AI 摘要”全部保留,可展开查看历史版本
|
||||
- **素材列表**:显示类型/名称/大小/创建时间/状态/操作,点列头可按类型/名称/大小/创建时间升序降序排序
|
||||
- **Logo 点击**:左上角 Logo 一键返回首页
|
||||
- **API 文档**:右上角 📖 按钮(或 `/api-doc`)查看完整接口文档,详见 `API.md`
|
||||
|
||||
## 技术栈
|
||||
- 后端:Flask 3 (Python 3.12, openclaw conda 环境)
|
||||
@@ -47,6 +50,10 @@ Flask + SQLite + FTS5 全文检索 + DeepSeek/豆包大模型分析 的素材管
|
||||
- 检索:SQLite FTS5 + jieba
|
||||
- LLM:DeepSeek `deepseek-v4-flash`(结构化 JSON 分析)+ 豆包 `doubao-seed-evolving`(图片视觉描述)
|
||||
|
||||
## API 文档
|
||||
- 网页版:`http://<IP>:16091/api-doc`(右上角 📖 按钮)
|
||||
- 完整文档见 `API.md`(含全部接口、参数、响应、curl 示例)
|
||||
|
||||
## 快速开始
|
||||
```bash
|
||||
./start.sh # 启动 (默认 16091)
|
||||
|
||||
@@ -309,6 +309,12 @@ def vision_config():
|
||||
def make_backup(manual=False):
|
||||
"""生成备份 zip(数据库 + 上传目录)"""
|
||||
with _lock:
|
||||
# 先落盘备份状态(让备份文件内包含正确的时间/计数),再 checkpoint 合并 WAL
|
||||
db = sqlite3.connect(str(DB_PATH))
|
||||
set_setting(db, "last_backup_time", _ts())
|
||||
set_setting(db, "change_counter", "0")
|
||||
db.commit()
|
||||
db.close()
|
||||
c = sqlite3.connect(str(DB_PATH))
|
||||
try:
|
||||
c.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
@@ -323,11 +329,6 @@ def make_backup(manual=False):
|
||||
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
|
||||
|
||||
@@ -650,6 +651,125 @@ def index():
|
||||
return send_file(str(STATIC_DIR / "index.html"))
|
||||
|
||||
|
||||
# ---------------- API 文档页 ----------------
|
||||
def _md_to_html(md):
|
||||
"""极简 markdown 渲染(标题/表格/代码块/列表/行内样式)"""
|
||||
import html as _html
|
||||
lines = md.split("\n")
|
||||
out = []
|
||||
in_code = False
|
||||
in_table = False
|
||||
buf = []
|
||||
|
||||
def flush_table():
|
||||
nonlocal buf
|
||||
if not buf:
|
||||
return
|
||||
rows = buf
|
||||
buf = []
|
||||
html = "<table class=\"md-t\">"
|
||||
for i, row in enumerate(rows):
|
||||
cells = [c.strip() for c in row.strip().strip("|").split("|")]
|
||||
tag = "th" if i == 0 else "td"
|
||||
html += "<tr>" + "".join(f"<{tag}>{_inline(c)}</{tag}>" for c in cells) + "</tr>"
|
||||
html += "</table>"
|
||||
out.append(html)
|
||||
|
||||
def _inline(t):
|
||||
t = _html.escape(t)
|
||||
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t)
|
||||
t = re.sub(r"`(.+?)`", r"<code>\1</code>", t)
|
||||
return t
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.strip().startswith("```"):
|
||||
if not in_code:
|
||||
in_code = True
|
||||
out.append("<pre class=\"md-pre\">")
|
||||
else:
|
||||
in_code = False
|
||||
out.append("</pre>")
|
||||
i += 1
|
||||
continue
|
||||
if in_code:
|
||||
out.append(_html.escape(line))
|
||||
i += 1
|
||||
continue
|
||||
if line.startswith("|"):
|
||||
if not in_table:
|
||||
in_table = True
|
||||
buf = []
|
||||
if re.fullmatch(r"\|?[\s|:—-]+\|?", line) and line.count("|") > 1:
|
||||
# 分隔行,跳过
|
||||
i += 1
|
||||
continue
|
||||
buf.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if in_table:
|
||||
flush_table()
|
||||
in_table = False
|
||||
s = line.strip()
|
||||
if not s:
|
||||
out.append("")
|
||||
i += 1
|
||||
continue
|
||||
if s.startswith("### "):
|
||||
out.append(f"<h3>{_inline(s[4:])}</h3>")
|
||||
elif s.startswith("## "):
|
||||
out.append(f"<h2>{_inline(s[3:])}</h2>")
|
||||
elif s.startswith("# "):
|
||||
out.append(f"<h1>{_inline(s[2:])}</h1>")
|
||||
elif s.startswith("- "):
|
||||
out.append(f"<li>{_inline(s[2:])}</li>")
|
||||
elif re.match(r"^\d+\.\s", s):
|
||||
out.append(f"<li>{_inline(re.sub(r'^\d+\.\s', '', s))}</li>")
|
||||
else:
|
||||
out.append(f"<p>{_inline(s)}</p>")
|
||||
i += 1
|
||||
if in_code:
|
||||
out.append("</pre>")
|
||||
if in_table:
|
||||
flush_table()
|
||||
# 合并连续的 <li> 为 <ul>
|
||||
merged = []
|
||||
for ln in out:
|
||||
if ln.startswith("<li>") and merged and merged[-1].startswith("<li>"):
|
||||
merged[-1] = merged[-1] + ln
|
||||
elif ln.startswith("<li>"):
|
||||
merged.append(ln)
|
||||
else:
|
||||
merged.append(ln)
|
||||
merged = [("<ul>" + ln + "</ul>") if ln.startswith("<li>") else ln for ln in merged]
|
||||
return "\n".join(merged)
|
||||
|
||||
|
||||
@app.route("/api-doc")
|
||||
def api_doc():
|
||||
md = (BASE_DIR / "API.md").read_text(encoding="utf-8")
|
||||
body = _md_to_html(md)
|
||||
html = f"""<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>素材库系统 API 文档</title>
|
||||
<style>
|
||||
body{{font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;background:#0f1420;color:#e8edf7;line-height:1.75;max-width:960px;margin:0 auto;padding:28px 22px}}
|
||||
h1{{font-size:24px;border-bottom:1px solid #2a3550;padding-bottom:10px}}
|
||||
h2{{font-size:19px;margin-top:28px;color:#4f8cff}}
|
||||
h3{{font-size:15px;margin-top:20px;color:#a78bfa}}
|
||||
code{{background:#1e2740;padding:2px 6px;border-radius:6px;font-size:13px;color:#22d3a5}}
|
||||
pre.md-pre{{background:#0b0f19;border:1px solid #2a3550;border-radius:10px;padding:14px;overflow-x:auto;font-size:12.5px;color:#c9d4ea}}
|
||||
pre.md-pre code{{background:none;padding:0;color:#c9d4ea}}
|
||||
table.md-t{{border-collapse:collapse;width:100%;margin:10px 0;font-size:13px}}
|
||||
.md-t th{{background:#1e2740;text-align:left;padding:8px 10px;border:1px solid #2a3550}}
|
||||
.md-t td{{padding:8px 10px;border:1px solid #2a3550}}
|
||||
li{{margin:4px 0}}
|
||||
p{{margin:8px 0}}
|
||||
</style></head><body>{body}</body></html>"""
|
||||
return html
|
||||
|
||||
|
||||
@app.route("/files/<int:mid>")
|
||||
def serve_file(mid):
|
||||
db = get_db()
|
||||
|
||||
+106
-12
@@ -127,6 +127,21 @@ main{flex:1;padding:22px 26px;overflow-y:auto;max-height:calc(100vh - 59px)}
|
||||
.b-extracted{background:rgba(79,140,255,.12);color:#8ab4ff}
|
||||
.b-empty{color:var(--dim);background:var(--panel2)}
|
||||
.mat-name{font-weight:600;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.md-prev{background:#0b0f19;border:1px solid var(--line);border-radius:10px;padding:14px;max-height:380px;overflow-y:auto;font-size:13px;line-height:1.75;word-break:break-word}
|
||||
.md-prev h1,.md-prev h2,.md-prev h3{color:var(--acc);margin:10px 0 6px}
|
||||
.md-prev h1{font-size:20px}.md-prev h2{font-size:17px}.md-prev h3{font-size:15px}
|
||||
.md-prev pre{background:#141a2c;border:1px solid var(--line);border-radius:8px;padding:10px;overflow-x:auto}
|
||||
.md-prev code{background:#1e2740;padding:2px 5px;border-radius:5px;font-size:12px;color:#22d3a5}
|
||||
.md-prev pre code{background:none;padding:0}
|
||||
.md-prev table{border-collapse:collapse;margin:8px 0}
|
||||
.md-prev th,.md-prev td{border:1px solid var(--line);padding:6px 10px}
|
||||
.md-prev ul,.md-prev ol{padding-left:20px}
|
||||
.md-prev li{margin:3px 0}
|
||||
.btn.sm.active{background:rgba(79,140,255,.2);border-color:var(--acc);color:var(--acc)}
|
||||
@keyframes pulse{0%{transform:scale(1)}50%{transform:scale(1.06)}100%{transform:scale(1)}}
|
||||
.btn.pulse{animation:pulse .8s ease infinite}
|
||||
th.sortable{cursor:pointer;user-select:none;white-space:nowrap}
|
||||
th.sortable:hover{color:var(--acc)}
|
||||
.mat-meta{font-size:11px;color:var(--dim)}
|
||||
|
||||
/* 弹窗 */
|
||||
@@ -165,9 +180,11 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<body>
|
||||
<header>
|
||||
<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="window.open('/api-doc','_blank')" title="API 文档">📖 API</button>
|
||||
<button class="btn ghost" onclick="openSettings()" title="设置">⚙️ 设置</button>
|
||||
<div class="chips">
|
||||
<div class="chip"><b id="stProj">0</b>项目</div>
|
||||
@@ -225,16 +242,15 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="matModal" hidden>
|
||||
<div class="overlay" id="matModal" hidden onclick="if(event.target===this)closeMatModal()">
|
||||
<div class="modal wide">
|
||||
<div class="m-head"><h3 id="mmTitle">素材详情</h3><button class="x" onclick="closeModal('matModal')">✕</button></div>
|
||||
<div class="m-head"><h3 id="mmTitle">素材详情</h3><button class="x" onclick="closeMatModal()">✕</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>
|
||||
<button class="btn primary" onclick="closeMatModal()">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -283,11 +299,13 @@ pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;bord
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/lib/marked.min.js"></script>
|
||||
<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:'', selectedTags:[], projects:[], meta:null};
|
||||
let sortF=null, sortD=1; // 素材列表排序
|
||||
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);}
|
||||
@@ -385,7 +403,7 @@ async function openProject(id){
|
||||
|
||||
function renderProj(){
|
||||
const p=state.project;
|
||||
const mats=p.materials||[];
|
||||
const mats=sortedMats();
|
||||
const rows=mats.map(m=>{
|
||||
const meta=m.meta||{};
|
||||
const metaStr=[meta.width&&meta.height?meta.width+'×'+meta.height:null, meta.format?meta.format:null, m.ext?m.ext.toUpperCase():null].filter(Boolean).join(' · ');
|
||||
@@ -396,7 +414,9 @@ function renderProj(){
|
||||
return `<tr>
|
||||
<td><span class="type-ic ${TYPE_CLS[m.mtype]||TYPE_CLS.other}">${TYPE_IC[m.mtype]||TYPE_IC.other}</span></td>
|
||||
<td><div class="mat-name" title="${escQ(m.original_name||m.name)}">${esc(m.name)}</div>
|
||||
<div class="mat-meta">${metaStr}${metaStr&&m.file_size?' · ':''}${fmtSize(m.file_size)}</div></td>
|
||||
<div class="mat-meta">${metaStr}</div></td>
|
||||
<td>${fmtSize(m.file_size)}</td>
|
||||
<td>${esc((m.created_at||'').slice(0,16))}</td>
|
||||
<td><span class="badge ${txCls}">${txTxt}</span></td>
|
||||
<td><span class="badge ${stCls}">${stTxt}</span></td>
|
||||
<td style="white-space:nowrap">
|
||||
@@ -451,11 +471,35 @@ function renderProj(){
|
||||
</div>
|
||||
<div class="dash-head" style="margin-bottom:10px"><h2 style="font-size:16px">📄 素材列表 (${mats.length})</h2></div>
|
||||
<div style="overflow-x:auto">
|
||||
<table class="mtable"><thead><tr><th>类型</th><th>名称</th><th>文本抽取</th><th>AI状态</th><th>操作</th></tr></thead>
|
||||
<table class="mtable"><thead><tr>
|
||||
<th class="sortable" onclick="sortBy('mtype')">类型 ${sortIcon('mtype')}</th>
|
||||
<th class="sortable" onclick="sortBy('name')">名称 ${sortIcon('name')}</th>
|
||||
<th class="sortable" onclick="sortBy('file_size')">大小 ${sortIcon('file_size')}</th>
|
||||
<th class="sortable" onclick="sortBy('created_at')">创建时间 ${sortIcon('created_at')}</th>
|
||||
<th>文本抽取</th><th>AI状态</th><th>操作</th>
|
||||
</tr></thead>
|
||||
<tbody>${rows||'<tr><td colspan="5" style="text-align:center;color:var(--dim);padding:30px">暂无素材,快上传吧</td></tr>'}</tbody></table>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/* ---------- 素材列表排序 ---------- */
|
||||
function sortBy(field){
|
||||
if(sortF===field){sortD=-sortD;}
|
||||
else{sortF=field;sortD=1;}
|
||||
renderProj();
|
||||
}
|
||||
function sortedMats(){
|
||||
const arr=[...(state.project.materials||[])];
|
||||
if(!sortF)return arr;
|
||||
const d=sortD;
|
||||
arr.sort((a,b)=>{
|
||||
if(sortF==='file_size'){return ((a.file_size||0)-(b.file_size||0))*d;}
|
||||
return String(a[sortF]||'').localeCompare(String(b[sortF]||''),'zh')*d;
|
||||
});
|
||||
return arr;
|
||||
}
|
||||
function sortIcon(f){return sortF===f?(sortD===1?'▲':'▼'):'⇅';}
|
||||
|
||||
/* ---------- 项目 CRUD ---------- */
|
||||
let editProjectId=null;
|
||||
function openProjectModal(id){
|
||||
@@ -543,18 +587,25 @@ async function uploadFiles(files){
|
||||
}catch(e){toast(e.message,true);}
|
||||
}
|
||||
|
||||
/* ---------- 素材详情 ---------- */
|
||||
/* ---------- 素材详情(文本可直接编辑 + Markdown 切换 + 未保存提示) ---------- */
|
||||
let curMat=null;
|
||||
let matDirty=false; // 文本是否有未保存修改
|
||||
let mdMode=false; // 是否 Markdown 预览
|
||||
|
||||
async function openMat(id){
|
||||
curMat=await api('/api/materials/'+id);
|
||||
matDirty=false; mdMode=false;
|
||||
renderMatModal();
|
||||
}
|
||||
function renderMatModal(){
|
||||
const m=curMat, meta=m.meta||{}, a=m.analysis;
|
||||
const editable=!!(m.extracted_text!==undefined)&&(m.mtype==='text'||m.extracted_text);
|
||||
const media=m.file_path?(m.mtype==='image'?`<img class="media-prev" src="/files/${m.id}">`
|
||||
:m.mtype==='video'?`<video class="media-prev" controls src="/files/${m.id}"></video>`
|
||||
: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">
|
||||
@@ -563,6 +614,18 @@ async function openMat(id){
|
||||
<span>创建 <b>${esc(m.created_at)}</b></span>
|
||||
<span>AI状态 <b>${m.status}</b></span>
|
||||
</div>
|
||||
${editable?`<div class="section">
|
||||
<h5>📄 原始文本 <span style="font-weight:400;font-size:12px;color:var(--dim)">(可直接编辑,点保存后生效)</span>
|
||||
<span style="float:right;display:inline-flex;gap:6px">
|
||||
<button class="btn ghost sm ${!mdMode?'active':''}" onclick="toggleMd(false)">纯文本</button>
|
||||
<button class="btn ghost sm ${mdMode?'active':''}" onclick="toggleMd(true)">Markdown</button>
|
||||
<button class="btn green sm" id="txtSave" onclick="saveMat()">💾 保存</button>
|
||||
</span>
|
||||
</h5>
|
||||
${mdMode
|
||||
? `<div class="md-prev">${marked.parse(esc(m.extracted_text||''))}</div><div style="font-size:11px;color:var(--dim);margin-top:6px">Markdown 预览(只读),编辑请切换回「纯文本」</div>`
|
||||
: `<textarea id="txtEdit" style="min-height:220px;width:100%;line-height:1.7" placeholder="在此直接编辑文本内容…" oninput="onTextEdit()">${esc(m.extracted_text||'')}</textarea>`}
|
||||
</div>`:''}
|
||||
${a?`<div class="section"><h5>🤖 AI 分析结果</h5>
|
||||
${a.detail&&a.detail.vision?`<div class="vision">👁️ 视觉描述:${esc(a.detail.vision)}</div>`:''}
|
||||
<div class="kv"><b>归类</b><span>${esc(a.category||'-')}</span><b>摘要</b><span style="line-height:1.7">${esc(a.summary)}</span></div>
|
||||
@@ -571,13 +634,43 @@ async function openMat(id){
|
||||
<div style="margin-top:10px"><b style="color:var(--dim);font-size:12px">推荐标签</b><div class="kw-tags" style="margin-top:6px">${(a.tags||[]).map(k=>`<span style="color:var(--purple)">${esc(k)}</span>`).join('')}</div></div>
|
||||
</div>`:
|
||||
`<div class="section" style="color:var(--dim);text-align:center">该素材还未进行 AI 分析<br><button class="btn green sm" style="margin-top:10px" onclick="analyzeOne(${m.id})">🤖 立即分析</button></div>`}
|
||||
<div class="section"><h5>📄 文本内容 ${m.text_status==='extracted'?'':'('+m.text_status+')'}</h5>
|
||||
${!editable?`<div class="section"><h5>📄 文本内容 ${m.text_status==='extracted'?'':'('+m.text_status+')'}</h5>
|
||||
${m.extracted_text?`<pre class="text-prev">${esc(m.extracted_text)}</pre>`:`<div style="color:var(--dim);font-size:13px">${m.mtype==='image'?'图片素材无文本内容(已通过视觉模型理解内容)':m.mtype==='video'?'视频素材暂未做语音转写,可结合文件名理解':'该素材无可提取文本'}</div>`}
|
||||
</div>`;
|
||||
</div>`:''}`;
|
||||
openModal('matModal');
|
||||
}
|
||||
function toggleMd(on){mdMode=on;renderMatModal();}
|
||||
function onTextEdit(){
|
||||
matDirty=true;
|
||||
const b=$('txtSave');
|
||||
if(b){b.textContent='💾 保存修改';b.classList.add('pulse');}
|
||||
}
|
||||
async function saveMat(){
|
||||
const ta=$('txtEdit');
|
||||
if(!ta)return;
|
||||
const content=ta.value;
|
||||
try{
|
||||
await api('/api/materials/'+curMat.id,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify({content})});
|
||||
matDirty=false;
|
||||
toast('已保存(原 AI 分析已作废,可重新分析)');
|
||||
curMat=await api('/api/materials/'+curMat.id);
|
||||
renderMatModal();
|
||||
if(state.view==='proj'&&state.project) await openProject(state.project.id);
|
||||
}catch(e){toast(e.message,true);}
|
||||
}
|
||||
async function closeMatModal(){
|
||||
if(matDirty){
|
||||
const ok=confirm('文本内容有未保存的修改,是否保存?\n确定 = 保存并关闭\n取消 = 放弃修改');
|
||||
if(ok){
|
||||
try{await saveMat();}catch(e){toast(e.message,true);return;}
|
||||
}
|
||||
}
|
||||
matDirty=false;
|
||||
closeModal('matModal');
|
||||
}
|
||||
async function analyzeOne(id){
|
||||
const mid=id||curMat.id;
|
||||
if(matDirty){try{await saveMat();}catch(e){toast(e.message,true);return;}}
|
||||
try{
|
||||
await api('/api/materials/'+mid+'/analyze',{method:'POST'});
|
||||
toast('AI 分析已启动…');
|
||||
@@ -603,9 +696,10 @@ async function genSummary(){
|
||||
function download(id){window.open('/files/'+id,'_blank');}
|
||||
async function delMat(id){
|
||||
if(!confirm('确定删除该素材?'))return;
|
||||
try{await api('/api/materials/'+id,{method:'DELETE'});toast('已删除');if(curMat&&curMat.id===id)closeModal('matModal');await openProject(state.project.id);await loadMeta();}
|
||||
try{await api('/api/materials/'+id,{method:'DELETE'});toast('已删除');if(curMat&&curMat.id===id){matDirty=false;closeModal('matModal');}await openProject(state.project.id);await loadMeta();}
|
||||
catch(e){toast(e.message,true);}
|
||||
}
|
||||
function deleteMaterial(){if(curMat)delMat(curMat.id);}
|
||||
|
||||
/* ---------- 一键复制 ---------- */
|
||||
function copyToClipboard(text){
|
||||
|
||||
Vendored
+6
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user