Files
shanxia2.0/docs/excel-column-filter-demo.html
T
2026-08-19 09:14:37 +08:00

948 lines
32 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
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>