v1.1.0: 设置面板+双触发自动备份+恢复 + 标签多选 + 摘要历史 + 文本编辑/一键复制 + 粘贴图片上传 + Logo回首页

This commit is contained in:
2026-08-27 15:59:12 +08:00
parent d682952f41
commit 82c0d51671
3 changed files with 581 additions and 40 deletions
+208 -13
View File
@@ -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>