Compare commits

...

2 Commits

Author SHA1 Message Date
Pine 1dcb4816d8 chore(brand): 默认后端指向 opc.pinesound.cn + 更新品牌标识资源
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 22:32:05 +08:00
Pine a610c22f45 feat(compute): 算力中心充值(小程序码支付)与转发端点
- PineAgents 供应商按登录态注入 JWT(登出移除)
- 本地后端新增充值转发(packages/orders/pay-params/status/orders)与算力余额路由
- console 充值弹窗:套餐/自定义金额→小程序码→轮询到账;余额卡加充值入口;i18n 文案

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 22:32:04 +08:00
11 changed files with 6694 additions and 70 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 877 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB

+10
View File
@@ -327,6 +327,16 @@ export const authApi = {
// ---- 计量:余额 / 本月用量(按模型) ----
getOpcComputeBalance: () => request<{ id: number; quota: number; used_quota: number }>("/opc/compute/balance"),
getOpcComputeUsage: () => request<{ month: string; cost_quota: number; input_tokens: number; output_tokens: number; items: { model_name: string; requests: number; input_tokens: number; output_tokens: number; cost_quota: number }[] }>("/opc/compute/usage"),
// ---- 算力充值(微信支付,native 扫码) ----
getOpcRechargePackages: () => request<{ enabled: boolean; items: { id: string; amount: number; bonus: number; label: string; quota_micro: number }[]; min_yuan: number; max_yuan: number }>("/opc/compute/recharge/packages"),
// client_type="mp":桌面端展示小程序码,微信扫码在小程序内完成支付(与扫码登录同构)
createOpcRechargeOrder: (body: { client_type: "mp" | "native" | "jsapi"; package_id?: string; amount_yuan?: number }) =>
request<{ order_no: string; amount_fen: number; quota_micro: number; bonus_quota_micro: number; status: string; code_url?: string; qr_image?: string; pay_params?: Record<string, string>; expires_at: string }>("/opc/compute/recharge/orders", { method: "POST", body: JSON.stringify(body) }),
getOpcRechargePayParams: (orderNo: string) =>
request<{ order: { order_no: string; amount_fen: number; quota_micro: number; status: string }; pay_params: Record<string, string> | null; finished: boolean }>(`/opc/compute/recharge/orders/${orderNo}/pay-params`),
getOpcRechargeStatus: (orderNo: string) =>
request<{ order_no: string; status: string; paid: boolean; credited: boolean; amount_fen: number; quota_micro: number; transaction_id: string; expires_at: string }>(`/opc/compute/recharge/orders/${orderNo}/status`, { method: "POST", body: "{}" }),
getOpcRechargeOrders: () => request<{ items: { order_no: string; amount_fen: number; quota_micro: number; status: string; created_at: string; paid_at: string }[] }>("/opc/compute/recharge/orders"),
// ---- 各业务端口工作台 ----
getEnterpriseDashboard: () => request<PortDashboardData>("/enterprise/dashboard"),
+1 -1
View File
@@ -364,7 +364,7 @@ export default function Header() {
*/}
<Slot name="header.logo" kind="replace">
<img
src="/logo.png"
src="/logo-light.svg"
alt="PineAgents"
className={styles.logoImg}
/>
+11
View File
@@ -3758,6 +3758,17 @@
"computeRequests": "请求",
"computeCost": "费用",
"computeNoUsage": "本月暂无用量,使用模型后显示",
"computeRecharge": "充值",
"computeRechargeTitle": "算力充值(微信扫码 · 小程序支付)",
"computeRechargePackages": "充值套餐",
"computeRechargeCustom": "自定义金额(元)",
"computeRechargePay": "生成小程序支付码",
"computeRechargeScan": "请使用微信扫码,在小程序中确认并完成支付,余额自动到账",
"computeRechargeSuccess": "充值成功,算力已到账",
"computeRechargeFailed": "下单失败",
"computeRechargeDisabled": "支付暂未配置,请联系运营",
"computeRechargeDone": "我已完成支付",
"computeRechargeBonus": "送 {{bonus}} 元",
"profile": "个人资料",
"username": "用户名",
"nickname": "昵称",
+6 -1
View File
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import { Button, Card, Col, List, Row, Space, Table, Tag, Typography, message } from "antd";
import { authApi } from "../../../api/modules/auth";
import { RequireRole } from "../../../auth/guards";
import RechargeModal from "./RechargeModal";
import styles from "./OpcList.module.less";
interface Token { id: number; name: string; key: string; created_time: number }
@@ -19,6 +20,7 @@ export default function OpcCompute() {
const [balance, setBalance] = useState<{ quota: number; used_quota: number } | null>(null);
const [usage, setUsage] = useState<{ month: string; cost_quota: number; input_tokens: number; output_tokens: number; items: any[] } | null>(null);
const [loading, setLoading] = useState(false);
const [rechargeOpen, setRechargeOpen] = useState(false);
const load = useCallback(() => {
authApi.getOpcComputeBase().then(setBase).catch(() => {});
@@ -86,7 +88,8 @@ export default function OpcCompute() {
/>
</Card>
<Card title={t("role.opc.computeUsage")} style={{ marginTop: 12 }} size="small">
<Card title={t("role.opc.computeUsage")} style={{ marginTop: 12 }} size="small"
extra={<Button type="primary" size="small" onClick={() => setRechargeOpen(true)}>{t("role.opc.computeRecharge")}</Button>}>
<Space size={24} wrap>
<div><Typography.Text type="secondary">{t("role.opc.computeBalance")}</Typography.Text>
<div className={styles.itemTitle}>¥{((balance?.quota ?? 0) / 1000000).toFixed(4)}</div></div>
@@ -131,6 +134,8 @@ export default function OpcCompute() {
</Card>
</Col>
</Row>
<RechargeModal open={rechargeOpen} onClose={() => setRechargeOpen(false)} onPaid={load} />
</div>
</RequireRole>
);
@@ -0,0 +1,124 @@
// 算力充值对话框:套餐/自定义金额 → 展示小程序码 → 微信扫码在小程序内完成支付 → 轮询到账
// (与「小程序扫码登录」同构:桌面出码,支付能力统一由小程序 wx.requestPayment 承载)
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Image, InputNumber, Modal, Radio, Space, Tag, Typography, message } from "antd";
import { authApi } from "../../../api/modules/auth";
interface Pkg { id: string; amount: number; bonus: number; label: string; quota_micro: number }
interface Packages { enabled: boolean; items: Pkg[]; min_yuan: number; max_yuan: number }
const MICRO = 1_000_000;
export default function RechargeModal({ open, onClose, onPaid }: {
open: boolean;
onClose: () => void;
onPaid: () => void;
}) {
const { t } = useTranslation();
const [pkgs, setPkgs] = useState<Packages | null>(null);
const [pkgId, setPkgId] = useState<string>("");
const [custom, setCustom] = useState<number | null>(null);
const [order, setOrder] = useState<{ order_no: string; qr_image: string; quota_micro: number } | null>(null);
const [busy, setBusy] = useState(false);
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
const loadPkgs = () => {
authApi.getOpcRechargePackages()
.then((r) => { setPkgs(r); setPkgId(r.items?.[0]?.id ?? ""); })
.catch(() => setPkgs({ enabled: false, items: [], min_yuan: 1, max_yuan: 5000 }));
};
const stopPoll = () => { if (timer.current) { clearInterval(timer.current); timer.current = null; } };
const poll = (orderNo: string) => {
stopPoll();
timer.current = setInterval(async () => {
try {
const st = await authApi.getOpcRechargeStatus(orderNo);
if (st.paid) {
stopPoll();
message.success(t("role.opc.computeRechargeSuccess"));
setOrder(null);
onPaid();
onClose();
}
} catch { /* 忽略瞬时错误,下一轮重试 */ }
}, 2000);
};
useEffect(() => {
if (open) { loadPkgs(); } else { stopPoll(); setOrder(null); setCustom(null); }
return stopPoll;
}, [open]);
const submit = async () => {
if (!pkgId && !(custom && custom > 0)) return;
setBusy(true);
try {
const body = pkgId
? { client_type: "mp" as const, package_id: pkgId }
: { client_type: "mp" as const, amount_yuan: custom ?? 0 };
const res = await authApi.createOpcRechargeOrder(body);
if (!res.qr_image) throw new Error("小程序码生成失败,请稍后重试");
setOrder(res);
poll(res.order_no);
} catch (err) {
message.error(err instanceof Error ? err.message : t("role.opc.computeRechargeFailed"));
} finally { setBusy(false); }
};
const min = pkgs?.min_yuan ?? 1;
const max = pkgs?.max_yuan ?? 5000;
return (
<Modal
title={t("role.opc.computeRechargeTitle")}
open={open}
onCancel={() => { stopPoll(); setOrder(null); onClose(); }}
footer={order ? [
<Button key="done" onClick={() => { stopPoll(); setOrder(null); loadPkgs(); }}>{t("role.opc.computeRechargeDone")}</Button>,
] : [
<Button key="cancel" onClick={() => onClose()}>{t("common.cancel", "取消")}</Button>,
<Button key="pay" type="primary" loading={busy} disabled={!pkgs?.enabled || (!pkgId && !(custom && custom > 0))}
onClick={submit}>{t("role.opc.computeRechargePay")}</Button>,
]}
>
{pkgs && !pkgs.enabled && (
<Typography.Paragraph type="warning">{t("role.opc.computeRechargeDisabled")}</Typography.Paragraph>
)}
{!order ? (
<>
{pkgs?.items?.length ? (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>{t("role.opc.computeRechargePackages")}</Typography.Paragraph>
<Radio.Group value={pkgId} onChange={(e) => { setPkgId(e.target.value); setCustom(null); }} style={{ width: "100%" }}>
<Space wrap>
{pkgs.items.map((p) => (
<Radio.Button key={p.id} value={p.id}>
¥{p.amount}{p.bonus > 0 ? <Tag color="orange" style={{ marginLeft: 6 }}>{t("role.opc.computeRechargeBonus", { bonus: p.bonus })}</Tag> : null}
</Radio.Button>
))}
</Space>
</Radio.Group>
</>
) : null}
<div style={{ marginTop: 16 }}>
<Typography.Paragraph type="secondary" style={{ marginBottom: 8 }}>{t("role.opc.computeRechargeCustom")}{min} ~ {max}</Typography.Paragraph>
<InputNumber min={min} max={max} precision={0} value={custom}
onChange={(v) => { setCustom(v); setPkgId(""); }} style={{ width: 200 }} />
</div>
</>
) : (
<div style={{ textAlign: "center" }}>
<Image src={order.qr_image} width={220} preview={false} />
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>{t("role.opc.computeRechargeScan")}</Typography.Paragraph>
<Typography.Text code>{order.order_no}</Typography.Text>
<div style={{ marginTop: 8 }}>
<Typography.Text strong>¥{(order.quota_micro / MICRO).toFixed(2)}</Typography.Text>
</div>
</div>
)}
</Modal>
);
}
+38
View File
@@ -291,6 +291,16 @@ async def opc_compute_prices(request: Request):
return await forward("GET", "/opc/compute/prices", auth_header=_auth(request))
@router.get("/opc/compute/balance")
async def opc_compute_balance(request: Request):
return await forward("GET", "/opc/compute/balance", auth_header=_auth(request))
@router.get("/opc/compute/usage")
async def opc_compute_usage(request: Request):
return await forward("GET", "/opc/compute/usage", auth_header=_auth(request))
@router.get("/opc/compute/tokens")
async def opc_compute_tokens(request: Request):
return await forward("GET", "/opc/compute/tokens", auth_header=_auth(request))
@@ -306,6 +316,34 @@ async def opc_compute_tokens_delete(token_id: int, request: Request):
return await forward("DELETE", f"/opc/compute/tokens/{token_id}", auth_header=_auth(request))
# ---------------------------------------------------------------------------
# 算力充值(转发 server-core /opc/compute/recharge;微信回调不经本地后端,直连公网)
# ---------------------------------------------------------------------------
@router.get("/opc/compute/recharge/packages")
async def opc_compute_recharge_packages(request: Request):
return await forward("GET", "/opc/compute/recharge/packages", auth_header=_auth(request))
@router.post("/opc/compute/recharge/orders")
async def opc_compute_recharge_create(req: dict, request: Request):
return await forward("POST", "/opc/compute/recharge/orders", body=req, auth_header=_auth(request))
@router.get("/opc/compute/recharge/orders/{order_no}/pay-params")
async def opc_compute_recharge_pay_params(order_no: str, request: Request):
return await forward("GET", f"/opc/compute/recharge/orders/{order_no}/pay-params", auth_header=_auth(request))
@router.post("/opc/compute/recharge/orders/{order_no}/status")
async def opc_compute_recharge_status(order_no: str, request: Request):
return await forward("POST", f"/opc/compute/recharge/orders/{order_no}/status", body={}, auth_header=_auth(request))
@router.get("/opc/compute/recharge/orders")
async def opc_compute_recharge_orders(request: Request):
return await forward("GET", "/opc/compute/recharge/orders", auth_header=_auth(request))
# ---------------------------------------------------------------------------
# 各业务端口工作台
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -164,10 +164,10 @@ SECRET_DIR = (
# Env key for overriding the OS keychain account used for the master key.
KEYRING_ACCOUNT_ENV = "QWENPAW_KEYRING_ACCOUNT"
# 演示 FastAPI 服务地址:登录/演示接口由 pineagents-demo 提供,本项目后端转发过去。
# 后端服务地址
DEMO_API_BASE_URL = EnvVarLoader.get_str(
"PINEAGENTS_DEMO_BASE_URL",
"http://192.168.1.9:8090",
"https://opc.pinesound.cn",
)
PROJECT_NAME = BRAND.project_name
@@ -1400,6 +1400,9 @@ class ProviderManager: # pylint: disable=too-many-public-methods
logger.warning("Failed to migrate legacy providers: %s", e)
self._init_from_storage()
self._apply_default_annotations()
# PineAgents(算力中心)中继按登录用户归户计量:注入本地持久化的服务端 JWT
# (置于 _init_from_storage 之后,避免被持久化配置覆盖)。
self._refresh_pineagents_auth()
def _prepare_disk_storage(self):
"""Prepare directory structure"""
@@ -1454,7 +1457,31 @@ class ProviderManager: # pylint: disable=too-many-public-methods
def _add_builtin(self, provider: Provider):
self.builtin_providers[provider.id] = provider
def _refresh_pineagents_auth(self) -> None:
"""把本地持久化的服务端 JWT 注入 PineAgents provider 请求头。
server-core /v1 中继据 JWT 归户到该用户的算力令牌并按其余额计量/拦截;
未登录时移除该头(引擎侧拒绝匿名调用)。
"""
pine = self.builtin_providers.get("pineagents")
if pine is None:
return
try:
from ..app.auth_token_store import load_auth_token
token = load_auth_token()
except Exception: # noqa: BLE001
token = ""
headers = dict(pine.custom_headers or {})
if token:
headers["Authorization"] = f"Bearer {token}"
else:
headers.pop("Authorization", None)
pine.custom_headers = headers
async def list_provider_info(self) -> List[ProviderInfo]:
# 登录态可能变化(登录/换发/登出),先刷新 PineAgents 鉴权头再拉模型。
self._refresh_pineagents_auth()
# PineAgents(算力中心):动态拉取 admin 模型(server-core /v1/models),失败回退静态 DEEPSEEK_MODELS。
pine = self.builtin_providers.get("pineagents")
if pine is not None and hasattr(pine, "fetch_models"):