0aa7d3a293
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 26s
- 弹窗改为 flex 三段式:头部固定 / 内容滚动 / 底栏固定 保存按钮常驻底部可见,外层 overflow:hidden 保住 20px 圆角 - 滚动条收进 modal-body,细圆角样式,不再贯穿整个弹窗 - 放大拆两级:弹窗头部 ⤢ 放大整个弹窗(加宽到 880px); 编辑器工具栏 ⤢ 仅编辑器最大化(980px x 92vh 浮层) - Esc 优先还原编辑器最大化,再按一次才关闭弹窗
1440 lines
64 KiB
HTML
1440 lines
64 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<meta name="theme-color" content="#6366f1">
|
||
<title>待办清单 · 公开看板</title>
|
||
<style>
|
||
:root {
|
||
--indigo: #6366f1;
|
||
--indigo-dark: #4f46e5;
|
||
--violet: #8b5cf6;
|
||
--pink: #ec4899;
|
||
--rose: #f43f5e;
|
||
--amber: #f59e0b;
|
||
--emerald: #10b981;
|
||
--sky: #0ea5e9;
|
||
--text: #1e293b;
|
||
--text-sub: #64748b;
|
||
--text-muted: #94a3b8;
|
||
--line: #e8ecf4;
|
||
--radius: 16px;
|
||
--shadow-sm: 0 1px 2px rgba(30, 41, 59, .05);
|
||
--shadow: 0 4px 16px rgba(30, 41, 59, .07);
|
||
--shadow-lg: 0 18px 44px rgba(30, 41, 59, .13);
|
||
}
|
||
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
|
||
html { -webkit-text-size-adjust: 100%; }
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||
color: var(--text);
|
||
background: #f7f8fc;
|
||
min-height: 100vh;
|
||
-webkit-font-smoothing: antialiased;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
/* ===== 背景光斑 ===== */
|
||
.bg-deco { position: fixed; inset: 0; overflow: hidden; pointer-events: none; z-index: 0; }
|
||
.blob { position: absolute; border-radius: 50%; filter: blur(72px); opacity: .48; animation: float 16s ease-in-out infinite; }
|
||
.blob-1 { width: 460px; height: 460px; top: -170px; left: -130px; background: linear-gradient(135deg, #a5b4fc, #818cf8); }
|
||
.blob-2 { width: 420px; height: 420px; top: -110px; right: -150px; background: linear-gradient(135deg, #fbcfe8, #f9a8d4); animation-delay: -5s; }
|
||
.blob-3 { width: 380px; height: 380px; bottom: -190px; left: 38%; background: linear-gradient(135deg, #a7f3d0, #6ee7b7); animation-delay: -9s; }
|
||
@keyframes float {
|
||
0%, 100% { transform: translate(0, 0) scale(1); }
|
||
50% { transform: translate(24px, -30px) scale(1.07); }
|
||
}
|
||
|
||
.wrap { position: relative; z-index: 1; max-width: 1060px; margin: 0 auto; padding: 40px 20px 96px; }
|
||
|
||
/* ===== 头部 ===== */
|
||
.top { display: flex; align-items: center; justify-content: space-between; gap: 24px; margin-bottom: 26px; flex-wrap: wrap; }
|
||
.brand h1 {
|
||
font-size: 30px; font-weight: 800; letter-spacing: -.4px;
|
||
background: linear-gradient(120deg, #4f46e5, #8b5cf6 45%, #ec4899);
|
||
-webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent;
|
||
color: var(--indigo-dark);
|
||
}
|
||
.brand p { color: var(--text-sub); font-size: 13.5px; margin-top: 5px; }
|
||
.brand .dot {
|
||
display: inline-block; width: 6px; height: 6px; border-radius: 50%;
|
||
background: var(--emerald); margin: 0 6px 2px 2px;
|
||
box-shadow: 0 0 0 3px rgba(16, 185, 129, .16);
|
||
}
|
||
|
||
.ring-box {
|
||
display: flex; align-items: center; gap: 14px;
|
||
background: rgba(255, 255, 255, .8); backdrop-filter: blur(14px);
|
||
border: 1px solid rgba(255, 255, 255, .9);
|
||
border-radius: 18px; padding: 12px 20px 12px 14px; box-shadow: var(--shadow);
|
||
}
|
||
.ring { position: relative; width: 64px; height: 64px; flex-shrink: 0; }
|
||
.ring svg { transform: rotate(-90deg); display: block; }
|
||
.ring .track { stroke: #eef1f8; }
|
||
.ring .bar { stroke: url(#ringGrad); stroke-linecap: round; transition: stroke-dashoffset .7s cubic-bezier(.4, 0, .2, 1); }
|
||
.ring .pct { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800; color: var(--indigo-dark); }
|
||
.ring-txt .big { font-size: 14px; font-weight: 700; }
|
||
.ring-txt .small { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
|
||
|
||
/* ===== 统计卡片 ===== */
|
||
.stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 22px; }
|
||
.stat {
|
||
position: relative; overflow: hidden; background: #fff; border: 1px solid var(--line);
|
||
border-radius: var(--radius); padding: 16px 18px; box-shadow: var(--shadow-sm);
|
||
cursor: pointer; transition: transform .18s, box-shadow .18s, border-color .18s;
|
||
}
|
||
.stat:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
|
||
.stat.on { border-color: currentColor; }
|
||
.stat::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: currentColor; opacity: .85; }
|
||
.stat .lbl { font-size: 12.5px; color: var(--text-sub); }
|
||
.stat .num { font-size: 27px; font-weight: 800; margin-top: 4px; letter-spacing: -.5px; color: var(--text); }
|
||
.stat.total { color: var(--indigo); }
|
||
.stat.active { color: var(--amber); }
|
||
.stat.done { color: var(--emerald); }
|
||
.stat.overdue { color: var(--rose); }
|
||
|
||
/* ===== 通用控件 ===== */
|
||
.panel { background: #fff; border: 1px solid var(--line); border-radius: var(--radius); box-shadow: var(--shadow); }
|
||
|
||
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, 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> 展开后的选项面板由系统绘制,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); }
|
||
|
||
/* ===== 富文本编辑器(contenteditable + execCommand 自研) ===== */
|
||
.rte { border: 1.5px solid var(--line); border-radius: 12px; background: #fbfcfe; overflow: hidden; transition: border-color .18s, box-shadow .18s, background .18s; }
|
||
.rte:focus-within { border-color: var(--indigo); background: #fff; box-shadow: 0 0 0 4px rgba(99, 102, 241, .11); }
|
||
.rte-bar { display: flex; align-items: center; gap: 3px; flex-wrap: wrap; padding: 7px 8px; border-bottom: 1px solid var(--line); background: #fff; }
|
||
.rte-bar .spacer { flex: 1; }
|
||
.rte-btn {
|
||
min-width: 28px; height: 28px; padding: 0 4px; border: none; background: transparent; border-radius: 7px;
|
||
color: var(--text-sub); cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
|
||
font-size: 13px; font-weight: 600; transition: background .14s, color .14s;
|
||
}
|
||
.rte-btn:hover { background: #eef1f8; color: var(--text); }
|
||
.rte-btn.on { background: rgba(99, 102, 241, .13); color: var(--indigo-dark); }
|
||
.rte-btn.txt { padding: 0 10px; font-size: 12.5px; gap: 4px; }
|
||
.rte-btn.warn:hover { background: #fff1f2; color: var(--rose); }
|
||
.rte-sep { width: 1px; height: 16px; background: var(--line); margin: 0 4px; }
|
||
.rte-body {
|
||
padding: 12px 14px; min-height: 150px; max-height: 44vh; overflow-y: auto;
|
||
font-size: 14px; line-height: 1.75; color: var(--text); outline: none; word-break: break-word;
|
||
}
|
||
.rte-body:empty::before { content: attr(data-ph); color: var(--text-muted); pointer-events: none; }
|
||
|
||
/* 弹窗级放大:弹窗头部 ⤢ 按钮切换,加宽整个弹窗 */
|
||
.modal.lg { max-width: 880px; }
|
||
/* 编辑器级最大化:工具栏 ⤢ 按钮切换,编辑器浮层铺满视口 */
|
||
.rte.max {
|
||
position: fixed; z-index: 80; top: 4vh; left: 50%; transform: translateX(-50%);
|
||
width: min(980px, 94vw); height: 92vh; display: flex; flex-direction: column;
|
||
background: #fff; border-color: transparent; border-radius: 16px;
|
||
box-shadow: 0 24px 64px rgba(15, 23, 42, .4);
|
||
}
|
||
.rte.max .rte-body { flex: 1; min-height: 0; max-height: none; padding: 18px 22px; font-size: 15px; }
|
||
|
||
/* 富文本排版(编辑器与卡片展开共用 .fmt) */
|
||
.fmt h2 { font-size: 17px; font-weight: 700; margin: 10px 0 5px; letter-spacing: -.2px; }
|
||
.fmt p { margin: 4px 0; }
|
||
.fmt blockquote { border-left: 3px solid var(--indigo); margin: 8px 0; padding: 5px 12px; background: #f5f6ff; border-radius: 0 8px 8px 0; color: var(--text-sub); }
|
||
.fmt pre { background: #1e293b; color: #e2e8f0; border-radius: 9px; padding: 11px 13px; font-size: 12.5px; font-family: Consolas, Monaco, monospace; overflow-x: auto; margin: 8px 0; white-space: pre-wrap; }
|
||
.fmt code { background: #eef1f8; border-radius: 5px; padding: 1px 5px; font-size: .92em; color: var(--indigo-dark); font-family: Consolas, Monaco, monospace; }
|
||
.fmt pre code { background: transparent; color: inherit; padding: 0; }
|
||
.fmt ul, .fmt ol { padding-left: 22px; margin: 6px 0; }
|
||
.fmt a { color: var(--indigo); text-decoration: underline; }
|
||
.fmt .ck-line { display: flex; align-items: flex-start; gap: 7px; margin: 3px 0; }
|
||
.fmt .ck-box {
|
||
flex-shrink: 0; width: 16px; height: 16px; margin-top: 4px; border: 1.6px solid #cbd5e1;
|
||
border-radius: 5px; background: #fff; cursor: pointer; user-select: none;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
}
|
||
.fmt .ck-box.ck-checked { background: linear-gradient(135deg, var(--indigo), var(--violet)); border-color: transparent; }
|
||
.fmt .ck-box.ck-checked::after { content: '✓'; color: #fff; font-size: 11px; font-weight: 800; }
|
||
|
||
/* 卡片备注:默认纯文本摘要,点击展开完整富文本 */
|
||
.note-box { margin-top: 6px; cursor: pointer; font-size: 13px; color: var(--text-sub); word-break: break-word; }
|
||
.note-box .plain { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||
.note-box .full { display: none; }
|
||
.note-box.open .plain { display: none; }
|
||
.note-box.open .full { display: block; padding: 10px 13px; background: #fafbff; border: 1px solid var(--line); border-radius: 10px; line-height: 1.75; cursor: default; }
|
||
.note-box .full .ck-box { pointer-events: none; }
|
||
|
||
.add-bar { padding: 14px; display: flex; gap: 10px; flex-wrap: wrap; }
|
||
.add-bar .grow { flex: 1; min-width: 200px; }
|
||
.add-bar .grow input[type="text"] { border: none; background: transparent; font-size: 15px; padding-left: 4px; }
|
||
.add-bar .grow input[type="text"]:focus { box-shadow: none; background: transparent; }
|
||
|
||
.btn {
|
||
font-family: inherit; font-size: 14px; font-weight: 600; border: none;
|
||
border-radius: 11px; padding: 10px 20px; cursor: pointer; white-space: nowrap;
|
||
transition: transform .15s, box-shadow .18s, background .18s;
|
||
}
|
||
.btn:active { transform: scale(.97); }
|
||
.btn-primary { color: #fff; background: linear-gradient(120deg, var(--indigo), var(--violet)); box-shadow: 0 6px 16px rgba(99, 102, 241, .32); }
|
||
.btn-primary:hover { box-shadow: 0 9px 22px rgba(99, 102, 241, .42); }
|
||
.btn-ghost { color: var(--text-sub); background: #f1f4fa; border: 1px solid var(--line); }
|
||
.btn-ghost:hover { background: #e9eef8; color: var(--text); }
|
||
|
||
/* ===== 筛选 ===== */
|
||
.filters { display: flex; align-items: center; gap: 10px; margin: 18px 0 14px; flex-wrap: wrap; }
|
||
.tabs { display: inline-flex; background: #eef1f8; border-radius: 12px; padding: 4px; gap: 2px; }
|
||
.tab {
|
||
font-family: inherit; font-size: 13.5px; font-weight: 600; color: var(--text-sub);
|
||
border: none; background: transparent; padding: 7px 16px; border-radius: 9px;
|
||
cursor: pointer; transition: all .2s;
|
||
}
|
||
.tab:hover { color: var(--text); }
|
||
.tab.on { background: #fff; color: var(--indigo-dark); box-shadow: var(--shadow-sm); }
|
||
.tab .badge {
|
||
display: inline-block; font-size: 11px; font-weight: 700;
|
||
background: rgba(99, 102, 241, .13); color: var(--indigo-dark);
|
||
border-radius: 20px; padding: 1px 7px; margin-left: 6px;
|
||
}
|
||
.filters .spacer { flex: 1; }
|
||
.filters .sel { min-width: 112px; }
|
||
.filters input[type="text"] { width: auto; min-width: 180px; font-size: 13.5px; padding: 9px 12px; }
|
||
|
||
/* ===== 任务列表 ===== */
|
||
.list { display: flex; flex-direction: column; gap: 10px; }
|
||
|
||
.task {
|
||
position: relative; overflow: hidden; display: flex; align-items: flex-start; gap: 13px;
|
||
background: #fff; border: 1px solid var(--line); border-radius: 14px;
|
||
padding: 15px 16px 15px 18px; box-shadow: var(--shadow-sm);
|
||
transition: transform .16s, box-shadow .2s, border-color .2s, opacity .2s;
|
||
animation: slideIn .28s cubic-bezier(.16, 1, .3, 1) backwards;
|
||
}
|
||
@keyframes slideIn { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } }
|
||
.task:hover { box-shadow: var(--shadow); border-color: #dde3f0; }
|
||
.task::before { content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--p-color); }
|
||
.task.p1 { --p-color: var(--emerald); }
|
||
.task.p2 { --p-color: var(--amber); }
|
||
.task.p3 { --p-color: var(--rose); }
|
||
.task.is-done { opacity: .58; }
|
||
.task.is-done .title { text-decoration: line-through; color: var(--text-muted); }
|
||
.task.is-overdue:not(.is-done) { border-color: #fecdd3; background: #fffafb; }
|
||
|
||
.check {
|
||
flex-shrink: 0; width: 22px; height: 22px; margin-top: 1px;
|
||
border: 2px solid #cbd5e1; border-radius: 50%; cursor: pointer; background: #fff;
|
||
display: flex; align-items: center; justify-content: center; transition: all .2s;
|
||
}
|
||
.check:hover { border-color: var(--indigo); transform: scale(1.08); }
|
||
.check svg { opacity: 0; transform: scale(.5); transition: all .2s; }
|
||
.task.is-done .check { background: linear-gradient(135deg, var(--emerald), #34d399); border-color: transparent; }
|
||
.task.is-done .check svg { opacity: 1; transform: scale(1); }
|
||
|
||
.body { flex: 1; min-width: 0; }
|
||
.title { font-size: 15px; font-weight: 600; word-break: break-word; }
|
||
.meta { display: flex; align-items: center; gap: 8px; margin-top: 9px; flex-wrap: wrap; }
|
||
|
||
.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; }
|
||
.chip.due-overdue { background: #fff1f2; color: #be123c; }
|
||
.pri { font-size: 11.5px; font-weight: 700; padding: 3px 9px; border-radius: 7px; }
|
||
.pri.p1 { background: rgba(16, 185, 129, .12); color: #047857; }
|
||
.pri.p2 { background: rgba(245, 158, 11, .14); color: #b45309; }
|
||
.pri.p3 { background: rgba(244, 63, 94, .12); color: #be123c; }
|
||
|
||
.actions { display: flex; gap: 4px; flex-shrink: 0; opacity: 0; transition: opacity .18s; }
|
||
.task:hover .actions { opacity: 1; }
|
||
@media (hover: none) { .actions { opacity: 1; } }
|
||
.icon-btn {
|
||
width: 30px; height: 30px; border: none; background: transparent; border-radius: 8px;
|
||
color: var(--text-muted); cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||
transition: all .18s;
|
||
}
|
||
.icon-btn:hover { background: #f1f4fa; color: var(--text); }
|
||
.icon-btn.del:hover { background: #fff1f2; color: var(--rose); }
|
||
|
||
/* ===== 空状态 / 加载 ===== */
|
||
.empty { text-align: center; padding: 66px 20px; background: #fff; border: 1px dashed #dfe5f0; border-radius: var(--radius); }
|
||
.empty .emo { font-size: 44px; margin-bottom: 14px; display: block; animation: bounce 2.4s ease-in-out infinite; }
|
||
@keyframes bounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-8px); } }
|
||
.empty h3 { font-size: 16px; font-weight: 700; margin-bottom: 6px; }
|
||
.empty p { font-size: 13.5px; color: var(--text-muted); }
|
||
|
||
.loading { text-align: center; padding: 60px 20px; color: var(--text-muted); font-size: 14px; }
|
||
.spinner {
|
||
width: 30px; height: 30px; margin: 0 auto 14px;
|
||
border: 3px solid #eef1f8; border-top-color: var(--indigo);
|
||
border-radius: 50%; animation: spin .8s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* ===== 弹窗 ===== */
|
||
.mask {
|
||
position: fixed; inset: 0; z-index: 50; background: rgba(15, 23, 42, .42);
|
||
backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center;
|
||
padding: 20px; animation: fadeIn .2s; overflow-y: auto;
|
||
}
|
||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||
.modal {
|
||
width: 100%; max-width: 480px; background: #fff; border-radius: 20px;
|
||
box-shadow: var(--shadow-lg); margin: auto; padding: 0;
|
||
animation: popIn .26s cubic-bezier(.16, 1, .3, 1);
|
||
max-height: 88vh; display: flex; flex-direction: column; overflow: hidden;
|
||
}
|
||
@keyframes popIn { from { opacity: 0; transform: scale(.94) translateY(12px); } to { opacity: 1; transform: scale(1) translateY(0); } }
|
||
/* 三段式布局:头部固定 / 内容滚动 / 底栏固定,外层 overflow:hidden 保住圆角 */
|
||
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 20px 24px 14px; flex-shrink: 0; }
|
||
.modal-head h2 { font-size: 18px; font-weight: 700; margin: 0; }
|
||
.modal-body { padding: 2px 24px 6px; overflow-y: auto; flex: 1; min-height: 0; }
|
||
.modal-body::-webkit-scrollbar, .rte-body::-webkit-scrollbar { width: 6px; }
|
||
.modal-body::-webkit-scrollbar-thumb, .rte-body::-webkit-scrollbar-thumb { background: #d5dbea; border-radius: 99px; }
|
||
.modal-body::-webkit-scrollbar-thumb:hover, .rte-body::-webkit-scrollbar-thumb:hover { background: #b6c0d8; }
|
||
.modal-body::-webkit-scrollbar-track, .rte-body::-webkit-scrollbar-track { background: transparent; }
|
||
.modal-foot { display: flex; gap: 10px; justify-content: flex-end; padding: 14px 24px; flex-shrink: 0; border-top: 1px solid var(--line); background: #fff; }
|
||
.mzoom {
|
||
width: 30px; height: 30px; border: none; background: #f1f4fa; border-radius: 8px;
|
||
color: var(--text-sub); cursor: pointer; font-size: 14px; flex-shrink: 0;
|
||
display: inline-flex; align-items: center; justify-content: center;
|
||
transition: background .14s, color .14s;
|
||
}
|
||
.mzoom:hover { background: rgba(99, 102, 241, .13); color: var(--indigo-dark); }
|
||
.field { margin-bottom: 15px; }
|
||
.field label { display: block; font-size: 13px; font-weight: 600; color: var(--text-sub); margin-bottom: 7px; }
|
||
.field textarea { resize: vertical; min-height: 76px; }
|
||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
|
||
/* ===== 危险操作确认弹窗(替代浏览器原生 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;
|
||
transform: translateX(-50%) translateY(20px);
|
||
background: #1e293b; color: #fff; font-size: 13.5px; font-weight: 500;
|
||
padding: 11px 22px; border-radius: 12px; box-shadow: var(--shadow-lg);
|
||
opacity: 0; pointer-events: none; transition: all .28s;
|
||
max-width: 82vw; text-align: center;
|
||
}
|
||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||
.toast.err { background: #be123c; }
|
||
|
||
/* ===== 底部 ===== */
|
||
.foot-bar { display: flex; align-items: center; gap: 10px; margin-top: 20px; flex-wrap: wrap; font-size: 12.5px; color: var(--text-muted); }
|
||
.foot-bar .spacer { flex: 1; }
|
||
.foot-bar kbd {
|
||
font-family: inherit; font-size: 11px; font-weight: 700; color: var(--text-sub);
|
||
background: #eef1f8; border: 1px solid #dfe5f0; border-bottom-width: 2px;
|
||
border-radius: 5px; padding: 1px 6px;
|
||
}
|
||
.foot-bar .btn { padding: 6px 14px; font-size: 12.5px; }
|
||
|
||
/* ===== 响应式 ===== */
|
||
@media (max-width: 860px) { .stats { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 620px) {
|
||
.wrap { padding: 24px 14px 80px; }
|
||
.brand h1 { font-size: 24px; }
|
||
.top { gap: 16px; }
|
||
.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 .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; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div class="bg-deco">
|
||
<div class="blob blob-1"></div>
|
||
<div class="blob blob-2"></div>
|
||
<div class="blob blob-3"></div>
|
||
</div>
|
||
|
||
<div class="wrap">
|
||
|
||
<div class="top">
|
||
<div class="brand">
|
||
<h1>待办清单</h1>
|
||
<p><span class="dot"></span>公开看板 · 无需登录 · 拿到链接谁都能用</p>
|
||
</div>
|
||
<div class="ring-box">
|
||
<div class="ring">
|
||
<svg width="64" height="64" viewBox="0 0 64 64">
|
||
<defs>
|
||
<linearGradient id="ringGrad" x1="0" y1="0" x2="1" y2="1">
|
||
<stop offset="0%" stop-color="#6366f1"/>
|
||
<stop offset="55%" stop-color="#8b5cf6"/>
|
||
<stop offset="100%" stop-color="#ec4899"/>
|
||
</linearGradient>
|
||
</defs>
|
||
<circle class="track" cx="32" cy="32" r="27" fill="none" stroke-width="7"/>
|
||
<circle class="bar" id="ringBar" cx="32" cy="32" r="27" fill="none"
|
||
stroke-width="7" stroke-dasharray="169.65" stroke-dashoffset="169.65"/>
|
||
</svg>
|
||
<div class="pct" id="ringPct">0%</div>
|
||
</div>
|
||
<div class="ring-txt">
|
||
<div class="big" id="ringHead">加载中…</div>
|
||
<div class="small" id="ringSub">正在读取待办清单</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="stats" id="stats">
|
||
<div class="stat total on" data-status="all">
|
||
<div class="lbl">📋 全部</div>
|
||
<div class="num" id="sTotal">0</div>
|
||
</div>
|
||
<div class="stat active" data-status="active">
|
||
<div class="lbl">⏳ 进行中</div>
|
||
<div class="num" id="sActive">0</div>
|
||
</div>
|
||
<div class="stat done" data-status="done">
|
||
<div class="lbl">✅ 已完成</div>
|
||
<div class="num" id="sDone">0</div>
|
||
</div>
|
||
<div class="stat overdue" data-status="overdue">
|
||
<div class="lbl">🔥 已逾期</div>
|
||
<div class="num" id="sOverdue">0</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="panel">
|
||
<div class="add-bar">
|
||
<div class="grow">
|
||
<input type="text" id="quickInput" placeholder="要做点什么?回车快速添加…" maxlength="200" autocomplete="off">
|
||
</div>
|
||
<span id="quickPriSlot"></span>
|
||
<span id="quickDueSlot"></span>
|
||
<button class="btn btn-primary" id="addBtn">添加</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<div class="tabs" id="tabs">
|
||
<button class="tab on" data-status="all">全部<span class="badge" id="bAll">0</span></button>
|
||
<button class="tab" data-status="active">进行中<span class="badge" id="bActive">0</span></button>
|
||
<button class="tab" data-status="done">已完成<span class="badge" id="bDone">0</span></button>
|
||
</div>
|
||
<div class="spacer"></div>
|
||
<span id="filterPriSlot"></span>
|
||
<span id="sortSlot"></span>
|
||
<input type="text" id="searchInput" placeholder="搜索标题 / 备注…" autocomplete="off">
|
||
</div>
|
||
|
||
<div id="listBox">
|
||
<div class="loading"><div class="spinner"></div>加载中…</div>
|
||
</div>
|
||
|
||
<div class="foot-bar">
|
||
<span id="footStat">共 0 条</span>
|
||
<div class="spacer"></div>
|
||
<span class="hint"><kbd>N</kbd> 新建</span>
|
||
<span class="hint"><kbd>/</kbd> 搜索</span>
|
||
<button class="btn btn-ghost" id="exportBtn">导出</button>
|
||
<button class="btn btn-ghost" id="importBtn">导入</button>
|
||
<button class="btn btn-ghost" id="clearDoneBtn">清除已完成</button>
|
||
<input type="file" id="importFile" accept="application/json" hidden>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<div id="modalRoot"></div>
|
||
<div class="toast" id="toast"></div>
|
||
|
||
<script>
|
||
(function () {
|
||
'use strict';
|
||
|
||
/* ===== 接口地址:生产走同源 /api(nginx 反代去前缀);本地 8080 开发时直连后端 8800 ===== */
|
||
var apiBase = ''
|
||
var apiPrefix = '/api'
|
||
if (location.port === '8080' && /^(localhost|127\.0\.0\.1)$/.test(location.hostname)) {
|
||
apiBase = 'http://localhost:8800'
|
||
apiPrefix = ''
|
||
}
|
||
var API = apiBase + apiPrefix + '/public-todo'
|
||
|
||
var PRI_TEXT = { 1: '低', 2: '中', 3: '高' }
|
||
|
||
var state = {
|
||
items: [],
|
||
status: 'all',
|
||
priority: '',
|
||
sort: 'created',
|
||
keyword: '',
|
||
loading: true
|
||
}
|
||
|
||
var elList = document.getElementById('listBox')
|
||
var elToast = document.getElementById('toast')
|
||
var elQuick = document.getElementById('quickInput')
|
||
var elModalRoot = document.getElementById('modalRoot')
|
||
|
||
/* ===== 工具 ===== */
|
||
function $(id) { return document.getElementById(id) }
|
||
|
||
function esc(s) {
|
||
return String(s == null ? '' : s)
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''')
|
||
}
|
||
|
||
var toastTimer = null
|
||
function toast(msg, isErr) {
|
||
elToast.textContent = msg
|
||
elToast.className = 'toast show' + (isErr ? ' err' : '')
|
||
clearTimeout(toastTimer)
|
||
toastTimer = setTimeout(function () { elToast.className = 'toast' }, 2200)
|
||
}
|
||
|
||
function todayStr() {
|
||
var d = new Date()
|
||
var m = d.getMonth() + 1
|
||
var day = d.getDate()
|
||
return d.getFullYear() + '-' + (m < 10 ? '0' + m : m) + '-' + (day < 10 ? '0' + day : day)
|
||
}
|
||
|
||
/* ===== HTML 净化 =====
|
||
公开页面任何人可写,富文本存 HTML 再渲染即存储型 XSS。
|
||
保存前与渲染前各净化一次,仅放行白名单标签与属性。 */
|
||
var ALLOW_TAGS = { P: 1, BR: 1, B: 1, STRONG: 1, I: 1, EM: 1, U: 1, S: 1, UL: 1, OL: 1, LI: 1, H2: 1, BLOCKQUOTE: 1, CODE: 1, PRE: 1, A: 1, SPAN: 1, DIV: 1 }
|
||
var ALLOW_CLASSES = ['ck-box', 'ck-checked', 'ck-line']
|
||
|
||
function sanitizeHtml(html) {
|
||
if (!html) return ''
|
||
// 直接解析 body 并整体净化,避免外层包裹被内容里的闭合标签截断
|
||
var doc = new DOMParser().parseFromString(html, 'text/html')
|
||
cleanNode(doc.body)
|
||
return doc.body.innerHTML
|
||
}
|
||
|
||
function cleanNode(node) {
|
||
Array.prototype.slice.call(node.childNodes).forEach(function (child) {
|
||
if (child.nodeType === 3) return // 文本节点保留
|
||
if (child.nodeType !== 1) { node.removeChild(child); return }
|
||
var tag = child.tagName
|
||
if (!ALLOW_TAGS[tag]) { // 白名单外:剥标签,只留文字
|
||
while (child.firstChild) node.insertBefore(child.firstChild, child)
|
||
node.removeChild(child)
|
||
return
|
||
}
|
||
Array.prototype.slice.call(child.attributes).forEach(function (a) {
|
||
var n = a.name.toLowerCase()
|
||
if (n === 'class') {
|
||
var keep = String(child.className || '').split(/\s+/).filter(function (c) { return ALLOW_CLASSES.indexOf(c) > -1 })
|
||
if (keep.length) child.className = keep.join(' ')
|
||
else child.removeAttribute('class')
|
||
} else if (tag === 'A' && n === 'href') {
|
||
if (!/^(https?:|mailto:)/i.test(a.value)) child.removeAttribute('href')
|
||
} else if (n === 'contenteditable') {
|
||
child.setAttribute('contenteditable', 'false')
|
||
} else {
|
||
child.removeAttribute(n)
|
||
}
|
||
})
|
||
if (tag === 'A') {
|
||
child.setAttribute('target', '_blank')
|
||
child.setAttribute('rel', 'noopener noreferrer')
|
||
}
|
||
cleanNode(child)
|
||
})
|
||
}
|
||
|
||
function stripHtml(html) {
|
||
var d = document.createElement('div')
|
||
d.innerHTML = html || ''
|
||
return (d.textContent || '').replace(/\s+/g, ' ').trim()
|
||
}
|
||
|
||
// 截止日期状态:overdue / today / soon / normal
|
||
function dueState(due) {
|
||
if (!due) return ''
|
||
var t = todayStr()
|
||
if (due === t) return 'today'
|
||
if (due < t) return 'overdue'
|
||
var diff = (new Date(due + 'T00:00:00') - new Date(t + 'T00:00:00')) / 86400000
|
||
return diff <= 3 ? 'soon' : 'normal'
|
||
}
|
||
|
||
function dueText(due) {
|
||
if (!due) return ''
|
||
var st = dueState(due)
|
||
var t = todayStr()
|
||
if (st === 'today') return '今天截止'
|
||
if (st === 'overdue') {
|
||
var diff = Math.round((new Date(t + 'T00:00:00') - new Date(due + 'T00:00:00')) / 86400000)
|
||
return '逾期 ' + diff + ' 天'
|
||
}
|
||
return due
|
||
}
|
||
|
||
function isOverdue(item) {
|
||
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
|
||
}
|
||
|
||
/* ===== 富文本编辑器(自研,contenteditable + execCommand) ===== */
|
||
var RTE_BAR =
|
||
'<div class="rte-bar" contenteditable="false">'
|
||
+ '<button type="button" class="rte-btn" data-cmd="bold" title="加粗"><b>B</b></button>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="italic" title="斜体"><i>I</i></button>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="underline" title="下划线"><u>U</u></button>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="strikeThrough" title="删除线"><s>S</s></button>'
|
||
+ '<span class="rte-sep"></span>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="P" title="正文">¶</button>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="H2" title="标题">H</button>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="formatBlock" data-val="BLOCKQUOTE" title="引用">❝</button>'
|
||
+ '<button type="button" class="rte-btn txt" data-cmd="formatBlock" data-val="PRE" title="代码块"></></button>'
|
||
+ '<span class="rte-sep"></span>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="insertUnorderedList" title="无序列表">☰</button>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="insertOrderedList" title="有序列表">≣</button>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="insertCheckbox" title="待办项">☑</button>'
|
||
+ '<span class="rte-sep"></span>'
|
||
+ '<button type="button" class="rte-btn" data-cmd="createLink" title="插入链接">🔗</button>'
|
||
+ '<button type="button" class="rte-btn warn" data-cmd="removeFormat" title="清除格式">✕</button>'
|
||
+ '<span class="spacer"></span>'
|
||
+ '<button type="button" class="rte-btn txt" id="rteExpand" title="放大书写面板">⤢ 放大</button>'
|
||
+ '</div>'
|
||
|
||
function initEditor(root) {
|
||
var body = root.querySelector('.rte-body')
|
||
var bar = root.querySelector('.rte-bar')
|
||
|
||
// 点工具栏时阻止焦点跳走,保住正文里的文字选区
|
||
bar.addEventListener('mousedown', function (e) { e.preventDefault() })
|
||
|
||
bar.addEventListener('click', function (e) {
|
||
var btn = e.target.closest('.rte-btn')
|
||
if (!btn) return
|
||
if (btn.id === 'rteExpand') {
|
||
var maxed = root.classList.toggle('max')
|
||
btn.innerHTML = maxed ? '⤡ 还原' : '⤢ 放大'
|
||
btn.title = maxed ? '还原编辑器' : '编辑器最大化'
|
||
body.focus()
|
||
return
|
||
}
|
||
execCmd(btn.dataset.cmd, btn.dataset.val)
|
||
refreshBarState()
|
||
})
|
||
|
||
function execCmd(cmd, val) {
|
||
body.focus()
|
||
if (cmd === 'insertCheckbox') {
|
||
document.execCommand('insertHTML', false, '<div class="ck-line"><span class="ck-box" contenteditable="false"></span> </div>')
|
||
} else if (cmd === 'createLink') {
|
||
var url = prompt('链接地址(http(s):// 或 mailto:):')
|
||
if (url && /^(https?:|mailto:)/i.test(url)) document.execCommand('createLink', false, url)
|
||
} else {
|
||
document.execCommand(cmd, false, val || null)
|
||
}
|
||
}
|
||
|
||
// 光标所在处的格式高亮到工具栏按钮(B/I/U/S 有效,块级命令不支持 state 时忽略)
|
||
function refreshBarState() {
|
||
Array.prototype.forEach.call(bar.querySelectorAll('.rte-btn[data-cmd]'), function (b) {
|
||
var on = false
|
||
try { on = document.queryCommandState(b.dataset.cmd) } catch (e) { /* 忽略 */ }
|
||
b.classList.toggle('on', !!on)
|
||
})
|
||
}
|
||
body.addEventListener('keyup', refreshBarState)
|
||
body.addEventListener('mouseup', refreshBarState)
|
||
|
||
// 编辑器内点击待办项方框切换勾选
|
||
body.addEventListener('click', function (e) {
|
||
var box = e.target.closest('.ck-box')
|
||
if (box) box.classList.toggle('ck-checked')
|
||
})
|
||
}
|
||
|
||
/* ===== 危险操作确认弹窗(替代原生 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()
|
||
Object.keys(data || {}).forEach(function (k) {
|
||
var v = data[k]
|
||
if (v !== undefined && v !== null && v !== '') body.append(k, v)
|
||
})
|
||
return fetch(API + path, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
|
||
body: body.toString()
|
||
}).then(function (r) { return r.json() })
|
||
}
|
||
|
||
/* ===== 渲染 ===== */
|
||
function computed() {
|
||
var all = state.items
|
||
var done = all.filter(function (i) { return i.status === 1 }).length
|
||
var overdue = all.filter(isOverdue).length
|
||
return { total: all.length, done: done, active: all.length - done, overdue: overdue }
|
||
}
|
||
|
||
function renderStats() {
|
||
var c = computed()
|
||
$('sTotal').textContent = c.total
|
||
$('sActive').textContent = c.active
|
||
$('sDone').textContent = c.done
|
||
$('sOverdue').textContent = c.overdue
|
||
$('bAll').textContent = c.total
|
||
$('bActive').textContent = c.active
|
||
$('bDone').textContent = c.done
|
||
|
||
var CIRC = 169.65
|
||
var pct = c.total ? Math.round(c.done / c.total * 100) : 0
|
||
$('ringPct').textContent = pct + '%'
|
||
$('ringBar').setAttribute('stroke-dashoffset', String(CIRC - CIRC * pct / 100))
|
||
|
||
if (!c.total) {
|
||
$('ringHead').textContent = '还没有任务'
|
||
$('ringSub').textContent = '添加第一条待办开始吧'
|
||
} else if (c.active === 0) {
|
||
$('ringHead').textContent = '全部完成 🎉'
|
||
$('ringSub').textContent = '共 ' + c.total + ' 条,一条不剩'
|
||
} else {
|
||
$('ringHead').textContent = '还差 ' + c.active + ' 条'
|
||
$('ringSub').textContent = '已完成 ' + c.done + ' / ' + c.total
|
||
}
|
||
|
||
$('footStat').textContent = '共 ' + c.total + ' 条 · 已完成 ' + c.done + ' 条'
|
||
}
|
||
|
||
function priorityLabel(p) {
|
||
return (p === 3 ? '🔴 ' : p === 1 ? '🟢 ' : '🟡 ') + PRI_TEXT[p || 2]
|
||
}
|
||
|
||
function taskHtml(item) {
|
||
var p = item.priority || 2
|
||
var done = item.status === 1
|
||
var overdue = isOverdue(item)
|
||
var ds = dueState(item.dueDate)
|
||
var cls = 'task p' + p + (done ? ' is-done' : '') + (overdue ? ' is-overdue' : '')
|
||
var chipCls = 'chip' + (ds === 'today' ? ' due-today' : ds === 'overdue' ? ' due-overdue' : ds === 'soon' ? ' due-soon' : '')
|
||
|
||
var html = '<div class="' + cls + '" data-id="' + item.id + '">'
|
||
html += '<div class="check" data-act="toggle" title="' + (done ? '标记未完成' : '标记完成') + '">'
|
||
html += '<svg width="12" height="12" viewBox="0 0 12 12"><path d="M1.5 6.2l3 3 6-6.4" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>'
|
||
html += '</div>'
|
||
html += '<div class="body">'
|
||
html += '<div class="title">' + esc(item.title) + '</div>'
|
||
if (item.remark && stripHtml(item.remark)) {
|
||
html += '<div class="note-box" data-act="peek" title="点击展开 / 收起">'
|
||
+ '<div class="plain">' + esc(stripHtml(item.remark)) + '</div>'
|
||
+ '<div class="full fmt">' + sanitizeHtml(item.remark) + '</div>'
|
||
+ '</div>'
|
||
}
|
||
html += '<div class="meta">'
|
||
html += '<span class="pri p' + p + '">' + priorityLabel(p) + '</span>'
|
||
if (item.dueDate) html += '<span class="' + chipCls + '">📅 ' + esc(dueText(item.dueDate)) + '</span>'
|
||
html += '</div></div>'
|
||
html += '<div class="actions">'
|
||
html += '<button class="icon-btn" data-act="edit" title="编辑"><svg width="15" height="15" viewBox="0 0 16 16"><path d="M11.5 2.5l2 2-8 8H3.5v-2z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"/></svg></button>'
|
||
html += '<button class="icon-btn del" data-act="del" title="删除"><svg width="15" height="15" viewBox="0 0 16 16"><path d="M3 4.5h10M6.5 4.5V3h3v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg></button>'
|
||
html += '</div></div>'
|
||
return html
|
||
}
|
||
|
||
function renderList() {
|
||
if (state.loading) {
|
||
elList.innerHTML = '<div class="loading"><div class="spinner"></div>加载中…</div>'
|
||
return
|
||
}
|
||
if (!state.items.length) {
|
||
var isFiltered = state.status !== 'all' || state.priority || state.keyword
|
||
elList.innerHTML = '<div class="empty">'
|
||
+ '<span class="emo">' + (isFiltered ? '🔍' : '🌱') + '</span>'
|
||
+ '<h3>' + (isFiltered ? '没有符合条件的待办' : '清单还是空的') + '</h3>'
|
||
+ '<p>' + (isFiltered ? '换个筛选条件试试,或清空搜索关键字' : '在上方输入框写上第一条待办,回车即可添加') + '</p>'
|
||
+ '</div>'
|
||
return
|
||
}
|
||
elList.innerHTML = '<div class="list">' + state.items.map(taskHtml).join('') + '</div>'
|
||
}
|
||
|
||
function renderAll() {
|
||
renderStats()
|
||
renderList()
|
||
}
|
||
|
||
/* ===== 数据 ===== */
|
||
function fetchList(silent) {
|
||
if (!silent) state.loading = true
|
||
renderList()
|
||
var params = {
|
||
sort: state.sort
|
||
}
|
||
if (state.status === 'active') params.status = 0
|
||
if (state.status === 'done') params.status = 1
|
||
if (state.priority) params.priority = state.priority
|
||
if (state.keyword) params.keyword = state.keyword
|
||
|
||
return post('/list', params).then(function (res) {
|
||
if (!res || res.code !== 200) throw new Error((res && res.message) || '加载失败')
|
||
var list = res.data || []
|
||
// 逾期筛选在前端做(需要依赖当天日期)
|
||
if (state.status === 'overdue') list = list.filter(isOverdue)
|
||
state.items = list
|
||
state.loading = false
|
||
renderAll()
|
||
}).catch(function (e) {
|
||
state.loading = false
|
||
state.items = []
|
||
elList.innerHTML = '<div class="empty"><span class="emo">⚠️</span><h3>加载失败</h3><p>'
|
||
+ esc(e.message || '网络异常') + ',请刷新页面重试</p></div>'
|
||
toast(e.message || '加载失败', true)
|
||
})
|
||
}
|
||
|
||
// 统计需要全量数据,单独拉一次(筛选后 items 不是全量)
|
||
function fetchStats() {
|
||
return post('/stats', {}).then(function (res) {
|
||
if (!res || res.code !== 200) return
|
||
var d = res.data || {}
|
||
var CIRC = 169.65
|
||
var pct = d.total ? Math.round((d.done || 0) / d.total * 100) : 0
|
||
$('ringPct').textContent = pct + '%'
|
||
$('ringBar').setAttribute('stroke-dashoffset', String(CIRC - CIRC * pct / 100))
|
||
$('sTotal').textContent = d.total || 0
|
||
$('sActive').textContent = d.active || 0
|
||
$('sDone').textContent = d.done || 0
|
||
$('sOverdue').textContent = d.overdue || 0
|
||
}).catch(function () { /* 统计失败不打断主流程 */ })
|
||
}
|
||
|
||
/* ===== 交互 ===== */
|
||
function addTodo() {
|
||
var title = (elQuick.value || '').trim()
|
||
if (!title) { toast('先写点什么吧'); elQuick.focus(); return }
|
||
var data = {
|
||
title: title,
|
||
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 = ''
|
||
setDateSel($('quickDue'), '')
|
||
toast('已添加')
|
||
return fetchList(true).then(fetchStats)
|
||
}).catch(function (e) { toast(e.message || '添加失败', true) })
|
||
}
|
||
|
||
function toggleStatus(id, cur) {
|
||
post('/updateStatus', { id: id, status: cur === 1 ? 0 : 1 }).then(function (res) {
|
||
if (!res || res.code !== 200) throw new Error((res && res.message) || '操作失败')
|
||
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, dueDate: '', remark: '', title: '' }
|
||
var html = '<div class="mask" id="mask"><div class="modal">'
|
||
+ '<div class="modal-head"><h2>' + (isNew ? '新建待办' : '编辑待办') + '</h2>'
|
||
+ '<button type="button" class="mzoom" id="mZoom" title="放大弹窗">⤢</button></div>'
|
||
+ '<div class="modal-body">'
|
||
+ '<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>' + 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>'
|
||
+ '<div class="rte">' + RTE_BAR
|
||
+ '<div class="rte-body fmt" id="mRemark" contenteditable="true" data-ph="补充说明,支持加粗 / 列表 / 待办项 / 代码块…"></div>'
|
||
+ '</div></div>'
|
||
+ '</div>'
|
||
+ '<div class="modal-foot">'
|
||
+ '<button class="btn btn-ghost" id="mCancel">取消</button>'
|
||
+ '<button class="btn btn-primary" id="mSave">保存</button>'
|
||
+ '</div></div></div>'
|
||
|
||
elModalRoot.innerHTML = html
|
||
initSel($('mPriority'))
|
||
initDate($('mDue'))
|
||
var rteBody = $('mRemark')
|
||
rteBody.innerHTML = sanitizeHtml(item.remark || '')
|
||
initEditor(rteBody.closest('.rte'))
|
||
var mask = $('mask')
|
||
var modal = mask.querySelector('.modal')
|
||
var mTitle = $('mTitle')
|
||
setTimeout(function () { mTitle.focus() }, 60)
|
||
|
||
// 弹窗头部 ⤢:放大整个弹窗(加宽)
|
||
$('mZoom').addEventListener('click', function () {
|
||
var lg = modal.classList.toggle('lg')
|
||
this.innerHTML = lg ? '⤡' : '⤢'
|
||
this.title = lg ? '还原弹窗' : '放大弹窗'
|
||
})
|
||
|
||
function close() { elModalRoot.innerHTML = '' }
|
||
function onKey(e) {
|
||
if (e.key !== 'Escape') return
|
||
// 编辑器最大化时 Esc 优先还原编辑器,再按一次才关弹窗
|
||
var maxed = modal.querySelector('.rte.max')
|
||
if (maxed) {
|
||
maxed.classList.remove('max')
|
||
var expBtn = maxed.querySelector('#rteExpand')
|
||
if (expBtn) { expBtn.innerHTML = '⤢ 放大'; expBtn.title = '编辑器最大化' }
|
||
return
|
||
}
|
||
close()
|
||
}
|
||
|
||
document.addEventListener('keydown', onKey)
|
||
mask.addEventListener('click', function (e) { if (e.target === mask) { close(); document.removeEventListener('keydown', onKey) } })
|
||
$('mCancel').addEventListener('click', function () { close(); document.removeEventListener('keydown', onKey) })
|
||
$('mSave').addEventListener('click', function () {
|
||
var title = (mTitle.value || '').trim()
|
||
if (!title) { toast('标题不能为空'); mTitle.focus(); return }
|
||
var remarkHtml = sanitizeHtml($('mRemark').innerHTML)
|
||
if (!stripHtml(remarkHtml) && remarkHtml.indexOf('ck-box') === -1) remarkHtml = ''
|
||
var data = {
|
||
title: title,
|
||
priority: getSel('mPriority'),
|
||
dueDate: getSel('mDue'),
|
||
remark: remarkHtml
|
||
}
|
||
var req
|
||
if (isNew) {
|
||
req = post('/add', data)
|
||
} else {
|
||
data.id = item.id
|
||
data.status = item.status
|
||
req = post('/update', data)
|
||
}
|
||
req.then(function (res) {
|
||
if (!res || res.code !== 200) throw new Error((res && res.message) || '保存失败')
|
||
close()
|
||
document.removeEventListener('keydown', onKey)
|
||
toast(isNew ? '已添加' : '已保存')
|
||
return fetchList(true).then(fetchStats)
|
||
}).catch(function (e) { toast(e.message || '保存失败', true) })
|
||
})
|
||
}
|
||
|
||
/* ===== 导入导出 ===== */
|
||
function exportJson() {
|
||
post('/list', { sort: 'created' }).then(function (res) {
|
||
var list = (res && res.data) || []
|
||
if (!list.length) { toast('没有可导出的数据'); return }
|
||
var blob = new Blob([JSON.stringify(list, null, 2)], { type: 'application/json' })
|
||
var a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob)
|
||
a.download = 'todo-backup-' + todayStr() + '.json'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
setTimeout(function () { URL.revokeObjectURL(a.href) }, 1000)
|
||
toast('已导出 ' + list.length + ' 条')
|
||
}).catch(function (e) { toast(e.message || '导出失败', true) })
|
||
}
|
||
|
||
function importJson(file) {
|
||
var reader = new FileReader()
|
||
reader.onload = function () {
|
||
var arr
|
||
try { arr = JSON.parse(reader.result) } catch (e) { toast('文件不是合法的 JSON', true); return }
|
||
if (!Array.isArray(arr) || !arr.length) { toast('文件里没有待办数据', true); return }
|
||
var chain = Promise.resolve()
|
||
var okCount = 0
|
||
arr.forEach(function (it) {
|
||
if (!it || !it.title) return
|
||
chain = chain.then(function () {
|
||
return post('/add', {
|
||
title: it.title,
|
||
priority: it.priority || 2,
|
||
dueDate: it.dueDate || '',
|
||
remark: sanitizeHtml(it.remark || '')
|
||
}).then(function (r) { if (r && r.code === 200) okCount++ })
|
||
})
|
||
})
|
||
chain.then(function () {
|
||
toast('已导入 ' + okCount + ' 条')
|
||
return fetchList(true).then(fetchStats)
|
||
})
|
||
}
|
||
reader.readAsText(file, 'utf-8')
|
||
}
|
||
|
||
function clearDone() {
|
||
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() }
|
||
})
|
||
|
||
document.getElementById('tabs').addEventListener('click', function (e) {
|
||
var btn = e.target.closest('.tab')
|
||
if (!btn) return
|
||
state.status = btn.dataset.status
|
||
Array.prototype.forEach.call(this.querySelectorAll('.tab'), function (t) {
|
||
t.classList.toggle('on', t === btn)
|
||
})
|
||
Array.prototype.forEach.call(document.querySelectorAll('.stat'), function (s) {
|
||
s.classList.toggle('on', s.dataset.status === state.status)
|
||
})
|
||
fetchList()
|
||
})
|
||
|
||
document.getElementById('stats').addEventListener('click', function (e) {
|
||
var card = e.target.closest('.stat')
|
||
if (!card) return
|
||
state.status = card.dataset.status
|
||
Array.prototype.forEach.call(document.querySelectorAll('.stat'), function (s) {
|
||
s.classList.toggle('on', s === card)
|
||
})
|
||
Array.prototype.forEach.call(document.querySelectorAll('.tab'), function (t) {
|
||
t.classList.toggle('on', t.dataset.status === state.status)
|
||
})
|
||
fetchList()
|
||
})
|
||
|
||
var searchTimer = null
|
||
$('searchInput').addEventListener('input', function () {
|
||
var v = this.value.trim()
|
||
clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(function () {
|
||
state.keyword = v
|
||
fetchList()
|
||
}, 300)
|
||
})
|
||
|
||
elList.addEventListener('click', function (e) {
|
||
var actEl = e.target.closest('[data-act]')
|
||
if (!actEl) return
|
||
var taskEl = e.target.closest('.task')
|
||
if (!taskEl) return
|
||
var id = taskEl.dataset.id
|
||
var act = actEl.dataset.act
|
||
var item = state.items.filter(function (i) { return String(i.id) === String(id) })[0]
|
||
if (!item) return
|
||
if (act === 'toggle') toggleStatus(item.id, item.status)
|
||
else if (act === 'edit') openEdit(item)
|
||
else if (act === 'del') removeTodo(item.id, item.title)
|
||
else if (act === 'peek') {
|
||
if (e.target.closest('a')) return // 点展开内容里的链接时不折叠
|
||
actEl.classList.toggle('open')
|
||
}
|
||
})
|
||
|
||
$('exportBtn').addEventListener('click', exportJson)
|
||
$('importBtn').addEventListener('click', function () { $('importFile').click() })
|
||
$('importFile').addEventListener('change', function () {
|
||
if (this.files && this.files[0]) importJson(this.files[0])
|
||
this.value = ''
|
||
})
|
||
$('clearDoneBtn').addEventListener('click', clearDone)
|
||
|
||
document.addEventListener('keydown', function (e) {
|
||
if (elModalRoot.innerHTML) return // 弹窗打开时不响应快捷键
|
||
var tag = (e.target.tagName || '').toLowerCase()
|
||
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() }
|
||
})
|
||
|
||
/* ===== 启动 ===== */
|
||
fetchList().then(fetchStats)
|
||
})()
|
||
</script>
|
||
</body>
|
||
</html>
|