内容
# 麦麦老样子 · McD Repeat Smarter
> 记住你常点的,听懂你这次改的,确认好再帮你点。
一个基于**麦当劳中国官方 MCP** 的个性化点餐助手。它不是又一个"帮你挑套餐"的推荐机器人,而是解决一件更小、更日常、也更没人做的事:
**你昨天、上周、过去十次点的都是同一份。今天你想改一点点,怎么用一句话说清楚,并且真的点出去。**
---
## 目录
- [为什么做这个](#为什么做这个)
- [截图](#截图)
- [视觉设计](#视觉设计)
- [快速开始](#快速开始)
- [使用示例](#使用示例)
- [它到底做了什么](#它到底做了什么)
- [技术架构](#技术架构)
- [关键设计决策](#关键设计决策)
- [真实 MCP 验证](#真实-mcp-验证)
- [运行测试](#运行测试)
- [已知限制](#已知限制)
- [竞品检查](#竞品检查)
- [文件清单](#文件清单)
---
## 为什么做这个
同类工具大多从「全量菜单 + 一堆推荐」出发,用户每次都要重新做一遍选择题。但真实的老客行为不是这样的:
- 一周五个工作日,早餐基本就那两三样;
- 午餐在固定两三家门店、固定几个套餐之间轮换;
- 变化往往只有一处——"今天不要薯饼"、"饮料换无糖"、"预算压到 25 以内"。
所以本项目的起点是**你的历史订单**,交互动词是**改**,不是**选**:
| 用户会说 | 系统要做的事 |
| --- | --- |
| "还是上次那份早餐" | 找到最近一单 → 校验每件商品今天还在不在售 → 核价 |
| "还是上次那份,不过今天不要香芋派,换个东西,预算不超过 25 元" | 保留其余,只动香芋派所在的那一格,给出同轮次候选,真实核价,超预算要说清楚 |
| "存成上班早餐" | 存**结构化餐品信息**(编码/名称/规格/数量/来源),不是存一句话 |
| 确认好了 | 展示完整订单 → 用户勾选确认 → 才调用下单 |
**目标用户**:一周吃三次以上麦当劳、有自己的固定搭配、懒得每次重新翻菜单的人。
---
## 截图
**概览**:纯黑侧栏配金色金拱门;首屏居中一句 68px 的「今天,还是**老样子**吗?」,下面是那张 3px 黑边、5px 硬阴影的点餐卡,卡内是四个真能发出去的快捷短语。往下的统计不装卡片,靠字号分主次;再往下是「趋势 / 记忆」两块面板、常用方案清单和最近订单。

**点单**:说一句话,给出「原方案 → 调整后」的前后对照和候选方案,每个金额都带官方核价的 `traceId`。商品被改过名(比如随心配1+1 → 人气经典随心配)时,候选标题会写「照旧 · 按当前菜单对齐」,不会一边说"原样不变"一边换个商品。

**确认**:门店、餐品(含套餐里具体选了什么)、改动内容、取餐方式、金额全列出来,**必须勾选「我已核对」下单按钮才会激活**。

**订单记录 / 常用方案**:订单页不是后台表格,是按月份分组的刊物式清单——点任意一行就地展开明细(餐品、套餐子项、取餐方式、订单号、实付)。


**移动端**:侧栏收成抽屉,页头变成命令条,快捷短语换行而不是横向截断。
<img src="docs/screenshots/nav-mobile.png" width="200" alt="移动端导航抽屉" />
<img src="docs/screenshots/dashboard-mobile.png" width="200" alt="移动端概览" />
<img src="docs/screenshots/order-mobile.png" width="200" alt="移动端点单结果" />
<img src="docs/screenshots/confirm-mobile.png" width="200" alt="移动端确认页" />
---
## 视觉设计
这一版的设计目标只有一句:**它得看起来是一家麦当劳的数字产品,而不是一个粉色调的国产 SaaS。**
中间返工过两次。参考项目是本地另一套产品(`C:\study\olay\fronted`,小红书 AI 内容鉴别工具)的**近白纸 + 衬线大标题 + 玻璃浮起 + 发丝线**——那套语言做出来好看,但它是日式精品店的语法,和麦当劳没关系。于是整套推翻,改成现在的样子。
### 美式快餐的七个特征,逐条落地
| 维度 | 具体做法 |
| --- | --- |
| **字重与墨色是地基** | 全站零衬线。`Noto Sans SC` 900 字重 + `letter-spacing -0.045em`;墨色是**纯黑 `#0d0d0d`** 而非暖黑。麦当劳没有一个衬线字,字重和反差全靠这个纯黑撑 |
| **主色是大色块** | 品牌红 `#d62315` 铺满侧栏选中态、点单主按钮、发送键、标题里的「老样子」,不是点缀 |
| **必须有第二品牌色** | 金拱黄 `#ffc72c`。只有红 + 中性灰 = 通用红色 App;有黄才是麦当劳。用在栏目标签、拱门图形、价格高亮 |
| **形状资产要稀有** | 金拱门只出现在三处:侧栏品牌区、首屏开场、空状态。形状资产之所以有力是因为不滥用 |
| **实体按键手感** | 按下时整块 `translate-y(3px)`,下方 3px 实心阴影露出。这与普通 SaaS 的 `active:scale-[0.98]` 是两种语言 |
| **按钮全是药丸** | 圆角 14~28px,侧栏导航、筛选芯片、快捷短语、主操作一律药丸形 |
| **过渡要快** | 100~150ms。快餐是快餐,不给你 300ms 的温柔 |
### 骨架
```
┌────────────┬──────────────────────────────────────────────────────┐
│ 纯黑侧栏 │ 顶栏:数据来源状态 ── 搜方案/订单/餐品 · 门店 · 🔔 · 红拱门 │
│ 248px ├──────────────────────────────────────────────────────┤
│ ● 金拱门 │ ● 金拱门落位(黄底圆) │
│ 麦麦老样子 │ 记住你常点的 · 听懂你这次改的 │
│ │ 今天,还是「老样子」吗? ← 900 字重,68px │
│ ▉开始点单→ │ ───────────────────────────────────── │
│ 概览 │ ┌──────────────────────────────────┐ ← 3px 黑边│
│ 订单记录 │ │ 还是上次那份,不过今天不要香芋派… │ 5px 硬│
│ 常用方案 │ │ ───────────────────────────────── │ 阴影 │
│ 门店 │ │ 试试说│还是老│再来一份│不要薯条│ ⬤ │ │
│ 关于 │ └──────────────────────────────────┘ │
│ │ 最近一单 · 7/6 12:12 · ¥21.51 ▉照这份再来 │
│ 最近的方案 ├──────────────────────────────────────────────────────┤
│ 门/来源 ● │ 累计下单 32 单 平均客单价 ¥21.51 ← 无卡片,仅字号 │
└────────────┴──────────────────────────────────────────────────────┘
```
侧栏 `lg` 以上常驻 248px 纯黑;主舞台居中。
### 三类容器,不是两类
| | 背景 | 边框 | 阴影 | 用在哪 |
| --- | --- | --- | --- | --- |
| `.composer` | `#ffffff` | **3px 纯黑** | `0 5px 0 #0d0d0d`(不散) | **只有一句话输入卡**,全站主入口 |
| `.float` | `#ffffff` | 2px `rgba(13,13,13,.12)` | `0 4px 0` + `0 12px 28px -14px` | 顶栏下拉、门店弹层、趋势 tooltip |
| `.panel` | `#ffffff` | 2px `rgba(13,13,13,.11)` | 不投影 | 页面上的分栏块 |
三处**都没有 `backdrop-filter`**。玻璃拟态在暖白底上只会让文字发虚;美式快餐要的是「实」——纸片、贴纸、色块的实体感。
`.composer` 之所以和另外两类分开:它不是「一块浮起来的东西」,它是**一张压在柜台台面上的点餐卡**。聚焦时整张卡坐下去——阴影从 5px 压到 2px、下沉 2px、描边翻红,和全站实体按键同一套反馈。
**`.composer` 不套 `.panel`。** 实体黑边卡套白卡会变成「卡中卡」,两层框线互相干扰(订单页原本就有这个问题,已拆掉外层 Panel)。
### 状态样式为什么写进 CSS 而不是 Tailwind 类
`border-ink` 与聚焦时的 `border-brand-500` 同为 `border-color` 工具类,**谁生效取决于 Tailwind 产物里的顺序,与 class 串的书写顺序无关**。实测中聚焦时边框确实没翻红。
所以 `.composer` / `.composer__send` / `.composer__chip` / `.composer__label` 四组状态的 `:hover` / `:active` / `:focus-within` / `:disabled` 全部写在 `src/styles/index.css` 里——这些伪类权重天然更高,不依赖产物顺序。`Composer.tsx` 里只剩尺寸和排版类。
### 字体:两套,各管一段
| 用途 | 字族 | 设置 |
| --- | --- | --- |
| 标题与大数字 | `Noto Sans SC` | `.display`:`font-weight 900`、`letter-spacing -0.045em`、`line-height 1.05` |
| 正文 | `Noto Sans SC` | 400/500,`font-size 14.5px`,`line-height 1.75` |
| 订单号这类「像代码」的串 | `DM Mono` | 只给 `.code`:订单号、`traceId`。**不给标签和时间戳** —— 中文小字配等宽像在看日志 |
字号只有四档:`micro 11 / tiny 12 / small 13.5 / body 14.5`。所有数字加 `.tnum`(`tabular-nums`),统计列和金额才对得齐。
### 配色与 token
| 角色 | 值 | 用在哪 |
| --- | --- | --- |
| 文字四级 | `ink #0d0d0d` / `soft #2f2f2f` / `mute #6a6a6a` / `faint #6e6e6e` | 正文 / 次级 / 说明 / 口径与时间戳 |
| 页面底 | `canvas #fdf6ec` | 全站背景(纸质物料的奶油白,不是纯白) |
| 内容块 | `raised #ffffff` | 压在奶油底上形成明确的「纸张」关系 |
| 线 | `line rgba(13,13,13,.14)` / `hair rgba(13,13,13,.09)` | 控件描边 / 分隔 |
| 行 hover | `wash #fff4de` | **暖黄,从不是灰** |
| 品牌红 | `500 #d62315` · `600 #c31f13` · `700 #a51810` · `800 #6d120b` | 大色块:选中态、主按钮、关键数字 |
| 金拱黄 | `400 #ffc72c` · `300 #ffd75e` · `600 #c98d00` | 栏目标签、拱门图形、价格高亮 |
| 状态 | `#2e6b41` 涨 / `#2e6b41`稳 / `#7a5600` 提示 | 涨跌与提示 |
**页面底不叠任何纹理。** 早期版本铺过 72px 细网格加三团径向光斑,全部删掉——麦当劳的视觉冲击靠**色块和字重**,不靠底纹。
`.eyebrow`(栏目小标签)是**黄底黑字实心药丸**,不是灰色小字:这是麦当劳菜单上分类标签的做法,深色压色块。
### 数字:每一个都必须真算得出来
统计条带、趋势图、注解栏里的每个数都从真实 `HistoryOrder` 派生(`src/lib/metrics.ts`,纯函数):
- 最常点带「出现在 N% 的订单里」,分母是**全部历史订单数**,不是 top5 之和;
- 平均客单价只算有金额的订单,并注明「基于 N 笔有金额的订单」;
- 累计下单的脚注是「最近一单 7/6」,上一窗口没样本就不写环比 —— 硬算出来的百分比是假的;
- 「套餐里最常选」统计的是套餐 `children`,这是比「又点了一次随心配」更细一层的记忆;没有 children 数据时整段不出现,不留空标题;
- **金额三态严格区分**:`null`(接口没给)显示「—」,`0`(券全额抵扣)显示 `¥0.00` + 极小「券」标记,正常金额正常显示。三者含义不同,混成一种最容易让人误判。
### 图表:手写 SVG,不引图表库
| 图 | 做法 | 为什么 |
| --- | --- | --- |
| 消费趋势 | 红色粗折线 + 实心交互点,只画 0 / 中值 / 上限三条网格 | 近 10 笔实付金额。纵轴按整数金额步长取整,20 元的账单不会被画在 100 元的轴上浪费高度 |
| 下单时段 | 注解栏里的可点条形 | 只有两三个时段有数据,环形图占地太大且不可点;条形每一行都能点,点一下就筛选下面的订单 |
**图表宽度是量出来的,不是写死的。** `ResizeObserver` + 挂载时实测 + `rAF` 复量拿真实像素宽,`viewBox` 跟着变,1 SVG 单位 = 1 CSS 像素,字号在任何断点都是字面值。
> 这里踩过一个隐蔽的坑:图表曾被压成左下角一小段。因为 `ResizeObserver` 首次回调拿到的是**尚未完成布局的 0 宽**,而元素之后不再变尺寸,于是永远停在 `Math.max(260, w)` 兜底值。量不到就不画,不要写兜底值。
### 交互:五处联动都是真功能
| 操作 | 结果 |
| --- | --- |
| 注解栏点某个时段 | 订单列表筛到该时段,标题旁出现「午餐 11–14 · 8 单 ✕」并滚动到位;再点或点「清除」复原 |
| 点趋势线上的任意数据点 | 展开对应订单明细并滚动到位;排在默认条数之外时列表自动放宽 |
| 点任意订单行 | 就地展开:完整餐品、套餐子项、取餐方式、订单号、实付、门店 +「照这一单再来」,不跳页 |
| 注解栏点「按这个点一单」 | 把「再来一份精选超值随心配」填进首屏输入框,可改后再发 |
| 门店列表 | 直接标出「营业中 / 至 23:59」或「休息中 / 06:30 起」—— 否则用户只能靠「核价失败」反推自己选了一家关门的店 |
**不做「所有卡片一起上浮」。** 数据面板里大面积位移会让页面像在漂。
### 状态反馈
| 状态 | 表现 |
| --- | --- |
| 读取中 | 骨架是**一条扫光**而不是脉冲(脉冲会让整页像在抖),高度与真实内容一致,不跳版 |
| 无历史订单 | 各处说明原因,订单区给「还是老样子」主操作;一条金额都没有时趋势面板整段不显示,不堆空状态 |
| 演示数据 | 侧栏底部状态点转黄 + 首屏下方一行说明,不占一整块 Alert |
| 核价失败 | 候选卡内直接写明失败原因(如「门店暂停营业,请重新选择门店」),吸底栏同步降级,不假装成功 |
| 金额未返回 | 显示「—」并带 tooltip,绝不画成 ¥0.00 |
| 筛选生效 | 标题右侧出现可清除的筛选标记,同时列出命中的单量 |
| 展开 / 收起 | `aria-expanded` + 箭头 180° 翻转,展开区 `animate-fade-up` |
### 响应式
| 断点 | 变化 |
| --- | --- |
| `≥1280px` | 侧栏常驻 248px;趋势 8 列 + 记忆 4 列并排 |
| `1024–1279px` | 侧栏仍常驻,正文单列 |
| `<1024px` | 侧栏收成抽屉(遮罩 + 滑入),顶栏出现汉堡键 |
| `<768px` | 统计条带 2×2,标题 `clamp(38px, 6.4vw, 68px)` 收缩 |
| `<640px` | 快捷短语换行不横向截断;订单行时间列收窄,状态并入副行 |
| 吸底栏 | 只在有结果时出现,`lg` 以上让出 248px 侧栏宽度 |
实测 1440 / 768 / 390 三档 × 六个页面**零横向溢出**,浏览器控制台零报错。
### 无障碍与对比度
**换底色后必须重算全部文字对比度** —— 中性灰在浅暖底上会比在纯白上更不够深。这一轮把 `ink-faint` 从 `#8f8f8f` 提到 `#6e6e6e`,因为前者在奶油底 `#fdf6ec` 上只有 3.01:1。
| 用途 | 值 | AA(4.5:1) |
| --- | --- | --- |
| 正文与标题 | `#0d0d0d` | 通过 |
| 次级文字 | `#2f2f2f` | 通过 |
| 说明文字 | `#6a6a6a` | 通过 |
| 口径标签 / 时间戳 | `#6e6e6e` | 4.75:1,通过 |
| 白字 / 品牌红按钮 | `#ffffff` on `#d62315` | 通过 |
另外三条不靠颜色单独承担信息:
- 涨跌同时给箭头方向 + 正负号 + 文字,色弱用户不依赖红绿也能读出方向;
- 所有可点元素 `:focus-visible` 统一红色 focus 环。浮层和 `.composer` 里的输入框**不再自己描一圈**(外层容器已经换了反馈,否则一次聚焦会出现两道环);
- `prefers-reduced-motion` 全局把动画时长压到 0.01ms、位移取消,只保留透明度变化;筛选与定位的滚动也据此切成 `auto`。
---
## 快速开始
### 1. 申请 MCP Token
打开 <https://open.mcd.cn/mcp> → 手机号登录 → 右上角「控制台」→「激活」→ 同意协议 → 复制 Token。
### 2. 安装与配置
```bash
git clone <this-repo>
cd mcd-repeat-smarter
npm install
cp .env.example .env
# 编辑 .env,填入 MCD_MCP_TOKEN=你的Token
# 可选:MCD_DEFAULT_STORE_CODE=1450543(默认门店,留空则在界面里选)
```
### 3. 运行
```bash
# 开发模式(前端 5173 + 服务端 8787,vite 自动代理 /api)
npm run dev
# 生产模式(构建后用 8787 单端口提供页面与 API)
npm run build
npm start
```
打开 <http://127.0.0.1:8787>。
> **不要同时开着 dev 和 start。** 两者都监听 8787,后启动的那个会直接退出(提示"端口已被占用"并给出换端口/查进程的命令)。切换模式前先用 `Ctrl+C` 停掉前一个,或 `PORT=8788 npm start` 换个端口。
### 4. 不想配 Token?
```bash
DEMO_MODE=true npm run dev
```
会用内置演示数据跑通完整流程。**演示模式在界面上有强制警示条,所有金额标记为演示数据,下单接口只返回模拟结果并明确说明"没有创建真实订单"。**
### 5. 自检连接是否正常
```bash
npm run mcp:probe
```
只读调用,不会创建订单。会列出当前 MCP Server 暴露的全部 Tool、门店在售商品数、可读历史订单数,以及一次真实核价的明细(含 `traceId`)。
---
## 使用示例
下面这些是**真机上跑过的输入**(门店:麦当劳上海浦东第二工业大学餐厅,`1450543`,到店自取)。
### 例 1:只改一处,带预算
> `还是上次那份,不过今天不要香芋派,换个东西,预算不超过 16 元`
系统行为:
1. 从 `order-list` 取最近一单:`随心配1+1`(那么大鸡排(椒盐风味)+ 香芋派);
2. 发现 `随心配1+1`(`9900010829`)**已不在当前门店在售菜单里**;
3. 按核心词重匹配到在售的 `人气经典随心配`,并在界面上**明确提示"编码已更新 → 现已对应「人气经典随心配」"**;
4. 在粉区里把香芋派换成另一种派/小食,给出 2 个候选;
5. 每个候选各调一次 `calculate-price`,金额来自官方核价(本例 ¥13.90,在 ¥16 预算内)。
### 例 2:原样重复
> `还是老样子`
不做任何修改,直接核价 + 进入确认流程。如果历史里有商品下架,会先告诉你。
### 例 3:加一件
> `加一份麦乐鸡`
在原方案基础上新增,原方案其余内容一字不动。
### 例 4:存成常用方案
调好之后点「存成常用方案」→ 选场景(上班早餐 / 日常午餐 / 周末套餐 / 加班夜宵)→ 命名。
存的是结构化数据:
```json
{
"id": "plan_xxx",
"name": "我的日常午餐",
"storeCode": "1450543",
"storeName": "麦当劳上海浦东第二工业大学餐厅",
"beType": 1,
"tag": "日常午餐",
"source": "user",
"items": [
{
"productCode": "9900013291",
"name": "人气经典随心配",
"quantity": 1,
"isCombo": true,
"children": [
{ "code": "521156", "name": "那么大鸡排(椒盐风味)", "quantity": 1, "round": "1", "roundName": "蓝区" },
{ "code": "4106", "name": "菠萝派", "quantity": 1, "round": "2", "roundName": "粉区" }
]
}
],
"createdAt": "2026-10-17T10:58:00.000Z"
}
```
**注意这里没有存价格。** 价格有时效性和门店差异,下次用之前一定重新 `calculate-price`。方案落盘到 `data/plans.json`(原子写 + 串行化,重启后仍可读)。
---
## 它到底做了什么
### 模块拆分
| 模块 | 文件 | 职责 |
| --- | --- | --- |
| 意图解析 | `server/domain/intent.ts` | 确定性规则引擎(正则 + 中文数字),把"不要薯饼,换个东西,不超过 25"拆成 `remove / replace(open) / budget`。不依赖 LLM,结果可复现、可单测 |
| 原方案匹配 | `server/domain/matcher.ts` | 商品身份重匹配:`exact / renamed / unavailable / substituted / unmatched`,处理"换码但同名"和"已下架" |
| 候选生成 | `server/domain/planner.ts` | `minimal`(严格照做)、`swap-fit`(同轮次另一种搭配)、`budget-fit`(仅超预算时) |
| 价格核算 | `server/mcp/mcdService.ts` + `server/domain/pricing.ts` | 调用官方 `calculate-price`,再做**覆盖性 / 合计一致性 / 零金额**三类自检 |
| 预算校验 | `server/domain/pricing.ts` | 与用户预算比较,超预算必须写明原因,**不允许静默突破** |
| 确认状态 | `server/routes/api.ts` | 两段式:`/order/preview` 发一次性令牌 → `/order/confirm` 校验 `confirmed === true` |
| 持久化 | `server/store/planStore.ts` | JSON 落盘,临时文件 + rename 原子写,写操作串行化 |
| 数据来源 | 全局 | 每个对象带 `source: 'mcp' \| 'user' \| 'demo'`,UI 必须区分展示 |
### 一次「调整」的完整链路
```
POST /api/adjust
├─ 规则引擎 → ParsedIntent
├─ 取原方案(order-list 最近一单 / 用户保存的方案)
├─ query-meals → 门店当前在售清单
├─ query-meal-detail → 套餐轮次结构(判断子项是否还合法、有哪些替代)
├─ matchPlan() → 身份重匹配(含别名、换码、下架)
├─ buildCandidates() → 2~3 个候选(每个都带"为什么给你这个")
├─ calculate-price ×N → 逐候选真实核价
├─ verifyBreakdown() → 价格自检
└─ markBudget() → 预算判定 + 超预算说明
```
---
## 技术架构
```
浏览器 (React 19 + TS + Vite)
│ 只发业务请求,永远拿不到 Token
▼
Express (Node) ← MCD_MCP_TOKEN 只在这里
├── server/mcp/client.ts 手写 MCP Streamable HTTP 客户端(JSON-RPC 2.0)
├── server/mcp/mcdService.ts 业务适配层(单位归一、字段映射)
├── server/domain/* 纯函数领域逻辑(意图 / 匹配 / 规划 / 定价)
├── server/store/planStore.ts 常用方案持久化
└── server/routes/api.ts 对外 API + 下单安全闸门
▼
https://mcp.mcd.cn (麦当劳官方 MCP Server)
```
**为什么不用 MCP SDK:** 只有 `initialize`、`tools/list`、`tools/call` 三个方法,手写 226 行即可,省掉一个依赖,也更容易看清"Token 具体在哪一行被使用"。
**领域层是纯函数**:`intent / matcher / planner / pricing` 不碰网络、不碰文件,因此可以用固定 fixture 做单元测试,79 个测试里 57 个属于这一类。
### 目录结构
```
mcd-repeat-smarter/
├── server/
│ ├── mcp/ MCP 客户端、真实实现、演示实现、网关接口
│ ├── domain/ 类型、意图、匹配、规划、定价、习惯画像
│ ├── store/ 常用方案持久化
│ ├── routes/ API 路由
│ ├── config.ts 网关工厂(有 Token 才连真实 MCP)
│ ├── app.ts createApp(gateway),便于测试注入
│ └── index.ts 启动入口
├── src/ React 前端
│ ├── views/ 六个页面:Overview / OrderView / PlansView / OrdersView / StoresView / AboutView
│ ├── components/ 布局与图表:Sidebar(纯黑侧栏)/ Topbar / SpendTrend
│ │ 概览页四件:Hero(主舞台)/ MemoryRail(记忆注解栏)/ PlanList(方案清单)/ OrderFeed(订单流)
│ │ 数据展示:OrdersTable(月份分组清单)/ PlansGrid
│ │ 业务流程:Composer(点餐卡)/ AdjustResultView / ConfirmSheet / StorePicker
│ │ 原子组件:ui.tsx(Panel / SectionHead / Button / Badge / Alert / Skeleton / EmptyState / MetricLead / MetricPair / TrendPill / Toasts)
│ ├── lib/metrics.ts KPI、趋势、时段、常点与套餐子项的全部派生逻辑(纯函数,任何数字都从真实订单算出来)
│ └── styles/index.css Tailwind 入口 + 页面底色 + .composer/.float/.panel 三类容器 + .display/.eyebrow/.hair/.code + 骨架扫光
├── tailwind.config.js 品牌色阶(brand-50…950、gold-100…800)、ink 四级文字、canvas / raised / line / hair / wash、圆角 / 阴影 / 动效 token
├── tests/ 79 个测试(全部在后端领域层与 API 层,前端改动不影响)
├── scripts/ probe-mcp.ts(MCP 自检)、screenshots.mjs(UI 截图)
└── docs/ 竞品检查、开发总结、截图
```
---
## 关键设计决策
**1. 金额一律用「分」的整数存储。**
`calculate-price` 返回「分」,`query-meals` 的 `currentPrice` 是「元」的字符串。混用会差 100 倍。统一在入口转成「分」,只在渲染时除以 100。
**2. 商品身份与展示信息分离,并且承认"身份会漂移"。**
真实数据里,用户最爱的 `随心配1+1`(`9900010829`)已经下架。直接报错是偷懒,静默换成别的是欺骗。做法是:按核心词重匹配到在售商品 + **显式告知"编码已更新,现已对应 XX,请确认"**,并保留 `aliasNames`,让用户之后仍能用旧名字提修改。
**3. 价格只来自 MCP,模型不参与算钱。**
所有金额都对应一次真实的 `calculate-price`,界面上能看到 `traceId`。演示模式的金额带 `source: 'demo'`,绝不冒充。
**4. 下单是两段式的。**
`/order/preview` 会再核一次价,并签发一个 5 分钟有效、绑定门店/商品/金额/取餐方式的一次性令牌;`/order/confirm` 必须带 `confirmed: true` 和这个令牌。少了任何一样都是 428 / 410。**UI 上的下单按钮在勾选前是 disabled 的,绕过 UI 直接打接口也拿不到 200。**
**5. 意图解析用规则引擎,不用 LLM。**
"不要 X,换个东西,不超过 25" 这类句式高度集中,正则 + 中文数字解析就够,而且结果稳定可测。代价是能理解的句式有限——这是明确的取舍,不是遗漏(见[已知限制](#已知限制))。
**6. 不理解的地方不猜。**
解析器识别不了的片段会进 `unrecognized`,界面会提示"有一部分没听懂"并给出可用说法示例,而不是假装懂了乱改。
---
## 真实 MCP 验证
`npm run mcp:probe` 的真实输出(2026-10-17):
```
端点:https://mcp.mcd.cn
门店:1450543 | 取餐方式 beType=1
1. 服务端实际暴露的 Tool(tools/list)
共 35 个
2. 门店菜单(query-meals)
在售商品 102 个(orderType=1)
3. 历史订单(order-list)
可读取订单 10 单
4. 官方核价(query-meal-detail + calculate-price)
套餐:9900005456 麦辣鸡腿汉堡三件套
轮次:麦辣鸡腿汉堡 / 选择套餐内小食 / 选择套餐内饮料
合计 33.50 元(原价 33.50 元,优惠 0.00 元)
数据来源 source=mcp | traceId=6e49dc0cdae58e8876891077361f39a1
```
本项目实际使用了 7 个 Tool:`query-nearby-stores`、`query-meals`、`query-meal-detail`、`order-list`、`calculate-price`、`query-my-coupons`、`create-order`。
详细的入参、出参、调用流程和踩过的坑,见 **[MCP_INTEGRATION.md](MCP_INTEGRATION.md)**。
---
## 运行测试
```bash
npm test # vitest,79 个测试
npm run typecheck # tsc --noEmit
npm run build # tsc --noEmit && vite build
```
当前状态:**79 passed / typecheck 无错误 / build 成功(JS gzip 102.1 KB + CSS gzip 7.8 KB)**。
覆盖范围:
| 测试文件 | 数量 | 覆盖什么 |
| --- | --- | --- |
| `tests/intent.test.ts` | 13 | 中文数字("二十五"/"十五")、否定语境("不要"里的"要"不是新增)、开放式替换、"换个东西"升级为无目标替换 |
| `tests/matcher.test.ts` | 17 | 换码同名、别名保留、下架替代、跨轮次名称搜索、模糊匹配阈值 |
| `tests/planner.test.ts` | 16 | 保留/删除/替换/新增、套餐删项后补位、候选生成、预算标记 |
| `tests/pricing.test.ts` | 11 | 覆盖性校验、合计一致性、零金额、价差计算、预算判定 |
| `tests/store.test.ts` | 7 | 真实落盘、新建 store 实例能读到(模拟重启)、并发写不互相覆盖、文件损坏不抛错 |
| `tests/api.test.ts` | 15 | 未确认时下单被拒(428)、伪造令牌(410)、非法取餐方式(400)、令牌一次性 |
`tests/api.test.ts` 里有专门的用例断言:**没有显式确认时,`createOrder` 一次都不会被调用**——用一个会计数的网关包住实现,验证"未确认 / confirmed=false / 伪造令牌"三种情况下副作用函数的调用次数都为 0,只有显式确认后才恰好为 1。
---
## 已知限制
诚实列出来,不粉饰:
| 限制 | 原因 | 影响 |
| --- | --- | --- |
| 只支持到店自取 | UI 当前只开放 `beType=1`;麦乐送/得来速/团餐需要地址、门店时段等额外参数 | 外送用户暂时用不了 |
| 历史订单不返回轮次,也不返回图片 | MCP 的 `order-list` 字段所限 | 子项轮次靠"按名称反查当前套餐结构"推断,同名商品出现在多个轮次时可能推错,此时会标记为需确认;「原方案」列没有商品图 |
| 不会还原上次的特制项 | 历史订单不返回用户上次选的少冰/去酱 | 「老样子」还原的是餐品结构,不含特制偏好 |
| 意图解析能覆盖的句式有限 | 规则引擎,不是 LLM | 复杂说法("我想换个口味但别太辣")会被标记为没听懂,而不是瞎猜 |
| 不做反向求解 | 只能执行用户明确说的改动 | 预算不满足时给最接近的方案 + 说明原因,不会偷偷换掉你要的东西 |
| 单笔只能用 1 张券 | 官方规则 | 不做多券叠加,不代选券 |
| 常用方案是单机文件存储 | 无后端账号体系 | 换设备/换浏览器拿不到,多人共用一台机器会共享 |
| 演示数据是内置的 | 无 Token 时的体验模式 | 只用于演示流程,不代表真实供应情况 |
---
## 竞品检查
开发前做过一次竞品检查,结论和**没查清的部分**都写在 **[docs/COMPETITIVE_CHECK.md](docs/COMPETITIVE_CHECK.md)**。
摘要:
- 排行榜 108 个项目,第 1 名 473★、第 100 名 13★,省钱 / 热量 / 决策 / 派对 / MBTI 等方向已经非常饱和;
- 与本项目最接近的是组合优化类(`mthli/mcd-order`,"求最省方案 + 默认不下单")和交互式点单类(`麥門搭子`,"全量菜单点单 + 确认后下单");
- 差异点在"以我的历史为起点 + 增量修改是一等公民 + 商品身份漂移显式处理"这三条;
- **没有逐个审阅全部仓库源码,因此不宣称"已证明没有竞争对手"**,无法核实项在文档里逐条标记为「未知」。
---
## 文件清单
| 文件 | 说明 |
| --- | --- |
| `README.md` | 本文件 |
| `CONTEST_DECLARATION.md` | 参赛声明(**官方原文,未做任何改动**) |
| `MCP_INTEGRATION.md` | MCP Server / Tool / 调用流程 / 业务价值 / 能力边界 |
| `mcp-config.example.json` | MCP 客户端配置示例(仅环境变量占位符) |
| `.env.example` | 环境变量模板(无真实 Token) |
| `workbuddy.md` | 本次开发的 WorkBuddy 真实对话记录导出(Token 已脱敏) |
| `server/` `src/` | 源代码 |
| `tests/` | 79 个测试 |
| `scripts/probe-mcp.ts` | MCP 能力自检(只读) |
| `scripts/screenshots.mjs` | UI 截图脚本(会走完流程但不提交订单) |
| `docs/COMPETITIVE_CHECK.md` | 竞品检查记录 |
| `docs/DEVELOPMENT_SUMMARY.md` | 开发过程整理稿(比对话记录更适合快速了解全貌) |
---
## 免责声明
本项目为**麦当劳程序员节创意开发大赛参赛作品**,由参赛者独立开发,非麦当劳官方产品。
餐品信息、价格及供应状态以麦当劳官方渠道的实时结果为准。项目不包含任何真实 Token、密钥或他人个人信息,配置文件仅使用环境变量占位符。
连接信息
猜你喜欢
暂无推荐服务器