| <!DOCTYPE html> |
| <html lang="zh-CN"> |
| <head> |
| <meta charset="UTF-8"> |
| |
| <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))); |
| |
| const $=(s,c=document)=>c.querySelector(s); |
| const $$=(s,c=document)=>[...c.querySelectorAll(s)]; |
| const esc=s=>String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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){}}; |
| |
| 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())}`}; |
| |
| |
| 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}; |
| |
| |
| 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)}`; |
| } |
| |
| |
| 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)); |
| |
| 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}} |
| |
| |
| 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); |
| } |
| |
| |
| 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()}; |
| } |
| |
| 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}))} |
| }); |
| |
| 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})); |
| }; |
| } |
| |
| 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); |
| }); |
| } |
| |
| |
| 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 · </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); |
| } |
| |
| 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>`} |
| |
| |
| 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='重试'} |
| }; |
| } |
| |
| 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='重试'} |
| }; |
| } |
| |
| |
| 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')} |
| })}); |
| } |
| |
| 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; |
| }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} |
| }; |
| } |
| |
| |
| 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; |
| |
| function fmtApply(ta,it){ |
| if(!ta||!it)return; |
| const v=ta.value,s=ta.selectionStart,e=ta.selectionEnd,sel=v.slice(s,e); |
| |
| 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, |
| isUrl?open.length+txt.length:9+href.length); |
| }else{ |
| let pre='',suf=''; |
| if(it.blk){ |
| 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>`; |
| } |
| |
| 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()}); |
| 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); |
| }); |
| } |
| |
| const PV_ALLOWED=['b','strong','i','em','u','ins','s','strike','del','a','code','pre','blockquote','br','span','tg-spoiler','tg-emoji']; |
| |
| function pvText(raw){ |
| const src=String(raw??''); |
| if(!src.trim())return''; |
| const bad=[]; |
| |
| |
| let out=esc(src).replace(/<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:\s(?:(?!>)[\s\S])*?)?)\s*(\/?)>/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*(?:"([\s\S]*?)"|'([^']*?)'|([^\s]+))/i)||[]; |
| const href=(hm[1]||hm[2]||hm[3]||'').replace(/&/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; |
| } |
| |
| |
| 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(); |
| }; |
| } |
| |
| |
| 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 <ID>')+`<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; |
| }; |
| } |
| |
| 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>`; |
| } |
| |
| 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>`; |
| } |
| |
| |
| 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)||''; |
| 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()); |
| |
| 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'; |
| } |
| |
| |
| 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'); |
| |
| $$('.rk-i',m).forEach(a=>a.onclick=e=>{e.preventDefault();openTgLink(a.getAttribute('href'))}); |
| } |
| |
| 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){} |
| } |
| |
| |
| const EV_MAX=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','事件流不可用')} |
| } |
| |
| |
| 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]); |
| }); |
| |
| |
| 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){ |
| |
| 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; |
| } |
| |
| 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> |
|
|