fix(frontend): 修复前端审计问题并补充回归测试
This commit is contained in:
@@ -759,8 +759,8 @@
|
||||
<!-- 路由规则列表(仅在启用时显示) -->
|
||||
<div v-if="createForm.model_routing_enabled" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in createModelRoutingRules"
|
||||
:key="index"
|
||||
v-for="rule in createModelRoutingRules"
|
||||
:key="getCreateRuleRenderKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -786,7 +786,7 @@
|
||||
{{ account.name }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeSelectedAccount(index, account.id, false)"
|
||||
@click="removeSelectedAccount(rule, account.id)"
|
||||
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
@@ -796,23 +796,23 @@
|
||||
<!-- 账号搜索输入框 -->
|
||||
<div class="relative account-search-container">
|
||||
<input
|
||||
v-model="accountSearchKeyword[`create-${index}`]"
|
||||
v-model="accountSearchKeyword[getCreateRuleSearchKey(rule)]"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')"
|
||||
@input="searchAccounts(`create-${index}`)"
|
||||
@focus="onAccountSearchFocus(index, false)"
|
||||
@input="searchAccountsByRule(rule)"
|
||||
@focus="onAccountSearchFocus(rule)"
|
||||
/>
|
||||
<!-- 搜索结果下拉框 -->
|
||||
<div
|
||||
v-if="showAccountDropdown[`create-${index}`] && accountSearchResults[`create-${index}`]?.length > 0"
|
||||
v-if="showAccountDropdown[getCreateRuleSearchKey(rule)] && accountSearchResults[getCreateRuleSearchKey(rule)]?.length > 0"
|
||||
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="account in accountSearchResults[`create-${index}`]"
|
||||
v-for="account in accountSearchResults[getCreateRuleSearchKey(rule)]"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
@click="selectAccount(index, account, false)"
|
||||
@click="selectAccount(rule, account)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
|
||||
:disabled="rule.accounts.some(a => a.id === account.id)"
|
||||
@@ -827,7 +827,7 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeCreateRoutingRule(index)"
|
||||
@click="removeCreateRoutingRule(rule)"
|
||||
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
|
||||
:title="t('admin.groups.modelRouting.removeRule')"
|
||||
>
|
||||
@@ -1439,8 +1439,8 @@
|
||||
<!-- 路由规则列表(仅在启用时显示) -->
|
||||
<div v-if="editForm.model_routing_enabled" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in editModelRoutingRules"
|
||||
:key="index"
|
||||
v-for="rule in editModelRoutingRules"
|
||||
:key="getEditRuleRenderKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
@@ -1466,7 +1466,7 @@
|
||||
{{ account.name }}
|
||||
<button
|
||||
type="button"
|
||||
@click="removeSelectedAccount(index, account.id, true)"
|
||||
@click="removeSelectedAccount(rule, account.id, true)"
|
||||
class="ml-0.5 text-primary-500 hover:text-primary-700 dark:hover:text-primary-200"
|
||||
>
|
||||
<Icon name="x" size="xs" />
|
||||
@@ -1476,23 +1476,23 @@
|
||||
<!-- 账号搜索输入框 -->
|
||||
<div class="relative account-search-container">
|
||||
<input
|
||||
v-model="accountSearchKeyword[`edit-${index}`]"
|
||||
v-model="accountSearchKeyword[getEditRuleSearchKey(rule)]"
|
||||
type="text"
|
||||
class="input text-sm"
|
||||
:placeholder="t('admin.groups.modelRouting.searchAccountPlaceholder')"
|
||||
@input="searchAccounts(`edit-${index}`)"
|
||||
@focus="onAccountSearchFocus(index, true)"
|
||||
@input="searchAccountsByRule(rule, true)"
|
||||
@focus="onAccountSearchFocus(rule, true)"
|
||||
/>
|
||||
<!-- 搜索结果下拉框 -->
|
||||
<div
|
||||
v-if="showAccountDropdown[`edit-${index}`] && accountSearchResults[`edit-${index}`]?.length > 0"
|
||||
v-if="showAccountDropdown[getEditRuleSearchKey(rule)] && accountSearchResults[getEditRuleSearchKey(rule)]?.length > 0"
|
||||
class="absolute z-50 mt-1 max-h-48 w-full overflow-auto rounded-lg border bg-white shadow-lg dark:border-dark-600 dark:bg-dark-800"
|
||||
>
|
||||
<button
|
||||
v-for="account in accountSearchResults[`edit-${index}`]"
|
||||
v-for="account in accountSearchResults[getEditRuleSearchKey(rule)]"
|
||||
:key="account.id"
|
||||
type="button"
|
||||
@click="selectAccount(index, account, true)"
|
||||
@click="selectAccount(rule, account, true)"
|
||||
class="w-full px-3 py-2 text-left text-sm hover:bg-gray-100 dark:hover:bg-dark-700"
|
||||
:class="{ 'opacity-50': rule.accounts.some(a => a.id === account.id) }"
|
||||
:disabled="rule.accounts.some(a => a.id === account.id)"
|
||||
@@ -1507,7 +1507,7 @@
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@click="removeEditRoutingRule(index)"
|
||||
@click="removeEditRoutingRule(rule)"
|
||||
class="mt-5 p-1.5 text-gray-400 hover:text-red-500 transition-colors"
|
||||
:title="t('admin.groups.modelRouting.removeRule')"
|
||||
>
|
||||
@@ -1687,6 +1687,8 @@ import Select from '@/components/common/Select.vue'
|
||||
import PlatformIcon from '@/components/common/PlatformIcon.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { VueDraggable } from 'vue-draggable-plus'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -1911,33 +1913,70 @@ const createModelRoutingRules = ref<ModelRoutingRule[]>([])
|
||||
// 编辑表单的模型路由规则
|
||||
const editModelRoutingRules = ref<ModelRoutingRule[]>([])
|
||||
|
||||
// 账号搜索相关状态
|
||||
const accountSearchKeyword = ref<Record<string, string>>({}) // 每个规则的搜索关键词 (key: "create-0" 或 "edit-0")
|
||||
const accountSearchResults = ref<Record<string, SimpleAccount[]>>({}) // 每个规则的搜索结果
|
||||
const showAccountDropdown = ref<Record<string, boolean>>({}) // 每个规则的下拉框显示状态
|
||||
let accountSearchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
// 规则对象稳定 key(避免使用 index 导致状态错位)
|
||||
const resolveCreateRuleKey = createStableObjectKeyResolver<ModelRoutingRule>('create-rule')
|
||||
const resolveEditRuleKey = createStableObjectKeyResolver<ModelRoutingRule>('edit-rule')
|
||||
|
||||
// 搜索账号(仅限 anthropic 平台)
|
||||
const searchAccounts = async (key: string) => {
|
||||
if (accountSearchTimeout) clearTimeout(accountSearchTimeout)
|
||||
accountSearchTimeout = setTimeout(async () => {
|
||||
const keyword = accountSearchKeyword.value[key] || ''
|
||||
try {
|
||||
const res = await adminAPI.accounts.list(1, 20, {
|
||||
const getCreateRuleRenderKey = (rule: ModelRoutingRule) => resolveCreateRuleKey(rule)
|
||||
const getEditRuleRenderKey = (rule: ModelRoutingRule) => resolveEditRuleKey(rule)
|
||||
|
||||
const getCreateRuleSearchKey = (rule: ModelRoutingRule) => `create-${resolveCreateRuleKey(rule)}`
|
||||
const getEditRuleSearchKey = (rule: ModelRoutingRule) => `edit-${resolveEditRuleKey(rule)}`
|
||||
|
||||
const getRuleSearchKey = (rule: ModelRoutingRule, isEdit: boolean = false) => {
|
||||
return isEdit ? getEditRuleSearchKey(rule) : getCreateRuleSearchKey(rule)
|
||||
}
|
||||
|
||||
// 账号搜索相关状态
|
||||
const accountSearchKeyword = ref<Record<string, string>>({})
|
||||
const accountSearchResults = ref<Record<string, SimpleAccount[]>>({})
|
||||
const showAccountDropdown = ref<Record<string, boolean>>({})
|
||||
|
||||
const clearAccountSearchStateByKey = (key: string) => {
|
||||
delete accountSearchKeyword.value[key]
|
||||
delete accountSearchResults.value[key]
|
||||
delete showAccountDropdown.value[key]
|
||||
}
|
||||
|
||||
const clearAllAccountSearchState = () => {
|
||||
accountSearchKeyword.value = {}
|
||||
accountSearchResults.value = {}
|
||||
showAccountDropdown.value = {}
|
||||
}
|
||||
|
||||
const accountSearchRunner = useKeyedDebouncedSearch<SimpleAccount[]>({
|
||||
delay: 300,
|
||||
search: async (keyword, { signal }) => {
|
||||
const res = await adminAPI.accounts.list(
|
||||
1,
|
||||
20,
|
||||
{
|
||||
search: keyword,
|
||||
platform: 'anthropic'
|
||||
})
|
||||
accountSearchResults.value[key] = res.items.map((a) => ({ id: a.id, name: a.name }))
|
||||
} catch {
|
||||
accountSearchResults.value[key] = []
|
||||
}
|
||||
}, 300)
|
||||
},
|
||||
{ signal }
|
||||
)
|
||||
return res.items.map((account) => ({ id: account.id, name: account.name }))
|
||||
},
|
||||
onSuccess: (key, result) => {
|
||||
accountSearchResults.value[key] = result
|
||||
},
|
||||
onError: (key) => {
|
||||
accountSearchResults.value[key] = []
|
||||
}
|
||||
})
|
||||
|
||||
// 搜索账号(仅限 anthropic 平台)
|
||||
const searchAccounts = (key: string) => {
|
||||
accountSearchRunner.trigger(key, accountSearchKeyword.value[key] || '')
|
||||
}
|
||||
|
||||
const searchAccountsByRule = (rule: ModelRoutingRule, isEdit: boolean = false) => {
|
||||
searchAccounts(getRuleSearchKey(rule, isEdit))
|
||||
}
|
||||
|
||||
// 选择账号
|
||||
const selectAccount = (ruleIndex: number, account: SimpleAccount, isEdit: boolean = false) => {
|
||||
const rules = isEdit ? editModelRoutingRules.value : createModelRoutingRules.value
|
||||
const rule = rules[ruleIndex]
|
||||
const selectAccount = (rule: ModelRoutingRule, account: SimpleAccount, isEdit: boolean = false) => {
|
||||
if (!rule) return
|
||||
|
||||
// 检查是否已选择
|
||||
@@ -1946,15 +1985,13 @@ const selectAccount = (ruleIndex: number, account: SimpleAccount, isEdit: boolea
|
||||
}
|
||||
|
||||
// 清空搜索
|
||||
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
|
||||
const key = getRuleSearchKey(rule, isEdit)
|
||||
accountSearchKeyword.value[key] = ''
|
||||
showAccountDropdown.value[key] = false
|
||||
}
|
||||
|
||||
// 移除已选账号
|
||||
const removeSelectedAccount = (ruleIndex: number, accountId: number, isEdit: boolean = false) => {
|
||||
const rules = isEdit ? editModelRoutingRules.value : createModelRoutingRules.value
|
||||
const rule = rules[ruleIndex]
|
||||
const removeSelectedAccount = (rule: ModelRoutingRule, accountId: number, _isEdit: boolean = false) => {
|
||||
if (!rule) return
|
||||
|
||||
rule.accounts = rule.accounts.filter(a => a.id !== accountId)
|
||||
@@ -1981,8 +2018,8 @@ const toggleEditScope = (scope: string) => {
|
||||
}
|
||||
|
||||
// 处理账号搜索输入框聚焦
|
||||
const onAccountSearchFocus = (ruleIndex: number, isEdit: boolean = false) => {
|
||||
const key = `${isEdit ? 'edit' : 'create'}-${ruleIndex}`
|
||||
const onAccountSearchFocus = (rule: ModelRoutingRule, isEdit: boolean = false) => {
|
||||
const key = getRuleSearchKey(rule, isEdit)
|
||||
showAccountDropdown.value[key] = true
|
||||
// 如果没有搜索结果,触发一次搜索
|
||||
if (!accountSearchResults.value[key]?.length) {
|
||||
@@ -1996,13 +2033,14 @@ const addCreateRoutingRule = () => {
|
||||
}
|
||||
|
||||
// 删除创建表单的路由规则
|
||||
const removeCreateRoutingRule = (index: number) => {
|
||||
const removeCreateRoutingRule = (rule: ModelRoutingRule) => {
|
||||
const index = createModelRoutingRules.value.indexOf(rule)
|
||||
if (index === -1) return
|
||||
|
||||
const key = getCreateRuleSearchKey(rule)
|
||||
accountSearchRunner.clearKey(key)
|
||||
clearAccountSearchStateByKey(key)
|
||||
createModelRoutingRules.value.splice(index, 1)
|
||||
// 清理相关的搜索状态
|
||||
const key = `create-${index}`
|
||||
delete accountSearchKeyword.value[key]
|
||||
delete accountSearchResults.value[key]
|
||||
delete showAccountDropdown.value[key]
|
||||
}
|
||||
|
||||
// 添加编辑表单的路由规则
|
||||
@@ -2011,13 +2049,14 @@ const addEditRoutingRule = () => {
|
||||
}
|
||||
|
||||
// 删除编辑表单的路由规则
|
||||
const removeEditRoutingRule = (index: number) => {
|
||||
const removeEditRoutingRule = (rule: ModelRoutingRule) => {
|
||||
const index = editModelRoutingRules.value.indexOf(rule)
|
||||
if (index === -1) return
|
||||
|
||||
const key = getEditRuleSearchKey(rule)
|
||||
accountSearchRunner.clearKey(key)
|
||||
clearAccountSearchStateByKey(key)
|
||||
editModelRoutingRules.value.splice(index, 1)
|
||||
// 清理相关的搜索状态
|
||||
const key = `edit-${index}`
|
||||
delete accountSearchKeyword.value[key]
|
||||
delete accountSearchResults.value[key]
|
||||
delete showAccountDropdown.value[key]
|
||||
}
|
||||
|
||||
// 将 UI 格式的路由规则转换为 API 格式
|
||||
@@ -2161,6 +2200,10 @@ const handlePageSizeChange = (pageSize: number) => {
|
||||
|
||||
const closeCreateModal = () => {
|
||||
showCreateModal.value = false
|
||||
createModelRoutingRules.value.forEach((rule) => {
|
||||
accountSearchRunner.clearKey(getCreateRuleSearchKey(rule))
|
||||
})
|
||||
clearAllAccountSearchState()
|
||||
createForm.name = ''
|
||||
createForm.description = ''
|
||||
createForm.platform = 'anthropic'
|
||||
@@ -2247,6 +2290,10 @@ const handleEdit = async (group: AdminGroup) => {
|
||||
}
|
||||
|
||||
const closeEditModal = () => {
|
||||
editModelRoutingRules.value.forEach((rule) => {
|
||||
accountSearchRunner.clearKey(getEditRuleSearchKey(rule))
|
||||
})
|
||||
clearAllAccountSearchState()
|
||||
showEditModal.value = false
|
||||
editingGroup.value = null
|
||||
editModelRoutingRules.value = []
|
||||
@@ -2382,5 +2429,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
accountSearchRunner.clearAll()
|
||||
clearAllAccountSearchState()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -94,57 +94,44 @@ const exportToExcel = async () => {
|
||||
if (exporting.value) return; exporting.value = true; exportProgress.show = true
|
||||
const c = new AbortController(); exportAbortController = c
|
||||
try {
|
||||
const all: AdminUsageLog[] = []; let p = 1; let total = pagination.total
|
||||
let p = 1; let total = pagination.total; let exportedCount = 0
|
||||
const XLSX = await import('xlsx')
|
||||
const headers = [
|
||||
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
|
||||
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'),
|
||||
t('usage.type'),
|
||||
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
|
||||
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
|
||||
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
|
||||
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
|
||||
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
|
||||
t('usage.firstToken'), t('usage.duration'),
|
||||
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
|
||||
]
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers])
|
||||
while (true) {
|
||||
const res = await adminUsageAPI.list({ page: p, page_size: 100, ...filters.value }, { signal: c.signal })
|
||||
if (c.signal.aborted) break; if (p === 1) { total = res.total; exportProgress.total = total }
|
||||
if (res.items?.length) all.push(...res.items)
|
||||
exportProgress.current = all.length; exportProgress.progress = total > 0 ? Math.min(100, Math.round(all.length/total*100)) : 0
|
||||
if (all.length >= total || res.items.length < 100) break; p++
|
||||
const rows = (res.items || []).map((log: AdminUsageLog) => [
|
||||
log.created_at, log.user?.email || '', log.api_key?.name || '', log.account?.name || '', log.model,
|
||||
formatReasoningEffort(log.reasoning_effort), log.group?.name || '', log.stream ? t('usage.stream') : t('usage.sync'),
|
||||
log.input_tokens, log.output_tokens, log.cache_read_tokens, log.cache_creation_tokens,
|
||||
log.input_cost?.toFixed(6) || '0.000000', log.output_cost?.toFixed(6) || '0.000000',
|
||||
log.cache_read_cost?.toFixed(6) || '0.000000', log.cache_creation_cost?.toFixed(6) || '0.000000',
|
||||
log.rate_multiplier?.toFixed(2) || '1.00', (log.account_rate_multiplier ?? 1).toFixed(2),
|
||||
log.total_cost?.toFixed(6) || '0.000000', log.actual_cost?.toFixed(6) || '0.000000',
|
||||
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6), log.first_token_ms ?? '', log.duration_ms,
|
||||
log.request_id || '', log.user_agent || '', log.ip_address || ''
|
||||
])
|
||||
if (rows.length) {
|
||||
XLSX.utils.sheet_add_aoa(ws, rows, { origin: -1 })
|
||||
}
|
||||
exportedCount += rows.length
|
||||
exportProgress.current = exportedCount
|
||||
exportProgress.progress = total > 0 ? Math.min(100, Math.round(exportedCount / total * 100)) : 0
|
||||
if (exportedCount >= total || res.items.length < 100) break; p++
|
||||
}
|
||||
if(!c.signal.aborted) {
|
||||
const XLSX = await import('xlsx')
|
||||
const headers = [
|
||||
t('usage.time'), t('admin.usage.user'), t('usage.apiKeyFilter'),
|
||||
t('admin.usage.account'), t('usage.model'), t('usage.reasoningEffort'), t('admin.usage.group'),
|
||||
t('usage.type'),
|
||||
t('admin.usage.inputTokens'), t('admin.usage.outputTokens'),
|
||||
t('admin.usage.cacheReadTokens'), t('admin.usage.cacheCreationTokens'),
|
||||
t('admin.usage.inputCost'), t('admin.usage.outputCost'),
|
||||
t('admin.usage.cacheReadCost'), t('admin.usage.cacheCreationCost'),
|
||||
t('usage.rate'), t('usage.accountMultiplier'), t('usage.original'), t('usage.userBilled'), t('usage.accountBilled'),
|
||||
t('usage.firstToken'), t('usage.duration'),
|
||||
t('admin.usage.requestId'), t('usage.userAgent'), t('admin.usage.ipAddress')
|
||||
]
|
||||
const rows = all.map(log => [
|
||||
log.created_at,
|
||||
log.user?.email || '',
|
||||
log.api_key?.name || '',
|
||||
log.account?.name || '',
|
||||
log.model,
|
||||
formatReasoningEffort(log.reasoning_effort),
|
||||
log.group?.name || '',
|
||||
log.stream ? t('usage.stream') : t('usage.sync'),
|
||||
log.input_tokens,
|
||||
log.output_tokens,
|
||||
log.cache_read_tokens,
|
||||
log.cache_creation_tokens,
|
||||
log.input_cost?.toFixed(6) || '0.000000',
|
||||
log.output_cost?.toFixed(6) || '0.000000',
|
||||
log.cache_read_cost?.toFixed(6) || '0.000000',
|
||||
log.cache_creation_cost?.toFixed(6) || '0.000000',
|
||||
log.rate_multiplier?.toFixed(2) || '1.00',
|
||||
(log.account_rate_multiplier ?? 1).toFixed(2),
|
||||
log.total_cost?.toFixed(6) || '0.000000',
|
||||
log.actual_cost?.toFixed(6) || '0.000000',
|
||||
(log.total_cost * (log.account_rate_multiplier ?? 1)).toFixed(6),
|
||||
log.first_token_ms ?? '',
|
||||
log.duration_ms,
|
||||
log.request_id || '',
|
||||
log.user_agent || '',
|
||||
log.ip_address || ''
|
||||
])
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows])
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Usage')
|
||||
saveAs(new Blob([XLSX.write(wb, { bookType: 'xlsx', type: 'array' })], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), `usage_${filters.value.start_date}_to_${filters.value.end_date}.xlsx`)
|
||||
|
||||
Reference in New Issue
Block a user