Files
freedom-web/public/todo-public.html
T
张家训 0aa7d3a293
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 26s
fix(public-todo): 弹窗三段式布局修复滚动与圆角,拆分两级放大
- 弹窗改为 flex 三段式:头部固定 / 内容滚动 / 底栏固定
  保存按钮常驻底部可见,外层 overflow:hidden 保住 20px 圆角
- 滚动条收进 modal-body,细圆角样式,不再贯穿整个弹窗
- 放大拆两级:弹窗头部 ⤢ 放大整个弹窗(加宽到 880px);
  编辑器工具栏 ⤢ 仅编辑器最大化(980px x 92vh 浮层)
- Esc 优先还原编辑器最大化,再按一次才关闭弹窗
2026-10-20 09:24:34 +08:00

1440 lines
64 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); }
/* ===== 富文本编辑器(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, '&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)
}
/* ===== 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="代码块">&lt;/&gt;</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>&nbsp;</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>