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>
)
}
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink/40 p-4" onClick={onClose}>
<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"></h3>
<button onClick={onClose} 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>
) : qr ? (
<div className="relative rounded-xl bg-white p-3">
<img src={qr} alt="微信登录二维码" width={220} height={220} />
{expired && (
<button
onClick={() => setNonce((n) => n + 1)}
className="absolute inset-0 flex flex-col items-center justify-center gap-1 rounded-xl bg-black/70 text-sm text-white"
>
<span className="rounded bg-white/20 px-2 py-1 text-xs"></span>
</button>
)}
</div>
) : (
<p className="py-16 text-sm text-ink-3"></p>
)}
<p className="text-center text-xs text-ink-3"></p>
</div>
</div>
</div>
)
}