From 43fead88231d368c63314e670a2c56d474615487 Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Sat, 11 Jul 2026 01:32:11 +0800 Subject: [PATCH] =?UTF-8?q?feat(website):=20=E6=9C=AC=E5=9C=B0=E8=81=94?= =?UTF-8?q?=E8=B0=83=20/buy=20=E6=B5=8B=E8=AF=95=E8=B4=AD=E4=B9=B0?= =?UTF-8?q?=E9=A1=B5(=E7=99=BB=E5=BD=95=E2=86=92=E4=B8=8B=E5=8D=95?= =?UTF-8?q?=E2=86=92mock=20=E4=BB=98=E6=AC=BE=E2=86=92=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E5=BC=80=E9=80=9A)+=20dev=20vite=20proxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/website/astro.config.mjs | 23 ++ web/website/src/components/BuyFlow.jsx | 462 +++++++++++++++++++++++++ web/website/src/pages/buy.astro | 58 ++++ web/website/src/styles/buy.css | 88 +++++ 4 files changed, 631 insertions(+) create mode 100644 web/website/src/components/BuyFlow.jsx create mode 100644 web/website/src/pages/buy.astro create mode 100644 web/website/src/styles/buy.css diff --git a/web/website/astro.config.mjs b/web/website/astro.config.mjs index a5c52a7..eb9ef7e 100644 --- a/web/website/astro.config.mjs +++ b/web/website/astro.config.mjs @@ -24,5 +24,28 @@ export default defineConfig({ // 关闭 JS 内联,保证 script-src 'self' 严格 CSP 下可运行。 assetsInlineLimit: 0, }, + server: { + // /buy 本地联调测试页专用:浏览器同源调后端,避免跨域。仅 dev 生效,不影响构建产物。 + // + // 坑:Astro 全站 trailingSlash:'always',dev server 对不带尾斜杠、又没有匹配页面路由 + // 的路径(如 /v1/pay/catalog)会在到达这里的 vite 代理之前就短路返回 Astro 自己的 404 + // (已用本地 mock 后端验证:带尾斜杠能命中此代理,不带则 404,Astro middleware 同样不触发, + // 因为它只包裹「匹配到的」路由渲染,不包裹压根没有路由匹配的任意路径)。 + // 于是这里约定:BuyFlow.jsx 发请求一律带尾斜杠(`/v1/pay/catalog/` 这类,与全站 + // trailingSlash 约定一致),再用 rewrite 在转发前把尾斜杠去掉,避免打到后端 chi 路由 + // (未挂 StripSlashes,尾斜杠会 404)。 + proxy: { + '/v1': { + target: 'http://127.0.0.1:8080', + changeOrigin: true, + rewrite: (p) => p.replace(/\/$/, ''), + }, + '/paydev': { + target: 'http://127.0.0.1:8090', + changeOrigin: true, + rewrite: (p) => p.replace(/^\/paydev/, '/api/v2/dev').replace(/\/$/, ''), + }, + }, + }, }, }); diff --git a/web/website/src/components/BuyFlow.jsx b/web/website/src/components/BuyFlow.jsx new file mode 100644 index 0000000..cb8f66e --- /dev/null +++ b/web/website/src/components/BuyFlow.jsx @@ -0,0 +1,462 @@ +/** + * BuyFlow.jsx — /buy 本地联调测试页的交互状态机(React island,client:load)。 + * + * ⚠️ 仅供本地联调,不是真实购买流程;不接入 i18n 字典,不参与生产首页。 + * 状态机:login → catalog → awaiting(下单后轮询) → activated / failed。 + * + * 后端契约(server/internal/pay/handler.go,已核对,见任务描述): + * POST /v1/auth/login → { access_token, refresh_token, expires_in, ... } + * GET /v1/pay/catalog → { items:[{ sku, plan, days, price_minor, currency }] } + * POST /v1/pay/orders → { order_no, session:{ render_type, payload, expires_at? } } + * GET /v1/pay/orders/{no} → { order_no, pay_status, activated, expires_at? } + * POST /paydev/orders/{no}/mark-paid → 200(本地 dev-only,经 vite proxy 转发到 pay 的 dev 端点) + * + * API base:同源相对路径,留 PUBLIC_API_BASE 口子(默认空 = 同源,走 astro.config.mjs 的 vite proxy)。 + * + * 坑(astro.config.mjs 里也记了一遍):全站 trailingSlash:'always',dev server 对不带尾斜杠又 + * 没匹配到页面路由的路径会在到达 vite 代理前被 Astro 自己短路 404,所以这里请求一律带尾斜杠 + * (与站点约定一致),astro.config.mjs 的 proxy rewrite 会在转发前把尾斜杠去掉再打后端 chi 路由。 + */ +import { useEffect, useRef, useState } from 'react'; +import { + Mail, + Lock, + Loader2, + CheckCircle2, + AlertCircle, + Copy, + ExternalLink, + QrCode, + RotateCcw, + FlaskConical, +} from 'lucide-react'; + +const API_BASE = import.meta.env.PUBLIC_API_BASE || ''; + +async function api(path, options = {}) { + const res = await fetch(API_BASE + path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(options.headers || {}), + }, + }); + const text = await res.text(); + let body = null; + if (text) { + try { + body = JSON.parse(text); + } catch { + body = text; + } + } + if (!res.ok) { + const msg = + (body && typeof body === 'object' && (body.error || body.message)) || + (typeof body === 'string' && body) || + `HTTP ${res.status}`; + throw new Error(msg); + } + return body; +} + +function fmtPrice(minor, currency) { + const v = (minor ?? 0) / 100; + return `${v.toFixed(2)} ${currency || ''}`.trim(); +} + +export default function BuyFlow() { + const [step, setStep] = useState('login'); // login | catalog | awaiting | activated + const [token, setToken] = useState(''); + + // login + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [loginLoading, setLoginLoading] = useState(false); + const [loginError, setLoginError] = useState(''); + + // catalog + const [items, setItems] = useState([]); + const [catalogLoading, setCatalogLoading] = useState(false); + const [catalogError, setCatalogError] = useState(''); + + // order + const [orderLoading, setOrderLoading] = useState(''); + const [orderError, setOrderError] = useState(''); + const [order, setOrder] = useState(null); // { order_no, session } + const [payStatus, setPayStatus] = useState(null); // { pay_status, activated } + const [pollError, setPollError] = useState(''); + + // mock pay + const [markPaidLoading, setMarkPaidLoading] = useState(false); + const [markPaidError, setMarkPaidError] = useState(''); + + const pollRef = useRef(null); + + const stopPolling = () => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + useEffect(() => stopPolling, []); // unmount 兜底清 interval + + async function fetchCatalog(bearer) { + setCatalogLoading(true); + setCatalogError(''); + try { + const data = await api('/v1/pay/catalog/', { + headers: { Authorization: `Bearer ${bearer}` }, + }); + setItems(data?.items || []); + } catch (e) { + setCatalogError(e.message || String(e)); + } finally { + setCatalogLoading(false); + } + } + + async function onLogin(e) { + e.preventDefault(); + setLoginError(''); + setLoginLoading(true); + try { + const data = await api('/v1/auth/login/', { + method: 'POST', + body: JSON.stringify({ + email, + password, + device: { + id: 'web-buy', + name: 'web', + platform: 'web', + client_version: '0', + }, + }), + }); + setToken(data.access_token); + setStep('catalog'); + fetchCatalog(data.access_token); + } catch (e) { + setLoginError(e.message || String(e)); + } finally { + setLoginLoading(false); + } + } + + async function onPickSku(sku) { + setOrderError(''); + setOrderLoading(sku); + try { + const data = await api('/v1/pay/orders/', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + sku, + method: 'fake', + metadata: { is_mobile: false }, + }), + }); + setOrder(data); + setPayStatus(null); + setPollError(''); + setStep('awaiting'); + startPolling(data.order_no); + } catch (e) { + setOrderError(e.message || String(e)); + } finally { + setOrderLoading(''); + } + } + + function startPolling(orderNo) { + stopPolling(); + const tick = async () => { + try { + const data = await api(`/v1/pay/orders/${orderNo}/`, { + headers: { Authorization: `Bearer ${token}` }, + }); + setPayStatus(data); + setPollError(''); + if (data && data.activated === true) { + stopPolling(); + setStep('activated'); + } + } catch (e) { + setPollError(e.message || String(e)); + } + }; + tick(); + pollRef.current = setInterval(tick, 2500); + } + + async function onMarkPaid() { + if (!order) return; + setMarkPaidError(''); + setMarkPaidLoading(true); + try { + await api(`/paydev/orders/${order.order_no}/mark-paid/`, { method: 'POST' }); + // 不主动切状态,靠轮询自然把 activated 翻 true。 + } catch (e) { + setMarkPaidError(e.message || String(e)); + } finally { + setMarkPaidLoading(false); + } + } + + function onReset() { + stopPolling(); + setOrder(null); + setPayStatus(null); + setOrderError(''); + setPollError(''); + setMarkPaidError(''); + setStep('catalog'); + } + + return ( +
已登录,方法固定为 fake(本地 mock 渠道)。
+ 订单号 {order.order_no}
+ {payStatus?.pay_status ? <> · pay_status = {payStatus.pay_status}> : null}
+
+ 登录 → 拉取套餐 → 选档下单(method=fake)→ 显示假收款 → 模拟付款成功 → 轮询直到已开通。 + 仅经 Astro dev vite proxy 同源转发到本地 pangolin-server / pay-server。 +
+