压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
@@ -0,0 +1,224 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { Moon, Sun, Loader2, Check, Monitor } from "lucide-react"
import { useTheme } from "next-themes"
import { toast } from "sonner"
import { Button } from "@/components/ui/button"
import { useCustomTheme } from "@/lib/theme"
import { cn } from "@/lib/utils"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
function ThemeModeSection() {
const { theme, setTheme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
const modes = [
{ id: "light", name: "明亮", icon: Sun },
{ id: "dark", name: "黑暗", icon: Moon },
{ id: "system", name: "自动", icon: Monitor }
]
if (!mounted) {
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
{modes.map((mode) => {
const Icon = mode.icon
return (
<Button
key={mode.id}
variant="secondary"
size="sm"
className="flex-1 text-xs"
>
{Icon && <Icon className="size-3 mr-1" />}
{mode.name}
</Button>
)
})}
</div>
</div>
)
}
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">主题模式</h2>
<p className="text-xs text-muted-foreground">
选择应用程序的整体明暗主题
</p>
</div>
<div className="flex gap-2">
{modes.map((mode) => {
const Icon = mode.icon
const isActive = theme === mode.id
return (
<Button
key={mode.id}
variant={isActive ? "default" : "secondary"}
size="sm"
onClick={() => setTheme(mode.id)}
className="flex-1 text-xs"
>
{Icon && <Icon className="size-3 mr-1" />}
{mode.name}
</Button>
)
})}
</div>
</div>
)
}
function InterfaceAppearanceSection() {
const { themes, currentThemeId, setTheme: setCustomTheme, isLoading } = useCustomTheme()
const { theme: mode } = useTheme()
const [mounted, setMounted] = React.useState(false)
const [switching, setSwitching] = React.useState<string | null>(null)
React.useEffect(() => {
setMounted(true)
}, [])
const handleSetTheme = (themeId: string) => {
if (switching === themeId) return
setSwitching(themeId)
try {
setCustomTheme(themeId)
const themeName = themes.find(t => t.id === themeId)?.name || themeId
toast.success(`外观已切换为 ${ themeName }`)
} catch {
toast.error("切换失败")
} finally {
setSwitching(null)
}
}
const isDark = mounted && (mode === 'dark' || (mode === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches))
return (
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">界面外观</h2>
<p className="text-xs text-muted-foreground">
选择界面的配色方案和视觉风格
</p>
</div>
<div>
{isLoading ? (
<div className="flex items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground/50" />
</div>
) : (
<div className="grid grid-cols-2 xs:grid-cols-3 sm:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-6">
{themes.map((theme) => {
const isActive = currentThemeId === theme.id || (!currentThemeId && theme.id === "default.css")
const isSwitching = switching === theme.id
const colors = isDark ? theme.colors.dark : theme.colors.light
return (
<div
key={theme.id}
onClick={() => handleSetTheme(theme.id)}
className={cn(
"group relative cursor-pointer rounded-xl border transition-all overflow-hidden",
isActive
? "border-primary ring-2 ring-primary/20"
: "border-border hover:border-border/50 hover:shadow-md"
)}
>
<div
className="aspect-[4/3] w-full relative"
style={{ backgroundColor: colors.background || colors['background'] }}
>
<div className="flex h-full p-2 gap-2">
<div
className="w-1/4 h-full rounded-sm opacity-80"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
<div className="flex-1 space-y-1.5 pt-1">
<div
className="h-1.5 w-1/3 rounded-full opacity-90"
style={{ backgroundColor: colors.primary || colors['primary'] }}
/>
<div
className="h-8 w-full rounded-sm opacity-40"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
<div
className="h-4 w-1/2 rounded-sm opacity-30"
style={{ backgroundColor: colors.sidebar || colors['sidebar'] }}
/>
</div>
</div>
{isActive && !isSwitching && (
<div className="absolute top-2 right-2 z-10 bg-primary text-primary-foreground rounded-full p-0.5 shadow-sm">
<Check className="h-3 w-3" />
</div>
)}
</div>
<div className="p-2 border-t border-border/50 bg-card">
<div className={cn("text-[10px] font-medium text-center truncate transition-colors", isActive ? "text-primary" : "text-muted-foreground")}>
{theme.name}
</div>
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
export function AppearanceMain() {
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">外观设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<ThemeModeSection />
<InterfaceAppearanceSection />
<div className="space-y-4 opacity-50 pointer-events-none grayscale">
<h2 className="font-medium text-sm text-muted-foreground">即将推出</h2>
<div className="bg-muted/30 rounded-xl p-6 h-32 flex items-center justify-center border border-border/50">
<span className="text-sm text-muted-foreground">更多自定义选项正在开发中...</span>
</div>
</div>
</div>
)
}
@@ -0,0 +1,60 @@
"use client"
import Link from "next/link"
import { Bell } from "lucide-react"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { useNotificationSettings } from "@/contexts/notification-settings-context"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
export function NotificationsMain() {
const { showBell, setShowBell } = useNotificationSettings()
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">通知设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="space-y-6">
<div>
<h2 className="font-medium text-sm text-foreground">通知显示</h2>
<p className="text-xs text-muted-foreground">
设置通知相关的显示选项
</p>
</div>
<div className="flex items-center justify-between rounded-lg border p-4">
<div className="flex items-center gap-3">
<Bell className="size-5 text-primary" />
<div className="space-y-0.5">
<Label htmlFor="show-bell" className="text-sm font-medium cursor-pointer">
显示通知铃铛
</Label>
<p className="text-xs text-muted-foreground">
在顶部导航栏中显示通知铃铛图标
</p>
</div>
</div>
<Switch
id="show-bell"
checked={showBell}
onCheckedChange={setShowBell}
/>
</div>
</div>
</div>
)
}
@@ -0,0 +1,469 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { motion, useAnimation } from "motion/react"
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious, type CarouselApi } from "@/components/ui/carousel"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Info } from "lucide-react"
import { cn } from "@/lib/utils"
import { PayLevel, User } from "@/lib/services/auth"
import { useUser } from "@/contexts/user-context"
import services from "@/lib/services"
import type { UserPayConfig } from "@/lib/services/admin"
/**
* 等级配置
*/
interface LevelConfig {
level: PayLevel
name: string
nameEn: string
minScore: number
maxScore: number | null
gradient: string
textColor: string
metalEffect?: boolean
dailyLimit: number | null
feeRate: number | string
scoreRate: number | string
}
/** 等级视觉样式配置(不包含业务数据) */
const LEVEL_STYLES = [
{
level: PayLevel.Ordinary,
name: "普通会员",
nameEn: "ORDINARY MEMBER",
gradient: "from-[#1e3a8a] via-[#2563eb] to-[#1d4ed8] shadow-lg shadow-blue-900/10",
textColor: "text-white/95",
},
{
level: PayLevel.Gold,
name: "黄金会员",
nameEn: "GOLD MEMBER",
gradient: "from-[#FDFCF0] via-[#FCE38A] to-[#B45309] shadow-lg shadow-amber-500/10",
textColor: "text-[#854D0E]",
metalEffect: true,
},
{
level: PayLevel.WhiteGold,
name: "白金会员",
nameEn: "PLATINUM MEMBER",
gradient: "from-[#F8FAFC] via-[#E2E8F0] to-[#94A3B8] shadow-lg shadow-slate-500/10",
textColor: "text-slate-800",
metalEffect: true,
},
{
level: PayLevel.BlackGold,
name: "黑金会员",
nameEn: "BLACK MEMBER",
gradient: "from-[#020617] via-[#1e293b] to-[#020617] shadow-lg shadow-black/20",
textColor: "text-[#FFE5B4]",
metalEffect: true,
},
] as const
/** 合并 API 数据和视觉样式 */
function mergeLevelConfigs(apiConfigs: UserPayConfig[]): LevelConfig[] {
return LEVEL_STYLES.map(style => {
const apiConfig = apiConfigs.find(c => c.level === style.level)
if (apiConfig) {
return {
...style,
minScore: apiConfig.min_score,
maxScore: apiConfig.max_score,
dailyLimit: apiConfig.daily_limit,
feeRate: apiConfig.fee_rate,
scoreRate: apiConfig.score_rate,
}
}
return {
...style,
minScore: 0,
maxScore: null,
dailyLimit: null,
feeRate: 0,
scoreRate: 0,
}
})
}
function getLevelConfig(score: number, configs: LevelConfig[]): LevelConfig {
if (configs.length === 0) {
return {
level: PayLevel.Ordinary,
name: "加载中...",
nameEn: "LOADING...",
gradient: "from-blue-600 via-blue-500 to-blue-700",
textColor: "text-white",
minScore: 0,
maxScore: null,
dailyLimit: null,
feeRate: 0,
scoreRate: 0,
}
}
for (let i = configs.length - 1; i >= 0; i--) {
if (score >= configs[i].minScore) {
return configs[i]
}
}
return configs[0]
}
function MembershipCard({
config,
user,
score,
currentLevel,
isActive,
levelConfigs,
}: {
config: LevelConfig
user: User
score: number
currentLevel: LevelConfig
isActive: boolean
levelConfigs: LevelConfig[]
}) {
const isAccessible = levelConfigs.findIndex(l => l.level === config.level) <= levelConfigs.findIndex(l => l.level === currentLevel.level)
const isDarkCard = config.level === PayLevel.BlackGold || config.level === PayLevel.Ordinary
return (
<div
className={cn(
"transition-all duration-500",
isActive ? "opacity-100 scale-100" : "opacity-40 scale-95"
)}
>
<div
className={cn(
"relative w-full aspect-[2/1] rounded-xl overflow-hidden bg-gradient-to-br shadow-lg",
config.gradient,
config.metalEffect && "shadow-2xl"
)}
>
<div className="absolute inset-0 opacity-10">
<div className="absolute top-0 right-0 w-40 h-40 bg-white rounded-full blur-3xl -translate-y-1/2 translate-x-1/2" />
<div className="absolute bottom-0 left-0 w-32 h-32 bg-white rounded-full blur-3xl translate-y-1/2 -translate-x-1/2" />
</div>
<div className="absolute inset-0 opacity-[0.15] bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
{config.metalEffect && (
<>
<div className="absolute inset-0 bg-gradient-to-br from-white/5 via-white/2 to-transparent" />
<div className="absolute inset-0 rounded-xl ring-1 ring-inset ring-white/25" />
<div className="absolute top-0 left-0 w-full h-1/4 bg-gradient-to-b from-white/15 via-transparent to-transparent rounded-t-xl" />
</>
)}
<div className={cn("relative h-full flex flex-col justify-between p-3 sm:p-4 md:p-5", config.textColor)}>
<div>
<div className="text-[8px] sm:text-[9px] md:text-[10px] font-medium opacity-70 tracking-wider">
{config.nameEn}
</div>
<div className="text-sm sm:text-base md:text-lg font-bold tracking-tight">
{config.name}
</div>
</div>
<div className="space-y-0.5 sm:space-y-1">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70 tracking-wide">平台分数</div>
<div className="text-lg sm:text-xl md:text-2xl font-bold tracking-tight tabular-nums">
{score.toLocaleString()}
</div>
</div>
<div className="flex items-end justify-between gap-1">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70">
LINUX DO <span className="italic font-serif">Credit</span>
</div>
<div className="text-right">
<div className="text-[8px] sm:text-[9px] md:text-[10px] opacity-70">分数范围</div>
<div className="text-[8px] sm:text-[9px] md:text-[10px] font-medium tabular-nums">
{config.minScore < 0 ? "0.00" : config.minScore.toLocaleString()} - {config.maxScore?.toLocaleString() || "∞"}
</div>
</div>
</div>
</div>
<div className="absolute top-3 right-3 sm:top-4 sm:right-4 md:top-5 md:right-5">
{isAccessible ? (
<Avatar className="size-8 sm:size-9 md:size-10 border-2 border-white/20">
<AvatarImage src={user?.avatar_url} alt={user?.nickname} />
<AvatarFallback
className={cn(
"text-xs sm:text-sm font-semibold",
isDarkCard ? "bg-white/20 text-white" : "bg-black/20 text-black"
)}
>
{user?.nickname?.slice(0, 2)}
</AvatarFallback>
</Avatar>
) : (
<div
className={cn(
"px-1.5 sm:px-2 py-0.5 sm:py-1 rounded-full text-[8px] sm:text-[9px] md:text-[10px] font-semibold backdrop-blur-sm",
isDarkCard ? "bg-white/20 text-white" : "bg-black/20 text-black"
)}
>
未获得
</div>
)}
</div>
</div>
</div>
)
}
function getPayLevelLabel(level: PayLevel): string {
switch (level) {
case PayLevel.BlackGold:
return "黑金会员"
case PayLevel.WhiteGold:
return "白金会员"
case PayLevel.Gold:
return "黄金会员"
case PayLevel.Ordinary:
return "普通会员"
default:
return "未知等级"
}
}
export function ProfileMain() {
const { user, loading, getTrustLevelLabel } = useUser()
const [api, setApi] = React.useState<CarouselApi>()
const [current, setCurrent] = React.useState(0)
const [levelConfigs, setLevelConfigs] = React.useState<LevelConfig[]>([])
const controls = useAnimation()
const isAnimatingRef = React.useRef(false)
const handleAvatarClick = () => {
if (isAnimatingRef.current) return
isAnimatingRef.current = true
controls.start({
rotate: [0, -20, 20, -20, 20, 0],
transition: { duration: 0.5, ease: "easeInOut" }
})
setTimeout(() => {
isAnimatingRef.current = false
}, 650)
}
const score = user?.pay_score ?? 0
const currentLevel = getLevelConfig(score, levelConfigs)
React.useEffect(() => {
async function fetchPayConfigs() {
const configs = await services.config.getUserPayConfigs()
setLevelConfigs(mergeLevelConfigs(configs))
}
fetchPayConfigs()
}, [])
React.useEffect(() => {
if (!api) return
setCurrent(api.selectedScrollSnap())
api.on("select", () => setCurrent(api.selectedScrollSnap()))
}, [api])
React.useEffect(() => {
if (!api || levelConfigs.length === 0 || !currentLevel) return
const scrollToCurrentLevel = () => {
const currentLevelIndex = levelConfigs.findIndex(
(config) => config.level === currentLevel.level
)
if (currentLevelIndex >= 0) {
api.scrollTo(currentLevelIndex, true)
}
}
scrollToCurrentLevel()
api.on("reInit", scrollToCurrentLevel)
return () => {
api.off("reInit", scrollToCurrentLevel)
}
}, [api, currentLevel, levelConfigs])
if (loading) {
return (
<div className="py-6 space-y-4">
<div className="border-b border-border pb-4">
<h1 className="text-2xl font-semibold">个人资料</h1>
</div>
</div>
)
}
if (!user) {
return (
<div className="py-6 space-y-6">
<div className="text-sm text-muted-foreground">未找到用户信息</div>
</div>
)
}
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">个人资料</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<h2 className="font-medium text-sm text-muted-foreground">会员等级</h2>
<Carousel
setApi={setApi}
opts={{
align: "center",
loop: false,
containScroll: false,
}}
className="w-full relative"
>
<div className="absolute inset-y-0 left-0 w-8 bg-gradient-to-r from-background to-transparent pointer-events-none z-10" />
<div className="absolute inset-y-0 right-0 w-8 bg-gradient-to-l from-background to-transparent pointer-events-none z-10" />
<CarouselContent className="-ml-4">
{levelConfigs.map((config, index) => (
<CarouselItem
key={config.level}
className="pl-4 basis-[85%] sm:basis-[70%] md:basis-[65%] lg:basis-[50%] xl:basis-[40%] 2xl:basis-[35%] cursor-pointer"
onClick={() => api?.scrollTo(index)}
>
<MembershipCard
config={config}
user={user}
score={score}
currentLevel={currentLevel}
isActive={index === current}
levelConfigs={levelConfigs}
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious className="left-0" />
<CarouselNext className="right-0" />
</Carousel>
<div className="space-y-4">
<h2 className="font-medium text-sm text-muted-foreground">基本信息</h2>
<div className="flex flex-col sm:flex-row items-start gap-4 sm:gap-6">
<motion.div
animate={controls}
onClick={handleAvatarClick}
className="cursor-pointer origin-center"
>
<Avatar className="size-14 sm:size-16">
<AvatarImage src={user.avatar_url} alt={user.nickname || user.username} />
<AvatarFallback className="text-lg sm:text-xl">
{(user.nickname || user.username).slice(0, 2)}
</AvatarFallback>
</Avatar>
</motion.div>
<div className="flex-1 w-full">
<div className="grid grid-cols-2 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
<div className="space-y-1">
<div className="text-xs text-muted-foreground">账户</div>
<div className="text-sm font-medium">@{user.username}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">昵称</div>
<div className="text-sm font-medium">{user.nickname || '未设置'}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">用户ID</div>
<div className="text-sm font-medium">{user.id}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">信任等级</div>
<div className="text-sm font-medium">{getTrustLevelLabel(user.trust_level)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台等级</div>
<div className="text-sm font-medium">{getPayLevelLabel(user.pay_level)}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台分数</div>
<div className="text-sm font-medium">{user.pay_score.toLocaleString()}</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">每日流转限额</div>
<div className="text-sm font-medium">
{currentLevel.dailyLimit ? `${ currentLevel.dailyLimit.toLocaleString() } 积分` : '无限额'}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">集市手续费率</div>
<div className="text-sm font-medium">
{typeof currentLevel.feeRate === 'number'
? `${ (currentLevel.feeRate * 100).toFixed(2) }%`
: `${ (parseFloat(currentLevel.feeRate as string) * 100).toFixed(2) }%`}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground">平台分数转化率</div>
<div className="text-sm font-medium">
{typeof currentLevel.scoreRate === 'number'
? `${ (currentLevel.scoreRate * 100).toFixed(2) }%`
: `${ (parseFloat(currentLevel.scoreRate as string) * 100).toFixed(2) }%`}
</div>
</div>
<div className="space-y-1">
<div className="text-xs text-muted-foreground flex items-center gap-1">
积分基准值
<Popover>
<PopoverTrigger>
<Info className="h-3 w-3 cursor-help" />
</PopoverTrigger>
<PopoverContent side="top" className="w-auto max-w-[280px] p-3">
<p className="text-xs">上一次从社区同步的积分基准值</p>
</PopoverContent>
</Popover>
</div>
<div className="text-sm font-medium tabular-nums">
{parseFloat(user.community_balance).toLocaleString()}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,212 @@
"use client"
import * as React from "react"
import Link from "next/link"
import { useRouter } from "next/navigation"
import { toast } from "sonner"
import { motion, AnimatePresence } from "motion/react"
import { ShieldCheck } from "lucide-react"
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb"
import { Button } from "@/components/ui/button"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Spinner } from "@/components/ui/spinner"
import { UserService } from "@/lib/services/user"
export function SecurityMain() {
const router = useRouter()
const [setupStep, setSetupStep] = React.useState<'password' | 'confirm'>('password')
const [payKey, setPayKey] = React.useState("")
const [confirmPayKey, setConfirmPayKey] = React.useState("")
const [isSubmitting, setIsSubmitting] = React.useState(false)
const isPayKeyValid = payKey.length === 6 && /^\d{6}$/.test(payKey)
const isConfirmValid = confirmPayKey.length === 6 && /^\d{6}$/.test(confirmPayKey)
const passwordsMatch = payKey === confirmPayKey
/* 安全密码输入 */
const handlePayKeyChange = (value: string) => {
const numericValue = value.replaceAll(/\D/g, '')
setPayKey(numericValue)
}
/* 确认安全密码 */
const handleConfirmPayKeyChange = (value: string) => {
const numericValue = value.replaceAll(/\D/g, '')
setConfirmPayKey(numericValue)
}
const handlePayKeySubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (setupStep === 'password') {
if (!isPayKeyValid) {
toast.error("安全密码必须为6位数字")
return
}
setSetupStep('confirm')
} else {
if (!isConfirmValid) {
toast.error("确认密码必须为6位数字")
return
}
if (!passwordsMatch) {
toast.error("两次输入的安全密码不一致")
setSetupStep('password')
setConfirmPayKey("")
return
}
setIsSubmitting(true)
try {
await UserService.updatePayKey(payKey)
toast.success("修改成功", {
description: "您的安全密码已成功更新",
})
// 立即退出
router.push('/settings')
} catch (error) {
toast.error("修改失败", {
description: error instanceof Error ? error.message : "更新密码时发生错误,请稍后重试",
})
setSetupStep('password')
setConfirmPayKey("")
} finally {
setIsSubmitting(false)
}
}
}
return (
<div className="py-6 space-y-6">
<div className="font-semibold">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/settings" className="text-base text-primary">设置</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="text-base font-semibold">安全设置</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</div>
<div className="space-y-6">
<div className="font-medium text-sm text-muted-foreground flex items-center gap-2">
<ShieldCheck className="w-4 h-4" />
密码安全
</div>
<div className="mt-8 flex justify-center">
<motion.div
className="w-full max-w-sm space-y-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<AnimatePresence mode="wait">
<motion.div
key={setupStep}
initial={{ opacity: 0, x: setupStep === 'confirm' ? 20 : -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="space-y-8"
>
<div className="flex flex-col items-center gap-2 mb-4">
<h3 className="text-xl font-bold tracking-tight text-center">
{setupStep === 'password' ? '设置新密码' : '确认新密码'}
</h3>
<p className="text-sm text-muted-foreground text-center max-w-[320px] mx-auto">
{setupStep === 'password'
? '请输入新的6位数字安全密码'
: '请再次输入密码进行确认'}
</p>
</div>
<form onSubmit={handlePayKeySubmit} className="space-y-6">
<div className="flex justify-center">
{setupStep === 'password' ? (
<InputOTP
maxLength={6}
value={payKey}
onChange={handlePayKeyChange}
disabled={isSubmitting}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-10 h-10 sm:w-11 sm:h-11 border-input transition-all ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" />
))}
</InputOTPGroup>
</InputOTP>
) : (
<div className="space-y-4">
<InputOTP
maxLength={6}
value={confirmPayKey}
onChange={handleConfirmPayKeyChange}
disabled={isSubmitting}
autoFocus
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((i) => (
<InputOTPSlot key={i} index={i} className="w-10 h-10 sm:w-11 sm:h-11 text-lg border-input transition-all ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2" />
))}
</InputOTPGroup>
</InputOTP>
{!passwordsMatch && confirmPayKey.length === 6 && (
<motion.p
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="text-xs text-red-500 text-center font-medium"
>
两次输入的密码不一致
</motion.p>
)}
</div>
)}
</div>
<div className="flex gap-4 sm:mx-8">
{setupStep === 'confirm' && (
<Button
type="button"
variant="secondary"
onClick={() => {
setSetupStep('password')
setConfirmPayKey('')
}}
className="flex-1 h-10 rounded-full tracking-wide text-sm font-bold transition-all active:scale-95"
>
返回
</Button>
)}
<Button
type="submit"
className={`flex-1 h-10 rounded-full tracking-wide bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-bold shadow-lg shadow-primary/20 hover:shadow-primary/30 transition-all active:scale-95 ${setupStep === 'password' ? 'w-full' : ''}`}
disabled={
setupStep === 'password'
? !isPayKeyValid
: isSubmitting || !isConfirmValid
}
>
{isSubmitting && <Spinner className="mr-2" />}
{setupStep === 'password' ? '继续' : '保存修改'}
</Button>
</div>
</form>
</motion.div>
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
)
}