claude-opus-4-7
该模型能力由 PackyAPI 站点接入
查看原始输出
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>番茄任务看板</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--border: #e3e5ea;
--text: #1f2330;
--muted: #6b7280;
--primary: #c0392b;
--primary-hover: #a93222;
--accent: #2c3e50;
--success: #2f855a;
--warning: #b45309;
--danger: #b91c1c;
--shadow: 0 1px 2px rgba(0,0,0,.04), 0 2px 8px rgba(0,0,0,.04);
}
html, body { margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
min-height: 100vh;
}
header.app-header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 14px 20px;
position: sticky;
top: 0;
z-index: 10;
}
.header-row {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
justify-content: space-between;
max-width: 1400px;
margin: 0 auto;
}
h1 {
font-size: 16px;
margin: 0;
font-weight: 600;
letter-spacing: .3px;
}
h1 .dot {
display: inline-block;
width: 8px; height: 8px;
background: var(--primary);
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
}
.controls {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
input[type="text"], input[type="number"], select, input[type="search"] {
font: inherit;
color: var(--text);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 10px;
outline: none;
transition: border-color .15s, box-shadow .15s;
}
input:focus, select:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(44,62,80,.12);
}
button {
font: inherit;
cursor: pointer;
background: var(--surface);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 7px 12px;
transition: background .15s, border-color .15s, color .15s;
}
button:hover { background: #f0f1f4; }
button.primary {
background: var(--primary);
color: #fff;
border-color: var(--primary);
}
button.primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); }
button.ghost { background: transparent; }
button.danger { color: var(--danger); }
button.danger:hover { background: #fdecec; }
.switch {
display: inline-flex;
align-items: center;
gap: 8px;
user-select: none;
cursor: pointer;
font-size: 13px;
color: var(--muted);
}
.switch input { display: none; }
.switch .track {
width: 34px; height: 18px;
background: #cbd0d8;
border-radius: 999px;
position: relative;
transition: background .15s;
}
.switch .track::after {
content: "";
position: absolute;
top: 2px; left: 2px;
width: 14px; height: 14px;
background: #fff;
border-radius: 50%;
transition: transform .15s;
box-shadow: 0 1px 2px rgba(0,0,0,.2);
}
.switch input:checked + .track { background: var(--primary); }
.switch input:checked + .track::after { transform: translateX(16px); }
main {
max-width: 1400px;
margin: 0 auto;
padding: 16px 20px 40px;
}
.new-task {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
margin-bottom: 16px;
box-shadow: var(--shadow);
}
.new-task form {
display: grid;
grid-template-columns: 2fr 1fr 1fr auto;
gap: 8px;
align-items: start;
}
.field { display: flex; flex-direction: column; gap: 4px; }
.field label {
font-size: 12px;
color: var(--muted);
}
.form-error {
color: var(--danger);
font-size: 12px;
margin-top: 8px;
min-height: 16px;
}
.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 14px;
}
.column {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
display: flex;
flex-direction: column;
min-height: 320px;
box-shadow: var(--shadow);
}
.column-head {
padding: 12px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
font-weight: 600;
font-size: 13px;
letter-spacing: .3px;
}
.column-head .count {
font-weight: 400;
color: var(--muted);
font-size: 12px;
background: #f0f1f4;
border-radius: 999px;
padding: 2px 8px;
}
.column-body {
flex: 1;
padding: 10px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 200px;
}
.column-body.drag-over {
background: #fffaf0;
outline: 2px dashed var(--primary);
outline-offset: -6px;
border-radius: 8px;
}
.empty-state {
text-align: center;
color: var(--muted);
padding: 24px 10px;
font-size: 13px;
border: 1px dashed var(--border);
border-radius: 6px;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 6px;
padding: 10px 12px;
cursor: grab;
display: flex;
flex-direction: column;
gap: 8px;
transition: box-shadow .15s, transform .15s;
}
.card:hover { box-shadow: 0 2px 6px rgba(0,0,0,.06); }
.card.dragging { opacity: .5; }
.card.priority-high { border-left-color: var(--danger); }
.card.priority-medium { border-left-color: var(--warning); }
.card.priority-low { border-left-color: var(--success); }
.card-title {
font-weight: 600;
font-size: 14px;
word-break: break-word;
}
.card-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 11px;
}
.tag {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
background: #f0f1f4;
color: var(--muted);
}
.tag.high { background: #fdecec; color: var(--danger); }
.tag.medium { background: #fff4e0; color: var(--warning); }
.tag.low { background: #e6f4ec; color: var(--success); }
.card-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
}
.card-actions button {
font-size: 12px;
padding: 4px 8px;
}
.timer {
font-variant-numeric: tabular-nums;
font-size: 13px;
color: var(--primary);
font-weight: 600;
margin-left: auto;
}
.timer.done { color: var(--success); }
.pomo-progress {
height: 4px;
background: #f0f1f4;
border-radius: 999px;
overflow: hidden;
}
.pomo-progress > div {
height: 100%;
background: var(--primary);
width: 0%;
transition: width .3s linear;
}
.search-row {
display: flex;
gap: 8px;
align-items: center;
}
.search-row input[type="search"] { width: 220px; }
@media (max-width: 900px) {
.board { grid-template-columns: 1fr; }
.new-task form { grid-template-columns: 1fr 1fr; }
.new-task form .field-title { grid-column: 1 / -1; }
.new-task form .submit-wrap { grid-column: 1 / -1; }
}
@media (max-width: 480px) {
header.app-header { padding: 12px 14px; }
main { padding: 12px 14px 32px; }
.search-row input[type="search"] { width: 100%; }
.controls { width: 100%; }
.search-row { flex: 1; }
.new-task form { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header class="app-header">
<div class="header-row">
<h1><span class="dot"></span>番茄任务看板</h1>
<div class="controls">
<div class="search-row">
<input type="search" id="search" placeholder="搜索任务标题…" aria-label="搜索任务">
</div>
<label class="switch" title="开启后 25 分钟将以 25 秒运行,便于演示">
<input type="checkbox" id="demoMode">
<span class="track"></span>
<span>演示加速</span>
</label>
<button id="clearDone" class="ghost danger" type="button">清空已完成</button>
</div>
</div>
</header>
<main>
<section class="new-task" aria-label="新建任务">
<form id="taskForm" novalidate>
<div class="field field-title">
<label for="title">任务标题</label>
<input type="text" id="title" maxlength="80" placeholder="例如:实现用户登录接口" required>
</div>
<div class="field">
<label for="estimate">预估番茄数</label>
<input type="number" id="estimate" min="1" max="20" value="1" required>
</div>
<div class="field">
<label for="priority">优先级</label>
<select id="priority">
<option value="high">高</option>
<option value="medium" selected>中</option>
<option value="low">低</option>
</select>
</div>
<div class="field submit-wrap">
<label> </label>
<button type="submit" class="primary">添加任务</button>
</div>
<div class="form-error" id="formError" role="alert"></div>
</form>
</section>
<section class="board" aria-label="任务看板">
<div class="column" data-status="todo">
<div class="column-head"><span>待办</span><span class="count" id="count-todo">0</span></div>
<div class="column-body" data-status="todo" id="col-todo"></div>
</div>
<div class="column" data-status="doing">
<div class="column-head"><span>进行中</span><span class="count" id="count-doing">0</span></div>
<div class="column-body" data-status="doing" id="col-doing"></div>
</div>
<div class="column" data-status="done">
<div class="column-head"><span>已完成</span><span class="count" id="count-done">0</span></div>
<div class="column-body" data-status="done" id="col-done"></div>
</div>
</section>
</main>
<script>
(function(){
"use strict";
const STORAGE_KEY = "pomo-board-v1";
const DEMO_KEY = "pomo-board-demo";
const FULL_SECONDS = 25 * 60;
const DEMO_SECONDS = 25;
/** @type {{id:string,title:string,estimate:number,priority:string,status:string,completedPomos:number,timer:{remaining:number,running:boolean,total:number}|null,createdAt:number}[]} */
let tasks = [];
let demoMode = false;
let searchQuery = "";
const tickHandles = {}; // taskId -> intervalId
// ---------- Persistence ----------
function load() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const arr = JSON.parse(raw);
if (Array.isArray(arr)) tasks = arr;
}
} catch (e) { tasks = []; }
try {
demoMode = localStorage.getItem(DEMO_KEY) === "1";
} catch(e){}
// make sure no timer auto-resumes after refresh
tasks.forEach(t => { if (t.timer) t.timer.running = false; });
}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks)); } catch(e){}
}
function saveDemo() {
try { localStorage.setItem(DEMO_KEY, demoMode ? "1" : "0"); } catch(e){}
}
// ---------- Helpers ----------
function uid() {
return "t_" + Date.now().toString(36) + "_" + Math.random().toString(36).slice(2, 7);
}
function fmt(sec) {
sec = Math.max(0, Math.floor(sec));
const m = Math.floor(sec / 60);
const s = sec % 60;
return (m < 10 ? "0"+m : m) + ":" + (s < 10 ? "0"+s : s);
}
function totalForTimer() {
return demoMode ? DEMO_SECONDS : FULL_SECONDS;
}
function priorityLabel(p) {
return p === "high" ? "高优先级" : p === "low" ? "低优先级" : "中优先级";
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'"
}[c]));
}
// ---------- Rendering ----------
function render() {
const cols = {
todo: document.getElementById("col-todo"),
doing: document.getElementById("col-doing"),
done: document.getElementById("col-done"),
};
Object.values(cols).forEach(c => c.innerHTML = "");
const q = searchQuery.trim().toLowerCase();
const counts = { todo:0, doing:0, done:0 };
const filtered = tasks.filter(t => !q || t.title.toLowerCase().includes(q));
// counts reflect filtered visible
filtered.forEach(t => counts[t.status] = (counts[t.status]||0)+1);
for (const status of ["todo","doing","done"]) {
const list = filtered.filter(t => t.status === status);
if (list.length === 0) {
const empty = document.createElement("div");
empty.className = "empty-state";
empty.textContent = q
? "没有匹配的任务"
: status === "todo" ? "暂无待办任务,先添加一个吧"
: status === "doing" ? "把卡片拖到这里开始专注"
: "完成的任务会显示在这里";
cols[status].appendChild(empty);
} else {
list.forEach(t => cols[status].appendChild(renderCard(t)));
}
document.getElementById("count-"+status).textContent = counts[status];
}
}
function renderCard(task) {
const el = document.createElement("div");
el.className = "card priority-" + task.priority;
el.draggable = true;
el.dataset.id = task.id;
const title = document.createElement("div");
title.className = "card-title";
title.textContent = task.title;
const meta = document.createElement("div");
meta.className = "card-meta";
meta.innerHTML =
'<span class="tag '+task.priority+'">'+priorityLabel(task.priority)+'</span>' +
'<span class="tag">???? '+task.completedPomos+' / '+task.estimate+'</span>';
const actions = document.createElement("div");
actions.className = "card-actions";
const hasTimer = !!task.timer;
const running = hasTimer && task.timer.running;
if (!hasTimer) {
const startBtn = document.createElement("button");
startBtn.type = "button";
startBtn.className = "primary";
startBtn.textContent = "开始 25 分钟";
startBtn.addEventListener("click", () => startPomo(task.id));
actions.appendChild(startBtn);
} else {
const toggleBtn = document.createElement("button");
toggleBtn.type = "button";
toggleBtn.className = running ? "" : "primary";
toggleBtn.textContent = running ? "暂停" : "继续";
toggleBtn.addEventListener("click", () => togglePomo(task.id));
actions.appendChild(toggleBtn);
const resetBtn = document.createElement("button");
resetBtn.type = "button";
resetBtn.textContent = "重置";
resetBtn.addEventListener("click", () => resetPomo(task.id));
actions.appendChild(resetBtn);
}
const delBtn = document.createElement("button");
delBtn.type = "button";
delBtn.className = "ghost danger";
delBtn.textContent = "删除";
delBtn.addEventListener("click", () => deleteTask(task.id));
actions.appendChild(delBtn);
const timerEl = document.createElement("span");
timerEl.className = "timer";
if (hasTimer) {
timerEl.textContent = fmt(task.timer.remaining);
if (task.timer.remaining <= 0) timerEl.classList.add("done");
} else {
timerEl.textContent = "";
}
actions.appendChild(timerEl);
el.appendChild(title);
el.appendChild(meta);
el.appendChild(actions);
if (hasTimer) {
const prog = document.createElement("div");
prog.className = "pomo-progress";
const inner = document.createElement("div");
const total = task.timer.total || totalForTimer();
const pct = Math.max(0, Math.min(100, ((total - task.timer.remaining) / total) * 100));
inner.style.width = pct + "%";
prog.appendChild(inner);
el.appendChild(prog);
}
// Drag events
el.addEventListener("dragstart", e => {
el.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", task.id);
});
el.addEventListener("dragend", () => {
el.classList.remove("dragging");
});
return el;
}
// ---------- Pomodoro logic ----------
function startPomo(id) {
const t = tasks.find(x => x.id === id);
if (!t) return;
if (t.status === "done") return;
if (t.status === "todo") t.status = "doing";
const total = totalForTimer();
t.timer = { remaining: total, running: true, total };
save();
render();
startTick(id);
}
function togglePomo(id) {
const t = tasks.find(x => x.id === id);
if (!t || !t.timer) return;
if (t.timer.remaining <= 0) return;
t.timer.running = !t.timer.running;
save();
render();
if (t.timer.running) startTick(id);
else stopTick(id);
}
function resetPomo(id) {
const t = tasks.find(x => x.id === id);
if (!t || !t.timer) return;
stopTick(id);
const total = totalForTimer();
t.timer = { remaining: total, running: false, total };
save();
render();
}
function startTick(id) {
stopTick(id);
tickHandles[id] = setInterval(() => {
const t = tasks.find(x => x.id === id);
if (!t || !t.timer || !t.timer.running) { stopTick(id); return; }
t.timer.remaining -= 1;
if (t.timer.remaining <= 0) {
t.timer.remaining = 0;
t.timer.running = false;
t.completedPomos = (t.completedPomos || 0) + 1;
stopTick(id);
save();
render();
// light notification
try {
// simple beep using Web Audio API (no external resource)
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const o = ctx.createOscillator();
const g = ctx.createGain();
o.connect(g); g.connect(ctx.destination);
o.frequency.value = 880;
g.gain.value = 0.05;
o.start();
setTimeout(()=>{ o.stop(); ctx.close(); }, 250);
} catch(e){}
return;
}
save();
// update only timer text & progress for performance
const card = document.querySelector('.card[data-id="'+id+'"]');
if (card) {
const timerEl = card.querySelector(".timer");
if (timerEl) timerEl.textContent = fmt(t.timer.remaining);
const prog = card.querySelector(".pomo-progress > div");
if (prog) {
const total = t.timer.total || totalForTimer();
prog.style.width = (((total - t.timer.remaining)/total)*100) + "%";
}
}
}, 1000);
}
function stopTick(id) {
if (tickHandles[id]) {
clearInterval(tickHandles[id]);
delete tickHandles[id];
}
}
function stopAllTicks() {
Object.keys(tickHandles).forEach(stopTick);
}
// ---------- CRUD ----------
function addTask(data) {
tasks.unshift({
id: uid(),
title: data.title,
estimate: data.estimate,
priority: data.priority,
status: "todo",
completedPomos: 0,
timer: null,
createdAt: Date.now(),
});
save();
render();
}
function deleteTask(id) {
stopTick(id);
tasks = tasks.filter(t => t.id !== id);
save();
render();
}
function moveTask(id, newStatus) {
const t = tasks.find(x => x.id === id);
if (!t || t.status === newStatus) return;
t.status = newStatus;
if (newStatus === "done") {
// stop any running timer
if (t.timer && t.timer.running) {
t.timer.running = false;
stopTick(id);
}
}
save();
render();
}
function clearDone() {
const doneTasks = tasks.filter(t => t.status === "done");
if (doneTasks.length === 0) {
alert("当前没有已完成的任务");
return;
}
if (!confirm("确定要清空 " + doneTasks.length + " 个已完成的任务吗?")) return;
doneTasks.forEach(t => stopTick(t.id));
tasks = tasks.filter(t => t.status !== "done");
save();
render();
}
// ---------- Form ----------
const form = document.getElementById("taskForm");
const errorEl = document.getElementById("formError");
form.addEventListener("submit", function(e){
e.preventDefault();
errorEl.textContent = "";
const titleInput = document.getElementById("title");
const estimateInput = document.getElementById("estimate");
const priorityInput = document.getElementById("priority");
const title = (titleInput.value || "").trim();
const estimate = parseInt(estimateInput.value, 10);
const priority = priorityInput.value;
if (!title) { errorEl.textContent = "请输入任务标题"; titleInput.focus(); return; }
if (title.length > 80) { errorEl.textContent = "标题不能超过 80 个字符"; return; }
if (!estimate || isNaN(estimate) || estimate < 1 || estimate > 20) {
errorEl.textContent = "预估番茄数应在 1 - 20 之间"; estimateInput.focus(); return;
}
if (!["high","medium","low"].includes(priority)) {
errorEl.textContent = "请选择优先级"; return;
}
addTask({ title, estimate, priority });
form.reset();
document.getElementById("estimate").value = 1;
document.getElementById("priority").value = "medium";
titleInput.focus();
});
// ---------- DnD ----------
document.querySelectorAll(".column-body").forEach(col => {
col.addEventListener("dragover", e => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
col.classList.add("drag-over");
});
col.addEventListener("dragleave", () => col.classList.remove("drag-over"));
col.addEventListener("drop", e => {
e.preventDefault();
col.classList.remove("drag-over");
const id = e.dataTransfer.getData("text/plain");
const status = col.dataset.status;
if (id && status) moveTask(id, status);
});
});
// ---------- Search ----------
document.getElementById("search").addEventListener("input", function(e){
searchQuery = e.target.value || "";
render();
});
// ---------- Demo mode ----------
const demoToggle = document.getElementById("demoMode");
demoToggle.addEventListener("change", function(){
demoMode = demoToggle.checked;
saveDemo();
// Don't retroactively change existing timers; new timers will use new value.
});
// ---------- Clear done ----------
document.getElementById("clearDone").addEventListener("click", clearDone);
// ---------- Init ----------
load();
demoToggle.checked = demoMode;
render();
window.addEventListener("beforeunload", () => {
// pause all timers so they don't auto-resume
tasks.forEach(t => { if (t.timer) t.timer.running = false; });
save();
stopAllTicks();
});
})();
</script>
</body>
</html>
2026-05-23 17:04:54 · 97079 ms · 总耗时 97s · 输入 659 / 输出 9,361 / 总计 10,020 tokens