| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947 |
- <!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8" />
- <meta name="viewport" content="width=device-width, initial-scale=1" />
- <title>列头过滤 Demo · 文本 / 数字 / 时间</title>
- <style>
- :root {
- --primary: #10b981;
- --primary-dark: #059669;
- --primary-bg: #ecfdf5;
- --text: #1e293b;
- --text-muted: #64748b;
- --border: #e2e8f0;
- --table-head: #f8fafc;
- --danger: #dc2626;
- --warning: #d97706;
- --radius: 8px;
- --shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
- font-family: Inter, system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
- }
- * { box-sizing: border-box; }
- body {
- margin: 0;
- padding: 24px;
- color: var(--text);
- background: #f1f5f9;
- font-size: 14px;
- line-height: 1.5;
- }
- .page {
- max-width: 1100px;
- margin: 0 auto;
- }
- h1 {
- margin: 0 0 8px;
- font-size: 22px;
- font-weight: 700;
- }
- .desc {
- margin: 0 0 16px;
- color: var(--text-muted);
- font-size: 13px;
- }
- .legend {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-bottom: 16px;
- }
- .chip {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- padding: 4px 10px;
- border-radius: 999px;
- border: 1px solid var(--border);
- background: #fff;
- font-size: 12px;
- color: var(--text-muted);
- }
- .chip strong { color: var(--text); font-weight: 600; }
- .dot {
- width: 8px; height: 8px; border-radius: 50%;
- background: var(--primary);
- }
- .dot.num { background: #3b82f6; }
- .dot.date { background: var(--warning); }
- .panel {
- background: #fff;
- border: 1px solid #d9e8e2;
- border-radius: 10px;
- box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
- overflow: hidden;
- }
- .toolbar {
- display: flex;
- flex-wrap: wrap;
- align-items: center;
- gap: 10px;
- padding: 12px 16px;
- background: var(--table-head);
- border-bottom: 1px solid var(--border);
- }
- .toolbar .hint { color: var(--text-muted); font-size: 12px; flex: 1; min-width: 200px; }
- button {
- font: inherit;
- cursor: pointer;
- border-radius: var(--radius);
- border: 1px solid #d9e8e2;
- background: #fff;
- color: var(--text);
- height: 32px;
- padding: 0 12px;
- display: inline-flex;
- align-items: center;
- gap: 6px;
- }
- button:hover { border-color: #a7f3d0; background: var(--primary-bg); }
- button.primary {
- background: var(--primary);
- border-color: var(--primary);
- color: #fff;
- font-weight: 600;
- }
- button.primary:hover { background: var(--primary-dark); border-color: var(--primary-dark); }
- button.ghost-danger { color: var(--danger); border-color: #fecaca; }
- button.ghost-danger:hover { background: #fef2f2; }
- button:disabled { opacity: 0.5; cursor: not-allowed; }
- .table-wrap { overflow: auto; }
- table {
- width: 100%;
- border-collapse: collapse;
- min-width: 860px;
- }
- th, td {
- padding: 10px 12px;
- border-bottom: 1px solid var(--border);
- text-align: left;
- vertical-align: middle;
- }
- th {
- position: sticky;
- top: 0;
- z-index: 2;
- background: var(--table-head);
- color: var(--text-muted);
- font-size: 12px;
- font-weight: 600;
- white-space: nowrap;
- user-select: none;
- }
- th .th-inner {
- display: inline-flex;
- align-items: center;
- gap: 6px;
- max-width: 100%;
- }
- th.align-right, td.align-right { text-align: right; }
- th.align-center, td.align-center { text-align: center; }
- tbody tr:nth-child(even) { background: #fafbfc; }
- tbody tr:hover { background: #f0fdf4; }
- tbody tr.is-hidden { display: none; }
- .filter-btn {
- width: 22px;
- height: 22px;
- padding: 0;
- border: none;
- border-radius: 4px;
- background: transparent;
- color: #94a3b8;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- }
- .filter-btn:hover { background: #e2e8f0; color: var(--text); }
- .filter-btn.active {
- color: #fff;
- background: var(--primary);
- }
- .filter-btn svg { width: 14px; height: 14px; }
- .tag {
- display: inline-block;
- padding: 2px 8px;
- border-radius: 4px;
- font-size: 12px;
- font-weight: 600;
- color: #fff;
- }
- .tag.draft { background: #64748b; }
- .tag.pending { background: var(--warning); }
- .tag.approved { background: var(--primary-dark); }
- .tag.rejected { background: var(--danger); }
- /* Popover panel */
- .popover {
- position: fixed;
- z-index: 1000;
- width: 280px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 10px;
- box-shadow: var(--shadow);
- display: none;
- flex-direction: column;
- max-height: min(420px, calc(100vh - 24px));
- }
- .popover.open { display: flex; }
- .popover-hd {
- padding: 10px 12px 8px;
- border-bottom: 1px solid var(--border);
- }
- .popover-hd .title {
- font-size: 13px;
- font-weight: 700;
- color: var(--text);
- margin-bottom: 2px;
- }
- .popover-hd .sub {
- font-size: 11px;
- color: var(--text-muted);
- }
- .popover-bd {
- padding: 10px 12px;
- overflow: auto;
- flex: 1;
- }
- .popover-ft {
- display: flex;
- justify-content: space-between;
- gap: 8px;
- padding: 10px 12px;
- border-top: 1px solid var(--border);
- background: var(--table-head);
- }
- .field { margin-bottom: 10px; }
- .field:last-child { margin-bottom: 0; }
- .field label {
- display: block;
- font-size: 11px;
- font-weight: 500;
- color: var(--text-muted);
- margin-bottom: 4px;
- }
- input[type="text"],
- input[type="number"],
- input[type="date"],
- input[type="datetime-local"],
- select {
- width: 100%;
- height: 34px;
- padding: 0 10px;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- font: inherit;
- color: var(--text);
- background: #fff;
- }
- input:focus, select:focus {
- outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.15);
- }
- .range-row {
- display: grid;
- grid-template-columns: 1fr auto 1fr;
- gap: 8px;
- align-items: center;
- }
- .range-row .sep { color: var(--text-muted); font-size: 12px; text-align: center; }
- .ops {
- display: grid;
- grid-template-columns: 110px 1fr;
- gap: 8px;
- }
- .value-list {
- max-height: 180px;
- overflow: auto;
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: 4px;
- }
- .value-item {
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 6px 8px;
- border-radius: 6px;
- cursor: pointer;
- font-size: 13px;
- }
- .value-item:hover { background: var(--primary-bg); }
- .value-item input { width: auto; margin: 0; }
- .value-item .count {
- margin-left: auto;
- color: var(--text-muted);
- font-size: 11px;
- }
- .select-all-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- margin-bottom: 6px;
- font-size: 12px;
- }
- .select-all-row button {
- height: 26px;
- padding: 0 8px;
- font-size: 12px;
- border: none;
- background: transparent;
- color: var(--primary-dark);
- }
- .select-all-row button:hover { background: var(--primary-bg); }
- .empty-tip {
- padding: 16px;
- text-align: center;
- color: var(--text-muted);
- font-size: 12px;
- }
- .footer-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: 10px 16px;
- border-top: 1px solid var(--border);
- color: var(--text-muted);
- font-size: 12px;
- background: #fff;
- }
- .notes {
- margin-top: 20px;
- padding: 14px 16px;
- background: #fff;
- border: 1px solid var(--border);
- border-radius: 10px;
- font-size: 13px;
- color: var(--text-muted);
- }
- .notes h2 {
- margin: 0 0 8px;
- font-size: 14px;
- color: var(--text);
- }
- .notes ul { margin: 0; padding-left: 18px; }
- .notes li { margin-bottom: 4px; }
- .notes code {
- font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
- font-size: 12px;
- background: #f1f5f9;
- padding: 1px 5px;
- border-radius: 4px;
- }
- </style>
- </head>
- <body>
- <div class="page">
- <h1>Excel 风格列头过滤 Demo</h1>
- <p class="desc">
- 模拟报销申请列表:点击表头漏斗图标过滤<strong>当前页数据</strong>。
- 已覆盖 <strong>文本</strong>、<strong>数字</strong>、<strong>日期/时间</strong>三类控件。
- </p>
- <div class="legend">
- <span class="chip"><span class="dot"></span><strong>文本</strong>:搜索 + 勾选本页去重值</span>
- <span class="chip"><span class="dot num"></span><strong>数字</strong>:运算符 + 单值 / 区间</span>
- <span class="chip"><span class="dot date"></span><strong>时间</strong>:日期区间(可扩到时分)</span>
- </div>
- <section class="panel">
- <div class="toolbar">
- <span class="hint" id="activeFilterHint">当前无列过滤</span>
- <button type="button" id="btnClearAll" class="ghost-danger" disabled>清空全部过滤</button>
- <button type="button" id="btnResetData">恢复演示数据</button>
- </div>
- <div class="table-wrap">
- <table id="demoTable">
- <thead>
- <tr>
- <th class="align-center" style="width:56px">序号</th>
- <th data-col="applyCode" data-type="text">
- <span class="th-inner">报销单号<button type="button" class="filter-btn" data-col="applyCode" aria-label="过滤报销单号"></button></span>
- </th>
- <th data-col="title" data-type="text">
- <span class="th-inner">报销标题<button type="button" class="filter-btn" data-col="title" aria-label="过滤报销标题"></button></span>
- </th>
- <th data-col="expenseName" data-type="text">
- <span class="th-inner">费用项目<button type="button" class="filter-btn" data-col="expenseName" aria-label="过滤费用项目"></button></span>
- </th>
- <th class="align-right" data-col="amount" data-type="number">
- <span class="th-inner">报销金额(元)<button type="button" class="filter-btn" data-col="amount" aria-label="过滤金额"></button></span>
- </th>
- <th class="align-center" data-col="status" data-type="enum">
- <span class="th-inner">状态<button type="button" class="filter-btn" data-col="status" aria-label="过滤状态"></button></span>
- </th>
- <th class="align-center" data-col="applyTime" data-type="datetime">
- <span class="th-inner">申请时间<button type="button" class="filter-btn" data-col="applyTime" aria-label="过滤申请时间"></button></span>
- </th>
- </tr>
- </thead>
- <tbody id="tbody"></tbody>
- </table>
- </div>
- <div class="footer-bar">
- <span id="pageStat">显示 0 / 0 条(当前页)</span>
- <span>说明:本 Demo 仅过滤当前表格内数据,不请求后端</span>
- </div>
- </section>
- <div class="notes">
- <h2>各数据类型过滤建议</h2>
- <ul>
- <li><strong>文本 / 枚举</strong>:漏斗内「关键词搜索」缩小可选值,再多选勾选;枚举列(状态)可固定选项,不必依赖本页去重。</li>
- <li><strong>数字</strong>:提供运算符(等于 / 大于 / 小于 / 介于);「介于」用最小值~最大值;注意金额展示与比较用原始数值。</li>
- <li><strong>日期 / 时间</strong>:默认「开始日期~结束日期」闭区间;若业务要精确到时分,用 <code>datetime-local</code> 或拆成日期+时间两段。</li>
- <li><strong>组合</strong>:多列过滤取交集(AND);任一列清空后只影响该列。</li>
- <li><strong>与顶部筛选并存时</strong>:顶部筛选 = 查库;列头过滤 = 当前页二次过滤,文案上要区分,避免用户误以为筛了全库。</li>
- </ul>
- </div>
- </div>
- <!-- Shared popover -->
- <div class="popover" id="popover" role="dialog" aria-modal="false">
- <div class="popover-hd">
- <div class="title" id="popTitle">过滤</div>
- <div class="sub" id="popSub">按本列筛选当前页</div>
- </div>
- <div class="popover-bd" id="popBody"></div>
- <div class="popover-ft">
- <button type="button" id="popClear">清空本列</button>
- <div style="display:flex;gap:8px">
- <button type="button" id="popCancel">取消</button>
- <button type="button" class="primary" id="popOk">确定</button>
- </div>
- </div>
- </div>
- <script>
- const STATUS_MAP = {
- draft: { label: '待提交', cls: 'draft' },
- pending: { label: '审批中', cls: 'pending' },
- approved: { label: '审批通过', cls: 'approved' },
- rejected: { label: '审批驳回', cls: 'rejected' }
- }
- const COLUMNS = {
- applyCode: { label: '报销单号', type: 'text' },
- title: { label: '报销标题', type: 'text' },
- expenseName: { label: '费用项目', type: 'text' },
- amount: { label: '报销金额(元)', type: 'number' },
- status: { label: '状态', type: 'enum', enumOptions: Object.keys(STATUS_MAP) },
- applyTime: { label: '申请时间', type: 'datetime' }
- }
- /** Demo rows — mixed types */
- const DEMO_ROWS = [
- { applyCode: 'BX20260818001', title: '差旅报销-上海客户拜访', expenseName: '差旅费 / 交通费', amount: 1280.5, status: 'pending', applyTime: '2026-08-10 09:20:00' },
- { applyCode: 'BX20260818002', title: '办公用品采购', expenseName: '办公费 / 耗材', amount: 356, status: 'draft', applyTime: '2026-08-11 14:05:00' },
- { applyCode: 'BX20260818003', title: '团队团建餐费', expenseName: '业务招待费', amount: 980, status: 'approved', applyTime: '2026-08-12 18:40:00' },
- { applyCode: 'BX20260818004', title: '快递运费报销', expenseName: '物流费', amount: 86.2, status: 'rejected', applyTime: '2026-08-13 10:12:00' },
- { applyCode: 'BX20260818005', title: '培训报名费', expenseName: '培训费', amount: 2500, status: 'pending', applyTime: '2026-08-14 11:30:00' },
- { applyCode: 'BX20260818006', title: '差旅报销-广州展会', expenseName: '差旅费 / 住宿费', amount: 1860, status: 'approved', applyTime: '2026-08-15 08:55:00' },
- { applyCode: 'BX20260818007', title: '加油站加油费', expenseName: '车辆使用费', amount: 420.8, status: 'draft', applyTime: '2026-08-16 16:22:00' },
- { applyCode: 'BX20260818008', title: '软件订阅年费', expenseName: '信息化费用', amount: 3999, status: 'pending', applyTime: '2026-08-17 09:08:00' },
- { applyCode: 'BX20260818009', title: '客户招待午餐', expenseName: '业务招待费', amount: 680, status: 'approved', applyTime: '2026-08-17 13:45:00' },
- { applyCode: 'BX20260818010', title: '打印机维修', expenseName: '办公费 / 维修', amount: 230, status: 'draft', applyTime: '2026-08-18 10:01:00' }
- ]
- /** Active filters: { colKey: filterState } */
- const filters = {}
- let draft = null // { col, type, ... } while popover open
- let openCol = null
- const funnelSvg = `
- <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
- <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"/>
- </svg>`
- document.querySelectorAll('.filter-btn').forEach((btn) => {
- btn.innerHTML = funnelSvg
- btn.addEventListener('click', (e) => {
- e.stopPropagation()
- openFilter(btn.dataset.col, btn)
- })
- })
- function formatMoney(n) {
- return Number(n).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
- }
- function formatDateOnly(s) {
- return String(s || '').slice(0, 10)
- }
- function parseDate(s) {
- // Treat as local date for range compare (date part or full datetime)
- const t = Date.parse(String(s).replace(/-/g, '/'))
- return Number.isNaN(t) ? null : t
- }
- function startOfDay(yyyyMmDd) {
- return parseDate(yyyyMmDd + ' 00:00:00')
- }
- function endOfDay(yyyyMmDd) {
- return parseDate(yyyyMmDd + ' 23:59:59')
- }
- function displayValue(col, row) {
- if (col === 'status') return STATUS_MAP[row.status]?.label || row.status
- if (col === 'amount') return formatMoney(row.amount)
- if (col === 'applyTime') return formatDateOnly(row.applyTime)
- return String(row[col] ?? '')
- }
- function renderTable() {
- const tbody = document.getElementById('tbody')
- tbody.innerHTML = ''
- let visible = 0
- DEMO_ROWS.forEach((row, idx) => {
- const pass = matchAllFilters(row)
- if (pass) visible += 1
- const tr = document.createElement('tr')
- if (!pass) tr.classList.add('is-hidden')
- const st = STATUS_MAP[row.status]
- tr.innerHTML = `
- <td class="align-center">${idx + 1}</td>
- <td>${escapeHtml(row.applyCode)}</td>
- <td>${escapeHtml(row.title)}</td>
- <td>${escapeHtml(row.expenseName)}</td>
- <td class="align-right">${formatMoney(row.amount)}</td>
- <td class="align-center"><span class="tag ${st.cls}">${st.label}</span></td>
- <td class="align-center">${formatDateOnly(row.applyTime)}</td>
- `
- tbody.appendChild(tr)
- })
- document.getElementById('pageStat').textContent =
- `显示 ${visible} / ${DEMO_ROWS.length} 条(当前页)`
- updateFilterUi()
- }
- function escapeHtml(s) {
- return String(s)
- .replace(/&/g, '&')
- .replace(/</g, '<')
- .replace(/>/g, '>')
- .replace(/"/g, '"')
- }
- function matchAllFilters(row) {
- return Object.keys(filters).every((col) => matchOne(col, filters[col], row))
- }
- function matchOne(col, f, row) {
- if (!f) return true
- const meta = COLUMNS[col]
- const type = meta.type
- if (type === 'text' || type === 'enum') {
- const selected = f.selected
- if (!selected || selected.length === 0) return true
- const raw = type === 'enum' ? row[col] : String(row[col] ?? '')
- return selected.includes(raw)
- }
- if (type === 'number') {
- const val = Number(row[col])
- const op = f.op || 'between'
- if (op === 'eq') return f.value !== '' && val === Number(f.value)
- if (op === 'gt') return f.value !== '' && val > Number(f.value)
- if (op === 'lt') return f.value !== '' && val < Number(f.value)
- if (op === 'gte') return f.value !== '' && val >= Number(f.value)
- if (op === 'lte') return f.value !== '' && val <= Number(f.value)
- // between
- const minOk = f.min === '' || f.min == null || val >= Number(f.min)
- const maxOk = f.max === '' || f.max == null || val <= Number(f.max)
- if ((f.min === '' || f.min == null) && (f.max === '' || f.max == null)) return true
- return minOk && maxOk
- }
- if (type === 'datetime') {
- const t = parseDate(row[col])
- if (t == null) return false
- const fromOk = !f.from || t >= startOfDay(f.from)
- const toOk = !f.to || t <= endOfDay(f.to)
- if (!f.from && !f.to) return true
- return fromOk && toOk
- }
- return true
- }
- function updateFilterUi() {
- const activeCols = Object.keys(filters).filter((k) => isFilterActive(k, filters[k]))
- document.querySelectorAll('.filter-btn').forEach((btn) => {
- btn.classList.toggle('active', activeCols.includes(btn.dataset.col))
- })
- const hint = document.getElementById('activeFilterHint')
- const clearBtn = document.getElementById('btnClearAll')
- if (activeCols.length === 0) {
- hint.textContent = '当前无列过滤'
- clearBtn.disabled = true
- } else {
- hint.textContent = '已过滤列:' + activeCols.map((c) => COLUMNS[c].label).join('、')
- clearBtn.disabled = false
- }
- }
- function isFilterActive(col, f) {
- if (!f) return false
- const type = COLUMNS[col].type
- if (type === 'text' || type === 'enum') return Array.isArray(f.selected) && f.selected.length > 0
- if (type === 'number') {
- if (f.op === 'between') return (f.min !== '' && f.min != null) || (f.max !== '' && f.max != null)
- return f.value !== '' && f.value != null
- }
- if (type === 'datetime') return !!(f.from || f.to)
- return false
- }
- function uniqueValues(col) {
- const map = new Map()
- DEMO_ROWS.forEach((row) => {
- const key = COLUMNS[col].type === 'enum' ? row[col] : String(row[col] ?? '')
- map.set(key, (map.get(key) || 0) + 1)
- })
- return [...map.entries()].sort((a, b) => String(a[0]).localeCompare(String(b[0]), 'zh-CN'))
- }
- function openFilter(col, anchorBtn) {
- const meta = COLUMNS[col]
- openCol = col
- draft = cloneFilter(col, filters[col])
- const pop = document.getElementById('popover')
- document.getElementById('popTitle').textContent = '过滤 · ' + meta.label
- document.getElementById('popSub').textContent =
- meta.type === 'text' ? '文本:搜索并勾选本页出现的值'
- : meta.type === 'enum' ? '枚举:勾选状态(固定选项)'
- : meta.type === 'number' ? '数字:运算符 + 单值或区间'
- : '时间:按日期闭区间过滤(含起止当天)'
- document.getElementById('popBody').innerHTML = buildBodyHtml(col, draft)
- wireBodyEvents(col)
- positionPopover(pop, anchorBtn)
- pop.classList.add('open')
- }
- function cloneFilter(col, f) {
- const type = COLUMNS[col].type
- if (!f) {
- if (type === 'text' || type === 'enum') return { selected: [], keyword: '' }
- if (type === 'number') return { op: 'between', value: '', min: '', max: '' }
- if (type === 'datetime') return { from: '', to: '' }
- }
- return JSON.parse(JSON.stringify(f))
- }
- function buildBodyHtml(col, f) {
- const type = COLUMNS[col].type
- if (type === 'text' || type === 'enum') {
- const options = type === 'enum'
- ? COLUMNS[col].enumOptions.map((k) => [k, DEMO_ROWS.filter((r) => r.status === k).length])
- : uniqueValues(col)
- const items = options.map(([key, count]) => {
- const checked = f.selected.includes(key) ? 'checked' : ''
- const label = type === 'enum' ? STATUS_MAP[key].label : key
- return `
- <label class="value-item" data-key="${escapeHtml(key)}">
- <input type="checkbox" value="${escapeHtml(key)}" ${checked} />
- <span>${escapeHtml(label)}</span>
- <span class="count">${count}</span>
- </label>`
- }).join('')
- return `
- <div class="field">
- <label>搜索可选值</label>
- <input type="text" id="kwInput" placeholder="输入关键字过滤下方列表" value="${escapeHtml(f.keyword || '')}" />
- </div>
- <div class="select-all-row">
- <span>本页可选值</span>
- <span>
- <button type="button" id="btnSelectAll">全选</button>
- <button type="button" id="btnSelectNone">全不选</button>
- </span>
- </div>
- <div class="value-list" id="valueList">${items || '<div class="empty-tip">无数据</div>'}</div>
- `
- }
- if (type === 'number') {
- const showSingle = f.op !== 'between'
- return `
- <div class="field">
- <label>条件</label>
- <div class="ops">
- <select id="numOp">
- <option value="between" ${f.op === 'between' ? 'selected' : ''}>介于</option>
- <option value="eq" ${f.op === 'eq' ? 'selected' : ''}>等于</option>
- <option value="gt" ${f.op === 'gt' ? 'selected' : ''}>大于</option>
- <option value="gte" ${f.op === 'gte' ? 'selected' : ''}>大于等于</option>
- <option value="lt" ${f.op === 'lt' ? 'selected' : ''}>小于</option>
- <option value="lte" ${f.op === 'lte' ? 'selected' : ''}>小于等于</option>
- </select>
- <div id="numSingleWrap" style="${showSingle ? '' : 'display:none'}">
- <input type="number" id="numValue" step="0.01" placeholder="输入金额" value="${escapeHtml(f.value ?? '')}" />
- </div>
- </div>
- </div>
- <div class="field" id="numRangeWrap" style="${showSingle ? 'display:none' : ''}">
- <label>金额区间(元)</label>
- <div class="range-row">
- <input type="number" id="numMin" step="0.01" placeholder="最小值" value="${escapeHtml(f.min ?? '')}" />
- <span class="sep">至</span>
- <input type="number" id="numMax" step="0.01" placeholder="最大值" value="${escapeHtml(f.max ?? '')}" />
- </div>
- </div>
- <p style="margin:8px 0 0;font-size:11px;color:var(--text-muted)">
- 提示:比较使用原始数值;本页金额范围约 ${formatMoney(Math.min(...DEMO_ROWS.map(r => r.amount)))} ~ ${formatMoney(Math.max(...DEMO_ROWS.map(r => r.amount)))}
- </p>
- `
- }
- if (type === 'datetime') {
- return `
- <div class="field">
- <label>开始日期</label>
- <input type="date" id="dateFrom" value="${escapeHtml(f.from || '')}" />
- </div>
- <div class="field">
- <label>结束日期</label>
- <input type="date" id="dateTo" value="${escapeHtml(f.to || '')}" />
- </div>
- <p style="margin:8px 0 0;font-size:11px;color:var(--text-muted)">
- 闭区间:含起止当天 00:00:00~23:59:59。若需时分秒,可把控件换成 <code>datetime-local</code>。
- </p>
- `
- }
- return ''
- }
- function wireBodyEvents(col) {
- const type = COLUMNS[col].type
- if (type === 'text' || type === 'enum') {
- const kw = document.getElementById('kwInput')
- const list = document.getElementById('valueList')
- kw.addEventListener('input', () => {
- draft.keyword = kw.value.trim()
- const q = draft.keyword.toLowerCase()
- list.querySelectorAll('.value-item').forEach((el) => {
- const key = el.dataset.key
- const label = COLUMNS[col].type === 'enum' ? STATUS_MAP[key].label : key
- const hit = !q || label.toLowerCase().includes(q) || String(key).toLowerCase().includes(q)
- el.style.display = hit ? '' : 'none'
- })
- })
- list.addEventListener('change', (e) => {
- if (e.target.type !== 'checkbox') return
- const val = e.target.value
- if (e.target.checked) {
- if (!draft.selected.includes(val)) draft.selected.push(val)
- } else {
- draft.selected = draft.selected.filter((x) => x !== val)
- }
- })
- document.getElementById('btnSelectAll').addEventListener('click', () => {
- list.querySelectorAll('.value-item').forEach((el) => {
- if (el.style.display === 'none') return
- const cb = el.querySelector('input')
- cb.checked = true
- if (!draft.selected.includes(cb.value)) draft.selected.push(cb.value)
- })
- })
- document.getElementById('btnSelectNone').addEventListener('click', () => {
- list.querySelectorAll('.value-item').forEach((el) => {
- if (el.style.display === 'none') return
- const cb = el.querySelector('input')
- cb.checked = false
- draft.selected = draft.selected.filter((x) => x !== cb.value)
- })
- })
- }
- if (type === 'number') {
- const op = document.getElementById('numOp')
- const sync = () => {
- draft.op = op.value
- const single = draft.op !== 'between'
- document.getElementById('numSingleWrap').style.display = single ? '' : 'none'
- document.getElementById('numRangeWrap').style.display = single ? 'none' : ''
- }
- op.addEventListener('change', sync)
- ;['numValue', 'numMin', 'numMax'].forEach((id) => {
- const el = document.getElementById(id)
- if (!el) return
- el.addEventListener('input', () => {
- if (id === 'numValue') draft.value = el.value
- if (id === 'numMin') draft.min = el.value
- if (id === 'numMax') draft.max = el.value
- })
- })
- }
- if (type === 'datetime') {
- document.getElementById('dateFrom').addEventListener('change', (e) => { draft.from = e.target.value })
- document.getElementById('dateTo').addEventListener('change', (e) => { draft.to = e.target.value })
- }
- }
- function readDraftFromDom(col) {
- const type = COLUMNS[col].type
- if (type === 'number') {
- draft.op = document.getElementById('numOp').value
- draft.value = document.getElementById('numValue')?.value ?? ''
- draft.min = document.getElementById('numMin')?.value ?? ''
- draft.max = document.getElementById('numMax')?.value ?? ''
- }
- if (type === 'datetime') {
- draft.from = document.getElementById('dateFrom').value
- draft.to = document.getElementById('dateTo').value
- }
- }
- function applyDraft() {
- if (!openCol) return
- readDraftFromDom(openCol)
- // Validate number range
- if (COLUMNS[openCol].type === 'number' && draft.op === 'between') {
- if (draft.min !== '' && draft.max !== '' && Number(draft.min) > Number(draft.max)) {
- alert('最小值不能大于最大值')
- return
- }
- }
- if (COLUMNS[openCol].type === 'datetime') {
- if (draft.from && draft.to && draft.from > draft.to) {
- alert('开始日期不能晚于结束日期')
- return
- }
- }
- if (isFilterActive(openCol, draft)) {
- filters[openCol] = JSON.parse(JSON.stringify(draft))
- } else {
- delete filters[openCol]
- }
- closePopover()
- renderTable()
- }
- function clearCurrentCol() {
- if (!openCol) return
- delete filters[openCol]
- closePopover()
- renderTable()
- }
- function clearAllFilters() {
- Object.keys(filters).forEach((k) => delete filters[k])
- closePopover()
- renderTable()
- }
- function closePopover() {
- document.getElementById('popover').classList.remove('open')
- openCol = null
- draft = null
- }
- function positionPopover(pop, anchor) {
- const rect = anchor.getBoundingClientRect()
- const pad = 8
- let left = rect.left
- let top = rect.bottom + 6
- pop.style.left = '0px'
- pop.style.top = '0px'
- pop.classList.add('open')
- const pw = pop.offsetWidth
- const ph = pop.offsetHeight
- if (left + pw > window.innerWidth - pad) left = window.innerWidth - pw - pad
- if (left < pad) left = pad
- if (top + ph > window.innerHeight - pad) top = rect.top - ph - 6
- if (top < pad) top = pad
- pop.style.left = left + 'px'
- pop.style.top = top + 'px'
- }
- document.getElementById('popOk').addEventListener('click', applyDraft)
- document.getElementById('popCancel').addEventListener('click', closePopover)
- document.getElementById('popClear').addEventListener('click', clearCurrentCol)
- document.getElementById('btnClearAll').addEventListener('click', clearAllFilters)
- document.getElementById('btnResetData').addEventListener('click', () => {
- clearAllFilters()
- })
- document.addEventListener('click', (e) => {
- const pop = document.getElementById('popover')
- if (!pop.classList.contains('open')) return
- if (pop.contains(e.target)) return
- if (e.target.closest('.filter-btn')) return
- closePopover()
- })
- document.addEventListener('keydown', (e) => {
- if (e.key === 'Escape') closePopover()
- })
- window.addEventListener('resize', () => {
- if (document.getElementById('popover').classList.contains('open')) closePopover()
- })
- renderTable()
- </script>
- </body>
- </html>
|