feat: add user api limit

This commit is contained in:
shinya
2025-08-20 19:37:36 +08:00
parent 1b5685c1bb
commit 6fc590cca1
12 changed files with 322 additions and 46 deletions

View File

@@ -35,6 +35,7 @@ import {
} from 'lucide-react';
import { GripVertical } from 'lucide-react';
import { Suspense, useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import Swal from 'sweetalert2';
import { AdminConfig, AdminConfigResult } from '@/lib/admin.types';
@@ -145,6 +146,13 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
username: '',
password: '',
});
const [showConfigureApisModal, setShowConfigureApisModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<{
username: string;
role: 'user' | 'admin' | 'owner';
enabledApis?: string[];
} | null>(null);
const [selectedApis, setSelectedApis] = useState<string[]>([]);
// 当前登录用户名
const currentUsername = getAuthInfoFromBrowserCookie()?.username || null;
@@ -209,6 +217,56 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
await handleUserAction('deleteUser', username);
};
const handleConfigureUserApis = (user: {
username: string;
role: 'user' | 'admin' | 'owner';
enabledApis?: string[];
}) => {
setSelectedUser(user);
setSelectedApis(user.enabledApis || []);
setShowConfigureApisModal(true);
};
// 提取URL域名的辅助函数
const extractDomain = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.hostname;
} catch {
// 如果URL格式不正确返回原字符串
return url;
}
};
const handleSaveUserApis = async () => {
if (!selectedUser) return;
try {
const res = await fetch('/api/admin/user', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetUsername: selectedUser.username,
action: 'updateUserApis',
enabledApis: selectedApis,
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `操作失败: ${res.status}`);
}
// 成功后刷新配置
await refreshConfig();
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
} catch (err) {
showError(err instanceof Error ? err.message : '操作失败');
}
};
// 通用请求函数
const handleUserAction = async (
action:
@@ -394,6 +452,12 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
>
</th>
<th
scope='col'
className='px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
>
</th>
<th
scope='col'
className='px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider'
@@ -470,6 +534,27 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
{!user.banned ? '正常' : '已封禁'}
</span>
</td>
<td className='px-6 py-4 whitespace-nowrap'>
<div className='flex items-center space-x-2'>
<span className='text-sm text-gray-900 dark:text-gray-100'>
{user.enabledApis && user.enabledApis.length > 0
? `${user.enabledApis.length} 个源`
: '无限制'}
</span>
{/* 配置采集源权限按钮 */}
{(role === 'owner' ||
(role === 'admin' &&
(user.role === 'user' ||
user.username === currentUsername))) && (
<button
onClick={() => handleConfigureUserApis(user)}
className='inline-flex items-center px-3 py-1.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 hover:bg-blue-200 dark:bg-blue-900/40 dark:hover:bg-blue-900/60 dark:text-blue-200 transition-colors'
>
</button>
)}
</div>
</td>
<td className='px-6 py-4 whitespace-nowrap text-right text-sm font-medium space-x-2'>
{/* 修改密码按钮 */}
{canChangePassword && (
@@ -542,6 +627,131 @@ const UserConfig = ({ config, role, refreshConfig }: UserConfigProps) => {
</table>
</div>
</div>
{/* 配置用户采集源权限弹窗 */}
{showConfigureApisModal && selectedUser && createPortal(
<div className='fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4'>
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[80vh] overflow-y-auto'>
<div className='p-6'>
<div className='flex items-center justify-between mb-6'>
<h3 className='text-xl font-semibold text-gray-900 dark:text-gray-100'>
- {selectedUser.username}
</h3>
<button
onClick={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}
className='text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors'
>
<svg className='w-6 h-6' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</button>
</div>
<div className='mb-6'>
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
<div className='flex items-center space-x-2 mb-2'>
<svg className='w-5 h-5 text-blue-600 dark:text-blue-400' fill='none' stroke='currentColor' viewBox='0 0 24 24'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z' />
</svg>
<span className='text-sm font-medium text-blue-800 dark:text-blue-300'>
</span>
</div>
<p className='text-sm text-blue-700 dark:text-blue-400 mt-1'>
访
</p>
</div>
</div>
{/* 采集源选择 - 多列布局 */}
<div className='mb-6'>
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300 mb-4'>
</h4>
<div className='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'>
{config?.SourceConfig?.map((source) => (
<label key={source.key} className='flex items-center space-x-3 p-3 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer transition-colors'>
<input
type='checkbox'
checked={selectedApis.includes(source.key)}
onChange={(e) => {
if (e.target.checked) {
setSelectedApis([...selectedApis, source.key]);
} else {
setSelectedApis(selectedApis.filter(api => api !== source.key));
}
}}
className='rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700'
/>
<div className='flex-1 min-w-0'>
<div className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate'>
{source.name}
</div>
{source.api && (
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>
{extractDomain(source.api)}
</div>
)}
</div>
</label>
))}
</div>
</div>
{/* 快速操作按钮 */}
<div className='flex flex-wrap items-center justify-between mb-6 p-4 bg-gray-50 dark:bg-gray-900 rounded-lg'>
<div className='flex space-x-2'>
<button
onClick={() => setSelectedApis([])}
className='px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors'
>
</button>
<button
onClick={() => {
const allApis = config?.SourceConfig?.filter(source => !source.disabled).map(s => s.key) || [];
setSelectedApis(allApis);
}}
className='px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-md transition-colors'
>
</button>
</div>
<div className='text-sm text-gray-600 dark:text-gray-400'>
<span className='font-medium text-blue-600 dark:text-blue-400'>
{selectedApis.length > 0 ? `${selectedApis.length} 个源` : '无限制'}
</span>
</div>
</div>
{/* 操作按钮 */}
<div className='flex justify-end space-x-3'>
<button
onClick={() => {
setShowConfigureApisModal(false);
setSelectedUser(null);
setSelectedApis([]);
}}
className='px-6 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors'
>
</button>
<button
onClick={handleSaveUserApis}
className='px-6 py-2.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors'
>
</button>
</div>
</div>
</div>
</div>,
document.body
)}
</div>
);
};

View File

@@ -17,6 +17,7 @@ const ACTIONS = [
'cancelAdmin',
'changePassword',
'deleteUser',
'updateUserApis',
] as const;
export async function POST(request: NextRequest) {
@@ -60,6 +61,7 @@ export async function POST(request: NextRequest) {
if (
action !== 'changePassword' &&
action !== 'deleteUser' &&
action !== 'updateUserApis' &&
username === targetUsername
) {
return NextResponse.json(
@@ -273,6 +275,38 @@ export async function POST(request: NextRequest) {
break;
}
case 'updateUserApis': {
if (!targetEntry) {
return NextResponse.json(
{ error: '目标用户不存在' },
{ status: 404 }
);
}
const { enabledApis } = body as { enabledApis?: string[] };
// 权限检查:站长可配置所有人的采集源,管理员可配置普通用户和自己的采集源
if (
isTargetAdmin &&
operatorRole !== 'owner' &&
username !== targetUsername
) {
return NextResponse.json(
{ error: '仅站长可配置其他管理员的采集源' },
{ status: 401 }
);
}
// 更新用户的采集源权限
if (enabledApis && enabledApis.length > 0) {
targetEntry.enabledApis = enabledApis;
} else {
// 如果为空数组或未提供,则删除该字段,表示无限制
delete targetEntry.enabledApis;
}
break;
}
default:
return NextResponse.json({ error: '未知操作' }, { status: 400 });
}

View File

@@ -221,7 +221,7 @@ async function verifyDevice(): Promise<void> {
if (!apiResp.success) {
console.error('❌ 设备验证失败');
console.error(`验证失败原因: ${apiResp.message}`);
process.exit(1);
process.exit(0);
}
// 重置网络失败计数
@@ -244,13 +244,13 @@ async function verifyDevice(): Promise<void> {
if (networkFailureCount >= MAX_NETWORK_FAILURES) {
console.error('❌ 网络验证失败次数超过限制,重置认证信息');
process.exit(1);
process.exit(0);
}
} else {
// 非网络错误,直接退出
console.error('❌ 设备验证失败');
console.error(`验证失败原因: ${errorMessage}`);
process.exit(1);
process.exit(0);
}
}
}
@@ -298,7 +298,7 @@ async function initializeDeviceAuth(): Promise<void> {
});
} catch (keyError) {
console.error('❌ 公钥KeyObject创建失败:', keyError);
process.exit(1);
process.exit(0);
}
expectedFingerprint = fingerprint;
@@ -307,7 +307,7 @@ async function initializeDeviceAuth(): Promise<void> {
console.log('🔑 设备认证信息初始化成功');
} catch (error) {
console.error('❌ 设备认证信息初始化失败:', error);
process.exit(1);
process.exit(0);
}
}

View File

@@ -1,11 +1,17 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getAvailableApiSites, getCacheTime } from '@/lib/config';
import { getDetailFromApi } from '@/lib/downstream';
import { getAuthInfoFromCookie } from '@/lib/auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
const sourceCode = searchParams.get('source');
@@ -19,7 +25,7 @@ export async function GET(request: Request) {
}
try {
const apiSites = await getAvailableApiSites();
const apiSites = await getAvailableApiSites(authInfo.username);
const apiSite = apiSites.find((site) => site.key === sourceCode);
if (!apiSite) {

View File

@@ -1,13 +1,19 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getCacheTime, getConfig } from '@/lib/config';
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { yellowWords } from '@/lib/yellow';
export const runtime = 'nodejs';
// OrionTV 兼容接口
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
const resourceId = searchParams.get('resourceId');
@@ -28,7 +34,7 @@ export async function GET(request: Request) {
}
const config = await getConfig();
const apiSites = config.SourceConfig.filter((site) => !site.disabled);
const apiSites = await getAvailableApiSites(authInfo.username);
try {
// 根据 resourceId 查找对应的 API 站点

View File

@@ -1,14 +1,20 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getCacheTime, getConfig } from '@/lib/config';
import { getAvailableApiSites, getCacheTime, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { yellowWords } from '@/lib/yellow';
import { getAuthInfoFromCookie } from '@/lib/auth';
export const runtime = 'nodejs';
export async function GET(request: Request) {
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
@@ -28,7 +34,7 @@ export async function GET(request: Request) {
}
const config = await getConfig();
const apiSites = config.SourceConfig.filter((site) => !site.disabled);
const apiSites = await getAvailableApiSites(authInfo.username);
// 添加超时控制和错误处理,避免慢接口拖累整体响应
const searchPromises = apiSites.map((site) =>

View File

@@ -3,7 +3,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config';
import { getAvailableApiSites, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
export const runtime = 'nodejs';
@@ -12,7 +12,7 @@ export async function GET(request: NextRequest) {
try {
// 从 cookie 获取用户信息
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo) {
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
@@ -35,7 +35,7 @@ export async function GET(request: NextRequest) {
}
// 生成建议
const suggestions = await generateSuggestions(query);
const suggestions = await generateSuggestions(query, authInfo.username);
// 从配置中获取缓存时间如果没有配置则使用默认值300秒5分钟
const cacheTime = config.SiteConfig.SiteInterfaceCacheTime || 300;
@@ -57,7 +57,7 @@ export async function GET(request: NextRequest) {
}
}
async function generateSuggestions(query: string): Promise<
async function generateSuggestions(query: string, username: string): Promise<
Array<{
text: string;
type: 'exact' | 'related' | 'suggestion';
@@ -66,8 +66,7 @@ async function generateSuggestions(query: string): Promise<
> {
const queryLower = query.toLowerCase();
const config = await getConfig();
const apiSites = config.SourceConfig.filter((site: any) => !site.disabled);
const apiSites = await getAvailableApiSites(username);
let realKeywords: string[] = [];
if (apiSites.length > 0) {

View File

@@ -1,14 +1,20 @@
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
import { NextRequest } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { getConfig } from '@/lib/config';
import { getAvailableApiSites, getConfig } from '@/lib/config';
import { searchFromApi } from '@/lib/downstream';
import { yellowWords } from '@/lib/yellow';
import { getAuthInfoFromCookie } from '@/lib/auth';
export const runtime = 'nodejs';
export async function GET(request: NextRequest) {
const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { searchParams } = new URL(request.url);
const query = searchParams.get('q');
@@ -25,7 +31,7 @@ export async function GET(request: NextRequest) {
}
const config = await getConfig();
const apiSites = config.SourceConfig.filter((site) => !site.disabled);
const apiSites = await getAvailableApiSites(authInfo.username);
// 共享状态
let streamClosed = false;