待办
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>燕幕·日程待办</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
user-select: none; /* 避免拖拽时选中文本, 宿主外框负责拖拽 */
}
html, body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: transparent;
}
body {
font-family: "Microsoft YaHei", "Segoe UI", "PingFang SC", Roboto, sans-serif;
background: transparent;
}
/* 主卡片:完全填充宿主区域,深色质感渐变 */
.card {
width: 100%;
height: 100%;
background: radial-gradient(ellipse at 30% 20%, #1A2A32, #0E1A1F);
border-radius: 18px;
border: 1px solid rgba(120, 180, 200, 0.35);
box-shadow: inset 0 1px 0.5px rgba(255, 255, 255, 0.08), 0 8px 20px rgba(0, 0, 0, 0.3);
box-sizing: border-box;
display: flex;
flex-direction: column;
backdrop-filter: blur(0px); /* 纯透明层不使用blur,避免文字模糊, 保持锐利 */
position: relative;
overflow: hidden;
}
/* 顶部操作栏 + 标题 */
.header {
padding: 12px 16px 8px 18px;
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: 1px solid rgba(70, 130, 150, 0.3);
background: transparent;
flex-shrink: 0;
}
.title-section {
display: flex;
align-items: baseline;
gap: 8px;
}
.title {
font-size: 1.05rem;
font-weight: 550;
letter-spacing: 0.5px;
background: linear-gradient(135deg, #E0F0F5, #B5D8E8);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
text-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.stats-badge {
font-size: 0.7rem;
background: rgba(40, 70, 80, 0.65);
padding: 2px 8px;
border-radius: 20px;
color: #9acad9;
font-weight: 400;
backdrop-filter: blur(2px);
}
/* 分类标签栏 */
.tabs {
display: flex;
gap: 8px;
padding: 8px 16px 0 18px;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
flex-shrink: 0;
}
.tabs::-webkit-scrollbar {
height: 2px;
}
.tab-btn {
background: rgba(30, 55, 65, 0.7);
border: none;
padding: 5px 14px;
border-radius: 40px;
font-size: 0.7rem;
font-weight: 500;
color: #B8D8E8;
cursor: pointer;
transition: all 0.2s ease;
backdrop-filter: blur(2px);
white-space: nowrap;
font-family: inherit;
}
.tab-btn.active {
background: #2F8FAA;
color: #F0F9FF;
box-shadow: 0 2px 8px rgba(47, 143, 170, 0.3);
border: 0.5px solid rgba(255,255,240,0.2);
}
.tab-btn:hover:not(.active) {
background: rgba(55, 100, 115, 0.9);
color: #E0F0FA;
}
/* 新增待办输入区 */
.input-area {
padding: 12px 16px 8px 18px;
display: flex;
gap: 10px;
align-items: center;
flex-shrink: 0;
}
.task-input {
flex: 1;
background: rgba(0, 0, 0, 0.35);
border: 1px solid rgba(80, 140, 160, 0.6);
border-radius: 28px;
padding: 8px 14px;
font-size: 0.8rem;
color: #EFF9FF;
font-family: inherit;
outline: none;
transition: all 0.2s;
backdrop-filter: blur(4px);
}
.task-input:focus {
border-color: #4AB3D0;
background: rgba(0, 0, 0, 0.5);
box-shadow: 0 0 0 1px rgba(74, 179, 208, 0.3);
}
.task-input::placeholder {
color: #8aabb8;
font-size: 0.75rem;
}
.add-btn {
background: #2F8FAA;
border: none;
width: 34px;
height: 34px;
border-radius: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: 0.18s;
color: white;
font-size: 1.3rem;
font-weight: 400;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
}
.add-btn:hover {
background: #40B0CE;
transform: scale(0.96);
}
.add-btn:active {
background: #1F6F88;
}
/* 时间段快捷提醒区 (贴片风格) */
.time-block-section {
padding: 5px 18px 5px 18px;
flex-shrink: 0;
}
.time-slot-row {
background: rgba(25, 50, 60, 0.6);
backdrop-filter: blur(6px);
border-radius: 40px;
padding: 6px 12px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
border: 0.5px solid rgba(90, 160, 180, 0.4);
}
.time-slot-label {
font-size: 0.7rem;
font-weight: 500;
color: #C0E0ED;
letter-spacing: 0.3px;
background: rgba(0,0,0,0.3);
padding: 3px 8px;
border-radius: 30px;
}
.slot-times {
display: flex;
gap: 16px;
font-size: 0.75rem;
font-weight: 500;
color: #DBF0F5;
}
.slot-item {
cursor: pointer;
transition: all 0.1s ease;
padding: 3px 6px;
border-radius: 20px;
background: rgba(30, 70, 85, 0.5);
}
.slot-item:hover {
background: #3C99B5;
color: white;
transform: scale(0.98);
}
/* 待办列表容器 (内部滚动, 自定义滚动条) */
.todo-list-container {
flex: 1;
overflow-y: auto;
margin: 4px 12px 12px 12px;
padding-right: 4px;
scrollbar-width: thin;
scrollbar-color: #3C7C8C #1E2F36;
}
.todo-list-container::-webkit-scrollbar {
width: 5px;
}
.todo-list-container::-webkit-scrollbar-track {
background: #1E2F36;
border-radius: 10px;
}
.todo-list-container::-webkit-scrollbar-thumb {
background: #3C7C8C;
border-radius: 10px;
}
.todo-list-container::-webkit-scrollbar-thumb:hover {
background: #5fa3b5;
}
.todo-item {
background: rgba(20, 45, 55, 0.55);
backdrop-filter: blur(4px);
border-radius: 14px;
padding: 8px 12px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 12px;
border: 0.5px solid rgba(100, 170, 190, 0.3);
transition: all 0.15s;
}
.todo-item:hover {
background: rgba(35, 75, 88, 0.7);
border-color: rgba(90, 180, 210, 0.5);
}
.todo-check {
width: 18px;
height: 18px;
border-radius: 30px;
border: 1.5px solid #70B8CE;
background: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: 0.1s;
flex-shrink: 0;
}
.todo-check.completed {
background: #3FACCA;
border-color: #B2E4F5;
position: relative;
}
.todo-check.completed::after {
content: "✓";
font-size: 12px;
color: #05252f;
font-weight: bold;
}
.todo-text {
flex: 1;
font-size: 0.8rem;
color: #E6F3F8;
word-break: break-word;
}
.todo-text.completed-text {
text-decoration: line-through;
color: #85A7B3;
}
.todo-category-badge {
background: #2A6F84;
border-radius: 16px;
padding: 2px 8px;
font-size: 0.6rem;
font-weight: 500;
color: #DDF4FF;
flex-shrink: 0;
}
.delete-todo {
opacity: 0.7;
cursor: pointer;
font-size: 1rem;
color: #C7DDE8;
transition: 0.1s;
padding: 4px;
line-height: 1;
flex-shrink: 0;
}
.delete-todo:hover {
opacity: 1;
color: #ffb5a0;
transform: scale(1.05);
}
.empty-message {
text-align: center;
padding: 30px 12px;
color: #7FA5B5;
font-size: 0.75rem;
letter-spacing: 0.3px;
}
/* 进度小计 */
.footer-stats {
flex-shrink: 0;
padding: 8px 18px 12px 18px;
border-top: 0.5px solid rgba(70, 130, 150, 0.3);
font-size: 0.65rem;
color: #97C1D0;
display: flex;
justify-content: space-between;
background: rgba(0,0,0,0.1);
}
</style>
</head>
<body>
<div class="card">
<div class="header">
<div class="title-section">
<span class="title">✦ 燕幕待办</span>
<span class="stats-badge" id="statsPercent">0%</span>
</div>
<div style="font-size:0.7rem; opacity:0.7;">今日专注</div>
</div>
<!-- 分类筛选 -->
<div class="tabs" id="tabContainer">
<button class="tab-btn active" data-cat="all">全部</button>
<button class="tab-btn" data-cat="工作">工作</button>
<button class="tab-btn" data-cat="学习">学习</button>
<button class="tab-btn" data-cat="生活">生活</button>
<button class="tab-btn" data-cat="其他">其他</button>
<button class="tab-btn" data-cat="已完成">已完成</button>
</div>
<!-- 时间段贴图: 可点击插入固定任务 -->
<div class="time-block-section">
<div class="time-slot-row">
<span class="time-slot-label">⏰ 时段贴片</span>
<div class="slot-times">
<span class="slot-item" data-slot="上午 09:00 开会">🌅 09:00 晨会</span>
<span class="slot-item" data-slot="下午 14:30 深度工作">📌 14:30 专注</span>
<span class="slot-item" data-slot="晚间 20:00 复习">🌙 20:00 复习</span>
</div>
</div>
</div>
<!-- 新增输入框 -->
<div class="input-area">
<input type="text" class="task-input" id="taskInput" placeholder="写个待办,回车或按 + 添加... (例如: 整理报告 #工作)">
<button class="add-btn" id="addTaskBtn">+</button>
</div>
<!-- 待办列表滚动区 -->
<div class="todo-list-container" id="todoListContainer">
<!-- 动态渲染 -->
<div class="empty-message">✨ 暂无待办,点击时段或添加任务</div>
</div>
<div class="footer-stats">
<span id="completedCount">0</span> / <span id="totalCount">0</span> 已完成
<span id="todayCompleteTip">今日完成: 0</span>
</div>
</div>
<script>
(function() {
// ---------- 待办数据结构 ----------
// 每个待办 { id, text, completed, category, createdAt }
let todos = [];
let currentFilter = "all"; // all,工作,学习,生活,其他,已完成
// 关键存储key (燕幕宿主隔离)
const STORAGE_KEY = "daily_tasks_data";
// 辅助方法: 从字符串解析 #标签 来决定默认分类
function extractCategoryFromText(rawText) {
const match = rawText.match(/#(工作|学习|生活|其他)/);
if(match) return match[1];
return "其他"; // 默认其他
}
// 清理文本移除标签符号
function cleanText(raw) {
return raw.replace(/#(工作|学习|生活|其他)/g, '').trim() || "未命名任务";
}
// 保存到宿主 (state.write)
async function saveTodosToHost() {
if (!window.yanm) return;
try {
await window.yanm.invoke('state.write', { key: STORAGE_KEY, value: JSON.stringify(todos) });
} catch(e) { console.warn("save state error", e); }
}
// 从宿主读取数据
async function loadTodosFromHost() {
if (!window.yanm) return null;
try {
const res = await window.yanm.invoke('state.read', { key: STORAGE_KEY, defaultValue: '[]' });
if (res && typeof res === 'string') {
return JSON.parse(res);
}
return null;
} catch(e) {
console.warn("load state error", e);
return null;
}
}
// 渲染界面 (根据currentFilter)
function renderTodos() {
const container = document.getElementById('todoListContainer');
if (!container) return;
let filtered = [...todos];
if (currentFilter === "已完成") {
filtered = filtered.filter(t => t.completed === true);
} else if (currentFilter !== "all") {
filtered = filtered.filter(t => t.category === currentFilter && !t.completed);
} else {
filtered = filtered.filter(t => !t.completed); // 全部未完成
}
if (filtered.length === 0) {
container.innerHTML = `<div class="empty-message">📭 没有待办,点时段或添加新任务~</div>`;
} else {
container.innerHTML = filtered.map(todo => `
<div class="todo-item" data-id="${todo.id}">
<div class="todo-check ${todo.completed ? 'completed' : ''}" data-action="toggle" data-id="${todo.id}"></div>
<div class="todo-text ${todo.completed ? 'completed-text' : ''}">${escapeHtml(todo.text)}</div>
<div class="todo-category-badge">${todo.category || '其他'}</div>
<div class="delete-todo" data-action="delete" data-id="${todo.id}">🗑️</div>
</div>
`).join('');
}
// 更新统计
const totalCount = todos.length;
const completedCount = todos.filter(t => t.completed === true).length;
const percent = totalCount === 0 ? 0 : Math.round((completedCount / totalCount) * 100);
document.getElementById('statsPercent').innerText = `${percent}%`;
document.getElementById('completedCount').innerText = completedCount;
document.getElementById('totalCount').innerText = totalCount;
// 今日完成数 (基于 createdAt 日期是今天)
const todayStr = new Date().toDateString();
const todayCompleted = todos.filter(t => {
if (!t.completed) return false;
const todoDate = new Date(t.createdAt).toDateString();
return todoDate === todayStr;
}).length;
document.getElementById('todayCompleteTip').innerHTML = `今日完成: ${todayCompleted}`;
}
// 简单防XSS
function escapeHtml(str) {
return str.replace(/[&<>]/g, function(m) {
if(m === '&') return '&';
if(m === '<') return '<';
if(m === '>') return '>';
return m;
}).replace(/[\uD800-\uDBFF][\uDC00-\uDFFF]/g, function(c) {
return c;
});
}
// 添加待办
function addTodo(rawText) {
if (!rawText || rawText.trim() === '') return false;
let category = extractCategoryFromText(rawText);
let cleanContent = cleanText(rawText);
if (cleanContent === "") cleanContent = "快速任务";
const newTodo = {
id: Date.now() + '-' + Math.random().toString(36).substr(2, 6),
text: cleanContent,
completed: false,
category: category,
createdAt: new Date().toISOString()
};
todos.unshift(newTodo);
renderTodos();
saveTodosToHost(); // 异步保存不阻塞界面
return true;
}
// 切换完成状态
function toggleComplete(id) {
const todo = todos.find(t => t.id == id);
if (todo) {
todo.completed = !todo.completed;
renderTodos();
saveTodosToHost();
}
}
// 删除待办
function deleteTodo(id) {
todos = todos.filter(t => t.id != id);
renderTodos();
saveTodosToHost();
}
// 事件委托
function handleContainerClick(e) {
const target = e.target;
const action = target.getAttribute('data-action');
if (action === 'toggle') {
const id = target.getAttribute('data-id');
if(id) toggleComplete(id);
} else if (action === 'delete') {
const id = target.getAttribute('data-id');
if(id) deleteTodo(id);
}
}
// 时段贴片点击添加
function bindTimeSlots() {
document.querySelectorAll('.slot-item').forEach(el => {
el.addEventListener('click', (e) => {
const slotDesc = el.getAttribute('data-slot') || el.innerText;
// 根据贴片内容智能分类: 含"开会"工作,"复习"学习,"专注"工作类
let raw = slotDesc;
if (slotDesc.includes('晨会') || slotDesc.includes('开会')) raw = slotDesc + " #工作";
else if (slotDesc.includes('复习')) raw = slotDesc + " #学习";
else if (slotDesc.includes('专注')) raw = slotDesc + " #工作";
else raw = slotDesc + " #其他";
addTodo(raw);
});
});
}
// 分类按钮切换
function bindTabs() {
const btns = document.querySelectorAll('.tab-btn');
btns.forEach(btn => {
btn.addEventListener('click', () => {
const cat = btn.getAttribute('data-cat');
if(cat) {
currentFilter = cat;
btns.forEach(b => b.classList.remove('active'));
btn.classList.add('active');
renderTodos();
}
});
});
}
// 初始化与宿主交互 (重试机制)
let initRetryCount = 0;
async function initWithHost() {
// 先渲染空UI占位,再尝试读宿主数据
renderTodos();
if (window.yanm && typeof window.yanm.invoke === 'function') {
// 读取持久化数据
const loaded = await loadTodosFromHost();
if (loaded && Array.isArray(loaded)) {
todos = loaded;
} else {
// 兜底预设两条样例
if(todos.length === 0) {
todos = [
{ id: 'sample1', text: '设计燕幕组件', completed: false, category: '工作', createdAt: new Date().toISOString() },
{ id: 'sample2', text: '阅读技术文章', completed: false, category: '学习', createdAt: new Date().toISOString() }
];
saveTodosToHost();
}
}
renderTodos();
} else {
// 未就绪重试 (setTimeout)
if (initRetryCount < 15) {
initRetryCount++;
setTimeout(initWithHost, 200);
} else {
// 完全离线圈: 使用localStorage兜底,但为了保证体验,初始化样例
try {
const fallback = localStorage.getItem(STORAGE_KEY);
if(fallback) todos = JSON.parse(fallback);
else if(todos.length === 0) todos = [{ id: 'local1', text: '欢迎使用燕幕待办', completed: false, category: '生活', createdAt: new Date().toISOString() }];
renderTodos();
} catch(e) {}
}
return;
}
}
// 监听回车添加、按钮添加
function bindInputEvents() {
const inputEl = document.getElementById('taskInput');
const addBtn = document.getElementById('addTaskBtn');
if(inputEl) {
inputEl.addEventListener('keypress', (e) => {
if(e.key === 'Enter') {
e.preventDefault();
const val = inputEl.value.trim();
if(val) {
addTodo(val);
inputEl.value = '';
}
}
});
}
if(addBtn) {
addBtn.addEventListener('click', () => {
const val = document.getElementById('taskInput').value.trim();
if(val) {
addTodo(val);
document.getElementById('taskInput').value = '';
}
});
}
}
// 宿主未提供window.close需求但是添加一个可选项: 不需要主动关闭组件, 但不排除
// 为了符合规范,不添加额外close
function mount() {
bindInputEvents();
bindTabs();
bindTimeSlots();
const containerDiv = document.getElementById('todoListContainer');
if(containerDiv) containerDiv.addEventListener('click', handleContainerClick);
initWithHost();
}
mount();
})();
</script>
</body>
</html>