From 8787e7f27c6a81a79e277c97807755517fec717f Mon Sep 17 00:00:00 2001 From: Pine Date: Sun, 30 Aug 2026 22:33:16 +0800 Subject: [PATCH] =?UTF-8?q?feat(miniprogram):=20=E7=AE=97=E5=8A=9B?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E9=A1=B5=E4=B8=8E=E6=89=AB=E7=A0=81=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E6=94=AF=E4=BB=98=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 个人中心新增算力管理入口:余额/已用/本月消费、基础API地址与API Key自服务、套餐+自定义金额充值 - 新增 pages/pay 确认支付页:微信扫桌面端充值码进入,按登录用户 openid 发起 JSAPI 支付并轮询到账 - 两页沉浸式导航+统一两行标题区;套餐单行排列、提示单行截断 Co-Authored-By: Claude --- miniprogram/src/app.config.js | 4 +- miniprogram/src/pages/compute/index.config.js | 1 + miniprogram/src/pages/compute/index.jsx | 222 ++++++++++++++++++ miniprogram/src/pages/compute/index.scss | 172 ++++++++++++++ miniprogram/src/pages/mine/index.jsx | 17 +- miniprogram/src/pages/pay/index.config.js | 1 + miniprogram/src/pages/pay/index.jsx | 118 ++++++++++ miniprogram/src/pages/pay/index.scss | 59 +++++ 8 files changed, 591 insertions(+), 3 deletions(-) create mode 100644 miniprogram/src/pages/compute/index.config.js create mode 100644 miniprogram/src/pages/compute/index.jsx create mode 100644 miniprogram/src/pages/compute/index.scss create mode 100644 miniprogram/src/pages/pay/index.config.js create mode 100644 miniprogram/src/pages/pay/index.jsx create mode 100644 miniprogram/src/pages/pay/index.scss diff --git a/miniprogram/src/app.config.js b/miniprogram/src/app.config.js index bfefb59..67137bf 100644 --- a/miniprogram/src/app.config.js +++ b/miniprogram/src/app.config.js @@ -20,7 +20,9 @@ export default { 'pages/my-bookings/index', 'pages/my-tasks/index', 'pages/my-park/index', - 'pages/my-cert/index' + 'pages/my-cert/index', + 'pages/compute/index', + 'pages/pay/index' ], window: { backgroundTextStyle: 'light', diff --git a/miniprogram/src/pages/compute/index.config.js b/miniprogram/src/pages/compute/index.config.js new file mode 100644 index 0000000..a4049ce --- /dev/null +++ b/miniprogram/src/pages/compute/index.config.js @@ -0,0 +1 @@ +export default { navigationStyle: 'custom' }; diff --git a/miniprogram/src/pages/compute/index.jsx b/miniprogram/src/pages/compute/index.jsx new file mode 100644 index 0000000..8704a03 --- /dev/null +++ b/miniprogram/src/pages/compute/index.jsx @@ -0,0 +1,222 @@ +import { View, Text, Button, Input } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import { useState, useEffect, useRef } from 'react'; +import { request } from '@/utils/api'; +import { isAuthed } from '@/utils/auth'; +import { PageHeader } from '@/components/TopInset'; +import './index.scss'; + +const MICRO = 1000000; // 1 元 = 1,000,000 微元(与引擎计费口径一致) + +const yuan = (micro) => `¥${((micro || 0) / MICRO).toFixed(2)}`; + +/** 算力管理(C端):余额 / Base URL / API Key 自服务 / 算力充值(微信 JSAPI 支付)。 + * 功能对齐桌面端「算力中心」。 */ +export default function ComputeCenter() { + const [authed] = useState(isAuthed()); + const [loaded, setLoaded] = useState(false); + const [err, setErr] = useState(''); + const [balance, setBalance] = useState(null); // { quota, used_quota } + const [base, setBase] = useState(null); // { base_url, username } + const [tokens, setTokens] = useState([]); // [{ id, name, key }] + const [usageCost, setUsageCost] = useState(null); + + // 充值状态 + const [pkgs, setPkgs] = useState([]); // [{ id, amount, bonus, label }] + const [payEnabled, setPayEnabled] = useState(false); + const [pkgId, setPkgId] = useState(''); + const [custom, setCustom] = useState(''); + const [paying, setPaying] = useState(false); + const pollRef = useRef(null); + + const load = async () => { + try { + const [bal, b, tk, us, pk] = await Promise.all([ + request('GET', '/opc/compute/balance', {}, true).catch(() => null), + request('GET', '/opc/compute/base', {}, true).catch(() => null), + request('GET', '/opc/compute/tokens', {}, true).catch(() => ({ items: [] })), + request('GET', '/opc/compute/usage', {}, true).catch(() => null), + request('GET', '/opc/compute/recharge/packages', {}, true).catch(() => ({ enabled: false, items: [] })) + ]); + setBalance(bal); + setBase(b); + setTokens(tk && tk.items ? tk.items : []); + setUsageCost(us && us.cost_quota != null ? us.cost_quota : null); + setPayEnabled(!!pk.enabled); + setPkgs(pk.items || []); + setPkgId((pk.items && pk.items[0] && pk.items[0].id) || ''); + setErr(''); + } catch (e) { setErr((e && e.message) || '加载失败'); } + finally { setLoaded(true); } + }; + + useEffect(() => { + if (authed) load(); + else setLoaded(true); + return () => { if (pollRef.current) clearInterval(pollRef.current); }; + }, []); + + const copy = (text, tip = '已复制') => { + const val = text == null ? '' : String(text); + if (!val) { + Taro.showToast({ title: '内容为空,稍后再试', icon: 'none' }); + return; + } + Taro.setClipboardData({ text: val, success: () => Taro.showToast({ title: tip, icon: 'success' }) }); + }; + + const createToken = async () => { + try { + await request('POST', '/opc/compute/tokens', {}, true); + Taro.showToast({ title: '令牌已创建', icon: 'success' }); + load(); + } catch (e) { Taro.showToast({ title: (e && e.message) || '创建失败', icon: 'none' }); } + }; + + const delToken = async (id) => { + try { + await request('DELETE', `/opc/compute/tokens/${id}`, {}, true); + Taro.showToast({ title: '已删除', icon: 'success' }); + load(); + } catch (e) { Taro.showToast({ title: (e && e.message) || '删除失败', icon: 'none' }); } + }; + + const pollStatus = (orderNo) => { + if (pollRef.current) clearInterval(pollRef.current); + let tries = 0; + pollRef.current = setInterval(async () => { + tries += 1; + if (tries > 40) { // 约 60 秒后停止轮询(回调/对账兜底会自动到账) + clearInterval(pollRef.current); + pollRef.current = null; + return; + } + try { + const st = await request('POST', `/opc/compute/recharge/orders/${orderNo}/status`, {}, true); + if (st.paid) { + if (pollRef.current) clearInterval(pollRef.current); + pollRef.current = null; + Taro.showToast({ title: '充值成功,已到账', icon: 'success' }); + load(); + } + } catch { /* 瞬时错误忽略,下一轮重试 */ } + }, 1500); + }; + + const pay = async () => { + const amountYuan = parseFloat(custom); + const body = pkgId + ? { client_type: 'jsapi', package_id: pkgId } + : { client_type: 'jsapi', amount_yuan: amountYuan }; + if (!pkgId && (!amountYuan || amountYuan <= 0)) { + Taro.showToast({ title: '请选择套餐或输入金额', icon: 'none' }); + return; + } + setPaying(true); + try { + const order = await request('POST', '/opc/compute/recharge/orders', body, true); + const pp = order.pay_params || {}; + Taro.requestPayment({ + timeStamp: pp.timeStamp, + nonceStr: pp.nonceStr, + package: pp.package, + signType: pp.signType || 'RSA', + paySign: pp.paySign, + success: () => pollStatus(order.order_no), + fail: (e) => { + const msg = (e && e.errMsg) || ''; + if (msg.indexOf('cancel') >= 0) Taro.showToast({ title: '已取消支付', icon: 'none' }); + else Taro.showToast({ title: '支付失败,请重试', icon: 'none' }); + } + }); + } catch (e) { + Taro.showToast({ title: (e && e.message) || '下单失败', icon: 'none' }); + } finally { setPaying(false); } + }; + + return ( + + + + {!authed && 请先登录(我的 → 登录绑定)。} + + {authed && ( + + {/* 余额 */} + + 算力余额 + {loaded && balance ? yuan(balance.quota) : '—'} + + 已用 {balance ? yuan(balance.used_quota) : '—'} + 本月消费 {usageCost != null ? yuan(usageCost) : '—'} + + + + {/* 接入地址 */} + + 基础 API 地址 + base && copy(base.base_url, '地址已复制')}> + {base ? base.base_url : '—'} + 复制 + + 填入 OpenAI 兼容客户端(base_url + 下方 API Key)即可调用平台模型。 + + + {/* API Key */} + + + 我的 API Key + + + {tokens.length === 0 ? 暂无令牌,点击「新建」创建。 : null} + {tokens.map((tk) => ( + + {tk.key} + + copy(tk.key, 'Key 已复制')}>复制 + delToken(tk.id)}>删除 + + + ))} + + + {/* 充值 */} + + 算力充值 + {!payEnabled ? ( + 支付暂未配置,请联系运营或前往桌面端充值。 + ) : ( + + + {pkgs.map((p) => ( + { setPkgId(p.id); setCustom(''); }} + > + ¥{p.pay != null ? p.pay : p.amount} + {p.amount > (p.pay ?? p.amount) ? {p.discount} 折扣 : null} + {p.bonus > 0 ? 送 ¥{p.bonus} : null} + + ))} + + + { setCustom(e.detail.value); setPkgId(''); }} + /> + + + + )} + + + {err && {err}} + + )} + + ); +} diff --git a/miniprogram/src/pages/compute/index.scss b/miniprogram/src/pages/compute/index.scss new file mode 100644 index 0000000..addc90a --- /dev/null +++ b/miniprogram/src/pages/compute/index.scss @@ -0,0 +1,172 @@ +/* 算力管理页:沿用全局 Soft UI 毛玻璃卡片(.card),仅补页内布局与算力专属样式 */ +@import '../../app.scss'; + +.cc-wrap { + padding: 0 24rpx 40rpx; +} + +/* 余额卡:品牌渐变强调 */ +.cc-balance { + display: flex; + flex-direction: column; + gap: 8rpx; + padding: 32rpx; + background: linear-gradient(135deg, rgba(98, 132, 255, 0.16), rgba(255, 255, 255, 0.55)); +} + +.cc-label { + font-size: 24rpx; + color: #8a8a93; +} + +.cc-amount { + font-size: 60rpx; + font-weight: 700; + color: #2b2f36; + letter-spacing: 1rpx; +} + +.cc-balance-row { + display: flex; + justify-content: space-between; + margin-top: 6rpx; +} + +/* 普通卡 */ +.cc-card { + margin-top: 24rpx; + padding: 28rpx; + display: flex; + flex-direction: column; + gap: 14rpx; +} + +.cc-title { + font-size: 28rpx; + font-weight: 600; + color: #2b2f36; +} + +.cc-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; +} + +.cc-mono { + font-family: Menlo, Consolas, monospace; + font-size: 24rpx; + color: #4a4f57; + word-break: break-all; +} + +.cc-act { + flex-shrink: 0; + font-size: 24rpx; + color: #6284ff; +} + +.cc-danger { + color: #e5484d; + margin-left: 20rpx; +} + +.cc-hint { + font-size: 22rpx; + line-height: 1.6; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cc-headrow { + display: flex; + align-items: center; + justify-content: space-between; +} + +.cc-btn-sm { + font-size: 24rpx; + padding: 0 24rpx; + height: 56rpx; + line-height: 56rpx; +} + +.cc-token { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16rpx; + padding: 14rpx 0; + border-top: 1rpx solid rgba(0, 0, 0, 0.05); +} + +.cc-token-key { + flex: 1; +} + +.cc-token-acts { + flex-shrink: 0; +} + +/* 充值套餐:单行等宽排列(数量多时横向滚动) */ +.cc-pkgs { + display: flex; + flex-wrap: nowrap; + gap: 12rpx; + overflow-x: auto; +} + +.cc-pkg { + flex: 1 0 auto; + min-width: 120rpx; + padding: 12rpx 16rpx; + border-radius: 16rpx; + background: rgba(255, 255, 255, 0.7); + border: 2rpx solid rgba(0, 0, 0, 0.06); + display: flex; + flex-direction: column; + align-items: center; + gap: 2rpx; +} + +.cc-pkg.on { + border-color: #6284ff; + background: rgba(98, 132, 255, 0.1); +} + +.cc-pkg-amount { + font-size: 28rpx; + font-weight: 700; + color: #2b2f36; + white-space: nowrap; +} + +.cc-pkg-bonus { + font-size: 20rpx; + color: #e8830c; + white-space: nowrap; +} + +.cc-custom { + margin-top: 8rpx; +} + +.cc-input { + background: rgba(255, 255, 255, 0.7); + border-radius: 18rpx; + padding: 16rpx 24rpx; + font-size: 26rpx; +} + +.cc-paybtn { + margin-top: 12rpx; +} + +.cc-err { + display: block; + margin: 20rpx 8rpx; + font-size: 24rpx; + color: #e5484d; +} diff --git a/miniprogram/src/pages/mine/index.jsx b/miniprogram/src/pages/mine/index.jsx index 33a3875..29b462f 100644 --- a/miniprogram/src/pages/mine/index.jsx +++ b/miniprogram/src/pages/mine/index.jsx @@ -69,11 +69,14 @@ export default function Mine() { const [pDebug, setPDebug] = useState(''); const refresh = async () => { - if (!isAuthed()) return; + if (!isAuthed()) { setUser(null); setLoaded(true); return; } try { const me = await getMe(); if (me.ok && me.user) setUser(getUser()); - } catch { /* ignore */ } + } catch (e) { + // 401 时 api.js 已清 pine_token:同步清掉本地假登录态,显示未登录卡片 + if (!isAuthed()) { setUser(null); logout(); } + } try { const d = await getMyBookings(); setBookings((d && d.list) || []); @@ -449,6 +452,16 @@ export default function Mine() { 任务广场请到 Web 端或桌面端查看 + {/* 算力管理:余额 / API Key / 算力充值(对齐桌面端算力中心) */} + + COMPUTE + 算力管理 + + + + 查看余额与 API Key,支持微信充值算力 + + {/* 我的园区:未入驻 → 申请入驻 + OPC认证;已入驻 → 园区信息 + OPC认证 + 转园 */} PARK diff --git a/miniprogram/src/pages/pay/index.config.js b/miniprogram/src/pages/pay/index.config.js new file mode 100644 index 0000000..a4049ce --- /dev/null +++ b/miniprogram/src/pages/pay/index.config.js @@ -0,0 +1 @@ +export default { navigationStyle: 'custom' }; diff --git a/miniprogram/src/pages/pay/index.jsx b/miniprogram/src/pages/pay/index.jsx new file mode 100644 index 0000000..4444a99 --- /dev/null +++ b/miniprogram/src/pages/pay/index.jsx @@ -0,0 +1,118 @@ +import { View, Text, Button } from '@tarojs/components'; +import Taro from '@tarojs/taro'; +import { useState, useEffect } from 'react'; +import { request } from '@/utils/api'; +import { isAuthed } from '@/utils/auth'; +import { PageHeader } from '@/components/TopInset'; +import './index.scss'; + +const MICRO = 1000000; // 1 元 = 1,000,000 微元 + +const yuan = (micro) => `¥${((micro || 0) / MICRO).toFixed(2)}`; + +/** 小程序确认支付页:微信扫桌面端「算力充值」小程序码进入(scene=订单号), + * 取 JSAPI 参数后 wx.requestPayment 拉起支付(与扫码登录同构的确认页)。 */ +export default function PayConfirm() { + const [scene] = useState(() => { + const router = Taro.getCurrentInstance ? Taro.getCurrentInstance().router : null; + const raw = (router && (router.params.scene || router.params.order_no)) || ''; + return decodeURIComponent(raw); + }); + const [info, setInfo] = useState(null); // { order: {...}, pay_params } + const [finished, setFinished] = useState(false); + const [err, setErr] = useState(''); + const [busy, setBusy] = useState(false); + const [paid, setPaid] = useState(false); + + const load = async () => { + if (!scene) { setErr('缺少订单信息(scene 为空)'); return; } + setBusy(true); + try { + const r = await request('GET', `/opc/compute/recharge/orders/${scene}/pay-params`, {}, true); + setInfo(r); + setFinished(!!r.finished); + setErr(''); + } catch (e) { setErr((e && e.message) || '加载订单失败'); } + finally { setBusy(false); } + }; + + // 进入即取一次(登录态就绪时) + useEffect(() => { if (isAuthed()) load(); }, []); + + const pay = async () => { + if (!info || !info.pay_params) return; + const pp = info.pay_params; + setBusy(true); + try { + await new Promise((resolve, reject) => { + Taro.requestPayment({ + timeStamp: pp.timeStamp, + nonceStr: pp.nonceStr, + package: pp.package, + signType: pp.signType || 'RSA', + paySign: pp.paySign, + success: resolve, + fail: reject + }); + }); + setPaid(true); + Taro.showToast({ title: '支付成功,余额到账中', icon: 'success' }); + // 轮询确认后端到账(回调/对账兜底) + let tries = 0; + const timer = setInterval(async () => { + tries += 1; + if (tries > 40) { clearInterval(timer); return; } + try { + const st = await request('POST', `/opc/compute/recharge/orders/${scene}/status`, {}, true); + if (st.paid) { clearInterval(timer); Taro.showToast({ title: '充值成功,已到账', icon: 'success' }); } + } catch { /* 忽略瞬时错误 */ } + }, 1500); + } catch (e) { + const msg = (e && e.errMsg) || ''; + if (msg.indexOf('cancel') >= 0) Taro.showToast({ title: '已取消支付', icon: 'none' }); + else Taro.showToast({ title: '支付失败,请重试', icon: 'none' }); + } finally { setBusy(false); } + }; + + const order = info && info.order; + + return ( + + + + {!isAuthed() && ( + + 请先在「我的」中登录后再扫码支付。 + + )} + + {isAuthed() && ( + + {!scene ? ( + 缺少订单信息,请从桌面端重新生成充值码。 + ) : err ? ( + {err} + ) : !order ? ( + {busy ? '加载中…' : '下拉或点击按钮获取订单'} + ) : paid || finished ? ( + + {paid ? '支付成功' : '订单已结束'} + {paid ? '算力余额将自动到账,可在「算力管理」中查看。' : (order.status === 'credited' ? '该订单已完成充值。' : '订单已过期或取消,请重新下单。')} + + ) : ( + + 算力充值 + {yuan(order.quota_micro)} + 订单号 {order.order_no} + + 由微信支付提供收款服务,支付后余额自动到账 + + )} + {!order && !err && scene ? ( + + ) : null} + + )} + + ); +} diff --git a/miniprogram/src/pages/pay/index.scss b/miniprogram/src/pages/pay/index.scss new file mode 100644 index 0000000..555831e --- /dev/null +++ b/miniprogram/src/pages/pay/index.scss @@ -0,0 +1,59 @@ +/* 小程序确认支付页:沿用全局 Soft UI 卡片,补支付专属样式 */ +@import '../../app.scss'; + +.pay-card { + margin: 0 24rpx; + padding: 40rpx 32rpx; + min-height: 300rpx; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16rpx; + text-align: center; +} + +.pay-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 16rpx; +} + +.pay-label { + font-size: 26rpx; + color: #8a8a93; +} + +.pay-amount { + font-size: 72rpx; + font-weight: 700; + color: #2b2f36; +} + +.pay-btn { + width: 420rpx; + margin-top: 16rpx; +} + +.pay-hint { + font-size: 22rpx; +} + +.pay-err { + font-size: 26rpx; + color: #e5484d; +} + +.pay-done { + display: flex; + flex-direction: column; + align-items: center; + gap: 12rpx; +} + +.pay-title { + font-size: 36rpx; + font-weight: 700; + color: #2b2f36; +}