excel-column-filter-demo.html 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947
  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" />
  6. <title>列头过滤 Demo · 文本 / 数字 / 时间</title>
  7. <style>
  8. :root {
  9. --primary: #10b981;
  10. --primary-dark: #059669;
  11. --primary-bg: #ecfdf5;
  12. --text: #1e293b;
  13. --text-muted: #64748b;
  14. --border: #e2e8f0;
  15. --table-head: #f8fafc;
  16. --danger: #dc2626;
  17. --warning: #d97706;
  18. --radius: 8px;
  19. --shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
  20. font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
  21. }
  22. * { box-sizing: border-box; }
  23. body {
  24. margin: 0;
  25. padding: 24px;
  26. color: var(--text);
  27. background: #f1f5f9;
  28. font-size: 14px;
  29. line-height: 1.5;
  30. }
  31. .page {
  32. max-width: 1100px;
  33. margin: 0 auto;
  34. }
  35. h1 {
  36. margin: 0 0 8px;
  37. font-size: 22px;
  38. font-weight: 700;
  39. }
  40. .desc {
  41. margin: 0 0 16px;
  42. color: var(--text-muted);
  43. font-size: 13px;
  44. }
  45. .legend {
  46. display: flex;
  47. flex-wrap: wrap;
  48. gap: 8px;
  49. margin-bottom: 16px;
  50. }
  51. .chip {
  52. display: inline-flex;
  53. align-items: center;
  54. gap: 6px;
  55. padding: 4px 10px;
  56. border-radius: 999px;
  57. border: 1px solid var(--border);
  58. background: #fff;
  59. font-size: 12px;
  60. color: var(--text-muted);
  61. }
  62. .chip strong { color: var(--text); font-weight: 600; }
  63. .dot {
  64. width: 8px; height: 8px; border-radius: 50%;
  65. background: var(--primary);
  66. }
  67. .dot.num { background: #3b82f6; }
  68. .dot.date { background: var(--warning); }
  69. .panel {
  70. background: #fff;
  71. border: 1px solid #d9e8e2;
  72. border-radius: 10px;
  73. box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
  74. overflow: hidden;
  75. }
  76. .toolbar {
  77. display: flex;
  78. flex-wrap: wrap;
  79. align-items: center;
  80. gap: 10px;
  81. padding: 12px 16px;
  82. background: var(--table-head);
  83. border-bottom: 1px solid var(--border);
  84. }
  85. .toolbar .hint { color: var(--text-muted); font-size: 12px; flex: 1; min-width: 200px; }
  86. button {
  87. font: inherit;
  88. cursor: pointer;
  89. border-radius: var(--radius);
  90. border: 1px solid #d9e8e2;
  91. background: #fff;
  92. color: var(--text);
  93. height: 32px;
  94. padding: 0 12px;
  95. display: inline-flex;
  96. align-items: center;
  97. gap: 6px;
  98. }
  99. button:hover { border-color: #a7f3d0; background: var(--primary-bg); }
  100. button.primary {
  101. background: var(--primary);
  102. border-color: var(--primary);
  103. color: #fff;
  104. font-weight: 600;
  105. }
  106. button.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
  107. button.ghost-danger { color: var(--danger); border-color: #fecaca; }
  108. button.ghost-danger:hover { background: #fef2f2; }
  109. button:disabled { opacity: 0.5; cursor: not-allowed; }
  110. .table-wrap { overflow: auto; }
  111. table {
  112. width: 100%;
  113. border-collapse: collapse;
  114. min-width: 860px;
  115. }
  116. th, td {
  117. padding: 10px 12px;
  118. border-bottom: 1px solid var(--border);
  119. text-align: left;
  120. vertical-align: middle;
  121. }
  122. th {
  123. position: sticky;
  124. top: 0;
  125. z-index: 2;
  126. background: var(--table-head);
  127. color: var(--text-muted);
  128. font-size: 12px;
  129. font-weight: 600;
  130. white-space: nowrap;
  131. user-select: none;
  132. }
  133. th .th-inner {
  134. display: inline-flex;
  135. align-items: center;
  136. gap: 6px;
  137. max-width: 100%;
  138. }
  139. th.align-right, td.align-right { text-align: right; }
  140. th.align-center, td.align-center { text-align: center; }
  141. tbody tr:nth-child(even) { background: #fafbfc; }
  142. tbody tr:hover { background: #f0fdf4; }
  143. tbody tr.is-hidden { display: none; }
  144. .filter-btn {
  145. width: 22px;
  146. height: 22px;
  147. padding: 0;
  148. border: none;
  149. border-radius: 4px;
  150. background: transparent;
  151. color: #94a3b8;
  152. display: inline-flex;
  153. align-items: center;
  154. justify-content: center;
  155. }
  156. .filter-btn:hover { background: #e2e8f0; color: var(--text); }
  157. .filter-btn.active {
  158. color: #fff;
  159. background: var(--primary);
  160. }
  161. .filter-btn svg { width: 14px; height: 14px; }
  162. .tag {
  163. display: inline-block;
  164. padding: 2px 8px;
  165. border-radius: 4px;
  166. font-size: 12px;
  167. font-weight: 600;
  168. color: #fff;
  169. }
  170. .tag.draft { background: #64748b; }
  171. .tag.pending { background: var(--warning); }
  172. .tag.approved { background: var(--primary-dark); }
  173. .tag.rejected { background: var(--danger); }
  174. /* Popover panel */
  175. .popover {
  176. position: fixed;
  177. z-index: 1000;
  178. width: 280px;
  179. background: #fff;
  180. border: 1px solid var(--border);
  181. border-radius: 10px;
  182. box-shadow: var(--shadow);
  183. display: none;
  184. flex-direction: column;
  185. max-height: min(420px, calc(100vh - 24px));
  186. }
  187. .popover.open { display: flex; }
  188. .popover-hd {
  189. padding: 10px 12px 8px;
  190. border-bottom: 1px solid var(--border);
  191. }
  192. .popover-hd .title {
  193. font-size: 13px;
  194. font-weight: 700;
  195. color: var(--text);
  196. margin-bottom: 2px;
  197. }
  198. .popover-hd .sub {
  199. font-size: 11px;
  200. color: var(--text-muted);
  201. }
  202. .popover-bd {
  203. padding: 10px 12px;
  204. overflow: auto;
  205. flex: 1;
  206. }
  207. .popover-ft {
  208. display: flex;
  209. justify-content: space-between;
  210. gap: 8px;
  211. padding: 10px 12px;
  212. border-top: 1px solid var(--border);
  213. background: var(--table-head);
  214. }
  215. .field { margin-bottom: 10px; }
  216. .field:last-child { margin-bottom: 0; }
  217. .field label {
  218. display: block;
  219. font-size: 11px;
  220. font-weight: 500;
  221. color: var(--text-muted);
  222. margin-bottom: 4px;
  223. }
  224. input[type="text"],
  225. input[type="number"],
  226. input[type="date"],
  227. input[type="datetime-local"],
  228. select {
  229. width: 100%;
  230. height: 34px;
  231. padding: 0 10px;
  232. border: 1px solid var(--border);
  233. border-radius: var(--radius);
  234. font: inherit;
  235. color: var(--text);
  236. background: #fff;
  237. }
  238. input:focus, select:focus {
  239. outline: none;
  240. border-color: var(--primary);
  241. box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
  242. }
  243. .range-row {
  244. display: grid;
  245. grid-template-columns: 1fr auto 1fr;
  246. gap: 8px;
  247. align-items: center;
  248. }
  249. .range-row .sep { color: var(--text-muted); font-size: 12px; text-align: center; }
  250. .ops {
  251. display: grid;
  252. grid-template-columns: 110px 1fr;
  253. gap: 8px;
  254. }
  255. .value-list {
  256. max-height: 180px;
  257. overflow: auto;
  258. border: 1px solid var(--border);
  259. border-radius: var(--radius);
  260. padding: 4px;
  261. }
  262. .value-item {
  263. display: flex;
  264. align-items: center;
  265. gap: 8px;
  266. padding: 6px 8px;
  267. border-radius: 6px;
  268. cursor: pointer;
  269. font-size: 13px;
  270. }
  271. .value-item:hover { background: var(--primary-bg); }
  272. .value-item input { width: auto; margin: 0; }
  273. .value-item .count {
  274. margin-left: auto;
  275. color: var(--text-muted);
  276. font-size: 11px;
  277. }
  278. .select-all-row {
  279. display: flex;
  280. align-items: center;
  281. justify-content: space-between;
  282. margin-bottom: 6px;
  283. font-size: 12px;
  284. }
  285. .select-all-row button {
  286. height: 26px;
  287. padding: 0 8px;
  288. font-size: 12px;
  289. border: none;
  290. background: transparent;
  291. color: var(--primary-dark);
  292. }
  293. .select-all-row button:hover { background: var(--primary-bg); }
  294. .empty-tip {
  295. padding: 16px;
  296. text-align: center;
  297. color: var(--text-muted);
  298. font-size: 12px;
  299. }
  300. .footer-bar {
  301. display: flex;
  302. align-items: center;
  303. justify-content: space-between;
  304. padding: 10px 16px;
  305. border-top: 1px solid var(--border);
  306. color: var(--text-muted);
  307. font-size: 12px;
  308. background: #fff;
  309. }
  310. .notes {
  311. margin-top: 20px;
  312. padding: 14px 16px;
  313. background: #fff;
  314. border: 1px solid var(--border);
  315. border-radius: 10px;
  316. font-size: 13px;
  317. color: var(--text-muted);
  318. }
  319. .notes h2 {
  320. margin: 0 0 8px;
  321. font-size: 14px;
  322. color: var(--text);
  323. }
  324. .notes ul { margin: 0; padding-left: 18px; }
  325. .notes li { margin-bottom: 4px; }
  326. .notes code {
  327. font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  328. font-size: 12px;
  329. background: #f1f5f9;
  330. padding: 1px 5px;
  331. border-radius: 4px;
  332. }
  333. </style>
  334. </head>
  335. <body>
  336. <div class="page">
  337. <h1>Excel 风格列头过滤 Demo</h1>
  338. <p class="desc">
  339. 模拟报销申请列表:点击表头漏斗图标过滤<strong>当前页数据</strong>。
  340. 已覆盖 <strong>文本</strong>、<strong>数字</strong>、<strong>日期/时间</strong>三类控件。
  341. </p>
  342. <div class="legend">
  343. <span class="chip"><span class="dot"></span><strong>文本</strong>:搜索 + 勾选本页去重值</span>
  344. <span class="chip"><span class="dot num"></span><strong>数字</strong>:运算符 + 单值 / 区间</span>
  345. <span class="chip"><span class="dot date"></span><strong>时间</strong>:日期区间(可扩到时分)</span>
  346. </div>
  347. <section class="panel">
  348. <div class="toolbar">
  349. <span class="hint" id="activeFilterHint">当前无列过滤</span>
  350. <button type="button" id="btnClearAll" class="ghost-danger" disabled>清空全部过滤</button>
  351. <button type="button" id="btnResetData">恢复演示数据</button>
  352. </div>
  353. <div class="table-wrap">
  354. <table id="demoTable">
  355. <thead>
  356. <tr>
  357. <th class="align-center" style="width:56px">序号</th>
  358. <th data-col="applyCode" data-type="text">
  359. <span class="th-inner">报销单号<button type="button" class="filter-btn" data-col="applyCode" aria-label="过滤报销单号"></button></span>
  360. </th>
  361. <th data-col="title" data-type="text">
  362. <span class="th-inner">报销标题<button type="button" class="filter-btn" data-col="title" aria-label="过滤报销标题"></button></span>
  363. </th>
  364. <th data-col="expenseName" data-type="text">
  365. <span class="th-inner">费用项目<button type="button" class="filter-btn" data-col="expenseName" aria-label="过滤费用项目"></button></span>
  366. </th>
  367. <th class="align-right" data-col="amount" data-type="number">
  368. <span class="th-inner">报销金额(元)<button type="button" class="filter-btn" data-col="amount" aria-label="过滤金额"></button></span>
  369. </th>
  370. <th class="align-center" data-col="status" data-type="enum">
  371. <span class="th-inner">状态<button type="button" class="filter-btn" data-col="status" aria-label="过滤状态"></button></span>
  372. </th>
  373. <th class="align-center" data-col="applyTime" data-type="datetime">
  374. <span class="th-inner">申请时间<button type="button" class="filter-btn" data-col="applyTime" aria-label="过滤申请时间"></button></span>
  375. </th>
  376. </tr>
  377. </thead>
  378. <tbody id="tbody"></tbody>
  379. </table>
  380. </div>
  381. <div class="footer-bar">
  382. <span id="pageStat">显示 0 / 0 条(当前页)</span>
  383. <span>说明:本 Demo 仅过滤当前表格内数据,不请求后端</span>
  384. </div>
  385. </section>
  386. <div class="notes">
  387. <h2>各数据类型过滤建议</h2>
  388. <ul>
  389. <li><strong>文本 / 枚举</strong>:漏斗内「关键词搜索」缩小可选值,再多选勾选;枚举列(状态)可固定选项,不必依赖本页去重。</li>
  390. <li><strong>数字</strong>:提供运算符(等于 / 大于 / 小于 / 介于);「介于」用最小值~最大值;注意金额展示与比较用原始数值。</li>
  391. <li><strong>日期 / 时间</strong>:默认「开始日期~结束日期」闭区间;若业务要精确到时分,用 <code>datetime-local</code> 或拆成日期+时间两段。</li>
  392. <li><strong>组合</strong>:多列过滤取交集(AND);任一列清空后只影响该列。</li>
  393. <li><strong>与顶部筛选并存时</strong>:顶部筛选 = 查库;列头过滤 = 当前页二次过滤,文案上要区分,避免用户误以为筛了全库。</li>
  394. </ul>
  395. </div>
  396. </div>
  397. <!-- Shared popover -->
  398. <div class="popover" id="popover" role="dialog" aria-modal="false">
  399. <div class="popover-hd">
  400. <div class="title" id="popTitle">过滤</div>
  401. <div class="sub" id="popSub">按本列筛选当前页</div>
  402. </div>
  403. <div class="popover-bd" id="popBody"></div>
  404. <div class="popover-ft">
  405. <button type="button" id="popClear">清空本列</button>
  406. <div style="display:flex;gap:8px">
  407. <button type="button" id="popCancel">取消</button>
  408. <button type="button" class="primary" id="popOk">确定</button>
  409. </div>
  410. </div>
  411. </div>
  412. <script>
  413. const STATUS_MAP = {
  414. draft: { label: '待提交', cls: 'draft' },
  415. pending: { label: '审批中', cls: 'pending' },
  416. approved: { label: '审批通过', cls: 'approved' },
  417. rejected: { label: '审批驳回', cls: 'rejected' }
  418. }
  419. const COLUMNS = {
  420. applyCode: { label: '报销单号', type: 'text' },
  421. title: { label: '报销标题', type: 'text' },
  422. expenseName: { label: '费用项目', type: 'text' },
  423. amount: { label: '报销金额(元)', type: 'number' },
  424. status: { label: '状态', type: 'enum', enumOptions: Object.keys(STATUS_MAP) },
  425. applyTime: { label: '申请时间', type: 'datetime' }
  426. }
  427. /** Demo rows — mixed types */
  428. const DEMO_ROWS = [
  429. { applyCode: 'BX20260818001', title: '差旅报销-上海客户拜访', expenseName: '差旅费 / 交通费', amount: 1280.5, status: 'pending', applyTime: '2026-08-10 09:20:00' },
  430. { applyCode: 'BX20260818002', title: '办公用品采购', expenseName: '办公费 / 耗材', amount: 356, status: 'draft', applyTime: '2026-08-11 14:05:00' },
  431. { applyCode: 'BX20260818003', title: '团队团建餐费', expenseName: '业务招待费', amount: 980, status: 'approved', applyTime: '2026-08-12 18:40:00' },
  432. { applyCode: 'BX20260818004', title: '快递运费报销', expenseName: '物流费', amount: 86.2, status: 'rejected', applyTime: '2026-08-13 10:12:00' },
  433. { applyCode: 'BX20260818005', title: '培训报名费', expenseName: '培训费', amount: 2500, status: 'pending', applyTime: '2026-08-14 11:30:00' },
  434. { applyCode: 'BX20260818006', title: '差旅报销-广州展会', expenseName: '差旅费 / 住宿费', amount: 1860, status: 'approved', applyTime: '2026-08-15 08:55:00' },
  435. { applyCode: 'BX20260818007', title: '加油站加油费', expenseName: '车辆使用费', amount: 420.8, status: 'draft', applyTime: '2026-08-16 16:22:00' },
  436. { applyCode: 'BX20260818008', title: '软件订阅年费', expenseName: '信息化费用', amount: 3999, status: 'pending', applyTime: '2026-08-17 09:08:00' },
  437. { applyCode: 'BX20260818009', title: '客户招待午餐', expenseName: '业务招待费', amount: 680, status: 'approved', applyTime: '2026-08-17 13:45:00' },
  438. { applyCode: 'BX20260818010', title: '打印机维修', expenseName: '办公费 / 维修', amount: 230, status: 'draft', applyTime: '2026-08-18 10:01:00' }
  439. ]
  440. /** Active filters: { colKey: filterState } */
  441. const filters = {}
  442. let draft = null // { col, type, ... } while popover open
  443. let openCol = null
  444. const funnelSvg = `
  445. <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
  446. <path d="M3 5a1 1 0 0 1 1-1h16a1 1 0 0 1 .8 1.6L15 13v5.2a1 1 0 0 1-1.45.9l-3-1.5A1 1 0 0 1 10 16.7V13L3.2 5.6A1 1 0 0 1 3 5z"/>
  447. </svg>`
  448. document.querySelectorAll('.filter-btn').forEach((btn) => {
  449. btn.innerHTML = funnelSvg
  450. btn.addEventListener('click', (e) => {
  451. e.stopPropagation()
  452. openFilter(btn.dataset.col, btn)
  453. })
  454. })
  455. function formatMoney(n) {
  456. return Number(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
  457. }
  458. function formatDateOnly(s) {
  459. return String(s || '').slice(0, 10)
  460. }
  461. function parseDate(s) {
  462. // Treat as local date for range compare (date part or full datetime)
  463. const t = Date.parse(String(s).replace(/-/g, '/'))
  464. return Number.isNaN(t) ? null : t
  465. }
  466. function startOfDay(yyyyMmDd) {
  467. return parseDate(yyyyMmDd + ' 00:00:00')
  468. }
  469. function endOfDay(yyyyMmDd) {
  470. return parseDate(yyyyMmDd + ' 23:59:59')
  471. }
  472. function displayValue(col, row) {
  473. if (col === 'status') return STATUS_MAP[row.status]?.label || row.status
  474. if (col === 'amount') return formatMoney(row.amount)
  475. if (col === 'applyTime') return formatDateOnly(row.applyTime)
  476. return String(row[col] ?? '')
  477. }
  478. function renderTable() {
  479. const tbody = document.getElementById('tbody')
  480. tbody.innerHTML = ''
  481. let visible = 0
  482. DEMO_ROWS.forEach((row, idx) => {
  483. const pass = matchAllFilters(row)
  484. if (pass) visible += 1
  485. const tr = document.createElement('tr')
  486. if (!pass) tr.classList.add('is-hidden')
  487. const st = STATUS_MAP[row.status]
  488. tr.innerHTML = `
  489. <td class="align-center">${idx + 1}</td>
  490. <td>${escapeHtml(row.applyCode)}</td>
  491. <td>${escapeHtml(row.title)}</td>
  492. <td>${escapeHtml(row.expenseName)}</td>
  493. <td class="align-right">${formatMoney(row.amount)}</td>
  494. <td class="align-center"><span class="tag ${st.cls}">${st.label}</span></td>
  495. <td class="align-center">${formatDateOnly(row.applyTime)}</td>
  496. `
  497. tbody.appendChild(tr)
  498. })
  499. document.getElementById('pageStat').textContent =
  500. `显示 ${visible} / ${DEMO_ROWS.length} 条(当前页)`
  501. updateFilterUi()
  502. }
  503. function escapeHtml(s) {
  504. return String(s)
  505. .replace(/&/g, '&amp;')
  506. .replace(/</g, '&lt;')
  507. .replace(/>/g, '&gt;')
  508. .replace(/"/g, '&quot;')
  509. }
  510. function matchAllFilters(row) {
  511. return Object.keys(filters).every((col) => matchOne(col, filters[col], row))
  512. }
  513. function matchOne(col, f, row) {
  514. if (!f) return true
  515. const meta = COLUMNS[col]
  516. const type = meta.type
  517. if (type === 'text' || type === 'enum') {
  518. const selected = f.selected
  519. if (!selected || selected.length === 0) return true
  520. const raw = type === 'enum' ? row[col] : String(row[col] ?? '')
  521. return selected.includes(raw)
  522. }
  523. if (type === 'number') {
  524. const val = Number(row[col])
  525. const op = f.op || 'between'
  526. if (op === 'eq') return f.value !== '' && val === Number(f.value)
  527. if (op === 'gt') return f.value !== '' && val > Number(f.value)
  528. if (op === 'lt') return f.value !== '' && val < Number(f.value)
  529. if (op === 'gte') return f.value !== '' && val >= Number(f.value)
  530. if (op === 'lte') return f.value !== '' && val <= Number(f.value)
  531. // between
  532. const minOk = f.min === '' || f.min == null || val >= Number(f.min)
  533. const maxOk = f.max === '' || f.max == null || val <= Number(f.max)
  534. if ((f.min === '' || f.min == null) && (f.max === '' || f.max == null)) return true
  535. return minOk && maxOk
  536. }
  537. if (type === 'datetime') {
  538. const t = parseDate(row[col])
  539. if (t == null) return false
  540. const fromOk = !f.from || t >= startOfDay(f.from)
  541. const toOk = !f.to || t <= endOfDay(f.to)
  542. if (!f.from && !f.to) return true
  543. return fromOk && toOk
  544. }
  545. return true
  546. }
  547. function updateFilterUi() {
  548. const activeCols = Object.keys(filters).filter((k) => isFilterActive(k, filters[k]))
  549. document.querySelectorAll('.filter-btn').forEach((btn) => {
  550. btn.classList.toggle('active', activeCols.includes(btn.dataset.col))
  551. })
  552. const hint = document.getElementById('activeFilterHint')
  553. const clearBtn = document.getElementById('btnClearAll')
  554. if (activeCols.length === 0) {
  555. hint.textContent = '当前无列过滤'
  556. clearBtn.disabled = true
  557. } else {
  558. hint.textContent = '已过滤列:' + activeCols.map((c) => COLUMNS[c].label).join('、')
  559. clearBtn.disabled = false
  560. }
  561. }
  562. function isFilterActive(col, f) {
  563. if (!f) return false
  564. const type = COLUMNS[col].type
  565. if (type === 'text' || type === 'enum') return Array.isArray(f.selected) && f.selected.length > 0
  566. if (type === 'number') {
  567. if (f.op === 'between') return (f.min !== '' && f.min != null) || (f.max !== '' && f.max != null)
  568. return f.value !== '' && f.value != null
  569. }
  570. if (type === 'datetime') return !!(f.from || f.to)
  571. return false
  572. }
  573. function uniqueValues(col) {
  574. const map = new Map()
  575. DEMO_ROWS.forEach((row) => {
  576. const key = COLUMNS[col].type === 'enum' ? row[col] : String(row[col] ?? '')
  577. map.set(key, (map.get(key) || 0) + 1)
  578. })
  579. return [...map.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0]), 'zh-CN'))
  580. }
  581. function openFilter(col, anchorBtn) {
  582. const meta = COLUMNS[col]
  583. openCol = col
  584. draft = cloneFilter(col, filters[col])
  585. const pop = document.getElementById('popover')
  586. document.getElementById('popTitle').textContent = '过滤 · ' + meta.label
  587. document.getElementById('popSub').textContent =
  588. meta.type === 'text' ? '文本:搜索并勾选本页出现的值'
  589. : meta.type === 'enum' ? '枚举:勾选状态(固定选项)'
  590. : meta.type === 'number' ? '数字:运算符 + 单值或区间'
  591. : '时间:按日期闭区间过滤(含起止当天)'
  592. document.getElementById('popBody').innerHTML = buildBodyHtml(col, draft)
  593. wireBodyEvents(col)
  594. positionPopover(pop, anchorBtn)
  595. pop.classList.add('open')
  596. }
  597. function cloneFilter(col, f) {
  598. const type = COLUMNS[col].type
  599. if (!f) {
  600. if (type === 'text' || type === 'enum') return { selected: [], keyword: '' }
  601. if (type === 'number') return { op: 'between', value: '', min: '', max: '' }
  602. if (type === 'datetime') return { from: '', to: '' }
  603. }
  604. return JSON.parse(JSON.stringify(f))
  605. }
  606. function buildBodyHtml(col, f) {
  607. const type = COLUMNS[col].type
  608. if (type === 'text' || type === 'enum') {
  609. const options = type === 'enum'
  610. ? COLUMNS[col].enumOptions.map((k) => [k, DEMO_ROWS.filter((r) => r.status === k).length])
  611. : uniqueValues(col)
  612. const items = options.map(([key, count]) => {
  613. const checked = f.selected.includes(key) ? 'checked' : ''
  614. const label = type === 'enum' ? STATUS_MAP[key].label : key
  615. return `
  616. <label class="value-item" data-key="${escapeHtml(key)}">
  617. <input type="checkbox" value="${escapeHtml(key)}" ${checked} />
  618. <span>${escapeHtml(label)}</span>
  619. <span class="count">${count}</span>
  620. </label>`
  621. }).join('')
  622. return `
  623. <div class="field">
  624. <label>搜索可选值</label>
  625. <input type="text" id="kwInput" placeholder="输入关键字过滤下方列表" value="${escapeHtml(f.keyword || '')}" />
  626. </div>
  627. <div class="select-all-row">
  628. <span>本页可选值</span>
  629. <span>
  630. <button type="button" id="btnSelectAll">全选</button>
  631. <button type="button" id="btnSelectNone">全不选</button>
  632. </span>
  633. </div>
  634. <div class="value-list" id="valueList">${items || '<div class="empty-tip">无数据</div>'}</div>
  635. `
  636. }
  637. if (type === 'number') {
  638. const showSingle = f.op !== 'between'
  639. return `
  640. <div class="field">
  641. <label>条件</label>
  642. <div class="ops">
  643. <select id="numOp">
  644. <option value="between" ${f.op === 'between' ? 'selected' : ''}>介于</option>
  645. <option value="eq" ${f.op === 'eq' ? 'selected' : ''}>等于</option>
  646. <option value="gt" ${f.op === 'gt' ? 'selected' : ''}>大于</option>
  647. <option value="gte" ${f.op === 'gte' ? 'selected' : ''}>大于等于</option>
  648. <option value="lt" ${f.op === 'lt' ? 'selected' : ''}>小于</option>
  649. <option value="lte" ${f.op === 'lte' ? 'selected' : ''}>小于等于</option>
  650. </select>
  651. <div id="numSingleWrap" style="${showSingle ? '' : 'display:none'}">
  652. <input type="number" id="numValue" step="0.01" placeholder="输入金额" value="${escapeHtml(f.value ?? '')}" />
  653. </div>
  654. </div>
  655. </div>
  656. <div class="field" id="numRangeWrap" style="${showSingle ? 'display:none' : ''}">
  657. <label>金额区间(元)</label>
  658. <div class="range-row">
  659. <input type="number" id="numMin" step="0.01" placeholder="最小值" value="${escapeHtml(f.min ?? '')}" />
  660. <span class="sep">至</span>
  661. <input type="number" id="numMax" step="0.01" placeholder="最大值" value="${escapeHtml(f.max ?? '')}" />
  662. </div>
  663. </div>
  664. <p style="margin:8px 0 0;font-size:11px;color:var(--text-muted)">
  665. 提示:比较使用原始数值;本页金额范围约 ${formatMoney(Math.min(...DEMO_ROWS.map(r => r.amount)))} ~ ${formatMoney(Math.max(...DEMO_ROWS.map(r => r.amount)))}
  666. </p>
  667. `
  668. }
  669. if (type === 'datetime') {
  670. return `
  671. <div class="field">
  672. <label>开始日期</label>
  673. <input type="date" id="dateFrom" value="${escapeHtml(f.from || '')}" />
  674. </div>
  675. <div class="field">
  676. <label>结束日期</label>
  677. <input type="date" id="dateTo" value="${escapeHtml(f.to || '')}" />
  678. </div>
  679. <p style="margin:8px 0 0;font-size:11px;color:var(--text-muted)">
  680. 闭区间:含起止当天 00:00:00~23:59:59。若需时分秒,可把控件换成 <code>datetime-local</code>。
  681. </p>
  682. `
  683. }
  684. return ''
  685. }
  686. function wireBodyEvents(col) {
  687. const type = COLUMNS[col].type
  688. if (type === 'text' || type === 'enum') {
  689. const kw = document.getElementById('kwInput')
  690. const list = document.getElementById('valueList')
  691. kw.addEventListener('input', () => {
  692. draft.keyword = kw.value.trim()
  693. const q = draft.keyword.toLowerCase()
  694. list.querySelectorAll('.value-item').forEach((el) => {
  695. const key = el.dataset.key
  696. const label = COLUMNS[col].type === 'enum' ? STATUS_MAP[key].label : key
  697. const hit = !q || label.toLowerCase().includes(q) || String(key).toLowerCase().includes(q)
  698. el.style.display = hit ? '' : 'none'
  699. })
  700. })
  701. list.addEventListener('change', (e) => {
  702. if (e.target.type !== 'checkbox') return
  703. const val = e.target.value
  704. if (e.target.checked) {
  705. if (!draft.selected.includes(val)) draft.selected.push(val)
  706. } else {
  707. draft.selected = draft.selected.filter((x) => x !== val)
  708. }
  709. })
  710. document.getElementById('btnSelectAll').addEventListener('click', () => {
  711. list.querySelectorAll('.value-item').forEach((el) => {
  712. if (el.style.display === 'none') return
  713. const cb = el.querySelector('input')
  714. cb.checked = true
  715. if (!draft.selected.includes(cb.value)) draft.selected.push(cb.value)
  716. })
  717. })
  718. document.getElementById('btnSelectNone').addEventListener('click', () => {
  719. list.querySelectorAll('.value-item').forEach((el) => {
  720. if (el.style.display === 'none') return
  721. const cb = el.querySelector('input')
  722. cb.checked = false
  723. draft.selected = draft.selected.filter((x) => x !== cb.value)
  724. })
  725. })
  726. }
  727. if (type === 'number') {
  728. const op = document.getElementById('numOp')
  729. const sync = () => {
  730. draft.op = op.value
  731. const single = draft.op !== 'between'
  732. document.getElementById('numSingleWrap').style.display = single ? '' : 'none'
  733. document.getElementById('numRangeWrap').style.display = single ? 'none' : ''
  734. }
  735. op.addEventListener('change', sync)
  736. ;['numValue', 'numMin', 'numMax'].forEach((id) => {
  737. const el = document.getElementById(id)
  738. if (!el) return
  739. el.addEventListener('input', () => {
  740. if (id === 'numValue') draft.value = el.value
  741. if (id === 'numMin') draft.min = el.value
  742. if (id === 'numMax') draft.max = el.value
  743. })
  744. })
  745. }
  746. if (type === 'datetime') {
  747. document.getElementById('dateFrom').addEventListener('change', (e) => { draft.from = e.target.value })
  748. document.getElementById('dateTo').addEventListener('change', (e) => { draft.to = e.target.value })
  749. }
  750. }
  751. function readDraftFromDom(col) {
  752. const type = COLUMNS[col].type
  753. if (type === 'number') {
  754. draft.op = document.getElementById('numOp').value
  755. draft.value = document.getElementById('numValue')?.value ?? ''
  756. draft.min = document.getElementById('numMin')?.value ?? ''
  757. draft.max = document.getElementById('numMax')?.value ?? ''
  758. }
  759. if (type === 'datetime') {
  760. draft.from = document.getElementById('dateFrom').value
  761. draft.to = document.getElementById('dateTo').value
  762. }
  763. }
  764. function applyDraft() {
  765. if (!openCol) return
  766. readDraftFromDom(openCol)
  767. // Validate number range
  768. if (COLUMNS[openCol].type === 'number' && draft.op === 'between') {
  769. if (draft.min !== '' && draft.max !== '' && Number(draft.min) > Number(draft.max)) {
  770. alert('最小值不能大于最大值')
  771. return
  772. }
  773. }
  774. if (COLUMNS[openCol].type === 'datetime') {
  775. if (draft.from && draft.to && draft.from > draft.to) {
  776. alert('开始日期不能晚于结束日期')
  777. return
  778. }
  779. }
  780. if (isFilterActive(openCol, draft)) {
  781. filters[openCol] = JSON.parse(JSON.stringify(draft))
  782. } else {
  783. delete filters[openCol]
  784. }
  785. closePopover()
  786. renderTable()
  787. }
  788. function clearCurrentCol() {
  789. if (!openCol) return
  790. delete filters[openCol]
  791. closePopover()
  792. renderTable()
  793. }
  794. function clearAllFilters() {
  795. Object.keys(filters).forEach((k) => delete filters[k])
  796. closePopover()
  797. renderTable()
  798. }
  799. function closePopover() {
  800. document.getElementById('popover').classList.remove('open')
  801. openCol = null
  802. draft = null
  803. }
  804. function positionPopover(pop, anchor) {
  805. const rect = anchor.getBoundingClientRect()
  806. const pad = 8
  807. let left = rect.left
  808. let top = rect.bottom + 6
  809. pop.style.left = '0px'
  810. pop.style.top = '0px'
  811. pop.classList.add('open')
  812. const pw = pop.offsetWidth
  813. const ph = pop.offsetHeight
  814. if (left + pw > window.innerWidth - pad) left = window.innerWidth - pw - pad
  815. if (left < pad) left = pad
  816. if (top + ph > window.innerHeight - pad) top = rect.top - ph - 6
  817. if (top < pad) top = pad
  818. pop.style.left = left + 'px'
  819. pop.style.top = top + 'px'
  820. }
  821. document.getElementById('popOk').addEventListener('click', applyDraft)
  822. document.getElementById('popCancel').addEventListener('click', closePopover)
  823. document.getElementById('popClear').addEventListener('click', clearCurrentCol)
  824. document.getElementById('btnClearAll').addEventListener('click', clearAllFilters)
  825. document.getElementById('btnResetData').addEventListener('click', () => {
  826. clearAllFilters()
  827. })
  828. document.addEventListener('click', (e) => {
  829. const pop = document.getElementById('popover')
  830. if (!pop.classList.contains('open')) return
  831. if (pop.contains(e.target)) return
  832. if (e.target.closest('.filter-btn')) return
  833. closePopover()
  834. })
  835. document.addEventListener('keydown', (e) => {
  836. if (e.key === 'Escape') closePopover()
  837. })
  838. window.addEventListener('resize', () => {
  839. if (document.getElementById('popover').classList.contains('open')) closePopover()
  840. })
  841. renderTable()
  842. </script>
  843. </body>
  844. </html>