Files
material-library/static/index.html
T

568 lines
34 KiB
HTML
Raw Permalink Normal View History

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>素材库系统</title>
<style>
:root{
--bg:#0f1420; --panel:#171e2e; --panel2:#1e2740; --line:#2a3550;
--txt:#e8edf7; --dim:#8b97b3; --acc:#4f8cff; --acc2:#22d3a5;
--warn:#f5a623; --danger:#ff5f7a; --purple:#a78bfa;
}
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:"PingFang SC","Microsoft YaHei",system-ui,sans-serif;background:var(--bg);color:var(--txt);min-height:100vh}
a{color:var(--acc);text-decoration:none}
::-webkit-scrollbar{width:8px;height:8px}
::-webkit-scrollbar-thumb{background:#2c3856;border-radius:4px}
::-webkit-scrollbar-track{background:transparent}
/* 顶部 */
header{position:sticky;top:0;z-index:50;display:flex;align-items:center;gap:18px;padding:12px 22px;background:rgba(15,20,32,.92);backdrop-filter:blur(8px);border-bottom:1px solid var(--line)}
.logo{display:flex;align-items:center;gap:10px;font-size:19px;font-weight:700;white-space:nowrap}
.logo .ic{width:34px;height:34px;border-radius:9px;background:linear-gradient(135deg,#4f8cff,#7c5cff);display:flex;align-items:center;justify-content:center;font-size:18px}
.search{flex:1;max-width:560px;position:relative}
.search input{width:100%;padding:9px 14px 9px 38px;border-radius:10px;border:1px solid var(--line);background:var(--panel2);color:var(--txt);font-size:14px;outline:none}
.search input:focus{border-color:var(--acc)}
.search .s-ic{position:absolute;left:12px;top:50%;transform:translateY(-50%);opacity:.6}
.chips{display:flex;gap:8px;margin-left:auto}
.chip{background:var(--panel2);border:1px solid var(--line);border-radius:10px;padding:6px 12px;font-size:12px;color:var(--dim)}
.chip b{color:var(--acc);font-size:14px;margin-right:2px}
.chip.g b{color:var(--acc2)}
/* 布局 */
.layout{display:flex;min-height:calc(100vh - 59px)}
aside{width:280px;flex-shrink:0;border-right:1px solid var(--line);padding:16px;overflow-y:auto;max-height:calc(100vh - 59px)}
main{flex:1;padding:22px 26px;overflow-y:auto;max-height:calc(100vh - 59px)}
@media(max-width:900px){aside{display:none}}
/* 侧栏 */
.side-title{font-size:12px;color:var(--dim);margin:16px 0 8px;text-transform:uppercase;letter-spacing:1px}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;border:none;border-radius:10px;cursor:pointer;font-size:13px;font-weight:600;padding:9px 14px;transition:.15s;color:var(--txt)}
.btn:disabled{opacity:.5;cursor:not-allowed}
.btn.primary{background:linear-gradient(135deg,#4f8cff,#7c5cff)}
.btn.primary:hover{filter:brightness(1.12)}
.btn.ghost{background:var(--panel2);border:1px solid var(--line)}
.btn.ghost:hover{border-color:var(--acc)}
.btn.green{background:linear-gradient(135deg,#22d3a5,#0ea5e9)}
.btn.danger{background:rgba(255,95,122,.15);color:var(--danger);border:1px solid rgba(255,95,122,.4)}
.btn.sm{padding:5px 10px;font-size:12px;border-radius:8px}
.btn.block{width:100%}
.filter-item{display:flex;align-items:center;gap:8px;padding:7px 10px;border-radius:9px;cursor:pointer;font-size:13px;color:var(--dim);transition:.12s}
.filter-item:hover{background:var(--panel2);color:var(--txt)}
.filter-item.active{background:rgba(79,140,255,.14);color:var(--acc)}
.filter-item .n{margin-left:auto;font-size:11px;background:var(--panel2);padding:1px 7px;border-radius:8px}
.proj-item{display:flex;flex-direction:column;gap:3px;padding:9px 10px;border-radius:10px;cursor:pointer;border:1px solid transparent;transition:.12s}
.proj-item:hover{background:var(--panel2)}
.proj-item.active{background:rgba(79,140,255,.14);border-color:rgba(79,140,255,.4)}
.proj-item .t{font-size:13px;font-weight:600;display:flex;gap:6px;align-items:center}
.proj-item .m{font-size:11px;color:var(--dim)}
.tag-cloud{display:flex;flex-wrap:wrap;gap:6px}
.tag-chip{font-size:11px;padding:3px 9px;border-radius:8px;background:var(--panel2);border:1px solid var(--line);color:var(--dim);cursor:pointer;transition:.12s}
.tag-chip:hover{color:var(--acc);border-color:var(--acc)}
.tag-chip.active{background:rgba(167,139,250,.16);color:var(--purple);border-color:var(--purple)}
/* 仪表盘 */
.dash-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-wrap:wrap;gap:10px}
.dash-head h2{font-size:20px}
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:20px}
.stat{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px}
.stat .v{font-size:26px;font-weight:700}
.stat .l{font-size:12px;color:var(--dim);margin-top:2px}
.card-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:14px}
.card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px;cursor:pointer;transition:.15s;position:relative}
.card:hover{border-color:var(--acc);transform:translateY(-2px);box-shadow:0 8px 24px rgba(0,0,0,.3)}
.card .cat{position:absolute;top:14px;right:14px;font-size:11px;background:rgba(79,140,255,.15);color:var(--acc);padding:3px 9px;border-radius:8px}
.card h3{font-size:15px;margin-bottom:6px;padding-right:70px}
.card .desc{font-size:12px;color:var(--dim);min-height:34px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;margin-bottom:10px}
.card .tags{display:flex;flex-wrap:wrap;gap:5px;margin-bottom:12px}
.card .tags span{font-size:11px;padding:2px 8px;border-radius:7px;background:var(--panel2);color:var(--purple);border:1px solid var(--line)}
.card .foot{display:flex;align-items:center;gap:14px;font-size:12px;color:var(--dim)}
.card .foot .bar{flex:1;height:5px;background:var(--panel2);border-radius:3px;overflow:hidden}
.card .foot .bar i{display:block;height:100%;background:linear-gradient(90deg,#22d3a5,#4f8cff);border-radius:3px}
.card .sum{font-size:12px;color:var(--dim);background:var(--panel2);border-radius:9px;padding:8px 10px;margin-bottom:10px;border-left:3px solid var(--acc2)}
.card .acts{display:flex;gap:6px;margin-top:12px}
.card .acts .btn{pointer-events:auto}
/* 项目详情 */
.pd-head{display:flex;align-items:flex-start;gap:16px;margin-bottom:18px;flex-wrap:wrap}
.pd-head .ttl{flex:1;min-width:260px}
.pd-head h2{font-size:22px;display:flex;align-items:center;gap:10px}
.pd-head .cat{font-size:12px;background:rgba(79,140,255,.15);color:var(--acc);padding:3px 10px;border-radius:8px}
.pd-head .tags{margin-top:8px;display:flex;flex-wrap:wrap;gap:6px}
.pd-head .tags span{font-size:12px;padding:3px 10px;border-radius:8px;background:var(--panel2);color:var(--purple);border:1px solid var(--line)}
.pd-head .desc{color:var(--dim);font-size:13px;margin-top:8px}
.pd-acts{display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start;max-width:520px}
.summary-card{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:18px;margin-bottom:16px}
.summary-card h4{font-size:14px;margin-bottom:10px;display:flex;align-items:center;gap:8px}
.summary-card .grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:800px){.summary-card .grid{grid-template-columns:1fr}}
.sum-card .lab{font-size:11px;color:var(--dim);margin-bottom:4px}
.kp li{margin:4px 0 4px 16px;font-size:13px}
.kw-tags{display:flex;flex-wrap:wrap;gap:6px}
.kw-tags span{font-size:12px;background:var(--panel2);border:1px solid var(--line);padding:3px 10px;border-radius:8px;color:var(--acc2)}
.upload-zone{border:2px dashed var(--line);border-radius:14px;padding:26px;text-align:center;color:var(--dim);margin-bottom:16px;cursor:pointer;transition:.15s;background:var(--panel)}
.upload-zone:hover,.upload-zone.drag{border-color:var(--acc);background:rgba(79,140,255,.06)}
.upload-zone .big{font-size:30px;margin-bottom:6px}
.upload-zone p{font-size:13px}
.upload-zone .hint{font-size:11px;margin-top:4px;opacity:.7}
/* 表格 */
.mtable{width:100%;border-collapse:collapse;font-size:13px}
.mtable th{text-align:left;padding:9px 10px;color:var(--dim);font-size:12px;border-bottom:1px solid var(--line);white-space:nowrap}
.mtable td{padding:9px 10px;border-bottom:1px solid rgba(42,53,80,.6);vertical-align:middle}
.mtable tr:hover td{background:rgba(79,140,255,.05)}
.type-ic{width:30px;height:30px;border-radius:8px;display:inline-flex;align-items:center;justify-content:center;font-size:14px}
.t-text{background:rgba(34,211,165,.14);color:#22d3a5}
.t-doc{background:rgba(79,140,255,.15);color:#4f8cff}
.t-image{background:rgba(245,166,35,.16);color:#f5a623}
.t-video{background:rgba(255,95,122,.15);color:#ff5f7a}
.t-audio{background:rgba(167,139,250,.16);color:#a78bfa}
.t-other{background:var(--panel2);color:var(--dim)}
.badge{font-size:11px;padding:2px 8px;border-radius:8px;white-space:nowrap}
.b-analyzed{background:rgba(34,211,165,.14);color:#22d3a5}
.b-uploaded{background:rgba(245,166,35,.15);color:#f5a623}
.b-analyzing{background:rgba(79,140,255,.15);color:#4f8cff}
.b-error{background:rgba(255,95,122,.15);color:#ff5f7a}
.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}
.mat-meta{font-size:11px;color:var(--dim)}
/* 弹窗 */
.overlay{position:fixed;inset:0;background:rgba(5,8,15,.7);z-index:100;display:none;align-items:center;justify-content:center;padding:20px}
.overlay[hidden]{display:none}
.overlay.show{display:flex}
.modal{background:var(--panel);border:1px solid var(--line);border-radius:16px;width:100%;max-width:680px;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}
.modal.wide{max-width:860px}
.m-head{padding:16px 20px;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between}
.m-head h3{font-size:16px}
.m-body{padding:18px 20px;overflow-y:auto}
.m-foot{padding:14px 20px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:10px}
.x{background:none;border:none;color:var(--dim);font-size:20px;cursor:pointer;padding:4px}
.x:hover{color:var(--txt)}
label{display:block;font-size:12px;color:var(--dim);margin:12px 0 6px}
input[type=text],textarea,select{width:100%;padding:9px 12px;border-radius:10px;border:1px solid var(--line);background:var(--panel2);color:var(--txt);font-size:13px;outline:none;font-family:inherit}
input:focus,textarea:focus,select:focus{border-color:var(--acc)}
textarea{resize:vertical;min-height:120px;line-height:1.6}
.two{display:grid;grid-template-columns:1fr 1fr;gap:14px}
pre.text-prev{white-space:pre-wrap;word-break:break-word;background:#0b0f19;border:1px solid var(--line);border-radius:10px;padding:14px;max-height:320px;overflow-y:auto;font-size:12.5px;line-height:1.7;color:#c9d4ea;margin-top:8px}
.section{background:var(--panel2);border:1px solid var(--line);border-radius:12px;padding:14px;margin-bottom:12px}
.section h5{font-size:13px;margin-bottom:8px;color:var(--acc);display:flex;align-items:center;gap:6px}
.mdl-row{display:flex;gap:14px;flex-wrap:wrap;font-size:12px;color:var(--dim);margin-bottom:12px}
.mdl-row b{color:var(--txt)}
.vision{background:rgba(167,139,250,.1);border:1px solid rgba(167,139,250,.3);border-radius:10px;padding:10px;font-size:12.5px;color:#d8ccff;margin-bottom:10px}
.media-prev{max-width:100%;max-height:300px;border-radius:10px;border:1px solid var(--line);margin-bottom:10px;display:block}
.progress{height:6px;background:var(--panel2);border-radius:3px;overflow:hidden;margin-top:10px}
.progress i{display:block;height:100%;background:linear-gradient(90deg,#22d3a5,#4f8cff);width:0%;transition:.3s}
.empty{text-align:center;color:var(--dim);padding:60px 20px}
.empty .big{font-size:40px;margin-bottom:10px}
.loading{text-align:center;color:var(--dim);padding:40px}
.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:#1e2740;border:1px solid var(--acc);color:var(--txt);padding:11px 20px;border-radius:12px;z-index:200;font-size:13px;box-shadow:0 8px 30px rgba(0,0,0,.4);display:none}
.toast.err{border-color:var(--danger)}
.kv{display:grid;grid-template-columns:auto 1fr;gap:4px 14px;font-size:12.5px}
.kv b{color:var(--dim);font-weight:400}
</style>
</head>
<body>
<header>
<div class="logo"><div class="ic">🗂️</div>素材库系统</div>
<div class="search"><span class="s-ic">🔍</span><input id="searchInput" placeholder="搜索项目、素材内容、关键词… (回车搜索)" value=""></div>
<div class="chips">
<div class="chip"><b id="stProj">0</b>项目</div>
<div class="chip"><b id="stMat">0</b>素材</div>
<div class="chip g"><b id="stAna">0</b>已分析</div>
</div>
</header>
<div class="layout">
<aside>
<button class="btn primary block" onclick="openProjectModal()"> 新建项目</button>
<div class="side-title">分类</div>
<div id="catList"><div class="filter-item active" data-cat="" onclick="setCat('')"><span>📂</span>全部项目<span class="n" id="allCatN">0</span></div></div>
<div class="side-title">标签</div>
<div class="tag-cloud" id="tagCloud"></div>
<div class="side-title">项目列表</div>
<div id="projList"></div>
</aside>
<main id="main"></main>
</div>
<!-- 弹窗模板 -->
<div class="overlay" id="projectModal" hidden>
<div class="modal">
<div class="m-head"><h3 id="pmTitle">新建项目</h3><button class="x" onclick="closeModal('projectModal')"></button></div>
<div class="m-body">
<label>项目名称 *</label>
<input type="text" id="pmName" placeholder="如:新能源汽车市场调研">
<div class="two">
<div><label>类别</label><input type="text" id="pmCategory" list="catDatalist" placeholder="如:市场资讯"><datalist id="catDatalist"></datalist></div>
<div><label>标签(逗号分隔)</label><input type="text" id="pmTags" placeholder="如:调研, 行业, 2026"></div>
</div>
<label>项目描述</label>
<textarea id="pmDesc" placeholder="这个项目是关于什么的?"></textarea>
</div>
<div class="m-foot">
<button class="btn ghost" onclick="closeModal('projectModal')">取消</button>
<button class="btn primary" id="pmSave" onclick="saveProject()">保存</button>
</div>
</div>
</div>
<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-body">
<label>素材名称</label>
<input type="text" id="tmName" placeholder="如:政策解读笔记">
<label>内容(支持粘贴任意文字 / 富文本转纯文本)</label>
<textarea id="tmContent" placeholder="粘贴或输入文本内容…" style="min-height:220px"></textarea>
</div>
<div class="m-foot">
<button class="btn ghost" onclick="closeModal('textModal')">取消</button>
<button class="btn primary" onclick="saveTextMaterial()">保存</button>
</div>
</div>
</div>
<div class="overlay" id="matModal" hidden>
<div class="modal wide">
<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 danger" onclick="deleteMaterial()">删除</button>
<button class="btn green" id="mmAnalyze" onclick="analyzeOne()">🤖 AI 分析</button>
<button class="btn primary" onclick="closeModal('matModal')">关闭</button>
</div>
</div>
</div>
<div class="toast" id="toast"></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 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);}
function closeModal(id){$(id).classList.remove('show');$(id).hidden=true;}
function openModal(id){$(id).hidden=false;requestAnimationFrame(()=>$(id).classList.add('show'));}
function fmtSize(n){if(!n)return '-';if(n<1024)return n+' B';if(n<1048576)return (n/1024).toFixed(1)+' KB';return (n/1048576).toFixed(2)+' MB';}
function esc(s){return (s==null?'':String(s)).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
function escQ(s){return esc(s).replace(/"/g,'&quot;');}
async function api(url,opt){
const r=await fetch(url,opt);
const d=await r.json().catch(()=>({}));
if(!r.ok && d.error) throw new Error(d.error);
if(!r.ok) throw new Error('请求失败 '+r.status);
return d;
}
/* ---------- 加载元数据 & 项目列表 ---------- */
async function loadMeta(){
state.meta=await api('/api/meta');
$('stProj').textContent=state.meta.stats.projects;
$('stMat').textContent=state.meta.stats.materials;
$('stAna').textContent=state.meta.stats.analyzed;
// 分类
$('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('');
// 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);
state.projects=await api(u);
renderProjList();
}
function renderProjList(){
$('projList').innerHTML=state.projects.length?state.projects.map(p=>
'<div class="proj-item '+(state.view==='proj'&&state.project&&state.project.id===p.id?'active':'')+'" onclick="openProject('+p.id+')">'+
'<div class="t">'+esc(p.name)+'</div><div class="m">'+p.material_count+' 素材 · '+p.category+'</div></div>'
).join(''):'<div style="color:var(--dim);font-size:12px;padding:8px">暂无项目</div>';
}
/* ---------- 视图 ---------- */
function setCat(c){state.cat=c;state.view='dash';loadProjects().then(renderDash);}
function setTag(t){state.tag=t;state.view='dash';loadProjects().then(renderDash);}
async function renderDash(){
state.view='dash';
await loadMeta();
await loadProjects();
const s=state.meta.stats;
const cards=state.projects.map(p=>{
const pct=s.projects&&state.cat===''?Math.round(p.analyzed_count/p.material_count*100||0):(p.material_count?Math.round(p.analyzed_count/p.material_count*100):0);
return `<div class="card" onclick="openProject(${p.id})">
<span class="cat">${esc(p.category)}</span>
<h3>${esc(p.name)}</h3>
<div class="desc">${esc(p.description||'暂无描述')}</div>
${p.analysis?`<div class="sum">📌 ${esc(p.analysis.summary||'')}</div>`:''}
<div class="tags">${(p.tags||'').split(',').filter(x=>x.trim()).slice(0,4).map(t=>`<span>#${esc(t.trim())}</span>`).join('')}</div>
<div class="foot"><span>📄 ${p.material_count}</span><span>🤖 ${p.analyzed_count} 已分析</span>
<div class="bar"><i style="width:${pct}%"></i></div></div>
<div class="acts" onclick="event.stopPropagation()">
<button class="btn ghost sm" onclick="openProject(${p.id})">打开</button>
<button class="btn ghost sm" onclick="openProjectModal(${p.id})">编辑</button>
<button class="btn danger sm" onclick="delProject(${p.id})">删除</button>
</div>
</div>`;
}).join('');
$('main').innerHTML=`
<div class="dash-head"><h2>📊 素材库概览${state.cat?' · '+esc(state.cat):''}${state.tag?' · #'+esc(state.tag):''}</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>
<div class="stat"><div class="v">${s.materials}</div><div class="l">素材总数</div></div>
<div class="stat"><div class="v" style="color:var(--acc2)">${s.analyzed}</div><div class="l">已 AI 分析</div></div>
</div>
${state.projects.length?`<div class="card-grid">${cards}</div>`:
`<div class="empty"><div class="big">🗂️</div>还没有项目<br>点击「新建项目」开始建立你的素材库</div>`}`;
}
async function openProject(id){
state.project=await api('/api/projects/'+id);
state.view='proj';
renderProj();
}
function renderProj(){
const p=state.project;
const mats=p.materials||[];
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(' · ');
const stCls={analyzed:'b-analyzed',uploaded:'b-uploaded',analyzing:'b-analyzing',error:'b-error'}[m.status]||'b-uploaded';
const stTxt={analyzed:'✅ 已分析',uploaded:'待分析',analyzing:'⏳ 分析中',error:'❌ 失败'}[m.status]||m.status;
const txCls={extracted:'b-extracted',empty:'b-empty',error:'b-error'}[m.text_status]||'b-empty';
const txTxt={extracted:'已抽取',empty:'无文本',error:'失败'}[m.text_status]||m.text_status;
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>
<td><span class="badge ${txCls}">${txTxt}</span></td>
<td><span class="badge ${stCls}">${stTxt}</span></td>
<td style="white-space:nowrap">
<button class="btn ghost sm" onclick="openMat(${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>
</td>
</tr>`;
}).join('');
const sum=p.analysis;
$('main').innerHTML=`
<div class="pd-head">
<div class="ttl">
<h2>${esc(p.name)} <span class="cat">${esc(p.category)}</span></h2>
<div class="tags">${(p.tags||'').split(',').filter(x=>x.trim()).map(t=>`<span>#${esc(t.trim())}</span>`).join('')}</div>
<div class="desc">${esc(p.description||'暂无描述')}</div>
<div class="mdl-row" style="margin-top:6px">创建于 <b>${esc(p.created_at)}</b> · 更新于 <b>${esc(p.updated_at)}</b></div>
</div>
<div class="pd-acts">
<button class="btn ghost" onclick="openProjectModal(${p.id})">✏️ 编辑项目</button>
<button class="btn ghost" onclick="openTextModal()">📝 新增文本</button>
<button class="btn primary" onclick="$('fileInput').click()">📤 上传素材</button>
<button class="btn green" onclick="analyzeAll()">🤖 分析全部</button>
<button class="btn ghost" onclick="genSummary()">📋 生成项目摘要</button>
<button class="btn ghost" onclick="renderDash()">← 返回</button>
</div>
<input type="file" id="fileInput" multiple hidden onchange="uploadFiles(this.files)">
</div>
${sum?`<div class="summary-card">
<h4>🧠 项目 AI 摘要 <span style="font-weight:400;color:var(--dim);font-size:12px">(${esc(sum.category||'')})</span></h4>
<div class="grid">
<div class="sum-card"><div class="lab">摘要</div><div style="font-size:13px;line-height:1.7">${esc(sum.summary)}</div>
<div class="lab" style="margin-top:10px">关键词</div><div class="kw-tags">${(sum.keywords||[]).map(k=>`<span>${esc(k)}</span>`).join('')}</div></div>
<div class="sum-card"><div class="lab">关键要点</div><ul class="kp">${(sum.key_points||[]).map(k=>`<li>${esc(k)}</li>`).join('')||'<li style="color:var(--dim)">暂无</li>'}</ul>
<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>`:''}
<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>
</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>
<tbody>${rows||'<tr><td colspan="5" style="text-align:center;color:var(--dim);padding:30px">暂无素材,快上传吧</td></tr>'}</tbody></table>
</div>`;
}
/* ---------- 项目 CRUD ---------- */
let editProjectId=null;
function openProjectModal(id){
editProjectId=id||null;
$('pmTitle').textContent=id?'编辑项目':'新建项目';
$('pmName').value='';$('pmCategory').value='';$('pmTags').value='';$('pmDesc').value='';
if(id){
const p=state.projects.find(x=>x.id===id)||state.project;
if(p){$('pmName').value=p.name;$('pmCategory').value=p.category;$('pmTags').value=p.tags;$('pmDesc').value=p.description||'';}
}
openModal('projectModal');
}
async function saveProject(){
const body={name:$('pmName').value.trim(),category:$('pmCategory').value.trim(),tags:$('pmTags').value.trim(),description:$('pmDesc').value.trim()};
if(!body.name){toast('请填写项目名称',true);return;}
try{
if(editProjectId){await api('/api/projects/'+editProjectId,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('项目已更新');}
else{await api('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});toast('项目已创建');}
closeModal('projectModal');
await loadMeta();await loadProjects();
if(editProjectId) await openProject(editProjectId); else renderDash();
}catch(e){toast(e.message,true);}
}
async function delProject(id){
if(!confirm('确定删除该项目及其所有素材?此操作不可恢复!'))return;
try{await api('/api/projects/'+id,{method:'DELETE'});toast('已删除');state.project=null;await loadMeta();await loadProjects();renderDash();}
catch(e){toast(e.message,true);}
}
/* ---------- 文本素材 ---------- */
function openTextModal(){$('tmName').value='';$('tmContent').value='';openModal('textModal');}
async function saveTextMaterial(){
const pid=state.project.id;
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();
}catch(e){toast(e.message,true);}
}
/* ---------- 上传 ---------- */
function dropFiles(e){e.preventDefault();e.target.classList.remove('drag');uploadFiles(e.dataTransfer.files);}
async function uploadFiles(files){
const pid=state.project.id;
const fd=new FormData();
let n=0;
for(const f of files){fd.append('files',f);n++;}
if(!n)return;
toast('上传中…');
try{
const r=await api(`/api/projects/${pid}/materials/upload`,{method:'POST',body:fd});
const ok=r.results.filter(x=>x.ok!==false).length;
const errs=r.results.filter(x=>x.ok===false);
toast(`上传完成:成功 ${ok}${errs.length ? `,失败 ${errs.length} 个` : ''}`);
if(errs.length)console.warn(errs);
await openProject(pid);await loadMeta();
}catch(e){toast(e.message,true);}
}
/* ---------- 素材详情 ---------- */
let curMat=null;
async function openMat(id){
curMat=await api('/api/materials/'+id);
const m=curMat, meta=m.meta||{}, a=m.analysis;
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;
$('mmBody').innerHTML=`
${media}
<div class="mdl-row">
<span>类型 <b>${m.mtype}</b></span><span>大小 <b>${fmtSize(m.file_size)}</b></span>
${meta.width?`<span>分辨率 <b>${meta.width}×${meta.height}</b></span>`:''}
<span>创建 <b>${esc(m.created_at)}</b></span>
<span>AI状态 <b>${m.status}</b></span>
</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>
<div style="margin-top:10px"><b style="color:var(--dim);font-size:12px">关键要点</b><ul class="kp">${(a.key_points||[]).map(k=>`<li>${esc(k)}</li>`).join('')||'<li style="color:var(--dim)">—</li>'}</ul></div>
<div style="margin-top:10px"><b style="color:var(--dim);font-size:12px">关键词</b><div class="kw-tags" style="margin-top:6px">${(a.keywords||[]).map(k=>`<span>${esc(k)}</span>`).join('')}</div></div>
<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>
${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>`;
openModal('matModal');
}
async function analyzeOne(id){
const mid=id||curMat.id;
try{
await api('/api/materials/'+mid+'/analyze',{method:'POST'});
toast('AI 分析已启动…');
closeModal('matModal');
startPoll('material:'+state.project.id,()=>openProject(state.project.id));
}catch(e){toast(e.message,true);}
}
async function analyzeAll(){
if(!confirm(`将对项目「${state.project.name}」中所有待分析素材运行 AI 分析,继续?`))return;
try{
await api(`/api/projects/${state.project.id}/analyze`,{method:'POST'});
toast('AI 分析已启动,请稍候…');
startPoll('material:'+state.project.id,()=>openProject(state.project.id));
}catch(e){toast(e.message,true);}
}
async function genSummary(){
try{
await api(`/api/projects/${state.project.id}/summary`,{method:'POST'});
toast('项目摘要生成中…');
startPoll('summary:'+state.project.id,()=>openProject(state.project.id));
}catch(e){toast(e.message,true);}
}
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();}
catch(e){toast(e.message,true);}
}
/* ---------- 任务轮询 ---------- */
function startPoll(key,onDone){
if(polling[key])return;
polling[key]=true;
const iv=setInterval(async()=>{
try{
const t=await api('/api/tasks/'+key);
if(t.running){/* 静默等待 */}
else{
clearInterval(iv);delete polling[key];
if(t.error)toast('分析出错:'+t.error,true);
else if(onDone)onDone();
toast(t.msg||'完成');
}
}catch(e){clearInterval(iv);delete polling[key];}
},2000);
}
/* ---------- 搜索 ---------- */
async function doSearch(q){
if(!q.trim()){renderDash();return;}
const d=await api('/api/search?q='+encodeURIComponent(q));
const pr=d.projects.map(p=>`<div class="card" onclick="openProject(${p.id})">
<span class="cat">${esc(p.category)}</span><h3>${esc(p.name)}</h3>
<div class="desc">${esc(p.description||'')}</div>
<div class="foot"><span>📄 ${p.material_count}</span><span>🤖 ${p.analyzed_count} 已分析</span>
<div class="bar"><i style="width:${p.material_count?Math.round(p.analyzed_count/p.material_count*100):0}%"></i></div></div></div>`).join('');
const mr=d.materials.map(m=>{
const hit=m.analysis?(m.analysis.summary||''):(m.extracted_text||'').slice(0,160);
return `<div class="card" onclick="openMat(${m.id})">
<span class="cat">${TYPE_IC[m.mtype]||''} ${esc(m.mtype)}</span>
<h3>${esc(m.name)}</h3>
<div class="desc">${esc(hit)}</div>
<div class="foot"><span>📄 项目 ${m.project_id}</span><span>${m.status==='analyzed'?'✅ 已分析':'待分析'}</span></div></div>`;
}).join('');
$('main').innerHTML=`<div class="dash-head"><h2>🔍 搜索「${esc(q)}」</h2><button class="btn ghost" onclick="renderDash()">← 返回</button></div>
<div class="dash-head" style="margin-bottom:10px"><h2 style="font-size:16px">项目匹配 (${d.projects.length})</h2></div>
${pr||'<div style="color:var(--dim);font-size:13px;margin-bottom:20px">无匹配项目</div>'}
<div class="dash-head" style="margin-bottom:10px"><h2 style="font-size:16px">素材匹配 (${d.materials.length})</h2></div>
<div class="card-grid">${mr||'<div style="color:var(--dim);font-size:13px">无匹配素材</div>'}</div>`;
// 在项目视图里打开素材时,需要知道 project id —— openMat 不依赖 state.project
}
$('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')doSearch(e.target.value);});
$('searchInput').addEventListener('input',e=>{if(!e.target.value.trim())renderDash();});
/* 初始化 */
(async()=>{await loadMeta();await loadProjects();renderDash();})();
</script>
</body>
</html>