Files
training/website/src/user/Profile.jsx
T
Pine a960703586 feat(website): 资讯详情双栏重设计+互动体系+全局UI优化
- 详情页:左正文右「更多文章」独立玻璃卡(悬浮、全类型按优先级/时间排序);发布者徽章;导读展示;头像有则显示
- 点赞(计数)/留言/分享(复制+toast) 真实接入;动作纯图标并入留言行;留言框浅色玻璃
- 全局按钮收小一档;卡片统一毛玻璃材质(card/form-card/auth/flow/timeline)
- 页脚品牌统一:云南省超级个体服务平台 · 云南派音人工智能科技有限公司 + 标准 logo
- 活动封面上传 dir=event;导读独立编辑不限字数
2026-08-28 17:20:45 +08:00

277 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;
refreshBind(); // 挂载即拉取真实绑定状态(手机号/小程序/微信),不只绑定时才刷新
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 });
// 用 fresh /me 回写本地用户缓存与用户卡,避免读 stale localStorage 导致手机号/绑定不同步
saveUser({ ...getUser(), 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 = [
['calendar', '我的报名', svc.bk == null ? '加载中…' : `${svc.bk} 条报名`, '#/my-bookings'],
['route', '我的任务', svc.tk == null ? '加载中…' : `${svc.tk} 个任务`, '#/my-tasks'],
['map-pin', '我的园区', svc.park, '#/my-park'],
['shield', '我的认证', 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 name={icon} className="uc-svc-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>
);
}