Files
freedom-web/public/todo-public.html
T
张家训 0e9ab36c94
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 43s
feat(public-todo): 自绘下拉与日历,移除分类,美化危险操作确认
- 下拉组件自绘:原生 select 展开后的选项面板由系统绘制,CSS 无法美化
- 日期选择器自绘:月历面板含上下月切换,附今天/明天/一周后/清除快捷项
- 移除分类:快捷栏、筛选栏、卡片标签、编辑弹窗、导入导出全清
- 删除与「清除已完成」改用自定义确认弹窗,替代浏览器原生 confirm
- 弹窗内面板用 fixed 定位并按剩余空间自动向上展开,避免被 overflow 裁剪
- 弹窗打开时屏蔽 N / 快捷键,避免误触发
2026-10-19 19:05:36 +08:00

1204 lines
51 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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); }
.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; }
.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; }
.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); padding: 24px; margin: auto;
animation: popIn .26s cubic-bezier(.16, 1, .3, 1); max-height: 92vh; overflow-y: auto;
}
@keyframes popIn { from { opacity: 0; transform: scale(.94) translateY(12px); } to { opacity: 1; transform: scale(1) translateY(0); } }
.modal h2 { font-size: 18px; font-weight: 700; margin-bottom: 18px; }
.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; }
.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;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;')
}
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)
}
// 截止日期状态: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
}
/* ===== 危险操作确认弹窗(替代原生 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) html += '<div class="note">' + esc(item.remark) + '</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">'
+ '<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>' + 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>'
+ '<textarea id="mRemark" maxlength="1000" placeholder="补充说明(可选)">' + esc(item.remark || '') + '</textarea></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 mask = $('mask')
var mTitle = $('mTitle')
setTimeout(function () { mTitle.focus() }, 60)
function close() { elModalRoot.innerHTML = '' }
function onKey(e) { if (e.key === 'Escape') 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 data = {
title: title,
priority: getSel('mPriority'),
dueDate: getSel('mDue'),
remark: $('mRemark').value
}
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: 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)
})
$('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>