添加账号系统

This commit is contained in:
2026-04-17 12:58:38 +08:00
parent 032e207888
commit d3b03cecf2
27 changed files with 931 additions and 254 deletions
+189
View File
@@ -0,0 +1,189 @@
"use client";
import { useRouter } from "next/navigation";
import { startTransition, useState } from "react";
type AccountAuthFormProps = {
redirectTo: string;
};
type AuthMode = "login" | "register";
export function AccountAuthForm({ redirectTo }: AccountAuthFormProps) {
const router = useRouter();
const [mode, setMode] = useState<AuthMode>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [submitting, setSubmitting] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!username.trim()) {
setNotice("请先输入用户名。");
return;
}
if (!password) {
setNotice("请先输入密码。");
return;
}
if (mode === "register" && password !== confirmPassword) {
setNotice("两次输入的密码还不一致,请再检查一次。");
return;
}
setSubmitting(true);
setNotice(mode === "login" ? "正在登录账号……" : "正在创建账号……");
try {
const response = await fetch(
mode === "login" ? "/api/account/login" : "/api/account/register",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
username: username.trim(),
password,
redirectTo,
}),
},
);
const payload = (await response.json().catch(() => null)) as
| { error?: string; redirectTo?: string }
| null;
if (!response.ok) {
throw new Error(
payload?.error ||
(mode === "login"
? "登录失败,请稍后再试。"
: "创建账号失败,请稍后再试。"),
);
}
setNotice(mode === "login" ? "登录成功,正在进入……" : "账号已创建,正在进入……");
startTransition(() => {
router.replace(payload?.redirectTo || redirectTo);
router.refresh();
});
} catch (error) {
setNotice(
error instanceof Error ? error.message : "操作失败,请稍后再试。",
);
} finally {
setSubmitting(false);
}
}
return (
<div className="rounded-[32px] border border-white/75 bg-white/84 p-6 shadow-[0_26px_60px_rgba(115,76,42,0.12)] backdrop-blur sm:p-7">
<div className="flex flex-wrap gap-3">
{[
{ id: "login", label: "已有账号,直接登录" },
{ id: "register", label: "第一次使用,创建账号" },
].map((item) => {
const active = mode === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => {
setMode(item.id as AuthMode);
setNotice(null);
}}
className={`inline-flex h-11 items-center justify-center rounded-full px-4 text-sm font-semibold transition ${
active
? "bg-[var(--ink)] text-white shadow-[0_12px_30px_rgba(36,27,20,0.18)]"
: "border border-[var(--line)] bg-white text-[var(--ink)] hover:bg-[var(--paper-soft)]"
}`}
>
{item.label}
</button>
);
})}
</div>
<form className="mt-6 space-y-4" onSubmit={handleSubmit}>
<div>
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-username">
用户名
</label>
<input
id="caregiver-username"
value={username}
onChange={(event) => setUsername(event.target.value)}
autoComplete="username"
placeholder="例如:family_guardian"
className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
/>
<p className="mt-2 text-xs leading-6 text-[var(--muted)]">
建议使用 3 到 24 位字母、数字、下划线或横线,方便全家统一登录。
</p>
</div>
<div>
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-password">
密码
</label>
<input
id="caregiver-password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete={mode === "login" ? "current-password" : "new-password"}
placeholder="请输入密码"
className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
/>
<p className="mt-2 text-xs leading-6 text-[var(--muted)]">
密码至少 8 位。同一账号可以在多台家属设备登录,消息提醒会同步到所有已登录设备。
</p>
</div>
{mode === "register" ? (
<div>
<label className="text-sm font-semibold text-[var(--ink)]" htmlFor="caregiver-password-confirm">
再输入一次密码
</label>
<input
id="caregiver-password-confirm"
type="password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
autoComplete="new-password"
placeholder="再次输入密码"
className="mt-2 h-13 w-full rounded-[22px] border border-[var(--line)] bg-[var(--paper-soft)] px-4 text-sm text-[var(--ink)] outline-none transition focus:border-[var(--copper)] focus:bg-white"
/>
</div>
) : null}
<button
type="submit"
disabled={submitting}
className="inline-flex h-12 w-full items-center justify-center rounded-full bg-[var(--copper)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--copper-deep)] disabled:cursor-not-allowed disabled:opacity-60"
>
{submitting
? mode === "login"
? "正在登录"
: "正在创建账号"
: mode === "login"
? "登录并继续"
: "创建账号并继续"}
</button>
</form>
{notice ? (
<p className="mt-4 rounded-[20px] bg-[var(--paper-soft)] px-4 py-3 text-sm leading-7 text-[var(--muted)]">
{notice}
</p>
) : null}
</div>
);
}
+1 -1
View File
@@ -50,7 +50,7 @@ export function ActivityChart({ data, color = "var(--copper)" }: ActivityChartPr
fontSize: 13,
}}
labelStyle={{ color: "#241b14", fontWeight: 600 }}
formatter={(value: number) => [`${value} 次`, "次数"]}
formatter={(value) => [`${Number(value ?? 0)} 次`, "次数"]}
/>
<Bar
dataKey="count"
+4 -30
View File
@@ -19,37 +19,11 @@ export function BindDeviceForm() {
}
setSubmitting(true);
setNotice("正在建立连接,请稍候……");
setNotice("正在前往绑定页面,请稍候……");
try {
const response = await fetch("/api/family/bind", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceUuid: rawCode }),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string }
| null;
if (!response.ok) {
throw new Error(payload?.error || "连接失败,请稍后再试。");
}
setNotice("连接成功!现在可以查看长辈的留言和动态了。\n");
setRawCode("");
startTransition(() => {
router.refresh();
});
} catch (error) {
setNotice(
error instanceof Error ? error.message : "连接失败,请稍后再试。",
);
} finally {
setSubmitting(false);
}
startTransition(() => {
router.push(`/bind?deviceUuid=${encodeURIComponent(rawCode.trim())}`);
});
}
return (
+11 -3
View File
@@ -22,7 +22,7 @@ type PanelShellProps = {
currentPath: string;
title: string;
description: string;
caregiverToken: string;
caregiverLabel: string;
children: ReactNode;
actions?: ReactNode;
eyebrow?: string;
@@ -32,7 +32,7 @@ export function PanelShell({
currentPath,
title,
description,
caregiverToken,
caregiverLabel,
children,
actions,
eyebrow = "家人连线",
@@ -81,8 +81,16 @@ export function PanelShell({
<div className="mt-6 flex flex-wrap gap-3 text-sm text-[var(--muted)]">
<span className="rounded-full border border-[var(--line)] bg-white/76 px-4 py-2">
账号:{caregiverToken.slice(0, 8).toUpperCase()}
账号:{caregiverLabel}
</span>
<form action="/api/account/logout" method="post">
<button
type="submit"
className="inline-flex h-10 items-center justify-center rounded-full border border-[var(--line)] bg-white px-4 font-semibold text-[var(--ink)] transition hover:bg-[var(--paper-soft)]"
>
退出登录
</button>
</form>
</div>
</div>
+5 -30
View File
@@ -45,38 +45,13 @@ export function ScanClient() {
scannerRef.current.stop();
setErrorText(null);
setStatusText("识别到了设备,正在连接……");
setStatusText("识别到了设备,正在打开绑定页面……");
try {
const response = await fetch("/api/family/bind", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ deviceUuid: scannedValue }),
});
const payload = (await response.json().catch(() => null)) as
| { error?: string; deviceUuid?: string }
| null;
const deviceUuid = payload?.deviceUuid;
if (!response.ok || !deviceUuid) {
throw new Error(payload?.error || "连接失败,请重新扫描。");
}
startTransition(() => {
router.replace(
`/bind?deviceUuid=${encodeURIComponent(deviceUuid)}&source=scan`,
);
});
} catch (error) {
setErrorText(
error instanceof Error ? error.message : "连接失败,请重新扫描。",
startTransition(() => {
router.replace(
`/bind?deviceUuid=${encodeURIComponent(scannedValue)}&source=scan`,
);
setStatusText("请再次将二维码对准镜头。");
await scannerRef.current.start().catch(() => null);
}
});
});
useEffect(() => {