feat(player): 优化桌面投注布局与账号功能

This commit is contained in:
wchino
2026-07-21 21:11:07 +08:00
parent 406ebb7671
commit 8307cc3f88
25 changed files with 1514 additions and 195 deletions

View File

@@ -0,0 +1,279 @@
"use client";
import { ChevronDown, KeyRound, Loader2, LogOut } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { putPlayerPassword } from "@/api/player-auth";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Field, FieldDescription, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { validatePlayerLoginPassword } from "@/lib/player-input-validation";
import { usePlayerSessionStore } from "@/stores/player-session-store";
import { LotteryApiBizError } from "@/types/api/errors";
type AccountView = "profile" | "password";
function ProfileRow({ label, value }: { label: string; value: string }) {
return (
<div className="grid grid-cols-[7.5rem_minmax(0,1fr)] items-start gap-3 py-2.5 text-sm">
<dt className="text-muted-foreground">{label}</dt>
<dd className="min-w-0 break-words text-right font-medium text-foreground">{value}</dd>
</div>
);
}
function accountInitial(value: string): string {
const trimmed = value.trim();
return trimmed === "" ? "P" : trimmed.slice(0, 1).toUpperCase();
}
export function PlayerAccountDialog() {
const { t } = useTranslation("player");
const router = useRouter();
const profile = usePlayerSessionStore((state) => state.profile);
const clearBearerToken = usePlayerSessionStore((state) => state.clearBearerToken);
const [open, setOpen] = useState(false);
const [view, setView] = useState<AccountView>("profile");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [saving, setSaving] = useState(false);
const displayName =
profile?.nickname?.trim() ||
profile?.username?.trim() ||
(profile?.id != null ? t("player.fallback", { id: profile.id }) : t("account.loading"));
const isNative = profile?.auth_source === "lottery_native";
function resetPasswordForm() {
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
}
function handleOpenChange(nextOpen: boolean) {
setOpen(nextOpen);
if (!nextOpen) {
setView("profile");
resetPasswordForm();
}
}
function handleLogout() {
handleOpenChange(false);
clearBearerToken();
router.replace("/login");
}
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
if (currentPassword === "" || newPassword === "" || confirmPassword === "") {
toast.error(t("account.password.required"));
return;
}
if (validatePlayerLoginPassword(newPassword) === "too_short") {
toast.error(t("account.password.tooShort"));
return;
}
if (newPassword !== confirmPassword) {
toast.error(t("account.password.mismatch"));
return;
}
if (newPassword === currentPassword) {
toast.error(t("account.password.mustDiffer"));
return;
}
setSaving(true);
try {
await putPlayerPassword({
current_password: currentPassword,
password: newPassword,
password_confirmation: confirmPassword,
});
handleOpenChange(false);
clearBearerToken();
router.replace("/login?password=changed");
} catch (error) {
toast.error(
error instanceof LotteryApiBizError ? error.message : t("account.password.failed"),
);
} finally {
setSaving(false);
}
}
return (
<>
<Button
type="button"
variant="outline"
size="sm"
disabled={profile === null}
aria-label={t("account.open")}
onClick={() => setOpen(true)}
className="max-w-[8.5rem] justify-start rounded-full bg-muted/40 px-1.5 sm:max-w-[11rem]"
>
<Avatar size="sm">
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
</Avatar>
<span className="min-w-0 flex-1 truncate text-left">{displayName}</span>
<ChevronDown data-icon="inline-end" className="hidden sm:block" />
</Button>
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
{view === "profile" ? (
<>
<DialogHeader>
<DialogTitle>{t("account.title")}</DialogTitle>
<DialogDescription>{t("account.description")}</DialogDescription>
</DialogHeader>
<Card>
<CardHeader>
<div className="flex items-center gap-3">
<Avatar size="lg">
<AvatarFallback>{accountInitial(displayName)}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<CardTitle className="truncate">{displayName}</CardTitle>
<CardDescription className="truncate">
{profile?.username || profile?.site_player_id || "—"}
</CardDescription>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-1">
<Badge variant="secondary">
{isNative ? t("account.auth.native") : t("account.auth.sso")}
</Badge>
<Badge variant="outline">
{profile?.funding_mode === "credit"
? t("account.funding.credit")
: t("account.funding.wallet")}
</Badge>
</div>
</CardHeader>
<CardContent>
<dl>
<ProfileRow label={t("account.fields.username")} value={profile?.username || "—"} />
<Separator />
<ProfileRow label={t("account.fields.site")} value={profile?.site_code || "—"} />
<Separator />
<ProfileRow
label={t("account.fields.playerId")}
value={profile?.site_player_id || "—"}
/>
<Separator />
<ProfileRow
label={t("account.fields.currency")}
value={profile?.default_currency || "—"}
/>
</dl>
</CardContent>
</Card>
{!isNative ? (
<p className="text-sm leading-relaxed text-muted-foreground">
{t("account.ssoManaged")}
</p>
) : null}
{isNative ? (
<DialogFooter>
<Button type="button" variant="destructive" onClick={handleLogout}>
<LogOut data-icon="inline-start" />
{t("account.logout")}
</Button>
<Button type="button" variant="outline" onClick={() => setView("password")}>
<KeyRound data-icon="inline-start" />
{t("account.password.action")}
</Button>
</DialogFooter>
) : null}
</>
) : (
<form onSubmit={handlePasswordSubmit}>
<div className="flex flex-col gap-4">
<DialogHeader>
<DialogTitle>{t("account.password.title")}</DialogTitle>
<DialogDescription>{t("account.password.description")}</DialogDescription>
</DialogHeader>
<FieldGroup>
<Field>
<FieldLabel htmlFor="player-current-password">
{t("account.password.current")}
</FieldLabel>
<Input
id="player-current-password"
type="password"
autoComplete="current-password"
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</Field>
<Field>
<FieldLabel htmlFor="player-new-password">
{t("account.password.new")}
</FieldLabel>
<Input
id="player-new-password"
type="password"
autoComplete="new-password"
value={newPassword}
onChange={(event) => setNewPassword(event.target.value)}
/>
<FieldDescription>{t("account.password.hint")}</FieldDescription>
</Field>
<Field>
<FieldLabel htmlFor="player-confirm-password">
{t("account.password.confirm")}
</FieldLabel>
<Input
id="player-confirm-password"
type="password"
autoComplete="new-password"
value={confirmPassword}
onChange={(event) => setConfirmPassword(event.target.value)}
/>
</Field>
</FieldGroup>
<DialogFooter>
<Button type="submit" disabled={saving}>
{saving ? <Loader2 data-icon="inline-start" className="animate-spin" /> : null}
{saving ? t("account.password.saving") : t("account.password.submit")}
</Button>
<Button type="button" variant="outline" disabled={saving} onClick={() => setView("profile")}>
{t("actions.cancel")}
</Button>
</DialogFooter>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
);
}