v1.2.0: 素材详情文本直接编辑+未保存提示+Markdown切换 + 素材列表显示创建时间/可排序 + API文档(/api-doc + API.md)

This commit is contained in:
2026-08-27 17:10:35 +08:00
parent cc6735dfe9
commit 222564f0da
5 changed files with 471 additions and 13 deletions
+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