Files
material-library/static/index.html
T

569 lines
34 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>