Initial commit: 团队协作看板 project

This commit is contained in:
2026-09-15 05:44:08 +00:00
commit 0791218880
4 changed files with 465 additions and 0 deletions

1
app/README.md Normal file
View File

@ -0,0 +1 @@
open kanban.html in a browser, or run `python3 -m http.server` from this folder then visit /kanban.html

324
app/kanban.html Normal file
View File

@ -0,0 +1,324 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>团队协作看板</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Noto Sans CJK SC", "Microsoft YaHei", sans-serif;
background: #f4f5f7;
color: #172b4d;
min-height: 100vh;
}
.top {
height: 64px;
background: #fff;
border-bottom: 1px solid #ebecf0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
gap: 16px;
}
.top h1 { font-size: 18px; font-weight: 600; }
.top-actions { display: flex; align-items: center; gap: 12px; }
.select {
height: 36px; padding: 0 12px; border: 1px solid #dfe1e6; border-radius: 6px;
background: #fff; color: #172b4d; font-size: 14px;
}
.btn {
height: 36px; padding: 0 14px; border: none; border-radius: 6px;
background: #0052cc; color: #fff; font-size: 14px; font-weight: 500; cursor: pointer;
}
.btn:hover { background: #0747a6; }
.board {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
padding: 20px 24px 32px;
align-items: start;
}
.col {
background: #ebecf0;
border-radius: 10px;
padding: 12px;
min-height: 420px;
}
.col-head {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; padding: 4px 4px 8px;
}
.col-title { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
.count {
min-width: 22px; height: 22px; padding: 0 6px; border-radius: 11px;
background: #dfe1e6; font-size: 12px; display: inline-flex; align-items: center; justify-content: center;
}
.card {
background: #fff; border-radius: 8px; padding: 12px;
box-shadow: 0 1px 2px rgba(9,30,66,.08);
margin-bottom: 10px;
}
.card h3 { font-size: 14px; font-weight: 600; margin-bottom: 6px; line-height: 1.4; }
.card p { font-size: 12px; color: #5e6c84; line-height: 1.5; margin-bottom: 10px; }
.card .actions { display: flex; gap: 8px; flex-wrap: wrap; }
.chip {
font-size: 12px; padding: 4px 8px; border-radius: 4px;
background: #f4f5f7; color: #42526e; border: 1px solid #dfe1e6;
cursor: pointer;
}
.chip:hover { background: #ebecf0; border-color: #c1c7d0; }
.empty {
border: 1px dashed #c1c7d0; border-radius: 8px; padding: 28px 12px;
text-align: center; color: #7a869a; font-size: 13px; background: rgba(255,255,255,.35);
}
.col.hidden { display: none; }
/* Modal */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(9,30,66,.4);
align-items: center; justify-content: center; z-index: 100;
}
.modal-overlay.open { display: flex; }
.modal {
background: #fff; border-radius: 10px; padding: 24px; width: 100%; max-width: 420px;
box-shadow: 0 8px 24px rgba(9,30,66,.2);
}
.modal h2 { font-size: 16px; font-weight: 600; margin-bottom: 16px; }
.modal label { display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #42526e; }
.modal input, .modal textarea {
width: 100%; padding: 8px 10px; border: 1px solid #dfe1e6; border-radius: 6px;
font-size: 14px; font-family: inherit; color: #172b4d; margin-bottom: 14px;
}
.modal textarea { min-height: 72px; resize: vertical; }
.modal input:focus, .modal textarea:focus { outline: none; border-color: #0052cc; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
.btn-ghost {
height: 36px; padding: 0 14px; border: 1px solid #dfe1e6; border-radius: 6px;
background: #fff; color: #42526e; font-size: 14px; cursor: pointer;
}
.err { color: #de350b; font-size: 12px; margin-top: -10px; margin-bottom: 10px; display: none; }
@media (max-width: 768px) {
.board { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<header class="top">
<h1>团队协作看板</h1>
<div class="top-actions">
<select class="select" id="filter" aria-label="状态筛选">
<option value="all">全部状态</option>
<option value="todo">仅待办</option>
<option value="doing">仅进行中</option>
<option value="done">仅已完成</option>
</select>
<button class="btn" id="btn-create" type="button">新建任务</button>
</div>
</header>
<main class="board">
<section class="col" id="col-todo" data-status="todo">
<div class="col-head"><div class="col-title">待办 <span class="count" id="count-todo">0</span></div></div>
<div class="cards" id="cards-todo"></div>
</section>
<section class="col" id="col-doing" data-status="doing">
<div class="col-head"><div class="col-title">进行中 <span class="count" id="count-doing">0</span></div></div>
<div class="cards" id="cards-doing"></div>
</section>
<section class="col" id="col-done" data-status="done">
<div class="col-head"><div class="col-title">已完成 <span class="count" id="count-done">0</span></div></div>
<div class="cards" id="cards-done"></div>
</section>
</main>
<div class="modal-overlay" id="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div class="modal">
<h2 id="modal-title">新建任务</h2>
<label for="task-title">标题 <span style="color:#de350b">*</span></label>
<input type="text" id="task-title" placeholder="请输入任务标题" maxlength="200" />
<div class="err" id="title-err">请填写标题</div>
<label for="task-note">备注(可选)</label>
<textarea id="task-note" placeholder="选填备注" maxlength="1000"></textarea>
<div class="modal-actions">
<button type="button" class="btn-ghost" id="btn-cancel">取消</button>
<button type="button" class="btn" id="btn-save">创建</button>
</div>
</div>
</div>
<script>
(function () {
var STORAGE_KEY = 'team-kanban-v2';
var STATUSES = ['todo', 'doing', 'done'];
var LABELS = { todo: '待办', doing: '进行中', done: '已完成' };
var EMPTY_TEXT = { todo: '暂无待办任务', doing: '暂无进行中任务', done: '暂无已完成任务' };
var MOVE_CHIPS = {
todo: [{ to: 'doing', label: '移到进行中' }, { to: 'done', label: '移到已完成' }],
doing: [{ to: 'todo', label: '移回待办' }, { to: 'done', label: '移到已完成' }],
done: [{ to: 'todo', label: '移回待办' }, { to: 'doing', label: '移到进行中' }]
};
var tasks = [];
var filter = 'all';
function uid() {
return 't-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 8);
}
function load() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (raw === null) {
// First visit: seed demo tasks
tasks = [
{ id: uid(), title: '整理验收检查清单', note: '对齐项目经理五条验收口径', status: 'todo' },
{ id: uid(), title: '准备演示话术', note: '选填备注示例', status: 'todo' },
{ id: uid(), title: '实现本地存储', note: '刷新后任务仍保留', status: 'doing' }
];
save();
return;
}
var parsed = JSON.parse(raw);
tasks = Array.isArray(parsed) ? parsed : [];
} catch (e) {
tasks = [];
}
}
function save() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(tasks));
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderCard(task) {
var chips = MOVE_CHIPS[task.status] || [];
var noteHtml = task.note
? '<p>' + escapeHtml(task.note) + '</p>'
: '';
var actions = chips.map(function (c) {
return '<button type="button" class="chip" data-move="' + c.to + '" data-id="' + escapeHtml(task.id) + '">' +
escapeHtml(c.label) + '</button>';
}).join('');
return '<article class="card" data-id="' + escapeHtml(task.id) + '">' +
'<h3>' + escapeHtml(task.title) + '</h3>' +
noteHtml +
'<div class="actions">' + actions + '</div>' +
'</article>';
}
function render() {
STATUSES.forEach(function (status) {
var col = document.getElementById('col-' + status);
var cardsEl = document.getElementById('cards-' + status);
var countEl = document.getElementById('count-' + status);
var inStatus = tasks.filter(function (t) { return t.status === status; });
countEl.textContent = String(inStatus.length);
// Filter: hide whole column when not matching
if (filter === 'all' || filter === status) {
col.classList.remove('hidden');
} else {
col.classList.add('hidden');
}
if (inStatus.length === 0) {
cardsEl.innerHTML = '<div class="empty">' + EMPTY_TEXT[status] + '</div>';
} else {
cardsEl.innerHTML = inStatus.map(renderCard).join('');
}
});
}
function createTask(title, note) {
tasks.push({
id: uid(),
title: title.trim(),
note: (note || '').trim(),
status: 'todo'
});
save();
render();
}
function moveTask(id, toStatus) {
if (STATUSES.indexOf(toStatus) === -1) return;
var t = tasks.find(function (x) { return x.id === id; });
if (!t) return;
t.status = toStatus;
save();
render();
}
// Modal
var modal = document.getElementById('modal');
var titleInput = document.getElementById('task-title');
var noteInput = document.getElementById('task-note');
var titleErr = document.getElementById('title-err');
function openModal() {
titleInput.value = '';
noteInput.value = '';
titleErr.style.display = 'none';
modal.classList.add('open');
setTimeout(function () { titleInput.focus(); }, 50);
}
function closeModal() {
modal.classList.remove('open');
}
document.getElementById('btn-create').addEventListener('click', openModal);
document.getElementById('btn-cancel').addEventListener('click', closeModal);
modal.addEventListener('click', function (e) {
if (e.target === modal) closeModal();
});
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && modal.classList.contains('open')) closeModal();
});
document.getElementById('btn-save').addEventListener('click', function () {
var title = titleInput.value.trim();
if (!title) {
titleErr.style.display = 'block';
titleInput.focus();
return;
}
titleErr.style.display = 'none';
createTask(title, noteInput.value);
closeModal();
});
titleInput.addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
e.preventDefault();
document.getElementById('btn-save').click();
}
});
// Chip move (event delegation)
document.querySelector('.board').addEventListener('click', function (e) {
var btn = e.target.closest('[data-move]');
if (!btn) return;
moveTask(btn.getAttribute('data-id'), btn.getAttribute('data-move'));
});
// Filter
document.getElementById('filter').addEventListener('change', function (e) {
filter = e.target.value;
render();
});
load();
render();
})();
</script>
</body>
</html>