欢迎光临
我们一直在努力

两台电脑共用一个待办清单:星空组网 + Python 零依赖实战

这次实操使用我手头已经加入同一星空组网、能够互访的 Mac 和 Ubuntu 22.04。Mac 的组网地址是 192.168.188.1,Ubuntu 是 192.168.188.5。这些是本次演示地址,照做时请换成你自己的地址。示例只用 Python 标准库,不需要安装 Flask 或数据库。

一、从账号入口到两台设备在线

先打开星空组网官网并进入登录页。已有账号就登录;没有账号则先按网页提示注册。下图是本次实拍的登录入口,出于隐私没有拍摄输入账号和密码的过程。 在这里插入图片描述

登录管理控制台后,在“设备管理 → 成员列表”查看或创建成员账号,再按客户端文档让两台设备加入同一个网络。建议为不同设备使用各自的成员账号;本文不展示任何密码。本次实际操作开始前,两台客户端已经登录并能互访,因此下面直接从在线状态继续,不把登录入口图当作“刚登录成功”的截图。 Mac 客户端连接后,可以看到在线状态和虚拟 IP。下图已裁去设备编号等信息。

在这里插入图片描述

通过 SSH 工具进入 Ubuntu 终端,执行下面两条命令:

python3 –version
ip -4 addr show dev StarVPN

我的 Ubuntu 是 22.04,Python 是 3.10.12,StarVPN 网卡上显示 192.168.188.5/24。如果你看到的网卡名称不同,以自己机器的实际名称为准。稍后浏览器要访问的是 Ubuntu 的组网虚拟 IP,不是公网 IP,也不是 Mac 的 IP。

在这里插入图片描述

二、在 Ubuntu 上创建项目

在 Ubuntu 终端创建独立目录:

mkdir -p ~/todo-demo
cd ~/todo-demo
nano server.py

把下面完整代码粘贴到 nano。保存时按 Ctrl+O、回车,再按 Ctrl+X 退出。我的实拍环境把同一份文件放在 /root/todo-demo-20260925/;目录名不同不影响运行。

"""A small to-do page reachable through a private StarVPN address."""

import argparse
import html
import json
import os
import tempfile
from http.server import BaseHTTPRequestHandler, HTTPServer
from pathlib import Path
from urllib.parse import parse_qs

DATA_FILE = Path(__file__).with_name("tasks.json")
MAX_TASK_LENGTH = 80
MAX_REQUEST_BYTES = 4096

PAGE_START = """<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>我的私人待办清单</title>
<style>
body{font-family:system-ui,-apple-system,sans-serif;background:#f4f7fb;color:#18304b;margin:0}
main{max-width:680px;margin:64px auto;padding:0 20px}
.card{background:#fff;border-radius:18px;padding:28px;box-shadow:0 8px 28px #18304b12}
h1{margin:0 0 8px;font-size:30px}p{color:#63758a}
.add{display:flex;gap:10px;margin:28px 0}input{flex:1;min-width:0;padding:12px;border:1px solid #c8d4e0;border-radius:9px;font-size:16px}
button{border:0;border-radius:9px;padding:11px 16px;background:#1769d2;color:#fff;cursor:pointer;font-size:14px}
button:hover{background:#0e57b4}button.delete{background:#edf1f5;color:#526579}
ul{list-style:none;padding:0;margin:0}li{display:flex;align-items:center;gap:10px;border-top:1px solid #e8edf2;padding:14px 0}
li span{flex:1;overflow-wrap:anywhere}li.done span{text-decoration:line-through;color:#8a99a8}
form{margin:0}.empty{padding:12px 0;color:#73849a}
@media(max-width:520px){main{margin:24px auto}.card{padding:20px}li{flex-wrap:wrap}}
</style></head><body><main><div class="card">
<h1>我的私人待办清单</h1><p>在 Mac 上记下任务,数据保存在 Ubuntu 服务器。</p>
<form class="add" action="/add" method="post">
<label for="task" style="position:absolute;left:-9999px">待办内容</label>
<input id="task" name="task" maxlength="80" placeholder="例如:整理周五会议资料" required>
<button type="submit">添加任务</button></form>
"""

PAGE_END = """</div></main></body></html>"""

def read_tasks() –> list[dict]:
if not DATA_FILE.exists():
return []
with DATA_FILE.open(encoding="utf-8") as stream:
tasks = json.load(stream)
if not isinstance(tasks, list):
raise ValueError("任务文件格式错误")
return tasks

def write_tasks(tasks: list[dict]) –> None:
temporary_path = None
try:
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=DATA_FILE.parent,
prefix=".tasks-", delete=False) as stream:
temporary_path = Path(stream.name)
json.dump(tasks, stream, ensure_ascii=False, indent=2)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary_path, DATA_FILE)
finally:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)

class TodoHandler(BaseHTTPRequestHandler):
def send_page(self, status: int, content: str) –> None:
data = content.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'")
self.end_headers()
self.wfile.write(data)

def fail(self, status: int, message: str) –> None:
self.send_page(status, f"<h1>操作失败</h1><p>{html.escape(message)}</p><p><a href='/'>返回清单</a></p>")

def do_GET(self) –> None:
if self.path != "/":
self.fail(404, "页面不存在")
return
try:
tasks = read_tasks()
except (OSError, ValueError, json.JSONDecodeError):
self.fail(500, "无法读取任务文件,请检查服务器终端。")
return
rows = [f"<p>共 {len(tasks)} 项,已完成 {sum(task['done'] for task in tasks)} 项</p><ul>"]
for task in tasks:
task_id = task["id"]
label = html.escape(task["text"])
state = "done" if task["done"] else ""
action = "恢复" if task["done"] else "完成"
rows.append(
f'<li class="{state}"><span>{label}</span>'
f'<form action="/toggle" method="post"><button name="id" value="{task_id}">{action}</button></form>'
f'<form action="/delete" method="post"><button class="delete" name="id" value="{task_id}">删除</button></form></li>'
)
rows.append("</ul>" if tasks else "</ul><p class='empty'>还没有任务,先添加一条吧。</p>")
self.send_page(200, PAGE_START + "".join(rows) + PAGE_END)

def do_POST(self) –> None:
if self.path not in ("/add", "/toggle", "/delete"):
self.fail(404, "操作不存在")
return
expected = f"{self.server.server_address[0]}:{self.server.server_address[1]}"
if self.headers.get("Host") != expected or self.headers.get("Origin", f"http://{expected}") != f"http://{expected}":
self.fail(403, "请使用启动时显示的组网地址。")
return
if self.headers.get("Content-Type", "").split(";")[0] != "application/x-www-form-urlencoded":
self.fail(415, "只接受网页表单。")
return
try:
size = int(self.headers.get("Content-Length", ""))
if not 0 < size <= MAX_REQUEST_BYTES:
raise ValueError
fields = parse_qs(self.rfile.read(size).decode("utf-8"), strict_parsing=True)
tasks = read_tasks()
if self.path == "/add":
if set(fields) != {"task"} or len(fields["task"]) != 1:
raise ValueError
text = fields["task"][0].strip()
if not text or len(text) > MAX_TASK_LENGTH or any(ord(char) < 32 for char in text):
raise ValueError
tasks.append({"id": max((task["id"] for task in tasks), default=0) + 1,
"text": text, "done": False})
else:
if set(fields) != {"id"} or len(fields["id"]) != 1 or not fields["id"][0].isascii() or not fields["id"][0].isdigit():
raise ValueError
task_id = int(fields["id"][0])
match = next((task for task in tasks if task["id"] == task_id), None)
if match is None:
self.fail(404, "这条任务已不存在。")
return
if self.path == "/toggle":
match["done"] = not match["done"]
else:
tasks.remove(match)
write_tasks(tasks)
except (ValueError, UnicodeDecodeError):
self.fail(400, "输入无效,请检查任务内容或刷新页面。")
return
except OSError:
self.fail(500, "无法保存任务,请检查服务器终端。")
return
self.send_response(303)
self.send_header("Location", "/")
self.send_header("Content-Length", "0")
self.end_headers()

if __name__ == "__main__":
parser = argparse.ArgumentParser(description="私人待办清单")
parser.add_argument("host", help="Ubuntu 的星空组网虚拟 IP")
parser.add_argument("port", type=int, help="演示端口")
arguments = parser.parse_args()
with HTTPServer((arguments.host, arguments.port), TodoHandler) as server:
print(f"待办清单已启动:http://{arguments.host}:{arguments.port}/")
try:
server.serve_forever()
except KeyboardInterrupt:
print("n服务已停止。")

这段程序只做四件事:显示网页、接收按钮操作、把任务保存到同目录的 tasks.json、读取旧任务。html.escape 防止任务文字被当成网页代码;写文件时先写临时文件再替换原文件,降低写入中断造成文件损坏的机会。因为只监听指定的组网 IP,服务不会绑定服务器的所有网卡;它仍然是一个无账号密码的演示服务,只适合放普通待办事项,不要存敏感资料。

三、启动服务并从 Mac 打开

先确认上一步查到的 Ubuntu 虚拟 IP,再在 Ubuntu 项目目录运行:

python3 server.py 192.168.188.5 18095

看到 待办清单已启动:http://192.168.188.5:18095/ 就表示程序已启动。终端要保持打开;关闭它或按 Ctrl+C,服务就会停止。18095 是本次实操选择的空闲端口,你也可以换成其他未占用端口,但浏览器地址要跟着改。

在这里插入图片描述

如果 Ubuntu 启用了 UFW 防火墙,Mac 打不开页面时,先检查服务器的入站规则。本次演示临时只允许 Mac 的组网 IP 通过 StarVPN 网卡访问该端口:

sudo ufw allow in on StarVPN from 192.168.188.1 to 192.168.188.5 port 18095 proto tcp comment 'todo-demo-20260925'

请把两个 IP、网卡名和端口换成自己的;若 UFW 未启用,就跳过这一步。不要为了演示把端口向公网全部开放。

现在在 Mac 浏览器地址栏输入:

http://192.168.188.5:18095/

首次打开是一张空清单。

在这里插入图片描述

输入“整理周五会议资料”,点“添加任务”;再添加“给项目组发送进度”。两项都会出现在列表里。

在这里插入图片描述

点第一项的“完成”,文字会出现删除线,计数也变成“已完成 1 项”。

在这里插入图片描述

再删除第二项,列表只保留第一项。

在这里插入图片描述

四、确认数据真的留在 Ubuntu

回到 Ubuntu,在另一个终端窗口进入项目目录,查看 tasks.json:

cd ~/todo-demo
cat tasks.json

我实际演示目录里的文件记录了保留下来的任务,其中 "done": true 表示已完成。

在这里插入图片描述

接着在运行服务的终端按 Ctrl+C,再执行同一条启动命令。刷新 Mac 浏览器,刚才的任务仍在。这说明数据存放在 Ubuntu 的 JSON 文件中,而不只是浏览器页面里的临时状态。

在这里插入图片描述

五、用完后停止演示

本次截图和验证结束后,我再次按 Ctrl+C 停止服务,并撤销刚才的临时 UFW 规则;server.py 和 tasks.json 保留。你也可以这样收尾:先在运行服务的终端按 Ctrl+C,再查看规则编号,确认是自己刚添加的规则后删除对应编号。

sudo ufw status numbered
sudo ufw delete 规则编号

规则编号 是说明文字,执行时替换成当前列表里的实际数字,不能原样复制。下次想继续用,进入项目目录重新运行 python3 server.py 你的Ubuntu虚拟IP 18095 即可;若之前删除了防火墙规则且 UFW 仍启用,还需重新添加那条仅允许 Mac 的规则。

如果网页打不开,按顺序检查:两台客户端是否在线;Ubuntu 虚拟 IP 是否写对;服务终端是否还在运行;端口是否被占用;UFW 是否拦截。若页面能打开但任务无法保存,检查项目目录是否可写,以及终端有没有错误提示。

这个小项目的重点是把“设备互访”变成一个能亲手验证的应用:Mac 负责操作,Ubuntu 负责运行程序和保存数据。它是学习用的私人小工具;想多人长期使用,还需要单独设计账号、备份和更可靠的部署方式。

赞(0)
未经允许不得转载:171主机测评 » 两台电脑共用一个待办清单:星空组网 + Python 零依赖实战
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址