mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-08 08:36:37 +08:00
feat(admin): support user profile editing, password resetting, and email column with search
- Add UpdateUser API and logics supporting nickname, email, admin flag modification, and password reset. - Relocate user delete button and confirmation Alert into the EditUserModal. - Optimize admin Switch change to trigger instant API request with rollback support. - Fix missing email field in edit form initialization by fetching full profile metadata. - Render email column in users list and support email-based filtering in UserFilterBar. - Remove hardcoded styles and sizes from Switch components to follow global theme.
This commit is contained in:
@@ -327,7 +327,7 @@ export function EventsTab() {
|
|||||||
<Switch
|
<Switch
|
||||||
checked={event.enabled}
|
checked={event.enabled}
|
||||||
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
|
onCheckedChange={() => toggleEventMutation.mutate(event.id)}
|
||||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
className="scale-75"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -324,7 +324,7 @@ export function SettingsTab() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
className="scale-75"
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
<TableCell className="sticky right-0 text-center bg-background z-10 py-1" onClick={(e) => e.stopPropagation()}>
|
||||||
|
|||||||
@@ -0,0 +1,280 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import {useEffect, useState} from "react"
|
||||||
|
import {Button} from "@/components/ui/button"
|
||||||
|
import {Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle} from "@/components/ui/dialog"
|
||||||
|
import {Input} from "@/components/ui/input"
|
||||||
|
import {Label} from "@/components/ui/label"
|
||||||
|
import {Switch} from "@/components/ui/switch"
|
||||||
|
import {useAdminUsers} from "@/contexts/admin-users-context"
|
||||||
|
import {useAuth} from "@/components/providers/auth-provider"
|
||||||
|
import type {AdminUser} from "@/lib/services/admin"
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle
|
||||||
|
} from "@/components/ui/alert-dialog"
|
||||||
|
import {Loader2} from "lucide-react"
|
||||||
|
|
||||||
|
interface EditUserForm {
|
||||||
|
nickname: string
|
||||||
|
email: string
|
||||||
|
is_admin: boolean
|
||||||
|
password?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditUserModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
user: AdminUser | null
|
||||||
|
}) {
|
||||||
|
const { updateUser, deleteUser, getUserDetail } = useAdminUsers()
|
||||||
|
const { user: currentUser } = useAuth()
|
||||||
|
|
||||||
|
const isSelf = currentUser && user && currentUser.id.toString() === user.id.toString()
|
||||||
|
|
||||||
|
const [form, setForm] = useState<EditUserForm>({
|
||||||
|
nickname: "",
|
||||||
|
email: "",
|
||||||
|
is_admin: false,
|
||||||
|
password: "",
|
||||||
|
})
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true
|
||||||
|
if (isOpen && user) {
|
||||||
|
setForm({
|
||||||
|
nickname: user.nickname || "",
|
||||||
|
email: user.email || "",
|
||||||
|
is_admin: user.is_admin || false,
|
||||||
|
password: "",
|
||||||
|
})
|
||||||
|
setErrors({})
|
||||||
|
|
||||||
|
getUserDetail(user.id)
|
||||||
|
.then((detail) => {
|
||||||
|
if (active && detail) {
|
||||||
|
setForm({
|
||||||
|
nickname: detail.nickname || "",
|
||||||
|
email: detail.email || "",
|
||||||
|
is_admin: detail.is_admin || false,
|
||||||
|
password: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
// ignore fetching error
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
}
|
||||||
|
}, [isOpen, user, getUserDetail])
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const newErrors: Record<string, string> = {}
|
||||||
|
|
||||||
|
if (!form.email.trim()) {
|
||||||
|
newErrors.email = "邮箱不能为空"
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email.trim())) {
|
||||||
|
newErrors.email = "邮箱格式不正确"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (form.password && form.password.length < 8) {
|
||||||
|
newErrors.password = "密码长度不能少于 8 位"
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(newErrors)
|
||||||
|
return Object.keys(newErrors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!user || !validate()) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await updateUser(user.id, {
|
||||||
|
nickname: form.nickname.trim() || undefined,
|
||||||
|
email: form.email.trim(),
|
||||||
|
is_admin: form.is_admin,
|
||||||
|
password: form.password?.trim() || undefined,
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
// Errors are handled by context toast notifications
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!user) return
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await deleteUser(user)
|
||||||
|
onClose()
|
||||||
|
} catch {
|
||||||
|
// Errors are handled by context toast notifications
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑用户</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
修改用户档案、授予/撤销管理权限或重置其密码。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSave} className="space-y-4 pt-2">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="username" className="text-xs text-muted-foreground">用户名 (不可修改)</Label>
|
||||||
|
<Input
|
||||||
|
id="username"
|
||||||
|
value={user.username}
|
||||||
|
disabled
|
||||||
|
className="bg-muted/50 cursor-not-allowed select-none font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="nickname">昵称 (选填)</Label>
|
||||||
|
<Input
|
||||||
|
id="nickname"
|
||||||
|
value={form.nickname}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, nickname: e.target.value }))}
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">邮箱</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, email: e.target.value }))}
|
||||||
|
placeholder="请输入邮箱地址"
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="text-xs text-destructive">{errors.email}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">重置密码 (选填)</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, password: e.target.value }))}
|
||||||
|
placeholder="留空表示保持当前密码,输入则重置 (至少 8 位)"
|
||||||
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="text-xs text-destructive">{errors.password}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between rounded-lg border border-dashed p-3 bg-muted/10">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-sm">管理员权限</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{isSelf ? "不能撤销当前登录用户的管理员权限。" : "开启后此账号将拥有后台管理权限。"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={form.is_admin}
|
||||||
|
disabled={!!isSelf || saving}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
setForm((prev) => ({ ...prev, is_admin: checked }))
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await updateUser(user.id, {
|
||||||
|
nickname: form.nickname.trim() || undefined,
|
||||||
|
email: form.email.trim(),
|
||||||
|
is_admin: checked,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
setForm((prev) => ({ ...prev, is_admin: !checked }))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-2 border-t mt-2">
|
||||||
|
<div>
|
||||||
|
{!user.is_admin && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
删除用户
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" type="button" onClick={onClose} disabled={saving}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={saving} variant="secondary">
|
||||||
|
{saving ? "保存中..." : "保存"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
确定要删除用户 {user.nickname || user.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={saving}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
handleDelete()
|
||||||
|
}}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{saving && <Loader2 className="size-3 animate-spin mr-1" />}
|
||||||
|
确认删除
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, Trash2, UserCheck,} from "lucide-react"
|
import {Globe, Loader2, Mail, MapPin, ShieldCheck, Smartphone, UserCheck,} from "lucide-react"
|
||||||
|
|
||||||
import {Button} from "@/components/ui/button"
|
import {Button} from "@/components/ui/button"
|
||||||
import {Badge} from "@/components/ui/badge"
|
import {Badge} from "@/components/ui/badge"
|
||||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||||
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
|
import {Sheet, SheetContent, SheetTitle} from "@/components/ui/sheet"
|
||||||
import type {AdminUser} from "@/lib/services/admin"
|
import type {AdminUser} from "@/lib/services/admin"
|
||||||
import {cn, formatDateTime} from "@/lib/utils"
|
import {formatDateTime} from "@/lib/utils"
|
||||||
|
|
||||||
interface UserDetailSheetProps {
|
interface UserDetailSheetProps {
|
||||||
selectedUser: AdminUser | null
|
selectedUser: AdminUser | null
|
||||||
@@ -16,7 +16,6 @@ interface UserDetailSheetProps {
|
|||||||
onOpenChange: (open: boolean) => void
|
onOpenChange: (open: boolean) => void
|
||||||
detailLoading: boolean
|
detailLoading: boolean
|
||||||
onStatusToggle: (user: AdminUser) => Promise<void>
|
onStatusToggle: (user: AdminUser) => Promise<void>
|
||||||
onDeleteTarget: (user: AdminUser) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserDetailSheet({
|
export function UserDetailSheet({
|
||||||
@@ -25,7 +24,6 @@ export function UserDetailSheet({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
detailLoading,
|
detailLoading,
|
||||||
onStatusToggle,
|
onStatusToggle,
|
||||||
onDeleteTarget,
|
|
||||||
}: UserDetailSheetProps) {
|
}: UserDetailSheetProps) {
|
||||||
|
|
||||||
const displayValue = (value?: string) => value && value.trim() ? value : "-"
|
const displayValue = (value?: string) => value && value.trim() ? value : "-"
|
||||||
@@ -56,7 +54,7 @@ export function UserDetailSheet({
|
|||||||
UID: {selectedUser.id}
|
UID: {selectedUser.id}
|
||||||
</Badge>
|
</Badge>
|
||||||
{selectedUser.is_admin && (
|
{selectedUser.is_admin && (
|
||||||
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium bg-primary text-primary-foreground">
|
<Badge className="h-4.5 px-1.5 text-[9px] uppercase font-medium">
|
||||||
Admin
|
Admin
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -156,15 +154,10 @@ export function UserDetailSheet({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!selectedUser.is_admin && (
|
{!selectedUser.is_admin && (
|
||||||
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0 flex flex-col gap-2">
|
<div className="p-4 border-t bg-background/80 backdrop-blur-md shrink-0">
|
||||||
<Button
|
<Button
|
||||||
variant={selectedUser.is_active ? "destructive" : "default"}
|
variant={selectedUser.is_active ? "destructive" : "default"}
|
||||||
className={cn(
|
className="w-full h-9 text-xs font-medium transition-all active:scale-[0.98]"
|
||||||
"w-full h-9 text-xs font-medium transition-all active:scale-[0.98]",
|
|
||||||
selectedUser.is_active
|
|
||||||
? "bg-red-500 hover:bg-red-600 text-white"
|
|
||||||
: "bg-primary text-primary-foreground hover:bg-primary/90"
|
|
||||||
)}
|
|
||||||
onClick={() => onStatusToggle(selectedUser)}
|
onClick={() => onStatusToggle(selectedUser)}
|
||||||
>
|
>
|
||||||
{selectedUser.is_active ? (
|
{selectedUser.is_active ? (
|
||||||
@@ -179,14 +172,6 @@ export function UserDetailSheet({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="w-full h-9 text-xs font-medium"
|
|
||||||
onClick={() => onDeleteTarget(selectedUser)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-3 mr-1" />
|
|
||||||
删除用户
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,17 +18,19 @@ export function UserFilterBar() {
|
|||||||
pageSize,
|
pageSize,
|
||||||
searchUserId,
|
searchUserId,
|
||||||
searchUsername,
|
searchUsername,
|
||||||
|
searchEmail,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
setPage,
|
setPage,
|
||||||
setPageSize,
|
setPageSize,
|
||||||
setSearchUserId,
|
setSearchUserId,
|
||||||
setSearchUsername,
|
setSearchUsername,
|
||||||
|
setSearchEmail,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
fetchUsers
|
fetchUsers
|
||||||
} = useAdminUsers()
|
} = useAdminUsers()
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / pageSize)
|
const totalPages = Math.ceil(total / pageSize)
|
||||||
const hasSearchFilter = Boolean(searchUserId || searchUsername)
|
const hasSearchFilter = Boolean(searchUserId || searchUsername || searchEmail)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-3">
|
||||||
@@ -49,8 +51,8 @@ export function UserFilterBar() {
|
|||||||
<>
|
<>
|
||||||
<Separator orientation="vertical" className="mx-1" />
|
<Separator orientation="vertical" className="mx-1" />
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="default"
|
||||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
className="text-[10px] h-3 px-1 rounded-full"
|
||||||
>
|
>
|
||||||
!
|
!
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -72,6 +74,12 @@ export function UserFilterBar() {
|
|||||||
value={searchUsername}
|
value={searchUsername}
|
||||||
onChange={(e) => setSearchUsername(e.target.value)}
|
onChange={(e) => setSearchUsername(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<input
|
||||||
|
className="w-full h-7 px-2 text-xs border border-dashed rounded-md outline-none focus:border-primary bg-background"
|
||||||
|
placeholder="输入邮箱..."
|
||||||
|
value={searchEmail}
|
||||||
|
onChange={(e) => setSearchEmail(e.target.value)}
|
||||||
|
/>
|
||||||
{hasSearchFilter && (
|
{hasSearchFilter && (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -80,6 +88,7 @@ export function UserFilterBar() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchUserId("")
|
setSearchUserId("")
|
||||||
setSearchUsername("")
|
setSearchUsername("")
|
||||||
|
setSearchEmail("")
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
清除
|
清除
|
||||||
@@ -105,8 +114,8 @@ export function UserFilterBar() {
|
|||||||
<>
|
<>
|
||||||
<Separator orientation="vertical" className="mx-1" />
|
<Separator orientation="vertical" className="mx-1" />
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
variant="default"
|
||||||
className="text-[10px] h-3 px-1 rounded-full bg-primary text-primary-foreground"
|
className="text-[10px] h-3 px-1 rounded-full"
|
||||||
>
|
>
|
||||||
1
|
1
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -160,6 +169,7 @@ export function UserFilterBar() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchUserId("")
|
setSearchUserId("")
|
||||||
setSearchUsername("")
|
setSearchUsername("")
|
||||||
|
setSearchEmail("")
|
||||||
setStatusFilter('all')
|
setStatusFilter('all')
|
||||||
}}
|
}}
|
||||||
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
className="h-5 px-2 lg:px-3 text-[11px] font-medium text-muted-foreground hover:text-foreground"
|
||||||
|
|||||||
@@ -6,18 +6,8 @@ import {Switch} from "@/components/ui/switch"
|
|||||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from "@/components/ui/table"
|
||||||
import {Badge} from "@/components/ui/badge"
|
import {Badge} from "@/components/ui/badge"
|
||||||
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
import {Avatar, AvatarFallback, AvatarImage} from "@/components/ui/avatar"
|
||||||
import {Eye, Layers, Loader2, Plus, Trash2, UserRound, UserX,} from "lucide-react"
|
import {Eye, Layers, Pencil, Plus, UserRound, UserX,} from "lucide-react"
|
||||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip"
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogAction,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle
|
|
||||||
} from "@/components/ui/alert-dialog"
|
|
||||||
|
|
||||||
import type {AdminUser} from "@/lib/services/admin"
|
import type {AdminUser} from "@/lib/services/admin"
|
||||||
import {formatDateTime} from "@/lib/utils"
|
import {formatDateTime} from "@/lib/utils"
|
||||||
@@ -28,6 +18,7 @@ import {useAdminUsers} from "@/contexts/admin-users-context"
|
|||||||
import {CreateUserModal} from "./components/create-user-modal"
|
import {CreateUserModal} from "./components/create-user-modal"
|
||||||
import {UserFilterBar} from "./components/user-filter-bar"
|
import {UserFilterBar} from "./components/user-filter-bar"
|
||||||
import {UserDetailSheet} from "./components/user-detail-sheet"
|
import {UserDetailSheet} from "./components/user-detail-sheet"
|
||||||
|
import {EditUserModal} from "./components/edit-user-modal"
|
||||||
|
|
||||||
export default function UsersPage() {
|
export default function UsersPage() {
|
||||||
const {
|
const {
|
||||||
@@ -36,16 +27,14 @@ export default function UsersPage() {
|
|||||||
error,
|
error,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
getUserDetail,
|
getUserDetail,
|
||||||
updateUserStatus,
|
updateUserStatus
|
||||||
deleteUser
|
|
||||||
} = useAdminUsers()
|
} = useAdminUsers()
|
||||||
|
|
||||||
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
const [selectedUser, setSelectedUser] = useState<AdminUser | null>(null)
|
||||||
const [detailOpen, setDetailOpen] = useState(false)
|
const [detailOpen, setDetailOpen] = useState(false)
|
||||||
const [detailLoading, setDetailLoading] = useState(false)
|
const [detailLoading, setDetailLoading] = useState(false)
|
||||||
const [deleteTarget, setDeleteTarget] = useState<AdminUser | null>(null)
|
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false)
|
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false)
|
const [createModalOpen, setCreateModalOpen] = useState(false)
|
||||||
|
const [editTarget, setEditTarget] = useState<AdminUser | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers()
|
fetchUsers()
|
||||||
@@ -74,21 +63,7 @@ export default function UsersPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDeleteUser = async () => {
|
|
||||||
if (!deleteTarget) return
|
|
||||||
|
|
||||||
setDeleteLoading(true)
|
|
||||||
try {
|
|
||||||
await deleteUser(deleteTarget)
|
|
||||||
if (selectedUser?.id === deleteTarget.id) {
|
|
||||||
setDetailOpen(false)
|
|
||||||
setSelectedUser(null)
|
|
||||||
}
|
|
||||||
setDeleteTarget(null)
|
|
||||||
} finally {
|
|
||||||
setDeleteLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 space-y-4">
|
<div className="py-6 space-y-4">
|
||||||
@@ -125,7 +100,8 @@ export default function UsersPage() {
|
|||||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||||
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
<TableHead className="w-[90px] whitespace-nowrap py-2 h-8">ID</TableHead>
|
||||||
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
|
<TableHead className="w-[120px] whitespace-nowrap py-2 h-8">用户</TableHead>
|
||||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8 pl-4">上次登陆</TableHead>
|
<TableHead className="whitespace-nowrap min-w-[160px] py-2 h-8 pl-4">邮箱</TableHead>
|
||||||
|
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次登陆</TableHead>
|
||||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
|
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">注册时间</TableHead>
|
||||||
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
|
<TableHead className="whitespace-nowrap min-w-[140px] py-2 h-8">上次更新</TableHead>
|
||||||
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
<TableHead className="sticky right-0 text-center bg-background z-10 w-[110px] py-2 h-8">操作</TableHead>
|
||||||
@@ -163,6 +139,10 @@ export default function UsersPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|
||||||
|
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||||
|
{user.email || "-"}
|
||||||
|
</TableCell>
|
||||||
|
|
||||||
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
<TableCell className="text-[10px] text-muted-foreground font-mono whitespace-nowrap py-1 pl-4">
|
||||||
{formatDateTime(user.last_login_at)}
|
{formatDateTime(user.last_login_at)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -181,7 +161,7 @@ export default function UsersPage() {
|
|||||||
checked={user.is_active}
|
checked={user.is_active}
|
||||||
onCheckedChange={() => handleStatusToggle(user)}
|
onCheckedChange={() => handleStatusToggle(user)}
|
||||||
disabled={user.is_admin}
|
disabled={user.is_admin}
|
||||||
className="scale-75 data-[state=checked]:bg-green-600 h-4 w-7"
|
className="scale-75"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
@@ -201,23 +181,18 @@ export default function UsersPage() {
|
|||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{!user.is_admin && (
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger asChild>
|
||||||
<TooltipTrigger asChild>
|
<Button variant="ghost" size="icon" className="h-6 w-6 text-muted-foreground hover:text-foreground" onClick={() => setEditTarget(user)}>
|
||||||
<Button
|
<Pencil className="size-3" />
|
||||||
variant="ghost"
|
</Button>
|
||||||
size="icon"
|
</TooltipTrigger>
|
||||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
<TooltipContent side="top" className="text-xs">
|
||||||
onClick={() => setDeleteTarget(user)}
|
编辑用户
|
||||||
>
|
</TooltipContent>
|
||||||
<Trash2 className="size-3" />
|
</Tooltip>
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="text-xs">
|
|
||||||
删除用户
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -235,27 +210,16 @@ export default function UsersPage() {
|
|||||||
onOpenChange={setDetailOpen}
|
onOpenChange={setDetailOpen}
|
||||||
detailLoading={detailLoading}
|
detailLoading={detailLoading}
|
||||||
onStatusToggle={handleStatusToggle}
|
onStatusToggle={handleStatusToggle}
|
||||||
onDeleteTarget={setDeleteTarget}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 删除确认警告弹窗 */}
|
{/* 编辑用户弹窗 */}
|
||||||
<AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && !deleteLoading && setDeleteTarget(null)}>
|
<EditUserModal
|
||||||
<AlertDialogContent>
|
user={editTarget}
|
||||||
<AlertDialogHeader>
|
isOpen={!!editTarget}
|
||||||
<AlertDialogTitle>确认删除用户</AlertDialogTitle>
|
onClose={() => setEditTarget(null)}
|
||||||
<AlertDialogDescription>
|
/>
|
||||||
确定要删除用户 {deleteTarget?.nickname || deleteTarget?.username} 吗?该操作会移除用户账号,删除后无法撤销。
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel disabled={deleteLoading}>取消</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onClick={handleDeleteUser} disabled={deleteLoading}>
|
|
||||||
{deleteLoading && <Loader2 className="size-3 animate-spin" />}
|
|
||||||
确认删除
|
|
||||||
</AlertDialogAction>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
{/* 新建用户模态弹窗 */}
|
{/* 新建用户模态弹窗 */}
|
||||||
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
<CreateUserModal isOpen={createModalOpen} onClose={() => setCreateModalOpen(false)} />
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
--color-background: var(--background);
|
||||||
--color-foreground: var(--foreground);
|
--color-foreground: var(--foreground);
|
||||||
--font-sans: var(--font-geist-sans), system-ui, -apple-system, BlinkMacSystemFont, var(--font-noto-sans-sc), sans-serif;
|
--font-sans: var(--font-inter), 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', var(--font-noto-sans-sc), monospace;
|
--font-mono: var(--font-geist-mono), 'SF Mono', 'Monaco', 'Inconsolata', 'Roboto Mono', 'PingFang SC', 'Microsoft YaHei', monospace;
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
@@ -196,4 +196,4 @@
|
|||||||
80% { transform: rotate(-1deg); }
|
80% { transform: rotate(-1deg); }
|
||||||
100% { transform: rotate(0deg); }
|
100% { transform: rotate(0deg); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {Metadata} from "next";
|
import type {Metadata} from "next";
|
||||||
|
import {Inter} from "next/font/google";
|
||||||
import {Toaster} from "@/components/ui/sonner";
|
import {Toaster} from "@/components/ui/sonner";
|
||||||
import {ThemeProvider} from "@/components/layout/theme-provider";
|
import {ThemeProvider} from "@/components/layout/theme-provider";
|
||||||
import {CustomThemeProvider} from "@/lib/theme";
|
import {CustomThemeProvider} from "@/lib/theme";
|
||||||
@@ -10,6 +11,12 @@ import {SiteTitleUpdater} from "@/components/providers/title-updater";
|
|||||||
import {RobotsMeta} from "@/components/layout/robots-meta";
|
import {RobotsMeta} from "@/components/layout/robots-meta";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
|
const inter = Inter({
|
||||||
|
subsets: ["latin"],
|
||||||
|
variable: "--font-inter",
|
||||||
|
display: "swap",
|
||||||
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "OpenFlare",
|
title: "OpenFlare",
|
||||||
description: "OpenFlare 边缘节点与反向代理管理平台",
|
description: "OpenFlare 边缘节点与反向代理管理平台",
|
||||||
@@ -23,7 +30,7 @@ export default function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="zh-CN"
|
lang="zh-CN"
|
||||||
className="hide-scrollbar font-sans"
|
className={`hide-scrollbar font-sans ${inter.variable}`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body
|
<body
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import * as React from "react"
|
import * as React from "react"
|
||||||
import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react"
|
import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react"
|
||||||
import {toast} from "sonner"
|
import {toast} from "sonner"
|
||||||
import type {AdminUser, CreateUserRequest, ListUsersRequest} from "@/lib/services/admin"
|
import type {AdminUser, CreateUserRequest, ListUsersRequest, UpdateUserRequest} from "@/lib/services/admin"
|
||||||
import services from "@/lib/services"
|
import services from "@/lib/services"
|
||||||
|
|
||||||
/** 用户列表查询参数 */
|
/** 用户列表查询参数 */
|
||||||
@@ -12,6 +12,7 @@ export interface UserQueryParams {
|
|||||||
page_size: number
|
page_size: number
|
||||||
user_id?: string
|
user_id?: string
|
||||||
username?: string
|
username?: string
|
||||||
|
email?: string
|
||||||
status?: 'all' | 'active' | 'inactive'
|
status?: 'all' | 'active' | 'inactive'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,6 +28,7 @@ interface AdminUsersContextState {
|
|||||||
pageSize: number
|
pageSize: number
|
||||||
searchUserId: string
|
searchUserId: string
|
||||||
searchUsername: string
|
searchUsername: string
|
||||||
|
searchEmail: string
|
||||||
statusFilter: 'all' | 'active' | 'inactive'
|
statusFilter: 'all' | 'active' | 'inactive'
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
@@ -34,6 +36,7 @@ interface AdminUsersContextState {
|
|||||||
setPageSize: (size: number) => void
|
setPageSize: (size: number) => void
|
||||||
setSearchUserId: (userId: string) => void
|
setSearchUserId: (userId: string) => void
|
||||||
setSearchUsername: (username: string) => void
|
setSearchUsername: (username: string) => void
|
||||||
|
setSearchEmail: (email: string) => void
|
||||||
setStatusFilter: (status: 'all' | 'active' | 'inactive') => void
|
setStatusFilter: (status: 'all' | 'active' | 'inactive') => void
|
||||||
|
|
||||||
fetchUsers: (force?: boolean) => Promise<void>
|
fetchUsers: (force?: boolean) => Promise<void>
|
||||||
@@ -41,6 +44,7 @@ interface AdminUsersContextState {
|
|||||||
getUserDetail: (id: string) => Promise<AdminUser>
|
getUserDetail: (id: string) => Promise<AdminUser>
|
||||||
updateUserStatus: (user: AdminUser) => Promise<void>
|
updateUserStatus: (user: AdminUser) => Promise<void>
|
||||||
createUser: (req: CreateUserRequest) => Promise<AdminUser>
|
createUser: (req: CreateUserRequest) => Promise<AdminUser>
|
||||||
|
updateUser: (id: string, req: UpdateUserRequest) => Promise<void>
|
||||||
deleteUser: (user: AdminUser) => Promise<void>
|
deleteUser: (user: AdminUser) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +64,11 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
|||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [searchUserId, setSearchUserId] = useState("")
|
const [searchUserId, setSearchUserId] = useState("")
|
||||||
const [searchUsername, setSearchUsername] = useState("")
|
const [searchUsername, setSearchUsername] = useState("")
|
||||||
|
const [searchEmail, setSearchEmail] = useState("")
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
|
||||||
const [debouncedSearchUserId, setDebouncedSearchUserId] = useState("")
|
const [debouncedSearchUserId, setDebouncedSearchUserId] = useState("")
|
||||||
const [debouncedSearchUsername, setDebouncedSearchUsername] = useState("")
|
const [debouncedSearchUsername, setDebouncedSearchUsername] = useState("")
|
||||||
|
const [debouncedSearchEmail, setDebouncedSearchEmail] = useState("")
|
||||||
|
|
||||||
// Cache
|
// Cache
|
||||||
const cacheRef = useRef<Record<string, { data: AdminUser[], total: number, timestamp: number }>>({})
|
const cacheRef = useRef<Record<string, { data: AdminUser[], total: number, timestamp: number }>>({})
|
||||||
@@ -73,12 +79,17 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
|||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
setDebouncedSearchUserId(searchUserId)
|
setDebouncedSearchUserId(searchUserId)
|
||||||
setDebouncedSearchUsername(searchUsername)
|
setDebouncedSearchUsername(searchUsername)
|
||||||
if (searchUserId !== debouncedSearchUserId || searchUsername !== debouncedSearchUsername) {
|
setDebouncedSearchEmail(searchEmail)
|
||||||
|
if (
|
||||||
|
searchUserId !== debouncedSearchUserId ||
|
||||||
|
searchUsername !== debouncedSearchUsername ||
|
||||||
|
searchEmail !== debouncedSearchEmail
|
||||||
|
) {
|
||||||
setPage(1) // Reset to page 1 on search change
|
setPage(1) // Reset to page 1 on search change
|
||||||
}
|
}
|
||||||
}, 500)
|
}, 500)
|
||||||
return () => clearTimeout(timer)
|
return () => clearTimeout(timer)
|
||||||
}, [searchUserId, searchUsername, debouncedSearchUserId, debouncedSearchUsername])
|
}, [searchUserId, searchUsername, searchEmail, debouncedSearchUserId, debouncedSearchUsername, debouncedSearchEmail])
|
||||||
|
|
||||||
const generateCacheKey = (params: UserQueryParams) => {
|
const generateCacheKey = (params: UserQueryParams) => {
|
||||||
return JSON.stringify(params)
|
return JSON.stringify(params)
|
||||||
@@ -90,6 +101,7 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
|||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
user_id: debouncedSearchUserId || undefined,
|
user_id: debouncedSearchUserId || undefined,
|
||||||
username: debouncedSearchUsername || undefined,
|
username: debouncedSearchUsername || undefined,
|
||||||
|
email: debouncedSearchEmail || undefined,
|
||||||
status: statusFilter
|
status: statusFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +139,8 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
|||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
user_id: debouncedSearchUserId || undefined,
|
user_id: debouncedSearchUserId || undefined,
|
||||||
username: debouncedSearchUsername || undefined
|
username: debouncedSearchUsername || undefined,
|
||||||
|
email: debouncedSearchEmail || undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await services.adminUser.listUsers(requestParams)
|
const response = await services.adminUser.listUsers(requestParams)
|
||||||
@@ -160,7 +173,7 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
|||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [page, pageSize, debouncedSearchUserId, debouncedSearchUsername, statusFilter])
|
}, [page, pageSize, debouncedSearchUserId, debouncedSearchUsername, debouncedSearchEmail, statusFilter])
|
||||||
|
|
||||||
// Removed auto-fetch useEffect. Consumer (UsersManager) should trigger fetch.
|
// Removed auto-fetch useEffect. Consumer (UsersManager) should trigger fetch.
|
||||||
|
|
||||||
@@ -224,6 +237,20 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const updateUser = async (id: string, req: UpdateUserRequest) => {
|
||||||
|
try {
|
||||||
|
await services.adminUser.updateUser(id, req)
|
||||||
|
setUsers(prev => prev.map(u =>
|
||||||
|
u.id === id ? { ...u, ...req } : u
|
||||||
|
))
|
||||||
|
cacheRef.current = {}
|
||||||
|
toast.success('更新用户信息成功')
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : '更新用户信息失败')
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
users,
|
users,
|
||||||
total,
|
total,
|
||||||
@@ -233,17 +260,20 @@ export function AdminUsersProvider({ children }: { children: React.ReactNode })
|
|||||||
pageSize,
|
pageSize,
|
||||||
searchUserId,
|
searchUserId,
|
||||||
searchUsername,
|
searchUsername,
|
||||||
|
searchEmail,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
setPage,
|
setPage,
|
||||||
setPageSize,
|
setPageSize,
|
||||||
setSearchUserId,
|
setSearchUserId,
|
||||||
setSearchUsername,
|
setSearchUsername,
|
||||||
|
setSearchEmail,
|
||||||
setStatusFilter,
|
setStatusFilter,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
refresh,
|
refresh,
|
||||||
getUserDetail,
|
getUserDetail,
|
||||||
updateUserStatus,
|
updateUserStatus,
|
||||||
createUser,
|
createUser,
|
||||||
|
updateUser,
|
||||||
deleteUser
|
deleteUser
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export type {
|
|||||||
ListUsersRequest,
|
ListUsersRequest,
|
||||||
ListUsersResponse,
|
ListUsersResponse,
|
||||||
UpdateUserStatusRequest,
|
UpdateUserStatusRequest,
|
||||||
|
UpdateUserRequest,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
AppUpdateStatus,
|
AppUpdateStatus,
|
||||||
Schedule,
|
Schedule,
|
||||||
@@ -40,4 +41,4 @@ export type {
|
|||||||
StorageDriver,
|
StorageDriver,
|
||||||
StorageConfig,
|
StorageConfig,
|
||||||
ObjectStorageConfig,
|
ObjectStorageConfig,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|||||||
@@ -310,6 +310,8 @@ export interface ListUsersRequest {
|
|||||||
user_id?: string;
|
user_id?: string;
|
||||||
/** 用户名前缀过滤(可选) */
|
/** 用户名前缀过滤(可选) */
|
||||||
username?: string;
|
username?: string;
|
||||||
|
/** 邮箱前缀过滤(可选) */
|
||||||
|
email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -348,6 +350,20 @@ export interface CreateUserRequest {
|
|||||||
is_admin?: boolean;
|
is_admin?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新用户请求参数
|
||||||
|
*/
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
/** 昵称 */
|
||||||
|
nickname?: string;
|
||||||
|
/** 邮箱 */
|
||||||
|
email: string;
|
||||||
|
/** 是否管理员 */
|
||||||
|
is_admin?: boolean;
|
||||||
|
/** 密码(可选重置) */
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 认证源信息
|
* 认证源信息
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
ListUsersRequest,
|
ListUsersRequest,
|
||||||
ListUsersResponse,
|
ListUsersResponse,
|
||||||
|
UpdateUserRequest,
|
||||||
UpdateUserStatusRequest,
|
UpdateUserStatusRequest,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
@@ -29,4 +30,8 @@ export class AdminUserService extends BaseService {
|
|||||||
static async deleteUser(id: string): Promise<void> {
|
static async deleteUser(id: string): Promise<void> {
|
||||||
return this.delete<void>(`/users/${id}`);
|
return this.delete<void>(`/users/${id}`);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
static async updateUser(id: string, request: UpdateUserRequest): Promise<void> {
|
||||||
|
return this.put<void>(`/users/${id}`, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export type {
|
|||||||
ListUsersResponse,
|
ListUsersResponse,
|
||||||
UpdateUserStatusRequest,
|
UpdateUserStatusRequest,
|
||||||
CreateUserRequest,
|
CreateUserRequest,
|
||||||
|
UpdateUserRequest,
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
AppUpdateStatus,
|
AppUpdateStatus,
|
||||||
Schedule,
|
Schedule,
|
||||||
|
|||||||
@@ -6,16 +6,18 @@
|
|||||||
package user
|
package user
|
||||||
|
|
||||||
const (
|
const (
|
||||||
userNotFound = "用户不存在"
|
userNotFound = "用户不存在"
|
||||||
cannotDisable = "不能禁用管理员用户"
|
cannotDisable = "不能禁用管理员用户"
|
||||||
cannotDelete = "不能删除管理员用户"
|
cannotDelete = "不能删除管理员用户"
|
||||||
cannotDeleteSelf = "不能删除当前登录用户"
|
cannotDeleteSelf = "不能删除当前登录用户"
|
||||||
updateUserFailed = "更新用户状态失败"
|
updateUserFailed = "更新用户状态失败"
|
||||||
deleteUserFailed = "删除用户失败"
|
deleteUserFailed = "删除用户失败"
|
||||||
usernameExists = "用户名已存在"
|
usernameExists = "用户名已存在"
|
||||||
usernameRequired = "用户名不能为空"
|
usernameRequired = "用户名不能为空"
|
||||||
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
passwordTooShort = "密码长度不能少于 8 位" //nolint:gosec // false positive: this is an error message, not hardcoded credentials
|
||||||
createUserFailed = "创建用户失败"
|
createUserFailed = "创建用户失败"
|
||||||
emailRequired = "邮箱不能为空"
|
emailRequired = "邮箱不能为空"
|
||||||
emailExists = "邮箱已被注册"
|
emailExists = "邮箱已被注册"
|
||||||
|
cannotRevokeSelfAdmin = "不能撤销当前登录用户的管理员权限"
|
||||||
|
updateUserInfoFailed = "更新用户信息失败"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ func listUsers(ctx context.Context, req listUsersRequest) (int64, []model.User,
|
|||||||
return repository.ListAdminUsers(ctx, repository.AdminUserListFilter{
|
return repository.ListAdminUsers(ctx, repository.AdminUserListFilter{
|
||||||
UserID: req.UserID,
|
UserID: req.UserID,
|
||||||
Username: strings.TrimSpace(req.Username),
|
Username: strings.TrimSpace(req.Username),
|
||||||
|
Email: strings.TrimSpace(req.Email),
|
||||||
Page: req.Page,
|
Page: req.Page,
|
||||||
PageSize: req.PageSize,
|
PageSize: req.PageSize,
|
||||||
})
|
})
|
||||||
@@ -132,3 +133,80 @@ func createUser(ctx context.Context, req createUserRequest) (model.User, error)
|
|||||||
}
|
}
|
||||||
return newUser, nil
|
return newUser, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type updateUserParam struct {
|
||||||
|
ID uint64
|
||||||
|
Nickname string
|
||||||
|
Email string
|
||||||
|
IsAdmin bool
|
||||||
|
Password string
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUser(ctx context.Context, currentUserID uint64, param updateUserParam) error {
|
||||||
|
param.Nickname = strings.TrimSpace(param.Nickname)
|
||||||
|
param.Email = strings.TrimSpace(param.Email)
|
||||||
|
param.Password = strings.TrimSpace(param.Password)
|
||||||
|
|
||||||
|
if param.Email == "" {
|
||||||
|
return errors.New(emailRequired)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUser, err := repository.GetAdminUserDetail(ctx, param.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不能撤销当前登录用户的管理员权限
|
||||||
|
if currentUserID == param.ID && !param.IsAdmin && targetUser.IsAdmin {
|
||||||
|
return errors.New(cannotRevokeSelfAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果修改了邮箱,检查邮箱是否被其他用户占用
|
||||||
|
if targetUser.Email != param.Email {
|
||||||
|
count, err := repository.CountUsersByEmail(ctx, param.Email)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return errors.New(emailExists)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密码强度校验(如果输入了新密码)
|
||||||
|
if param.Password != "" && len(param.Password) < minPasswordLength {
|
||||||
|
return errors.New(passwordTooShort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 是否需要撤销 Token (重置密码或取消管理员)
|
||||||
|
needRevokeTokens := (param.Password != "") || (targetUser.IsAdmin && !param.IsAdmin)
|
||||||
|
var tokens []model.AccessToken
|
||||||
|
if needRevokeTokens {
|
||||||
|
_ = db.DB(ctx).Where("user_id = ?", param.ID).Find(&tokens).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字段
|
||||||
|
targetUser.Nickname = param.Nickname
|
||||||
|
if targetUser.Nickname == "" {
|
||||||
|
targetUser.Nickname = targetUser.Username
|
||||||
|
}
|
||||||
|
targetUser.Email = param.Email
|
||||||
|
targetUser.IsAdmin = param.IsAdmin
|
||||||
|
|
||||||
|
if param.Password != "" {
|
||||||
|
if err := targetUser.SetEncryptedPassword(param.Password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行更新
|
||||||
|
err = repository.UpdateUser(ctx, &targetUser)
|
||||||
|
if err == nil {
|
||||||
|
oauth.InvalidateCachedUser(ctx, param.ID)
|
||||||
|
if needRevokeTokens {
|
||||||
|
for _, token := range tokens {
|
||||||
|
oauth.InvalidateCachedToken(ctx, token.TokenHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ type listUsersRequest struct {
|
|||||||
PageSize int `form:"page_size" binding:"min=1,max=100"`
|
PageSize int `form:"page_size" binding:"min=1,max=100"`
|
||||||
UserID *uint64 `form:"user_id" binding:"omitempty,gt=0"`
|
UserID *uint64 `form:"user_id" binding:"omitempty,gt=0"`
|
||||||
Username string `form:"username"`
|
Username string `form:"username"`
|
||||||
|
Email string `form:"email"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type user struct {
|
type user struct {
|
||||||
@@ -289,3 +290,59 @@ func CreateUser(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, response.OK(toUser(newUser)))
|
c.JSON(http.StatusOK, response.OK(toUser(newUser)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateUserRequest 更新用户信息请求
|
||||||
|
type updateUserRequest struct {
|
||||||
|
Nickname string `json:"nickname" binding:"max=64"`
|
||||||
|
Email string `json:"email" binding:"required,email,max=255"`
|
||||||
|
IsAdmin bool `json:"is_admin"`
|
||||||
|
Password string `json:"password" binding:"omitempty,min=8,max=64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateUser 更新用户信息
|
||||||
|
// @Summary 更新用户信息
|
||||||
|
// @Description 更新指定用户的昵称、邮箱、管理员权限,并可选重置密码,需要管理员权限
|
||||||
|
// @Tags admin
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "用户 ID"
|
||||||
|
// @Param request body user.updateUserRequest true "更新参数"
|
||||||
|
// @Success 200 {object} response.Any{data=string} "更新成功"
|
||||||
|
// @Failure 400 {object} response.Any "参数错误"
|
||||||
|
// @Failure 401 {object} response.Any "未登录"
|
||||||
|
// @Failure 403 {object} response.Any "无管理员权限或尝试修改自身权限"
|
||||||
|
// @Failure 404 {object} response.Any "用户不存在"
|
||||||
|
// @Failure 500 {object} response.Any "内部错误"
|
||||||
|
// @Router /api/v1/admin/users/{id} [put]
|
||||||
|
func UpdateUser(c *gin.Context) {
|
||||||
|
var req updateUserRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.AbortBadRequest(c, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id, ok := parseUserID(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currUser, _ := oauth.GetFromContext[*model.User](c, oauth.UserObjKey)
|
||||||
|
err := updateUser(c.Request.Context(), currUser.ID, updateUserParam{
|
||||||
|
ID: id,
|
||||||
|
Nickname: req.Nickname,
|
||||||
|
Email: req.Email,
|
||||||
|
IsAdmin: req.IsAdmin,
|
||||||
|
Password: req.Password,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if abortUserLogicError(c, err, userNotFound, []string{cannotRevokeSelfAdmin}, []string{emailRequired, emailExists, passwordTooShort}) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.AbortInternal(c, updateUserInfoFailed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response.OKNil())
|
||||||
|
}
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ func GetFirstAdminUser(ctx context.Context) (model.User, error) {
|
|||||||
type AdminUserListFilter struct {
|
type AdminUserListFilter struct {
|
||||||
UserID *uint64
|
UserID *uint64
|
||||||
Username string
|
Username string
|
||||||
|
Email string
|
||||||
Page int
|
Page int
|
||||||
PageSize int
|
PageSize int
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,9 @@ func ListAdminUsers(ctx context.Context, filter AdminUserListFilter) (int64, []m
|
|||||||
if filter.Username != "" {
|
if filter.Username != "" {
|
||||||
query = query.Where("username LIKE ?", filter.Username+"%")
|
query = query.Where("username LIKE ?", filter.Username+"%")
|
||||||
}
|
}
|
||||||
|
if filter.Email != "" {
|
||||||
|
query = query.Where("email LIKE ?", filter.Email+"%")
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := query.Count(&total).Error; err != nil {
|
if err := query.Count(&total).Error; err != nil {
|
||||||
@@ -77,7 +81,7 @@ func ListAdminUsers(ctx context.Context, filter AdminUserListFilter) (int64, []m
|
|||||||
var users []model.User
|
var users []model.User
|
||||||
offset := (filter.Page - 1) * filter.PageSize
|
offset := (filter.Page - 1) * filter.PageSize
|
||||||
if err := query.
|
if err := query.
|
||||||
Select("id, username, nickname, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at").
|
Select("id, username, nickname, email, avatar_url, is_active, is_admin, last_login_at, created_at, updated_at").
|
||||||
Order("id ASC").
|
Order("id ASC").
|
||||||
Offset(offset).
|
Offset(offset).
|
||||||
Limit(filter.PageSize).
|
Limit(filter.PageSize).
|
||||||
@@ -170,3 +174,8 @@ func ListUsersByIDs(ctx context.Context, ids []uint64) ([]model.User, error) {
|
|||||||
}
|
}
|
||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateUser updates all fields of an existing user.
|
||||||
|
func UpdateUser(ctx context.Context, user *model.User) error {
|
||||||
|
return db.DB(ctx).Save(user).Error
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ func registerAdminIAMRoutes(adminRouter *gin.RouterGroup) {
|
|||||||
users.POST("", admin_user.CreateUser)
|
users.POST("", admin_user.CreateUser)
|
||||||
users.GET("/:id", admin_user.GetUser)
|
users.GET("/:id", admin_user.GetUser)
|
||||||
users.PUT("/:id/status", admin_user.UpdateUserStatus)
|
users.PUT("/:id/status", admin_user.UpdateUserStatus)
|
||||||
|
users.PUT("/:id", admin_user.UpdateUser)
|
||||||
users.DELETE("/:id", admin_user.DeleteUser)
|
users.DELETE("/:id", admin_user.DeleteUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user