# TOGETHER FIT · 双人健身饮食记录

纯 HTML、CSS、JavaScript，Supabase JS SDK 与 Chart.js 由 CDN 加载；无需构建、密码、注册或登录。首次打开选择 A / B，身份仅保存在当前浏览器，两个人都能编辑全部记录。

## 文件

```text
together-fit/
├─ dist/
│  ├─ index.html    页面与表单
│  ├─ styles.css    响应式布局、手机底部导航
│  ├─ app.js        顶部 CONFIG、云端 CRUD、图表、豆包识别
│  └─ core.js       营养换算、统计、输入与 AI JSON 校验
├─ supabase.sql    建表、约束、开放读写策略、种子、Realtime
├─ check.mjs       可运行的基础逻辑检查
└─ README.md       配置与部署说明
```

仅部署 `dist`。未配置数据库时显示只读示例，不能保存；示例不会上传到 Supabase。当前本地版本已填写用户提供的豆包 Key 和接入点，并于 2026-10-11 从临时公网预览的浏览器实际识别成功（HTTP 200，香蕉及营养 JSON）。Supabase 尚未配置，云端保存仍不可用。当前代码和交付包包含方舟 Key，部署后访问者可读取并使用其调用额度；不要把 Key 提交到 Git 历史。

## 1. 建立 Supabase 数据库

1. 在 [Supabase](https://supabase.com/dashboard) 创建一个专用项目。
2. 打开项目的 SQL Editor，新建查询，粘贴并执行整个 `supabase.sql`。脚本创建 5 张表、14 个动作、18 个食物，并启用 Realtime。
3. 在项目 Connect 对话框取得项目 URL 和 **publishable key**；也可在 Settings → API Keys 取得公开 Key。旧项目的 `anon` Key 也可用。
4. 确认 Data API 开启且暴露 `public` schema。不需要打开 Auth 的匿名登录，不需要创建 A/B 账号。
5. 修改 `dist/app.js` 顶部 CONFIG，填入 URL 和公开 Key。

脚本在事务中执行，可重复运行；种子 `ON CONFLICT DO NOTHING` 保留已有同名动作/食物。没有删表或清空记录的语句。它用于初始化本项目，不负责迁移其他项目的同名旧表。

**访问范围必须理解：** SQL 对 `anon` / `authenticated` 都放行全部记录的读、增、改、删。A/B 是可自行选择的标签，不是身份验证；创建人标签也不能证明真实操作者。任何取得项目 URL 和公开 Key 的人都能读取、修改或删除饮食、体重、围度与动作库，知道站点地址即可取得这些值。只把网址发给两个人不能限制访问。请使用独立项目，只保存你接受公开读写的数据，并定期从 Supabase 导出备份。

**禁止**把 `service_role` 或 `sb_secret_...` Key 放到前端。公开 Key 的权限取决于数据库 grants 和 RLS；本项目的 RLS 明确开放，并不提供隐私隔离。参见 [Supabase API Keys](https://supabase.com/docs/guides/getting-started/api-keys) 与 [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)。

## 2. 配置豆包视觉识别

1. 在 [火山方舟控制台](https://console.volcengine.com/ark/) 创建 API Key，并开通支持**图片输入**和 Chat Completions 的视觉模型。
2. 创建或选择该模型的推理接入点，复制接入点 ID（通常为 `ep-...`）。
3. 填写 CONFIG 的 `DOUBAO_API_KEY`、`DOUBAO_ENDPOINT_ID`；API URL 保持如下值。

```js
const CONFIG = {
  SUPABASE_URL: 'https://你的项目.supabase.co',
  SUPABASE_KEY: 'sb_publishable_公开Key或旧版anonKey',
  DOUBAO_API_KEY: '火山方舟APIKey',
  DOUBAO_ENDPOINT_ID: 'ep-你的视觉模型接入点ID',
  DOUBAO_API_URL: 'https://ark.cn-beijing.volces.com/api/v3/chat/completions',
};
```

这段是必填配置示例；修改现有 CONFIG 的对应字段，保留文件中其他字段。

**模型名称更新：** 用户指定的 `doubao-vision-pro` 是旧系列名称。官方 2025 年下线公告列出 `doubao-vision-pro-32k-241028` / `241215` 于 2025-08-28 停止服务并迁移；不能假设旧模型现在仍可新开通。代码保留方舟 Chat API 接入方式，实际模型由你填写的 endpoint ID 决定，请在控制台选择当前可用的图片理解模型。如果已有旧接入点，也要核对它当前实际指向的模型。[官方旧模型下线公告](https://docs.volcengine.com/docs/ark/model-deprecation-announcement-2025?lang=zh)

识别流程：拍摄或选择图片 → 浏览器压缩为 JPEG → 用 `data:image/jpeg;base64,...` 作为 `image_url` 发送 → 校验返回 JSON → 展示可修改的估算结果 → 你确认后保存。不会自动写入饮食记录。图片不保存到 Supabase，只有确认后的食物和营养数字入库；识别图片会发送给火山方舟。

模型被要求返回下列结构，所有营养数值都是**这一份食物的总量**：

```json
{
  "foods": [
    {
      "food_name": "鸡胸肉",
      "grams": 150,
      "calories": 247.5,
      "protein": 46.5,
      "carbs": 0,
      "fat": 5.4
    }
  ]
}
```

单位：份量和三大营养素为 g，热量为 kcal。照片不能准确确定份量、油量和配料，保存前按实际情况修正；食物库初始数值也是近似参考值，注意生熟状态、品牌与烹饪方式，优先使用包装标签实测值。

**前端 Key 与调用费用：** 把方舟 Key 写在浏览器配置中，使用者、开发者工具和拿到前端文件的人都能读取它，并可在站点外发起收费请求。不要使用主账号的通用 Key；使用专用、权限尽量受限的 Key，设置可用的额度/预算/费用提醒并监控用量，发现外泄或异常调用立即吊销、更换。额度或提醒不一定是硬性费用上限，请以方舟控制台实际能力为准。纯前端无法隐藏此 Key；需要隐藏时必须增加服务端代理，该交付按要求没有后端。

**CORS 限制：** 浏览器直连还要求方舟接口允许你的网站来源和带 Authorization 的预检请求。官方 API 文档不能保证你的部署域名一定获准跨域；如果出现 CORS / Failed to fetch，请检查浏览器 Network 和控制台，不要关闭浏览器安全设置，也不要把 Key 发给第三方“免费 CORS 代理”。纯前端无法修复服务端拒绝的跨域请求；此时手动记录仍可用，AI 直连须由服务端允许来源，或后续改用自己的代理。真实成功识别需用你的 Key、endpoint 与部署域名实测。

接口依据：[Chat API 请求与返回字段](https://docs.volcengine.com/docs/ark/chat-api?lang=zh)、[图片理解与 Base64 格式](https://docs.volcengine.com/docs/ark/image-understanding?lang=zh)。

## 3. 本地打开

在 `together-fit` 目录运行（已安装 Python）：

```powershell
python -m http.server 8000 --directory dist
```

浏览器打开 [http://localhost:8000](http://localhost:8000)。不要直接双击 `index.html`，页面使用 JS 模块，需要 HTTP 服务。手机同一局域网可访问 `http://电脑局域网IP:8000`；电脑防火墙需要允许该端口。上线使用 HTTPS。

CDN 和云端 API 均需要网络。离线或云端连接失败时不会假装保存成功，失败表单保留供重试。浏览器本地只保存 A/B 选择，不用 localStorage 代替云端保存记录。

## 4. 部署静态网站

无需安装 npm 或执行构建命令；把 `dist` 内的全部文件部署到任意静态托管。

- **Netlify 手动上传：** 完成配置后，把 `dist` 文件夹拖入 [Netlify Drop](https://app.netlify.com/drop)。网站根目录应直接包含 `index.html`。后续配置变化重新上传此文件夹。[官方手动部署说明](https://docs.netlify.com/deploy/create-deploys/)
- **GitHub Pages：** 将 `dist` 内文件复制到一个专用仓库的根目录并提交，在 Settings → Pages 选择 Deploy from a branch、`main` 和 `/(root)`。分支发布只支持根目录或 `/docs`，不能直接选择 `/dist`。也可复制到仓库 `/docs` 后选择该目录。[官方发布目录说明](https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site)
- **其他静态托管：** 发布目录填 `dist`，不设构建命令；若托管商只接受文件上传，则上传该目录的内容。

前端源文件和其中的方舟 Key 会随部署公开；提交到公开 Git 仓库还会永久留在提交历史，删除当前文件不能收回已泄露 Key。Key 一旦进入公开仓库，请在方舟吊销后重建专用 Key。不要把云服务控制台的个人登录凭据写进任何文件。

如果附带 Sites 预览链接，它可能受 Sites 平台登录或分享权限控制，用来预览页面；无验证的正式网址按上述静态托管方式部署。网站本身没有登录流程，托管平台的分享权限是另外一层控制，不能改变已开放的 Supabase API 权限。

## 数据和统计约定

| 表 | 内容与单位 |
| --- | --- |
| `exercises` | 动作库，名称与部位分类 |
| `foods` | 每 100 g 的热量 kcal、蛋白质/碳水/脂肪 g |
| `training_records` | 日期、所属 A/B、动作名快照、负重 kg、组数、每组次数 |
| `diet_records` | 日期、所属 A/B、餐次、份量 g、整份营养总量、来源 |
| `body_records` | 体重 kg；腰/胸/臀/臂/腿围 cm；每人每天最多一条，至少填一项 |

- 身份选择用于 `created_by` / `updated_by`；记录的 `person` 是测量或训练所属人，两者可以不同。预设库条目的创建人标记为 A。
- 修改记录保留原创建人和创建时间；数据库触发器更新时间。A/B 可修改对方的训练、饮食、体测以及公共库。
- 单条训练量 = `负重 × 组数 × 每组次数`，按日期和所属人汇总。自重动作填 0 时不把身体重量计入训练量；不同负重的组请拆成多条。
- 食物库按 `每 100 g 营养 × 份量 / 100` 换算，手动或 AI 输入的是整份总量。选择 A 或 B 时卡片与三大营养素曲线展示该人的数据；选择“全部”时这些数值合计 A+B。训练量、每日热量和体重图分别展示 A/B。
- `CONFIG.DAILY_TARGETS` 是每人每天的展示参考目标，可自行修改；“全部”模式的参考目标乘以 2。这些值仅用于进度显示，不会自动按个人情况定制。
- 动作名和营养总量保存为记录快照。改名/删库条目不会重写历史营养或动作名；删除动作后历史记录的 `exercise_id` 为空。
- 编辑/删除带 `updated_at` 条件，避免正常界面操作覆盖另一人刚保存的版本；冲突提示并刷新数据，编辑表单保留。体测同人同日重复会提示，改为编辑已有记录。直接调用公开 API 的人仍可绕过前端的版本比较。
- 新增草稿与识别食物使用固定 UUID。同一草稿遇到网络异常后重试，不会重复插入；若接口此前已提交但响应丢失，重试会提示记录已存在，请刷新核对并编辑已有记录。关闭后新建、再次识别属于新草稿，请先确认云端情况。
- Realtime 收到变更后重新加载数据；前台每 30 秒轮询一次补漏，恢复网络或回到页面也会刷新，可随时点击手动刷新。SQL 已加入 `supabase_realtime` publication；Realtime 异常按 [官方排查说明](https://supabase.com/docs/guides/troubleshooting/realtime-postgres-changes-troubleshooting) 检查，重点是表 publication、grants 和 RLS。

## 验收

基础逻辑检查（已安装 Node.js）：

```powershell
node check.mjs
```

数据库最小检查：先执行建表脚本，再把以下片段单独放进 Supabase SQL Editor 运行。它以未登录访问者 `anon` 的权限验证跨人编辑、创建人保护、版本冲突和删除；结尾回滚，不留下测试记录。

```sql
begin;
set local role anon;
do $$
declare
  exercise_id uuid;
  record_id uuid;
  original_version timestamptz;
  original_created timestamptz;
  row_value public.training_records%rowtype;
  affected integer;
begin
  insert into public.exercises (name, created_by, updated_by)
  values ('验收动作_' || gen_random_uuid(), 'A', 'A') returning id into exercise_id;
  insert into public.training_records (date, person, exercise_id, exercise_name, weight, sets, reps, created_by, updated_by)
  values (current_date, 'B', exercise_id, '验收动作快照', 20, 3, 10, 'A', 'A')
  returning id, updated_at, created_at into record_id, original_version, original_created;
  update public.training_records
  set weight = 25, created_by = 'B', created_at = '2000-01-01', updated_by = 'B'
  where id = record_id and updated_at = original_version;
  select * into row_value from public.training_records where id = record_id;
  if row_value.weight <> 25 or row_value.created_by <> 'A' or row_value.updated_by <> 'B'
     or row_value.created_at <> original_created or row_value.updated_at <= original_version then
    raise exception '跨人编辑或创建人/时间保护失败';
  end if;
  update public.training_records set weight = 99 where id = record_id and updated_at = original_version;
  get diagnostics affected = row_count;
  if affected <> 0 then raise exception '旧版本覆盖了新记录'; end if;
  delete from public.exercises where id = exercise_id;
  select * into row_value from public.training_records where id = record_id;
  if row_value.exercise_id is not null or row_value.exercise_name <> '验收动作快照' then
    raise exception '删除动作库破坏了历史快照';
  end if;
  delete from public.training_records where id = record_id;
  if exists (select 1 from public.training_records where id = record_id) then
    raise exception '删除失败';
  end if;
  raise notice '通过：anon 跨人 CRUD、创建人保护、版本条件、历史快照';
end;
$$;
rollback;
```

配置真实服务后，用手机和电脑打开**同一个网址**，分别选 A / B：

1. 新建训练记录，另一端出现；另一端修改重量、删除，原端同步。确认创建人不变、修改人变化。
2. 食物库选 150 g 鸡胸肉，核对按每 100 g 换算；再手动填一餐，确认日期与所属人的当日营养总数。
3. 拍照识别一餐，查看可编辑结果，确认之前数据库没有新增记录，确认保存后两端可见。另测非食物照片、取消和接口报错。
4. 新建体重/腰围，编辑、删除；同人同日重复新增被拒绝；只填围度也可保存，空项不应绘制成 0。
5. 增删改动作和食物，重跑 SQL 后已有修改仍在，已有训练与饮食快照不变。
6. 两端先打开同一记录，再先后保存不同修改：后保存端应提示冲突，不能静默覆盖。测试断网保存失败仍保留表单。
7. 检查统计日期范围、A/B 训练量、营养与体重曲线；检查窄屏底部导航、表单和图表无横向溢出。

未提供真实云端凭据时，上述云端、Realtime 和收费识别验收尚未执行；`check.mjs` 只检查本地逻辑，不代表服务连通。SQL 需要在你的 Supabase SQL Editor 执行验证。

官方文档核对日期：2026-10-11。云服务控制台、模型名称和跨域策略可能变化，部署时以你实际账号状态为准。
