Skip to content

taskflow 跨设备备忘录

时间:2026 年 5 月 4 日

应学校科创节的要求而生。详细的原理后期会解释。

体验 taskflow


填坑。

由于时间紧迫,这个程序有借助 dpsk 的力量。其他的就不放出来了,丢人。

后端的核心,是根据 api 传来的 syncKey 参数匹配对应的表,读取后返回。基于 node.js 实现,核心为 index.js

javascript
// 定义待办事项的数据结构
// {
//   id: string,
//   text: string,
//   completed: boolean,
//   createdAt: number
// }

// 处理跨域请求(CORS)的响应头
const corsHeaders = {
	'Access-Control-Allow-Origin': '*',
	'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
	'Access-Control-Allow-Headers': 'Content-Type',
};

/**
 * 处理 HTTP 请求的主函数
 * @param {Request} request - 请求对象
 * @param {Object} env - 环境变量,包含 KV 绑定 TODO_KV
 * @returns {Promise<Response>}
 */
export default {
	async fetch(request, env) {
		// 处理预检请求(OPTIONS)
		if (request.method === 'OPTIONS') {
			return new Response(null, { headers: corsHeaders });
		}

		const url = new URL(request.url);
		const path = url.pathname;
		const syncKey = url.searchParams.get('syncKey');

		// 辅助函数:从 KV 中读取该用户的待办列表
		async function getTodos() {
			const kvKey = `todos:${syncKey}`;
			const data = await env.TODO_KV.get(kvKey, 'json');
			return data || [];
		}

		// 辅助函数:保存待办列表到 KV
		async function saveTodos(todos) {
			const kvKey = `todos:${syncKey}`;
			await env.TODO_KV.put(kvKey, JSON.stringify(todos));
		}

		// 生成一个新的同步码(新用户)
		if (path === '/generateKey' && request.method === 'GET') {
			const newKey = crypto.randomUUID().slice(0, 8); // 短码方便输入
			// 预存一个空数组
			await env.TODO_KV.put(`todos:${newKey}`, JSON.stringify([]));
			return new Response(JSON.stringify({ syncKey: newKey }), {
				headers: { 'Content-Type': 'application/json', ...corsHeaders },
			});
		}

		// 所有其他路由都需要 syncKey
		if (!syncKey) {
			return new Response(JSON.stringify({ error: 'Missing syncKey' }), {
				status: 400,
				headers: { 'Content-Type': 'application/json', ...corsHeaders },
			});
		}

		// 1. 获取所有待办
		if (path === '/todos' && request.method === 'GET') {
			const todos = await getTodos();
			return new Response(JSON.stringify(todos), {
				headers: { 'Content-Type': 'application/json', ...corsHeaders },
			});
		}

		// 2. 新增待办
		if (path === '/todos' && request.method === 'POST') {
			const { text } = await request.json();
			if (!text || typeof text !== 'string') {
				return new Response(JSON.stringify({ error: 'Invalid text' }), {
					status: 400,
					headers: { 'Content-Type': 'application/json', ...corsHeaders },
				});
			}
			const todos = await getTodos();
			const newTodo = {
				id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
				text: text.trim(),
				completed: false,
				createdAt: Date.now(),
			};
			todos.push(newTodo);
			await saveTodos(todos);
			return new Response(JSON.stringify(newTodo), {
				status: 201,
				headers: { 'Content-Type': 'application/json', ...corsHeaders },
			});
		}

		// 3. 更新待办(支持修改 text 或 completed 状态)
		if (path.startsWith('/todos/') && request.method === 'PUT') {
			const id = path.split('/')[2];
			const updates = await request.json();
			const todos = await getTodos();
			const index = todos.findIndex((todo) => todo.id === id);
			if (index === -1) {
				return new Response(JSON.stringify({ error: 'Todo not found' }), {
					status: 404,
					headers: { 'Content-Type': 'application/json', ...corsHeaders },
				});
			}
			// 只允许更新 text 和 completed 字段
			if (updates.text !== undefined) todos[index].text = updates.text.trim();
			if (updates.completed !== undefined) todos[index].completed = !!updates.completed;
			await saveTodos(todos);
			return new Response(JSON.stringify(todos[index]), {
				headers: { 'Content-Type': 'application/json', ...corsHeaders },
			});
		}

		// 4. 删除待办
		if (path.startsWith('/todos/') && request.method === 'DELETE') {
			const id = path.split('/')[2];
			const todos = await getTodos();
			const filtered = todos.filter((todo) => todo.id !== id);
			await saveTodos(filtered);
			return new Response(null, { status: 204, headers: corsHeaders });
		}

		// 未匹配任何路由
		return new Response(JSON.stringify({ error: 'Not found' }), {
			status: 404,
			headers: { 'Content-Type': 'application/json', ...corsHeaders },
		});
	},
};

前端就是个简单的 vue.js 页面。算是初次体验了。核心为 TodoApp.vue

vue
<template>
	<div class="app">
		<div class="todo-card">
			<h1>
				<span class="icon">📝</span>
				taskflow 跨设备备忘录
			</h1>

			<!-- 同步码管理区域 -->
			<div class="sync-section" :class="{ hasKey: syncKey }">
				<div v-if="syncKey" class="has-key">
					<div class="key-display">
						<span class="label">同步码</span>
						<code class="key">{{ syncKey }}</code>
						<button
							class="copy"
							@click="copySyncKey"
							title="复制同步码"
						>
							📋
						</button>
					</div>
					<p class="hint">
						在其他设备输入此码,即可同步待办清单<button
							class="switch-btn"
							@click="resetSyncKey"
						>
							切换同步码
						</button>
					</p>
				</div>
				<div v-else class="no-key">
					<input
						v-model="inputKey"
						type="text"
						placeholder="输入已有同步码"
						@keyup.enter="loadWithKey"
					/>
					<button @click="loadWithKey" :disabled="!inputKey.trim()">
						加载
					</button>
					<button @click="generateNewKey" class="primary">
						生成新同步码
					</button>
				</div>
			</div>

			<!-- 待办输入框 -->
			<div class="add-todo">
				<input
					v-model="newTodoText"
					type="text"
					placeholder="写一个任务..."
					@keyup.enter="addTodo"
					:disabled="!syncKey || isLoading"
				/>
				<button
					@click="addTodo"
					:disabled="!syncKey || !newTodoText.trim() || isLoading"
				>
					<span>+</span> 添加
				</button>
			</div>

			<!-- 待办列表 -->
			<div class="todo-list-container">
				<div v-if="isLoading && todos.length === 0" class="loading">
					<div class="spinner"></div>
					加载中...
				</div>
				<div v-else-if="todos.length === 0" class="empty-state">
					<div class="emoji">📭</div>
					<p>暂无待办,添加一条吧~</p>
				</div>
				<ul v-else class="todo-list">
					<li
						v-for="todo in todos"
						:key="todo.id"
						:class="{ completed: todo.completed }"
					>
						<input
							type="checkbox"
							v-model="todo.completed"
							@change="updateTodo(todo)"
						/>
						<span
							class="todo-text"
							contenteditable="true"
							@blur="editTodoText(todo, $event)"
							@keyup.enter="$event.target.blur()"
							>{{ todo.text }}</span
						>
						<button
							class="delete"
							@click="deleteTodo(todo.id)"
							title="删除"
						>

						</button>
					</li>
				</ul>
			</div>

			<!-- 手动同步按钮 -->
			<div class="sync-bar" v-if="syncKey">
				<button
					@click="manualSync"
					:disabled="isLoading"
					class="sync-btn"
				>
					<span>🔄</span> 同步
				</button>
				<span class="auto-hint">自动同步每 5 秒</span>
			</div>

			<!-- 版权声明 -->
			<footer class="copyright">
				<p>
					© 2026-present
					<a href="https://www.daoxi365.top">PanDaoxi</a> |
					<a href="https://projects.daoxi365.top/taskflow.html"
						>taskflow 跨设备备忘录</a
					>
				</p>
			</footer>
		</div>
	</div>
</template>

<script setup>
import { ref, onMounted, onUnmounted } from "vue";
import axios from "axios";

// ---------- 配置 ----------
// 替换为你的 Cloudflare Worker 部署地址
const API_BASE = "http://127.0.0.1:8787";

// ---------- 响应式数据 ----------
const syncKey = ref("");
const inputKey = ref("");
const todos = ref([]);
const newTodoText = ref("");
const isLoading = ref(false);
let syncInterval = null;

// ---------- 辅助函数 ----------
function showMessage(msg, isError = false) {
	const div = document.createElement("div");
	div.textContent = msg;
	div.style.cssText = `
    position: fixed;
    bottom: 20px;
    left: 50%;
    transform: translateX(-50%);
    background: ${isError ? "#f44336" : "#4caf50"};
    color: white;
    padding: 10px 20px;
    border-radius: 30px;
    font-size: 14px;
    z-index: 9999;
    box-shadow: 0 2px 10px rgba(0,0,0,0.2);
    animation: fadeOut 2s forwards;
	font-family:
		"HYTMR",
		"LXGW WenKai Mono Lite",
		system-ui,
		-apple-system,
		"Segoe UI",
		Roboto,
		"Helvetica Neue",
		sans-serif;
  `;
	document.body.appendChild(div);
	setTimeout(() => div.remove(), 2000);
}

async function copySyncKey() {
	try {
		await navigator.clipboard.writeText(syncKey.value);
		showMessage("同步码已复制");
	} catch {
		showMessage("复制失败,请手动复制", true);
	}
}

// ---------- API 调用 ----------
async function apiRequest(method, endpoint, data = null) {
	try {
		const url = `${API_BASE}${endpoint}`;
		const params = syncKey.value ? { syncKey: syncKey.value } : {};
		const config = {
			params,
			headers: { "Content-Type": "application/json" },
		};
		if (method === "GET") {
			const res = await axios.get(url, config);
			return res.data;
		} else if (method === "POST") {
			const res = await axios.post(url, data, config);
			return res.data;
		} else if (method === "PUT") {
			const res = await axios.put(url, data, config);
			return res.data;
		} else if (method === "DELETE") {
			await axios.delete(url, config);
			return null;
		}
	} catch (err) {
		const msg = err.response?.data?.error || err.message || "请求失败";
		showMessage(msg, true);
		throw err;
	}
}

async function fetchTodos() {
	if (!syncKey.value) return;
	isLoading.value = true;
	try {
		const data = await apiRequest("GET", "/todos");
		todos.value = data || [];
	} catch (err) {
		// 错误已在 apiRequest 中提示
	} finally {
		isLoading.value = false;
	}
}

async function addTodo() {
	const text = newTodoText.value.trim();
	if (!text) return;
	isLoading.value = true;
	try {
		await apiRequest("POST", "/todos", { text });
		newTodoText.value = "";
		await fetchTodos();
		showMessage("添加成功");
	} catch (err) {
		// 错误处理
	} finally {
		isLoading.value = false;
	}
}

async function updateTodo(todo) {
	try {
		await apiRequest("PUT", `/todos/${todo.id}`, {
			completed: todo.completed,
		});
	} catch (err) {
		todo.completed = !todo.completed;
		showMessage("更新失败", true);
	}
}

async function editTodoText(todo, event) {
	const newText = event.target.innerText.trim();
	if (newText === todo.text) return;
	if (!newText) {
		event.target.innerText = todo.text;
		return;
	}
	const oldText = todo.text;
	todo.text = newText;
	try {
		await apiRequest("PUT", `/todos/${todo.id}`, { text: newText });
		showMessage("已更新");
	} catch (err) {
		todo.text = oldText;
		event.target.innerText = oldText;
		showMessage("更新失败", true);
	}
}

async function deleteTodo(id) {
	if (!confirm("确定删除此项?")) return;
	isLoading.value = true;
	try {
		await apiRequest("DELETE", `/todos/${id}`);
		await fetchTodos();
		showMessage("已删除");
	} catch (err) {
		// 错误已提示
	} finally {
		isLoading.value = false;
	}
}

async function manualSync() {
	await fetchTodos();
	showMessage("同步完成");
}

async function generateNewKey() {
	isLoading.value = true;
	try {
		const res = await axios.get(`${API_BASE}/generateKey`);
		const newKey = res.data.syncKey;
		syncKey.value = newKey;
		localStorage.setItem("todo_syncKey", newKey);
		todos.value = [];
		showMessage(`新同步码:${newKey},请保存好`);
	} catch (err) {
		showMessage("生成失败,请检查网络", true);
	} finally {
		isLoading.value = false;
	}
}

async function loadWithKey() {
	const key = inputKey.value.trim();
	if (!key) return;
	syncKey.value = key;
	localStorage.setItem("todo_syncKey", key);
	await fetchTodos();
	showMessage("加载成功");
}

function resetSyncKey() {
	if (syncInterval) clearInterval(syncInterval);
	syncKey.value = "";
	localStorage.removeItem("todo_syncKey");
	todos.value = [];
	inputKey.value = "";
	showMessage("已退出,可输入新同步码或生成新码");
	startAutoSync();
}

function startAutoSync() {
	if (syncInterval) clearInterval(syncInterval);
	syncInterval = setInterval(() => {
		if (syncKey.value && !isLoading.value) {
			apiRequest("GET", "/todos")
				.then((data) => {
					if (data) todos.value = data;
				})
				.catch(() => {});
		}
	}, 5000);
}

onMounted(() => {
	const savedKey = localStorage.getItem("todo_syncKey");
	if (savedKey) {
		syncKey.value = savedKey;
		fetchTodos();
	}
	startAutoSync();
});

onUnmounted(() => {
	if (syncInterval) clearInterval(syncInterval);
});

// 动态添加动画关键帧(避免重复添加)
if (!document.querySelector("#todo-animation-style")) {
	const style = document.createElement("style");
	style.id = "todo-animation-style";
	style.textContent = `
    @keyframes fadeOut {
      0% { opacity: 1; }
      70% { opacity: 1; }
      100% { opacity: 0; visibility: hidden; }
    }
  `;
	document.head.appendChild(style);
}
document.title = "taskflow 跨设备备忘录";
</script>

<style scoped>
/* 原有字体导入与基础样式保持不变 */
@import url("https://npm.webcache.cn/fonts-daoxi365@1.0.8/LXGWWenKaiMonoLite-Regular/result.css");

@font-face {
	font-family: "HYTMR";
	src: url("https://npm.elemecdn.com/fontcdn-ariasaka@1.0.0/HYTangMeiRen55W.woff2")
		format("woff2");
	font-weight: normal;
	font-style: normal;
	font-display: swap;
}
* {
	box-sizing: border-box;
	font-family:
		"HYTMR",
		"LXGW WenKai Mono Lite",
		system-ui,
		-apple-system,
		"Segoe UI",
		Roboto,
		"Helvetica Neue",
		sans-serif;
}

.app {
	min-height: 100vh;
	background: linear-gradient(135deg, #f0f4fa 0%, #d9e2ef 100%);
	display: flex;
	justify-content: center;
	align-items: center;
	padding: 2rem;
	font-family:
		"HYTMR",
		"LXGW WenKai Mono Lite",
		system-ui,
		-apple-system,
		"Segoe UI",
		Roboto,
		"Helvetica Neue",
		sans-serif;
}

/* 卡片宽度:PC 上更宽,移动端自动缩小 */
.todo-card {
	width: 100%;
	max-width: 1000px; /* 增加最大宽度,不再是窄窄一溜 */
	background: rgba(255, 255, 255, 0.92);
	backdrop-filter: blur(12px);
	border-radius: 2rem;
	box-shadow:
		0 25px 45px -12px rgba(0, 0, 0, 0.2),
		0 0 0 1px rgba(255, 255, 255, 0.6);
	padding: 2rem 2rem 1.5rem;
	transition: all 0.2s ease;
}

h1 {
	font-size: 2rem;
	font-weight: 600;
	margin: 0 0 1.5rem 0;
	display: flex;
	align-items: center;
	gap: 12px;
	color: #1a2a3f;
	letter-spacing: -0.01em;
}

.icon {
	font-size: 2.2rem;
}

/* 同步区块 */
.sync-section {
	background: white;
	border-radius: 1.5rem;
	padding: 1.2rem 1.5rem;
	margin-bottom: 2rem;
	box-shadow: 0 2px 8px rgba(0, 0, 0, 0.02);
	border: 1px solid #eef3fc;
}

.has-key {
	display: flex;
	flex-direction: column;
	gap: 0.6rem;
}

.key-display {
	display: flex;
	align-items: center;
	gap: 12px;
	flex-wrap: wrap;
}

.label {
	font-size: 0.75rem;
	font-weight: 600;
	color: #5b6e8c;
	text-transform: uppercase;
	letter-spacing: 0.5px;
}

.key {
	background: #f0f4fa;
	padding: 0.4rem 0.9rem;
	border-radius: 40px;
	font-family: "SF Mono", Monaco, monospace;
	font-size: 0.9rem;
	font-weight: 500;
	color: #1a2a3f;
	letter-spacing: 0.3px;
}

.copy {
	background: none;
	border: none;
	font-size: 1.2rem;
	cursor: pointer;
	padding: 0 4px;
	opacity: 0.65;
	transition: opacity 0.1s;
}

.copy:hover {
	opacity: 1;
}

.hint {
	font-size: 0.75rem;
	color: #6c7e9e;
	display: flex;
	flex-direction: row; /* 修改为行内排列 */
	justify-content: space-between; /* 使用 space-between 使子元素分布在两端 */
	align-items: center; /* 添加这一行以使子元素垂直居中 */
	gap: 0.6rem;
}

.switch-btn {
	align-self: flex-start;
	background: none;
	border: 1px solid #cddae9;
	border-radius: 40px;
	padding: 0.3rem 1rem;
	font-size: 0.75rem;
	color: #4a5b7a;
	cursor: pointer;
	transition: all 0.1s;
}

.switch-btn:hover {
	background: #f0f4fa;
	border-color: #9aaec9;
}

.no-key {
	display: flex;
	flex-wrap: wrap;
	gap: 12px;
	align-items: center;
}

.no-key input {
	flex: 2;
	min-width: 180px;
	background: white;
	border: 1px solid #cfdfed;
	border-radius: 60px;
	padding: 0.7rem 1.2rem;
	font-size: 0.95rem;
	outline: none;
	transition: border 0.1s;
}

.no-key input:focus {
	border-color: #7c9bcb;
	box-shadow: 0 0 0 2px rgba(96, 128, 176, 0.1);
}

.no-key button {
	background: #f0f4fa;
	border: none;
	border-radius: 60px;
	padding: 0.7rem 1.2rem;
	font-weight: 500;
	cursor: pointer;
	transition: background 0.1s;
	color: #2c3e50;
}

.no-key button.primary {
	background: #1a2a3f;
	color: white;
}

.no-key button.primary:hover {
	background: #2c3f5a;
}

.no-key button:hover:not(:disabled) {
	background: #e2e8f0;
}

/* 添加待办 */
.add-todo {
	display: flex;
	gap: 14px;
	margin-bottom: 2rem;
}

.add-todo input {
	flex: 1;
	border: 1px solid #cfdfed;
	border-radius: 60px;
	padding: 0.9rem 1.3rem;
	font-size: 1rem;
	background: white;
	outline: none;
	transition: all 0.1s;
}

.add-todo input:focus {
	border-color: #7c9bcb;
	box-shadow: 0 0 0 3px rgba(96, 128, 176, 0.15);
}

.add-todo button {
	background: #1a2a3f;
	border: none;
	border-radius: 60px;
	padding: 0 1.8rem;
	color: white;
	font-weight: 600;
	font-size: 0.95rem;
	display: flex;
	align-items: center;
	gap: 6px;
	cursor: pointer;
	transition: background 0.1s;
}

.add-todo button:hover:not(:disabled) {
	background: #2c3f5a;
}

.add-todo button:disabled,
.sync-btn:disabled {
	opacity: 0.55;
	cursor: not-allowed;
}

/* 待办列表容器 */
.todo-list-container {
	min-height: 320px;
}

.loading,
.empty-state {
	text-align: center;
	padding: 2.5rem;
	color: #6b7c9e;
}

.spinner {
	width: 32px;
	height: 32px;
	border: 3px solid #e2e8f0;
	border-top-color: #2c3f5a;
	border-radius: 50%;
	animation: spin 0.6s linear infinite;
	margin: 0 auto 12px;
}

@keyframes spin {
	to {
		transform: rotate(360deg);
	}
}

.emoji {
	font-size: 3.2rem;
	margin-bottom: 0.5rem;
	opacity: 0.5;
}

/* 待办列表项 */
.todo-list {
	list-style: none;
	margin: 0;
	padding: 0;
}

.todo-list li {
	display: flex;
	align-items: center;
	gap: 14px;
	background: white;
	padding: 1rem 1.2rem;
	margin-bottom: 0.75rem;
	border-radius: 1.2rem;
	border: 1px solid #eef3fc;
	transition: all 0.15s ease;
	animation: fadeIn 0.2s ease;
}

@keyframes fadeIn {
	from {
		opacity: 0;
		transform: translateY(6px);
	}
	to {
		opacity: 1;
		transform: translateY(0);
	}
}

.todo-list li:hover {
	border-color: #cbdbe9;
	box-shadow: 0 4px 12px rgba(0, 0, 0, 0.04);
	transform: translateY(-1px);
}

.todo-list li.completed .todo-text {
	text-decoration: line-through;
	color: #97a9c2;
}

.todo-text {
	flex: 1;
	font-size: 1rem;
	color: #1e2f3e;
	outline: none;
	padding: 4px 8px;
	border-radius: 12px;
	transition: background 0.1s;
}

.todo-text[contenteditable="true"]:hover {
	background: #f9fbfe;
}

.todo-text:focus {
	background: #f0f4fa;
}

input[type="checkbox"] {
	width: 1.2rem;
	height: 1.2rem;
	cursor: pointer;
	accent-color: #2c5a7a;
}

.delete {
	background: none;
	border: none;
	font-size: 1.3rem;
	cursor: pointer;
	color: #a0b3ce;
	transition: color 0.1s;
	padding: 0 4px;
	visibility: hidden;
}

.todo-list li:hover .delete {
	visibility: visible;
}

.delete:hover {
	color: #e54b4b;
}

/* 手动同步栏 */
.sync-bar {
	margin-top: 1.8rem;
	display: flex;
	justify-content: flex-end;
	align-items: center;
	gap: 18px;
	border-top: 1px solid #eef3fc;
	padding-top: 1.2rem;
}

.sync-btn {
	background: #ffffff;
	border: 1px solid #cfdfed;
	border-radius: 40px;
	padding: 0.5rem 1.2rem;
	font-size: 0.85rem;
	display: flex;
	align-items: center;
	gap: 6px;
	cursor: pointer;
	transition: all 0.1s;
}

.sync-btn:hover {
	background: #f7fafd;
	border-color: #9aaec9;
}

.auto-hint {
	font-size: 0.7rem;
	color: #8c9eb5;
}

/* 版权声明 */
.copyright {
	margin-top: 2rem;
	text-align: center;
	font-size: 0.7rem;
	color: #8c9ab0;
	border-top: 1px solid rgba(0, 0, 0, 0.05);
	padding-top: 1.2rem;
}
.copyright p {
	margin: 0;
}

/* 移动端适配 */
@media (max-width: 650px) {
	.app {
		padding: 1rem;
	}
	.todo-card {
		padding: 1.2rem 1rem 1rem;
		border-radius: 1.5rem;
	}
	h1 {
		font-size: 1.6rem;
		margin-bottom: 1rem;
	}
	.sync-section {
		padding: 1rem;
	}
	.no-key {
		flex-direction: column;
		align-items: stretch;
	}
	.add-todo button span {
		display: none;
	}
	.add-todo button {
		padding: 0 1.2rem;
	}
	.delete {
		visibility: visible;
		opacity: 0.5;
	}
	.todo-list li {
		padding: 0.8rem 1rem;
	}
	.copyright {
		font-size: 0.6rem;
	}
}
</style>

Powered by VitePress