feat: 增加管理端多语言与多模块界面国际化支持
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
@@ -11,13 +12,12 @@ import {
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
import { adminShellNavItems, ADMIN_BASE } from "@/modules/_config/admin-nav";
|
||||
import { CONFIG_ROUTE_LABELS } from "@/modules/config/config-nav-model";
|
||||
import React from "react";
|
||||
|
||||
const DRAW_ROUTE_LABELS: Record<string, string> = {
|
||||
finance: "期号收支",
|
||||
review: "审核",
|
||||
results: "开奖结果",
|
||||
finance: "Draw Finance",
|
||||
review: "Review",
|
||||
results: "Results",
|
||||
};
|
||||
|
||||
function titleCase(value: string): string {
|
||||
@@ -34,15 +34,16 @@ type BreadcrumbCrumb = {
|
||||
};
|
||||
|
||||
export function AdminBreadcrumb() {
|
||||
const { t } = useTranslation(["common", "dashboard", "reports", "audit", "config", "draws"]);
|
||||
const pathname = usePathname();
|
||||
|
||||
// 把路径拆分成段
|
||||
// Split the current path into segments.
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
|
||||
// 基础面包屑:首页/仪表盘
|
||||
// Base breadcrumb: home / dashboard.
|
||||
const breadcrumbs: BreadcrumbCrumb[] = [
|
||||
{
|
||||
label: "首页",
|
||||
label: t("nav.home", { ns: "common" }),
|
||||
href: ADMIN_BASE,
|
||||
isCurrent: pathname === ADMIN_BASE,
|
||||
},
|
||||
@@ -56,8 +57,16 @@ export function AdminBreadcrumb() {
|
||||
});
|
||||
|
||||
if (navItem && navItem.href !== ADMIN_BASE) {
|
||||
const navLabelMap: Record<string, string> = {
|
||||
dashboard: t("title", { ns: "dashboard" }),
|
||||
reports: t("title", { ns: "reports" }),
|
||||
"audit-logs": t("title", { ns: "audit" }),
|
||||
};
|
||||
breadcrumbs.push({
|
||||
label: navItem.segment === "draws" ? "期号列表" : navItem.label,
|
||||
label:
|
||||
navItem.segment === "draws"
|
||||
? "Draws"
|
||||
: navLabelMap[navItem.segment] ?? navItem.label,
|
||||
href: navItem.href,
|
||||
isCurrent: pathname === navItem.href || segments.length === 2,
|
||||
});
|
||||
@@ -67,9 +76,14 @@ export function AdminBreadcrumb() {
|
||||
const subSegment = segments[2];
|
||||
let subLabel = "";
|
||||
if (businessSegment === "config" && subSegment) {
|
||||
subLabel = CONFIG_ROUTE_LABELS[subSegment] ?? titleCase(subSegment);
|
||||
subLabel = t(`nav.items.${subSegment}`, { ns: "config", defaultValue: titleCase(subSegment) });
|
||||
} else {
|
||||
subLabel = subSegment ? DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment) : "";
|
||||
subLabel = subSegment
|
||||
? t(`subnav.${subSegment}`, {
|
||||
ns: "draws",
|
||||
defaultValue: DRAW_ROUTE_LABELS[subSegment] ?? titleCase(subSegment),
|
||||
})
|
||||
: "";
|
||||
}
|
||||
if (subLabel) {
|
||||
breadcrumbs.push({
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { format, parse } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { enUS } from "date-fns/locale";
|
||||
import { CalendarIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@@ -16,16 +17,18 @@ export function AdminDateField({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "选择日期",
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
/** `yyyy-MM-dd` 或空 */
|
||||
/** `yyyy-MM-dd` or empty */
|
||||
value: string;
|
||||
onChange: (next: string) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const resolvedPlaceholder = placeholder ?? t("date.placeholder", { ns: "common", defaultValue: "Select date" });
|
||||
|
||||
const parsed = React.useMemo(() => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
@@ -35,7 +38,7 @@ export function AdminDateField({
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}, [value]);
|
||||
|
||||
const summary = parsed ? format(parsed, "yyyy年M月d日", { locale: zhCN }) : placeholder;
|
||||
const summary = parsed ? format(parsed, "yyyy-MM-dd", { locale: enUS }) : resolvedPlaceholder;
|
||||
|
||||
return (
|
||||
<div className="grid gap-1.5">
|
||||
@@ -62,7 +65,7 @@ export function AdminDateField({
|
||||
<PopoverContent align="start" sideOffset={6} className="w-auto min-w-fit p-0">
|
||||
<Calendar
|
||||
mode="single"
|
||||
locale={zhCN}
|
||||
locale={enUS}
|
||||
captionLayout="dropdown"
|
||||
selected={parsed}
|
||||
defaultMonth={parsed}
|
||||
@@ -82,7 +85,7 @@ export function AdminDateField({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
{t("actions.clear", { ns: "common", defaultValue: "Clear" })}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import * as React from "react";
|
||||
import type { DateRange } from "react-day-picker";
|
||||
import { format, parse } from "date-fns";
|
||||
import { zhCN } from "date-fns/locale";
|
||||
import { enUS } from "date-fns/locale";
|
||||
import { CalendarRange } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { Calendar } from "@/components/ui/calendar";
|
||||
@@ -31,19 +32,19 @@ function summarize(from: string, to: string, placeholder: string): string {
|
||||
if (!df && !dt) {
|
||||
return placeholder;
|
||||
}
|
||||
const a = df ? format(df, "yyyy年M月d日", { locale: zhCN }) : "…";
|
||||
const b = dt ? format(dt, "yyyy年M月d日", { locale: zhCN }) : "…";
|
||||
return `${a} 至 ${b}`;
|
||||
const a = df ? format(df, "yyyy-MM-dd", { locale: enUS }) : "...";
|
||||
const b = dt ? format(dt, "yyyy-MM-dd", { locale: enUS }) : "...";
|
||||
return `${a} - ${b}`;
|
||||
}
|
||||
|
||||
/** shadcn Popover + Calendar `mode="range"`;输出与原先两个 `yyyy-MM-dd` 筛选字段兼容 */
|
||||
/** Range date picker compatible with legacy `yyyy-MM-dd` filter fields. */
|
||||
export function AdminDateRangeField({
|
||||
id,
|
||||
label,
|
||||
from: fromProp,
|
||||
to: toProp,
|
||||
onRangeChange,
|
||||
placeholder = "选择日期范围",
|
||||
placeholder,
|
||||
}: {
|
||||
id: string;
|
||||
label?: string;
|
||||
@@ -52,8 +53,11 @@ export function AdminDateRangeField({
|
||||
onRangeChange: (next: { from: string; to: string }) => void;
|
||||
placeholder?: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const resolvedPlaceholder =
|
||||
placeholder ?? t("date.rangePlaceholder", { ns: "common", defaultValue: "Select date range" });
|
||||
|
||||
const selected = React.useMemo((): DateRange | undefined => {
|
||||
const df = parseYmd(fromProp);
|
||||
@@ -98,16 +102,20 @@ export function AdminDateRangeField({
|
||||
>
|
||||
<CalendarRange className="pointer-events-none size-4 shrink-0 opacity-70" aria-hidden />
|
||||
<span className="min-w-0 flex-1 truncate text-left">
|
||||
{summarize(fromProp, toProp, placeholder)}
|
||||
{summarize(fromProp, toProp, resolvedPlaceholder)}
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" sideOffset={6} className="w-auto max-w-[calc(100vw-2rem)] min-w-fit p-0">
|
||||
<p className="text-muted-foreground border-b px-3 py-2 text-xs leading-relaxed">
|
||||
先选开始日,再选结束日(单日可对同一天点两次);点「完成」关闭面板。
|
||||
{t("date.rangeHint", {
|
||||
ns: "common",
|
||||
defaultValue:
|
||||
"Select a start date, then an end date. For a single day, click the same date twice. Click Done to close.",
|
||||
})}
|
||||
</p>
|
||||
<Calendar
|
||||
mode="range"
|
||||
locale={zhCN}
|
||||
locale={enUS}
|
||||
captionLayout="dropdown"
|
||||
selected={selected}
|
||||
defaultMonth={defaultMonth}
|
||||
@@ -133,7 +141,7 @@ export function AdminDateRangeField({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
清除
|
||||
{t("actions.clear", { ns: "common", defaultValue: "Clear" })}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -144,7 +152,7 @@ export function AdminDateRangeField({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
完成
|
||||
{t("actions.done", { ns: "common", defaultValue: "Done" })}
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -37,7 +37,11 @@ export function AdminLanguageSwitcher() {
|
||||
applyAdminUiLocale(next);
|
||||
await i18n.changeLanguage(next);
|
||||
setLocale(next);
|
||||
toast.success(`${t("language.changed", { defaultValue: "语言已切换" })}: ${ADMIN_LOCALE_LABELS[next]}`);
|
||||
toast.success(
|
||||
t("language.changed", {
|
||||
language: ADMIN_LOCALE_LABELS[next],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -49,7 +53,7 @@ export function AdminLanguageSwitcher() {
|
||||
<DropdownMenuContent align="end" className="min-w-[12rem]">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground">
|
||||
{t("language.title", { defaultValue: "界面语言" })}
|
||||
{t("language.title")}
|
||||
</DropdownMenuLabel>
|
||||
{ADMIN_API_LOCALES.map((code) => (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Pagination,
|
||||
@@ -21,7 +22,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export const ADMIN_LIST_PER_PAGE_OPTIONS = [10, 20, 25, 50, 100] as const;
|
||||
|
||||
/** 服务端分页页码序列(省略号);与 shadcn Pagination 拼装用 */
|
||||
/** Server-side pagination page slices with ellipsis markers. */
|
||||
export function adminListPageSlices(
|
||||
current: number,
|
||||
last: number,
|
||||
@@ -58,15 +59,16 @@ export function AdminPerPagePicker({
|
||||
perPage,
|
||||
onChange,
|
||||
}: {
|
||||
/** 表单控件 id · 区分同页多块列表 */
|
||||
/** Form control id used to distinguish multiple lists on one page. */
|
||||
selectId: string;
|
||||
perPage: number;
|
||||
onChange: (next: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Label htmlFor={selectId} className="text-sm leading-none whitespace-nowrap">
|
||||
每页条数
|
||||
{t("pagination.perPage", { defaultValue: "Per page" })}
|
||||
</Label>
|
||||
<Select
|
||||
modal={false}
|
||||
@@ -79,7 +81,7 @@ export function AdminPerPagePicker({
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id={selectId} size="sm" className="w-[6.75rem]">
|
||||
<SelectValue placeholder="请选择" />
|
||||
<SelectValue placeholder={t("pagination.selectPlaceholder", { defaultValue: "Select" })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="start" sideOffset={6}>
|
||||
{ADMIN_LIST_PER_PAGE_OPTIONS.map((n) => (
|
||||
@@ -93,7 +95,7 @@ export function AdminPerPagePicker({
|
||||
);
|
||||
}
|
||||
|
||||
/** 表格底栏:左统计 + 右「每页条数」与 shadcn Pagination(与 Data Table pagination 一节一致) */
|
||||
/** Table footer: left summary, right per-page picker and pagination. */
|
||||
export function AdminListPaginationFooter({
|
||||
selectId,
|
||||
total,
|
||||
@@ -113,10 +115,16 @@ export function AdminListPaginationFooter({
|
||||
onPerPageChange: (nextPerPage: number) => void;
|
||||
onPageChange: (nextPage: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
return (
|
||||
<div className="flex flex-col gap-4 border-t border-border pt-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-muted-foreground text-sm tabular-nums">
|
||||
共 {total} 条;第 {page} / {lastPage} 页
|
||||
{t("pagination.summary", {
|
||||
total,
|
||||
page,
|
||||
lastPage,
|
||||
defaultValue: "{{total}} total, page {{page}} / {{lastPage}}",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex w-full flex-col items-stretch gap-4 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center sm:justify-end lg:gap-6">
|
||||
<AdminPerPagePicker
|
||||
@@ -132,7 +140,7 @@ export function AdminListPaginationFooter({
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
text="上一页"
|
||||
text={t("pagination.previous", { defaultValue: "Previous" })}
|
||||
aria-disabled={page <= 1 || loading}
|
||||
className={cn(
|
||||
(page <= 1 || loading) && "pointer-events-none opacity-50",
|
||||
@@ -175,7 +183,7 @@ export function AdminListPaginationFooter({
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
text="下一页"
|
||||
text={t("pagination.next", { defaultValue: "Next" })}
|
||||
aria-disabled={page >= lastPage || loading}
|
||||
className={cn(
|
||||
(page >= lastPage || loading) && "pointer-events-none opacity-50",
|
||||
|
||||
@@ -4,6 +4,7 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useMemo } from "react";
|
||||
import { SparklesIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -33,6 +34,7 @@ function isActive(pathname: string, item: { href: string; activeMatchPrefix?: st
|
||||
}
|
||||
|
||||
export function AdminAppSidebar() {
|
||||
const { t } = useTranslation(["common", "dashboard", "players", "draws", "config", "wallet", "risk", "settlement", "jackpot", "reconcile", "tickets", "reports", "audit"]);
|
||||
const pathname = usePathname();
|
||||
const profile = useAdminProfile();
|
||||
const visibleNav = useMemo(
|
||||
@@ -55,7 +57,7 @@ export function AdminAppSidebar() {
|
||||
<SparklesIcon data-icon="inline-start" aria-hidden />
|
||||
<div className="flex flex-col items-start gap-0 group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold tracking-tight text-sidebar-foreground">
|
||||
彩票后台
|
||||
{t("app.title", { ns: "common" })}
|
||||
</span>
|
||||
<span className="text-[11px] leading-tight text-sidebar-foreground/70">
|
||||
Lottery Admin
|
||||
@@ -67,7 +69,7 @@ export function AdminAppSidebar() {
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>工作台</SidebarGroupLabel>
|
||||
<SidebarGroupLabel>{t("sidebar.workspace", { ns: "common", defaultValue: "Workspace" })}</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{visibleNav.map((item) => {
|
||||
@@ -75,12 +77,12 @@ export function AdminAppSidebar() {
|
||||
return (
|
||||
<SidebarMenuItem key={item.segment}>
|
||||
<SidebarMenuButton
|
||||
tooltip={item.label}
|
||||
tooltip={t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}
|
||||
isActive={isActive(pathname, item)}
|
||||
render={<Link href={item.href} />}
|
||||
>
|
||||
<Icon data-icon="inline-start" aria-hidden />
|
||||
<span>{item.label}</span>
|
||||
<span>{t(`nav.${item.segment}`, { ns: "common", defaultValue: item.label })}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { readToken } from "@/stores/admin-token";
|
||||
|
||||
@@ -10,10 +11,11 @@ type ShellAuthGateProps = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 壳内路由:仅客户端可读 `localStorage` Token,无 Token 时跳转登录。
|
||||
* 注意:首屏仍可能先出现服务端渲染的页面片段,再完成跳转(未用 Cookie + middleware 前无法完全避免)。
|
||||
* Shell route guard. Reads the auth token from localStorage on the client and
|
||||
* redirects to the login page when no token is present.
|
||||
*/
|
||||
export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const [allowed, setAllowed] = useState(false);
|
||||
|
||||
@@ -31,7 +33,7 @@ export function ShellAuthGate({ children }: ShellAuthGateProps) {
|
||||
if (!allowed) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] w-full flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
正在校验登录状态…
|
||||
{t("auth.checking", { defaultValue: "Checking sign-in status…" })}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { ShieldCheckIcon, TriangleAlertIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { isAxiosError } from "axios";
|
||||
|
||||
@@ -18,6 +19,7 @@ import { useAdminSessionStore } from "@/stores/admin-session";
|
||||
import { LotteryApiBizError } from "@/types/api/errors";
|
||||
|
||||
export function LoginForm() {
|
||||
const { t } = useTranslation("auth");
|
||||
const router = useRouter();
|
||||
const setBearerToken = useAdminSessionStore((s) => s.setBearerToken);
|
||||
const setAdminProfile = useAdminSessionStore((s) => s.setAdminProfile);
|
||||
@@ -43,7 +45,7 @@ export function LoginForm() {
|
||||
try {
|
||||
const data = await getAdminCaptcha();
|
||||
if (!data) {
|
||||
toast.error("无法获取验证码,请检查接口或网络");
|
||||
toast.error(t("captchaLoadFailed"));
|
||||
setCaptchaKey(null);
|
||||
setCaptchaSrc(null);
|
||||
|
||||
@@ -55,7 +57,7 @@ export function LoginForm() {
|
||||
} finally {
|
||||
setLoadingCaptcha(false);
|
||||
}
|
||||
}, [apiConfigured]);
|
||||
}, [apiConfigured, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (readToken()) {
|
||||
@@ -73,12 +75,12 @@ export function LoginForm() {
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
if (!apiConfigured) {
|
||||
toast.error("未配置 NEXT_PUBLIC_LOTTERY_API_BASE_URL");
|
||||
toast.error(t("apiBaseMissingToast"));
|
||||
|
||||
return;
|
||||
}
|
||||
if (!captchaKey || !captchaSrc) {
|
||||
toast.error("请先刷新验证码");
|
||||
toast.error(t("captchaRequired"));
|
||||
void loadCaptcha();
|
||||
|
||||
return;
|
||||
@@ -94,7 +96,9 @@ export function LoginForm() {
|
||||
});
|
||||
setBearerToken(result.token);
|
||||
setAdminProfile(result.admin);
|
||||
toast.success(`欢迎,${result.admin.nickname || result.admin.username}`);
|
||||
toast.success(
|
||||
t("welcome", { name: result.admin.nickname || result.admin.username }),
|
||||
);
|
||||
router.replace("/admin");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
@@ -105,11 +109,11 @@ export function LoginForm() {
|
||||
return;
|
||||
}
|
||||
if (isAxiosError(err)) {
|
||||
toast.error(err.message || "网络请求失败");
|
||||
toast.error(err.message || t("networkFailed"));
|
||||
|
||||
return;
|
||||
}
|
||||
toast.error("登录失败");
|
||||
toast.error(t("loginFailed"));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -142,7 +146,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<CardTitle className="text-balance text-2xl font-semibold tracking-tight">
|
||||
{authModuleMeta.title}
|
||||
{t("loginTitle", { defaultValue: authModuleMeta.title })}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -151,19 +155,19 @@ export function LoginForm() {
|
||||
{!apiConfigured ? (
|
||||
<Alert variant="destructive" className="text-left">
|
||||
<TriangleAlertIcon />
|
||||
<AlertTitle>未配置 API 地址</AlertTitle>
|
||||
<AlertTitle>{t("apiMissingTitle")}</AlertTitle>
|
||||
<AlertDescription>
|
||||
请在环境中设置{" "}
|
||||
{t("apiMissingDescriptionPrefix")}{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
NEXT_PUBLIC_LOTTERY_API_BASE_URL
|
||||
</code>{" "}
|
||||
(Laravel 根 URL,如 http://127.0.0.1:8000)。
|
||||
{t("apiMissingDescriptionSuffix")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="admin-account" className="text-sm font-medium">
|
||||
账号
|
||||
{t("account")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-account"
|
||||
@@ -171,7 +175,7 @@ export function LoginForm() {
|
||||
autoComplete="username"
|
||||
value={account}
|
||||
onChange={(ev) => setAccount(ev.target.value)}
|
||||
placeholder="登录账号"
|
||||
placeholder={t("accountPlaceholder")}
|
||||
required
|
||||
disabled={submitting}
|
||||
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
|
||||
@@ -179,7 +183,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="admin-password" className="text-sm font-medium">
|
||||
密码
|
||||
{t("password")}
|
||||
</Label>
|
||||
<Input
|
||||
id="admin-password"
|
||||
@@ -188,7 +192,7 @@ export function LoginForm() {
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(ev) => setPassword(ev.target.value)}
|
||||
placeholder="密码"
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
required
|
||||
disabled={submitting}
|
||||
className="h-11 transition-shadow focus-visible:ring-2 focus-visible:ring-primary/25"
|
||||
@@ -196,7 +200,7 @@ export function LoginForm() {
|
||||
</div>
|
||||
<div className="mb-8 flex flex-col gap-2">
|
||||
<Label htmlFor="admin-captcha" className="text-sm font-medium">
|
||||
验证码
|
||||
{t("captcha")}
|
||||
</Label>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
@@ -205,7 +209,7 @@ export function LoginForm() {
|
||||
autoComplete="off"
|
||||
value={captchaCode}
|
||||
onChange={(ev) => setCaptchaCode(ev.target.value)}
|
||||
placeholder="图中字符"
|
||||
placeholder={t("captchaPlaceholder")}
|
||||
maxLength={32}
|
||||
required
|
||||
disabled={submitting}
|
||||
@@ -218,7 +222,7 @@ export function LoginForm() {
|
||||
disabled={
|
||||
loadingCaptcha || !apiConfigured || submitting
|
||||
}
|
||||
aria-label={loadingCaptcha ? "加载验证码中" : "点击刷新验证码"}
|
||||
aria-label={loadingCaptcha ? t("captchaLoading") : t("captchaRefresh")}
|
||||
>
|
||||
{captchaSrc ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- data URL from API
|
||||
@@ -231,7 +235,7 @@ export function LoginForm() {
|
||||
/>
|
||||
) : (
|
||||
<span className="px-2 text-xs text-muted-foreground">
|
||||
{loadingCaptcha ? "加载中…" : "点击获取"}
|
||||
{loadingCaptcha ? t("captchaLoading") : t("captchaFetch")}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -245,7 +249,7 @@ export function LoginForm() {
|
||||
className="h-11 w-full text-base font-medium shadow-sm"
|
||||
disabled={submitting || !apiConfigured}
|
||||
>
|
||||
{submitting ? "登录中…" : "登录"}
|
||||
{submitting ? t("submitting") : t("submit")}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UserRoundIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AdminLanguageSwitcher } from "@/components/admin/admin-language-switcher";
|
||||
@@ -64,6 +65,7 @@ function initialsFromProfile(profile: AdminProfile | null): string {
|
||||
}
|
||||
|
||||
export function ShellToolbar() {
|
||||
const { t } = useTranslation("common");
|
||||
const router = useRouter();
|
||||
const adminProfile = useAdminProfile();
|
||||
const clearSession = useAdminSessionStore((s) => s.clearSession);
|
||||
@@ -71,11 +73,11 @@ export function ShellToolbar() {
|
||||
const displayName =
|
||||
adminProfile?.nickname?.trim() ||
|
||||
adminProfile?.username?.trim() ||
|
||||
"管理员";
|
||||
t("toolbar.defaultAdmin");
|
||||
|
||||
function onLogout() {
|
||||
clearSession();
|
||||
toast.success("已退出登录");
|
||||
toast.success(t("toolbar.loggedOut"));
|
||||
router.replace("/admin/login");
|
||||
router.refresh();
|
||||
}
|
||||
@@ -87,9 +89,9 @@ export function ShellToolbar() {
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative shrink-0 text-primary hover:text-primary"
|
||||
aria-label="通知"
|
||||
title="通知"
|
||||
onClick={() => toast.message("通知功能开发中")}
|
||||
aria-label={t("toolbar.notifications")}
|
||||
title={t("toolbar.notifications")}
|
||||
onClick={() => toast.message(t("toolbar.notificationsComingSoon"))}
|
||||
>
|
||||
<BellIcon className="size-5 stroke-[1.75]" />
|
||||
<span className="pointer-events-none absolute -top-0.5 -right-0.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-semibold leading-none text-white shadow-sm ring-2 ring-background">
|
||||
@@ -132,7 +134,7 @@ export function ShellToolbar() {
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem disabled className="gap-2">
|
||||
<UserRoundIcon />
|
||||
账号设置
|
||||
{t("toolbar.accountSettings")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
@@ -143,7 +145,7 @@ export function ShellToolbar() {
|
||||
onClick={onLogout}
|
||||
>
|
||||
<LogOutIcon />
|
||||
退出登录
|
||||
{t("actions.logout")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
|
||||
Reference in New Issue
Block a user