948 lines
32 KiB
HTML
948 lines
32 KiB
HTML
<!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>
|