2 Commits
5 changed files with 477 additions and 18 deletions
+232
View File
@@ -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
```
+8 -1
View File
@@ -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
- LLMDeepSeek `deepseek-v4-flash`(结构化 JSON 分析)+ 豆包 `doubao-seed-evolving`(图片视觉描述)
## API 文档
- 网页版:`http://<IP>:16091/api-doc`(右上角 📖 按钮)
- 完整文档见 `API.md`(含全部接口、参数、响应、curl 示例)
## 快速开始
```bash
./start.sh # 启动 (默认 16091)
+125 -5
View File
@@ -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
View File
@@ -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){
+6
View File
File diff suppressed because one or more lines are too long