Files
training/website/src/user/Profile.jsx
T
Pine 4419c9265f feat(user-center): 顶栏用户下拉 + 玻璃个人中心 + 我的报名/任务/园区/认证页
- Header: 登录后头像+用户名玻璃下拉(个人中心/我的报名/我的任务/我的园区/认证中心/退出);未登录显示登录/注册;移动端同步
- Profile: 玻璃用户卡(头像上传/身份标签) + 我的服务宫格(实时状态) + 报名资料 + 登录绑定
- 新增 my-bookings/my-tasks/my-park/my-cert 四页;auth.pickUser 保留 role/account_type;booking/park 新增 myBookings/myParkAdmission
- 新增 user-center.css 玻璃样式;vite build 通过
2026-08-27 20:15:01 +08:00

274 lines
13 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 React from 'react';
import { ContentTemplate } from '@/components/templates';
import { SectionHead, FieldText, FieldSelect, FieldCheck, Icon } from '@/components/atoms';
import AuthGate from '@/components/AuthGate';
import {
getUser, saveUser, updateProfile, bindPhone, unbind, sendCode, me, mpQrBindStart, mpQrPoll,
} from '@/services/auth';
import { myBookings } from '@/services/booking';
import { myTasks } from '@/services/tasks';
import { myParkAdmission, uploadParkDoc } from '@/services/park';
import { getOpCertMine } from '@/services/opcProfile';
import { BK_STATUS_OPTIONS, BK_TOPIC_OPTIONS, BK_SOURCE_OPTIONS } from '@/data/booking';
import '@/styles/booking.css';
import '@/styles/user-center.css';
function maskPhone(p) {
if (!p) return '';
if (/^1\d{10}$/.test(p)) return p.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
return p;
}
function parkText(s) {
const k = String(s || '').toLowerCase();
if (['approved', 'verified', 'admitted'].includes(k) || s === '已入驻' || s === '通过') return '已入驻';
if (['pending', 'reviewing'].includes(k) || s === '审核中') return '审核中';
if (['rejected'].includes(k) || s === '未通过') return '未通过';
return s ? String(s) : '未提交';
}
function certText(s) {
const k = String(s || '').toLowerCase();
if (k === 'certified' || s === '已认证') return '已认证';
if (k === 'reviewing' || s === '审核中') return '审核中';
if (k === 'pending' || s === '待提交') return '待提交';
return s ? String(s) : '未认证';
}
/* 个人中心:用户卡 + 我的服务 + 报名资料 + 登录绑定(玻璃卡片) */
export default function Profile() {
const [user, setUser] = React.useState(getUser() || {});
const [authed, setAuthed] = React.useState(!!user.username || !!getUser()?.username);
const [form, setForm] = React.useState({
name: user.name || '', status: user.status || '', topics: user.topics || [], source: user.source || '',
});
const [msg, setMsg] = React.useState('');
const [err, setErr] = React.useState('');
const [busy, setBusy] = React.useState(false);
const [avBusy, setAvBusy] = React.useState(false);
const set = (k) => (v) => setForm((f) => ({ ...f, [k]: v }));
/* 我的服务实时状态 */
const [svc, setSvc] = React.useState({ bk: null, tk: null, park: '', cert: '' });
React.useEffect(() => {
if (!authed) return;
Promise.all([
myBookings().then((l) => setSvc((s) => ({ ...s, bk: Array.isArray(l) ? l.length : 0 }))).catch(() => {}),
myTasks().then((r) => setSvc((s) => ({ ...s, tk: r.ok ? (r.items || []).length : 0 }))).catch(() => {}),
myParkAdmission().then((r) => setSvc((s) => ({ ...s, park: parkText(r?.admission?.status || r?.status || '') }))).catch(() => {}),
getOpCertMine().then((r) => setSvc((s) => ({ ...s, cert: certText(r?.certification_status || '') }))).catch(() => {}),
]);
}, [authed]);
/* 绑定管理 */
const [bind, setBind] = React.useState({ phone: '', phoneBound: false, wxBound: false, wxMiniBound: false });
const [bphone, setBphone] = React.useState('');
const [bcode, setBcode] = React.useState('');
const [cd, setCd] = React.useState(0);
const [bindMsg, setBindMsg] = React.useState('');
const [bindErr, setBindErr] = React.useState('');
const [binding, setBinding] = React.useState(false);
const refreshBind = async () => {
try {
const p = await me();
setBind({ phone: p.phone || '', phoneBound: !!p.phoneBound, wxBound: !!p.wxBound, wxMiniBound: !!p.wxMiniBound });
setUser(getUser() || {});
} catch (e) { /* 未登录 */ }
};
const save = async (e) => {
e.preventDefault();
setErr(''); setMsg('');
if (!form.name.trim()) { setErr('请填写姓名 / 称呼'); return; }
setBusy(true);
try {
await updateProfile(form);
setUser(getUser() || {});
setMsg('资料已保存');
} catch (ex) { setErr((ex && ex.message) || '保存失败'); }
finally { setBusy(false); }
};
const onAvatar = async (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
setErr(''); setAvBusy(true);
try {
const url = await uploadParkDoc(f);
await updateProfile({ avatar: url });
saveUser({ ...getUser(), avatar: url });
setUser(getUser() || {});
setMsg('头像已更新');
} catch (ex) { setErr((ex && ex.message) || '头像上传失败'); }
finally { setAvBusy(false); e.target.value = ''; }
};
const send = async () => {
setBindErr('');
if (!/^1\d{10}$/.test(bphone)) { setBindErr('请输入 11 位手机号'); return; }
setBinding(true);
try {
const r = await sendCode(bphone);
setBindMsg(`验证码已发送${r.debugCode ? `(演示:${r.debugCode}` : ''}`);
setCd(60);
const t = window.setInterval(() => setCd((c) => (c <= 1 ? (window.clearInterval(t), 0) : c - 1)), 1000);
} catch (e) { setBindErr((e && e.message) || '发送失败'); }
finally { setBinding(false); }
};
const doBind = async (e) => {
e.preventDefault();
setBindErr(''); setBindMsg('');
if (!/^1\d{10}$/.test(bphone)) { setBindErr('请输入 11 位手机号'); return; }
if (!bcode) { setBindErr('请输入验证码'); return; }
setBinding(true);
try {
await bindPhone(bphone, bcode);
setBindMsg('绑定成功'); setBphone(''); setBcode('');
setAuthed(true);
await refreshBind();
} catch (ex) { setBindErr((ex && ex.message) || '绑定失败'); }
finally { setBinding(false); }
};
const doUnbind = async (type) => {
setBinding(true); setBindErr('');
try {
await unbind(type);
setBindMsg('已解绑');
await refreshBind();
} catch (ex) { setBindErr((ex && ex.message) || '解绑失败'); }
finally { setBinding(false); }
};
/* 绑定小程序 */
const [mpImg, setMpImg] = React.useState('');
const [mpStarting, setMpStarting] = React.useState(false);
const bindMini = async () => {
setBindErr(''); setMpStarting(true); setMpImg('');
try {
const r = await mpQrBindStart();
setMpImg(r.qr_image || '');
setBindMsg('请用微信「扫一扫」打开小程序并确认绑定');
if (r.scene) {
const t = window.setInterval(async () => {
try {
const poll = await mpQrPoll(r.scene);
if (poll.status === 'done') { window.clearInterval(t); setMpImg(''); setBindMsg('小程序绑定成功'); await refreshBind(); }
else if (poll.status === 'expired') { window.clearInterval(t); setMpImg(''); setBindMsg('二维码已过期,请重试'); }
} catch { /* ignore */ }
}, 2000);
}
} catch (ex) { setBindErr((ex && ex.message) || '发起失败'); }
finally { setMpStarting(false); }
};
if (!authed) {
return (
<ContentTemplate active="profile" kicker="Profile" title="个人中心" desc="完善报名资料,报名活动时自动带入,无需重复填写。">
<section className="section">
<AuthGate subtitle="登录后可设置并保存报名资料,报名活动自动带入。" onAuthed={() => setAuthed(true)} />
{err && <div className="bk-err">{err}</div>}
</section>
</ContentTemplate>
);
}
const svcGrid = [
['📋', '我的报名', svc.bk == null ? '加载中…' : `${svc.bk} 条报名`, '#/my-bookings'],
['🧩', '我的任务', svc.tk == null ? '加载中…' : `${svc.tk} 个任务`, '#/my-tasks'],
['🏢', '我的园区', svc.park, '#/my-park'],
['🏅', '我的认证', svc.cert, '#/my-cert'],
];
return (
<ContentTemplate active="profile" kicker="Profile" title="个人中心" desc="管理你的资料、报名、任务、园区与认证。">
<section className="section" style={{ paddingBottom: 0 }}>
{/* 用户卡 */}
<div className="uc-card uc-glass">
<div className="uc-user">
<label className="user-avatar" htmlFor="uc-upload" title="点击更换头像">
{user.avatar ? <img src={user.avatar} alt="" /> : (user.name || 'U').slice(0, 1).toUpperCase()}
</label>
<div className="uc-user-meta">
<div className="uc-user-name">{user.name || '用户'}</div>
<div className="uc-user-sub">{maskPhone(user.phone) || user.username || '未绑定手机'}</div>
{(user.account_type_label || user.role) && (
<div className="uc-tags"><span className="uc-tag">{user.account_type_label || user.role}</span></div>
)}
</div>
<div className="uc-avatar-actions">
<label className="btn btn-cta btn-sm" htmlFor="uc-upload">{avBusy ? '上传中…' : '更换头像'}</label>
<input id="uc-upload" type="file" accept="image/*" hidden onChange={onAvatar} />
</div>
</div>
</div>
{/* 我的服务 */}
<div className="uc-grid">
{svcGrid.map(([icon, t, d, href]) => (
<a className="uc-service uc-glass" key={href} href={href}>
<span className="i">{icon}</span>
<span className="t">{t}</span>
<span className="d">{d}</span>
</a>
))}
</div>
</section>
<section className="section">
<SectionHead no="01" title="报名资料" en="Profile" />
<form className="form-card" onSubmit={save}>
<FieldText label="姓名 / 称呼" placeholder="怎么称呼你" value={form.name} onChange={set('name')} />
<FieldSelect label="你的状态" options={BK_STATUS_OPTIONS} value={form.status} onChange={set('status')} />
<FieldCheck label="感兴趣的主题(可多选)" options={BK_TOPIC_OPTIONS} value={form.topics} onChange={set('topics')} />
<FieldSelect label="从哪知道我们" options={BK_SOURCE_OPTIONS} value={form.source} onChange={set('source')} />
{msg && <div className="bk-ok"><Icon name="check" size="sm" /> {msg}</div>}
{err && <div className="bk-err">{err}</div>}
<div className="form-actions">
<button className="btn btn-cta" type="submit" disabled={busy}>{busy ? '保存中…' : '保存资料'}</button>
</div>
</form>
</section>
{/* 登录方式绑定 */}
<section className="section">
<SectionHead no="02" title="登录与绑定" en="Security" />
<div className="form-card">
<div className="bk-line"><span>手机号</span>
{bind.phoneBound
? <span className="bk-bound">{bind.phone} <button type="button" className="btn btn-dark btn-sm" disabled={binding} onClick={() => doUnbind('phone')}>解绑</button></span>
: (
<form onSubmit={doBind} className="pf-bindform">
<input value={bphone} onChange={(e) => setBphone(e.target.value)} placeholder="11 位手机号" />
<input value={bcode} onChange={(e) => setBcode(e.target.value)} placeholder="验证码" style={{ width: 100 }} />
<button type="button" className="btn btn-dark btn-sm" disabled={binding || cd > 0} onClick={send}>{cd > 0 ? `${cd}s` : '验证码'}</button>
<button className="btn btn-cta btn-sm" disabled={binding || !bphone || !bcode}>绑定</button>
</form>
)}
</div>
<div className="bk-line"><span>小程序</span>
<span className="bk-bound">{bind.wxMiniBound ? '已绑定' : '未绑定'}
{!bind.wxMiniBound && <button type="button" className="btn btn-cta btn-sm" disabled={binding || mpStarting} onClick={bindMini}>{mpStarting ? '发起中…' : '绑定'}</button>}
{bind.wxMiniBound && <button type="button" className="btn btn-dark btn-sm" disabled={binding} onClick={() => doUnbind('wx_mini')}>解绑</button>}
</span>
</div>
{mpImg && (
<div className="pf-mpbox">
<img src={mpImg} alt="小程序码" className="pf-mpimg" />
<p className="pf-tip">用微信扫一扫打开小程序在你手机上点确认登录完成绑定</p>
</div>
)}
<div className="bk-line"><span>微信(开放平台)</span>
<span className="bk-bound">{bind.wxBound ? '已绑定' : '未绑定'}
{bind.wxBound && <button type="button" className="btn btn-dark btn-sm" disabled={binding} onClick={() => doUnbind('wx')}>解绑</button>}
</span>
</div>
{bindMsg && <div className="bk-ok"><Icon name="check" size="sm" /> {bindMsg}</div>}
{bindErr && <div className="bk-err">{bindErr}</div>}
<p className="pf-tip">小程序绑定用同一手机号登录过小程序(微信)后会自动与当前账号合并为同一账号</p>
</div>
</section>
</ContentTemplate>
);
}