325 lines
11 KiB
HTML
325 lines
11 KiB
HTML
<!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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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>
|