merge: 同步 upstream/main 新功能到定制版本

- feat: Add slider verification code (bd67997)
- fix bug: Inventory cannot be zero (1f7a6ee)
- fix: resolve merge conflicts and lint errors
This commit is contained in:
2026-03-23 21:50:10 -07:00
parent 3806264343
commit d6616c5859
63 changed files with 3111 additions and 1130 deletions
@@ -6,7 +6,21 @@
"noImage": "No Image",
"placeholder": "Enter captcha code...",
"refresh": "Refresh captcha",
"required": "Please enter captcha code"
"required": "Please enter captcha code",
"sliderRequired": "Please complete the slider verification",
"slider": {
"clickToVerify": "Click to verify",
"fail": "Try again",
"hint": "Drag the piece to fit the puzzle",
"success": "Verified",
"title": "Security Verification"
},
"turnstile": {
"cancel": "Cancel",
"clickToVerify": "Click to verify",
"success": "Verified",
"title": "Security Verification"
}
},
"get": "Get Code",
"login": {
@@ -6,7 +6,21 @@
"noImage": "无图片",
"placeholder": "请输入验证码...",
"refresh": "刷新验证码",
"required": "请输入验证码"
"required": "请输入验证码",
"sliderRequired": "请完成滑块验证",
"slider": {
"clickToVerify": "点击进行验证",
"fail": "请重试",
"hint": "拖动拼图块到对应位置",
"success": "验证成功",
"title": "安全验证"
},
"turnstile": {
"cancel": "取消",
"clickToVerify": "点击进行验证",
"success": "验证成功",
"title": "安全验证"
}
},
"get": "获取验证码",
"login": {
@@ -15,9 +15,10 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function LoginForm({
loading,
@@ -39,6 +40,7 @@ export default function LoginForm({
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_login_captcha;
const formSchema = z.object({
@@ -52,14 +54,26 @@ export default function LoginForm({
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
@@ -70,6 +84,7 @@ export default function LoginForm({
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -84,7 +99,10 @@ export default function LoginForm({
<FormItem>
<FormControl>
<Input
placeholder={t("login.emailPlaceholder", "Enter your email...")}
placeholder={t(
"login.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
@@ -100,7 +118,10 @@ export default function LoginForm({
<FormItem>
<FormControl>
<Input
placeholder={t("login.passwordPlaceholder", "Enter your password...")}
placeholder={t(
"login.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
@@ -136,8 +157,8 @@ export default function LoginForm({
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
@@ -145,6 +166,20 @@ export default function LoginForm({
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
@@ -16,10 +16,11 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function RegisterForm({
loading,
@@ -41,6 +42,7 @@ export default function RegisterForm({
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_register_captcha;
const handleCheckUser = async (email: string) => {
@@ -78,8 +80,16 @@ export default function RegisterForm({
: z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
? z
.string()
.min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
@@ -94,6 +104,9 @@ export default function RegisterForm({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
invite: localStorage.getItem("invite") || "",
},
@@ -101,6 +114,7 @@ export default function RegisterForm({
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
@@ -111,6 +125,7 @@ export default function RegisterForm({
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -130,7 +145,10 @@ export default function RegisterForm({
<FormItem>
<FormControl>
<Input
placeholder={t("register.emailPlaceholder", "Enter your email...")}
placeholder={t(
"register.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
@@ -146,7 +164,10 @@ export default function RegisterForm({
<FormItem>
<FormControl>
<Input
placeholder={t("register.passwordPlaceholder", "Enter your password...")}
placeholder={t(
"register.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
@@ -163,7 +184,10 @@ export default function RegisterForm({
<FormControl>
<Input
disabled={loading}
placeholder={t("register.repeatPasswordPlaceholder", "Enter password again...")}
placeholder={t(
"register.repeatPasswordPlaceholder",
"Enter password again..."
)}
type="password"
{...field}
/>
@@ -182,7 +206,10 @@ export default function RegisterForm({
<div className="flex items-center gap-2">
<Input
disabled={loading}
placeholder={t("register.codePlaceholder", "Enter code...")}
placeholder={t(
"register.codePlaceholder",
"Enter code..."
)}
type="text"
{...field}
value={field.value as string}
@@ -248,8 +275,8 @@ export default function RegisterForm({
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
@@ -257,6 +284,20 @@ export default function RegisterForm({
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")}
@@ -15,10 +15,11 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function ResetForm({
loading,
@@ -41,6 +42,7 @@ export default function ResetForm({
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({
@@ -57,14 +59,26 @@ export default function ResetForm({
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
@@ -75,6 +89,7 @@ export default function ResetForm({
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -89,7 +104,10 @@ export default function ResetForm({
<FormItem>
<FormControl>
<Input
placeholder={t("reset.emailPlaceholder", "Enter your email...")}
placeholder={t(
"reset.emailPlaceholder",
"Enter your email..."
)}
type="email"
{...field}
/>
@@ -132,7 +150,10 @@ export default function ResetForm({
<FormItem>
<FormControl>
<Input
placeholder={t("reset.passwordPlaceholder", "Enter your new password...")}
placeholder={t(
"reset.passwordPlaceholder",
"Enter your new password..."
)}
type="password"
{...field}
/>
@@ -168,8 +189,8 @@ export default function ResetForm({
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
@@ -177,6 +198,20 @@ export default function ResetForm({
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
+74 -69
View File
@@ -2,7 +2,7 @@ import { Button } from "@workspace/ui/components/button";
import { Input } from "@workspace/ui/components/input";
import { Icon } from "@workspace/ui/composed/icon";
import { generateCaptcha } from "@workspace/ui/services/common/auth";
import { forwardRef, useEffect, useImperativeHandle, useState } from "react";
import { useEffect, useImperativeHandle, useState } from "react";
import { useTranslation } from "react-i18next";
export interface LocalCaptchaRef {
@@ -15,80 +15,85 @@ interface LocalCaptchaProps {
onCaptchaIdChange?: (id: string) => void;
}
const LocalCaptcha = forwardRef<LocalCaptchaRef, LocalCaptchaProps>(
({ value, onChange, onCaptchaIdChange }, ref) => {
const { t } = useTranslation("auth");
const [captchaImage, setCaptchaImage] = useState("");
const [loading, setLoading] = useState(false);
const LocalCaptcha = ({
value,
onChange,
onCaptchaIdChange,
ref,
}: LocalCaptchaProps & { ref?: RefObject<LocalCaptchaRef | null> }) => {
const { t } = useTranslation("auth");
const [captchaImage, setCaptchaImage] = useState("");
const [loading, setLoading] = useState(false);
const fetchCaptcha = async () => {
setLoading(true);
try {
const res = await generateCaptcha();
const captchaData = res.data?.data;
if (captchaData) {
setCaptchaImage(captchaData.image);
onCaptchaIdChange?.(captchaData.id);
}
} catch (error) {
console.error("Failed to generate captcha:", error);
} finally {
setLoading(false);
const fetchCaptcha = async () => {
setLoading(true);
try {
const res = await generateCaptcha();
const captchaData = res.data?.data;
if (captchaData) {
setCaptchaImage(captchaData.image);
onCaptchaIdChange?.(captchaData.id);
}
};
} catch (error) {
console.error("Failed to generate captcha:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
useEffect(() => {
fetchCaptcha();
}, []);
useImperativeHandle(ref, () => ({
reset: () => {
onChange?.("");
fetchCaptcha();
}, []);
},
}));
useImperativeHandle(ref, () => ({
reset: () => {
onChange?.("");
fetchCaptcha();
},
}));
return (
<div className="flex gap-2">
<Input
placeholder={t("captcha.placeholder", "Enter captcha code...")}
value={value || ""}
onChange={(e) => onChange?.(e.target.value)}
className="flex-1"
/>
<div className="relative h-10 w-32 flex-shrink-0">
{loading ? (
<div className="flex h-full items-center justify-center bg-muted">
<Icon className="animate-spin" icon="mdi:loading" />
</div>
) : captchaImage ? (
<img
src={captchaImage}
alt="captcha"
className="h-full w-full cursor-pointer object-contain"
onClick={fetchCaptcha}
title={t("captcha.clickToRefresh", "Click to refresh")}
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-xs text-muted-foreground">
{t("captcha.noImage", "No Image")}
</div>
)}
</div>
<Button
type="button"
variant="outline"
size="icon"
onClick={fetchCaptcha}
disabled={loading}
title={t("captcha.refresh", "Refresh captcha")}
>
<Icon icon="mdi:refresh" />
</Button>
return (
<div className="flex gap-2">
<Input
className="flex-1"
onChange={(e) => onChange?.(e.target.value)}
placeholder={t("captcha.placeholder", "Enter captcha code...")}
value={value || ""}
/>
<div className="relative h-10 w-32 flex-shrink-0">
{loading ? (
<div className="flex h-full items-center justify-center bg-muted">
<Icon className="animate-spin" icon="mdi:loading" />
</div>
) : captchaImage ? (
<img
alt="captcha"
className="h-full w-full cursor-pointer object-contain"
height={40}
onClick={fetchCaptcha}
src={captchaImage}
title={t("captcha.clickToRefresh", "Click to refresh")}
width={120}
/>
) : (
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground text-xs">
{t("captcha.noImage", "No Image")}
</div>
)}
</div>
);
}
);
<Button
disabled={loading}
onClick={fetchCaptcha}
size="icon"
title={t("captcha.refresh", "Refresh captcha")}
type="button"
variant="outline"
>
<Icon icon="mdi:refresh" />
</Button>
</div>
);
};
LocalCaptcha.displayName = "LocalCaptcha";
@@ -16,10 +16,11 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function LoginForm({
loading,
@@ -39,6 +40,7 @@ export default function LoginForm({
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_login_captcha;
const formSchema = z.object({
@@ -54,16 +56,28 @@ export default function LoginForm({
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().optional(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const [mode, setMode] = useState<"password" | "code">("password");
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
@@ -74,6 +88,7 @@ export default function LoginForm({
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -104,7 +119,10 @@ export default function LoginForm({
);
}
}}
placeholder={t("register.areaCodePlaceholder", "Area code...")}
placeholder={t(
"register.areaCodePlaceholder",
"Area code..."
)}
simple
value={field.value}
/>
@@ -115,7 +133,10 @@ export default function LoginForm({
/>
<Input
className="rounded-l-none"
placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
placeholder={t(
"register.telephonePlaceholder",
"Enter your telephone..."
)}
type="tel"
{...field}
/>
@@ -137,7 +158,10 @@ export default function LoginForm({
placeholder={
mode === "code"
? t("register.codePlaceholder", "Enter code...")
: t("login.passwordPlaceholder", "Enter your password...")
: t(
"login.passwordPlaceholder",
"Enter your password..."
)
}
type={mode === "code" ? "text" : "password"}
{...field}
@@ -202,8 +226,8 @@ export default function LoginForm({
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
@@ -211,6 +235,20 @@ export default function LoginForm({
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("login.title", "Login")}
@@ -17,10 +17,11 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function RegisterForm({
loading,
@@ -41,6 +42,7 @@ export default function RegisterForm({
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_register_captcha;
const formSchema = z
@@ -57,8 +59,16 @@ export default function RegisterForm({
: z.string().nullish(),
captcha_code:
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
? z
.string()
.min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
})
.superRefine(({ password, repeat_password }, ctx) => {
if (password !== repeat_password) {
@@ -73,6 +83,9 @@ export default function RegisterForm({
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
telephone_area_code: initialValues?.telephone_area_code || "1",
invite: localStorage.getItem("invite") || "",
@@ -81,6 +94,7 @@ export default function RegisterForm({
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
@@ -91,6 +105,7 @@ export default function RegisterForm({
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -126,7 +141,10 @@ export default function RegisterForm({
);
}
}}
placeholder={t("register.areaCodePlaceholder", "Area code...")}
placeholder={t(
"register.areaCodePlaceholder",
"Area code..."
)}
simple
value={field.value}
whitelist={enable_whitelist ? whitelist : []}
@@ -138,7 +156,10 @@ export default function RegisterForm({
/>
<Input
className="rounded-l-none"
placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
placeholder={t(
"register.telephonePlaceholder",
"Enter your telephone..."
)}
type="tel"
{...field}
/>
@@ -155,7 +176,10 @@ export default function RegisterForm({
<FormItem>
<FormControl>
<Input
placeholder={t("register.passwordPlaceholder", "Enter your password...")}
placeholder={t(
"register.passwordPlaceholder",
"Enter your password..."
)}
type="password"
{...field}
/>
@@ -172,7 +196,10 @@ export default function RegisterForm({
<FormControl>
<Input
disabled={loading}
placeholder={t("register.repeatPasswordPlaceholder", "Enter password again...")}
placeholder={t(
"register.repeatPasswordPlaceholder",
"Enter password again..."
)}
type="password"
{...field}
/>
@@ -190,7 +217,10 @@ export default function RegisterForm({
<div className="flex items-center gap-2">
<Input
disabled={loading}
placeholder={t("register.codePlaceholder", "Enter code...")}
placeholder={t(
"register.codePlaceholder",
"Enter code..."
)}
type="text"
{...field}
value={field.value as string}
@@ -259,8 +289,8 @@ export default function RegisterForm({
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
@@ -268,6 +298,20 @@ export default function RegisterForm({
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("register.title", "Register")}
@@ -16,10 +16,11 @@ import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import { useGlobalStore } from "@/stores/global";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
import SendCode from "../send-code";
import SliderCaptcha, { type SliderCaptchaRef } from "../slider-captcha";
import type { TurnstileRef } from "../turnstile";
import CloudFlareTurnstile from "../turnstile";
import LocalCaptcha, { type LocalCaptchaRef } from "../local-captcha";
export default function ResetForm({
loading,
@@ -41,6 +42,7 @@ export default function ResetForm({
const isTurnstile = verify.captcha_type === "turnstile";
const isLocal = verify.captcha_type === "local";
const isSlider = verify.captcha_type === "slider";
const captchaEnabled = verify.enable_user_reset_password_captcha;
const formSchema = z.object({
@@ -56,14 +58,26 @@ export default function ResetForm({
captchaEnabled && isLocal
? z.string().min(1, t("captcha.required", "Please enter captcha code"))
: z.string().nullish(),
slider_token:
captchaEnabled && isSlider
? z
.string()
.min(1, t("captcha.sliderRequired", "Please complete the slider"))
: z.string().optional(),
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
defaultValues: {
cf_token: "",
captcha_code: "",
slider_token: "",
...initialValues,
},
});
const turnstile = useRef<TurnstileRef>(null);
const localCaptcha = useRef<LocalCaptchaRef>(null);
const sliderCaptcha = useRef<SliderCaptchaRef>(null);
const handleSubmit = form.handleSubmit((data) => {
try {
// Add captcha_id for local captcha
@@ -74,6 +88,7 @@ export default function ResetForm({
} catch (_error) {
turnstile.current?.reset();
localCaptcha.current?.reset();
sliderCaptcha.current?.reset();
}
});
@@ -104,7 +119,10 @@ export default function ResetForm({
);
}
}}
placeholder={t("register.areaCodePlaceholder", "Area code...")}
placeholder={t(
"register.areaCodePlaceholder",
"Area code..."
)}
simple
value={field.value}
/>
@@ -115,7 +133,10 @@ export default function ResetForm({
/>
<Input
className="rounded-l-none"
placeholder={t("register.telephonePlaceholder", "Enter your telephone...")}
placeholder={t(
"register.telephonePlaceholder",
"Enter your telephone..."
)}
type="tel"
{...field}
/>
@@ -133,7 +154,10 @@ export default function ResetForm({
<FormControl>
<div className="flex items-center gap-2">
<Input
placeholder={t("register.codePlaceholder", "Enter code...")}
placeholder={t(
"register.codePlaceholder",
"Enter code..."
)}
type="text"
{...field}
value={field.value as string}
@@ -159,7 +183,10 @@ export default function ResetForm({
<FormItem>
<FormControl>
<Input
placeholder={t("reset.passwordPlaceholder", "Enter your new password...")}
placeholder={t(
"reset.passwordPlaceholder",
"Enter your new password..."
)}
type="password"
{...field}
/>
@@ -195,8 +222,8 @@ export default function ResetForm({
<FormControl>
<LocalCaptcha
{...field}
ref={localCaptcha}
onCaptchaIdChange={setCaptchaId}
ref={localCaptcha}
/>
</FormControl>
<FormMessage />
@@ -204,6 +231,20 @@ export default function ResetForm({
)}
/>
)}
{captchaEnabled && isSlider && (
<FormField
control={form.control}
name="slider_token"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderCaptcha {...field} ref={sliderCaptcha} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<Button disabled={loading} type="submit">
{loading && <Icon className="animate-spin" icon="mdi:loading" />}
{t("reset.title", "Reset Password")}
@@ -0,0 +1,355 @@
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import { Icon } from "@workspace/ui/composed/icon";
import {
generateCaptcha,
verifyCaptchaSlider,
} from "@workspace/ui/services/common/auth";
import { useCallback, useImperativeHandle, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
export interface SliderCaptchaRef {
reset: () => void;
}
interface SliderCaptchaProps {
value?: string;
onChange?: (value: string) => void;
}
interface TrailPoint {
x: number;
y: number;
t: number;
}
const BLOCK_SIZE = 100;
const BG_NATURAL_WIDTH = 560;
const BG_NATURAL_HEIGHT = 280;
const SliderCaptcha = ({
onChange,
ref,
}: SliderCaptchaProps & { ref?: RefObject<SliderCaptchaRef | null> }) => {
const { t } = useTranslation("auth");
const containerRef = useRef<HTMLDivElement>(null);
const [open, setOpen] = useState(false);
const [captchaId, setCaptchaId] = useState("");
const [bgImage, setBgImage] = useState("");
const [blockImage, setBlockImage] = useState("");
const [loading, setLoading] = useState(false);
const [verified, setVerified] = useState(false);
const [status, setStatus] = useState<"idle" | "success" | "fail">("idle");
const [blockPos, setBlockPos] = useState({ x: 0, y: 50 });
const [shaking, setShaking] = useState(false);
const dragging = useRef(false);
const startPointer = useRef({ x: 0, y: 0 });
const startBlock = useRef({ x: 0, y: 50 });
const dragStartTime = useRef(0);
const trail = useRef<TrailPoint[]>([]);
const getContainerSize = () => {
const el = containerRef.current;
if (!el) return { w: BG_NATURAL_WIDTH, h: BG_NATURAL_HEIGHT };
return { w: el.clientWidth, h: el.clientHeight };
};
const fetchCaptcha = useCallback(async () => {
setLoading(true);
setStatus("idle");
setBlockPos({ x: 0, y: 50 });
trail.current = [];
try {
const res = await generateCaptcha();
const data = res.data?.data;
if (data) {
setCaptchaId(data.id);
setBgImage(data.image);
setBlockImage(data.block_image ?? "");
}
} catch (e) {
console.error("Failed to generate slider captcha:", e);
} finally {
setLoading(false);
}
}, []);
const handleOpen = () => {
if (verified) return;
setOpen(true);
fetchCaptcha();
};
useImperativeHandle(ref, () => ({
reset: () => {
setVerified(false);
onChange?.("");
setOpen(false);
trail.current = [];
},
}));
const onPointerDown = (e: React.PointerEvent) => {
if (status === "success" || loading) return;
dragging.current = true;
dragStartTime.current = Date.now();
trail.current = [];
startPointer.current = { x: e.clientX, y: e.clientY };
startBlock.current = { ...blockPos };
(e.target as HTMLElement).setPointerCapture(e.pointerId);
e.preventDefault();
trail.current.push({
x: Math.round(startBlock.current.x),
y: Math.round(startBlock.current.y),
t: 0,
});
};
const onPointerMove = (e: React.PointerEvent) => {
if (!dragging.current) return;
const { w, h } = getContainerSize();
const scaleX = BG_NATURAL_WIDTH / w;
const scaleY = BG_NATURAL_HEIGHT / h;
const dx = (e.clientX - startPointer.current.x) * scaleX;
const dy = (e.clientY - startPointer.current.y) * scaleY;
const newX = Math.max(
0,
Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE)
);
const newY = Math.max(
0,
Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE)
);
setBlockPos({ x: newX, y: newY });
trail.current.push({
x: Math.round(newX),
y: Math.round(newY),
t: Date.now() - dragStartTime.current,
});
};
const onPointerUp = async (e: React.PointerEvent) => {
if (!dragging.current) return;
dragging.current = false;
const { w, h } = getContainerSize();
const scaleX = BG_NATURAL_WIDTH / w;
const scaleY = BG_NATURAL_HEIGHT / h;
const dx = (e.clientX - startPointer.current.x) * scaleX;
const dy = (e.clientY - startPointer.current.y) * scaleY;
const finalX = Math.round(
Math.max(
0,
Math.min(startBlock.current.x + dx, BG_NATURAL_WIDTH - BLOCK_SIZE)
)
);
const finalY = Math.round(
Math.max(
0,
Math.min(startBlock.current.y + dy, BG_NATURAL_HEIGHT - BLOCK_SIZE)
)
);
setBlockPos({ x: finalX, y: finalY });
trail.current.push({
x: finalX,
y: finalY,
t: Date.now() - dragStartTime.current,
});
try {
const res = await verifyCaptchaSlider({
id: captchaId,
x: finalX,
y: finalY,
trail: JSON.stringify(trail.current),
});
const token = res.data?.data?.token;
if (token) {
setStatus("success");
setTimeout(() => {
setVerified(true);
onChange?.(token);
setOpen(false);
}, 600);
} else {
triggerFail();
}
} catch {
triggerFail();
}
};
const triggerFail = () => {
setStatus("fail");
setShaking(true);
setTimeout(() => {
setShaking(false);
fetchCaptcha();
}, 800);
};
const blockLeftPct = (blockPos.x / BG_NATURAL_WIDTH) * 100;
const blockTopPct = (blockPos.y / BG_NATURAL_HEIGHT) * 100;
const blockSizeWPct = (BLOCK_SIZE / BG_NATURAL_WIDTH) * 100;
const blockSizeHPct = (BLOCK_SIZE / BG_NATURAL_HEIGHT) * 100;
return (
<>
{/* Trigger button */}
<button
className={`relative flex w-full items-center gap-3 rounded-md border px-4 py-3 text-sm transition-colors ${
verified
? "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30"
: "border-input bg-background hover:bg-muted"
}`}
onClick={handleOpen}
type="button"
>
<span
className={`relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
verified ? "bg-green-500" : "bg-primary"
}`}
>
{verified ? (
<Icon className="text-white text-xs" icon="mdi:check" />
) : (
<>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
</>
)}
</span>
<span className={verified ? "font-medium" : "text-muted-foreground"}>
{verified
? t("captcha.slider.success", "Verified")
: t("captcha.slider.clickToVerify", "Click to verify")}
</span>
{verified && (
<Icon className="ml-auto text-green-500" icon="mdi:check-circle" />
)}
</button>
{/* Slider dialog */}
<Dialog
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
open={open}
>
<DialogContent className="select-none p-6 sm:max-w-md">
<DialogHeader>
<DialogTitle>
{t("captcha.slider.title", "Security Verification")}
</DialogTitle>
</DialogHeader>
<div
className={`relative w-full overflow-hidden rounded-md bg-muted ${
shaking ? "animate-[shake_0.4s_ease-in-out]" : ""
}`}
ref={containerRef}
style={{
paddingTop: `${(BG_NATURAL_HEIGHT / BG_NATURAL_WIDTH) * 100}%`,
}}
>
<div className="absolute inset-0">
{loading ? (
<div className="flex h-full items-center justify-center">
<Icon className="animate-spin text-2xl" icon="mdi:loading" />
</div>
) : bgImage ? (
<>
<img
alt="captcha background"
className="absolute inset-0 h-full w-full"
draggable={false}
height={BG_NATURAL_HEIGHT}
src={bgImage}
width={BG_NATURAL_WIDTH}
/>
{blockImage && (
<img
alt="captcha block"
className="absolute cursor-grab active:cursor-grabbing"
draggable={false}
height={BG_NATURAL_HEIGHT}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
src={blockImage}
style={{
filter:
status === "success"
? "drop-shadow(0 0 3px rgba(74,222,128,0.9))"
: status === "fail"
? "drop-shadow(0 0 3px rgba(248,113,113,0.9))"
: "drop-shadow(0 0 2px rgba(255,255,255,0.7))",
left: `${blockLeftPct}%`,
top: `${blockTopPct}%`,
width: `${blockSizeWPct}%`,
height: `${blockSizeHPct}%`,
touchAction: "none",
}}
width={BG_NATURAL_WIDTH}
/>
)}
{status !== "idle" && (
<div
className={`absolute inset-0 flex items-center justify-center font-medium text-sm ${
status === "success"
? "bg-green-500/20 text-green-700"
: "bg-red-500/20 text-red-700"
}`}
>
{status === "success"
? t("captcha.slider.success", "Verified")
: t("captcha.slider.fail", "Try again")}
</div>
)}
</>
) : null}
</div>
</div>
<p className="text-center text-muted-foreground text-xs">
{t("captcha.slider.hint", "Drag the piece to fit the puzzle")}
</p>
<Button
className="w-full"
disabled={loading}
onClick={fetchCaptcha}
size="sm"
type="button"
variant="ghost"
>
<Icon icon="mdi:refresh" />
{t("captcha.clickToRefresh", "Refresh")}
</Button>
</DialogContent>
</Dialog>
<style>{`
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-8px); }
40% { transform: translateX(8px); }
60% { transform: translateX(-6px); }
80% { transform: translateX(6px); }
}
`}</style>
</>
);
};
SliderCaptcha.displayName = "SliderCaptcha";
export default SliderCaptcha;
+110 -25
View File
@@ -1,7 +1,20 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@workspace/ui/components/dialog";
import { Icon } from "@workspace/ui/composed/icon";
import { useTheme } from "next-themes";
import { type RefObject, useEffect, useImperativeHandle } from "react";
import {
type RefObject,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import Turnstile, { useTurnstile } from "react-turnstile";
import { useGlobalStore } from "@/stores/global";
@@ -24,47 +37,119 @@ const CloudFlareTurnstile = function CloudFlareTurnstile({
const { common } = useGlobalStore();
const { verify } = common;
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const { i18n, t } = useTranslation("auth");
const locale = i18n.language;
const turnstile = useTurnstile();
const [open, setOpen] = useState(false);
const [verified, setVerified] = useState(false);
useImperativeHandle(
ref,
() => ({
reset: () => turnstile.reset(),
reset: () => {
setVerified(false);
onChange("");
turnstile.reset();
},
}),
[turnstile]
[turnstile, onChange]
);
useEffect(() => {
if (value === "") {
setVerified(false);
turnstile.reset();
}
}, [turnstile, value]);
const handleOpen = () => {
if (verified) return;
setOpen(true);
};
if (!verify.turnstile_site_key) return null;
return (
verify.turnstile_site_key && (
<Turnstile
fixedSize
id={id}
language={locale.toLowerCase()}
onExpire={() => {
onChange();
turnstile.reset();
<>
{/* Trigger button */}
<button
className={`relative flex w-full items-center gap-3 rounded-md border px-4 py-3 text-sm transition-colors ${
verified
? "border-green-400 bg-green-50 text-green-700 dark:bg-green-950/30"
: "border-input bg-background hover:bg-muted"
}`}
onClick={handleOpen}
type="button"
>
<span
className={`relative flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${
verified ? "bg-green-500" : "bg-primary"
}`}
>
{verified ? (
<Icon className="text-white text-xs" icon="mdi:check" />
) : (
<>
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-primary opacity-60" />
<span className="relative inline-flex h-3 w-3 rounded-full bg-primary" />
</>
)}
</span>
<span className={verified ? "font-medium" : "text-muted-foreground"}>
{verified
? t("captcha.turnstile.success", "Verified")
: t("captcha.turnstile.clickToVerify", "Click to verify")}
</span>
{verified && (
<Icon className="ml-auto text-green-500" icon="mdi:check-circle" />
)}
</button>
{/* Turnstile dialog */}
<Dialog
onOpenChange={(o) => {
if (!o) setOpen(false);
}}
onTimeout={() => {
onChange();
turnstile.reset();
}}
onVerify={(token) => onChange(token)}
// onError={() => {
// onChange();
// turnstile.reset();
// }}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
)
open={open}
>
<DialogContent className="flex w-auto flex-col items-center gap-4 p-6">
<DialogHeader>
<DialogTitle>
{t("captcha.turnstile.title", "Security Verification")}
</DialogTitle>
</DialogHeader>
<Turnstile
fixedSize
id={id}
language={locale.toLowerCase()}
onExpire={() => {
onChange("");
turnstile.reset();
}}
onTimeout={() => {
onChange("");
turnstile.reset();
}}
onVerify={(token) => {
setVerified(true);
onChange(token);
setTimeout(() => setOpen(false), 400);
}}
sitekey={verify.turnstile_site_key}
theme={resolvedTheme as "light" | "dark"}
/>
<Button
className="w-full"
onClick={() => setOpen(false)}
size="sm"
type="button"
variant="ghost"
>
{t("captcha.turnstile.cancel", "Cancel")}
</Button>
</DialogContent>
</Dialog>
</>
);
};
@@ -1,20 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardHeader, CardTitle } from "@workspace/ui/components/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@workspace/ui/components/select";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Tabs, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
import { Icon } from "@workspace/ui/composed/icon";
import { queryUserSubscribe } from "@workspace/ui/services/user/user";
import { getUserTrafficStats } from "@workspace/ui/services/user/traffic";
import { queryUserSubscribe } from "@workspace/ui/services/user/user";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import TrafficTrendChart from "./traffic-trend-chart";
import TrafficRatioChart from "./traffic-ratio-chart";
import TrafficStatsCards from "./traffic-stats-cards";
import TrafficTrendChart from "./traffic-trend-chart";
export default function TrafficStatistics() {
const { t } = useTranslation("traffic");
const [days, setDays] = useState<7 | 30>(7);
const [selectedSubscribeId, setSelectedSubscribeId] = useState<string | null>(null);
const [selectedSubscribeId, setSelectedSubscribeId] = useState<string | null>(
null
);
// 查询用户订阅列表
const { data: userSubscribe = [] } = useQuery({
@@ -26,7 +39,8 @@ export default function TrafficStatistics() {
});
// 使用 id_str 字段,避免 JavaScript 精度丢失
const activeSubscribeId = selectedSubscribeId || (userSubscribe[0]?.id_str || null);
const activeSubscribeId =
selectedSubscribeId || userSubscribe[0]?.id_str || null;
// 查询流量统计数据
const { data: trafficStats, isLoading } = useQuery({
@@ -54,11 +68,13 @@ export default function TrafficStatistics() {
{/* 订阅选择 */}
{userSubscribe.length > 1 && (
<Select
value={activeSubscribeId || undefined}
onValueChange={(value) => setSelectedSubscribeId(value)}
value={activeSubscribeId || undefined}
>
<SelectTrigger className="w-full md:w-[200px]">
<SelectValue placeholder={t("selectSubscription", "Select Subscription")} />
<SelectValue
placeholder={t("selectSubscription", "Select Subscription")}
/>
</SelectTrigger>
<SelectContent>
{userSubscribe.map((sub) => (
@@ -70,7 +86,10 @@ export default function TrafficStatistics() {
</Select>
)}
{/* 时间范围切换 */}
<Tabs value={String(days)} onValueChange={(value) => setDays(Number(value) as 7 | 30)}>
<Tabs
onValueChange={(value) => setDays(Number(value) as 7 | 30)}
value={String(days)}
>
<TabsList>
<TabsTrigger value="7">{t("days7", "7 Days")}</TabsTrigger>
<TabsTrigger value="30">{t("days30", "30 Days")}</TabsTrigger>
@@ -87,7 +106,9 @@ export default function TrafficStatistics() {
{/* 流量趋势图 */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("trafficTrend", "Traffic Trend")}</CardTitle>
<CardTitle className="text-base">
{t("trafficTrend", "Traffic Trend")}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
@@ -107,17 +128,21 @@ export default function TrafficStatistics() {
{/* 流量占比图 */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t("trafficRatio", "Upload/Download Ratio")}</CardTitle>
<CardTitle className="text-base">
{t("trafficRatio", "Upload/Download Ratio")}
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex h-[300px] items-center justify-center">
<Icon className="size-8 animate-spin" icon="uil:spinner" />
</div>
) : trafficStats && (trafficStats.total_upload > 0 || trafficStats.total_download > 0) ? (
) : trafficStats &&
(trafficStats.total_upload > 0 ||
trafficStats.total_download > 0) ? (
<TrafficRatioChart
upload={trafficStats.total_upload}
download={trafficStats.total_download}
upload={trafficStats.total_upload}
/>
) : (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
@@ -129,4 +154,4 @@ export default function TrafficStatistics() {
</div>
</div>
);
}
}
@@ -1,12 +1,22 @@
import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
import {
Cell,
Legend,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
} from "recharts";
interface TrafficRatioChartProps {
upload: number;
download: number;
}
export default function TrafficRatioChart({ upload, download }: TrafficRatioChartProps) {
export default function TrafficRatioChart({
upload,
download,
}: TrafficRatioChartProps) {
const { t } = useTranslation("traffic");
const data = [
@@ -31,20 +41,22 @@ export default function TrafficRatioChart({ upload, download }: TrafficRatioChar
};
return (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer height={300} width="100%">
<PieChart>
<Pie
data={data}
cx="50%"
cy="50%"
labelLine={false}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(1)}%`}
outerRadius={80}
fill="#8884d8"
data={data}
dataKey="value"
fill="#8884d8"
label={({ name, percent }) =>
`${name}: ${(percent * 100).toFixed(1)}%`
}
labelLine={false}
outerRadius={80}
>
{data.map((_, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
<Cell fill={COLORS[index % COLORS.length]} key={`cell-${index}`} />
))}
</Pie>
<Tooltip formatter={(value: number) => formatTraffic(value)} />
@@ -38,7 +38,9 @@ export default function TrafficStatsCards({ stats }: TrafficStatsCardsProps) {
<Card key={card.title}>
<CardContent className="flex items-center justify-between p-6">
<div className="flex flex-col gap-1">
<span className="text-muted-foreground text-sm">{card.title}</span>
<span className="text-muted-foreground text-sm">
{card.title}
</span>
<span className="font-bold text-2xl">
<Display type="traffic" value={card.value} />
</span>
@@ -2,14 +2,14 @@ import type { GetUserTrafficStatsResponse } from "@workspace/ui/services/user/tr
import { format } from "date-fns";
import { useTranslation } from "react-i18next";
import {
LineChart,
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
interface TrafficTrendChartProps {
@@ -28,7 +28,8 @@ export default function TrafficTrendChart({ data }: TrafficTrendChartProps) {
// 格式化流量显示
const formatTraffic = (value: number | string) => {
const numValue = typeof value === "string" ? parseFloat(value) : value;
const numValue =
typeof value === "string" ? Number.parseFloat(value) : value;
if (numValue >= 1024) {
return `${(numValue / 1024).toFixed(2)} GB`;
}
@@ -36,44 +37,52 @@ export default function TrafficTrendChart({ data }: TrafficTrendChartProps) {
};
return (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer height={300} width="100%">
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
dataKey="date"
label={{ value: t("date", "Date"), position: "insideBottom", offset: -5 }}
label={{
value: t("date", "Date"),
position: "insideBottom",
offset: -5,
}}
/>
<YAxis
label={{ value: t("traffic", "Traffic (MB)"), angle: -90, position: "insideLeft" }}
label={{
value: t("traffic", "Traffic (MB)"),
angle: -90,
position: "insideLeft",
}}
/>
<Tooltip
formatter={(value: number | string) => formatTraffic(value)}
labelStyle={{ color: "#000" }}
/>
<Legend
verticalAlign="bottom"
height={36}
verticalAlign="bottom"
wrapperStyle={{
position: "absolute",
width: "444px",
height: "36px",
left: "5px",
bottom: "-5px"
bottom: "-5px",
}}
/>
<Line
type="monotone"
dataKey="upload"
stroke="#10b981"
name={t("upload", "Upload")}
stroke="#10b981"
strokeWidth={2}
type="monotone"
/>
<Line
type="monotone"
dataKey="download"
stroke="#3b82f6"
name={t("download", "Download")}
stroke="#3b82f6"
strokeWidth={2}
type="monotone"
/>
</LineChart>
</ResponsiveContainer>