feat(public-todo): 新增免登录公开待办公开页面
CI/CD Deploy (Web) / 构建前端镜像并部署 (push) Successful in 1m4s

- public/todo-public.html:单文件零依赖原生 JS,经 vue-cli 原样复制到 dist/
- 环形完成率 + 4 个可点击筛选统计卡
- 快捷添加、Tab/优先级/分类/排序/关键字筛选
- 编辑弹窗、删除确认、导出导入 JSON、清除已完成
- 快捷键 N 新建、/ 搜索,Toast 提示,移动端自适应
- 紫罗兰渐变配色 + 毛玻璃卡片 + 背景模糊光斑动画
This commit is contained in:
张家训
2026-10-19 18:46:01 +08:00
parent 2d6cac2d06
commit 0eaf88f006
+895
View File
@@ -0,0 +1,895 @@
<!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"], select, textarea {
font-family: inherit; font-size: 14px; color: var(--text);
border: 1.5px solid var(--line); border-radius: 11px; padding: 10px 13px;
outline: none; background: #fbfcfe; width: 100%;
transition: border-color .18s, box-shadow .18s, background .18s;
}
input[type="text"]:focus, input[type="date"]:focus, select:focus, textarea:focus {
border-color: var(--indigo); background: #fff; box-shadow: 0 0 0 4px rgba(99, 102, 241, .11);
}
input::placeholder, textarea::placeholder { color: var(--text-muted); }
select {
cursor: pointer; appearance: none; padding-right: 32px;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2394a3b8' d='M6 8.5L1.5 4h9z'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 12px center;
}
.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 select { width: auto; min-width: 118px; font-size: 13.5px; padding: 9px 32px 9px 12px; }
.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; }
.tag { font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 7px; background: rgba(99, 102, 241, .1); color: var(--indigo-dark); }
.chip { display: inline-flex; align-items: center; gap: 5px; font-size: 11.5px; font-weight: 600; padding: 3px 9px; border-radius: 7px; background: #f1f4fa; color: var(--text-sub); }
.chip.due-soon { background: #eff6ff; color: #1d4ed8; }
.chip.due-today { background: #fff7ed; color: #c2410c; }
.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; }
/* ===== 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 .btn-primary { width: 100%; }
.filters { gap: 8px; }
.tabs { width: 100%; }
.tab { flex: 1; padding: 8px 6px; text-align: center; }
.filters .spacer { display: none; }
.filters select, .filters input[type="text"] { flex: 1; min-width: 0; }
.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>
<select id="quickPriority" title="优先级">
<option value="3">🔴 高</option>
<option value="2" selected>🟡 中</option>
<option value="1">🟢 低</option>
</select>
<select id="quickCat" title="分类">
<option value="">无分类</option>
<option>工作</option>
<option>学习</option>
<option>生活</option>
<option>紧急</option>
<option>想法</option>
</select>
<input type="date" id="quickDue" title="截止日期" style="width:152px">
<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>
<select id="filterPri" title="按优先级筛选">
<option value="">全部优先级</option>
<option value="3">高优先级</option>
<option value="2">中优先级</option>
<option value="1">低优先级</option>
</select>
<select id="filterCat" title="按分类筛选">
<option value="">全部分类</option>
</select>
<select id="sortBy" title="排序">
<option value="created">最新创建</option>
<option value="priority">优先级高→低</option>
<option value="due">截止日期近→远</option>
<option value="title">标题 A→Z</option>
</select>
<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: '',
category: '',
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()
}
/* ===== 请求 ===== */
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 renderCategoryOptions() {
var sel = $('filterCat')
var prev = sel.value
var cats = {}
state.items.forEach(function (i) { if (i.category) cats[i.category] = 1 })
var html = '<option value="">全部分类</option>'
Object.keys(cats).sort().forEach(function (c) {
html += '<option value="' + esc(c) + '">' + esc(c) + '</option>'
})
sel.innerHTML = html
if (prev && cats[prev]) sel.value = prev
}
function priorityLabel(p) {
return (p === 3 ? '🔴 ' : p === 1 ? '🟢 ' : '🟡 ') + PRI_TEXT[p || 2]
}
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.category) html += '<span class="tag">' + esc(item.category) + '</span>'
if (item.dueDate) html += '<span class="' + chipCls + '">📅 ' + esc(dueText(item.dueDate)) + '</span>'
html += '</div></div>'
html += '<div class="actions">'
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.category || 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()
renderCategoryOptions()
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.category) params.category = state.category
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: $('quickPriority').value,
category: $('quickCat').value,
dueDate: $('quickDue').value
}
post('/add', data).then(function (res) {
if (!res || res.code !== 200) throw new Error((res && res.message) || '添加失败')
elQuick.value = ''
$('quickDue').value = ''
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) {
if (!confirm('确定删除这条待办吗?')) return
post('/remove', { id: id }).then(function (res) {
if (!res || res.code !== 200) throw new Error((res && res.message) || '删除失败')
toast('已删除')
return fetchList(true).then(fetchStats)
}).catch(function (e) { toast(e.message || '删除失败', true) })
}
/* ===== 编辑弹窗 ===== */
function openEdit(item) {
var isNew = !item
item = item || { priority: 2, category: '', dueDate: '', remark: '', title: '' }
var html = '<div class="mask" id="mask"><div class="modal">'
+ '<h2>' + (isNew ? '新建待办' : '编辑待办') + '</h2>'
+ '<div class="field"><label>标题</label>'
+ '<input type="text" id="mTitle" maxlength="200" value="' + esc(item.title) + '" placeholder="要做什么?"></div>'
+ '<div class="field-row">'
+ '<div class="field"><label>优先级</label><select id="mPriority">'
+ '<option value="3"' + (item.priority === 3 ? ' selected' : '') + '>🔴 高</option>'
+ '<option value="2"' + ((item.priority || 2) === 2 ? ' selected' : '') + '>🟡 中</option>'
+ '<option value="1"' + (item.priority === 1 ? ' selected' : '') + '>🟢 低</option>'
+ '</select></div>'
+ '<div class="field"><label>分类</label><select id="mCategory">'
+ ['', '工作', '学习', '生活', '紧急', '想法'].map(function (c) {
return '<option value="' + c + '"' + (item.category === c ? ' selected' : '') + '>' + (c || '无分类') + '</option>'
}).join('')
+ '</select></div>'
+ '</div>'
+ '<div class="field"><label>截止日期</label>'
+ '<input type="date" id="mDue" value="' + esc(item.dueDate || '') + '"></div>'
+ '<div class="field"><label>备注</label>'
+ '<textarea id="mRemark" maxlength="1000" placeholder="补充说明(可选)">' + esc(item.remark || '') + '</textarea></div>'
+ '<div class="modal-foot">'
+ '<button class="btn btn-ghost" id="mCancel">取消</button>'
+ '<button class="btn btn-primary" id="mSave">保存</button>'
+ '</div></div></div>'
elModalRoot.innerHTML = html
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: $('mPriority').value,
category: $('mCategory').value,
dueDate: $('mDue').value,
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,
category: it.category || '',
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() {
if (!confirm('确定清除所有已完成的待办吗?此操作不可撤销。')) return
post('/removeDone', {}).then(function (res) {
if (!res || res.code !== 200) throw new Error((res && res.message) || '清理失败')
toast('已清除')
return fetchList(true).then(fetchStats)
}).catch(function (e) { toast(e.message || '清理失败', true) })
}
/* ===== 事件绑定 ===== */
$('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()
})
$('filterPri').addEventListener('change', function () { state.priority = this.value; fetchList() })
$('filterCat').addEventListener('change', function () { state.category = this.value; fetchList() })
$('sortBy').addEventListener('change', function () { state.sort = this.value; fetchList() })
var searchTimer = null
$('searchInput').addEventListener('input', function () {
var v = this.value.trim()
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)
})
$('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) {
var tag = (e.target.tagName || '').toLowerCase()
if (tag === 'input' || tag === 'textarea' || tag === 'select') 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>