File size: 127,578 Bytes
c339529 9daee09 c339529 1932286 c339529 9daee09 1932286 c339529 9daee09 1932286 9daee09 c339529 1932286 c339529 9daee09 c339529 9daee09 c339529 9daee09 c339529 fc9a1b3 c339529 9daee09 e8fdc14 9daee09 e8fdc14 9daee09 e8fdc14 9daee09 c339529 1932286 c339529 d7351be c339529 fc9a1b3 c339529 e8fdc14 fc9a1b3 c339529 fc9a1b3 e8fdc14 c339529 e8fdc14 fc9a1b3 e8fdc14 c339529 d7351be c339529 d7351be c339529 e8fdc14 c339529 1932286 c339529 fc9a1b3 c339529 e8fdc14 c339529 fc9a1b3 c339529 e8fdc14 c339529 e8fdc14 1932286 e8fdc14 1932286 c339529 fc9a1b3 c339529 fc9a1b3 c339529 fc9a1b3 c339529 fc9a1b3 c339529 fc9a1b3 c339529 9daee09 c339529 9daee09 c339529 9daee09 c339529 9daee09 c339529 e8fdc14 c339529 d7351be c339529 d7351be c339529 d7351be c339529 d7351be c339529 e8fdc14 fc9a1b3 e8fdc14 c339529 d7351be c339529 e8fdc14 d7351be e8fdc14 d7351be e8fdc14 fc9a1b3 e8fdc14 fc9a1b3 e8fdc14 fc9a1b3 e8fdc14 fc9a1b3 e8fdc14 c339529 fc9a1b3 d7351be fc9a1b3 d7351be fc9a1b3 c339529 fc9a1b3 c339529 d7351be c339529 fc9a1b3 c339529 d7351be fc9a1b3 d7351be fc9a1b3 c339529 fc9a1b3 c339529 fc9a1b3 c339529 fc9a1b3 ee6c12e fc9a1b3 ee6c12e fc9a1b3 ee6c12e c339529 e8fdc14 c339529 e8fdc14 c339529 fc9a1b3 e8fdc14 c339529 e8fdc14 c339529 9daee09 c339529 9daee09 c339529 e8fdc14 c339529 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 | <!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<!-- 🆕 interactive-widget=overlays-content:手机键盘弹出时不再挤压/上推固定元素;maximum-scale 禁止聚焦自动放大 -->
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content">
<title>NINE7 · 频道中枢</title>
<script src="https://telegram.org/js/telegram-web-app.js"></script>
<script src="https://unpkg.com/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@600;700;800&family=Space+Grotesk:wght@400;500;600;700&family=Noto+Serif+SC:wght@700;900&family=Noto+Sans+SC:wght@400;500;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root{
--bg:#070709; --panel:#0D0D10; --panel2:#111115;
--ink:#EFEDE6; --mut:rgba(239,237,230,.48); --dim:rgba(239,237,230,.26);
--acc:#D9FF3D; --acc-ink:#0C0D05; --vio:#9B8CFF; --red:#FF5D5D; --grn:#5DFFA0;
--line:rgba(239,237,230,.09); --line2:rgba(239,237,230,.18);
--card:rgba(255,255,255,.028);
--ease:cubic-bezier(.16,1,.3,1);
--fd:"Syne","Noto Serif SC",serif;
--fb:"Space Grotesk","Noto Sans SC",sans-serif;
--fm:"JetBrains Mono",monospace;
}
*{margin:0;padding:0;box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html{scroll-behavior:smooth}
body{background:var(--bg);color:var(--ink);font-family:var(--fb);font-size:15px;line-height:1.6;overflow-x:hidden;min-height:100dvh}
::selection{background:var(--acc);color:var(--acc-ink)}
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-track{background:transparent}
::-webkit-scrollbar-thumb{background:var(--line2);border-radius:99px}
a{color:inherit;text-decoration:none}
button{font-family:inherit}
svg.lucide{display:inline-block;vertical-align:middle;flex:none}
i[data-lucide]{display:inline-flex;width:18px;height:18px;align-items:center;justify-content:center}
i[data-lucide]:not(:has(svg))::after{content:'';width:6px;height:6px;border:1px solid currentColor;border-radius:1px}
/* ---------- atmosphere ---------- */
.noise{position:fixed;inset:-50%;z-index:200;pointer-events:none;opacity:.05;
background-image:url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
animation:grain 8s steps(10) infinite}
@keyframes grain{0%,100%{transform:translate(0,0)}10%{transform:translate(-2%,-3%)}30%{transform:translate(3%,-2%)}50%{transform:translate(-3%,2%)}70%{transform:translate(2%,3%)}90%{transform:translate(-1%,1%)}}
.glow{position:fixed;z-index:0;pointer-events:none;border-radius:50%;filter:blur(120px);opacity:.16}
.glow-a{width:44vw;height:44vw;background:var(--acc);top:-18vw;right:-14vw}
.glow-b{width:36vw;height:36vw;background:var(--vio);bottom:-14vw;left:-10vw;opacity:.1}
/* ---------- cursor ---------- */
.cur,.cur-ring{position:fixed;top:0;left:0;z-index:300;pointer-events:none;border-radius:50%;display:none}
@media(pointer:fine){.cur,.cur-ring{display:block}}
.cur{width:6px;height:6px;background:var(--acc);transform:translate(-50%,-50%)}
.cur-ring{width:34px;height:34px;border:1px solid var(--line2);transform:translate(-50%,-50%);transition:width .3s var(--ease),height .3s var(--ease),border-color .3s}
body.cur-hov .cur-ring{width:56px;height:56px;border-color:var(--acc)}
/* ---------- topbar ---------- */
.topbar{position:fixed;top:0;left:0;right:0;z-index:60;display:flex;align-items:center;justify-content:space-between;
padding:16px 28px;border-bottom:1px solid var(--line);backdrop-filter:blur(16px);background:rgba(7,7,9,.7)}
.brand{display:flex;align-items:baseline;gap:12px;font-family:var(--fd);font-weight:800;letter-spacing:.02em}
.brand b{font-size:17px}
.brand span{font-size:12px;color:var(--mut);font-family:var(--fb);font-weight:500;letter-spacing:.24em}
.top-r{display:flex;align-items:center;gap:14px}
.pill{display:flex;align-items:center;gap:8px;padding:7px 14px;border:1px solid var(--line2);border-radius:99px;font:600 10px var(--fm);letter-spacing:.14em;text-transform:uppercase}
.pill .dot{width:6px;height:6px;border-radius:50%;background:var(--grn);animation:pulse 2s infinite}
.pill.off .dot{background:var(--red);animation:none}
@keyframes pulse{0%,100%{box-shadow:0 0 0 0 rgba(93,255,160,.4)}50%{box-shadow:0 0 0 6px rgba(93,255,160,0)}}
.clock{font:500 12px var(--fm);color:var(--mut);letter-spacing:.08em}
.demo-tag{font:700 9px var(--fm);letter-spacing:.2em;color:var(--acc);border:1px dashed var(--acc);padding:5px 10px;border-radius:99px}
/* ---------- nav rail / dock ---------- */
.rail{position:fixed;z-index:60;left:0;top:0;bottom:0;width:84px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border-right:1px solid var(--line);background:rgba(7,7,9,.7);backdrop-filter:blur(16px)}
.nav-b{position:relative;width:56px;height:56px;border-radius:16px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;color:var(--mut);cursor:pointer;border:1px solid transparent;transition:.35s var(--ease);background:none}
.nav-b i{width:19px;height:19px}
.nav-b em{font:600 8px var(--fm);font-style:normal;letter-spacing:.1em}
.nav-b .nav-t{display:none}
.nav-b:hover{color:var(--ink)}
.nav-b.on{color:var(--acc);background:rgba(217,255,61,.06);border-color:rgba(217,255,61,.25)}
@media(max-width:860px){
.rail{top:auto;bottom:0;left:0;right:0;width:auto;height:62px;flex-direction:row;border-right:none;border-top:1px solid var(--line);padding:0 6px;padding-bottom:env(safe-area-inset-bottom);
transition:transform .22s var(--ease)}
.nav-b{flex:1;height:48px;gap:3px}
.nav-b em{display:none}
.nav-b i{width:17px;height:17px}
.nav-b .nav-t{display:block;font:600 9px/1 var(--fm);letter-spacing:.05em;white-space:nowrap}
}
/* 🆕 键盘弹出时:底部导航整体下沉隐藏,绝不跟着键盘上移(仅手机布局,桌面左侧栏不受影响) */
@media(max-width:860px){
body.kb .rail{transform:translateY(160%);pointer-events:none}
body.kb .toasts{top:12px}
}
/* ---------- view frame ---------- */
.view{position:relative;z-index:1;max-width:1180px;margin:0 auto;padding:120px 32px 140px 116px}
@media(max-width:860px){.view{padding:104px 20px 130px}}
.view [data-rv]{opacity:0;transform:translateY(26px);transition:opacity .8s var(--ease),transform .8s var(--ease)}
.view.ready [data-rv]{opacity:1;transform:none}
.vh{display:flex;align-items:flex-end;justify-content:space-between;gap:20px;margin-bottom:38px;flex-wrap:wrap}
.vh-k{font:600 10px var(--fm);letter-spacing:.3em;text-transform:uppercase;color:var(--acc);margin-bottom:10px;display:flex;align-items:center;gap:10px}
.vh-k::before{content:'';width:22px;height:1px;background:var(--acc)}
.vh h1{font:800 clamp(34px,6vw,58px)/1.02 var(--fd);letter-spacing:-.02em}
.vh h1 .en{display:block;font-size:.42em;color:transparent;-webkit-text-stroke:1px var(--dim);letter-spacing:.14em;text-transform:uppercase;margin-top:6px}
.vh-p{color:var(--mut);font-size:13px;max-width:420px}
.mono{font-family:var(--fm)}
.acc{color:var(--acc)}
</style>
</head>
<body>
<style>
/* ---------- buttons / chips ---------- */
.btn{display:inline-flex;align-items:center;gap:9px;padding:13px 24px;border-radius:99px;border:1px solid var(--line2);
background:transparent;color:var(--ink);font:600 13px var(--fb);letter-spacing:.05em;cursor:pointer;
transition:border-color .35s,color .35s,background .35s,transform .2s var(--ease);will-change:transform}
.btn:hover{border-color:var(--acc);color:var(--acc)}
.btn-acc{background:var(--acc);color:var(--acc-ink);border-color:var(--acc)}
.btn-acc:hover{color:var(--acc-ink);filter:brightness(1.1)}
.btn-danger:hover{border-color:var(--red);color:var(--red)}
.btn-s{padding:8px 15px;font-size:12px;gap:7px}
.btn-s i{width:14px;height:14px}
.btn[disabled]{opacity:.45;pointer-events:none}
.chip{display:inline-flex;align-items:center;gap:6px;padding:6px 12px;border:1px solid var(--line);border-radius:99px;
font:500 11px var(--fm);color:var(--mut);letter-spacing:.02em;transition:.3s}
.chip i{width:11px;height:11px}
.chip.on{background:var(--acc);color:var(--acc-ink);border-color:var(--acc)}
.chip.pk-c{cursor:pointer}
.chip.pk-c:hover{border-color:var(--acc);color:var(--acc)}
/* ---------- hero ---------- */
.hero{position:relative;padding:56px 0 44px}
.hero-k{display:flex;align-items:center;gap:10px;font:600 11px var(--fm);letter-spacing:.32em;text-transform:uppercase;color:var(--mut);margin-bottom:26px}
.hero-k .dot{width:7px;height:7px;border-radius:50%;background:var(--acc);animation:pulse 2s infinite}
.hero-t{font:800 clamp(58px,11.5vw,150px)/0.96 var(--fd);letter-spacing:-.03em;margin-left:-.04em}
.hero-t .l1{display:block;color:transparent;-webkit-text-stroke:1.5px var(--dim);text-transform:uppercase;font-size:clamp(44px,9.6vw,126px);letter-spacing:-.01em}
.hero-t .l2{display:block;font-family:"Noto Serif SC",serif;font-weight:900}
.hero-t .l2 .acc{font-family:var(--fd)}
.hero-d{margin-top:26px;color:var(--mut);font-size:14px;max-width:520px}
.hero-b{margin-top:32px;display:flex;gap:14px;flex-wrap:wrap}
.badge-rot{position:absolute;top:30px;right:0;width:132px;height:132px;animation:rot 16s linear infinite;opacity:.9}
@media(max-width:860px){.badge-rot{display:none}}
@keyframes rot{to{transform:rotate(360deg)}}
.badge-rot text{font:600 10.5px var(--fm);letter-spacing:.34em;fill:var(--mut);text-transform:uppercase}
.spin{animation:rot 1s linear infinite}
/* ---------- marquee ---------- */
.mq{overflow:hidden;border-block:1px solid var(--line);padding:15px 0;margin:10px 0 44px}
.mq-in{display:flex;width:max-content;animation:mqx 26s linear infinite}
.mq-in span{font:700 clamp(13px,1.8vw,18px) var(--fd);letter-spacing:.16em;text-transform:uppercase;padding:0 26px;white-space:nowrap;color:var(--dim)}
.mq-in b{color:var(--acc);font-weight:700}
@keyframes mqx{to{transform:translateX(-50%)}}
/* ---------- tiles ---------- */
.tiles{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin-bottom:44px}
@media(max-width:860px){.tiles{grid-template-columns:repeat(2,1fr)}}
.tile{position:relative;border:1px solid var(--line);border-radius:20px;padding:24px 22px 20px;background:var(--card);overflow:hidden;cursor:pointer;transition:.45s var(--ease)}
.tile:hover{border-color:var(--line2);transform:translateY(-4px)}
.tile:hover .t-num{color:var(--acc)}
.tile .t-ic{color:var(--dim);margin-bottom:16px}
.tile .t-num{font:700 clamp(40px,4.6vw,56px)/1 var(--fd);letter-spacing:-.02em;transition:color .4s}
.tile .t-l{font:600 10px var(--fm);letter-spacing:.24em;text-transform:uppercase;color:var(--mut);margin-top:10px}
.tile .t-sp{position:absolute;bottom:0;left:0;right:0;height:34px;opacity:.35}
/* ---------- panels & feed ---------- */
.grid2{display:grid;grid-template-columns:1fr 1.25fr;gap:14px}
@media(max-width:860px){.grid2{grid-template-columns:1fr}}
.panel{border:1px solid var(--line);border-radius:20px;background:var(--card);overflow:hidden}
.panel-h{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid var(--line)}
.panel-t{font:700 12px var(--fm);letter-spacing:.22em;text-transform:uppercase;color:var(--mut);display:flex;align-items:center;gap:10px}
.panel-t i{width:15px;height:15px;color:var(--acc)}
.kv{display:flex;justify-content:space-between;align-items:center;gap:14px;padding:15px 22px;border-bottom:1px solid var(--line);font-size:13px}
.kv:last-child{border-bottom:none}
.kv .k{color:var(--mut);display:flex;align-items:center;gap:9px}
.kv .k i{width:14px;height:14px;color:var(--dim)}
.kv .v{font-family:var(--fm);font-size:12px;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60%}
.st-on{color:var(--grn)}.st-off{color:var(--red)}
.feed{padding:8px 0;max-height:330px;overflow-y:auto}
.feed-l{display:flex;align-items:center;gap:12px;padding:9px 22px;font:500 12px var(--fm);color:var(--mut);border-bottom:1px solid rgba(239,237,230,.04);animation:feedin .5s var(--ease)}
@keyframes feedin{from{opacity:0;transform:translateX(-8px)}}
.feed-l .tm{color:var(--dim);flex:none}
.feed-l .ms{color:var(--ink);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.feed-l .ms i[data-lucide]{margin-right:7px;color:var(--dim);transform:translateY(-1px)}
.tg{font:700 9px var(--fm);letter-spacing:.1em;padding:3px 8px;border-radius:99px;border:1px solid;flex:none;text-transform:uppercase}
.tg-sync{color:var(--acc);border-color:rgba(217,255,61,.35)}
.tg-bkp{color:var(--vio);border-color:rgba(155,140,255,.35)}
.tg-dir{color:#6EC9FF;border-color:rgba(110,201,255,.35)}
.tg-err{color:var(--red);border-color:rgba(255,93,93,.35)}
.tg-sys{color:var(--mut);border-color:var(--line2)}
/* ---------- list items ---------- */
.itm{position:relative;border:1px solid var(--line);border-radius:20px;background:var(--card);padding:24px 26px;margin-bottom:14px;overflow:hidden;transition:.45s var(--ease)}
.itm:hover{border-color:var(--line2)}
.itm .gidx{position:absolute;right:16px;top:6px;font:800 52px/1 var(--fd);color:transparent;-webkit-text-stroke:1px var(--line);pointer-events:none;transition:.4s}
.itm:hover .gidx{-webkit-text-stroke-color:rgba(217,255,61,.3)}
.itm-t{font:700 19px var(--fd);letter-spacing:-.01em;margin-bottom:6px;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
.itm-s{color:var(--mut);font-size:12.5px}
.route{display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin:14px 0 4px}
.route .mono{font-size:12px;color:var(--ink);background:rgba(255,255,255,.05);padding:6px 12px;border-radius:8px}
.route i{width:15px;height:15px;color:var(--acc)}
.meta{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}
.itm-a{display:flex;gap:10px;margin-top:18px;flex-wrap:wrap}
.prog{height:4px;background:rgba(255,255,255,.06);border-radius:99px;overflow:hidden;margin-top:14px}
.prog i{display:block;height:100%;background:var(--acc);border-radius:99px;transition:width 1s var(--ease)}
.tagcloud{display:flex;flex-wrap:wrap;gap:7px;margin-top:14px}
.empty{border:1px dashed var(--line2);border-radius:20px;padding:60px 30px;text-align:center;color:var(--mut)}
.empty i{width:34px;height:34px;margin-bottom:14px;color:var(--dim)}
.empty .e-t{font:700 16px var(--fd);margin-bottom:6px;color:var(--ink)}
.empty .e-s{font-size:12.5px}
</style>
<style>
/* ---------- forms ---------- */
.fld{display:flex;flex-direction:column;gap:8px;margin-bottom:18px}
.fld-l{font:600 10px var(--fm);letter-spacing:.2em;text-transform:uppercase;color:var(--mut)}
.fld-h{font:400 11px var(--fb);color:var(--dim)}
.inp,textarea.inp,select.inp{width:100%;background:rgba(255,255,255,.03);border:1px solid var(--line);color:var(--ink);
padding:13px 15px;border-radius:12px;font:500 14px var(--fb);outline:none;transition:.3s;appearance:none}
select.inp option{background:var(--panel2)}
.inp:focus{border-color:var(--acc);background:rgba(217,255,61,.04)}
textarea.inp{resize:vertical;min-height:88px;line-height:1.55}
/* ---------- 🆕 Telegram 文本格式工具条 ---------- */
.fmt{display:flex;flex-wrap:wrap;gap:4px;align-items:center;margin:-1px 0 1px}
.fmt button{flex:none;min-width:30px;height:30px;padding:0 6px;border:1px solid var(--line);border-radius:9px;
background:rgba(255,255,255,.03);color:var(--mut);cursor:pointer;transition:.2s;
display:inline-flex;align-items:center;justify-content:center;gap:5px;font:600 11px var(--fb)}
.fmt button:hover{border-color:var(--acc);color:var(--acc);background:rgba(217,255,61,.07)}
.fmt button:active{transform:scale(.93)}
.fmt button i{width:13px;height:13px}
.fmt button .kb{font:600 10px var(--fm);letter-spacing:.02em}
.fmt button.gp{margin-left:8px;position:relative}
.fmt button.gp::before{content:"";position:absolute;left:-5px;top:6px;bottom:6px;width:1px;background:var(--line2)}
.fmt-h{font:400 11px var(--fb);color:var(--dim)}
.fmt-h b{color:var(--mut);font-weight:600}
@media(max-width:640px){.fmt button{min-width:30px;height:29px;padding:0 6px}}
.frm-grid{display:grid;grid-template-columns:1fr 1fr;gap:0 16px}
@media(max-width:640px){.frm-grid{grid-template-columns:1fr}}
.pk{display:flex;flex-wrap:wrap;gap:8px}
.pk-manual{margin-top:10px}
.dyn-row{display:flex;gap:8px;margin-bottom:8px;align-items:center}
.dyn-row .inp{flex:1}
.dyn-x{flex:none;width:42px;height:42px;border-radius:10px;border:1px solid var(--line);background:none;color:var(--mut);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.3s}
.dyn-x:hover{border-color:var(--red);color:var(--red)}
.dyn-add{width:100%;border:1px dashed var(--line2);border-radius:12px;background:none;color:var(--mut);padding:11px;cursor:pointer;font:600 12px var(--fb);display:flex;align-items:center;justify-content:center;gap:8px;transition:.3s}
.dyn-add:hover{border-color:var(--acc);color:var(--acc)}
.sw{width:48px;height:27px;border:1px solid var(--line2);border-radius:99px;position:relative;cursor:pointer;transition:.35s;flex:none;background:none}
.sw::after{content:'';position:absolute;top:3px;left:3px;width:19px;height:19px;border-radius:50%;background:var(--mut);transition:.4s var(--ease)}
.sw.on{background:var(--acc);border-color:var(--acc)}
.sw.on::after{left:24px;background:var(--acc-ink)}
.sw-row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:15px;border:1px solid var(--line);border-radius:12px;margin-bottom:18px}
.sw-row .t{font:600 13px var(--fb)}
.sw-row .s{font-size:11px;color:var(--dim);margin-top:2px}
/* 🆕 媒体附件:文件输入框铺满整块区域(opacity:0),点哪都能唤起系统选择器 —— hidden + JS .click() 在手机 WebView 里常被拦 */
.file-drop{position:relative;display:block;border:1px dashed var(--line2);border-radius:12px;padding:20px;text-align:center;
color:var(--mut);cursor:pointer;transition:.3s;font-size:12.5px;overflow:hidden}
.file-drop:hover,.file-drop:focus-within{border-color:var(--acc);color:var(--acc)}
.file-drop i{width:22px;height:22px;margin-bottom:8px}
.file-drop .file-in{position:absolute;inset:0;width:100%;height:100%;opacity:0;cursor:pointer;font-size:16px}
.file-name{display:flex;align-items:center;gap:10px;margin-top:10px;font:500 12px var(--fm);color:var(--acc)}
.file-name .fx{margin-left:auto;flex:none;width:26px;height:26px;border-radius:8px;border:1px solid var(--line);
background:none;color:var(--mut);cursor:pointer;display:flex;align-items:center;justify-content:center}
.file-name .fx:hover{border-color:var(--red);color:var(--red)}
.file-name .fx i{width:12px;height:12px}
/* ---------- sheet ---------- */
.sh-wrap{position:fixed;inset:0;z-index:90;display:flex;align-items:flex-end;justify-content:center}
.sh-bg{position:absolute;inset:0;background:rgba(4,4,6,.65);backdrop-filter:blur(10px);opacity:0;transition:.4s}
.sh{position:relative;width:min(660px,100%);max-height:88dvh;overflow-y:auto;background:var(--panel);
border:1px solid var(--line2);border-bottom:none;border-radius:26px 26px 0 0;padding:28px 28px 40px;
transform:translateY(60px);opacity:0;transition:.55s var(--ease)}
@media(min-width:760px){.sh-wrap{align-items:center;padding:32px}.sh{border-radius:26px;border-bottom:1px solid var(--line2)}}
.sh-wrap.in .sh-bg{opacity:1}
.sh-wrap.in .sh{transform:none;opacity:1}
.sh-grip{width:38px;height:4px;border-radius:99px;background:var(--line2);margin:0 auto 20px}
.sh-h{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;margin-bottom:24px}
.sh-t{font:800 22px var(--fd);letter-spacing:-.01em}
.sh-s{font-size:12px;color:var(--mut);margin-top:5px}
.sh-x{width:38px;height:38px;border-radius:50%;border:1px solid var(--line);background:none;color:var(--mut);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.3s;flex:none}
.sh-x:hover{border-color:var(--ink);color:var(--ink);transform:rotate(90deg)}
.sh-f{display:flex;gap:12px;margin-top:26px;justify-content:flex-end}
.sec-t{font:700 10px var(--fm);letter-spacing:.24em;text-transform:uppercase;color:var(--acc);margin:26px 0 14px;display:flex;align-items:center;gap:10px}
.sec-t::after{content:'';flex:1;height:1px;background:var(--line)}
/* ---------- toast ---------- */
.toasts{position:fixed;top:76px;right:24px;z-index:120;display:flex;flex-direction:column;gap:10px;align-items:flex-end}
.toast{display:flex;align-items:center;gap:10px;background:var(--panel2);border:1px solid var(--line2);border-radius:14px;
padding:13px 18px;font:500 13px var(--fb);transform:translateX(30px);opacity:0;transition:.5s var(--ease);max-width:min(360px,80vw)}
.toast.in{transform:none;opacity:1}
.toast i{width:16px;height:16px;flex:none}
.toast.ok i{color:var(--acc)}
.toast.err{border-color:rgba(255,93,93,.4)}
.toast.err i{color:var(--red)}
.toast.info i{color:var(--vio)}
@media(max-width:640px){.toasts{right:12px;left:12px;align-items:stretch}}
/* ---------- compose ---------- */
.cmp-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
@media(max-width:860px){.cmp-grid{grid-template-columns:1fr}}
.cmp{border:1px solid var(--line);border-radius:20px;background:var(--card);padding:26px}
.cmp-t{font:700 16px var(--fd);margin-bottom:4px;display:flex;align-items:center;gap:10px}
.cmp-t i{width:17px;height:17px;color:var(--acc)}
.cmp-s{font-size:12px;color:var(--mut);margin-bottom:22px}
/* ---------- ops ---------- */
.ops-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px;align-items:start}
@media(max-width:860px){.ops-grid{grid-template-columns:1fr}}
.ops-c{border:1px solid var(--line);border-radius:20px;background:var(--card);padding:26px;margin-bottom:14px;break-inside:avoid}
.mon-row{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:13px 0;border-bottom:1px solid var(--line);font-size:12.5px;flex-wrap:wrap}
.mon-row:last-child{border-bottom:none}
.mon-row .mono{font-size:11.5px}
.kbd-hint{position:fixed;bottom:22px;right:26px;z-index:50;font:500 10px var(--fm);color:var(--dim);letter-spacing:.1em;display:flex;gap:14px}
@media(max-width:860px){.kbd-hint{display:none}}
.kbd-hint b{border:1px solid var(--line2);border-radius:5px;padding:1px 6px;color:var(--mut)}
.foot{margin-top:70px;padding-top:22px;border-top:1px solid var(--line);display:flex;justify-content:space-between;gap:14px;flex-wrap:wrap;font:500 10px var(--fm);letter-spacing:.16em;color:var(--dim);text-transform:uppercase}
.ch-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:14px}
.ch-c{position:relative;border:1px solid var(--line);border-radius:20px;background:var(--card);padding:24px;transition:.4s var(--ease);overflow:hidden}
.ch-c:hover{border-color:var(--line2);transform:translateY(-3px)}
.ch-c .nm{font:700 16px var(--fd);margin-bottom:6px;word-break:break-all}
.ch-c .cid{font:500 11px var(--fm);color:var(--mut);word-break:break-all}
.ch-c .ops{display:flex;gap:8px;margin-top:16px}
.ic-b{width:34px;height:34px;border-radius:10px;border:1px solid var(--line);background:none;color:var(--mut);cursor:pointer;display:flex;align-items:center;justify-content:center;transition:.3s}
.ic-b:hover{border-color:var(--acc);color:var(--acc)}
.ic-b.danger:hover{border-color:var(--red);color:var(--red)}
.ic-b i{width:14px;height:14px}
.add-c{border:1px dashed var(--line2);border-radius:20px;padding:24px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;color:var(--mut);cursor:pointer;min-height:150px;transition:.3s;background:none;width:100%}
.add-c:hover{border-color:var(--acc);color:var(--acc)}
.add-c i{width:22px;height:22px}
.boot{position:fixed;inset:0;z-index:400;background:var(--bg);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:18px;transition:opacity .7s var(--ease),visibility .7s}
.boot.out{opacity:0;visibility:hidden}
.boot .b-t{font:800 clamp(34px,7vw,54px) var(--fd);letter-spacing:-.02em;overflow:hidden}
.boot .b-t span{display:inline-block;transform:translateY(110%);animation:bootup .9s var(--ease) forwards}
@keyframes bootup{to{transform:none}}
.boot .b-n{font:700 13px var(--fm);color:var(--acc);letter-spacing:.3em}
.boot .b-bar{width:180px;height:1px;background:var(--line);position:relative;overflow:hidden}
.boot .b-bar i{position:absolute;inset:0;background:var(--acc);transform-origin:left;transform:scaleX(0)}
/* ---------- 🆕 user chip (头像 + 名字) ---------- */
.me{display:flex;align-items:center;gap:10px;padding:5px 12px 5px 5px;border:1px solid var(--line2);border-radius:99px}
.ava{width:34px;height:34px;border-radius:50%;object-fit:cover;flex:none;background:var(--panel2);
border:1px solid var(--line2);display:inline-flex;align-items:center;justify-content:center;
font:700 13px var(--fd);color:var(--dim);overflow:hidden}
.ava.sq{border-radius:11px}.ava.lg{width:44px;height:44px;font-size:16px}
.ch-c .ch-hd{display:flex;align-items:center;gap:11px;margin-bottom:6px}
.rs-box{display:flex;align-items:center;gap:11px;padding:11px 13px;border:1px solid var(--line);
border-radius:13px;background:var(--panel2);margin:-4px 0 14px}
.rs-box .t{font:600 13px var(--fb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rs-box .s{font:500 10.5px var(--fm);color:var(--dim)}
.me .av{width:28px;height:28px;border-radius:50%;object-fit:cover;background:var(--panel2);flex:none;
display:flex;align-items:center;justify-content:center;font:700 11px var(--fm);color:var(--acc);border:1px solid var(--line2)}
.me .mn{display:flex;flex-direction:column;line-height:1.15;max-width:150px}
.me .mn b{font:600 12px var(--fb);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.me .mn span{font:500 9.5px var(--fm);color:var(--dim);letter-spacing:.06em;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
@media(max-width:640px){.me .mn{display:none}.me{padding:4px}}
/* ---------- 🆕 live preview (Telegram 风格) ---------- */
.pv{border:1px solid var(--line);border-radius:18px;background:var(--panel2);padding:16px;margin-top:6px}
.pv-h{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:12px}
.pv-h .pt{font:700 10px var(--fm);letter-spacing:.22em;text-transform:uppercase;color:var(--acc);display:flex;align-items:center;gap:8px}
.pv-h .pc{font:500 10px var(--fm);color:var(--dim)}
.bub{background:#17212B;border-radius:14px;padding:0;overflow:hidden;max-width:100%;border:1px solid rgba(255,255,255,.05)}
.bub-top{display:flex;align-items:center;gap:9px;padding:11px 13px 0}
.bub-top .ic{width:26px;height:26px;border-radius:50%;background:var(--acc);color:var(--acc-ink);display:flex;align-items:center;justify-content:center;font:700 11px var(--fm);flex:none}
.bub-top .nm{font:600 12.5px var(--fb);color:#6EC9FF}
.bub-md{margin:10px 0 0;max-height:220px;width:100%;object-fit:cover;display:block;background:#0d1520}
.bub-file{display:flex;align-items:center;gap:10px;margin:11px 13px 0;padding:10px;border-radius:10px;background:rgba(255,255,255,.05);font:500 12px var(--fb)}
.bub-file i{width:16px;height:16px;color:#6EC9FF}
.bub-tx{padding:10px 13px 12px;font:400 13.5px/1.55 var(--fb);color:#F1F1F1;white-space:pre-wrap;word-break:break-word}
.bub-tx:empty::after{content:'(正文为空)';color:rgba(255,255,255,.3);font-style:italic}
.bub-tx b,.bub-tx strong{font-weight:700}.bub-tx i,.bub-tx em{font-style:italic}
.bub-tx u{text-decoration:underline}.bub-tx s,.bub-tx del{text-decoration:line-through}
.bub-tx a{color:#6EC9FF;text-decoration:none}
.bub-tx code{font-family:var(--fm);font-size:12px;background:rgba(255,255,255,.08);padding:1px 5px;border-radius:5px}
.bub-tx pre{font-family:var(--fm);font-size:12px;background:rgba(255,255,255,.08);padding:9px 11px;border-radius:8px;overflow-x:auto;margin:6px 0}
.bub-tx blockquote{border-left:3px solid #6EC9FF;padding:2px 0 2px 10px;margin:6px 0;color:rgba(241,241,241,.85)}
.bub-tx .sp{background:rgba(255,255,255,.22);border-radius:4px;color:transparent;text-shadow:0 0 8px rgba(255,255,255,.6)}
.bub-tx .warn{color:var(--red);font:500 12px var(--fm)}
.bub-btns{display:flex;flex-direction:column;gap:2px;padding:2px;border-top:1px solid rgba(255,255,255,.06)}
.bub-btns .bb{display:flex;align-items:center;justify-content:center;gap:7px;padding:11px;border-radius:9px;
background:rgba(255,255,255,.07);font:600 12.5px var(--fb);color:#6EC9FF;text-align:center;word-break:break-all}
.bub-btns .bb i{width:12px;height:12px;opacity:.7}
.bub-btns .bb.bad{color:var(--red)}
.bub-meta{display:flex;justify-content:flex-end;gap:6px;padding:0 13px 10px;font:500 10px var(--fm);color:rgba(255,255,255,.35)}
/* ---------- 🆕 榜单名单(Mini App 视图) ---------- */
.rk{max-width:640px;margin:0 auto}
.rk-h{text-align:center;margin-bottom:22px}
.rk-h h1{font:800 clamp(22px,5.5vw,32px) var(--fd);letter-spacing:-.01em}
.rk-h p{font:500 11px var(--fm);color:var(--mut);margin-top:8px;letter-spacing:.1em}
.rk-i{display:flex;align-items:center;gap:12px;padding:13px 16px;border:1px solid var(--line);border-radius:14px;
background:var(--card);margin-bottom:8px;transition:.3s}
.rk-i:hover{border-color:var(--acc)}
.rk-i .r{font:700 12px var(--fm);color:var(--acc);width:38px;flex:none}
.rk-i .n{flex:1;font:500 13.5px var(--fb);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.rk-i .n.done{text-decoration:line-through;opacity:.45}
.rk-i .c{font:700 12px var(--fm);color:var(--vio);flex:none}
/* ================= 🆕 手机端紧凑化(整体缩小一档,避免显示不全) ================= */
@media(max-width:860px){
.view{padding:84px 14px 100px}
.kbd-hint{display:none}
}
@media(max-width:640px){
body{font-size:13.5px}
/* 顶栏:去掉副标题与多余间距,防止「频道中枢」被挤成竖排 */
.topbar{padding:9px 13px}
.brand b{font-size:15px}
.brand span{display:none}
.top-r{gap:8px}
.pill{padding:5px 9px;font-size:8.5px;letter-spacing:.1em;white-space:nowrap;gap:6px}
.demo-tag{padding:4px 8px;font-size:8px;letter-spacing:.1em;white-space:nowrap}
.clock{font-size:10px;letter-spacing:.02em}
.me{padding:3px}
.view{padding:74px 13px 96px}
/* 首页 hero */
.hero{padding:22px 0 24px}
.hero-k{font-size:9px;letter-spacing:.18em;margin-bottom:14px}
.hero-t{font-size:clamp(36px,11.5vw,60px)}
.hero-t .l1{font-size:clamp(26px,8.6vw,44px);-webkit-text-stroke-width:1px}
.hero-d{font-size:12.5px;margin-top:15px}
.hero-b{margin-top:20px;gap:9px}
.btn{padding:11px 17px;font-size:12.5px;gap:7px}
.btn-s{padding:6px 12px;font-size:11px}
/* 各视图标题 */
.vh{margin-bottom:22px;gap:12px}
.vh h1{font-size:clamp(25px,7.6vw,38px)}
.vh h1 .en{font-size:.4em;letter-spacing:.1em;margin-top:3px}
.vh-k{font-size:9px;letter-spacing:.18em;margin-bottom:7px}
.vh-p{font-size:12px}
.mq{padding:10px 0;margin:4px 0 24px}
.mq-in span{font-size:11px;padding:0 15px;letter-spacing:.1em}
/* 指标卡 / 面板 */
.tiles{gap:9px;margin-bottom:24px}
.tile{padding:15px 13px 12px;border-radius:16px}
.tile .t-ic{margin-bottom:8px}.tile .t-ic i{width:15px;height:15px}
.tile .t-num{font-size:clamp(27px,8.6vw,36px)}
.tile .t-l{font-size:8.5px;letter-spacing:.14em;margin-top:5px}
.grid2,.cmp-grid,.ops-grid,.ch-grid{gap:10px}
.panel,.itm,.cmp,.ops-c,.ch-c,.empty{border-radius:16px}
.panel-h{padding:13px 14px}
.panel-t{font-size:10.5px;letter-spacing:.14em}
.panel-t i{width:14px;height:14px}
.kv{padding:11px 14px;font-size:12px;gap:10px}
.kv .v{font-size:11px;max-width:58%}
.feed{max-height:250px}
.feed-l{padding:7px 14px;font-size:11px;gap:9px}
/* 列表项 */
.itm{padding:16px 14px;margin-bottom:10px}
.itm .gidx{font-size:36px;right:10px;top:2px}
.itm-t{font-size:15.5px;gap:8px;margin-bottom:4px}
.itm-s{font-size:11.5px}
.route{gap:7px;margin:10px 0 2px}
.route .mono{font-size:11px;padding:5px 9px}
.route i{width:13px;height:13px}
.meta{gap:6px;margin-top:10px}
.itm-a{gap:8px;margin-top:12px}
.chip{padding:5px 10px;font-size:10.5px}
.empty{padding:36px 18px}
.empty i{width:26px;height:26px;margin-bottom:10px}
/* 表单:输入框本体字号必须 ≥16px,否则 iOS 聚焦时会自动放大整个界面 */
.fld{margin-bottom:12px;gap:5px}
.fld-l{font-size:9px;letter-spacing:.14em}
/* 🆕 说明文字与占位提示单独缩小:placeholder 字号不影响 iOS 的放大判定 */
.fld-h,.fmt-h{font-size:9.5px;line-height:1.45}
.inp,textarea.inp,select.inp{padding:11px 13px;border-radius:11px;font-size:16px}
.inp::placeholder,textarea.inp::placeholder{font-size:12px;opacity:.75}
.inp::-webkit-input-placeholder,textarea.inp::-webkit-input-placeholder{font-size:12px}
textarea.inp{min-height:74px}
.dyn-x{width:38px;height:38px}
.dyn-add{padding:9px;font-size:11.5px}
.sw-row{padding:12px;margin-bottom:14px;gap:10px}
.sw-row .t{font-size:12.5px}
.cmp,.ops-c{padding:16px 14px}
.cmp-t{font-size:14.5px}.cmp-t i{width:15px;height:15px}
.cmp-s{font-size:11.5px;margin-bottom:15px}
.sec-t{margin:18px 0 10px;font-size:9px;letter-spacing:.16em}
.pv{padding:12px;border-radius:14px}
.file-drop{padding:14px;font-size:12px}
.file-drop i{width:18px;height:18px;margin-bottom:5px}
.file-name{font-size:11px;margin-top:8px}
.ch-grid{grid-template-columns:1fr}
.ch-c{padding:16px 14px}
.ch-c .nm{font-size:14.5px}
.foot{margin-top:38px;font-size:9px;letter-spacing:.1em}
.sh{padding:20px 16px 30px;border-radius:20px 20px 0 0;max-height:92dvh}
.sh-t{font-size:18px}
.sh-s{font-size:11.5px}
.sh-h{margin-bottom:18px}
.sh-x{width:34px;height:34px}
.mon-row{padding:11px 0;font-size:11.5px}
.rk-i{padding:11px 13px}
}
</style>
<div class="noise"></div>
<div class="glow glow-a"></div><div class="glow glow-b"></div>
<div class="cur"></div><div class="cur-ring"></div>
<div class="boot" id="boot">
<div class="b-n">NINE7 CONSOLE</div>
<div class="b-t"><span>频道中枢</span></div>
<div class="b-bar"><i id="bootBar"></i></div>
</div>
<header class="topbar">
<div class="brand"><b>NINE7</b><span>频道中枢</span></div>
<div class="top-r">
<span class="pill" id="pill"><span class="dot"></span><span id="pillT">CONNECTING</span></span>
<span class="clock" id="clock">00:00:00</span>
<div class="me" id="me" style="display:none"></div>
</div>
</header>
<nav class="rail" id="nav"></nav>
<main class="view" id="view"></main>
<div class="kbd-hint"><span><b>1-7</b> 切换视图</span><span><b>ESC</b> 关闭弹层</span></div>
<div class="toasts" id="toasts"></div>
<div id="sheets"></div>
<script>
'use strict';
window.__errs=[];
addEventListener('error',e=>window.__errs.push((e.message||'')+' @:'+(e.lineno||'')));
addEventListener('unhandledrejection',e=>window.__errs.push('PROMISE: '+((e.reason&&(e.reason.stack||e.reason.message))||e.reason)));
/* ================= utils ================= */
const $=(s,c=document)=>c.querySelector(s);
const $$=(s,c=document)=>[...c.querySelectorAll(s)];
const esc=s=>String(s??'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
const I=(n,s)=>`<i data-lucide="${n}"${s?` style="width:${s}px;height:${s}px"`:''}></i>`;
const icons=root=>{try{lucide.createIcons(root?{root}:undefined)}catch(e){}};
/* 事件流:事件类型 → Lucide 图标;事件文本里的 emoji 一律剥掉,统一用图标表达 */
const EV_ICON={connected:'wifi',sys:'terminal',backup_done:'archive',dir_done:'folder',replace_done:'replace',
members_done:'users',sync_done:'repeat',sync_edit:'pencil',stats_done:'trophy',follow:'user-plus',err:'alert-triangle'};
const deEmoji=s=>String(s).replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{2B00}-\u{2BFF}\u{FE0F}\u{200D}]/gu,'').replace(/\s{2,}/g,' ').trim();
const delay=ms=>new Promise(r=>setTimeout(r,ms));
const clone=o=>JSON.parse(JSON.stringify(o));
const shortId=id=>{id=String(id??'');return id.length>16?id.slice(0,8)+'…'+id.slice(-5):id};
const fmtT=ts=>{if(!ts)return'—';const d=new Date(ts*1000);const p=n=>String(n).padStart(2,'0');return`${p(d.getMonth()+1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`};
/* ================= state ================= */
const TG=window.Telegram&&window.Telegram.WebApp;
const INIT=(TG&&TG.initData)||'';
const TGU=(TG&&TG.initDataUnsafe&&TG.initDataUnsafe.user)||null;
const START_PARAM=(TG&&TG.initDataUnsafe&&TG.initDataUnsafe.start_param)||
(new URLSearchParams(location.search).get('startapp')||new URLSearchParams(location.search).get('list')||'');
let DEMO=!INIT;
const S={view:'overview',data:null,meta:{msg:0,dav:'',bot:false},me:null,events:[],ready:false};
/* ========== 🆕 频道标识:支持 -100ID / @用户名 / https://t.me/xxx ========== */
const CH_HINT='-100 数字 ID、@用户名 或 https://t.me/xxx';
function normCh(raw){
const s=String(raw??'').trim();if(!s)return'';
const m=s.match(/^(?:https?:\/\/)?(?:www\.)?(?:t|telegram)\.me\/(.+)$/i);
if(m){
const parts=m[1].split(/[?#]/)[0].replace(/^\/+|\/+$/g,'').split('/').filter(Boolean);
if(!parts.length)return'';
if(parts[0].toLowerCase()==='c'&&/^\d+$/.test(parts[1]||''))return parts[1].startsWith('-100')?parts[1]:'-100'+parts[1];
if(parts[0].startsWith('+')||parts[0].toLowerCase()==='joinchat')return s;
return '@'+parts[0].replace(/^@/,'');
}
if(s.startsWith('@'))return '@'+s.slice(1).trim();
if(/^-?\d+$/.test(s))return s;
if(/^[A-Za-z][A-Za-z0-9_]{3,31}$/.test(s))return '@'+s;
return s;
}
function msgRef(raw){
const s=String(raw??'').trim();if(!s)return{ch:'',id:''};
const m=s.match(/^(?:https?:\/\/)?(?:www\.)?(?:t|telegram)\.me\/(.+)$/i);
if(m){
const parts=m[1].split(/[?#]/)[0].replace(/^\/+|\/+$/g,'').split('/').filter(Boolean);
const nums=parts.filter(p=>/^\d+$/.test(p));
if(parts[0]&&parts[0].toLowerCase()==='c')return{ch:normCh(s),id:nums.length>1?nums[nums.length-1]:''};
return{ch:parts[0]?'@'+parts[0].replace(/^@/,''):'',id:nums.length?nums[nums.length-1]:''};
}
return{ch:'',id:/^\d+$/.test(s)?s:''};
}
/* 频道展示名:地址簿有备注就带上备注 */
function chName(cid){const ab=(S.data&&S.data.address_book)||{};return ab[cid]||''}
function chLabel(cid,{icon=true}={}){
const nm=chName(cid);const sid=shortId(cid);
return nm?`${icon?I('library',11):''}<b style="font-weight:600">${esc(nm)}</b> <span style="opacity:.55">${esc(sid)}</span>`
:`${icon?I('hash',11):''}${esc(sid)}`;
}
/* ================= mock db (demo mode) ================= */
const nowS=()=>Math.floor(Date.now()/1000);
const SEED={
groups:[
{name:'主站分发',src:'-1001682349051',tgt:['-1001733118244','-1001552209873']},
{name:'镜像存档',src:'-1001682349051',tgt:['-1001908744236','@Nine_7Eleven']},
{name:'精选推送',src:'-1001445096327',tgt:['-1001733118244','-1001908744236','-1001612987450']}
],
stats_tasks:[
{task_name:'七月热评榜',channel_id:'-1001682349051',msg_id:'1024',table_title:'本周热评 TOP10',top_n:10,trigger_tag:'#更新',interval:15,duration:7,start_time:nowS()-86400*2,last_run:nowS()-600,completed_items:['夜航星','雾都夜谈'],stats_blacklist:['旧日支配者','无名氏'],blacklist_title:'本月轮换限制'},
{task_name:'周刊人气投票',channel_id:'-1001445096327',msg_id:'512',table_title:'周刊人气榜',top_n:5,trigger_tag:'#周刊',interval:30,duration:14,start_time:nowS()-86400,last_run:nowS()-1800,completed_items:[],stats_blacklist:[],blacklist_title:''}
],
dir_tasks:[
{task_name:'资源标签目录',scan_id:'-1001682349051',targets:[{channel_id:'-1001733118244',msg_id:'88'},{channel_id:'-1001908744236',msg_id:'12'}],blacklist:['#广告'],interval:15,last_run:nowS()-300,tags_cache:['#电影','#剧集','#纪录片','#动画','#音乐','#演唱会','#电子书','#教程','#软件','#游戏','#4K','#无损']},
{task_name:'周刊索引',scan_id:'-1001445096327',targets:[{channel_id:'-1001612987450',msg_id:'45'}],blacklist:[],interval:30,last_run:nowS()-900,tags_cache:['#周刊','#访谈','#专栏']}
],
address_book:{'-1001682349051':'主频道','-1001733118244':'分发一号','-1001552209873':'存档仓','-1001908744236':'镜像站','-1001445096327':'周刊编辑部','-1001612987450':'索引频道','@Nine_7Eleven':'公开频道 Nine7'},
member_monitors:[
{channel_id:'-1001682349051',started_at:nowS()-86400*5,interval:60,last_run:nowS()-1200,member_count:1284,in_count:1271,left_total:13,rt_at:nowS()-180,scan_at:nowS()-1200,capped:true,remote_path:'members/backup.csv',last_error:'',joined_count:12,left_count:3,first_scan_at:nowS()-86400*5},
{channel_id:'@Nine_7Eleven',started_at:nowS()-86400*2,interval:120,last_run:nowS()-7200,member_count:356,in_count:356,left_total:0,rt_at:0,scan_at:nowS()-7200,capped:false,remote_path:'',last_error:'ChatAdminRequired',joined_count:0,left_count:0,first_scan_at:0}
]
};
function mockDB(){try{return JSON.parse(localStorage.getItem('nine7_mock'))||null}catch(e){return null}}
function mockSave(d){try{localStorage.setItem('nine7_mock',JSON.stringify(d))}catch(e){}}
if(DEMO&&!mockDB())mockSave(clone(SEED));
/* ================= api layer ================= */
async function mockApi(path,{method='GET',body={},form}={}){
await delay(120+Math.random()*260);
const db=mockDB();const u=db;
const out=(extra={})=>({ok:true,user:clone(u),...extra});
const m=path.split('?')[0].match(/^\/api\/(\w+)(?:\/(.+))?$/);if(!m)return{ok:false,msg:'未知接口'}; /* 🆕 容忍查询串 */
const[,res,rest]=m;const seg=rest?rest.split('/'):[];
const d=form?Object.fromEntries(form.entries()):body;
if(res==='events'&&method==='DELETE')return{ok:true,msg:'事件记录已清空'};
if(res==='resolve'){
const raw=(new URLSearchParams(path.split('?')[1]||'')).get('ch')||'';
if(!raw)return{ok:false,msg:'请输入频道 ID'};
return{ok:true,id:/^-?\d+$/.test(raw)?raw:'-1001987654321',title:'公开频道 Nine7',
username:'Nine_7Eleven',members:12840,avatar:''};
}
if(res==='data')return{ok:true,user:clone(u),msg_count:3841,webdav_url:'https://dav.example.com/nine7/',userbot:true,
ch_avatars:{},
me_cache:{id:'88888888',name:'演示 用户',username:'nine7_demo',avatar:''},
events:[{type:'sync_done',data:'📤 主频道 → 3 个目标',time:'09:42:11'},
{type:'stats_done',data:'🏆 七月热评榜 已刷新 · 主频道',time:'09:40:02'},
{type:'members_done',data:'👥 分发一号 新增 12 / 退出 3',time:'09:31:48'},
{type:'dir_done',data:'📂 资源标签目录 已更新 → 索引频道(428 个标签)',time:'09:15:07'},
{type:'err',data:'⚠️ 目录推送失败 → 镜像站:message is not modified',time:'08:58:20'},
{type:'sync_edit',data:'✏️ 主频道 编辑已同步至 3 个目标',time:'08:44:36'}],
bot_username:'nine7_bot',me:{id:'88888888',first_name:'演示',last_name:'用户',username:'nine7_demo',photo_url:'',is_premium:true}};
if(res==='admins'){
db.admin_ids=db.admin_ids||['77777777'];
const t=String((d&&d.uid)||'').replace(/\D/g,'');
let msg='';
if(method==='POST'){if(!t)msg='请给出纯数字的 Telegram 用户 ID';
else if(db.admin_ids.includes(t)||t==='88888888')msg=t+' 已经在白名单里了';
else{db.admin_ids.push(t);msg='已放行 '+t};mockSave(db)}
if(method==='DELETE'){if(t==='88888888')msg='不能把自己移出白名单';
else{db.admin_ids=db.admin_ids.filter(x=>x!==t);msg='已移除 '+t};mockSave(db)}
return{ok:!/已经在|不能|请给出/.test(msg),msg,me:'88888888',owner:true,owners:['88888888'],members:db.admin_ids.slice(),
profiles:{'88888888':{id:'88888888',name:'演示 用户',username:'nine7_demo',avatar:''},
'77777777':{id:'77777777',name:'小助手',username:'',avatar:''},
'123456789':{id:'123456789',name:'',username:'',avatar:''}}};
}
if(res==='groups'){
if(method==='POST'){u.groups.push({name:d.name||'',src:d.src,tgt:[].concat(d.tgt||[]).filter(Boolean)});mockSave(db);return out()}
const i=+seg[0];
if(method==='PUT'){u.groups[i]={name:d.name||'',src:d.src,tgt:[].concat(d.tgt||[]).filter(Boolean)};mockSave(db);return out()}
if(method==='DELETE'){u.groups.splice(i,1);mockSave(db);return out()}
}
if(res==='channels'){
if(method==='POST'){u.address_book[d.id]=d.name;mockSave(db);return out()}
const cid=decodeURIComponent(seg[0]||'');
if(method==='PUT'){if(d.id!==cid)delete u.address_book[cid];u.address_book[d.id]=d.name;mockSave(db);return out()}
if(method==='DELETE'){delete u.address_book[cid];mockSave(db);return out()}
}
if(res==='stats'){
if(method==='POST'){u.stats_tasks.push({task_name:d.task_name,channel_id:d.channel_id,msg_id:d.msg_id,table_title:d.table_title,top_n:+d.top_n||10,trigger_tag:d.trigger_tag,interval:+d.interval||15,duration:+d.duration||7,start_time:nowS(),last_run:0,completed_items:[],stats_blacklist:String(d.stats_blacklist||'').split(/[\s\n]+/).filter(Boolean),blacklist_title:d.blacklist_title||''});mockSave(db);return out()}
const i=+seg[0];
if(method==='PUT'){const t=u.stats_tasks[i];const{field,value}=d;
if(field==='add_stats_bl'){t.stats_blacklist=[...new Set([...(t.stats_blacklist||[]),...String(value).split(/[\s\n]+/).filter(Boolean)])]}
else if(field==='rm_stats_bl'){const rm=String(value).split(/[\s\n]+/).filter(Boolean);t.stats_blacklist=(t.stats_blacklist||[]).filter(x=>!rm.includes(x))}
else if(['top_n','interval','duration'].includes(field))t[field]=+value;
else if(field==='extract_blacklist'){t.stats_blacklist=[...new Set([...(t.stats_blacklist||[]),'雾都夜谈','白夜行'])]}
else t[field]=value;
t.last_run=0;mockSave(db);return out()}
if(method==='DELETE'){u.stats_tasks.splice(i,1);mockSave(db);return out()}
}
if(res==='dirs'){
if(method==='POST'){u.dir_tasks.push({task_name:d.task_name,scan_id:d.scan_id,targets:d.targets||[],blacklist:d.blacklist||[],interval:+d.interval||15,last_run:0,tags_cache:[]});mockSave(db);return out()}
const i=+seg[0];const t=u.dir_tasks[i];
if(method==='PUT'){const{field,value}=d;
if(field==='add_blacklist'){t.blacklist=[...new Set([...t.blacklist,...String(value).split(/\s+/).filter(Boolean)])]}
else if(field==='rm_blacklist'){const rm=String(value).split(/\s+/).filter(Boolean);t.blacklist=t.blacklist.filter(x=>!rm.includes(x))}
else if(field==='interval')t.interval=+value;
else if(field==='add_target'){t.targets.push(JSON.parse(value))}
else if(field==='rm_target'){t.targets.splice(+value,1)}
else if(field==='target_msg_id'){const up=JSON.parse(value);t.targets[up.index].msg_id=up.msg_id}
mockSave(db);return out()}
if(method==='DELETE'){u.dir_tasks.splice(i,1);mockSave(db);return out()}
}
if(res==='member_monitors'){
if(method==='POST'&&seg.length===0){u.member_monitors.push({channel_id:d.ch_id,started_at:nowS(),interval:+d.interval||60,last_run:0,member_count:0,remote_path:'',last_error:''});mockSave(db);return out()}
const cid=decodeURIComponent(seg[0]||'');
if(seg[1]==='download')return{ok:true,msg:'备份文件将由机器人发送给你'};
if(seg[1]==='scan')return{ok:true,msg:'已排入队列,10 秒内对一次账'};
if(method==='PUT'){const m2=u.member_monitors.find(x=>String(x.channel_id)===cid);if(m2){m2.interval=+d.interval;m2.last_run=0}mockSave(db);return out()}
if(method==='DELETE'){u.member_monitors=u.member_monitors.filter(x=>String(x.channel_id)!==cid);mockSave(db);return out()}
}
if(res==='member_log'){
const cid=decodeURIComponent(seg[0]||''),mm=u.member_monitors.find(x=>String(x.channel_id)===cid)||{};
const n=mm.member_count||0,names=['夜航星','雾都夜谈','银月','拾柒','老鹿','K','南风','阿波罗'];
const mk=(i,left)=>({id:String(70000+i),name:names[i%names.length]+i,un:'user'+i,bot:false,t:nowS()-i*3600,left:left?nowS()-i*600:0});
const mem=Array.from({length:Math.min(200,n)},(_,i)=>mk(i,i%23===0));
return{ok:true,id:cid,title:cid,members:mem,stats:{total:n,in:mm.in_count||n,left:mm.left_total||0},cap:200,shown:mem.length,matched:n,
capped:!!mm.capped,visible:200,scan_at:mm.scan_at||0,rt_at:mm.rt_at||0,rt_src:'bot',backup_at:nowS()-120,first_scan_at:mm.first_scan_at||0,
log:mem.slice(0,20).map((x,i)=>({t:nowS()-i*900,u:x.id,n:x.name,un:x.un,a:i%5===0?'left':'join',s:i%3===0?'scan':'rt'}))};
}
if(['btn_multi','btn_old','btn_new','btn_new_media','gen_dir','replace_tag','backup','batch_create'].includes(res))return{ok:true};
return{ok:false,msg:'演示模式不支持该操作'};
}
async function api(path,{method='GET',body,form}={}){
if(DEMO)return mockApi(path,{method,body,form});
const opt={method,headers:{'X-Init-Data':INIT}};
if(form)opt.body=form;else if(body){opt.headers['Content-Type']='application/json';opt.body=JSON.stringify(body)}
const r=await fetch(path,opt);
const j=await r.json().catch(()=>({ok:false,msg:'响应解析失败'}));
if(!r.ok||j.ok===false){const err=new Error(j.msg||('HTTP '+r.status));err.status=r.status;throw err}
return j;
}
function applyUser(j){if(j&&j.user){S.data=j.user}}
/* ================= toast ================= */
function toast(msg,type='ok'){
const t=document.createElement('div');t.className='toast '+type;
t.innerHTML=`${I(type==='ok'?'check':type==='err'?'triangle-alert':'info',16)}<span>${esc(msg)}</span>`;
$('#toasts').appendChild(t);icons();
requestAnimationFrame(()=>t.classList.add('in'));
setTimeout(()=>{t.classList.remove('in');setTimeout(()=>t.remove(),500)},3600);
}
/* ================= sheet ================= */
function sheet({title,sub='',body='',foot=''}){
const w=document.createElement('div');w.className='sh-wrap';
w.innerHTML=`<div class="sh-bg"></div><div class="sh"><div class="sh-grip"></div>
<div class="sh-h"><div><div class="sh-t">${title}</div>${sub?`<div class="sh-s">${sub}</div>`:''}</div>
<button class="sh-x" type="button">${I('x',16)}</button></div>
<div class="sh-b">${body}</div>${foot?`<div class="sh-f">${foot}</div>`:''}</div>`;
$('#sheets').appendChild(w);icons();
const close=()=>{w.classList.remove('in');setTimeout(()=>w.remove(),450)};
$('.sh-bg',w).onclick=close;$('.sh-x',w).onclick=close;
w._close=close;
requestAnimationFrame(()=>w.classList.add('in'));
return w;
}
function confirmSheet(msg,onYes,label='确认删除'){
const w=sheet({title:'请确认',body:`<p style="color:var(--mut);font-size:13.5px;line-height:1.7">${esc(msg)}</p>`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-danger" data-a="yes">${I('trash-2',14)} ${label}</button>`});
icons();$('[data-a=no]',w).onclick=w._close;
$('[data-a=yes]',w).onclick=async e=>{e.target.closest('button').disabled=true;await onYes();w._close()};
}
/* field builders */
const fld=(label,inner,hint='')=>`<label class="fld"><span class="fld-l">${label}</span>${inner}${hint?`<span class="fld-h">${hint}</span>`:''}</label>`;
const inp=(id,val='',ph='',type='text',extra='')=>`<input class="inp" id="${id}" type="${type}" value="${esc(val)}" placeholder="${esc(ph)}" ${extra}>`;
const txa=(id,val='',ph='',rows=3)=>`<textarea class="inp" id="${id}" rows="${rows}" placeholder="${esc(ph)}">${esc(val)}</textarea>`;
const gv=(id,el0=document)=>{const n=$('#'+id,el0);return n?n.value.trim():''};
function picker(id,book,selected=''){
const chips=Object.entries(book||{}).map(([cid,nm])=>`<span class="chip pk-c${cid===selected?' on':''}" data-cid="${esc(cid)}">${esc(nm)}</span>`).join('');
return`<div class="pk" id="${id}_pk">${chips||'<span class="fld-h">地址簿为空,请手动输入</span>'}</div>
<div class="pk-manual">${inp(id,selected,CH_HINT)}<span class="fld-h" style="display:block;margin-top:6px">支持 ${esc(CH_HINT)}</span></div>`;
}
function bindPicker(id,root){
const box=$('#'+id,root);
$$('#'+id+'_pk .pk-c',root).forEach(c=>c.onclick=()=>{
$$('#'+id+'_pk .pk-c',root).forEach(x=>x.classList.remove('on'));c.classList.add('on');
if(box){box.value=c.dataset.cid;box.dispatchEvent(new Event('input',{bubbles:true}))}
});
// 🆕 手动输入 t.me 链接 / @用户名,失焦即归一化,并高亮地址簿中对应频道
if(box)box.onchange=()=>{
const v=normCh(box.value);if(v!==box.value)box.value=v;
$$('#'+id+'_pk .pk-c',root).forEach(x=>x.classList.toggle('on',x.dataset.cid===v));
box.dispatchEvent(new Event('input',{bubbles:true}));
};
}
/* ================= router ================= */
const VIEWS={
overview:{cn:'总览',en:'OVERVIEW',icon:'layout-grid',fn:vOverview},
sync:{cn:'同步组',en:'SYNC',icon:'refresh-ccw',fn:vSync},
stats:{cn:'热评榜',en:'STATS',icon:'trophy',fn:vStats},
dirs:{cn:'目录',en:'DIRECTORY',icon:'folder-tree',fn:vDirs},
channels:{cn:'地址簿',en:'CHANNELS',icon:'library',fn:vChannels},
compose:{cn:'发布',en:'COMPOSE',icon:'send',fn:vCompose},
ops:{cn:'运维',en:'OPS',icon:'wrench',fn:vOps},
};
function buildNav(){
$('#nav').innerHTML=Object.entries(VIEWS).map(([k,v],i)=>
`<button class="nav-b${k===S.view?' on':''}" data-v="${k}">${I(v.icon,19)}<em>0${i+1}</em><b class="nav-t">${v.cn}</b></button>`).join('');
$$('#nav .nav-b').forEach(b=>b.onclick=()=>go(b.dataset.v));icons();
}
function go(v){
if(!VIEWS[v])return;S.view=v;
$$('#nav .nav-b').forEach(b=>b.classList.toggle('on',b.dataset.v===v));
const m=$('#view');m.classList.remove('ready');
m.innerHTML='';VIEWS[v].fn(m);
window.scrollTo({top:0});
requestAnimationFrame(()=>{m.classList.add('ready');icons();stagger(m);countUp(m);bindFx(m)});
}
function stagger(root){$$('[data-rv]',root).forEach((el,i)=>el.style.transitionDelay=Math.min(i*55,500)+'ms')}
function vh(idx,cn,en,desc,btn=''){
return`<div class="vh" data-rv><div>
<div class="vh-k">${idx} — ${en}</div><h1>${cn}<span class="en">${en}</span></h1></div>
<div style="display:flex;flex-direction:column;gap:16px;align-items:flex-end">
${btn}${desc?`<p class="vh-p">${desc}</p>`:''}</div></div>`;
}
function countUp(root){
$$('[data-cnt]',root).forEach(el=>{
const target=+el.dataset.cnt||0;const dur=1100;const t0=performance.now();
const step=t=>{const p=Math.min((t-t0)/dur,1);const e=1-Math.pow(1-p,4);
el.textContent=Math.round(target*e).toLocaleString();if(p<1)requestAnimationFrame(step)};
requestAnimationFrame(step);
});
}
/* ================= 01 overview ================= */
function vOverview(m){
const d=S.data||{},g=(d.groups||[]).length,st=(d.stats_tasks||[]).length,di=(d.dir_tasks||[]).length,ab=Object.keys(d.address_book||{}).length;
const tiles=[
{n:g,l:'同步组',ic:'refresh-ccw',v:'sync',sp:'0,28 14,10 28,20 42,8 56,16 70,6 84,12'},
{n:st,l:'热评榜任务',ic:'trophy',v:'stats',sp:'0,20 14,24 28,12 42,18 56,8 70,14 84,6'},
{n:di,l:'目录任务',ic:'folder-tree',v:'dirs',sp:'0,16 14,22 28,10 42,14 56,20 70,8 84,14'},
{n:ab,l:'地址簿频道',ic:'library',v:'channels',sp:'0,22 14,14 28,18 42,10 56,16 70,10 84,4'},
];
m.innerHTML=`
<section class="hero">
<div class="hero-k" data-rv><span class="dot"></span>TELEGRAM CHANNEL CONSOLE — V5</div>
<h1 class="hero-t" data-rv><span class="l1">Channel</span><span class="l2">频道中枢<span class="acc">.</span></span></h1>
<p class="hero-d" data-rv>同步、榜单、目录、备份,为频道运营者打造的一体化指挥台。双引擎驱动,WebDAV 云端持久化,全量实时事件流。</p>
<div class="hero-b" data-rv>
<button class="btn btn-acc" id="hGo1">${I('arrow-up-right',15)} 进入同步组</button>
<button class="btn" id="hGo2">${I('send',15)} 发布消息</button>
</div>
<svg class="badge-rot" viewBox="0 0 132 132"><defs><path id="cir" d="M66,66 m-52,0 a52,52 0 1,1 104,0 a52,52 0 1,1 -104,0"/></defs>
<text><textPath href="#cir">NINE7 · CHANNEL CONSOLE · OPS · SYNC · </textPath></text>
<circle cx="66" cy="66" r="4" fill="var(--acc)"/></svg>
</section>
<div class="mq" data-rv><div class="mq-in">${mqx()}</div></div>
<div class="tiles">${tiles.map((t,i)=>`
<div class="tile" data-rv data-go="${t.v}">
<div class="t-ic">${I(t.ic,20)}</div>
<div class="t-num" data-cnt="${t.n}">0</div>
<div class="t-l">${t.l}</div>
<svg class="t-sp" viewBox="0 0 84 30" preserveAspectRatio="none"><polyline points="${t.sp}" fill="none" stroke="var(--acc)" stroke-width="1.2"/></svg>
</div>`).join('')}
</div>
<div class="grid2">
<div class="panel" data-rv>
<div class="panel-h"><span class="panel-t">${I('server')} 系统状态</span><span class="mono" style="font-size:10px;color:var(--dim)">V5.9</span></div>
<div class="kv"><span class="k">${I('user')} 当前用户</span><span class="v" style="display:flex;align-items:center;gap:8px;justify-content:flex-end">
${(()=>{const u=meInfo();const ini=esc((u.name||'U').slice(0,1).toUpperCase());
return`${u.photo?`<img src="${esc(u.photo)}" referrerpolicy="no-referrer" style="width:22px;height:22px;border-radius:50%;object-fit:cover">`
:`<span style="width:22px;height:22px;border-radius:50%;border:1px solid var(--line2);display:inline-flex;align-items:center;justify-content:center;font:700 10px var(--fm);color:var(--acc)">${ini}</span>`}
<span>${esc(u.name)}${u.uname?' · @'+esc(u.uname):''}</span>`})()}
</span></div>
<div class="kv"><span class="k">${I('cloud')} WebDAV 节点</span><span class="v">${esc(shortId(S.meta.dav||'—'))}</span></div>
<div class="kv"><span class="k">${I('cpu')} Userbot 引擎</span><span class="v ${S.meta.bot?'st-on':'st-off'}">${S.meta.bot?'在线':'离线'}</span></div>
<div class="kv"><span class="k">${I('database')} 消息映射</span><span class="v">${(+S.meta.msg||0).toLocaleString()} 条</span></div>
<div class="kv"><span class="k">${I('hard-drive')} 数据文件</span><span class="v">tg_bot_data_v5.json</span></div>
<div class="kv"><span class="k">${I('shield-check')} 鉴权方式</span><span class="v">${DEMO?'DEMO MODE':'HMAC-SHA256'}</span></div>
</div>
<div class="panel" data-rv>
<div class="panel-h"><span class="panel-t">${I('terminal')} 实时事件流</span>
<span style="display:flex;align-items:center;gap:8px">
<span class="tg tg-sys" id="evN">0/${EV_MAX}</span>
<button class="ic-b" id="evClr" title="清空记录">${I('eraser',13)}</button>
</span></div>
<div class="feed" id="feed"></div>
</div>
</div>
<div class="foot" data-rv><span>NINE7 CONSOLE — BUILD 5.9</span><span>WEBDAV SYNCED · DUAL ENGINE</span><span>MMXXVI</span></div>`;
$('#hGo1',m).onclick=()=>go('sync');
$('#hGo2',m).onclick=()=>go('compose');
$$('.tile',m).forEach(t=>t.onclick=()=>go(t.dataset.go));
renderFeed();
const clr=$('#evClr',m);
if(clr)clr.onclick=()=>confirmSheet('清空事件记录?只删记录,不影响任务。',async()=>{
try{await api('/api/events',{method:'DELETE'});S.events=[];renderFeed();toast('事件记录已清空')}catch(e){toast(e.message,'err')}
});
}
function mqx(){
const items=['实时同步','SYNC','热评榜单','STATS','标签目录','DIRECTORY','智能备份','BACKUP','成员监控','MEMBERS'];
const half=items.map((s,i)=>i%2?`<span><b>${s}</b></span>`:`<span>${s}</span>`).join('');
return half+half;
}
function renderFeed(){
const f=$('#feed');if(!f)return;
const n=$('#evN');if(n)n.textContent=`${S.events.length}/${EV_MAX}`;
f.innerHTML=S.events.length?S.events.slice(0,EV_MAX).map(e=>{
const t=e.type||'sys';
const cls=t.includes('backup')?'tg-bkp':t.includes('dir')?'tg-dir':t.includes('err')?'tg-err':(t==='connected'||t==='sys')?'tg-sys':'tg-sync';
const lb={connected:'SYS',sys:'SYS',backup_done:'BACKUP',dir_done:'DIR',replace_done:'REPLACE',
members_done:'MEMBER',sync_done:'SYNC',sync_edit:'EDIT',stats_done:'STATS',follow:'FOLLOW',err:'ERROR'}[t]||'INFO';
const txt=deEmoji(e.data||'');
return`<div class="feed-l"><span class="tm">${esc(e.time||'--:--:--')}</span><span class="tg ${cls}">${lb}</span><span class="ms" title="${esc(txt)}">${I(EV_ICON[t]||'info',13)}<span>${esc(txt)}</span></span></div>`;
}).join(''):`<div class="feed-l"><span class="tm">--:--:--</span><span class="tg tg-sys">IDLE</span><span class="ms">暂无事件记录</span></div>`;
icons(f);
}
/* ================= 02 sync ================= */
function vSync(m){
const groups=(S.data&&S.data.groups)||[];
m.innerHTML=vh('02','同步组','SYNC GROUPS','源频道发布的内容将实时复制到全部目标频道,编辑亦同步。',
`<button class="btn btn-acc" id="addG">${I('plus',15)} 新建同步组</button>`)+
(groups.length?groups.map((g,i)=>{
const tgts=[].concat(g.tgt||[]);
return`<div class="itm" data-rv><div class="gidx">${String(i+1).padStart(2,'0')}</div>
<div class="itm-t">${esc(g.name||'未命名同步组')}<span class="chip">${I('repeat',11)} ${tgts.length} 个目标</span></div>
<div class="itm-s">${chName(g.src)?`来源:${esc(chName(g.src))}`:'来源未登记地址簿'}</div>
<div class="route"><span class="mono">${chLabel(g.src)}</span>${I('arrow-right',15)}
<span style="display:flex;gap:7px;flex-wrap:wrap">${tgts.map(t=>`<span class="chip">${chLabel(t)}</span>`).join('')}</span></div>
<div class="itm-a">
<button class="btn btn-s" data-e="${i}">${I('pencil',13)} 编辑</button>
<button class="btn btn-s btn-danger" data-d="${i}">${I('trash-2',13)} 删除</button>
</div></div>`;
}).join(''):emptyBox('refresh-ccw','暂无同步组','点击右上角按钮,建立第一条实时同步链路'));
$('#addG',m).onclick=()=>groupSheet();
$$('[data-e]',m).forEach(b=>b.onclick=()=>groupSheet(+b.dataset.e));
$$('[data-d]',m).forEach(b=>b.onclick=()=>confirmSheet('删除后该链路将立即停止同步,且不可恢复。',async()=>{
try{applyUser(await api('/api/groups/'+b.dataset.d,{method:'DELETE'}));toast('同步组已删除');go('sync')}catch(e){toast(e.message,'err')}
}));
}
function groupSheet(idx){
const edit=idx!==undefined;const g=edit?clone(S.data.groups[idx]):{name:'',src:'',tgt:[]};
const w=sheet({title:edit?'编辑同步组':'新建同步组',sub:'源频道 → 多个目标频道的实时复制链路',
body:`
${fld('同步组名称',inp('g_name',g.name,'例如:主站分发'))}
${fld('源频道',picker('g_src',S.data.address_book,g.src))}
<div class="sec-t">目标频道列表</div>
<div id="g_tgts"></div>
<button class="dyn-add" id="g_addT" type="button">${I('plus',13)} 添加目标频道</button>`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} ${edit?'保存修改':'创建链路'}</button>`});
icons();bindPicker('g_src',w);
const book=S.data.address_book||{};
const bookChips=Object.entries(book).map(([cid,nm])=>`<span class="chip pk-c" data-pick="${esc(cid)}">${esc(nm)}</span>`).join('');
const list=$('#g_tgts',w);
const addRow=(val='')=>{const wrap=document.createElement('div');
wrap.innerHTML=`<div class="dyn-row"><input class="inp" placeholder="${esc(CH_HINT)}" value="${esc(val)}"><button class="dyn-x" type="button">${I('x',14)}</button></div>
<div class="fld-h" style="margin:-4px 0 10px 2px"></div>`;
const r=wrap.firstElementChild,tip=wrap.lastElementChild,box=$('.inp',r);
// 🆕 输入框下方实时回显地址簿备注名
const sync=()=>{const v=normCh(box.value);if(v&&v!==box.value)box.value=v;
tip.innerHTML=v?(chName(v)?`📔 ${esc(chName(v))}`:'未登记到地址簿'):''};
box.oninput=()=>{tip.innerHTML=chName(normCh(box.value))?`📔 ${esc(chName(normCh(box.value)))}`:''};
box.onchange=sync;sync();
$('.dyn-x',r).onclick=()=>wrap.remove();list.appendChild(wrap);icons()};
(g.tgt.length?g.tgt:['']).forEach(addRow);
$('#g_addT',w).onclick=()=>addRow();
// 从地址簿一键追加目标
const quick=document.createElement('div');quick.className='pk';quick.style.marginTop='10px';
quick.innerHTML=bookChips?`<span class="fld-h" style="width:100%">从地址簿快速添加:</span>${bookChips}`:'';
$('#g_addT',w).after(quick);
$$('[data-pick]',quick).forEach(c=>c.onclick=()=>addRow(c.dataset.pick));
$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',14)} 处理中`;icons();
const tgt=$$('.dyn-row .inp',list).map(i=>normCh(i.value)).filter(Boolean);
const body={name:gv('g_name',w),src:normCh(gv('g_src',w)),tgt};
if(!body.src||!tgt.length){toast('请填写源频道与至少一个目标','err');btn.disabled=false;btn.innerHTML=`${I('check',14)} 重试`;icons();return}
try{
applyUser(await api(edit?'/api/groups/'+idx:'/api/groups',{method:edit?'PUT':'POST',body}));
toast(edit?'同步组已更新':'同步链路已建立');w._close();go('sync');
}catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'}
};
}
function emptyBox(ic,t,s){return`<div class="empty" data-rv>${I(ic,34)}<div class="e-t">${t}</div><div class="e-s">${s}</div></div>`}
/* ================= 03 stats ================= */
function vStats(m){
const tasks=(S.data&&S.data.stats_tasks)||[];
m.innerHTML=vh('03','热评榜','LEADERBOARDS','基于评论反应数的自动榜单,按周期刷新并追加到目标消息。',
`<button class="btn btn-acc" id="addS">${I('plus',15)} 新建榜单</button>`)+
(tasks.length?tasks.map((t,i)=>{
const done=(t.completed_items||[]).length,top=t.top_n||10;
return`<div class="itm" data-rv><div class="gidx">${String(i+1).padStart(2,'0')}</div>
<div class="itm-t">${esc(t.task_name||'未命名榜单')}<span class="chip">${I('hash',11)} ${esc(t.trigger_tag||'—')}</span></div>
<div class="itm-s mono">${chLabel(t.channel_id)} · 消息 #${esc(t.msg_id)}</div>
<div class="meta">
<span class="chip">${I('trophy',11)} TOP ${top}</span>
<span class="chip">${I('timer',11)} 每 ${t.interval||60} 分钟</span>
<span class="chip">${I('calendar-clock',11)} ${t.duration||7} 天周期</span>
<span class="chip">${I('shield-off',11)} 屏蔽 ${(t.stats_blacklist||[]).length}</span>
<span class="chip">${I('history',11)} 上次 ${fmtT(t.last_run)}</span>
</div>
<div class="prog"><i style="width:${Math.min(done/top*100,100)}%"></i></div>
<div class="itm-s" style="margin-top:8px">已完成 ${done} / ${top}${t.blacklist_title?` · ${esc(t.blacklist_title)}`:''}</div>
<div class="itm-a">
<button class="btn btn-s" data-e="${i}">${I('pencil',13)} 编辑</button>
<button class="btn btn-s btn-danger" data-d="${i}">${I('trash-2',13)} 删除</button>
</div></div>`;
}).join(''):emptyBox('trophy','暂无榜单任务','创建热评榜,让频道互动数据自动更新'));
$('#addS',m).onclick=()=>statSheet();
$$('[data-e]',m).forEach(b=>b.onclick=()=>statSheet(+b.dataset.e));
$$('[data-d]',m).forEach(b=>b.onclick=()=>confirmSheet('删除后榜单将停止自动更新。',async()=>{
try{applyUser(await api('/api/stats/'+b.dataset.d,{method:'DELETE'}));toast('榜单任务已删除');go('stats')}catch(e){toast(e.message,'err')}
}));
}
function statSheet(idx){
const edit=idx!==undefined;const t=edit?clone(S.data.stats_tasks[idx]):{};
const w=sheet({title:edit?'编辑榜单任务':'新建热评榜',sub:'反应数排行 · 自动去重 · 完成项划线',
body:`
<div class="sec-t">基本信息</div>
<div class="frm-grid">
${fld('任务名称',inp('s_name',t.task_name,'例如:七月热评榜'))}
${fld('榜单标题',inp('s_title',t.table_title,'例如:本周热评 TOP10'))}
</div>
${fld('所在频道',picker('s_ch',S.data.address_book,t.channel_id))}
<div class="frm-grid">
${fld('消息 ID / 链接',inp('s_msg',t.msg_id,'1024 或 https://t.me/xxx/1024'))}
${fld('触发标签',inp('s_trig',t.trigger_tag,'#更新'))}
</div>
<div class="sec-t">运行参数</div>
<div class="frm-grid">
${fld('上榜名额',inp('s_top',t.top_n??10,'10','number','min="1"'))}
${fld('更新频率(分钟)',inp('s_intv',t.interval??15,'15','number','min="1"'))}
</div>
${fld('存活期限(天)',inp('s_dura',t.duration??7,'7','number','min="1"'))}
<div class="sec-t">屏蔽名单</div>
${fld(edit?'点击名字即可解除屏蔽':'当前屏蔽',`<div class="tagcloud">${(t.stats_blacklist||[]).map(b=>edit
?`<span class="chip pk-c" data-rmbl="${esc(b)}" title="点击移除屏蔽">${esc(b)} ${I('x',11)}</span>`
:`<span class="chip">${esc(b)}</span>`).join('')||'<span class="fld-h">暂无屏蔽</span>'}</div>`)}
${fld('追加屏蔽',txa('s_abl','','空格或换行分隔',2))}
${fld('屏蔽区标题',inp('s_blt',t.blacklist_title,'留空则不显示标题'))}
${!edit?fld('初始屏蔽名单(可选)',txa('s_bl','','空格或换行分隔',2)):''}
${fld('从榜单提取屏蔽(可选)',inp('s_ext','','上期榜单消息 ID 或链接'),'将解析斜体名单并加入屏蔽')}`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} ${edit?'保存修改':'创建榜单'}</button>`});
icons();bindPicker('s_ch',w);
// 🆕 点击屏蔽名字直接移除
$$('[data-rmbl]',w).forEach(c=>c.onclick=async()=>{
const name=c.dataset.rmbl;c.style.opacity='.4';c.style.pointerEvents='none';
try{applyUser(await api('/api/stats/'+idx,{method:'PUT',body:{field:'rm_stats_bl',value:name}}));
toast(`已解除屏蔽 ${name}`);c.remove();go('stats')}
catch(e){toast(e.message,'err');c.style.opacity='';c.style.pointerEvents=''}
});
$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',14)} 处理中`;icons();
try{
if(!edit){
const ref=msgRef(gv('s_msg',w));
const body={task_name:gv('s_name',w),channel_id:normCh(gv('s_ch',w))||ref.ch,msg_id:ref.id||gv('s_msg',w),table_title:gv('s_title',w),
trigger_tag:gv('s_trig',w),top_n:gv('s_top',w),interval:gv('s_intv',w),duration:gv('s_dura',w),
stats_blacklist:gv('s_bl',w),blacklist_title:gv('s_blt',w),extract_from_msg:gv('s_ext',w)};
if(!body.task_name||!body.channel_id||!body.msg_id)throw new Error('名称、频道与消息 ID 必填');
applyUser(await api('/api/stats',{method:'POST',body}));toast('榜单任务已创建');
}else{
const cur=S.data.stats_tasks[idx];const puts=[];
const map={task_name:gv('s_name',w),table_title:gv('s_title',w),channel_id:normCh(gv('s_ch',w)),msg_id:gv('s_msg',w),trigger_tag:gv('s_trig',w)};
for(const[f,v]of Object.entries(map))if(String(v)!==String(cur[f]??''))puts.push({field:f,value:v});
for(const f of['top_n','interval','duration']){const id={top_n:'s_top',interval:'s_intv',duration:'s_dura'}[f];
if(+gv(id,w)!==+cur[f])puts.push({field:f,value:gv(id,w)})}
if(String(gv('s_blt',w))!==String(cur.blacklist_title??''))puts.push({field:'blacklist_title',value:gv('s_blt',w)||'无'});
if(gv('s_abl',w))puts.push({field:'add_stats_bl',value:gv('s_abl',w)});
if(gv('s_ext',w))puts.push({field:'extract_blacklist',value:gv('s_ext',w)});
let last=null;for(const p of puts)last=await api('/api/stats/'+idx,{method:'PUT',body:p});
if(last)applyUser(last);toast(puts.length?'榜单已更新':'无变更');
}
w._close();go('stats');
}catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'}
};
}
/* ================= 04 dirs ================= */
function vDirs(m){
const tasks=(S.data&&S.data.dir_tasks)||[];
m.innerHTML=vh('04','标签目录','DIRECTORIES','扫描源频道全部标签,按拼音字母归类,自动推送到多个承载消息。',
`<button class="btn btn-acc" id="addD">${I('plus',15)} 新建目录</button>`)+
(tasks.length?tasks.map((t,i)=>{
const tags=t.tags_cache||[],tgts=t.targets||[];
return`<div class="itm" data-rv><div class="gidx">${String(i+1).padStart(2,'0')}</div>
<div class="itm-t">${esc(t.task_name||'未命名目录')}<span class="chip">${I('tags',11)} ${tags.length} 个标签</span></div>
<div class="itm-s mono">扫描 ${chLabel(t.scan_id)} · 每 ${t.interval||15} 分钟</div>
<div class="meta">
${tgts.map(x=>`<span class="chip">${I('file-text',11)} ${chLabel(x.channel_id,{icon:false})} · #${esc(x.msg_id)}</span>`).join('')}
${(t.blacklist||[]).map(b=>`<span class="chip">${I('shield-off',11)} ${esc(b)}</span>`).join('')}
</div>
${tags.length?`<div class="tagcloud">${tags.slice(0,18).map(x=>`<span class="chip">${esc(x)}</span>`).join('')}${tags.length>18?`<span class="chip">+${tags.length-18}</span>`:''}</div>`:''}
<div class="itm-a">
<button class="btn btn-s" data-e="${i}">${I('pencil',13)} 编辑</button>
<button class="btn btn-s btn-danger" data-d="${i}">${I('trash-2',13)} 删除</button>
</div></div>`;
}).join(''):emptyBox('folder-tree','暂无目录任务','创建目录,标签索引将自动维护更新'));
$('#addD',m).onclick=()=>dirSheet();
$$('[data-e]',m).forEach(b=>b.onclick=()=>dirSheet(+b.dataset.e));
$$('[data-d]',m).forEach(b=>b.onclick=()=>confirmSheet('删除后目录将停止自动更新。',async()=>{
try{applyUser(await api('/api/dirs/'+b.dataset.d,{method:'DELETE'}));toast('目录任务已删除');go('dirs')}catch(e){toast(e.message,'err')}
}));
}
function dirSheet(idx){
const edit=idx!==undefined;const t=edit?clone(S.data.dir_tasks[idx]):{targets:[],blacklist:[]};
const w=sheet({title:edit?'编辑目录任务':'新建标签目录',sub:'全量扫描 · 拼音归类 · 多目标推送',
body:`
${!edit?`${fld('任务名称',inp('d_name','','例如:资源标签目录'))}
${fld('扫描频道',picker('d_scan',S.data.address_book,''))}
${fld('扫描频率(分钟)',inp('d_intv',15,'15','number','min="1"'),'新建时即可设定,之后可在编辑里修改')}
<div class="sec-t">承载目标(频道 + 消息)</div>
<div id="d_tgts"></div>
<button class="dyn-add" id="d_addT" type="button">${I('plus',13)} 添加承载目标</button>
<div style="height:14px"></div>
${fld('屏蔽标签(可选)',txa('d_bl','','空格分隔,如:#广告 #闲置',2))}`
:`
${fld('扫描频率(分钟)',inp('d_intv',t.interval??15,'15','number','min="1"'))}
<div class="sec-t">承载目标管理</div>
<div class="tagcloud" style="margin:0 0 14px">${(t.targets||[]).map((x,ti)=>`<span class="chip">${chLabel(x.channel_id,{icon:false})} · #${esc(x.msg_id)}
<a data-rt="${ti}" style="cursor:pointer;display:inline-flex">${I('x',11)}</a></span>`).join('')||'<span class="fld-h">暂无目标</span>'}</div>
<div class="frm-grid">
${fld('新增目标频道',inp('d_ntch','',CH_HINT))}
${fld('承载消息 ID',inp('d_ntmsg','','消息 ID 或链接'))}
</div>
<div class="sec-t">屏蔽标签</div>
${fld('点击标签即可解除屏蔽',`<div class="tagcloud" style="margin:0">${(t.blacklist||[]).map(b=>`<span class="chip pk-c" data-rmbl="${esc(b)}" title="点击移除屏蔽">${esc(b)} ${I('x',11)}</span>`).join('')||'<span class="fld-h">暂无屏蔽</span>'}</div>`)}
${fld('追加屏蔽',inp('d_abl','','空格分隔,如:#广告 #闲置'))}`}`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} ${edit?'保存修改':'创建目录'}</button>`});
icons();
if(!edit){
bindPicker('d_scan',w);
const list=$('#d_tgts',w);
const addRow=(ch='',msg='')=>{const r=document.createElement('div');r.className='dyn-row';
r.innerHTML=`<input class="inp" placeholder="频道 ID / @用户名 / 链接" value="${esc(ch)}" style="flex:1.2"><input class="inp" placeholder="消息 ID / 链接" value="${esc(msg)}" style="flex:.8"><button class="dyn-x" type="button">${I('x',14)}</button>`;
$('.dyn-x',r).onclick=()=>r.remove();list.appendChild(r);icons()};
addRow();$('#d_addT',w).onclick=()=>addRow();
}else{
$$('[data-rt]',w).forEach(a=>a.onclick=async()=>{
try{applyUser(await api('/api/dirs/'+idx,{method:'PUT',body:{field:'rm_target',value:a.dataset.rt}}));toast('目标已移除');w._close();dirSheet(idx)}
catch(e){toast(e.message,'err')}
});
// 🆕 点击屏蔽标签直接移除,无需再手动输入
$$('[data-rmbl]',w).forEach(c=>c.onclick=async()=>{
const tag=c.dataset.rmbl;c.style.opacity='.4';c.style.pointerEvents='none';
try{applyUser(await api('/api/dirs/'+idx,{method:'PUT',body:{field:'rm_blacklist',value:tag}}));
toast(`已解除屏蔽 ${tag}`);c.remove();go('dirs')}
catch(e){toast(e.message,'err');c.style.opacity='';c.style.pointerEvents=''}
});
}
$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;btn.innerHTML=`${I('loader',14)} 处理中`;icons();
try{
if(!edit){
const rows=$$('#d_tgts .dyn-row',w);
const targets=rows.map(r=>{const ii=$$('.inp',r);const ref=msgRef(ii[1].value);
return{channel_id:normCh(ii[0].value)||ref.ch,msg_id:ref.id||ii[1].value.trim()}}).filter(x=>x.channel_id&&x.msg_id);
const body={task_name:gv('d_name',w),scan_id:normCh(gv('d_scan',w)),targets,
interval:+gv('d_intv',w)||15,blacklist:gv('d_bl',w).split(/\s+/).filter(Boolean)};
if(!body.task_name||!body.scan_id||!targets.length)throw new Error('名称、扫描频道与至少一个目标必填');
applyUser(await api('/api/dirs',{method:'POST',body}));toast('目录任务已创建');
}else{
const cur=S.data.dir_tasks[idx];let last=null;
if(+gv('d_intv',w)!==+cur.interval)last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'interval',value:gv('d_intv',w)}});
if(gv('d_abl',w))last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'add_blacklist',value:gv('d_abl',w)}});
if(gv('d_ntch',w)&&gv('d_ntmsg',w))last=await api('/api/dirs/'+idx,{method:'PUT',body:{field:'add_target',value:JSON.stringify({channel_id:normCh(gv('d_ntch',w)),msg_id:gv('d_ntmsg',w)})}});
if(last)applyUser(last);toast('目录任务已更新');
}
w._close();go('dirs');
}catch(err){toast(err.message,'err');btn.disabled=false;btn.innerHTML='重试'}
};
}
/* ================= 05 channels ================= */
function vChannels(m){
const ab=(S.data&&S.data.address_book)||{};
const ents=Object.entries(ab);
m.innerHTML=vh('05','地址簿','ADDRESS BOOK','常用频道的快速索引,各模块可直接引用。',
`<button class="btn" id="batch">${I('layers',15)} 批量建频道</button>`)+
`<div class="ch-grid">
<button class="add-c" id="addC" data-rv>${I('plus',22)}<span style="font:600 12px var(--fb)">收藏频道</span></button>
${ents.map(([cid,nm],i)=>`<div class="ch-c" data-rv>
<div class="ch-hd">${ava(chAvatar(cid),nm,'sq lg')}<div class="nm" style="margin:0">${esc(nm)}</div></div>
<div class="cid">${esc(cid)}</div>
<div class="ops">
<button class="ic-b" data-c="${i}" title="复制 ID">${I('copy',14)}</button>
<button class="ic-b" data-e="${i}" title="编辑">${I('pencil',14)}</button>
<button class="ic-b danger" data-d="${i}" title="删除">${I('trash-2',14)}</button>
</div></div>`).join('')}
</div>`;
$('#addC',m).onclick=()=>chSheet();
$('#batch',m).onclick=()=>batchSheet();
$$('[data-c]',m).forEach(b=>b.onclick=async()=>{
try{await navigator.clipboard.writeText(ents[+b.dataset.c][0]);toast('频道 ID 已复制')}catch(e){toast('复制失败','err')}
});
$$('[data-e]',m).forEach(b=>b.onclick=()=>chSheet(ents[+b.dataset.e][0]));
$$('[data-d]',m).forEach(b=>b.onclick=()=>{const cid=ents[+b.dataset.d][0];
confirmSheet(`将「${ab[cid]}」从地址簿移除?`,async()=>{
try{applyUser(await api('/api/channels/'+encodeURIComponent(cid),{method:'DELETE'}));toast('已移除');go('channels')}catch(e){toast(e.message,'err')}
})});
}
/* 🆕 频道头像:后端认过一次就缓存在 avatar_cache/,地址由 /api/data 下发 */
function chAvatar(cid){return (S.chAva&&S.chAva[cid])||''}
function chSheet(cid){
const edit=cid!==undefined;const nm=edit?S.data.address_book[cid]:'';
const w=sheet({title:edit?'编辑频道':'收藏频道',sub:'填 ID / @用户名 / 链接,自动认出频道名和头像',
body:`${fld('频道 ID / 链接',inp('c_id',edit?cid:'',CH_HINT),'支持 '+CH_HINT)}
<div id="rsBox"></div>
${fld('备注名',inp('c_name',nm,'留空则用识别出的频道名'))}`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} 保存</button>`});
icons();$('[data-a=no]',w).onclick=w._close;
/* 🆕 输入频道后自动识别:带出真实名称、成员数与头像 */
let rsTimer=null,rsLast='';
const rsBox=$('#rsBox',w);
const rsShow=h=>{rsBox.innerHTML=h;icons()};
async function resolveCh(){
const raw=(gv('c_id',w)||'').trim();
if(!raw||raw===rsLast)return;
rsLast=raw;
rsShow(`<div class="rs-box"><span class="ava sq">…</span><div><div class="t">识别中…</div><div class="s">${esc(normCh(raw)||raw)}</div></div></div>`);
try{
const j=await api('/api/resolve?ch='+encodeURIComponent(raw));
if(!j.ok){rsShow(`<div class="rs-box" style="border-color:rgba(255,93,93,.35)">${I('triangle-alert',16)}<div><div class="t">认不出这个频道</div><div class="s">${esc(j.msg||'请检查 ID 或把机器人加进频道')}</div></div></div>`);return}
S.chAva=S.chAva||{};if(j.avatar)S.chAva[j.id]=j.avatar;
rsShow(`<div class="rs-box">${ava(j.avatar,j.title,'sq lg')}
<div style="min-width:0"><div class="t">${esc(j.title||'未命名')}</div>
<div class="s">${j.username?'@'+esc(j.username)+' · ':''}${esc(j.id)}${j.members?' · '+j.members.toLocaleString()+' 人':''}</div></div></div>`);
const nameEl=$('#c_name',w);
if(nameEl&&!nameEl.value.trim()&&j.title)nameEl.value=j.title; /* 备注名留空就自动填 */
const idEl=$('#c_id',w);
if(idEl&&j.id&&!/^@/.test(raw))idEl.value=j.id; /* 统一成规范 ID */
}catch(e){rsShow(`<div class="rs-box" style="border-color:rgba(255,93,93,.35)">${I('triangle-alert',16)}<div><div class="t">识别失败</div><div class="s">${esc(e.message)}</div></div></div>`)}
}
const idIn=$('#c_id',w);
if(idIn){
idIn.addEventListener('input',()=>{clearTimeout(rsTimer);rsTimer=setTimeout(resolveCh,700)});
idIn.addEventListener('blur',resolveCh);
if(edit)resolveCh();
}
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
const rsT=$('#rsBox .t',w);
const body={id:normCh(gv('c_id',w)),name:gv('c_name',w)||(rsT?rsT.textContent.trim():'')};
if(!body.id)throw new Error('请填写频道 ID / 链接');
if(!body.name||body.name==='识别中…')throw new Error('填个备注名,或等自动识别出频道名');
applyUser(await api(edit?'/api/channels/'+encodeURIComponent(cid):'/api/channels',{method:edit?'PUT':'POST',body}));
toast('地址簿已更新');w._close();go('channels');
}catch(err){toast(err.message,'err');btn.disabled=false}
};
}
function batchSheet(){
const w=sheet({title:'批量建频道',sub:'由 Userbot 自动创建并登记到地址簿,后台异步执行',
body:`
${fld('频道名称(每行一个)',txa('b_names','','例如:\n分站\n资源库',4))}
${fld('追加管理员(可选)',txa('b_users','','用户 ID 或用户名,空格分隔',2))}
${fld('每个名称创建数量',inp('b_count',1,'1','number','min="1" max="20"'),'大于 1 时自动追加序号')}`,
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('zap',14)} 开始创建</button>`});
icons();$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
if(!gv('b_names',w))throw new Error('请输入频道名称');
await api('/api/batch_create',{method:'POST',body:{names:gv('b_names',w),users:gv('b_users',w),count:+gv('b_count',w)||1}});
toast('批量创建已在后台执行');w._close();
}catch(err){toast(err.message,'err');btn.disabled=false}
};
}
/* ================= 🆕 Telegram 文本格式工具条(点击即套用,无需手写标签) ================= */
/* Telegram Bot API 官方支持的全部 HTML 格式 */
const FMT_ITEMS=[
{k:'b', ic:'bold', ti:'加粗(Ctrl+B)', open:'<b>', close:'</b>'},
{k:'i', ic:'italic', ti:'斜体(Ctrl+I)', open:'<i>', close:'</i>'},
{k:'u', ic:'underline', ti:'下划线(Ctrl+U)', open:'<u>', close:'</u>'},
{k:'s', ic:'strikethrough', ti:'删除线', open:'<s>', close:'</s>'},
{k:'sp', ic:'eye-off', ti:'剧透遮罩(点击才显示)', open:'<tg-spoiler>', close:'</tg-spoiler>'},
{k:'a', ic:'link', gp:1, ti:'超链接(Ctrl+K):选中文字后点此,再粘贴网址', link:1},
{k:'mt', ic:'at-sign', ti:'提及用户:tg://user?id= 形式的内联链接', mention:1},
{k:'c', ic:'code', gp:1, ti:'行内等宽代码', open:'<code>', close:'</code>'},
{k:'pre',ic:'code-xml', ti:'代码块(光标停在 language- 后,可直接写 python 等语言)', open:'<pre><code class="language-">', close:'</code></pre>', blk:1, cur:-2},
{k:'q', ic:'text-quote', ti:'引用块', open:'<blockquote>', close:'</blockquote>', blk:1},
{k:'qe', ic:'chevrons-down-up',ti:'可折叠引用块', open:'<blockquote expandable>', close:'</blockquote>', blk:1},
{k:'clr',ic:'remove-formatting',gp:1,ti:'清除所选文字的全部格式', clr:1},
];
const FMT_TAGS=/<\/?(?:b|strong|i|em|u|ins|s|strike|del|a|code|pre|blockquote|tg-spoiler|tg-emoji|span)(?:\s[^>]*)?>/gi;
/* 对 textarea 的当前选区套用 / 取消一种格式 */
function fmtApply(ta,it){
if(!ta||!it)return;
const v=ta.value,s=ta.selectionStart,e=ta.selectionEnd,sel=v.slice(s,e);
// ins=插入内容, a/b=插入后要选中的相对区间
const put=(ins,a,b)=>{ta.value=v.slice(0,s)+ins+v.slice(e);ta.setSelectionRange(s+a,s+(b==null?a:b))};
if(it.clr){
const cl=sel.replace(FMT_TAGS,'');put(cl,0,cl.length);
}else if(it.link||it.mention){
const isUrl=/^(https?:\/\/|tg:\/\/)\S+$/i.test(sel.trim());
const href=it.mention?'tg://user?id=':(isUrl?sel.trim():'https://');
const txt=(isUrl||!sel)?(it.mention?'@某人':'链接文字'):sel;
const open=`<a href="${href}">`;
put(open+txt+'</a>',
isUrl?open.length:9, // '<a href="'.length === 9
isUrl?open.length+txt.length:9+href.length);// 选中网址占位符,直接粘贴覆盖
}else{
let pre='',suf='';
if(it.blk){ // 引用 / 代码块独占一行,效果才和 Telegram 一致
if(s>0&&v[s-1]!=='\n')pre='\n';
if(e<v.length&&v[e]!=='\n')suf='\n';
}
if(!sel){ // 没选中文字 → 插入一对标签,光标落在中间
const cur=pre.length+it.open.length+(it.cur||0);
put(pre+it.open+it.close+suf,cur);
}else if(sel.startsWith(it.open)&&sel.endsWith(it.close)){ // 已套用 → 再点一次取消
const inner=sel.slice(it.open.length,sel.length-it.close.length);
put(inner,0,inner.length);
}else{
put(pre+it.open+sel+it.close+suf,pre.length+it.open.length,pre.length+it.open.length+sel.length);
}
}
ta.focus();
ta.dispatchEvent(new Event('input',{bubbles:true})); // 触发实时预览刷新
}
function fmtBar(id){
return`<div class="fmt" data-fmt="${id}">${FMT_ITEMS.map((it,ix)=>
`<button type="button" class="${it.gp?'gp':''}" data-fi="${ix}" title="${esc(it.ti)}" aria-label="${esc(it.ti)}">${I(it.ic,13)}</button>`).join('')}</div>`;
}
/* 🆕 媒体附件字段:用 <label> 包住铺满的 file input,原生唤起选择器 */
const fldFile=(label,id,txt,hint='')=>`<div class="fld"><span class="fld-l">${label}</span>
<label class="file-drop" for="${id}">${I('paperclip',22)}<div>${esc(txt)}</div><input class="file-in" type="file" id="${id}"></label>
<div class="file-name" id="${id}_nm" style="display:none"></div>${hint?`<span class="fld-h">${hint}</span>`:''}</div>`;
function bindFile(root,id,onChange){
const f=$('#'+id,root),nm=$('#'+id+'_nm',root);
if(!f||!nm)return;
const clear=()=>{f.value='';nm.style.display='none';nm.innerHTML='';onChange&&onChange()};
f.addEventListener('change',()=>{
const x=f.files&&f.files[0];
if(!x){clear();return}
nm.style.display='flex';
nm.innerHTML=`${I('file-up',14)} <span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(x.name)}</span>
<span style="color:var(--dim);flex:none">${(x.size/1048576).toFixed(2)} MB</span>
<button class="fx" type="button" title="移除附件">${I('x',12)}</button>`;
icons(nm);$('.fx',nm).onclick=clear;
onChange&&onChange();
});
}
/* 带工具条的正文输入框 */
const fldFmt=(label,id,ph,rows=4,hint='')=>`<div class="fld"><span class="fld-l">${label}</span>${fmtBar(id)}<textarea class="inp" id="${id}" rows="${rows}" placeholder="${esc(ph)}"></textarea>${hint?`<span class="fmt-h">${hint}</span>`:''}</div>`;
function bindFmt(root,id){
const bar=$(`.fmt[data-fmt="${id}"]`,root),ta=$('#'+id,root);
if(!bar||!ta)return;
bar.addEventListener('mousedown',ev=>{if(ev.target.closest('button'))ev.preventDefault()}); // 保住 textarea 选区
bar.addEventListener('click',ev=>{const b=ev.target.closest('button[data-fi]');if(!b)return;
ev.preventDefault();fmtApply(ta,FMT_ITEMS[+b.dataset.fi])});
ta.addEventListener('keydown',ev=>{ // 快捷键
if(!(ev.ctrlKey||ev.metaKey)||ev.altKey)return;
const k={b:'b',i:'i',u:'u',k:'a'}[(ev.key||'').toLowerCase()];if(!k)return;
const it=FMT_ITEMS.find(x=>x.k===k);if(!it)return;
ev.preventDefault();fmtApply(ta,it);
});
}
/* ================= 🆕 实时预览(Telegram 消息外观) ================= */
const PV_ALLOWED=['b','strong','i','em','u','ins','s','strike','del','a','code','pre','blockquote','br','span','tg-spoiler','tg-emoji'];
/* 把用户写的 HTML 安全地渲染成预览:只放行 Telegram 支持的标签,其余转义显示 */
function pvText(raw){
const src=String(raw??'');
if(!src.trim())return'';
const bad=[];
// 先整体转义,再把白名单标签还原,杜绝脚本注入
// 属性里的引号已被转义成 ",所以属性段要允许 & 出现,只在遇到 > 时收尾
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();
};
}
/* ================= 07 ops ================= */
function vOps(m){
const mons=(S.data&&S.data.member_monitors)||[];
m.innerHTML=vh('08','运维','OPERATIONS','备份、批量处理与成员监控等高阶工具。操作在后台异步执行。')+
`<div class="ops-grid">
<div>
<div class="ops-c" data-rv>
<div class="cmp-t">${I('hard-drive-download')} 智能全量备份</div>
<div class="cmp-s">双引擎扫描源频道历史,增量复制到目标</div>
${fld('源频道',picker('b_src',S.data.address_book,''))}
<div class="sec-t">目标频道(可多选)</div>
<div class="pk" id="b_tgts" style="margin-bottom:18px">
${Object.entries(S.data.address_book||{}).map(([cid,nm])=>`<span class="chip pk-c" data-cid="${esc(cid)}">${esc(nm)}</span>`).join('')||'<span class="fld-h">地址簿为空</span>'}
</div>
${fld('最新消息链接',inp('b_link','','粘贴源频道最新一条消息的链接'))}
<div class="sw-row"><div><div class="t">洗 MD5 模式</div><div class="s">重写文件指纹,视频自动补缩略图</div></div><button class="sw" id="b_wash" type="button"></button></div>
<button class="btn btn-acc" id="b_go" style="width:100%;justify-content:center">${I('zap',15)} 启动备份</button>
</div>
<div class="ops-c" data-rv>
<div class="cmp-t">${I('replace')} 批量替换标签</div>
<div class="cmp-s">遍历频道历史消息,替换或删除指定标签</div>
${fld('处理频道',picker('r_ch',S.data.address_book,''))}
<div class="frm-grid">
${fld('旧标签',inp('r_old','','#旧标签'))}
${fld('新标签',inp('r_new','','留空即删除'))}
</div>
<button class="btn" id="r_go" style="width:100%;justify-content:center">${I('replace',15)} 开始替换</button>
</div>
<div class="ops-c" data-rv>
<div class="cmp-t">${I('scan-line')} 手动生成目录</div>
<div class="cmp-s">立即扫描频道全部标签并回传目录</div>
${fld('扫描频道',picker('g_ch',S.data.address_book,''))}
<button class="btn" id="g_go" style="width:100%;justify-content:center">${I('folder-output',15)} 生成目录</button>
</div>
</div>
<div>
<div class="ops-c" data-rv>
<div class="cmp-t">${I('users')} 成员监控 <span class="chip" style="color:var(--grn);border-color:rgba(93,255,160,.35);margin-left:6px">${I('radio',10)} 实时</span></div>
<div class="cmp-s">有人进就立刻记一笔并备份 · 名单只增不删,每个频道各记各的</div>
<div id="monList">
${mons.length?mons.map(mm=>`<div class="mon-row">
<div><div style="font-weight:600">${esc(S.data.address_book?.[mm.channel_id]||'未登记频道')}</div>
<div class="mono" style="color:var(--dim)">${esc(shortId(mm.channel_id))} · 兜底对账每 ${mm.interval} 分钟 · ${mm.first_scan_at?(mm.rt_at?('实时 '+fmtT(mm.rt_at)):'等实时事件'):'待首次建档'}</div></div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span class="chip" title="累计记录过的人">${I('users',11)} ${(mm.member_count||0).toLocaleString()}</span>
${mm.in_count?`<span class="chip" style="color:var(--grn);border-color:rgba(93,255,160,.35)" title="当前在册">${I('user-check',11)} ${mm.in_count.toLocaleString()}</span>`:''}
${mm.left_total?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.3)" title="已退出">${I('user-minus',11)} ${mm.left_total.toLocaleString()}</span>`:''}
${mm.last_error?`<span class="chip" style="color:var(--red);border-color:rgba(255,93,93,.35)">${I('triangle-alert',11)} 异常</span>`:''}
<button class="ic-b" data-mv="${esc(mm.channel_id)}" title="查看名单与动态">${I('list',14)}</button>
<button class="ic-b" data-sc="${esc(mm.channel_id)}" title="立即对账">${I('refresh-cw',14)}</button>
<button class="ic-b" data-dl="${esc(mm.channel_id)}" title="下载备份">${I('download',14)}</button>
<button class="ic-b" data-ed="${esc(mm.channel_id)}" title="修改间隔">${I('pencil',14)}</button>
<button class="ic-b danger" data-de="${esc(mm.channel_id)}" title="删除">${I('trash-2',14)}</button>
</div></div>`).join(''):'<div class="fld-h" style="padding:14px 0">暂无监控任务</div>'}
</div>
<div class="sec-t">新增监控</div>
${fld('频道',picker('m_ch',S.data.address_book,''))}
${fld('检测间隔(分钟)',inp('m_intv',60,'60','number','min="1"'),'实时事件为主,这个间隔只是兜底对账')}
<button class="btn btn-acc" id="m_add" style="width:100%;justify-content:center">${I('plus',15)} 添加监控</button>
</div>
<!-- 🆕 白名单:在这里随时增删,不用改环境变量 -->
<div class="ops-c" data-rv>
<div class="cmp-t">${I('shield-check')} 访问控制</div>
<div class="cmp-s">谁能进这个控制台、谁能用机器人指令</div>
<div id="aclBox"><div class="fld-h" style="padding:14px 0">读取中…</div></div>
<div id="aclAdd" data-form="${esc(`<div class="sec-t">放行新用户</div>`+fld('Telegram 用户 ID',inp('acl_uid','','例如 88888888'),'让对方把 @userinfobot 的回复截图发你,或在机器人里发 /admin add <ID>')+`<button class="btn btn-acc" id="acl_add" style="width:100%;justify-content:center">${I('user-plus',15)} 加入白名单</button>`)}"></div>
</div>
</div>
</div>
<div class="foot" data-rv><span>危险操作请二次确认</span><span>OPS MODULE</span></div>`;
bindPicker('b_src',m);bindPicker('r_ch',m);bindPicker('g_ch',m);bindPicker('m_ch',m);
$$('#b_tgts .pk-c',m).forEach(c=>c.onclick=()=>c.classList.toggle('on'));
$('#b_wash',m).onclick=e=>e.currentTarget.classList.toggle('on');
$('#b_go',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
const tgt=$$('#b_tgts .pk-c.on',m).map(c=>c.dataset.cid);
if(!gv('b_src',m)||!tgt.length||!gv('b_link',m))throw new Error('源频道、目标与消息链接必填');
await api('/api/backup',{method:'POST',body:{src:gv('b_src',m),tgt,link:gv('b_link',m),wash:$('#b_wash',m).classList.contains('on')}});
toast('备份任务已启动,完成后将推送通知');
}catch(err){toast(err.message,'err')}
btn.disabled=false;
};
$('#r_go',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
if(!gv('r_ch',m)||!gv('r_old',m))throw new Error('频道与旧标签必填');
await api('/api/replace_tag',{method:'POST',body:{ch_id:gv('r_ch',m),old_tag:gv('r_old',m),new_tag:gv('r_new',m)||'删除'}});
toast('批量替换已在后台执行');
}catch(err){toast(err.message,'err')}
btn.disabled=false;
};
$('#g_go',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
if(!gv('g_ch',m))throw new Error('请选择频道');
await api('/api/gen_dir',{method:'POST',body:{ch_id:gv('g_ch',m)}});toast('目录生成中,稍后将由机器人发送');
}catch(err){toast(err.message,'err')}
btn.disabled=false;
};
$('#m_add',m).onclick=async e=>{
const btn=e.currentTarget;btn.disabled=true;
try{
if(!gv('m_ch',m))throw new Error('请选择频道');
applyUser(await api('/api/member_monitors',{method:'POST',body:{ch_id:gv('m_ch',m),interval:+gv('m_intv',m)||60}}));
toast('监控任务已添加');go('ops');
}catch(err){toast(err.message,'err');btn.disabled=false}
};
$$('[data-dl]',m).forEach(b=>b.onclick=async()=>{
try{const j=await api('/api/member_monitors/'+encodeURIComponent(b.dataset.dl)+'/download',{method:'POST'});toast(j.msg||'备份文件将由机器人发送')}catch(e){toast(e.message,'err')}
});
$$('[data-ed]',m).forEach(b=>b.onclick=()=>{
const cid=b.dataset.ed;const mon=mons.find(x=>String(x.channel_id)===String(cid));
const w=sheet({title:'修改检测间隔',sub:`频道 ${shortId(cid)}`,
body:fld('间隔(分钟)',inp('m_nintv',mon?mon.interval:60,'60','number','min="1"')),
foot:`<button class="btn" data-a="no">取消</button><button class="btn btn-acc" data-a="ok">${I('check',14)} 保存</button>`});
icons();$('[data-a=no]',w).onclick=w._close;
$('[data-a=ok]',w).onclick=async()=>{
try{applyUser(await api('/api/member_monitors/'+encodeURIComponent(cid),{method:'PUT',body:{interval:+gv('m_nintv',w)||60}}));toast('间隔已更新');w._close();go('ops')}catch(e){toast(e.message,'err')}
};
});
$$('[data-sc]',m).forEach(b=>b.onclick=async()=>{
try{const j=await api('/api/member_monitors/'+encodeURIComponent(b.dataset.sc)+'/scan',{method:'POST'});toast(j.msg||'已排队对账')}catch(e){toast(e.message,'err')}
});
$$('[data-mv]',m).forEach(b=>b.onclick=()=>memSheet(b.dataset.mv));
$$('[data-de]',m).forEach(b=>b.onclick=()=>confirmSheet('删除该成员监控任务?频道台账与 WebDAV 备份都保留。',async()=>{
try{applyUser(await api('/api/member_monitors/'+encodeURIComponent(b.dataset.de),{method:'DELETE'}));toast('监控已删除');go('ops')}catch(e){toast(e.message,'err')}
}));
loadAcl(m);
}
/* 🆕 单频道成员台账:累计人数、实时动态、成员列表(各频道分开记) */
async function memSheet(cid){
const w=sheet({title:'成员台账',sub:esc(S.data.address_book?.[cid]||shortId(cid)),
body:'<div id="memBox"><div class="fld-h" style="padding:16px 0">读取中…</div></div>'});
const draw=async(q='')=>{
try{
const j=await api('/api/member_log/'+encodeURIComponent(cid)+(q?('?q='+encodeURIComponent(q)):''));
const st=j.stats||{},box=$('#memBox',w);
const tag=(t,v,c)=>`<div style="flex:1;min-width:86px;background:var(--card2,rgba(255,255,255,.03));border:1px solid var(--line);border-radius:12px;padding:9px 11px">
<div style="font-size:11px;color:var(--dim)">${t}</div><div style="font-size:18px;font-weight:700;${c?'color:'+c:''}">${(v||0).toLocaleString()}</div></div>`;
const when=ts=>ts?fmtT(ts):'—';
box.innerHTML=`<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px">
${tag('累计记录',st.total)}${tag('当前在册',st.in,'var(--grn)')}${tag('已退出',st.left,'var(--red)')}
</div>
<div class="fld-h" style="margin-bottom:12px">实时 ${when(j.rt_at)}${j.rt_src?'('+(j.rt_src==='bot'?'机器人':'userbot')+')':''} · 对账 ${when(j.scan_at)} · 备份 ${when(j.backup_at)}
${j.capped?`<br>Telegram 单次最多看到 ${j.cap} 人(本次看到 ${j.visible}),名单靠实时事件继续累加,不会把老成员挤掉`:''}</div>
<div class="sec-t">最近动态</div>
<div style="max-height:190px;overflow:auto">${(j.log||[]).length?(j.log||[]).slice(0,80).map(e=>`<div class="mon-row" style="padding:8px 0">
<div><span style="color:${e.a==='join'?'var(--grn)':'var(--red)'}">${e.a==='join'?'+':'-'}</span> ${esc(e.n||e.u)}
<span class="mono" style="color:var(--dim)">${e.un?'@'+esc(e.un):esc(e.u)}</span></div>
<div class="mono" style="color:var(--dim)">${fmtT(e.t)} · ${e.s==='scan'?'对账':(e.s==='bot'?'实时·机器人':'实时')}</div></div>`).join(''):'<div class="fld-h" style="padding:12px 0">还没有进出记录</div>'}</div>
<div class="sec-t">成员名单(最新 ${j.shown}/${j.matched}${j.matched>j.cap?',一次最多显示 '+j.cap:''})</div>
${inp('mem_q',q,'搜索名字 / @用户名 / ID')}
<div style="max-height:240px;overflow:auto;margin-top:10px">${(j.members||[]).length?(j.members||[]).map(u=>`<div class="mon-row" style="padding:8px 0">
<div>${esc(u.name||u.id)} <span class="mono" style="color:var(--dim)">${u.un?'@'+esc(u.un):esc(u.id)}</span>${u.bot?' <span class="chip">BOT</span>':''}</div>
<div class="mono" style="color:${u.left?'var(--red)':'var(--dim)'}">${u.left?'已退 '+fmtT(u.left):'进于 '+fmtT(u.t)}</div></div>`).join(''):'<div class="fld-h" style="padding:12px 0">没有匹配的成员</div>'}</div>`;
icons();
const qi=$('#mem_q',w);
if(qi){let tm;qi.oninput=()=>{clearTimeout(tm);tm=setTimeout(()=>{const v=qi.value.trim();draw(v).then(()=>{const n=$('#mem_q',w);if(n){n.focus();n.setSelectionRange(v.length,v.length)}})},350)}}
}catch(e){const box=$('#memBox',w);if(box)box.innerHTML=`<div class="fld-h" style="padding:16px 0">读取失败:${esc(e.message)}</div>`}
};
draw();
}
/* 🆕 白名单面板 */
async function loadAcl(m,retry=0){
try{
const j=await api('/api/admins');renderAcl(m,j);
/* 🆕 名字/头像是后台刷的:还缺就静默再取两次,不用退出面板再进 */
const miss=Object.values(j.profiles||{}).some(p=>!p.avatar||!p.name);
if(miss&&retry<2&&document.body.contains(m))setTimeout(()=>loadAcl(m,retry+1),retry?6000:2500);
}
catch(e){const b=$('#aclBox',m);if(b)b.innerHTML=`<div class="fld-h" style="padding:14px 0">读取失败:${esc(e.message)}</div>`}
}
function renderAcl(m,j){
const box=$('#aclBox',m);if(!box)return;
const me=String(j.me||meInfo().id||'');
const prof=j.profiles||{};
S.owner=!!j.owner; /* 🆕 只有主人能增删 */
const row=(id,owner)=>{
const p=prof[id]||{};
const nm=p.name||(p.username?'@'+p.username:'');
return`<div class="mon-row">
<div style="display:flex;align-items:center;gap:11px;min-width:0">
${ava(p.avatar,nm||id)}
<div style="min-width:0">
<div style="font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
${nm?esc(nm):'<span style="color:var(--dim)">未跟机器人说过话</span>'}${id===me?' <span class="chip">我</span>':''}</div>
<div class="mono" style="color:var(--dim)">${p.username?'@'+esc(p.username)+' · ':''}ID ${esc(id)} · ${owner?'主人':'成员'}</div>
</div></div>
<div style="display:flex;align-items:center;gap:8px">
${owner?`<span class="chip">${I('lock',11)} 固定</span>`
:(S.owner?`<button class="ic-b danger" data-acl="${esc(id)}" title="移出白名单">${I('user-minus',14)}</button>`
:`<span class="chip">${I('eye',11)} 只读</span>`)}
</div></div>`};
const owners=(j.owners||[]).map(x=>row(String(x),true));
const members=(j.members||[]).map(x=>row(String(x),false));
box.innerHTML=(owners.concat(members).join(''))||'<div class="fld-h" style="padding:14px 0">未配置白名单:目前只有已建档用户能进</div>';
/* 🆕 非主人:隐藏「放行新用户」表单,只留说明 */
const addWrap=$('#aclAdd',m);
if(addWrap)addWrap.innerHTML=S.owner?addWrap.dataset.form:
`<div class="fld-h" style="padding:12px 0 2px">只有主人(环境变量 ADMIN_IDS 里的人)能增删白名单,你可以查看名单。</div>`;
icons();
if(S.owner)bindAclAdd(m);
$$('[data-acl]',box).forEach(b=>b.onclick=()=>confirmSheet(`把 ${b.dataset.acl} 移出白名单?他将立刻无法进入控制台与使用机器人。`,async()=>{
try{const r=await api('/api/admins',{method:'DELETE',body:{uid:b.dataset.acl}});toast(r.msg||'已移除');renderAcl(m,r)}catch(e){toast(e.message,'err')}
}));
}
function bindAclAdd(m){
const btn=$('#acl_add',m);if(!btn)return;
btn.onclick=async e=>{
const b=e.currentTarget;const uid=(gv('acl_uid',m)||'').replace(/\D/g,'');
if(!uid)return toast('请填写纯数字的用户 ID','err');
b.disabled=true;
try{const j=await api('/api/admins',{method:'POST',body:{uid}});toast(j.msg||'已加入白名单');$('#acl_uid',m).value='';renderAcl(m,j)}
catch(err){toast(err.message,'err')}
b.disabled=false;
};
}
/* ================= 🆕 头像组件(服务端缓存,换头像后 URL 带新指纹自动刷新) ================= */
const AVA_CSS=`.ava{width:34px;height:34px;border-radius:50%;object-fit:cover;flex:none;background:var(--panel2);
border:1px solid var(--line2);display:inline-flex;align-items:center;justify-content:center;
font:700 13px var(--fd);color:var(--dim);overflow:hidden}
.ava.sq{border-radius:11px}.ava.lg{width:44px;height:44px;font-size:16px}`;
function ava(url,name,cls=''){
const ini=esc(String(name||'?').trim().slice(0,1).toUpperCase());
return url?`<img class="ava ${cls}" src="${esc(url)}" alt="" loading="lazy" referrerpolicy="no-referrer"
data-ini="${ini}" data-cls="${esc(cls)}" onerror="avaErr(this)">`
:`<span class="ava ${cls}">${ini}</span>`;
}
/* 🆕 头像取失败先重试一次(服务端可能正在从 WebDAV 回源),真没有才退回首字母 */
function avaErr(img){
if(!img.dataset.retry){
img.dataset.retry='1';
const u=img.src.split('#')[0].replace(/[?&]r=\d+/,'');
setTimeout(()=>{img.src=u+(u.includes('?')?'&':'?')+'r='+Date.now()},1800);
return;
}
img.outerHTML=`<span class="ava ${img.dataset.cls||''}">${img.dataset.ini||'?'}</span>`;
}
/* ================= 🆕 08 我的资料(头像 + 名字) ================= */
function meInfo(){
const p=S.me||{};
const cache=S.meCache||{};
const first=p.first_name||(TGU&&TGU.first_name)||'';
const last=p.last_name||(TGU&&TGU.last_name)||'';
const uname=p.username||(TGU&&TGU.username)||'';
const photo=cache.avatar||p.photo_url||(TGU&&TGU.photo_url)||''; /* 🆕 先用服务端缓存(同源、命中浏览器缓存、秒开),TG 的 photo_url 慢且会过期 */
const id=p.id||(TGU&&TGU.id)||'';
const name=[first,last].filter(Boolean).join(' ')||cache.name||(uname?'@'+uname:(DEMO?'演示用户':'Telegram 用户'));
return{name,uname,photo,id,premium:!!(p.is_premium||(TGU&&TGU.is_premium))};
}
function renderMe(){
const box=$('#me');if(!box)return;
const u=meInfo();
const initials=esc((u.name||'U').trim().slice(0,1).toUpperCase());
/* 🆕 头像三级回退:服务端缓存(快)→ TG 的 photo_url(慢、会过期)→ 首字母 */
const cached=(S.meCache&&S.meCache.avatar)||'';
const srcs=[cached,u.photo].filter(Boolean);
box.innerHTML=`${srcs.length?`<img class="av" src="${esc(srcs[0])}" alt="" referrerpolicy="no-referrer">`:`<span class="av">${initials}</span>`}
<div class="mn"><b>${esc(u.name)}${u.premium?' ⭐':''}</b>
<span>${esc(u.uname?'@'+u.uname:(u.id?'ID '+u.id:'GUEST'))}</span></div>`;
const img=$('img.av',box);
if(img){let i=0;img.onerror=()=>{i++;if(srcs[i])img.src=srcs[i];else img.outerHTML=`<span class="av">${initials}</span>`}}
box.style.display='flex';
}
/* ================= 🆕 09 榜单完整名单(在 Telegram 内打开) ================= */
async function vRankList(m,key){
m.innerHTML=`<div class="rk"><div class="rk-h"><h1>完整名单</h1><p>加载中…</p></div></div>`;
let payload=null;
try{
if(DEMO){payload={title:'本周热评 TOP10(演示)',updated:'今天',items:
Array.from({length:24},(_,i)=>({r:i+1,n:['夜航星','雾都夜谈','白夜行','长安十二时辰','银河edge'][i%5]+'('+(i+1)+')',
c:180-i*6,l:'https://t.me/Nine_7Eleven/88?comment='+(500+i),d:i%7===0}))};}
else{const r=await fetch('/api/list/'+encodeURIComponent(key));payload=await r.json();
if(!payload||payload.ok===false)throw new Error(payload&&payload.msg||'名单不存在');}
}catch(e){
m.innerHTML=`<div class="rk">${emptyBox('list-x','名单不可用',esc(e.message||'请等待榜单下次刷新后重试'))}
<button class="btn" id="rkBack" style="margin:18px auto;display:flex">${I('arrow-left',14)} 返回控制台</button></div>`;
icons();$('#rkBack',m).onclick=()=>{S.list=null;go('overview')};return;
}
const items=payload.items||[];
m.innerHTML=`<div class="rk">
<div class="rk-h" data-rv><h1>${esc(payload.title||'完整名单')}</h1>
<p>更新于 ${esc(payload.updated||'—')} · 共 ${items.length} 人 · 点击跳转投票</p></div>
${items.map(it=>`<a class="rk-i" data-rv href="${esc(it.l||'#')}" ${it.l&&it.l!=='#'?'':'onclick="return false"'}>
<span class="r">#${it.r}</span><span class="n${it.d?' done':''}">${esc(it.n)}</span>
<span class="c">${it.c} 赞</span></a>`).join('')||emptyBox('inbox','暂无数据','榜单还没有收集到评论')}
<button class="btn" id="rkBack" style="margin:24px auto 0;display:flex">${I('layout-grid',14)} 打开控制台</button></div>`;
icons();$('#rkBack',m).onclick=()=>{S.list=null;buildNav();go('overview')};
if(payload.stale)toast('这份名单是旧版缓存,链接要等下一轮榜单刷新才补齐','warn');
// 名单里的链接交给 Telegram 打开
$$('.rk-i',m).forEach(a=>a.onclick=e=>{e.preventDefault();openTgLink(a.getAttribute('href'))});
}
/* 🆕 打开 t.me 链接:交给 Telegram 处理,不再弹「没跳过去」的兜底提示 */
function openTgLink(href){
if(!href||href==='#'){toast('这条评论没有可用的跳转链接(等下一轮榜单刷新)','err');return}
try{
if(TG&&TG.openTelegramLink&&/^https:\/\/t\.me\//i.test(href))TG.openTelegramLink(href);
else if(TG&&TG.openLink&&/^https?:/i.test(href))TG.openLink(href);
else{const w2=window.open(href,'_blank');if(!w2)window.location.href=href}
}catch(err){}
}
/* ================= events stream ================= */
const EV_MAX=50; /* 🆕 与后端一致:最多 50 条 */
function pushEv(type,data,time,ts){
const ev={type,data,time:time||new Date().toTimeString().slice(0,8),ts:ts||Math.floor(Date.now()/1000)};
/* 🆕 重连时后端可能重发同一条,按「时间+内容」去重 */
if(S.events.some(e=>e.time===ev.time&&e.data===ev.data&&e.type===ev.type))return;
S.events.unshift(ev);S.events=S.events.slice(0,EV_MAX);renderFeed();
}
/* 🆕 用服务端的持久化事件填充(含控制台关着时后台产生的事件) */
function seedEvents(list){
if(!Array.isArray(list)||!list.length)return;
S.events=list.slice(0,EV_MAX).map(e=>({type:e.type||'sys',data:e.data||'',time:e.time||'--:--:--',ts:e.ts||0}));
renderFeed();
}
function startEvents(){
if(DEMO){
pushEv('connected','演示模式已连接 · 事件为模拟数据');
const pool=[
['backup_done','备份完成 → 存档仓,新增 128 条'],
['dir_done','目录「资源标签目录」已推送至 2 个目标'],
['replace_done','替换完成,修改 46 条'],
['members_done','成员备份完成:1,284 人'],
['backup_done','备份完成 → 镜像站,新增 64 条'],
];let i=0;
setInterval(()=>{const[t,d]=pool[i++%pool.length];pushEv(t,d)},5200);
return;
}
try{
const es=new EventSource('/api/events?init_data='+encodeURIComponent(INIT));
es.onmessage=e=>{try{const ev=JSON.parse(e.data);pushEv(ev.type,ev.data,ev.time,ev.ts)}catch(_){}};
es.onerror=()=>{pushEv('err','事件流连接中断,自动重试中…')};
}catch(e){pushEv('err','事件流不可用')}
}
/* ================= fx ================= */
function bindFx(root){
if(!matchMedia('(pointer:fine)').matches)return;
$$('.btn',root).forEach(b=>{
b.onmousemove=e=>{const r=b.getBoundingClientRect();
b.style.transform=`translate(${(e.clientX-r.left-r.width/2)*.12}px,${(e.clientY-r.top-r.height/2)*.22}px)`};
b.onmouseleave=()=>b.style.transform='';
});
}
function cursorFx(){
if(!matchMedia('(pointer:fine)').matches)return;
const c=$('.cur'),r=$('.cur-ring');let x=innerWidth/2,y=innerHeight/2,rx=x,ry=y;
addEventListener('mousemove',e=>{x=e.clientX;y=e.clientY;c.style.left=x+'px';c.style.top=y+'px';
document.body.classList.toggle('cur-hov',!!e.target.closest('a,button,.chip,.tile,.itm,.pk-c,input,textarea,select,.file-drop'))});
(function loop(){rx+=(x-rx)*.14;ry+=(y-ry)*.14;r.style.left=rx+'px';r.style.top=ry+'px';requestAnimationFrame(loop)})();
}
function clockFx(){const el=$('#clock');setInterval(()=>el.textContent=new Date().toTimeString().slice(0,8),1000);el.textContent=new Date().toTimeString().slice(0,8)}
addEventListener('keydown',e=>{
if(e.key==='Escape')$$('.sh-wrap').forEach(w=>w._close&&w._close());
const n=+e.key;if(n>=1&&n<=7&&!e.target.closest('input,textarea'))go(Object.keys(VIEWS)[n-1]);
});
/* ================= boot ================= */
async function load(){
try{
const j=await api('/api/data');
S.data=j.user||{};S.meta={msg:j.msg_count||0,dav:j.webdav_url||'',bot:!!j.userbot,botName:j.bot_username||''};
S.me=j.me||(S.data&&S.data.profile)||null;
S.meCache=j.me_cache||null; /* 🆕 服务端缓存的名字与头像 */
S.chAva=j.ch_avatars||{}; /* 🆕 地址簿频道头像 */
seedEvents(j.events); /* 🆕 历史事件 */
setPill(true,DEMO?'DEMO MODE':'LIVE');
}catch(e){
// 🆕 401:不在白名单,直接上锁,不再降级展示界面
if(!DEMO&&e.status===401){S.denied=true;setPill(false,'NO ACCESS');return}
if(!DEMO){setPill(false,'OFFLINE');toast('数据加载失败:'+e.message,'err')}
S.data=clone(SEED);
}
S.ready=true;
}
function setPill(on,txt){const p=$('#pill');p.classList.toggle('off',!on);$('#pillT').textContent=txt}
/* 🆕 手机键盘守卫:正在输入时把底部导航沉下去,不让它跟着键盘上移;界面尺寸不做任何缩放 */
const EDITABLE='input:not([type=file]):not([type=checkbox]):not([type=radio]),textarea,select,[contenteditable=""],[contenteditable=true]';
function kbGuard(){
const editing=()=>{const a=document.activeElement;return !!a&&a.matches&&a.matches(EDITABLE)};
const sync=()=>document.body.classList.toggle('kb',editing());
document.addEventListener('focusin',sync);
document.addEventListener('focusout',()=>setTimeout(sync,60));
// 视觉视口变化(键盘开合、旋屏)后再校准一次
if(window.visualViewport)window.visualViewport.addEventListener('resize',()=>setTimeout(sync,60));
window.addEventListener('orientationchange',()=>setTimeout(sync,400));
}
async function boot(){
if(TG){try{TG.ready();TG.expand();TG.setHeaderColor&&TG.setHeaderColor('#070709');TG.setBackgroundColor&&TG.setBackgroundColor('#070709')}catch(e){}}
kbGuard();cursorFx();clockFx();
$('#bootBar').style.transition='transform 1.1s var(--ease)';
requestAnimationFrame(()=>$('#bootBar').style.transform='scaleX(1)');
await load();renderMe();
if(S.denied){ // 🆕 非授权用户看到的唯一界面
$('#nav').innerHTML='';
$('#view').innerHTML=`<div class="rk" style="padding-top:12vh">${emptyBox('lock','无访问权限',
'这个控制台仅限管理员使用。如果你是来看榜单的,请回到频道点击「完整名单」。')}</div>`;
$('#view').classList.add('ready');icons();
await delay(400);$('#boot').classList.add('out');setTimeout(()=>$('#boot').remove(),900);
return;
}
// 🆕 由榜单「完整名单」链接进来(startapp=list_xxx):直接在 Telegram 内渲染名单
const lk=String(START_PARAM||'');
if(lk.startsWith('list_')||lk.startsWith('list-')){
S.list=lk.slice(5);$('#nav').innerHTML='';
const mv=$('#view');mv.classList.remove('ready');
await vRankList(mv,S.list);
requestAnimationFrame(()=>{mv.classList.add('ready');icons();stagger(mv)});
}else{buildNav();go('overview')}
startEvents();
await delay(650);$('#boot').classList.add('out');
setTimeout(()=>$('#boot').remove(),900);
}
boot();
</script>
</body>
</html>
|