- 下拉组件自绘:原生 select 展开后的选项面板由系统绘制,CSS 无法美化 - 日期选择器自绘:月历面板含上下月切换,附今天/明天/一周后/清除快捷项 - 移除分类:快捷栏、筛选栏、卡片标签、编辑弹窗、导入导出全清 - 删除与「清除已完成」改用自定义确认弹窗,替代浏览器原生 confirm - 弹窗内面板用 fixed 定位并按剩余空间自动向上展开,避免被 overflow 裁剪 - 弹窗打开时屏蔽 N / 快捷键,避免误触发
This commit is contained in:
+404
-96
@@ -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() }
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user