From 6fc9686be99ea657f7ef53bbe44aa16be7b824da Mon Sep 17 00:00:00 2001 From: lidong-sal Date: Thu, 25 Jun 2026 14:59:07 +0800 Subject: [PATCH] feat(roles): enhance role management panel with user list and inline editing Redesign the Emperor's Role Management panel from a single-user search-and-assign form into a full user management dashboard with browsable user list and inline role editing. --- app/api/roles/users/route.ts | 72 ++++- app/components/profile/promote-panel.tsx | 319 +++++++++++++++-------- app/i18n/messages/en/profile.json | 8 +- app/i18n/messages/ja/profile.json | 8 +- app/i18n/messages/ko/profile.json | 8 +- app/i18n/messages/zh-CN/profile.json | 8 +- app/i18n/messages/zh-TW/profile.json | 8 +- 7 files changed, 308 insertions(+), 123 deletions(-) diff --git a/app/api/roles/users/route.ts b/app/api/roles/users/route.ts index f6e8a24..2c9e33b 100644 --- a/app/api/roles/users/route.ts +++ b/app/api/roles/users/route.ts @@ -1,10 +1,78 @@ import { createDb } from "@/lib/db" import { users } from "@/lib/schema" -import { eq } from "drizzle-orm" +import { eq, like, or, sql } from "drizzle-orm" +import { checkPermission } from "@/lib/auth" +import { PERMISSIONS } 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 userList = await db.query.users.findMany({ + where: searchCondition, + with: { + userRoles: { + with: { + role: true, + }, + }, + }, + limit: pageSize, + offset: (page - 1) * pageSize, + orderBy: (users, { desc }) => [desc(users.id)], + }) + + return Response.json({ + users: userList.map((u) => ({ + id: u.id, + name: u.name, + username: u.username, + email: u.email, + image: u.image, + role: u.userRoles[0]?.role.name || 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 +114,4 @@ export async function POST(request: Request) { { status: 500 } ) } -} \ No newline at end of file +} diff --git a/app/components/profile/promote-panel.tsx b/app/components/profile/promote-panel.tsx index 51abcba..273fed9 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 } 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 { @@ -16,6 +16,7 @@ import { } from "@/components/ui/select" const roleIcons = { + [ROLES.EMPEROR]: Crown, [ROLES.DUKE]: Gem, [ROLES.KNIGHT]: Sword, [ROLES.CIVILIAN]: User2, @@ -23,147 +24,243 @@ 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 { 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 } - - if (!res.ok) throw new Error(data.error || "未知错误") - - if (!data.user) { - toast({ - title: t("noUsers"), - description: t("searchPlaceholder"), - variant: "destructive" - }) - return - } - - if (data.user.role === targetRole) { - toast({ - title: t("updateSuccess"), - description: t("updateSuccess"), - }) - return - } - - const promoteRes = await fetch("/api/roles/promote", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - userId: data.user.id, - roleName: targetRole - }) - }) - - if (!promoteRes.ok) { - const error = await promoteRes.json() as { error: string } - throw new Error(error.error || t("updateFailed")) - } - - toast({ - title: t("updateSuccess"), - description: `${data.user.username || data.user.email} - ${roleNames[targetRole]}`, - }) - setSearchText("") - } catch (error) { + setUsers(data.users) + setTotal(data.total) + } catch { toast({ title: t("updateFailed"), - description: error instanceof Error ? error.message : t("updateFailed"), - variant: "destructive" + variant: "destructive", }) } finally { setLoading(false) } - } + }, [page, search, t, toast]) - const Icon = roleIcons[targetRole] + useEffect(() => { + fetchUsers() + }, [fetchUsers]) + + useEffect(() => { + setPage(1) + }, [search]) + + 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, roleName: newRole }), + }) + if (!res.ok) { + const error = await res.json() as { error: string } + throw new Error(error.error) + } + 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 : undefined, + variant: "destructive", + }) + } finally { + setUpdatingUserId(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 -
+ + {totalPages > 1 && ( +
+ + + {t("pageInfo", { current: page, total: totalPages })} + + +
)} - -
+ + )}
) -} \ No newline at end of file +} diff --git a/app/i18n/messages/en/profile.json b/app/i18n/messages/en/profile.json index b501b89..313cbc5 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,10 @@ "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}" } } diff --git a/app/i18n/messages/ja/profile.json b/app/i18n/messages/ja/profile.json index ccbb450..5c7ae17 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,10 @@ "noUsers": "ユーザーが見つかりません", "loading": "読み込み中...", "updateSuccess": "ユーザーロールを更新しました", - "updateFailed": "ユーザーロールの更新に失敗しました" + "updateFailed": "ユーザーロールの更新に失敗しました", + "totalUsers": "合計 {count} ユーザー", + "prevPage": "前へ", + "nextPage": "次へ", + "pageInfo": "{current} / {total} ページ" } } diff --git a/app/i18n/messages/ko/profile.json b/app/i18n/messages/ko/profile.json index a8e1461..899169d 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,10 @@ "noUsers": "사용자를 찾을 수 없습니다", "loading": "로딩 중...", "updateSuccess": "사용자 역할이 성공적으로 업데이트되었습니다", - "updateFailed": "사용자 역할 업데이트에 실패했습니다" + "updateFailed": "사용자 역할 업데이트에 실패했습니다", + "totalUsers": "총 {count}명의 사용자", + "prevPage": "이전", + "nextPage": "다음", + "pageInfo": "{current} / {total} 페이지" } } diff --git a/app/i18n/messages/zh-CN/profile.json b/app/i18n/messages/zh-CN/profile.json index 4df2595..1ead9ca 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,10 @@ "noUsers": "未找到用户", "loading": "加载中...", "updateSuccess": "用户角色更新成功", - "updateFailed": "更新用户角色失败" + "updateFailed": "更新用户角色失败", + "totalUsers": "共 {count} 个用户", + "prevPage": "上一页", + "nextPage": "下一页", + "pageInfo": "第 {current} / {total} 页" } } diff --git a/app/i18n/messages/zh-TW/profile.json b/app/i18n/messages/zh-TW/profile.json index 1b93f5c..302c475 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,10 @@ "noUsers": "未找到使用者", "loading": "載入中...", "updateSuccess": "使用者角色更新成功", - "updateFailed": "更新使用者角色失敗" + "updateFailed": "更新使用者角色失敗", + "totalUsers": "共 {count} 個使用者", + "prevPage": "上一頁", + "nextPage": "下一頁", + "pageInfo": "第 {current} / {total} 頁" } }