Download webapp.html from ljx77qaq/Nine7: direct link, hf CLI and curl.
- Browser
- Download file 165 kB
-
https://huggingface.co/spaces/ljx77qaq/Nine7/resolve/main/webapp.html
- Command line
-
hf download hf://spaces/ljx77qaq/Nine7/webapp.html
-
curl -L -o webapp.html https://huggingface.co/spaces/ljx77qaq/Nine7/resolve/main/webapp.html
165 kB
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <!-- 🆕 interactive-widget=overlays-content:手机键盘弹出时不再挤压/上推固定元素;maximum-scale 禁止聚焦自动放大 --> | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content"> | |
| <title>NINE7 · 频道中枢</title> | |
| <script src="https://telegram.org/js/telegram-web-app.js"></script> | |
| <script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=Noto+Serif+SC:wght@700;900&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet"> | |
| <style> | |
| :root{ | |
| --bg:#070709; --panel:#0D0D10; --panel2:#111115; | |
| --ink:#EFEDE6; --mut:rgba(239,237,230,.48); --dim:rgba(239,237,230,.26); | |
| --acc:#D9FF3D; --acc-ink:#0C0D05; --vio:#9B8CFF; --red:#FF5D5D; --grn:#5DFFA0; | |
| --line:rgba(239,237,230,.09); --line2:rgba(239,237,230,.18); | |
| --card:rgba(255,255,255,.028); | |
| --ease:cubic-bezier(.16,1,.3,1); | |
| --fd:"Syne","Noto Serif SC",serif; | |
| --fb:"Space Grotesk","Noto Sans SC",sans-serif; | |
| --fm:"JetBrains Mono",monospace; | |
| } | |
| *{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent} | |
| html{scroll-behavior:smooth} | |
| body{background:var(--bg);color:var(--ink);font-family:var(--fb);font-size:15px;line-height:1.6;overflow-x:hidden;min-height:100dvh} | |
| ::selection{background:var(--acc);color:var(--acc-ink)} | |
| ::-webkit-scrollbar{width:5px;height:5px} | |
| ::-webkit-scrollbar-track{background:transparent} | |
| ::-webkit-scrollbar-thumb{background:var(--line2);border-radius:99px} | |
| a{color:inherit;text-decoration:none} | |
| button{font-family:inherit} | |
| svg.lucide{display:inline-block;vertical-align:middle;flex:none} | |
| i[data-lucide]{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center} | |
| i[data-lucide]:not(:has(svg))::after{content:'';width:6px;height:6px;border:1px solid currentColor;border-radius:1px} | |
| /* ---------- atmosphere ---------- */ | |
| .noise{position:fixed;inset:-50%;z-index:200;pointer-events:none;opacity:.05; | |
| background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| animation:grain 8s steps(10) infinite} | |
| @keyframes grain{0%,100%{transform:translate(0,0)}10%{transform:translate(-2%,-3%)}30%{transform:translate(3%,-2%)}50%{transform:translate(-3%,2%)}70%{transform:translate(2%,3%)}90%{transform:translate(-1%,1%)}} | |
| .glow{position:fixed;z-index:0;pointer-events:none;border-radius:50%;filter:blur(120px);opacity:.16} | |
| .glow-a{width:44vw;height:44vw;background:var(--acc);top:-18vw;right:-14vw} | |
| .glow-b{width:36vw;height:36vw;background:var(--vio);bottom:-14vw;left:-10vw;opacity:.1} | |
| /* ---------- cursor ---------- */ | |
| .cur,.cur-ring{position:fixed;top:0;left:0;z-index:300;pointer-events:none;border-radius:50%;display:none} | |
| @media(pointer:fine){.cur,.cur-ring{display:block}} | |
| .cur{width:6px;height:6px;background:var(--acc);transform:translate(-50%,-50%)} | |
| .cur-ring{width:34px;height:34px;border:1px solid var(--line2);transform:translate(-50%,-50%);transition:width .3s var(--ease),height .3s var(--ease),border-color .3s} | |
| body.cur-hov .cur-ring{width:56px;height:56px;border-color:var(--acc)} | |
| /* ---------- topbar ---------- */ | |
| .topbar{position:fixed;top:0;left:0;right:0;z-index:60;display:flex;align-items:center;justify-content:space-between; | |
| padding:16px 28px;border-bottom:1px solid var(--line);backdrop-filter:blur(16px);background:rgba(7,7,9,.7)} | |
| .brand{display:flex;align-items:baseline;gap:12px;font-family:var(--fd);font-weight:800;letter-spacing:.02em} | |
| .brand b{font-size:17px} | |
| .brand span{font-size:12px;color:var(--mut);font-family:var(--fb);font-weight:500;letter-spacing:.24em} | |
| .top-r{display:flex;align-items:center;gap:14px} | |
| .pill{display:flex;align-items:center;gap:8px;padding:7px 14px;border:1px solid var(--line2);border-radius:99px;font:600 10px var(--fm);letter-spacing:.14em;text-transform:uppercase} | |
| .pill .dot{width:6px;height:6px;border-radius:50%;background:var(--grn);animation:pulse 2s infinite} | |
| .pill.off .dot{background:var(--red);animation:none} | |
| @keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(93,255,160,.4)}50%{box-shadow:0 0 0 6px rgba(93,255,160,0)}} | |
| .clock{font:500 12px var(--fm);color:var(--mut);letter-spacing:.08em} | |
| .demo-tag{font:700 9px var(--fm);letter-spacing:.2em;color:var(--acc);border:1px dashed var(--acc);padding:5px 10px;border-radius:99px} | |
| /* ---------- nav rail / dock ---------- */ | |
| .rail{position:fixed;z-index:60;left:0;top:0;bottom:0;width:84px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border-right:1px solid var(--line);background:rgba(7,7,9,.7);backdrop-filter:blur(16px)} | |
| .nav-b{position:relative;width:56px;height:56px;border-radius:16px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:var(--mut);cursor:pointer;border:1px solid transparent;transition:.35s var(--ease);background:none} | |
| .nav-b i{width:19px;height:19px} | |
| .nav-b em{font:600 8px var(--fm);font-style:normal;letter-spacing:.1em} | |
| .nav-b .nav-t{display:none} | |
| .nav-b:hover{color:var(--ink)} | |
| .nav-b.on{color:var(--acc);background:rgba(217,255,61,.06);border-color:rgba(217,255,61,.25)} | |
| @media(max-width:860px){ | |
| .rail{top:auto;bottom:0;left:0;right:0;width:auto;height:62px;flex-direction:row;border-right:none;border-top:1px solid var(--line);padding:0 6px;padding-bottom:env(safe-area-inset-bottom); | |
| transition:transform .22s var(--ease)} | |
| .nav-b{flex:1;height:48px;gap:3px} | |
| .nav-b em{display:none} | |
| .nav-b i{width:17px;height:17px} | |
| .nav-b .nav-t{display:block;font:600 9px/1 var(--fm);letter-spacing:.05em;white-space:nowrap} | |
| } | |
| /* 🆕 键盘弹出时:底部导航整体下沉隐藏,绝不跟着键盘上移(仅手机布局,桌面左侧栏不受影响) */ | |
| @media(max-width:860px){ | |
| body.kb .rail{transform:translateY(160%);pointer-events:none} | |
| body.kb .toasts{top:12px} | |
| } | |
| /* ---------- view frame ---------- */ | |
| .view{position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:120px 32px 140px 116px} | |
| @media(max-width:860px){.view{padding:104px 20px 130px}} | |
| .view [data-rv]{opacity:0;transform:translateY(26px);transition:opacity .8s var(--ease),transform .8s var(--ease)} | |
| .view.ready [data-rv]{opacity:1;transform:none} | |
| .vh{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:38px;flex-wrap:wrap} | |
| .vh-k{font:600 10px var(--fm);letter-spacing:.3em;text-transform:uppercase;color:var(--acc);margin-bottom:10px;display:flex;align-items:center;gap:10px} | |
| .vh-k::before{content:'';width:22px;height:1px;background:var(--acc)} | |
| .vh h1{font:800 clamp(34px,6vw,58px)/1.02 var(--fd);letter-spacing:-.02em} | |
| .vh h1 .en{display:block;font-size:.42em;color:transparent;-webkit-text-stroke:1px var(--dim);letter-spacing:.14em;text-transform:uppercase;margin-top:6px} | |
| .vh-p{color:var(--mut);font-size:13px;max-width:420px} | |
| .mono{font-family:var(--fm)} | |
| .acc{color:var(--acc)} | |
| </style> | |
| </head> | |
| <body> | |
| <style> | |
| /* ---------- buttons / chips ---------- */ | |
| .btn{display:inline-flex;align-items:center;gap:9px;padding:13px 24px;border-radius:99px;border:1px solid var(--line2); | |
| background:transparent;color:var(--ink);font:600 13px var(--fb);letter-spacing:.05em;cursor:pointer; | |
| transition:border-color .35s,color .35s,background .35s,transform .2s var(--ease);will-change:transform} | |
| .btn:hover{border-color:var(--acc);color:var(--acc)} | |
| .btn-acc{background:var(--acc);color:var(--acc-ink);border-color:var(--acc)} | |
| .btn-acc:hover{color:var(--acc-ink);filter:brightness(1.1)} | |
| .btn-danger:hover{border-color:var(--red);color:var(--red)} | |
| .btn-s{padding:8px 15px;font-size:12px;gap:7px} | |
| .btn-s i{width:14px;height:14px} | |
| .btn[disabled]{opacity:.45;pointer-events:none} | |
| .chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--line);border-radius:99px; | |
| font:500 11px var(--fm);color:var(--mut);letter-spacing:.02em;transition:.3s} | |
| .chip i{width:11px;height:11px} | |
| .chip.on{background:var(--acc);color:var(--acc-ink);border-color:var(--acc)} | |
| .chip.pk-c{cursor:pointer} | |
| .chip.pk-c:hover{border-color:var(--acc);color:var(--acc)} | |
| .chip.pk-c.on,.chip.pk-c.on:hover{background:var(--acc);color:var(--acc-ink);border-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-main{display:flex;align-items:center;gap:14px;min-width:0;position:relative;z-index:1} | |
| .tile .t-ic{color:var(--dim);display:flex;align-items:center;flex:none} | |
| .tile .t-num{font:700 clamp(40px,4.6vw,56px)/1 var(--fd);letter-spacing:-.02em;transition:color .4s;flex:none} | |
| .tile .t-l{font:600 10px var(--fm);letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin:0;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} | |
| .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 13px 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-fold{display:flex;align-items:center;justify-content:space-between;gap:10px;width:100%;padding:9px 11px;border:1px solid var(--line);border-radius:11px;background:rgba(255,255,255,.025);color:var(--mut);cursor:pointer;text-align:left;transition:.25s} | |
| .pk-fold:hover{border-color:var(--acc);color:var(--txt)} | |
| .pk-fold strong{display:block;color:var(--txt);font:600 12px var(--fb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} | |
| .pk-fold small{display:block;margin-top:3px;color:var(--dim);font:400 10px var(--fm)} | |
| .pk-fold i{flex:none;transition:transform .25s} | |
| .pk.is-open .pk-fold i{transform:rotate(180deg)} | |
| .pk-list{display:none;width:100%;padding-top:8px} | |
| .pk.is-open .pk-list{display:flex} | |
| .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} | |
| .task-center{padding:20px 0;border-bottom:1px solid var(--line)} | |
| .task-tabs{display:flex;gap:8px;margin:18px 0} | |
| .task-tabs [aria-pressed=true]{background:var(--acc);color:var(--bg)} | |
| .task-row{padding:18px 0;border-top:1px solid var(--line)} | |
| .task-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:10px} | |
| .task-heading strong{font-size:13px;overflow-wrap:anywhere} | |
| .task-meta{font-size:12px;color:var(--mut);margin-top:9px;line-height:1.6} | |
| .task-error{color:var(--red)} | |
| .task-reason{font-size:12px;color:var(--mut);line-height:1.6;margin-top:8px;overflow-wrap:anywhere} | |
| .task-actions{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:10px} | |
| .task-actions details{margin-right:auto;font-size:12px;color:var(--mut)} | |
| @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:flex-start;gap:11px;margin-bottom:6px} | |
| .ch-c .ch-info{min-width:0;flex:1;height:44px;display:flex;flex-direction:column;justify-content:space-between} | |
| .ch-c .ch-name{display:flex;align-items:flex-start;gap:8px;min-width:0} | |
| .ch-c .ch-name .nm{min-width:0;flex:1;margin:0!important} | |
| .ch-c .ch-actions{display:flex;align-items:flex-start;gap:6px;flex:none} | |
| .ch-c .ch-actions .ic-b{width:28px;height:28px;border-radius:8px} | |
| .ch-c .cid{margin:0} | |
| .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-main{gap:9px} | |
| .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} | |
| .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:10px 12px;border-radius:11px;font-size:16px;line-height:1.35} | |
| .inp::placeholder,textarea.inp::placeholder{font-size:11.5px;opacity:.75} | |
| .inp::-webkit-input-placeholder,textarea.inp::-webkit-input-placeholder{font-size:11.5px} | |
| 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> | |
| ; | |
| window.__errs=[]; | |
| addEventListener('error',e=>window.__errs.push((e.message||'')+' @:'+(e.lineno||''))); | |
| addEventListener('unhandledrejection',e=>window.__errs.push('PROMISE: '+((e.reason&&(e.reason.stack||e.reason.message))||e.reason))); | |
| /* ================= utils ================= */ | |
| const $=(s,c=document)=>c.querySelector(s); | |
| const $$=(s,c=document)=>[...c.querySelectorAll(s)]; | |
| const esc=s=>String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); | |
| const I=(n,s)=>`<i data-lucide="${n}"${s?` style="width:${s}px;height:${s}px"`:''}></i>`; | |
| const icons=root=>{try{lucide.createIcons(root?{root}:undefined)}catch(e){}}; | |
| /* 事件流:事件类型 → Lucide 图标;事件文本里的 emoji 一律剥掉,统一用图标表达 */ | |
| const EV_ICON={connected:'wifi',sys:'terminal',backup_done:'archive',dir_done:'folder',replace_done:'replace', | |
| members_done:'users',sync_done:'repeat',sync_edit:'pencil',stats_done:'trophy',follow:'user-plus',err:'alert-triangle'}; | |
| const deEmoji=s=>String(s).replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{200D}]/gu,'').replace(/\s{2,}/g,' ').trim(); | |
| const delay=ms=>new Promise(r=>setTimeout(r,ms)); | |
| const clone=o=>JSON.parse(JSON.stringify(o)); | |
| const shortId=id=>{id=String(id??'');return id.length>16?id.slice(0,8)+'…'+id.slice(-5):id}; | |
| const fmtT=ts=>{if(!ts)return'—';const d=new Date(ts*1000);const p=n=>String(n).padStart(2,'0');return`${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`}; | |
| /* ================= state ================= */ | |
| const TG=window.Telegram&&window.Telegram.WebApp; | |
| const INIT=(TG&&TG.initData)||''; | |
| const TGU=(TG&&TG.initDataUnsafe&&TG.initDataUnsafe.user)||null; | |
| const START_PARAM=(TG&&TG.initDataUnsafe&&TG.initDataUnsafe.start_param)|| | |
| (new URLSearchParams(location.search).get('startapp')||new URLSearchParams(location.search).get('list')||''); | |
| let DEMO=!INIT; | |
| const S={view:'ops',data:null,meta:{msg:0,dav:'',bot:false},me:null,events:[],ready:false}; | |
| /* ========== 🆕 频道标识:支持 -100ID / @用户名 / https://t.me/xxx ========== */ | |
| const CH_HINT='-100 数字 ID、@用户名 或 https://t.me/xxx'; | |
| function normCh(raw){ | |
| const s=String(raw??'').trim();if(!s)return''; | |
| const m=s.match(/^(?:https?:\/\/)?(?:www\.)?(?:t|telegram)\.me\/(.+)$/i); | |
| if(m){ | |
| const parts=m[1].split(/[?#]/)[0].replace(/^\/+|\/+$/g,'').split('/').filter(Boolean); | |
| if(!parts.length)return''; | |
| if(parts[0].toLowerCase()==='c'&&/^\d+$/.test(parts[1]||''))return parts[1].startsWith('-100')?parts[1]:'-100'+parts[1]; | |
| if(parts[0].startsWith('+')||parts[0].toLowerCase()==='joinchat')return s; | |
| return '@'+parts[0].replace(/^@/,''); | |
| } | |
| if(s.startsWith('@'))return '@'+s.slice(1).trim(); | |
| if(/^-?\d+$/.test(s))return s; | |
| if(/^[A-Za-z][A-Za-z0-9_]{3,31}$/.test(s))return '@'+s; | |
| return s; | |
| } | |
| function msgRef(raw){ | |
| const s=String(raw??'').trim();if(!s)return{ch:'',id:''}; | |
| const m=s.match(/^(?:https?:\/\/)?(?:www\.)?(?:t|telegram)\.me\/(.+)$/i); | |
| if(m){ | |
| const parts=m[1].split(/[?#]/)[0].replace(/^\/+|\/+$/g,'').split('/').filter(Boolean); | |
| const nums=parts.filter(p=>/^\d+$/.test(p)); | |
| if(parts[0]&&parts[0].toLowerCase()==='c')return{ch:normCh(s),id:nums.length>1?nums[nums.length-1]:''}; | |
| return{ch:parts[0]?'@'+parts[0].replace(/^@/,''):'',id:nums.length?nums[nums.length-1]:''}; | |
| } | |
| return{ch:'',id:/^\d+$/.test(s)?s:''}; | |
| } | |
| /* 业务界面只显示地址簿备注名,真实频道标识仅用于提交 */ | |
| function chName(cid){const ab=(S.data&&S.data.address_book)||{};return ab[cid]||''} | |
| function chLabel(cid,{icon=true}={}){ | |
| const nm=chName(cid); | |
| return nm?`${icon?I('library',11):''}<b style="font-weight:600">${esc(nm)}</b>`:`${icon?I('library',11):''}<b style="font-weight:600">未登记频道</b>`; | |
| } | |
| /* ================= mock db (demo mode) ================= */ | |
| const nowS=()=>Math.floor(Date.now()/1000); | |
| const SEED={ | |
| groups:[ | |
| {name:'主站分发',src:'-1001682349051',tgt:['-1001733118244','-1001552209873']}, | |
| {name:'镜像存档',src:'-1001682349051',tgt:['-1001908744236','@Nine_7Eleven']}, | |
| {name:'精选推送',src:'-1001445096327',tgt:['-1001733118244','-1001908744236','-1001612987450']} | |
| ], | |
| stats_tasks:[ | |
| {task_name:'七月热评榜',channel_id:'-1001682349051',msg_id:'1024',table_title:'本周热评 TOP10',top_n:10,trigger_tag:'#更新',interval:15,duration:7,start_time:nowS()-86400*2,last_run:nowS()-600,completed_items:['夜航星','雾都夜谈'],stats_blacklist:['旧日支配者','无名氏'],blacklist_title:'本月轮换限制'}, | |
| {task_name:'周刊人气投票',channel_id:'-1001445096327',msg_id:'512',table_title:'周刊人气榜',top_n:5,trigger_tag:'#周刊',interval:30,duration:14,start_time:nowS()-86400,last_run:nowS()-1800,completed_items:[],stats_blacklist:[],blacklist_title:''} | |
| ], | |
| dir_tasks:[ | |
| {task_name:'资源标签目录',scan_id:'-1001682349051',targets:[{channel_id:'-1001733118244',msg_id:'88'},{channel_id:'-1001908744236',msg_id:'12'}],blacklist:['#广告'],interval:15,last_run:nowS()-300,tags_cache:['#电影','#剧集','#纪录片','#动画','#音乐','#演唱会','#电子书','#教程','#软件','#游戏','#4K','#无损']}, | |
| {task_name:'周刊索引',scan_id:'-1001445096327',targets:[{channel_id:'-1001612987450',msg_id:'45'}],blacklist:[],interval:30,last_run:nowS()-900,tags_cache:['#周刊','#访谈','#专栏']} | |
| ], | |
| address_book:{'-1001682349051':'主频道','-1001733118244':'分发一号','-1001552209873':'存档仓','-1001908744236':'镜像站','-1001445096327':'周刊编辑部','-1001612987450':'索引频道','@Nine_7Eleven':'公开频道 Nine7'}, | |
| member_monitors:[ | |
| {channel_id:'-1001682349051',started_at:nowS()-86400*5,interval:60,last_run:nowS()-1200,member_count:1284,in_count:1271,left_total:13,rt_at:nowS()-180,scan_at:nowS()-1200,capped:true,remote_path:'members/backup.csv',last_error:'',joined_count:12,left_count:3,first_scan_at:nowS()-86400*5}, | |
| {channel_id:'@Nine_7Eleven',started_at:nowS()-86400*2,interval:120,last_run:nowS()-7200,member_count:356,in_count:356,left_total:0,rt_at:0,scan_at:nowS()-7200,capped:false,remote_path:'',last_error:'ChatAdminRequired',joined_count:0,left_count:0,first_scan_at:0} | |
| ] | |
| }; | |
| function mockDB(){try{return JSON.parse(localStorage.getItem('nine7_mock'))||null}catch(e){return null}} | |
| function mockSave(d){try{localStorage.setItem('nine7_mock',JSON.stringify(d))}catch(e){}} | |
| if(DEMO&&!mockDB())mockSave(clone(SEED)); | |
| function demoTasks(){ | |
| try{ | |
| const saved=JSON.parse(localStorage.getItem('nine7_demo_tasks')); | |
| if(saved)return saved; | |
| }catch(e){} | |
| const entries=sortedBookEntries(SEED.address_book); | |
| const tasks=[{id:'demo-running',kind:'backup',src:entries[0][0],tgt:entries[1][0],started:Date.now()-5000}, | |
| {id:'demo-failed',kind:'backup',src:entries[0][0],tgt:entries[2][0],started:Date.now()-90000}, | |
| {id:'demo-spoiler',kind:'spoiler',channel:entries[0][0],src:entries[0][0],tgt:entries[0][0],started:Date.now()-2000}]; | |
| localStorage.setItem('nine7_demo_tasks',JSON.stringify(tasks));return tasks; | |
| } | |
| function demoTaskState(x){ | |
| const elapsed=(Date.now()-x.started)/1000; | |
| const running=elapsed<30,waiting=elapsed>=10&&elapsed<15; | |
| const total=120,processed=elapsed<4?0:Math.min(total,Math.floor(Math.max(0,elapsed-4-(elapsed>=15?5:0))*6)); | |
| const failed=!running&&!x.retried?2:0; | |
| return{...x,running,stage:running?(elapsed<4?'扫描历史':waiting?'限流等待':'处理消息'):failed?'部分失败':'已完成', | |
| text:running?'正在处理频道媒体':failed?'2 条消息处理失败':'处理完成',total:elapsed<4?null:total, | |
| scanned:elapsed<4?Math.floor(elapsed*25):x.kind==='spoiler'?processed:total, | |
| success:Math.max(0,processed-failed),changed:Math.max(0,processed-failed),failed,skipped:0, | |
| failed_ids:failed?[301,309]:[],error:failed?'Telegram 临时网络错误':'',ended_at:running?null:Math.floor((x.started+30000)/1000)}; | |
| } | |
| /* ================= api layer ================= */ | |
| async function mockApi(path,{method='GET',body={},form}={}){ | |
| await delay(120+Math.random()*260); | |
| const db=mockDB();const u=db; | |
| const out=(extra={})=>({ok:true,user:clone(u),...extra}); | |
| const m=path.split('?')[0].match(/^\/api\/(\w+)(?:\/(.+))?$/);if(!m)return{ok:false,msg:'未知接口'}; /* 🆕 容忍查询串 */ | |
| const[,res,rest]=m;const seg=rest?rest.split('/'):[]; | |
| const d=form?Object.fromEntries(form.entries()):body; | |
| if(res==='events'&&method==='DELETE')return{ok:true,msg:'事件记录已清空'}; | |
| if(res==='resolve'){ | |
| const raw=(new URLSearchParams(path.split('?')[1]||'')).get('ch')||''; | |
| if(!raw)return{ok:false,msg:'请输入频道 ID'}; | |
| return{ok:true,id:/^-?\d+$/.test(raw)?raw:'-1001987654321',title:'公开频道 Nine7', | |
| username:'Nine_7Eleven',members:12840,avatar:''}; | |
| } | |
| if(res==='data')return{ok:true,user:clone(u),msg_count:3841,webdav_url:'https://dav.example.com/nine7/',userbot:true, | |
| ch_avatars:{}, | |
| me_cache:{id:'88888888',name:'演示 用户',username:'nine7_demo',avatar:''}, | |
| events:[{type:'sync_done',data:'主频道 → 3 个目标',time:'09:42:11'}, | |
| {type:'stats_done',data:'七月热评榜 已刷新 · 主频道',time:'09:40:02'}, | |
| {type:'members_done',data:'分发一号 新增 12 / 退出 3',time:'09:31:48'}, | |
| {type:'dir_done',data:'资源标签目录 已更新 → 索引频道(428 个标签)',time:'09:15:07'}, | |
| {type:'err',data:'目录推送失败 → 镜像站:message is not modified',time:'08:58:20'}, | |
| {type:'sync_edit',data:'主频道 编辑已同步至 3 个目标',time:'08:44:36'}], | |
| bot_username:'nine7_bot',me:{id:'88888888',first_name:'演示',last_name:'用户',username:'nine7_demo',photo_url:'',is_premium:true}}; | |
| if(res==='admins'){ | |
| db.admin_ids=db.admin_ids||['77777777']; | |
| const t=String((d&&d.uid)||'').replace(/\D/g,''); | |
| let msg=''; | |
| if(method==='POST'){if(!t)msg='请给出纯数字的 Telegram 用户 ID'; | |
| else if(db.admin_ids.includes(t)||t==='88888888')msg=t+' 已经在白名单里了'; | |
| else{db.admin_ids.push(t);msg='已放行 '+t};mockSave(db)} | |
| if(method==='DELETE'){if(t==='88888888')msg='不能把自己移出白名单'; | |
| else{db.admin_ids=db.admin_ids.filter(x=>x!==t);msg='已移除 '+t};mockSave(db)} | |
| return{ok:!/已经在|不能|请给出/.test(msg),msg,me:'88888888',owner:true,owners:['88888888'],members:db.admin_ids.slice(), | |
| profiles:{'88888888':{id:'88888888',name:'演示 用户',username:'nine7_demo',avatar:''}, | |
| '77777777':{id:'77777777',name:'小助手',username:'',avatar:''}, | |
| '123456789':{id:'123456789',name:'',username:'',avatar:''}}}; | |
| } | |
| if(res==='groups'){ | |
| if(method==='POST'){u.groups.push({name:d.name||'',src:d.src,tgt:[].concat(d.tgt||[]).filter(Boolean)});mockSave(db);return out()} | |
| const i=+seg[0]; | |
| if(method==='PUT'){u.groups[i]={name:d.name||'',src:d.src,tgt:[].concat(d.tgt||[]).filter(Boolean)};mockSave(db);return out()} | |
| if(method==='DELETE'){u.groups.splice(i,1);mockSave(db);return out()} | |
| } | |
| if(res==='channels'){ | |
| if(method==='POST'){u.address_book[d.id]=d.name;mockSave(db);return out()} | |
| const cid=decodeURIComponent(seg[0]||''); | |
| if(method==='PUT'){if(d.id!==cid)delete u.address_book[cid];u.address_book[d.id]=d.name;mockSave(db);return out()} | |
| if(method==='DELETE'){delete u.address_book[cid];mockSave(db);return out()} | |
| } | |
| if(res==='stats'){ | |
| if(method==='POST'){u.stats_tasks.push({task_name:d.task_name,channel_id:d.channel_id,msg_id:d.msg_id,table_title:d.table_title,top_n:+d.top_n||10,trigger_tag:d.trigger_tag,interval:+d.interval||15,duration:+d.duration||7,start_time:nowS(),last_run:0,completed_items:[],stats_blacklist:String(d.stats_blacklist||'').split(/[\s\n]+/).filter(Boolean),blacklist_title:d.blacklist_title||'',directory_task_idx:d.directory_task_idx==null?-1:+d.directory_task_idx,directory_title:d.directory_title||'参与投票名单',show_participant_list:d.show_participant_list!==false,show_blacklist_list:d.show_blacklist_list!==false,show_progress:!!d.show_progress,stats_deadline:+d.stats_deadline||0,expires_at:+d.expires_at||nowS(),enabled:true});mockSave(db);return out()} | |
| const i=+seg[0]; | |
| if(method==='PUT'){const t=u.stats_tasks[i];for(const{field,value} of (d.fields||[{field:d.field,value:d.value}])){ | |
| 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_import'){ | |
| return{ok:true,msg:'导入完成(演示模式)',counts:{added:3,updated:8,status:2,skipped:1},stats:{total:1287,in:1273,left:14},user:clone(u)}; | |
| } | |
| 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(res==='backup'||res==='channel_spoiler'){ | |
| const kind=res==='backup'?'backup':'spoiler'; | |
| const jobs=demoTasks(); | |
| if(method==='GET')return{ok:true,jobs:jobs.filter(x=>x.kind===kind).map(demoTaskState)}; | |
| const targets=kind==='backup'?[].concat(d.tgt||[]):[d.ch_id]; | |
| for(const target of targets){ | |
| if(jobs.some(x=>x.kind===kind&&x.tgt===target&&demoTaskState(x).running))return{ok:false,msg:'该频道已有任务正在运行'}; | |
| jobs.push({id:'demo-'+Date.now()+'-'+Math.random().toString(36).slice(2),kind,src:d.src||d.ch_id,tgt:target,channel:d.ch_id,started:Date.now(),params:d,dismissed:false}); | |
| } | |
| localStorage.setItem('nine7_demo_tasks',JSON.stringify(jobs));return{ok:true}; | |
| } | |
| if(res==='tasks'&&seg[0]==='action'){ | |
| const jobs=demoTasks(),job=jobs.find(x=>x.id===d.id); | |
| if(!job)return{ok:false,msg:'任务不存在'}; | |
| if(d.action==='dismiss')job.dismissed=true; | |
| if(d.action==='retry'){job.started=Date.now();job.retried=true;job.dismissed=false} | |
| localStorage.setItem('nine7_demo_tasks',JSON.stringify(jobs));return{ok:true}; | |
| } | |
| if(['btn_multi','btn_old','btn_new','btn_new_media','gen_dir','replace_tag','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 attempts=method==='GET'?3:1; | |
| for(let attempt=0;attempt<attempts;attempt++){ | |
| const controller=new AbortController(); | |
| const timer=setTimeout(()=>controller.abort(),method==='GET'?20000:90000); | |
| try{ | |
| const r=await fetch(path,{...opt,signal:controller.signal}); | |
| if([502,503,504].includes(r.status)&&attempt+1<attempts){await r.body?.cancel();continue} | |
| 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; | |
| }catch(e){ | |
| if(e.name==='AbortError'||e instanceof TypeError){ | |
| if(attempt+1<attempts)continue; | |
| throw new Error(method==='GET'?'连接中断或请求超时,请检查网络后重试':'连接中断或请求超时,操作可能已生效,请刷新确认后再操作'); | |
| } | |
| throw e; | |
| }finally{clearTimeout(timer)} | |
| } | |
| } | |
| function applyUser(j){if(j&&j.user){S.data=j.user}} | |
| /* ================= toast ================= */ | |
| function toast(msg,type='ok'){ | |
| const t=document.createElement('div');t.className='toast '+type; | |
| t.innerHTML=`${I(type==='ok'?'check':type==='err'?'triangle-alert':'info',16)}<span>${esc(msg)}</span>`; | |
| $('#toasts').appendChild(t);icons(); | |
| requestAnimationFrame(()=>t.classList.add('in')); | |
| setTimeout(()=>{t.classList.remove('in');setTimeout(()=>t.remove(),500)},3600); | |
| } | |
| /* ================= sheet ================= */ | |
| function sheet({title,sub='',body='',foot=''}){ | |
| const w=document.createElement('div');w.className='sh-wrap'; | |
| w.innerHTML=`<div class="sh-bg"></div><div class="sh"><div class="sh-grip"></div> | |
| <div class="sh-h"><div><div class="sh-t">${title}</div>${sub?`<div class="sh-s">${sub}</div>`:''}</div> | |
| <button class="sh-x" type="button">${I('x',16)}</button></div> | |
| <div class="sh-b">${body}</div>${foot?`<div class="sh-f">${foot}</div>`:''}</div>`; | |
| $('#sheets').appendChild(w);icons(); | |
| const close=()=>{w.classList.remove('in');setTimeout(()=>w.remove(),450)}; | |
| $('.sh-bg',w).onclick=close;$('.sh-x',w).onclick=close; | |
| w._close=close; | |
| requestAnimationFrame(()=>w.classList.add('in')); | |
| return w; | |
| } | |
| function confirmSheet(msg,onYes,label='确认删除'){ | |
| const w=sheet({title:'请确认',body:`<p style="color:var(--mut);font-size:13.5px;line-height:1.7">${esc(msg)}</p>`, | |
| foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-danger" data-a="yes">${I('trash-2',14)} ${label}</button>`}); | |
| icons();$('[data-a=no]',w).onclick=w._close; | |
| $('[data-a=yes]',w).onclick=async e=>{e.target.closest('button').disabled=true;await onYes();w._close()}; | |
| } | |
| /* field builders */ | |
| const fld=(label,inner,hint='')=>`<label class="fld"><span class="fld-l">${label}</span>${inner}${hint?`<span class="fld-h">${hint}</span>`:''}</label>`; | |
| const inp=(id,val='',ph='',type='text',extra='')=>`<input class="inp" id="${id}" type="${type}" value="${esc(val)}" placeholder="${esc(ph)}" ${extra}>`; | |
| const txa=(id,val='',ph='',rows=3)=>`<textarea class="inp" id="${id}" rows="${rows}" placeholder="${esc(ph)}">${esc(val)}</textarea>`; | |
| const gv=(id,el0=document)=>{const n=$('#'+id,el0);return n?n.value.trim():''}; | |
| function sortedBookEntries(book){ | |
| return Object.entries(book||{}).sort((a,b)=>{ | |
| const an=String(a[1]||'').trim()||shortId(a[0]),bn=String(b[1]||'').trim()||shortId(b[0]); | |
| return an.localeCompare(bn,'zh-Hans',{numeric:true,sensitivity:'base'})||String(a[0]).localeCompare(String(b[0])); | |
| }); | |
| } | |
| function picker(id,book,selected=''){ | |
| const entries=sortedBookEntries(book); | |
| const selectedKey=String(selected??''); | |
| const chips=entries.map(([cid,nm])=>{const label=String(nm||'').trim()||shortId(cid);return`<span class="chip pk-c${String(cid)===selectedKey?' on':''}" data-cid="${esc(cid)}" data-label="${esc(label)}">${esc(label)}</span>`}).join(''); | |
| const current=selectedKey?(String(book?.[selectedKey]||'').trim()||'未命名频道'):'请选择地址簿频道'; | |
| return`<div class="pk" id="${id}_pk"> | |
| <button class="pk-fold" type="button" aria-expanded="false"><span><strong class="pk-summary">${esc(current)}</strong><small>${entries.length?`地址簿 ${entries.length} 个频道 · 点击展开选择`:'地址簿为空,请联系管理员添加频道'}</small></span>${I('chevron-down',15)}</button> | |
| <div class="pk pk-list">${chips||'<span class="fld-h">地址簿为空,请联系管理员添加频道</span>'}</div> | |
| </div> | |
| <input type="hidden" id="${id}" value="${esc(selected)}">`; | |
| } | |
| function bindPicker(id,root){ | |
| const wrap=$('#'+id+'_pk',root),box=$('#'+id,root); | |
| if(!wrap)return; | |
| const list=$('.pk-list',wrap),summary=$('.pk-summary',wrap),fold=$('.pk-fold',wrap); | |
| const refresh=()=>{ | |
| const v=box?normCh(box.value):''; | |
| const chip=$$('.pk-c',wrap).find(c=>c.dataset.cid===v); | |
| if(summary)summary.textContent=chip?chip.textContent.trim():(v?'未登记频道':'请选择地址簿频道'); | |
| if(fold)fold.querySelector('small').textContent=v?(chip?'已选择地址簿频道 · 点击切换':'频道未登记,请从地址簿选择'):(list?.querySelector('.pk-c')?`地址簿 ${$$('.pk-c',wrap).length} 个频道 · 点击展开选择`:'地址簿为空,请联系管理员添加频道'); | |
| }; | |
| fold.onclick=()=>{ | |
| const open=wrap.classList.toggle('is-open');fold.setAttribute('aria-expanded',String(open)); | |
| }; | |
| $$('.pk-c',wrap).forEach(c=>c.onclick=()=>{ | |
| $$('.pk-c',wrap).forEach(x=>x.classList.remove('on'));c.classList.add('on'); | |
| if(box){box.value=c.dataset.cid;box.dispatchEvent(new Event('input',{bubbles:true}))} | |
| wrap.classList.remove('is-open');fold.setAttribute('aria-expanded','false');refresh(); | |
| }); | |
| if(box)box.onchange=()=>{ | |
| const v=normCh(box.value);if(v!==box.value)box.value=v; | |
| $$('.pk-c',wrap).forEach(x=>x.classList.toggle('on',x.dataset.cid===v)); | |
| box.dispatchEvent(new Event('input',{bubbles:true}));refresh(); | |
| }; | |
| refresh(); | |
| } | |
| /* ================= router ================= */ | |
| const VIEWS={ | |
| ops:{cn:'运维',en:'OPS',icon:'wrench',fn:vOps}, | |
| members:{cn:'回家',en:'MEMBERS',icon:'users',fn:vMembers}, | |
| 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}, | |
| }; | |
| 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 renderFeed(){ | |
| const f=$('#feed');if(!f)return; | |
| const n=$('#evN');if(n)n.textContent=`${S.events.length}/${EV_MAX}`; | |
| f.innerHTML=S.events.length?S.events.slice(0,EV_MAX).map(e=>{ | |
| const t=e.type||'sys'; | |
| const cls=t.includes('backup')?'tg-bkp':t.includes('dir')?'tg-dir':t.includes('err')?'tg-err':(t==='connected'||t==='sys')?'tg-sys':'tg-sync'; | |
| const lb={connected:'SYS',sys:'SYS',backup_done:'BACKUP',dir_done:'DIR',replace_done:'REPLACE', | |
| members_done:'MEMBER',sync_done:'SYNC',sync_edit:'EDIT',stats_done:'STATS',follow:'FOLLOW',err:'ERROR'}[t]||'INFO'; | |
| const txt=deEmoji(e.data||''); | |
| return`<div class="feed-l"><span class="tm">${esc(e.time||'--:--:--')}</span><span class="tg ${cls}">${lb}</span><span class="ms" title="${esc(txt)}">${I(EV_ICON[t]||'info',13)}<span>${esc(txt)}</span></span></div>`; | |
| }).join(''):`<div class="feed-l"><span class="tm">--:--:--</span><span class="tg tg-sys">IDLE</span><span class="ms">暂无事件记录</span></div>`; | |
| icons(f); | |
| } | |
| /* ================= 02 sync ================= */ | |
| function vSync(m){ | |
| const groups=(S.data&&S.data.groups)||[]; | |
| m.innerHTML=vh('03','同步组','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="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 class="pk" id="g_tgts" style="margin-bottom:12px"> | |
| <button class="pk-fold" type="button" aria-expanded="false"><span><strong class="pk-summary">${g.tgt.length?'已选择 '+g.tgt.length+' 个目标频道':'暂未选择目标频道'}</strong></span>${I('chevron-down',15)}</button> | |
| <div class="pk pk-list">${sortedBookEntries(S.data.address_book||{}).map(([cid,nm])=>{const label=String(nm||'').trim()||shortId(cid);return`<span class="chip pk-c${g.tgt.map(normCh).includes(normCh(cid))?' on':''}" data-cid="${esc(cid)}" data-label="${esc(label)}">${esc(label)}</span>`}).join('')||'<span class="fld-h">地址簿为空</span>'}</div> | |
| </div> | |
| `, | |
| 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 targetBox=$('#g_tgts',w),targetFold=$('.pk-fold',targetBox),targetSummary=$('.pk-summary',targetBox); | |
| const selected=new Set(g.tgt.map(normCh).filter(Boolean)); | |
| const refreshTargets=()=>{ | |
| const chosen=$$('.pk-c.on',targetBox); | |
| targetSummary.textContent=chosen.length?`已选择 ${chosen.length} 个目标频道`:'暂未选择目标频道'; | |
| }; | |
| targetFold.onclick=()=>{const open=targetBox.classList.toggle('is-open');targetFold.setAttribute('aria-expanded',String(open))}; | |
| $$('.pk-c',targetBox).forEach(c=>c.onclick=()=>{ | |
| const cid=normCh(c.dataset.cid);c.classList.toggle('on');c.classList.contains('on')?selected.add(cid):selected.delete(cid);refreshTargets(); | |
| }); | |
| refreshTargets(); | |
| $('[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=[...selected]; | |
| const body={name:gv('g_name',w),src:normCh(gv('g_src',w)),tgt}; | |
| if(!body.src||!tgt.length){toast('请填写源频道与至少一个目标','err');btn.disabled=false;btn.innerHTML=`${I('check',14)} 重试`;icons();return} | |
| try{ | |
| applyUser(await api(edit?'/api/groups/'+idx:'/api/groups',{method:edit?'PUT':'POST',body})); | |
| toast(edit?'同步组已更新':'同步链路已建立');w._close();go('sync'); | |
| }catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'} | |
| }; | |
| } | |
| function emptyBox(ic,t,s){return`<div class="empty" data-rv>${I(ic,34)}<div class="e-t">${t}</div><div class="e-s">${s}</div></div>`} | |
| /* ================= 03 stats ================= */ | |
| function vStats(m){ | |
| const tasks=(S.data&&S.data.stats_tasks)||[]; | |
| m.innerHTML=vh('04','热评榜','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" style="${t.enabled===false?'color:var(--dim);border-color:var(--dim)':''}">${I(t.enabled===false?'pause':'play',11)} ${t.enabled===false?'已停止':'运行中'}</span> | |
| ${t.stats_deadline?`<span class="chip">${I('hourglass',11)} 截止 ${fmtT(t.stats_deadline)}</span>`:''} | |
| ${t.directory_task_idx>=0?`<span class="chip">${I('folder-tree',11)} 目录过滤</span>`:''} | |
| ${t.show_progress?`<span class="chip">${I('chart-no-axes-combined',11)} 显示进度</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> | |
| ${t.show_progress?`<div class="prog"><i style="width:${Math.min(done/top*100,100)}%"></i></div><div class="itm-s" style="margin-top:8px">已完成 ${done} / ${top}</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 statLocalDateTime(ts){ | |
| const d=ts?new Date(ts*1000):new Date(); | |
| const pad=n=>String(n).padStart(2,'0'); | |
| return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; | |
| } | |
| function statSheet(idx){ | |
| const edit=idx!==undefined;const t=edit?clone(S.data.stats_tasks[idx]):{}; | |
| const defaultStatTime=statLocalDateTime(t.start_time); | |
| 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_expire',t.expires_at?statLocalDateTime(t.expires_at):defaultStatTime,'选择统计任务停止时间','datetime-local'))} | |
| <div class="sw-row"><div><div class="t">显示进度</div><div class="s">开启后显示完成数 / 总任务数</div></div><button class="sw${t.show_progress?' on':''}" id="s_progress" type="button" aria-pressed="${!!t.show_progress}"></button></div> | |
| <div class="frm-grid"> | |
| ${fld('统计截止时间',inp('s_deadline',t.stats_deadline?statLocalDateTime(t.stats_deadline):defaultStatTime,'统计截止时间,默认使用任务创建时间','datetime-local'))} | |
| </div> | |
| <div class="sw-row"><div><div class="t">显示参与名单</div><div class="s">在统计消息中显示目录参与名单;关闭不影响目录筛选及完整名单</div></div><button class="sw${(t.show_participant_list??t.show_message_lists)!==false?' on':''}" id="s_participant_list" type="button" aria-pressed="${(t.show_participant_list??t.show_message_lists)!==false}"></button></div> | |
| <div class="sw-row"><div><div class="t">显示屏蔽名单</div><div class="s">在统计消息中显示屏蔽名单;关闭不影响屏蔽筛选及完整名单</div></div><button class="sw${(t.show_blacklist_list??t.show_message_lists)!==false?' on':''}" id="s_blacklist_list" type="button" aria-pressed="${(t.show_blacklist_list??t.show_message_lists)!==false}"></button></div> | |
| <div class="sec-t">目录过滤</div> | |
| ${fld('目录任务',`<select class="inp" id="s_dir"><option value="-1">请选择目录任务</option>${(S.data.dir_tasks||[]).map((d,di)=>`<option value="${di}" ${+t.directory_task_idx===di?'selected':''}>${esc(d.task_name||'未命名目录')}(${(d.tags_cache||[]).length} 个标签)</option>`).join('')}</select>`,'以下两个开关需要选择目录任务')} | |
| <div class="sw-row"><div><div class="t">只统计目录名单</div><div class="s">只保留目录中的名单</div></div><button class="sw${t.directory_only?' on':''}" id="s_dir_only" type="button" aria-pressed="${!!t.directory_only}"></button></div> | |
| <div class="sw-row"><div><div class="t">一键屏蔽目录名单</div><div class="s">不统计目录里已有的名单</div></div><button class="sw${t.directory_block?' on':''}" id="s_dir_block" type="button" aria-pressed="${!!t.directory_block}"></button></div> | |
| ${fld('参与名单标题',inp('s_dir_title',t.directory_title==='参与投票名单'?'':(t.directory_title||''),'参与投票名单'),'用于统计消息中的参与名单标题,留空使用“参与投票名单”。')} | |
| <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>`)} | |
| ${edit?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); | |
| ['s_progress','s_dir_only','s_dir_block','s_participant_list','s_blacklist_list'].forEach(id=>{$('#'+id,w).onclick=e=>{const b=e.currentTarget;b.classList.toggle('on');b.setAttribute('aria-pressed',String(b.classList.contains('on')));if(b.classList.contains('on')&&id==='s_dir_only'){['s_dir_block'].forEach(x=>{$('#'+x,w).classList.remove('on');$('#'+x,w).setAttribute('aria-pressed','false')})}if(b.classList.contains('on')&&id==='s_dir_block'){['s_dir_only'].forEach(x=>{$('#'+x,w).classList.remove('on');$('#'+x,w).setAttribute('aria-pressed','false')})}}}); | |
| // 点击屏蔽名字直接移除 | |
| $$('[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:7, | |
| stats_blacklist:gv('s_bl',w),blacklist_title:gv('s_blt',w),extract_from_msg:gv('s_ext',w), | |
| directory_task_idx:+gv('s_dir',w),directory_title:gv('s_dir_title',w),show_participant_list:$('#s_participant_list',w).classList.contains('on'),show_blacklist_list:$('#s_blacklist_list',w).classList.contains('on'),directory_only:$('#s_dir_only',w).classList.contains('on'),directory_block:$('#s_dir_block',w).classList.contains('on'),show_progress:$('#s_progress',w).classList.contains('on'), | |
| expires_at:Math.floor(new Date(gv('s_expire',w)).getTime()/1000), | |
| stats_deadline:gv('s_deadline',w)?Math.floor(new Date(gv('s_deadline',w)).getTime()/1000):0}; | |
| if(!body.task_name||!body.channel_id||!body.msg_id)throw new Error('名称、频道与消息 ID 必填'); | |
| const result=await api('/api/stats',{method:'POST',body});applyUser(result);toast(result.msg||'榜单任务已创建',result.extract_error?'err':'ok'); | |
| }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']){const id={top_n:'s_top',interval:'s_intv'}[f]; | |
| if(+gv(id,w)!==+cur[f])puts.push({field:f,value:gv(id,w)})} | |
| const deadline=gv('s_deadline',w)?Math.floor(new Date(gv('s_deadline',w)).getTime()/1000):0; | |
| const expiresAt=Math.floor(new Date(gv('s_expire',w)).getTime()/1000); | |
| if(+deadline!==+(cur.stats_deadline||0))puts.push({field:'stats_deadline',value:deadline}); | |
| if(+expiresAt!==+(cur.expires_at||0))puts.push({field:'expires_at',value:expiresAt}); | |
| if($('#s_participant_list',w).classList.contains('on')!==((cur.show_participant_list??cur.show_message_lists)!==false))puts.push({field:'show_participant_list',value:$('#s_participant_list',w).classList.contains('on')}); | |
| if($('#s_blacklist_list',w).classList.contains('on')!==((cur.show_blacklist_list??cur.show_message_lists)!==false))puts.push({field:'show_blacklist_list',value:$('#s_blacklist_list',w).classList.contains('on')}); | |
| if(gv('s_dir_title',w)!==(cur.directory_title||'参与投票名单'))puts.push({field:'directory_title',value:gv('s_dir_title',w)}); | |
| if(+gv('s_dir',w)!==+(cur.directory_task_idx??-1))puts.push({field:'directory_task_idx',value:gv('s_dir',w)}); | |
| for(const [id,field] of [['s_progress','show_progress'],['s_dir_only','directory_only'],['s_dir_block','directory_block']]){ | |
| const on=$('#'+id,w).classList.contains('on');if(on!==!!cur[field])puts.push({field,value:on}) | |
| } | |
| 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; | |
| if(puts.length)last=await api('/api/stats/'+idx,{method:'PUT',body:{fields:puts}}); | |
| if(last)applyUser(last);toast(last?.msg||(puts.length?'榜单已更新':'无变更')); | |
| } | |
| w._close();go('stats'); | |
| }catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'} | |
| }; | |
| } | |
| /* ================= 04 dirs ================= */ | |
| function vDirs(m){ | |
| const tasks=(S.data&&S.data.dir_tasks)||[]; | |
| m.innerHTML=vh('05','标签目录','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','暂无目录任务','创建目录,标签索引将自动维护更新')); | |
| m.innerHTML+=` <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>`; | |
| bindPicker('g_ch',m); | |
| $('#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; | |
| }; | |
| $('#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('扫描频道',picker('d_scan',S.data.address_book,t.scan_id||''))} | |
| ${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('新增目标频道',picker('d_ntch',S.data.address_book,''))} | |
| ${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),book=S.data.address_book||{};let rowNo=0; | |
| const addRow=(ch='',msg='')=>{const r=document.createElement('div');r.className='dyn-row';r.style.alignItems='flex-start';const pickerId='d_tch_'+rowNo++; | |
| r.innerHTML=`<div style="flex:1;min-width:0"><div style="display:flex;gap:8px;align-items:center"><input class="inp" data-msg placeholder="消息 ID / 链接" value="${esc(msg)}" style="flex:1"><button class="dyn-x" type="button">${I('x',14)}</button></div><div style="margin-top:8px">${picker(pickerId,book,ch)}</div></div>`; | |
| list.appendChild(r);icons();bindPicker(pickerId,r); | |
| $('.dyn-x',r).onclick=()=>r.remove()}; | |
| addRow();$('#d_addT',w).onclick=()=>addRow(); | |
| }else{ | |
| bindPicker('d_scan',w); | |
| bindPicker('d_ntch',w); | |
| $$('[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 ch=r.querySelector('input[type="hidden"]')?.value||'',msg=r.querySelector('[data-msg]')?.value||'';const ref=msgRef(msg); | |
| return{channel_id:normCh(ch)||ref.ch,msg_id:ref.id||msg.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(normCh(gv('d_scan',w))!==normCh(cur.scan_id))last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'scan_id',value:gv('d_scan',w)}}); | |
| if(+gv('d_intv',w)!==+cur.interval)last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'interval',value:gv('d_intv',w)}}); | |
| if(gv('d_abl',w))last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'add_blacklist',value:gv('d_abl',w)}}); | |
| if(gv('d_ntch',w)&&gv('d_ntmsg',w))last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'add_target',value:JSON.stringify({channel_id:normCh(gv('d_ntch',w)),msg_id:gv('d_ntmsg',w)})}}); | |
| if(last)applyUser(last);toast('目录任务已更新'); | |
| } | |
| w._close();go('dirs'); | |
| }catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'} | |
| }; | |
| } | |
| /* ================= 05 channels ================= */ | |
| function vChannels(m){ | |
| const ab=(S.data&&S.data.address_book)||{}; | |
| const ents=sortedBookEntries(ab); | |
| m.innerHTML=vh('06','地址簿','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="ch-info"><div class="ch-name"><div class="nm">${esc(nm)}</div><div class="ch-actions"><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><div class="cid">${esc(cid)}</div></div></div> | |
| </div>`).join('')} | |
| </div>`; | |
| $('#addC',m).onclick=()=>chSheet(); | |
| $('#batch',m).onclick=()=>batchSheet(); | |
| $$('[data-c]',m).forEach(b=>b.onclick=async()=>{ | |
| try{await navigator.clipboard.writeText(ents[+b.dataset.c][0]);toast('频道 ID 已复制')}catch(e){toast('复制失败','err')} | |
| }); | |
| $$('[data-e]',m).forEach(b=>b.onclick=()=>chSheet(ents[+b.dataset.e][0])); | |
| $$('[data-d]',m).forEach(b=>b.onclick=()=>{const cid=ents[+b.dataset.d][0]; | |
| confirmSheet(`将「${ab[cid]}」从地址簿移除?`,async()=>{ | |
| try{applyUser(await api('/api/channels/'+encodeURIComponent(cid),{method:'DELETE'}));toast('已移除');go('channels')}catch(e){toast(e.message,'err')} | |
| })}); | |
| } | |
| /* 🆕 频道头像:后端认过一次就缓存在 avatar_cache/,地址由 /api/data 下发 */ | |
| function chAvatar(cid){return (S.chAva&&S.chAva[cid])||''} | |
| function chSheet(cid){ | |
| const edit=cid!==undefined;const nm=edit?S.data.address_book[cid]:''; | |
| const w=sheet({title:edit?'编辑频道':'收藏频道',sub:'填 ID / @用户名 / 链接,自动认出频道名和头像', | |
| body:`${fld('频道 ID / 链接',inp('c_id',edit?cid:'',CH_HINT),'支持 '+CH_HINT)} | |
| <div id="rsBox"></div> | |
| ${fld('备注名',inp('c_name',nm,'留空则用识别出的频道名'))}`, | |
| foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} 保存</button>`}); | |
| icons();$('[data-a=no]',w).onclick=w._close; | |
| /* 🆕 输入频道后自动识别:带出真实名称、成员数与头像 */ | |
| let rsTimer=null,rsLast=''; | |
| const rsBox=$('#rsBox',w); | |
| const rsShow=h=>{rsBox.innerHTML=h;icons()}; | |
| async function resolveCh(){ | |
| const raw=(gv('c_id',w)||'').trim(); | |
| if(!raw||raw===rsLast)return; | |
| rsLast=raw; | |
| rsShow(`<div class="rs-box"><span class="ava sq">…</span><div><div class="t">识别中…</div><div class="s">${esc(normCh(raw)||raw)}</div></div></div>`); | |
| try{ | |
| const j=await api('/api/resolve?ch='+encodeURIComponent(raw)); | |
| if(!j.ok){rsShow(`<div class="rs-box" style="border-color:rgba(255,93,93,.35)">${I('triangle-alert',16)}<div><div class="t">认不出这个频道</div><div class="s">${esc(j.msg||'请检查 ID 或把机器人加进频道')}</div></div></div>`);return} | |
| S.chAva=S.chAva||{};if(j.avatar)S.chAva[j.id]=j.avatar; | |
| rsShow(`<div class="rs-box">${ava(j.avatar,j.title,'sq lg')} | |
| <div style="min-width:0"><div class="t">${esc(j.title||'未命名')}</div> | |
| <div class="s">${j.username?'@'+esc(j.username)+' · ':''}${esc(j.id)}${j.members?' · '+j.members.toLocaleString()+' 人':''}</div></div></div>`); | |
| const nameEl=$('#c_name',w); | |
| if(nameEl&&!nameEl.value.trim()&&j.title)nameEl.value=j.title; /* 备注名留空就自动填 */ | |
| const idEl=$('#c_id',w); | |
| if(idEl&&j.id&&!/^@/.test(raw))idEl.value=j.id; /* 统一成规范 ID */ | |
| }catch(e){rsShow(`<div class="rs-box" style="border-color:rgba(255,93,93,.35)">${I('triangle-alert',16)}<div><div class="t">识别失败</div><div class="s">${esc(e.message)}</div></div></div>`)} | |
| } | |
| const idIn=$('#c_id',w); | |
| if(idIn){ | |
| idIn.addEventListener('input',()=>{clearTimeout(rsTimer);rsTimer=setTimeout(resolveCh,700)}); | |
| idIn.addEventListener('blur',resolveCh); | |
| if(edit)resolveCh(); | |
| } | |
| $('[data-a=ok]',w).onclick=async e=>{ | |
| const btn=e.currentTarget;btn.disabled=true; | |
| try{ | |
| const rsT=$('#rsBox .t',w); | |
| const body={id:normCh(gv('c_id',w)),name:gv('c_name',w)||(rsT?rsT.textContent.trim():'')}; | |
| if(!body.id)throw new Error('请填写频道 ID / 链接'); | |
| if(!body.name||body.name==='识别中…')throw new Error('填个备注名,或等自动识别出频道名'); | |
| applyUser(await api(edit?'/api/channels/'+encodeURIComponent(cid):'/api/channels',{method:edit?'PUT':'POST',body})); | |
| toast('地址簿已更新');w._close();go('channels'); | |
| }catch(err){toast(err.message,'err');btn.disabled=false} | |
| }; | |
| } | |
| function batchSheet(){ | |
| const w=sheet({title:'批量建频道',sub:'由 Userbot 自动创建并登记到地址簿,后台异步执行', | |
| body:` | |
| ${fld('频道名称(每行一个)',txa('b_names','','例如:\n分站\n资源库',4))} | |
| ${fld('追加管理员(可选)',txa('b_users','','用户 ID 或用户名,空格分隔',2))} | |
| ${fld('每个名称创建数量',inp('b_count',1,'1','number','min="1" max="20"'),'大于 1 时自动追加序号')}`, | |
| foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('zap',14)} 开始创建</button>`}); | |
| icons();$('[data-a=no]',w).onclick=w._close; | |
| $('[data-a=ok]',w).onclick=async e=>{ | |
| const btn=e.currentTarget;btn.disabled=true; | |
| try{ | |
| if(!gv('b_names',w))throw new Error('请输入频道名称'); | |
| await api('/api/batch_create',{method:'POST',body:{names:gv('b_names',w),users:gv('b_users',w),count:+gv('b_count',w)||1}}); | |
| toast('批量创建已在后台执行');w._close(); | |
| }catch(err){toast(err.message,'err');btn.disabled=false} | |
| }; | |
| } | |
| /* ================= 🆕 Telegram 文本格式工具条(点击即套用,无需手写标签) ================= */ | |
| /* Telegram Bot API 官方支持的全部 HTML 格式 */ | |
| const FMT_ITEMS=[ | |
| {k:'b', ic:'bold', ti:'加粗(Ctrl+B)', open:'<b>', close:'</b>'}, | |
| {k:'i', ic:'italic', ti:'斜体(Ctrl+I)', open:'<i>', close:'</i>'}, | |
| {k:'u', ic:'underline', ti:'下划线(Ctrl+U)', open:'<u>', close:'</u>'}, | |
| {k:'s', ic:'strikethrough', ti:'删除线', open:'<s>', close:'</s>'}, | |
| {k:'sp', ic:'eye-off', ti:'剧透遮罩(点击才显示)', open:'<tg-spoiler>', close:'</tg-spoiler>'}, | |
| {k:'a', ic:'link', gp:1, ti:'超链接(Ctrl+K):选中文字后点此,再粘贴网址', link:1}, | |
| {k:'mt', ic:'at-sign', ti:'提及用户:tg://user?id= 形式的内联链接', mention:1}, | |
| {k:'emoji',ic:'smile', ti:'会员表情:导入表情包并点选插入', emoji:1}, | |
| {k:'c', ic:'code', gp:1, ti:'行内等宽代码', open:'<code>', close:'</code>'}, | |
| {k:'pre',ic:'code-xml', ti:'代码块(光标停在 language- 后,可直接写 python 等语言)', open:'<pre><code class="language-">', close:'</code></pre>', blk:1, cur:-2}, | |
| {k:'q', ic:'text-quote', ti:'引用块', open:'<blockquote>', close:'</blockquote>', blk:1}, | |
| {k:'qe', ic:'chevrons-down-up',ti:'可折叠引用块', open:'<blockquote expandable>', close:'</blockquote>', blk:1}, | |
| {k:'clr',ic:'remove-formatting',gp:1,ti:'清除所选文字的全部格式', clr:1}, | |
| ]; | |
| const FMT_TAGS=/<\/?(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji|span)(?:\s[^>]*)?>/gi; | |
| /* 对 textarea 的当前选区套用 / 取消一种格式 */ | |
| function openEmojiPicker(ta,start,end){ | |
| const w=sheet({title:'会员表情',sub:'导入整包后点选插入;发送仍需机器人具有对应权限。',body:` | |
| ${fld('表情包链接',inp('emoji_link','','https://t.me/addemoji/包名'))} | |
| <button type="button" class="btn btn-acc" id="emoji_import">整包导入</button> | |
| <div id="emoji_packs" style="display:flex;gap:6px;flex-wrap:wrap;margin:12px 0"></div> | |
| <div id="emoji_status" role="status">读取已导入表情包…</div> | |
| <div id="emoji_grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(52px,1fr));gap:8px;margin-top:12px"></div> | |
| <button type="button" class="btn" id="emoji_more" style="display:none;margin-top:12px">加载更多</button>`}); | |
| let packs=[],pack=null,page=0,generation=0,closed=false,urls=[]; | |
| const abort=new AbortController(),grid=$('#emoji_grid',w),status=$('#emoji_status',w),more=$('#emoji_more',w),importBtn=$('#emoji_import',w); | |
| const release=()=>{urls.forEach(u=>URL.revokeObjectURL(u));urls=[]}; | |
| const close=w._close; | |
| w._close=()=>{closed=true;generation++;abort.abort();release();close()}; | |
| $('.sh-bg',w).onclick=w._close;$('.sh-x',w).onclick=w._close; | |
| const observer=new MutationObserver(()=>{if(!w.isConnected){closed=true;abort.abort();release();observer.disconnect()}}); | |
| observer.observe(w.parentNode,{childList:true}); | |
| function drawPacks(){ | |
| $('#emoji_packs',w).innerHTML=packs.map((p,i)=>`<button type="button" class="btn" data-pack="${i}">${esc(p.title)}</button>`).join(''); | |
| $$('[data-pack]',w).forEach(b=>b.onclick=()=>load(packs[+b.dataset.pack].name)); | |
| } | |
| async function showMore(){ | |
| if(!pack)return; | |
| const current=pack,version=generation,items=current.stickers.slice(page,page+48);page+=items.length; | |
| more.style.display=page<current.stickers.length?'':'none'; | |
| const jobs=[]; | |
| for(const item of items){ | |
| const b=document.createElement('button');b.type='button';b.className='btn';b.style.cssText='height:56px;padding:4px;justify-content:center;font-size:28px'; | |
| b.textContent=item.emoji;b.title=`${item.emoji} · ${item.id}`;b.setAttribute('aria-label','插入 '+item.emoji); | |
| b.onclick=()=>{ | |
| if(!ta.isConnected||!/^\d+$/.test(item.id))return; | |
| const text=`<tg-emoji emoji-id="${item.id}">${esc(item.emoji)}</tg-emoji>`; | |
| ta.focus();ta.setSelectionRange(start,end);ta.setRangeText(text,ta.selectionStart,ta.selectionEnd,'end'); | |
| ta.dispatchEvent(new Event('input',{bubbles:true}));w._close(); | |
| }; | |
| grid.appendChild(b); | |
| if(item.thumbnail)jobs.push(async()=>{ | |
| try{ | |
| const response=await fetch(`/api/emoji_sets/${encodeURIComponent(current.name)}/${encodeURIComponent(item.id)}/thumbnail`,{headers:{'X-Init-Data':INIT},signal:abort.signal}); | |
| if(!response.ok)return; | |
| const blob=await response.blob();if(closed||generation!==version)return; | |
| const url=URL.createObjectURL(blob);urls.push(url); | |
| const img=document.createElement('img');img.alt=item.emoji;img.style.cssText='width:44px;height:44px;object-fit:contain'; | |
| img.onload=()=>{if(!closed&&generation===version)b.replaceChildren(img)};img.src=url; | |
| }catch(err){} | |
| }); | |
| } | |
| more.disabled=true; | |
| let index=0; | |
| await Promise.all(Array.from({length:4},async()=>{while(index<jobs.length&&!closed&&generation===version){await jobs[index++]()}})); | |
| if(!closed&&generation===version)more.disabled=false; | |
| } | |
| async function load(link){ | |
| const version=++generation;release();grid.replaceChildren();more.style.display='none';importBtn.disabled=true;status.textContent='正在读取表情包…'; | |
| try{ | |
| const result=await api('/api/emoji_sets',{method:'POST',body:{link}}); | |
| if(closed||version!==generation)return; | |
| if(!result.ok)throw new Error(result.msg||'导入失败'); | |
| pack=result;page=0; | |
| if(!packs.some(p=>p.name===result.name))packs.push({name:result.name,title:result.title}); | |
| drawPacks();status.textContent=`${result.title} · ${result.stickers.length} 个表情,点击插入(缩略图加载失败时显示普通表情)`; | |
| showMore(); | |
| }catch(err){if(!closed&&version===generation)status.textContent=err.message} | |
| finally{if(!closed&&version===generation)importBtn.disabled=false} | |
| } | |
| importBtn.onclick=()=>load(gv('emoji_link',w));more.onclick=showMore; | |
| api('/api/emoji_sets').then(result=>{ | |
| if(closed||generation)return; | |
| if(!result.ok)throw new Error(result.msg||'读取失败'); | |
| packs=result.packs||[];drawPacks(); | |
| if(packs.length)load(packs[packs.length-1].name);else status.textContent='粘贴会员表情包链接,导入后即可点选。'; | |
| }).catch(err=>{if(!closed&&!generation)status.textContent=err.message}); | |
| } | |
| function fmtApply(ta,it){ | |
| if(!ta||!it)return; | |
| const v=ta.value,s=ta.selectionStart,e=ta.selectionEnd,sel=v.slice(s,e); | |
| // ins=插入内容, a/b=插入后要选中的相对区间 | |
| const put=(ins,a,b)=>{ta.value=v.slice(0,s)+ins+v.slice(e);ta.setSelectionRange(s+a,s+(b==null?a:b))}; | |
| if(it.clr){ | |
| const cl=sel.replace(FMT_TAGS,'');put(cl,0,cl.length); | |
| }else if(it.emoji){ | |
| openEmojiPicker(ta,s,e);return; | |
| }else if(it.link||it.mention){ | |
| const isUrl=/^(https?:\/\/|tg:\/\/)\S+$/i.test(sel.trim()); | |
| const href=it.mention?'tg://user?id=':(isUrl?sel.trim():'https://'); | |
| const txt=(isUrl||!sel)?(it.mention?'@某人':'链接文字'):sel; | |
| const open=`<a href="${href}">`; | |
| put(open+txt+'</a>', | |
| isUrl?open.length:9, // '<a href="'.length === 9 | |
| isUrl?open.length+txt.length:9+href.length);// 选中网址占位符,直接粘贴覆盖 | |
| }else{ | |
| let pre='',suf=''; | |
| if(it.blk){ // 引用 / 代码块独占一行,效果才和 Telegram 一致 | |
| if(s>0&&v[s-1]!=='\n')pre='\n'; | |
| if(e<v.length&&v[e]!=='\n')suf='\n'; | |
| } | |
| if(!sel){ // 没选中文字 → 插入一对标签,光标落在中间 | |
| const cur=pre.length+it.open.length+(it.cur||0); | |
| put(pre+it.open+it.close+suf,cur); | |
| }else if(sel.startsWith(it.open)&&sel.endsWith(it.close)){ // 已套用 → 再点一次取消 | |
| const inner=sel.slice(it.open.length,sel.length-it.close.length); | |
| put(inner,0,inner.length); | |
| }else{ | |
| put(pre+it.open+sel+it.close+suf,pre.length+it.open.length,pre.length+it.open.length+sel.length); | |
| } | |
| } | |
| ta.focus(); | |
| ta.dispatchEvent(new Event('input',{bubbles:true})); // 触发实时预览刷新 | |
| } | |
| function fmtBar(id){ | |
| return`<div class="fmt" data-fmt="${id}">${FMT_ITEMS.map((it,ix)=> | |
| `<button type="button" class="${it.gp?'gp':''}" data-fi="${ix}" title="${esc(it.ti)}" aria-label="${esc(it.ti)}">${I(it.ic,13)}</button>`).join('')}</div>`; | |
| } | |
| /* 🆕 媒体附件字段:用 <label> 包住铺满的 file input,原生唤起选择器 */ | |
| const fldFile=(label,id,txt,hint='')=>`<div class="fld"><span class="fld-l">${label}</span> | |
| <label class="file-drop" for="${id}">${I('paperclip',22)}<div>${esc(txt)}</div><input class="file-in" type="file" id="${id}"></label> | |
| <div class="file-name" id="${id}_nm" style="display:none"></div>${hint?`<span class="fld-h">${hint}</span>`:''}</div>`; | |
| function bindFile(root,id,onChange){ | |
| const f=$('#'+id,root),nm=$('#'+id+'_nm',root); | |
| if(!f||!nm)return; | |
| const clear=()=>{f.value='';nm.style.display='none';nm.innerHTML='';onChange&&onChange()}; | |
| f.addEventListener('change',()=>{ | |
| const x=f.files&&f.files[0]; | |
| if(!x){clear();return} | |
| nm.style.display='flex'; | |
| nm.innerHTML=`${I('file-up',14)} <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.name)}</span> | |
| <span style="color:var(--dim);flex:none">${(x.size/1048576).toFixed(2)} MB</span> | |
| <button class="fx" type="button" title="移除附件">${I('x',12)}</button>`; | |
| icons(nm);$('.fx',nm).onclick=clear; | |
| onChange&&onChange(); | |
| }); | |
| } | |
| /* 带工具条的正文输入框 */ | |
| const fldFmt=(label,id,ph,rows=4,hint='')=>`<div class="fld"><span class="fld-l">${label}</span>${fmtBar(id)}<textarea class="inp" id="${id}" rows="${rows}" placeholder="${esc(ph)}"></textarea>${hint?`<span class="fmt-h">${hint}</span>`:''}</div>`; | |
| function bindFmt(root,id){ | |
| const bar=$(`.fmt[data-fmt="${id}"]`,root),ta=$('#'+id,root); | |
| if(!bar||!ta)return; | |
| bar.addEventListener('mousedown',ev=>{if(ev.target.closest('button'))ev.preventDefault()}); // 保住 textarea 选区 | |
| bar.addEventListener('click',ev=>{const b=ev.target.closest('button[data-fi]');if(!b)return; | |
| ev.preventDefault();fmtApply(ta,FMT_ITEMS[+b.dataset.fi])}); | |
| ta.addEventListener('keydown',ev=>{ // 快捷键 | |
| if(!(ev.ctrlKey||ev.metaKey)||ev.altKey)return; | |
| const k={b:'b',i:'i',u:'u',k:'a'}[(ev.key||'').toLowerCase()];if(!k)return; | |
| const it=FMT_ITEMS.find(x=>x.k===k);if(!it)return; | |
| ev.preventDefault();fmtApply(ta,it); | |
| }); | |
| } | |
| /* ================= 🆕 实时预览(Telegram 消息外观) ================= */ | |
| const PV_ALLOWED=['b','strong','i','em','u','ins','s','strike','del','a','code','pre','blockquote','br','span','tg-spoiler','tg-emoji']; | |
| /* 把用户写的 HTML 安全地渲染成预览:只放行 Telegram 支持的标签,其余转义显示 */ | |
| function pvText(raw){ | |
| const src=String(raw??''); | |
| if(!src.trim())return''; | |
| const bad=[]; | |
| // 先整体转义,再把白名单标签还原,杜绝脚本注入 | |
| // 属性里的引号已被转义成 ",所以属性段要允许 & 出现,只在遇到 > 时收尾 | |
| 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; | |
| } | |
| /* ================= 06 compose ================= */ | |
| function vCompose(m){ | |
| m.innerHTML=vh('07','发布台','COMPOSE','发送或编辑带按钮的消息,支持图片、视频与文档附件。')+ | |
| `<div class="cmp-grid"> | |
| <div class="cmp" data-rv> | |
| <div class="cmp-t">${I('send')} 发送新消息</div> | |
| <div class="cmp-s">正文支持 HTML 标签,按钮以整行排列</div> | |
| ${fld('目标频道',picker('n_ch',S.data.address_book,''))} | |
| ${fldFmt('消息正文','n_text','选中文字后点上方按钮套用格式',5,'选中文字 → 点按钮套用(再点一次取消);未选中则插入标签对。<b>Ctrl+B/I/U/K</b> 亦可。')} | |
| <div class="sec-t">链接按钮</div> | |
| <div id="n_btns"></div> | |
| <button class="dyn-add" id="n_addB" type="button">${I('plus',13)} 添加按钮</button> | |
| <div style="height:14px"></div> | |
| ${fldFile('媒体附件(可选)','n_file','点击选择图片 / 视频 / 文档','带附件时正文作为图注,上限 1024 字')} | |
| <div class="pv" id="n_pv"></div> | |
| <div style="height:16px"></div> | |
| <button class="btn btn-acc" id="n_send" style="width:100%;justify-content:center">${I('send',15)} 发送到频道</button> | |
| </div> | |
| <div class="cmp" data-rv> | |
| <div class="cmp-t">${I('square-pen')} 编辑旧消息</div> | |
| <div class="cmp-s">更新按钮、正文,或直接替换媒体内容</div> | |
| ${fld('所在频道',picker('o_ch',S.data.address_book,''))} | |
| ${fld('消息 ID / 链接',inp('o_msg','','1024 或 https://t.me/xxx/1024'),'粘贴完整链接可自动识别频道')} | |
| ${fldFmt('新正文(留空则不改动文字)','o_text','选中文字后点上方按钮套用格式',4,'与左侧一致:选中文字点按钮即套用格式。')} | |
| <div class="sec-t">链接按钮</div> | |
| <div id="o_btns"></div> | |
| <button class="dyn-add" id="o_addB" type="button">${I('plus',13)} 添加按钮</button> | |
| <div style="height:14px"></div> | |
| ${fldFile('替换媒体(可选)','o_file','点击选择要替换的媒体','留空则只改文字与按钮')} | |
| <div class="pv" id="o_pv"></div> | |
| <div style="height:16px"></div> | |
| <button class="btn" id="o_go" style="width:100%;justify-content:center">${I('check',15)} 应用修改</button> | |
| </div> | |
| </div>`; | |
| bindPicker('n_ch',m);bindPicker('o_ch',m); | |
| bindFmt(m,'n_text');bindFmt(m,'o_text'); // 🆕 格式工具条 | |
| const btnRows=(host)=>{const add=(t='',u='')=>{const r=document.createElement('div');r.className='dyn-row'; | |
| r.innerHTML=`<input class="inp" placeholder="按钮文字" value="${esc(t)}" style="flex:1"><input class="inp" placeholder="https:// 链接" value="${esc(u)}" style="flex:1.4"><button class="dyn-x" type="button">${I('x',14)}</button>`; | |
| $('.dyn-x',r).onclick=()=>r.remove();host.appendChild(r);icons()};return add}; | |
| const addN=btnRows($('#n_btns',m)),addO=btnRows($('#o_btns',m)); | |
| $('#n_addB',m).onclick=()=>addN();$('#o_addB',m).onclick=()=>addO(); | |
| // 🆕 发布 / 修改都挂实时预览 | |
| const paintN=bindPreview(m,{chId:'n_ch',textId:'n_text',btnsId:'n_btns',fileId:'n_file',hostId:'n_pv',mode:'send'}); | |
| const paintO=bindPreview(m,{chId:'o_ch',textId:'o_text',btnsId:'o_btns',fileId:'o_file',hostId:'o_pv',mode:'edit'}); | |
| bindFile(m,'n_file',paintN);bindFile(m,'o_file',paintO); // 🆕 附件:原生选择器 + 可移除 | |
| const collectBtns=host=>$$('.dyn-row',host).map(r=>{const ii=$$('.inp',r);return{text:ii[0].value.trim(),url:ii[1].value.trim()}}).filter(b=>b.text&&b.url); | |
| $('#n_send',m).onclick=async e=>{ | |
| const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',15)} 发送中`;icons(); | |
| try{ | |
| const ch=normCh(gv('n_ch',m));if(!ch)throw new Error('请选择目标频道'); | |
| const fd=new FormData();fd.set('ch_id',ch);fd.set('text',gv('n_text',m)); | |
| fd.set('buttons',JSON.stringify(collectBtns($('#n_btns',m)))); | |
| const f=$('#n_file',m).files[0];if(f)fd.set('media',f); | |
| if(!gv('n_text',m)&&!f)throw new Error('请输入正文或选择媒体'); | |
| await api('/api/btn_multi',{method:'POST',form:fd});toast('消息已发送'); | |
| if(paintN)paintN(); | |
| }catch(err){toast(err.message,'err')} | |
| btn.disabled=false;btn.innerHTML=`${I('send',15)} 发送到频道`;icons(); | |
| }; | |
| $('#o_go',m).onclick=async e=>{ | |
| const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',15)} 处理中`;icons(); | |
| try{ | |
| const ref=msgRef(gv('o_msg',m));const och=normCh(gv('o_ch',m))||ref.ch; | |
| if(!och||!(ref.id||gv('o_msg',m)))throw new Error('频道与消息 ID 必填'); | |
| const fd=new FormData();fd.set('ch_id',och);fd.set('msg_id',ref.id||gv('o_msg',m));fd.set('text',gv('o_text',m)); | |
| fd.set('buttons',JSON.stringify(collectBtns($('#o_btns',m)))); | |
| const f=$('#o_file',m).files[0];if(f)fd.set('media',f); | |
| await api('/api/btn_old',{method:'POST',form:fd});toast('旧消息已更新'); | |
| if(paintO)paintO(); | |
| }catch(err){toast(err.message,'err')} | |
| btn.disabled=false;btn.innerHTML=`${I('check',15)} 应用修改`;icons(); | |
| }; | |
| } | |
| function taskMarkup(x){ | |
| const kind=x.kind==='backup'?'备份':'防剧透'; | |
| const done=x.kind==='backup'?(x.success||0)+(x.failed||0)+(x.skipped||0):x.scanned||0; | |
| const pct=x.total?Math.min(100,Math.floor(done*100/x.total)):0; | |
| const label=cid=>chName(cid)||shortId(cid||''); | |
| const heading=x.kind==='backup'?`${label(x.src)} → ${label(x.tgt)}`:label(x.channel); | |
| const state=x.running?(x.stage||'处理中'):(x.error||x.failed?'部分失败':'已完成'); | |
| return `<article class="task-row"><div class="task-heading"><strong>${esc(heading)}</strong><span class="chip${x.error||x.failed?' task-error':''}">${esc(state)}</span></div><div class="task-meta">${kind} · ${x.total==null?'已扫描 '+(x.scanned||0):done+' / '+x.total+' · '+pct+'%'}</div><div class="prog" role="progressbar" aria-label="${kind}进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${pct}"><i style="width:${pct}%"></i></div><div class="task-meta">成功 ${x.success??x.changed??0} · 跳过 ${x.skipped||0} · 失败 ${x.failed||0}</div>${x.error?`<div class="task-reason">${esc(x.error)}</div>`:''}<div class="task-actions">${x.failed_ids?.length?`<details><summary>失败详情(${x.failed_ids.length})</summary><div class="task-reason">消息 ID:${x.failed_ids.map(esc).join('、')}</div></details>`:''}${!x.running&&x.id?`<button class="ic-b" title="收起任务" data-task-action="dismiss" data-kind="${x.kind}" data-id="${esc(x.id)}">${I('x',15)}</button>${(x.error||x.failed)&&x.retryable!==false?`<button class="btn" data-task-action="retry" data-kind="${x.kind}" data-id="${esc(x.id)}">${I('rotate-cw',14)} 重试任务</button>`:''}`:''}</div></article>`; | |
| } | |
| /* ================= 07 ops ================= */ | |
| function vOps(m){ | |
| m.innerHTML=vh('01','运维','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"> | |
| <button class="pk-fold" type="button" aria-expanded="false"><span><strong class="pk-summary">暂未选择目标频道</strong></span>${I('chevron-down',15)}</button> | |
| <div class="pk pk-list">${sortedBookEntries(S.data.address_book||{}).map(([cid,nm])=>{const label=String(nm||'').trim()||shortId(cid);return`<span class="chip pk-c" data-cid="${esc(cid)}" data-label="${esc(label)}">${esc(label)}</span>`}).join('')||'<span class="fld-h">地址簿为空</span>'}</div> | |
| </div> | |
| ${fld('起始消息链接(可选)',inp('b_start','','从这条旧消息开始备份到最新'),'包含起始消息;所在相册整组备份。填写后自动取任务启动时的最新消息。')} | |
| ${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 id="b_progress" class="fld-h" role="status" style="margin-top:12px">读取备份进度…</div> | |
| </div> | |
| <div class="ops-c" data-rv> | |
| <div class="cmp-t">${I('eye-off')} 全频道防剧透</div> | |
| <div class="cmp-s">仅处理全部历史图片/视频/动画遮罩,文字和说明保持原样。原地编辑,不重新上传。Userbot 需要编辑消息权限。</div> | |
| ${fld('处理频道',picker('sp_ch',S.data.address_book,''))} | |
| <div class="sw-row"><div><div class="t">相册首个媒体不加遮罩</div><div class="s">单个媒体仍加遮罩;相册首项已有遮罩会移除</div></div><button class="sw" id="sp_skip_first" type="button" aria-label="相册首个媒体不加遮罩" aria-pressed="false"></button></div> | |
| <button class="btn btn-acc" id="sp_on" style="width:100%;justify-content:center">${I('eye-off',15)} 开启防剧透</button> | |
| <button class="btn" id="sp_off" style="width:100%;justify-content:center;margin-top:8px">${I('eye',15)} 移除防剧透</button> | |
| <div id="sp_progress" role="status" class="fld-h" style="margin-top:12px">读取进度…</div> | |
| </div> | |
| </div> | |
| <div> | |
| <section class="task-center" data-rv> | |
| <div class="cmp-t">${I('activity')} 任务中心 <span class="chip" id="task_count">0</span></div> | |
| <div class="task-tabs"><button class="btn" id="tasks_active" aria-pressed="true">当前任务</button><button class="btn" id="tasks_history" aria-pressed="false">历史记录</button></div> | |
| <div id="task_list">读取任务…</div> | |
| </section> | |
| <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> | |
| </div> | |
| <div class="foot" data-rv><span>危险操作请二次确认</span><span>OPS MODULE</span></div>`; | |
| bindPicker('b_src',m);bindPicker('r_ch',m);bindPicker('sp_ch',m); | |
| let taskTab='active'; | |
| const taskData={backup:[],spoiler:[]}; | |
| const renderTasks=()=>{ | |
| const all=[...taskData.backup.map(x=>({...x,kind:'backup'})),...taskData.spoiler.map(x=>({...x,kind:'spoiler'}))]; | |
| const rows=all.filter(x=>taskTab==='history'?!x.running:!x.dismissed&&(x.running||x.error||x.failed>0)); | |
| $('#task_count',m).textContent=String(all.filter(x=>x.running).length); | |
| $('#task_list',m).innerHTML=rows.map(taskMarkup).join('')||'<div class="task-meta">暂无'+(taskTab==='history'?'历史记录':'进行中的任务')+'</div>'; | |
| icons(); | |
| $$('[data-task-action]',m).forEach(button=>button.onclick=async()=>{ | |
| button.disabled=true; | |
| try{await api('/api/tasks/action',{method:'POST',body:{kind:button.dataset.kind,id:button.dataset.id,action:button.dataset.taskAction}});await Promise.all([refreshBackup(false),refreshSpoiler(false)])} | |
| catch(e){toast(e.message,'err');button.disabled=false} | |
| }); | |
| }; | |
| for(const [id,tab] of [['tasks_active','active'],['tasks_history','history']])$('#'+id,m).onclick=()=>{taskTab=tab;$('#tasks_active',m).setAttribute('aria-pressed',String(tab==='active'));$('#tasks_history',m).setAttribute('aria-pressed',String(tab==='history'));renderTasks()}; | |
| const backupBox=$('#b_progress',m); | |
| const refreshBackup=async(schedule=true)=>{ | |
| if(!backupBox.isConnected)return; | |
| try{ | |
| const j=await api('/api/backup/progress'); | |
| if(!backupBox.isConnected)return; | |
| taskData.backup=j.jobs||[];renderTasks(); | |
| const visibleJobs=taskData.backup.filter(x=>!x.dismissed&&x.running); | |
| backupBox.hidden=visibleJobs.length===0; | |
| backupBox.innerHTML=visibleJobs.length?`<div class="task-meta">${visibleJobs.length} 个目标正在备份 · <a href="#task_list" style="color:var(--acc)">查看任务进度</a></div>`:''; | |
| }catch(e){if(backupBox.isConnected)backupBox.textContent='进度读取失败:'+e.message} | |
| if(schedule&&backupBox.isConnected)setTimeout(refreshBackup,2000); | |
| }; | |
| refreshBackup(); | |
| const progressBox=$('#sp_progress',m); | |
| const refreshSpoiler=async(schedule=true)=>{ | |
| if(!progressBox.isConnected)return; | |
| try{ | |
| const j=await api('/api/channel_spoiler'); | |
| if(!progressBox.isConnected)return; | |
| taskData.spoiler=j.jobs||[];renderTasks(); | |
| const visibleJobs=taskData.spoiler.filter(x=>!x.dismissed&&x.running); | |
| progressBox.hidden=visibleJobs.length===0; | |
| progressBox.innerHTML=visibleJobs.length?`<div class="task-meta">${visibleJobs.length} 个频道正在处理 · <a href="#task_list" style="color:var(--acc)">查看任务进度</a></div>`:''; | |
| }catch(e){if(progressBox.isConnected)progressBox.textContent='进度读取失败:'+e.message} | |
| if(schedule&&progressBox.isConnected)setTimeout(refreshSpoiler,2000); | |
| }; | |
| refreshSpoiler(); | |
| $('#sp_skip_first',m).onclick=e=>{ | |
| const on=e.currentTarget.classList.toggle('on'); | |
| e.currentTarget.setAttribute('aria-pressed',String(on)); | |
| }; | |
| const startSpoiler=enabled=>{ | |
| const ch=gv('sp_ch',m); | |
| if(!ch)return toast('请选择频道','err'); | |
| confirmSheet(`确认对该频道全部历史内容${enabled?'开启':'移除'}防剧透?仅处理图片、视频和动画,文字不变。`,async()=>{ | |
| $('#sp_on',m).disabled=$('#sp_off',m).disabled=true; | |
| try{ | |
| await api('/api/channel_spoiler',{method:'POST',body:{ch_id:ch,enabled,skip_album_first:$('#sp_skip_first',m).classList.contains('on')}}); | |
| toast('防剧透任务已启动,进度显示在按钮下方'); | |
| }catch(e){toast(e.message,'err')} | |
| finally{$('#sp_on',m).disabled=$('#sp_off',m).disabled=false} | |
| },'开始处理'); | |
| }; | |
| $('#sp_on',m).onclick=()=>startSpoiler(true); | |
| $('#sp_off',m).onclick=()=>startSpoiler(false); | |
| const targetBox=$('#b_tgts',m),targetFold=$('.pk-fold',targetBox),targetSummary=$('.pk-summary',targetBox); | |
| const refreshTargets=()=>{const chosen=$$('.pk-c.on',targetBox);targetSummary.textContent=chosen.length?`已选择 ${chosen.length} 个目标频道`:'暂未选择目标频道';} | |
| targetFold.onclick=()=>{const open=targetBox.classList.toggle('is-open');targetFold.setAttribute('aria-expanded',String(open))}; | |
| $$('#b_tgts .pk-c',m).forEach(c=>c.onclick=()=>{c.classList.toggle('on');refreshTargets()}); | |
| refreshTargets(); | |
| $('#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)&&!gv('b_start',m)))throw new Error('请选择源频道、目标,并填写起始或截止消息链接'); | |
| const body={src:gv('b_src',m),tgt,link:gv('b_link',m),start_link:gv('b_start',m),wash:$('#b_wash',m).classList.contains('on')}; | |
| confirmSheet(`确认备份「${chName(body.src)||body.src}」到 ${tgt.length} 个目标频道?范围:${body.start_link?'从 '+body.start_link+' 到启动时最新消息':'频道开头到 '+body.link};${body.wash?'洗 MD5':'普通复制'}。`,async()=>{ | |
| try{await api('/api/backup',{method:'POST',body});toast('备份任务已启动');await refreshBackup(false)}catch(err){toast(err.message,'err')} | |
| },'启动备份'); | |
| }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; | |
| }; | |
| } | |
| function vMembers(m){ | |
| const mons=(S.data&&S.data.member_monitors)||[]; | |
| m.innerHTML=vh('02','回家','MEMBERS','成员台账、实时监控与宝贝回家任务。')+ | |
| `<div class="ops-grid"> <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)">兜底对账每 ${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-imp="${esc(mm.channel_id)}" title="导入 CSV 台账">${I('file-up',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> | |
| <input id="m_csv" type="file" accept=".csv,text/csv" hidden> | |
| <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('home')} 宝贝回家 <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">按源频道成员 ID 发放一次性入群申请链接,自动校验并审批</div> | |
| ${fld('源频道',picker('hm_src',S.data.address_book,''))} | |
| ${fld('目标频道',picker('hm_tgt',S.data.address_book,''))} | |
| ${fld('任务暗号',inp('hm_keyword','','例如:回家一号','text','maxlength=64'),'私信精确匹配(忽略首尾空格,区分大小写);留空保留原领取方式。不同任务不可重复。')} | |
| ${[['hm_first','匹配ID回复','宝贝回家:{invite_link}'],['hm_unmatched','未匹配ID回复','你不在会员回家名单内,如果购买过但仍显示不在名单内请联系客服']].map(([id,label,hint])=>fldFmt(label,id,hint,4,'支持回车换行;选中文字后点击工具栏设置格式。')+`<div style="display:flex;gap:6px;flex-wrap:wrap;margin:8px 0 16px">${[['invite_link','邀请链接'],['user_id','用户 ID'],['source_channel','源频道 ID'],['target_channel','目标频道 ID']].map(([key,name])=>`<button type="button" class="btn" style="padding:5px 8px;font-size:11px" data-hm-insert="${key}" data-hm-field="${id}" title="插入 {${key}}">${name}</button>`).join('')}</div>`).join('')} | |
| <button class="btn btn-acc" id="hm_save" style="width:100%;justify-content:center">保存迁移规则</button> | |
| <div id="hm_list" style="padding:12px 0">读取中…</div> | |
| </div> | |
| </div>`; | |
| bindPicker('m_ch',m);bindPicker('hm_src',m);bindPicker('hm_tgt',m); | |
| bindFmt(m,'hm_first');bindFmt(m,'hm_unmatched'); | |
| const csvInput=$('#m_csv',m),importButtons=$$('[data-imp]',m); | |
| let importTarget=null; | |
| importButtons.forEach(btn=>btn.onclick=()=>{ | |
| importTarget=btn;csvInput.value='';csvInput.click(); | |
| }); | |
| csvInput.onchange=async()=>{ | |
| const file=csvInput.files?.[0],btn=importTarget; | |
| if(!file||!btn)return; | |
| const cid=btn.dataset.imp; | |
| importButtons.forEach(b=>b.disabled=true); | |
| try{ | |
| const fd=new FormData();fd.set('file',file); | |
| const j=await api('/api/member_import/'+encodeURIComponent(cid),{method:'POST',form:fd}); | |
| applyUser(j.user);toast(`导入完成:新增 ${j.counts.added},更新 ${j.counts.updated},在册 ${j.stats.in},退出 ${j.stats.left},累计 ${j.stats.total}`);go('members'); | |
| }catch(err){toast(err.message,'err')} | |
| finally{importButtons.forEach(b=>b.disabled=false);csvInput.value='';importTarget=null} | |
| }; | |
| $('#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('members'); | |
| }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('members')}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('members')}catch(e){toast(e.message,'err')} | |
| })); | |
| $$('[data-hm-insert]',m).forEach(b=>{ | |
| b.onmousedown=e=>e.preventDefault(); | |
| b.onclick=()=>{ | |
| const input=$('#'+b.dataset.hmField,m), value='{'+b.dataset.hmInsert+'}'; | |
| const start=input.selectionStart??input.value.length, end=input.selectionEnd??start; | |
| input.setRangeText(value,start,end,'end'); | |
| input.focus(); | |
| input.dispatchEvent(new Event('input',{bubbles:true})); | |
| }; | |
| }); | |
| const drawHome=async()=>{ | |
| try{ | |
| const j=await api('/api/migrations'); | |
| $('#hm_list',m).innerHTML=(j.rules||[]).map(r=>{ | |
| const claims=Object.values(j.claims||{}).filter(c=>c.source===r.source&&c.target===r.target), issued=claims.length, joined=claims.filter(c=>c.status==='joined').length; | |
| const failed=claims.filter(c=>c.status==='error').length, enabled=r.enabled!==false; | |
| const state=!enabled?'已停用':failed?'异常':issued?'运行中':'待触发'; | |
| const color=!enabled?'var(--dim)':failed?'var(--red)':issued?'var(--grn)':'var(--mut)'; | |
| return`<div class="mon-row" style="align-items:flex-start"><div style="min-width:180px;flex:1"><div style="font-weight:600">${esc(chName(r.source)||r.source)} → ${esc(chName(r.target)||r.target)}</div><div class="mono" style="color:var(--dim)">${r.created_at?`创建于 ${fmtT(r.created_at)} · `:''}自动审批 · ${r.keyword?'暗号:'+esc(r.keyword):'未设置暗号'}</div></div><div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;justify-content:flex-end"><span class="chip" style="color:${color};border-color:${color}">${I(failed?'alert-triangle':enabled?'radio':'pause',11)} ${state}</span><span class="chip" title="已发放 / 已入群">${I('send',11)} ${issued} / ${joined}</span><button class="ic-b" data-hm-view="${esc(r.source)}" title="查看领取记录">${I('list',14)}</button><button class="ic-b" data-hm-edit="${esc(r.source)}" title="编辑任务暗号及回复">${I('pencil',14)}</button><button class="ic-b danger" data-hm-del="${esc(r.source)}" title="删除任务">${I('trash-2',14)}</button></div><div style="width:100%;color:var(--dim);font-size:11px;line-height:1.6"><span class="mono">匹配ID:${esc(r.first_reply||'')}</span><br><span class="mono">未匹配ID:${esc(r.unmatched_reply||'你不在会员回家名单内,如果购买过但仍显示不在名单内请联系客服')}</span></div></div>`; | |
| }).join('')||'<div class="fld-h" style="padding:14px 0">暂无宝贝回家任务</div>'; | |
| icons($('#hm_list',m)); | |
| $$('[data-hm-view]',m).forEach(b=>b.onclick=()=>homeSheet(b.dataset.hmView,j)); | |
| $$('[data-hm-edit]',m).forEach(b=>b.onclick=()=>{ | |
| const r=(j.rules||[]).find(x=>x.source===b.dataset.hmEdit);if(!r)return; | |
| const w=sheet({title:'编辑回家任务',sub:`${esc(chName(r.source)||r.source)} → ${esc(chName(r.target)||r.target)}`, | |
| body:fld('任务暗号',inp('hm_edit_keyword',r.keyword||'','例如:回家一号','text','maxlength=64'),'私信精确匹配;留空保留原领取方式。')+[['hm_edit_first','匹配ID回复',r.first_reply||'宝贝回家:{invite_link}'],['hm_edit_unmatched','未匹配ID回复',r.unmatched_reply||'你不在会员回家名单内,如果购买过但仍显示不在名单内请联系客服']].map(([id,label,value])=>fldFmt(label,id,value,4,'支持回车换行;可使用 {invite_link}、{user_id} 等变量。')).join(''), | |
| foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} 保存修改</button>`}); | |
| icons(w);bindFmt(w,'hm_edit_first');bindFmt(w,'hm_edit_unmatched'); | |
| $('#hm_edit_first',w).value=r.first_reply||'宝贝回家:{invite_link}'; | |
| $('#hm_edit_unmatched',w).value=r.unmatched_reply||'你不在会员回家名单内,如果购买过但仍显示不在名单内请联系客服'; | |
| $('[data-a=no]',w).onclick=w._close; | |
| $('[data-a=ok]',w).onclick=async()=>{const btn=$('[data-a=ok]',w);btn.disabled=true;try{await api('/api/migrations/'+encodeURIComponent(r.source),{method:'PUT',body:{keyword:gv('hm_edit_keyword',w),first_reply:gv('hm_edit_first',w),unmatched_reply:gv('hm_edit_unmatched',w)}});toast('任务暗号及回复已更新');w._close();drawHome()}catch(e){toast(e.message,'err');btn.disabled=false}}; | |
| }); | |
| $$('[data-hm-del]',m).forEach(b=>b.onclick=async()=>{try{await api('/api/migrations/'+encodeURIComponent(b.dataset.hmDel),{method:'DELETE'});toast('迁移规则已删除');drawHome()}catch(e){toast(e.message,'err')}}); | |
| }catch(e){$('#hm_list',m).textContent='读取失败:'+e.message} | |
| }; | |
| $('#hm_save',m).onclick=async()=>{try{const body={source:gv('hm_src',m),target:gv('hm_tgt',m),keyword:gv('hm_keyword',m),first_reply:gv('hm_first',m),unmatched_reply:gv('hm_unmatched',m)};if(!body.source||!body.target)throw new Error('源频道和目标频道必填');await api('/api/migrations',{method:'POST',body});toast('迁移规则已保存');drawHome()}catch(e){toast(e.message,'err')}}; | |
| drawHome(); | |
| } | |
| /* 宝贝回家领取记录:样式对齐成员台账 */ | |
| function homeSheet(source,j){ | |
| const rule=(j.rules||[]).find(r=>r.source===source)||{}; | |
| const rows=Object.values(j.claims||{}).filter(c=>c.source===source&&c.target===rule.target) | |
| .sort((a,b)=>((b.joined_at||b.created_at||0)-(a.joined_at||a.created_at||0))); | |
| const issued=rows.length, joined=rows.filter(c=>c.status==='joined').length, failed=rows.filter(c=>c.error||c.status==='error').length; | |
| const w=sheet({title:'回家记录',sub:`${esc(chName(source)||source)} → ${esc(chName(rule.target)||rule.target||'')}`, | |
| body:'<div id="homeBox"><div class="fld-h" style="padding:16px 0">读取中…</div></div>'}); | |
| 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 line=c=>{ | |
| const ok=c.status==='joined',err=c.error||c.status==='error',sign=err?'!':ok?'+':'→',col=err?'var(--red)':ok?'var(--grn)':'var(--mut)'; | |
| const t=ok?(c.joined_at||c.created_at):c.created_at, lab=err?'失败':ok?'已入群':'已发放'; | |
| return`<div class="mon-row" style="padding:8px 0"><div><span style="color:${col}">${sign}</span> ${esc(c.name||c.user_id||'—')} <span class="mono" style="color:var(--dim)">${c.un?'@'+esc(c.un):esc(c.user_id||'—')}</span></div><div class="mono" style="color:${col}">${fmtT(t)} · ${lab}</div></div>`; | |
| }; | |
| $('#homeBox',w).innerHTML=`<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px"> | |
| ${tag('累计发放',issued)}${tag('已入群',joined,'var(--grn)')}${tag('异常',failed,'var(--red)')} | |
| </div> | |
| <div class="fld-h" style="margin-bottom:12px">${rule.created_at?`规则创建于 ${fmtT(rule.created_at)} · `:''}自动审批 · 成员触发${rule.enabled===false?' · 已停用':''}</div> | |
| <div class="sec-t">最近动态</div> | |
| <div style="max-height:190px;overflow:auto">${rows.length?rows.slice(0,80).map(line).join(''):'<div class="fld-h" style="padding:12px 0">还没有领取记录</div>'}</div> | |
| <div class="sec-t">回复内容</div> | |
| <div class="mon-row" style="padding:8px 0"><div style="color:var(--mut)">匹配ID</div><div class="mono" style="color:var(--dim);max-width:70%;text-align:right;white-space:pre-wrap">${esc(rule.first_reply||'—')}</div></div> | |
| <div class="mon-row" style="padding:8px 0"><div style="color:var(--mut)">未匹配ID</div><div class="mono" style="color:var(--dim);max-width:70%;text-align:right;white-space:pre-wrap">${esc(rule.unmatched_reply||'你不在会员回家名单内,如果购买过但仍显示不在名单内请联系客服')}</div></div>`; | |
| icons(); | |
| } | |
| /* 🆕 单频道成员台账:累计人数、实时动态、成员列表(各频道分开记) */ | |
| async function memSheet(cid){ | |
| const w=sheet({title:'成员台账',sub:esc(S.data.address_book?.[cid]||shortId(cid)), | |
| body:inp('mem_q','','搜索名字 / @用户名 / ID')+'<div id="memBox"><div class="fld-h" style="padding:16px 0">读取中…</div></div>'}); | |
| let revision=0,tm,composing=false; | |
| const qi=$('#mem_q',w); | |
| const schedule=()=>{clearTimeout(tm);revision++;if(!composing)tm=setTimeout(()=>draw(qi.value.trim()),350)}; | |
| qi.oninput=schedule; | |
| qi.oncompositionstart=()=>{composing=true;clearTimeout(tm);revision++}; | |
| qi.oncompositionend=()=>{composing=false;schedule()}; | |
| const draw=async(q='')=>{ | |
| const current=++revision; | |
| try{ | |
| const j=await api('/api/member_log/'+encodeURIComponent(cid)+(q?('?q='+encodeURIComponent(q)):'')); | |
| if(current!==revision||!w.isConnected)return; | |
| 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> | |
| <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(); | |
| }catch(e){if(current!==revision||!w.isConnected)return;const box=$('#memBox',w);if(box)box.textContent='读取失败:'+e.message} | |
| }; | |
| draw(); | |
| } | |
| function adminMenu(){ | |
| const w=sheet({title:'管理员',body:`<div style="display:grid;gap:12px"><button class="btn" id="admin_acl">${I('shield-check',16)} 访问控制</button><button class="btn" id="admin_session">${I('key-round',16)} 获取 Session</button></div>`}); | |
| icons(); | |
| $('#admin_acl',w).onclick=()=>{w._close();openAccessControl()}; | |
| $('#admin_session',w).onclick=()=>{w._close();openSessionControl()}; | |
| } | |
| function openAccessControl(){ | |
| const m=sheet({title:'访问控制',body:` <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>`}); | |
| loadAcl(m); | |
| } | |
| function openSessionControl(){ | |
| const m=sheet({title:'获取 Session',body:'<div id="sessionBox" class="fld-h">读取登录状态…</div>'}); | |
| const sessionBox=$('#sessionBox',m); | |
| const drawSession=(j)=>{ | |
| const stage=j.stage||'phone',st=j.status||{}; | |
| sessionBox.innerHTML=`<div class="cmp-s">${esc(j.msg|| (st.has?`已保存 · ${st.live?'在线':'未连接'} · ${st.name||''}`:'尚未保存 Session'))}</div> | |
| ${stage==='phone'?fld('手机号(含国家码)',inp('session_value','','+8613800138000','tel','autocomplete="off"')):stage==='code'?fld('Telegram 验证码',inp('session_value','','输入验证码','text','autocomplete="one-time-code" inputmode="numeric"')):fld('两步验证密码',inp('session_value','','输入两步验证密码','password','autocomplete="off"'))} | |
| <div style="display:flex;gap:8px;flex-wrap:wrap"><button class="btn btn-acc" id="session_submit">${stage==='phone'?'获取验证码':stage==='code'?'验证登录':'提交密码'}</button><button class="btn" id="session_cancel">取消登录</button><button class="btn" id="session_refresh">刷新状态</button></div>`; | |
| const run=async(action)=>{ | |
| const input=$('#session_value',sessionBox),value=input.value; | |
| if(action!=='cancel'&&!value.trim())return toast('请填写登录信息','err'); | |
| $$('button',sessionBox).forEach(b=>b.disabled=true); | |
| try{ | |
| const body={action};if(action!=='cancel')body[stage==='phone'?'phone':stage]=value; | |
| const result=await api('/api/userbot/session',{method:'POST',body}); | |
| input.value='';drawSession(result);toast(result.msg); | |
| }catch(e){input.value='';toast(e.message,'err')} | |
| finally{$$('button',sessionBox).forEach(b=>b.disabled=false)} | |
| }; | |
| $('#session_submit',sessionBox).onclick=()=>run(stage==='phone'?'start':stage); | |
| $('#session_cancel',sessionBox).onclick=()=>run('cancel'); | |
| $('#session_refresh',sessionBox).onclick=loadSession; | |
| }; | |
| const loadSession=async()=>{ | |
| if(DEMO){sessionBox.textContent='演示模式不支持登录,请勿输入真实手机号、验证码或密码。';return} | |
| try{drawSession(await api('/api/userbot/session'))}catch(e){sessionBox.textContent=e.message} | |
| }; | |
| loadSession(); | |
| } | |
| /* 🆕 白名单面板 */ | |
| async function loadAcl(m,retry=0){ | |
| try{ | |
| const j=await api('/api/admins');renderAcl(m,j); | |
| /* 🆕 名字/头像是后台刷的:还缺就静默再取两次,不用退出面板再进 */ | |
| const miss=Object.values(j.profiles||{}).some(p=>!p.avatar||!p.name); | |
| if(miss&&retry<2&&document.body.contains(m))setTimeout(()=>loadAcl(m,retry+1),retry?6000:2500); | |
| } | |
| catch(e){const b=$('#aclBox',m);if(b)b.innerHTML=`<div class="fld-h" style="padding:14px 0">读取失败:${esc(e.message)}</div>`} | |
| } | |
| function renderAcl(m,j){ | |
| const box=$('#aclBox',m);if(!box)return; | |
| const me=String(j.me||meInfo().id||''); | |
| const prof=j.profiles||{}; | |
| S.owner=!!j.owner; /* 🆕 只有主人能增删 */ | |
| const row=(id,owner)=>{ | |
| const p=prof[id]||{}; | |
| const nm=p.name||(p.username?'@'+p.username:''); | |
| return`<div class="mon-row"> | |
| <div style="display:flex;align-items:center;gap:11px;min-width:0"> | |
| ${ava(p.avatar,nm||id)} | |
| <div style="min-width:0"> | |
| <div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"> | |
| ${nm?esc(nm):'<span style="color:var(--dim)">未跟机器人说过话</span>'}${id===me?' <span class="chip">我</span>':''}</div> | |
| <div class="mono" style="color:var(--dim)">${p.username?'@'+esc(p.username)+' · ':''}ID ${esc(id)} · ${owner?'主人':'成员'}</div> | |
| </div></div> | |
| <div style="display:flex;align-items:center;gap:8px"> | |
| ${owner?`<span class="chip">${I('lock',11)} 固定</span>` | |
| :(S.owner?`<button class="ic-b danger" data-acl="${esc(id)}" title="移出白名单">${I('user-minus',14)}</button>` | |
| :`<span class="chip">${I('eye',11)} 只读</span>`)} | |
| </div></div>`}; | |
| const owners=(j.owners||[]).map(x=>row(String(x),true)); | |
| const members=(j.members||[]).map(x=>row(String(x),false)); | |
| box.innerHTML=(owners.concat(members).join(''))||'<div class="fld-h" style="padding:14px 0">未配置白名单:目前只有已建档用户能进</div>'; | |
| /* 🆕 非主人:隐藏「放行新用户」表单,只留说明 */ | |
| const addWrap=$('#aclAdd',m); | |
| if(addWrap)addWrap.innerHTML=S.owner?addWrap.dataset.form: | |
| `<div class="fld-h" style="padding:12px 0 2px">只有主人(环境变量 ADMIN_IDS 里的人)能增删白名单,你可以查看名单。</div>`; | |
| icons(); | |
| if(S.owner)bindAclAdd(m); | |
| $$('[data-acl]',box).forEach(b=>b.onclick=()=>confirmSheet(`把 ${b.dataset.acl} 移出白名单?他将立刻无法进入控制台与使用机器人。`,async()=>{ | |
| try{const r=await api('/api/admins',{method:'DELETE',body:{uid:b.dataset.acl}});toast(r.msg||'已移除');renderAcl(m,r)}catch(e){toast(e.message,'err')} | |
| })); | |
| } | |
| function bindAclAdd(m){ | |
| const btn=$('#acl_add',m);if(!btn)return; | |
| btn.onclick=async e=>{ | |
| const b=e.currentTarget;const uid=(gv('acl_uid',m)||'').replace(/\D/g,''); | |
| if(!uid)return toast('请填写纯数字的用户 ID','err'); | |
| b.disabled=true; | |
| try{const j=await api('/api/admins',{method:'POST',body:{uid}});toast(j.msg||'已加入白名单');$('#acl_uid',m).value='';renderAcl(m,j)} | |
| catch(err){toast(err.message,'err')} | |
| b.disabled=false; | |
| }; | |
| } | |
| /* ================= 🆕 头像组件(服务端缓存,换头像后 URL 带新指纹自动刷新) ================= */ | |
| const AVA_CSS=`.ava{width:34px;height:34px;border-radius:50%;object-fit:cover;flex:none;background:var(--panel2); | |
| border:1px solid var(--line2);display:inline-flex;align-items:center;justify-content:center; | |
| font:700 13px var(--fd);color:var(--dim);overflow:hidden} | |
| .ava.sq{border-radius:11px}.ava.lg{width:44px;height:44px;font-size:16px}`; | |
| function ava(url,name,cls=''){ | |
| const ini=esc(String(name||'?').trim().slice(0,1).toUpperCase()); | |
| return url?`<img class="ava ${cls}" src="${esc(url)}" alt="" loading="lazy" referrerpolicy="no-referrer" | |
| data-ini="${ini}" data-cls="${esc(cls)}" onerror="avaErr(this)">` | |
| :`<span class="ava ${cls}">${ini}</span>`; | |
| } | |
| /* 🆕 头像取失败先重试一次(服务端可能正在从 WebDAV 回源),真没有才退回首字母 */ | |
| function avaErr(img){ | |
| if(!img.dataset.retry){ | |
| img.dataset.retry='1'; | |
| const u=img.src.split('#')[0].replace(/[?&]r=\d+/,''); | |
| setTimeout(()=>{img.src=u+(u.includes('?')?'&':'?')+'r='+Date.now()},1800); | |
| return; | |
| } | |
| img.outerHTML=`<span class="ava ${img.dataset.cls||''}">${img.dataset.ini||'?'}</span>`; | |
| } | |
| /* ================= 🆕 08 我的资料(头像 + 名字) ================= */ | |
| function meInfo(){ | |
| const p=S.me||{}; | |
| const cache=S.meCache||{}; | |
| const first=p.first_name||(TGU&&TGU.first_name)||''; | |
| const last=p.last_name||(TGU&&TGU.last_name)||''; | |
| const uname=p.username||(TGU&&TGU.username)||''; | |
| const photo=cache.avatar||p.photo_url||(TGU&&TGU.photo_url)||''; /* 🆕 先用服务端缓存(同源、命中浏览器缓存、秒开),TG 的 photo_url 慢且会过期 */ | |
| const id=p.id||(TGU&&TGU.id)||''; | |
| const name=[first,last].filter(Boolean).join(' ')||cache.name||(uname?'@'+uname:(DEMO?'演示用户':'Telegram 用户')); | |
| return{name,uname,photo,id,premium:!!(p.is_premium||(TGU&&TGU.is_premium))}; | |
| } | |
| function renderMe(){ | |
| const box=$('#me');if(!box)return; | |
| const u=meInfo(); | |
| const initials=esc((u.name||'U').trim().slice(0,1).toUpperCase()); | |
| /* 🆕 头像三级回退:服务端缓存(快)→ TG 的 photo_url(慢、会过期)→ 首字母 */ | |
| const cached=(S.meCache&&S.meCache.avatar)||''; | |
| const srcs=[cached,u.photo].filter(Boolean); | |
| box.innerHTML=`${srcs.length?`<img class="av" src="${esc(srcs[0])}" alt="" referrerpolicy="no-referrer">`:`<span class="av">${initials}</span>`} | |
| <div class="mn"><b>${esc(u.name)}</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'; | |
| box.setAttribute('role','button');box.setAttribute('tabindex','0'); | |
| box.setAttribute('aria-label','管理员设置');box.style.cursor='pointer'; | |
| box.onclick=adminMenu; | |
| box.onkeydown=e=>{if(e.key==='Enter'||e.key===' '){e.preventDefault();adminMenu()}}; | |
| } | |
| /* ================= 🆕 09 榜单完整名单(在 Telegram 内打开) ================= */ | |
| async function vRankList(m,key){ | |
| m.innerHTML=`<div class="rk"><div class="rk-h"><h1>完整名单</h1><p>加载中…</p></div></div>`; | |
| let payload=null; | |
| try{ | |
| if(DEMO){payload={title:'本周热评 TOP10(演示)',updated:'今天',items: | |
| Array.from({length:24},(_,i)=>({r:i+1,n:['夜航星','雾都夜谈','白夜行','长安十二时辰','银河edge'][i%5]+'('+(i+1)+')', | |
| c:180-i*6,l:'https://t.me/Nine_7Eleven/88?comment='+(500+i),d:i%7===0}))};} | |
| else{const r=await fetch('/api/list/'+encodeURIComponent(key));payload=await r.json(); | |
| if(!payload||payload.ok===false)throw new Error(payload&&payload.msg||'名单不存在');} | |
| }catch(e){ | |
| m.innerHTML=`<div class="rk">${emptyBox('list-x','名单不可用',esc(e.message||'请等待榜单下次刷新后重试'))} | |
| <button class="btn" id="rkBack" style="margin:18px auto;display:flex">${I('arrow-left',14)} 返回控制台</button></div>`; | |
| icons();$('#rkBack',m).onclick=()=>{S.list=null;go('ops')};return; | |
| } | |
| const items=payload.items||[]; | |
| const secondary=payload.list_mode==='participants'?(payload.participants||[]):(payload.blacklist||[]); | |
| const secondaryTitle=payload.list_mode==='participants'?'参与投票名单':'完整屏蔽名单'; | |
| m.innerHTML=`<div class="rk"> | |
| <div class="rk-h" data-rv><h1>${esc(payload.title||'完整名单')}</h1> | |
| <p>更新于 ${esc(payload.updated||'—')} · <span id="rkSummary">共 ${items.length} 人 · 点击跳转投票</span></p></div> | |
| <div style="display:flex;gap:8px;margin:16px 0" aria-label="名单切换"><button class="btn" type="button" id="rkTabRank" aria-pressed="true">完整名单(${items.length})</button><button class="btn" type="button" id="rkTabBlocked" aria-pressed="false">${secondaryTitle}(${secondary.length})</button></div> | |
| <div id="rkRankPanel"> | |
| ${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','暂无数据','榜单还没有收集到评论')} | |
| </div><section id="rkBlockedPanel" hidden style="margin-top:24px"><h2 style="font-size:16px;margin-bottom:12px">${secondaryTitle}(${secondary.length})</h2> | |
| <div class="tagcloud">${secondary.map(name=>`<span class="chip" style="white-space:normal;overflow-wrap:anywhere">${esc(name)}</span>`).join('')||`<span class="fld-h">暂无${secondaryTitle}</span>`}</div></section> | |
| <button class="btn" id="rkBack" style="margin:24px auto 0;display:flex">${I('layout-grid',14)} 打开控制台</button></div>`; | |
| const switchList=blocked=>{ | |
| $('#rkRankPanel',m).hidden=blocked; | |
| $('#rkBlockedPanel',m).hidden=!blocked; | |
| $('#rkSummary',m).textContent=blocked?'共 '+secondary.length+' 人 · '+secondaryTitle:'共 '+items.length+' 人 · 点击跳转投票'; | |
| for(const [id,active] of [['rkTabRank',!blocked],['rkTabBlocked',blocked]]){ | |
| const button=$('#'+id,m);button.setAttribute('aria-pressed',String(active)); | |
| button.style.background=active?'var(--acc)':'';button.style.color=active?'var(--bg)':''; | |
| } | |
| }; | |
| $('#rkTabRank',m).onclick=()=>switchList(false); | |
| $('#rkTabBlocked',m).onclick=()=>switchList(true); | |
| switchList(false); | |
| icons();$('#rkBack',m).onclick=()=>{S.list=null;buildNav();go('ops')}; | |
| if(payload.stale)toast('这份名单是旧版缓存,链接要等下一轮榜单刷新才补齐','warn'); | |
| // 名单里的链接交给 Telegram 打开 | |
| $$('.rk-i',m).forEach(a=>a.onclick=e=>{e.preventDefault();openTgLink(a.getAttribute('href'))}); | |
| } | |
| /* 🆕 打开 t.me 链接:交给 Telegram 处理,不再弹「没跳过去」的兜底提示 */ | |
| function openTgLink(href){ | |
| if(!href||href==='#'){toast('这条评论没有可用的跳转链接(等下一轮榜单刷新)','err');return} | |
| try{ | |
| if(TG&&TG.openTelegramLink&&/^https:\/\/t\.me\//i.test(href))TG.openTelegramLink(href); | |
| else if(TG&&TG.openLink&&/^https?:/i.test(href))TG.openLink(href); | |
| else{const w2=window.open(href,'_blank');if(!w2)window.location.href=href} | |
| }catch(err){} | |
| } | |
| /* ================= events stream ================= */ | |
| const EV_MAX=50; /* 🆕 与后端一致:最多 50 条 */ | |
| function pushEv(type,data,time,ts){ | |
| const ev={type,data,time:time||new Date().toTimeString().slice(0,8),ts:ts||Math.floor(Date.now()/1000)}; | |
| /* 🆕 重连时后端可能重发同一条,按「时间+内容」去重 */ | |
| if(S.events.some(e=>e.time===ev.time&&e.data===ev.data&&e.type===ev.type))return; | |
| S.events.unshift(ev);S.events=S.events.slice(0,EV_MAX);renderFeed(); | |
| } | |
| /* 🆕 用服务端的持久化事件填充(含控制台关着时后台产生的事件) */ | |
| function seedEvents(list){ | |
| if(!Array.isArray(list)||!list.length)return; | |
| S.events=list.slice(0,EV_MAX).map(e=>({type:e.type||'sys',data:e.data||'',time:e.time||'--:--:--',ts:e.ts||0})); | |
| renderFeed(); | |
| } | |
| async 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{ | |
| await api('/api/events/session',{method:'POST'}); | |
| const es=new EventSource('/api/events'); | |
| es.onmessage=e=>{try{const ev=JSON.parse(e.data);pushEv(ev.type,ev.data,ev.time,ev.ts)}catch(_){}}; | |
| es.onerror=()=>{pushEv('err','事件流连接中断,自动重试中…')}; | |
| }catch(e){pushEv('err','事件流不可用')} | |
| } | |
| /* ================= fx ================= */ | |
| function bindFx(root){ | |
| if(!matchMedia('(pointer:fine)').matches)return; | |
| $$('.btn',root).forEach(b=>{ | |
| b.onmousemove=e=>{const r=b.getBoundingClientRect(); | |
| b.style.transform=`translate(${(e.clientX-r.left-r.width/2)*.12}px,${(e.clientY-r.top-r.height/2)*.22}px)`}; | |
| b.onmouseleave=()=>b.style.transform=''; | |
| }); | |
| } | |
| function cursorFx(){ | |
| if(!matchMedia('(pointer:fine)').matches)return; | |
| const c=$('.cur'),r=$('.cur-ring');let x=innerWidth/2,y=innerHeight/2,rx=x,ry=y; | |
| addEventListener('mousemove',e=>{x=e.clientX;y=e.clientY;c.style.left=x+'px';c.style.top=y+'px'; | |
| document.body.classList.toggle('cur-hov',!!e.target.closest('a,button,.chip,.tile,.itm,.pk-c,input,textarea,select,.file-drop'))}); | |
| (function loop(){rx+=(x-rx)*.14;ry+=(y-ry)*.14;r.style.left=rx+'px';r.style.top=ry+'px';requestAnimationFrame(loop)})(); | |
| } | |
| function clockFx(){const el=$('#clock');setInterval(()=>el.textContent=new Date().toTimeString().slice(0,8),1000);el.textContent=new Date().toTimeString().slice(0,8)} | |
| addEventListener('keydown',e=>{ | |
| if(e.key==='Escape')$$('.sh-wrap').forEach(w=>w._close&&w._close()); | |
| const n=+e.key;if(n>=1&&n<=7&&!e.target.closest('input,textarea'))go(Object.keys(VIEWS)[n-1]); | |
| }); | |
| /* ================= boot ================= */ | |
| async function load(){ | |
| try{ | |
| const j=await api('/api/data'); | |
| S.data=j.user||{};S.meta={msg:j.msg_count||0,dav:j.webdav_url||'',bot:!!j.userbot,botName:j.bot_username||''}; | |
| S.me=j.me||(S.data&&S.data.profile)||null; | |
| S.meCache=j.me_cache||null; /* 🆕 服务端缓存的名字与头像 */ | |
| S.chAva=j.ch_avatars||{}; /* 🆕 地址簿频道头像 */ | |
| seedEvents(j.events); /* 🆕 历史事件 */ | |
| setPill(true,DEMO?'DEMO MODE':'LIVE'); | |
| }catch(e){ | |
| // 🆕 401:不在白名单,直接上锁,不再降级展示界面 | |
| if(!DEMO&&e.status===401){S.denied=true;setPill(false,'NO ACCESS');return} | |
| if(!DEMO){setPill(false,'OFFLINE');toast('数据加载失败:'+e.message,'err')} | |
| S.data=clone(SEED); | |
| } | |
| S.ready=true; | |
| } | |
| function setPill(on,txt){const p=$('#pill');p.classList.toggle('off',!on);$('#pillT').textContent=txt} | |
| /* 🆕 手机键盘守卫:正在输入时把底部导航沉下去,不让它跟着键盘上移;界面尺寸不做任何缩放 */ | |
| const EDITABLE='input:not([type=file]):not([type=checkbox]):not([type=radio]),textarea,select,[contenteditable=""],[contenteditable=true]'; | |
| function kbGuard(){ | |
| const editing=()=>{const a=document.activeElement;return !!a&&a.matches&&a.matches(EDITABLE)}; | |
| const sync=()=>document.body.classList.toggle('kb',editing()); | |
| document.addEventListener('focusin',sync); | |
| document.addEventListener('focusout',()=>setTimeout(sync,60)); | |
| // 视觉视口变化(键盘开合、旋屏)后再校准一次 | |
| if(window.visualViewport)window.visualViewport.addEventListener('resize',()=>setTimeout(sync,60)); | |
| window.addEventListener('orientationchange',()=>setTimeout(sync,400)); | |
| } | |
| async function boot(){ | |
| if(TG){try{TG.ready();TG.expand();TG.setHeaderColor&&TG.setHeaderColor('#070709');TG.setBackgroundColor&&TG.setBackgroundColor('#070709')}catch(e){}} | |
| kbGuard();cursorFx();clockFx(); | |
| $('#bootBar').style.transition='transform 1.1s var(--ease)'; | |
| requestAnimationFrame(()=>$('#bootBar').style.transform='scaleX(1)'); | |
| await load();renderMe(); | |
| if(S.denied){ // 🆕 非授权用户看到的唯一界面 | |
| $('#nav').innerHTML=''; | |
| $('#view').innerHTML=`<div class="rk" style="padding-top:12vh">${emptyBox('lock','无访问权限', | |
| '这个控制台仅限管理员使用。如果你是来看榜单的,请回到频道点击「完整名单」。')}</div>`; | |
| $('#view').classList.add('ready');icons(); | |
| await delay(400);$('#boot').classList.add('out');setTimeout(()=>$('#boot').remove(),900); | |
| return; | |
| } | |
| // 🆕 由榜单「完整名单」链接进来(startapp=list_xxx):直接在 Telegram 内渲染名单 | |
| const lk=String(START_PARAM||''); | |
| if(lk.startsWith('list_')||lk.startsWith('list-')){ | |
| S.list=lk.slice(5);$('#nav').innerHTML=''; | |
| const mv=$('#view');mv.classList.remove('ready'); | |
| await vRankList(mv,S.list); | |
| requestAnimationFrame(()=>{mv.classList.add('ready');icons();stagger(mv)}); | |
| }else{buildNav();go('ops')} | |
| startEvents(); | |
| await delay(650);$('#boot').classList.add('out'); | |
| setTimeout(()=>$('#boot').remove(),900); | |
| } | |
| boot(); | |
| </script> | |
| </body> | |
| </html> | |