feat: 新增账号设置页面并优化报表与管理端交互样式

This commit is contained in:
2026-05-22 14:34:04 +08:00
parent 2ca2790f07
commit 51891548a5
8 changed files with 208 additions and 43 deletions

View File

@@ -42,3 +42,11 @@ export async function postAdminLogin(
export async function getAdminMe(): Promise<AdminAuthMeResponse> {
return adminRequest.get<AdminAuthMeResponse>(`${API_V1_PREFIX}/admin/auth/me`);
}
/** `PUT /api/v1/admin/auth/me`(更新自身账号信息,需 Token */
export async function putAdminMe(body: {
nickname?: string;
password?: string;
}): Promise<AdminAuthMeResponse> {
return adminRequest.put<AdminAuthMeResponse>(`${API_V1_PREFIX}/admin/auth/me`, body);
}

View File

@@ -0,0 +1,11 @@
import { Metadata } from "next";
import { AccountSettingsConsole } from "@/modules/account/account-settings-console";
export const metadata: Metadata = {
title: "账号设置 - 管理后台",
};
export default function AdminAccountPage() {
return <AccountSettingsConsole />;
}

View File

@@ -131,7 +131,7 @@
@layer components {
.admin-list-card {
@apply overflow-hidden border-border/80 bg-card shadow-sm;
@apply border-border/80 bg-card shadow-sm;
}
.admin-list-header {

View File

@@ -66,7 +66,7 @@ export function AdminLanguageSwitcher() {
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-[188px] overflow-hidden rounded-[20px] border border-slate-200 bg-white p-1 shadow-[0_16px_40px_rgba(15,23,42,0.12)]"
className="w-[188px] overflow-hidden rounded-xl border border-slate-200 bg-white p-1 shadow-[0_16px_40px_rgba(15,23,42,0.12)]"
>
<DropdownMenuGroup className="space-y-0.5">
<DropdownMenuLabel className="sr-only">
@@ -79,14 +79,14 @@ export function AdminLanguageSwitcher() {
<DropdownMenuItem
key={code}
className={cn(
"flex min-h-[42px] items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 text-slate-700 outline-none transition",
"flex min-h-[42px] items-center gap-2 rounded-md border border-transparent px-2 py-1.5 text-slate-700 outline-none transition",
active
? "border-rose-100 bg-rose-50 text-rose-600"
: "hover:bg-slate-50 focus:bg-slate-50",
)}
onClick={() => void onSelectLocale(code)}
>
<span className="flex size-8 shrink-0 items-center justify-center rounded-xl bg-white text-lg shadow-[inset_0_0_0_1px_rgba(148,163,184,0.16)]">
<span className="flex size-8 shrink-0 items-center justify-center rounded-md bg-white text-lg shadow-[inset_0_0_0_1px_rgba(148,163,184,0.16)]">
{LOCALE_FLAGS[code]}
</span>
<span className="min-w-0 flex-1">

View File

@@ -5,6 +5,7 @@ import {
LogOutIcon,
UserRoundIcon,
} from "lucide-react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -66,9 +67,11 @@ export function ShellToolbar() {
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem disabled className="gap-2">
<UserRoundIcon />
{t("toolbar.accountSettings")}
<DropdownMenuItem asChild>
<Link href="/admin/account" className="flex items-center gap-2 cursor-pointer">
<UserRoundIcon className="size-4" />
{t("toolbar.accountSettings")}
</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />

View File

@@ -0,0 +1,143 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Loader2 } from "lucide-react";
import { putAdminMe } from "@/api/admin-auth";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useAdminProfile, useAdminSessionStore } from "@/stores/admin-session";
import { LotteryApiBizError } from "@/types/api/errors";
export function AccountSettingsConsole() {
const { t } = useTranslation(["common"]);
const adminProfile = useAdminProfile();
const refreshAdminProfile = useAdminSessionStore((s) => s.refreshAdminProfile);
const [nickname, setNickname] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
if (adminProfile) {
setNickname(adminProfile.nickname ?? "");
}
}, [adminProfile]);
async function handleUpdateProfile() {
if (!nickname.trim()) {
toast.error(t("validation.required", { field: t("fields.nickname", { defaultValue: "昵称" }) }));
return;
}
setLoading(true);
try {
await putAdminMe({ nickname: nickname.trim() });
toast.success(t("actions.updateSuccess", { defaultValue: "更新成功" }));
void refreshAdminProfile();
} catch (err) {
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed", { defaultValue: "更新失败" }));
} finally {
setLoading(false);
}
}
async function handleUpdatePassword() {
if (!password) {
toast.error(t("validation.required", { field: t("fields.newPassword", { defaultValue: "新密码" }) }));
return;
}
if (password !== confirmPassword) {
toast.error(t("validation.passwordMismatch", { defaultValue: "两次输入的密码不一致" }));
return;
}
setLoading(true);
try {
await putAdminMe({ password });
toast.success(t("actions.updateSuccess", { defaultValue: "更新成功" }));
setPassword("");
setConfirmPassword("");
} catch (err) {
toast.error(err instanceof LotteryApiBizError ? err.message : t("actions.updateFailed", { defaultValue: "更新失败" }));
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-4 md:p-6 lg:p-8">
<div className="flex flex-col gap-1">
<h1 className="text-xl font-semibold tracking-tight text-[#13315f]">
{t("accountSettings", { defaultValue: "账号设置" })}
</h1>
<p className="text-sm text-muted-foreground">
{t("accountSettingsDesc", { defaultValue: "管理您的基本账号资料及安全设置。" })}
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("profileSettings", { defaultValue: "基本资料" })}</CardTitle>
<CardDescription>
{t("profileSettingsDesc", { defaultValue: "更新您的显示名称。" })}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 max-w-md">
<div className="space-y-1.5">
<Label htmlFor="nickname">{t("fields.nickname", { defaultValue: "昵称" })}</Label>
<Input
id="nickname"
value={nickname}
onChange={(e) => setNickname(e.target.value)}
placeholder={t("placeholders.nickname", { defaultValue: "请输入昵称" })}
/>
</div>
<Button onClick={handleUpdateProfile} disabled={loading}>
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
{t("actions.save", { defaultValue: "保存修改" })}
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t("securitySettings", { defaultValue: "安全设置" })}</CardTitle>
<CardDescription>
{t("securitySettingsDesc", { defaultValue: "修改您的登录密码。如不修改请留空。" })}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 max-w-md">
<div className="space-y-1.5">
<Label htmlFor="password">{t("fields.newPassword", { defaultValue: "新密码" })}</Label>
<Input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("placeholders.password", { defaultValue: "请输入新密码" })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="confirm-password">{t("fields.confirmPassword", { defaultValue: "确认密码" })}</Label>
<Input
id="confirm-password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={t("placeholders.confirmPassword", { defaultValue: "请再次输入新密码" })}
/>
</div>
<Button onClick={handleUpdatePassword} disabled={loading || !password}>
{loading && <Loader2 className="mr-2 size-4 animate-spin" />}
{t("actions.updatePassword", { defaultValue: "更新密码" })}
</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -239,7 +239,7 @@ export function JackpotPoolsConsole({ embedded = false }: JackpotPoolsConsolePro
onValueChange={(v) => updateDraft(p.id, { status: v ?? "0" })}
>
<SelectTrigger>
<SelectValue />
<SelectValue>{d.status === "1" ? t("enabled") : t("disabled")}</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="0">{t("disabled")}</SelectItem>

View File

@@ -39,6 +39,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import {
Select,
SelectContent,
@@ -773,11 +774,27 @@ export function ReportsConsole() {
const value =
kind === "draw" ? filters.drawNo : kind === "player" ? filters.player : filters.operator;
const labelKey = kind === "draw" ? "drawNo" : kind === "player" ? "player" : "operator";
const open = search.open === kind;
return (
<div className="relative grid gap-1.5">
<div className="grid gap-1.5">
<Label htmlFor={`report-${kind}`}>{t(`fields.${labelKey}`)}</Label>
<div className="flex gap-2">
<Popover
open={open}
onOpenChange={(nextOpen) => {
setSearch((prev) =>
nextOpen
? {
...prev,
open: kind,
query: value,
}
: emptySearch,
);
}}
modal={false}
>
<div className="flex gap-2">
<Input
id={`report-${kind}`}
value={value}
@@ -793,25 +810,17 @@ export function ReportsConsole() {
}}
placeholder={t(`placeholders.${labelKey}`)}
/>
<Button
type="button"
variant="outline"
className="shrink-0"
onClick={() => {
setSearch((prev) => ({
...prev,
open: prev.open === kind ? null : kind,
query: value,
}));
}}
aria-label={t("searchPicker.open")}
<PopoverTrigger render={<Button type="button" variant="outline" className="shrink-0" aria-label={t("searchPicker.open")} />}>
<Search data-icon="inline-start" />
{t("searchPicker.select")}
</PopoverTrigger>
</div>
<PopoverContent
align="start"
side="bottom"
sideOffset={6}
className="z-[80] w-[var(--anchor-width)] min-w-[min(24rem,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-2"
>
<Search data-icon="inline-start" />
{t("searchPicker.select")}
</Button>
</div>
{search.open === kind ? (
<div className="absolute left-0 right-0 top-[4.55rem] z-20 rounded-md border border-border bg-popover p-2 shadow-lg">
<Input
value={search.query}
placeholder={t("searchPicker.keyword")}
@@ -878,8 +887,8 @@ export function ReportsConsole() {
))
) : null}
</div>
</div>
) : null}
</PopoverContent>
</Popover>
</div>
);
};
@@ -1156,7 +1165,6 @@ export function ReportsConsole() {
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="text-lg font-semibold tracking-tight text-[#13315f]">{t("title")}</h1>
<p className="mt-1 text-sm text-muted-foreground">{t("subtitle")}</p>
</div>
<Badge variant="outline" className="h-7 px-3">
{resultRowCount(result)} {t("preview.exportableRows")}
@@ -1168,7 +1176,7 @@ export function ReportsConsole() {
<CardHeader className="admin-list-header pb-4">
<CardTitle className="admin-list-title">{t("chooseReport")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 pt-4">
<CardContent className="space-y-1.5 pt-3">
{REPORTS.map((report) => {
const Icon = report.icon;
const active = report.key === selectedReport.key;
@@ -1178,7 +1186,7 @@ export function ReportsConsole() {
type="button"
onClick={() => setSelectedKey(report.key)}
className={cn(
"flex w-full min-w-0 items-start gap-3 rounded-md border px-3 py-3 text-left transition",
"flex w-full min-w-0 items-center gap-3 rounded-md border px-3 py-2.5 text-left transition",
active
? "border-primary bg-primary/[0.05] shadow-sm ring-1 ring-primary/15"
: "border-border/80 bg-card hover:border-primary/35 hover:bg-muted/30",
@@ -1188,10 +1196,7 @@ export function ReportsConsole() {
<Icon className="size-4" aria-hidden />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-semibold text-foreground">{t(`items.${report.key}.title`)}</span>
<span className="mt-1 block text-xs text-muted-foreground">
{t(`categories.${report.category}`)} · {formatKind(report.filterKind, t)}
</span>
<span className="block truncate text-sm font-medium text-foreground">{t(`items.${report.key}.title`)}</span>
</span>
</button>
);
@@ -1204,17 +1209,13 @@ export function ReportsConsole() {
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<CardTitle className="admin-list-title">{t("filterPanel")}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">{t(`items.${selectedReport.key}.summary`)}</p>
</div>
</CardHeader>
<CardContent className="space-y-4 pt-4">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{selectedReport.fields.map(renderField)}
</div>
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-between">
<p className="text-sm text-muted-foreground">
{selectedReport.connected ? t("queryHint") : t("backendPending")}
</p>
<div className="flex flex-col gap-3 border-t border-border/60 pt-4 sm:flex-row sm:items-center sm:justify-end">
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" onClick={resetFilters}>
{t("reset")}
@@ -1253,7 +1254,6 @@ export function ReportsConsole() {
<CardHeader className="admin-list-header flex flex-col gap-3 pb-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle className="admin-list-title">{t("preview.title")}</CardTitle>
<p className="mt-1 text-sm text-muted-foreground">{t("preview.subtitle")}</p>
</div>
<div className="flex shrink-0 gap-2">
<Button type="button" variant="outline" disabled={!result || exporting !== null} onClick={() => exportReport("csv")}>