Improve dashboard filter controls

This commit is contained in:
liuji
2026-10-10 18:23:23 +08:00
parent 3a15bb8352
commit 286a6d3255
+167 -55
View File
@@ -46,17 +46,33 @@
.alert[hidden] { display: none; } .alert[hidden] { display: none; }
.filters { display: grid; gap: 12px; margin: 0 0 20px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); } .filters { display: grid; gap: 12px; margin: 0 0 20px; padding: 16px 18px; border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
.filter-row { display: flex; flex-wrap: wrap; align-items: end; gap: 14px 20px; } .filter-row { display: flex; flex-wrap: wrap; align-items: end; gap: 14px 20px; }
.filter-group { display: grid; gap: 5px; } .filter-group { display: grid; gap: 5px; min-width: 160px; }
.filter-group > span, .filter-group > label { color: var(--muted); font-size: 13px; } .filter-group > span, .filter-group > label { color: var(--muted); font-size: 13px; }
.filter-group input, .filter-group select { min-height: 36px; padding: 6px 9px; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); font: inherit; } .filter-group input, .filter-group select { min-height: 36px; padding: 6px 9px; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); font: inherit; }
.filter-group select[multiple] { min-width: 220px; min-height: 96px; }
.filter-group select:disabled { background: #f1f4f8; color: var(--muted); } .filter-group select:disabled { background: #f1f4f8; color: var(--muted); }
.filter-help { color: var(--muted); font-size: 12px; } .currency-group { min-width: 150px; }
.range-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; } .currency-help, .filter-help, .rate-info { color: var(--muted); font-size: 12px; }
.range-button { min-height: 36px; padding: 6px 11px; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); cursor: pointer; font: inherit; } .currency-help { line-height: 1.35; }
.range-button:hover, .range-button:focus, .range-button[data-active="true"] { border-color: var(--primary); color: var(--primary); } .date-fields { display: flex; flex-wrap: wrap; gap: 12px 14px; align-items: end; }
.currency-help, .rate-info { color: var(--muted); font-size: 12px; } .date-fields[hidden] { display: none; }
.rate-info { display: flex; flex-wrap: wrap; gap: 5px 10px; align-items: center; } .multi-select { position: relative; min-width: 250px; }
.multi-select-trigger { display: flex; width: 100%; min-height: 36px; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 8px; background: #fff; color: var(--text); cursor: pointer; font: inherit; text-align: left; }
.multi-select-trigger::after { content: '⌄'; color: var(--muted); }
.multi-select-trigger:hover, .multi-select-trigger:focus, .multi-select-trigger[aria-expanded="true"] { border-color: var(--primary); outline: 2px solid rgba(37, 99, 235, 0.12); }
.multi-select-trigger:disabled { background: #f1f4f8; color: var(--muted); cursor: not-allowed; }
.multi-select-panel { position: absolute; z-index: 10; top: calc(100% + 6px); left: 0; width: min(320px, calc(100vw - 40px)); padding: 10px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); box-shadow: var(--shadow); }
.multi-select-panel[hidden] { display: none; }
.multi-select-search { width: 100%; min-height: 34px; margin-bottom: 8px; }
.multi-select-list { display: grid; max-height: 220px; gap: 2px; overflow-y: auto; padding: 2px; }
.multi-select-option { display: flex; min-height: 32px; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 6px; cursor: pointer; }
.multi-select-option:hover, .multi-select-option:focus-within { background: #f1f5ff; }
.multi-select-option input { width: 16px; height: 16px; min-height: 0; margin: 0; }
.multi-select-empty { padding: 8px 6px; color: var(--muted); font-size: 13px; }
.multi-select-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
.multi-select-action { min-height: 32px; padding: 5px 10px; border: 1px solid var(--border); border-radius: 7px; background: #fff; color: var(--text); cursor: pointer; font: inherit; }
.multi-select-action:hover, .multi-select-action:focus { border-color: var(--primary); color: var(--primary); }
.multi-select-action[data-action="confirm"] { border-color: var(--primary); background: var(--primary); color: #fff; }
.rate-info { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
.rate-info a { color: var(--primary); } .rate-info a { color: var(--primary); }
.metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; } .metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
.metric, .chart-card { border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); } .metric, .chart-card { border: 1px solid var(--border); border-radius: 14px; background: var(--surface); box-shadow: var(--shadow); }
@@ -83,6 +99,8 @@
.chart { height: 360px; } .chart { height: 360px; }
.filter-row { align-items: stretch; } .filter-row { align-items: stretch; }
.filter-group { flex: 1 1 145px; } .filter-group { flex: 1 1 145px; }
.multi-select { min-width: 0; }
.multi-select-panel { left: 50%; transform: translateX(-50%); max-width: calc(100vw - 32px); }
} }
</style> </style>
</head> </head>
@@ -103,34 +121,46 @@
<section class="filters" id="filters" aria-label="筛选与换算" hidden> <section class="filters" id="filters" aria-label="筛选与换算" hidden>
<div class="filter-row"> <div class="filter-row">
<div class="filter-group"> <div class="filter-group currency-group">
<label for="currency-select">显示币种</label> <label for="currency-select">显示币种</label>
<select id="currency-select"></select> <select id="currency-select"></select>
<span class="currency-help" id="currency-help"></span>
</div> </div>
<div class="filter-group"> <div class="filter-group">
<span>日期范围</span> <label for="range-select">日期范围</label>
<div class="range-actions"> <select id="range-select">
<button class="range-button" type="button" data-range="month">当月(按数据最新月份)</button> <option value="month">当月(按数据最新月份)</option>
<button class="range-button" type="button" data-range="recent">最近30天(按数据最新日期)</button> <option value="recent">最近30天</option>
<button class="range-button" type="button" data-range="all">全部</button> <option value="all">全部</option>
<option value="custom">自定义</option>
</select>
</div>
<div class="date-fields" id="date-fields" hidden>
<div class="filter-group">
<label for="start-date">开始日期</label>
<input id="start-date" type="date">
</div>
<div class="filter-group">
<label for="end-date">结束日期</label>
<input id="end-date" type="date">
</div> </div>
</div> </div>
</div>
<div class="filter-row">
<div class="filter-group"> <div class="filter-group">
<label for="start-date">开始日期</label> <label for="link-name-trigger">LinkName</label>
<input id="start-date" type="date"> <div class="multi-select" id="link-name-picker">
</div> <button class="multi-select-trigger" id="link-name-trigger" type="button" aria-haspopup="dialog" aria-expanded="false" aria-controls="link-name-panel" disabled>无可选 LinkName</button>
<div class="filter-group"> <div class="multi-select-panel" id="link-name-panel" role="dialog" aria-label="选择 LinkName" hidden>
<label for="end-date">结束日期</label> <input class="multi-select-search" id="link-name-search" type="search" placeholder="搜索 LinkName" aria-label="搜索 LinkName">
<input id="end-date" type="date"> <div class="multi-select-list" id="link-name-list" role="group"></div>
</div> <div class="multi-select-actions">
<div class="filter-group"> <button class="multi-select-action" type="button" data-action="select-all">全选</button>
<label for="link-name-select">LinkName</label> <button class="multi-select-action" type="button" data-action="clear">清空</button>
<select id="link-name-select" multiple size="4" disabled aria-describedby="link-name-summary"></select> <button class="multi-select-action" type="button" data-action="confirm">确定</button>
</div>
</div>
</div>
<span class="filter-help" id="link-name-summary" aria-live="polite">无可选 LinkName</span> <span class="filter-help" id="link-name-summary" aria-live="polite">无可选 LinkName</span>
</div> </div>
<div class="currency-help" id="currency-help"></div>
</div> </div>
<div class="rate-info" id="rate-info"> <div class="rate-info" id="rate-info">
<span id="rate-status">尚未需要汇率换算</span> <span id="rate-status">尚未需要汇率换算</span>
@@ -193,11 +223,17 @@
filters: document.getElementById('filters'), filters: document.getElementById('filters'),
currency: document.getElementById('currency-select'), currency: document.getElementById('currency-select'),
currencyHelp: document.getElementById('currency-help'), currencyHelp: document.getElementById('currency-help'),
rangeSelect: document.getElementById('range-select'),
dateFields: document.getElementById('date-fields'),
startDate: document.getElementById('start-date'), startDate: document.getElementById('start-date'),
endDate: document.getElementById('end-date'), endDate: document.getElementById('end-date'),
linkName: document.getElementById('link-name-select'), linkNamePicker: document.getElementById('link-name-picker'),
linkNameTrigger: document.getElementById('link-name-trigger'),
linkNamePanel: document.getElementById('link-name-panel'),
linkNameSearch: document.getElementById('link-name-search'),
linkNameList: document.getElementById('link-name-list'),
linkNameActions: Array.from(document.querySelectorAll('[data-action]')),
linkNameSummary: document.getElementById('link-name-summary'), linkNameSummary: document.getElementById('link-name-summary'),
rangeButtons: Array.from(document.querySelectorAll('[data-range]')),
rateStatus: document.getElementById('rate-status'), rateStatus: document.getElementById('rate-status'),
metrics: document.getElementById('metrics'), metrics: document.getElementById('metrics'),
recordCount: document.getElementById('record-count'), recordCount: document.getElementById('record-count'),
@@ -223,7 +259,9 @@
invalidCurrencyRows: 0, invalidCurrencyRows: 0,
syncLog: null, syncLog: null,
syncLogUnavailable: false, syncLogUnavailable: false,
selectedLinkNames: new Set() availableLinkNames: [],
selectedLinkNames: new Set(),
draftLinkNames: new Set()
}; };
function setStatus(message, status) { function setStatus(message, status) {
@@ -408,13 +446,19 @@
var dates = state.rawRecords.map(function (record) { return record.date; }).sort(function (a, b) { return a.time - b.time; }); var dates = state.rawRecords.map(function (record) { return record.date; }).sort(function (a, b) { return a.time - b.time; });
var first = dates[0]; var first = dates[0];
var latest = dates[dates.length - 1]; var latest = dates[dates.length - 1];
elements.rangeSelect.value = rangeName;
elements.dateFields.hidden = rangeName !== 'custom';
if (rangeName === 'custom') {
if (!elements.startDate.value) elements.startDate.value = first.key;
if (!elements.endDate.value) elements.endDate.value = latest.key;
return;
}
var start = first; var start = first;
if (rangeName === 'month') start = parseDate(latest.key.slice(0, 7) + '-01'); if (rangeName === 'month') start = parseDate(latest.key.slice(0, 7) + '-01');
if (rangeName === 'recent') start = new Date(latest.time - 29 * 24 * 60 * 60 * 1000); if (rangeName === 'recent') start = new Date(latest.time - 29 * 24 * 60 * 60 * 1000);
var startKey = rangeName === 'recent' ? new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())).toISOString().slice(0, 10) : start.key; var startKey = rangeName === 'recent' ? new Date(Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate())).toISOString().slice(0, 10) : start.key;
elements.startDate.value = startKey < first.key ? first.key : startKey; elements.startDate.value = rangeName === 'all' ? first.key : (startKey < first.key ? first.key : startKey);
elements.endDate.value = latest.key; elements.endDate.value = latest.key;
elements.rangeButtons.forEach(function (button) { button.dataset.active = button.dataset.range === rangeName ? 'true' : 'false'; });
} }
function currentRange() { function currentRange() {
@@ -425,15 +469,65 @@
} }
function updateLinkNameSummary() { function updateLinkNameSummary() {
var total = elements.linkName.options.length; var total = state.availableLinkNames.length;
var selected = elements.linkName.selectedOptions.length; var selected = state.selectedLinkNames.size;
var label;
if (!total) { if (!total) {
elements.linkNameSummary.textContent = '无可选 LinkName'; label = '无可选 LinkName';
} else if (selected === total) { } else if (selected === total) {
elements.linkNameSummary.textContent = '全部 LinkName(' + formatNumber(total) + ')'; label = '全部 LinkName';
} else { } else {
elements.linkNameSummary.textContent = '已选 ' + formatNumber(selected) + ' / ' + formatNumber(total) + ' 个 LinkName'; label = '已选 ' + formatNumber(selected) + ' 项';
} }
elements.linkNameTrigger.textContent = label;
elements.linkNameSummary.textContent = label;
}
function renderLinkNameOptions() {
elements.linkNameList.replaceChildren();
var query = elements.linkNameSearch.value.trim().toLocaleLowerCase();
var visibleCount = 0;
state.availableLinkNames.forEach(function (linkName) {
if (query && linkName.toLocaleLowerCase().indexOf(query) === -1) return;
visibleCount += 1;
var label = document.createElement('label');
label.className = 'multi-select-option';
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = linkName;
checkbox.checked = state.draftLinkNames.has(linkName);
checkbox.setAttribute('aria-label', linkName);
checkbox.addEventListener('change', function () {
if (checkbox.checked) state.draftLinkNames.add(linkName);
else state.draftLinkNames.delete(linkName);
});
var text = document.createElement('span');
text.textContent = linkName;
label.appendChild(checkbox);
label.appendChild(text);
elements.linkNameList.appendChild(label);
});
if (!visibleCount) {
var empty = document.createElement('div');
empty.className = 'multi-select-empty';
empty.textContent = '没有匹配的 LinkName';
elements.linkNameList.appendChild(empty);
}
}
function closeLinkNamePicker() {
elements.linkNamePanel.hidden = true;
elements.linkNameTrigger.setAttribute('aria-expanded', 'false');
}
function openLinkNamePicker() {
if (elements.linkNameTrigger.disabled) return;
state.draftLinkNames = new Set(state.selectedLinkNames);
elements.linkNameSearch.value = '';
renderLinkNameOptions();
elements.linkNamePanel.hidden = false;
elements.linkNameTrigger.setAttribute('aria-expanded', 'true');
elements.linkNameSearch.focus();
} }
function updateLinkNameOptions() { function updateLinkNameOptions() {
@@ -448,16 +542,12 @@
} catch (error) { } catch (error) {
available = []; available = [];
} }
elements.linkName.replaceChildren(); state.availableLinkNames = available;
available.forEach(function (linkName) {
var option = document.createElement('option');
option.value = linkName;
option.textContent = linkName;
option.selected = true;
elements.linkName.appendChild(option);
});
state.selectedLinkNames = new Set(available); state.selectedLinkNames = new Set(available);
elements.linkName.disabled = available.length === 0; state.draftLinkNames = new Set(available);
elements.linkNameTrigger.disabled = available.length === 0;
closeLinkNamePicker();
renderLinkNameOptions();
updateLinkNameSummary(); updateLinkNameSummary();
} }
@@ -696,25 +786,47 @@
state.selectedCurrency = elements.currency.value; state.selectedCurrency = elements.currency.value;
renderCurrentView(); renderCurrentView();
}); });
elements.linkName.addEventListener('change', function () { elements.rangeSelect.addEventListener('change', function () {
state.selectedLinkNames = new Set(Array.from(elements.linkName.selectedOptions).map(function (option) { setRange(elements.rangeSelect.value);
return option.value; updateLinkNameOptions();
}));
updateLinkNameSummary();
renderCurrentView(); renderCurrentView();
}); });
elements.startDate.addEventListener('change', function () { elements.startDate.addEventListener('change', function () {
elements.rangeButtons.forEach(function (button) { button.dataset.active = 'false'; });
updateLinkNameOptions(); updateLinkNameOptions();
renderCurrentView(); renderCurrentView();
}); });
elements.endDate.addEventListener('change', function () { elements.endDate.addEventListener('change', function () {
elements.rangeButtons.forEach(function (button) { button.dataset.active = 'false'; });
updateLinkNameOptions(); updateLinkNameOptions();
renderCurrentView(); renderCurrentView();
}); });
elements.rangeButtons.forEach(function (button) { elements.linkNameTrigger.addEventListener('click', function () {
button.addEventListener('click', function () { setRange(button.dataset.range); updateLinkNameOptions(); renderCurrentView(); }); if (elements.linkNamePanel.hidden) openLinkNamePicker();
else closeLinkNamePicker();
});
elements.linkNameSearch.addEventListener('input', renderLinkNameOptions);
elements.linkNameActions.forEach(function (button) {
button.addEventListener('click', function () {
var action = button.dataset.action;
if (action === 'select-all') state.draftLinkNames = new Set(state.availableLinkNames);
if (action === 'clear') state.draftLinkNames = new Set();
if (action === 'confirm') {
state.selectedLinkNames = new Set(state.draftLinkNames);
updateLinkNameSummary();
closeLinkNamePicker();
renderCurrentView();
return;
}
renderLinkNameOptions();
});
});
document.addEventListener('click', function (event) {
if (!elements.linkNamePicker.contains(event.target)) closeLinkNamePicker();
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && !elements.linkNamePanel.hidden) {
closeLinkNamePicker();
elements.linkNameTrigger.focus();
}
}); });
window.addEventListener('resize', function () { charts.forEach(function (chart) { chart.resize(); }); }); window.addEventListener('resize', function () { charts.forEach(function (chart) { chart.resize(); }); });
loadData(); loadData();