{“content”:”---\nname: quotation-saas-debug\ndescription: Quotation SaaS (quotation.hsdesign.biz) 调试 — D1数据库、Worker路由、登录流程\n---\n\n# Quotation SaaS Debug\n\n## 重要:D1 数据库分离\nquotation.hsdesign.biz 使用的是 quotation-saas D1,不是 hs-design-quotes!\n\n| 数据库 | ID | 用途 |\n|--------|----|------|\n| quotation-saas | f6b7326e471bbe3d1b0a0e2ba770f47d | quotation.hsdesign.biz worker |\n| hs-design-quotes | 8d776216-e135-4c9f-b1bb-9669cb10bd85 | hsdesign-d1-api.ida-czia.workers.dev |\n\n⚠️ 2026-05-04: D1 ID 说明 — Worker 源码中使用的是 b1381cb9-de0b-4f2b-8122-59a71d05d0ca,这是 quotation-saas D1 的正确 ID。Quota API 和 accounts 表都在这个 DB 里。\n\n## Cloudflare API 查询 D1(Python)\npython\nimport urllib.request, json\n# CF credentials: [email protected], ACCOUNT_ID: f6b7326e471bbe3d1b0a0e2ba770f47d\nCF_API_KEY = \"<from memory: cloudflare global api key>\"\nCF_EMAIL = \"[email protected]\"\nACCOUNT_ID = \"f6b7326e471bbe3d1b0a0e2ba770f47d\"\nDB_ID = \"b1381cb9-de0b-4f2b-8122-59a71d05d0ca\" # quotation-saas\n\ndef d1_query(sql):\n url = f\"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/d1/database/{DB_ID}/query\"\n data = json.dumps({\"sql\": sql}).encode('utf-8')\n req = urllib.request.Request(url, data=data, headers={\n \"X-Auth-Email\": CF_EMAIL, \"X-Auth-Key\": CF_API_KEY, \"Content-Type\": \"application/json\"\n })\n return json.loads(urllib.request.urlopen(req).read())\n\n# 列出所有 D1 数据库\nurl = f\"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/d1/database\"\nreq = urllib.request.Request(url, headers={\"X-Auth-Email\": CF_EMAIL, \"X-Auth-Key\": CF_API_KEY})\ndbs = json.loads(urllib.request.urlopen(req).read())\nfor db in dbs['result']:\n print(db['name'], db['uuid'])\n\n\n## quotation-saas 表结构\n- accounts: id, email, password_hash, name, company, logo_url, color_hex, login_method, email_verified, plan, referral_code, referred_by, referral_adfree_months, session_token, created_at\n- quotations: id, account_id, qno, name, proj, addr, date, status, data, total, created_at, updated_at\n- subscriptions: id, account_id, plan, status, started_at, ends_at, created_at\n- referral_credits: id, account_id, amount, used, created_at\n- sessions: id, account_id, token_hash, expires_at, created_at\n\n## API 路由\n- POST /auth/register — 注册\n- POST /auth/login — 登录\n- GET /auth/me — 当前用户信息\n- POST /auth/logout — 登出\n- GET /auth/google — Google OAuth 跳转\n- GET /auth/callback — Google OAuth 回调\n- GET /api/account — ✅ 已验证正常(2026-05-04)\n- GET /api/quotations — 报价列表(正常)\n- GET /api/quota — 404\n- GET /dashboard — 仪表板\n\n## 当前已知问题(已修复)\n- ✅ /api/account 返回 500 — 结论:不是代码 bug,是测试 token 无效导致。代码逻辑正常,requireAuth 返回 { account }account.sub 可以正确访问。2026-05-04 用新注册账号的 token 实测全部端点返回 200。\n- /api/quota 返回 404 — ✅ 已实现(2026-05-04)\n\n## 常见 Bug\n\n### Bug 15: New Quotation → Create 后静默失败(2026-05-04)\n症状:New Quotation Modal 点 Create 按钮后 loading 状态出现但创建从未完成,无错误提示。\n根因onClick={() => { setIsCreating(true); }}onCreate() 回调从未被调用,漏掉了 onCreate();。\n修复:改为 onClick={() => { setIsCreating(true); onCreate(); }}\n位置:QuotaModal.tsx 的 Create 按钮\n\n### Bug 1: 登录后空白页\n症状:Google OAuth 登录成功后跳转 //dashboard 显示空白,刷新后正常。\n\n排查结果: 代码逻辑完全正常,requireAuth 返回 { account: payload }account.sub 引用正确。问题根源是测试 token 无效(execute_code 输出被截断导致复制的 token 不完整)。\n\n验证方法: 注册新账号后立即用返回的 token 测试(不经过中间复制步骤):\npython\nimport urllib.request, json\n# 注册并立即使用 token(避免复制截断)\nurl = \"https://quotation.hsdesign.biz/auth/register\"\ndata = json.dumps({\"email\": \"[email protected]\", \"password\": \"test123456\"}).encode()\nreq = urllib.request.Request(url, data=data, headers={\"Content-Type\": \"application/json\", \"User-Agent\": \"Mozilla/5.0\"})\nresp = urllib.request.urlopen(req, timeout=15)\nresult = json.loads(resp.read().decode())\ntoken = result[\"token\"] # 不经过输出复制,直接用\n\n# 测试 /api/account\nreq2 = urllib.request.Request(\"https://quotation.hsdesign.biz/api/account\", headers={\n \"Authorization\": f\"Bearer {token}\", \"User-Agent\": \"Mozilla/5.0\"\n})\nresp2 = urllib.request.urlopen(req2, timeout=15)\nprint(resp2.read().decode()) # ✅ HTTP 200\n\n\n经验: execute_code 输出 token 时会被截断显示(***),不要手动复制输出中的 token — 直接在代码里传递。\n\n### Bug 2: 注册后跳转 dashboard 但无 token\n症狀: 註冊成功後跳轉到 /dashboard,但 dashboard 顯示未登入\n\n原因: 註冊表單提交成功後,只做了 window.location.href = '/dashboard',但沒有保存 token 到 localStorage\n\n修復:\njavascript\n// 錯誤:\n} else {\n window.location.href = '/dashboard';\n}\n\n// 正確:\n} else {\n localStorage.setItem('token', data.token);\n window.location.href = '/dashboard';\n}\n\n\n### Bug 3: /ads.js 1101 - Worker 环境使用 DOM API\n症狀: GET /ads.js 返回 1101 Worker exception\n\n原因: 代码在 Worker 服务器端执行了 document.createElement('script') 等 DOM API。Cloudflare Worker 是 V8 沙盒环境,没有 DOM。\n\n修復: 改为返回 JS 字符串给浏览器执行,而不是在服务器端创建 DOM 元素:\njavascript\n// 錯誤(在 Worker 服务器端):\nconst script = document.createElement('script');\nscript.src = 'https://pagead2.googles...';\ndocument.head.appendChild(script);\n\n// 正確(返回 JS 字符串):\nconst js = `\n(function() {\n const script = document.createElement('script');\n script.src = 'https://pagead2.googles...';\n document.head.appendChild(script);\n})();\n`;\nreturn new Response(js, { headers: { 'Content-Type': 'application/javascript' } });\n\n\n### Bug 4: /pricing 1101 - 顶层函数无法在路由 handler 内访问\n\n症狀: GET /pricing 返回 1101 Worker exception,但其他路由正常\n\n原因: escHtml() 定义在 handleRequest 外部(顶层,0空格缩进),在 Cloudflare Worker V8 沙盒中路由 handler 无法访问外层作用域的函数。\n\n文件结构示意:\n\naddEventListener('fetch', event => { // line 1\n event.respondWith(handleRequest(...)); // line 2\n}); // line 3\n\nasync function handleRequest(request, env) { // line 5 (2空格缩进)\n // ... cfDbQuery 定义在这里 (2空格) ✅ 可访问\n // ... 其他路由 (4空格) ✅ 可访问\n\n if (path === '/pricing') { // 4空格\n // ❌ escHtml() 在这里 1101 — escHtml 定义在外部(0空格)\n const html = `<li>${escHtml(t.name)}</li>`; // 1101!\n }\n\n return makeResponse({ error: 'Not found' }, 404); // line ~1585 (2空格)\n} // handleRequest 结束\n\n// escHtml 在这里 (0空格缩进) — 文件顶层,handleRequest 外部\nfunction escHtml(s) { ... } // ❌ 无法从 handleRequest 内访问!\n\n\n为什么 cfDbQuery 能用但 escHtml 不能?\n- cfDbQuery 定义在 handleRequest 内部(2空格缩进)→ 在 handler 里可用\n- escHtml 定义在 handleRequest 外部(0空格缩进)→ V8 沙盒隔离,handler 里访问不到\n\n修复: 将辅助函数定义在路由 handler 内部(作为局部函数):\njavascript\n// ✅ 正確(定義在路由 handler 內部):\nif (path === '/pricing') {\n function escHtml(s) { return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }\n // ✅ escHtml 现在可用\n const html = tiers.map(t => `<li>${escHtml(t.name)}</li>`).join('');\n}\n\n\n经验: Cloudflare Worker 沙盒化程度高,所有工具函数必须内联在各自的路由 handler 内,不能放在 handleRequest 外层或文件顶层。\n\n## 部署修复流程\n\n1. 下载部署的 worker 代码:\npython\nimport urllib.request\nurl = f\"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/workers/scripts/quotation-saas\"\nreq = urllib.request.Request(url, headers={\"X-Auth-Email\": CF_EMAIL, \"X-Auth-Key\": CF_API_KEY})\ndeployed_code = urllib.request.urlopen(req).read().decode('utf-8')\n\n\n2. 比较本地和部署代码:\npython\nimport difflib\ndiff = list(difflib.unified_diff(local_lines, deployed_lines, n=0))\n# 只顯示 5 行差異 = 關鍵 bug\n\n\n3. 修复后重新部署 (PUT):\npython\nimport urllib.request\nurl = f\"https://api.cloudflare.com/client/v4/accounts/{ACCOUNT_ID}/workers/scripts/quotation-saas\"\nreq = urllib.request.Request(url, data=fixed_code.encode('utf-8'), headers={\n \"X-Auth-Email\": CF_EMAIL, \"X-Auth-Key\": CF_API_KEY,\n \"Content-Type\": \"application/javascript\",\n}, method=\"PUT\")\nresp = urllib.request.urlopen(req, timeout=30)\n\n\n## Worker Deployment(重要:认证格式不同)\n\nWorkers Script API(PUT 上传)用 X-Auth-Email + X-Auth-Key,不能用 Bearer:\nbash\ncurl -X PUT \"https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/workers/scripts/quotation-saas\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: ${CF_KEY}\" \\\n -H \"Content-Type: application/javascript\" \\\n --data-binary @/data/data/com.termux/files/home/quotation-saas/quotation-worker.js\n\n\n⚠️ D1 Query API 用 Authorization: Bearer,Workers Script upload 用 X-Auth-Email + X-Auth-Key,两者认证方式不同。\n\n## Debug 方法\n1. 用 execute_code + urllib 直接調 Cloudflare API 查 D1\n2. 下載部署的 worker 代碼比較(見上方流程)\n3. 重要: 調用 Cloudflare API 必須加 User-Agent 否則返回 1010 bot protection:\n \"User-Agent\": \"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0 Safari/537.36\"\n\n## API 路由(2026-05-04 全量验证通过)\n| 端點 | 方法 | 狀態 | 說明 |\n|------|------|------|------|\n| /auth/register | POST | ✅ | 返回 JWT token |\n| /auth/login | POST | ✅ | 返回 JWT token |\n| /auth/me | GET | ✅ | 返回完整 account 信息 |\n| /auth/logout | POST | ✅ | 登出 |\n| /auth/google | GET | ✅ | Google OAuth 跳转 |\n| /auth/callback | GET | ✅ | OAuth 回调(Bug 13 修复后正常) |\n| /api/account | GET | ✅ | 返回用户信息(代码正常,token 问题导致误报) |\n| /api/account | PATCH | ✅ | 更新用户资料 |\n| /api/quotations | GET | ✅ | 报价列表 |\n| /api/quotations | POST | ✅ | 创建报价 |\n| /api/quota | GET | ✅ | 用户配额 |\n| /api/health | GET | ✅ | 健康检查 |\n| /ads.js | GET | ✅ | 广告脚本 |\n| /login | GET | ✅ | 登录页面 |\n| /register | GET | ✅ | 注册页面 |\n| /pricing | GET | ✅ | 订阅页面 |\n| /dashboard | GET | ✅ | 仪表板页面(fetch SaaS Frontend) |\n\n## Bug 5: login 页面 inline script 缺少 闭合\n症状: /login 页面表单提交变成 GET 请求(URL 显示 query params),addEventListener 未生效\n\n原因: _worker.js 的 login HTML 里 inline <script> 没有 </script> 闭合标签。整个 form handler 代码变成纯文本,<form onsubmit=\"handleLogin(event)\"> 调用时函数不存在。\n\n检测方法:\npython\nhtml.count('</script>') # 应 ≥ inline script 数量\n# 正确:每個 inline <script>...</script> 都應有閉合\n\n\n修复: 在 inline script 末尾(}); 后)添加 \\n</script>\\n:\njavascript\n// 錯誤:\n...});\n<script src=\"/ads.js\">\n\n// 正確:\n...});\n</script>\n<script src=\"/ads.js\">\n\n\n## Bug 6: loadQuotes() await r.json() 在 try 外\n症状: API 返回 HTML(如 401)时页面报错 “Unexpected token ’<’“\n\n原因:\njavascript\n// 錯誤:r.json() 在 try 外面,throw 不被捕获\ntry {\n const res = await fetch(API_BASE + '/api/quotations', options);\n const data = await res.json(); // ← 如果 body 是 HTML,这里 throw\n if (!res.ok) ...\n} catch(e) { ... }\n\n\n修复:\njavascript\n// 正確:整段在 try 內\ntry {\n const res = await fetch(API_BASE + '/api/quotations', options);\n const data = await res.json(); // ← throw 被捕获\n if (!res.ok) { ... }\n quotes = (data.quotations || []).map(parseSaaS);\n} catch(e) {\n setSS('Error loading: ' + e.message);\n quotes = [];\n}\n\n\n## Bug 7: saveQuote 重复声明 data\n症状: “Identifier ‘data’ has already been declared”\n\n原因: 修复时同时用了 let data = {}data = await res.json(),同一作用域重复声明。\n\n修复: 用 let 声明或统一用 const + 解构。\n\n## Bug 8: Cloudflare Worker 版本不同步\n症状: 修改本地 worker 后部署,但 Cloudflare 返回的还是旧版本\n\n原因: 本地文件(1535行)和 Cloudflare 线上版本(1629行)完全不同步。Cloudflare 的版本是 Service Worker 格式(addEventListener),本地可能是旧版。\n\n每次修改前先下载线上版本:\nbash\ncurl -s \"https://api.cloudflare.com/client/v4/accounts/f6b7326e471bbe3d1b0a0e2ba770f47d/workers/scripts/quotation-saas\" \\\n -H \"X-Auth-Email: [email protected]\" \\\n -H \"X-Auth-Key: cfk_r8ECCUq8K0nZjyuJGkbWEelbYX4r3NxyBnuqK5zj38f26a11\" | \\\n python3 -c \"import sys,json; print(json.load(sys.stdin)['result']['script'])\" > worker-cloudflare.js\n\n\n### Bug 9: /dashboard fetch 旧 HTML(Staff 版而非 SaaS 版)\n症状: /dashboard 显示 Staff Portal 版本(标题 “HS Design — Staff Quotation Tool”,有 #gate 密码锁),不是真正的 SaaS 前端\n\n原因: Worker /dashboard 路由错误地 fetch 了 https://hsdesign-7ni.pages.dev/HS_Design_Quotation(Staff 版),而不是 https://quotation-saas-frontend.pages.dev/(SaaS 版)\n\n诊断:\npython\n# 检查 dashboard 返回的 HTML\nimport urllib.request\nhtml = urllib.request.urlopen(\"https://quotation.hsdesign.biz/dashboard\").read().decode()\nprint(\"Staff版:\", \"Staff Quotation Tool\" in html) # True = 错误\nprint(\"SaaS版:\", \"Quotation SaaS\" in html) # True = 正确\n\n\n修复 (2026-05-04 最终版):\njavascript\n// ✅ SPA 直接 embed 在 Worker 里,不再外部 fetch\nif (path === '/dashboard') {\n return Response.redirect('https://quotation.hsdesign.biz/app', 302);\n}\n\n// ✅ /app 路由 serve embed 的 SPA HTML\nif (path === '/app') {\n let html = SPA_HTML;\n if (token) {\n // Inject token injection script before IIFE\n const inject = `<script>if(window.location.search.includes('token=')){const p=new URLSearchParams(window.location.search);const t=p.get('token');if(t&&t.length>20){localStorage.setItem('qs_token',t);history.replaceState(null,'','/');}}</script>`;\n const si = html.indexOf('</script>', html.indexOf('<script>'));\n html = html.slice(0, si+9) + inject + html.slice(si+9);\n }\n return new Response(html, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });\n}\n\n\n关键区别: Staff 版需要外部 quote-app.js(有 #gate 密码锁),SaaS 版是自包含 SPA(所有 JS 内联,使用 localStorage.getItem('qs_token') 作为 token key)。\n\n### Bug 10: Worker HTML 的 </script> 导致 JS handler 失效\n症状: /login/register 表单提交变成 GET(URL 带 query params),handleLogin/handleRegister 未执行\n原因: Worker 模板字符串里的 </script> 被 HTML parser 识别为闭合标签,inline script 实际从未执行\n验证: 浏览器 DevTools → Network → 提交表单后 URL 变成 ?email=...&password=...\n修复: 把 /login/register 路由改为 fetch SaaS Frontend URL(见下方修复代码)\n已修复: 2026-05-04\n\n### Bug 11: SaaS Frontend 是 SPA — URL 不变,dashboard 通过 card toggle 显示\n发现: 注册/登录成功后 URL 仍停留在 /register,但 dashboardCardhidden class 被移除(仪表盘实际显示了)\n原因: SaaS Frontend 是纯 SPA,showDashboard()showAuth() 只切换 DOM 元素可见性,不改变 URL\n影响: Playwright 测试不能依赖 page.url 判断是否到达 dashboard,要检查 DOM 状态:\npython\n# ❌ 错误判断\nif 'dashboard' in page.url: # URL 从不变\n\n# ✅ 正确判断\ndash_visible = await page.evaluate(\n \"!document.getElementById('dashboardCard').classList.contains('hidden')\"\n)\n\nSPA 路由行为:\n- /registershowAuth() → 显示 #authCard(hidden=false)\n- 注册成功 → setToken() + loadDashboard()#authCard hidden=true#dashboardCard hidden=false\n- URL 从不变,仍是 /register\n\n### Bug 12: /api/quotations 响应格式与前端期望不匹配\n症状: 前端 dashboard 显示 “Loading…” 不消失,或 total 显示 NaN\n原因:\n- GET /api/quotations 返回 {\"quotations\":[...]} 没有 total 字段,但前端期望 data.total\n- POST /api/quotations 返回 {\"quotation\":{...}},但前端检查 d.id\n状态: ⚠️ 待验证 — 2026-05-04 后端 API 确认返回格式正确,需验证前端 JS 是否正确解析\n\n---\n\n### Bug 13: Google OAuth localStorage key 不匹配(永久回到登录页)\n症状: Email/password 登录正常。Google 登录跳转到 callback 后,永远回到登录画面。\n\n根因: Worker callback HTML 里设置了 localStorage.setItem('token', jwt),但前端 SPA 的 getToken() 读取的是 localStorage.getItem('qs_token')。Key 不一致导致前端永远读不到 token。\n\n注意 (2026-05-05 更新): 嵌入的报价工具(HS_Design_Quotation.html)实际使用 localStorage.getItem('token')(不是 qs_token)作为 auth token key。Gate UI 的 checkAuth() 函数定义在 quote-app.js 里:\njavascript\nfunction checkAuth() {\n const token = localStorage.getItem('token'); // 不是 qs_token!\n return token || null;\n}\n\n\n修复 (2026-05-04,line ~430):\njavascript\n// 修复前(错误)\nlocalStorage.setItem('token', '${jwt}');\nwindow.location.href = '${dashboardUrl}';\n\n// 修复后(正确)\nlocalStorage.setItem('qs_token', '${jwt}');\nwindow.location.href = '${dashboardUrl}?token=${jwt}';\n\n\n部署后需清除浏览器 localStorage 后重试 Google 登录。\n\n### Bug 20: Google OAuth callback 路径不匹配 — /auth/callback vs /app(2026-05-05)\n症状: Google 登录后跳转回 quotation.hsdesign.biz,始终显示登录页,token 未被保存。\n\n根因: Worker OAuth callback(line ~832)redirect 到 /app?token=${jwt}:\njavascript\nreturn Response.redirect(`/app?token=${jwt}`, 302);\n\n但前端 SPA 的 callback handler 只检查 /auth/callback 路径:\njavascript\nif (url.pathname === '/auth/callback') { // ← 永远不会匹配 /app\n const token = url.searchParams.get('token');\n if (token) { setToken(token); ... }\n}\n\n路径不匹配 → token 从未被提取 → 登录失败。\n\n修复: 前端 callback handler 同时处理 /auth/callback/app:\njavascript\nif (url.pathname === '/auth/callback' || url.pathname === '/app') {\n const token = url.searchParams.get('token') || url.searchParams.get('session_token');\n if (token) {\n setToken(token);\n window.location.href = '/';\n return;\n }\n}\n\n\n验证: 修复后,用假 token 直接测试:\npython\n# 在 Playwright 里 inject 假 token 后 navigate 到 /app?token=xxx\nawait page.goto('https://quotation.hsdesign.biz/app?token=fake_token_123')\n# 应该看到 token 被保存 + redirect 到 /\n\n\n### Bug 17: 嵌入报价工具 Gate UI 需要替换(Staff Portal 密码锁残留)\n症状: Google 登录成功后访问 /dashboard,仍显示 “HS Design Staff Portal” 密码输入框,要求输入 “HSHSHS”。\n\n根因: worker_full.js/dashboard 路由里嵌入的 HS_Design_Quotation.html 包含旧的 gate div(密码输入框),这个 gate div 直接显示在 DOM 里,没有自动跳转到 /login。\n\n修复 (2026-05-05): 替换 gate div 内容 + 添加 auto-redirect script:\njavascript\n// 替换前的旧 gate div:\n/*\n<div id=\"gate\">\n <div class=\"g-box\">\n <div class=\"emoji\">🏢</div>\n <h2>HS Design Staff Portal</h2>\n <p>Internal quotation tool — password required</p>\n <input class=\"gate-input\" type=\"password\" id=\"pass\" placeholder=\"Password\" onkeydown=\"if(event.key==='Enter')chkGate()\">\n <button class=\"g-btn\" onclick=\"chkGate()\">Enter</button>\n <div class=\"g-err\" id=\"gerr\">Incorrect password</div>\n </div>\n</div>\n*/\n\n// 替换后的新 gate div(直接嵌入 worker_full.js 的 HTML 模板里):\n<div id=\"gate\">\n <div class=\"g-box\" style=\"text-align:center\">\n <div class=\"emoji\">🔐</div>\n <h2>Login Required</h2>\n <p>Please login to access the quotation tool</p>\n <a href=\"/login\" class=\"g-btn\" style=\"display:inline-block;text-align:center;text-decoration:none;background:linear-gradient(90deg,#e2b04a,#c9962a);color:#1a1a2e;padding:14px 36px;border-radius:8px;font-weight:700;font-size:1rem;border:none;cursor:pointer\">Login with Google</a>\n </div>\n</div>\n<script>\n// Auto-redirect to login if no token\n(function(){if(!localStorage.getItem('token')){window.location.href='/login';}})();\n</script>\n\n\n关键: chkGate() 仍会检查 localStorage.getItem('token'),如果有 token 就 hide gate 显示报价工具。所以只需要替换 gate div + 加 auto-redirect script。\n\nWorker dashboard 路由定位 (worker_full.js):\n- /dashboard 路由在 line 1022: if (path === '/dashboard') {\n- HTML 模板 literal 从 line 1023 开始 (const html = \\``)\n- Gate div 在 HTML 模板内,搜索 <div id=“gate”>即可找到\n\n**正确的登录流程**:\n1. 直接访问/dashboard→ 无 token → auto-redirect 到/login\n2. 点 \"Login with Google\" → Google OAuth → callback → JWT 存入 localStorage.token\n3. 跳转回 /dashboardcheckAuth()找到 token → gate hidden → 显示报价工具\n\n### Bug 14: SPA 跨域 localStorage + OAuth redirect 丢 token\n**症状**: Google OAuth callback → 302 redirect → token 参数丢失,SaaS 前端无法登录。\n\n**问题链**:\n1. Worker callback 设置localStorage.setItem(‘qs_token’, jwt)quotation.hsdesign.bizorigin\n2. 302 redirect 到/dashboard\n3. /dashboard fetch SaaS 前端(quotation-saas-frontend.pages.dev)— 不同 origin,localStorage 读不到\n4. 即使 redirect 到 SaaS 前端,query string 在 302 过程中丢失\n\n**架构变更(2026-05-04)**: SPA 不再从外部 fetch,直接 embed 在 Worker 里:\n- Worker 存储 SPA_HTML常量(完整 SaaS 前端 HTML,已含 token 注入修复)\n-/app路由 serveSPA_HTML,有 token 时 inject script 存 localStorage\n- /dashboardredirect 到/app\n- Google callback redirect 到 /app?token=xxx(Worker serve 含 token 的 HTML)\n\n**SPA HTML inject 逻辑**:\n```javascript\n// 在 SPA HTML 的 IIFE 之前 inject 这段 script:\nif (window.location.search.includes('token=')) {\n const params = new URLSearchParams(window.location.search);\n const tok = params.get('token');\n if (tok && tok.length > 20) {\n localStorage.setItem('qs_token', tok);\n history.replaceState(null, '', '/');\n }\n}\n```\n\n### Bug 15: handleCreateQuote 用 d.id 判断,但 API 返回 d.quotation.id\n**症状**: 创建报价后显示 \"Failed to create\",但 API 实际返回 201 成功。\n\n**根因**: POST /api/quotations返回{ quotation: { id: ”…” } },但前端判断:\n```javascript\nif (d.id) { /* success */ } // ❌ d.id === undefined\nelse { showError('Failed to create'); } // 永远走到这里\n```\n\n**修复**: 前端 handleCreateQuote改为if (d.quotation && d.quotation.id)\n\n### Bug 18: Worker 部署后 10021 语法错误 — 文件末尾多余闭合括号\n**症状**: PUT部署返回{“success”:false,“errors”:[{“code”:10021,“message”:“Unexpected token ’}‘\\n at worker.js:2172:2”}]\n\n**根因**: worker_full.js文件里有多余的}闭合括号。在 line 2168 关闭/dashboard的 if 块后,line 2170 和 2172 有额外的两个}。\n\n**诊断**:\n```python\n# 检查 worker 文件的闭合括号数量(应该有 2 个:handleRequest + addEventListener)\nwith open(worker_path) as f:\n lines = f.readlines()\nfor i in range(2164, 2180):\n print(f\"Line {i+1}: {repr(lines[i])}\")\n# 会看到:\n# Line 2168: ' }' ← 关闭 /dashboard if 块 ✅\n# Line 2170: ' }' ← ❌ 多余\n# Line 2172: ' }' ← ❌ 多余\n```\n\n**修复**: 删除 line 2170 和 2172(1-indexed),或直接在编辑器里删除这两个多余的闭合括号。\n\n**验证**: 修复后重新 PUT部署,应返回{“success”:true}。\n\n### Bug 19: HTML 文件 BOM 导致 400 错误\n**症状**: 嵌入 HTML 模板到 Worker 后,PUT部署返回 400 错误。\n\n**根因**:HS_Design_Quotation.html 文件开头有 BOM(\ufeff,UTF-8 signature)。嵌入 Worker 模板 literal 后导致 JS 语法错误。\n\n**诊断**:\n```python\nwith open(html_path, 'rb') as f:\n first_bytes = f.read(3)\nprint(first_bytes.hex()) # 'efbbbf' = BOM\n```\n\n**修复**: 读取 HTML 时用 encoding=‘utf-8-sig’自动去掉 BOM:\n```python\nwith open(html_path, 'r', encoding='utf-8-sig') as f:\n html_content = f.read()\n```\n\n### Bug 10: /api/quota 缺失\n**状态**: ✅ 已实现(2026-05-04)\n\n**功能**: 返回用户当前配额信息\n```json\nGET /api/quota\nAuthorization: Bearer <token>\n\nResponse:\n{ \"plan\": \"free\", \"limit\": 100, \"used\": 0, \"remaining\": 100 }\n// plan = free(100), adfree(unlimited=-1), pro(200), beta(unlimited=-1)\n```\n\n**实现逻辑**:\n1.requireAuth验证 token\n2. 从accounts表查用户 plan\n3. 用planLimits 映射限额:{ free: 100, adfree: -1, pro: 200, beta: -1 }\n4. 统计当月报价数量:WHERE account_id = ? AND date >= <月初日期>\n5. 返回 { plan, limit, used, remaining }\n\n### Bug 16: Worker HTML 中文乱码 — Content-Type 缺少 charset=utf-8\n**症状**: Google OAuth callback 成功后显示的 HTML 页面中文全是乱码(如 \"登入æˆ�功\" 而不是 \"登录成功\")\n\n**根因**: Worker 返回含中文的 HTML 时,Content-Type: text/html没有加charset=utf-8,浏览器按默认编码(Latin-1)解析中文字符,导致乱码。\n\n**受影响位置**(worker_full.js中 4 处):\n-/auth/callback成功页(\"登录成功\"、\"欢迎\"、\"正在跳转到 Dashboard...\")\n-/auth/callback错误页(\"Google 登录失败\"、\"获取访问令牌失败\"、\"登录出错\")\n- 其他返回中文 HTML 的路由\n\n**修复**: 所有new Response(html, { headers: { ‘Content-Type’: ‘text/html’ } })` 改为:\njavascript\n// ✅ 正确\n{ headers: { 'Content-Type': 'text/html; charset=utf-8' } }\n\n\n验证: 修复后用 Google 登录,callback 页中文应正常显示,不再乱码。\n”}