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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> {
|
||||
const t = siteToken();
|
||||
const h: Record<string, string> = 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<SiteUser | null> {
|
||||
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<PayOrder> {
|
||||
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<PayOrder> & { 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 };
|
||||
}
|
||||
@@ -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<SubPlan[]>([])
|
||||
const [packs, setPacks] = useState<Pack[]>([])
|
||||
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [user, setUser] = useState<SiteUser | null>(null)
|
||||
const [buying, setBuying] = useState<BuyItem | null>(null) // 已登录,正在支付
|
||||
const [pendingBuy, setPendingBuy] = useState<BuyItem | null>(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' && <p className="mt-12 text-sm text-ink-3">正在获取价格…</p>}
|
||||
{state === 'error' && (
|
||||
<p className="mt-12 text-sm text-ink-3">
|
||||
价格暂时取不到,请稍后再试,或直接
|
||||
<a href={CHECKOUT} className="ml-1 text-accent underline underline-offset-4">
|
||||
前往账单页
|
||||
</a>
|
||||
。
|
||||
价格暂时取不到,请稍后再试。
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -102,12 +113,12 @@ export default function PricingPage() {
|
||||
</li>
|
||||
<li className="text-ink-3">积分累加,用不完不清零</li>
|
||||
</ul>
|
||||
<a
|
||||
href={CHECKOUT}
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
购买
|
||||
</a>
|
||||
</button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
@@ -125,12 +136,12 @@ export default function PricingPage() {
|
||||
<h3 className="text-sm font-semibold text-ink">{p.name}</h3>
|
||||
<div className="mt-2 text-2xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</div>
|
||||
<p className="mt-1 text-sm text-ink-2">{credits(p.credits_micro)} 积分</p>
|
||||
<a
|
||||
href={CHECKOUT}
|
||||
<button
|
||||
onClick={() => 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"
|
||||
>
|
||||
购买 →
|
||||
</a>
|
||||
</button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
@@ -146,6 +157,20 @@ export default function PricingPage() {
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 未登录点购买 → 扫码登录 → 登录后自动继续之前那笔购买 */}
|
||||
{pendingBuy && (
|
||||
<WechatLoginDialog
|
||||
onClose={() => setPendingBuy(null)}
|
||||
onLoggedIn={(u) => {
|
||||
setUser(u)
|
||||
const item = pendingBuy
|
||||
setPendingBuy(null)
|
||||
setBuying(item)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{buying && <PayDialog item={buying} onClose={() => setBuying(null)} />}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user