feat: 用户名密码登录注册系统

- 新增 /api/auth/register 和 /api/auth/login 接口,使用 bcryptjs 哈希密码
- User 模型改为 username + passwordHash,id 自动生成 cuid
- 新增 AuthModal 组件(登录/注册双标签页),替换旧的 ProfileSetupModal
- 重写 /profile 页面:支持修改用户名、密码、头像、绑定邮箱、退出登录
- /api/user PUT 支持密码修改(需验证当前密码)和用户名唯一性校验
- 游客模式保留,右上角显示"登录"按钮;登录后显示头像和用户名
- 全局 nickname -> username 重命名(types、SwipeDeck、RoomManageModal、buildRoomStatus)
- 新增 logout() 清除登录态并重新生成游客 UUID
This commit is contained in:
2026-02-25 00:21:03 +08:00
parent a28f4405e9
commit 04c7b547aa
24 changed files with 1613 additions and 134 deletions
+281
View File
@@ -0,0 +1,281 @@
"use client";
import { useState, useRef } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { X, Loader2, Eye, EyeOff } from "lucide-react";
import { AVATARS } from "@/lib/avatars";
import { setCachedProfile } from "@/lib/userId";
import type { UserProfile } from "@/types";
type Tab = "login" | "register";
interface AuthModalProps {
open: boolean;
onClose: () => void;
onAuth: (profile: UserProfile) => void;
}
export default function AuthModal({ open, onClose, onAuth }: AuthModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const [tab, setTab] = useState<Tab>("login");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [avatar, setAvatar] = useState<string>(AVATARS[0].emoji);
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleBackdropClick = (e: React.MouseEvent) => {
if (e.target === backdropRef.current) onClose();
};
const resetForm = () => {
setUsername("");
setPassword("");
setConfirmPassword("");
setAvatar(AVATARS[0].emoji);
setShowPassword(false);
setError("");
};
const switchTab = (t: Tab) => {
setTab(t);
resetForm();
};
const handleSubmit = async () => {
const trimmedUsername = username.trim();
if (!trimmedUsername) {
setError("请输入用户名");
return;
}
if (!password) {
setError("请输入密码");
return;
}
if (tab === "register") {
if (trimmedUsername.length < 2 || trimmedUsername.length > 16) {
setError("用户名需要 2-16 个字符");
return;
}
if (password.length < 6) {
setError("密码至少 6 个字符");
return;
}
if (password !== confirmPassword) {
setError("两次密码不一致");
return;
}
}
setLoading(true);
setError("");
try {
const endpoint = tab === "login" ? "/api/auth/login" : "/api/auth/register";
const payload =
tab === "login"
? { username: trimmedUsername, password }
: { username: trimmedUsername, password, avatar };
const res = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "操作失败");
return;
}
const profile: UserProfile = {
id: data.id,
username: data.username,
avatar: data.avatar,
};
localStorage.setItem("nowhatever_user_id", profile.id);
setCachedProfile(profile);
onAuth(profile);
onClose();
resetForm();
} catch {
setError("网络错误,请重试");
} finally {
setLoading(false);
}
};
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 backdrop-blur-sm sm:items-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
onClick={handleBackdropClick}
>
<motion.div
className="relative w-full max-w-sm rounded-t-3xl bg-white px-5 pb-8 pt-5 shadow-2xl sm:rounded-3xl sm:pb-6"
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "100%" }}
transition={{ type: "spring", damping: 28, stiffness: 350 }}
>
<div className="mb-4 flex items-center justify-between">
<span className="text-lg font-bold text-zinc-900"></span>
<button
onClick={onClose}
className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-100 text-zinc-400 transition-colors active:bg-zinc-200"
>
<X size={16} />
</button>
</div>
{/* Tabs */}
<div className="flex gap-1 rounded-xl bg-zinc-100 p-1">
{(["login", "register"] as const).map((t) => (
<button
key={t}
onClick={() => switchTab(t)}
className={`relative flex-1 rounded-lg py-2 text-sm font-semibold transition-colors ${
tab === t ? "text-zinc-900" : "text-zinc-400"
}`}
>
{tab === t && (
<motion.div
layoutId="auth-tab"
className="absolute inset-0 rounded-lg bg-white shadow-sm"
transition={{ type: "spring", stiffness: 400, damping: 30 }}
/>
)}
<span className="relative z-10">
{t === "login" ? "登录" : "注册"}
</span>
</button>
))}
</div>
{/* Username */}
<div className="mt-5">
<p className="text-xs font-medium text-zinc-500"></p>
<input
type="text"
value={username}
onChange={(e) => {
setUsername(e.target.value.slice(0, 16));
setError("");
}}
placeholder={tab === "register" ? "2-16 个字符" : "请输入用户名"}
maxLength={16}
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
/>
</div>
{/* Password */}
<div className="mt-4">
<p className="text-xs font-medium text-zinc-500"></p>
<div className="relative mt-2">
<input
type={showPassword ? "text" : "password"}
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError("");
}}
placeholder={tab === "register" ? "至少 6 个字符" : "请输入密码"}
className="h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 pr-10 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 transition-colors active:text-zinc-600"
>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
{/* Confirm password (register only) */}
{tab === "register" && (
<div className="mt-4">
<p className="text-xs font-medium text-zinc-500"></p>
<input
type={showPassword ? "text" : "password"}
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
setError("");
}}
placeholder="再次输入密码"
className="mt-2 h-11 w-full rounded-xl border border-zinc-200 bg-white px-4 text-sm text-zinc-800 outline-none transition-colors placeholder:text-zinc-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-100"
/>
</div>
)}
{/* Avatar picker (register only) */}
{tab === "register" && (
<div className="mt-4">
<p className="text-xs font-medium text-zinc-500">
<span className="ml-1 text-zinc-300"></span>
</p>
<div className="mt-2 grid grid-cols-6 gap-2">
{AVATARS.map((a) => (
<button
key={a.emoji}
onClick={() => setAvatar(a.emoji)}
className={`flex h-11 w-11 items-center justify-center rounded-xl text-xl transition-all ${
avatar === a.emoji
? `${a.bg} scale-110 ring-2 ring-emerald-400 ring-offset-1`
: "bg-zinc-50 hover:bg-zinc-100"
}`}
>
{a.emoji}
</button>
))}
</div>
</div>
)}
{error && (
<motion.p
className="mt-3 text-center text-xs font-medium text-rose-500"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</motion.p>
)}
<button
onClick={handleSubmit}
disabled={loading}
className="mt-5 flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-emerald-500 text-sm font-bold text-white shadow-md shadow-emerald-200 transition-colors hover:bg-emerald-600 disabled:opacity-50"
>
{loading ? (
<>
<Loader2 size={16} className="animate-spin" />
{tab === "login" ? "登录中..." : "注册中..."}
</>
) : tab === "login" ? (
"登录"
) : (
"注册"
)}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
+25
View File
@@ -22,6 +22,7 @@ import {
} from "lucide-react";
import { Restaurant, MatchType, RunnerUp } from "@/types";
import { fireCelebration, playChime } from "@/lib/celebrate";
import { isRegistered } from "@/lib/userId";
interface MatchResultProps {
restaurant: Restaurant;
@@ -30,6 +31,8 @@ interface MatchResultProps {
runnerUps: RunnerUp[];
allRestaurants: Restaurant[];
userCount: number;
roomId: string;
userId: string;
onReset: () => Promise<void>;
onNarrow: (restaurantIds: string[]) => Promise<void>;
resetting: boolean;
@@ -168,6 +171,8 @@ export default function MatchResult({
runnerUps,
allRestaurants,
userCount,
roomId,
userId,
onReset,
onNarrow,
resetting,
@@ -176,6 +181,7 @@ export default function MatchResult({
const [showRunnerUps, setShowRunnerUps] = useState(false);
const [toast, setToast] = useState("");
const celebratedRef = useRef(false);
const historySavedRef = useRef(false);
const isUnanimous = matchType === "unanimous";
const showToast = useCallback((msg: string) => {
@@ -194,6 +200,25 @@ export default function MatchResult({
}
}, [isUnanimous]);
useEffect(() => {
if (historySavedRef.current) return;
if (!isRegistered()) return;
if (matchType === "no_match") return;
historySavedRef.current = true;
fetch("/api/user/history", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
userId,
roomId,
restaurant,
matchType,
participants: userCount,
}),
}).catch(() => {});
}, [userId, roomId, restaurant, matchType, userCount]);
const handleShare = useCallback(async () => {
const lines = [
isUnanimous
+33 -2
View File
@@ -1,8 +1,9 @@
"use client";
import { useCallback } from "react";
import { Star, MapPin, Clock, ExternalLink, Flame } from "lucide-react";
import { useCallback, useState } from "react";
import { Star, MapPin, Clock, ExternalLink, Flame, Bookmark } from "lucide-react";
import { Restaurant } from "@/types";
import { getUserId, isRegistered } from "@/lib/userId";
interface RestaurantCardProps {
restaurant: Restaurant;
@@ -14,6 +15,22 @@ function stopAll(e: React.SyntheticEvent) {
}
export default function RestaurantCard({ restaurant, likeCount = 0 }: RestaurantCardProps) {
const [favorited, setFavorited] = useState(false);
const handleFavorite = useCallback(async (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();
e.preventDefault();
if (favorited) return;
try {
const res = await fetch("/api/user/favorite", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: getUserId(), restaurant }),
});
if (res.ok) setFavorited(true);
} catch {}
}, [restaurant, favorited]);
const openLink = useCallback(
(url: string) => (e: React.MouseEvent | React.TouchEvent) => {
e.stopPropagation();
@@ -59,6 +76,20 @@ export default function RestaurantCard({ restaurant, likeCount = 0 }: Restaurant
{restaurant.name}
</h2>
<div className="mt-0.5 flex shrink-0 gap-1.5">
{isRegistered() && (
<button
onClick={handleFavorite}
onPointerDown={stopAll}
onTouchStart={stopAll}
className={`flex items-center justify-center rounded-full p-1 transition-colors ${
favorited
? "bg-amber-100 text-amber-500"
: "bg-zinc-50 text-zinc-400 active:bg-amber-50 active:text-amber-500"
}`}
>
<Bookmark size={13} className={favorited ? "fill-amber-400" : ""} />
</button>
)}
<button
onClick={openLink(amapUrl)}
onPointerDown={stopAll}
+11 -27
View File
@@ -11,29 +11,8 @@ import {
Crown,
Loader2,
} from "lucide-react";
const AVATARS = [
{ emoji: "🐱", bg: "bg-amber-100" },
{ emoji: "🐶", bg: "bg-orange-100" },
{ emoji: "🦊", bg: "bg-red-100" },
{ emoji: "🐰", bg: "bg-pink-100" },
{ emoji: "🐼", bg: "bg-zinc-100" },
{ emoji: "🐨", bg: "bg-sky-100" },
{ emoji: "🦁", bg: "bg-yellow-100" },
{ emoji: "🐸", bg: "bg-lime-100" },
{ emoji: "🐵", bg: "bg-stone-100" },
{ emoji: "🐷", bg: "bg-rose-100" },
{ emoji: "🐙", bg: "bg-purple-100" },
{ emoji: "🦄", bg: "bg-violet-100" },
] as const;
function getAvatar(uid: string) {
let hash = 0;
for (let i = 0; i < uid.length; i++) {
hash = (hash * 31 + uid.charCodeAt(i)) | 0;
}
return AVATARS[((hash % AVATARS.length) + AVATARS.length) % AVATARS.length];
}
import { UserProfile } from "@/types";
import { getAvatar, getAvatarBg } from "@/lib/avatars";
interface RoomManageModalProps {
open: boolean;
@@ -44,6 +23,7 @@ interface RoomManageModalProps {
locked: boolean;
swipeCounts: Record<string, number>;
totalCards: number;
userProfiles: Record<string, UserProfile>;
onToast: (msg: string) => void;
}
@@ -56,6 +36,7 @@ export default function RoomManageModal({
locked,
swipeCounts,
totalCards,
userProfiles,
onToast,
}: RoomManageModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
@@ -172,7 +153,10 @@ export default function RoomManageModal({
</h3>
<div className="mt-2 flex flex-col gap-1.5">
{users.map((uid) => {
const avatar = getAvatar(uid);
const profile = userProfiles[uid];
const emoji = profile?.avatar ?? getAvatar(uid).emoji;
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(uid).bg;
const displayName = profile?.username ?? uid.slice(0, 8);
const isCreator = uid === userId;
const swiped = swipeCounts[uid] ?? 0;
const finished = swiped >= totalCards;
@@ -183,9 +167,9 @@ export default function RoomManageModal({
className="flex items-center gap-2.5 rounded-xl bg-zinc-50 px-3 py-2.5"
>
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${avatar.bg}`}
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-base ${bg}`}
>
{avatar.emoji}
{emoji}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex items-center gap-1.5">
@@ -196,7 +180,7 @@ export default function RoomManageModal({
</span>
)}
<span className="truncate text-xs font-medium text-zinc-500">
{uid.slice(0, 8)}
{displayName}
</span>
</div>
<span
+38 -34
View File
@@ -6,65 +6,53 @@ import SwipeableCard from "./SwipeableCard";
import ActionButtons from "./ActionButtons";
import MatchResult from "./MatchResult";
import SwipeGuide from "./SwipeGuide";
import { Restaurant, SwipeDirection, MatchType, RunnerUp } from "@/types";
import { Restaurant, SwipeDirection, MatchType, RunnerUp, UserProfile } from "@/types";
import { Heart, Undo2, Check } from "lucide-react";
const AVATARS = [
{ emoji: "🐱", bg: "bg-amber-100" },
{ emoji: "🐶", bg: "bg-orange-100" },
{ emoji: "🦊", bg: "bg-red-100" },
{ emoji: "🐰", bg: "bg-pink-100" },
{ emoji: "🐼", bg: "bg-zinc-100" },
{ emoji: "🐨", bg: "bg-sky-100" },
{ emoji: "🦁", bg: "bg-yellow-100" },
{ emoji: "🐸", bg: "bg-lime-100" },
{ emoji: "🐵", bg: "bg-stone-100" },
{ emoji: "🐷", bg: "bg-rose-100" },
{ emoji: "🐙", bg: "bg-purple-100" },
{ emoji: "🦄", bg: "bg-violet-100" },
] as const;
function getAvatar(uid: string) {
let hash = 0;
for (let i = 0; i < uid.length; i++) {
hash = (hash * 31 + uid.charCodeAt(i)) | 0;
}
return AVATARS[((hash % AVATARS.length) + AVATARS.length) % AVATARS.length];
}
import { getAvatar, getAvatarBg } from "@/lib/avatars";
function UserProgressBar({
userId,
swipeCounts,
localIndex,
total,
userProfiles,
}: {
userId: string;
swipeCounts: Record<string, number>;
localIndex: number;
total: number;
userProfiles: Record<string, UserProfile>;
}) {
const others = Object.entries(swipeCounts).filter(([id]) => id !== userId);
if (others.length === 0) return null;
const myProfile = userProfiles[userId];
const myAvatar = myProfile?.avatar ?? getAvatar(userId).emoji;
const myAvatarBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
return (
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="flex items-center gap-1 text-[11px] tabular-nums text-emerald-500">
<span className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-emerald-100 text-[10px] leading-none">
{getAvatar(userId).emoji}
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${myAvatarBg} text-[10px] leading-none`}>
{myAvatar}
</span>
{localIndex}/{total}
</span>
{others.map(([id, count]) => {
const finished = count >= total;
const avatar = getAvatar(id);
const profile = userProfiles[id];
const emoji = profile?.avatar ?? getAvatar(id).emoji;
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
const label = profile?.username ?? "";
return (
<span
key={id}
className={`flex items-center gap-1 text-[11px] tabular-nums ${finished ? "text-emerald-400" : "text-zinc-400"}`}
>
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${avatar.bg} text-[10px] leading-none`}>
{avatar.emoji}
<span className={`inline-flex h-4 w-4 items-center justify-center rounded-full ${bg} text-[10px] leading-none`}>
{emoji}
</span>
{label && <span className="max-w-[3rem] truncate">{label}</span>}
{count}/{total}
{finished && <Check size={10} className="text-emerald-400" />}
</span>
@@ -78,10 +66,12 @@ function WaitingProgress({
userId,
swipeCounts,
total,
userProfiles,
}: {
userId: string;
swipeCounts: Record<string, number>;
total: number;
userProfiles: Record<string, UserProfile>;
}) {
const entries = Object.entries(swipeCounts);
if (entries.length <= 1) return null;
@@ -89,12 +79,16 @@ function WaitingProgress({
const others = entries.filter(([id]) => id !== userId);
const finishedCount = others.filter(([, c]) => c >= total).length;
const myProfile = userProfiles[userId];
const myEmoji = myProfile?.avatar ?? getAvatar(userId).emoji;
const myBg = myProfile ? getAvatarBg(myProfile.avatar) : "bg-emerald-100";
return (
<div className="flex w-60 flex-col gap-2.5 rounded-2xl bg-zinc-50 px-4 py-3">
<div className="flex items-center justify-between">
<span className="flex items-center gap-1.5 text-xs font-medium text-emerald-600">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full bg-emerald-100 text-sm leading-none">
{getAvatar(userId).emoji}
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full ${myBg} text-sm leading-none`}>
{myEmoji}
</span>
{total}/{total}
</span>
@@ -104,14 +98,18 @@ function WaitingProgress({
{others.map(([id, count]) => {
const finished = count >= total;
const pct = Math.min((count / total) * 100, 100);
const avatar = getAvatar(id);
const profile = userProfiles[id];
const emoji = profile?.avatar ?? getAvatar(id).emoji;
const bg = profile ? getAvatarBg(profile.avatar) : getAvatar(id).bg;
const label = profile?.username ?? "";
return (
<div key={id} className="flex flex-col gap-1">
<div className="flex items-center justify-between">
<span className={`flex items-center gap-1.5 text-xs font-medium ${finished ? "text-emerald-600" : "text-zinc-500"}`}>
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-100" : avatar.bg}`}>
{avatar.emoji}
<span className={`inline-flex h-5 w-5 items-center justify-center rounded-full text-sm leading-none ${finished ? "bg-emerald-100" : bg}`}>
{emoji}
</span>
{label && <span className="max-w-[3rem] truncate">{label}</span>}
{count}/{total}
</span>
{finished && <Check size={14} className="text-emerald-400" />}
@@ -148,6 +146,7 @@ interface SwipeDeckProps {
likeCounts: Record<string, number>;
swipeCounts: Record<string, number>;
userCount: number;
userProfiles: Record<string, UserProfile>;
onReset: () => Promise<void>;
onNarrow: (restaurantIds: string[]) => Promise<void>;
}
@@ -164,6 +163,7 @@ export default function SwipeDeck({
likeCounts,
swipeCounts,
userCount,
userProfiles,
onReset,
onNarrow,
}: SwipeDeckProps) {
@@ -351,6 +351,7 @@ export default function SwipeDeck({
swipeCounts={swipeCounts}
localIndex={currentIndex}
total={restaurants.length}
userProfiles={userProfiles}
/>
</div>
)}
@@ -388,6 +389,7 @@ export default function SwipeDeck({
userId={userId}
swipeCounts={swipeCounts}
total={restaurants.length}
userProfiles={userProfiles}
/>
</div>
)}
@@ -419,6 +421,8 @@ export default function SwipeDeck({
runnerUps={runnerUps}
allRestaurants={restaurants}
userCount={userCount}
roomId={roomId}
userId={userId}
onReset={handleReset}
onNarrow={handleNarrow}
resetting={resetting}
+4
View File
@@ -5,6 +5,7 @@ import { Users, QrCode, LogOut, Crown, Lock } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
import QrInviteModal from "./QrInviteModal";
import RoomManageModal from "./RoomManageModal";
import type { UserProfile } from "@/types";
interface TopNavProps {
roomId: string;
@@ -16,6 +17,7 @@ interface TopNavProps {
locked?: boolean;
swipeCounts?: Record<string, number>;
totalCards?: number;
userProfiles?: Record<string, UserProfile>;
}
export default function TopNav({
@@ -28,6 +30,7 @@ export default function TopNav({
locked = false,
swipeCounts = {},
totalCards = 0,
userProfiles = {},
}: TopNavProps) {
const [toast, setToast] = useState("");
const [showQr, setShowQr] = useState(false);
@@ -119,6 +122,7 @@ export default function TopNav({
locked={locked}
swipeCounts={swipeCounts}
totalCards={totalCards}
userProfiles={userProfiles}
onToast={showToast}
/>
)}