fix(frontend): 修复前端审计问题并补充回归测试
This commit is contained in:
@@ -39,16 +39,6 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => appStore.siteName,
|
||||
(newName) => {
|
||||
if (newName) {
|
||||
document.title = `${newName} - AI API Gateway`
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Watch for authentication state and manage subscription data
|
||||
watch(
|
||||
() => authStore.isAuthenticated,
|
||||
|
||||
@@ -58,12 +58,16 @@ describe('ImportDataModal', () => {
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve('invalid json')
|
||||
})
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.accounts.dataImportParseFailed')
|
||||
})
|
||||
|
||||
@@ -58,12 +58,16 @@ describe('Proxy ImportDataModal', () => {
|
||||
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const file = new File(['invalid json'], 'data.json', { type: 'application/json' })
|
||||
Object.defineProperty(file, 'text', {
|
||||
value: () => Promise.resolve('invalid json')
|
||||
})
|
||||
Object.defineProperty(input.element, 'files', {
|
||||
value: [file]
|
||||
})
|
||||
|
||||
await input.trigger('change')
|
||||
await wrapper.find('form').trigger('submit')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(showError).toHaveBeenCalledWith('admin.proxies.dataImportParseFailed')
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -654,6 +654,7 @@ import Select from '@/components/common/Select.vue'
|
||||
import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -695,6 +696,7 @@ const baseUrl = ref('')
|
||||
const modelRestrictionMode = ref<'whitelist' | 'mapping'>('whitelist')
|
||||
const allowedModels = ref<string[]>([])
|
||||
const modelMappings = ref<ModelMapping[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('bulk-model-mapping')
|
||||
const selectedErrorCodes = ref<number[]>([])
|
||||
const customErrorCodeInput = ref<number | null>(null)
|
||||
const interceptWarmupRequests = ref(false)
|
||||
|
||||
@@ -714,7 +714,7 @@
|
||||
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in antigravityModelMappings"
|
||||
:key="index"
|
||||
:key="getAntigravityModelMappingKey(mapping)"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -966,7 +966,7 @@
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -1225,7 +1225,7 @@
|
||||
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in tempUnschedRules"
|
||||
:key="index"
|
||||
:key="getTempUnschedRuleKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
@@ -2097,6 +2097,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import OAuthAuthorizationFlow from './OAuthAuthorizationFlow.vue'
|
||||
|
||||
// Type for exposed OAuthAuthorizationFlow component
|
||||
@@ -2227,6 +2228,9 @@ const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const antigravityPresetMappings = computed(() => getPresetMappingsByPlatform('antigravity'))
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('create-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('create-temp-unsched-rule')
|
||||
const geminiOAuthType = ref<'code_assist' | 'google_one' | 'ai_studio'>('google_one')
|
||||
const geminiAIStudioOAuthEnabled = ref(false)
|
||||
|
||||
|
||||
@@ -169,7 +169,7 @@
|
||||
<div v-if="modelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in modelMappings"
|
||||
:key="index"
|
||||
:key="getModelMappingKey(mapping)"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
@@ -417,7 +417,7 @@
|
||||
<div v-if="antigravityModelMappings.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(mapping, index) in antigravityModelMappings"
|
||||
:key="index"
|
||||
:key="getAntigravityModelMappingKey(mapping)"
|
||||
class="space-y-1"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
@@ -542,7 +542,7 @@
|
||||
<div v-if="tempUnschedRules.length > 0" class="space-y-3">
|
||||
<div
|
||||
v-for="(rule, index) in tempUnschedRules"
|
||||
:key="index"
|
||||
:key="getTempUnschedRuleKey(rule)"
|
||||
class="rounded-lg border border-gray-200 p-3 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
@@ -1093,6 +1093,7 @@ import ProxySelector from '@/components/common/ProxySelector.vue'
|
||||
import GroupSelector from '@/components/common/GroupSelector.vue'
|
||||
import ModelWhitelistSelector from '@/components/account/ModelWhitelistSelector.vue'
|
||||
import { formatDateTimeLocalInput, parseDateTimeLocalInput } from '@/utils/format'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
import {
|
||||
getPresetMappingsByPlatform,
|
||||
commonErrorCodes,
|
||||
@@ -1158,6 +1159,9 @@ const antigravityWhitelistModels = ref<string[]>([])
|
||||
const antigravityModelMappings = ref<ModelMapping[]>([])
|
||||
const tempUnschedEnabled = ref(false)
|
||||
const tempUnschedRules = ref<TempUnschedRuleForm[]>([])
|
||||
const getModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-model-mapping')
|
||||
const getAntigravityModelMappingKey = createStableObjectKeyResolver<ModelMapping>('edit-antigravity-model-mapping')
|
||||
const getTempUnschedRuleKey = createStableObjectKeyResolver<TempUnschedRuleForm>('edit-temp-unsched-rule')
|
||||
|
||||
// Mixed channel warning dialog state
|
||||
const showMixedChannelWarning = ref(false)
|
||||
|
||||
@@ -143,6 +143,24 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
}
|
||||
|
||||
if (typeof sourceFile.arrayBuffer === 'function') {
|
||||
const buffer = await sourceFile.arrayBuffer()
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.accounts.dataImportSelectFile'))
|
||||
@@ -151,7 +169,7 @@ const handleImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.accounts.importData({
|
||||
|
||||
@@ -143,6 +143,24 @@ const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const readFileAsText = async (sourceFile: File): Promise<string> => {
|
||||
if (typeof sourceFile.text === 'function') {
|
||||
return sourceFile.text()
|
||||
}
|
||||
|
||||
if (typeof sourceFile.arrayBuffer === 'function') {
|
||||
const buffer = await sourceFile.arrayBuffer()
|
||||
return new TextDecoder().decode(buffer)
|
||||
}
|
||||
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||
reader.onerror = () => reject(reader.error || new Error('Failed to read file'))
|
||||
reader.readAsText(sourceFile)
|
||||
})
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!file.value) {
|
||||
appStore.showError(t('admin.proxies.dataImportSelectFile'))
|
||||
@@ -151,7 +169,7 @@ const handleImport = async () => {
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const text = await file.value.text()
|
||||
const text = await readFileAsText(file.value)
|
||||
const dataPayload = JSON.parse(text)
|
||||
|
||||
const res = await adminAPI.proxies.importData({ data: dataPayload })
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<template v-if="loading">
|
||||
<div v-for="i in 5" :key="i" class="rounded-lg border border-gray-200 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
||||
<div class="space-y-3">
|
||||
<div v-for="column in columns.filter(c => c.key !== 'actions')" :key="column.key" class="flex justify-between">
|
||||
<div v-for="column in dataColumns" :key="column.key" class="flex justify-between">
|
||||
<div class="h-4 w-20 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
<div class="h-4 w-32 animate-pulse rounded bg-gray-200 dark:bg-dark-700"></div>
|
||||
</div>
|
||||
@@ -39,7 +39,7 @@
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="column in columns.filter(c => c.key !== 'actions')"
|
||||
v-for="column in dataColumns"
|
||||
:key="column.key"
|
||||
class="flex items-start justify-between gap-4"
|
||||
>
|
||||
@@ -439,10 +439,15 @@ const resolveRowKey = (row: any, index: number) => {
|
||||
return key ?? index
|
||||
}
|
||||
|
||||
const dataColumns = computed(() => props.columns.filter((column) => column.key !== 'actions'))
|
||||
const columnsSignature = computed(() =>
|
||||
props.columns.map((column) => `${column.key}:${column.sortable ? '1' : '0'}`).join('|')
|
||||
)
|
||||
|
||||
// 数据/列变化时重新检查滚动状态
|
||||
// 注意:不能监听 actionsExpanded,因为 checkActionsColumnWidth 会临时修改它,会导致无限循环
|
||||
watch(
|
||||
[() => props.data.length, () => props.columns],
|
||||
[() => props.data.length, columnsSignature],
|
||||
async () => {
|
||||
await nextTick()
|
||||
checkScrollable()
|
||||
@@ -555,7 +560,7 @@ onMounted(() => {
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.columns,
|
||||
columnsSignature,
|
||||
() => {
|
||||
// If current sort key is no longer sortable/visible, fall back to default/persisted.
|
||||
const normalized = normalizeSortKey(sortKey.value)
|
||||
@@ -575,7 +580,7 @@ watch(
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
watch(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<div class="relative" ref="dropdownRef">
|
||||
<button
|
||||
@click="toggleDropdown"
|
||||
:disabled="switching"
|
||||
class="flex items-center gap-1.5 rounded-lg px-2 py-1.5 text-sm font-medium text-gray-600 transition-colors hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-dark-700"
|
||||
:title="currentLocale?.name"
|
||||
>
|
||||
@@ -23,6 +24,7 @@
|
||||
<button
|
||||
v-for="locale in availableLocales"
|
||||
:key="locale.code"
|
||||
:disabled="switching"
|
||||
@click="selectLocale(locale.code)"
|
||||
class="flex w-full items-center gap-2 px-3 py-2 text-sm text-gray-700 transition-colors hover:bg-gray-100 dark:text-gray-200 dark:hover:bg-dark-700"
|
||||
:class="{
|
||||
@@ -49,6 +51,7 @@ const { locale } = useI18n()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const dropdownRef = ref<HTMLElement | null>(null)
|
||||
const switching = ref(false)
|
||||
|
||||
const currentLocaleCode = computed(() => locale.value)
|
||||
const currentLocale = computed(() => availableLocales.find((l) => l.code === locale.value))
|
||||
@@ -57,9 +60,18 @@ function toggleDropdown() {
|
||||
isOpen.value = !isOpen.value
|
||||
}
|
||||
|
||||
function selectLocale(code: string) {
|
||||
setLocale(code)
|
||||
isOpen.value = false
|
||||
async function selectLocale(code: string) {
|
||||
if (switching.value || code === currentLocaleCode.value) {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
switching.value = true
|
||||
try {
|
||||
await setLocale(code)
|
||||
isOpen.value = false
|
||||
} finally {
|
||||
switching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
|
||||
@@ -84,8 +84,8 @@
|
||||
|
||||
<!-- Page numbers -->
|
||||
<button
|
||||
v-for="pageNum in visiblePages"
|
||||
:key="pageNum"
|
||||
v-for="(pageNum, index) in visiblePages"
|
||||
:key="`${pageNum}-${index}`"
|
||||
@click="typeof pageNum === 'number' && goToPage(pageNum)"
|
||||
:disabled="typeof pageNum !== 'number'"
|
||||
:class="[
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
<!-- Progress bar -->
|
||||
<div v-if="toast.duration" class="h-1 bg-gray-100 dark:bg-dark-700">
|
||||
<div
|
||||
:class="['h-full transition-all', getProgressBarColor(toast.type)]"
|
||||
:style="{ width: `${getProgress(toast)}%` }"
|
||||
:class="['h-full toast-progress', getProgressBarColor(toast.type)]"
|
||||
:style="{ animationDuration: `${toast.duration}ms` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -77,7 +77,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted } from 'vue'
|
||||
import { computed } from 'vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
@@ -129,36 +129,25 @@ const getProgressBarColor = (type: string): string => {
|
||||
return colors[type] || colors.info
|
||||
}
|
||||
|
||||
const getProgress = (toast: any): number => {
|
||||
if (!toast.duration || !toast.startTime) return 100
|
||||
const elapsed = Date.now() - toast.startTime
|
||||
const progress = Math.max(0, 100 - (elapsed / toast.duration) * 100)
|
||||
return progress
|
||||
}
|
||||
|
||||
const removeToast = (id: string) => {
|
||||
appStore.hideToast(id)
|
||||
}
|
||||
|
||||
let intervalId: number | undefined
|
||||
|
||||
onMounted(() => {
|
||||
// Check for expired toasts every 100ms
|
||||
intervalId = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
toasts.value.forEach((toast) => {
|
||||
if (toast.duration && toast.startTime) {
|
||||
if (now - toast.startTime >= toast.duration) {
|
||||
removeToast(toast.id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}, 100)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (intervalId !== undefined) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-progress {
|
||||
width: 100%;
|
||||
animation-name: toast-progress-shrink;
|
||||
animation-timing-function: linear;
|
||||
animation-fill-mode: forwards;
|
||||
}
|
||||
|
||||
@keyframes toast-progress-shrink {
|
||||
from {
|
||||
width: 100%;
|
||||
}
|
||||
to {
|
||||
width: 0%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -143,7 +143,7 @@
|
||||
<!-- Options (for select/multi_select) -->
|
||||
<div v-if="form.type === 'select' || form.type === 'multi_select'" class="space-y-2">
|
||||
<label class="input-label">{{ t('admin.users.attributes.options') }}</label>
|
||||
<div v-for="(option, index) in form.options" :key="index" class="flex items-center gap-2">
|
||||
<div v-for="(option, index) in form.options" :key="getOptionKey(option)" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="option.value"
|
||||
type="text"
|
||||
@@ -246,6 +246,7 @@ import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
|
||||
import Icon from '@/components/icons/Icon.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -270,6 +271,7 @@ const showEditModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const editingAttribute = ref<UserAttributeDefinition | null>(null)
|
||||
const deletingAttribute = ref<UserAttributeDefinition | null>(null)
|
||||
const getOptionKey = createStableObjectKeyResolver<UserAttributeOption>('user-attr-option')
|
||||
|
||||
const form = reactive({
|
||||
key: '',
|
||||
@@ -315,7 +317,7 @@ const openEditModal = (attr: UserAttributeDefinition) => {
|
||||
form.placeholder = attr.placeholder || ''
|
||||
form.required = attr.required
|
||||
form.enabled = attr.enabled
|
||||
form.options = attr.options ? [...attr.options] : []
|
||||
form.options = attr.options ? attr.options.map((opt) => ({ ...opt })) : []
|
||||
showEditModal.value = true
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
@@ -107,6 +107,7 @@ const loading = ref(false)
|
||||
const error = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
const form = ref({
|
||||
emailCode: '',
|
||||
password: ''
|
||||
@@ -139,10 +140,17 @@ const handleSendCode = async () => {
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
cooldownTimer.value = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
@@ -176,4 +184,11 @@ const handleDisable = async () => {
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -175,7 +175,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, nextTick, watch, computed } from 'vue'
|
||||
import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { totpAPI } from '@/api'
|
||||
@@ -198,6 +198,7 @@ const verifyForm = ref({ emailCode: '', password: '' })
|
||||
const verifyError = ref('')
|
||||
const sendingCode = ref(false)
|
||||
const codeCooldown = ref(0)
|
||||
const cooldownTimer = ref<ReturnType<typeof setInterval> | null>(null)
|
||||
|
||||
const setupLoading = ref(false)
|
||||
const setupData = ref<TotpSetupResponse | null>(null)
|
||||
@@ -338,10 +339,17 @@ const handleSendCode = async () => {
|
||||
appStore.showSuccess(t('profile.totp.codeSent'))
|
||||
// Start cooldown
|
||||
codeCooldown.value = 60
|
||||
const timer = setInterval(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
cooldownTimer.value = setInterval(() => {
|
||||
codeCooldown.value--
|
||||
if (codeCooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
}
|
||||
}, 1000)
|
||||
} catch (err: any) {
|
||||
@@ -397,4 +405,11 @@ const handleVerify = async () => {
|
||||
onMounted(() => {
|
||||
loadVerificationMethod()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cooldownTimer.value) {
|
||||
clearInterval(cooldownTimer.value)
|
||||
cooldownTimer.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import TotpSetupModal from '@/components/user/profile/TotpSetupModal.vue'
|
||||
import TotpDisableDialog from '@/components/user/profile/TotpDisableDialog.vue'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
getVerificationMethod: vi.fn(),
|
||||
sendVerifyCode: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: mocks.showSuccess,
|
||||
showError: mocks.showError
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/api', () => ({
|
||||
totpAPI: {
|
||||
getVerificationMethod: mocks.getVerificationMethod,
|
||||
sendVerifyCode: mocks.sendVerifyCode,
|
||||
initiateSetup: vi.fn(),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
const flushPromises = async () => {
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
||||
|
||||
describe('TOTP 弹窗定时器清理', () => {
|
||||
let intervalSeed = 1000
|
||||
let setIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
let clearIntervalSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
intervalSeed = 1000
|
||||
mocks.showSuccess.mockReset()
|
||||
mocks.showError.mockReset()
|
||||
mocks.getVerificationMethod.mockReset()
|
||||
mocks.sendVerifyCode.mockReset()
|
||||
|
||||
mocks.getVerificationMethod.mockResolvedValue({ method: 'email' })
|
||||
mocks.sendVerifyCode.mockResolvedValue({ success: true })
|
||||
|
||||
setIntervalSpy = vi.spyOn(window, 'setInterval').mockImplementation(((handler: TimerHandler) => {
|
||||
void handler
|
||||
intervalSeed += 1
|
||||
return intervalSeed as unknown as number
|
||||
}) as typeof window.setInterval)
|
||||
clearIntervalSpy = vi.spyOn(window, 'clearInterval')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setIntervalSpy.mockRestore()
|
||||
clearIntervalSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('TotpSetupModal 卸载时清理倒计时定时器', async () => {
|
||||
const wrapper = mount(TotpSetupModal)
|
||||
await flushPromises()
|
||||
|
||||
const sendButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('profile.totp.sendCode'))
|
||||
|
||||
expect(sendButton).toBeTruthy()
|
||||
await sendButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
|
||||
it('TotpDisableDialog 卸载时清理倒计时定时器', async () => {
|
||||
const wrapper = mount(TotpDisableDialog)
|
||||
await flushPromises()
|
||||
|
||||
const sendButton = wrapper
|
||||
.findAll('button')
|
||||
.find((button) => button.text().includes('profile.totp.sendCode'))
|
||||
|
||||
expect(sendButton).toBeTruthy()
|
||||
await sendButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(setIntervalSpy).toHaveBeenCalledTimes(1)
|
||||
const timerId = setIntervalSpy.mock.results[0]?.value
|
||||
|
||||
wrapper.unmount()
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalledWith(timerId)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useKeyedDebouncedSearch } from '@/composables/useKeyedDebouncedSearch'
|
||||
|
||||
const flushPromises = () => Promise.resolve()
|
||||
|
||||
describe('useKeyedDebouncedSearch', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('为不同 key 独立防抖触发搜索', async () => {
|
||||
const search = vi.fn().mockResolvedValue([])
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 100,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('a', 'foo')
|
||||
searcher.trigger('b', 'bar')
|
||||
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
await flushPromises()
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2)
|
||||
expect(search).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'foo',
|
||||
expect.objectContaining({ key: 'a', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(search).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'bar',
|
||||
expect.objectContaining({ key: 'b', signal: expect.any(AbortSignal) })
|
||||
)
|
||||
expect(onSuccess).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('同 key 新请求会取消旧请求并忽略过期响应', async () => {
|
||||
const resolves: Array<(value: string[]) => void> = []
|
||||
const search = vi.fn().mockImplementation(
|
||||
() => new Promise<string[]>((resolve) => {
|
||||
resolves.push(resolve)
|
||||
})
|
||||
)
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 50,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('rule-1', 'first')
|
||||
vi.advanceTimersByTime(50)
|
||||
await flushPromises()
|
||||
|
||||
searcher.trigger('rule-1', 'second')
|
||||
vi.advanceTimersByTime(50)
|
||||
await flushPromises()
|
||||
|
||||
expect(search).toHaveBeenCalledTimes(2)
|
||||
|
||||
resolves[1](['second'])
|
||||
await flushPromises()
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
expect(onSuccess).toHaveBeenLastCalledWith('rule-1', ['second'])
|
||||
|
||||
resolves[0](['first'])
|
||||
await flushPromises()
|
||||
expect(onSuccess).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('clearKey 会取消未执行任务', () => {
|
||||
const search = vi.fn().mockResolvedValue([])
|
||||
const onSuccess = vi.fn()
|
||||
|
||||
const searcher = useKeyedDebouncedSearch<string[]>({
|
||||
delay: 100,
|
||||
search,
|
||||
onSuccess
|
||||
})
|
||||
|
||||
searcher.trigger('a', 'foo')
|
||||
searcher.clearKey('a')
|
||||
|
||||
vi.advanceTimersByTime(100)
|
||||
|
||||
expect(search).not.toHaveBeenCalled()
|
||||
expect(onSuccess).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
103
frontend/src/composables/useKeyedDebouncedSearch.ts
Normal file
103
frontend/src/composables/useKeyedDebouncedSearch.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { getCurrentInstance, onUnmounted } from 'vue'
|
||||
|
||||
export interface KeyedDebouncedSearchContext {
|
||||
key: string
|
||||
signal: AbortSignal
|
||||
}
|
||||
|
||||
interface UseKeyedDebouncedSearchOptions<T> {
|
||||
delay?: number
|
||||
search: (keyword: string, context: KeyedDebouncedSearchContext) => Promise<T>
|
||||
onSuccess: (key: string, result: T) => void
|
||||
onError?: (key: string, error: unknown) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 多实例隔离的防抖搜索:每个 key 有独立的防抖、请求取消与过期响应保护。
|
||||
*/
|
||||
export function useKeyedDebouncedSearch<T>(options: UseKeyedDebouncedSearchOptions<T>) {
|
||||
const delay = options.delay ?? 300
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
const controllers = new Map<string, AbortController>()
|
||||
const versions = new Map<string, number>()
|
||||
|
||||
const clearKey = (key: string) => {
|
||||
const timer = timers.get(key)
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
timers.delete(key)
|
||||
}
|
||||
|
||||
const controller = controllers.get(key)
|
||||
if (controller) {
|
||||
controller.abort()
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
versions.delete(key)
|
||||
}
|
||||
|
||||
const clearAll = () => {
|
||||
const allKeys = new Set<string>([
|
||||
...timers.keys(),
|
||||
...controllers.keys(),
|
||||
...versions.keys()
|
||||
])
|
||||
|
||||
allKeys.forEach((key) => clearKey(key))
|
||||
}
|
||||
|
||||
const trigger = (key: string, keyword: string) => {
|
||||
const nextVersion = (versions.get(key) ?? 0) + 1
|
||||
versions.set(key, nextVersion)
|
||||
|
||||
const existingTimer = timers.get(key)
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer)
|
||||
timers.delete(key)
|
||||
}
|
||||
|
||||
const inFlight = controllers.get(key)
|
||||
if (inFlight) {
|
||||
inFlight.abort()
|
||||
controllers.delete(key)
|
||||
}
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
timers.delete(key)
|
||||
|
||||
const controller = new AbortController()
|
||||
controllers.set(key, controller)
|
||||
const requestVersion = versions.get(key)
|
||||
|
||||
try {
|
||||
const result = await options.search(keyword, { key, signal: controller.signal })
|
||||
if (controller.signal.aborted) return
|
||||
if (versions.get(key) !== requestVersion) return
|
||||
options.onSuccess(key, result)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) return
|
||||
if (versions.get(key) !== requestVersion) return
|
||||
options.onError?.(key, error)
|
||||
} finally {
|
||||
if (controllers.get(key) === controller) {
|
||||
controllers.delete(key)
|
||||
}
|
||||
}
|
||||
}, delay)
|
||||
|
||||
timers.set(key, timer)
|
||||
}
|
||||
|
||||
if (getCurrentInstance()) {
|
||||
onUnmounted(() => {
|
||||
clearAll()
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
trigger,
|
||||
clearKey,
|
||||
clearAll
|
||||
}
|
||||
}
|
||||
@@ -1,53 +1,83 @@
|
||||
import { createI18n } from 'vue-i18n'
|
||||
import en from './locales/en'
|
||||
import zh from './locales/zh'
|
||||
|
||||
type LocaleCode = 'en' | 'zh'
|
||||
|
||||
type LocaleMessages = Record<string, any>
|
||||
|
||||
const LOCALE_KEY = 'sub2api_locale'
|
||||
const DEFAULT_LOCALE: LocaleCode = 'en'
|
||||
|
||||
function getDefaultLocale(): string {
|
||||
// Check localStorage first
|
||||
const localeLoaders: Record<LocaleCode, () => Promise<{ default: LocaleMessages }>> = {
|
||||
en: () => import('./locales/en'),
|
||||
zh: () => import('./locales/zh')
|
||||
}
|
||||
|
||||
function isLocaleCode(value: string): value is LocaleCode {
|
||||
return value === 'en' || value === 'zh'
|
||||
}
|
||||
|
||||
function getDefaultLocale(): LocaleCode {
|
||||
const saved = localStorage.getItem(LOCALE_KEY)
|
||||
if (saved && ['en', 'zh'].includes(saved)) {
|
||||
if (saved && isLocaleCode(saved)) {
|
||||
return saved
|
||||
}
|
||||
|
||||
// Check browser language
|
||||
const browserLang = navigator.language.toLowerCase()
|
||||
if (browserLang.startsWith('zh')) {
|
||||
return 'zh'
|
||||
}
|
||||
|
||||
return 'en'
|
||||
return DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: getDefaultLocale(),
|
||||
fallbackLocale: 'en',
|
||||
messages: {
|
||||
en,
|
||||
zh
|
||||
},
|
||||
fallbackLocale: DEFAULT_LOCALE,
|
||||
messages: {},
|
||||
// 禁用 HTML 消息警告 - 引导步骤使用富文本内容(driver.js 支持 HTML)
|
||||
// 这些内容是内部定义的,不存在 XSS 风险
|
||||
warnHtmlMessage: false
|
||||
})
|
||||
|
||||
export function setLocale(locale: string) {
|
||||
if (['en', 'zh'].includes(locale)) {
|
||||
i18n.global.locale.value = locale as 'en' | 'zh'
|
||||
localStorage.setItem(LOCALE_KEY, locale)
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
const loadedLocales = new Set<LocaleCode>()
|
||||
|
||||
export async function loadLocaleMessages(locale: LocaleCode): Promise<void> {
|
||||
if (loadedLocales.has(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
const loader = localeLoaders[locale]
|
||||
const module = await loader()
|
||||
i18n.global.setLocaleMessage(locale, module.default)
|
||||
loadedLocales.add(locale)
|
||||
}
|
||||
|
||||
export function getLocale(): string {
|
||||
return i18n.global.locale.value
|
||||
export async function initI18n(): Promise<void> {
|
||||
const current = getLocale()
|
||||
await loadLocaleMessages(current)
|
||||
document.documentElement.setAttribute('lang', current)
|
||||
}
|
||||
|
||||
export async function setLocale(locale: string): Promise<void> {
|
||||
if (!isLocaleCode(locale)) {
|
||||
return
|
||||
}
|
||||
|
||||
await loadLocaleMessages(locale)
|
||||
i18n.global.locale.value = locale
|
||||
localStorage.setItem(LOCALE_KEY, locale)
|
||||
document.documentElement.setAttribute('lang', locale)
|
||||
}
|
||||
|
||||
export function getLocale(): LocaleCode {
|
||||
const current = i18n.global.locale.value
|
||||
return isLocaleCode(current) ? current : DEFAULT_LOCALE
|
||||
}
|
||||
|
||||
export const availableLocales = [
|
||||
{ code: 'en', name: 'English', flag: '🇺🇸' },
|
||||
{ code: 'zh', name: '中文', flag: '🇨🇳' }
|
||||
]
|
||||
] as const
|
||||
|
||||
export default i18n
|
||||
|
||||
@@ -2,28 +2,33 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import i18n from './i18n'
|
||||
import i18n, { initI18n } from './i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
async function bootstrap() {
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
app.use(pinia)
|
||||
|
||||
// Initialize settings from injected config BEFORE mounting (prevents flash)
|
||||
// This must happen after pinia is installed but before router and i18n
|
||||
import { useAppStore } from '@/stores/app'
|
||||
const appStore = useAppStore()
|
||||
appStore.initFromInjectedConfig()
|
||||
// Initialize settings from injected config BEFORE mounting (prevents flash)
|
||||
// This must happen after pinia is installed but before router and i18n
|
||||
const appStore = useAppStore()
|
||||
appStore.initFromInjectedConfig()
|
||||
|
||||
// Set document title immediately after config is loaded
|
||||
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
|
||||
document.title = `${appStore.siteName} - AI API Gateway`
|
||||
// Set document title immediately after config is loaded
|
||||
if (appStore.siteName && appStore.siteName !== 'Sub2API') {
|
||||
document.title = `${appStore.siteName} - AI API Gateway`
|
||||
}
|
||||
|
||||
await initI18n()
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
|
||||
await router.isReady()
|
||||
app.mount('#app')
|
||||
}
|
||||
|
||||
app.use(router)
|
||||
app.use(i18n)
|
||||
|
||||
// 等待路由器完成初始导航后再挂载,避免竞态条件导致的空白渲染
|
||||
router.isReady().then(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
bootstrap()
|
||||
|
||||
25
frontend/src/router/__tests__/title.spec.ts
Normal file
25
frontend/src/router/__tests__/title.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveDocumentTitle } from '@/router/title'
|
||||
|
||||
describe('resolveDocumentTitle', () => {
|
||||
it('路由存在标题时,使用“路由标题 - 站点名”格式', () => {
|
||||
expect(resolveDocumentTitle('Usage Records', 'My Site')).toBe('Usage Records - My Site')
|
||||
})
|
||||
|
||||
it('路由无标题时,回退到站点名', () => {
|
||||
expect(resolveDocumentTitle(undefined, 'My Site')).toBe('My Site')
|
||||
})
|
||||
|
||||
it('站点名为空时,回退默认站点名', () => {
|
||||
expect(resolveDocumentTitle('Dashboard', '')).toBe('Dashboard - Sub2API')
|
||||
expect(resolveDocumentTitle(undefined, ' ')).toBe('Sub2API')
|
||||
})
|
||||
|
||||
it('站点名变更时仅影响后续路由标题计算', () => {
|
||||
const before = resolveDocumentTitle('Admin Dashboard', 'Alpha')
|
||||
const after = resolveDocumentTitle('Admin Dashboard', 'Beta')
|
||||
|
||||
expect(before).toBe('Admin Dashboard - Alpha')
|
||||
expect(after).toBe('Admin Dashboard - Beta')
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useNavigationLoadingState } from '@/composables/useNavigationLoading'
|
||||
import { useRoutePrefetch } from '@/composables/useRoutePrefetch'
|
||||
import { resolveDocumentTitle } from './title'
|
||||
|
||||
/**
|
||||
* Route definitions with lazy loading
|
||||
@@ -389,12 +390,7 @@ router.beforeEach((to, _from, next) => {
|
||||
|
||||
// Set page title
|
||||
const appStore = useAppStore()
|
||||
const siteName = appStore.siteName || 'Sub2API'
|
||||
if (to.meta.title) {
|
||||
document.title = `${to.meta.title} - ${siteName}`
|
||||
} else {
|
||||
document.title = siteName
|
||||
}
|
||||
document.title = resolveDocumentTitle(to.meta.title, appStore.siteName)
|
||||
|
||||
// Check if route requires authentication
|
||||
const requiresAuth = to.meta.requiresAuth !== false // Default to true
|
||||
|
||||
12
frontend/src/router/title.ts
Normal file
12
frontend/src/router/title.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 统一生成页面标题,避免多处写入 document.title 产生覆盖冲突。
|
||||
*/
|
||||
export function resolveDocumentTitle(routeTitle: unknown, siteName?: string): string {
|
||||
const normalizedSiteName = typeof siteName === 'string' && siteName.trim() ? siteName.trim() : 'Sub2API'
|
||||
|
||||
if (typeof routeTitle === 'string' && routeTitle.trim()) {
|
||||
return `${routeTitle.trim()} - ${normalizedSiteName}`
|
||||
}
|
||||
|
||||
return normalizedSiteName
|
||||
}
|
||||
37
frontend/src/utils/__tests__/stableObjectKey.spec.ts
Normal file
37
frontend/src/utils/__tests__/stableObjectKey.spec.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createStableObjectKeyResolver } from '@/utils/stableObjectKey'
|
||||
|
||||
describe('createStableObjectKeyResolver', () => {
|
||||
it('对同一对象返回稳定 key', () => {
|
||||
const resolve = createStableObjectKeyResolver<{ value: string }>('rule')
|
||||
const obj = { value: 'a' }
|
||||
|
||||
const key1 = resolve(obj)
|
||||
const key2 = resolve(obj)
|
||||
|
||||
expect(key1).toBe(key2)
|
||||
expect(key1.startsWith('rule-')).toBe(true)
|
||||
})
|
||||
|
||||
it('不同对象返回不同 key', () => {
|
||||
const resolve = createStableObjectKeyResolver<{ value: string }>('rule')
|
||||
|
||||
const key1 = resolve({ value: 'a' })
|
||||
const key2 = resolve({ value: 'a' })
|
||||
|
||||
expect(key1).not.toBe(key2)
|
||||
})
|
||||
|
||||
it('不同 resolver 互不影响', () => {
|
||||
const resolveA = createStableObjectKeyResolver<{ id: number }>('a')
|
||||
const resolveB = createStableObjectKeyResolver<{ id: number }>('b')
|
||||
const obj = { id: 1 }
|
||||
|
||||
const keyA = resolveA(obj)
|
||||
const keyB = resolveB(obj)
|
||||
|
||||
expect(keyA).not.toBe(keyB)
|
||||
expect(keyA.startsWith('a-')).toBe(true)
|
||||
expect(keyB.startsWith('b-')).toBe(true)
|
||||
})
|
||||
})
|
||||
19
frontend/src/utils/stableObjectKey.ts
Normal file
19
frontend/src/utils/stableObjectKey.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
let globalStableObjectKeySeed = 0
|
||||
|
||||
/**
|
||||
* 为对象实例生成稳定 key(基于 WeakMap,不污染业务对象)
|
||||
*/
|
||||
export function createStableObjectKeyResolver<T extends object>(prefix = 'item') {
|
||||
const keyMap = new WeakMap<T, string>()
|
||||
|
||||
return (item: T): string => {
|
||||
const cached = keyMap.get(item)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const key = `${prefix}-${++globalStableObjectKeySeed}`
|
||||
keyMap.set(item, key)
|
||||
return key
|
||||
}
|
||||
}
|
||||
@@ -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