From 0e9ab36c945c3fd8d4528c6ccb4a364293cd11c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Mon, 19 Oct 2026 19:05:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(public-todo):=20=E8=87=AA=E7=BB=98?= =?UTF-8?q?=E4=B8=8B=E6=8B=89=E4=B8=8E=E6=97=A5=E5=8E=86=EF=BC=8C=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E5=88=86=E7=B1=BB=EF=BC=8C=E7=BE=8E=E5=8C=96=E5=8D=B1?= =?UTF-8?q?=E9=99=A9=E6=93=8D=E4=BD=9C=E7=A1=AE=E8=AE=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 下拉组件自绘:原生 select 展开后的选项面板由系统绘制,CSS 无法美化 - 日期选择器自绘:月历面板含上下月切换,附今天/明天/一周后/清除快捷项 - 移除分类:快捷栏、筛选栏、卡片标签、编辑弹窗、导入导出全清 - 删除与「清除已完成」改用自定义确认弹窗,替代浏览器原生 confirm - 弹窗内面板用 fixed 定位并按剩余空间自动向上展开,避免被 overflow 裁剪 - 弹窗打开时屏蔽 N / 快捷键,避免误触发 --- public/todo-public.html | 500 ++++++++++++++++++++++++++++++++-------- 1 file changed, 404 insertions(+), 96 deletions(-) diff --git a/public/todo-public.html b/public/todo-public.html index 83b7380..ecc61fa 100644 --- a/public/todo-public.html +++ b/public/todo-public.html @@ -101,21 +101,84 @@ /* ===== 通用控件 ===== */ .panel { background: #fff; border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); } - input[type="text"], input[type="date"], select, textarea { + input[type="text"], input[type="date"], textarea { font-family: inherit; font-size: 14px; color: var(--text); border: 1.5px solid var(--line); border-radius: 11px; padding: 10px 13px; outline: none; background: #fbfcfe; width: 100%; transition: border-color .18s, box-shadow .18s, background .18s; } - input[type="text"]:focus, input[type="date"]:focus, select:focus, textarea:focus { + input[type="text"]:focus, input[type="date"]:focus, textarea:focus { border-color: var(--indigo); background: #fff; box-shadow: 0 0 0 4px rgba(99, 102, 241, .11); } input::placeholder, textarea::placeholder { color: var(--text-muted); } - select { - cursor: pointer; appearance: none; padding-right: 32px; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8.5L1.5 4h9z'/%3E%3C/svg%3E"); - background-repeat: no-repeat; background-position: right 12px center; + + /* ===== 自定义下拉 ===== + 原生 - - - + + @@ -373,21 +453,8 @@
- - - + + @@ -430,7 +497,6 @@ items: [], status: 'all', priority: '', - category: '', sort: 'created', keyword: '', loading: true @@ -491,6 +557,249 @@ return item.status !== 1 && item.dueDate && item.dueDate < todayStr() } + /* ===== 下拉组件 ===== + 原生 select 展开面板由系统绘制无法美化,这里整体自绘。 + 每个下拉维护:容器 data-value 存当前值,.sel-txt 存按钮文案。 */ + var SVG_CHEV = '' + var SVG_TICK = '' + var SVG_TRASH = '' + + // opts: [{ v: 值, t: 选项文案, s: 选中后按钮显示的短文案(可选) }] + var PRI_OPTS = [ + { v: '3', t: '🔴 高优先级' }, + { v: '2', t: '🟡 中优先级' }, + { v: '1', t: '🟢 低优先级' } + ] + var FILTER_PRI_OPTS = [{ v: '', t: '全部优先级', s: '优先级' }].concat(PRI_OPTS) + var SORT_OPTS = [ + { v: 'created', t: '最新创建' }, + { v: 'priority', t: '优先级高→低' }, + { v: 'due', t: '截止日期近→远' }, + { v: 'title', t: '标题 A→Z' } + ] + + var openSel = null + + function closeAllSels() { + if (openSel) { openSel.classList.remove('open'); openSel = null } + } + + // 弹窗内 overflow:auto 会裁剪面板,故改用 fixed 定位,并按下方剩余空间决定向上/向下展开 + function placeMenu(sel) { + if (!sel.closest('.modal')) return + var btn = sel.querySelector('.sel-btn') + var panel = sel.querySelector('.sel-menu') + var r = btn.getBoundingClientRect() + panel.style.left = r.left + 'px' + panel.style.minWidth = r.width + 'px' + if (window.innerHeight - r.bottom < 320) { + panel.classList.add('up') + panel.style.top = 'auto' + panel.style.bottom = (window.innerHeight - r.top + 6) + 'px' + } else { + panel.classList.remove('up') + panel.style.bottom = 'auto' + panel.style.top = (r.bottom + 6) + 'px' + } + } + + function selHtml(id, value, opts) { + var cur = null + opts.forEach(function (o) { if (o.v === value) cur = o }) + cur = cur || opts[0] + var h = '
' + h += '' + h += '
' + opts.forEach(function (o) { + h += '
' + esc(o.t) + SVG_TICK + '
' + }) + h += '
' + return h + } + + function initSel(sel, onChange) { + if (sel.dataset.init) return sel + sel.dataset.init = '1' + var btn = sel.querySelector('.sel-btn') + var menu = sel.querySelector('.sel-menu') + + btn.addEventListener('click', function (e) { + e.stopPropagation() + var willOpen = !sel.classList.contains('open') + closeAllSels() + if (willOpen) { placeMenu(sel); sel.classList.add('open'); openSel = sel } + }) + + menu.addEventListener('click', function (e) { + var opt = e.target.closest('.sel-opt') + if (!opt) return + sel.dataset.value = opt.dataset.value + btn.querySelector('.sel-txt').textContent = opt.dataset.short + Array.prototype.forEach.call(menu.querySelectorAll('.sel-opt'), function (o) { + o.classList.toggle('on', o === opt) + }) + closeAllSels() + if (onChange) onChange(opt.dataset.value) + }) + + return sel + } + + function getSel(id) { + var el = $(id) + return el ? el.dataset.value : '' + } + + /* ===== 日期选择器 ===== */ + var WEEK_CN = ['一', '二', '三', '四', '五', '六', '日'] + var SVG_PREV = '' + var SVG_NEXT = '' + + function ymd(d) { + var m = d.getMonth() + 1 + var day = d.getDate() + return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day) + } + + function shiftStr(n) { + var d = new Date() + d.setDate(d.getDate() + n) + return ymd(d) + } + + function dateSelHtml(id, value) { + return '
' + + '' + + '
' + } + + function renderDatePanel(sel, viewY, viewM) { + var panel = sel.querySelector('.dp-panel') + var startIdx = (new Date(viewY, viewM, 1).getDay() + 6) % 7 // 周一作为一周起点 + var days = new Date(viewY, viewM + 1, 0).getDate() + var prevDays = new Date(viewY, viewM, 0).getDate() + var today = todayStr() + var cur = sel.dataset.value + + var cells = [] + var i + for (i = 0; i < startIdx; i++) { + cells.push({ n: prevDays - startIdx + 1 + i, v: ymd(new Date(viewY, viewM - 1, prevDays - startIdx + 1 + i)), o: 1 }) + } + for (i = 1; i <= days; i++) cells.push({ n: i, v: ymd(new Date(viewY, viewM, i)), o: 0 }) + var tail = (7 - cells.length % 7) % 7 + for (i = 1; i <= tail; i++) cells.push({ n: i, v: ymd(new Date(viewY, viewM + 1, i)), o: 1 }) + + var h = '
' + + '' + + '
' + viewY + ' 年 ' + (viewM + 1) + ' 月
' + + '' + + '
' + WEEK_CN.forEach(function (w) { h += '
' + w + '
' }) + cells.forEach(function (c) { + h += '' + }) + h += '
' + + '' + + '' + + '' + + '' + + '
' + panel.innerHTML = h + } + + function setDateSel(sel, value) { + sel.dataset.value = value || '' + var txt = sel.querySelector('.sel-txt') + txt.textContent = value ? '📅 ' + value : '📅 截止日期' + txt.classList.toggle('ph', !value) + } + + function initDate(sel, onChange) { + if (sel.dataset.init) return sel + sel.dataset.init = '1' + var btn = sel.querySelector('.sel-btn') + var panel = sel.querySelector('.dp-panel') + var view = { y: new Date().getFullYear(), m: new Date().getMonth() } + + function pick(v) { + setDateSel(sel, v) + closeAllSels() + if (onChange) onChange(v) + } + + btn.addEventListener('click', function (e) { + e.stopPropagation() + var willOpen = !sel.classList.contains('open') + closeAllSels() + if (!willOpen) return + var base = sel.dataset.value ? new Date(sel.dataset.value + 'T00:00:00') : new Date() + view = { y: base.getFullYear(), m: base.getMonth() } + renderDatePanel(sel, view.y, view.m) + placeMenu(sel) + sel.classList.add('open') + openSel = sel + }) + + panel.addEventListener('click', function (e) { + var nav = e.target.closest('[data-dp]') + if (nav) { + view.m += parseInt(nav.dataset.dp, 10) + if (view.m < 0) { view.m = 11; view.y -= 1 } + else if (view.m > 11) { view.m = 0; view.y += 1 } + renderDatePanel(sel, view.y, view.m) + return + } + var cell = e.target.closest('.dp-d') + if (cell) { pick(cell.dataset.date); return } + var q = e.target.closest('[data-quick]') + if (!q) return + var k = q.dataset.quick + if (k === 'today') pick(todayStr()) + else if (k === 'tomorrow') pick(shiftStr(1)) + else if (k === 'week') pick(shiftStr(7)) + else if (k === 'clear') pick('') + }) + + return sel + } + + /* ===== 危险操作确认弹窗(替代原生 confirm) ===== */ + function confirmDialog(cfg) { + return new Promise(function (resolve) { + elModalRoot.innerHTML = '
' + + function done(ok) { + elModalRoot.innerHTML = '' + document.removeEventListener('keydown', onKey) + resolve(ok) + } + function onKey(e) { + if (e.key === 'Escape') done(false) + else if (e.key === 'Enter') done(true) + } + + document.addEventListener('keydown', onKey) + $('mask').addEventListener('click', function (e) { if (e.target === $('mask')) done(false) }) + $('cCancel').addEventListener('click', function () { done(false) }) + $('cOk').addEventListener('click', function () { done(true) }) + setTimeout(function () { $('cOk').focus() }, 60) + }) + } + /* ===== 请求 ===== */ function post(path, data) { var body = new URLSearchParams() @@ -542,19 +851,6 @@ $('footStat').textContent = '共 ' + c.total + ' 条 · 已完成 ' + c.done + ' 条' } - function renderCategoryOptions() { - var sel = $('filterCat') - var prev = sel.value - var cats = {} - state.items.forEach(function (i) { if (i.category) cats[i.category] = 1 }) - var html = '' - Object.keys(cats).sort().forEach(function (c) { - html += '' - }) - sel.innerHTML = html - if (prev && cats[prev]) sel.value = prev - } - function priorityLabel(p) { return (p === 3 ? '🔴 ' : p === 1 ? '🟢 ' : '🟡 ') + PRI_TEXT[p || 2] } @@ -576,7 +872,6 @@ if (item.remark) html += '
' + esc(item.remark) + '
' html += '
' html += '' + priorityLabel(p) + '' - if (item.category) html += '' + esc(item.category) + '' if (item.dueDate) html += '📅 ' + esc(dueText(item.dueDate)) + '' html += '
' html += '
' @@ -592,7 +887,7 @@ return } if (!state.items.length) { - var isFiltered = state.status !== 'all' || state.priority || state.category || state.keyword + var isFiltered = state.status !== 'all' || state.priority || state.keyword elList.innerHTML = '
' + '' + (isFiltered ? '🔍' : '🌱') + '' + '

' + (isFiltered ? '没有符合条件的待办' : '清单还是空的') + '

' @@ -605,7 +900,6 @@ function renderAll() { renderStats() - renderCategoryOptions() renderList() } @@ -619,7 +913,6 @@ if (state.status === 'active') params.status = 0 if (state.status === 'done') params.status = 1 if (state.priority) params.priority = state.priority - if (state.category) params.category = state.category if (state.keyword) params.keyword = state.keyword return post('/list', params).then(function (res) { @@ -661,14 +954,13 @@ if (!title) { toast('先写点什么吧'); elQuick.focus(); return } var data = { title: title, - priority: $('quickPriority').value, - category: $('quickCat').value, - dueDate: $('quickDue').value + priority: getSel('quickPriority'), + dueDate: getSel('quickDue') } post('/add', data).then(function (res) { if (!res || res.code !== 200) throw new Error((res && res.message) || '添加失败') elQuick.value = '' - $('quickDue').value = '' + setDateSel($('quickDue'), '') toast('已添加') return fetchList(true).then(fetchStats) }).catch(function (e) { toast(e.message || '添加失败', true) }) @@ -681,37 +973,34 @@ }).catch(function (e) { toast(e.message || '操作失败', true) }) } - function removeTodo(id) { - if (!confirm('确定删除这条待办吗?')) return - post('/remove', { id: id }).then(function (res) { - if (!res || res.code !== 200) throw new Error((res && res.message) || '删除失败') - toast('已删除') - return fetchList(true).then(fetchStats) - }).catch(function (e) { toast(e.message || '删除失败', true) }) + function removeTodo(id, title) { + confirmDialog({ + title: '删除这条待办?', + text: '删除后不再显示,且无法恢复。', + quote: title, + okText: '删除' + }).then(function (ok) { + if (!ok) return + return post('/remove', { id: id }).then(function (res) { + if (!res || res.code !== 200) throw new Error((res && res.message) || '删除失败') + toast('已删除') + return fetchList(true).then(fetchStats) + }).catch(function (e) { toast(e.message || '删除失败', true) }) + }) } /* ===== 编辑弹窗 ===== */ function openEdit(item) { var isNew = !item - item = item || { priority: 2, category: '', dueDate: '', remark: '', title: '' } + item = item || { priority: 2, dueDate: '', remark: '', title: '' } var html = '
' elModalRoot.innerHTML = html + initSel($('mPriority')) + initDate($('mDue')) var mask = $('mask') var mTitle = $('mTitle') setTimeout(function () { mTitle.focus() }, 60) @@ -735,9 +1026,8 @@ if (!title) { toast('标题不能为空'); mTitle.focus(); return } var data = { title: title, - priority: $('mPriority').value, - category: $('mCategory').value, - dueDate: $('mDue').value, + priority: getSel('mPriority'), + dueDate: getSel('mDue'), remark: $('mRemark').value } var req @@ -789,7 +1079,6 @@ return post('/add', { title: it.title, priority: it.priority || 2, - category: it.category || '', dueDate: it.dueDate || '', remark: it.remark || '' }).then(function (r) { if (r && r.code === 200) okCount++ }) @@ -804,15 +1093,37 @@ } function clearDone() { - if (!confirm('确定清除所有已完成的待办吗?此操作不可撤销。')) return - post('/removeDone', {}).then(function (res) { - if (!res || res.code !== 200) throw new Error((res && res.message) || '清理失败') - toast('已清除') - return fetchList(true).then(fetchStats) - }).catch(function (e) { toast(e.message || '清理失败', true) }) + var n = computed().done + if (!n) { toast('没有已完成的待办'); return } + confirmDialog({ + title: '清除全部已完成?', + text: '共 ' + n + ' 条已完成的待办会被清除,此操作不可恢复。', + okText: '清除' + }).then(function (ok) { + if (!ok) return + return post('/removeDone', {}).then(function (res) { + if (!res || res.code !== 200) throw new Error((res && res.message) || '清理失败') + toast('已清除 ' + n + ' 条') + return fetchList(true).then(fetchStats) + }).catch(function (e) { toast(e.message || '清理失败', true) }) + }) } /* ===== 事件绑定 ===== */ + // 下拉:用 HTML 占位槽替换成自绘组件,避免原生 select 的展开面板无法美化 + $('quickPriSlot').outerHTML = selHtml('quickPriority', '2', PRI_OPTS) + $('quickDueSlot').outerHTML = dateSelHtml('quickDue', '') + $('filterPriSlot').outerHTML = selHtml('filterPri', '', FILTER_PRI_OPTS) + $('sortSlot').outerHTML = selHtml('sortBy', 'created', SORT_OPTS) + + initSel($('quickPriority')) + initDate($('quickDue')) + initSel($('filterPri'), function (v) { state.priority = v; fetchList() }) + initSel($('sortBy'), function (v) { state.sort = v; fetchList() }) + + // 点击空白处收起所有下拉 + document.addEventListener('click', function () { closeAllSels() }) + $('addBtn').addEventListener('click', addTodo) elQuick.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); addTodo() } @@ -844,10 +1155,6 @@ fetchList() }) - $('filterPri').addEventListener('change', function () { state.priority = this.value; fetchList() }) - $('filterCat').addEventListener('change', function () { state.category = this.value; fetchList() }) - $('sortBy').addEventListener('change', function () { state.sort = this.value; fetchList() }) - var searchTimer = null $('searchInput').addEventListener('input', function () { var v = this.value.trim() @@ -869,7 +1176,7 @@ if (!item) return if (act === 'toggle') toggleStatus(item.id, item.status) else if (act === 'edit') openEdit(item) - else if (act === 'del') removeTodo(item.id) + else if (act === 'del') removeTodo(item.id, item.title) }) $('exportBtn').addEventListener('click', exportJson) @@ -881,8 +1188,9 @@ $('clearDoneBtn').addEventListener('click', clearDone) document.addEventListener('keydown', function (e) { + if (elModalRoot.innerHTML) return // 弹窗打开时不响应快捷键 var tag = (e.target.tagName || '').toLowerCase() - if (tag === 'input' || tag === 'textarea' || tag === 'select') return + if (tag === 'input' || tag === 'textarea') return if (e.key === 'n' || e.key === 'N') { e.preventDefault(); openEdit(null) } else if (e.key === '/') { e.preventDefault(); $('searchInput').focus() } })