feat: 为API Key添加模型限制功能

- 前端:在API Key创建和编辑表单中添加模型限制开关和标签输入
- 前端:支持动态添加/删除限制的模型列表
- 后端:更新API Key数据结构,新增enableModelRestriction和restrictedModels字段
- 后端:在中转请求时检查模型访问权限
- 修复:Enter键提交表单问题,使用@keydown.enter.prevent
- 优化:限制模型数据持久化,关闭开关时不清空数据

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
shaw
2025-07-19 20:54:26 +08:00
parent f9933f7061
commit f962083752
9 changed files with 278 additions and 22 deletions

View File

@@ -43,6 +43,8 @@ LOG_MAX_FILES=5
CLEANUP_INTERVAL=3600000 CLEANUP_INTERVAL=3600000
TOKEN_USAGE_RETENTION=2592000000 TOKEN_USAGE_RETENTION=2592000000
HEALTH_CHECK_INTERVAL=60000 HEALTH_CHECK_INTERVAL=60000
SYSTEM_TIMEZONE=Asia/Shanghai
TIMEZONE_OFFSET=8
# 🎨 Web 界面配置 # 🎨 Web 界面配置
WEB_TITLE=Claude Relay Service WEB_TITLE=Claude Relay Service

View File

@@ -62,7 +62,9 @@ const config = {
system: { system: {
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL) || 3600000, // 1小时 cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL) || 3600000, // 1小时
tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天 tokenUsageRetention: parseInt(process.env.TOKEN_USAGE_RETENTION) || 2592000000, // 30天
healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000 // 1分钟 healthCheckInterval: parseInt(process.env.HEALTH_CHECK_INTERVAL) || 60000, // 1分钟
timezone: process.env.SYSTEM_TIMEZONE || 'Asia/Shanghai', // 默认UTC+8中国时区
timezoneOffset: parseInt(process.env.TIMEZONE_OFFSET) || 8 // UTC偏移小时数默认+8
}, },
// 🎨 Web界面配置 // 🎨 Web界面配置

View File

@@ -109,7 +109,9 @@ const authenticateApiKey = async (req, res, next) => {
name: validation.keyData.name, name: validation.keyData.name,
tokenLimit: validation.keyData.tokenLimit, tokenLimit: validation.keyData.tokenLimit,
claudeAccountId: validation.keyData.claudeAccountId, claudeAccountId: validation.keyData.claudeAccountId,
concurrencyLimit: validation.keyData.concurrencyLimit concurrencyLimit: validation.keyData.concurrencyLimit,
enableModelRestriction: validation.keyData.enableModelRestriction,
restrictedModels: validation.keyData.restrictedModels
}; };
req.usage = validation.keyData.usage; req.usage = validation.keyData.usage;

View File

@@ -2,6 +2,26 @@ const Redis = require('ioredis');
const config = require('../../config/config'); const config = require('../../config/config');
const logger = require('../utils/logger'); const logger = require('../utils/logger');
// 时区辅助函数
function getDateInTimezone(date = new Date()) {
const offset = config.system.timezoneOffset || 8; // 默认UTC+8
const utcTime = date.getTime() + (date.getTimezoneOffset() * 60000);
const targetTime = new Date(utcTime + (offset * 3600000));
return targetTime;
}
// 获取配置时区的日期字符串 (YYYY-MM-DD)
function getDateStringInTimezone(date = new Date()) {
const tzDate = getDateInTimezone(date);
return `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}-${String(tzDate.getDate()).padStart(2, '0')}`;
}
// 获取配置时区的小时 (0-23)
function getHourInTimezone(date = new Date()) {
const tzDate = getDateInTimezone(date);
return tzDate.getHours();
}
class RedisClient { class RedisClient {
constructor() { constructor() {
this.client = null; this.client = null;
@@ -140,9 +160,10 @@ class RedisClient {
async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') { async incrementTokenUsage(keyId, tokens, inputTokens = 0, outputTokens = 0, cacheCreateTokens = 0, cacheReadTokens = 0, model = 'unknown') {
const key = `usage:${keyId}`; const key = `usage:${keyId}`;
const now = new Date(); const now = new Date();
const today = now.toISOString().split('T')[0]; const today = getDateStringInTimezone(now);
const currentMonth = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; const tzDate = getDateInTimezone(now);
const currentHour = `${today}:${String(now.getHours()).padStart(2, '0')}`; // 新增小时级别 const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
const currentHour = `${today}:${String(getHourInTimezone(now)).padStart(2, '0')}`; // 新增小时级别
const daily = `usage:daily:${keyId}:${today}`; const daily = `usage:daily:${keyId}:${today}`;
const monthly = `usage:monthly:${keyId}:${currentMonth}`; const monthly = `usage:monthly:${keyId}:${currentMonth}`;
@@ -263,9 +284,10 @@ class RedisClient {
async getUsageStats(keyId) { async getUsageStats(keyId) {
const totalKey = `usage:${keyId}`; const totalKey = `usage:${keyId}`;
const today = new Date().toISOString().split('T')[0]; const today = getDateStringInTimezone();
const dailyKey = `usage:daily:${keyId}:${today}`; const dailyKey = `usage:daily:${keyId}:${today}`;
const currentMonth = `${new Date().getFullYear()}-${String(new Date().getMonth() + 1).padStart(2, '0')}`; const tzDate = getDateInTimezone();
const currentMonth = `${tzDate.getFullYear()}-${String(tzDate.getMonth() + 1).padStart(2, '0')}`;
const monthlyKey = `usage:monthly:${keyId}:${currentMonth}`; const monthlyKey = `usage:monthly:${keyId}:${currentMonth}`;
const [total, daily, monthly] = await Promise.all([ const [total, daily, monthly] = await Promise.all([
@@ -534,7 +556,7 @@ class RedisClient {
// 📊 获取今日系统统计 // 📊 获取今日系统统计
async getTodayStats() { async getTodayStats() {
try { try {
const today = new Date().toISOString().split('T')[0]; const today = getDateStringInTimezone();
const dailyKeys = await this.client.keys(`usage:daily:*:${today}`); const dailyKeys = await this.client.keys(`usage:daily:*:${today}`);
let totalRequestsToday = 0; let totalRequestsToday = 0;

View File

@@ -32,7 +32,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
tokenLimit, tokenLimit,
expiresAt, expiresAt,
claudeAccountId, claudeAccountId,
concurrencyLimit concurrencyLimit,
enableModelRestriction,
restrictedModels
} = req.body; } = req.body;
// 输入验证 // 输入验证
@@ -57,13 +59,24 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' }); return res.status(400).json({ error: 'Concurrency limit must be a non-negative integer' });
} }
// 验证模型限制字段
if (enableModelRestriction !== undefined && typeof enableModelRestriction !== 'boolean') {
return res.status(400).json({ error: 'Enable model restriction must be a boolean' });
}
if (restrictedModels !== undefined && !Array.isArray(restrictedModels)) {
return res.status(400).json({ error: 'Restricted models must be an array' });
}
const newKey = await apiKeyService.generateApiKey({ const newKey = await apiKeyService.generateApiKey({
name, name,
description, description,
tokenLimit, tokenLimit,
expiresAt, expiresAt,
claudeAccountId, claudeAccountId,
concurrencyLimit concurrencyLimit,
enableModelRestriction,
restrictedModels
}); });
logger.success(`🔑 Admin created new API key: ${name}`); logger.success(`🔑 Admin created new API key: ${name}`);
@@ -78,9 +91,9 @@ router.post('/api-keys', authenticateAdmin, async (req, res) => {
router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => { router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
try { try {
const { keyId } = req.params; const { keyId } = req.params;
const { tokenLimit, concurrencyLimit, claudeAccountId } = req.body; const { tokenLimit, concurrencyLimit, claudeAccountId, enableModelRestriction, restrictedModels } = req.body;
// 只允许更新tokenLimit、concurrencyLimit和claudeAccountId // 只允许更新指定字段
const updates = {}; const updates = {};
if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') { if (tokenLimit !== undefined && tokenLimit !== null && tokenLimit !== '') {
@@ -102,6 +115,21 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
updates.claudeAccountId = claudeAccountId || ''; updates.claudeAccountId = claudeAccountId || '';
} }
// 处理模型限制字段
if (enableModelRestriction !== undefined) {
if (typeof enableModelRestriction !== 'boolean') {
return res.status(400).json({ error: 'Enable model restriction must be a boolean' });
}
updates.enableModelRestriction = enableModelRestriction;
}
if (restrictedModels !== undefined) {
if (!Array.isArray(restrictedModels)) {
return res.status(400).json({ error: 'Restricted models must be an array' });
}
updates.restrictedModels = restrictedModels;
}
await apiKeyService.updateApiKey(keyId, updates); await apiKeyService.updateApiKey(keyId, updates);
logger.success(`📝 Admin updated API key: ${keyId}`); logger.success(`📝 Admin updated API key: ${keyId}`);

View File

@@ -18,7 +18,9 @@ class ApiKeyService {
expiresAt = null, expiresAt = null,
claudeAccountId = null, claudeAccountId = null,
isActive = true, isActive = true,
concurrencyLimit = 0 concurrencyLimit = 0,
enableModelRestriction = false,
restrictedModels = []
} = options; } = options;
// 生成简单的API Key (64字符十六进制) // 生成简单的API Key (64字符十六进制)
@@ -35,6 +37,8 @@ class ApiKeyService {
concurrencyLimit: String(concurrencyLimit ?? 0), concurrencyLimit: String(concurrencyLimit ?? 0),
isActive: String(isActive), isActive: String(isActive),
claudeAccountId: claudeAccountId || '', claudeAccountId: claudeAccountId || '',
enableModelRestriction: String(enableModelRestriction),
restrictedModels: JSON.stringify(restrictedModels || []),
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
lastUsedAt: '', lastUsedAt: '',
expiresAt: expiresAt || '', expiresAt: expiresAt || '',
@@ -55,6 +59,8 @@ class ApiKeyService {
concurrencyLimit: parseInt(keyData.concurrencyLimit), concurrencyLimit: parseInt(keyData.concurrencyLimit),
isActive: keyData.isActive === 'true', isActive: keyData.isActive === 'true',
claudeAccountId: keyData.claudeAccountId, claudeAccountId: keyData.claudeAccountId,
enableModelRestriction: keyData.enableModelRestriction === 'true',
restrictedModels: JSON.parse(keyData.restrictedModels),
createdAt: keyData.createdAt, createdAt: keyData.createdAt,
expiresAt: keyData.expiresAt, expiresAt: keyData.expiresAt,
createdBy: keyData.createdBy createdBy: keyData.createdBy
@@ -131,6 +137,12 @@ class ApiKeyService {
key.concurrencyLimit = parseInt(key.concurrencyLimit || 0); key.concurrencyLimit = parseInt(key.concurrencyLimit || 0);
key.currentConcurrency = await redis.getConcurrency(key.id); key.currentConcurrency = await redis.getConcurrency(key.id);
key.isActive = key.isActive === 'true'; key.isActive = key.isActive === 'true';
key.enableModelRestriction = key.enableModelRestriction === 'true';
try {
key.restrictedModels = key.restrictedModels ? JSON.parse(key.restrictedModels) : [];
} catch (e) {
key.restrictedModels = [];
}
delete key.apiKey; // 不返回哈希后的key delete key.apiKey; // 不返回哈希后的key
} }
@@ -150,12 +162,20 @@ class ApiKeyService {
} }
// 允许更新的字段 // 允许更新的字段
const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'isActive', 'claudeAccountId', 'expiresAt']; const allowedUpdates = ['name', 'description', 'tokenLimit', 'concurrencyLimit', 'isActive', 'claudeAccountId', 'expiresAt', 'enableModelRestriction', 'restrictedModels'];
const updatedData = { ...keyData }; const updatedData = { ...keyData };
for (const [field, value] of Object.entries(updates)) { for (const [field, value] of Object.entries(updates)) {
if (allowedUpdates.includes(field)) { if (allowedUpdates.includes(field)) {
updatedData[field] = (value != null ? value : '').toString(); if (field === 'restrictedModels') {
// 特殊处理 restrictedModels 数组
updatedData[field] = JSON.stringify(value || []);
} else if (field === 'enableModelRestriction') {
// 布尔值转字符串
updatedData[field] = String(value);
} else {
updatedData[field] = (value != null ? value : '').toString();
}
} }
} }

View File

@@ -22,6 +22,24 @@ class ClaudeRelayService {
let upstreamRequest = null; let upstreamRequest = null;
try { try {
// 检查模型限制
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels && apiKeyData.restrictedModels.length > 0) {
const requestedModel = requestBody.model;
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
logger.warn(`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`);
return {
statusCode: 403,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
error: {
type: 'forbidden',
message: '暂无该模型访问权限'
}
})
};
}
}
// 生成会话哈希用于sticky会话 // 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(requestBody); const sessionHash = sessionHelper.generateSessionHash(requestBody);
@@ -419,6 +437,26 @@ class ClaudeRelayService {
// 🌊 处理流式响应带usage数据捕获 // 🌊 处理流式响应带usage数据捕获
async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback) { async relayStreamRequestWithUsageCapture(requestBody, apiKeyData, responseStream, clientHeaders, usageCallback) {
try { try {
// 检查模型限制
if (apiKeyData.enableModelRestriction && apiKeyData.restrictedModels && apiKeyData.restrictedModels.length > 0) {
const requestedModel = requestBody.model;
if (requestedModel && apiKeyData.restrictedModels.includes(requestedModel)) {
logger.warn(`🚫 Model restriction violation for key ${apiKeyData.name}: Attempted to use restricted model ${requestedModel}`);
// 对于流式响应,需要写入错误并结束流
const errorResponse = JSON.stringify({
error: {
type: 'forbidden',
message: '暂无该模型访问权限'
}
});
responseStream.writeHead(403, { 'Content-Type': 'application/json' });
responseStream.end(errorResponse);
return;
}
}
// 生成会话哈希用于sticky会话 // 生成会话哈希用于sticky会话
const sessionHash = sessionHelper.generateSessionHash(requestBody); const sessionHash = sessionHelper.generateSessionHash(requestBody);

View File

@@ -117,7 +117,10 @@ const app = createApp({
tokenLimit: '', tokenLimit: '',
description: '', description: '',
concurrencyLimit: '', concurrencyLimit: '',
claudeAccountId: '' claudeAccountId: '',
enableModelRestriction: false,
restrictedModels: [],
modelInput: ''
}, },
apiKeyModelStats: {}, // 存储每个key的模型统计数据 apiKeyModelStats: {}, // 存储每个key的模型统计数据
expandedApiKeys: {}, // 跟踪展开的API Keys expandedApiKeys: {}, // 跟踪展开的API Keys
@@ -155,7 +158,10 @@ const app = createApp({
name: '', name: '',
tokenLimit: '', tokenLimit: '',
concurrencyLimit: '', concurrencyLimit: '',
claudeAccountId: '' claudeAccountId: '',
enableModelRestriction: false,
restrictedModels: [],
modelInput: ''
}, },
// 账户 // 账户
@@ -313,6 +319,20 @@ const app = createApp({
return this.apiKeys.filter(key => key.claudeAccountId === accountId).length; return this.apiKeys.filter(key => key.claudeAccountId === accountId).length;
}, },
// 添加限制模型
addRestrictedModel(form) {
const model = form.modelInput.trim();
if (model && !form.restrictedModels.includes(model)) {
form.restrictedModels.push(model);
form.modelInput = '';
}
},
// 移除限制模型
removeRestrictedModel(form, index) {
form.restrictedModels.splice(index, 1);
},
// Toast 通知方法 // Toast 通知方法
showToast(message, type = 'info', title = null, duration = 5000) { showToast(message, type = 'info', title = null, duration = 5000) {
const id = ++this.toastIdCounter; const id = ++this.toastIdCounter;
@@ -1191,7 +1211,9 @@ const app = createApp({
tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null, tokenLimit: this.apiKeyForm.tokenLimit && this.apiKeyForm.tokenLimit.trim() ? parseInt(this.apiKeyForm.tokenLimit) : null,
description: this.apiKeyForm.description || '', description: this.apiKeyForm.description || '',
concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0, concurrencyLimit: this.apiKeyForm.concurrencyLimit && this.apiKeyForm.concurrencyLimit.trim() ? parseInt(this.apiKeyForm.concurrencyLimit) : 0,
claudeAccountId: this.apiKeyForm.claudeAccountId || null claudeAccountId: this.apiKeyForm.claudeAccountId || null,
enableModelRestriction: this.apiKeyForm.enableModelRestriction,
restrictedModels: this.apiKeyForm.restrictedModels
}) })
}); });
@@ -1209,7 +1231,7 @@ const app = createApp({
// 关闭创建弹窗并清理表单 // 关闭创建弹窗并清理表单
this.showCreateApiKeyModal = false; this.showCreateApiKeyModal = false;
this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', claudeAccountId: '' }; this.apiKeyForm = { name: '', tokenLimit: '', description: '', concurrencyLimit: '', claudeAccountId: '', enableModelRestriction: false, restrictedModels: [], modelInput: '' };
// 重新加载API Keys列表 // 重新加载API Keys列表
await this.loadApiKeys(); await this.loadApiKeys();
@@ -1253,7 +1275,10 @@ const app = createApp({
name: key.name, name: key.name,
tokenLimit: key.tokenLimit || '', tokenLimit: key.tokenLimit || '',
concurrencyLimit: key.concurrencyLimit || '', concurrencyLimit: key.concurrencyLimit || '',
claudeAccountId: key.claudeAccountId || '' claudeAccountId: key.claudeAccountId || '',
enableModelRestriction: key.enableModelRestriction || false,
restrictedModels: key.restrictedModels ? [...key.restrictedModels] : [],
modelInput: ''
}; };
this.showEditApiKeyModal = true; this.showEditApiKeyModal = true;
}, },
@@ -1265,7 +1290,10 @@ const app = createApp({
name: '', name: '',
tokenLimit: '', tokenLimit: '',
concurrencyLimit: '', concurrencyLimit: '',
claudeAccountId: '' claudeAccountId: '',
enableModelRestriction: false,
restrictedModels: [],
modelInput: ''
}; };
}, },
@@ -1281,7 +1309,9 @@ const app = createApp({
body: JSON.stringify({ body: JSON.stringify({
tokenLimit: this.editApiKeyForm.tokenLimit && this.editApiKeyForm.tokenLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.tokenLimit) : 0, tokenLimit: this.editApiKeyForm.tokenLimit && this.editApiKeyForm.tokenLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.tokenLimit) : 0,
concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0, concurrencyLimit: this.editApiKeyForm.concurrencyLimit && this.editApiKeyForm.concurrencyLimit.toString().trim() !== '' ? parseInt(this.editApiKeyForm.concurrencyLimit) : 0,
claudeAccountId: this.editApiKeyForm.claudeAccountId || null claudeAccountId: this.editApiKeyForm.claudeAccountId || null,
enableModelRestriction: this.editApiKeyForm.enableModelRestriction,
restrictedModels: this.editApiKeyForm.restrictedModels
}) })
}); });

View File

@@ -1842,6 +1842,62 @@
<p class="text-xs text-gray-500 mt-2">选择专属账号后此API Key将只使用该账号不选择则使用共享账号池</p> <p class="text-xs text-gray-500 mt-2">选择专属账号后此API Key将只使用该账号不选择则使用共享账号池</p>
</div> </div>
<div>
<div class="flex items-center mb-3">
<input
type="checkbox"
v-model="apiKeyForm.enableModelRestriction"
id="enableModelRestriction"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="enableModelRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
启用模型限制
</label>
</div>
<div v-if="apiKeyForm.enableModelRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
<span
v-for="(model, index) in apiKeyForm.restrictedModels"
:key="index"
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
>
{{ model }}
<button
type="button"
@click="removeRestrictedModel(apiKeyForm, index)"
class="ml-2 text-red-600 hover:text-red-800"
>
<i class="fas fa-times text-xs"></i>
</button>
</span>
<span v-if="apiKeyForm.restrictedModels.length === 0" class="text-gray-400 text-sm">
暂无限制的模型
</span>
</div>
<div class="flex gap-2">
<input
v-model="apiKeyForm.modelInput"
@keydown.enter.prevent="addRestrictedModel(apiKeyForm)"
type="text"
placeholder="输入模型名称,按回车添加"
class="form-input flex-1"
>
<button
type="button"
@click="addRestrictedModel(apiKeyForm)"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
<i class="fas fa-plus"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-2">设置此API Key无法访问的模型例如claude-opus-4-20250514</p>
</div>
</div>
</div>
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
type="button" type="button"
@@ -1936,6 +1992,62 @@
<p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p> <p class="text-xs text-gray-500 mt-2">修改绑定账号将影响此API Key的请求路由</p>
</div> </div>
<div>
<div class="flex items-center mb-3">
<input
type="checkbox"
v-model="editApiKeyForm.enableModelRestriction"
id="editEnableModelRestriction"
class="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
>
<label for="editEnableModelRestriction" class="ml-2 text-sm font-semibold text-gray-700 cursor-pointer">
启用模型限制
</label>
</div>
<div v-if="editApiKeyForm.enableModelRestriction" class="space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-2">限制的模型列表</label>
<div class="flex flex-wrap gap-2 mb-3 min-h-[32px] p-2 bg-gray-50 rounded-lg border border-gray-200">
<span
v-for="(model, index) in editApiKeyForm.restrictedModels"
:key="index"
class="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800"
>
{{ model }}
<button
type="button"
@click="removeRestrictedModel(editApiKeyForm, index)"
class="ml-2 text-red-600 hover:text-red-800"
>
<i class="fas fa-times text-xs"></i>
</button>
</span>
<span v-if="editApiKeyForm.restrictedModels.length === 0" class="text-gray-400 text-sm">
暂无限制的模型
</span>
</div>
<div class="flex gap-2">
<input
v-model="editApiKeyForm.modelInput"
@keydown.enter.prevent="addRestrictedModel(editApiKeyForm)"
type="text"
placeholder="输入模型名称,按回车添加"
class="form-input flex-1"
>
<button
type="button"
@click="addRestrictedModel(editApiKeyForm)"
class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
>
<i class="fas fa-plus"></i>
</button>
</div>
<p class="text-xs text-gray-500 mt-2">设置此API Key无法访问的模型例如claude-opus-4-20250514</p>
</div>
</div>
</div>
<div class="flex gap-3 pt-4"> <div class="flex gap-3 pt-4">
<button <button
type="button" type="button"