Files
jinghui b5acbed047 init: TriliumNext × WechatSync 多平台发布桥接
- bridge/ 常驻桥接服务器(WebSocket 9600 + HTTP 9601,平台状态缓存)
- patch/ TriliumNext 补丁(publisher 服务、API 路由、发布控制台前端)
- install.sh 一键安装 + Docker 容器 CLI 处理
- 全链路使用 host.docker.internal 解决容器访问宿主机
- mcp/ AI Agent 集成说明
2026-10-17 21:59:00 +08:00

298 lines
14 KiB
HTML
Raw Permalink 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"><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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}
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>