Merge pull request 'feat(site): 官网直接登录 + 购买(不再单独部署 Web 面)' (#10) from feat/site into main
deploy-132 / deploy (push) Successful in 2m27s

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-07-21 01:26:43 +00:00
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>
)
}
+102
View File
@@ -0,0 +1,102 @@
// 官网侧的用户 API:微信扫码登录 + 购买(下单/查单)。
//
// 与运维后台的 api.ts 分开:普通用户在官网登录拿到的是「用户 JWT」,
// 存独立 keysdx_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 };
}
+39 -14
View File
@@ -1,5 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { GATEWAY } from '../../api' 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 price_fen: number
} }
// 结账在 Web 面完成(那里有登录态、租户上下文与支付轮询)。官网只负责"看价 → 去买" // 一个待购项:点「购买」后,未登录先扫码登录,再弹微信支付。全程在官网完成。
// 避免同一套支付流程在两处各实现一遍。 type BuyItem = { kind: 'sub' | 'pack'; id: string; name: string; priceFen: number }
const CHECKOUT = '/usage'
export default function PricingPage() { export default function PricingPage() {
const [plans, setPlans] = useState<SubPlan[]>([]) const [plans, setPlans] = useState<SubPlan[]>([])
const [packs, setPacks] = useState<Pack[]>([]) const [packs, setPacks] = useState<Pack[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading') 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(() => { useEffect(() => {
// GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite // GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite
@@ -62,11 +77,7 @@ export default function PricingPage() {
{state === 'loading' && <p className="mt-12 text-sm text-ink-3"></p>} {state === 'loading' && <p className="mt-12 text-sm text-ink-3"></p>}
{state === 'error' && ( {state === 'error' && (
<p className="mt-12 text-sm text-ink-3"> <p className="mt-12 text-sm text-ink-3">
<a href={CHECKOUT} className="ml-1 text-accent underline underline-offset-4">
</a>
</p> </p>
)} )}
@@ -102,12 +113,12 @@ export default function PricingPage() {
</li> </li>
<li className="text-ink-3"></li> <li className="text-ink-3"></li>
</ul> </ul>
<a <button
href={CHECKOUT} 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" 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> </article>
) )
})} })}
@@ -125,12 +136,12 @@ export default function PricingPage() {
<h3 className="text-sm font-semibold text-ink">{p.name}</h3> <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> <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> <p className="mt-1 text-sm text-ink-2">{credits(p.credits_micro)} </p>
<a <button
href={CHECKOUT} 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" className="mt-4 inline-flex text-sm font-medium text-accent underline underline-offset-4"
> >
</a> </button>
</article> </article>
))} ))}
</div> </div>
@@ -146,6 +157,20 @@ export default function PricingPage() {
</p> </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> </main>
) )
} }