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 ( +
+ {/* ---------- step: login ---------- */} + {step === 'login' && ( +
+

1. 登录测试账号

+

用编排时注册好的账号邮箱 + 密码登录,仅本地联调使用。

+
+
+ + setEmail(e.target.value)} + required + /> +
+
+
+
+ + setPassword(e.target.value)} + required + /> +
+ +
+ {loginError && ( +
+ + {loginError} +
+ )} +
+ )} + + {/* ---------- step: catalog ---------- */} + {step === 'catalog' && ( +
+

2. 选择套餐下单

+

已登录,方法固定为 fake(本地 mock 渠道)。

+ + {catalogLoading && ( +
+ + 加载套餐中… +
+ )} + {catalogError && ( +
+ + {catalogError} + +
+ )} + + {!catalogLoading && !catalogError && items.length === 0 && ( +
catalog 为空。
+ )} + +
+ {items.map((it) => ( +
+
{it.plan}
+
+ {fmtPrice(it.price_minor, it.currency)} +
+
{it.days} 天 · {it.sku}
+ +
+ ))} +
+ + {orderError && ( +
+ + {orderError} +
+ )} +
+ )} + + {/* ---------- step: awaiting payment ---------- */} + {step === 'awaiting' && order && ( +
+

3. 假收款 · 等待付款

+

+ 订单号 {order.order_no} + {payStatus?.pay_status ? <> · pay_status = {payStatus.pay_status} : null} +

+ + + +
+ {payStatus?.activated ? ( +
+ + 已开通 +
+ ) : ( +
+ + 轮询中(每 2.5s)… activated = {String(payStatus?.activated ?? false)} +
+ )} + {pollError && ( +
+ + 轮询出错:{pollError} +
+ )} +
+ +
+ + +
+ {markPaidError && ( +
+ + {markPaidError} +
+ )} +
+ )} + + {/* ---------- step: activated ---------- */} + {step === 'activated' && ( +
+
+ + 已开通!订单 {order?.order_no} 支付完成,套餐已生效。 +
+ +
+ )} +
+ ); +} + +/** + * PaymentPanel — 按 render_type 多态渲染假收款信息。 + * 三态:crypto_address(地址文本)/ redirect(跳转链接)/ qr(二维码内容文本, + * 不引外部 CDN 画码,直接展示可复制的原始内容,严格 CSP 下最稳妥)。 + */ +function PaymentPanel({ session }) { + const [copied, setCopied] = useState(false); + if (!session) return null; + + const { render_type, payload } = session; + + async function copy(text) { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + } catch { + // 剪贴板权限失败时静默,用户可手动选中复制。 + } + } + + if (render_type === 'redirect') { + const url = payload?.url || ''; + return ( +
+ +
+ 跳转支付 +
{url}
+ + + 去支付 + +
+
+ ); + } + + if (render_type === 'qr') { + const content = payload?.qr_content || ''; + return ( +
+ +
+ 二维码内容 +
未接入本地二维码渲染库,直接展示原始内容,可复制到二维码工具生成。
+
{content}
+ +
+
+ ); + } + + // crypto_address(fake 渠道默认)+ 未识别的 render_type 兜底同样展示。 + const address = payload?.address || payload?.qr_content || payload?.url || JSON.stringify(payload); + const amount = payload?.amount; + const currency = payload?.currency; + return ( +
+ +
+ 假收款地址 + {amount ? ( +
+ 金额:{amount} {currency || ''} +
+ ) : null} +
{address}
+ +
+
+ ); +} diff --git a/web/website/src/pages/buy.astro b/web/website/src/pages/buy.astro new file mode 100644 index 0000000..f64faca --- /dev/null +++ b/web/website/src/pages/buy.astro @@ -0,0 +1,58 @@ +--- +/** + * buy.astro — 本地联调测试路由 /buy:登录 → 拉套餐 → 选档下单 → 假收款 → 模拟付款 → 轮询开通。 + * + * ⚠️ 仅供本地开发联调(对接 server /v1/pay/* + pay dev 端点),不是生产购买页, + * 不改首页 / PricingPlans / Pricing.astro,不接入 i18n 字典。 + * 样式复用 website.css 既有类(section、wrap、plan、pcta、su- 前缀、btn- 前缀),新增小工具类见 buy.css。 + */ +import '@fontsource/sora/500.css'; +import '@fontsource/sora/600.css'; +import '@fontsource/sora/700.css'; +import '@fontsource/manrope/400.css'; +import '@fontsource/manrope/500.css'; +import '@fontsource/manrope/600.css'; +import '@fontsource/manrope/700.css'; +import '@fontsource/noto-sans-sc/400.css'; +import '@fontsource/noto-sans-sc/500.css'; +import '@fontsource/noto-sans-sc/700.css'; +import '@fontsource/jetbrains-mono/400.css'; +import '@fontsource/jetbrains-mono/500.css'; + +import '../styles/tokens.gen.css'; +import '../styles/website.css'; +import '../styles/site-extra.css'; +import '../styles/buy.css'; + +import BuyFlow from '../components/BuyFlow.jsx'; +--- + + + + + + 本地联调 · 购买流程测试 — Pangolin + + + +
+
+ + Pangolin + + 🧪 本地联调测试页 +
+ +
+
Dev only
+

购买流程本地联调

+

+ 登录 → 拉取套餐 → 选档下单(method=fake)→ 显示假收款 → 模拟付款成功 → 轮询直到已开通。 + 仅经 Astro dev vite proxy 同源转发到本地 pangolin-server / pay-server。 +

+
+ + +
+ + diff --git a/web/website/src/styles/buy.css b/web/website/src/styles/buy.css new file mode 100644 index 0000000..811632f --- /dev/null +++ b/web/website/src/styles/buy.css @@ -0,0 +1,88 @@ +/* buy.css — /buy 本地联调测试页专属工具类。 + 只加这一页要用、website.css 里没有的样式;颜色/间距一律用既有 --token,禁手写 hex。 + 不改 website.css(保持「逐字迁自原型」不漂移),不影响首页任何路由。 */ + +.buy-page { padding: 40px 0 96px; } + +.buy-topbar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 40px; } +.buy-topbar .brand .nm { font-family: var(--font-display); font-weight: 700; font-size: 19px; letter-spacing: -0.01em; } +.buy-badge { + font-size: 12.5px; + font-weight: 700; + color: var(--accent); + background: var(--accent-subtle); + border: 1px solid var(--accent-border); + border-radius: var(--radius-full); + padding: 6px 12px; +} + +.buy-sub { max-width: 640px; } + +.buy-card { + max-width: 640px; + margin: 36px auto 0; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-2xl); + box-shadow: var(--shadow-sm); + padding: 32px; +} + +.buy-block-center { text-align: center; display: flex; flex-direction: column; align-items: center; gap: 20px; } + +.buy-h { font-family: var(--font-display); font-weight: 700; font-size: 20px; margin: 0 0 6px; } +.buy-hint { font-size: 13.5px; color: var(--fg3); line-height: 1.6; margin: 0 0 16px; } +.buy-hint code, .buy-h code { font-family: var(--font-mono); background: var(--bg-subtle); border-radius: var(--radius-sm); padding: 1px 6px; } + +.buy-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 16px; margin-top: 4px; } +.buy-plan { padding: 22px 18px; } +.buy-plan .price { font-size: 26px; margin: 8px 0 4px; } +.buy-plan .pdesc { min-height: auto; margin-bottom: 14px; } + +.buy-loading, .buy-err { + display: flex; + align-items: center; + gap: 8px; + font-size: 13.5px; + margin-top: 14px; +} +.buy-loading { color: var(--fg2); } +.buy-loading svg { width: 16px; height: 16px; color: var(--accent); } + +.buy-err { color: var(--danger); background: var(--danger-subtle); border-radius: var(--radius-md); padding: 10px 14px; flex-wrap: wrap; } +.buy-err svg { width: 16px; height: 16px; flex-shrink: 0; } +.buy-err button { margin-left: auto; } + +.buy-spin { animation: buy-spin 0.9s linear infinite; } +@keyframes buy-spin { to { transform: rotate(360deg); } } + +.buy-status { margin: 20px 0; } + +.buy-actions { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 22px; } +.buy-mock-btn { border: 1.5px dashed var(--border-strong); } + +.buy-pay-panel { margin-top: 0; } +.buy-pay-panel > div { width: 100%; } +.buy-pay-cta { margin-top: 12px; } + +.buy-mono { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--fg1); + background: var(--bg-subtle); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 10px 12px; + margin-top: 8px; + word-break: break-all; + line-height: 1.5; +} +.buy-selectable { user-select: all; } + +.buy-ok-big { font-size: 15px; } +.buy-ok-big svg { width: 22px; height: 22px; } + +@media (max-width: 640px) { + .buy-card { padding: 22px; margin-top: 24px; } + .buy-topbar { flex-wrap: wrap; gap: 10px; } +}