diff --git a/app/api/roles/users/route.ts b/app/api/roles/users/route.ts index f6e8a240..a402f836 100644 --- a/app/api/roles/users/route.ts +++ b/app/api/roles/users/route.ts @@ -1,10 +1,89 @@ import { createDb } from "@/lib/db" -import { users } from "@/lib/schema" -import { eq } from "drizzle-orm" +import { users, userRoles, roles } from "@/lib/schema" +import { eq, like, or, sql } from "drizzle-orm" +import { checkPermission } from "@/lib/auth" +import { PERMISSIONS, ROLES } from "@/lib/permissions" export const runtime = "edge" +export async function GET(request: Request) { + const canPromote = await checkPermission(PERMISSIONS.PROMOTE_USER) + if (!canPromote) { + return Response.json({ error: "权限不足" }, { status: 403 }) + } + + const { searchParams } = new URL(request.url) + const page = Math.max(1, Number(searchParams.get("page") || "1")) + const pageSize = Math.min(50, Math.max(1, Number(searchParams.get("pageSize") || "20"))) + const search = searchParams.get("search")?.trim() + + const db = createDb() + + try { + const searchCondition = search + ? or( + like(users.username, `%${search}%`), + like(users.email, `%${search}%`), + like(users.name, `%${search}%`) + ) + : undefined + + const totalResult = await db + .select({ count: sql`count(*)` }) + .from(users) + .where(searchCondition) + const total = Number(totalResult[0].count) + + const roleRank = sql`CASE COALESCE(${roles.name}, ${ROLES.CIVILIAN}) + WHEN ${ROLES.EMPEROR} THEN 0 + WHEN ${ROLES.DUKE} THEN 1 + WHEN ${ROLES.KNIGHT} THEN 2 + WHEN ${ROLES.CIVILIAN} THEN 3 + ELSE 4 + END` + + const userList = await db + .select({ + id: users.id, + name: users.name, + username: users.username, + email: users.email, + image: users.image, + role: roles.name, + }) + .from(users) + .leftJoin(userRoles, eq(userRoles.userId, users.id)) + .leftJoin(roles, eq(roles.id, userRoles.roleId)) + .where(searchCondition) + .orderBy(roleRank, sql`LENGTH(COALESCE(${users.username}, ${users.name}))`, sql`LOWER(COALESCE(${users.username}, ${users.name}))`) + .limit(pageSize) + .offset((page - 1) * pageSize) + + return Response.json({ + users: userList.map((u) => ({ + id: u.id, + name: u.name, + username: u.username, + email: u.email, + image: u.image, + role: u.role || null, + })), + total, + page, + pageSize, + }) + } catch (error) { + console.error("Failed to list users:", error) + return Response.json({ error: "获取用户列表失败" }, { status: 500 }) + } +} + export async function POST(request: Request) { + const canPromote = await checkPermission(PERMISSIONS.PROMOTE_USER) + if (!canPromote) { + return Response.json({ error: "权限不足" }, { status: 403 }) + } + try { const json = await request.json() const { searchText } = json as { searchText: string } @@ -46,4 +125,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/app/api/users/[id]/route.ts b/app/api/users/[id]/route.ts new file mode 100644 index 00000000..718dfcf9 --- /dev/null +++ b/app/api/users/[id]/route.ts @@ -0,0 +1,52 @@ +import { createDb } from "@/lib/db"; +import { users, userRoles, apiKeys } from "@/lib/schema"; +import { eq } from "drizzle-orm"; +import { ROLES, PERMISSIONS } from "@/lib/permissions"; +import { checkPermission } from "@/lib/auth"; +import { getUserId } from "@/lib/apiKey"; + +export const runtime = "edge"; + +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const canManage = await checkPermission(PERMISSIONS.PROMOTE_USER); + if (!canManage) { + return Response.json({ error: "权限不足" }, { status: 403 }); + } + + try { + const { id: userId } = await params; + if (!userId) { + return Response.json({ error: "缺少必要参数" }, { status: 400 }); + } + + const currentUserId = await getUserId(); + if (userId === currentUserId) { + return Response.json({ error: "不能删除自己" }, { status: 400 }); + } + + const db = createDb(); + + const targetUserRole = await db.query.userRoles.findFirst({ + where: eq(userRoles.userId, userId), + with: { + role: true, + }, + }); + + if (targetUserRole?.role.name === ROLES.EMPEROR) { + return Response.json({ error: "不能删除皇帝" }, { status: 400 }); + } + + // apiKeys 未配置级联删除,需先手动删除;其余(accounts / emails→messages / webhooks / userRoles)由外键级联处理 + await db.delete(apiKeys).where(eq(apiKeys.userId, userId)); + await db.delete(users).where(eq(users.id, userId)); + + return Response.json({ success: true }); + } catch (error) { + console.error("Failed to delete user:", error); + return Response.json({ error: "操作失败" }, { status: 500 }); + } +} diff --git a/app/components/profile/promote-panel.tsx b/app/components/profile/promote-panel.tsx index 51abcba1..9f943386 100644 --- a/app/components/profile/promote-panel.tsx +++ b/app/components/profile/promote-panel.tsx @@ -2,9 +2,9 @@ import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" -import { Gem, Sword, User2, Loader2 } from "lucide-react" +import { Crown, Gem, Sword, User2, Loader2, Search, ChevronLeft, ChevronRight, Users, Trash2 } from "lucide-react" import { Input } from "@/components/ui/input" -import { useState } from "react" +import { useState, useEffect, useCallback } from "react" import { useToast } from "@/components/ui/use-toast" import { ROLES, Role } from "@/lib/permissions" import { @@ -14,8 +14,19 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" const roleIcons = { + [ROLES.EMPEROR]: Crown, [ROLES.DUKE]: Gem, [ROLES.KNIGHT]: Sword, [ROLES.CIVILIAN]: User2, @@ -23,147 +34,326 @@ const roleIcons = { type RoleWithoutEmperor = Exclude +interface UserItem { + id: string + name: string | null + username: string | null + email: string | null + image: string | null + role: string | null +} + +const PAGE_SIZE = 10 + export function PromotePanel() { const t = useTranslations("profile.promote") const tCard = useTranslations("profile.card") - const [searchText, setSearchText] = useState("") - const [loading, setLoading] = useState(false) - const [targetRole, setTargetRole] = useState(ROLES.KNIGHT) + const [users, setUsers] = useState([]) + const [total, setTotal] = useState(0) + const [page, setPage] = useState(1) + const [search, setSearch] = useState("") + const [loading, setLoading] = useState(true) + const [updatingUserId, setUpdatingUserId] = useState(null) + const [deletingUserId, setDeletingUserId] = useState(null) + const [userToDelete, setUserToDelete] = useState(null) const { toast } = useToast() - + const roleNames = { + [ROLES.EMPEROR]: tCard("roles.EMPEROR"), [ROLES.DUKE]: tCard("roles.DUKE"), [ROLES.KNIGHT]: tCard("roles.KNIGHT"), [ROLES.CIVILIAN]: tCard("roles.CIVILIAN"), } as const - const handleAction = async () => { - if (!searchText) return + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) + const fetchUsers = useCallback(async () => { setLoading(true) try { - const res = await fetch("/api/roles/users", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ searchText }) + const params = new URLSearchParams({ + page: page.toString(), + pageSize: PAGE_SIZE.toString(), }) + if (search.trim()) { + params.set("search", search.trim()) + } + const res = await fetch(`/api/roles/users?${params}`) + if (!res.ok) throw new Error("Failed to fetch") const data = await res.json() as { - user?: { - id: string - name?: string - username?: string - email: string - role?: string - } - error?: string + users: UserItem[] + total: number + page: number + pageSize: number } + setUsers(data.users) + setTotal(data.total) + } catch { + toast({ + title: t("updateFailed"), + variant: "destructive", + }) + } finally { + setLoading(false) + } + }, [page, search, t, toast]) - if (!res.ok) throw new Error(data.error || "未知错误") - - if (!data.user) { - toast({ - title: t("noUsers"), - description: t("searchPlaceholder"), - variant: "destructive" - }) - return - } + useEffect(() => { + fetchUsers() + }, [fetchUsers]) - if (data.user.role === targetRole) { - toast({ - title: t("updateSuccess"), - description: t("updateSuccess"), - }) - return - } + useEffect(() => { + setPage(1) + }, [search]) - const promoteRes = await fetch("/api/roles/promote", { + const handleRoleChange = async (userId: string, newRole: RoleWithoutEmperor) => { + setUpdatingUserId(userId) + try { + const res = await fetch("/api/roles/promote", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - userId: data.user.id, - roleName: targetRole - }) + body: JSON.stringify({ userId, roleName: newRole }), }) - - if (!promoteRes.ok) { - const error = await promoteRes.json() as { error: string } - throw new Error(error.error || t("updateFailed")) + if (!res.ok) { + const error = await res.json() as { error: string } + throw new Error(error.error) } - - toast({ - title: t("updateSuccess"), - description: `${data.user.username || data.user.email} - ${roleNames[targetRole]}`, - }) - setSearchText("") + setUsers((prev) => + prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u)) + ) + toast({ title: t("updateSuccess") }) } catch (error) { toast({ title: t("updateFailed"), - description: error instanceof Error ? error.message : t("updateFailed"), - variant: "destructive" + description: error instanceof Error ? error.message : undefined, + variant: "destructive", }) } finally { - setLoading(false) + setUpdatingUserId(null) } } - const Icon = roleIcons[targetRole] + const handleDelete = async (user: UserItem) => { + setDeletingUserId(user.id) + try { + const res = await fetch(`/api/users/${user.id}`, { + method: "DELETE", + }) + if (!res.ok) { + const error = await res.json() as { error: string } + throw new Error(error.error) + } + toast({ title: t("deleteSuccess") }) + setUserToDelete(null) + // 若删除的是当前页最后一条,且不在首页,则退回上一页(useEffect 会重新拉取) + if (users.length === 1 && page > 1) { + setPage((p) => p - 1) + } else { + await fetchUsers() + } + } catch (error) { + toast({ + title: t("deleteFailed"), + description: error instanceof Error ? error.message : undefined, + variant: "destructive", + }) + } finally { + setDeletingUserId(null) + } + } return (
- +

{t("title")}

+ + {t("totalUsers", { count: total })} +
-
-
-
- setSearchText(e.target.value)} - placeholder={t("searchPlaceholder")} - /> -
- +
+ + setSearch(e.target.value)} + placeholder={t("searchPlaceholder")} + className="pl-9" + /> +
+ + {loading ? ( +
+ + {t("loading")}
+ ) : users.length === 0 ? ( +
+ {t("noUsers")} +
+ ) : ( + <> +
+ {users.map((user) => { + const isEmperor = user.role === ROLES.EMPEROR + const RoleIcon = roleIcons[user.role as Role] || User2 + const isUpdating = updatingUserId === user.id + + return ( +
+ {user.image ? ( + + ) : ( +
+ +
+ )} + +
+
+ {user.name || user.username || "—"} +
+
+ {user.email || user.username || "—"} +
+
- +
+ )} +
+ ) + })} +
+ + {totalPages > 1 && ( +
+ + + {t("pageInfo", { current: page, total: totalPages })} + + +
)} - -
+ + )} + + { + if (!open && !deletingUserId) setUserToDelete(null) + }} + > + + + {t("deleteTitle")} + + {t("deleteConfirm", { + name: + userToDelete?.name || + userToDelete?.username || + userToDelete?.email || + "", + })} + + + + + {t("cancel")} + + { + e.preventDefault() + if (userToDelete) handleDelete(userToDelete) + }} + > + {deletingUserId ? ( + + ) : ( + t("deleteConfirmButton") + )} + + + +
) -} \ No newline at end of file +} diff --git a/app/i18n/messages/en/profile.json b/app/i18n/messages/en/profile.json index b501b89a..020495db 100644 --- a/app/i18n/messages/en/profile.json +++ b/app/i18n/messages/en/profile.json @@ -125,7 +125,7 @@ "title": "Role Management", "description": "Manage user roles (Emperor only)", "search": "Search Users", - "searchPlaceholder": "Enter username or email", + "searchPlaceholder": "Search by username, email or name", "username": "Username", "email": "Email", "role": "Role", @@ -134,6 +134,17 @@ "noUsers": "No users found", "loading": "Loading...", "updateSuccess": "User role updated successfully", - "updateFailed": "Failed to update user role" + "updateFailed": "Failed to update user role", + "totalUsers": "{count} users total", + "prevPage": "Previous", + "nextPage": "Next", + "pageInfo": "Page {current} / {total}", + "deleteUser": "Delete User", + "deleteTitle": "Delete User", + "deleteConfirm": "Are you sure you want to delete user {name}? All of their emails, messages and other data will be permanently deleted and cannot be recovered.", + "deleteConfirmButton": "Delete", + "cancel": "Cancel", + "deleteSuccess": "User deleted successfully", + "deleteFailed": "Failed to delete user" } } diff --git a/app/i18n/messages/ja/profile.json b/app/i18n/messages/ja/profile.json index ccbb4507..0e905d1e 100644 --- a/app/i18n/messages/ja/profile.json +++ b/app/i18n/messages/ja/profile.json @@ -125,7 +125,7 @@ "title": "ロール管理", "description": "ユーザーロールを管理(皇帝のみ利用可能)", "search": "ユーザーを検索", - "searchPlaceholder": "ユーザー名またはメールを入力", + "searchPlaceholder": "ユーザー名、メールまたは名前で検索", "username": "ユーザー名", "email": "メール", "role": "ロール", @@ -134,6 +134,17 @@ "noUsers": "ユーザーが見つかりません", "loading": "読み込み中...", "updateSuccess": "ユーザーロールを更新しました", - "updateFailed": "ユーザーロールの更新に失敗しました" + "updateFailed": "ユーザーロールの更新に失敗しました", + "totalUsers": "合計 {count} ユーザー", + "prevPage": "前へ", + "nextPage": "次へ", + "pageInfo": "{current} / {total} ページ", + "deleteUser": "ユーザーを削除", + "deleteTitle": "ユーザーを削除", + "deleteConfirm": "ユーザー {name} を削除してもよろしいですか?このユーザーのメール・メッセージ等のデータはすべて完全に削除され、復元できません。", + "deleteConfirmButton": "削除", + "cancel": "キャンセル", + "deleteSuccess": "ユーザーを削除しました", + "deleteFailed": "ユーザーの削除に失敗しました" } } diff --git a/app/i18n/messages/ko/profile.json b/app/i18n/messages/ko/profile.json index a8e1461d..c88758e0 100644 --- a/app/i18n/messages/ko/profile.json +++ b/app/i18n/messages/ko/profile.json @@ -125,7 +125,7 @@ "title": "역할 관리", "description": "사용자 역할 관리 (황제 전용)", "search": "사용자 검색", - "searchPlaceholder": "사용자 이름 또는 이메일 입력", + "searchPlaceholder": "사용자 이름, 이메일 또는 이름으로 검색", "username": "사용자 이름", "email": "이메일", "role": "역할", @@ -134,6 +134,17 @@ "noUsers": "사용자를 찾을 수 없습니다", "loading": "로딩 중...", "updateSuccess": "사용자 역할이 성공적으로 업데이트되었습니다", - "updateFailed": "사용자 역할 업데이트에 실패했습니다" + "updateFailed": "사용자 역할 업데이트에 실패했습니다", + "totalUsers": "총 {count}명의 사용자", + "prevPage": "이전", + "nextPage": "다음", + "pageInfo": "{current} / {total} 페이지", + "deleteUser": "사용자 삭제", + "deleteTitle": "사용자 삭제", + "deleteConfirm": "사용자 {name}을(를) 삭제하시겠습니까? 이 사용자의 모든 이메일, 메시지 등의 데이터가 영구적으로 삭제되며 복구할 수 없습니다.", + "deleteConfirmButton": "삭제", + "cancel": "취소", + "deleteSuccess": "사용자가 삭제되었습니다", + "deleteFailed": "사용자 삭제에 실패했습니다" } } diff --git a/app/i18n/messages/zh-CN/profile.json b/app/i18n/messages/zh-CN/profile.json index 4df25953..2d2de6a9 100644 --- a/app/i18n/messages/zh-CN/profile.json +++ b/app/i18n/messages/zh-CN/profile.json @@ -125,7 +125,7 @@ "title": "角色管理", "description": "管理用户角色(仅皇帝可用)", "search": "搜索用户", - "searchPlaceholder": "输入用户名或邮箱", + "searchPlaceholder": "搜索用户名、邮箱或昵称", "username": "用户名", "email": "邮箱", "role": "角色", @@ -134,6 +134,17 @@ "noUsers": "未找到用户", "loading": "加载中...", "updateSuccess": "用户角色更新成功", - "updateFailed": "更新用户角色失败" + "updateFailed": "更新用户角色失败", + "totalUsers": "共 {count} 个用户", + "prevPage": "上一页", + "nextPage": "下一页", + "pageInfo": "第 {current} / {total} 页", + "deleteUser": "删除用户", + "deleteTitle": "删除用户", + "deleteConfirm": "确定要删除用户 {name} 吗?该用户名下的所有邮箱、邮件等数据将被永久删除且无法恢复。", + "deleteConfirmButton": "删除", + "cancel": "取消", + "deleteSuccess": "用户删除成功", + "deleteFailed": "删除用户失败" } } diff --git a/app/i18n/messages/zh-TW/profile.json b/app/i18n/messages/zh-TW/profile.json index 1b93f5c9..52fa7521 100644 --- a/app/i18n/messages/zh-TW/profile.json +++ b/app/i18n/messages/zh-TW/profile.json @@ -125,7 +125,7 @@ "title": "角色管理", "description": "管理使用者角色(僅皇帝可用)", "search": "搜尋使用者", - "searchPlaceholder": "輸入使用者名稱或郵箱", + "searchPlaceholder": "搜尋使用者名稱、郵箱或暱稱", "username": "使用者名稱", "email": "郵箱", "role": "角色", @@ -134,6 +134,17 @@ "noUsers": "未找到使用者", "loading": "載入中...", "updateSuccess": "使用者角色更新成功", - "updateFailed": "更新使用者角色失敗" + "updateFailed": "更新使用者角色失敗", + "totalUsers": "共 {count} 個使用者", + "prevPage": "上一頁", + "nextPage": "下一頁", + "pageInfo": "第 {current} / {total} 頁", + "deleteUser": "刪除使用者", + "deleteTitle": "刪除使用者", + "deleteConfirm": "確定要刪除使用者 {name} 嗎?該使用者名下的所有郵箱、郵件等資料將被永久刪除且無法復原。", + "deleteConfirmButton": "刪除", + "cancel": "取消", + "deleteSuccess": "使用者刪除成功", + "deleteFailed": "刪除使用者失敗" } }