- bridge/ 常驻桥接服务器(WebSocket 9600 + HTTP 9601,平台状态缓存) - patch/ TriliumNext 补丁(publisher 服务、API 路由、发布控制台前端) - install.sh 一键安装 + Docker 容器 CLI 处理 - 全链路使用 host.docker.internal 解决容器访问宿主机 - mcp/ AI Agent 集成说明
298 lines
14 KiB
HTML
298 lines
14 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="utf-8"><title>📤 发布控制台</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box}
|
||
body{font-family:-apple-system,PingFang SC,Microsoft YaHei,sans-serif;background:#f3f4f6;color:#111;min-height:100vh}
|
||
.header{background:#fff;border-bottom:1px solid #e5e7eb;padding:16px 32px;display:flex;align-items:center;flex-wrap:wrap;gap:8px}
|
||
.header h1{font-size:20px;font-weight:700;flex:1;min-width:120px}
|
||
.bridge-badge{font-size:12px}
|
||
.container{max-width:800px;margin:24px auto;padding:0 20px 60px}
|
||
.phase{display:flex;margin-bottom:16px;background:#fff;border-radius:12px;overflow:hidden;border:1px solid #e5e7eb}
|
||
.phase-step{flex:1;padding:12px;text-align:center;font-size:13px;font-weight:500;color:#9ca3af;border-right:1px solid #e5e7eb}
|
||
.phase-step:last-child{border-right:none}
|
||
.phase-step.active{color:#22c55e;background:#f0fdf4;font-weight:700}
|
||
.phase-step.done{color:#6b7280;background:#f9fafb}
|
||
.section{background:#fff;border:1px solid #e5e7eb;border-radius:12px;margin-bottom:12px;overflow:hidden}
|
||
.section-title{font-size:13px;font-weight:600;padding:12px 16px;border-bottom:1px solid #e5e7eb;display:flex;align-items:center;gap:8px}
|
||
.section-title .sub{font-size:11px;font-weight:400;color:#9ca3af}
|
||
.load-area{padding:12px 16px;background:#f9fafb;display:flex;gap:8px;flex-wrap:wrap;border-bottom:1px solid #e5e7eb}
|
||
.load-area input{flex:1;min-width:120px;padding:8px 12px;border:1px solid #d1d5db;border-radius:8px;font-size:13px}
|
||
.load-area button{padding:8px 16px;background:#3b82f6;color:#fff;border:none;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer}
|
||
.load-area .hint{font-size:12px;color:#6b7280;width:100%}
|
||
.item{display:flex;align-items:center;padding:8px 16px;gap:10px;border-bottom:1px solid #f3f4f6;cursor:pointer;transition:background .1s}
|
||
.item:hover{background:#f9fafb}
|
||
.item input[type=checkbox]{width:15px;height:15px;cursor:pointer;flex-shrink:0}
|
||
.item .name{font-size:13px;font-weight:500;flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||
.item .status{font-size:11px;flex-shrink:0}
|
||
.item.disabled{opacity:.4;cursor:not-allowed;pointer-events:none}
|
||
.item.disabled input{cursor:not-allowed}
|
||
.actions{display:flex;gap:8px;padding:12px 16px}
|
||
.btn{padding:10px 20px;border:1px solid #d1d5db;border-radius:8px;font-size:13px;font-weight:600;cursor:pointer;background:#fff;color:#111;flex:1;text-align:center;transition:background .15s}
|
||
.btn-primary{background:#22c55e;color:#fff;border-color:#22c55e}
|
||
.btn-primary:hover{background:#16a34a}
|
||
.btn:disabled{opacity:.4;cursor:not-allowed}
|
||
.select-bar{display:flex;align-items:center;gap:10px;padding:8px 16px;border-bottom:1px solid #f3f4f6;background:#fafafa;font-size:12px}
|
||
.select-bar .spacer{flex:1}
|
||
.grp{border:1px solid #e5e7eb;border-radius:8px;margin:6px 12px}
|
||
.grp-title{font-size:12px;font-weight:600;padding:6px 12px;background:#f9fafb;border-bottom:1px solid #e5e7eb}
|
||
.grp .item{padding:5px 12px 5px 24px;font-size:12px}
|
||
.empty{text-align:center;padding:40px 20px;color:#9ca3af;font-size:13px}
|
||
.tip{font-size:11px;color:#9ca3af;text-align:center;padding:6px 16px 10px}
|
||
</style></head><body>
|
||
<div class="header">
|
||
<h1>📤 发布控制台</h1>
|
||
<span id="bridgeStatus" class="bridge-badge">加载中...</span>
|
||
</div>
|
||
<div class="container">
|
||
<div class="phase">
|
||
<div class="phase-step active" id="ph1">📝 选文章</div>
|
||
<div class="phase-step" id="ph2">🌐 选平台</div>
|
||
<div class="phase-step" id="ph3">🚀 结果</div>
|
||
</div>
|
||
<div class="section" id="secNotes">
|
||
<div class="load-area">
|
||
<span class="hint">输入目录笔记ID(从URL复制):</span>
|
||
<input type="text" id="parentInput" placeholder="如: PuLnqqL23KQo" />
|
||
<button id="loadBtn">加载</button>
|
||
</div>
|
||
<div id="notesList"><div class="empty">输入目录ID后点加载</div></div>
|
||
<div id="nextStepDiv" style="display:none;padding:12px 16px;border-top:1px solid #e5e7eb">
|
||
<button class="btn btn-primary" id="nextBtn">下一步:选择平台 →</button>
|
||
</div>
|
||
</div>
|
||
<div class="section" id="secPlats" style="display:none">
|
||
<div class="section-title">🌐 选择发布平台 <span class="sub" id="authSummary"></span></div>
|
||
<div id="platsList"></div>
|
||
<div class="tip">❌ 灰底 = 扩展中未登录 · 点击扩展图标扫码登录</div>
|
||
<div class="actions">
|
||
<button class="btn" id="backBtn">← 返回</button>
|
||
<button class="btn btn-primary" id="pubBtn">🚀 发布</button>
|
||
</div>
|
||
</div>
|
||
<div class="section" id="secRes" style="display:none">
|
||
<div class="section-title">🚀 发布结果</div>
|
||
<div id="resList"></div>
|
||
<div class="actions"><button class="btn" id="doneBtn">← 完成</button></div>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
var notes=[],platforms=[],T=new Map(),SN=new Set(),SP=new Set(),results=new Map(),busy=false,
|
||
API=window.location.origin,authMap=new Map(),extCached=false,extCachedAt=null;
|
||
function $(id){return document.getElementById(id)}
|
||
|
||
async function init(){
|
||
try{
|
||
// 先检查 Trilium 登录
|
||
var r1=await fetch(API+"/api/publisher/platforms?_t="+Date.now(),{credentials:"include"});
|
||
if(r1.status===401){$("bridgeStatus").innerHTML='<span style="color:#ef4444">🔒 请先登录Trilium</span>';return}
|
||
var d=await r1.json();
|
||
if(!d||!d.platforms){$("bridgeStatus").textContent="⚠️ 无平台配置";return}
|
||
|
||
platforms=d.platforms;
|
||
for(var p of platforms)SP.add(p.id);
|
||
|
||
// 获取扩展登录状态(单独 catch 不阻断主流程)
|
||
try{
|
||
var r2=await fetch(API+"/api/publisher/platform-status?_t="+Date.now(),{credentials:"include"});
|
||
var ext=await r2.json();
|
||
if(ext&&ext.platforms){
|
||
for(var p of ext.platforms) authMap.set(p.id,p);
|
||
extCached=ext.cached===true;
|
||
extCachedAt=ext.cachedAt||null;
|
||
renderBridgeStatus(ext);
|
||
}else{
|
||
$("bridgeStatus").innerHTML='<span style="color:#f59e0b">● 扩展离线</span>';
|
||
}
|
||
}catch(e){
|
||
$("bridgeStatus").innerHTML='<span style="color:#f59e0b">● 扩展未连接</span>';
|
||
}
|
||
|
||
renderPlats();
|
||
|
||
// 绑定事件
|
||
$("loadBtn").onclick=loadChildren;
|
||
$("parentInput").onkeydown=function(e){if(e.key==="Enter")loadChildren()};
|
||
$("nextBtn").onclick=function(){goPhase(2)};
|
||
$("backBtn").onclick=function(){goPhase(1)};
|
||
$("pubBtn").onclick=handlePublish;
|
||
$("doneBtn").onclick=function(){location.reload()};
|
||
}catch(e){$("bridgeStatus").textContent="⚠️ 加载失败: "+e.message}
|
||
}
|
||
|
||
function renderBridgeStatus(ext){
|
||
if(!ext||!ext.platforms){
|
||
$("bridgeStatus").innerHTML='<span style="color:#ef4444;cursor:pointer" onclick="showBridgeHelp()">● 桥接离线</span>';
|
||
return;
|
||
}
|
||
var logged=ext.platforms.filter(function(p){return p.isAuthenticated}).length;
|
||
var total=ext.platforms.length;
|
||
var cachedBadge=extCached?' <span style="font-size:11px;color:#f59e0b;cursor:help" title="扩展离线,显示的是上次缓存数据('+new Date(extCachedAt).toLocaleTimeString()+')">📦 缓存</span>':'';
|
||
$("bridgeStatus").innerHTML='<span style="color:#22c55e">●</span> 已登录 '+logged+'/'+total+' 平台'+cachedBadge;
|
||
var names=ext.platforms.filter(function(p){return p.isAuthenticated}).map(function(p){return p.name+(p.username?'('+p.username+')':'')}).join('、')||'(无)';
|
||
$("bridgeStatus").title='已登录平台:'+names+(extCached?' (缓存数据)':'');
|
||
}
|
||
|
||
function showBridgeHelp(){
|
||
if(confirm("同步助手扩展未连接。\n\n1. Chrome 打开 WechatSync 扩展\n2. 设置服务器地址为 ws://192.168.68.19:9600\n3. 输入 Token(扩展设置页显示)\n4. 扫码登录各平台\n\n确定后打开帮助页面?")){
|
||
window.open("https://github.com/wechatsync/WechatSync","_blank")
|
||
}
|
||
}
|
||
|
||
async function loadChildren(){
|
||
var id=$("parentInput").value.trim();
|
||
if(!id)return;
|
||
try{
|
||
var r=await fetch(API+"/api/publisher/notes-with-children/"+id,{credentials:"include"});
|
||
if(r.status===401){alert("Trilium 登录已过期,请刷新页面重新登录");return}
|
||
var d=await r.json();
|
||
if(d&&d.notes){
|
||
for(var n of d.notes){if(!T.has(n.noteId)){T.set(n.noteId,n.title);SN.add(n.noteId)}}
|
||
notes=[];T.forEach(function(t,id){notes.push({noteId:id,title:t})});
|
||
renderNotes();renderNextBtn();
|
||
}else{
|
||
alert("该目录没有子笔记");
|
||
}
|
||
}catch(e){alert("加载失败: "+e.message)}
|
||
}
|
||
|
||
function renderNextBtn(){var el=$("nextStepDiv");el.style.display=(notes.length>0&&SN.size>0)?"block":"none"}
|
||
|
||
function renderNotes(){
|
||
var el=$("notesList");
|
||
if(!notes.length){el.innerHTML='<div class="empty">无文章,输入其他目录ID试试</div>';return}
|
||
var ac=(SN.size===notes.length);
|
||
var h='<div class="select-bar"><input type="checkbox" id="ckAllN"'+(ac?" checked":"")+'><label for="ckAllN" style="cursor:pointer">'+(ac?"取消全选":"全选")+'</label><span>'+SN.size+"/"+notes.length+"篇</span></div>";
|
||
for(var n of notes){
|
||
var ck=SN.has(n.noteId)?" checked":"";
|
||
h+='<div class="item"><input type="checkbox" class="nc" value="'+n.noteId+'"'+ck+'><span class="name">'+n.title+'</span></div>';
|
||
}
|
||
el.innerHTML=h;
|
||
$("ckAllN").onchange=function(){if(this.checked){for(var n of notes)SN.add(n.noteId)}else SN.clear();renderNotes()};
|
||
el.querySelectorAll(".item").forEach(function(e){
|
||
e.onclick=function(ev){
|
||
if(ev.target.tagName==='INPUT')return;
|
||
var c=e.querySelector(".nc");c.checked=!c.checked;
|
||
if(c.checked)SN.add(c.value);else SN.delete(c.value);renderNotes()
|
||
}
|
||
});
|
||
renderNextBtn();
|
||
}
|
||
|
||
function renderPlats(){
|
||
if(!platforms.length){$("platsList").innerHTML='<div class="empty">无可用平台</div>';return}
|
||
var authCount=0,totalCount=platforms.length;
|
||
for(var p of platforms){if(getAuth(p))authCount++}
|
||
$("authSummary").textContent="(已登录 "+authCount+"/"+totalCount+",仅可选已登录平台)";
|
||
|
||
// 获取过滤状态(checkbox可能尚未渲染,默认显示所有)
|
||
var faEl=document.getElementById("filterAuth");
|
||
var filterHidden=faEl&&faEl.checked;
|
||
|
||
var h='<div class="select-bar"><span>仅显示已登录 <label><input type="checkbox" id="filterAuth" checked> 隐藏未登录</label></span><span class="spacer"></span><span id="platCount">'+SP.size+"/"+totalCount+"个</span></div>";
|
||
for(var p of platforms){
|
||
var auth=getAuth(p);
|
||
var statusHtml=getStatusHtml(p);
|
||
var isDisabled=(!auth);
|
||
var hidden=(filterHidden&&isDisabled)?" style='display:none'":"";
|
||
var ck=(auth&&SP.has(p.id))?" checked":"";
|
||
if(isDisabled&&SP.has(p.id))SP.delete(p.id);
|
||
h+='<div class="item'+(isDisabled?" disabled":"")+'"'+hidden+' data-pid="'+p.id+'"><input type="checkbox" class="pc" value="'+p.id+'"'+ck+(isDisabled?" disabled":"")+'><span class="name">'+p.name+'</span><span class="status">'+statusHtml+'</span></div>';
|
||
}
|
||
var el=$("platsList");el.innerHTML=h;
|
||
updateBtn();
|
||
|
||
// 单个点击
|
||
el.querySelectorAll(".item").forEach(function(e){
|
||
e.onclick=function(ev){
|
||
if(ev.target.tagName==='INPUT')return;
|
||
var c=e.querySelector(".pc");
|
||
if(!c||c.disabled)return;
|
||
c.checked=!c.checked;
|
||
if(c.checked)SP.add(c.value);else SP.delete(c.value);renderPlats()
|
||
}
|
||
});
|
||
// 过滤切换
|
||
var fa=document.getElementById("filterAuth");
|
||
if(fa)fa.onchange=function(){renderPlats()};
|
||
}
|
||
|
||
function getAuth(p){
|
||
var pid=p.config&&p.config.platforms;
|
||
if(!pid)return true;
|
||
var ids=pid.split(",");
|
||
for(var i=0;i<ids.length;i++){
|
||
var ep=authMap.get(ids[i].trim());
|
||
if(ep&&ep.isAuthenticated)return true;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getStatusHtml(p){
|
||
var pid=p.config&&p.config.platforms;
|
||
if(!pid)return '<span style="color:#22c55e">✅</span>';
|
||
var ids=pid.split(",");
|
||
for(var i=0;i<ids.length;i++){
|
||
var ep=authMap.get(ids[i].trim());
|
||
if(ep){
|
||
if(ep.isAuthenticated)return '<span style="color:#22c55e">✅ '+escHtml(ep.username||'已登录')+'</span>';
|
||
return '<span style="color:#ef4444">❌ 未登录</span>';
|
||
}
|
||
}
|
||
return '<span style="color:#9ca3af;font-style:italic">❓ 未知</span>';
|
||
}
|
||
|
||
function escHtml(s){return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')}
|
||
|
||
function updateBtn(){var b=$("pubBtn");b.textContent="🚀 发布 "+SN.size+" 篇到 "+SP.size+" 个平台";b.disabled=!SN.size||!SP.size}
|
||
|
||
function goPhase(p){
|
||
$("ph1").className="phase-step"+(p>=1?(p==1?" active":" done"):"");
|
||
$("ph2").className="phase-step"+(p>=2?(p==2?" active":" done"):"");
|
||
$("ph3").className="phase-step"+(p>=3?(p==3?" active":" done"):"");
|
||
$("secNotes").style.display=p==1?"":"none";$("secPlats").style.display=p==2?"":"none";$("secRes").style.display=p==3?"":"none";
|
||
if(p==2)updateBtn()
|
||
}
|
||
|
||
async function handlePublish(){
|
||
if(busy)return;busy=true;
|
||
var nids=[],pids=[];SN.forEach(function(v){nids.push(v)});SP.forEach(function(v){pids.push(v)});
|
||
results=new Map();goPhase(3);
|
||
for(var ni=0;ni<nids.length;ni++){
|
||
var nid=nids[ni],nr=new Map();results.set(nid,nr);
|
||
for(var pi=0;pi<pids.length;pi++)nr.set(pids[pi],{status:"publishing"});
|
||
rr();
|
||
try{
|
||
var r=await fetch(API+"/api/publisher/publish",{method:"POST",headers:{"Content-Type":"application/json"},credentials:"include",body:JSON.stringify({noteId:nid,platforms:pids})});
|
||
if(r.status===401){alert("登录过期,刷新页面重新登录");busy=false;return}
|
||
var rd=await r.json();
|
||
if(rd&&rd.results){for(var ri=0;ri<rd.results.length;ri++){var r2=rd.results[ri];nr.set(r2.id||r2.platform,r2)}}
|
||
}catch(e){for(var pi=0;pi<pids.length;pi++){var r3=nr.get(pids[pi]);if(r3&&r3.status==="publishing")nr.set(pids[pi],{status:"failed",errorMessage:e.message})}}
|
||
results.set(nid,nr);rr();
|
||
}
|
||
busy=false
|
||
}
|
||
|
||
function rr(){
|
||
var el=$("resList"),h=[];
|
||
results.forEach(function(nr,nid){
|
||
var t=T.get(nid)||nid;
|
||
h.push('<div class="grp"><div class="grp-title">'+t+"</div>");
|
||
nr.forEach(function(r,pid){
|
||
var nm=pid;for(var p of platforms){if(p.id===pid){nm=p.name;break}}
|
||
var rh="";
|
||
if(r.status==="success"&&r.url)rh='<a href="'+r.url+'" target="_blank">✏️ 编辑草稿</a>';
|
||
else if(r.status==="success")rh="✅ 成功";
|
||
else if(r.status==="failed")rh="❌ "+(r.errorMessage?r.errorMessage.substring(0,60):"失败");
|
||
else if(r.status==="publishing")rh="⏳ 发布中…";
|
||
else rh="⏳";
|
||
h.push('<div class="item"><span class="name">'+nm+'</span><span class="status">'+rh+"</span></div>")
|
||
});
|
||
h.push("</div>")
|
||
});
|
||
el.innerHTML=h.length?h.join(""):'<div class="empty">无结果</div>'
|
||
}
|
||
init();
|
||
</script>
|
||
</body></html>
|