用户管理增加用户名搜索

This commit is contained in:
mtvpls
2026-05-15 12:06:34 +08:00
parent ae3a054e56
commit 09fc63c116
6 changed files with 268 additions and 80 deletions
+132 -36
View File
@@ -41,12 +41,14 @@ import {
Mail,
Palette,
Plus,
Search,
Settings,
Trash2,
Tv,
UserPlus,
Users,
Video,
X,
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import {
@@ -512,8 +514,10 @@ interface UserConfigProps {
userPage: number;
userTotalPages: number;
userTotal: number;
fetchUsersV2: (page: number) => Promise<void>;
fetchUsersV2: (page: number, search?: string) => Promise<void>;
userListLoading: boolean;
userSearch: string;
setUserSearch: (value: string) => void;
}
const UserConfig = ({
@@ -526,6 +530,8 @@ const UserConfig = ({
userTotal,
fetchUsersV2,
userListLoading,
userSearch,
setUserSearch,
}: UserConfigProps) => {
const { alertModal, showAlert, hideAlert } = useAlertModal();
const { isLoading, withLoading } = useLoadingState();
@@ -582,6 +588,7 @@ const UserConfig = ({
} | null>(null);
const [showDeleteUserModal, setShowDeleteUserModal] = useState(false);
const [deletingUser, setDeletingUser] = useState<string | null>(null);
const trimmedUserSearch = userSearch.trim();
// 当前登录用户名
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
@@ -887,7 +894,7 @@ const UserConfig = ({
if (checked) {
// 只选择自己有权限操作的用户
const selectableUsernames =
config?.UserConfig?.Users?.filter(
displayUsers?.filter(
(user) =>
role === 'owner' ||
(role === 'admin' &&
@@ -898,7 +905,7 @@ const UserConfig = ({
setSelectedUsers(new Set());
}
},
[config?.UserConfig?.Users, role, currentUsername]
[displayUsers, role, currentUsername]
);
// 批量设置用户组
@@ -1240,15 +1247,87 @@ const UserConfig = ({
{/* 用户列表 */}
<div>
<div className='flex items-center justify-between mb-3'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<div className='flex items-center space-x-2'>
<div className='mb-3 space-y-3'>
<div className='flex items-center justify-between gap-3'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
</h4>
<button
onClick={() => {
setShowAddUserForm(!showAddUserForm);
if (showChangePasswordForm) {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}
}}
className={
showAddUserForm
? buttonStyles.secondary
: buttonStyles.success
}
>
{showAddUserForm ? '取消' : '添加用户'}
</button>
</div>
<div className='flex w-full flex-wrap items-center justify-end gap-2'>
{!hasOldUserData && usersV2 && (
<form
onSubmit={(e) => {
e.preventDefault();
setSelectedUsers(new Set());
fetchUsersV2(1, trimmedUserSearch);
}}
className='ml-auto flex min-w-0 items-center gap-2'
>
<div className='relative w-44 sm:w-56'>
<Search className='absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400' />
<input
type='text'
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
placeholder='按用户名搜索'
className='w-full pl-9 pr-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent'
/>
</div>
{trimmedUserSearch && (
<button
type='button'
onClick={() => {
setUserSearch('');
setSelectedUsers(new Set());
fetchUsersV2(1, '');
}}
disabled={userListLoading}
aria-label='清空搜索'
title='清空'
className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
userListLoading
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white'
: 'bg-gray-600 hover:bg-gray-700 dark:bg-gray-600 dark:hover:bg-gray-700 text-white'
}`}
>
<X className='h-4 w-4' />
</button>
)}
<button
type='submit'
disabled={userListLoading}
aria-label='搜索用户'
title='搜索'
className={`inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg transition-colors ${
userListLoading
? 'bg-gray-400 dark:bg-gray-600 cursor-not-allowed text-white'
: 'bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white'
}`}
>
<Search className='h-4 w-4' />
</button>
</form>
)}
{/* 批量操作按钮 */}
{selectedUsers.size > 0 && (
<>
<div className='flex items-center space-x-3'>
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:space-x-3'>
<span className='text-sm text-gray-600 dark:text-gray-400'>
{selectedUsers.size}
</span>
@@ -1259,23 +1338,9 @@ const UserConfig = ({
</button>
</div>
<div className='w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
<div className='hidden sm:block w-px h-6 bg-gray-300 dark:bg-gray-600'></div>
</>
)}
<button
onClick={() => {
setShowAddUserForm(!showAddUserForm);
if (showChangePasswordForm) {
setShowChangePasswordForm(false);
setChangePasswordUser({ username: '', password: '' });
}
}}
className={
showAddUserForm ? buttonStyles.secondary : buttonStyles.success
}
>
{showAddUserForm ? '取消' : '添加用户'}
</button>
</div>
</div>
@@ -1448,7 +1513,7 @@ const UserConfig = ({
<th className='w-10 px-1 py-3 text-center'>
{(() => {
// 检查是否有权限操作任何用户
const hasAnyPermission = config?.UserConfig?.Users?.some(
const hasAnyPermission = displayUsers?.some(
(user) =>
role === 'owner' ||
(role === 'admin' &&
@@ -1516,7 +1581,7 @@ const UserConfig = ({
<tbody>
<tr>
<td
colSpan={7}
colSpan={8}
className='px-6 py-8 text-center text-gray-500 dark:text-gray-400'
>
...
@@ -1536,6 +1601,23 @@ const UserConfig = ({
};
return priority(a) - priority(b);
});
if (sortedUsers.length === 0) {
return (
<tbody>
<tr>
<td
colSpan={8}
className='px-6 py-8 text-center text-gray-500 dark:text-gray-400'
>
{trimmedUserSearch
? `未找到用户名包含“${trimmedUserSearch}”的用户`
: '暂无用户'}
</td>
</tr>
</tbody>
);
}
return (
<tbody className='divide-y divide-gray-200 dark:divide-gray-700'>
{sortedUsers.map((user) => {
@@ -1778,11 +1860,14 @@ const UserConfig = ({
{!hasOldUserData && usersV2 && userTotalPages > 1 && (
<div className='mt-4 flex items-center justify-between px-4'>
<div className='text-sm text-gray-600 dark:text-gray-400'>
{userTotal} {userPage} / {userTotalPages}
{trimmedUserSearch
? `搜索结果 ${userTotal} 个用户`
: `${userTotal} 个用户`}
{userPage} / {userTotalPages}
</div>
<div className='flex items-center space-x-2'>
<button
onClick={() => fetchUsersV2(1)}
onClick={() => fetchUsersV2(1, trimmedUserSearch)}
disabled={userPage === 1}
className={`px-3 py-1 text-sm rounded ${
userPage === 1
@@ -1793,7 +1878,7 @@ const UserConfig = ({
</button>
<button
onClick={() => fetchUsersV2(userPage - 1)}
onClick={() => fetchUsersV2(userPage - 1, trimmedUserSearch)}
disabled={userPage === 1}
className={`px-3 py-1 text-sm rounded ${
userPage === 1
@@ -1804,7 +1889,7 @@ const UserConfig = ({
</button>
<button
onClick={() => fetchUsersV2(userPage + 1)}
onClick={() => fetchUsersV2(userPage + 1, trimmedUserSearch)}
disabled={userPage === userTotalPages}
className={`px-3 py-1 text-sm rounded ${
userPage === userTotalPages
@@ -1815,7 +1900,7 @@ const UserConfig = ({
</button>
<button
onClick={() => fetchUsersV2(userTotalPages)}
onClick={() => fetchUsersV2(userTotalPages, trimmedUserSearch)}
disabled={userPage === userTotalPages}
className={`px-3 py-1 text-sm rounded ${
userPage === userTotalPages
@@ -16125,14 +16210,23 @@ function AdminPageClient() {
const [userTotalPages, setUserTotalPages] = useState(1);
const [userTotal, setUserTotal] = useState(0);
const [userListLoading, setUserListLoading] = useState(false);
const [userSearch, setUserSearch] = useState('');
const userLimit = 10;
// 获取新版本用户列表
const fetchUsersV2 = useCallback(async (page = 1) => {
const fetchUsersV2 = useCallback(async (page = 1, search = userSearch) => {
try {
setUserListLoading(true);
const params = new URLSearchParams({
page: String(page),
limit: String(userLimit),
});
const trimmedSearch = search.trim();
if (trimmedSearch) {
params.set('search', trimmedSearch);
}
const response = await fetch(
`/api/admin/users?page=${page}&limit=${userLimit}`
`/api/admin/users?${params.toString()}`
);
if (response.ok) {
const data = await response.json();
@@ -16146,7 +16240,7 @@ function AdminPageClient() {
} finally {
setUserListLoading(false);
}
}, []);
}, [userSearch]);
// 刷新配置和用户列表
const refreshConfigAndUsers = useCallback(async () => {
@@ -16418,9 +16512,9 @@ function AdminPageClient() {
</CollapsibleTab>
<div className='space-y-4'>
{/* 用户配置标签 */}
{/* 用户管理标签 */}
<CollapsibleTab
title='用户配置'
title='用户管理'
icon={
<Users size={20} className='text-gray-600 dark:text-gray-400' />
}
@@ -16437,6 +16531,8 @@ function AdminPageClient() {
userTotal={userTotal}
fetchUsersV2={fetchUsersV2}
userListLoading={userListLoading}
userSearch={userSearch}
setUserSearch={setUserSearch}
/>
</CollapsibleTab>
+2 -1
View File
@@ -44,10 +44,11 @@ export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1', 10);
const limit = parseInt(searchParams.get('limit') || '10', 10);
const search = (searchParams.get('search') || '').trim();
const offset = (page - 1) * limit;
// 获取用户列表(优先使用新版本)
const result = await db.getUserListV2(offset, limit, process.env.USERNAME);
const result = await db.getUserListV2(offset, limit, process.env.USERNAME, search);
if (result.users.length > 0) {
// 使用新版本数据
+45 -17
View File
@@ -1306,7 +1306,8 @@ export class D1Storage implements IStorage {
async getUserListV2(
offset = 0,
limit = 20,
ownerUsername?: string
ownerUsername?: string,
search?: string
): Promise<{
users: Array<{
username: string;
@@ -1320,10 +1321,16 @@ export class D1Storage implements IStorage {
total: number;
}> {
try {
const trimmedSearch = search?.trim() || '';
const searchPattern = `%${trimmedSearch}%`;
// 获取总数
const countResult = await this.db
.prepare('SELECT COUNT(*) as total FROM users')
.first();
const countQuery = trimmedSearch
? this.db
.prepare('SELECT COUNT(*) as total FROM users WHERE username LIKE ?')
.bind(searchPattern)
: this.db.prepare('SELECT COUNT(*) as total FROM users');
const countResult = await countQuery.first();
let total = (countResult?.total as number) || 0;
// 检查站长是否在数据库中
@@ -1333,8 +1340,11 @@ export class D1Storage implements IStorage {
ownerInfo = await this.getUserInfoV2(ownerUsername);
ownerInDatabase = !!ownerInfo && ownerInfo.created_at !== 0;
// 如果站长不在数据库中,总数+1
if (!ownerInDatabase) {
// 如果站长不在数据库中且匹配搜索条件,总数+1
if (
!ownerInDatabase &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
total += 1;
}
}
@@ -1343,7 +1353,11 @@ export class D1Storage implements IStorage {
let actualOffset = offset;
let actualLimit = limit;
if (ownerUsername && !ownerInDatabase) {
if (
ownerUsername &&
!ownerInDatabase &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
if (offset === 0) {
// 第一页:只获取 limit-1 个用户,为站长留出位置
actualLimit = limit - 1;
@@ -1354,20 +1368,34 @@ export class D1Storage implements IStorage {
}
// 获取用户列表(按创建时间降序)
const result = await this.db
.prepare(`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`)
.bind(actualLimit, actualOffset)
.all();
const listQuery = trimmedSearch
? this.db
.prepare(`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users
WHERE username LIKE ?
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`)
.bind(searchPattern, actualLimit, actualOffset)
: this.db
.prepare(`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`)
.bind(actualLimit, actualOffset);
const result = await listQuery.all();
const users = [];
// 如果有站长且在第一页,确保站长始终在第一位
if (ownerUsername && offset === 0) {
if (
ownerUsername &&
offset === 0 &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
users.push({
username: ownerUsername,
role: 'owner' as const,
+3 -2
View File
@@ -555,7 +555,8 @@ export class DbManager {
async getUserListV2(
offset = 0,
limit = 20,
ownerUsername?: string
ownerUsername?: string,
search?: string
): Promise<{
users: Array<{
username: string;
@@ -569,7 +570,7 @@ export class DbManager {
total: number;
}> {
if (typeof (this.storage as any).getUserListV2 === 'function') {
return (this.storage as any).getUserListV2(offset, limit, ownerUsername);
return (this.storage as any).getUserListV2(offset, limit, ownerUsername, search);
}
return { users: [], total: 0 };
}
+45 -17
View File
@@ -563,7 +563,8 @@ export class PostgresStorage implements IStorage {
async getUserListV2(
offset = 0,
limit = 20,
ownerUsername?: string
ownerUsername?: string,
search?: string
): Promise<{
users: Array<{
username: string;
@@ -577,10 +578,16 @@ export class PostgresStorage implements IStorage {
total: number;
}> {
try {
const trimmedSearch = search?.trim() || '';
const searchPattern = `%${trimmedSearch}%`;
// 获取总数
const countResult = await this.db
.prepare('SELECT COUNT(*) as total FROM users')
.first();
const countQuery = trimmedSearch
? this.db
.prepare('SELECT COUNT(*) as total FROM users WHERE username LIKE $1')
.bind(searchPattern)
: this.db.prepare('SELECT COUNT(*) as total FROM users');
const countResult = await countQuery.first();
let total = (countResult?.total as number) || 0;
// 检查站长是否在数据库中
@@ -590,8 +597,11 @@ export class PostgresStorage implements IStorage {
ownerInfo = await this.getUserInfoV2(ownerUsername);
ownerInDatabase = !!ownerInfo && ownerInfo.created_at !== 0;
// 如果站长不在数据库中,总数+1
if (!ownerInDatabase) {
// 如果站长不在数据库中且匹配搜索条件,总数+1
if (
!ownerInDatabase &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
total += 1;
}
}
@@ -600,7 +610,11 @@ export class PostgresStorage implements IStorage {
let actualOffset = offset;
let actualLimit = limit;
if (ownerUsername && !ownerInDatabase) {
if (
ownerUsername &&
!ownerInDatabase &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
if (offset === 0) {
// 第一页:只获取 limit-1 个用户,为站长留出位置
actualLimit = limit - 1;
@@ -611,20 +625,34 @@ export class PostgresStorage implements IStorage {
}
// 获取用户列表(按创建时间降序)
const result = await this.db
.prepare(`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`)
.bind(actualLimit, actualOffset)
.all();
const listQuery = trimmedSearch
? this.db
.prepare(`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users
WHERE username LIKE $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
`)
.bind(searchPattern, actualLimit, actualOffset)
: this.db
.prepare(`
SELECT username, role, banned, tags, oidc_sub, enabled_apis, created_at
FROM users
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
`)
.bind(actualLimit, actualOffset);
const result = await listQuery.all();
const users = [];
// 如果有站长且在第一页,确保站长始终在第一位
if (ownerUsername && offset === 0) {
if (
ownerUsername &&
offset === 0 &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
users.push({
username: ownerUsername,
role: 'owner' as const,
+41 -7
View File
@@ -1347,7 +1347,8 @@ export abstract class BaseRedisStorage implements IStorage {
async getUserListV2(
offset = 0,
limit = 20,
ownerUsername?: string
ownerUsername?: string,
search?: string
): Promise<{
users: Array<{
username: string;
@@ -1360,6 +1361,8 @@ export abstract class BaseRedisStorage implements IStorage {
}>;
total: number;
}> {
const trimmedSearch = search?.trim() || '';
// 获取总数
let total = await this.withRetry(() => this.adapter.zCard(this.userListKey()));
@@ -1386,8 +1389,11 @@ export abstract class BaseRedisStorage implements IStorage {
ownerExistenceCache.set(ownerUsername, ownerInDatabase);
}
// 如果站长不在数据库中,总数+1(无论在哪一页都要加)
if (!ownerInDatabase) {
// 如果站长不在数据库中且匹配搜索条件,总数+1(无论在哪一页都要加)
if (
!ownerInDatabase &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
total += 1;
}
}
@@ -1396,7 +1402,11 @@ export abstract class BaseRedisStorage implements IStorage {
let actualOffset = offset;
let actualLimit = limit;
if (ownerUsername && !ownerInDatabase) {
if (
ownerUsername &&
!ownerInDatabase &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
if (offset === 0) {
// 第一页:只获取 limit-1 个用户,为站长留出位置
actualLimit = limit - 1;
@@ -1407,14 +1417,38 @@ export abstract class BaseRedisStorage implements IStorage {
}
// 获取用户列表(按注册时间升序)
const usernames = await this.withRetry(() =>
this.adapter.zRange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
let usernames = await this.withRetry(() =>
trimmedSearch
? this.adapter.zRange(this.userListKey(), 0, -1)
: this.adapter.zRange(
this.userListKey(),
actualOffset,
actualOffset + actualLimit - 1
)
);
if (trimmedSearch) {
usernames = usernames.filter((username) =>
ensureString(username).includes(trimmedSearch)
);
total = usernames.length;
if (
ownerUsername &&
!ownerInDatabase &&
ownerUsername.includes(trimmedSearch)
) {
total += 1;
}
usernames = usernames.slice(actualOffset, actualOffset + actualLimit);
}
const users = [];
// 如果有站长且在第一页,确保站长始终在第一位
if (ownerUsername && offset === 0) {
if (
ownerUsername &&
offset === 0 &&
(!trimmedSearch || ownerUsername.includes(trimmedSearch))
) {
// 即使站长不在数据库中,也要添加站长(站长使用环境变量认证)
users.push({
username: ownerUsername,