feat(public-todo): 自绘下拉与日历,移除分类,美化危险操作确认
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 43s

- 下拉组件自绘:原生 select 展开后的选项面板由系统绘制,CSS 无法美化
- 日期选择器自绘:月历面板含上下月切换,附今天/明天/一周后/清除快捷项
- 移除分类:快捷栏、筛选栏、卡片标签、编辑弹窗、导入导出全清
- 删除与「清除已完成」改用自定义确认弹窗,替代浏览器原生 confirm
- 弹窗内面板用 fixed 定位并按剩余空间自动向上展开,避免被 overflow 裁剪
- 弹窗打开时屏蔽 N / 快捷键,避免误触发
This commit is contained in:
张家训
2026-10-19 19:05:36 +08:00
parent 0eaf88f006
commit 0e9ab36c94
+404 -96
View File
@@ -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;
/* ===== 自定义下拉 =====
原生 <select> 展开后的选项面板由系统绘制,CSS 无法美化,故整体自绘 */
.sel { position: relative; display: inline-block; vertical-align: middle; }
.field .sel { display: block; }
.sel-btn {
display: flex; align-items: center; gap: 7px; width: 100%;
font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--text);
background: #fbfcfe; border: 1.5px solid var(--line); border-radius: 11px;
padding: 9px 11px; cursor: pointer; white-space: nowrap; text-align: left;
transition: border-color .18s, box-shadow .18s, background .18s;
}
.sel-btn:hover { border-color: #d3dbec; background: #fff; }
.sel.open .sel-btn { border-color: var(--indigo); background: #fff; box-shadow: 0 0 0 4px rgba(99, 102, 241, .11); }
.sel-btn .chev { flex-shrink: 0; margin-left: auto; color: var(--text-muted); transition: transform .22s; }
.sel.open .sel-btn .chev { transform: rotate(180deg); }
.sel-menu {
position: absolute; top: calc(100% + 6px); left: 0; min-width: 100%; z-index: 30;
background: #fff; border: 1px solid var(--line); border-radius: 12px;
box-shadow: var(--shadow-lg); padding: 5px;
opacity: 0; transform: translateY(-6px) scale(.97); pointer-events: none;
transition: opacity .18s, transform .18s cubic-bezier(.16, 1, .3, 1);
}
.sel.open .sel-menu { opacity: 1; transform: none; pointer-events: auto; }
.sel-menu.up { top: auto; bottom: calc(100% + 6px); }
.sel-opt {
display: flex; align-items: center; gap: 8px; white-space: nowrap;
padding: 8px 11px; border-radius: 8px; font-size: 13.5px; font-weight: 600;
color: var(--text-sub); cursor: pointer;
transition: background .14s, color .14s;
}
.sel-opt:hover { background: #f1f4fa; color: var(--text); }
.sel-opt.on { background: rgba(99, 102, 241, .1); color: var(--indigo-dark); }
.sel-opt .tick { margin-left: auto; flex-shrink: 0; opacity: 0; color: var(--indigo); }
.sel-opt.on .tick { opacity: 1; }
.sel-txt.ph { color: var(--text-muted); font-weight: 500; }
/* ===== 日期选择器(原生 input[type=date] 的日历面板同样无法美化,故自绘) ===== */
.dp-panel { padding: 10px; min-width: 252px; }
.dp-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.dp-nav {
width: 26px; height: 26px; border: none; background: transparent; border-radius: 7px;
color: var(--text-sub); cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background .14s, color .14s;
}
.dp-nav:hover { background: #f1f4fa; color: var(--text); }
.dp-title { font-size: 13.5px; font-weight: 700; letter-spacing: .2px; }
.dp-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 2px; }
.dp-w { text-align: center; font-size: 11px; font-weight: 600; color: var(--text-muted); padding: 3px 0 5px; }
.dp-d {
height: 30px; border: none; background: transparent; border-radius: 8px;
font-family: inherit; font-size: 12.5px; font-weight: 600; color: var(--text);
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background .14s, color .14s;
}
.dp-d:hover { background: #eef1f8; }
.dp-d.out { color: var(--text-muted); opacity: .42; }
.dp-d.today { color: var(--indigo); box-shadow: inset 0 0 0 1.5px rgba(99, 102, 241, .38); }
.dp-d.on { background: linear-gradient(120deg, var(--indigo), var(--violet)); color: #fff; box-shadow: none; }
.dp-d.on:hover { background: linear-gradient(120deg, var(--indigo), var(--violet)); }
.dp-quick { display: flex; gap: 5px; margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--line); }
.dp-quick button {
flex: 1; font-family: inherit; font-size: 12px; font-weight: 600; color: var(--text-sub);
background: #f7f8fc; border: none; border-radius: 8px; padding: 6px 4px; cursor: pointer;
transition: background .14s, color .14s;
}
.dp-quick button:hover { background: rgba(99, 102, 241, .1); color: var(--indigo-dark); }
.dp-quick button.dp-clear:hover { background: #fff1f2; color: var(--rose); }
.add-bar { padding: 14px; display: flex; gap: 10px; flex-wrap: wrap; }
.add-bar .grow { flex: 1; min-width: 200px; }
@@ -149,7 +212,7 @@
border-radius: 20px; padding: 1px 7px; margin-left: 6px;
}
.filters .spacer { flex: 1; }
.filters select { width: auto; min-width: 118px; font-size: 13.5px; padding: 9px 32px 9px 12px; }
.filters .sel { min-width: 112px; }
.filters input[type="text"] { width: auto; min-width: 180px; font-size: 13.5px; padding: 9px 12px; }
/* ===== 任务列表 ===== */
@@ -187,7 +250,6 @@
.note { font-size: 13px; color: var(--text-sub); margin-top: 5px; word-break: break-word; white-space: pre-wrap; }
.meta { display: flex; align-items: center; gap: 8px; margin-top: 9px; flex-wrap: wrap; }
.tag { font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 7px; background: rgba(99, 102, 241, .1); color: var(--indigo-dark); }
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 7px; background: #f1f4fa; color: var(--text-sub); }
.chip.due-soon { background: #eff6ff; color: #1d4ed8; }
.chip.due-today { background: #fff7ed; color: #c2410c; }
@@ -243,6 +305,35 @@
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.modal-foot { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; }
/* ===== 危险操作确认弹窗(替代浏览器原生 confirm) ===== */
.confirm { max-width: 372px; padding: 26px 24px 20px; text-align: center; }
.confirm .ico {
width: 54px; height: 54px; margin: 0 auto 14px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: #fff1f2; color: var(--rose);
animation: popIcon .42s cubic-bezier(.16, 1, .3, 1);
}
@keyframes popIcon {
0% { transform: scale(.6); opacity: 0; }
55% { transform: scale(1.07); opacity: 1; }
100% { transform: scale(1); }
}
.confirm h2 { font-size: 17px; font-weight: 700; margin-bottom: 7px; }
.confirm p { font-size: 13.5px; color: var(--text-sub); line-height: 1.6; }
.confirm .quote {
margin-top: 13px; padding: 10px 14px; border-radius: 10px;
background: #f7f8fc; border: 1px solid var(--line);
font-size: 13.5px; font-weight: 600; color: var(--text);
word-break: break-word; text-align: left;
}
.confirm-foot { display: flex; gap: 10px; margin-top: 22px; }
.confirm-foot .btn { flex: 1; padding: 11px 20px; }
.btn-danger {
color: #fff; background: linear-gradient(120deg, var(--rose), #e11d48);
box-shadow: 0 6px 16px rgba(244, 63, 94, .3);
}
.btn-danger:hover { box-shadow: 0 9px 22px rgba(244, 63, 94, .42); }
/* ===== toast ===== */
.toast {
position: fixed; left: 50%; bottom: 34px; z-index: 60;
@@ -274,12 +365,13 @@
.ring-box { padding: 10px 16px 10px 12px; }
.add-bar { flex-direction: column; }
.add-bar .grow { min-width: 0; }
.add-bar .sel { width: 100%; }
.add-bar .btn-primary { width: 100%; }
.filters { gap: 8px; }
.tabs { width: 100%; }
.tab { flex: 1; padding: 8px 6px; text-align: center; }
.filters .spacer { display: none; }
.filters select, .filters input[type="text"] { flex: 1; min-width: 0; }
.filters .sel, .filters input[type="text"] { flex: 1; min-width: 0; }
.field-row { grid-template-columns: 1fr; }
.task { padding: 13px 13px 13px 16px; }
.foot-bar .hint { display: none; }
@@ -348,20 +440,8 @@
<div class="grow">
<input type="text" id="quickInput" placeholder="要做点什么?回车快速添加…" maxlength="200" autocomplete="off">
</div>
<select id="quickPriority" title="优先级">
<option value="3">🔴 高</option>
<option value="2" selected>🟡 中</option>
<option value="1">🟢 低</option>
</select>
<select id="quickCat" title="分类">
<option value="">无分类</option>
<option>工作</option>
<option>学习</option>
<option>生活</option>
<option>紧急</option>
<option>想法</option>
</select>
<input type="date" id="quickDue" title="截止日期" style="width:152px">
<span id="quickPriSlot"></span>
<span id="quickDueSlot"></span>
<button class="btn btn-primary" id="addBtn">添加</button>
</div>
</div>
@@ -373,21 +453,8 @@
<button class="tab" data-status="done">已完成<span class="badge" id="bDone">0</span></button>
</div>
<div class="spacer"></div>
<select id="filterPri" title="按优先级筛选">
<option value="">全部优先级</option>
<option value="3">高优先级</option>
<option value="2">中优先级</option>
<option value="1">低优先级</option>
</select>
<select id="filterCat" title="按分类筛选">
<option value="">全部分类</option>
</select>
<select id="sortBy" title="排序">
<option value="created">最新创建</option>
<option value="priority">优先级高→低</option>
<option value="due">截止日期近→远</option>
<option value="title">标题 A→Z</option>
</select>
<span id="filterPriSlot"></span>
<span id="sortSlot"></span>
<input type="text" id="searchInput" placeholder="搜索标题 / 备注…" autocomplete="off">
</div>
@@ -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 = '<svg class="chev" width="11" height="11" viewBox="0 0 12 12" aria-hidden="true"><path fill="currentColor" d="M6 8.5L1.5 4h9z"/></svg>'
var SVG_TICK = '<svg class="tick" width="12" height="12" viewBox="0 0 12 12" aria-hidden="true"><path d="M2 6.2l2.6 2.6L10 3.2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg>'
var SVG_TRASH = '<svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true">'
+ '<path d="M4 7h16M9.5 7V4.8h5V7M6.6 7l.8 12.1a1 1 0 001 .9h7.2a1 1 0 001-.9L17.4 7" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>'
+ '<path d="M10 11v5.5M14 11v5.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>'
// 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 = '<div class="sel" id="' + id + '" data-value="' + esc(String(value)) + '">'
h += '<button type="button" class="sel-btn"><span class="sel-txt">' + esc(cur.s || cur.t) + '</span>' + SVG_CHEV + '</button>'
h += '<div class="sel-menu">'
opts.forEach(function (o) {
h += '<div class="sel-opt' + (o.v === value ? ' on' : '') + '" data-value="' + esc(String(o.v)) + '"'
+ ' data-short="' + esc(o.s || o.t) + '">' + esc(o.t) + SVG_TICK + '</div>'
})
h += '</div></div>'
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 = '<svg width="8" height="8" viewBox="0 0 8 8" aria-hidden="true"><path d="M5.2 1L2.2 4l3 3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
var SVG_NEXT = '<svg width="8" height="8" viewBox="0 0 8 8" aria-hidden="true"><path d="M2.8 1l3 3-3 3" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'
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 '<div class="sel dp" id="' + id + '" data-value="' + esc(value || '') + '">'
+ '<button type="button" class="sel-btn"><span class="sel-txt' + (value ? '' : ' ph') + '">'
+ esc(value ? '📅 ' + value : '📅 截止日期') + '</span>' + SVG_CHEV + '</button>'
+ '<div class="sel-menu dp-panel"></div></div>'
}
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 = '<div class="dp-head">'
+ '<button type="button" class="dp-nav" data-dp="-1" aria-label="上个月">' + SVG_PREV + '</button>'
+ '<div class="dp-title">' + viewY + ' 年 ' + (viewM + 1) + ' 月</div>'
+ '<button type="button" class="dp-nav" data-dp="1" aria-label="下个月">' + SVG_NEXT + '</button>'
+ '</div><div class="dp-grid">'
WEEK_CN.forEach(function (w) { h += '<div class="dp-w">' + w + '</div>' })
cells.forEach(function (c) {
h += '<button type="button" class="dp-d' + (c.o ? ' out' : '') + (c.v === today ? ' today' : '')
+ (c.v === cur ? ' on' : '') + '" data-date="' + c.v + '">' + c.n + '</button>'
})
h += '</div><div class="dp-quick">'
+ '<button type="button" data-quick="today">今天</button>'
+ '<button type="button" data-quick="tomorrow">明天</button>'
+ '<button type="button" data-quick="week">一周后</button>'
+ '<button type="button" data-quick="clear" class="dp-clear">清除</button>'
+ '</div>'
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 = '<div class="mask" id="mask"><div class="modal confirm">'
+ '<div class="ico">' + SVG_TRASH + '</div>'
+ '<h2>' + esc(cfg.title) + '</h2>'
+ '<p>' + esc(cfg.text) + '</p>'
+ (cfg.quote ? '<div class="quote">' + esc(cfg.quote) + '</div>' : '')
+ '<div class="confirm-foot">'
+ '<button class="btn btn-ghost" id="cCancel">取消</button>'
+ '<button class="btn btn-danger" id="cOk">' + esc(cfg.okText || '删除') + '</button>'
+ '</div></div></div>'
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 = '<option value="">全部分类</option>'
Object.keys(cats).sort().forEach(function (c) {
html += '<option value="' + esc(c) + '">' + esc(c) + '</option>'
})
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 += '<div class="note">' + esc(item.remark) + '</div>'
html += '<div class="meta">'
html += '<span class="pri p' + p + '">' + priorityLabel(p) + '</span>'
if (item.category) html += '<span class="tag">' + esc(item.category) + '</span>'
if (item.dueDate) html += '<span class="' + chipCls + '">📅 ' + esc(dueText(item.dueDate)) + '</span>'
html += '</div></div>'
html += '<div class="actions">'
@@ -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 = '<div class="empty">'
+ '<span class="emo">' + (isFiltered ? '🔍' : '🌱') + '</span>'
+ '<h3>' + (isFiltered ? '没有符合条件的待办' : '清单还是空的') + '</h3>'
@@ -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 = '<div class="mask" id="mask"><div class="modal">'
+ '<h2>' + (isNew ? '新建待办' : '编辑待办') + '</h2>'
+ '<div class="field"><label>标题</label>'
+ '<input type="text" id="mTitle" maxlength="200" value="' + esc(item.title) + '" placeholder="要做什么?"></div>'
+ '<div class="field-row">'
+ '<div class="field"><label>优先级</label><select id="mPriority">'
+ '<option value="3"' + (item.priority === 3 ? ' selected' : '') + '>🔴 高</option>'
+ '<option value="2"' + ((item.priority || 2) === 2 ? ' selected' : '') + '>🟡 中</option>'
+ '<option value="1"' + (item.priority === 1 ? ' selected' : '') + '>🟢 低</option>'
+ '</select></div>'
+ '<div class="field"><label>分类</label><select id="mCategory">'
+ ['', '工作', '学习', '生活', '紧急', '想法'].map(function (c) {
return '<option value="' + c + '"' + (item.category === c ? ' selected' : '') + '>' + (c || '无分类') + '</option>'
}).join('')
+ '</select></div>'
+ '<div class="field"><label>优先级</label>' + selHtml('mPriority', String(item.priority || 2), PRI_OPTS) + '</div>'
+ '<div class="field"><label>截止日期</label>' + dateSelHtml('mDue', item.dueDate || '') + '</div>'
+ '</div>'
+ '<div class="field"><label>截止日期</label>'
+ '<input type="date" id="mDue" value="' + esc(item.dueDate || '') + '"></div>'
+ '<div class="field"><label>备注</label>'
+ '<textarea id="mRemark" maxlength="1000" placeholder="补充说明(可选)">' + esc(item.remark || '') + '</textarea></div>'
+ '<div class="modal-foot">'
@@ -720,6 +1009,8 @@
+ '</div></div></div>'
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() }
})