From a9d8a865391c98b4b243607615c5e2d96d5dc5ed Mon Sep 17 00:00:00 2001 From: qiuyijoy-lgtm Date: Sun, 16 Aug 2026 15:02:27 +0800 Subject: [PATCH 1/2] =?UTF-8?q?OSM=20=E5=AF=BC=E5=85=A5=E5=8A=A0=20toilets?= =?UTF-8?q?=3Dyes=20=E6=95=B0=E6=8D=AE=E6=BA=90=EF=BC=8C=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E8=B6=85=E9=95=BF=E5=90=8D=E5=AD=97=E5=AF=BC=E8=87=B4=E6=95=B4?= =?UTF-8?q?=E6=89=B9=E5=A4=B1=E8=B4=A5=E7=9A=84=20bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 除了专门标成厕所的点位,现在也抓酒店/商场等地点上明确标注"有厕所" (toilets=yes)的地方,同一批查询里一起要,不额外增加请求。国内愿意 打这个标签的人不多,实测上海只多出 3 个,但都是真实标注过的数据, 不是靠猜的。 顺手改进了场所分类逻辑:以前只能靠厕所自身的 access/building 标签 瞎猜类型,现在优先看这个点本身是什么地方(商场/餐饮/车站/校园…), 连带着让已有数据的分类也变准了。 修的 bug:北京有个厕所点的 OSM name 标签把中英文名挤在一起写, 超过数据库"厕所命名投票"表 40 字的限制,导致那一条报错、还连累 整批导入提前失败退出。现在超长名字会自动截断,不会再拖累整批。 Co-Authored-By: Claude Sonnet 5 --- scripts/import-osm.ts | 35 ++++++++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/scripts/import-osm.ts b/scripts/import-osm.ts index 4c062f9..b2326a4 100644 --- a/scripts/import-osm.ts +++ b/scripts/import-osm.ts @@ -123,7 +123,23 @@ function mapSeatType(tags: Record): string | null { return null } +/** + * 场所类型。先看这个点本身是什么地方(商场、餐饮、车站……), + * 这类标签比"厕所本身的 access 标签"更准——后者是给独立厕所点用的兜底逻辑, + * 商场/酒店这种从 toilets=yes 拿到的点,主标签本来就说明了自己是什么。 + * + * Category 枚举里没有"酒店",酒店暂时落进 other,不为了这一个类型改数据库约束。 + */ function mapCategory(tags: Record): string { + if (tags['shop'] === 'mall' || tags['shop'] === 'department_store') return 'mall' + if (['restaurant', 'cafe', 'fast_food', 'bar', 'pub'].includes(tags['amenity'] ?? '')) { + return 'restaurant' + } + if (tags['railway'] || tags['aeroway'] || tags['amenity'] === 'bus_station') return 'transit' + if (['university', 'college', 'school'].includes(tags['amenity'] ?? '')) return 'campus' + if (tags['leisure'] === 'park') return 'park' + if (tags['office']) return 'office' + const access = tags['access'] if (access === 'customers') return 'restaurant' if (tags['building'] || tags['indoor'] === 'yes') return 'other' @@ -139,6 +155,14 @@ function mapCategory(tags: Record): string { * 这不是 bug,是数据现状。能区分它们的是距离和评价 —— 而评价正是这个产品的护城河。 * 这里只做力所能及的事:有 ref(公厕编号)或 operator 就拼上去,多少能分出个前后。 */ +// toilet_name_proposals 表把名字长度卡在 40 字(含)以内(见 migration)。 +// OSM 上偶尔有人把中英文名挤进同一个 name 标签,一条超长记录不该拖累整批导入。 +const MAX_NAME_LEN = 40 + +function clampName(s: string): string { + return s.length > MAX_NAME_LEN ? `${s.slice(0, MAX_NAME_LEN - 1)}…` : s +} + function buildName( tags: Record, ): { name: string; nameEn: string | null } { @@ -146,7 +170,7 @@ function buildName( const en = tags['name:en']?.trim() if (zh || en) { - return { name: zh || en!, nameEn: en || null } + return { name: clampName(zh || en!), nameEn: en ? clampName(en) : null } } // 没名字:用管理方兜底。ref 多数是十几位的政府编号, @@ -166,11 +190,20 @@ async function fetchOverpass(bbox: Bbox): Promise { const area = `${south},${west},${north},${east}` // way / relation 也要:不少厕所是按面画的,只查 node 会漏掉一大半 + // + // 两路数据源: + // 1. amenity=toilets —— 专门画出来的厕所点 + // 2. toilets=yes —— 打在酒店、商场等地点本身上的"这儿有厕所"标注。 + // 只认这个明确标签,不会因为"这是个商场"就猜它有厕所(CLAUDE.md:不编造未提供的事实)。 + // 国内愿意打这个标签的人不多,能导入的数量本来就有限,属于数据源覆盖问题。 const query = `[out:json][timeout:90]; ( node["amenity"="toilets"](${area}); way["amenity"="toilets"](${area}); relation["amenity"="toilets"](${area}); + node["toilets"="yes"](${area}); + way["toilets"="yes"](${area}); + relation["toilets"="yes"](${area}); ); out center tags;` From bff1ca2deed0786d9541f9e6257da63441c4b81d Mon Sep 17 00:00:00 2001 From: qiuyijoy-lgtm Date: Sun, 16 Aug 2026 15:03:56 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E9=82=AE=E7=AE=B1=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E7=99=BB=E5=BD=95=20+=20=E6=88=91=E7=9A=84=E6=88=98?= =?UTF-8?q?=E7=BB=A9=E7=BB=9F=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 访客模式(匿名身份)完全保留,不登录也能正常浏览、报厕所、写评价。 新增"我的"入口:访客状态下是邮箱验证码登录表单,登录后展示战绩 (AI 生成 / 手写评论分别计数)和退出登录。 登录本身不是新建账号,是把当前匿名身份原地升级(Supabase updateUser({email}) 保留 user.id 不变),历史评价不会丢。同一个 邮箱换设备回来登录时,自动改走"登录已有账号"那条路,用户感知不到 区别,都是"输入邮箱、输入验证码"两步。 验证码走邮箱,不是点链接确认——手机上邮件常在另一个 App/浏览器 打开,点链接容易把登录状态跳丢,输验证码更稳。为此把 Supabase 默认 的"仅链接"邮件模板换成带验证码的版本,中英双语、带产品调性的搞笑 文案,本地和云端各存一份(本地免费版 Dashboard 编辑不了模板,接了 Resend 才解锁)。 本地开发的 enable_confirmations 顺手从 false 改成 true——之前是 为了测试图快跳过确认,现在有真实的验证码流程要测,跳过反而会让这条 路径直到上线才第一次被真实验证到。 Co-Authored-By: Claude Sonnet 5 --- src/api/auth.ts | 92 ++++++++++ src/api/index.ts | 13 +- src/api/reviews.ts | 38 +++++ src/components/AccountSheet.tsx | 242 +++++++++++++++++++++++++++ src/components/TopBar.tsx | 22 ++- src/constants/copy.ts | 41 +++++ supabase/config.toml | 15 +- supabase/templates/change-email.html | 34 ++++ supabase/templates/magic-link.html | 34 ++++ 9 files changed, 527 insertions(+), 4 deletions(-) create mode 100644 src/components/AccountSheet.tsx create mode 100644 supabase/templates/change-email.html create mode 100644 supabase/templates/magic-link.html diff --git a/src/api/auth.ts b/src/api/auth.ts index 882a0ea..98a8e41 100644 --- a/src/api/auth.ts +++ b/src/api/auth.ts @@ -5,8 +5,19 @@ export interface CurrentUser { id: string nickname: string | null isAnonymous: boolean + email: string | null } +/** + * 发验证码这一步分两种情况,但对用户来说是同一个动作(输入邮箱): + * + * - 'upgrade':当前是匿名身份,第一次绑邮箱,把匿名账号原地升级成正式账号, + * user.id 不变,历史评价不丢(CLAUDE.md 5:升级用 linkIdentity 类似的思路)。 + * - 'signin':这个邮箱已经绑过别的账号了 —— 说明是老用户换设备回来找号, + * 改成登录已有账号,这次登录后会换成那个账号的 session。 + */ +export type EmailLoginMode = 'upgrade' | 'signin' + /** * 正在进行中的登录请求。 * @@ -89,6 +100,85 @@ export async function setNickname(nickname: string): Promise } } +/** + * 发验证码。先假设是"给匿名账号首次绑邮箱";如果这个邮箱已经被注册过 + * (error.code === 'email_exists'),说明猜错了,改走"登录已有账号"那条路重发一次。 + * 两条路用的是不同的验证码类型,所以要把选中的模式带回去,验证时才知道该传哪个 type。 + */ +export async function startEmailLogin(email: string): Promise> { + if (!isSupabaseConfigured) { + return fail('not_configured', 'Supabase 未配置 / Supabase is not configured') + } + + const trimmed = email.trim() + if (!trimmed) return fail('invalid_input', '请输入邮箱 / enter an email') + + try { + const { data: session } = await supabase.auth.getSession() + const isAnon = session.session?.user?.is_anonymous ?? true + + if (isAnon) { + const { error } = await supabase.auth.updateUser({ email: trimmed }) + if (!error) return ok('upgrade') + if (error.code !== 'email_exists') return fail(error.code ?? 'auth_error', error.message) + // 邮箱已经被注册过,往下走登录已有账号那条路 + } + + const { error } = await supabase.auth.signInWithOtp({ + email: trimmed, + options: { shouldCreateUser: false }, + }) + if (error) return fail(error.code ?? 'auth_error', error.message) + return ok('signin') + } catch (e) { + return fromThrown(e, 'auth_error') + } +} + +/** 校验用户从邮件里抄回来的 6 位验证码,完成升级或登录。 */ +export async function verifyEmailLogin( + email: string, + token: string, + mode: EmailLoginMode, +): Promise> { + if (!isSupabaseConfigured) { + return fail('not_configured', 'Supabase 未配置 / Supabase is not configured') + } + + const trimmedToken = token.trim() + if (!trimmedToken) return fail('invalid_input', '请输入验证码 / enter the code') + + try { + const { data, error } = await supabase.auth.verifyOtp({ + email: email.trim(), + token: trimmedToken, + type: mode === 'upgrade' ? 'email_change' : 'email', + }) + if (error) return fail(error.code ?? 'auth_error', error.message) + if (!data.user) return fail('auth_error', '验证失败 / verification failed') + return ok(toCurrentUser(data.user)) + } catch (e) { + return fromThrown(e, 'auth_error') + } +} + +/** + * 退出正式账号,换回一个新的匿名身份 —— 不是"变成没有身份", + * 这个产品从打开就得有身份(发评价要挂 user_id),退出登录只是换一个。 + */ +export async function signOut(): Promise> { + if (!isSupabaseConfigured) { + return fail('not_configured', 'Supabase 未配置 / Supabase is not configured') + } + try { + const { error } = await supabase.auth.signOut() + if (error) return fail(error.code ?? 'auth_error', error.message) + return signIn() + } catch (e) { + return fromThrown(e, 'auth_error') + } +} + /** 拿当前 access token,给 Edge Function 调用用。 */ export async function getAccessToken(): Promise { if (!isSupabaseConfigured) return null @@ -102,6 +192,7 @@ export async function getAccessToken(): Promise { interface AuthUserLike { id: string + email?: string is_anonymous?: boolean user_metadata?: Record } @@ -112,5 +203,6 @@ function toCurrentUser(user: AuthUserLike): CurrentUser { id: user.id, nickname: typeof raw === 'string' && raw ? raw : null, isAnonymous: user.is_anonymous ?? true, + email: user.email || null, } } diff --git a/src/api/index.ts b/src/api/index.ts index 6d99baf..d58c3b9 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,7 +1,16 @@ // UI 层的唯一数据入口。组件只从这里 import,绝不碰 supabase 客户端本身。 export * from './types' export { isSupabaseConfigured } from './client' -export { ensureSession, getCurrentUser, setNickname, type CurrentUser } from './auth' +export { + ensureSession, + getCurrentUser, + setNickname, + startEmailLogin, + verifyEmailLogin, + signOut, + type CurrentUser, + type EmailLoginMode, +} from './auth' export { getNearbyToilets, getToiletById, @@ -19,9 +28,11 @@ export { generateReview, createReview, deleteReview, + getMyReviewStats, type GenerateInput, type GeneratedReview, type CreateReviewInput, + type ReviewStats, } from './reviews' export { reportReview } from './reports' export { searchAddress, type GeocodeResult } from './geocode' diff --git a/src/api/reviews.ts b/src/api/reviews.ts index e2f4cf7..d7274e1 100644 --- a/src/api/reviews.ts +++ b/src/api/reviews.ts @@ -325,3 +325,41 @@ export async function deleteReview(id: string): Promise> { return fromThrown(e, 'db_error') } } + +export interface ReviewStats { + aiCount: number + manualCount: number +} + +/** + * 我的战绩:AI 生成 / 手写评论各写了多少条。 + * 两条 count 查询并发,head:true 只要总数不要行数据,比拉全部再数快得多。 + */ +export async function getMyReviewStats(): Promise> { + if (!isSupabaseConfigured) return ok({ aiCount: 0, manualCount: 0 }) + + const session = await ensureSession() + if (session.error) return { data: null, error: session.error } + + try { + const [ai, manual] = await Promise.all([ + supabase + .from('reviews') + .select('id', { count: 'exact', head: true }) + .eq('user_id', session.data.id) + .eq('is_ai', true), + supabase + .from('reviews') + .select('id', { count: 'exact', head: true }) + .eq('user_id', session.data.id) + .eq('is_ai', false), + ]) + + if (ai.error) return fail(ai.error.code || 'db_error', ai.error.message) + if (manual.error) return fail(manual.error.code || 'db_error', manual.error.message) + + return ok({ aiCount: ai.count ?? 0, manualCount: manual.count ?? 0 }) + } catch (e) { + return fromThrown(e, 'db_error') + } +} diff --git a/src/components/AccountSheet.tsx b/src/components/AccountSheet.tsx new file mode 100644 index 0000000..78e40a3 --- /dev/null +++ b/src/components/AccountSheet.tsx @@ -0,0 +1,242 @@ +import { useEffect, useState } from 'react' +import { createPortal } from 'react-dom' +import { Loader2, LogOut, X } from 'lucide-react' + +import { + getCurrentUser, + getMyReviewStats, + signOut, + startEmailLogin, + verifyEmailLogin, + type CurrentUser, + type EmailLoginMode, + type ReviewStats, +} from '@/api' +import { useI18n } from '@/i18n/useI18n' + +interface AccountSheetProps { + onClose: () => void +} + +/** + * "我的" 面板:访客模式下是邮箱验证码登录表单,登录后是战绩 + 退出登录。 + * + * 登录本身分两步(发验证码 → 输验证码),但对用户来说只是"输入邮箱、输入验证码" + * 两个框,是升级匿名账号还是登录老账号,由 startEmailLogin 内部判断,这里不用关心。 + */ +export default function AccountSheet({ onClose }: AccountSheetProps) { + const { t } = useI18n() + + const [user, setUser] = useState(null) + const [stats, setStats] = useState(null) + const [loadingUser, setLoadingUser] = useState(true) + + const [email, setEmail] = useState('') + const [code, setCode] = useState('') + const [mode, setMode] = useState(null) + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + + void getCurrentUser().then((res) => { + if (cancelled) return + setUser(res.data ?? null) + setLoadingUser(false) + if (res.data && !res.data.isAnonymous) { + void getMyReviewStats().then((s) => { + if (!cancelled) setStats(s.data) + }) + } + }) + + return () => { + cancelled = true + } + }, []) + + async function handleSendCode() { + setBusy(true) + setError(null) + const res = await startEmailLogin(email) + setBusy(false) + + if (res.error) { + setError(t.accountSendFailed) + return + } + setMode(res.data) + } + + async function handleVerify() { + if (!mode) return + setBusy(true) + setError(null) + const res = await verifyEmailLogin(email, code, mode) + setBusy(false) + + if (res.error) { + setError(t.accountVerifyFailed) + return + } + setUser(res.data) + const s = await getMyReviewStats() + setStats(s.data) + } + + async function handleSignOut() { + setBusy(true) + const res = await signOut() + setBusy(false) + if (res.data) { + setUser(res.data) + setStats(null) + setEmail('') + setCode('') + setMode(null) + } + } + + // TopBar 自己有 z-index,会给子元素罩一层"层叠上下文"天花板—— + // 弹窗哪怕自己 z-40 也冲不出去,会被下面地图面板盖住。传送到 body 上就没有这个限制了。 + return createPortal( +
+
+
+

{t.account}

+ +
+ +
+ {loadingUser ? ( +
+ +
+ ) : user && !user.isAnonymous ? ( + <> +

{user.email}

+ +
+

{t.accountStats}

+ {stats ? ( +
+
+

{stats.aiCount}

+

+ {t.accountStatsAi} + {t.accountStatsUnit} +

+
+
+

{stats.manualCount}

+

+ {t.accountStatsManual} + {t.accountStatsUnit} +

+
+
+ ) : ( + + )} +
+ + + + ) : ( + <> +
+

{t.accountGuestMode}

+

{t.accountGuestHint}

+
+ +
+ setEmail(e.target.value)} + disabled={mode !== null} + placeholder={t.accountEmailPlaceholder} + className="w-full rounded-xl border border-poo-200 px-3 py-2 text-sm outline-none focus:border-poo-500 disabled:bg-poo-50 disabled:text-ink-soft" + /> +
+ + {mode === null ? ( + + ) : ( + <> +

{t.accountCodeSent}

+ setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder={t.accountCodePlaceholder} + className="w-full rounded-xl border border-poo-200 px-3 py-2 text-center text-lg tracking-[0.3em] outline-none focus:border-poo-500" + /> + + + + )} + + {error && ( +

{error}

+ )} + + )} +
+
+
, + document.body, + ) +} diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 1fd083a..6a7cff6 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,5 +1,7 @@ -import { Globe, Languages } from 'lucide-react' +import { useState } from 'react' +import { CircleUserRound, Globe, Languages } from 'lucide-react' +import AccountSheet from '@/components/AccountSheet' import { useI18n } from '@/i18n/useI18n' // 同样绕开桶文件,别把地图库拉进首屏(见 src/map/index.ts) import { setRegion, type Region } from '@/map/region' @@ -10,13 +12,14 @@ interface TopBarProps { } /** - * 语言和地图版本的切换入口。 + * 语言、地图版本、账号的入口。 * * 地图版本放在最外层是刻意的:在北京上海实测时,第一件要确认的事就是 * 「现在用的是不是高德底图、点位有没有偏」,切换必须一步到位,不能埋进二级菜单。 */ export default function TopBar({ region, onRegionChange }: TopBarProps) { const { t, locale, toggleLocale } = useI18n() + const [accountOpen, setAccountOpen] = useState(false) function switchRegion(next: Region) { if (next === region) return @@ -75,8 +78,23 @@ export default function TopBar({ region, onRegionChange }: TopBarProps) { {locale === 'zh' ? 'EN' : '中'} + + + + {accountOpen && ( +
+ setAccountOpen(false)} /> +
+ )} ) } diff --git a/src/constants/copy.ts b/src/constants/copy.ts index e4f5b18..5cbf834 100644 --- a/src/constants/copy.ts +++ b/src/constants/copy.ts @@ -178,6 +178,27 @@ const zh = { nicknamePlaceholder: '随便起一个', saveNickname: '存好了', + // —— 账号 / 登录 + account: '我的', + accountGuestMode: '访客模式', + accountGuestHint: '登录后能保存战绩,换设备也找得回来', + accountLogin: '登录', + accountEmailPlaceholder: '你的邮箱', + accountSendCode: '发验证码', + accountSending: '发送中…', + accountCodeSent: '验证码发到邮箱了,10 分钟内有效', + accountCodePlaceholder: '6 位验证码', + accountVerify: '确认', + accountVerifying: '验证中…', + accountResendCode: '没收到?重发', + accountSendFailed: '没发出去,检查一下邮箱地址', + accountVerifyFailed: '验证码不对,再试一次', + accountSignOut: '退出登录', + accountStats: '我的战绩', + accountStatsAi: 'AI 生成', + accountStatsManual: '手写', + accountStatsUnit: '条', + // —— 通用 back: '返回', close: '关闭', @@ -368,6 +389,26 @@ const en: Dict = { nicknamePlaceholder: 'Anything works', saveNickname: 'Saved', + account: 'Me', + accountGuestMode: 'Guest mode', + accountGuestHint: "Log in to save your stats — they'll follow you to any device", + accountLogin: 'Log in', + accountEmailPlaceholder: 'Your email', + accountSendCode: 'Send code', + accountSending: 'Sending…', + accountCodeSent: 'Code sent to your email, valid for 10 minutes', + accountCodePlaceholder: '6-digit code', + accountVerify: 'Confirm', + accountVerifying: 'Verifying…', + accountResendCode: "Didn't get it? Resend", + accountSendFailed: "Couldn't send it, double-check the email", + accountVerifyFailed: 'Wrong code, try again', + accountSignOut: 'Sign out', + accountStats: 'My stats', + accountStatsAi: 'AI-generated', + accountStatsManual: 'Hand-written', + accountStatsUnit: '', + back: 'Back', close: 'Close', cancel: 'Cancel', diff --git a/supabase/config.toml b/supabase/config.toml index 011f9c8..1d3912b 100644 --- a/supabase/config.toml +++ b/supabase/config.toml @@ -36,10 +36,23 @@ enable_anonymous_sign_ins = true [auth.email] enable_signup = true -enable_confirmations = false +# 之前是 false(本地测试图快,直接跳过确认)。加了邮箱验证码登录之后改成 true, +# 让本地也走一遍真实的"发验证码 → 用户输入确认"流程,跟云端行为对齐,不然验证码 +# 这一步只有上线后才第一次被真实测到。 +enable_confirmations = true # 不接 Google / Apple 登录 —— 国内直接死(CLAUDE.md 8.1) +# 中英双语、带"厕评"调性的验证码文案,源文件在 supabase/templates/。 +# 云端 Dashboard 那边的模板要手动同步一份,Supabase 目前没有本地/云端模板互通的机制。 +[auth.email.template.email_change] +subject = "厕评 · 你的验证码来啦 💩 / Your verification code" +content_path = "./supabase/templates/change-email.html" + +[auth.email.template.magic_link] +subject = "厕评 · 欢迎回来 🚽 / Welcome back" +content_path = "./supabase/templates/magic-link.html" + [analytics] enabled = false diff --git a/supabase/templates/change-email.html b/supabase/templates/change-email.html new file mode 100644 index 0000000..5d11e57 --- /dev/null +++ b/supabase/templates/change-email.html @@ -0,0 +1,34 @@ +
+

💩

+ +

你的验证码来啦

+

+ 关键时刻验证码可不能掉链子——跟找厕所一个道理,越急越不能等。 +

+

+ {{ .Token }} +

+

+ 10 分钟内把这串数字填进 App,你的战绩就正式存进账号里了,换手机也找得回来。 + 嫌打字麻烦也行,直接点这个链接:确认邮箱 +

+ +
+ +

Your verification code

+

+ Don't let this code go missing — like finding a bathroom when you really need one, timing is everything. +

+

+ {{ .Token }} +

+

+ Enter it in the app within 10 minutes to lock in your stats for good — they'll follow you to any device. + Or just click: Confirm email +

+ +

+ 厕评 pooping · 不是你本人操作的话,忽略这封邮件就行
+ Didn't request this? You can safely ignore this email. +

+
diff --git a/supabase/templates/magic-link.html b/supabase/templates/magic-link.html new file mode 100644 index 0000000..2c0f1c1 --- /dev/null +++ b/supabase/templates/magic-link.html @@ -0,0 +1,34 @@ +
+

🚽

+ +

欢迎回来

+

+ 你的马桶战绩一直在等你回来点名,验证码已经送到: +

+

+ {{ .Token }} +

+

+ 10 分钟内把这串数字填进 App 就能登录。 + 也可以直接点这个链接:登录 +

+ +
+ +

Welcome back

+

+ Your bathroom stats have been waiting for you. Here's your code: +

+

+ {{ .Token }} +

+

+ Enter it in the app within 10 minutes to sign in. + Or just click: Sign in +

+ +

+ 厕评 pooping · 不是你本人操作的话,忽略这封邮件就行
+ Didn't request this? You can safely ignore this email. +

+