mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-22 16:43:35 +00:00
feat: 完善 API Keys 批量删除功能并修复搜索跨选择问题
## 主要改进 ### 🔧 核心修复 - 修复搜索时勾选状态无法保存的问题 - 优化全选/取消全选逻辑,支持跨搜索结果保持选择状态 - 改进批量删除的用户体验 - 添加 Unicode 字符处理中间件,提升请求体解析稳定性 ### 🎯 具体变更 - **路由修复**: 解决批量删除路由匹配问题,调整路由顺序 - **API客户端**: 修复 DELETE 方法支持请求体数据传输 - **前端逻辑**: 分离筛选和搜索的监听器,搜索时保持已选中状态 - **全选优化**: 取消全选时只移除当前页选中项,保留其他页面选择 - **Unicode处理**: 添加无效 UTF-16 代理对清理和错误处理机制 - **配置管理**: 将 .mcp.json 添加到 .gitignore,避免本地配置被提交 ### 🚀 用户体验提升 - 支持跨搜索结果批量选择和删除 - 批量删除按钮显示选中数量 - 智能的全选状态管理 - 更好的 Unicode 字符处理容错性 ### 🧪 测试验证 - 验证搜索切换时选择状态保持 - 确认批量删除功能正常工作 - 检查 Redis 数据清理完整性 - 测试 Unicode 字符处理稳定性 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,6 +13,9 @@ pnpm-debug.log*
|
|||||||
# Claude specific directories
|
# Claude specific directories
|
||||||
.claude/
|
.claude/
|
||||||
|
|
||||||
|
# MCP configuration (local only)
|
||||||
|
.mcp.json
|
||||||
|
|
||||||
# Data directory (contains sensitive information)
|
# Data directory (contains sensitive information)
|
||||||
data/
|
data/
|
||||||
!data/.gitkeep
|
!data/.gitkeep
|
||||||
|
|||||||
14
.mcp.json
14
.mcp.json
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"spec-workflow": {
|
|
||||||
"type": "stdio",
|
|
||||||
"command": "npx",
|
|
||||||
"args": [
|
|
||||||
"-y",
|
|
||||||
"@pimzino/spec-workflow-mcp@latest",
|
|
||||||
"/Users/weidian/project/claude-relay-service"
|
|
||||||
],
|
|
||||||
"env": {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
52
src/app.js
52
src/app.js
@@ -141,10 +141,62 @@ class Application {
|
|||||||
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
if (buf && buf.length && !buf.toString(encoding || 'utf8').trim()) {
|
||||||
throw new Error('Invalid JSON: empty body')
|
throw new Error('Invalid JSON: empty body')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unicode 字符清理 - 清理无效的 UTF-16 代理对
|
||||||
|
if (buf && buf.length) {
|
||||||
|
try {
|
||||||
|
const str = buf.toString(encoding || 'utf8')
|
||||||
|
// 移除无效的 UTF-16 代理对字符
|
||||||
|
const cleanedStr = str.replace(
|
||||||
|
/[\uDC00-\uDFFF](?![\uD800-\uDBFF])|[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g,
|
||||||
|
'\uFFFD'
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果字符串被清理过,重新写入buffer
|
||||||
|
if (cleanedStr !== str) {
|
||||||
|
logger.warn('🧹 Cleaned invalid Unicode characters from request body')
|
||||||
|
const cleanedBuf = Buffer.from(cleanedStr, encoding || 'utf8')
|
||||||
|
// 将清理后的内容复制回原buffer
|
||||||
|
cleanedBuf.copy(buf, 0)
|
||||||
|
// 调整buffer长度
|
||||||
|
buf._charsWritten = cleanedBuf.length
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(
|
||||||
|
'⚠️ Unicode cleaning failed, proceeding with original buffer:',
|
||||||
|
error.message
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }))
|
||||||
|
|
||||||
|
// 🧹 Unicode 错误处理中间件
|
||||||
|
this.app.use((err, req, res, next) => {
|
||||||
|
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||||
|
// 检查是否是Unicode相关的JSON解析错误
|
||||||
|
if (
|
||||||
|
err.message.includes('surrogate') ||
|
||||||
|
err.message.includes('UTF-16') ||
|
||||||
|
err.message.includes('invalid character')
|
||||||
|
) {
|
||||||
|
logger.warn('🧹 Detected Unicode JSON parsing error, attempting recovery:', err.message)
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
type: 'error',
|
||||||
|
error: {
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
message:
|
||||||
|
'The request body contains invalid Unicode characters. Please ensure your text uses valid UTF-8 encoding and does not contain malformed surrogate pairs.'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next(err)
|
||||||
|
})
|
||||||
|
|
||||||
this.app.use(securityMiddleware)
|
this.app.use(securityMiddleware)
|
||||||
|
|
||||||
// 🎯 信任代理
|
// 🎯 信任代理
|
||||||
|
|||||||
@@ -799,28 +799,20 @@ router.put('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 删除API Key
|
// 批量删除API Keys(必须在 :keyId 路由之前定义)
|
||||||
router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { keyId } = req.params
|
|
||||||
|
|
||||||
await apiKeyService.deleteApiKey(keyId)
|
|
||||||
|
|
||||||
logger.success(`🗑️ Admin deleted API key: ${keyId}`)
|
|
||||||
return res.json({ success: true, message: 'API key deleted successfully' })
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('❌ Failed to delete API key:', error)
|
|
||||||
return res.status(500).json({ error: 'Failed to delete API key', message: error.message })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// 批量删除API Keys
|
|
||||||
router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { keyIds } = req.body
|
const { keyIds } = req.body
|
||||||
|
|
||||||
|
// 调试信息
|
||||||
|
logger.info(`🐛 Batch delete request body: ${JSON.stringify(req.body)}`)
|
||||||
|
logger.info(`🐛 keyIds type: ${typeof keyIds}, value: ${JSON.stringify(keyIds)}`)
|
||||||
|
|
||||||
// 参数验证
|
// 参数验证
|
||||||
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
|
if (!keyIds || !Array.isArray(keyIds) || keyIds.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
`🚨 Invalid keyIds: ${JSON.stringify({ keyIds, type: typeof keyIds, isArray: Array.isArray(keyIds) })}`
|
||||||
|
)
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
error: 'Invalid request',
|
error: 'Invalid request',
|
||||||
message: 'keyIds 必须是一个非空数组'
|
message: 'keyIds 必须是一个非空数组'
|
||||||
@@ -843,7 +835,9 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`🗑️ Admin attempting batch delete of ${keyIds.length} API keys`)
|
logger.info(
|
||||||
|
`🗑️ Admin attempting batch delete of ${keyIds.length} API keys: ${JSON.stringify(keyIds)}`
|
||||||
|
)
|
||||||
|
|
||||||
const results = {
|
const results = {
|
||||||
successCount: 0,
|
successCount: 0,
|
||||||
@@ -855,8 +849,8 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
for (const keyId of keyIds) {
|
for (const keyId of keyIds) {
|
||||||
try {
|
try {
|
||||||
// 检查API Key是否存在
|
// 检查API Key是否存在
|
||||||
const apiKey = await apiKeyService.getApiKey(keyId)
|
const apiKey = await redis.getApiKey(keyId)
|
||||||
if (!apiKey) {
|
if (!apiKey || Object.keys(apiKey).length === 0) {
|
||||||
results.failedCount++
|
results.failedCount++
|
||||||
results.errors.push({ keyId, error: 'API Key 不存在' })
|
results.errors.push({ keyId, error: 'API Key 不存在' })
|
||||||
continue
|
continue
|
||||||
@@ -903,6 +897,21 @@ router.delete('/api-keys/batch', authenticateAdmin, async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 删除单个API Key(必须在批量删除路由之后定义)
|
||||||
|
router.delete('/api-keys/:keyId', authenticateAdmin, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { keyId } = req.params
|
||||||
|
|
||||||
|
await apiKeyService.deleteApiKey(keyId)
|
||||||
|
|
||||||
|
logger.success(`🗑️ Admin deleted API key: ${keyId}`)
|
||||||
|
return res.json({ success: true, message: 'API key deleted successfully' })
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('❌ Failed to delete API key:', error)
|
||||||
|
return res.status(500).json({ error: 'Failed to delete API key', message: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 👥 账户分组管理
|
// 👥 账户分组管理
|
||||||
|
|
||||||
// 创建账户分组
|
// 创建账户分组
|
||||||
|
|||||||
@@ -12,11 +12,52 @@ const sessionHelper = require('../utils/sessionHelper')
|
|||||||
|
|
||||||
const router = express.Router()
|
const router = express.Router()
|
||||||
|
|
||||||
|
// 🧹 Unicode 字符清理函数
|
||||||
|
function cleanUnicodeString(str) {
|
||||||
|
if (typeof str !== 'string') {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除无效的 UTF-16 代理对字符
|
||||||
|
// 匹配无效的低代理字符 (0xDC00-0xDFFF) 没有对应的高代理字符
|
||||||
|
// 匹配无效的高代理字符 (0xD800-0xDBFF) 没有对应的低代理字符
|
||||||
|
return str.replace(
|
||||||
|
/[\uDC00-\uDFFF](?![\uD800-\uDBFF])|[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g,
|
||||||
|
'\uFFFD'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🧹 递归清理对象中的 Unicode 字符
|
||||||
|
function cleanUnicodeInObject(obj) {
|
||||||
|
if (typeof obj === 'string') {
|
||||||
|
return cleanUnicodeString(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(obj)) {
|
||||||
|
return obj.map((item) => cleanUnicodeInObject(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (obj && typeof obj === 'object') {
|
||||||
|
const cleaned = {}
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
cleaned[cleanUnicodeString(key)] = cleanUnicodeInObject(value)
|
||||||
|
}
|
||||||
|
return cleaned
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
// 🔧 共享的消息处理函数
|
// 🔧 共享的消息处理函数
|
||||||
async function handleMessagesRequest(req, res) {
|
async function handleMessagesRequest(req, res) {
|
||||||
try {
|
try {
|
||||||
const startTime = Date.now()
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Unicode 字符清理 - 在输入验证之前清理请求体
|
||||||
|
if (req.body) {
|
||||||
|
req.body = cleanUnicodeInObject(req.body)
|
||||||
|
}
|
||||||
|
|
||||||
// 严格的输入验证
|
// 严格的输入验证
|
||||||
if (!req.body || typeof req.body !== 'object') {
|
if (!req.body || typeof req.body !== 'object') {
|
||||||
return res.status(400).json({
|
return res.status(400).json({
|
||||||
|
|||||||
@@ -152,9 +152,12 @@ class ApiClient {
|
|||||||
// DELETE 请求
|
// DELETE 请求
|
||||||
async delete(url, options = {}) {
|
async delete(url, options = {}) {
|
||||||
const fullUrl = createApiUrl(url)
|
const fullUrl = createApiUrl(url)
|
||||||
|
const { data, ...restOptions } = options
|
||||||
|
|
||||||
const config = this.buildConfig({
|
const config = this.buildConfig({
|
||||||
...options,
|
...restOptions,
|
||||||
method: 'DELETE'
|
method: 'DELETE',
|
||||||
|
body: data ? JSON.stringify(data) : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -88,6 +88,19 @@
|
|||||||
/>
|
/>
|
||||||
<span class="relative">刷新</span>
|
<span class="relative">刷新</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- 批量删除按钮 -->
|
||||||
|
<button
|
||||||
|
v-if="selectedApiKeys.length > 0"
|
||||||
|
class="group relative flex items-center justify-center gap-2 rounded-lg border border-red-200 bg-red-50 px-4 py-2 text-sm font-medium text-red-700 shadow-sm transition-all duration-200 hover:border-red-300 hover:bg-red-100 hover:shadow-md sm:w-auto"
|
||||||
|
@click="batchDeleteApiKeys()"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-red-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||||
|
></div>
|
||||||
|
<i class="fas fa-trash relative text-red-600" />
|
||||||
|
<span class="relative">删除选中 ({{ selectedApiKeys.length }})</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- 创建按钮 -->
|
<!-- 创建按钮 -->
|
||||||
<button
|
<button
|
||||||
@@ -120,6 +133,17 @@
|
|||||||
<table class="w-full table-fixed">
|
<table class="w-full table-fixed">
|
||||||
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
<thead class="bg-gray-50/80 backdrop-blur-sm">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="w-[50px] px-3 py-4 text-left">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="selectAllChecked"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
:indeterminate="isIndeterminate"
|
||||||
|
type="checkbox"
|
||||||
|
@change="handleSelectAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
<th
|
<th
|
||||||
class="w-[25%] min-w-[200px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100"
|
class="w-[25%] min-w-[200px] cursor-pointer px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 hover:bg-gray-100"
|
||||||
@click="sortApiKeys('name')"
|
@click="sortApiKeys('name')"
|
||||||
@@ -216,6 +240,17 @@
|
|||||||
<template v-for="key in paginatedApiKeys" :key="key.id">
|
<template v-for="key in paginatedApiKeys" :key="key.id">
|
||||||
<!-- API Key 主行 -->
|
<!-- API Key 主行 -->
|
||||||
<tr class="table-row">
|
<tr class="table-row">
|
||||||
|
<td class="px-3 py-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<input
|
||||||
|
v-model="selectedApiKeys"
|
||||||
|
class="mr-3 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
type="checkbox"
|
||||||
|
:value="key.id"
|
||||||
|
@change="updateSelectAllState"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td class="px-3 py-4">
|
<td class="px-3 py-4">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div
|
<div
|
||||||
@@ -505,7 +540,7 @@
|
|||||||
|
|
||||||
<!-- 模型统计展开区域 -->
|
<!-- 模型统计展开区域 -->
|
||||||
<tr v-if="key && key.id && expandedApiKeys[key.id]">
|
<tr v-if="key && key.id && expandedApiKeys[key.id]">
|
||||||
<td class="bg-gray-50 px-3 py-4" colspan="7">
|
<td class="bg-gray-50 px-3 py-4" colspan="8">
|
||||||
<div v-if="!apiKeyModelStats[key.id]" class="py-4 text-center">
|
<div v-if="!apiKeyModelStats[key.id]" class="py-4 text-center">
|
||||||
<div class="loading-spinner mx-auto" />
|
<div class="loading-spinner mx-auto" />
|
||||||
<p class="mt-2 text-sm text-gray-500">加载模型统计...</p>
|
<p class="mt-2 text-sm text-gray-500">加载模型统计...</p>
|
||||||
@@ -748,6 +783,13 @@
|
|||||||
<!-- 卡片头部 -->
|
<!-- 卡片头部 -->
|
||||||
<div class="mb-3 flex items-start justify-between">
|
<div class="mb-3 flex items-start justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
v-model="selectedApiKeys"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
type="checkbox"
|
||||||
|
:value="key.id"
|
||||||
|
@change="updateSelectAllState"
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"
|
class="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-blue-500 to-blue-600"
|
||||||
>
|
>
|
||||||
@@ -1152,6 +1194,11 @@ import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
|||||||
// 响应式数据
|
// 响应式数据
|
||||||
const clientsStore = useClientsStore()
|
const clientsStore = useClientsStore()
|
||||||
const apiKeys = ref([])
|
const apiKeys = ref([])
|
||||||
|
|
||||||
|
// 多选相关状态
|
||||||
|
const selectedApiKeys = ref([])
|
||||||
|
const selectAllChecked = ref(false)
|
||||||
|
const isIndeterminate = ref(false)
|
||||||
const apiKeysLoading = ref(false)
|
const apiKeysLoading = ref(false)
|
||||||
const apiKeyStatsTimeRange = ref('today')
|
const apiKeyStatsTimeRange = ref('today')
|
||||||
const apiKeysSortBy = ref('')
|
const apiKeysSortBy = ref('')
|
||||||
@@ -1831,6 +1878,12 @@ const deleteApiKey = async (keyId) => {
|
|||||||
const data = await apiClient.delete(`/admin/api-keys/${keyId}`)
|
const data = await apiClient.delete(`/admin/api-keys/${keyId}`)
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
showToast('API Key 已删除', 'success')
|
showToast('API Key 已删除', 'success')
|
||||||
|
// 从选中列表中移除
|
||||||
|
const index = selectedApiKeys.value.indexOf(keyId)
|
||||||
|
if (index > -1) {
|
||||||
|
selectedApiKeys.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
updateSelectAllState()
|
||||||
loadApiKeys()
|
loadApiKeys()
|
||||||
} else {
|
} else {
|
||||||
showToast(data.message || '删除失败', 'error')
|
showToast(data.message || '删除失败', 'error')
|
||||||
@@ -1840,6 +1893,96 @@ const deleteApiKey = async (keyId) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量删除API Keys
|
||||||
|
const batchDeleteApiKeys = async () => {
|
||||||
|
const selectedCount = selectedApiKeys.value.length
|
||||||
|
if (selectedCount === 0) {
|
||||||
|
showToast('请先选择要删除的 API Keys', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmed = false
|
||||||
|
const message = `确定要删除选中的 ${selectedCount} 个 API Key 吗?此操作不可恢复。`
|
||||||
|
|
||||||
|
if (window.showConfirm) {
|
||||||
|
confirmed = await window.showConfirm('批量删除 API Keys', message, '确定删除', '取消')
|
||||||
|
} else {
|
||||||
|
confirmed = confirm(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
const keyIds = [...selectedApiKeys.value]
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await apiClient.delete('/admin/api-keys/batch', {
|
||||||
|
data: { keyIds }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
const { successCount, failedCount, errors } = data.data
|
||||||
|
|
||||||
|
if (successCount > 0) {
|
||||||
|
showToast(`成功删除 ${successCount} 个 API Keys`, 'success')
|
||||||
|
|
||||||
|
// 如果有失败的,显示详细信息
|
||||||
|
if (failedCount > 0) {
|
||||||
|
const errorMessages = errors.map((e) => `${e.keyId}: ${e.error}`).join('\n')
|
||||||
|
showToast(`${failedCount} 个删除失败:\n${errorMessages}`, 'warning')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('所有 API Keys 删除失败', 'error')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空选中状态
|
||||||
|
selectedApiKeys.value = []
|
||||||
|
updateSelectAllState()
|
||||||
|
loadApiKeys()
|
||||||
|
} else {
|
||||||
|
showToast(data.message || '批量删除失败', 'error')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('批量删除失败', 'error')
|
||||||
|
console.error('批量删除 API Keys 失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理全选/取消全选
|
||||||
|
const handleSelectAll = () => {
|
||||||
|
if (selectAllChecked.value) {
|
||||||
|
// 全选当前页的所有API Keys
|
||||||
|
paginatedApiKeys.value.forEach((key) => {
|
||||||
|
if (!selectedApiKeys.value.includes(key.id)) {
|
||||||
|
selectedApiKeys.value.push(key.id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// 取消全选:只移除当前页的选中项,保留其他页面的选中项
|
||||||
|
const currentPageIds = new Set(paginatedApiKeys.value.map((key) => key.id))
|
||||||
|
selectedApiKeys.value = selectedApiKeys.value.filter((id) => !currentPageIds.has(id))
|
||||||
|
}
|
||||||
|
updateSelectAllState()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新全选状态
|
||||||
|
const updateSelectAllState = () => {
|
||||||
|
const totalInCurrentPage = paginatedApiKeys.value.length
|
||||||
|
const selectedInCurrentPage = paginatedApiKeys.value.filter((key) =>
|
||||||
|
selectedApiKeys.value.includes(key.id)
|
||||||
|
).length
|
||||||
|
|
||||||
|
if (selectedInCurrentPage === 0) {
|
||||||
|
selectAllChecked.value = false
|
||||||
|
isIndeterminate.value = false
|
||||||
|
} else if (selectedInCurrentPage === totalInCurrentPage) {
|
||||||
|
selectAllChecked.value = true
|
||||||
|
isIndeterminate.value = false
|
||||||
|
} else {
|
||||||
|
selectAllChecked.value = false
|
||||||
|
isIndeterminate.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 复制API统计页面链接
|
// 复制API统计页面链接
|
||||||
const copyApiStatsLink = (apiKey) => {
|
const copyApiStatsLink = (apiKey) => {
|
||||||
// 构建统计页面的完整URL
|
// 构建统计页面的完整URL
|
||||||
@@ -1980,14 +2123,43 @@ const clearSearch = () => {
|
|||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听筛选条件变化,重置页码
|
// 监听筛选条件变化,重置页码和选中状态
|
||||||
watch([selectedTagFilter, apiKeyStatsTimeRange, searchKeyword], () => {
|
// 监听筛选条件变化(不包括搜索),清空选中状态
|
||||||
|
watch([selectedTagFilter, apiKeyStatsTimeRange], () => {
|
||||||
currentPage.value = 1
|
currentPage.value = 1
|
||||||
|
// 清空选中状态
|
||||||
|
selectedApiKeys.value = []
|
||||||
|
updateSelectAllState()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听搜索关键词变化,只重置分页,保持选中状态
|
||||||
|
watch(searchKeyword, () => {
|
||||||
|
currentPage.value = 1
|
||||||
|
// 不清空选中状态,允许跨搜索保持勾选
|
||||||
|
updateSelectAllState()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听分页变化,更新全选状态
|
||||||
|
watch([currentPage, pageSize], () => {
|
||||||
|
updateSelectAllState()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听API Keys数据变化,清理无效的选中状态
|
||||||
|
watch(apiKeys, () => {
|
||||||
|
const validIds = new Set(apiKeys.value.map((key) => key.id))
|
||||||
|
|
||||||
|
// 过滤出仍然有效的选中项
|
||||||
|
selectedApiKeys.value = selectedApiKeys.value.filter((id) => validIds.has(id))
|
||||||
|
|
||||||
|
updateSelectAllState()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 并行加载所有需要的数据
|
// 并行加载所有需要的数据
|
||||||
await Promise.all([clientsStore.loadSupportedClients(), loadAccounts(), loadApiKeys()])
|
await Promise.all([clientsStore.loadSupportedClients(), loadAccounts(), loadApiKeys()])
|
||||||
|
|
||||||
|
// 初始化全选状态
|
||||||
|
updateSelectAllState()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user