我为家人做了一个旅行行程应用——里面还内置了一个 AI 助手
- 发布日期
- 2026年7月11日
- 作者
- Jacob Lloyd —— 项目完成后,在 AI 协助下撰写
- 阅读时长
- 约 9 分钟阅读
简单来说: 我做了一个小网站,专门给我家人的日本之旅用——它显示每天的计划、天气、地图,就算手机没信号也能在手机上用。它还有一个小小的 AI 助手按钮,能回答关于旅行的问题、微调计划。这篇文章是我在大量借助 AI 的情况下如何做出它的老实交代:哪些行得通,哪些出了岔子,以及我如何把一份脆弱的设计工具导出文件,变成一个既能安全编辑、又能安全放到网上的东西。你可以下载一份清理过的示例,在任何浏览器里打开。
我家人去了一趟日本旅行,我希望每个人都能把计划装进口袋——每一天的安排都列得明明白白,地图点一下就能看,还有天气,以及那些在火车站倒时差、脑子昏昏沉沉时最容易忘掉的小细节。于是我做了一个只属于我们的小网页应用:一份私密的、PIN 保护的行程,带每日日程、实时天气、离线支持,以及——毕竟是我嘛——一个内置的 AI 助手,能回答关于这趟旅行的问题,甚至做一些小幅编辑。
这是一份诚实的制作记录:哪些部分顺利,哪些部分以我没料到的方式出了问题,以及一份脆弱的设计工具导出文件,是怎么变成一个我敢放到公网上的东西的。文末有一份脱敏示例,可以下载后在任意浏览器打开。
tl;dr
- 这是什么:一个移动端优先的行程网页应用——日程、停靠点、实时天气、货币和时区小工具、离线/可安装,还带一个 AI 助手标签页。
- 核心思路:一份美观但脆弱的设计工具导出文件,一旦整个日程都由一个小小的数据文件驱动、并由构建步骤重新生成应用,就变得可以安全编辑了。
- 门禁:客户端的 PIN 只是做做样子——真正管用的是带限流锁定的服务器端会话。
- AI:一个自托管的本地模型,被拴着短绳(只回答问题;只能做小幅的、有防护的编辑)。
- 反复验证的教训:确认一个改动是否生效,唯一的办法是在真实浏览器里把应用渲染出来亲眼看——永远别相信文件"应该"是好的。
起点:一个华丽的黑盒子
我用一个 AI 设计工具设计好外观,导出了它。出来的是单个自包含的 HTML 文件——一个完整的 React 应用,字体和图片全都内联在里面——单个文件大约 14MB。它看起来非常漂亮,双击一下就能直接运行。
但它同时也是个黑盒子。整个应用被压缩成一个巨大的团块,日程安排就埋在里面深处。手动编辑行程意味着要对机器生成的代码动手术。偶尔一次还行,但每次计划变动都要来一遍就是噩梦——而在家庭旅行中,计划几乎每天都在变。
所以第一个真正重要的决定是架构层面的:不再直接编辑导出文件。
让后面一切成为可能的一个想法
与其去动那个 14MB 的包,我把整个日程搬进了一个小巧、易读的数据文件——一个 content.json,里面装着日程、停靠点、天气备注,以及每个停靠点的照片和花费。然后一个简短的构建步骤读取这个文件,并从那份原封不动的导出文件重新生成整个应用。
这彻底改变了整个项目的性质。现在"加一个停靠点"或"改一下时间",就变成了对数据文件的两行编辑——人或者 AI 助手都能安全地完成——然后重新构建一次。脆弱的那部分(14MB 的应用)从此再也不用手动碰。之后我加的每一个小组件——实时天气卡片、本地时间 ↔ 东京时间双显时钟、双向货币换算器、带照片和停车备注的可展开停靠点——都是从同一套数据驱动的构建流程生成出来的。
吃掉每个组件的坑:应用启动时会自我重建
这是让我花费时间最多的一个陷阱,写下来是为了让你不必再踩一次。
导出的应用在启动时会重建整个页面。如果你往静态 HTML 里插入自己的标记——一个时钟、一条横幅,什么都一样——它会在应用挂载的那一刻被彻底抹掉。你的元素 ID 消失了,getElementById 永远返回 null,你的组件悄无声息地永远不会出现。更糟的是:如果你靠搜索保存的 HTML 文件来"验证",你的标记明明就在那里,所以看起来一切正常。它只是从来没能活着出现在屏幕上而已。
两条规则彻底解决了这个问题:
- 插入到应用自己的模板里,而不是页面里。 把你的组件加在应用组装 UI 的地方,这样它就会作为应用的一部分被渲染出来,能挺过启动阶段。
- 用
document上的事件委托来接线行为逻辑。 切换标签页时,应用会把整块区域重新挂载,销毁并重建里面的节点。直接挂在按钮上的监听器会在下一次重新渲染时死掉;而挂在document上的一个委托监听器(判断点击的到底是什么)能挺过一切。
由此得出的推论,是我现在奉为铁律的一条测试规则:不要靠 grep 文件或检查 HTTP 200 来验证一个网页 UI。要在真实(无头)浏览器里等它启动完成之后渲染出来,亲眼看结果。这是唯一能抓到这类 bug 的检查方式——我确实靠这个方法抓到了好几个,包括一个在挪进模板之前一直"隐形"的货币换算器。
号称"自包含",其实并不是
导出文件号称是单个自包含文件。其实并不完全是。它在运行时悄悄地从一个公共 CDN 拉取 UI 框架,而地址是从代码里的 URL 片段拼出来的——所以直接对 CDN 做文本搜索什么都找不到,文件看起来已经具备离线能力,实际上完全不是。在有严格安全策略的网页主机上,那次隐藏的请求被拦截了,导致整个应用启动失败。在我自己的笔记本上,由于没有这种策略,它能正常运行——所以这个问题一直藏着,直到碰上真正的服务器才暴露出来。
解决办法是自己托管这个框架(把它和应用打包在一起,这样就不会有外部请求),对于可下载的示例,则直接内联进去,让整个东西真正变成一个文件,完全不用联网就能从磁盘打开。如果你要从这一节带走一个实用的结论:"在我机器上能跑"和"在真实网页服务器的安全响应头背后也能跑"是两个不同的断言——一定要测试后者。
门禁:做样子 vs. 真正的锁
第一版是靠页面 JavaScript 里的一个 PIN 来给网站上锁的。那就是安全表演:整份行程都摆在页面源码里,任何会打开开发者工具的人,压根不用输入密码就能读到全部内容。用来防个不经意的一瞥、藏个惊喜派对还行;真正在意的东西可不能这样。
真正的版本把门禁挪到了服务器端:提交 PIN,服务器核实后开启一个会话,只有到这时才会把行程交出去。它还有一个按 IP、逐步升级的锁定机制——猜错几次,那个地址就会被锁定越来越长的时间,谁也没法把所有 6 位数字组合挨个机枪式试一遍。
关于它的局限性我要说实话,因为假装没有局限性才是人栽跟头的原因:一个只做了限流的 6 位 PIN,用来保护一份风险不高的家庭行程还算够用,但绝对达不到银行级别。如果你要保护的是敏感内容,请用一个足够长的密码短语和正规的登录机制。锁的强度,要和门后面藏的东西相匹配。
AI 助手,拴着短绳
好玩的部分来了:一个住在自己标签页里的小助手——我给它取名叫 Yoshi。在私密的家庭版里,它能回答关于旅行的问题("水族馆那天的计划是什么?"、"我们该带什么?"),还能对日程做小幅的、有防护的编辑。关键在于,它编辑的是那个安全的数据文件,而不是那个脆弱的应用——而且它被圈在栏杆里:能添加或微调,但不能删除某一天,航班和预订都被硬锁死,任何聊天消息都动不了它们。
关键的是,Yoshi 跑在一个自托管的本地模型上——是我自己硬件上的一个小型 AI,不是付费的云端 API——通过一个有防护的中继来访问。这本身就是一整个话题(如何安全地把一个本地托管的聊天机器人放到公网上,以及它在消费级硬件上有多慢),所以我单独写了一篇:把聊天机器人放到公网上——用我自己的硬件。
关于下面这份可下载示例的说明:助手标签页保留在那里,是为了让你看到它住在哪儿,但在示例里它是刻意关闭的——没有接到任何 AI 上,也不会回答。要在你自己的副本里让它工作,你需要把它指向你自己的本地模型。这是故意的:我不打算发布一个会悄悄联系某个运行中 AI 的公开文件。
离线、可安装、出国也扛得住
最后一批打磨,针对的是旅途中实际使用的种种现实:时断时续的酒店 Wi-Fi、飞机上没有网络、时有时无的漫游信号。所以这个应用变成了可安装的 PWA——添加到主屏幕后,打开起来就像一个真正的应用——而且能离线工作,一旦加载过一次,就能直接从设备本地提供整份行程。我格外留意的一条规则是:只有在成功解锁之后才会缓存行程,这样离线缓存永远不会绕过门禁。联网时,它始终会重新检查门禁;离线时,它才信任手机上已有的那份副本。对于一份家庭旅行计划来说,这是恰当的取舍。
(Service worker 这东西有些锋利的边角——一个旧的缓存版本,可能在你修好问题很久之后,还在继续提供一份过时的页面。我学到的教训是:每次都要发布一个更新过的 worker,而不是只删掉旧的,这样浏览器才会真正拿到新版本。)
像对待真实生产环境一样测试它,因为它就是
因为家人在旅途中一直在实际使用这个应用,我把每一次改动都当成一次正式发布来对待:构建它,等启动完成后在无头浏览器里渲染真实页面,操作真正的标签页和按钮,确认无误之后才发布。旅途中,我用这种方式推送过几个小的线上修复——一个高亮当天的 "TODAY" 徽章(以东京时间为准,而不是看的人手机上的时间)、刷新过的天气、几处布局小瑕疵——每一个都先在真实浏览器里验证过。这和"启动重建"那个坑是同一个教训,再次得到验证:文件存在,不等于功能真的能用。
亲自试试——下载示例
下面的下载内容是这个应用的脱敏公开示例。所有姓名、日期、地址和细节都是虚构的演示内容;这是一份虚构的 7 天东京行程。用任意浏览器打开 index.html——不用安装,不用服务器,不用账号。它能离线工作;只有实时天气卡片和地图链接会连接互联网。
两个坦诚的注意事项。第一,这是单个约 14MB 的文件,因为整个应用、字体和图片都被内联进去了,所以能做到零依赖运行——这也导致它首次打开时比较慢;一个生产环境的构建会把这些内容拆分和压缩。第二,如前所述,示例中的 AI 助手是关闭的——打开那个标签页会告诉你这一点。
自己动手做一个
文章开头那个"自己动手实现"框不是句口号——这篇文章本来就是打算直接拿去交给 AI 助手用的。浓缩版的做法如下:
- 把计划放进一个数据文件里。 日程、停靠点、备注、天气——一个易读的文件,作为唯一的真实来源。
- 从它生成应用。 一个确定性的构建步骤把数据变成 UI,这样编辑始终针对数据,而不是生成出来的代码。
- 如果你从一份设计工具导出文件开始,要尊重它的启动过程。 插入到应用的模板里,使用委托事件,并自己托管它会请求的任何外部资源。
- 门禁放在服务器上,而不是页面里。 会话加限流锁定,永远比 JavaScript 里的 PIN 靠谱。
- 如果要带出门,就做成离线优先——只有真正解锁之后才缓存。
- 在浏览器里验证,而且要等启动完成之后。 不是"文件存在"。是屏幕上看到的东西。
踩过的坑,汇总
- 导出文件会在启动时重建 DOM——任何你插入到页面里的东西都会被抹掉;改成插入到应用的模板里,并把事件委托到
document上。 - "自包含"未必真的自包含——导出文件可能在运行时才去请求它的框架;把它自己托管起来,并在真实服务器的安全响应头背后测试,而不只是在本地测试。
- 客户端的 PIN 只是做样子——内容就在页面里;门禁要放在服务器上。
- 6 位 PIN 只是被限流了,不是攻不破——低风险场景够用,敏感内容不适用。
- Service worker 缓存得很激进——要推出修复,得发布一个更新过的 worker;单纯删掉旧文件并不会注销它。
- "文件存在"不等于"功能能用"——每次都要渲染出来亲眼看。
下载
- 下载示例行程应用(zip) 10.3 MB
仅限个人使用免费。如果它替你省下了一下午的时间,欢迎点旁边的咖啡按钮支持一下。