From 04b56776052ad268da2feb7b14275369da7463cc Mon Sep 17 00:00:00 2001 From: Blizzard Date: Tue, 21 Jul 2026 09:23:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(site):=20=E5=AE=98=E7=BD=91=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E7=99=BB=E5=BD=95=20+=20=E8=B4=AD=E4=B9=B0=EF=BC=88?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E5=8D=95=E7=8B=AC=E9=83=A8=E7=BD=B2=20Web=20?= =?UTF-8?q?=E9=9D=A2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户要求「直接在官网登录,别多搞一个 web 工程」。官网本就是 admin 前端的一部分 (embed 进 gateway),在它上面加登录 + 购买,仍是「一个前端」,零新增部署。 sundynix-web(薄 Web 面)不部署;账单/组织/团队等留桌面端。 定价页「购买」流程: 未登录 → 弹微信扫码登录(带参二维码 + 关注/扫码事件)→ 登录后自动继续那笔购买 已登录 → 直接弹微信支付二维码 → 轮询单态 → 到账 支付走后端现有链路(下单/回调/查单/掉单补偿全复用),与桌面端同一套。 站点用户 token 存独立 key(sdx_site_token),与运维后台的 sdx_admin_token 隔开——普通用户登录拿到的用户 JWT 不该与管理员令牌混用。 本地验证:未登录点购买 → 弹登录弹窗;未配微信时如实显示「微信登录未配置」 (部署配好即真二维码)。登录门 + 支付弹窗 UI 骨架与错误态均正确;真二维码/ 真支付留待部署后。 Co-Authored-By: Claude Opus 4.8 --- .../src/site/components/pay-dialog.tsx | 103 ++++++++++++++++++ .../site/components/wechat-login-dialog.tsx | 95 ++++++++++++++++ sundynix-admin/src/site/lib/site-api.ts | 102 +++++++++++++++++ sundynix-admin/src/site/pages/pricing.tsx | 53 ++++++--- 4 files changed, 339 insertions(+), 14 deletions(-) create mode 100644 sundynix-admin/src/site/components/pay-dialog.tsx create mode 100644 sundynix-admin/src/site/components/wechat-login-dialog.tsx create mode 100644 sundynix-admin/src/site/lib/site-api.ts diff --git a/sundynix-admin/src/site/components/pay-dialog.tsx b/sundynix-admin/src/site/components/pay-dialog.tsx new file mode 100644 index 0000000..3170b57 --- /dev/null +++ b/sundynix-admin/src/site/components/pay-dialog.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef, useState } from 'react' +import QRCode from 'qrcode' +import { createOrder, orderStatus } from '../lib/site-api' + +// 官网购买支付弹窗:下单 → 微信 code_url 画二维码 → 轮询单态。 +// 复用后端支付链路(下单/回调/查单/掉单补偿全共用),与桌面端/联调台同一套。 +const POLL_MS = 2500 + +type Phase = 'creating' | 'waiting' | 'paid' | 'expired' + +export function PayDialog({ + item, + onClose, +}: { + item: { kind: 'sub' | 'pack'; id: string; name: string; priceFen: number } + onClose: (paid: boolean) => void +}) { + const [qr, setQr] = useState('') + const [phase, setPhase] = useState('creating') + const [err, setErr] = useState('') + const [warn, setWarn] = useState('') + const orderRef = useRef('') + + useEffect(() => { + let alive = true + let timer: number | null = null + + void (async () => { + try { + const o = await createOrder(item.kind === 'sub' ? { planId: item.id } : { packId: item.id }) + if (!alive) return + orderRef.current = o.order_id + setQr(await QRCode.toDataURL(o.code_url, { width: 220, margin: 1 })) + setPhase('waiting') + const tick = async () => { + if (!alive) return + try { + const s = await orderStatus(orderRef.current) + if (!alive) return + setWarn(s.warn ?? '') + if (s.status === 'paid') { + setPhase('paid') + window.setTimeout(() => onClose(true), 900) + return + } + if (s.status === 'expired' || s.status === 'failed') { + setPhase('expired') + return + } + } catch { + /* 单次失败忽略 */ + } + timer = window.setTimeout(() => void tick(), POLL_MS) + } + timer = window.setTimeout(() => void tick(), POLL_MS) + } catch (e) { + if (alive) setErr((e as Error).message) + } + })() + + return () => { + alive = false + if (timer) window.clearTimeout(timer) + } + }, [item, onClose]) + + return ( +
onClose(phase === 'paid')}> +
e.stopPropagation()}> +
+

微信支付 · {item.name}

+ +
+ +
+ {err ? ( +

{err}

+ ) : phase === 'creating' ? ( +

正在生成支付二维码…

+ ) : ( + <> +
+ 微信支付二维码 + {(phase === 'paid' || phase === 'expired') && ( +
+ {phase === 'paid' ? '✅ 支付成功' : '二维码已失效'} +
+ )} +
+
+
¥{(item.priceFen / 100).toFixed(2)}
+ {phase === 'waiting' &&
微信扫一扫完成支付
} +
+ + )} + {warn &&

{warn}

} +
+
+
+ ) +} diff --git a/sundynix-admin/src/site/components/wechat-login-dialog.tsx b/sundynix-admin/src/site/components/wechat-login-dialog.tsx new file mode 100644 index 0000000..1c5c707 --- /dev/null +++ b/sundynix-admin/src/site/components/wechat-login-dialog.tsx @@ -0,0 +1,95 @@ +import { useEffect, useRef, useState } from 'react' +import { wxTicket, wxPoll, type SiteUser } from '../lib/site-api' + +// 官网微信扫码登录弹窗:建票拿微信二维码图 → 展示 → 轮询登录态。 +// 扫码后弹公众号关注页,关注即登录(带参二维码 + 关注/扫码事件)。 +export function WechatLoginDialog({ onClose, onLoggedIn }: { onClose: () => void; onLoggedIn: (u: SiteUser) => void }) { + const [qr, setQr] = useState('') + const [err, setErr] = useState('') + const [expired, setExpired] = useState(false) + const [nonce, setNonce] = useState(0) + const ticketRef = useRef('') + + useEffect(() => { + let alive = true + let timer: number | null = null + setErr('') + setExpired(false) + setQr('') + + void (async () => { + try { + const t = await wxTicket() + if (!alive) return + ticketRef.current = t.ticket + setQr(t.qr_image) + const deadline = Date.now() + t.expires_in * 1000 + const tick = async () => { + if (!alive) return + if (Date.now() > deadline) { + setExpired(true) + return + } + try { + const r = await wxPoll(ticketRef.current) + if (!alive) return + if (r.status === 'authorized' && r.user) { + onLoggedIn(r.user) + return + } + if (r.status === 'expired') { + setExpired(true) + return + } + } catch { + /* 单次失败忽略,继续轮询 */ + } + timer = window.setTimeout(() => void tick(), 2000) + } + timer = window.setTimeout(() => void tick(), 2000) + } catch (e) { + if (alive) setErr((e as Error).message) + } + })() + + return () => { + alive = false + if (timer) window.clearTimeout(timer) + } + }, [nonce, onLoggedIn]) + + return ( +
+
e.stopPropagation()}> +
+

微信扫码登录

+ +
+ +
+ {err ? ( +

{err}

+ ) : qr ? ( +
+ 微信登录二维码 + {expired && ( + + )} +
+ ) : ( +

正在生成二维码…

+ )} +

微信扫一扫,关注公众号即可登录

+
+
+
+ ) +} diff --git a/sundynix-admin/src/site/lib/site-api.ts b/sundynix-admin/src/site/lib/site-api.ts new file mode 100644 index 0000000..09e6d6d --- /dev/null +++ b/sundynix-admin/src/site/lib/site-api.ts @@ -0,0 +1,102 @@ +// 官网侧的用户 API:微信扫码登录 + 购买(下单/查单)。 +// +// 与运维后台的 api.ts 分开:普通用户在官网登录拿到的是「用户 JWT」, +// 存独立 key(sdx_site_token),不与管理员令牌(sdx_admin_token)混用。 +// 主产品(跑 Agent/知识库/报告)在桌面端,官网只做「登录 + 购买」。 + +import { GATEWAY } from "../../api"; + +const SITE_TOKEN = "sdx_site_token"; + +export function siteToken(): string { + try { + return localStorage.getItem(SITE_TOKEN) ?? ""; + } catch { + return ""; + } +} +function setSiteToken(t: string) { + try { + localStorage.setItem(SITE_TOKEN, t); + } catch { + /* ignore */ + } +} +export function clearSiteToken() { + try { + localStorage.removeItem(SITE_TOKEN); + } catch { + /* ignore */ + } +} + +function authHeaders(json = false): Record { + const t = siteToken(); + const h: Record = t ? { Authorization: `Bearer ${t}` } : {}; + if (json) h["Content-Type"] = "application/json"; + return h; +} + +export interface SiteUser { + id: string; + name?: string; +} + +// me:有 token 时确认登录态;无效则清掉。 +export async function siteMe(): Promise { + if (!siteToken()) return null; + const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: authHeaders() }); + if (!res.ok) { + clearSiteToken(); + return null; + } + return ((await res.json()) as { user?: SiteUser }).user ?? null; +} + +// ---- 微信扫码登录 ---- +export async function wxTicket(): Promise<{ ticket: string; qr_image: string; expires_in: number }> { + const res = await fetch(`${GATEWAY}/api/v1/wx/mp/ticket`, { method: "POST" }); + if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? "创建登录二维码失败"); + return res.json(); +} + +// 轮询:pending | authorized(带 token+user) | expired | consumed。 +export async function wxPoll(ticket: string): Promise<{ status: string; user?: SiteUser }> { + const res = await fetch(`${GATEWAY}/api/v1/wx/mp/poll?t=${encodeURIComponent(ticket)}`); + if (!res.ok) return { status: "expired" }; + const d = (await res.json()) as { status?: string; token?: string; user?: SiteUser }; + if (d.token && d.user) { + setSiteToken(d.token); + return { status: "authorized", user: d.user }; + } + return { status: d.status ?? "pending" }; +} + +// ---- 购买(复用支付链路:下单 → 微信 code_url 二维码 → 轮询单态)---- +export interface PayOrder { + order_id: string; + code_url: string; + amount_fen: number; + expires_at: string; +} + +// target 二选一:买订阅传 planId,买积分包传 packId。 +export async function createOrder(target: { planId: string } | { packId: string }): Promise { + const body = "planId" in target ? { plan_id: target.planId } : { pack_id: target.packId }; + const res = await fetch(`${GATEWAY}/api/v1/billing/orders`, { + method: "POST", + headers: authHeaders(true), + body: JSON.stringify(body), + }); + const d = (await res.json().catch(() => ({}))) as Partial & { error?: string }; + if (!res.ok) throw new Error(d.error ?? "下单失败"); + return d as PayOrder; +} + +// warn:已付但金额不符(服务端挂起人工核对,订单停在 pending)。 +export async function orderStatus(orderId: string): Promise<{ status: string; warn?: string }> { + const res = await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: authHeaders() }); + if (!res.ok) return { status: "unknown" }; + const d = (await res.json()) as { order?: { status?: string }; warn?: string }; + return { status: d.order?.status ?? "pending", warn: d.warn }; +} diff --git a/sundynix-admin/src/site/pages/pricing.tsx b/sundynix-admin/src/site/pages/pricing.tsx index 29cd909..4f7d480 100644 --- a/sundynix-admin/src/site/pages/pricing.tsx +++ b/sundynix-admin/src/site/pages/pricing.tsx @@ -1,5 +1,8 @@ import { useEffect, useState } from 'react' import { GATEWAY } from '../../api' +import { siteMe, type SiteUser } from '../lib/site-api' +import { WechatLoginDialog } from '../components/wechat-login-dialog' +import { PayDialog } from '../components/pay-dialog' // 官网定价页。数据来自后台配置的「订阅套餐 / 积分包」,**不写死在前端**—— // 运营改价、上下架、调发放节奏都在管理端完成,这里跟着变,不用发版。 @@ -27,14 +30,26 @@ interface Pack { price_fen: number } -// 结账在 Web 面完成(那里有登录态、租户上下文与支付轮询)。官网只负责"看价 → 去买", -// 避免同一套支付流程在两处各实现一遍。 -const CHECKOUT = '/usage' +// 一个待购项:点「购买」后,未登录先扫码登录,再弹微信支付。全程在官网完成。 +type BuyItem = { kind: 'sub' | 'pack'; id: string; name: string; priceFen: number } export default function PricingPage() { const [plans, setPlans] = useState([]) const [packs, setPacks] = useState([]) const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading') + const [user, setUser] = useState(null) + const [buying, setBuying] = useState(null) // 已登录,正在支付 + const [pendingBuy, setPendingBuy] = useState(null) // 待登录后继续购买 + + useEffect(() => { + void siteMe().then(setUser) // 有历史登录态则免扫码 + }, []) + + // 点购买:未登录先记下意图并弹登录,登录后自动继续;已登录直接支付。 + const onBuy = (item: BuyItem) => { + if (user) setBuying(item) + else setPendingBuy(item) + } useEffect(() => { // GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite @@ -62,11 +77,7 @@ export default function PricingPage() { {state === 'loading' &&

正在获取价格…

} {state === 'error' && (

- 价格暂时取不到,请稍后再试,或直接 - - 前往账单页 - - 。 + 价格暂时取不到,请稍后再试。

)} @@ -102,12 +113,12 @@ export default function PricingPage() {
  • 积分累加,用不完不清零
  • - onBuy({ kind: 'sub', id: p.id, name: p.name, priceFen: p.price_fen })} className="mt-6 inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90" > 购买 - + ) })} @@ -125,12 +136,12 @@ export default function PricingPage() {

    {p.name}

    ¥{yuan(p.price_fen)}

    {credits(p.credits_micro)} 积分

    - onBuy({ kind: 'pack', id: p.id, name: p.name, priceFen: p.price_fen })} className="mt-4 inline-flex text-sm font-medium text-accent underline underline-offset-4" > 购买 → - + ))} @@ -146,6 +157,20 @@ export default function PricingPage() {

    )} + + {/* 未登录点购买 → 扫码登录 → 登录后自动继续之前那笔购买 */} + {pendingBuy && ( + setPendingBuy(null)} + onLoggedIn={(u) => { + setUser(u) + const item = pendingBuy + setPendingBuy(null) + setBuying(item) + }} + /> + )} + {buying && setBuying(null)} />} ) } -- 2.52.0