764 lines
45 KiB
HTML
764 lines
45 KiB
HTML
<!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" 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>
|
||
<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 id="tmTitle">新增文本素材</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 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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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:'', 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);}
|
||
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function escQ(s){return esc(s).replace(/"/g,'"');}
|
||
|
||
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.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.selectedTags.length)u+='tags='+encodeURIComponent(state.selectedTags.join(','));
|
||
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';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';
|
||
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.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>
|
||
<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.project.summaries=await api('/api/projects/'+id+'/summaries');
|
||
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.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>
|
||
</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>`:''}
|
||
${(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>
|
||
</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);}
|
||
}
|
||
|
||
/* ---------- 文本素材 ---------- */
|
||
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{
|
||
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();
|
||
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;
|
||
$('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">
|
||
<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 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;
|
||
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();});
|
||
|
||
/* 初始化 */
|
||
setupPaste();
|
||
(async()=>{await loadMeta();await loadProjects();renderDash();})();
|
||
</script>
|
||
</body>
|
||
</html>
|