fresh-mint-portal-home-demo.html 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8" />
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  6. <title>Fresh Mint · 首页菜单目录 Demo(找得到)</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com" />
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  9. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
  10. <style>
  11. :root {
  12. --primary: #10b981;
  13. --primary-dark: #059669;
  14. --primary-bg: #ecfdf5;
  15. --text: #1e293b;
  16. --text-muted: #64748b;
  17. --border: #d9e8e2;
  18. --surface: #ffffff;
  19. --bg-page: #f8fafc;
  20. --shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
  21. --radius: 10px;
  22. --warn: #d97706;
  23. --row-hover: #ecfdf5;
  24. --hit: #065f46;
  25. }
  26. * { box-sizing: border-box; }
  27. body {
  28. margin: 0;
  29. min-height: 100vh;
  30. font-family: Inter, -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
  31. font-size: 14px;
  32. line-height: 1.5;
  33. color: var(--text);
  34. background: var(--bg-page);
  35. }
  36. button, input { font-family: inherit; }
  37. .page { max-width: 1080px; margin: 0 auto; padding: 24px 16px 72px; }
  38. .intro { margin-bottom: 16px; }
  39. .intro h1 {
  40. margin: 0 0 6px;
  41. font-size: 20px;
  42. font-weight: 700;
  43. }
  44. .intro h1 span { color: var(--primary); }
  45. .intro p {
  46. margin: 0;
  47. font-size: 13px;
  48. color: var(--text-muted);
  49. max-width: 760px;
  50. }
  51. .intro ul {
  52. margin: 10px 0 0;
  53. padding: 10px 12px 10px 28px;
  54. background: #fff;
  55. border: 1px solid var(--border);
  56. border-radius: 8px;
  57. font-size: 12px;
  58. color: var(--text-muted);
  59. }
  60. .intro li { margin: 3px 0; }
  61. .intro b { color: var(--text); font-weight: 600; }
  62. /* 工作区 */
  63. .shell {
  64. border: 1px solid var(--border);
  65. border-radius: 12px;
  66. background: #fff;
  67. box-shadow: var(--shadow);
  68. overflow: hidden;
  69. display: grid;
  70. grid-template-rows: auto 1fr;
  71. min-height: 620px;
  72. max-height: calc(100vh - 140px);
  73. }
  74. .search-bar {
  75. display: flex;
  76. align-items: center;
  77. gap: 12px;
  78. padding: 14px 16px;
  79. border-bottom: 1px solid var(--border);
  80. background: linear-gradient(180deg, #ecfdf5 0%, #fff 85%);
  81. }
  82. .search-wrap {
  83. position: relative;
  84. flex: 1;
  85. }
  86. .search-wrap svg {
  87. position: absolute;
  88. left: 12px;
  89. top: 50%;
  90. transform: translateY(-50%);
  91. width: 18px;
  92. height: 18px;
  93. color: var(--text-muted);
  94. pointer-events: none;
  95. }
  96. .search-wrap input {
  97. width: 100%;
  98. height: 42px;
  99. padding: 0 96px 0 40px;
  100. border: 1px solid var(--border);
  101. border-radius: 8px;
  102. font-size: 15px;
  103. font-weight: 500;
  104. outline: none;
  105. background: #fff;
  106. }
  107. .search-wrap input:focus {
  108. border-color: var(--primary);
  109. box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
  110. }
  111. .search-wrap .hint {
  112. position: absolute;
  113. right: 10px;
  114. top: 50%;
  115. transform: translateY(-50%);
  116. font-size: 11px;
  117. color: #94a3b8;
  118. pointer-events: none;
  119. }
  120. .search-meta {
  121. flex-shrink: 0;
  122. font-size: 12px;
  123. color: var(--text-muted);
  124. white-space: nowrap;
  125. }
  126. .search-meta b {
  127. color: var(--primary-dark);
  128. font-variant-numeric: tabular-nums;
  129. }
  130. .body {
  131. display: grid;
  132. grid-template-columns: 168px 1fr 36px;
  133. min-height: 0;
  134. }
  135. @media (max-width: 800px) {
  136. .body { grid-template-columns: 1fr; }
  137. .index, .letters { display: none !important; }
  138. }
  139. /* 左侧:模块索引(只负责跳转/过滤,不铺卡片) */
  140. .index {
  141. border-right: 1px solid var(--border);
  142. background: #f8fafc;
  143. overflow: auto;
  144. padding: 8px 6px;
  145. }
  146. .index-item {
  147. display: flex;
  148. align-items: center;
  149. justify-content: space-between;
  150. gap: 6px;
  151. width: 100%;
  152. padding: 8px 10px;
  153. margin: 1px 0;
  154. border: 0;
  155. border-radius: 8px;
  156. background: transparent;
  157. color: var(--text);
  158. font-size: 13px;
  159. font-weight: 500;
  160. text-align: left;
  161. cursor: pointer;
  162. }
  163. .index-item:hover { background: #eef2f7; }
  164. .index-item.is-on {
  165. background: var(--primary-bg);
  166. color: var(--primary-dark);
  167. font-weight: 700;
  168. }
  169. .index-item .n {
  170. font-size: 11px;
  171. font-weight: 600;
  172. color: #94a3b8;
  173. font-variant-numeric: tabular-nums;
  174. }
  175. .index-item.is-on .n { color: var(--primary-dark); }
  176. /* 中间:可扫读的菜单目录 */
  177. .dir {
  178. overflow: auto;
  179. min-height: 0;
  180. padding: 0 0 16px;
  181. scroll-behavior: smooth;
  182. }
  183. .fav-box {
  184. margin: 12px 14px 8px;
  185. padding: 10px 12px;
  186. border: 1px solid #bbf7d0;
  187. border-radius: 8px;
  188. background: #f0fdf4;
  189. }
  190. .fav-box.is-hidden { display: none; }
  191. .fav-box__title {
  192. margin: 0 0 8px;
  193. font-size: 12px;
  194. font-weight: 700;
  195. color: var(--primary-dark);
  196. }
  197. .fav-links {
  198. display: flex;
  199. flex-wrap: wrap;
  200. gap: 6px 14px;
  201. }
  202. .fav-links a {
  203. font-size: 13px;
  204. font-weight: 600;
  205. color: var(--text);
  206. text-decoration: none;
  207. border-bottom: 1px dashed transparent;
  208. }
  209. .fav-links a:hover {
  210. color: var(--primary-dark);
  211. border-bottom-color: var(--primary);
  212. }
  213. .group {
  214. padding: 4px 0 2px;
  215. scroll-margin-top: 8px;
  216. }
  217. .group.is-hidden { display: none; }
  218. .group__head {
  219. position: sticky;
  220. top: 0;
  221. z-index: 2;
  222. display: flex;
  223. align-items: center;
  224. gap: 8px;
  225. padding: 8px 14px;
  226. background: rgba(255, 255, 255, 0.96);
  227. border-bottom: 1px solid #eef2f0;
  228. backdrop-filter: none;
  229. }
  230. .group__head h3 {
  231. margin: 0;
  232. font-size: 13px;
  233. font-weight: 700;
  234. }
  235. .group__head span {
  236. font-size: 11px;
  237. color: var(--text-muted);
  238. font-weight: 500;
  239. }
  240. /* 关键:多列文字链接,一眼扫名称 */
  241. .menu-cols {
  242. display: grid;
  243. grid-template-columns: repeat(3, minmax(0, 1fr));
  244. gap: 0 8px;
  245. padding: 4px 10px 10px;
  246. }
  247. @media (max-width: 960px) {
  248. .menu-cols { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  249. }
  250. @media (max-width: 560px) {
  251. .menu-cols { grid-template-columns: 1fr; }
  252. }
  253. .menu-row {
  254. display: grid;
  255. grid-template-columns: 1fr auto;
  256. align-items: center;
  257. gap: 4px;
  258. min-height: 34px;
  259. padding: 4px 8px;
  260. border-radius: 6px;
  261. cursor: pointer;
  262. border: 0;
  263. background: transparent;
  264. text-align: left;
  265. width: 100%;
  266. color: inherit;
  267. }
  268. .menu-row:hover,
  269. .menu-row.is-active {
  270. background: var(--row-hover);
  271. }
  272. .menu-row.is-hidden { display: none; }
  273. .menu-row__name {
  274. font-size: 13px;
  275. font-weight: 600;
  276. white-space: nowrap;
  277. overflow: hidden;
  278. text-overflow: ellipsis;
  279. }
  280. .menu-row__name mark {
  281. background: #a7f3d0;
  282. color: var(--hit);
  283. border-radius: 2px;
  284. padding: 0 1px;
  285. font-weight: 700;
  286. }
  287. .menu-row__path {
  288. display: none;
  289. font-size: 11px;
  290. color: var(--text-muted);
  291. margin-top: 1px;
  292. white-space: nowrap;
  293. overflow: hidden;
  294. text-overflow: ellipsis;
  295. }
  296. /* 搜索时显示路径,帮助消歧 */
  297. .dir.is-filtering .menu-row__path { display: block; }
  298. .dir.is-filtering .menu-cols {
  299. grid-template-columns: 1fr;
  300. gap: 0;
  301. padding: 4px 10px 12px;
  302. }
  303. .dir.is-filtering .menu-row {
  304. grid-template-columns: 1fr 28px;
  305. min-height: 40px;
  306. padding: 6px 10px;
  307. border-bottom: 1px solid #f1f5f9;
  308. border-radius: 0;
  309. }
  310. .menu-row__star {
  311. width: 24px;
  312. height: 24px;
  313. border: 0;
  314. border-radius: 6px;
  315. background: transparent;
  316. color: #cbd5e1;
  317. cursor: pointer;
  318. display: grid;
  319. place-items: center;
  320. padding: 0;
  321. opacity: 0;
  322. flex-shrink: 0;
  323. }
  324. .menu-row:hover .menu-row__star,
  325. .menu-row__star.is-on { opacity: 1; }
  326. .menu-row__star.is-on { color: var(--warn); }
  327. .menu-row__star:hover { background: #fff7ed; color: var(--warn); }
  328. .empty {
  329. display: none;
  330. padding: 48px 20px;
  331. text-align: center;
  332. color: var(--text-muted);
  333. font-size: 13px;
  334. }
  335. .empty.show { display: block; }
  336. .empty code {
  337. padding: 1px 6px;
  338. border-radius: 4px;
  339. background: #f1f5f9;
  340. color: var(--text);
  341. }
  342. /* 右侧:拼音首字母快速跳转 */
  343. .letters {
  344. border-left: 1px solid var(--border);
  345. background: #f8fafc;
  346. display: flex;
  347. flex-direction: column;
  348. align-items: center;
  349. padding: 8px 0;
  350. gap: 1px;
  351. overflow: auto;
  352. }
  353. .letters button {
  354. width: 28px;
  355. height: 20px;
  356. border: 0;
  357. border-radius: 4px;
  358. background: transparent;
  359. color: var(--text-muted);
  360. font-size: 11px;
  361. font-weight: 700;
  362. cursor: pointer;
  363. padding: 0;
  364. }
  365. .letters button:hover:not(:disabled) {
  366. background: var(--primary-bg);
  367. color: var(--primary-dark);
  368. }
  369. .letters button:disabled {
  370. opacity: 0.25;
  371. cursor: default;
  372. }
  373. .letters button.is-on {
  374. background: var(--primary);
  375. color: #fff;
  376. }
  377. .toast {
  378. position: fixed;
  379. left: 50%;
  380. bottom: 24px;
  381. transform: translateX(-50%) translateY(12px);
  382. opacity: 0;
  383. padding: 8px 14px;
  384. border-radius: 8px;
  385. background: #1e293b;
  386. color: #fff;
  387. font-size: 13px;
  388. transition: .18s ease;
  389. pointer-events: none;
  390. z-index: 30;
  391. }
  392. .toast.show {
  393. opacity: 1;
  394. transform: translateX(-50%) translateY(0);
  395. }
  396. </style>
  397. </head>
  398. <body>
  399. <div class="page">
  400. <header class="intro">
  401. <h1>首页 · <span>菜单目录</span>(以「找得到」为目标)</h1>
  402. <p>不做卡片墙、不做整行模块区。首页就是一张可搜索的菜单目录:左边点模块,中间扫名称,右边按拼音首字母跳转;搜索即时过滤并高亮匹配字。</p>
  403. <ul>
  404. <li><b>记得名字</b> → 直接输入(支持中文 / 拼音 / 首字母,如 <code>ht</code>、<code>hetong</code>、<code>合同</code>)</li>
  405. <li><b>记得模块</b> → 左侧点模块,中间只看该模块</li>
  406. <li><b>按字母找</b> → 右侧 A–Z 跳到对应首字母分组</li>
  407. <li><b>常用</b> → 顶部收藏链接,一键直达</li>
  408. </ul>
  409. </header>
  410. <div class="shell">
  411. <div class="search-bar">
  412. <div class="search-wrap">
  413. <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
  414. <circle cx="11" cy="11" r="7" /><path d="m20 20-3.5-3.5" />
  415. </svg>
  416. <input id="q" type="search" placeholder="输入菜单名 / 拼音 / 首字母,回车打开第一条" autocomplete="off" autofocus />
  417. <span class="hint">Enter 打开 · Esc 清空</span>
  418. </div>
  419. <div class="search-meta">匹配 <b id="hit-n">0</b> / <span id="total-n">0</span></div>
  420. </div>
  421. <div class="body">
  422. <aside class="index" id="index" aria-label="模块"></aside>
  423. <main class="dir" id="dir">
  424. <div class="fav-box" id="fav-box">
  425. <div class="fav-box__title">常用</div>
  426. <div class="fav-links" id="fav-links"></div>
  427. </div>
  428. <div id="groups"></div>
  429. <div class="empty" id="empty">没有匹配 <code id="empty-q"></code> 的菜单。可换拼音首字母试试,如 <code>ht</code></div>
  430. </main>
  431. <aside class="letters" id="letters" aria-label="拼音首字母"></aside>
  432. </div>
  433. </div>
  434. </div>
  435. <div class="toast" id="toast"></div>
  436. <script>
  437. /**
  438. * 目标:最快找到菜单。
  439. * - 即时过滤(不切页面)
  440. * - 密排文字目录(比图标墙更好扫读)
  441. * - 拼音 / 首字母(演示用内置字段)
  442. * - 左侧模块 + 右侧字母双索引
  443. */
  444. const MODULES = [
  445. {
  446. id: 'crm', title: 'CRM 管理',
  447. items: [
  448. { name: '客户档案', py: 'kehu dang\'an', ab: 'khda' },
  449. { name: '联系人', py: 'lianxiren', ab: 'lxr' },
  450. { name: '商机跟进', py: 'shangji genjin', ab: 'sjgj' },
  451. { name: '合同管理', py: 'hetong guanli', ab: 'htgl' },
  452. { name: '回款记录', py: 'huikuan jilu', ab: 'hkjl' },
  453. { name: '报价单', py: 'baojia dan', ab: 'bjd' },
  454. { name: '拜访计划', py: 'baifang jihua', ab: 'bfjh' },
  455. { name: '公海池', py: 'gonghai chi', ab: 'ghc' }
  456. ]
  457. },
  458. {
  459. id: 'pl', title: '计划物流',
  460. items: [
  461. { name: '运输计划', py: 'yunshu jihua', ab: 'ysjh' },
  462. { name: '车辆调度', py: 'cheliang diaodu', ab: 'cldd' },
  463. { name: '人员排班', py: 'renyuan paiban', ab: 'rypb' },
  464. { name: '运力看板', py: 'yunli kanban', ab: 'ylkb' },
  465. { name: '线路管理', py: 'xianlu guanli', ab: 'xlgl' },
  466. { name: '运费结算', py: 'yunfei jiesuan', ab: 'yfjs' },
  467. { name: '异常反馈', py: 'yichang fankui', ab: 'ycfk' }
  468. ]
  469. },
  470. {
  471. id: 'delivery', title: '配送业务',
  472. items: [
  473. { name: '配送订单', py: 'peisong dingdan', ab: 'psdd' },
  474. { name: '配送任务', py: 'peisong renwu', ab: 'psrw' },
  475. { name: '签收管理', py: 'qianshou guanli', ab: 'qsgl' },
  476. { name: '配送员', py: 'peisong yuan', ab: 'psy' },
  477. { name: '站点配置', py: 'zhandian peizhi', ab: 'zdpz' }
  478. ]
  479. },
  480. {
  481. id: 'weigh', title: '地磅协同',
  482. items: [
  483. { name: '过磅记录', py: 'guobang jilu', ab: 'gbjl' },
  484. { name: '车辆登记', py: 'cheliang dengji', ab: 'cldj' },
  485. { name: '物资对照', py: 'wuzi duizhao', ab: 'wzd z' },
  486. { name: '异常过磅', py: 'yichang guobang', ab: 'ycgb' }
  487. ]
  488. },
  489. {
  490. id: 'product', title: '商品中心',
  491. items: [
  492. { name: '商品列表', py: 'shangpin liebiao', ab: 'splb' },
  493. { name: '分类管理', py: 'fenlei guanli', ab: 'flgl' },
  494. { name: '计量单位', py: 'jiliang danwei', ab: 'jldw' },
  495. { name: '价格策略', py: 'jiage celue', ab: 'jgcl' },
  496. { name: '库存概览', py: 'kucun gailan', ab: 'kcgl' }
  497. ]
  498. },
  499. {
  500. id: 'order', title: '订单中心',
  501. items: [
  502. { name: '销售订单', py: 'xiaoshou dingdan', ab: 'xsdd' },
  503. { name: '采购订单', py: 'caigou dingdan', ab: 'cgdd' },
  504. { name: '退货单', py: 'tuihuo dan', ab: 'thd' },
  505. { name: '对账单', py: 'duizhang dan', ab: 'dzd' }
  506. ]
  507. },
  508. {
  509. id: 'supplier', title: '供应商',
  510. items: [
  511. { name: '供应商档案', py: 'gongyingshang dang\'an', ab: 'gysda' },
  512. { name: '资质审核', py: 'zizhi shenhe', ab: 'zzsh' },
  513. { name: '供货协议', py: 'gonghuo xieyi', ab: 'ghxy' },
  514. { name: '评价记录', py: 'pingjia jilu', ab: 'pjjl' }
  515. ]
  516. },
  517. {
  518. id: 'system', title: '系统管理',
  519. items: [
  520. { name: '用户管理', py: 'yonghu guanli', ab: 'yhgl' },
  521. { name: '角色权限', py: 'juese quanxian', ab: 'jsqx' },
  522. { name: '部门管理', py: 'bumen guanli', ab: 'bmgl' },
  523. { name: '租户管理', py: 'zuhu guanli', ab: 'zhgl' },
  524. { name: '菜单管理', py: 'caidan guanli', ab: 'cdgl' },
  525. { name: '字典配置', py: 'zidian peizhi', ab: 'zdpz' },
  526. { name: '操作日志', py: 'caozuo rizhi', ab: 'czrz' }
  527. ]
  528. },
  529. {
  530. id: 'demo', title: 'Demo',
  531. items: [
  532. { name: '标准列表', py: 'biaozhun liebiao', ab: 'bzlb' },
  533. { name: '复杂列表', py: 'fuza liebiao', ab: 'fzlb' },
  534. { name: '商品列表', py: 'shangpin liebiao', ab: 'splb' },
  535. { name: '树形主数据', py: 'shuxing zhushuju', ab: 'sxzsj' },
  536. { name: '系统用户', py: 'xitong yonghu', ab: 'xtyh' }
  537. ]
  538. }
  539. ]
  540. // 修正笔误
  541. MODULES.find((m) => m.id === 'weigh').items[2].ab = 'wzdz'
  542. const FLAT = MODULES.flatMap((m) =>
  543. m.items.map((it) => ({
  544. ...it,
  545. moduleId: m.id,
  546. moduleTitle: m.title,
  547. letter: (it.py.replace(/^[^a-z]*/i, '')[0] || '#').toUpperCase(),
  548. path: m.title
  549. }))
  550. )
  551. let pins = ['合同管理', '运输计划', '配送订单', '用户管理']
  552. let activeModule = 'all'
  553. let activeLetter = ''
  554. let activeName = ''
  555. const starSvg = (on) =>
  556. on
  557. ? '<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M12 17.27 18.18 21l-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z"/></svg>'
  558. : '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m12 17.27 6.18 3.73-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27z"/></svg>'
  559. function toast(msg) {
  560. const el = document.getElementById('toast')
  561. el.textContent = msg
  562. el.classList.add('show')
  563. clearTimeout(toast._t)
  564. toast._t = setTimeout(() => el.classList.remove('show'), 1100)
  565. }
  566. function normalize(s) {
  567. return String(s || '')
  568. .toLowerCase()
  569. .replace(/['\s]/g, '')
  570. }
  571. function matchItem(it, q) {
  572. if (!q) return true
  573. const nq = normalize(q)
  574. return (
  575. it.name.includes(q) ||
  576. normalize(it.py).includes(nq) ||
  577. normalize(it.ab).includes(nq) ||
  578. normalize(it.moduleTitle).includes(nq)
  579. )
  580. }
  581. function highlight(name, q) {
  582. if (!q || !name.includes(q)) return name
  583. return name.split(q).join('<mark>' + q + '</mark>')
  584. }
  585. function openMenu(name) {
  586. activeName = name
  587. document.querySelectorAll('.menu-row').forEach((el) => {
  588. el.classList.toggle('is-active', el.getAttribute('data-name') === name)
  589. })
  590. toast('打开「' + name + '」')
  591. }
  592. function togglePin(name) {
  593. if (pins.includes(name)) pins = pins.filter((x) => x !== name)
  594. else pins = [name, ...pins]
  595. renderFav()
  596. // 更新星标状态
  597. document.querySelectorAll('.menu-row__star').forEach((btn) => {
  598. const n = btn.getAttribute('data-star')
  599. const on = pins.includes(n)
  600. btn.classList.toggle('is-on', on)
  601. btn.innerHTML = starSvg(on)
  602. })
  603. toast(pins.includes(name) ? '已加入常用' : '已取消常用')
  604. }
  605. function renderFav() {
  606. const box = document.getElementById('fav-box')
  607. const root = document.getElementById('fav-links')
  608. const q = document.getElementById('q').value.trim()
  609. const list = pins.filter((n) => {
  610. const it = FLAT.find((x) => x.name === n)
  611. return it && matchItem(it, q) && (activeModule === 'all' || it.moduleId === activeModule)
  612. })
  613. box.classList.toggle('is-hidden', !list.length && !pins.length)
  614. if (!pins.length) {
  615. root.innerHTML = '<span style="font-size:12px;color:#94a3b8">点击菜单右侧星标加入常用</span>'
  616. return
  617. }
  618. if (!list.length) {
  619. box.classList.add('is-hidden')
  620. return
  621. }
  622. box.classList.remove('is-hidden')
  623. root.innerHTML = list
  624. .map((n) => '<a href="#" data-open="' + n + '">' + n + '</a>')
  625. .join('')
  626. root.querySelectorAll('[data-open]').forEach((a) => {
  627. a.addEventListener('click', (e) => {
  628. e.preventDefault()
  629. openMenu(a.getAttribute('data-open'))
  630. })
  631. })
  632. }
  633. function renderIndex(counts) {
  634. const root = document.getElementById('index')
  635. const allCount = Object.values(counts).reduce((a, b) => a + b, 0)
  636. const rows = [{ id: 'all', title: '全部菜单', n: allCount }].concat(
  637. MODULES.map((m) => ({ id: m.id, title: m.title, n: counts[m.id] || 0 }))
  638. )
  639. root.innerHTML = rows
  640. .map((r) => {
  641. if (r.id !== 'all' && r.n === 0 && document.getElementById('q').value.trim()) {
  642. return ''
  643. }
  644. return (
  645. '<button type="button" class="index-item' +
  646. (activeModule === r.id ? ' is-on' : '') +
  647. '" data-mod="' +
  648. r.id +
  649. '"><span>' +
  650. r.title +
  651. '</span><span class="n">' +
  652. r.n +
  653. '</span></button>'
  654. )
  655. })
  656. .join('')
  657. root.querySelectorAll('[data-mod]').forEach((btn) => {
  658. btn.addEventListener('click', () => {
  659. activeModule = btn.getAttribute('data-mod')
  660. activeLetter = ''
  661. applyFilter({ scrollMod: activeModule === 'all' ? null : activeModule })
  662. })
  663. })
  664. }
  665. function renderLetters(available) {
  666. const root = document.getElementById('letters')
  667. const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
  668. root.innerHTML = alphabet
  669. .map((L) => {
  670. const ok = available.has(L)
  671. return (
  672. '<button type="button" data-letter="' +
  673. L +
  674. '"' +
  675. (ok ? '' : ' disabled') +
  676. (activeLetter === L ? ' class="is-on"' : '') +
  677. '>' +
  678. L +
  679. '</button>'
  680. )
  681. })
  682. .join('')
  683. root.querySelectorAll('button[data-letter]:not(:disabled)').forEach((btn) => {
  684. btn.addEventListener('click', () => {
  685. const L = btn.getAttribute('data-letter')
  686. activeLetter = activeLetter === L ? '' : L
  687. // 字母跳转:滚到该字母第一条
  688. applyFilter({ scrollLetter: activeLetter || null })
  689. })
  690. })
  691. }
  692. function applyFilter(opts) {
  693. opts = opts || {}
  694. const q = document.getElementById('q').value.trim()
  695. const dir = document.getElementById('dir')
  696. const groupsRoot = document.getElementById('groups')
  697. dir.classList.toggle('is-filtering', !!q)
  698. const counts = {}
  699. MODULES.forEach((m) => (counts[m.id] = 0))
  700. const letters = new Set()
  701. let hit = 0
  702. let firstHitName = null
  703. groupsRoot.innerHTML = MODULES.map((m) => {
  704. const rows = m.items
  705. .map((it) => {
  706. const full = { ...it, moduleId: m.id, moduleTitle: m.title, path: m.title, letter: (it.py[0] || '#').toUpperCase() }
  707. const okMatch = matchItem(full, q)
  708. const okMod = activeModule === 'all' || activeModule === m.id
  709. const okLetter = !activeLetter || full.letter === activeLetter
  710. const show = okMatch && okMod && okLetter
  711. if (okMatch && (activeModule === 'all' || activeModule === m.id)) {
  712. // 计数不受字母限制,便于看模块存量;字母只影响列表显示
  713. }
  714. if (okMatch && okMod) {
  715. counts[m.id]++
  716. letters.add(full.letter)
  717. }
  718. if (!show) {
  719. return (
  720. '<button type="button" class="menu-row is-hidden" data-name="' +
  721. it.name +
  722. '" data-letter="' +
  723. full.letter +
  724. '"></button>'
  725. )
  726. }
  727. hit++
  728. if (!firstHitName) firstHitName = it.name
  729. const on = pins.includes(it.name)
  730. return (
  731. '<div class="menu-row-wrap">' +
  732. '<button type="button" class="menu-row' +
  733. (activeName === it.name ? ' is-active' : '') +
  734. '" data-name="' +
  735. it.name +
  736. '" data-letter="' +
  737. full.letter +
  738. '">' +
  739. '<span>' +
  740. '<div class="menu-row__name">' +
  741. highlight(it.name, q) +
  742. '</div>' +
  743. '<div class="menu-row__path">' +
  744. m.title +
  745. ' / ' +
  746. it.name +
  747. '</div>' +
  748. '</span>' +
  749. '<button type="button" class="menu-row__star' +
  750. (on ? ' is-on' : '') +
  751. '" data-star="' +
  752. it.name +
  753. '" aria-label="常用" tabindex="-1">' +
  754. starSvg(on) +
  755. '</button>' +
  756. '</button>' +
  757. '</div>'
  758. )
  759. })
  760. .join('')
  761. const groupShow = counts[m.id] > 0 && (activeModule === 'all' || activeModule === m.id)
  762. // 若选了字母,组内可能全隐藏
  763. const visibleInLetter = m.items.some((it) => {
  764. const letter = (it.py[0] || '#').toUpperCase()
  765. return matchItem({ ...it, moduleTitle: m.title }, q) && (!activeLetter || letter === activeLetter)
  766. })
  767. if (!groupShow || (activeLetter && !visibleInLetter && activeModule !== 'all' && activeModule !== m.id)) {
  768. /* keep */
  769. }
  770. const showGroup =
  771. (activeModule === 'all' || activeModule === m.id) &&
  772. counts[m.id] > 0 &&
  773. (!activeLetter ||
  774. m.items.some((it) => {
  775. const letter = (it.py[0] || '#').toUpperCase()
  776. return matchItem({ ...it, moduleTitle: m.title }, q) && letter === activeLetter
  777. }))
  778. return (
  779. '<section class="group' +
  780. (showGroup ? '' : ' is-hidden') +
  781. '" id="mod-' +
  782. m.id +
  783. '" data-mod="' +
  784. m.id +
  785. '">' +
  786. '<div class="group__head"><h3>' +
  787. m.title +
  788. '</h3><span>' +
  789. counts[m.id] +
  790. ' 项</span></div>' +
  791. '<div class="menu-cols">' +
  792. rows +
  793. '</div>' +
  794. '</section>'
  795. )
  796. }).join('')
  797. // 字母过滤时隐藏不匹配行(上面已写入 is-hidden;再扫一遍修正)
  798. groupsRoot.querySelectorAll('.menu-row').forEach((row) => {
  799. if (!row.getAttribute('data-name')) {
  800. row.remove()
  801. return
  802. }
  803. const name = row.getAttribute('data-name')
  804. const it = FLAT.find((x) => x.name === name)
  805. if (!it) return
  806. const okMatch = matchItem(it, q)
  807. const okMod = activeModule === 'all' || activeModule === it.moduleId
  808. const okLetter = !activeLetter || it.letter === activeLetter
  809. const show = okMatch && okMod && okLetter
  810. row.classList.toggle('is-hidden', !show)
  811. // 重新统计 hit(上面 hit 在字母时可能偏大)
  812. })
  813. // 精确重算 hit
  814. hit = groupsRoot.querySelectorAll('.menu-row:not(.is-hidden)').length
  815. firstHitName =
  816. (groupsRoot.querySelector('.menu-row:not(.is-hidden)') || {}).getAttribute?.('data-name') || null
  817. document.getElementById('hit-n').textContent = String(hit)
  818. document.getElementById('total-n').textContent = String(FLAT.length)
  819. document.getElementById('empty').classList.toggle('show', hit === 0)
  820. document.getElementById('empty-q').textContent = q || activeLetter || '当前条件'
  821. renderIndex(counts)
  822. renderLetters(letters)
  823. renderFav()
  824. groupsRoot.querySelectorAll('.menu-row:not(.is-hidden)').forEach((row) => {
  825. row.addEventListener('click', (e) => {
  826. if (e.target.closest('[data-star]')) return
  827. openMenu(row.getAttribute('data-name'))
  828. })
  829. })
  830. groupsRoot.querySelectorAll('[data-star]').forEach((btn) => {
  831. btn.addEventListener('click', (e) => {
  832. e.stopPropagation()
  833. togglePin(btn.getAttribute('data-star'))
  834. })
  835. })
  836. if (opts.scrollMod) {
  837. const el = document.getElementById('mod-' + opts.scrollMod)
  838. if (el && !el.classList.contains('is-hidden')) el.scrollIntoView({ block: 'start' })
  839. }
  840. if (opts.scrollLetter) {
  841. const el = groupsRoot.querySelector(
  842. '.menu-row:not(.is-hidden)[data-letter="' + opts.scrollLetter + '"]'
  843. )
  844. if (el) el.scrollIntoView({ block: 'center' })
  845. }
  846. applyFilter._first = firstHitName
  847. }
  848. const qInput = document.getElementById('q')
  849. qInput.addEventListener('input', () => {
  850. activeLetter = ''
  851. applyFilter()
  852. })
  853. qInput.addEventListener('keydown', (e) => {
  854. if (e.key === 'Enter') {
  855. e.preventDefault()
  856. if (applyFilter._first) openMenu(applyFilter._first)
  857. }
  858. if (e.key === 'Escape') {
  859. qInput.value = ''
  860. activeLetter = ''
  861. applyFilter()
  862. }
  863. })
  864. document.addEventListener('keydown', (e) => {
  865. if (e.key === '/' && document.activeElement !== qInput) {
  866. e.preventDefault()
  867. qInput.focus()
  868. qInput.select()
  869. }
  870. })
  871. applyFilter()
  872. qInput.focus()
  873. </script>
  874. </body>
  875. </html>