Compare commits

...

2 Commits

Author SHA1 Message Date
Pine fb7e0edd8f chore: 登录请求工具微调与品牌资源更新
Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 22:33:16 +08:00
Pine 8787e7f27c feat(miniprogram): 算力管理页与扫码确认支付页
- 个人中心新增算力管理入口:余额/已用/本月消费、基础API地址与API Key自服务、套餐+自定义金额充值
- 新增 pages/pay 确认支付页:微信扫桌面端充值码进入,按登录用户 openid 发起 JSAPI 支付并轮询到账
- 两页沉浸式导航+统一两行标题区;套餐单行排列、提示单行截断

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-30 22:33:16 +08:00
11 changed files with 597 additions and 6 deletions
+3 -1
View File
@@ -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',
@@ -0,0 +1 @@
export default { navigationStyle: 'custom' };
+222
View File
@@ -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 (
<View className="page-in">
<PageHeader no="04" title="算力管理" en="COMPUTE" back />
{!authed && <View className="card"><Text className="muted">请先登录我的 登录绑定</Text></View>}
{authed && (
<View className="cc-wrap">
{/* 余额 */}
<View className="card cc-balance">
<Text className="cc-label">算力余额</Text>
<Text className="cc-amount">{loaded && balance ? yuan(balance.quota) : '—'}</Text>
<View className="cc-balance-row">
<Text className="muted">已用 {balance ? yuan(balance.used_quota) : '—'}</Text>
<Text className="muted">本月消费 {usageCost != null ? yuan(usageCost) : '—'}</Text>
</View>
</View>
{/* 接入地址 */}
<View className="card cc-card">
<Text className="cc-title">基础 API 地址</Text>
<View className="cc-row" onClick={() => base && copy(base.base_url, '地址已复制')}>
<Text className="cc-mono">{base ? base.base_url : '—'}</Text>
<Text className="cc-act">复制</Text>
</View>
<Text className="muted cc-hint">填入 OpenAI 兼容客户端base_url + 下方 API Key即可调用平台模型</Text>
</View>
{/* API Key */}
<View className="card cc-card">
<View className="cc-headrow">
<Text className="cc-title">我的 API Key</Text>
<Button className="btn-ghost cc-btn-sm" onClick={createToken}>新建</Button>
</View>
{tokens.length === 0 ? <Text className="muted">暂无令牌点击新建创建</Text> : null}
{tokens.map((tk) => (
<View className="cc-token" key={String(tk.id)}>
<Text className="cc-mono cc-token-key">{tk.key}</Text>
<View className="cc-token-acts">
<Text className="cc-act" onClick={() => copy(tk.key, 'Key 已复制')}>复制</Text>
<Text className="cc-act cc-danger" onClick={() => delToken(tk.id)}>删除</Text>
</View>
</View>
))}
</View>
{/* 充值 */}
<View className="card cc-card">
<Text className="cc-title">算力充值</Text>
{!payEnabled ? (
<Text className="muted">支付暂未配置请联系运营或前往桌面端充值</Text>
) : (
<View>
<View className="cc-pkgs">
{pkgs.map((p) => (
<View
key={p.id}
className={`cc-pkg ${pkgId === p.id ? 'on' : ''}`}
onClick={() => { setPkgId(p.id); setCustom(''); }}
>
<Text className="cc-pkg-amount">¥{p.pay != null ? p.pay : p.amount}</Text>
{p.amount > (p.pay ?? p.amount) ? <Text className="cc-pkg-bonus">{p.discount} 折扣</Text> : null}
{p.bonus > 0 ? <Text className="cc-pkg-bonus"> ¥{p.bonus}</Text> : null}
</View>
))}
</View>
<View className="cc-custom">
<Input
className="cc-input"
type="digit"
placeholder="自定义金额(元)"
value={custom}
onInput={(e) => { setCustom(e.detail.value); setPkgId(''); }}
/>
</View>
<Button className="btn-main cc-paybtn" loading={paying} onClick={pay}>微信支付</Button>
</View>
)}
</View>
{err && <Text className="cc-err">{err}</Text>}
</View>
)}
</View>
);
}
+172
View File
@@ -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;
}
+15 -2
View File
@@ -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() {
<Text className="muted mk-center-hint">任务广场请到 Web 端或桌面端查看</Text>
</View>
{/* 算力管理:余额 / API Key / 算力充值(对齐桌面端算力中心) */}
<View className="mine-section-head">
<Text className="mine-no">COMPUTE</Text>
<Text className="mine-subtitle">算力管理</Text>
</View>
<View className="card mk-act">
<Button className="btn-main mk-btn-sm" onClick={() => Taro.navigateTo({ url: '/pages/compute/index' })}>算力中心</Button>
<Text className="muted mk-center-hint">查看余额与 API Key支持微信充值算力</Text>
</View>
{/* 我的园区:未入驻 → 申请入驻 + OPC认证;已入驻 → 园区信息 + OPC认证 + 转园 */}
<View className="mine-section-head">
<Text className="mine-no">PARK</Text>
@@ -0,0 +1 @@
export default { navigationStyle: 'custom' };
+118
View File
@@ -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 (
<View className="page-in">
<PageHeader no="05" title="确认支付" en="PAY" back />
{!isAuthed() && (
<View className="card pay-card">
<Text className="muted">请先在我的中登录后再扫码支付</Text>
</View>
)}
{isAuthed() && (
<View className="card pay-card">
{!scene ? (
<Text className="muted">缺少订单信息请从桌面端重新生成充值码</Text>
) : err ? (
<Text className="pay-err">{err}</Text>
) : !order ? (
<Text className="muted">{busy ? '加载中…' : '下拉或点击按钮获取订单'}</Text>
) : paid || finished ? (
<View className="pay-done">
<Text className="pay-title">{paid ? '支付成功' : '订单已结束'}</Text>
<Text className="muted">{paid ? '算力余额将自动到账,可在「算力管理」中查看。' : (order.status === 'credited' ? '该订单已完成充值。' : '订单已过期或取消,请重新下单。')}</Text>
</View>
) : (
<View className="pay-body">
<Text className="pay-label">算力充值</Text>
<Text className="pay-amount">{yuan(order.quota_micro)}</Text>
<Text className="muted">订单号 {order.order_no}</Text>
<Button className="btn-main pay-btn" loading={busy} onClick={pay}>立即支付</Button>
<Text className="muted pay-hint">由微信支付提供收款服务支付后余额自动到账</Text>
</View>
)}
{!order && !err && scene ? (
<Button className="btn-ghost pay-btn" loading={busy} onClick={load}>获取订单</Button>
) : null}
</View>
)}
</View>
);
}
+59
View File
@@ -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;
}
+4
View File
@@ -27,6 +27,10 @@ export function request(method, path, data = {}, withAuth = false) {
const d = res.data || {};
if (res.statusCode >= 200 && res.statusCode < 300 && d.ok !== false) {
resolve(d);
} else if (res.statusCode === 401) {
// 登录已失效(token 过期/被吊销/token_version 变更):清掉假登录态,引导重新登录
Taro.removeStorageSync('pine_token');
reject(new Error('登录已过期,请重新登录'));
} else {
reject(new Error(d.detail || d.error || '请求失败,请稍后重试'));
}
+2 -3
View File
@@ -1,8 +1,7 @@
import Taro from '@tarojs/taro';
import { request } from './api';
/* 用户端认证(统一账号):登录/资料走平台 /auth(单一 users + 平台 JWT
手机绑定走培训 /api/auth(阶段3 后同样读写平台 users + 验平台 JWT)。
/* 用户端认证(统一账号):登录/资料/手机绑定均走平台 /auth(单一 users + 平台 JWT
与 web 端一致,见 小程序同步约束.md */
/** pine_user 契约字段(各页面读取):username/name/avatar/phone/phoneBound/status/topics/source */
@@ -103,7 +102,7 @@ export async function bindPhone(phone, code) {
const data = await request('POST', '/auth/bind-phone', { phone, code }, true);
if (data.token) {
Taro.setStorageSync('pine_token', data.token);
Taro.setStorageSync('pine_user', JSON.stringify(pickUser(data)));
saveUser(data); // pine_user 统一存对象(此前误存 JSON 字符串致资料页字段丢失)
}
return { ok: true, phone, phoneBound: true, ...data };
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

After

Width:  |  Height:  |  Size: 2.0 MiB