Nine7 / webapp.html
ljx77qaq
成员监控失败退避+权限提示;事件流Lucide图标化;移动端底栏加文字标签
1932286
Raw
History Blame Contribute Delete
128 kB
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<!-- 🆕 interactive-widget=overlays-content:手机键盘弹出时不再挤压/上推固定元素;maximum-scale 禁止聚焦自动放大 -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content">
<title>NINE7 · 频道中枢</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=Noto+Serif+SC:wght@700;900&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#070709; --panel:#0D0D10; --panel2:#111115;
--ink:#EFEDE6; --mut:rgba(239,237,230,.48); --dim:rgba(239,237,230,.26);
--acc:#D9FF3D; --acc-ink:#0C0D05; --vio:#9B8CFF; --red:#FF5D5D; --grn:#5DFFA0;
--line:rgba(239,237,230,.09); --line2:rgba(239,237,230,.18);
--card:rgba(255,255,255,.028);
--ease:cubic-bezier(.16,1,.3,1);
--fd:"Syne","Noto Serif SC",serif;
--fb:"Space Grotesk","Noto Sans SC",sans-serif;
--fm:"JetBrains Mono",monospace;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html{scroll-behavior:smooth}
body{background:var(--bg);color:var(--ink);font-family:var(--fb);font-size:15px;line-height:1.6;overflow-x:hidden;min-height:100dvh}
::selection{background:var(--acc);color:var(--acc-ink)}
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--line2);border-radius:99px}
a{color:inherit;text-decoration:none}
button{font-family:inherit}
svg.lucide{display:inline-block;vertical-align:middle;flex:none}
i[data-lucide]{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center}
i[data-lucide]:not(:has(svg))::after{content:'';width:6px;height:6px;border:1px solid currentColor;border-radius:1px}
/* ---------- atmosphere ---------- */
.noise{position:fixed;inset:-50%;z-index:200;pointer-events:none;opacity:.05;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation:grain 8s steps(10) infinite}
@keyframes grain{0%,100%{transform:translate(0,0)}10%{transform:translate(-2%,-3%)}30%{transform:translate(3%,-2%)}50%{transform:translate(-3%,2%)}70%{transform:translate(2%,3%)}90%{transform:translate(-1%,1%)}}
.glow{position:fixed;z-index:0;pointer-events:none;border-radius:50%;filter:blur(120px);opacity:.16}
.glow-a{width:44vw;height:44vw;background:var(--acc);top:-18vw;right:-14vw}
.glow-b{width:36vw;height:36vw;background:var(--vio);bottom:-14vw;left:-10vw;opacity:.1}
/* ---------- cursor ---------- */
.cur,.cur-ring{position:fixed;top:0;left:0;z-index:300;pointer-events:none;border-radius:50%;display:none}
@media(pointer:fine){.cur,.cur-ring{display:block}}
.cur{width:6px;height:6px;background:var(--acc);transform:translate(-50%,-50%)}
.cur-ring{width:34px;height:34px;border:1px solid var(--line2);transform:translate(-50%,-50%);transition:width .3s var(--ease),height .3s var(--ease),border-color .3s}
body.cur-hov .cur-ring{width:56px;height:56px;border-color:var(--acc)}
/* ---------- topbar ---------- */
.topbar{position:fixed;top:0;left:0;right:0;z-index:60;display:flex;align-items:center;justify-content:space-between;
padding:16px 28px;border-bottom:1px solid var(--line);backdrop-filter:blur(16px);background:rgba(7,7,9,.7)}
.brand{display:flex;align-items:baseline;gap:12px;font-family:var(--fd);font-weight:800;letter-spacing:.02em}
.brand b{font-size:17px}
.brand span{font-size:12px;color:var(--mut);font-family:var(--fb);font-weight:500;letter-spacing:.24em}
.top-r{display:flex;align-items:center;gap:14px}
.pill{display:flex;align-items:center;gap:8px;padding:7px 14px;border:1px solid var(--line2);border-radius:99px;font:600 10px var(--fm);letter-spacing:.14em;text-transform:uppercase}
.pill .dot{width:6px;height:6px;border-radius:50%;background:var(--grn);animation:pulse 2s infinite}
.pill.off .dot{background:var(--red);animation:none}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(93,255,160,.4)}50%{box-shadow:0 0 0 6px rgba(93,255,160,0)}}
.clock{font:500 12px var(--fm);color:var(--mut);letter-spacing:.08em}
.demo-tag{font:700 9px var(--fm);letter-spacing:.2em;color:var(--acc);border:1px dashed var(--acc);padding:5px 10px;border-radius:99px}
/* ---------- nav rail / dock ---------- */
.rail{position:fixed;z-index:60;left:0;top:0;bottom:0;width:84px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border-right:1px solid var(--line);background:rgba(7,7,9,.7);backdrop-filter:blur(16px)}
.nav-b{position:relative;width:56px;height:56px;border-radius:16px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:var(--mut);cursor:pointer;border:1px solid transparent;transition:.35s var(--ease);background:none}
.nav-b i{width:19px;height:19px}
.nav-b em{font:600 8px var(--fm);font-style:normal;letter-spacing:.1em}
.nav-b .nav-t{display:none}
.nav-b:hover{color:var(--ink)}
.nav-b.on{color:var(--acc);background:rgba(217,255,61,.06);border-color:rgba(217,255,61,.25)}
@media(max-width:860px){
.rail{top:auto;bottom:0;left:0;right:0;width:auto;height:62px;flex-direction:row;border-right:none;border-top:1px solid var(--line);padding:0 6px;padding-bottom:env(safe-area-inset-bottom);
transition:transform .22s var(--ease)}
.nav-b{flex:1;height:48px;gap:3px}
.nav-b em{display:none}
.nav-b i{width:17px;height:17px}
.nav-b .nav-t{display:block;font:600 9px/1 var(--fm);letter-spacing:.05em;white-space:nowrap}
}
/* 🆕 键盘弹出时:底部导航整体下沉隐藏,绝不跟着键盘上移(仅手机布局,桌面左侧栏不受影响) */
@media(max-width:860px){
body.kb .rail{transform:translateY(160%);pointer-events:none}
body.kb .toasts{top:12px}
}
/* ---------- view frame ---------- */
.view{position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:120px 32px 140px 116px}
@media(max-width:860px){.view{padding:104px 20px 130px}}
.view [data-rv]{opacity:0;transform:translateY(26px);transition:opacity .8s var(--ease),transform .8s var(--ease)}
.view.ready [data-rv]{opacity:1;transform:none}
.vh{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:38px;flex-wrap:wrap}
.vh-k{font:600 10px var(--fm);letter-spacing:.3em;text-transform:uppercase;color:var(--acc);margin-bottom:10px;display:flex;align-items:center;gap:10px}
.vh-k::before{content:'';width:22px;height:1px;background:var(--acc)}
.vh h1{font:800 clamp(34px,6vw,58px)/1.02 var(--fd);letter-spacing:-.02em}
.vh h1 .en{display:block;font-size:.42em;color:transparent;-webkit-text-stroke:1px var(--dim);letter-spacing:.14em;text-transform:uppercase;margin-top:6px}
.vh-p{color:var(--mut);font-size:13px;max-width:420px}
.mono{font-family:var(--fm)}
.acc{color:var(--acc)}
</style>
</head>
<body>
<style>
/* ---------- buttons / chips ---------- */
.btn{display:inline-flex;align-items:center;gap:9px;padding:13px 24px;border-radius:99px;border:1px solid var(--line2);
background:transparent;color:var(--ink);font:600 13px var(--fb);letter-spacing:.05em;cursor:pointer;
transition:border-color .35s,color .35s,background .35s,transform .2s var(--ease);will-change:transform}
.btn:hover{border-color:var(--acc);color:var(--acc)}
.btn-acc{background:var(--acc);color:var(--acc-ink);border-color:var(--acc)}
.btn-acc:hover{color:var(--acc-ink);filter:brightness(1.1)}
.btn-danger:hover{border-color:var(--red);color:var(--red)}
.btn-s{padding:8px 15px;font-size:12px;gap:7px}
.btn-s i{width:14px;height:14px}
.btn[disabled]{opacity:.45;pointer-events:none}
.chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--line);border-radius:99px;
font:500 11px var(--fm);color:var(--mut);letter-spacing:.02em;transition:.3s}
.chip i{width:11px;height:11px}
.chip.on{background:var(--acc);color:var(--acc-ink);border-color:var(--acc)}
.chip.pk-c{cursor:pointer}
.chip.pk-c:hover{border-color:var(--acc);color:var(--acc)}
/* ---------- hero ---------- */
.hero{position:relative;padding:56px 0 44px}
.hero-k{display:flex;align-items:center;gap:10px;font:600 11px var(--fm);letter-spacing:.32em;text-transform:uppercase;color:var(--mut);margin-bottom:26px}
.hero-k .dot{width:7px;height:7px;border-radius:50%;background:var(--acc);animation:pulse 2s infinite}
.hero-t{font:800 clamp(58px,11.5vw,150px)/0.96 var(--fd);letter-spacing:-.03em;margin-left:-.04em}
.hero-t .l1{display:block;color:transparent;-webkit-text-stroke:1.5px var(--dim);text-transform:uppercase;font-size:clamp(44px,9.6vw,126px);letter-spacing:-.01em}
.hero-t .l2{display:block;font-family:"Noto Serif SC",serif;font-weight:900}
.hero-t .l2 .acc{font-family:var(--fd)}
.hero-d{margin-top:26px;color:var(--mut);font-size:14px;max-width:520px}
.hero-b{margin-top:32px;display:flex;gap:14px;flex-wrap:wrap}
.badge-rot{position:absolute;top:30px;right:0;width:132px;height:132px;animation:rot 16s linear infinite;opacity:.9}
@media(max-width:860px){.badge-rot{display:none}}
@keyframes rot{to{transform:rotate(360deg)}}
.badge-rot text{font:600 10.5px var(--fm);letter-spacing:.34em;fill:var(--mut);text-transform:uppercase}
.spin{animation:rot 1s linear infinite}
/* ---------- marquee ---------- */
.mq{overflow:hidden;border-block:1px solid var(--line);padding:15px 0;margin:10px 0 44px}
.mq-in{display:flex;width:max-content;animation:mqx 26s linear infinite}
.mq-in span{font:700 clamp(13px,1.8vw,18px) var(--fd);letter-spacing:.16em;text-transform:uppercase;padding:0 26px;white-space:nowrap;color:var(--dim)}
.mq-in b{color:var(--acc);font-weight:700}
@keyframes mqx{to{transform:translateX(-50%)}}
/* ---------- tiles ---------- */
.tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:44px}
@media(max-width:860px){.tiles{grid-template-columns:repeat(2,1fr)}}
.tile{position:relative;border:1px solid var(--line);border-radius:20px;padding:24px 22px 20px;background:var(--card);overflow:hidden;cursor:pointer;transition:.45s var(--ease)}
.tile:hover{border-color:var(--line2);transform:translateY(-4px)}
.tile:hover .t-num{color:var(--acc)}
.tile .t-ic{color:var(--dim);margin-bottom:16px}
.tile .t-num{font:700 clamp(40px,4.6vw,56px)/1 var(--fd);letter-spacing:-.02em;transition:color .4s}
.tile .t-l{font:600 10px var(--fm);letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin-top:10px}
.tile .t-sp{position:absolute;bottom:0;left:0;right:0;height:34px;opacity:.35}
/* ---------- panels & feed ---------- */
.grid2{display:grid;grid-template-columns:1fr 1.25fr;gap:14px}
@media(max-width:860px){.grid2{grid-template-columns:1fr}}
.panel{border:1px solid var(--line);border-radius:20px;background:var(--card);overflow:hidden}
.panel-h{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--line)}
.panel-t{font:700 12px var(--fm);letter-spacing:.22em;text-transform:uppercase;color:var(--mut);display:flex;align-items:center;gap:10px}
.panel-t i{width:15px;height:15px;color:var(--acc)}
.kv{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:15px 22px;border-bottom:1px solid var(--line);font-size:13px}
.kv:last-child{border-bottom:none}
.kv .k{color:var(--mut);display:flex;align-items:center;gap:9px}
.kv .k i{width:14px;height:14px;color:var(--dim)}
.kv .v{font-family:var(--fm);font-size:12px;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
.st-on{color:var(--grn)}.st-off{color:var(--red)}
.feed{padding:8px 0;max-height:330px;overflow-y:auto}
.feed-l{display:flex;align-items:center;gap:12px;padding:9px 22px;font:500 12px var(--fm);color:var(--mut);border-bottom:1px solid rgba(239,237,230,.04);animation:feedin .5s var(--ease)}
@keyframes feedin{from{opacity:0;transform:translateX(-8px)}}
.feed-l .tm{color:var(--dim);flex:none}
.feed-l .ms{color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed-l .ms i[data-lucide]{margin-right:7px;color:var(--dim);transform:translateY(-1px)}
.tg{font:700 9px var(--fm);letter-spacing:.1em;padding:3px 8px;border-radius:99px;border:1px solid;flex:none;text-transform:uppercase}
.tg-sync{color:var(--acc);border-color:rgba(217,255,61,.35)}
.tg-bkp{color:var(--vio);border-color:rgba(155,140,255,.35)}
.tg-dir{color:#6EC9FF;border-color:rgba(110,201,255,.35)}
.tg-err{color:var(--red);border-color:rgba(255,93,93,.35)}
.tg-sys{color:var(--mut);border-color:var(--line2)}
/* ---------- list items ---------- */
.itm{position:relative;border:1px solid var(--line);border-radius:20px;background:var(--card);padding:24px 26px;margin-bottom:14px;overflow:hidden;transition:.45s var(--ease)}
.itm:hover{border-color:var(--line2)}
.itm .gidx{position:absolute;right:16px;top:6px;font:800 52px/1 var(--fd);color:transparent;-webkit-text-stroke:1px var(--line);pointer-events:none;transition:.4s}
.itm:hover .gidx{-webkit-text-stroke-color:rgba(217,255,61,.3)}
.itm-t{font:700 19px var(--fd);letter-spacing:-.01em;margin-bottom:6px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.itm-s{color:var(--mut);font-size:12.5px}
.route{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:14px 0 4px}
.route .mono{font-size:12px;color:var(--ink);background:rgba(255,255,255,.05);padding:6px 12px;border-radius:8px}
.route i{width:15px;height:15px;color:var(--acc)}
.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.itm-a{display:flex;gap:10px;margin-top:18px;flex-wrap:wrap}
.prog{height:4px;background:rgba(255,255,255,.06);border-radius:99px;overflow:hidden;margin-top:14px}
.prog i{display:block;height:100%;background:var(--acc);border-radius:99px;transition:width 1s var(--ease)}
.tagcloud{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px}
.empty{border:1px dashed var(--line2);border-radius:20px;padding:60px 30px;text-align:center;color:var(--mut)}
.empty i{width:34px;height:34px;margin-bottom:14px;color:var(--dim)}
.empty .e-t{font:700 16px var(--fd);margin-bottom:6px;color:var(--ink)}
.empty .e-s{font-size:12.5px}
</style>
<style>
/* ---------- forms ---------- */
.fld{display:flex;flex-direction:column;gap:8px;margin-bottom:18px}
.fld-l{font:600 10px var(--fm);letter-spacing:.2em;text-transform:uppercase;color:var(--mut)}
.fld-h{font:400 11px var(--fb);color:var(--dim)}
.inp,textarea.inp,select.inp{width:100%;background:rgba(255,255,255,.03);border:1px solid var(--line);color:var(--ink);
padding:13px 15px;border-radius:12px;font:500 14px var(--fb);outline:none;transition:.3s;appearance:none}
select.inp option{background:var(--panel2)}
.inp:focus{border-color:var(--acc);background:rgba(217,255,61,.04)}
textarea.inp{resize:vertical;min-height:88px;line-height:1.55}
/* ---------- 🆕 Telegram 文本格式工具条 ---------- */
.fmt{display:flex;flex-wrap:wrap;gap:4px;align-items:center;margin:-1px 0 1px}
.fmt button{flex:none;min-width:30px;height:30px;padding:0 6px;border:1px solid var(--line);border-radius:9px;
background:rgba(255,255,255,.03);color:var(--mut);cursor:pointer;transition:.2s;
display:inline-flex;align-items:center;justify-content:center;gap:5px;font:600 11px var(--fb)}
.fmt button:hover{border-color:var(--acc);color:var(--acc);background:rgba(217,255,61,.07)}
.fmt button:active{transform:scale(.93)}
.fmt button i{width:13px;height:13px}
.fmt button .kb{font:600 10px var(--fm);letter-spacing:.02em}
.fmt button.gp{margin-left:8px;position:relative}
.fmt button.gp::before{content:"";position:absolute;left:-5px;top:6px;bottom:6px;width:1px;background:var(--line2)}
.fmt-h{font:400 11px var(--fb);color:var(--dim)}
.fmt-h b{color:var(--mut);font-weight:600}
@media(max-width:640px){.fmt button{min-width:30px;height:29px;padding:0 6px}}
.frm-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
@media(max-width:640px){.frm-grid{grid-template-columns:1fr}}
.pk{display:flex;flex-wrap:wrap;gap:8px}
.pk-manual{margin-top:10px}
.dyn-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
.dyn-row .inp{flex:1}
.dyn-x{flex:none;width:42px;height:42px;border-radius:10px;border:1px solid var(--line);background:none;color:var(--mut);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.3s}
.dyn-x:hover{border-color:var(--red);color:var(--red)}
.dyn-add{width:100%;border:1px dashed var(--line2);border-radius:12px;background:none;color:var(--mut);padding:11px;cursor:pointer;font:600 12px var(--fb);display:flex;align-items:center;justify-content:center;gap:8px;transition:.3s}
.dyn-add:hover{border-color:var(--acc);color:var(--acc)}
.sw{width:48px;height:27px;border:1px solid var(--line2);border-radius:99px;position:relative;cursor:pointer;transition:.35s;flex:none;background:none}
.sw::after{content:'';position:absolute;top:3px;left:3px;width:19px;height:19px;border-radius:50%;background:var(--mut);transition:.4s var(--ease)}
.sw.on{background:var(--acc);border-color:var(--acc)}
.sw.on::after{left:24px;background:var(--acc-ink)}
.sw-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:15px;border:1px solid var(--line);border-radius:12px;margin-bottom:18px}
.sw-row .t{font:600 13px var(--fb)}
.sw-row .s{font-size:11px;color:var(--dim);margin-top:2px}
/* 🆕 媒体附件:文件输入框铺满整块区域(opacity:0),点哪都能唤起系统选择器 —— hidden + JS .click() 在手机 WebView 里常被拦 */
.file-drop{position:relative;display:block;border:1px dashed var(--line2);border-radius:12px;padding:20px;text-align:center;
color:var(--mut);cursor:pointer;transition:.3s;font-size:12.5px;overflow:hidden}
.file-drop:hover,.file-drop:focus-within{border-color:var(--acc);color:var(--acc)}
.file-drop i{width:22px;height:22px;margin-bottom:8px}
.file-drop .file-in{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;font-size:16px}
.file-name{display:flex;align-items:center;gap:10px;margin-top:10px;font:500 12px var(--fm);color:var(--acc)}
.file-name .fx{margin-left:auto;flex:none;width:26px;height:26px;border-radius:8px;border:1px solid var(--line);
background:none;color:var(--mut);cursor:pointer;display:flex;align-items:center;justify-content:center}
.file-name .fx:hover{border-color:var(--red);color:var(--red)}
.file-name .fx i{width:12px;height:12px}
/* ---------- sheet ---------- */
.sh-wrap{position:fixed;inset:0;z-index:90;display:flex;align-items:flex-end;justify-content:center}
.sh-bg{position:absolute;inset:0;background:rgba(4,4,6,.65);backdrop-filter:blur(10px);opacity:0;transition:.4s}
.sh{position:relative;width:min(660px,100%);max-height:88dvh;overflow-y:auto;background:var(--panel);
border:1px solid var(--line2);border-bottom:none;border-radius:26px 26px 0 0;padding:28px 28px 40px;
transform:translateY(60px);opacity:0;transition:.55s var(--ease)}
@media(min-width:760px){.sh-wrap{align-items:center;padding:32px}.sh{border-radius:26px;border-bottom:1px solid var(--line2)}}
.sh-wrap.in .sh-bg{opacity:1}
.sh-wrap.in .sh{transform:none;opacity:1}
.sh-grip{width:38px;height:4px;border-radius:99px;background:var(--line2);margin:0 auto 20px}
.sh-h{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px}
.sh-t{font:800 22px var(--fd);letter-spacing:-.01em}
.sh-s{font-size:12px;color:var(--mut);margin-top:5px}
.sh-x{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:none;color:var(--mut);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.3s;flex:none}
.sh-x:hover{border-color:var(--ink);color:var(--ink);transform:rotate(90deg)}
.sh-f{display:flex;gap:12px;margin-top:26px;justify-content:flex-end}
.sec-t{font:700 10px var(--fm);letter-spacing:.24em;text-transform:uppercase;color:var(--acc);margin:26px 0 14px;display:flex;align-items:center;gap:10px}
.sec-t::after{content:'';flex:1;height:1px;background:var(--line)}
/* ---------- toast ---------- */
.toasts{position:fixed;top:76px;right:24px;z-index:120;display:flex;flex-direction:column;gap:10px;align-items:flex-end}
.toast{display:flex;align-items:center;gap:10px;background:var(--panel2);border:1px solid var(--line2);border-radius:14px;
padding:13px 18px;font:500 13px var(--fb);transform:translateX(30px);opacity:0;transition:.5s var(--ease);max-width:min(360px,80vw)}
.toast.in{transform:none;opacity:1}
.toast i{width:16px;height:16px;flex:none}
.toast.ok i{color:var(--acc)}
.toast.err{border-color:rgba(255,93,93,.4)}
.toast.err i{color:var(--red)}
.toast.info i{color:var(--vio)}
@media(max-width:640px){.toasts{right:12px;left:12px;align-items:stretch}}
/* ---------- compose ---------- */
.cmp-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
@media(max-width:860px){.cmp-grid{grid-template-columns:1fr}}
.cmp{border:1px solid var(--line);border-radius:20px;background:var(--card);padding:26px}
.cmp-t{font:700 16px var(--fd);margin-bottom:4px;display:flex;align-items:center;gap:10px}
.cmp-t i{width:17px;height:17px;color:var(--acc)}
.cmp-s{font-size:12px;color:var(--mut);margin-bottom:22px}
/* ---------- ops ---------- */
.ops-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
@media(max-width:860px){.ops-grid{grid-template-columns:1fr}}
.ops-c{border:1px solid var(--line);border-radius:20px;background:var(--card);padding:26px;margin-bottom:14px;break-inside:avoid}
.mon-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 0;border-bottom:1px solid var(--line);font-size:12.5px;flex-wrap:wrap}
.mon-row:last-child{border-bottom:none}
.mon-row .mono{font-size:11.5px}
.kbd-hint{position:fixed;bottom:22px;right:26px;z-index:50;font:500 10px var(--fm);color:var(--dim);letter-spacing:.1em;display:flex;gap:14px}
@media(max-width:860px){.kbd-hint{display:none}}
.kbd-hint b{border:1px solid var(--line2);border-radius:5px;padding:1px 6px;color:var(--mut)}
.foot{margin-top:70px;padding-top:22px;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:14px;flex-wrap:wrap;font:500 10px var(--fm);letter-spacing:.16em;color:var(--dim);text-transform:uppercase}
.ch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
.ch-c{position:relative;border:1px solid var(--line);border-radius:20px;background:var(--card);padding:24px;transition:.4s var(--ease);overflow:hidden}
.ch-c:hover{border-color:var(--line2);transform:translateY(-3px)}
.ch-c .nm{font:700 16px var(--fd);margin-bottom:6px;word-break:break-all}
.ch-c .cid{font:500 11px var(--fm);color:var(--mut);word-break:break-all}
.ch-c .ops{display:flex;gap:8px;margin-top:16px}
.ic-b{width:34px;height:34px;border-radius:10px;border:1px solid var(--line);background:none;color:var(--mut);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.3s}
.ic-b:hover{border-color:var(--acc);color:var(--acc)}
.ic-b.danger:hover{border-color:var(--red);color:var(--red)}
.ic-b i{width:14px;height:14px}
.add-c{border:1px dashed var(--line2);border-radius:20px;padding:24px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--mut);cursor:pointer;min-height:150px;transition:.3s;background:none;width:100%}
.add-c:hover{border-color:var(--acc);color:var(--acc)}
.add-c i{width:22px;height:22px}
.boot{position:fixed;inset:0;z-index:400;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;transition:opacity .7s var(--ease),visibility .7s}
.boot.out{opacity:0;visibility:hidden}
.boot .b-t{font:800 clamp(34px,7vw,54px) var(--fd);letter-spacing:-.02em;overflow:hidden}
.boot .b-t span{display:inline-block;transform:translateY(110%);animation:bootup .9s var(--ease) forwards}
@keyframes bootup{to{transform:none}}
.boot .b-n{font:700 13px var(--fm);color:var(--acc);letter-spacing:.3em}
.boot .b-bar{width:180px;height:1px;background:var(--line);position:relative;overflow:hidden}
.boot .b-bar i{position:absolute;inset:0;background:var(--acc);transform-origin:left;transform:scaleX(0)}
/* ---------- 🆕 user chip (头像 + 名字) ---------- */
.me{display:flex;align-items:center;gap:10px;padding:5px 12px 5px 5px;border:1px solid var(--line2);border-radius:99px}
.ava{width:34px;height:34px;border-radius:50%;object-fit:cover;flex:none;background:var(--panel2);
border:1px solid var(--line2);display:inline-flex;align-items:center;justify-content:center;
font:700 13px var(--fd);color:var(--dim);overflow:hidden}
.ava.sq{border-radius:11px}.ava.lg{width:44px;height:44px;font-size:16px}
.ch-c .ch-hd{display:flex;align-items:center;gap:11px;margin-bottom:6px}
.rs-box{display:flex;align-items:center;gap:11px;padding:11px 13px;border:1px solid var(--line);
border-radius:13px;background:var(--panel2);margin:-4px 0 14px}
.rs-box .t{font:600 13px var(--fb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rs-box .s{font:500 10.5px var(--fm);color:var(--dim)}
.me .av{width:28px;height:28px;border-radius:50%;object-fit:cover;background:var(--panel2);flex:none;
display:flex;align-items:center;justify-content:center;font:700 11px var(--fm);color:var(--acc);border:1px solid var(--line2)}
.me .mn{display:flex;flex-direction:column;line-height:1.15;max-width:150px}
.me .mn b{font:600 12px var(--fb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.me .mn span{font:500 9.5px var(--fm);color:var(--dim);letter-spacing:.06em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@media(max-width:640px){.me .mn{display:none}.me{padding:4px}}
/* ---------- 🆕 live preview (Telegram 风格) ---------- */
.pv{border:1px solid var(--line);border-radius:18px;background:var(--panel2);padding:16px;margin-top:6px}
.pv-h{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}
.pv-h .pt{font:700 10px var(--fm);letter-spacing:.22em;text-transform:uppercase;color:var(--acc);display:flex;align-items:center;gap:8px}
.pv-h .pc{font:500 10px var(--fm);color:var(--dim)}
.bub{background:#17212B;border-radius:14px;padding:0;overflow:hidden;max-width:100%;border:1px solid rgba(255,255,255,.05)}
.bub-top{display:flex;align-items:center;gap:9px;padding:11px 13px 0}
.bub-top .ic{width:26px;height:26px;border-radius:50%;background:var(--acc);color:var(--acc-ink);display:flex;align-items:center;justify-content:center;font:700 11px var(--fm);flex:none}
.bub-top .nm{font:600 12.5px var(--fb);color:#6EC9FF}
.bub-md{margin:10px 0 0;max-height:220px;width:100%;object-fit:cover;display:block;background:#0d1520}
.bub-file{display:flex;align-items:center;gap:10px;margin:11px 13px 0;padding:10px;border-radius:10px;background:rgba(255,255,255,.05);font:500 12px var(--fb)}
.bub-file i{width:16px;height:16px;color:#6EC9FF}
.bub-tx{padding:10px 13px 12px;font:400 13.5px/1.55 var(--fb);color:#F1F1F1;white-space:pre-wrap;word-break:break-word}
.bub-tx:empty::after{content:'(正文为空)';color:rgba(255,255,255,.3);font-style:italic}
.bub-tx b,.bub-tx strong{font-weight:700}.bub-tx i,.bub-tx em{font-style:italic}
.bub-tx u{text-decoration:underline}.bub-tx s,.bub-tx del{text-decoration:line-through}
.bub-tx a{color:#6EC9FF;text-decoration:none}
.bub-tx code{font-family:var(--fm);font-size:12px;background:rgba(255,255,255,.08);padding:1px 5px;border-radius:5px}
.bub-tx pre{font-family:var(--fm);font-size:12px;background:rgba(255,255,255,.08);padding:9px 11px;border-radius:8px;overflow-x:auto;margin:6px 0}
.bub-tx blockquote{border-left:3px solid #6EC9FF;padding:2px 0 2px 10px;margin:6px 0;color:rgba(241,241,241,.85)}
.bub-tx .sp{background:rgba(255,255,255,.22);border-radius:4px;color:transparent;text-shadow:0 0 8px rgba(255,255,255,.6)}
.bub-tx .warn{color:var(--red);font:500 12px var(--fm)}
.bub-btns{display:flex;flex-direction:column;gap:2px;padding:2px;border-top:1px solid rgba(255,255,255,.06)}
.bub-btns .bb{display:flex;align-items:center;justify-content:center;gap:7px;padding:11px;border-radius:9px;
background:rgba(255,255,255,.07);font:600 12.5px var(--fb);color:#6EC9FF;text-align:center;word-break:break-all}
.bub-btns .bb i{width:12px;height:12px;opacity:.7}
.bub-btns .bb.bad{color:var(--red)}
.bub-meta{display:flex;justify-content:flex-end;gap:6px;padding:0 13px 10px;font:500 10px var(--fm);color:rgba(255,255,255,.35)}
/* ---------- 🆕 榜单名单(Mini App 视图) ---------- */
.rk{max-width:640px;margin:0 auto}
.rk-h{text-align:center;margin-bottom:22px}
.rk-h h1{font:800 clamp(22px,5.5vw,32px) var(--fd);letter-spacing:-.01em}
.rk-h p{font:500 11px var(--fm);color:var(--mut);margin-top:8px;letter-spacing:.1em}
.rk-i{display:flex;align-items:center;gap:12px;padding:13px 16px;border:1px solid var(--line);border-radius:14px;
background:var(--card);margin-bottom:8px;transition:.3s}
.rk-i:hover{border-color:var(--acc)}
.rk-i .r{font:700 12px var(--fm);color:var(--acc);width:38px;flex:none}
.rk-i .n{flex:1;font:500 13.5px var(--fb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rk-i .n.done{text-decoration:line-through;opacity:.45}
.rk-i .c{font:700 12px var(--fm);color:var(--vio);flex:none}
/* ================= 🆕 手机端紧凑化(整体缩小一档,避免显示不全) ================= */
@media(max-width:860px){
.view{padding:84px 14px 100px}
.kbd-hint{display:none}
}
@media(max-width:640px){
body{font-size:13.5px}
/* 顶栏:去掉副标题与多余间距,防止「频道中枢」被挤成竖排 */
.topbar{padding:9px 13px}
.brand b{font-size:15px}
.brand span{display:none}
.top-r{gap:8px}
.pill{padding:5px 9px;font-size:8.5px;letter-spacing:.1em;white-space:nowrap;gap:6px}
.demo-tag{padding:4px 8px;font-size:8px;letter-spacing:.1em;white-space:nowrap}
.clock{font-size:10px;letter-spacing:.02em}
.me{padding:3px}
.view{padding:74px 13px 96px}
/* 首页 hero */
.hero{padding:22px 0 24px}
.hero-k{font-size:9px;letter-spacing:.18em;margin-bottom:14px}
.hero-t{font-size:clamp(36px,11.5vw,60px)}
.hero-t .l1{font-size:clamp(26px,8.6vw,44px);-webkit-text-stroke-width:1px}
.hero-d{font-size:12.5px;margin-top:15px}
.hero-b{margin-top:20px;gap:9px}
.btn{padding:11px 17px;font-size:12.5px;gap:7px}
.btn-s{padding:6px 12px;font-size:11px}
/* 各视图标题 */
.vh{margin-bottom:22px;gap:12px}
.vh h1{font-size:clamp(25px,7.6vw,38px)}
.vh h1 .en{font-size:.4em;letter-spacing:.1em;margin-top:3px}
.vh-k{font-size:9px;letter-spacing:.18em;margin-bottom:7px}
.vh-p{font-size:12px}
.mq{padding:10px 0;margin:4px 0 24px}
.mq-in span{font-size:11px;padding:0 15px;letter-spacing:.1em}
/* 指标卡 / 面板 */
.tiles{gap:9px;margin-bottom:24px}
.tile{padding:15px 13px 12px;border-radius:16px}
.tile .t-ic{margin-bottom:8px}.tile .t-ic i{width:15px;height:15px}
.tile .t-num{font-size:clamp(27px,8.6vw,36px)}
.tile .t-l{font-size:8.5px;letter-spacing:.14em;margin-top:5px}
.grid2,.cmp-grid,.ops-grid,.ch-grid{gap:10px}
.panel,.itm,.cmp,.ops-c,.ch-c,.empty{border-radius:16px}
.panel-h{padding:13px 14px}
.panel-t{font-size:10.5px;letter-spacing:.14em}
.panel-t i{width:14px;height:14px}
.kv{padding:11px 14px;font-size:12px;gap:10px}
.kv .v{font-size:11px;max-width:58%}
.feed{max-height:250px}
.feed-l{padding:7px 14px;font-size:11px;gap:9px}
/* 列表项 */
.itm{padding:16px 14px;margin-bottom:10px}
.itm .gidx{font-size:36px;right:10px;top:2px}
.itm-t{font-size:15.5px;gap:8px;margin-bottom:4px}
.itm-s{font-size:11.5px}
.route{gap:7px;margin:10px 0 2px}
.route .mono{font-size:11px;padding:5px 9px}
.route i{width:13px;height:13px}
.meta{gap:6px;margin-top:10px}
.itm-a{gap:8px;margin-top:12px}
.chip{padding:5px 10px;font-size:10.5px}
.empty{padding:36px 18px}
.empty i{width:26px;height:26px;margin-bottom:10px}
/* 表单:输入框本体字号必须 ≥16px,否则 iOS 聚焦时会自动放大整个界面 */
.fld{margin-bottom:12px;gap:5px}
.fld-l{font-size:9px;letter-spacing:.14em}
/* 🆕 说明文字与占位提示单独缩小:placeholder 字号不影响 iOS 的放大判定 */
.fld-h,.fmt-h{font-size:9.5px;line-height:1.45}
.inp,textarea.inp,select.inp{padding:11px 13px;border-radius:11px;font-size:16px}
.inp::placeholder,textarea.inp::placeholder{font-size:12px;opacity:.75}
.inp::-webkit-input-placeholder,textarea.inp::-webkit-input-placeholder{font-size:12px}
textarea.inp{min-height:74px}
.dyn-x{width:38px;height:38px}
.dyn-add{padding:9px;font-size:11.5px}
.sw-row{padding:12px;margin-bottom:14px;gap:10px}
.sw-row .t{font-size:12.5px}
.cmp,.ops-c{padding:16px 14px}
.cmp-t{font-size:14.5px}.cmp-t i{width:15px;height:15px}
.cmp-s{font-size:11.5px;margin-bottom:15px}
.sec-t{margin:18px 0 10px;font-size:9px;letter-spacing:.16em}
.pv{padding:12px;border-radius:14px}
.file-drop{padding:14px;font-size:12px}
.file-drop i{width:18px;height:18px;margin-bottom:5px}
.file-name{font-size:11px;margin-top:8px}
.ch-grid{grid-template-columns:1fr}
.ch-c{padding:16px 14px}
.ch-c .nm{font-size:14.5px}
.foot{margin-top:38px;font-size:9px;letter-spacing:.1em}
.sh{padding:20px 16px 30px;border-radius:20px 20px 0 0;max-height:92dvh}
.sh-t{font-size:18px}
.sh-s{font-size:11.5px}
.sh-h{margin-bottom:18px}
.sh-x{width:34px;height:34px}
.mon-row{padding:11px 0;font-size:11.5px}
.rk-i{padding:11px 13px}
}
</style>
<div class="noise"></div>
<div class="glow glow-a"></div><div class="glow glow-b"></div>
<div class="cur"></div><div class="cur-ring"></div>
<div class="boot" id="boot">
<div class="b-n">NINE7 CONSOLE</div>
<div class="b-t"><span>频道中枢</span></div>
<div class="b-bar"><i id="bootBar"></i></div>
</div>
<header class="topbar">
<div class="brand"><b>NINE7</b><span>频道中枢</span></div>
<div class="top-r">
<span class="pill" id="pill"><span class="dot"></span><span id="pillT">CONNECTING</span></span>
<span class="clock" id="clock">00:00:00</span>
<div class="me" id="me" style="display:none"></div>
</div>
</header>
<nav class="rail" id="nav"></nav>
<main class="view" id="view"></main>
<div class="kbd-hint"><span><b>1-7</b> 切换视图</span><span><b>ESC</b> 关闭弹层</span></div>
<div class="toasts" id="toasts"></div>
<div id="sheets"></div>
<script>
'use strict';
window.__errs=[];
addEventListener('error',e=>window.__errs.push((e.message||'')+' @:'+(e.lineno||'')));
addEventListener('unhandledrejection',e=>window.__errs.push('PROMISE: '+((e.reason&&(e.reason.stack||e.reason.message))||e.reason)));
/* ================= utils ================= */
const $=(s,c=document)=>c.querySelector(s);
const $$=(s,c=document)=>[...c.querySelectorAll(s)];
const esc=s=>String(s??'').replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
const I=(n,s)=>`<i data-lucide="${n}"${s?` style="width:${s}px;height:${s}px"`:''}></i>`;
const icons=root=>{try{lucide.createIcons(root?{root}:undefined)}catch(e){}};
/* 事件流:事件类型 → Lucide 图标;事件文本里的 emoji 一律剥掉,统一用图标表达 */
const EV_ICON={connected:'wifi',sys:'terminal',backup_done:'archive',dir_done:'folder',replace_done:'replace',
members_done:'users',sync_done:'repeat',sync_edit:'pencil',stats_done:'trophy',follow:'user-plus',err:'alert-triangle'};
const deEmoji=s=>String(s).replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{200D}]/gu,'').replace(/\s{2,}/g,' ').trim();
const delay=ms=>new Promise(r=>setTimeout(r,ms));
const clone=o=>JSON.parse(JSON.stringify(o));
const shortId=id=>{id=String(id??'');return id.length>16?id.slice(0,8)+'…'+id.slice(-5):id};
const fmtT=ts=>{if(!ts)return'—';const d=new Date(ts*1000);const p=n=>String(n).padStart(2,'0');return`${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`};
/* ================= state ================= */
const TG=window.Telegram&&window.Telegram.WebApp;
const INIT=(TG&&TG.initData)||'';
const TGU=(TG&&TG.initDataUnsafe&&TG.initDataUnsafe.user)||null;
const START_PARAM=(TG&&TG.initDataUnsafe&&TG.initDataUnsafe.start_param)||
(new URLSearchParams(location.search).get('startapp')||new URLSearchParams(location.search).get('list')||'');
let DEMO=!INIT;
const S={view:'overview',data:null,meta:{msg:0,dav:'',bot:false},me:null,events:[],ready:false};
/* ========== 🆕 频道标识:支持 -100ID / @用户名 / https://t.me/xxx ========== */
const CH_HINT='-100 数字 ID、@用户名 或 https://t.me/xxx';
function normCh(raw){
const s=String(raw??'').trim();if(!s)return'';
const m=s.match(/^(?:https?:\/\/)?(?:www\.)?(?:t|telegram)\.me\/(.+)$/i);
if(m){
const parts=m[1].split(/[?#]/)[0].replace(/^\/+|\/+$/g,'').split('/').filter(Boolean);
if(!parts.length)return'';
if(parts[0].toLowerCase()==='c'&&/^\d+$/.test(parts[1]||''))return parts[1].startsWith('-100')?parts[1]:'-100'+parts[1];
if(parts[0].startsWith('+')||parts[0].toLowerCase()==='joinchat')return s;
return '@'+parts[0].replace(/^@/,'');
}
if(s.startsWith('@'))return '@'+s.slice(1).trim();
if(/^-?\d+$/.test(s))return s;
if(/^[A-Za-z][A-Za-z0-9_]{3,31}$/.test(s))return '@'+s;
return s;
}
function msgRef(raw){
const s=String(raw??'').trim();if(!s)return{ch:'',id:''};
const m=s.match(/^(?:https?:\/\/)?(?:www\.)?(?:t|telegram)\.me\/(.+)$/i);
if(m){
const parts=m[1].split(/[?#]/)[0].replace(/^\/+|\/+$/g,'').split('/').filter(Boolean);
const nums=parts.filter(p=>/^\d+$/.test(p));
if(parts[0]&&parts[0].toLowerCase()==='c')return{ch:normCh(s),id:nums.length>1?nums[nums.length-1]:''};
return{ch:parts[0]?'@'+parts[0].replace(/^@/,''):'',id:nums.length?nums[nums.length-1]:''};
}
return{ch:'',id:/^\d+$/.test(s)?s:''};
}
/* 频道展示名:地址簿有备注就带上备注 */
function chName(cid){const ab=(S.data&&S.data.address_book)||{};return ab[cid]||''}
function chLabel(cid,{icon=true}={}){
const nm=chName(cid);const sid=shortId(cid);
return nm?`${icon?I('library',11):''}<b style="font-weight:600">${esc(nm)}</b> <span style="opacity:.55">${esc(sid)}</span>`
:`${icon?I('hash',11):''}${esc(sid)}`;
}
/* ================= mock db (demo mode) ================= */
const nowS=()=>Math.floor(Date.now()/1000);
const SEED={
groups:[
{name:'主站分发',src:'-1001682349051',tgt:['-1001733118244','-1001552209873']},
{name:'镜像存档',src:'-1001682349051',tgt:['-1001908744236','@Nine_7Eleven']},
{name:'精选推送',src:'-1001445096327',tgt:['-1001733118244','-1001908744236','-1001612987450']}
],
stats_tasks:[
{task_name:'七月热评榜',channel_id:'-1001682349051',msg_id:'1024',table_title:'本周热评 TOP10',top_n:10,trigger_tag:'#更新',interval:15,duration:7,start_time:nowS()-86400*2,last_run:nowS()-600,completed_items:['夜航星','雾都夜谈'],stats_blacklist:['旧日支配者','无名氏'],blacklist_title:'本月轮换限制'},
{task_name:'周刊人气投票',channel_id:'-1001445096327',msg_id:'512',table_title:'周刊人气榜',top_n:5,trigger_tag:'#周刊',interval:30,duration:14,start_time:nowS()-86400,last_run:nowS()-1800,completed_items:[],stats_blacklist:[],blacklist_title:''}
],
dir_tasks:[
{task_name:'资源标签目录',scan_id:'-1001682349051',targets:[{channel_id:'-1001733118244',msg_id:'88'},{channel_id:'-1001908744236',msg_id:'12'}],blacklist:['#广告'],interval:15,last_run:nowS()-300,tags_cache:['#电影','#剧集','#纪录片','#动画','#音乐','#演唱会','#电子书','#教程','#软件','#游戏','#4K','#无损']},
{task_name:'周刊索引',scan_id:'-1001445096327',targets:[{channel_id:'-1001612987450',msg_id:'45'}],blacklist:[],interval:30,last_run:nowS()-900,tags_cache:['#周刊','#访谈','#专栏']}
],
address_book:{'-1001682349051':'主频道','-1001733118244':'分发一号','-1001552209873':'存档仓','-1001908744236':'镜像站','-1001445096327':'周刊编辑部','-1001612987450':'索引频道','@Nine_7Eleven':'公开频道 Nine7'},
member_monitors:[
{channel_id:'-1001682349051',started_at:nowS()-86400*5,interval:60,last_run:nowS()-1200,member_count:1284,in_count:1271,left_total:13,rt_at:nowS()-180,scan_at:nowS()-1200,capped:true,remote_path:'members/backup.csv',last_error:'',joined_count:12,left_count:3,first_scan_at:nowS()-86400*5},
{channel_id:'@Nine_7Eleven',started_at:nowS()-86400*2,interval:120,last_run:nowS()-7200,member_count:356,in_count:356,left_total:0,rt_at:0,scan_at:nowS()-7200,capped:false,remote_path:'',last_error:'ChatAdminRequired',joined_count:0,left_count:0,first_scan_at:0}
]
};
function mockDB(){try{return JSON.parse(localStorage.getItem('nine7_mock'))||null}catch(e){return null}}
function mockSave(d){try{localStorage.setItem('nine7_mock',JSON.stringify(d))}catch(e){}}
if(DEMO&&!mockDB())mockSave(clone(SEED));
/* ================= api layer ================= */
async function mockApi(path,{method='GET',body={},form}={}){
await delay(120+Math.random()*260);
const db=mockDB();const u=db;
const out=(extra={})=>({ok:true,user:clone(u),...extra});
const m=path.split('?')[0].match(/^\/api\/(\w+)(?:\/(.+))?$/);if(!m)return{ok:false,msg:'未知接口'}; /* 🆕 容忍查询串 */
const[,res,rest]=m;const seg=rest?rest.split('/'):[];
const d=form?Object.fromEntries(form.entries()):body;
if(res==='events'&&method==='DELETE')return{ok:true,msg:'事件记录已清空'};
if(res==='resolve'){
const raw=(new URLSearchParams(path.split('?')[1]||'')).get('ch')||'';
if(!raw)return{ok:false,msg:'请输入频道 ID'};
return{ok:true,id:/^-?\d+$/.test(raw)?raw:'-1001987654321',title:'公开频道 Nine7',
username:'Nine_7Eleven',members:12840,avatar:''};
}
if(res==='data')return{ok:true,user:clone(u),msg_count:3841,webdav_url:'https://dav.example.com/nine7/',userbot:true,
ch_avatars:{},
me_cache:{id:'88888888',name:'演示 用户',username:'nine7_demo',avatar:''},
events:[{type:'sync_done',data:'📤 主频道 → 3 个目标',time:'09:42:11'},
{type:'stats_done',data:'🏆 七月热评榜 已刷新 · 主频道',time:'09:40:02'},
{type:'members_done',data:'👥 分发一号 新增 12 / 退出 3',time:'09:31:48'},
{type:'dir_done',data:'📂 资源标签目录 已更新 → 索引频道(428 个标签)',time:'09:15:07'},
{type:'err',data:'⚠️ 目录推送失败 → 镜像站:message is not modified',time:'08:58:20'},
{type:'sync_edit',data:'✏️ 主频道 编辑已同步至 3 个目标',time:'08:44:36'}],
bot_username:'nine7_bot',me:{id:'88888888',first_name:'演示',last_name:'用户',username:'nine7_demo',photo_url:'',is_premium:true}};
if(res==='admins'){
db.admin_ids=db.admin_ids||['77777777'];
const t=String((d&&d.uid)||'').replace(/\D/g,'');
let msg='';
if(method==='POST'){if(!t)msg='请给出纯数字的 Telegram 用户 ID';
else if(db.admin_ids.includes(t)||t==='88888888')msg=t+' 已经在白名单里了';
else{db.admin_ids.push(t);msg='已放行 '+t};mockSave(db)}
if(method==='DELETE'){if(t==='88888888')msg='不能把自己移出白名单';
else{db.admin_ids=db.admin_ids.filter(x=>x!==t);msg='已移除 '+t};mockSave(db)}
return{ok:!/已经在|不能|请给出/.test(msg),msg,me:'88888888',owner:true,owners:['88888888'],members:db.admin_ids.slice(),
profiles:{'88888888':{id:'88888888',name:'演示 用户',username:'nine7_demo',avatar:''},
'77777777':{id:'77777777',name:'小助手',username:'',avatar:''},
'123456789':{id:'123456789',name:'',username:'',avatar:''}}};
}
if(res==='groups'){
if(method==='POST'){u.groups.push({name:d.name||'',src:d.src,tgt:[].concat(d.tgt||[]).filter(Boolean)});mockSave(db);return out()}
const i=+seg[0];
if(method==='PUT'){u.groups[i]={name:d.name||'',src:d.src,tgt:[].concat(d.tgt||[]).filter(Boolean)};mockSave(db);return out()}
if(method==='DELETE'){u.groups.splice(i,1);mockSave(db);return out()}
}
if(res==='channels'){
if(method==='POST'){u.address_book[d.id]=d.name;mockSave(db);return out()}
const cid=decodeURIComponent(seg[0]||'');
if(method==='PUT'){if(d.id!==cid)delete u.address_book[cid];u.address_book[d.id]=d.name;mockSave(db);return out()}
if(method==='DELETE'){delete u.address_book[cid];mockSave(db);return out()}
}
if(res==='stats'){
if(method==='POST'){u.stats_tasks.push({task_name:d.task_name,channel_id:d.channel_id,msg_id:d.msg_id,table_title:d.table_title,top_n:+d.top_n||10,trigger_tag:d.trigger_tag,interval:+d.interval||15,duration:+d.duration||7,start_time:nowS(),last_run:0,completed_items:[],stats_blacklist:String(d.stats_blacklist||'').split(/[\s\n]+/).filter(Boolean),blacklist_title:d.blacklist_title||''});mockSave(db);return out()}
const i=+seg[0];
if(method==='PUT'){const t=u.stats_tasks[i];const{field,value}=d;
if(field==='add_stats_bl'){t.stats_blacklist=[...new Set([...(t.stats_blacklist||[]),...String(value).split(/[\s\n]+/).filter(Boolean)])]}
else if(field==='rm_stats_bl'){const rm=String(value).split(/[\s\n]+/).filter(Boolean);t.stats_blacklist=(t.stats_blacklist||[]).filter(x=>!rm.includes(x))}
else if(['top_n','interval','duration'].includes(field))t[field]=+value;
else if(field==='extract_blacklist'){t.stats_blacklist=[...new Set([...(t.stats_blacklist||[]),'雾都夜谈','白夜行'])]}
else t[field]=value;
t.last_run=0;mockSave(db);return out()}
if(method==='DELETE'){u.stats_tasks.splice(i,1);mockSave(db);return out()}
}
if(res==='dirs'){
if(method==='POST'){u.dir_tasks.push({task_name:d.task_name,scan_id:d.scan_id,targets:d.targets||[],blacklist:d.blacklist||[],interval:+d.interval||15,last_run:0,tags_cache:[]});mockSave(db);return out()}
const i=+seg[0];const t=u.dir_tasks[i];
if(method==='PUT'){const{field,value}=d;
if(field==='add_blacklist'){t.blacklist=[...new Set([...t.blacklist,...String(value).split(/\s+/).filter(Boolean)])]}
else if(field==='rm_blacklist'){const rm=String(value).split(/\s+/).filter(Boolean);t.blacklist=t.blacklist.filter(x=>!rm.includes(x))}
else if(field==='interval')t.interval=+value;
else if(field==='add_target'){t.targets.push(JSON.parse(value))}
else if(field==='rm_target'){t.targets.splice(+value,1)}
else if(field==='target_msg_id'){const up=JSON.parse(value);t.targets[up.index].msg_id=up.msg_id}
mockSave(db);return out()}
if(method==='DELETE'){u.dir_tasks.splice(i,1);mockSave(db);return out()}
}
if(res==='member_monitors'){
if(method==='POST'&&seg.length===0){u.member_monitors.push({channel_id:d.ch_id,started_at:nowS(),interval:+d.interval||60,last_run:0,member_count:0,remote_path:'',last_error:''});mockSave(db);return out()}
const cid=decodeURIComponent(seg[0]||'');
if(seg[1]==='download')return{ok:true,msg:'备份文件将由机器人发送给你'};
if(seg[1]==='scan')return{ok:true,msg:'已排入队列,10 秒内对一次账'};
if(method==='PUT'){const m2=u.member_monitors.find(x=>String(x.channel_id)===cid);if(m2){m2.interval=+d.interval;m2.last_run=0}mockSave(db);return out()}
if(method==='DELETE'){u.member_monitors=u.member_monitors.filter(x=>String(x.channel_id)!==cid);mockSave(db);return out()}
}
if(res==='member_log'){
const cid=decodeURIComponent(seg[0]||''),mm=u.member_monitors.find(x=>String(x.channel_id)===cid)||{};
const n=mm.member_count||0,names=['夜航星','雾都夜谈','银月','拾柒','老鹿','K','南风','阿波罗'];
const mk=(i,left)=>({id:String(70000+i),name:names[i%names.length]+i,un:'user'+i,bot:false,t:nowS()-i*3600,left:left?nowS()-i*600:0});
const mem=Array.from({length:Math.min(200,n)},(_,i)=>mk(i,i%23===0));
return{ok:true,id:cid,title:cid,members:mem,stats:{total:n,in:mm.in_count||n,left:mm.left_total||0},cap:200,shown:mem.length,matched:n,
capped:!!mm.capped,visible:200,scan_at:mm.scan_at||0,rt_at:mm.rt_at||0,rt_src:'bot',backup_at:nowS()-120,first_scan_at:mm.first_scan_at||0,
log:mem.slice(0,20).map((x,i)=>({t:nowS()-i*900,u:x.id,n:x.name,un:x.un,a:i%5===0?'left':'join',s:i%3===0?'scan':'rt'}))};
}
if(['btn_multi','btn_old','btn_new','btn_new_media','gen_dir','replace_tag','backup','batch_create'].includes(res))return{ok:true};
return{ok:false,msg:'演示模式不支持该操作'};
}
async function api(path,{method='GET',body,form}={}){
if(DEMO)return mockApi(path,{method,body,form});
const opt={method,headers:{'X-Init-Data':INIT}};
if(form)opt.body=form;else if(body){opt.headers['Content-Type']='application/json';opt.body=JSON.stringify(body)}
const r=await fetch(path,opt);
const j=await r.json().catch(()=>({ok:false,msg:'响应解析失败'}));
if(!r.ok||j.ok===false){const err=new Error(j.msg||('HTTP '+r.status));err.status=r.status;throw err}
return j;
}
function applyUser(j){if(j&&j.user){S.data=j.user}}
/* ================= toast ================= */
function toast(msg,type='ok'){
const t=document.createElement('div');t.className='toast '+type;
t.innerHTML=`${I(type==='ok'?'check':type==='err'?'triangle-alert':'info',16)}<span>${esc(msg)}</span>`;
$('#toasts').appendChild(t);icons();
requestAnimationFrame(()=>t.classList.add('in'));
setTimeout(()=>{t.classList.remove('in');setTimeout(()=>t.remove(),500)},3600);
}
/* ================= sheet ================= */
function sheet({title,sub='',body='',foot=''}){
const w=document.createElement('div');w.className='sh-wrap';
w.innerHTML=`<div class="sh-bg"></div><div class="sh"><div class="sh-grip"></div>
<div class="sh-h"><div><div class="sh-t">${title}</div>${sub?`<div class="sh-s">${sub}</div>`:''}</div>
<button class="sh-x" type="button">${I('x',16)}</button></div>
<div class="sh-b">${body}</div>${foot?`<div class="sh-f">${foot}</div>`:''}</div>`;
$('#sheets').appendChild(w);icons();
const close=()=>{w.classList.remove('in');setTimeout(()=>w.remove(),450)};
$('.sh-bg',w).onclick=close;$('.sh-x',w).onclick=close;
w._close=close;
requestAnimationFrame(()=>w.classList.add('in'));
return w;
}
function confirmSheet(msg,onYes,label='确认删除'){
const w=sheet({title:'请确认',body:`<p style="color:var(--mut);font-size:13.5px;line-height:1.7">${esc(msg)}</p>`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-danger" data-a="yes">${I('trash-2',14)} ${label}</button>`});
icons();$('[data-a=no]',w).onclick=w._close;
$('[data-a=yes]',w).onclick=async e=>{e.target.closest('button').disabled=true;await onYes();w._close()};
}
/* field builders */
const fld=(label,inner,hint='')=>`<label class="fld"><span class="fld-l">${label}</span>${inner}${hint?`<span class="fld-h">${hint}</span>`:''}</label>`;
const inp=(id,val='',ph='',type='text',extra='')=>`<input class="inp" id="${id}" type="${type}" value="${esc(val)}" placeholder="${esc(ph)}" ${extra}>`;
const txa=(id,val='',ph='',rows=3)=>`<textarea class="inp" id="${id}" rows="${rows}" placeholder="${esc(ph)}">${esc(val)}</textarea>`;
const gv=(id,el0=document)=>{const n=$('#'+id,el0);return n?n.value.trim():''};
function picker(id,book,selected=''){
const chips=Object.entries(book||{}).map(([cid,nm])=>`<span class="chip pk-c${cid===selected?' on':''}" data-cid="${esc(cid)}">${esc(nm)}</span>`).join('');
return`<div class="pk" id="${id}_pk">${chips||'<span class="fld-h">地址簿为空,请手动输入</span>'}</div>
<div class="pk-manual">${inp(id,selected,CH_HINT)}<span class="fld-h" style="display:block;margin-top:6px">支持 ${esc(CH_HINT)}</span></div>`;
}
function bindPicker(id,root){
const box=$('#'+id,root);
$$('#'+id+'_pk .pk-c',root).forEach(c=>c.onclick=()=>{
$$('#'+id+'_pk .pk-c',root).forEach(x=>x.classList.remove('on'));c.classList.add('on');
if(box){box.value=c.dataset.cid;box.dispatchEvent(new Event('input',{bubbles:true}))}
});
// 🆕 手动输入 t.me 链接 / @用户名,失焦即归一化,并高亮地址簿中对应频道
if(box)box.onchange=()=>{
const v=normCh(box.value);if(v!==box.value)box.value=v;
$$('#'+id+'_pk .pk-c',root).forEach(x=>x.classList.toggle('on',x.dataset.cid===v));
box.dispatchEvent(new Event('input',{bubbles:true}));
};
}
/* ================= router ================= */
const VIEWS={
overview:{cn:'总览',en:'OVERVIEW',icon:'layout-grid',fn:vOverview},
sync:{cn:'同步组',en:'SYNC',icon:'refresh-ccw',fn:vSync},
stats:{cn:'热评榜',en:'STATS',icon:'trophy',fn:vStats},
dirs:{cn:'目录',en:'DIRECTORY',icon:'folder-tree',fn:vDirs},
channels:{cn:'地址簿',en:'CHANNELS',icon:'library',fn:vChannels},
compose:{cn:'发布',en:'COMPOSE',icon:'send',fn:vCompose},
ops:{cn:'运维',en:'OPS',icon:'wrench',fn:vOps},
};
function buildNav(){
$('#nav').innerHTML=Object.entries(VIEWS).map(([k,v],i)=>
`<button class="nav-b${k===S.view?' on':''}" data-v="${k}">${I(v.icon,19)}<em>0${i+1}</em><b class="nav-t">${v.cn}</b></button>`).join('');
$$('#nav .nav-b').forEach(b=>b.onclick=()=>go(b.dataset.v));icons();
}
function go(v){
if(!VIEWS[v])return;S.view=v;
$$('#nav .nav-b').forEach(b=>b.classList.toggle('on',b.dataset.v===v));
const m=$('#view');m.classList.remove('ready');
m.innerHTML='';VIEWS[v].fn(m);
window.scrollTo({top:0});
requestAnimationFrame(()=>{m.classList.add('ready');icons();stagger(m);countUp(m);bindFx(m)});
}
function stagger(root){$$('[data-rv]',root).forEach((el,i)=>el.style.transitionDelay=Math.min(i*55,500)+'ms')}
function vh(idx,cn,en,desc,btn=''){
return`<div class="vh" data-rv><div>
<div class="vh-k">${idx}${en}</div><h1>${cn}<span class="en">${en}</span></h1></div>
<div style="display:flex;flex-direction:column;gap:16px;align-items:flex-end">
${btn}${desc?`<p class="vh-p">${desc}</p>`:''}</div></div>`;
}
function countUp(root){
$$('[data-cnt]',root).forEach(el=>{
const target=+el.dataset.cnt||0;const dur=1100;const t0=performance.now();
const step=t=>{const p=Math.min((t-t0)/dur,1);const e=1-Math.pow(1-p,4);
el.textContent=Math.round(target*e).toLocaleString();if(p<1)requestAnimationFrame(step)};
requestAnimationFrame(step);
});
}
/* ================= 01 overview ================= */
function vOverview(m){
const d=S.data||{},g=(d.groups||[]).length,st=(d.stats_tasks||[]).length,di=(d.dir_tasks||[]).length,ab=Object.keys(d.address_book||{}).length;
const tiles=[
{n:g,l:'同步组',ic:'refresh-ccw',v:'sync',sp:'0,28 14,10 28,20 42,8 56,16 70,6 84,12'},
{n:st,l:'热评榜任务',ic:'trophy',v:'stats',sp:'0,20 14,24 28,12 42,18 56,8 70,14 84,6'},
{n:di,l:'目录任务',ic:'folder-tree',v:'dirs',sp:'0,16 14,22 28,10 42,14 56,20 70,8 84,14'},
{n:ab,l:'地址簿频道',ic:'library',v:'channels',sp:'0,22 14,14 28,18 42,10 56,16 70,10 84,4'},
];
m.innerHTML=`
<section class="hero">
<div class="hero-k" data-rv><span class="dot"></span>TELEGRAM CHANNEL CONSOLE — V5</div>
<h1 class="hero-t" data-rv><span class="l1">Channel</span><span class="l2">频道中枢<span class="acc">.</span></span></h1>
<p class="hero-d" data-rv>同步、榜单、目录、备份,为频道运营者打造的一体化指挥台。双引擎驱动,WebDAV 云端持久化,全量实时事件流。</p>
<div class="hero-b" data-rv>
<button class="btn btn-acc" id="hGo1">${I('arrow-up-right',15)} 进入同步组</button>
<button class="btn" id="hGo2">${I('send',15)} 发布消息</button>
</div>
<svg class="badge-rot" viewBox="0 0 132 132"><defs><path id="cir" d="M66,66 m-52,0 a52,52 0 1,1 104,0 a52,52 0 1,1 -104,0"/></defs>
<text><textPath href="#cir">NINE7 · CHANNEL CONSOLE · OPS · SYNC ·&#160;</textPath></text>
<circle cx="66" cy="66" r="4" fill="var(--acc)"/></svg>
</section>
<div class="mq" data-rv><div class="mq-in">${mqx()}</div></div>
<div class="tiles">${tiles.map((t,i)=>`
<div class="tile" data-rv data-go="${t.v}">
<div class="t-ic">${I(t.ic,20)}</div>
<div class="t-num" data-cnt="${t.n}">0</div>
<div class="t-l">${t.l}</div>
<svg class="t-sp" viewBox="0 0 84 30" preserveAspectRatio="none"><polyline points="${t.sp}" fill="none" stroke="var(--acc)" stroke-width="1.2"/></svg>
</div>`).join('')}
</div>
<div class="grid2">
<div class="panel" data-rv>
<div class="panel-h"><span class="panel-t">${I('server')} 系统状态</span><span class="mono" style="font-size:10px;color:var(--dim)">V5.9</span></div>
<div class="kv"><span class="k">${I('user')} 当前用户</span><span class="v" style="display:flex;align-items:center;gap:8px;justify-content:flex-end">
${(()=>{const u=meInfo();const ini=esc((u.name||'U').slice(0,1).toUpperCase());
return`${u.photo?`<img src="${esc(u.photo)}" referrerpolicy="no-referrer" style="width:22px;height:22px;border-radius:50%;object-fit:cover">`
:`<span style="width:22px;height:22px;border-radius:50%;border:1px solid var(--line2);display:inline-flex;align-items:center;justify-content:center;font:700 10px var(--fm);color:var(--acc)">${ini}</span>`}
<span>${esc(u.name)}${u.uname?' · @'+esc(u.uname):''}</span>`})()}
</span></div>
<div class="kv"><span class="k">${I('cloud')} WebDAV 节点</span><span class="v">${esc(shortId(S.meta.dav||'—'))}</span></div>
<div class="kv"><span class="k">${I('cpu')} Userbot 引擎</span><span class="v ${S.meta.bot?'st-on':'st-off'}">${S.meta.bot?'在线':'离线'}</span></div>
<div class="kv"><span class="k">${I('database')} 消息映射</span><span class="v">${(+S.meta.msg||0).toLocaleString()} 条</span></div>
<div class="kv"><span class="k">${I('hard-drive')} 数据文件</span><span class="v">tg_bot_data_v5.json</span></div>
<div class="kv"><span class="k">${I('shield-check')} 鉴权方式</span><span class="v">${DEMO?'DEMO MODE':'HMAC-SHA256'}</span></div>
</div>
<div class="panel" data-rv>
<div class="panel-h"><span class="panel-t">${I('terminal')} 实时事件流</span>
<span style="display:flex;align-items:center;gap:8px">
<span class="tg tg-sys" id="evN">0/${EV_MAX}</span>
<button class="ic-b" id="evClr" title="清空记录">${I('eraser',13)}</button>
</span></div>
<div class="feed" id="feed"></div>
</div>
</div>
<div class="foot" data-rv><span>NINE7 CONSOLE — BUILD 5.9</span><span>WEBDAV SYNCED · DUAL ENGINE</span><span>MMXXVI</span></div>`;
$('#hGo1',m).onclick=()=>go('sync');
$('#hGo2',m).onclick=()=>go('compose');
$$('.tile',m).forEach(t=>t.onclick=()=>go(t.dataset.go));
renderFeed();
const clr=$('#evClr',m);
if(clr)clr.onclick=()=>confirmSheet('清空事件记录?只删记录,不影响任务。',async()=>{
try{await api('/api/events',{method:'DELETE'});S.events=[];renderFeed();toast('事件记录已清空')}catch(e){toast(e.message,'err')}
});
}
function mqx(){
const items=['实时同步','SYNC','热评榜单','STATS','标签目录','DIRECTORY','智能备份','BACKUP','成员监控','MEMBERS'];
const half=items.map((s,i)=>i%2?`<span><b>${s}</b></span>`:`<span>${s}</span>`).join('');
return half+half;
}
function renderFeed(){
const f=$('#feed');if(!f)return;
const n=$('#evN');if(n)n.textContent=`${S.events.length}/${EV_MAX}`;
f.innerHTML=S.events.length?S.events.slice(0,EV_MAX).map(e=>{
const t=e.type||'sys';
const cls=t.includes('backup')?'tg-bkp':t.includes('dir')?'tg-dir':t.includes('err')?'tg-err':(t==='connected'||t==='sys')?'tg-sys':'tg-sync';
const lb={connected:'SYS',sys:'SYS',backup_done:'BACKUP',dir_done:'DIR',replace_done:'REPLACE',
members_done:'MEMBER',sync_done:'SYNC',sync_edit:'EDIT',stats_done:'STATS',follow:'FOLLOW',err:'ERROR'}[t]||'INFO';
const txt=deEmoji(e.data||'');
return`<div class="feed-l"><span class="tm">${esc(e.time||'--:--:--')}</span><span class="tg ${cls}">${lb}</span><span class="ms" title="${esc(txt)}">${I(EV_ICON[t]||'info',13)}<span>${esc(txt)}</span></span></div>`;
}).join(''):`<div class="feed-l"><span class="tm">--:--:--</span><span class="tg tg-sys">IDLE</span><span class="ms">暂无事件记录</span></div>`;
icons(f);
}
/* ================= 02 sync ================= */
function vSync(m){
const groups=(S.data&&S.data.groups)||[];
m.innerHTML=vh('02','同步组','SYNC GROUPS','源频道发布的内容将实时复制到全部目标频道,编辑亦同步。',
`<button class="btn btn-acc" id="addG">${I('plus',15)} 新建同步组</button>`)+
(groups.length?groups.map((g,i)=>{
const tgts=[].concat(g.tgt||[]);
return`<div class="itm" data-rv><div class="gidx">${String(i+1).padStart(2,'0')}</div>
<div class="itm-t">${esc(g.name||'未命名同步组')}<span class="chip">${I('repeat',11)} ${tgts.length} 个目标</span></div>
<div class="itm-s">${chName(g.src)?`来源:${esc(chName(g.src))}`:'来源未登记地址簿'}</div>
<div class="route"><span class="mono">${chLabel(g.src)}</span>${I('arrow-right',15)}
<span style="display:flex;gap:7px;flex-wrap:wrap">${tgts.map(t=>`<span class="chip">${chLabel(t)}</span>`).join('')}</span></div>
<div class="itm-a">
<button class="btn btn-s" data-e="${i}">${I('pencil',13)} 编辑</button>
<button class="btn btn-s btn-danger" data-d="${i}">${I('trash-2',13)} 删除</button>
</div></div>`;
}).join(''):emptyBox('refresh-ccw','暂无同步组','点击右上角按钮,建立第一条实时同步链路'));
$('#addG',m).onclick=()=>groupSheet();
$$('[data-e]',m).forEach(b=>b.onclick=()=>groupSheet(+b.dataset.e));
$$('[data-d]',m).forEach(b=>b.onclick=()=>confirmSheet('删除后该链路将立即停止同步,且不可恢复。',async()=>{
try{applyUser(await api('/api/groups/'+b.dataset.d,{method:'DELETE'}));toast('同步组已删除');go('sync')}catch(e){toast(e.message,'err')}
}));
}
function groupSheet(idx){
const edit=idx!==undefined;const g=edit?clone(S.data.groups[idx]):{name:'',src:'',tgt:[]};
const w=sheet({title:edit?'编辑同步组':'新建同步组',sub:'源频道 → 多个目标频道的实时复制链路',
body:`
${fld('同步组名称',inp('g_name',g.name,'例如:主站分发'))}
${fld('源频道',picker('g_src',S.data.address_book,g.src))}
<div class="sec-t">目标频道列表</div>
<div id="g_tgts"></div>
<button class="dyn-add" id="g_addT" type="button">${I('plus',13)} 添加目标频道</button>`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} ${edit?'保存修改':'创建链路'}</button>`});
icons();bindPicker('g_src',w);
const book=S.data.address_book||{};
const bookChips=Object.entries(book).map(([cid,nm])=>`<span class="chip pk-c" data-pick="${esc(cid)}">${esc(nm)}</span>`).join('');
const list=$('#g_tgts',w);
const addRow=(val='')=>{const wrap=document.createElement('div');
wrap.innerHTML=`<div class="dyn-row"><input class="inp" placeholder="${esc(CH_HINT)}" value="${esc(val)}"><button class="dyn-x" type="button">${I('x',14)}</button></div>
<div class="fld-h" style="margin:-4px 0 10px 2px"></div>`;
const r=wrap.firstElementChild,tip=wrap.lastElementChild,box=$('.inp',r);
// 🆕 输入框下方实时回显地址簿备注名
const sync=()=>{const v=normCh(box.value);if(v&&v!==box.value)box.value=v;
tip.innerHTML=v?(chName(v)?`📔 ${esc(chName(v))}`:'未登记到地址簿'):''};
box.oninput=()=>{tip.innerHTML=chName(normCh(box.value))?`📔 ${esc(chName(normCh(box.value)))}`:''};
box.onchange=sync;sync();
$('.dyn-x',r).onclick=()=>wrap.remove();list.appendChild(wrap);icons()};
(g.tgt.length?g.tgt:['']).forEach(addRow);
$('#g_addT',w).onclick=()=>addRow();
// 从地址簿一键追加目标
const quick=document.createElement('div');quick.className='pk';quick.style.marginTop='10px';
quick.innerHTML=bookChips?`<span class="fld-h" style="width:100%">从地址簿快速添加:</span>${bookChips}`:'';
$('#g_addT',w).after(quick);
$$('[data-pick]',quick).forEach(c=>c.onclick=()=>addRow(c.dataset.pick));
$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',14)} 处理中`;icons();
const tgt=$$('.dyn-row .inp',list).map(i=>normCh(i.value)).filter(Boolean);
const body={name:gv('g_name',w),src:normCh(gv('g_src',w)),tgt};
if(!body.src||!tgt.length){toast('请填写源频道与至少一个目标','err');btn.disabled=false;btn.innerHTML=`${I('check',14)} 重试`;icons();return}
try{
applyUser(await api(edit?'/api/groups/'+idx:'/api/groups',{method:edit?'PUT':'POST',body}));
toast(edit?'同步组已更新':'同步链路已建立');w._close();go('sync');
}catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'}
};
}
function emptyBox(ic,t,s){return`<div class="empty" data-rv>${I(ic,34)}<div class="e-t">${t}</div><div class="e-s">${s}</div></div>`}
/* ================= 03 stats ================= */
function vStats(m){
const tasks=(S.data&&S.data.stats_tasks)||[];
m.innerHTML=vh('03','热评榜','LEADERBOARDS','基于评论反应数的自动榜单,按周期刷新并追加到目标消息。',
`<button class="btn btn-acc" id="addS">${I('plus',15)} 新建榜单</button>`)+
(tasks.length?tasks.map((t,i)=>{
const done=(t.completed_items||[]).length,top=t.top_n||10;
return`<div class="itm" data-rv><div class="gidx">${String(i+1).padStart(2,'0')}</div>
<div class="itm-t">${esc(t.task_name||'未命名榜单')}<span class="chip">${I('hash',11)} ${esc(t.trigger_tag||'—')}</span></div>
<div class="itm-s mono">${chLabel(t.channel_id)} · 消息 #${esc(t.msg_id)}</div>
<div class="meta">
<span class="chip">${I('trophy',11)} TOP ${top}</span>
<span class="chip">${I('timer',11)}${t.interval||60} 分钟</span>
<span class="chip">${I('calendar-clock',11)} ${t.duration||7} 天周期</span>
<span class="chip">${I('shield-off',11)} 屏蔽 ${(t.stats_blacklist||[]).length}</span>
<span class="chip">${I('history',11)} 上次 ${fmtT(t.last_run)}</span>
</div>
<div class="prog"><i style="width:${Math.min(done/top*100,100)}%"></i></div>
<div class="itm-s" style="margin-top:8px">已完成 ${done} / ${top}${t.blacklist_title?` · ${esc(t.blacklist_title)}`:''}</div>
<div class="itm-a">
<button class="btn btn-s" data-e="${i}">${I('pencil',13)} 编辑</button>
<button class="btn btn-s btn-danger" data-d="${i}">${I('trash-2',13)} 删除</button>
</div></div>`;
}).join(''):emptyBox('trophy','暂无榜单任务','创建热评榜,让频道互动数据自动更新'));
$('#addS',m).onclick=()=>statSheet();
$$('[data-e]',m).forEach(b=>b.onclick=()=>statSheet(+b.dataset.e));
$$('[data-d]',m).forEach(b=>b.onclick=()=>confirmSheet('删除后榜单将停止自动更新。',async()=>{
try{applyUser(await api('/api/stats/'+b.dataset.d,{method:'DELETE'}));toast('榜单任务已删除');go('stats')}catch(e){toast(e.message,'err')}
}));
}
function statSheet(idx){
const edit=idx!==undefined;const t=edit?clone(S.data.stats_tasks[idx]):{};
const w=sheet({title:edit?'编辑榜单任务':'新建热评榜',sub:'反应数排行 · 自动去重 · 完成项划线',
body:`
<div class="sec-t">基本信息</div>
<div class="frm-grid">
${fld('任务名称',inp('s_name',t.task_name,'例如:七月热评榜'))}
${fld('榜单标题',inp('s_title',t.table_title,'例如:本周热评 TOP10'))}
</div>
${fld('所在频道',picker('s_ch',S.data.address_book,t.channel_id))}
<div class="frm-grid">
${fld('消息 ID / 链接',inp('s_msg',t.msg_id,'1024 或 https://t.me/xxx/1024'))}
${fld('触发标签',inp('s_trig',t.trigger_tag,'#更新'))}
</div>
<div class="sec-t">运行参数</div>
<div class="frm-grid">
${fld('上榜名额',inp('s_top',t.top_n??10,'10','number','min="1"'))}
${fld('更新频率(分钟)',inp('s_intv',t.interval??15,'15','number','min="1"'))}
</div>
${fld('存活期限(天)',inp('s_dura',t.duration??7,'7','number','min="1"'))}
<div class="sec-t">屏蔽名单</div>
${fld(edit?'点击名字即可解除屏蔽':'当前屏蔽',`<div class="tagcloud">${(t.stats_blacklist||[]).map(b=>edit
?`<span class="chip pk-c" data-rmbl="${esc(b)}" title="点击移除屏蔽">${esc(b)} ${I('x',11)}</span>`
:`<span class="chip">${esc(b)}</span>`).join('')||'<span class="fld-h">暂无屏蔽</span>'}</div>`)}
${fld('追加屏蔽',txa('s_abl','','空格或换行分隔',2))}
${fld('屏蔽区标题',inp('s_blt',t.blacklist_title,'留空则不显示标题'))}
${!edit?fld('初始屏蔽名单(可选)',txa('s_bl','','空格或换行分隔',2)):''}
${fld('从榜单提取屏蔽(可选)',inp('s_ext','','上期榜单消息 ID 或链接'),'将解析斜体名单并加入屏蔽')}`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} ${edit?'保存修改':'创建榜单'}</button>`});
icons();bindPicker('s_ch',w);
// 🆕 点击屏蔽名字直接移除
$$('[data-rmbl]',w).forEach(c=>c.onclick=async()=>{
const name=c.dataset.rmbl;c.style.opacity='.4';c.style.pointerEvents='none';
try{applyUser(await api('/api/stats/'+idx,{method:'PUT',body:{field:'rm_stats_bl',value:name}}));
toast(`已解除屏蔽 ${name}`);c.remove();go('stats')}
catch(e){toast(e.message,'err');c.style.opacity='';c.style.pointerEvents=''}
});
$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',14)} 处理中`;icons();
try{
if(!edit){
const ref=msgRef(gv('s_msg',w));
const body={task_name:gv('s_name',w),channel_id:normCh(gv('s_ch',w))||ref.ch,msg_id:ref.id||gv('s_msg',w),table_title:gv('s_title',w),
trigger_tag:gv('s_trig',w),top_n:gv('s_top',w),interval:gv('s_intv',w),duration:gv('s_dura',w),
stats_blacklist:gv('s_bl',w),blacklist_title:gv('s_blt',w),extract_from_msg:gv('s_ext',w)};
if(!body.task_name||!body.channel_id||!body.msg_id)throw new Error('名称、频道与消息 ID 必填');
applyUser(await api('/api/stats',{method:'POST',body}));toast('榜单任务已创建');
}else{
const cur=S.data.stats_tasks[idx];const puts=[];
const map={task_name:gv('s_name',w),table_title:gv('s_title',w),channel_id:normCh(gv('s_ch',w)),msg_id:gv('s_msg',w),trigger_tag:gv('s_trig',w)};
for(const[f,v]of Object.entries(map))if(String(v)!==String(cur[f]??''))puts.push({field:f,value:v});
for(const f of['top_n','interval','duration']){const id={top_n:'s_top',interval:'s_intv',duration:'s_dura'}[f];
if(+gv(id,w)!==+cur[f])puts.push({field:f,value:gv(id,w)})}
if(String(gv('s_blt',w))!==String(cur.blacklist_title??''))puts.push({field:'blacklist_title',value:gv('s_blt',w)||'无'});
if(gv('s_abl',w))puts.push({field:'add_stats_bl',value:gv('s_abl',w)});
if(gv('s_ext',w))puts.push({field:'extract_blacklist',value:gv('s_ext',w)});
let last=null;for(const p of puts)last=await api('/api/stats/'+idx,{method:'PUT',body:p});
if(last)applyUser(last);toast(puts.length?'榜单已更新':'无变更');
}
w._close();go('stats');
}catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'}
};
}
/* ================= 04 dirs ================= */
function vDirs(m){
const tasks=(S.data&&S.data.dir_tasks)||[];
m.innerHTML=vh('04','标签目录','DIRECTORIES','扫描源频道全部标签,按拼音字母归类,自动推送到多个承载消息。',
`<button class="btn btn-acc" id="addD">${I('plus',15)} 新建目录</button>`)+
(tasks.length?tasks.map((t,i)=>{
const tags=t.tags_cache||[],tgts=t.targets||[];
return`<div class="itm" data-rv><div class="gidx">${String(i+1).padStart(2,'0')}</div>
<div class="itm-t">${esc(t.task_name||'未命名目录')}<span class="chip">${I('tags',11)} ${tags.length} 个标签</span></div>
<div class="itm-s mono">扫描 ${chLabel(t.scan_id)} · 每 ${t.interval||15} 分钟</div>
<div class="meta">
${tgts.map(x=>`<span class="chip">${I('file-text',11)} ${chLabel(x.channel_id,{icon:false})} · #${esc(x.msg_id)}</span>`).join('')}
${(t.blacklist||[]).map(b=>`<span class="chip">${I('shield-off',11)} ${esc(b)}</span>`).join('')}
</div>
${tags.length?`<div class="tagcloud">${tags.slice(0,18).map(x=>`<span class="chip">${esc(x)}</span>`).join('')}${tags.length>18?`<span class="chip">+${tags.length-18}</span>`:''}</div>`:''}
<div class="itm-a">
<button class="btn btn-s" data-e="${i}">${I('pencil',13)} 编辑</button>
<button class="btn btn-s btn-danger" data-d="${i}">${I('trash-2',13)} 删除</button>
</div></div>`;
}).join(''):emptyBox('folder-tree','暂无目录任务','创建目录,标签索引将自动维护更新'));
$('#addD',m).onclick=()=>dirSheet();
$$('[data-e]',m).forEach(b=>b.onclick=()=>dirSheet(+b.dataset.e));
$$('[data-d]',m).forEach(b=>b.onclick=()=>confirmSheet('删除后目录将停止自动更新。',async()=>{
try{applyUser(await api('/api/dirs/'+b.dataset.d,{method:'DELETE'}));toast('目录任务已删除');go('dirs')}catch(e){toast(e.message,'err')}
}));
}
function dirSheet(idx){
const edit=idx!==undefined;const t=edit?clone(S.data.dir_tasks[idx]):{targets:[],blacklist:[]};
const w=sheet({title:edit?'编辑目录任务':'新建标签目录',sub:'全量扫描 · 拼音归类 · 多目标推送',
body:`
${!edit?`${fld('任务名称',inp('d_name','','例如:资源标签目录'))}
${fld('扫描频道',picker('d_scan',S.data.address_book,''))}
${fld('扫描频率(分钟)',inp('d_intv',15,'15','number','min="1"'),'新建时即可设定,之后可在编辑里修改')}
<div class="sec-t">承载目标(频道 + 消息)</div>
<div id="d_tgts"></div>
<button class="dyn-add" id="d_addT" type="button">${I('plus',13)} 添加承载目标</button>
<div style="height:14px"></div>
${fld('屏蔽标签(可选)',txa('d_bl','','空格分隔,如:#广告 #闲置',2))}`
:`
${fld('扫描频率(分钟)',inp('d_intv',t.interval??15,'15','number','min="1"'))}
<div class="sec-t">承载目标管理</div>
<div class="tagcloud" style="margin:0 0 14px">${(t.targets||[]).map((x,ti)=>`<span class="chip">${chLabel(x.channel_id,{icon:false})} · #${esc(x.msg_id)}
<a data-rt="${ti}" style="cursor:pointer;display:inline-flex">${I('x',11)}</a></span>`).join('')||'<span class="fld-h">暂无目标</span>'}</div>
<div class="frm-grid">
${fld('新增目标频道',inp('d_ntch','',CH_HINT))}
${fld('承载消息 ID',inp('d_ntmsg','','消息 ID 或链接'))}
</div>
<div class="sec-t">屏蔽标签</div>
${fld('点击标签即可解除屏蔽',`<div class="tagcloud" style="margin:0">${(t.blacklist||[]).map(b=>`<span class="chip pk-c" data-rmbl="${esc(b)}" title="点击移除屏蔽">${esc(b)} ${I('x',11)}</span>`).join('')||'<span class="fld-h">暂无屏蔽</span>'}</div>`)}
${fld('追加屏蔽',inp('d_abl','','空格分隔,如:#广告 #闲置'))}`}`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} ${edit?'保存修改':'创建目录'}</button>`});
icons();
if(!edit){
bindPicker('d_scan',w);
const list=$('#d_tgts',w);
const addRow=(ch='',msg='')=>{const r=document.createElement('div');r.className='dyn-row';
r.innerHTML=`<input class="inp" placeholder="频道 ID / @用户名 / 链接" value="${esc(ch)}" style="flex:1.2"><input class="inp" placeholder="消息 ID / 链接" value="${esc(msg)}" style="flex:.8"><button class="dyn-x" type="button">${I('x',14)}</button>`;
$('.dyn-x',r).onclick=()=>r.remove();list.appendChild(r);icons()};
addRow();$('#d_addT',w).onclick=()=>addRow();
}else{
$$('[data-rt]',w).forEach(a=>a.onclick=async()=>{
try{applyUser(await api('/api/dirs/'+idx,{method:'PUT',body:{field:'rm_target',value:a.dataset.rt}}));toast('目标已移除');w._close();dirSheet(idx)}
catch(e){toast(e.message,'err')}
});
// 🆕 点击屏蔽标签直接移除,无需再手动输入
$$('[data-rmbl]',w).forEach(c=>c.onclick=async()=>{
const tag=c.dataset.rmbl;c.style.opacity='.4';c.style.pointerEvents='none';
try{applyUser(await api('/api/dirs/'+idx,{method:'PUT',body:{field:'rm_blacklist',value:tag}}));
toast(`已解除屏蔽 ${tag}`);c.remove();go('dirs')}
catch(e){toast(e.message,'err');c.style.opacity='';c.style.pointerEvents=''}
});
}
$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',14)} 处理中`;icons();
try{
if(!edit){
const rows=$$('#d_tgts .dyn-row',w);
const targets=rows.map(r=>{const ii=$$('.inp',r);const ref=msgRef(ii[1].value);
return{channel_id:normCh(ii[0].value)||ref.ch,msg_id:ref.id||ii[1].value.trim()}}).filter(x=>x.channel_id&&x.msg_id);
const body={task_name:gv('d_name',w),scan_id:normCh(gv('d_scan',w)),targets,
interval:+gv('d_intv',w)||15,blacklist:gv('d_bl',w).split(/\s+/).filter(Boolean)};
if(!body.task_name||!body.scan_id||!targets.length)throw new Error('名称、扫描频道与至少一个目标必填');
applyUser(await api('/api/dirs',{method:'POST',body}));toast('目录任务已创建');
}else{
const cur=S.data.dir_tasks[idx];let last=null;
if(+gv('d_intv',w)!==+cur.interval)last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'interval',value:gv('d_intv',w)}});
if(gv('d_abl',w))last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'add_blacklist',value:gv('d_abl',w)}});
if(gv('d_ntch',w)&&gv('d_ntmsg',w))last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'add_target',value:JSON.stringify({channel_id:normCh(gv('d_ntch',w)),msg_id:gv('d_ntmsg',w)})}});
if(last)applyUser(last);toast('目录任务已更新');
}
w._close();go('dirs');
}catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'}
};
}
/* ================= 05 channels ================= */
function vChannels(m){
const ab=(S.data&&S.data.address_book)||{};
const ents=Object.entries(ab);
m.innerHTML=vh('05','地址簿','ADDRESS BOOK','常用频道的快速索引,各模块可直接引用。',
`<button class="btn" id="batch">${I('layers',15)} 批量建频道</button>`)+
`<div class="ch-grid">
<button class="add-c" id="addC" data-rv>${I('plus',22)}<span style="font:600 12px var(--fb)">收藏频道</span></button>
${ents.map(([cid,nm],i)=>`<div class="ch-c" data-rv>
<div class="ch-hd">${ava(chAvatar(cid),nm,'sq lg')}<div class="nm" style="margin:0">${esc(nm)}</div></div>
<div class="cid">${esc(cid)}</div>
<div class="ops">
<button class="ic-b" data-c="${i}" title="复制 ID">${I('copy',14)}</button>
<button class="ic-b" data-e="${i}" title="编辑">${I('pencil',14)}</button>
<button class="ic-b danger" data-d="${i}" title="删除">${I('trash-2',14)}</button>
</div></div>`).join('')}
</div>`;
$('#addC',m).onclick=()=>chSheet();
$('#batch',m).onclick=()=>batchSheet();
$$('[data-c]',m).forEach(b=>b.onclick=async()=>{
try{await navigator.clipboard.writeText(ents[+b.dataset.c][0]);toast('频道 ID 已复制')}catch(e){toast('复制失败','err')}
});
$$('[data-e]',m).forEach(b=>b.onclick=()=>chSheet(ents[+b.dataset.e][0]));
$$('[data-d]',m).forEach(b=>b.onclick=()=>{const cid=ents[+b.dataset.d][0];
confirmSheet(`将「${ab[cid]}」从地址簿移除?`,async()=>{
try{applyUser(await api('/api/channels/'+encodeURIComponent(cid),{method:'DELETE'}));toast('已移除');go('channels')}catch(e){toast(e.message,'err')}
})});
}
/* 🆕 频道头像:后端认过一次就缓存在 avatar_cache/,地址由 /api/data 下发 */
function chAvatar(cid){return (S.chAva&&S.chAva[cid])||''}
function chSheet(cid){
const edit=cid!==undefined;const nm=edit?S.data.address_book[cid]:'';
const w=sheet({title:edit?'编辑频道':'收藏频道',sub:'填 ID / @用户名 / 链接,自动认出频道名和头像',
body:`${fld('频道 ID / 链接',inp('c_id',edit?cid:'',CH_HINT),'支持 '+CH_HINT)}
<div id="rsBox"></div>
${fld('备注名',inp('c_name',nm,'留空则用识别出的频道名'))}`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} 保存</button>`});
icons();$('[data-a=no]',w).onclick=w._close;
/* 🆕 输入频道后自动识别:带出真实名称、成员数与头像 */
let rsTimer=null,rsLast='';
const rsBox=$('#rsBox',w);
const rsShow=h=>{rsBox.innerHTML=h;icons()};
async function resolveCh(){
const raw=(gv('c_id',w)||'').trim();
if(!raw||raw===rsLast)return;
rsLast=raw;
rsShow(`<div class="rs-box"><span class="ava sq">…</span><div><div class="t">识别中…</div><div class="s">${esc(normCh(raw)||raw)}</div></div></div>`);
try{
const j=await api('/api/resolve?ch='+encodeURIComponent(raw));
if(!j.ok){rsShow(`<div class="rs-box" style="border-color:rgba(255,93,93,.35)">${I('triangle-alert',16)}<div><div class="t">认不出这个频道</div><div class="s">${esc(j.msg||'请检查 ID 或把机器人加进频道')}</div></div></div>`);return}
S.chAva=S.chAva||{};if(j.avatar)S.chAva[j.id]=j.avatar;
rsShow(`<div class="rs-box">${ava(j.avatar,j.title,'sq lg')}
<div style="min-width:0"><div class="t">${esc(j.title||'未命名')}</div>
<div class="s">${j.username?'@'+esc(j.username)+' · ':''}${esc(j.id)}${j.members?' · '+j.members.toLocaleString()+' 人':''}</div></div></div>`);
const nameEl=$('#c_name',w);
if(nameEl&&!nameEl.value.trim()&&j.title)nameEl.value=j.title; /* 备注名留空就自动填 */
const idEl=$('#c_id',w);
if(idEl&&j.id&&!/^@/.test(raw))idEl.value=j.id; /* 统一成规范 ID */
}catch(e){rsShow(`<div class="rs-box" style="border-color:rgba(255,93,93,.35)">${I('triangle-alert',16)}<div><div class="t">识别失败</div><div class="s">${esc(e.message)}</div></div></div>`)}
}
const idIn=$('#c_id',w);
if(idIn){
idIn.addEventListener('input',()=>{clearTimeout(rsTimer);rsTimer=setTimeout(resolveCh,700)});
idIn.addEventListener('blur',resolveCh);
if(edit)resolveCh();
}
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
const rsT=$('#rsBox .t',w);
const body={id:normCh(gv('c_id',w)),name:gv('c_name',w)||(rsT?rsT.textContent.trim():'')};
if(!body.id)throw new Error('请填写频道 ID / 链接');
if(!body.name||body.name==='识别中…')throw new Error('填个备注名,或等自动识别出频道名');
applyUser(await api(edit?'/api/channels/'+encodeURIComponent(cid):'/api/channels',{method:edit?'PUT':'POST',body}));
toast('地址簿已更新');w._close();go('channels');
}catch(err){toast(err.message,'err');btn.disabled=false}
};
}
function batchSheet(){
const w=sheet({title:'批量建频道',sub:'由 Userbot 自动创建并登记到地址簿,后台异步执行',
body:`
${fld('频道名称(每行一个)',txa('b_names','','例如:\n分站\n资源库',4))}
${fld('追加管理员(可选)',txa('b_users','','用户 ID 或用户名,空格分隔',2))}
${fld('每个名称创建数量',inp('b_count',1,'1','number','min="1" max="20"'),'大于 1 时自动追加序号')}`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('zap',14)} 开始创建</button>`});
icons();$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
if(!gv('b_names',w))throw new Error('请输入频道名称');
await api('/api/batch_create',{method:'POST',body:{names:gv('b_names',w),users:gv('b_users',w),count:+gv('b_count',w)||1}});
toast('批量创建已在后台执行');w._close();
}catch(err){toast(err.message,'err');btn.disabled=false}
};
}
/* ================= 🆕 Telegram 文本格式工具条(点击即套用,无需手写标签) ================= */
/* Telegram Bot API 官方支持的全部 HTML 格式 */
const FMT_ITEMS=[
{k:'b', ic:'bold', ti:'加粗(Ctrl+B)', open:'<b>', close:'</b>'},
{k:'i', ic:'italic', ti:'斜体(Ctrl+I)', open:'<i>', close:'</i>'},
{k:'u', ic:'underline', ti:'下划线(Ctrl+U)', open:'<u>', close:'</u>'},
{k:'s', ic:'strikethrough', ti:'删除线', open:'<s>', close:'</s>'},
{k:'sp', ic:'eye-off', ti:'剧透遮罩(点击才显示)', open:'<tg-spoiler>', close:'</tg-spoiler>'},
{k:'a', ic:'link', gp:1, ti:'超链接(Ctrl+K):选中文字后点此,再粘贴网址', link:1},
{k:'mt', ic:'at-sign', ti:'提及用户:tg://user?id= 形式的内联链接', mention:1},
{k:'c', ic:'code', gp:1, ti:'行内等宽代码', open:'<code>', close:'</code>'},
{k:'pre',ic:'code-xml', ti:'代码块(光标停在 language- 后,可直接写 python 等语言)', open:'<pre><code class="language-">', close:'</code></pre>', blk:1, cur:-2},
{k:'q', ic:'text-quote', ti:'引用块', open:'<blockquote>', close:'</blockquote>', blk:1},
{k:'qe', ic:'chevrons-down-up',ti:'可折叠引用块', open:'<blockquote expandable>', close:'</blockquote>', blk:1},
{k:'clr',ic:'remove-formatting',gp:1,ti:'清除所选文字的全部格式', clr:1},
];
const FMT_TAGS=/<\/?(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji|span)(?:\s[^>]*)?>/gi;
/* 对 textarea 的当前选区套用 / 取消一种格式 */
function fmtApply(ta,it){
if(!ta||!it)return;
const v=ta.value,s=ta.selectionStart,e=ta.selectionEnd,sel=v.slice(s,e);
// ins=插入内容, a/b=插入后要选中的相对区间
const put=(ins,a,b)=>{ta.value=v.slice(0,s)+ins+v.slice(e);ta.setSelectionRange(s+a,s+(b==null?a:b))};
if(it.clr){
const cl=sel.replace(FMT_TAGS,'');put(cl,0,cl.length);
}else if(it.link||it.mention){
const isUrl=/^(https?:\/\/|tg:\/\/)\S+$/i.test(sel.trim());
const href=it.mention?'tg://user?id=':(isUrl?sel.trim():'https://');
const txt=(isUrl||!sel)?(it.mention?'@某人':'链接文字'):sel;
const open=`<a href="${href}">`;
put(open+txt+'</a>',
isUrl?open.length:9, // '<a href="'.length === 9
isUrl?open.length+txt.length:9+href.length);// 选中网址占位符,直接粘贴覆盖
}else{
let pre='',suf='';
if(it.blk){ // 引用 / 代码块独占一行,效果才和 Telegram 一致
if(s>0&&v[s-1]!=='\n')pre='\n';
if(e<v.length&&v[e]!=='\n')suf='\n';
}
if(!sel){ // 没选中文字 → 插入一对标签,光标落在中间
const cur=pre.length+it.open.length+(it.cur||0);
put(pre+it.open+it.close+suf,cur);
}else if(sel.startsWith(it.open)&&sel.endsWith(it.close)){ // 已套用 → 再点一次取消
const inner=sel.slice(it.open.length,sel.length-it.close.length);
put(inner,0,inner.length);
}else{
put(pre+it.open+sel+it.close+suf,pre.length+it.open.length,pre.length+it.open.length+sel.length);
}
}
ta.focus();
ta.dispatchEvent(new Event('input',{bubbles:true})); // 触发实时预览刷新
}
function fmtBar(id){
return`<div class="fmt" data-fmt="${id}">${FMT_ITEMS.map((it,ix)=>
`<button type="button" class="${it.gp?'gp':''}" data-fi="${ix}" title="${esc(it.ti)}" aria-label="${esc(it.ti)}">${I(it.ic,13)}</button>`).join('')}</div>`;
}
/* 🆕 媒体附件字段:用 <label> 包住铺满的 file input,原生唤起选择器 */
const fldFile=(label,id,txt,hint='')=>`<div class="fld"><span class="fld-l">${label}</span>
<label class="file-drop" for="${id}">${I('paperclip',22)}<div>${esc(txt)}</div><input class="file-in" type="file" id="${id}"></label>
<div class="file-name" id="${id}_nm" style="display:none"></div>${hint?`<span class="fld-h">${hint}</span>`:''}</div>`;
function bindFile(root,id,onChange){
const f=$('#'+id,root),nm=$('#'+id+'_nm',root);
if(!f||!nm)return;
const clear=()=>{f.value='';nm.style.display='none';nm.innerHTML='';onChange&&onChange()};
f.addEventListener('change',()=>{
const x=f.files&&f.files[0];
if(!x){clear();return}
nm.style.display='flex';
nm.innerHTML=`${I('file-up',14)} <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.name)}</span>
<span style="color:var(--dim);flex:none">${(x.size/1048576).toFixed(2)} MB</span>
<button class="fx" type="button" title="移除附件">${I('x',12)}</button>`;
icons(nm);$('.fx',nm).onclick=clear;
onChange&&onChange();
});
}
/* 带工具条的正文输入框 */
const fldFmt=(label,id,ph,rows=4,hint='')=>`<div class="fld"><span class="fld-l">${label}</span>${fmtBar(id)}<textarea class="inp" id="${id}" rows="${rows}" placeholder="${esc(ph)}"></textarea>${hint?`<span class="fmt-h">${hint}</span>`:''}</div>`;
function bindFmt(root,id){
const bar=$(`.fmt[data-fmt="${id}"]`,root),ta=$('#'+id,root);
if(!bar||!ta)return;
bar.addEventListener('mousedown',ev=>{if(ev.target.closest('button'))ev.preventDefault()}); // 保住 textarea 选区
bar.addEventListener('click',ev=>{const b=ev.target.closest('button[data-fi]');if(!b)return;
ev.preventDefault();fmtApply(ta,FMT_ITEMS[+b.dataset.fi])});
ta.addEventListener('keydown',ev=>{ // 快捷键
if(!(ev.ctrlKey||ev.metaKey)||ev.altKey)return;
const k={b:'b',i:'i',u:'u',k:'a'}[(ev.key||'').toLowerCase()];if(!k)return;
const it=FMT_ITEMS.find(x=>x.k===k);if(!it)return;
ev.preventDefault();fmtApply(ta,it);
});
}
/* ================= 🆕 实时预览(Telegram 消息外观) ================= */
const PV_ALLOWED=['b','strong','i','em','u','ins','s','strike','del','a','code','pre','blockquote','br','span','tg-spoiler','tg-emoji'];
/* 把用户写的 HTML 安全地渲染成预览:只放行 Telegram 支持的标签,其余转义显示 */
function pvText(raw){
const src=String(raw??'');
if(!src.trim())return'';
const bad=[];
// 先整体转义,再把白名单标签还原,杜绝脚本注入
// 属性里的引号已被转义成 &quot;,所以属性段要允许 & 出现,只在遇到 &gt; 时收尾
let out=esc(src).replace(/&lt;(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:\s(?:(?!&gt;)[\s\S])*?)?)\s*(\/?)&gt;/g,(mm,slash,tag,attrs,selfc)=>{
const t=tag.toLowerCase();
if(!PV_ALLOWED.includes(t)){bad.push(t);return mm}
if(t==='a'){
const hm=attrs.match(/href\s*=\s*(?:&quot;([\s\S]*?)&quot;|'([^']*?)'|([^\s]+))/i)||[];
const href=(hm[1]||hm[2]||hm[3]||'').replace(/&amp;/g,'&');
return slash?'</a>':`<a href="${esc(href)}" target="_blank" rel="noreferrer">`;
}
if(t==='tg-spoiler')return slash?'</span>':'<span class="sp">';
if(t==='span'){
if(/tg-spoiler/i.test(attrs))return slash?'</span>':'<span class="sp">';
return slash?'</span>':'<span>';
}
if(t==='tg-emoji')return slash?'</span>':'<span>';
if(t==='br')return'<br>';
return`<${slash}${t}>`;
});
const uniq=[...new Set(bad)];
if(uniq.length)out+=`<div class="warn">⚠️ Telegram 不支持标签:${esc(uniq.join(', '))}(将原样显示)</div>`;
return out;
}
function pvRender(host,{ch,text,buttons=[],file=null,fileUrl='',mode='send'}){
if(!host)return;
const nm=chName(ch)||(ch?String(ch):'未选择频道');
const kind=file?(file.type.startsWith('image/')?'image':file.type.startsWith('video/')?'video':'file'):'';
let media='';
if(kind==='image')media=`<img class="bub-md" src="${fileUrl}" alt="">`;
else if(kind==='video')media=`<video class="bub-md" src="${fileUrl}" muted playsinline controls></video>`;
else if(kind==='file')media=`<div class="bub-file">${I('file',16)}<span>${esc(file.name)} · ${(file.size/1048576).toFixed(2)} MB</span></div>`;
const btns=buttons.length?`<div class="bub-btns">${buttons.map(b=>{
const ok=/^https?:\/\/|^tg:\/\//i.test(b.url);
return`<div class="bb${ok?'':' bad'}">${I(ok?'external-link':'triangle-alert',12)} ${esc(b.text||'(缺少文字)')}</div>`}).join('')}</div>`:'';
const now=new Date();const hm=`${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`;
const len=String(text||'').length;const lim=file?1024:4096;
host.innerHTML=`<div class="pv-h">
<span class="pt">${I('eye',13)} 实时预览</span>
<span class="pc">${len}/${lim}${len>lim?' ⚠️ 超长':''}</span>
</div>
<div class="bub">
<div class="bub-top"><span class="ic">${esc((nm||'C').slice(0,1).toUpperCase())}</span><span class="nm">${esc(nm)}</span></div>
${media}
<div class="bub-tx">${pvText(text)}</div>
<div class="bub-meta">${mode==='edit'?'已编辑 · ':''}${hm}</div>
${btns}
</div>`;
icons(host);
}
/* 绑定一组输入到预览面板,输入即刷新 */
function bindPreview(root,{chId,textId,btnsId,fileId,hostId,mode='send'}){
const host=$('#'+hostId,root);if(!host)return;
let url='';
const paint=()=>{
const f=($('#'+fileId,root)||{}).files&&$('#'+fileId,root).files[0]||null;
if(f){if(url)URL.revokeObjectURL(url);url=URL.createObjectURL(f)}else if(url){URL.revokeObjectURL(url);url=''}
pvRender(host,{ch:normCh(gv(chId,root)),text:($('#'+textId,root)||{}).value||'',
buttons:$$('#'+btnsId+' .dyn-row',root).map(r=>{const ii=$$('.inp',r);return{text:ii[0].value.trim(),url:ii[1].value.trim()}}).filter(b=>b.text||b.url),
file:f,fileUrl:url,mode});
};
root.addEventListener('input',e=>{if(e.target.closest('#'+btnsId)||['#'+chId,'#'+textId].some(s=>e.target.matches(s)))paint()});
root.addEventListener('change',paint);
root.addEventListener('click',e=>{if(e.target.closest('.dyn-x,.dyn-add,.pk-c'))setTimeout(paint,30)});
paint();return paint;
}
/* ================= 06 compose ================= */
function vCompose(m){
m.innerHTML=vh('07','发布台','COMPOSE','发送或编辑带按钮的消息,支持图片、视频与文档附件。')+
`<div class="cmp-grid">
<div class="cmp" data-rv>
<div class="cmp-t">${I('send')} 发送新消息</div>
<div class="cmp-s">正文支持 HTML 标签,按钮以整行排列</div>
${fld('目标频道',picker('n_ch',S.data.address_book,''))}
${fldFmt('消息正文','n_text','选中文字后点上方按钮套用格式',5,'选中文字 → 点按钮套用(再点一次取消);未选中则插入标签对。<b>Ctrl+B/I/U/K</b> 亦可。')}
<div class="sec-t">链接按钮</div>
<div id="n_btns"></div>
<button class="dyn-add" id="n_addB" type="button">${I('plus',13)} 添加按钮</button>
<div style="height:14px"></div>
${fldFile('媒体附件(可选)','n_file','点击选择图片 / 视频 / 文档','带附件时正文作为图注,上限 1024 字')}
<div class="pv" id="n_pv"></div>
<div style="height:16px"></div>
<button class="btn btn-acc" id="n_send" style="width:100%;justify-content:center">${I('send',15)} 发送到频道</button>
</div>
<div class="cmp" data-rv>
<div class="cmp-t">${I('square-pen')} 编辑旧消息</div>
<div class="cmp-s">更新按钮、正文,或直接替换媒体内容</div>
${fld('所在频道',picker('o_ch',S.data.address_book,''))}
${fld('消息 ID / 链接',inp('o_msg','','1024 或 https://t.me/xxx/1024'),'粘贴完整链接可自动识别频道')}
${fldFmt('新正文(留空则不改动文字)','o_text','选中文字后点上方按钮套用格式',4,'与左侧一致:选中文字点按钮即套用格式。')}
<div class="sec-t">链接按钮</div>
<div id="o_btns"></div>
<button class="dyn-add" id="o_addB" type="button">${I('plus',13)} 添加按钮</button>
<div style="height:14px"></div>
${fldFile('替换媒体(可选)','o_file','点击选择要替换的媒体','留空则只改文字与按钮')}
<div class="pv" id="o_pv"></div>
<div style="height:16px"></div>
<button class="btn" id="o_go" style="width:100%;justify-content:center">${I('check',15)} 应用修改</button>
</div>
</div>`;
bindPicker('n_ch',m);bindPicker('o_ch',m);
bindFmt(m,'n_text');bindFmt(m,'o_text'); // 🆕 格式工具条
const btnRows=(host)=>{const add=(t='',u='')=>{const r=document.createElement('div');r.className='dyn-row';
r.innerHTML=`<input class="inp" placeholder="按钮文字" value="${esc(t)}" style="flex:1"><input class="inp" placeholder="https:// 链接" value="${esc(u)}" style="flex:1.4"><button class="dyn-x" type="button">${I('x',14)}</button>`;
$('.dyn-x',r).onclick=()=>r.remove();host.appendChild(r);icons()};return add};
const addN=btnRows($('#n_btns',m)),addO=btnRows($('#o_btns',m));
$('#n_addB',m).onclick=()=>addN();$('#o_addB',m).onclick=()=>addO();
// 🆕 发布 / 修改都挂实时预览
const paintN=bindPreview(m,{chId:'n_ch',textId:'n_text',btnsId:'n_btns',fileId:'n_file',hostId:'n_pv',mode:'send'});
const paintO=bindPreview(m,{chId:'o_ch',textId:'o_text',btnsId:'o_btns',fileId:'o_file',hostId:'o_pv',mode:'edit'});
bindFile(m,'n_file',paintN);bindFile(m,'o_file',paintO); // 🆕 附件:原生选择器 + 可移除
const collectBtns=host=>$$('.dyn-row',host).map(r=>{const ii=$$('.inp',r);return{text:ii[0].value.trim(),url:ii[1].value.trim()}}).filter(b=>b.text&&b.url);
$('#n_send',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',15)} 发送中`;icons();
try{
const ch=normCh(gv('n_ch',m));if(!ch)throw new Error('请选择目标频道');
const fd=new FormData();fd.set('ch_id',ch);fd.set('text',gv('n_text',m));
fd.set('buttons',JSON.stringify(collectBtns($('#n_btns',m))));
const f=$('#n_file',m).files[0];if(f)fd.set('media',f);
if(!gv('n_text',m)&&!f)throw new Error('请输入正文或选择媒体');
await api('/api/btn_multi',{method:'POST',form:fd});toast('消息已发送');
if(paintN)paintN();
}catch(err){toast(err.message,'err')}
btn.disabled=false;btn.innerHTML=`${I('send',15)} 发送到频道`;icons();
};
$('#o_go',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',15)} 处理中`;icons();
try{
const ref=msgRef(gv('o_msg',m));const och=normCh(gv('o_ch',m))||ref.ch;
if(!och||!(ref.id||gv('o_msg',m)))throw new Error('频道与消息 ID 必填');
const fd=new FormData();fd.set('ch_id',och);fd.set('msg_id',ref.id||gv('o_msg',m));fd.set('text',gv('o_text',m));
fd.set('buttons',JSON.stringify(collectBtns($('#o_btns',m))));
const f=$('#o_file',m).files[0];if(f)fd.set('media',f);
await api('/api/btn_old',{method:'POST',form:fd});toast('旧消息已更新');
if(paintO)paintO();
}catch(err){toast(err.message,'err')}
btn.disabled=false;btn.innerHTML=`${I('check',15)} 应用修改`;icons();
};
}
/* ================= 07 ops ================= */
function vOps(m){
const mons=(S.data&&S.data.member_monitors)||[];
m.innerHTML=vh('08','运维','OPERATIONS','备份、批量处理与成员监控等高阶工具。操作在后台异步执行。')+
`<div class="ops-grid">
<div>
<div class="ops-c" data-rv>
<div class="cmp-t">${I('hard-drive-download')} 智能全量备份</div>
<div class="cmp-s">双引擎扫描源频道历史,增量复制到目标</div>
${fld('源频道',picker('b_src',S.data.address_book,''))}
<div class="sec-t">目标频道(可多选)</div>
<div class="pk" id="b_tgts" style="margin-bottom:18px">
${Object.entries(S.data.address_book||{}).map(([cid,nm])=>`<span class="chip pk-c" data-cid="${esc(cid)}">${esc(nm)}</span>`).join('')||'<span class="fld-h">地址簿为空</span>'}
</div>
${fld('最新消息链接',inp('b_link','','粘贴源频道最新一条消息的链接'))}
<div class="sw-row"><div><div class="t">洗 MD5 模式</div><div class="s">重写文件指纹,视频自动补缩略图</div></div><button class="sw" id="b_wash" type="button"></button></div>
<button class="btn btn-acc" id="b_go" style="width:100%;justify-content:center">${I('zap',15)} 启动备份</button>
</div>
<div class="ops-c" data-rv>
<div class="cmp-t">${I('replace')} 批量替换标签</div>
<div class="cmp-s">遍历频道历史消息,替换或删除指定标签</div>
${fld('处理频道',picker('r_ch',S.data.address_book,''))}
<div class="frm-grid">
${fld('旧标签',inp('r_old','','#旧标签'))}
${fld('新标签',inp('r_new','','留空即删除'))}
</div>
<button class="btn" id="r_go" style="width:100%;justify-content:center">${I('replace',15)} 开始替换</button>
</div>
<div class="ops-c" data-rv>
<div class="cmp-t">${I('scan-line')} 手动生成目录</div>
<div class="cmp-s">立即扫描频道全部标签并回传目录</div>
${fld('扫描频道',picker('g_ch',S.data.address_book,''))}
<button class="btn" id="g_go" style="width:100%;justify-content:center">${I('folder-output',15)} 生成目录</button>
</div>
</div>
<div>
<div class="ops-c" data-rv>
<div class="cmp-t">${I('users')} 成员监控 <span class="chip" style="color:var(--grn);border-color:rgba(93,255,160,.35);margin-left:6px">${I('radio',10)} 实时</span></div>
<div class="cmp-s">有人进就立刻记一笔并备份 · 名单只增不删,每个频道各记各的</div>
<div id="monList">
${mons.length?mons.map(mm=>`<div class="mon-row">
<div><div style="font-weight:600">${esc(S.data.address_book?.[mm.channel_id]||'未登记频道')}</div>
<div class="mono" style="color:var(--dim)">${esc(shortId(mm.channel_id))} · 兜底对账每 ${mm.interval} 分钟 · ${mm.first_scan_at?(mm.rt_at?('实时 '+fmtT(mm.rt_at)):'等实时事件'):'待首次建档'}</div></div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span class="chip" title="累计记录过的人">${I('users',11)} ${(mm.member_count||0).toLocaleString()}</span>
${mm.in_count?`<span class="chip" style="color:var(--grn);border-color:rgba(93,255,160,.35)" title="当前在册">${I('user-check',11)} ${mm.in_count.toLocaleString()}</span>`:''}
${mm.left_total?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.3)" title="已退出">${I('user-minus',11)} ${mm.left_total.toLocaleString()}</span>`:''}
${mm.last_error?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.35)">${I('triangle-alert',11)} 异常</span>`:''}
<button class="ic-b" data-mv="${esc(mm.channel_id)}" title="查看名单与动态">${I('list',14)}</button>
<button class="ic-b" data-sc="${esc(mm.channel_id)}" title="立即对账">${I('refresh-cw',14)}</button>
<button class="ic-b" data-dl="${esc(mm.channel_id)}" title="下载备份">${I('download',14)}</button>
<button class="ic-b" data-ed="${esc(mm.channel_id)}" title="修改间隔">${I('pencil',14)}</button>
<button class="ic-b danger" data-de="${esc(mm.channel_id)}" title="删除">${I('trash-2',14)}</button>
</div></div>`).join(''):'<div class="fld-h" style="padding:14px 0">暂无监控任务</div>'}
</div>
<div class="sec-t">新增监控</div>
${fld('频道',picker('m_ch',S.data.address_book,''))}
${fld('检测间隔(分钟)',inp('m_intv',60,'60','number','min="1"'),'实时事件为主,这个间隔只是兜底对账')}
<button class="btn btn-acc" id="m_add" style="width:100%;justify-content:center">${I('plus',15)} 添加监控</button>
</div>
<!-- 🆕 白名单:在这里随时增删,不用改环境变量 -->
<div class="ops-c" data-rv>
<div class="cmp-t">${I('shield-check')} 访问控制</div>
<div class="cmp-s">谁能进这个控制台、谁能用机器人指令</div>
<div id="aclBox"><div class="fld-h" style="padding:14px 0">读取中…</div></div>
<div id="aclAdd" data-form="${esc(`<div class="sec-t">放行新用户</div>`+fld('Telegram 用户 ID',inp('acl_uid','','例如 88888888'),'让对方把 @userinfobot 的回复截图发你,或在机器人里发 /admin add &lt;ID&gt;')+`<button class="btn btn-acc" id="acl_add" style="width:100%;justify-content:center">${I('user-plus',15)} 加入白名单</button>`)}"></div>
</div>
</div>
</div>
<div class="foot" data-rv><span>危险操作请二次确认</span><span>OPS MODULE</span></div>`;
bindPicker('b_src',m);bindPicker('r_ch',m);bindPicker('g_ch',m);bindPicker('m_ch',m);
$$('#b_tgts .pk-c',m).forEach(c=>c.onclick=()=>c.classList.toggle('on'));
$('#b_wash',m).onclick=e=>e.currentTarget.classList.toggle('on');
$('#b_go',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
const tgt=$$('#b_tgts .pk-c.on',m).map(c=>c.dataset.cid);
if(!gv('b_src',m)||!tgt.length||!gv('b_link',m))throw new Error('源频道、目标与消息链接必填');
await api('/api/backup',{method:'POST',body:{src:gv('b_src',m),tgt,link:gv('b_link',m),wash:$('#b_wash',m).classList.contains('on')}});
toast('备份任务已启动,完成后将推送通知');
}catch(err){toast(err.message,'err')}
btn.disabled=false;
};
$('#r_go',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
if(!gv('r_ch',m)||!gv('r_old',m))throw new Error('频道与旧标签必填');
await api('/api/replace_tag',{method:'POST',body:{ch_id:gv('r_ch',m),old_tag:gv('r_old',m),new_tag:gv('r_new',m)||'删除'}});
toast('批量替换已在后台执行');
}catch(err){toast(err.message,'err')}
btn.disabled=false;
};
$('#g_go',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
if(!gv('g_ch',m))throw new Error('请选择频道');
await api('/api/gen_dir',{method:'POST',body:{ch_id:gv('g_ch',m)}});toast('目录生成中,稍后将由机器人发送');
}catch(err){toast(err.message,'err')}
btn.disabled=false;
};
$('#m_add',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
if(!gv('m_ch',m))throw new Error('请选择频道');
applyUser(await api('/api/member_monitors',{method:'POST',body:{ch_id:gv('m_ch',m),interval:+gv('m_intv',m)||60}}));
toast('监控任务已添加');go('ops');
}catch(err){toast(err.message,'err');btn.disabled=false}
};
$$('[data-dl]',m).forEach(b=>b.onclick=async()=>{
try{const j=await api('/api/member_monitors/'+encodeURIComponent(b.dataset.dl)+'/download',{method:'POST'});toast(j.msg||'备份文件将由机器人发送')}catch(e){toast(e.message,'err')}
});
$$('[data-ed]',m).forEach(b=>b.onclick=()=>{
const cid=b.dataset.ed;const mon=mons.find(x=>String(x.channel_id)===String(cid));
const w=sheet({title:'修改检测间隔',sub:`频道 ${shortId(cid)}`,
body:fld('间隔(分钟)',inp('m_nintv',mon?mon.interval:60,'60','number','min="1"')),
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} 保存</button>`});
icons();$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async()=>{
try{applyUser(await api('/api/member_monitors/'+encodeURIComponent(cid),{method:'PUT',body:{interval:+gv('m_nintv',w)||60}}));toast('间隔已更新');w._close();go('ops')}catch(e){toast(e.message,'err')}
};
});
$$('[data-sc]',m).forEach(b=>b.onclick=async()=>{
try{const j=await api('/api/member_monitors/'+encodeURIComponent(b.dataset.sc)+'/scan',{method:'POST'});toast(j.msg||'已排队对账')}catch(e){toast(e.message,'err')}
});
$$('[data-mv]',m).forEach(b=>b.onclick=()=>memSheet(b.dataset.mv));
$$('[data-de]',m).forEach(b=>b.onclick=()=>confirmSheet('删除该成员监控任务?频道台账与 WebDAV 备份都保留。',async()=>{
try{applyUser(await api('/api/member_monitors/'+encodeURIComponent(b.dataset.de),{method:'DELETE'}));toast('监控已删除');go('ops')}catch(e){toast(e.message,'err')}
}));
loadAcl(m);
}
/* 🆕 单频道成员台账:累计人数、实时动态、成员列表(各频道分开记) */
async function memSheet(cid){
const w=sheet({title:'成员台账',sub:esc(S.data.address_book?.[cid]||shortId(cid)),
body:'<div id="memBox"><div class="fld-h" style="padding:16px 0">读取中…</div></div>'});
const draw=async(q='')=>{
try{
const j=await api('/api/member_log/'+encodeURIComponent(cid)+(q?('?q='+encodeURIComponent(q)):''));
const st=j.stats||{},box=$('#memBox',w);
const tag=(t,v,c)=>`<div style="flex:1;min-width:86px;background:var(--card2,rgba(255,255,255,.03));border:1px solid var(--line);border-radius:12px;padding:9px 11px">
<div style="font-size:11px;color:var(--dim)">${t}</div><div style="font-size:18px;font-weight:700;${c?'color:'+c:''}">${(v||0).toLocaleString()}</div></div>`;
const when=ts=>ts?fmtT(ts):'—';
box.innerHTML=`<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
${tag('累计记录',st.total)}${tag('当前在册',st.in,'var(--grn)')}${tag('已退出',st.left,'var(--red)')}
</div>
<div class="fld-h" style="margin-bottom:12px">实时 ${when(j.rt_at)}${j.rt_src?'('+(j.rt_src==='bot'?'机器人':'userbot')+')':''} · 对账 ${when(j.scan_at)} · 备份 ${when(j.backup_at)}
${j.capped?`<br>Telegram 单次最多看到 ${j.cap} 人(本次看到 ${j.visible}),名单靠实时事件继续累加,不会把老成员挤掉`:''}</div>
<div class="sec-t">最近动态</div>
<div style="max-height:190px;overflow:auto">${(j.log||[]).length?(j.log||[]).slice(0,80).map(e=>`<div class="mon-row" style="padding:8px 0">
<div><span style="color:${e.a==='join'?'var(--grn)':'var(--red)'}">${e.a==='join'?'+':'-'}</span> ${esc(e.n||e.u)}
<span class="mono" style="color:var(--dim)">${e.un?'@'+esc(e.un):esc(e.u)}</span></div>
<div class="mono" style="color:var(--dim)">${fmtT(e.t)} · ${e.s==='scan'?'对账':(e.s==='bot'?'实时·机器人':'实时')}</div></div>`).join(''):'<div class="fld-h" style="padding:12px 0">还没有进出记录</div>'}</div>
<div class="sec-t">成员名单(最新 ${j.shown}/${j.matched}${j.matched>j.cap?',一次最多显示 '+j.cap:''})</div>
${inp('mem_q',q,'搜索名字 / @用户名 / ID')}
<div style="max-height:240px;overflow:auto;margin-top:10px">${(j.members||[]).length?(j.members||[]).map(u=>`<div class="mon-row" style="padding:8px 0">
<div>${esc(u.name||u.id)} <span class="mono" style="color:var(--dim)">${u.un?'@'+esc(u.un):esc(u.id)}</span>${u.bot?' <span class="chip">BOT</span>':''}</div>
<div class="mono" style="color:${u.left?'var(--red)':'var(--dim)'}">${u.left?'已退 '+fmtT(u.left):'进于 '+fmtT(u.t)}</div></div>`).join(''):'<div class="fld-h" style="padding:12px 0">没有匹配的成员</div>'}</div>`;
icons();
const qi=$('#mem_q',w);
if(qi){let tm;qi.oninput=()=>{clearTimeout(tm);tm=setTimeout(()=>{const v=qi.value.trim();draw(v).then(()=>{const n=$('#mem_q',w);if(n){n.focus();n.setSelectionRange(v.length,v.length)}})},350)}}
}catch(e){const box=$('#memBox',w);if(box)box.innerHTML=`<div class="fld-h" style="padding:16px 0">读取失败:${esc(e.message)}</div>`}
};
draw();
}
/* 🆕 白名单面板 */
async function loadAcl(m,retry=0){
try{
const j=await api('/api/admins');renderAcl(m,j);
/* 🆕 名字/头像是后台刷的:还缺就静默再取两次,不用退出面板再进 */
const miss=Object.values(j.profiles||{}).some(p=>!p.avatar||!p.name);
if(miss&&retry<2&&document.body.contains(m))setTimeout(()=>loadAcl(m,retry+1),retry?6000:2500);
}
catch(e){const b=$('#aclBox',m);if(b)b.innerHTML=`<div class="fld-h" style="padding:14px 0">读取失败:${esc(e.message)}</div>`}
}
function renderAcl(m,j){
const box=$('#aclBox',m);if(!box)return;
const me=String(j.me||meInfo().id||'');
const prof=j.profiles||{};
S.owner=!!j.owner; /* 🆕 只有主人能增删 */
const row=(id,owner)=>{
const p=prof[id]||{};
const nm=p.name||(p.username?'@'+p.username:'');
return`<div class="mon-row">
<div style="display:flex;align-items:center;gap:11px;min-width:0">
${ava(p.avatar,nm||id)}
<div style="min-width:0">
<div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${nm?esc(nm):'<span style="color:var(--dim)">未跟机器人说过话</span>'}${id===me?' <span class="chip">我</span>':''}</div>
<div class="mono" style="color:var(--dim)">${p.username?'@'+esc(p.username)+' · ':''}ID ${esc(id)} · ${owner?'主人':'成员'}</div>
</div></div>
<div style="display:flex;align-items:center;gap:8px">
${owner?`<span class="chip">${I('lock',11)} 固定</span>`
:(S.owner?`<button class="ic-b danger" data-acl="${esc(id)}" title="移出白名单">${I('user-minus',14)}</button>`
:`<span class="chip">${I('eye',11)} 只读</span>`)}
</div></div>`};
const owners=(j.owners||[]).map(x=>row(String(x),true));
const members=(j.members||[]).map(x=>row(String(x),false));
box.innerHTML=(owners.concat(members).join(''))||'<div class="fld-h" style="padding:14px 0">未配置白名单:目前只有已建档用户能进</div>';
/* 🆕 非主人:隐藏「放行新用户」表单,只留说明 */
const addWrap=$('#aclAdd',m);
if(addWrap)addWrap.innerHTML=S.owner?addWrap.dataset.form:
`<div class="fld-h" style="padding:12px 0 2px">只有主人(环境变量 ADMIN_IDS 里的人)能增删白名单,你可以查看名单。</div>`;
icons();
if(S.owner)bindAclAdd(m);
$$('[data-acl]',box).forEach(b=>b.onclick=()=>confirmSheet(`把 ${b.dataset.acl} 移出白名单?他将立刻无法进入控制台与使用机器人。`,async()=>{
try{const r=await api('/api/admins',{method:'DELETE',body:{uid:b.dataset.acl}});toast(r.msg||'已移除');renderAcl(m,r)}catch(e){toast(e.message,'err')}
}));
}
function bindAclAdd(m){
const btn=$('#acl_add',m);if(!btn)return;
btn.onclick=async e=>{
const b=e.currentTarget;const uid=(gv('acl_uid',m)||'').replace(/\D/g,'');
if(!uid)return toast('请填写纯数字的用户 ID','err');
b.disabled=true;
try{const j=await api('/api/admins',{method:'POST',body:{uid}});toast(j.msg||'已加入白名单');$('#acl_uid',m).value='';renderAcl(m,j)}
catch(err){toast(err.message,'err')}
b.disabled=false;
};
}
/* ================= 🆕 头像组件(服务端缓存,换头像后 URL 带新指纹自动刷新) ================= */
const AVA_CSS=`.ava{width:34px;height:34px;border-radius:50%;object-fit:cover;flex:none;background:var(--panel2);
border:1px solid var(--line2);display:inline-flex;align-items:center;justify-content:center;
font:700 13px var(--fd);color:var(--dim);overflow:hidden}
.ava.sq{border-radius:11px}.ava.lg{width:44px;height:44px;font-size:16px}`;
function ava(url,name,cls=''){
const ini=esc(String(name||'?').trim().slice(0,1).toUpperCase());
return url?`<img class="ava ${cls}" src="${esc(url)}" alt="" loading="lazy" referrerpolicy="no-referrer"
data-ini="${ini}" data-cls="${esc(cls)}" onerror="avaErr(this)">`
:`<span class="ava ${cls}">${ini}</span>`;
}
/* 🆕 头像取失败先重试一次(服务端可能正在从 WebDAV 回源),真没有才退回首字母 */
function avaErr(img){
if(!img.dataset.retry){
img.dataset.retry='1';
const u=img.src.split('#')[0].replace(/[?&]r=\d+/,'');
setTimeout(()=>{img.src=u+(u.includes('?')?'&':'?')+'r='+Date.now()},1800);
return;
}
img.outerHTML=`<span class="ava ${img.dataset.cls||''}">${img.dataset.ini||'?'}</span>`;
}
/* ================= 🆕 08 我的资料(头像 + 名字) ================= */
function meInfo(){
const p=S.me||{};
const cache=S.meCache||{};
const first=p.first_name||(TGU&&TGU.first_name)||'';
const last=p.last_name||(TGU&&TGU.last_name)||'';
const uname=p.username||(TGU&&TGU.username)||'';
const photo=cache.avatar||p.photo_url||(TGU&&TGU.photo_url)||''; /* 🆕 先用服务端缓存(同源、命中浏览器缓存、秒开),TG 的 photo_url 慢且会过期 */
const id=p.id||(TGU&&TGU.id)||'';
const name=[first,last].filter(Boolean).join(' ')||cache.name||(uname?'@'+uname:(DEMO?'演示用户':'Telegram 用户'));
return{name,uname,photo,id,premium:!!(p.is_premium||(TGU&&TGU.is_premium))};
}
function renderMe(){
const box=$('#me');if(!box)return;
const u=meInfo();
const initials=esc((u.name||'U').trim().slice(0,1).toUpperCase());
/* 🆕 头像三级回退:服务端缓存(快)→ TG 的 photo_url(慢、会过期)→ 首字母 */
const cached=(S.meCache&&S.meCache.avatar)||'';
const srcs=[cached,u.photo].filter(Boolean);
box.innerHTML=`${srcs.length?`<img class="av" src="${esc(srcs[0])}" alt="" referrerpolicy="no-referrer">`:`<span class="av">${initials}</span>`}
<div class="mn"><b>${esc(u.name)}${u.premium?' ⭐':''}</b>
<span>${esc(u.uname?'@'+u.uname:(u.id?'ID '+u.id:'GUEST'))}</span></div>`;
const img=$('img.av',box);
if(img){let i=0;img.onerror=()=>{i++;if(srcs[i])img.src=srcs[i];else img.outerHTML=`<span class="av">${initials}</span>`}}
box.style.display='flex';
}
/* ================= 🆕 09 榜单完整名单(在 Telegram 内打开) ================= */
async function vRankList(m,key){
m.innerHTML=`<div class="rk"><div class="rk-h"><h1>完整名单</h1><p>加载中…</p></div></div>`;
let payload=null;
try{
if(DEMO){payload={title:'本周热评 TOP10(演示)',updated:'今天',items:
Array.from({length:24},(_,i)=>({r:i+1,n:['夜航星','雾都夜谈','白夜行','长安十二时辰','银河edge'][i%5]+'('+(i+1)+')',
c:180-i*6,l:'https://t.me/Nine_7Eleven/88?comment='+(500+i),d:i%7===0}))};}
else{const r=await fetch('/api/list/'+encodeURIComponent(key));payload=await r.json();
if(!payload||payload.ok===false)throw new Error(payload&&payload.msg||'名单不存在');}
}catch(e){
m.innerHTML=`<div class="rk">${emptyBox('list-x','名单不可用',esc(e.message||'请等待榜单下次刷新后重试'))}
<button class="btn" id="rkBack" style="margin:18px auto;display:flex">${I('arrow-left',14)} 返回控制台</button></div>`;
icons();$('#rkBack',m).onclick=()=>{S.list=null;go('overview')};return;
}
const items=payload.items||[];
m.innerHTML=`<div class="rk">
<div class="rk-h" data-rv><h1>${esc(payload.title||'完整名单')}</h1>
<p>更新于 ${esc(payload.updated||'—')} · 共 ${items.length} 人 · 点击跳转投票</p></div>
${items.map(it=>`<a class="rk-i" data-rv href="${esc(it.l||'#')}" ${it.l&&it.l!=='#'?'':'onclick="return false"'}>
<span class="r">#${it.r}</span><span class="n${it.d?' done':''}">${esc(it.n)}</span>
<span class="c">${it.c} 赞</span></a>`).join('')||emptyBox('inbox','暂无数据','榜单还没有收集到评论')}
<button class="btn" id="rkBack" style="margin:24px auto 0;display:flex">${I('layout-grid',14)} 打开控制台</button></div>`;
icons();$('#rkBack',m).onclick=()=>{S.list=null;buildNav();go('overview')};
if(payload.stale)toast('这份名单是旧版缓存,链接要等下一轮榜单刷新才补齐','warn');
// 名单里的链接交给 Telegram 打开
$$('.rk-i',m).forEach(a=>a.onclick=e=>{e.preventDefault();openTgLink(a.getAttribute('href'))});
}
/* 🆕 打开 t.me 链接:交给 Telegram 处理,不再弹「没跳过去」的兜底提示 */
function openTgLink(href){
if(!href||href==='#'){toast('这条评论没有可用的跳转链接(等下一轮榜单刷新)','err');return}
try{
if(TG&&TG.openTelegramLink&&/^https:\/\/t\.me\//i.test(href))TG.openTelegramLink(href);
else if(TG&&TG.openLink&&/^https?:/i.test(href))TG.openLink(href);
else{const w2=window.open(href,'_blank');if(!w2)window.location.href=href}
}catch(err){}
}
/* ================= events stream ================= */
const EV_MAX=50; /* 🆕 与后端一致:最多 50 条 */
function pushEv(type,data,time,ts){
const ev={type,data,time:time||new Date().toTimeString().slice(0,8),ts:ts||Math.floor(Date.now()/1000)};
/* 🆕 重连时后端可能重发同一条,按「时间+内容」去重 */
if(S.events.some(e=>e.time===ev.time&&e.data===ev.data&&e.type===ev.type))return;
S.events.unshift(ev);S.events=S.events.slice(0,EV_MAX);renderFeed();
}
/* 🆕 用服务端的持久化事件填充(含控制台关着时后台产生的事件) */
function seedEvents(list){
if(!Array.isArray(list)||!list.length)return;
S.events=list.slice(0,EV_MAX).map(e=>({type:e.type||'sys',data:e.data||'',time:e.time||'--:--:--',ts:e.ts||0}));
renderFeed();
}
function startEvents(){
if(DEMO){
pushEv('connected','演示模式已连接 · 事件为模拟数据');
const pool=[
['backup_done','备份完成 → 存档仓,新增 128 条'],
['dir_done','目录「资源标签目录」已推送至 2 个目标'],
['replace_done','替换完成,修改 46 条'],
['members_done','成员备份完成:1,284 人'],
['backup_done','备份完成 → 镜像站,新增 64 条'],
];let i=0;
setInterval(()=>{const[t,d]=pool[i++%pool.length];pushEv(t,d)},5200);
return;
}
try{
const es=new EventSource('/api/events?init_data='+encodeURIComponent(INIT));
es.onmessage=e=>{try{const ev=JSON.parse(e.data);pushEv(ev.type,ev.data,ev.time,ev.ts)}catch(_){}};
es.onerror=()=>{pushEv('err','事件流连接中断,自动重试中…')};
}catch(e){pushEv('err','事件流不可用')}
}
/* ================= fx ================= */
function bindFx(root){
if(!matchMedia('(pointer:fine)').matches)return;
$$('.btn',root).forEach(b=>{
b.onmousemove=e=>{const r=b.getBoundingClientRect();
b.style.transform=`translate(${(e.clientX-r.left-r.width/2)*.12}px,${(e.clientY-r.top-r.height/2)*.22}px)`};
b.onmouseleave=()=>b.style.transform='';
});
}
function cursorFx(){
if(!matchMedia('(pointer:fine)').matches)return;
const c=$('.cur'),r=$('.cur-ring');let x=innerWidth/2,y=innerHeight/2,rx=x,ry=y;
addEventListener('mousemove',e=>{x=e.clientX;y=e.clientY;c.style.left=x+'px';c.style.top=y+'px';
document.body.classList.toggle('cur-hov',!!e.target.closest('a,button,.chip,.tile,.itm,.pk-c,input,textarea,select,.file-drop'))});
(function loop(){rx+=(x-rx)*.14;ry+=(y-ry)*.14;r.style.left=rx+'px';r.style.top=ry+'px';requestAnimationFrame(loop)})();
}
function clockFx(){const el=$('#clock');setInterval(()=>el.textContent=new Date().toTimeString().slice(0,8),1000);el.textContent=new Date().toTimeString().slice(0,8)}
addEventListener('keydown',e=>{
if(e.key==='Escape')$$('.sh-wrap').forEach(w=>w._close&&w._close());
const n=+e.key;if(n>=1&&n<=7&&!e.target.closest('input,textarea'))go(Object.keys(VIEWS)[n-1]);
});
/* ================= boot ================= */
async function load(){
try{
const j=await api('/api/data');
S.data=j.user||{};S.meta={msg:j.msg_count||0,dav:j.webdav_url||'',bot:!!j.userbot,botName:j.bot_username||''};
S.me=j.me||(S.data&&S.data.profile)||null;
S.meCache=j.me_cache||null; /* 🆕 服务端缓存的名字与头像 */
S.chAva=j.ch_avatars||{}; /* 🆕 地址簿频道头像 */
seedEvents(j.events); /* 🆕 历史事件 */
setPill(true,DEMO?'DEMO MODE':'LIVE');
}catch(e){
// 🆕 401:不在白名单,直接上锁,不再降级展示界面
if(!DEMO&&e.status===401){S.denied=true;setPill(false,'NO ACCESS');return}
if(!DEMO){setPill(false,'OFFLINE');toast('数据加载失败:'+e.message,'err')}
S.data=clone(SEED);
}
S.ready=true;
}
function setPill(on,txt){const p=$('#pill');p.classList.toggle('off',!on);$('#pillT').textContent=txt}
/* 🆕 手机键盘守卫:正在输入时把底部导航沉下去,不让它跟着键盘上移;界面尺寸不做任何缩放 */
const EDITABLE='input:not([type=file]):not([type=checkbox]):not([type=radio]),textarea,select,[contenteditable=""],[contenteditable=true]';
function kbGuard(){
const editing=()=>{const a=document.activeElement;return !!a&&a.matches&&a.matches(EDITABLE)};
const sync=()=>document.body.classList.toggle('kb',editing());
document.addEventListener('focusin',sync);
document.addEventListener('focusout',()=>setTimeout(sync,60));
// 视觉视口变化(键盘开合、旋屏)后再校准一次
if(window.visualViewport)window.visualViewport.addEventListener('resize',()=>setTimeout(sync,60));
window.addEventListener('orientationchange',()=>setTimeout(sync,400));
}
async function boot(){
if(TG){try{TG.ready();TG.expand();TG.setHeaderColor&&TG.setHeaderColor('#070709');TG.setBackgroundColor&&TG.setBackgroundColor('#070709')}catch(e){}}
kbGuard();cursorFx();clockFx();
$('#bootBar').style.transition='transform 1.1s var(--ease)';
requestAnimationFrame(()=>$('#bootBar').style.transform='scaleX(1)');
await load();renderMe();
if(S.denied){ // 🆕 非授权用户看到的唯一界面
$('#nav').innerHTML='';
$('#view').innerHTML=`<div class="rk" style="padding-top:12vh">${emptyBox('lock','无访问权限',
'这个控制台仅限管理员使用。如果你是来看榜单的,请回到频道点击「完整名单」。')}</div>`;
$('#view').classList.add('ready');icons();
await delay(400);$('#boot').classList.add('out');setTimeout(()=>$('#boot').remove(),900);
return;
}
// 🆕 由榜单「完整名单」链接进来(startapp=list_xxx):直接在 Telegram 内渲染名单
const lk=String(START_PARAM||'');
if(lk.startsWith('list_')||lk.startsWith('list-')){
S.list=lk.slice(5);$('#nav').innerHTML='';
const mv=$('#view');mv.classList.remove('ready');
await vRankList(mv,S.list);
requestAnimationFrame(()=>{mv.classList.add('ready');icons();stagger(mv)});
}else{buildNav();go('overview')}
startEvents();
await delay(650);$('#boot').classList.add('out');
setTimeout(()=>$('#boot').remove(),900);
}
boot();
</script>
</body>
</html>