feat(profile): 管理平台个人中心 —— 绑定/解绑 手机号·小程序·微信

- 新增 /settings (个人中心): 展示账号+绑定状态, 绑定手机号(验证码), 解绑 手机/小程序/微信
- auth.ts 加 bindPhone(合并返新令牌)/unbind; 路由+导航项
This commit is contained in:
Pine
2026-08-26 12:37:09 +08:00
parent 0f47db9699
commit 044ec4bb81
4 changed files with 150 additions and 0 deletions
+2
View File
@@ -21,6 +21,7 @@ import ProvidersPage from "@/pages/providers";
import ConfigPage from "@/pages/config";
import RolesPage from "@/pages/roles";
import ParkPage from "@/pages/park";
import SettingsPage from "@/pages/settings";
function RequireAuth() {
const { isAuthed } = useAuth();
@@ -66,6 +67,7 @@ export default function App() {
<Route path="/providers" element={<ProvidersPage />} />
<Route path="/config" element={<ConfigPage />} />
<Route path="/roles" element={<RolesPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
{/* 园区管理:平台(运营端)全园管理 / 园区账密(园区端)仅本园区 —— 园区端无平台角色,故不套 RoleRoute */}
<Route path="/park" element={<ParkPage />} />
+12
View File
@@ -27,6 +27,18 @@ export async function phoneLogin(phone: string, code: string): Promise<LoginResp
return res;
}
/** 绑定手机号(验证码;若手机号已被占用则后端按手机号合并,返回新令牌需切换) */
export async function bindPhone(phone: string, code: string): Promise<LoginResponse> {
const res = await api.post<LoginResponse>("/auth/bind-phone", { phone, code });
setToken(res.token); // 可能合并成另一个账号,必须切换到新令牌
return res;
}
/** 解绑登录方式:phone | wx | wx_mini(至少保留一种) */
export async function unbind(type: "phone" | "wx" | "wx_mini") {
return api.post("/auth/unbind", { type });
}
export async function wxQrStart(): Promise<{ scene: string; qr_url: string; redirect_uri: string; stub: boolean }> {
return api.get("/auth/wx-qr/start");
}
+2
View File
@@ -14,6 +14,7 @@ import {
Flag,
Store,
Settings,
UserCircle2,
} from "lucide-react";
import { useAuth } from "@/lib/auth";
import { isParkUser } from "@/lib/parkAuth";
@@ -47,6 +48,7 @@ export const NAV: NavGroup[] = [
{ to: "/roles", label: "角色权限", icon: ShieldCheck, roles: ["operator"] },
{ to: "/compute", label: "算力管理", icon: Cpu, roles: ["operator"] },
{ to: "/park", label: "园区管理", icon: Building2, roles: ["operator", "carrier"] },
{ to: "/settings", label: "个人中心", icon: UserCircle2, roles: ["operator"] },
],
},
{
+134
View File
@@ -0,0 +1,134 @@
import * as React from "react";
import { useAuth } from "@/lib/auth";
import * as authApi from "@/api/auth";
import { PageHeader } from "@/components/generic/PageHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Loader2, ShieldCheck, Smartphone, MessageSquareText, QrCode } from "lucide-react";
type Binding = { phone: string; phoneBound: boolean; wxBound: boolean; wxMiniBound: boolean };
export default function SettingsPage() {
const { user, loginWithResponse } = useAuth();
const [b, setB] = React.useState<Binding>({
phone: "", phoneBound: false, wxBound: false, wxMiniBound: false,
});
const [phone, setPhone] = React.useState("");
const [code, setCode] = React.useState("");
const [countdown, setCountdown] = React.useState(0);
const [busy, setBusy] = React.useState(false);
const [msg, setMsg] = React.useState<string | null>(null);
const [err, setErr] = React.useState<string | null>(null);
const refresh = React.useCallback(async () => {
try {
const r = await authApi.fetchMe();
const u = r as unknown as Binding & { username?: string };
setB({
phone: (u.phone as string) || "",
phoneBound: !!(u.phoneBound ?? (u.phone as string)),
wxBound: !!u.wxBound,
wxMiniBound: !!u.wxMiniBound,
});
} catch { /* 401 时 client 已登出 */ }
}, []);
React.useEffect(() => { refresh(); }, [refresh]);
const send = async () => {
setErr(null);
if (!/^1\d{10}$/.test(phone)) { setErr("请输入 11 位手机号"); return; }
setBusy(true);
try {
const r = await authApi.sendCode(phone);
setMsg(`验证码已发送${r.stub_code ? `(演示:${r.stub_code}` : ""}`);
setCountdown(60);
const t = window.setInterval(() => setCountdown((c) => (c <= 1 ? (window.clearInterval(t), 0) : c - 1)), 1000);
} catch (e) { setErr(e instanceof Error ? e.message : "发送失败"); }
finally { setBusy(false); }
};
const doBind = async () => {
setErr(null);
if (!/^1\d{10}$/.test(phone)) { setErr("请输入 11 位手机号"); return; }
if (!code) { setErr("请输入验证码"); return; }
setBusy(true);
try {
const res = await authApi.bindPhone(phone, code);
await loginWithResponse(res); // 写新令牌(可能合并成另一账号)
setMsg("手机号绑定成功"); setPhone(""); setCode("");
await refresh();
} catch (e) { setErr(e instanceof Error ? e.message : "绑定失败"); }
finally { setBusy(false); }
};
const doUnbind = async (type: "phone" | "wx" | "wx_mini") => {
setErr(null); setMsg(null); setBusy(true);
try {
await authApi.unbind(type);
setMsg("已解绑");
await refresh();
} catch (e) { setErr(e instanceof Error ? e.message : "解绑失败"); }
finally { setBusy(false); }
};
const items: { key: string; label: string; icon: React.ReactNode; bound: boolean }[] = [
{ key: "phone", label: "手机号", icon: <Smartphone className="h-4 w-4" />, bound: b.phoneBound },
{ key: "wx_mini", label: "小程序", icon: <QrCode className="h-4 w-4" />, bound: b.wxMiniBound },
{ key: "wx", label: "微信(开放平台)", icon: <MessageSquareText className="h-4 w-4" />, bound: b.wxBound },
];
return (
<div className="mx-auto max-w-2xl space-y-6">
<PageHeader title="个人中心" description="账号与安全:绑定/解绑登录方式(同一账号,绝不重复创建)" />
<Card>
<CardHeader><CardTitle className="t text-base"><ShieldCheck className="mr-1 inline h-4 w-4" /></CardTitle>
<CardDescription>{user?.username || "已登录"}</CardDescription></CardHeader>
<CardContent className="space-y-2">
{items.map((it) => (
<div key={it.key} className="flex items-center justify-between rounded-md border px-3 py-2">
<span className="flex items-center gap-2 text-sm">{it.icon}{it.label}</span>
<div className="flex items-center gap-2">
<span className={`text-sm ${it.bound ? "text-emerald-500" : "text-muted-foreground"}`}>{it.bound ? "已绑定" : "未绑定"}</span>
{it.bound && it.key !== "phone" && <Button size="sm" variant="outline" disabled={busy} onClick={() => doUnbind(it.key as never)}></Button>}
</div>
</div>
))}
<p className="text-xs text-muted-foreground">()</p>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-base"></CardTitle>
<CardDescription>{b.phoneBound ? `已绑定 ${b.phone}` : "未绑定手机号"}</CardDescription></CardHeader>
<CardContent className="space-y-3">
{b.phoneBound ? (
<div className="flex items-center justify-between">
<span className="text-sm">{b.phone}</span>
<Button variant="outline" disabled={busy} onClick={() => doUnbind("phone")}></Button>
</div>
) : (
<>
<div className="space-y-1">
<Label></Label>
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="11 位手机号" />
</div>
<div className="space-y-1">
<Label></Label>
<div className="flex gap-2">
<Input value={code} onChange={(e) => setCode(e.target.value)} placeholder="验证码" />
<Button variant="outline" className="shrink-0" disabled={countdown > 0 || busy} onClick={send}>{countdown > 0 ? `${countdown}s` : "获取验证码"}</Button>
</div>
</div>
{msg && <p className="text-sm text-emerald-500">{msg}</p>}
{err && <p className="text-sm text-destructive">{err}</p>}
<Button className="w-full" disabled={busy || !phone || !code} onClick={doBind}>{busy && <Loader2 className="animate-spin" />}</Button>
</>
)}
</CardContent>
</Card>
</div>
);
}