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)} />}
)
}