Files
training/miniprogram/src/pages/mine/index.jsx
T
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

507 lines
22 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { View, Text, Button, Input, Image } from '@tarojs/components';
import Taro from '@tarojs/taro';
import { useState, useEffect } from 'react';
import { useTabIndex } from '@/utils/tab';
import { isAuthed, getUser, logout, wxLogin, phoneLogin, bindPhone, sendCode, wxPhoneBind, updateProfile, getMe } from '@/utils/auth';
import { getMyBookings, checkIn, parseIso, BK_STATUS_OPTIONS, BK_TOPIC_OPTIONS, BK_SOURCE_OPTIONS } from '@/utils/booking';
import { getMyAdmission } from '@/utils/parks';
import { API_BASE } from '@/utils/api';
import Skeleton from '@/components/skeleton';
import CountdownBox from '@/components/CountdownBox';
import './index.scss';
import { PageHeader } from '@/components/TopInset';
/* 报名标签:按 auditStatus + event_status 推断 待参加 / 审核中 / 已结束 / 未通过 */
const PHASE_META = {
pending: { text: '审核中', cls: 'pending' },
rejected: { text: '未通过', cls: 'rejected' },
done: { text: '已结束', cls: 'done' },
upcoming: { text: '待参加', cls: 'upcoming' }
};
const phaseOf = (b) => {
// 兼容 camelCase(后端新契约) 与 snake_case(旧数据)
const audit = b.auditStatus !== undefined ? b.auditStatus : b.audit_status;
const evStatus = b.event_status !== undefined ? b.event_status : b.eventStatus;
if (audit === 'pending') return PHASE_META.pending;
if (audit === 'rejected') return PHASE_META.rejected;
if (evStatus === 'done') return PHASE_META.done;
return PHASE_META.upcoming;
};
/* 报名状态筛选(对齐活动页 全部/公益/... 的筛选形式) */
const PHASE_FILTERS = [
{ key: 'all', label: '全部' },
{ key: 'upcoming', label: '待参加' },
{ key: 'pending', label: '审核中' },
{ key: 'rejected', label: '未通过' },
{ key: 'done', label: '已结束' }
];
const fmtTime = (iso) => {
if (!iso) return '';
const d = new Date(iso);
const p = (n) => String(n).padStart(2, '0');
const week = ['日', '一', '二', '三', '四', '五', '六'][d.getDay()];
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}${week}`;
};
export default function Mine() {
useTabIndex(4);
const [user, setUser] = useState(getUser());
const [bookings, setBookings] = useState([]);
const [phase, setPhase] = useState('all'); // 报名状态筛选
const [admission, setAdmission] = useState([]); // 我的园区入驻记录
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
/* 绑定手机 */
const [showBind, setShowBind] = useState(false);
const [phone, setPhone] = useState('');
const [code, setCode] = useState('');
const [cd, setCd] = useState(0);
const [bindDebug, setBindDebug] = useState('');
/* 手机号登录 */
const [showPhone, setShowPhone] = useState(false);
const [pPhone, setPPhone] = useState('');
const [pCode, setPCode] = useState('');
const [pCd, setPCd] = useState(0);
const [pDebug, setPDebug] = useState('');
const refresh = async () => {
if (!isAuthed()) { setUser(null); setLoaded(true); return; }
try {
const me = await getMe();
if (me.ok && me.user) setUser(getUser());
} catch (e) {
// 401 时 api.js 已清 pine_token:同步清掉本地假登录态,显示未登录卡片
if (!isAuthed()) { setUser(null); logout(); }
}
try {
const d = await getMyBookings();
setBookings((d && d.list) || []);
} catch { /* 保留上次列表,避免重进闪空 */ }
try {
const a = await getMyAdmission();
setAdmission(Array.isArray(a) ? a : []);
} catch { /* 保留上次 */ }
finally { setLoaded(true); }
};
Taro.useDidShow(() => { refresh(); });
/* 微信一键登录 */
const onWxLogin = async () => {
setErr('');
setBusy(true);
try {
let nickName = '', avatarUrl = '';
try {
const { userInfo } = await Taro.getUserProfile({ desc: '用于完善会员资料' });
nickName = (userInfo && userInfo.nickName) || '';
avatarUrl = (userInfo && userInfo.avatarUrl) || '';
} catch { /* 用户拒绝授权昵称,仍可用 code 登录 */ }
const { code: loginCode } = await Taro.login();
const data = await wxLogin({ code: loginCode, nickName, avatarUrl });
setUser(getUser());
refresh();
} catch (e) {
setErr((e && e.message) || '微信登录失败,请重试');
} finally {
setBusy(false);
}
};
const sendBind = async () => {
if (!/^1\d{10}$/.test(phone)) { setErr('请输入正确的 11 位手机号'); return; }
setErr(''); setBusy(true);
try {
const r = await sendCode(phone);
setBindDebug(`验证码已发送${r.debugCode ? ',演示验证码:' + r.debugCode : ''}`);
setCd(60);
const t = setInterval(() => setCd((v) => { if (v <= 1) { clearInterval(t); return 0; } return v - 1; }), 1000);
} catch (e) { setErr((e && e.message) || '发送失败'); }
finally { setBusy(false); }
};
const doBind = async () => {
if (!/^1\d{10}$/.test(phone)) { setErr('请输入正确的 11 位手机号'); return; }
if (!code) { setErr('请输入验证码'); return; }
setErr(''); setBusy(true);
try {
const r = await bindPhone(phone, code);
if (r.ok) {
setShowBind(false);
setPhone(''); setCode(''); setBindDebug('');
setUser(getUser());
Taro.showToast({ title: '手机绑定成功', icon: 'success' });
} else {
setErr(r.error || '绑定失败');
}
} catch (e) { setErr((e && e.message) || '绑定失败'); }
finally { setBusy(false); }
};
/* 微信官方一键绑定手机号(getPhoneNumber 回调) */
const onWxPhone = async (e) => {
const detail = (e.detail && e.detail) || {};
const pcode = detail.code;
if (!pcode) { Taro.showToast({ title: '已取消授权', icon: 'none' }); return; }
setBusy(true); setErr('');
try {
const r = await wxPhoneBind(pcode);
if (r.ok) {
setShowBind(false); setUser(getUser());
Taro.showToast({ title: '手机绑定成功', icon: 'success' });
} else {
Taro.showToast({ title: r.error || '绑定失败', icon: 'none' });
}
} catch (ex) { Taro.showToast({ title: (ex && ex.message) || '绑定失败', icon: 'none' }); }
finally { setBusy(false); }
};
const sendPhone = async () => {
if (!/^1\d{10}$/.test(pPhone)) { setErr('请输入正确的 11 位手机号'); return; }
setErr(''); setBusy(true);
try {
const r = await sendCode(pPhone);
setPDebug(`验证码已发送${r.debugCode ? ',演示验证码:' + r.debugCode : ''}`);
setPCd(60);
const t = setInterval(() => setPCd((v) => { if (v <= 1) { clearInterval(t); return 0; } return v - 1; }), 1000);
} catch (e) { setErr((e && e.message) || '发送失败'); }
finally { setBusy(false); }
};
const doPhoneLogin = async () => {
if (!/^1\d{10}$/.test(pPhone)) { setErr('请输入正确的 11 位手机号'); return; }
if (!pCode) { setErr('请输入验证码'); return; }
setErr(''); setBusy(true);
try {
await phoneLogin(pPhone, pCode);
setUser(getUser());
setShowPhone(false);
refresh();
} catch (e) { setErr((e && e.message) || '登录失败'); }
finally { setBusy(false); }
};
const onCheckin = async (id) => {
setErr('');
try {
const r = await checkIn(id);
if (r.ok) {
Taro.showToast({ title: '签到成功', icon: 'success' });
refresh();
} else {
Taro.showToast({ title: r.error || '签到失败', icon: 'none' });
}
} catch (e) {
Taro.showToast({ title: (e && e.message) || '签到失败', icon: 'none' });
}
};
/* 微信头像昵称填写能力:选头像→上传;填昵称→保存。确保登录后能渲染真实头像/昵称 */
const [nickDraft, setNickDraft] = useState(() => (getUser() || {}).name || '');
/* 登录/登出/刷新用户后,昵称草稿随 user.name 同步,避免登出残留或登录后不显示昵称 */
useEffect(() => { setNickDraft((user && user.name) || ''); }, [user]);
/* 报名资料(姓名/状态/主题/来源)——个人中心设置,报名自动带入 */
const [pName, setPName] = useState((user && user.name) || '');
const [pStatus, setPStatus] = useState((user && user.status) || '');
const [pTopics, setPTopics] = useState((user && user.topics) || []);
const [pSource, setPSource] = useState((user && user.source) || '');
const [saving, setSaving] = useState(false);
useEffect(() => {
setPName((user && user.name) || '');
setPStatus((user && user.status) || '');
setPTopics((user && user.topics) || []);
setPSource((user && user.source) || '');
}, [user]);
const savePrefs = async () => {
setErr('');
if (!pName.trim()) { setErr('请输入姓名'); return; }
setSaving(true);
try {
const r = await updateProfile({ name: pName.trim(), status: pStatus, topics: pTopics, source: pSource });
if (r.ok) { setUser(getUser()); Taro.showToast({ title: '已保存', icon: 'success' }); }
else { setErr(r.error || '保存失败'); }
} catch (e) { setErr((e && e.message) || '保存失败'); }
finally { setSaving(false); }
};
const onChooseAvatar = async (e) => {
const tempPath = e.detail && e.detail.avatarUrl;
if (!tempPath) return;
try {
const token = Taro.getStorageSync('pine_token') || '';
const up = await Taro.uploadFile({
url: `${API_BASE}/api/upload`,
filePath: tempPath,
name: 'file',
formData: { dir: 'avatar' },
header: { Authorization: `Bearer ${token}` }
});
const data = JSON.parse(up.data || '{}');
if (!data.ok) throw new Error(data.error || '上传失败');
await updateProfile({ avatar: data.url });
setUser(getUser());
Taro.showToast({ title: '头像已更新', icon: 'success' });
} catch (ex) {
Taro.showToast({ title: (ex && ex.message) || '头像更新失败', icon: 'none' });
}
};
const onSaveNick = async () => {
const v = (nickDraft || '').trim();
if (!v) { setNickDraft((getUser() || {}).name || ''); return; }
try {
await updateProfile({ name: v });
setUser(getUser());
Taro.showToast({ title: '昵称已更新', icon: 'success' });
} catch (ex) {
Taro.showToast({ title: (ex && ex.message) || '昵称更新失败', icon: 'none' });
}
};
const onLogout = () => {
logout();
setUser(null);
setBookings([]);
setShowBind(false); setShowPhone(false);
setPhone(''); setCode(''); setBindDebug('');
setErr('');
Taro.showToast({ title: '已退出登录', icon: 'none' });
};
/* ============ 未登录:登录入口 ============ */
if (!user || !isAuthed()) {
return (
<View className="mine page-in">
<PageHeader no="MY" title="我的" en="Account" />
<View className="mine-card login-card">
<Button className="btn-main wx-btn" loading={busy} onClick={onWxLogin}>微信一键登录</Button>
<Text className="muted mine-tip">登录后可绑定手机查看报名场次与活动签到</Text>
<Text className="link" onClick={() => setShowPhone(!showPhone)}>{showPhone ? '收起手机号登录' : '或用手机号登录'}</Text>
</View>
{showPhone && (
<View className="card">
<View className="field">
<Text className="label">手机号</Text>
<Input value={pPhone} onInput={(e) => setPPhone(e.detail.value)} type="number" maxlength={11} placeholder="11 位手机号" className="input" />
</View>
<View className="field">
<Text className="label">验证码</Text>
<View className="row">
<Input value={pCode} onInput={(e) => setPCode(e.detail.value)} type="number" maxlength={6} placeholder="6 位验证码" className="input flex" />
<Button className="btn-ghost code-btn" disabled={pCd > 0} onClick={sendPhone}>{pCd > 0 ? `${pCd}s` : '获取验证码'}</Button>
</View>
</View>
{pDebug && <Text className="ok-text">{pDebug}</Text>}
<Button className="btn-main submit" loading={busy} onClick={doPhoneLogin}>{busy ? '登录中…' : '手机号登录'}</Button>
</View>
)}
{err && <Text className="err-text">{err}</Text>}
<View className="mk-foot">
<Text className="mk-foot-line">云南省首个由政府主办的免费大学生创业服务平台</Text>
<Text className="mk-foot-line">云南派音人工智能科技有限公司 && 云南汇浙青创科技有限公司</Text>
<Text className="mk-foot-line">像素字体 BubbledotICG-FinePos · CC BY 4.0</Text>
</View>
</View>
);
}
/* ============ 已登录:资料 + 我的报名 ============ */
return (
<View className="mine page-in">
<PageHeader no="MY" title="我的" en="Account" />
{/* 资料卡 */}
<View className="mine-card profile">
<Button className="avatar-btn" openType="chooseAvatar" onChooseAvatar={onChooseAvatar}>
{user.avatar ? (
<Image className="avatar" src={user.avatar} mode="aspectFill" />
) : (
<View className="avatar placeholder">
<Text>{(user.name || '我').slice(0, 1)}</Text>
</View>
)}
</Button>
<View className="profile-info">
<Input className="profile-name" type="nickname" value={nickDraft} onInput={(e) => setNickDraft(e.detail.value)} placeholder="点此填写昵称" />
<Text className={`phone-tag${user.phoneBound ? ' bound' : ''}`}>
{user.phoneBound ? (user.phone ? `已绑定 ${user.phone}` : '已绑定手机') : '未绑定手机'}
</Text>
{user.account_type_label || user.role ? (
<Text className="role-tag">{user.account_type_label || user.role}</Text>
) : null}
{nickDraft && nickDraft !== ((user && user.name) || '') && (
<Button className="btn-ghost save-nick" onClick={onSaveNick}>保存昵称</Button>
)}
</View>
<Button className="btn-ghost logout-btn" onClick={onLogout}>退出</Button>
</View>
{/* 下一场活动倒计时(个人信息卡之后显示;取最早的待参加且未开始的报名场次) */}
{(() => {
const now = Date.now();
const upcoming = bookings
.filter((b) => b.event_status !== 'done' && !b.checkinAt && parseIso(b.eventStart) && parseIso(b.eventStart).getTime() > now)
.sort((a, b) => parseIso(a.eventStart).getTime() - parseIso(b.eventStart).getTime());
const nxt = upcoming[0];
if (!nxt) return null;
return (
<View className="card next-card">
<Text className="next-lbl">您参加的下一场活动</Text>
<Text className="next-title">{nxt.eventTitle}</Text>
{nxt.eventStart && <Text className="next-time">{fmtTime(nxt.eventStart)} · 敬请准时到场</Text>}
<CountdownBox target={nxt.eventStart} />
</View>
);
})()}
{/* 绑定手机 */}
{!user.phoneBound && (
<View className="card bind-card">
<View className="bind-head">
<Text className="bind-title">绑定手机</Text>
</View>
<Text className="bind-tip">绑定后用于接收活动通知与报名确认</Text>
{!showBind ? (
<Button className="btn-main" onClick={() => setShowBind(true)}>去绑定手机</Button>
) : (
<View className="bind-form">
<Button className="btn-main" openType="getPhoneNumber" onGetPhoneNumber={onWxPhone} loading={busy}>微信一键绑定手机号</Button>
<Text className="divider"></Text>
<View className="field">
<Text className="label">手机号</Text>
<Input value={phone} onInput={(e) => setPhone(e.detail.value)} type="number" maxlength={11} placeholder="11 位手机号" className="input" />
</View>
<View className="field">
<Text className="label">验证码</Text>
<View className="row">
<Input value={code} onInput={(e) => setCode(e.detail.value)} type="number" maxlength={6} placeholder="6 位验证码" className="input flex" />
<Button className="btn-ghost code-btn" disabled={cd > 0} onClick={sendBind}>{cd > 0 ? `${cd}s` : '获取验证码'}</Button>
</View>
</View>
{bindDebug && <Text className="ok-text">{bindDebug}</Text>}
<Button className="btn-main submit" loading={busy} onClick={doBind}>{busy ? '提交中…' : '确认绑定'}</Button>
<Button className="btn-ghost bind-cancel" onClick={() => setShowBind(false)}>取消</Button>
</View>
)}
</View>
)}
{/* 我的报名 */}
<View className="mine-section-head">
<Text className="mine-no">BOOK</Text>
<Text className="mine-subtitle">我的报名{bookings.length}</Text>
</View>
<View className="phase-filters">
{PHASE_FILTERS.map((f) => (
<Text key={f.key} className={`phase-tab${phase === f.key ? ' on' : ''}`} onClick={() => setPhase(f.key)}>{f.label}</Text>
))}
</View>
{(() => {
const shown = phase === 'all' ? bookings : bookings.filter((b) => phaseOf(b).cls === phase);
if (!loaded) return <View className="card empty"><Skeleton.Rows n={3} gap={14} /></View>;
if (shown.length === 0) return (
<View className="card empty">
<Text className="muted">{bookings.length === 0 ? '还没有报名记录。' : '该状态下暂无报名。'}</Text>
<Text className="link" onClick={() => Taro.switchTab({ url: '/pages/booking/index' })}>去看活动 </Text>
</View>
);
return (
<View className="bk-list">
{shown.map((b) => {
const p = phaseOf(b);
const ended = b.event_status === 'done';
const ready = b.can_checkin;
return (
<View className="bk-card" key={b.id}>
<View className="bk-head">
<Text className="bk-want">{b.eventTitle || b.want || '报名'}</Text>
<Text className={`bk-phase ${p.cls}`}>{p.text}</Text>
</View>
{b.eventStart && <Text className="bk-time">场次时间{fmtTime(b.eventStart)}</Text>}
<Text className="bk-time">报名时间{fmtTime(b.createdAt)}</Text>
<Text className="bk-extra">编号 {b.id} · {b.name || '—'}</Text>
{b.checkinAt ? (
<Text className="bk-checked">已于 {fmtTime(b.checkinAt)} 签到</Text>
) : ended ? (
<Text className="bk-checked done">本场已结束</Text>
) : ready ? (
<Button className="btn-main bk-checkin" onClick={() => onCheckin(b.id)}>活动签到</Button>
) : (
<Text className="bk-checked">未到签到时间活动开始后即可签到</Text>
)}
</View>
);
})}
</View>
);
})()}
{/* 我的接单:扫码接单;任务广场移到 Web / 桌面端 */}
<View className="mine-section-head">
<Text className="mine-no">ACCEPT</Text>
<Text className="mine-subtitle">我的接单</Text>
</View>
<View className="card mk-act">
<Button className="btn-main mk-btn-sm" onClick={() => Taro.navigateTo({ url: '/pages/scan/index' })}>扫码接单</Button>
<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>
<Text className="mine-subtitle">我的园区</Text>
</View>
{(() => {
const ad = admission[0] || {};
const st = String(ad.status || ad.audit_status || '').toLowerCase();
const admitted = ['approved', 'verified', 'admitted'].includes(st) || ['已入驻', '通过', 'approved'].includes(ad.status);
if (!admitted) {
return (
<View className="card mk-actions">
<Text className="muted mk-center-hint">暂未入驻园区可申请入驻并完成 OPC 认证</Text>
<View className="mk-btnrow">
<Button className="btn-main mk-btn-sm" onClick={() => Taro.navigateTo({ url: '/pages/park-apply/index' })}>申请入驻</Button>
<Button className="btn-ghost mk-btn-sm" onClick={() => Taro.navigateTo({ url: '/pages/cert-apply/index' })}>OPC 认证</Button>
</View>
</View>
);
}
const name = ad.tenant_name || ad.park_name || ad.company_name || ad.company || '已入驻园区';
return (
<View className="card mk-park">
<View className="mk-park-name">{name}</View>
{ad.region ? <Text className="mk-hint">区域{ad.region}</Text> : null}
<View className="mk-btnrow">
<Button className="btn-ghost mk-btn-sm" onClick={() => Taro.navigateTo({ url: '/pages/cert-apply/index' })}>OPC 认证</Button>
<Button className="btn-ghost mk-btn-sm" onClick={() => Taro.navigateTo({ url: '/pages/park-transfer/index' })}>转园申请</Button>
</View>
</View>
);
})()}
{err && <Text className="err-text">{err}</Text>}
<View className="mk-foot">
<Text className="mk-foot-line">云南省首个由政府主办的免费大学生创业服务平台</Text>
<Text className="mk-foot-line">云南派音人工智能科技有限公司 · 云南汇浙青创科技有限公司</Text>
<Text className="mk-foot-line">像素字体 BubbledotICG-FinePos · CC BY 4.0</Text>
</View>
</View>
);
}