feat(site): 官网直接登录 + 购买(不再单独部署 Web 面)

用户要求「直接在官网登录,别多搞一个 web 工程」。官网本就是 admin 前端的一部分
(embed 进 gateway),在它上面加登录 + 购买,仍是「一个前端」,零新增部署。
sundynix-web(薄 Web 面)不部署;账单/组织/团队等留桌面端。

定价页「购买」流程:
  未登录 → 弹微信扫码登录(带参二维码 + 关注/扫码事件)→ 登录后自动继续那笔购买
  已登录 → 直接弹微信支付二维码 → 轮询单态 → 到账
支付走后端现有链路(下单/回调/查单/掉单补偿全复用),与桌面端同一套。

站点用户 token 存独立 key(sdx_site_token),与运维后台的 sdx_admin_token
隔开——普通用户登录拿到的用户 JWT 不该与管理员令牌混用。

本地验证:未登录点购买 → 弹登录弹窗;未配微信时如实显示「微信登录未配置」
(部署配好即真二维码)。登录门 + 支付弹窗 UI 骨架与错误态均正确;真二维码/
真支付留待部署后。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-21 09:23:23 +08:00
parent 883540bd7e
commit 04b5677605
4 changed files with 339 additions and 14 deletions
@@ -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<Phase>('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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={() => onClose(phase === 'paid')}>
<div className="w-full max-w-sm rounded-2xl border border-hairline bg-surface p-6" onClick={(e) => e.stopPropagation()}>
<div className="flex items-start justify-between">
<h3 className="text-base font-semibold text-ink"> · {item.name}</h3>
<button onClick={() => onClose(phase === 'paid')} className="text-sm text-ink-3 hover:text-ink">
</button>
</div>
<div className="mt-5 flex flex-col items-center gap-3">
{err ? (
<p className="py-10 text-center text-sm text-red-500">{err}</p>
) : phase === 'creating' ? (
<p className="py-16 text-sm text-ink-3"></p>
) : (
<>
<div className="relative rounded-xl bg-white p-3">
<img src={qr} alt="微信支付二维码" width={220} height={220} />
{(phase === 'paid' || phase === 'expired') && (
<div className="absolute inset-0 flex items-center justify-center rounded-xl bg-white/85 text-sm font-medium text-ink">
{phase === 'paid' ? '✅ 支付成功' : '二维码已失效'}
</div>
)}
</div>
<div className="text-center">
<div className="text-lg font-semibold tabular-nums text-ink">¥{(item.priceFen / 100).toFixed(2)}</div>
{phase === 'waiting' && <div className="mt-0.5 text-xs text-ink-3"></div>}
</div>
</>
)}
{warn && <p className="w-full rounded-lg bg-amber-50 px-3 py-2 text-center text-xs text-amber-700">{warn}</p>}
</div>
</div>
</div>
)
}