feat(profile): 网站个人中心加「登录与绑定」 —— 绑定手机号, 解绑 手机/小程序/微信

- Profile 页加「登录与绑定」区块: 绑定手机号(验证码), 展示+解绑 手机/小程序/微信
- services/auth.js 加 bindPhone(合并返新令牌)/unbind/me
This commit is contained in:
Pine
2026-08-26 12:37:31 +08:00
parent d6c28b0015
commit d0979aa8ef
3 changed files with 117 additions and 12 deletions
+14
View File
@@ -66,6 +66,20 @@ export async function login(username, password) {
return data;
}
/* ---------------- 绑定 / 解绑(统一账号,按手机号合并,绝不新建账号) ---------------- */
export async function bindPhone(phone, code) {
const data = await req('/auth/bind-phone', { body: { phone, code } });
if (data.token) saveSession(data); // 可能合并成另一账号,切换新令牌
return data;
}
export async function unbind(type) {
return req('/auth/unbind', { body: { type }, auth: true });
}
/** 拉取当前账号(含 phoneBound/wxBound/wxMiniBound */
export async function me() {
return req('/auth/me', { method: 'GET', auth: true });
}
/* ---------------- 微信扫码 / 小程序扫码登录 ---------------- */
export async function wxQrStart() {
return req('/auth/wx-qr/start', { method: 'GET' });
+15
View File
@@ -122,3 +122,18 @@
.bk-ev-status.done { color: #9a9a9a; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.18); }
.bk-ev-status.pending { color: #8e8e8e; background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.18); }
.bk-ev-status.invite { color: #a8c4ff; background: rgba(168,196,255,0.12); border-color: rgba(168,196,255,0.4); }
/* 个人中心 · 登录与绑定 */
.bk-line {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 0; border-bottom: 1px solid var(--line); font-size: 14px; color: #fff;
}
.bk-line > span:first-child { color: #c4c2c3; }
.bk-bound { display: inline-flex; align-items: center; gap: 10px; color: #fff; }
.pf-bindform { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.pf-bindform input {
flex: 1; min-width: 130px; padding: 9px 12px; border-radius: 10px;
background: #161618; border: 1px solid #2a2a2d; color: #fff; font-size: 13.5px;
}
.pf-tip { margin-top: 12px; font-size: 12.5px; color: #8e8e8e; }
.btn-sm { font-size: 12.5px; padding: 4px 12px; line-height: 1.7; }
+88 -12
View File
@@ -2,11 +2,11 @@ import React from 'react';
import { ContentTemplate } from '@/components/templates';
import { SectionHead, FieldText, FieldSelect, FieldCheck, Button, Icon } from '@/components/atoms';
import AuthGate from '@/components/AuthGate';
import { getUser, updateProfile } from '@/services/auth';
import { getUser, updateProfile, bindPhone, unbind, sendCode, me } from '@/services/auth';
import { BK_STATUS_OPTIONS, BK_TOPIC_OPTIONS, BK_SOURCE_OPTIONS } from '@/data/booking';
import '@/styles/booking.css';
/* 个人中心:报名资料设置(姓名/状态/主题/来源),报名时自动带入 */
/* 个人中心:报名资料设置(姓名/状态/主题/来源),报名时自动带入 + 登录方式绑定 */
export default function Profile() {
const [authed, setAuthed] = React.useState(!!getUser());
const user = getUser() || {};
@@ -21,17 +21,61 @@ export default function Profile() {
const [busy, setBusy] = React.useState(false);
const set = (k) => (v) => setForm((f) => ({ ...f, [k]: v }));
const save = async (e) => {
e.preventDefault();
setErr(''); setMsg(''); setBusy(true);
/* 绑定管理 */
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 r = await updateProfile({ name: form.name.trim(), status: form.status, topics: form.topics, source: form.source });
if (r.ok) { setMsg('已保存,报名时将自动带入。'); }
else throw new Error(r.detail || r.error || '保存失败');
setAuthed(true); // 若此前未登录,登录卡回来就刷新
} catch (ex) {
setErr(ex && ex.message ? ex.message : '保存失败,请重试');
} finally { setBusy(false); }
const p = await me();
setBind({
phone: p.phone || '', phoneBound: !!p.phoneBound, wxBound: !!p.wxBound, wxMiniBound: !!p.wxMiniBound,
});
} catch (e) { /* 未登录 */ }
};
React.useEffect(() => { if (authed) void refreshBind(); }, [authed]);
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); }
};
if (!authed) {
@@ -61,6 +105,38 @@ export default function Profile() {
</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-dark btn-sm" disabled={binding} onClick={() => doUnbind('wx_mini')}>解绑</button>}
</span>
</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>
);
}