a960703586
- 详情页:左正文右「更多文章」独立玻璃卡(悬浮、全类型按优先级/时间排序);发布者徽章;导读展示;头像有则显示 - 点赞(计数)/留言/分享(复制+toast) 真实接入;动作纯图标并入留言行;留言框浅色玻璃 - 全局按钮收小一档;卡片统一毛玻璃材质(card/form-card/auth/flow/timeline) - 页脚品牌统一:云南省超级个体服务平台 · 云南派音人工智能科技有限公司 + 标准 logo - 活动封面上传 dir=event;导读独立编辑不限字数
277 lines
13 KiB
React
277 lines
13 KiB
React
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>
|
||
);
|
||
}
|