完善个人中心页面

This commit is contained in:
Eamon
2026-09-04 13:21:47 +08:00
parent 6d6d300203
commit f59e929843
27 changed files with 614 additions and 414 deletions

View File

@@ -1,16 +1,9 @@
import { useEffect, useState } from "react"
import { Navigate, useLocation } from "react-router-dom"
import { AUTH_EVENT, isAuthed } from "@/api/token"
import { useProfileStore } from "@/components/stores/profile"
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const location = useLocation()
const [isValid, setIsValid] = useState(isAuthed)
useEffect(() => {
const update = () => setIsValid(isAuthed())
window.addEventListener(AUTH_EVENT, update)
return () => window.removeEventListener(AUTH_EVENT, update)
}, [])
const isValid = useProfileStore(state => state.token !== null)
if (!isValid) {
return (

View File

@@ -0,0 +1,170 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Eye, EyeOff } from "lucide-react"
import { useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { updateUserPwd } from "@/api/user"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
type ChangePwdValues = {
oldPwd: string
newPwd: string
confirmPwd: string
}
const changePwdSchema = z
.object({
oldPwd: z.string().min(1, "请输入原密码"),
newPwd: z.string().min(1, "请输入新密码"),
confirmPwd: z.string().min(1, "请再次输入新密码"),
})
.refine(v => v.newPwd === v.confirmPwd, {
message: "两次输入的密码不一致",
path: ["confirmPwd"],
})
function PasswordInput(props: {
value: string
onChange: (value: string) => void
placeholder?: string
}) {
const [show, setShow] = useState(false)
return (
<div className="relative">
<Input
value={props.value}
onChange={e => props.onChange(e.target.value)}
type={show ? "text" : "password"}
placeholder={props.placeholder}
className="h-11 bg-gray-50 border-gray-200 pr-10"
/>
<button
type="button"
onClick={() => setShow(!show)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{show ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
</button>
</div>
)
}
export function ChangePwdDialog({
open,
onOpenChange,
}: {
open: boolean
onOpenChange: (open: boolean) => void
}) {
const [submitting, setSubmitting] = useState(false)
const form = useForm<ChangePwdValues>({
resolver: zodResolver(changePwdSchema),
defaultValues: { oldPwd: "", newPwd: "", confirmPwd: "" },
})
const handleOpenChange = (next: boolean) => {
onOpenChange(next)
if (!next) form.reset()
}
const onSubmit = async (data: ChangePwdValues) => {
setSubmitting(true)
const res = await updateUserPwd({
OldPwd: data.oldPwd,
NewPwd: data.newPwd,
ConfirmPwd: data.confirmPwd,
})
setSubmitting(false)
if (!res.success) {
toast.error(res.message)
return
}
toast.success("密码修改成功")
handleOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-120 p-6">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-center">
修改密码
</DialogTitle>
</DialogHeader>
<form className="space-y-4 mt-2" onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">原密码</Label>
<Controller
name="oldPwd"
control={form.control}
render={({ field, fieldState }) => (
<>
<PasswordInput
value={field.value}
onChange={field.onChange}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">新密码</Label>
<Controller
name="newPwd"
control={form.control}
render={({ field, fieldState }) => (
<>
<PasswordInput
value={field.value}
onChange={field.onChange}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">确认新密码</Label>
<Controller
name="confirmPwd"
control={form.control}
render={({ field, fieldState }) => (
<>
<PasswordInput
value={field.value}
onChange={field.onChange}
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div>
<Button
type="submit"
disabled={submitting}
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
>
{submitting ? "提交中..." : "提交"}
</Button>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -1,8 +1,9 @@
import { zodResolver } from "@hookform/resolvers/zod"
import { Eye, EyeOff } from "lucide-react"
import { useEffect, useState } from "react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { updateUserInfo } from "@/api/user"
import { Button } from "@/components/ui/button"
import {
Dialog,
@@ -15,51 +16,45 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
export type EditInfoValues = {
username: string
taobao: string
password: string
email: string
wechat: string
qq: string
wechat: string
taobao: string
email: string
}
const editInfoSchema = z.object({
username: z.string().trim().min(1, "请输入用户名"),
qq: z.string(),
wechat: z.string(),
taobao: z.string(),
password: z.string().min(1, "请输入密码"),
email: z
.string()
.refine(
v => !v || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v),
"请输入正确的邮箱",
),
wechat: z.string(),
qq: z.string(),
})
interface EditInfoDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
initialData?: EditInfoValues
onSave?: (data: EditInfoValues) => void
onSaved?: () => void
}
const DEFAULT_INITIAL_DATA: EditInfoValues = {
username: "191****1234",
taobao: "191****1234",
password: "********",
email: "123456123@qq.com",
wechat: "",
qq: "",
wechat: "",
taobao: "",
email: "",
}
export function EditInfoDialog({
open,
onOpenChange,
initialData = DEFAULT_INITIAL_DATA,
onSave,
onSaved,
}: EditInfoDialogProps) {
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false)
const form = useForm<EditInfoValues>({
resolver: zodResolver(editInfoSchema),
@@ -72,9 +67,23 @@ export function EditInfoDialog({
}
}, [open, initialData, form])
const onSubmit = (data: EditInfoValues) => {
onSave?.(data)
const onSubmit = async (data: EditInfoValues) => {
setSubmitting(true)
const res = await updateUserInfo({
QQ: data.qq,
Wx: data.wechat,
TaoBao: data.taobao,
Email: data.email,
})
setSubmitting(false)
if (!res.success) {
toast.error(res.message)
return
}
toast.success("保存成功")
onOpenChange(false)
onSaved?.()
}
return (
@@ -88,18 +97,23 @@ export function EditInfoDialog({
<form className="space-y-4 mt-2" onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">用户名</Label>
<Label className="text-sm text-gray-600">QQ号</Label>
<Controller
name="username"
name="qq"
control={form.control}
render={({ field, fieldState }) => (
<>
<Input
{...field}
className="h-11 bg-gray-50 border-gray-200"
/>
<FieldError>{fieldState.error?.message}</FieldError>
</>
render={({ field }) => (
<Input {...field} className="h-11 bg-gray-50 border-gray-200" />
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">微信号</Label>
<Controller
name="wechat"
control={form.control}
render={({ field }) => (
<Input {...field} className="h-11 bg-gray-50 border-gray-200" />
)}
/>
</div>
@@ -115,37 +129,6 @@ export function EditInfoDialog({
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">密码</Label>
<Controller
name="password"
control={form.control}
render={({ field, fieldState }) => (
<>
<div className="relative">
<Input
{...field}
type={showPassword ? "text" : "password"}
className="h-11 bg-gray-50 border-gray-200 pr-10"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
<FieldError>{fieldState.error?.message}</FieldError>
</>
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">邮箱</Label>
<Controller
@@ -163,41 +146,12 @@ export function EditInfoDialog({
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">微信</Label>
<Controller
name="wechat"
control={form.control}
render={({ field }) => (
<Input
{...field}
placeholder="请输入"
className="h-11 bg-gray-50 border-gray-200"
/>
)}
/>
</div>
<div className="space-y-1.5">
<Label className="text-sm text-gray-600">QQ</Label>
<Controller
name="qq"
control={form.control}
render={({ field }) => (
<Input
{...field}
placeholder="请输入"
className="h-11 bg-gray-50 border-gray-200"
/>
)}
/>
</div>
<Button
type="submit"
disabled={submitting}
className="w-full h-11 mt-2 bg-blue-600 hover:bg-blue-700 text-white font-medium"
>
保存
{submitting ? "保存中..." : "保存"}
</Button>
</form>
</DialogContent>

View File

@@ -1,22 +1,11 @@
import { useEffect, useState } from "react"
import { NavLink, useNavigate } from "react-router-dom"
import { logout } from "@/api/auth"
import { AUTH_EVENT, isAuthed } from "@/api/token"
import { useProfileStore } from "@/components/stores/profile"
export default function ProfileOrLogin() {
const [authed, setAuthed] = useState(isAuthed)
const authed = useProfileStore(state => state.token !== null)
const navigate = useNavigate()
useEffect(() => {
const update = () => setAuthed(isAuthed())
window.addEventListener("focus", update)
window.addEventListener(AUTH_EVENT, update)
return () => {
window.removeEventListener("focus", update)
window.removeEventListener(AUTH_EVENT, update)
}
}, [])
const handleLogout = async () => {
await logout()
}

View File

@@ -0,0 +1,11 @@
import { create } from "zustand"
import { API_BASE_URL } from "@/lib/api"
// ==================== 应用配置 ====================
// 后端地址等运行时配置统一从 store 读取,初始值来自 import.meta.env(见 lib/api.ts)
type AppState = {
apiUrl: string
}
export const useAppStore = create<AppState>()(() => ({ apiUrl: API_BASE_URL }))

View File

@@ -0,0 +1,54 @@
import { create } from "zustand"
type ClientState = {
breakpoint: {
sm: boolean
md: boolean
lg: boolean
xl: boolean
}
}
type ClientActions = {
setBreakpoints: (breakpoints: Partial<ClientState["breakpoint"]>) => void
}
export type ClientStore = ClientState & ClientActions
export const useClientStore = create<ClientStore>()(set => ({
breakpoint: { sm: false, md: false, lg: false, xl: false },
setBreakpoints: breakpoints =>
set(state => ({
breakpoint: { ...state.breakpoint, ...breakpoints },
})),
}))
let initialized = false
// 应用入口调用一次,开始监听窗口断点变化
export function initClientStore() {
if (initialized) return
initialized = true
const queries = {
sm: window.matchMedia("(width >= 40rem)"),
md: window.matchMedia("(width >= 48rem)"),
lg: window.matchMedia("(width >= 64rem)"),
xl: window.matchMedia("(width >= 80rem)"),
}
const setBreakpoints = useClientStore.getState().setBreakpoints
setBreakpoints({
sm: queries.sm.matches,
md: queries.md.matches,
lg: queries.lg.matches,
xl: queries.xl.matches,
})
for (const key of Object.keys(queries) as (keyof typeof queries)[]) {
queries[key].addEventListener("change", e => {
setBreakpoints({ [key]: e.matches })
})
}
}

View File

@@ -0,0 +1,41 @@
import { create } from "zustand"
import { fetchAgreementStatus } from "@/api/agreement"
import { fetchApiIndex } from "@/api/dashboard"
import type { ApiIndexItem } from "@/lib/models/dashboard"
// ==================== 个人中心账户总览 ====================
// 数据源:.NET /user/ApiIndex;协议签署状态:php-api /tencent/index/get_is_upload
export type DashboardState = {
data: ApiIndexItem | null
agreementSigned: boolean | null
loading: boolean
error: string | null
}
type DashboardActions = {
refresh: () => Promise<void>
refreshAgreement: () => Promise<void>
}
export type DashboardStore = DashboardState & DashboardActions
export const useDashboardStore = create<DashboardStore>()(set => ({
data: null,
agreementSigned: null,
loading: false,
error: null,
refresh: async () => {
set({ loading: true })
const res = await fetchApiIndex()
if (res.success) {
set({ data: res.data, error: null, loading: false })
} else {
set({ error: res.message, loading: false })
}
},
refreshAgreement: async () => {
const res = await fetchAgreementStatus()
if (res.success) set({ agreementSigned: res.data })
},
}))

View File

@@ -0,0 +1,20 @@
import { create } from "zustand"
type LayoutState = {
navbar: boolean
}
type LayoutActions = {
setNavbar: (navbar: boolean) => void
toggleNavbar: () => void
}
export type LayoutStore = LayoutState & LayoutActions
export const useLayoutStore = create<LayoutStore>()(set => ({
navbar: false,
setNavbar: navbar => set({ navbar }),
toggleNavbar: () => set(state => ({ navbar: !state.navbar })),
}))

View File

@@ -0,0 +1,34 @@
import { create } from "zustand"
import {
AUTH_EVENT,
getUserInfo,
getUserToken,
type UserInfoRecord,
} from "@/api/token"
export type ProfileState = {
token: string | null
userInfo: UserInfoRecord | null
}
type ProfileActions = {
refreshFromStorage: () => void
}
export type ProfileStore = ProfileState & ProfileActions
export const useProfileStore = create<ProfileStore>()(set => ({
token: getUserToken(),
userInfo: getUserInfo<UserInfoRecord>(),
refreshFromStorage: () => {
set({ token: getUserToken(), userInfo: getUserInfo<UserInfoRecord>() })
},
}))
// 应用入口调用一次,监听登录态变化(含跨标签页 focus 同步)
export function initProfileStore() {
const sync = () => useProfileStore.getState().refreshFromStorage()
window.addEventListener(AUTH_EVENT, sync)
window.addEventListener("focus", sync)
}