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>