This commit is contained in:
SunSeekerX
2026-01-21 11:55:28 +08:00
149 changed files with 15035 additions and 4017 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,46 @@
/* Inter 字体本地化 - 仅包含项目使用的字重 (300, 400, 500, 600, 700) */
/* Inter Light - 300 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('./Inter-Light.woff2') format('woff2');
}
/* Inter Regular - 400 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('./Inter-Regular.woff2') format('woff2');
}
/* Inter Medium - 500 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('./Inter-Medium.woff2') format('woff2');
}
/* Inter SemiBold - 600 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('./Inter-SemiBold.woff2') format('woff2');
}
/* Inter Bold - 700 */
@font-face {
font-family: 'Inter';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('./Inter-Bold.woff2') format('woff2');
}

View File

@@ -0,0 +1,293 @@
<template>
<el-dialog
:append-to-body="true"
class="balance-script-dialog"
:close-on-click-modal="false"
:destroy-on-close="true"
:model-value="show"
:title="`配置余额脚本 - ${account?.name || ''}`"
top="5vh"
width="720px"
@close="emitClose"
>
<div class="space-y-4">
<div class="grid gap-3 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
<input v-model="form.apiKey" class="input-text" placeholder="access token / key" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>请求地址baseUrl</label
>
<input v-model="form.baseUrl" class="input-text" placeholder="https://api.example.com" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">Token可选</label>
<input v-model="form.token" class="input-text" placeholder="Bearer token" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>额外参数 (extra / userId)</label
>
<input v-model="form.extra" class="input-text" placeholder="用户ID等" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">超时时间()</label>
<input v-model.number="form.timeoutSeconds" class="input-text" min="1" type="number" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>自动查询间隔(分钟)</label
>
<input
v-model.number="form.autoIntervalMinutes"
class="input-text"
min="0"
type="number"
/>
<p class="text-xs text-gray-500 dark:text-gray-400">0 表示仅手动刷新</p>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 md:col-span-2">
可用变量{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}apiKey{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}accountId{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}extra{{ '}'
}}{{ '}' }}
</div>
</div>
<div>
<div class="mb-2 flex items-center justify-between">
<div class="text-sm font-semibold text-gray-800 dark:text-gray-100">提取器代码</div>
<button
class="rounded bg-gray-200 px-2 py-1 text-xs dark:bg-gray-700"
@click="applyPreset"
>
使用示例
</button>
</div>
<textarea
v-model="form.scriptBody"
class="min-h-[260px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
spellcheck="false"
></textarea>
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
extractor 可返回isValidinvalidMessageremainingunitplanNametotalusedextra
</div>
</div>
<div v-if="testResult" class="rounded-lg bg-gray-50 p-3 text-sm dark:bg-gray-800/60">
<div class="flex items-center justify-between">
<span class="font-semibold">测试结果</span>
<span
:class="[
'rounded px-2 py-0.5 text-xs',
testResult.mapped?.status === 'success'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
]"
>
{{ testResult.mapped?.status || 'unknown' }}
</span>
</div>
<div class="mt-2 text-xs text-gray-600 dark:text-gray-300">
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
错误: {{ testResult.mapped.errorMessage }}
</div>
</div>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看 extractor 输出</summary>
<pre class="mt-1 whitespace-pre-wrap break-all">{{
formatJson(testResult.extracted)
}}</pre>
</details>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看原始响应</summary>
<pre class="mt-1 whitespace-pre-wrap break-all">{{
formatJson(testResult.response)
}}</pre>
</details>
</div>
</div>
<template #footer>
<div class="flex items-center gap-2">
<el-button :loading="testing" @click="testScript">测试脚本</el-button>
<el-button :loading="saving" type="primary" @click="saveConfig">保存配置</el-button>
<el-button @click="emitClose">取消</el-button>
</div>
</template>
</el-dialog>
</template>
<script setup>
import { reactive, ref, watch } from 'vue'
import {
getAccountBalanceScriptApi,
updateAccountBalanceScriptApi,
testAccountBalanceScriptApi
} from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
const props = defineProps({
show: { type: Boolean, default: false },
account: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close', 'saved'])
const saving = ref(false)
const testing = ref(false)
const testResult = ref(null)
const presetScript = `({
request: {
url: "{{baseUrl}}/api/user/self",
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer {{apiKey}}",
"New-Api-User": "{{extra}}"
}
},
extractor: function (response) {
if (response && response.success && response.data) {
const quota = response.data.quota || 0;
const used = response.data.used_quota || 0;
return {
planName: response.data.group || "默认套餐",
remaining: quota / 500000,
used: used / 500000,
total: (quota + used) / 500000,
unit: "USD"
};
}
return {
isValid: false,
invalidMessage: (response && response.message) || "查询失败"
};
}
})`
const form = reactive({
baseUrl: '',
apiKey: '',
token: '',
extra: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
scriptBody: ''
})
const buildDefaultForm = () => ({
baseUrl: '',
apiKey: '',
token: '',
extra: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
// 默认给出示例脚本,字段保持清空,避免“上一个账户的配置污染当前账户”
scriptBody: presetScript
})
const emitClose = () => emit('close')
const resetForm = () => {
Object.assign(form, buildDefaultForm())
testResult.value = null
saving.value = false
testing.value = false
}
const loadConfig = async () => {
if (!props.account?.id || !props.account?.platform) return
const res = await getAccountBalanceScriptApi(props.account.id, props.account.platform)
if (res?.success && res.data) {
Object.assign(form, res.data)
}
}
const saveConfig = async () => {
if (!props.account?.id || !props.account?.platform) return
saving.value = true
const res = await updateAccountBalanceScriptApi(props.account.id, props.account.platform, {
...form
})
if (res?.success) {
showToast('已保存', 'success')
emit('saved')
} else {
showToast(res?.message || '保存失败', 'error')
}
saving.value = false
}
const testScript = async () => {
if (!props.account?.id || !props.account?.platform) return
testing.value = true
testResult.value = null
const res = await testAccountBalanceScriptApi(props.account.id, props.account.platform, {
...form
})
if (res?.success) {
testResult.value = res.data
showToast('测试完成', 'success')
} else {
showToast(res?.error || '测试失败', 'error')
}
testing.value = false
}
const applyPreset = () => {
form.scriptBody = presetScript
}
const displayAmount = (val) => {
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
return Number(val).toFixed(2)
}
const formatJson = (data) => {
try {
return JSON.stringify(data, null, 2)
} catch (error) {
return String(data)
}
}
watch(
() => props.show,
(val) => {
if (val) {
resetForm()
loadConfig()
}
}
)
</script>
<style scoped>
:deep(.balance-script-dialog) {
max-height: 90vh;
display: flex;
flex-direction: column;
}
:deep(.balance-script-dialog .el-dialog__body) {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
:deep(.balance-script-dialog .el-dialog__footer) {
border-top: 1px solid rgba(229, 231, 235, 0.7);
}
.input-text {
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
}
</style>

View File

@@ -477,6 +477,36 @@
<i class="fas fa-check text-xs text-white"></i>
</div>
</label>
<label
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
:class="[
form.platform === 'gemini-antigravity'
? 'border-purple-500 bg-purple-50 dark:border-purple-400 dark:bg-purple-900/30'
: 'border-gray-300 bg-white hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700 dark:hover:border-purple-500 dark:hover:bg-purple-900/20'
]"
>
<input
v-model="form.platform"
class="sr-only"
type="radio"
value="gemini-antigravity"
/>
<div class="flex items-center gap-2">
<i class="fas fa-rocket text-sm text-purple-600 dark:text-purple-400"></i>
<div>
<span class="block text-xs font-medium text-gray-900 dark:text-gray-100"
>Antigravity</span
>
<span class="text-xs text-gray-500 dark:text-gray-400">OAuth</span>
</div>
</div>
<div
v-if="form.platform === 'gemini-antigravity'"
class="absolute right-1 top-1 flex h-4 w-4 items-center justify-center rounded-full bg-purple-500"
>
<i class="fas fa-check text-xs text-white"></i>
</div>
</label>
<label
class="group relative flex cursor-pointer items-center rounded-md border p-2 transition-all"
@@ -772,7 +802,7 @@
</div>
<!-- Gemini 项目 ID 字段 -->
<div v-if="form.platform === 'gemini'">
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>项目 ID (可选)</label
>
@@ -822,41 +852,194 @@
</div>
<!-- Bedrock 特定字段 -->
<div v-if="form.platform === 'bedrock' && !isEdit" class="space-y-4">
<div v-if="form.platform === 'bedrock'" class="space-y-4">
<!-- 凭证类型选择器 -->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 访问密钥 ID *</label
>凭证类型 *</label
>
<input
v-model="form.accessKeyId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.accessKeyId }"
placeholder="请输入 AWS Access Key ID"
required
type="text"
/>
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
{{ errors.accessKeyId }}
</p>
<div v-if="!isEdit" class="flex gap-4">
<label class="flex cursor-pointer items-center">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="access_key"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>AWS Access Key(访问密钥)</span
>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="radio"
value="bearer_token"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>Bearer Token长期令牌</span
>
</label>
</div>
<div v-else class="flex gap-4">
<label class="flex items-center opacity-60">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
disabled
type="radio"
value="access_key"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>AWS Access Key访问密钥</span
>
</label>
<label class="flex items-center opacity-60">
<input
v-model="form.credentialType"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
disabled
type="radio"
value="bearer_token"
/>
<span class="text-sm text-gray-700 dark:text-gray-300"
>Bearer Token长期令牌</span
>
</label>
</div>
<div
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
>
<div class="flex items-start gap-2">
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
<div class="text-xs text-blue-700 dark:text-blue-300">
<p v-if="form.credentialType === 'access_key'" class="font-medium">
使用 AWS Access Key ID 和 Secret Access Key 进行身份验证(支持临时凭证)
</p>
<p v-else class="font-medium">
使用 AWS Bedrock API Keys 生成的 Bearer Token
进行身份验证,更简单、权限范围更小
</p>
<p v-if="isEdit" class="mt-1 text-xs italic">
💡 编辑模式下凭证类型不可更改,如需切换类型请重新创建账户
</p>
</div>
</div>
</div>
</div>
<div>
<!-- AWS Access Key 字段(仅在 access_key 模式下显示)-->
<div v-if="form.credentialType === 'access_key'">
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 访问密钥 ID {{ isEdit ? '' : '*' }}</label
>
<input
v-model="form.accessKeyId"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.accessKeyId }"
:placeholder="isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Access Key ID'"
:required="!isEdit"
type="text"
/>
<p v-if="errors.accessKeyId" class="mt-1 text-xs text-red-500">
{{ errors.accessKeyId }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Access Key ID 不变
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 秘密访问密钥 {{ isEdit ? '' : '*' }}</label
>
<input
v-model="form.secretAccessKey"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.secretAccessKey }"
:placeholder="
isEdit ? '留空则保持原有凭证不变' : '请输入 AWS Secret Access Key'
"
:required="!isEdit"
type="password"
/>
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
{{ errors.secretAccessKey }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Secret Access Key 不变
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>会话令牌 (可选)</label
>
<input
v-model="form.sessionToken"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:placeholder="
isEdit
? '留空则保持原有 Session Token 不变'
: '如果使用临时凭证,请输入会话令牌'
"
type="password"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
仅在使用临时 AWS 凭证时需要填写
</p>
</div>
</div>
<!-- Bearer Token 字段(仅在 bearer_token 模式下显示)-->
<div v-if="form.credentialType === 'bearer_token'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 秘密访问密钥 *</label
>Bearer Token {{ isEdit ? '' : '*' }}</label
>
<input
v-model="form.secretAccessKey"
v-model="form.bearerToken"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
:class="{ 'border-red-500': errors.secretAccessKey }"
placeholder="请输入 AWS Secret Access Key"
required
:class="{ 'border-red-500': errors.bearerToken }"
:placeholder="
isEdit ? '留空则保持原有 Bearer Token 不变' : '请输入 AWS Bearer Token'
"
:required="!isEdit"
type="password"
/>
<p v-if="errors.secretAccessKey" class="mt-1 text-xs text-red-500">
{{ errors.secretAccessKey }}
<p v-if="errors.bearerToken" class="mt-1 text-xs text-red-500">
{{ errors.bearerToken }}
</p>
<p v-if="isEdit" class="mt-1 text-xs text-gray-500 dark:text-gray-400">
💡 编辑模式下,留空则保持原有 Bearer Token 不变
</p>
<div
class="mt-2 rounded-lg border border-green-200 bg-green-50 p-3 dark:border-green-700 dark:bg-green-900/30"
>
<div class="flex items-start gap-2">
<i class="fas fa-key mt-0.5 text-green-600 dark:text-green-400" />
<div class="text-xs text-green-700 dark:text-green-300">
<p class="mb-1 font-medium">Bearer Token 说明:</p>
<ul class="list-inside list-disc space-y-1 text-xs">
<li>输入 AWS Bedrock API Keys 生成的 Bearer Token</li>
<li>Bearer Token 仅限 Bedrock 服务访问,权限范围更小</li>
<li>相比 Access Key 更简单,无需 Secret Key</li>
<li>
参考:<a
class="text-green-600 underline dark:text-green-400"
href="https://aws.amazon.com/cn/blogs/machine-learning/accelerate-ai-development-with-amazon-bedrock-api-keys/"
target="_blank"
>AWS 官方文档</a
>
</li>
</ul>
</div>
</div>
</div>
</div>
<!-- AWS 区域(两种凭证类型都需要)-->
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>AWS 区域 *</label
@@ -872,10 +1055,12 @@
<p v-if="errors.region" class="mt-1 text-xs text-red-500">
{{ errors.region }}
</p>
<div class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3">
<div
class="mt-2 rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-700 dark:bg-blue-900/30"
>
<div class="flex items-start gap-2">
<i class="fas fa-info-circle mt-0.5 text-blue-600" />
<div class="text-xs text-blue-700">
<i class="fas fa-info-circle mt-0.5 text-blue-600 dark:text-blue-400" />
<div class="text-xs text-blue-700 dark:text-blue-300">
<p class="mb-1 font-medium">常用 AWS 区域参考:</p>
<div class="grid grid-cols-2 gap-1 text-xs">
<span>• us-east-1 (美国东部)</span>
@@ -885,27 +1070,14 @@
<span>• ap-northeast-1 (东京)</span>
<span>• eu-central-1 (法兰克福)</span>
</div>
<p class="mt-2 text-blue-600">💡 请输入完整的区域代码,如 us-east-1</p>
<p class="mt-2 text-blue-600 dark:text-blue-400">
💡 请输入完整的区域代码,如 us-east-1
</p>
</div>
</div>
</div>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>会话令牌 (可选)</label
>
<input
v-model="form.sessionToken"
class="form-input w-full border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200 dark:placeholder-gray-400"
placeholder="如果使用临时凭证,请输入会话令牌"
type="password"
/>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
仅在使用临时 AWS 凭证时需要填写
</p>
</div>
<div>
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>默认主模型 (可选)</label
@@ -1824,7 +1996,7 @@
Token建议也一并填写以支持自动刷新。
</p>
<p
v-else-if="form.platform === 'gemini'"
v-else-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'"
class="mb-2 text-sm text-blue-800 dark:text-blue-300"
>
请输入有效的 Gemini Access Token。如果您有 Refresh
@@ -1861,7 +2033,9 @@
文件中的凭证, 请勿使用 Claude 官网 API Keys 页面的密钥。
</p>
<p
v-else-if="form.platform === 'gemini'"
v-else-if="
form.platform === 'gemini' || form.platform === 'gemini-antigravity'
"
class="text-xs text-blue-800 dark:text-blue-300"
>
请从已登录 Gemini CLI 的机器上获取
@@ -2591,7 +2765,7 @@
</div>
<!-- Gemini 项目 ID 字段 -->
<div v-if="form.platform === 'gemini'">
<div v-if="form.platform === 'gemini' || form.platform === 'gemini-antigravity'">
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>项目 ID (可选)</label
>
@@ -3805,10 +3979,9 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { showToast } from '@/utils/tools'
import * as httpApi from '@/utils/http_apis'
import { getModels } from '@/utils/http_apis'
import * as httpApis from '@/utils/http_apis'
import { useAccountsStore } from '@/stores/accounts'
import { useConfirm } from '@/composables/useConfirm'
import ProxyConfig from './ProxyConfig.vue'
import OAuthFlow from './OAuthFlow.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
@@ -3825,7 +3998,28 @@ const props = defineProps({
const emit = defineEmits(['close', 'success', 'platform-changed'])
const accountsStore = useAccountsStore()
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
// 确认弹窗状态
const showConfirmModal = ref(false)
const confirmOptions = ref({ title: '', message: '', confirmText: '继续', cancelText: '取消' })
let confirmResolve = null
const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
return new Promise((resolve) => {
confirmOptions.value = { title, message, confirmText, cancelText }
confirmResolve = resolve
showConfirmModal.value = true
})
}
const handleConfirm = () => {
showConfirmModal.value = false
confirmResolve?.(true)
confirmResolve = null
}
const handleCancel = () => {
showConfirmModal.value = false
confirmResolve?.(false)
confirmResolve = null
}
// 是否为编辑模式
const isEdit = computed(() => !!props.account)
@@ -3881,7 +4075,7 @@ const determinePlatformGroup = (platform) => {
return 'claude'
} else if (['openai', 'openai-responses', 'azure_openai'].includes(platform)) {
return 'openai'
} else if (['gemini', 'gemini-api'].includes(platform)) {
} else if (['gemini', 'gemini-antigravity', 'gemini-api'].includes(platform)) {
return 'gemini'
} else if (platform === 'droid') {
return 'droid'
@@ -4016,7 +4210,8 @@ const form = ref({
platform: props.account?.platform || 'claude',
addType: (() => {
const platform = props.account?.platform || 'claude'
if (platform === 'gemini' || platform === 'openai') return 'oauth'
if (platform === 'gemini' || platform === 'gemini-antigravity' || platform === 'openai')
return 'oauth'
if (platform === 'claude') return 'oauth'
return 'manual'
})(),
@@ -4073,10 +4268,12 @@ const form = ref({
// 并发控制字段
maxConcurrentTasks: props.account?.maxConcurrentTasks || 0,
// Bedrock 特定字段
credentialType: props.account?.credentialType || 'access_key', // 'access_key' 或 'bearer_token'
accessKeyId: props.account?.accessKeyId || '',
secretAccessKey: props.account?.secretAccessKey || '',
region: props.account?.region || '',
sessionToken: props.account?.sessionToken || '',
bearerToken: props.account?.bearerToken || '', // Bearer Token 字段
defaultModel: props.account?.defaultModel || '',
smallFastModel: props.account?.smallFastModel || '',
// Azure OpenAI 特定字段
@@ -4117,7 +4314,7 @@ const commonModels = ref([])
// 加载模型列表
const loadCommonModels = async () => {
try {
const result = await getModels()
const result = await httpApis.getModelsApi()
if (result.success && result.data?.all) {
commonModels.value = result.data.all
}
@@ -4239,6 +4436,7 @@ const errors = ref({
accessKeyId: '',
secretAccessKey: '',
region: '',
bearerToken: '',
azureEndpoint: '',
deploymentName: ''
})
@@ -4331,7 +4529,7 @@ const loadAccountUsage = async () => {
if (!isEdit.value || !props.account?.id) return
try {
const response = await httpApi.get(`/admin/claude-console-accounts/${props.account.id}/usage`)
const response = await httpApis.getClaudeConsoleAccountUsageApi(props.account.id)
if (response) {
// 更新表单中的使用量数据
form.value.dailyUsage = response.dailyUsage || 0
@@ -4358,7 +4556,7 @@ const selectPlatformGroup = (group) => {
} else if (group === 'openai') {
form.value.platform = 'openai'
} else if (group === 'gemini') {
form.value.platform = 'gemini'
form.value.platform = 'gemini' // Default to Gemini CLI, user can select Antigravity
} else if (group === 'droid') {
form.value.platform = 'droid'
}
@@ -4395,7 +4593,11 @@ const nextStep = async () => {
}
// 对于Gemini账户检查项目 ID
if (form.value.platform === 'gemini' && oauthStep.value === 1 && form.value.addType === 'oauth') {
if (
(form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') &&
oauthStep.value === 1 &&
form.value.addType === 'oauth'
) {
if (!form.value.projectId || form.value.projectId.trim() === '') {
// 使用自定义确认弹窗
const confirmed = await showConfirm(
@@ -4768,9 +4970,14 @@ const handleOAuthSuccess = async (tokenInfoOrList) => {
hasClaudePro: form.value.subscriptionType === 'claude_pro',
manuallySet: true // 标记为手动设置
}
} else if (currentPlatform === 'gemini') {
// Gemini使用geminiOauth字段
} else if (currentPlatform === 'gemini' || currentPlatform === 'gemini-antigravity') {
// Gemini/Antigravity使用geminiOauth字段
data.geminiOauth = tokenInfo.tokens || tokenInfo
// 根据 platform 设置 oauthProvider
data.oauthProvider =
currentPlatform === 'gemini-antigravity'
? 'antigravity'
: tokenInfo.oauthProvider || 'gemini-cli'
if (form.value.projectId) {
data.projectId = form.value.projectId
}
@@ -4942,14 +5149,27 @@ const createAccount = async () => {
hasError = true
}
} else if (form.value.platform === 'bedrock') {
// Bedrock 验证
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
hasError = true
}
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') {
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
hasError = true
// Bedrock 验证 - 根据凭证类型进行不同验证
if (form.value.credentialType === 'access_key') {
// Access Key 模式:创建时必填,编辑时可选(留空则保持原有凭证)
if (!isEdit.value) {
if (!form.value.accessKeyId || form.value.accessKeyId.trim() === '') {
errors.value.accessKeyId = '请填写 AWS 访问密钥 ID'
hasError = true
}
if (!form.value.secretAccessKey || form.value.secretAccessKey.trim() === '') {
errors.value.secretAccessKey = '请填写 AWS 秘密访问密钥'
hasError = true
}
}
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:创建时必填,编辑时可选(留空则保持原有凭证)
if (!isEdit.value) {
if (!form.value.bearerToken || form.value.bearerToken.trim() === '') {
errors.value.bearerToken = '请填写 Bearer Token'
hasError = true
}
}
}
if (!form.value.region || form.value.region.trim() === '') {
errors.value.region = '请选择 AWS 区域'
@@ -5192,6 +5412,10 @@ const createAccount = async () => {
data.rateLimitDuration = 60 // 默认值60不从用户输入获取
data.dailyQuota = form.value.dailyQuota || 0
data.quotaResetTime = form.value.quotaResetTime || '00:00'
} else if (form.value.platform === 'gemini-antigravity') {
// Antigravity OAuth - set oauthProvider, submission happens below
data.oauthProvider = 'antigravity'
data.priority = form.value.priority || 50
} else if (form.value.platform === 'gemini-api') {
// Gemini API 账户特定数据
data.baseUrl = form.value.baseUrl || 'https://generativelanguage.googleapis.com'
@@ -5201,12 +5425,21 @@ const createAccount = async () => {
? form.value.supportedModels
: []
} else if (form.value.platform === 'bedrock') {
// Bedrock 账户特定数据 - 构造 awsCredentials 对象
data.awsCredentials = {
accessKeyId: form.value.accessKeyId,
secretAccessKey: form.value.secretAccessKey,
sessionToken: form.value.sessionToken || null
// Bedrock 账户特定数据
data.credentialType = form.value.credentialType || 'access_key'
// 根据凭证类型构造不同的凭证对象
if (form.value.credentialType === 'access_key') {
data.awsCredentials = {
accessKeyId: form.value.accessKeyId,
secretAccessKey: form.value.secretAccessKey,
sessionToken: form.value.sessionToken || null
}
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:必须传递 Bearer Token
data.bearerToken = form.value.bearerToken
}
data.region = form.value.region
data.defaultModel = form.value.defaultModel || null
data.smallFastModel = form.value.smallFastModel || null
@@ -5243,7 +5476,7 @@ const createAccount = async () => {
result = await accountsStore.createOpenAIAccount(data)
} else if (form.value.platform === 'azure_openai') {
result = await accountsStore.createAzureOpenAIAccount(data)
} else if (form.value.platform === 'gemini') {
} else if (form.value.platform === 'gemini' || form.value.platform === 'gemini-antigravity') {
result = await accountsStore.createGeminiAccount(data)
} else if (form.value.platform === 'gemini-api') {
result = await accountsStore.createGeminiApiAccount(data)
@@ -5534,19 +5767,33 @@ const updateAccount = async () => {
// Bedrock 特定更新
if (props.account.platform === 'bedrock') {
// 只有当有凭证变更时才构造 awsCredentials 对象
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
data.awsCredentials = {}
if (form.value.accessKeyId) {
data.awsCredentials.accessKeyId = form.value.accessKeyId
// 更新凭证类型
if (form.value.credentialType) {
data.credentialType = form.value.credentialType
}
// 根据凭证类型更新凭证
if (form.value.credentialType === 'access_key') {
// 只有当有凭证变更时才构造 awsCredentials 对象
if (form.value.accessKeyId || form.value.secretAccessKey || form.value.sessionToken) {
data.awsCredentials = {}
if (form.value.accessKeyId) {
data.awsCredentials.accessKeyId = form.value.accessKeyId
}
if (form.value.secretAccessKey) {
data.awsCredentials.secretAccessKey = form.value.secretAccessKey
}
if (form.value.sessionToken !== undefined) {
data.awsCredentials.sessionToken = form.value.sessionToken || null
}
}
if (form.value.secretAccessKey) {
data.awsCredentials.secretAccessKey = form.value.secretAccessKey
}
if (form.value.sessionToken !== undefined) {
data.awsCredentials.sessionToken = form.value.sessionToken || null
} else if (form.value.credentialType === 'bearer_token') {
// Bearer Token 模式:更新 Bearer Token编辑时可选留空则保留原有凭证
if (form.value.bearerToken && form.value.bearerToken.trim()) {
data.bearerToken = form.value.bearerToken
}
}
if (form.value.region) {
data.region = form.value.region
}
@@ -5734,7 +5981,7 @@ const filteredGroups = computed(() => {
const loadGroups = async () => {
loadingGroups.value = true
try {
const response = await httpApi.get('/admin/account-groups')
const response = await httpApis.getAccountGroupsApi()
groups.value = response.data || []
} catch (error) {
showToast('加载分组列表失败', 'error')
@@ -6187,7 +6434,7 @@ watch(
// 否则查找账户所属的分组
const checkPromises = groups.value.map(async (group) => {
try {
const response = await httpApi.get(`/admin/account-groups/${group.id}/members`)
const response = await httpApis.getAccountGroupMembersApi(group.id)
const members = response.data || []
if (members.some((m) => m.id === newAccount.id)) {
foundGroupIds.push(group.id)
@@ -6215,7 +6462,7 @@ watch(
// 获取统一 User-Agent 信息
const fetchUnifiedUserAgent = async () => {
try {
const response = await httpApi.get('/admin/claude-code-version')
const response = await httpApis.getClaudeCodeVersionApi()
if (response.success && response.userAgent) {
unifiedUserAgent.value = response.userAgent
} else {
@@ -6231,7 +6478,7 @@ const fetchUnifiedUserAgent = async () => {
const clearUnifiedCache = async () => {
clearingCache.value = true
try {
const response = await httpApi.post('/admin/claude-code-version/clear')
const response = await httpApis.clearClaudeCodeVersionApi()
if (response.success) {
unifiedUserAgent.value = ''
showToast('统一User-Agent缓存已清除', 'success')

View File

@@ -220,7 +220,7 @@
<script setup>
import { ref, watch } from 'vue'
import { API_PREFIX } from '@/utils/http_apis'
import { APP_CONFIG } from '@/utils/tools'
import { showToast } from '@/utils/tools'
const props = defineProps({
@@ -287,7 +287,7 @@ async function loadConfig() {
// 根据平台获取配置端点
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
endpoint = `${APP_CONFIG.apiPrefix}/admin/claude-accounts/${props.account.id}/test-config`
} else {
// 其他平台暂不支持
loading.value = false
@@ -344,7 +344,7 @@ async function saveConfig() {
let endpoint = ''
if (platform === 'claude') {
endpoint = `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test-config`
endpoint = `${APP_CONFIG.apiPrefix}/admin/claude-accounts/${props.account.id}/test-config`
} else {
saving.value = false
return

View File

@@ -68,6 +68,22 @@
{{ platformLabel }}
</span>
</div>
<!-- Bedrock 账号类型 -->
<div
v-if="props.account?.platform === 'bedrock'"
class="flex items-center justify-between text-sm"
>
<span class="text-gray-500 dark:text-gray-400">账号类型</span>
<span
:class="[
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
credentialTypeBadgeClass
]"
>
<i :class="credentialTypeIcon" />
{{ credentialTypeLabel }}
</span>
</div>
<div class="flex items-center justify-between text-sm">
<span class="text-gray-500 dark:text-gray-400">测试模型</span>
<select
@@ -183,7 +199,7 @@
<script setup>
import { ref, computed, watch, onUnmounted } from 'vue'
import { API_PREFIX } from '@/utils/http_apis'
import { APP_CONFIG } from '@/utils/tools'
const props = defineProps({
show: {
@@ -313,6 +329,36 @@ const platformBadgeClass = computed(() => {
return classes[platform] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
})
// Bedrock 账号类型相关
const credentialTypeLabel = computed(() => {
if (!props.account || props.account.platform !== 'bedrock') return ''
const credentialType = props.account.credentialType
if (credentialType === 'access_key') return 'Access Key'
if (credentialType === 'bearer_token') return 'Bearer Token'
return 'Unknown'
})
const credentialTypeIcon = computed(() => {
if (!props.account || props.account.platform !== 'bedrock') return ''
const credentialType = props.account.credentialType
if (credentialType === 'access_key') return 'fas fa-key'
if (credentialType === 'bearer_token') return 'fas fa-ticket'
return 'fas fa-question'
})
const credentialTypeBadgeClass = computed(() => {
if (!props.account || props.account.platform !== 'bedrock')
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
const credentialType = props.account.credentialType
if (credentialType === 'access_key') {
return 'bg-blue-100 text-blue-700 dark:bg-blue-500/20 dark:text-blue-300'
}
if (credentialType === 'bearer_token') {
return 'bg-green-100 text-green-700 dark:bg-green-500/20 dark:text-green-300'
}
return 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300'
})
const statusTitle = computed(() => {
switch (testStatus.value) {
case 'idle':
@@ -424,14 +470,14 @@ function getTestEndpoint() {
if (!props.account) return ''
const platform = props.account.platform
const endpoints = {
claude: `${API_PREFIX}/admin/claude-accounts/${props.account.id}/test`,
'claude-console': `${API_PREFIX}/admin/claude-console-accounts/${props.account.id}/test`,
bedrock: `${API_PREFIX}/admin/bedrock-accounts/${props.account.id}/test`,
gemini: `${API_PREFIX}/admin/gemini-accounts/${props.account.id}/test`,
'openai-responses': `${API_PREFIX}/admin/openai-responses-accounts/${props.account.id}/test`,
'azure-openai': `${API_PREFIX}/admin/azure-openai-accounts/${props.account.id}/test`,
droid: `${API_PREFIX}/admin/droid-accounts/${props.account.id}/test`,
ccr: `${API_PREFIX}/admin/ccr-accounts/${props.account.id}/test`
claude: `${APP_CONFIG.apiPrefix}/admin/claude-accounts/${props.account.id}/test`,
'claude-console': `${APP_CONFIG.apiPrefix}/admin/claude-console-accounts/${props.account.id}/test`,
bedrock: `${APP_CONFIG.apiPrefix}/admin/bedrock-accounts/${props.account.id}/test`,
gemini: `${APP_CONFIG.apiPrefix}/admin/gemini-accounts/${props.account.id}/test`,
'openai-responses': `${APP_CONFIG.apiPrefix}/admin/openai-responses-accounts/${props.account.id}/test`,
'azure-openai': `${APP_CONFIG.apiPrefix}/admin/azure-openai-accounts/${props.account.id}/test`,
droid: `${APP_CONFIG.apiPrefix}/admin/droid-accounts/${props.account.id}/test`,
ccr: `${APP_CONFIG.apiPrefix}/admin/ccr-accounts/${props.account.id}/test`
}
return endpoints[platform] || ''
}
@@ -571,7 +617,7 @@ function handleClose() {
emit('close')
}
// 监听show变化重置状态
// 监听show变化重置状态并设置测试模型
watch(
() => props.show,
(newVal) => {
@@ -580,6 +626,21 @@ watch(
responseText.value = ''
errorMessage.value = ''
testDuration.value = 0
// 根据平台和账号类型设置测试模型
if (props.account?.platform === 'bedrock') {
const credentialType = props.account.credentialType
if (credentialType === 'bearer_token') {
// Bearer Token 模式使用 Sonnet 4.5
selectedModel.value = 'us.anthropic.claude-sonnet-4-5-20250929-v1:0'
} else {
// Access Key 模式使用 Haiku更快更便宜
selectedModel.value = 'us.anthropic.claude-3-5-haiku-20241022-v1:0'
}
} else {
// 其他平台使用默认模型
selectedModel.value = 'claude-sonnet-4-5-20250929'
}
}
}
)

View File

@@ -338,6 +338,8 @@ import { storeToRefs } from 'pinia'
import Chart from 'chart.js/auto'
import { useThemeStore } from '@/stores/theme'
import { formatNumber } from '@/utils/tools'
const props = defineProps({
show: { type: Boolean, default: false },
account: { type: Object, default: () => ({}) },
@@ -364,7 +366,8 @@ const platformLabelMap = {
'openai-responses': 'OpenAI Responses',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
droid: 'Droid'
droid: 'Droid',
bedrock: 'Claude AWS Bedrock'
}
const platformLabel = computed(() => platformLabelMap[props.account?.platform] || '未知平台')
@@ -388,13 +391,6 @@ const totalTokens = computed(() => props.summary?.totalTokens || 0)
const overviewInputTokens = computed(() => props.overview?.total?.inputTokens || 0)
const overviewOutputTokens = computed(() => props.overview?.total?.outputTokens || 0)
const formatNumber = (value) => {
const num = Number(value || 0)
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(2)}M`
if (num >= 1_000) return `${(num / 1_000).toFixed(2)}K`
return num.toLocaleString()
}
const formatCost = (value) => {
const num = Number(value || 0)
if (Number.isNaN(num)) return '$0.000000'
@@ -410,9 +406,7 @@ const formatDate = (value) => {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
const parts = value.split('-')
if (parts.length === 3) {
return `${parts[1]}-${parts[2]}`
}
if (parts.length === 3) return `${parts[1]}-${parts[2]}`
return value
}
const month = String(date.getMonth() + 1).padStart(2, '0')

View File

@@ -397,7 +397,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { showToast } from '@/utils/tools'
import * as httpApi from '@/utils/http_apis'
import { getDroidAccountByIdApi, updateDroidAccountApi } from '@/utils/http_apis'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const props = defineProps({
@@ -518,7 +518,7 @@ const errorKeysCount = computed(() => {
const loadApiKeys = async () => {
loading.value = true
try {
const response = await httpApi.get(`/admin/droid-accounts/${props.accountId}`)
const response = await getDroidAccountByIdApi(props.accountId)
const account = response.data
// 解析 apiKeys
@@ -613,7 +613,7 @@ const deleteApiKey = async (apiKey) => {
apiKeyUpdateMode: 'delete'
}
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
await updateDroidAccountApi(props.accountId, updateData)
showToast('API Key 已删除', 'success')
await loadApiKeys()
@@ -654,7 +654,7 @@ const resetApiKeyStatus = async (apiKey) => {
apiKeyUpdateMode: 'update'
}
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
await updateDroidAccountApi(props.accountId, updateData)
showToast('API Key 状态已重置', 'success')
await loadApiKeys()
@@ -695,7 +695,7 @@ const deleteAllErrorKeys = async () => {
apiKeyUpdateMode: 'delete'
}
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
await updateDroidAccountApi(props.accountId, updateData)
showToast(`成功删除 ${errorKeys.length} 个异常 API Key`, 'success')
await loadApiKeys()
@@ -742,7 +742,7 @@ const deleteAllKeys = async () => {
apiKeyUpdateMode: 'delete'
}
await httpApi.put(`/admin/droid-accounts/${props.accountId}`, updateData)
await updateDroidAccountApi(props.accountId, updateData)
showToast(`成功删除所有 ${keysToDelete.length} 个 API Key`, 'success')
await loadApiKeys()

View File

@@ -0,0 +1,361 @@
<template>
<div class="min-w-[200px] space-y-1">
<div v-if="loading" class="flex items-center gap-2">
<i class="fas fa-spinner fa-spin text-gray-400 dark:text-gray-500"></i>
<span class="text-xs text-gray-500 dark:text-gray-400">加载中...</span>
</div>
<div v-else-if="requestError" class="flex items-center gap-2">
<i class="fas fa-exclamation-circle text-red-500"></i>
<span class="text-xs text-red-600 dark:text-red-400">{{ requestError }}</span>
<button
class="text-xs text-blue-500 hover:text-blue-600 dark:text-blue-400"
:disabled="refreshing"
@click="reload"
>
重试
</button>
</div>
<div v-else-if="balanceData" class="space-y-1">
<div v-if="balanceData.status === 'error' && balanceData.error" class="text-xs text-red-500">
{{ balanceData.error }}
</div>
<div class="flex items-center justify-between gap-2">
<div class="flex items-center gap-2">
<i
class="fas"
:class="
balanceData.balance
? 'fa-wallet text-green-600 dark:text-green-400'
: 'fa-chart-line text-gray-500 dark:text-gray-400'
"
></i>
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ primaryText }}
</span>
<span class="rounded px-1.5 py-0.5 text-xs" :class="sourceClass">
{{ sourceLabel }}
</span>
</div>
<button
v-if="!hideRefresh"
class="text-xs text-gray-500 hover:text-blue-600 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-400 dark:hover:text-blue-400"
:disabled="refreshing || !canRefresh"
:title="refreshTitle"
@click="refresh"
>
<i class="fas fa-sync-alt" :class="{ 'fa-spin': refreshing }"></i>
</button>
</div>
<!-- 配额如适用 -->
<div v-if="quotaInfo && isAntigravityQuota" class="space-y-2">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>剩余</span>
<span>{{ formatQuotaNumber(quotaInfo.remaining) }}</span>
</div>
<div class="space-y-1">
<div
v-for="row in antigravityRows"
:key="row.category"
class="flex items-center gap-2 rounded-md bg-gray-50 px-2 py-1.5 dark:bg-gray-700/60"
>
<span class="h-2 w-2 shrink-0 rounded-full" :class="row.dotClass"></span>
<span
class="min-w-0 flex-1 truncate text-xs font-medium text-gray-800 dark:text-gray-100"
:title="row.category"
>
{{ row.category }}
</span>
<div class="flex w-[94px] flex-col gap-0.5">
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-600">
<div
class="h-1.5 rounded-full transition-all"
:class="row.barClass"
:style="{ width: `${row.remainingPercent ?? 0}%` }"
></div>
</div>
<div
class="flex items-center justify-between text-[11px] text-gray-500 dark:text-gray-300"
>
<span>{{ row.remainingText }}</span>
<span v-if="row.resetAt" class="text-gray-400 dark:text-gray-400">{{
formatResetTime(row.resetAt)
}}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else-if="quotaInfo" class="space-y-1">
<div class="flex items-center justify-between text-xs text-gray-600 dark:text-gray-400">
<span>已用: {{ formatQuotaNumber(quotaInfo.used) }}</span>
<span>剩余: {{ formatQuotaNumber(quotaInfo.remaining) }}</span>
</div>
<div class="h-1.5 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-1.5 rounded-full transition-all"
:class="quotaBarClass"
:style="{ width: `${Math.min(100, quotaInfo.percentage)}%` }"
></div>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-gray-500 dark:text-gray-400">
{{ quotaInfo.percentage.toFixed(1) }}% 已使用
</span>
<span v-if="quotaInfo.resetAt" class="text-gray-400 dark:text-gray-500">
重置: {{ formatResetTime(quotaInfo.resetAt) }}
</span>
</div>
</div>
<div v-else-if="balanceData.quota?.unlimited" class="flex items-center gap-2">
<i class="fas fa-infinity text-blue-500 dark:text-blue-400"></i>
<span class="text-xs text-gray-600 dark:text-gray-400">无限制</span>
</div>
<div
v-if="balanceData.cacheExpiresAt && balanceData.source === 'cache'"
class="text-xs text-gray-400 dark:text-gray-500"
>
缓存至: {{ formatCacheExpiry(balanceData.cacheExpiresAt) }}
</div>
</div>
<div v-else class="text-xs text-gray-400 dark:text-gray-500">暂无余额数据</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { getAccountBalanceApi, refreshAccountBalanceApi } from '@/utils/http_apis'
import { formatNumber } from '@/utils/tools'
const props = defineProps({
accountId: { type: String, required: true },
platform: { type: String, required: true },
initialBalance: { type: Object, default: null },
hideRefresh: { type: Boolean, default: false },
autoLoad: { type: Boolean, default: true },
queryMode: { type: String, default: 'local' } // local | auto | api
})
const emit = defineEmits(['refreshed', 'error'])
const balanceData = ref(props.initialBalance)
const loading = ref(false)
const refreshing = ref(false)
const requestError = ref(null)
const sourceClass = computed(() => {
const source = balanceData.value?.source
return {
'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300': source === 'api',
'bg-gray-100 text-gray-600 dark:bg-gray-700/60 dark:text-gray-300': source === 'cache',
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300': source === 'local'
}
})
const sourceLabel = computed(() => {
const source = balanceData.value?.source
return { api: 'API', cache: '缓存', local: '本地' }[source] || '未知'
})
const quotaInfo = computed(() => {
const quota = balanceData.value?.quota
if (!quota || quota.unlimited) return null
if (typeof quota.percentage !== 'number' || !Number.isFinite(quota.percentage)) return null
return {
used: quota.used ?? 0,
remaining: quota.remaining ?? 0,
percentage: quota.percentage,
resetAt: quota.resetAt || null
}
})
const isAntigravityQuota = computed(() => {
return balanceData.value?.quota?.type === 'antigravity'
})
const antigravityRows = computed(() => {
if (!isAntigravityQuota.value) return []
const buckets = balanceData.value?.quota?.buckets
const list = Array.isArray(buckets) ? buckets : []
const map = new Map(list.map((b) => [b?.category, b]))
const order = ['Gemini Pro', 'Claude', 'Gemini Flash', 'Gemini Image']
const styles = {
'Gemini Pro': { dotClass: 'bg-blue-500', barClass: 'bg-blue-500 dark:bg-blue-400' },
Claude: { dotClass: 'bg-purple-500', barClass: 'bg-purple-500 dark:bg-purple-400' },
'Gemini Flash': { dotClass: 'bg-cyan-500', barClass: 'bg-cyan-500 dark:bg-cyan-400' },
'Gemini Image': { dotClass: 'bg-emerald-500', barClass: 'bg-emerald-500 dark:bg-emerald-400' }
}
return order.map((category) => {
const raw = map.get(category) || null
const remaining = raw?.remaining
const remainingPercent = Number.isFinite(Number(remaining))
? Math.max(0, Math.min(100, Number(remaining)))
: null
return {
category,
remainingPercent,
remainingText: remainingPercent === null ? '—' : `${Math.round(remainingPercent)}%`,
resetAt: raw?.resetAt || null,
dotClass: styles[category]?.dotClass || 'bg-gray-400',
barClass: styles[category]?.barClass || 'bg-gray-400'
}
})
})
const quotaBarClass = computed(() => {
const percentage = quotaInfo.value?.percentage || 0
if (percentage >= 90) return 'bg-red-500 dark:bg-red-600'
if (percentage >= 70) return 'bg-yellow-500 dark:bg-yellow-600'
return 'bg-green-500 dark:bg-green-600'
})
const canRefresh = computed(() => {
// antigravity 配额:允许直接触发 Provider 刷新(无需脚本)
if (props.queryMode === 'api' || props.queryMode === 'auto') {
return true
}
// 其他平台:仅在“已启用脚本且该账户配置了脚本”时允许刷新,避免误导(非脚本 Provider 多为降级策略)
const data = balanceData.value
if (!data) return false
if (data.scriptEnabled === false) return false
return !!data.scriptConfigured
})
const refreshTitle = computed(() => {
if (refreshing.value) return '刷新中...'
if (!canRefresh.value) {
if (balanceData.value?.scriptEnabled === false) {
return '余额脚本功能已禁用'
}
return '请先配置余额脚本'
}
if (isAntigravityQuota.value) {
return '刷新配额(调用 Antigravity API'
}
return '刷新余额(调用脚本配置的余额 API'
})
const primaryText = computed(() => {
if (balanceData.value?.balance?.formattedAmount) {
return balanceData.value.balance.formattedAmount
}
const dailyCost = Number(balanceData.value?.statistics?.dailyCost || 0)
return `今日成本 ${formatCurrency(dailyCost)}`
})
const load = async () => {
if (!props.autoLoad) return
if (!props.accountId || !props.platform) return
loading.value = true
requestError.value = null
const params = {
platform: props.platform,
queryApi: props.queryMode === 'api' ? true : props.queryMode === 'auto' ? 'auto' : false
}
const response = await getAccountBalanceApi(props.accountId, params)
if (response?.success) {
balanceData.value = response.data
} else {
requestError.value = response?.error || '加载失败'
}
loading.value = false
}
const refresh = async () => {
if (!props.accountId || !props.platform) return
if (refreshing.value) return
if (!canRefresh.value) return
refreshing.value = true
requestError.value = null
const response = await refreshAccountBalanceApi(props.accountId, { platform: props.platform })
if (response?.success) {
balanceData.value = response.data
emit('refreshed', response.data)
} else {
requestError.value = response?.error || '刷新失败'
}
refreshing.value = false
}
const reload = async () => {
await load()
}
const formatQuotaNumber = (num) => {
if (num === Infinity) return '∞'
const value = Number(num)
if (!Number.isFinite(value)) return 'N/A'
if (isAntigravityQuota.value) {
return `${Math.round(value)}%`
}
return formatNumber(value)
}
const formatCurrency = (amount) => {
const value = Number(amount)
if (!Number.isFinite(value)) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
if (value >= 0.01) return `$${value.toFixed(3)}`
return `$${value.toFixed(6)}`
}
const formatResetTime = (isoString) => {
const date = new Date(isoString)
const now = new Date()
const diff = date.getTime() - now.getTime()
if (!Number.isFinite(diff)) return '未知'
if (diff < 0) return '已过期'
const minutes = Math.floor(diff / (1000 * 60))
const hours = Math.floor(minutes / 60)
const remainMinutes = minutes % 60
if (hours >= 24) {
const days = Math.floor(hours / 24)
return `${days}天后`
}
return `${hours}小时${remainMinutes}分钟`
}
const formatCacheExpiry = (isoString) => {
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
watch(
() => props.initialBalance,
(newVal) => {
if (newVal) {
balanceData.value = newVal
}
}
)
onMounted(() => {
if (!props.initialBalance) {
load()
}
})
defineExpose({ refresh, reload })
</script>

View File

@@ -259,7 +259,7 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import * as httpApi from '@/utils/http_apis'
import { updateCcrAccountApi, createCcrAccountApi } from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
import ProxyConfig from '@/components/accounts/ProxyConfig.vue'
@@ -344,7 +344,7 @@ const submit = async () => {
if (form.value.apiKey && form.value.apiKey.trim().length > 0) {
updates.apiKey = form.value.apiKey
}
const res = await httpApi.put(`/admin/ccr-accounts/${props.account.id}`, updates)
const res = await updateCcrAccountApi(props.account.id, updates)
if (res.success) {
// 不在这里显示 toast由父组件统一处理
emit('success')
@@ -367,7 +367,7 @@ const submit = async () => {
dailyQuota: Number(form.value.dailyQuota || 0),
quotaResetTime: form.value.quotaResetTime || '00:00'
}
const res = await httpApi.post('/admin/ccr-accounts', payload)
const res = await createCcrAccountApi(payload)
if (res.success) {
// 不在这里显示 toast由父组件统一处理
emit('success')

View File

@@ -11,7 +11,9 @@
>
<i class="fas fa-layer-group text-sm text-white sm:text-base" />
</div>
<h3 class="text-lg font-bold text-gray-900 sm:text-xl">账户分组管理</h3>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100 sm:text-xl">
账户分组管理
</h3>
</div>
<button
class="p-1 text-gray-400 transition-colors hover:text-gray-600"
@@ -151,7 +153,7 @@
>
<div class="modal-content w-full max-w-lg p-4 sm:p-6">
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-bold text-gray-900">编辑分组</h3>
<h3 class="text-lg font-bold text-gray-900 dark:text-gray-100">编辑分组</h3>
<button class="text-gray-400 transition-colors hover:text-gray-600" @click="cancelEdit">
<i class="fas fa-times" />
</button>
@@ -303,8 +305,9 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { showToast } from '@/utils/tools'
import * as httpApi from '@/utils/http_apis'
import { showToast, formatDate } from '@/utils/tools'
import * as httpApis from '@/utils/http_apis'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const emit = defineEmits(['close', 'refresh'])
@@ -362,17 +365,12 @@ const editForm = ref({
})
// 格式化日期
const formatDate = (dateStr) => {
if (!dateStr) return '-'
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN')
}
// 加载分组列表
const loadGroups = async () => {
loading.value = true
try {
const response = await httpApi.get('/admin/account-groups')
const response = await httpApis.getAccountGroupsApi()
groups.value = response.data || []
} catch (error) {
showToast('加载分组列表失败', 'error')
@@ -390,7 +388,7 @@ const createGroup = async () => {
creating.value = true
try {
await httpApi.post('/admin/account-groups', {
await httpApis.createAccountGroupApi({
name: createForm.value.name,
platform: createForm.value.platform,
description: createForm.value.description
@@ -443,7 +441,7 @@ const updateGroup = async () => {
updating.value = true
try {
await httpApi.put(`/admin/account-groups/${editingGroup.value.id}`, {
await httpApis.updateAccountGroupApi(editingGroup.value.id, {
name: editForm.value.name,
description: editForm.value.description
})
@@ -484,7 +482,7 @@ const deleteGroup = (group) => {
const confirmDelete = async () => {
if (!deletingGroup.value) return
try {
await httpApi.del(`/admin/account-groups/${deletingGroup.value.id}`)
await httpApis.deleteAccountGroupApi(deletingGroup.value.id)
showToast('分组删除成功', 'success')
cancelDelete()
await loadGroups()

View File

@@ -287,7 +287,7 @@
</div>
<!-- Gemini OAuth流程 -->
<div v-else-if="platform === 'gemini'">
<div v-else-if="platform === 'gemini' || platform === 'gemini-antigravity'">
<div
class="rounded-lg border border-green-200 bg-green-50 p-6 dark:border-green-700 dark:bg-green-900/30"
>
@@ -303,6 +303,16 @@
请按照以下步骤完成 Gemini 账户的授权
</p>
<!-- 授权来源显示由平台类型决定 -->
<div class="mb-4">
<p class="text-sm text-green-800 dark:text-green-300">
<i class="fas fa-info-circle mr-1"></i>
授权类型<span class="font-semibold">{{
platform === 'gemini-antigravity' ? 'Antigravity OAuth' : 'Gemini CLI OAuth'
}}</span>
</p>
</div>
<div class="space-y-4">
<!-- 步骤1: 生成授权链接 -->
<div
@@ -818,6 +828,13 @@ const exchanging = ref(false)
const authUrl = ref('')
const authCode = ref('')
const copied = ref(false)
// oauthProvider is now derived from platform prop
const geminiOauthProvider = computed(() => {
if (props.platform === 'gemini-antigravity') {
return 'antigravity'
}
return 'gemini-cli'
})
const sessionId = ref('') // 保存sessionId用于后续交换
const userCode = ref('')
const verificationUri = ref('')
@@ -921,7 +938,11 @@ watch(authCode, (newValue) => {
console.error('Failed to parse URL:', error)
showToast('链接格式错误,请检查是否为完整的 URL', 'error')
}
} else if (props.platform === 'gemini' || props.platform === 'openai') {
} else if (
props.platform === 'gemini' ||
props.platform === 'gemini-antigravity' ||
props.platform === 'openai'
) {
// Gemini 和 OpenAI 平台可能使用不同的回调URL
// 尝试从任何URL中提取code参数
try {
@@ -972,8 +993,11 @@ const generateAuthUrl = async () => {
const result = await accountsStore.generateClaudeAuthUrl(proxyConfig)
authUrl.value = result.authUrl
sessionId.value = result.sessionId
} else if (props.platform === 'gemini') {
const result = await accountsStore.generateGeminiAuthUrl(proxyConfig)
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
const result = await accountsStore.generateGeminiAuthUrl({
...proxyConfig,
oauthProvider: geminiOauthProvider.value
})
authUrl.value = result.authUrl
sessionId.value = result.sessionId
} else if (props.platform === 'openai') {
@@ -996,6 +1020,8 @@ const generateAuthUrl = async () => {
}
}
// onGeminiOauthProviderChange removed - oauthProvider is now computed from platform
// 重新生成授权URL
const regenerateAuthUrl = () => {
stopCountdown()
@@ -1079,11 +1105,12 @@ const exchangeCode = async () => {
sessionId: sessionId.value,
callbackUrl: authCode.value.trim()
}
} else if (props.platform === 'gemini') {
// Gemini使用code和sessionId
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
// Gemini/Antigravity使用code和sessionId
data = {
code: authCode.value.trim(),
sessionId: sessionId.value
sessionId: sessionId.value,
oauthProvider: geminiOauthProvider.value
}
} else if (props.platform === 'openai') {
// OpenAI使用code和sessionId
@@ -1111,8 +1138,12 @@ const exchangeCode = async () => {
let tokenInfo
if (props.platform === 'claude') {
tokenInfo = await accountsStore.exchangeClaudeCode(data)
} else if (props.platform === 'gemini') {
} else if (props.platform === 'gemini' || props.platform === 'gemini-antigravity') {
tokenInfo = await accountsStore.exchangeGeminiCode(data)
// 附加 oauthProvider 信息到 tokenInfo
if (tokenInfo) {
tokenInfo.oauthProvider = geminiOauthProvider.value
}
} else if (props.platform === 'openai') {
tokenInfo = await accountsStore.exchangeOpenAICode(data)
} else if (props.platform === 'droid') {

View File

@@ -192,7 +192,7 @@
<script setup>
import { ref, watch } from 'vue'
import * as httpApi from '@/utils/http_apis'
import { updateFrontUserRoleApi } from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
const props = defineProps({
@@ -221,7 +221,7 @@ const handleSubmit = async () => {
error.value = ''
try {
const response = await httpApi.patch(`/users/${props.user.id}/role`, {
const response = await updateFrontUserRoleApi(props.user.id, {
role: selectedRole.value
})
@@ -248,7 +248,3 @@ watch([() => props.show, () => props.user], ([show, user]) => {
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -347,8 +347,8 @@
<script setup>
import { ref, watch } from 'vue'
import * as httpApi from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
import { getFrontUserUsageStatsApi, getFrontUserByIdApi } from '@/utils/http_apis'
import { showToast, formatNumber, formatDate } from '@/utils/tools'
const props = defineProps({
show: {
@@ -368,36 +368,14 @@ const selectedPeriod = ref('week')
const usageStats = ref(null)
const userDetails = ref(null)
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsageStats = async () => {
if (!props.user) return
loading.value = true
try {
const [statsResponse, userResponse] = await Promise.all([
httpApi.get(`/users/${props.user.id}/usage-stats`, {
params: { period: selectedPeriod.value }
}),
httpApi.get(`/users/${props.user.id}`)
getFrontUserUsageStatsApi(props.user.id, { period: selectedPeriod.value }),
getFrontUserByIdApi(props.user.id)
])
if (statsResponse.success) {
@@ -422,7 +400,3 @@ watch([() => props.show, () => props.user], ([show, user]) => {
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -241,8 +241,8 @@
<script setup>
import { ref, computed, watch, onUnmounted, onMounted } from 'vue'
import { API_PREFIX } from '@/utils/http_apis'
import { getModels } from '@/utils/http_apis'
import { APP_CONFIG } from '@/utils/tools'
import { getModelsApi } from '@/utils/http_apis'
const props = defineProps({
show: {
@@ -303,7 +303,7 @@ const modelsFromApi = ref({
// 加载模型列表
const loadModels = async () => {
try {
const result = await getModels()
const result = await getModelsApi()
if (result.success && result.data) {
modelsFromApi.value = {
claude: result.data.claude || [],
@@ -488,7 +488,7 @@ async function startTest() {
// 使用公开的测试端点,不需要管理员认证
// apiStats 路由挂载在 /apiStats 下
const endpoint = `${API_PREFIX}/apiStats${serviceConfig.value.endpoint}`
const endpoint = `${APP_CONFIG.apiPrefix}/apiStats${serviceConfig.value.endpoint}`
try {
// 使用fetch发送POST请求并处理SSE

View File

@@ -448,7 +448,7 @@
import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/tools'
import { useApiKeysStore } from '@/stores/apiKeys'
import * as httpApi from '@/utils/http_apis'
import * as httpApis from '@/utils/http_apis'
import AccountSelector from '@/components/common/AccountSelector.vue'
const props = defineProps({
@@ -549,6 +549,8 @@ const droidAccountSelectorValue = createAccountSelectorModel('droidAccountId')
const isServiceSelectable = (service) => {
if (!form.permissions) return true
if (form.permissions === 'all') return true
if (Array.isArray(form.permissions) && form.permissions.length === 0) return true
if (Array.isArray(form.permissions)) return form.permissions.includes(service)
return form.permissions === service
}
@@ -588,15 +590,15 @@ const refreshAccounts = async () => {
droidData,
groupsData
] = await Promise.all([
httpApi.get('/admin/claude-accounts'),
httpApi.get('/admin/claude-console-accounts'),
httpApi.get('/admin/gemini-accounts'),
httpApi.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
httpApi.get('/admin/openai-accounts'),
httpApi.get('/admin/openai-responses-accounts'),
httpApi.get('/admin/bedrock-accounts'),
httpApi.get('/admin/droid-accounts'),
httpApi.get('/admin/account-groups')
httpApis.getClaudeAccountsApi(),
httpApis.getClaudeConsoleAccountsApi(),
httpApis.getGeminiAccountsApi(),
httpApis.getGeminiApiAccountsApi(), // 获取 Gemini-API 账号
httpApis.getOpenAIAccountsApi(),
httpApis.getOpenAIResponsesAccountsApi(),
httpApis.getBedrockAccountsApi(),
httpApis.getDroidAccountsApi(),
httpApis.getAccountGroupsApi()
])
// 合并Claude OAuth账户和Claude Console账户
@@ -801,7 +803,7 @@ const batchUpdateApiKeys = async () => {
updates.tagOperation = tagOperation.value
}
const result = await httpApi.put('/admin/api-keys/batch', {
const result = await httpApis.batchUpdateApiKeysApi({
keyIds: props.selectedKeys,
updates
})
@@ -882,7 +884,3 @@ onMounted(async () => {
}
})
</script>
<style scoped>
/* 表单样式由全局样式提供 */
</style>

View File

@@ -461,6 +461,51 @@
/>
</div>
<!-- 服务倍率设置 -->
<div
class="rounded-lg border border-purple-200 bg-gradient-to-r from-purple-50 to-indigo-50 p-3 dark:border-purple-700 dark:from-purple-900/20 dark:to-indigo-900/20 sm:p-4"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<input
id="enableServiceRates"
v-model="enableServiceRates"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-purple-600 focus:ring-purple-500"
type="checkbox"
/>
<label
class="cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="enableServiceRates"
>
自定义服务倍率
</label>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
与全局倍率相乘用于 VIP 折扣等如全局1.5 × Key倍率0.8 = 1.2
</span>
</div>
<div v-if="enableServiceRates" class="mt-3 space-y-2">
<div
v-for="service in availableServices"
:key="service.key"
class="flex items-center gap-2"
>
<span class="w-20 text-xs text-gray-600 dark:text-gray-400">{{
service.label
}}</span>
<input
v-model.number="form.serviceRates[service.key]"
class="form-input w-24 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="1.0"
step="0.1"
type="number"
/>
<span class="text-xs text-gray-400">默认 1.0</span>
</div>
</div>
</div>
<div>
<label class="mb-2 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>过期设置</label
@@ -582,17 +627,8 @@
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
:checked="form.permissions === 'all'"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
@change="toggleAllServices"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="selectedServices"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
v-model="form.permissions"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="claude"
@change="updatePermissions"
@@ -601,8 +637,8 @@
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="selectedServices"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
v-model="form.permissions"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="gemini"
@change="updatePermissions"
@@ -611,8 +647,8 @@
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="selectedServices"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
v-model="form.permissions"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="openai"
@change="updatePermissions"
@@ -621,8 +657,8 @@
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="selectedServices"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
v-model="form.permissions"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="droid"
@change="updatePermissions"
@@ -631,7 +667,7 @@
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务可多选
不选择任何服务表示允许访问全部服务
</p>
</div>
@@ -666,7 +702,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('claude')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -680,7 +716,7 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('gemini')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
@@ -694,7 +730,7 @@
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('openai')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
@@ -708,7 +744,7 @@
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('claude')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]"
placeholder="请选择Bedrock账号"
platform="bedrock"
@@ -722,7 +758,7 @@
v-model="form.droidAccountId"
:accounts="localAccounts.droid"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('droid')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups"
placeholder="请选择Droid账号"
platform="droid"
@@ -908,7 +944,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/tools'
import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys'
import * as httpApi from '@/utils/http_apis'
import * as httpApis from '@/utils/http_apis'
import AccountSelector from '@/components/common/AccountSelector.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
@@ -998,12 +1034,25 @@ const unselectedTags = computed(() => {
// 支持的客户端列表
const supportedClients = ref([])
// 服务倍率相关
const enableServiceRates = ref(false)
const availableServices = [
{ key: 'claude', label: 'Claude' },
{ key: 'gemini', label: 'Gemini' },
{ key: 'codex', label: 'Codex' },
{ key: 'droid', label: 'Droid' },
{ key: 'bedrock', label: 'Bedrock' },
{ key: 'azure', label: 'Azure' },
{ key: 'ccr', label: 'CCR' }
]
// 表单数据
const form = reactive({
createType: 'single',
batchCount: 10,
name: '',
description: '',
serviceRates: {}, // API Key 级别服务倍率
rateLimitWindow: '',
rateLimitRequests: '',
rateLimitCost: '', // 新增:费用限制
@@ -1017,7 +1066,7 @@ const form = reactive({
expirationMode: 'fixed', // 过期模式fixed(固定) 或 activation(激活)
activationDays: 30, // 激活后有效天数
activationUnit: 'days', // 激活时间单位hours 或 days
permissions: 'all',
permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
@@ -1031,37 +1080,9 @@ const form = reactive({
tags: []
})
// 多选服务
const allServices = ['claude', 'gemini', 'openai', 'droid']
const selectedServices = ref([...allServices])
// 切换全部服务
const toggleAllServices = (event) => {
if (event.target.checked) {
selectedServices.value = [...allServices]
form.permissions = 'all'
} else {
selectedServices.value = []
form.permissions = ''
}
}
// 更新权限
// 更新权限(数组格式,空数组=全部服务
const updatePermissions = () => {
if (selectedServices.value.length === allServices.length) {
form.permissions = 'all'
} else if (selectedServices.value.length === 1) {
form.permissions = selectedServices.value[0]
} else if (selectedServices.value.length > 1) {
form.permissions = selectedServices.value.join(',')
} else {
form.permissions = ''
}
}
// 检查服务是否启用
const isServiceEnabled = (service) => {
return form.permissions === 'all' || selectedServices.value.includes(service)
// form.permissions 已经是数组,由 v-model 自动管理
}
// 加载支持的客户端和已存在的标签
@@ -1130,15 +1151,15 @@ const refreshAccounts = async () => {
droidData,
groupsData
] = await Promise.all([
httpApi.get('/admin/claude-accounts'),
httpApi.get('/admin/claude-console-accounts'),
httpApi.get('/admin/gemini-accounts'),
httpApi.get('/admin/gemini-api-accounts'), // 获取 Gemini-API 账号
httpApi.get('/admin/openai-accounts'),
httpApi.get('/admin/openai-responses-accounts'), // 获取 OpenAI-Responses 账号
httpApi.get('/admin/bedrock-accounts'),
httpApi.get('/admin/droid-accounts'),
httpApi.get('/admin/account-groups')
httpApis.getClaudeAccountsApi(),
httpApis.getClaudeConsoleAccountsApi(),
httpApis.getGeminiAccountsApi(),
httpApis.getGeminiApiAccountsApi(), // 获取 Gemini-API 账号
httpApis.getOpenAIAccountsApi(),
httpApis.getOpenAIResponsesAccountsApi(), // 获取 OpenAI-Responses 账号
httpApis.getBedrockAccountsApi(),
httpApis.getDroidAccountsApi(),
httpApis.getAccountGroupsApi()
])
// 合并Claude OAuth账户和Claude Console账户
@@ -1431,8 +1452,19 @@ const createApiKey = async () => {
try {
// 准备提交的数据
// 过滤掉空值的服务倍率
const filteredServiceRates = {}
if (enableServiceRates.value) {
for (const [key, value] of Object.entries(form.serviceRates)) {
if (value !== null && value !== undefined && value !== '') {
filteredServiceRates[key] = value
}
}
}
const baseData = {
description: form.description || undefined,
serviceRates: filteredServiceRates,
tokenLimit: 0, // 设置为0清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
@@ -1514,7 +1546,7 @@ const createApiKey = async () => {
name: form.name
}
const result = await httpApi.post('/admin/api-keys', data)
const result = await httpApis.createApiKeyApi(data)
if (result.success) {
showToast('API Key 创建成功', 'success')
@@ -1532,7 +1564,7 @@ const createApiKey = async () => {
count: form.batchCount
}
const result = await httpApi.post('/admin/api-keys/batch', data)
const result = await httpApis.batchCreateApiKeysApi(data)
if (result.success) {
showToast(`成功创建 ${result.data.length} 个 API Key`, 'success')
@@ -1549,7 +1581,3 @@ const createApiKey = async () => {
}
}
</script>
<style scoped>
/* 表单样式由全局样式提供 */
</style>

View File

@@ -47,6 +47,51 @@
</p>
</div>
<!-- 服务倍率设置 -->
<div
class="rounded-lg border border-purple-200 bg-gradient-to-r from-purple-50 to-indigo-50 p-3 dark:border-purple-700 dark:from-purple-900/20 dark:to-indigo-900/20 sm:p-4"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<input
id="editEnableServiceRates"
v-model="enableServiceRates"
class="h-4 w-4 rounded border-gray-300 bg-gray-100 text-purple-600 focus:ring-purple-500"
type="checkbox"
/>
<label
class="cursor-pointer text-sm font-semibold text-gray-700 dark:text-gray-300"
for="editEnableServiceRates"
>
自定义服务倍率
</label>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
与全局倍率相乘用于 VIP 折扣等如全局1.5 × Key倍率0.8 = 1.2
</span>
</div>
<div v-if="enableServiceRates" class="mt-3 space-y-2">
<div
v-for="service in availableServices"
:key="service.key"
class="flex items-center gap-2"
>
<span class="w-20 text-xs text-gray-600 dark:text-gray-400">{{
service.label
}}</span>
<input
v-model.number="form.serviceRates[service.key]"
class="form-input w-24 border-gray-300 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-200"
min="0"
placeholder="1.0"
step="0.1"
type="number"
/>
<span class="text-xs text-gray-400">默认 1.0</span>
</div>
</div>
</div>
<!-- 所有者选择 -->
<div>
<label
@@ -415,17 +460,8 @@
<div class="flex flex-wrap gap-4">
<label class="flex cursor-pointer items-center">
<input
:checked="form.permissions === 'all'"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
@change="toggleAllServices"
/>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">全部服务</span>
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="selectedServices"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
v-model="form.permissions"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="claude"
@change="updatePermissions"
@@ -434,8 +470,8 @@
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="selectedServices"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
v-model="form.permissions"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="gemini"
@change="updatePermissions"
@@ -444,8 +480,8 @@
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="selectedServices"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
v-model="form.permissions"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="openai"
@change="updatePermissions"
@@ -454,8 +490,8 @@
</label>
<label class="flex cursor-pointer items-center">
<input
v-model="selectedServices"
class="mr-2 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
v-model="form.permissions"
class="mr-2 rounded text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700"
type="checkbox"
value="droid"
@change="updatePermissions"
@@ -464,7 +500,7 @@
</label>
</div>
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
控制此 API Key 可以访问哪些服务可多选
不选择任何服务表示允许访问全部服务
</p>
</div>
@@ -499,7 +535,7 @@
v-model="form.claudeAccountId"
:accounts="localAccounts.claude"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('claude')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="localAccounts.claudeGroups"
placeholder="请选择Claude账号"
platform="claude"
@@ -513,7 +549,7 @@
v-model="form.geminiAccountId"
:accounts="localAccounts.gemini"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('gemini')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('gemini')"
:groups="localAccounts.geminiGroups"
placeholder="请选择Gemini账号"
platform="gemini"
@@ -527,7 +563,7 @@
v-model="form.openaiAccountId"
:accounts="localAccounts.openai"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('openai')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('openai')"
:groups="localAccounts.openaiGroups"
placeholder="请选择OpenAI账号"
platform="openai"
@@ -541,7 +577,7 @@
v-model="form.bedrockAccountId"
:accounts="localAccounts.bedrock"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('claude')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('claude')"
:groups="[]"
placeholder="请选择Bedrock账号"
platform="bedrock"
@@ -555,7 +591,7 @@
v-model="form.droidAccountId"
:accounts="localAccounts.droid"
default-option-text="使用共享账号池"
:disabled="!isServiceEnabled('droid')"
:disabled="form.permissions.length > 0 && !form.permissions.includes('droid')"
:groups="localAccounts.droidGroups"
placeholder="请选择Droid账号"
platform="droid"
@@ -746,7 +782,7 @@ import { ref, reactive, computed, onMounted } from 'vue'
import { showToast } from '@/utils/tools'
import { useClientsStore } from '@/stores/clients'
import { useApiKeysStore } from '@/stores/apiKeys'
import * as httpApi from '@/utils/http_apis'
import * as httpApis from '@/utils/http_apis'
import AccountSelector from '@/components/common/AccountSelector.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
@@ -840,9 +876,22 @@ const unselectedTags = computed(() => {
return availableTags.value.filter((tag) => !form.tags.includes(tag))
})
// 服务倍率相关
const enableServiceRates = ref(false)
const availableServices = [
{ key: 'claude', label: 'Claude' },
{ key: 'gemini', label: 'Gemini' },
{ key: 'codex', label: 'Codex' },
{ key: 'droid', label: 'Droid' },
{ key: 'bedrock', label: 'Bedrock' },
{ key: 'azure', label: 'Azure' },
{ key: 'ccr', label: 'CCR' }
]
// 表单数据
const form = reactive({
name: '',
serviceRates: {}, // API Key 级别服务倍率
tokenLimit: '', // 保留用于检测历史数据
rateLimitWindow: '',
rateLimitRequests: '',
@@ -851,7 +900,7 @@ const form = reactive({
dailyCostLimit: '',
totalCostLimit: '',
weeklyOpusCostLimit: '',
permissions: 'all',
permissions: [], // 数组格式,空数组表示全部服务
claudeAccountId: '',
geminiAccountId: '',
openaiAccountId: '',
@@ -867,48 +916,9 @@ const form = reactive({
ownerId: '' // 新增所有者ID
})
// 多选服务
const allServices = ['claude', 'gemini', 'openai', 'droid']
const selectedServices = ref([...allServices])
// 切换全部服务
const toggleAllServices = (event) => {
if (event.target.checked) {
selectedServices.value = [...allServices]
form.permissions = 'all'
} else {
selectedServices.value = []
form.permissions = ''
}
}
// 更新权限
// 更新权限(数组格式,空数组=全部服务
const updatePermissions = () => {
if (selectedServices.value.length === allServices.length) {
form.permissions = 'all'
} else if (selectedServices.value.length === 1) {
form.permissions = selectedServices.value[0]
} else if (selectedServices.value.length > 1) {
form.permissions = selectedServices.value.join(',')
} else {
form.permissions = ''
}
}
// 检查服务是否启用
const isServiceEnabled = (service) => {
return form.permissions === 'all' || selectedServices.value.includes(service)
}
// 根据 permissions 初始化 selectedServices
const initSelectedServices = (permissions) => {
if (permissions === 'all') {
selectedServices.value = [...allServices]
} else if (permissions) {
selectedServices.value = permissions.split(',').filter((s) => allServices.includes(s))
} else {
selectedServices.value = []
}
// form.permissions 已经是数组,由 v-model 自动管理
}
// 添加限制的模型
@@ -980,8 +990,19 @@ const updateApiKey = async () => {
try {
// 准备提交的数据
// 过滤掉空值的服务倍率
const filteredServiceRates = {}
if (enableServiceRates.value) {
for (const [key, value] of Object.entries(form.serviceRates)) {
if (value !== null && value !== undefined && value !== '') {
filteredServiceRates[key] = value
}
}
}
const data = {
name: form.name, // 添加名称字段
serviceRates: filteredServiceRates,
tokenLimit: 0, // 清除历史token限制
rateLimitWindow:
form.rateLimitWindow !== '' && form.rateLimitWindow !== null
@@ -1079,7 +1100,7 @@ const updateApiKey = async () => {
data.ownerId = form.ownerId
}
const result = await httpApi.put(`/admin/api-keys/${props.apiKey.id}`, data)
const result = await httpApis.updateApiKeyApi(props.apiKey.id, data)
if (result.success) {
emit('success')
@@ -1109,15 +1130,15 @@ const refreshAccounts = async () => {
droidData,
groupsData
] = await Promise.all([
httpApi.get('/admin/claude-accounts'),
httpApi.get('/admin/claude-console-accounts'),
httpApi.get('/admin/gemini-accounts'),
httpApi.get('/admin/gemini-api-accounts'),
httpApi.get('/admin/openai-accounts'),
httpApi.get('/admin/openai-responses-accounts'),
httpApi.get('/admin/bedrock-accounts'),
httpApi.get('/admin/droid-accounts'),
httpApi.get('/admin/account-groups')
httpApis.getClaudeAccountsApi(),
httpApis.getClaudeConsoleAccountsApi(),
httpApis.getGeminiAccountsApi(),
httpApis.getGeminiApiAccountsApi(),
httpApis.getOpenAIAccountsApi(),
httpApis.getOpenAIResponsesAccountsApi(),
httpApis.getBedrockAccountsApi(),
httpApis.getDroidAccountsApi(),
httpApis.getAccountGroupsApi()
])
// 合并Claude OAuth账户和Claude Console账户
@@ -1230,7 +1251,7 @@ const refreshAccounts = async () => {
// 加载用户列表
const loadUsers = async () => {
try {
const response = await httpApi.get('/admin/users')
const response = await httpApis.getUsersApi()
if (response.success) {
availableUsers.value = response.data || []
}
@@ -1314,6 +1335,8 @@ onMounted(async () => {
// 使用缓存的账号数据,不自动刷新(用户可点击"刷新账号"按钮手动刷新)
form.name = props.apiKey.name
form.serviceRates = props.apiKey.serviceRates || {}
enableServiceRates.value = Object.keys(form.serviceRates).length > 0
// 处理速率限制迁移如果有tokenLimit且没有rateLimitCost提示用户
form.tokenLimit = props.apiKey.tokenLimit || ''
@@ -1331,8 +1354,32 @@ onMounted(async () => {
form.dailyCostLimit = props.apiKey.dailyCostLimit || ''
form.totalCostLimit = props.apiKey.totalCostLimit || ''
form.weeklyOpusCostLimit = props.apiKey.weeklyOpusCostLimit || ''
form.permissions = props.apiKey.permissions || 'all'
initSelectedServices(form.permissions)
// 处理权限数据,兼容旧格式(字符串)和新格式(数组)
// 有效的权限值
const VALID_PERMS = ['claude', 'gemini', 'openai', 'droid']
let perms = props.apiKey.permissions
// 如果是字符串,尝试 JSON.parseRedis 可能返回 "[]" 或 "[\"gemini\"]"
if (typeof perms === 'string') {
if (perms === 'all' || perms === '') {
perms = []
} else if (perms.startsWith('[')) {
try {
perms = JSON.parse(perms)
} catch {
perms = VALID_PERMS.includes(perms) ? [perms] : []
}
} else if (VALID_PERMS.includes(perms)) {
perms = [perms]
} else {
perms = []
}
}
if (Array.isArray(perms)) {
// 过滤掉无效值(如 "[]"
form.permissions = perms.filter((p) => VALID_PERMS.includes(p))
} else {
form.permissions = []
}
// 处理 Claude 账号(区分 OAuth 和 Console
if (props.apiKey.claudeConsoleAccountId) {
form.claudeAccountId = `console:${props.apiKey.claudeConsoleAccountId}`
@@ -1364,7 +1411,3 @@ onMounted(async () => {
form.ownerId = props.apiKey.userId || 'admin'
})
</script>
<style scoped>
/* 表单样式由全局样式提供 */
</style>

View File

@@ -218,7 +218,7 @@ const compactBarClass = computed(() => {
case 'total':
return 'bg-blue-500 dark:bg-blue-400'
default:
return 'bg-slate-400 dark:bg-slate-500'
return 'bg-gray-400 dark:bg-gray-500'
}
})

View File

@@ -177,7 +177,7 @@ const formattedTime = computed(() => {
})
const formattedCosts = computed(() => {
const breakdown = props.record?.costBreakdown || {}
const breakdown = props.record?.realCostBreakdown || props.record?.costBreakdown || {}
const formatValue = (value) => {
const num = typeof value === 'number' ? value : 0
if (num >= 1) return `$${num.toFixed(2)}`

View File

@@ -98,7 +98,7 @@
<script setup>
import { ref, reactive, computed } from 'vue'
import { showToast } from '@/utils/tools'
import * as httpApi from '@/utils/http_apis'
import * as httpApis from '@/utils/http_apis'
const props = defineProps({
apiKey: {
@@ -206,7 +206,7 @@ const renewApiKey = async () => {
expiresAt: form.renewDuration === 'permanent' ? null : form.newExpiresAt
}
const result = await httpApi.put(`/admin/api-keys/${props.apiKey.id}`, data)
const result = await httpApis.updateApiKeyApi(props.apiKey.id, data)
if (result.success) {
showToast('API Key 续期成功', 'success')
@@ -225,7 +225,3 @@ const renewApiKey = async () => {
// 初始化
updateRenewExpireAt()
</script>
<style scoped>
/* 表单样式由全局样式提供 */
</style>

View File

@@ -0,0 +1,292 @@
<template>
<Teleport to="body">
<Transition name="modal">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
@click.self="handleClose"
>
<div class="w-full max-w-lg rounded-2xl bg-white shadow-2xl dark:bg-gray-800" @click.stop>
<!-- 头部 -->
<div
class="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700"
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
<i class="fas fa-tags mr-2 text-purple-500" />
标签管理
</h3>
<button
class="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
@click="handleClose"
>
<i class="fas fa-times" />
</button>
</div>
<!-- 内容 -->
<div class="max-h-[60vh] overflow-y-auto px-6 py-4">
<!-- 新增标签 -->
<div class="mb-4 flex gap-2">
<input
v-model="newTagInput"
class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="输入新标签名称"
type="text"
@keyup.enter="createTag"
/>
<button
class="rounded-lg bg-purple-500 px-4 py-2 text-sm font-medium text-white hover:bg-purple-600 disabled:opacity-50"
:disabled="!newTagInput.trim() || creating || processing"
@click="createTag"
>
<i v-if="creating" class="fas fa-spinner fa-spin mr-1" />
<i v-else class="fas fa-plus mr-1" />
新增
</button>
</div>
<div v-if="loading" class="py-8 text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400" />
<p class="mt-2 text-gray-500 dark:text-gray-400">加载中...</p>
</div>
<div v-else-if="tags.length === 0" class="py-8 text-center">
<i class="fas fa-tag text-4xl text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-gray-500 dark:text-gray-400">暂无标签</p>
</div>
<div v-else class="space-y-2">
<div
v-for="tag in tags"
:key="tag.name"
class="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-700/50"
>
<div class="flex items-center gap-3">
<i class="fas fa-tag text-purple-500" />
<span class="font-medium text-gray-700 dark:text-gray-200">{{ tag.name }}</span>
<span
class="rounded-full bg-purple-100 px-2 py-0.5 text-xs text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"
>
{{ tag.count }} Key
</span>
</div>
<div class="flex gap-1">
<button
class="rounded-lg p-2 text-gray-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900/30 dark:hover:text-blue-400"
:disabled="processing"
title="重命名"
@click="startRename(tag)"
>
<i class="fas fa-edit" />
</button>
<button
class="rounded-lg p-2 text-gray-400 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
:disabled="processing"
title="删除标签"
@click="confirmDelete(tag)"
>
<i class="fas fa-trash" />
</button>
</div>
</div>
</div>
</div>
<!-- 底部 -->
<div class="flex justify-end border-t border-gray-200 px-6 py-4 dark:border-gray-700">
<button
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="handleClose"
>
关闭
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
<!-- 删除确认弹窗 -->
<ConfirmModal
cancel-text="取消"
confirm-text="确定删除"
:message="`此操作将从 ${confirmingTag?.count || 0} 个 API Key 中移除该标签,不可恢复。`"
:show="showDeleteConfirm"
:title="`删除标签「${confirmingTag?.name || ''}」`"
type="danger"
@cancel="showDeleteConfirm = false"
@confirm="executeDelete"
/>
<!-- 重命名弹窗 -->
<Teleport to="body">
<div
v-if="showRenameModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
@click.self="showRenameModal = false"
>
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">重命名标签</h3>
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
新名称
</label>
<input
v-model="newTagName"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="请输入新标签名称"
type="text"
@keyup.enter="executeRename"
/>
</div>
<div class="flex justify-end gap-3">
<button
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="showRenameModal = false"
>
取消
</button>
<button
class="rounded-lg bg-blue-500 px-4 py-2 text-sm font-medium text-white hover:bg-blue-600 disabled:opacity-50"
:disabled="!newTagName.trim() || processing"
@click="executeRename"
>
<i v-if="processing" class="fas fa-spinner fa-spin mr-1" />
确定
</button>
</div>
</div>
</div>
</Teleport>
</template>
<script setup>
import { ref, watch } from 'vue'
import {
getApiKeyTagsDetailsApi,
createApiKeyTagApi,
deleteApiKeyTagApi,
renameApiKeyTagApi
} from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
const props = defineProps({
show: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'updated'])
const loading = ref(false)
const processing = ref(false)
const creating = ref(false)
const tags = ref([])
const newTagInput = ref('')
const showDeleteConfirm = ref(false)
const showRenameModal = ref(false)
const confirmingTag = ref(null)
const renamingTag = ref(null)
const newTagName = ref('')
const loadTags = async () => {
loading.value = true
const res = await getApiKeyTagsDetailsApi()
loading.value = false
if (res.success) {
tags.value = res.data
}
}
const createTag = async () => {
if (!newTagInput.value.trim()) return
creating.value = true
const res = await createApiKeyTagApi(newTagInput.value.trim())
creating.value = false
if (res.success) {
showToast('标签创建成功', 'success')
newTagInput.value = ''
loadTags()
emit('updated')
} else {
showToast(res.error || '创建失败', 'error')
}
}
const confirmDelete = (tag) => {
confirmingTag.value = tag
showDeleteConfirm.value = true
}
const executeDelete = async () => {
if (!confirmingTag.value) return
showDeleteConfirm.value = false
processing.value = true
const tagName = confirmingTag.value.name
const res = await deleteApiKeyTagApi(tagName)
processing.value = false
if (res.success) {
showToast(`标签「${tagName}」已删除`, 'success')
tags.value = tags.value.filter((t) => t.name !== tagName)
confirmingTag.value = null
emit('updated')
} else {
showToast(res.error || '删除失败', 'error')
}
}
const startRename = (tag) => {
renamingTag.value = tag
newTagName.value = tag.name
showRenameModal.value = true
}
const executeRename = async () => {
if (!renamingTag.value || !newTagName.value.trim()) return
processing.value = true
const oldName = renamingTag.value.name
const res = await renameApiKeyTagApi(oldName, newTagName.value.trim())
processing.value = false
if (res.success) {
showToast('标签已重命名', 'success')
showRenameModal.value = false
renamingTag.value = null
loadTags()
emit('updated')
} else {
showToast(res.error || '重命名失败', 'error')
}
}
const handleClose = () => {
confirmingTag.value = null
emit('close')
}
watch(
() => props.show,
(val) => {
if (val) {
confirmingTag.value = null
newTagInput.value = ''
loadTags()
}
}
)
</script>
<style scoped>
.modal-enter-active,
.modal-leave-active {
transition: opacity 0.2s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
</style>

View File

@@ -248,6 +248,8 @@ import { computed } from 'vue'
import LimitProgressBar from './LimitProgressBar.vue'
import WindowCountdown from './WindowCountdown.vue'
import { formatNumber } from '@/utils/tools'
const props = defineProps({
show: {
type: Boolean,
@@ -305,10 +307,6 @@ const opusUsagePercentage = computed(() => {
})
// 方法
const formatNumber = (num) => {
if (!num && num !== 0) return '0'
return num.toLocaleString('zh-CN')
}
// 格式化Token数量使用K/M单位
const formatTokenCount = (count) => {
@@ -328,7 +326,3 @@ const openTimeline = () => {
emit('open-timeline', props.apiKey?.id)
}
</script>
<style scoped>
/* 使用项目的通用样式,不需要额外定义 */
</style>

View File

@@ -69,6 +69,7 @@
</template>
<script setup>
import { formatNumber } from '@/utils/tools'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
@@ -146,21 +147,6 @@ const getProgressColor = (index) => {
}
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
</script>
<style scoped>

View File

@@ -57,15 +57,23 @@
<!-- API Key 输入 -->
<div class="lg:col-span-3">
<!-- Key 模式输入框 -->
<input
v-if="!multiKeyMode"
v-model="apiKey"
class="wide-card-input w-full"
:disabled="loading"
placeholder="请输入您的 API Key (cr_...)"
type="password"
@keyup.enter="queryStats"
/>
<div v-if="!multiKeyMode" class="relative">
<input
v-model="apiKey"
class="wide-card-input w-full pr-10"
:disabled="loading"
placeholder="请输入您的 API Key (cr_...)"
:type="showPassword ? 'text' : 'password'"
@keyup.enter="queryStats"
/>
<button
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
type="button"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" />
</button>
</div>
<!-- Key 模式输入框 -->
<div v-else class="relative">
@@ -125,7 +133,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, ref } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
@@ -133,6 +141,8 @@ const apiStatsStore = useApiStatsStore()
const { apiKey, loading, multiKeyMode } = storeToRefs(apiStatsStore)
const { queryStats, clearInput } = apiStatsStore
const showPassword = ref(false)
// 解析输入的 API Keys
const parsedApiKeys = computed(() => {
if (!multiKeyMode.value || !apiKey.value) return []

View File

@@ -102,7 +102,7 @@
</div>
<!-- 仅在单 Key 模式下显示限制配置 -->
<div v-if="!multiKeyMode" class="space-y-4 md:space-y-5">
<div v-if="!multiKeyMode && statsData?.limits" class="space-y-4 md:space-y-5">
<!-- 每日费用限制 -->
<div>
<div class="mb-2 flex items-center justify-between">
@@ -272,7 +272,7 @@
<span
v-for="client in statsData.restrictions.allowedClients"
:key="client"
class="flex items-center gap-1 rounded-full bg-white px-2 py-1 text-xs text-blue-700 shadow-sm dark:bg-slate-900 dark:text-blue-300 md:text-sm"
class="flex items-center gap-1 rounded-full bg-white px-2 py-1 text-xs text-blue-700 shadow-sm dark:bg-gray-800 dark:text-blue-300 md:text-sm"
>
<i class="fas fa-id-badge" />
{{ client }}
@@ -321,6 +321,7 @@
</template>
<script setup>
import { formatNumber } from '@/utils/tools'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
@@ -404,22 +405,6 @@ const getOpusWeeklyCostProgressColor = () => {
}
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
</script>
<style scoped>

View File

@@ -53,7 +53,7 @@
{{ model.formatted?.total || '$0.00' }}
</span>
<template v-if="serviceRates?.rates">
<span class="ml-2 text-gray-500">折合CC</span>
<span class="ml-2 text-gray-500">计费</span>
<span class="ml-1 font-semibold text-amber-600 dark:text-amber-400">
{{ calculateCcCost(model) }}
</span>
@@ -75,7 +75,7 @@
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
import { copyText } from '@/utils/tools'
import { copyText, formatNumber } from '@/utils/tools'
const props = defineProps({
period: {
@@ -136,22 +136,6 @@ const calculateCcCost = (model) => {
}
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
</script>
<style scoped>

View File

@@ -8,7 +8,7 @@
服务费用统计
</span>
<span class="text-xs font-normal text-gray-500 dark:text-gray-400">
CC 倍率基准: Claude = 1.0
计费 = 官方费用 × 全局倍率 × Key倍率
</span>
</h3>
@@ -23,11 +23,21 @@
<span class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ service.label }}
</span>
<span
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
>
{{ service.rate }}x
</span>
<div class="flex items-center gap-1">
<span
class="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
title="全局倍率"
>
全局 {{ service.globalRate }}x
</span>
<span
v-if="!multiKeyMode"
class="rounded-full bg-purple-100 px-2 py-0.5 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"
title="Key倍率"
>
Key {{ service.keyRate }}x
</span>
</div>
</div>
<!-- Token 详情 -->
@@ -67,7 +77,7 @@
</span>
</div>
<div class="flex justify-between">
<span class="text-gray-600 dark:text-gray-400">折合CC</span>
<span class="text-gray-600 dark:text-gray-400">计费费用</span>
<span class="font-semibold text-amber-600 dark:text-amber-400">
{{ service.ccCost }}
</span>
@@ -102,12 +112,13 @@
</template>
<script setup>
import { formatNumber } from '@/utils/tools'
import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
const apiStatsStore = useApiStatsStore()
const { modelStats, serviceRates } = storeToRefs(apiStatsStore)
const { modelStats, serviceRates, keyServiceRates, multiKeyMode } = storeToRefs(apiStatsStore)
// 服务标签映射
const serviceLabels = {
@@ -167,20 +178,23 @@ const serviceStats = computed(() => {
}
})
// 转换为数组并计算 CC 费用
// 转换为数组并计算计费费用
return Object.entries(stats)
.filter(
([, data]) =>
data.inputTokens > 0 || data.outputTokens > 0 || data.cacheCreateTokens > 0 || data.cost > 0
)
.map(([service, data]) => {
const rate = serviceRates.value.rates[service] || 1.0
const ccCostValue = data.cost * rate
const globalRate = serviceRates.value.rates[service] || 1.0
// 批量模式下不使用 Key 倍率
const keyRate = multiKeyMode.value ? 1.0 : (keyServiceRates.value?.[service] ?? 1.0)
const ccCostValue = data.cost * globalRate * keyRate
const p = data.pricing
return {
name: service,
label: serviceLabels[service] || service,
rate: rate,
globalRate: globalRate,
keyRate: keyRate,
inputTokens: data.inputTokens,
outputTokens: data.outputTokens,
cacheCreateTokens: data.cacheCreateTokens,
@@ -209,13 +223,6 @@ const formatCost = (cost) => {
}
// 格式化数字
const formatNumber = (num) => {
if (!num) return '0'
if (num >= 1e9) return (num / 1e9).toFixed(2) + 'B'
if (num >= 1e6) return (num / 1e6).toFixed(2) + 'M'
if (num >= 1e3) return (num / 1e3).toFixed(1) + 'K'
return num.toLocaleString()
}
</script>
<style scoped>

View File

@@ -92,6 +92,18 @@
<p class="info-label">权限</p>
<p class="info-value">{{ formatPermissions(statsData.permissions) }}</p>
</div>
<div v-if="hasServiceRates" class="info-item xl:col-span-2">
<p class="info-label">服务倍率</p>
<div class="flex flex-wrap gap-2">
<span
v-for="(rate, service) in statsData.serviceRates"
:key="service"
class="inline-flex items-center rounded-full bg-purple-100 px-2.5 py-0.5 text-xs font-medium text-purple-800 dark:bg-purple-900/30 dark:text-purple-300"
>
{{ service }}: {{ rate }}x
</span>
</div>
</div>
<div class="info-item">
<p class="info-label">创建时间</p>
<p class="info-value break-all">{{ formatDate(statsData.createdAt) }}</p>
@@ -272,7 +284,7 @@
</div>
<p
v-else
class="rounded-xl bg-slate-100 px-3 py-2 text-xs text-slate-500 dark:bg-slate-800 dark:text-slate-300"
class="rounded-xl bg-gray-100 px-3 py-2 text-xs text-gray-500 dark:bg-gray-800 dark:text-gray-300"
>
暂无额度使用数据
</p>
@@ -289,7 +301,7 @@ import { computed } from 'vue'
import { storeToRefs } from 'pinia'
import dayjs from 'dayjs'
import { useApiStatsStore } from '@/stores/apistats'
import { copyText } from '@/utils/tools'
import { copyText, formatNumber, formatDate } from '@/utils/tools'
const apiStatsStore = useApiStatsStore()
const {
@@ -309,21 +321,17 @@ const topContributors = computed(() => {
.slice(0, 3)
})
// 是否有自定义服务倍率
const hasServiceRates = computed(() => {
return statsData.value?.serviceRates && Object.keys(statsData.value.serviceRates).length > 0
})
const calculateContribution = (stat) => {
if (!aggregatedStats.value || !aggregatedStats.value.usage.allTokens) return 0
const percentage = ((stat.usage?.allTokens || 0) / aggregatedStats.value.usage.allTokens) * 100
return percentage.toFixed(1)
}
const formatDate = (dateString) => {
if (!dateString) return '无'
try {
return dayjs(dateString).format('YYYY年MM月DD日 HH:mm')
} catch (error) {
return '格式错误'
}
}
const formatExpireDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
@@ -349,21 +357,35 @@ const isApiKeyExpiringSoon = (expiresAt) => {
return daysUntilExpire > 0 && daysUntilExpire <= 7
}
const formatNumber = (num) => {
if (typeof num !== 'number') num = parseInt(num) || 0
if (num === 0) return '0'
if (num >= 1_000_000) return (num / 1_000_000).toFixed(1) + 'M'
if (num >= 1_000) return (num / 1_000).toFixed(1) + 'K'
return num.toLocaleString()
}
const formatPermissions = (permissions) => {
const map = {
claude: 'Claude',
gemini: 'Gemini',
all: '全部模型'
codex: 'Codex',
droid: 'Droid',
bedrock: 'Bedrock',
azure: 'Azure',
ccr: 'CCR'
}
return map[permissions] || permissions || '未知'
// 空值 = 全部服务
if (!permissions) return '全部服务'
// 尝试解析字符串格式的数组
let parsed = permissions
if (typeof permissions === 'string') {
if (permissions === 'all' || permissions === '[]') return '全部服务'
try {
parsed = JSON.parse(permissions)
} catch {
return map[permissions] || permissions
}
}
// 空数组 = 全部服务
if (Array.isArray(parsed) && parsed.length === 0) return '全部服务'
// 数组格式
if (Array.isArray(parsed)) {
return parsed.map((p) => map[p] || p).join(', ')
}
return map[permissions] || permissions
}
const boundAccountList = computed(() => {
@@ -518,11 +540,9 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
<style scoped>
.card-section {
@apply flex h-full flex-col gap-4 rounded-2xl border border-slate-200/70 bg-white/90 p-4 shadow-md dark:border-slate-700/60 dark:bg-slate-900/70 md:p-6;
}
:global(.dark) .card-section {
backdrop-filter: blur(10px);
@apply flex h-full flex-col gap-4 rounded-2xl p-4 shadow-md md:p-6;
background: var(--surface-color);
border: 1px solid var(--border-color);
}
.section-header {
@@ -534,11 +554,18 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
}
.header-title {
@apply text-lg font-semibold text-slate-900 dark:text-slate-100 md:text-xl;
@apply text-lg font-semibold md:text-xl;
color: var(--text-primary, #1e293b);
}
:global(.dark) .header-title {
color: var(--text-primary, #f1f5f9);
}
.header-tag {
@apply ml-auto rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium text-slate-500 dark:bg-slate-800 dark:text-slate-300;
@apply ml-auto rounded-full px-2 py-0.5 text-xs font-medium;
background: rgba(var(--primary-rgb), 0.1);
color: var(--primary-color);
}
.info-grid {
@@ -559,20 +586,43 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
}
.info-item {
@apply rounded-xl border border-slate-200 bg-white/70 p-4 dark:border-slate-700 dark:bg-slate-900/60;
@apply rounded-xl p-4;
background: rgba(var(--primary-rgb), 0.03);
border: 1px solid var(--border-color);
min-height: 86px;
}
:global(.dark) .info-item {
background: rgba(var(--primary-rgb), 0.08);
}
.info-label {
@apply text-xs uppercase tracking-wide text-slate-400;
@apply text-xs uppercase tracking-wide;
color: var(--text-secondary, #64748b);
}
:global(.dark) .info-label {
color: var(--text-secondary, #94a3b8);
}
.info-value {
@apply mt-2 text-sm text-slate-800 dark:text-slate-100;
@apply mt-2 text-sm;
color: var(--text-primary, #1e293b);
}
:global(.dark) .info-value {
color: var(--text-primary, #f1f5f9);
}
.contributor-item {
@apply flex items-center justify-between rounded-lg bg-slate-50 px-3 py-2 text-xs text-slate-600 dark:bg-slate-800 dark:text-slate-300;
@apply flex items-center justify-between rounded-lg px-3 py-2 text-xs;
background: rgba(var(--primary-rgb), 0.05);
color: var(--text-secondary, #64748b);
}
:global(.dark) .contributor-item {
background: rgba(var(--primary-rgb), 0.1);
color: var(--text-secondary, #cbd5e1);
}
.metric-grid {
@@ -580,7 +630,13 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
}
.metric-card {
@apply rounded-xl border border-slate-200 bg-white/70 p-4 text-center shadow-sm dark:border-slate-700 dark:bg-slate-900/60;
@apply rounded-xl p-4 text-center shadow-sm;
background: rgba(var(--primary-rgb), 0.03);
border: 1px solid var(--border-color);
}
:global(.dark) .metric-card {
background: rgba(var(--primary-rgb), 0.08);
}
.metric-value {
@@ -588,11 +644,18 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
}
.metric-label {
@apply mt-1 text-xs text-slate-500 dark:text-slate-300;
@apply mt-1 text-xs;
color: var(--text-secondary, #64748b);
}
:global(.dark) .metric-label {
color: var(--text-secondary, #cbd5e1);
}
.account-card {
@apply rounded-2xl border border-slate-200 bg-white/80 p-4 shadow-sm transition-shadow hover:shadow-md dark:border-slate-700 dark:bg-slate-900/60;
@apply rounded-2xl p-4 shadow-sm transition-shadow hover:shadow-md;
background: var(--surface-color);
border: 1px solid var(--border-color);
}
.account-icon {
@@ -608,15 +671,26 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
}
.account-name {
@apply text-sm font-semibold text-slate-900 dark:text-slate-100;
@apply text-sm font-semibold;
color: var(--text-primary, #1e293b);
}
:global(.dark) .account-name {
color: var(--text-primary, #f1f5f9);
}
.account-sub {
@apply text-xs text-slate-500 dark:text-slate-400;
@apply text-xs;
color: var(--text-secondary, #64748b);
}
:global(.dark) .account-sub {
color: var(--text-secondary, #94a3b8);
}
.rate-badge {
@apply rounded-full bg-slate-100 px-2 py-0.5 text-xs font-medium dark:bg-slate-800;
@apply rounded-full px-2 py-0.5 text-xs font-medium;
background: rgba(var(--primary-rgb), 0.1);
}
.progress-row {
@@ -624,7 +698,12 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
}
.progress-track {
@apply h-1.5 flex-1 rounded-full bg-slate-200 dark:bg-slate-700;
@apply h-1.5 flex-1 rounded-full;
background: rgba(var(--primary-rgb), 0.15);
}
:global(.dark) .progress-track {
background: rgba(var(--primary-rgb), 0.25);
}
.progress-bar {
@@ -632,11 +711,22 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
}
.progress-value {
@apply text-xs font-semibold text-slate-600 dark:text-slate-200;
@apply text-xs font-semibold;
color: var(--text-secondary, #475569);
}
:global(.dark) .progress-value {
color: var(--text-secondary, #e2e8f0);
}
.quota-row {
@apply rounded-xl border border-slate-200 bg-white/60 p-3 dark:border-slate-700 dark:bg-slate-900/50;
@apply rounded-xl p-3;
background: rgba(var(--primary-rgb), 0.03);
border: 1px solid var(--border-color);
}
:global(.dark) .quota-row {
background: rgba(var(--primary-rgb), 0.08);
}
.quota-header {
@@ -656,10 +746,20 @@ const getCodexWindowLabel = (type) => (type === 'secondary' ? '周限' : '5h')
}
.quota-percent {
@apply text-xs font-semibold text-slate-600 dark:text-slate-200;
@apply text-xs font-semibold;
color: var(--text-secondary, #475569);
}
:global(.dark) .quota-percent {
color: var(--text-secondary, #e2e8f0);
}
.quota-foot {
@apply mt-1 text-[11px] text-slate-400 dark:text-slate-300;
@apply mt-1 text-[11px];
color: var(--text-tertiary, #94a3b8);
}
:global(.dark) .quota-foot {
color: var(--text-tertiary, #cbd5e1);
}
</style>

View File

@@ -61,6 +61,7 @@
</template>
<script setup>
import { formatNumber } from '@/utils/tools'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
@@ -68,22 +69,6 @@ const apiStatsStore = useApiStatsStore()
const { statsPeriod, currentPeriodData } = storeToRefs(apiStatsStore)
// 格式化数字
const formatNumber = (num) => {
if (typeof num !== 'number') {
num = parseInt(num) || 0
}
if (num === 0) return '0'
// 大数字使用简化格式
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
} else {
return num.toLocaleString()
}
}
</script>
<style scoped>

View File

@@ -297,6 +297,7 @@
<script setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue'
import { formatDate } from '@/utils/tools'
const props = defineProps({
modelValue: {
@@ -542,23 +543,6 @@ const hasResults = computed(() => {
})
// 格式化日期
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
const now = new Date()
const diffInHours = (now - date) / (1000 * 60 * 60)
if (diffInHours < 24) {
return '今天创建'
} else if (diffInHours < 48) {
return '昨天创建'
} else if (diffInHours < 168) {
// 7天内
return `${Math.floor(diffInHours / 24)} 天前`
} else {
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })
}
}
// 更新下拉菜单位置
const updateDropdownPosition = () => {

View File

@@ -1,260 +0,0 @@
<template>
<Teleport to="body">
<Transition appear name="modal">
<div
v-if="isVisible"
class="modal fixed inset-0 z-[100] flex items-center justify-center p-4"
@click.self="handleCancel"
>
<div class="modal-content mx-auto w-full max-w-md p-6">
<div class="mb-6 flex items-start gap-4">
<div
:class="[
'flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl',
dialogType === 'danger'
? 'bg-gradient-to-br from-red-500 to-red-600'
: dialogType === 'warning'
? 'bg-gradient-to-br from-amber-500 to-amber-600'
: 'bg-primary'
]"
>
<i
:class="[
'text-lg text-white',
dialogType === 'danger'
? 'fas fa-trash-alt'
: dialogType === 'warning'
? 'fas fa-exclamation-triangle'
: 'fas fa-question-circle'
]"
/>
</div>
<div class="flex-1">
<h3 class="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{{ title }}
</h3>
<div class="whitespace-pre-line leading-relaxed text-gray-700 dark:text-gray-400">
{{ message }}
</div>
</div>
</div>
<div class="flex items-center justify-end gap-3">
<button
class="btn bg-gray-100 px-6 py-3 text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
:disabled="isProcessing"
@click="handleCancel"
>
{{ cancelText }}
</button>
<button
:class="[
'btn px-6 py-3',
dialogType === 'danger'
? 'btn-danger'
: dialogType === 'warning'
? 'btn-warning'
: 'btn-primary'
]"
:disabled="isProcessing"
@click="handleConfirm"
>
<div v-if="isProcessing" class="loading-spinner mr-2" />
{{ confirmText }}
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
// 状态
const isVisible = ref(false)
const isProcessing = ref(false)
const title = ref('')
const message = ref('')
const confirmText = ref('确认')
const cancelText = ref('取消')
const dialogType = ref('primary') // primary | warning | danger
let resolvePromise = null
// 显示确认对话框
const showConfirm = (
titleText,
messageText,
confirmTextParam = '确认',
cancelTextParam = '取消',
type = 'primary'
) => {
return new Promise((resolve) => {
title.value = titleText
message.value = messageText
confirmText.value = confirmTextParam
cancelText.value = cancelTextParam
dialogType.value = type
isVisible.value = true
isProcessing.value = false
resolvePromise = resolve
})
}
// 处理确认
const handleConfirm = () => {
if (isProcessing.value) return
isProcessing.value = true
// 延迟一点时间以显示loading状态
setTimeout(() => {
isVisible.value = false
isProcessing.value = false
if (resolvePromise) {
resolvePromise(true)
resolvePromise = null
}
}, 200)
}
// 处理取消
const handleCancel = () => {
if (isProcessing.value) return
isVisible.value = false
if (resolvePromise) {
resolvePromise(false)
resolvePromise = null
}
}
// 键盘事件处理
const handleKeydown = (event) => {
if (!isVisible.value) return
if (event.key === 'Escape') {
handleCancel()
} else if (event.key === 'Enter' && !event.shiftKey && !event.ctrlKey && !event.altKey) {
handleConfirm()
}
}
// 全局方法注册
onMounted(() => {
window.showConfirm = showConfirm
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
if (window.showConfirm === showConfirm) {
delete window.showConfirm
}
document.removeEventListener('keydown', handleKeydown)
})
// 暴露方法供组件使用
defineExpose({
showConfirm
})
</script>
<style scoped>
.modal {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
}
:global(.dark) .modal {
background: rgba(0, 0, 0, 0.7);
}
.modal-content {
background: white;
border-radius: 16px;
box-shadow: 0 20px 64px rgba(0, 0, 0, 0.15);
border: 1px solid #e5e7eb;
max-height: 90vh;
overflow-y: auto;
}
:global(.dark) .modal-content {
background: var(--bg-gradient-start);
border: 1px solid var(--border-color);
box-shadow: 0 20px 64px rgba(0, 0, 0, 0.8);
}
.btn {
@apply inline-flex items-center justify-center rounded-lg px-4 py-2 text-sm font-semibold transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
}
.btn-danger {
@apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
}
.btn-warning {
@apply bg-amber-600 text-white hover:bg-amber-700 focus:ring-amber-500;
}
.btn-primary {
background-color: var(--primary-color);
@apply text-white hover:opacity-90 focus:ring-indigo-500;
}
.loading-spinner {
@apply h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-white;
}
/* Modal transitions */
.modal-enter-active,
.modal-leave-active {
transition: all 0.3s ease;
}
.modal-enter-from,
.modal-leave-to {
opacity: 0;
}
.modal-enter-active .modal-content,
.modal-leave-active .modal-content {
transition: transform 0.3s ease;
}
.modal-enter-from .modal-content,
.modal-leave-to .modal-content {
transform: scale(0.9) translateY(-20px);
}
/* Scrollbar styling */
.modal-content::-webkit-scrollbar {
width: 6px;
}
.modal-content::-webkit-scrollbar-track {
background: #f1f5f9;
border-radius: 3px;
}
:global(.dark) .modal-content::-webkit-scrollbar-track {
background: var(--bg-gradient-mid);
}
.modal-content::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 3px;
}
:global(.dark) .modal-content::-webkit-scrollbar-thumb {
background: var(--bg-gradient-end);
}
.modal-content::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
:global(.dark) .modal-content::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
</style>

View File

@@ -59,7 +59,3 @@ const iconBgClass = computed(() => {
return colorMap[props.iconColor] || colorMap.primary
})
</script>
<style scoped>
/* 使用全局样式中定义的 .stat-card 和 .stat-icon 类 */
</style>

View File

@@ -51,7 +51,7 @@
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { Chart } from 'chart.js/auto'
import { useDashboardStore } from '@/stores/dashboard'
import { useChartConfig } from '@/composables/useChartConfig'
import { useChartConfig } from '@/utils/useChartConfig'
import { formatNumber } from '@/utils/tools'
const dashboardStore = useDashboardStore()
@@ -144,7 +144,3 @@ onUnmounted(() => {
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -38,7 +38,7 @@
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { Chart } from 'chart.js/auto'
import { useDashboardStore } from '@/stores/dashboard'
import { useChartConfig } from '@/composables/useChartConfig'
import { useChartConfig } from '@/utils/useChartConfig'
import { useThemeStore } from '@/stores/theme'
const dashboardStore = useDashboardStore()
@@ -189,7 +189,3 @@ onUnmounted(() => {
}
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -206,13 +206,22 @@
<label class="mb-3 block text-sm font-semibold text-gray-700 dark:text-gray-300"
>当前密码</label
>
<input
v-model="changePasswordForm.currentPassword"
class="form-input w-full"
placeholder="请输入当前密码"
required
type="password"
/>
<div class="relative">
<input
v-model="changePasswordForm.currentPassword"
class="form-input w-full pr-10"
placeholder="请输入当前密码"
required
:type="showCurrentPassword ? 'text' : 'password'"
/>
<button
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
type="button"
@click="showCurrentPassword = !showCurrentPassword"
>
<i :class="showCurrentPassword ? 'fas fa-eye-slash' : 'fas fa-eye'" />
</button>
</div>
</div>
<div>
@@ -282,7 +291,8 @@ import { ref, reactive, computed, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { showToast } from '@/utils/tools'
import * as httpApi from '@/utils/http_apis'
import { checkUpdatesApi, changePasswordApi } from '@/utils/http_apis'
import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
@@ -314,6 +324,7 @@ const userMenuOpen = ref(false)
// 修改密码模态框
const showChangePasswordModal = ref(false)
const changePasswordLoading = ref(false)
const showCurrentPassword = ref(false)
const changePasswordForm = reactive({
currentPassword: '',
newPassword: '',
@@ -363,7 +374,7 @@ const checkForUpdates = async () => {
versionInfo.value.checkingUpdate = true
try {
const result = await httpApi.get('/admin/check-updates')
const result = await checkUpdatesApi()
if (result.success) {
const data = result.data
@@ -443,7 +454,7 @@ const changePassword = async () => {
changePasswordLoading.value = true
try {
const data = await httpApi.post('/web/auth/change-password', {
const data = await changePasswordApi({
currentPassword: changePasswordForm.currentPassword,
newPassword: changePasswordForm.newPassword,
newUsername: changePasswordForm.newUsername || undefined

View File

@@ -40,7 +40,6 @@ const tabRouteMap = computed(() => {
apiKeys: '/api-keys',
accounts: '/accounts',
quotaCards: '/quota-cards',
tutorial: '/tutorial',
settings: '/settings'
}
@@ -69,7 +68,6 @@ const initActiveTab = () => {
ApiKeys: 'apiKeys',
Accounts: 'accounts',
QuotaCards: 'quotaCards',
Tutorial: 'tutorial',
Settings: 'settings'
}
if (routeName && nameToTabMap[routeName]) {
@@ -136,7 +134,3 @@ const handleTabChange = async (tabKey) => {
// OEM设置已在App.vue中加载无需重复加载
</script>
<style scoped>
/* 使用全局定义的过渡样式 */
</style>

View File

@@ -70,15 +70,8 @@ const tabs = computed(() => {
})
}
baseTabs.push(
{ key: 'tutorial', name: '使用教程', shortName: '教程', icon: 'fas fa-graduation-cap' },
{ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' }
)
baseTabs.push({ key: 'settings', name: '系统设置', shortName: '设置', icon: 'fas fa-cogs' })
return baseTabs
})
</script>
<style scoped>
/* 使用全局样式中定义的 .tab-btn 类 */
</style>

View File

@@ -475,7 +475,7 @@
<script setup>
import { computed } from 'vue'
import { useTutorialUrls } from '@/composables/useTutorialUrls'
import { useTutorialUrls } from '@/utils/useTutorialUrls'
import NodeInstallTutorial from './NodeInstallTutorial.vue'
const props = defineProps({

View File

@@ -298,7 +298,7 @@
<script setup>
import { computed } from 'vue'
import { useTutorialUrls } from '@/composables/useTutorialUrls'
import { useTutorialUrls } from '@/utils/useTutorialUrls'
import NodeInstallTutorial from './NodeInstallTutorial.vue'
const props = defineProps({

View File

@@ -60,7 +60,7 @@
<script setup>
import { computed } from 'vue'
import { useTutorialUrls } from '@/composables/useTutorialUrls'
import { useTutorialUrls } from '@/utils/useTutorialUrls'
import NodeInstallTutorial from './NodeInstallTutorial.vue'
defineProps({

View File

@@ -168,7 +168,7 @@
</template>
<script setup>
import { useTutorialUrls } from '@/composables/useTutorialUrls'
import { useTutorialUrls } from '@/utils/useTutorialUrls'
import NodeInstallTutorial from './NodeInstallTutorial.vue'
defineProps({

View File

@@ -58,7 +58,7 @@
如果你安装了 Chocolatey Scoop可以使用命令行安装
</p>
<div
class="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 dark:border dark:border-slate-700 dark:bg-slate-900 sm:p-4 sm:text-sm"
class="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 dark:border dark:border-gray-700 dark:bg-gray-900 sm:p-4 sm:text-sm"
>
<div class="mb-2"># 使用 Chocolatey</div>
<div class="whitespace-nowrap text-gray-300">choco install nodejs</div>
@@ -85,7 +85,7 @@
<!-- macOS -->
<div v-else-if="platform === 'macos'" class="node-install-section">
<div
class="mb-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-slate-50 p-4 dark:border-gray-700 dark:from-gray-800 dark:to-slate-800 sm:mb-6 sm:p-6"
class="mb-4 rounded-xl border border-gray-200 bg-gradient-to-r from-gray-50 to-gray-50 p-4 dark:border-gray-700 dark:from-gray-800 dark:to-gray-800 sm:mb-6 sm:p-6"
>
<h5
class="mb-2 flex items-center text-base font-semibold text-gray-800 dark:text-gray-200 sm:mb-3 sm:text-lg"
@@ -99,7 +99,7 @@
如果你已经安装了 Homebrew使用它安装 Node.js 会更方便
</p>
<div
class="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 dark:border dark:border-slate-700 dark:bg-slate-900 sm:p-4 sm:text-sm"
class="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 dark:border dark:border-gray-700 dark:bg-gray-900 sm:p-4 sm:text-sm"
>
<div class="mb-2"># 更新 Homebrew</div>
<div class="whitespace-nowrap text-gray-300">brew update</div>
@@ -167,7 +167,7 @@
nvm 可以方便地管理多个 Node.js 版本
</p>
<div
class="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 dark:border dark:border-slate-700 dark:bg-slate-900 sm:p-4 sm:text-sm"
class="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 dark:border dark:border-gray-700 dark:bg-gray-900 sm:p-4 sm:text-sm"
>
<div class="mb-2"># 安装 nvm</div>
<div class="whitespace-nowrap text-gray-300">
@@ -182,7 +182,7 @@
<div class="mb-4">
<p class="mb-3 text-gray-700 dark:text-gray-300">方法二使用包管理器</p>
<div
class="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 dark:border dark:border-slate-700 dark:bg-slate-900 sm:p-4 sm:text-sm"
class="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs text-green-400 dark:border dark:border-gray-700 dark:bg-gray-900 sm:p-4 sm:text-sm"
>
<div class="mb-2"># Ubuntu/Debian</div>
<div class="whitespace-nowrap text-gray-300">

View File

@@ -259,7 +259,3 @@ watch(
}
)
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -249,7 +249,7 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/tools'
import { showToast, formatNumber, formatDate } from '@/utils/tools'
import CreateApiKeyModal from './CreateApiKeyModal.vue'
import ViewApiKeyModal from './ViewApiKeyModal.vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
@@ -280,26 +280,6 @@ const activeApiKeysCount = computed(() => {
return apiKeys.value.filter((key) => !(key.isDeleted === 'true' || key.deletedAt)).length
})
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadApiKeys = async () => {
loading.value = true
try {
@@ -348,7 +328,3 @@ onMounted(() => {
loadApiKeys()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -351,7 +351,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useUserStore } from '@/stores/user'
import { showToast } from '@/utils/tools'
import { showToast, formatNumber } from '@/utils/tools'
const userStore = useUserStore()
@@ -360,15 +360,6 @@ const selectedPeriod = ref('week')
const usageStats = ref(null)
const userApiKeys = ref([])
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const loadUsageStats = async () => {
loading.value = true
try {
@@ -391,7 +382,3 @@ onMounted(() => {
loadUsageStats()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -197,7 +197,7 @@
<script setup>
import { ref } from 'vue'
import { showToast } from '@/utils/tools'
import { showToast, formatNumber, formatDate } from '@/utils/tools'
defineProps({
show: {
@@ -214,26 +214,6 @@ const emit = defineEmits(['close'])
const showFullKey = ref(false)
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const copyToClipboard = async (text) => {
try {
await navigator.clipboard.writeText(text)
@@ -244,7 +224,3 @@ const copyToClipboard = async (text) => {
}
}
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -1,49 +0,0 @@
import { ref } from 'vue'
const showConfirmModal = ref(false)
const confirmOptions = ref({
title: '',
message: '',
confirmText: '继续',
cancelText: '取消'
})
const confirmResolve = ref(null)
export function useConfirm() {
const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
return new Promise((resolve) => {
confirmOptions.value = {
title,
message,
confirmText,
cancelText
}
confirmResolve.value = resolve
showConfirmModal.value = true
})
}
const handleConfirm = () => {
showConfirmModal.value = false
if (confirmResolve.value) {
confirmResolve.value(true)
confirmResolve.value = null
}
}
const handleCancel = () => {
showConfirmModal.value = false
if (confirmResolve.value) {
confirmResolve.value(false)
confirmResolve.value = null
}
}
return {
showConfirmModal,
confirmOptions,
showConfirm,
handleConfirm,
handleCancel
}
}

View File

@@ -1,13 +0,0 @@
export const APP_CONFIG = {
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
apiPrefix: import.meta.env.DEV ? '/webapi' : ''
}
export function getAppUrl(path = '') {
if (path && !path.startsWith('/')) path = '/' + path
return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path)
}
export function getLoginUrl() {
return getAppUrl('/login')
}

View File

@@ -4,6 +4,7 @@ import ElementPlus from 'element-plus'
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
import 'element-plus/dist/index.css'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './assets/fonts/inter/inter.css'
import App from './App.vue'
import router from './router'
import { useUserStore } from './stores/user'

View File

@@ -1,8 +1,7 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useUserStore } from '@/stores/user'
import { APP_CONFIG } from '@/config/app'
import { showToast } from '@/utils/tools'
import { APP_CONFIG, showToast } from '@/utils/tools'
// 路由懒加载
const LoginView = () => import('@/views/LoginView.vue')
@@ -15,7 +14,6 @@ const ApiKeysView = () => import('@/views/ApiKeysView.vue')
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
const AccountsView = () => import('@/views/AccountsView.vue')
const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
const TutorialView = () => import('@/views/TutorialView.vue')
const SettingsView = () => import('@/views/SettingsView.vue')
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
const QuotaCardsView = () => import('@/views/QuotaCardsView.vue')
@@ -125,18 +123,6 @@ const routes = [
}
]
},
{
path: '/tutorial',
component: MainLayout,
meta: { requiresAuth: true },
children: [
{
path: '',
name: 'Tutorial',
component: TutorialView
}
]
},
{
path: '/settings',
component: MainLayout,

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import {
getApiKeys,
createApiKey as apiCreateApiKey,
updateApiKey as apiUpdateApiKey,
toggleApiKey as apiToggleApiKey,
deleteApiKey as apiDeleteApiKey,
getApiKeyStats,
getApiKeyTags
} from '@/utils/http_apis'
import * as httpApis from '@/utils/http_apis'
export const useApiKeysStore = defineStore('apiKeys', () => {
const apiKeys = ref([])
@@ -20,129 +13,58 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
const fetchApiKeys = async () => {
loading.value = true
error.value = null
try {
const response = await getApiKeys()
if (response.success) {
apiKeys.value = response.data || []
} else {
throw new Error(response.message || '获取API Keys失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
const res = await httpApis.getApiKeysApi()
if (res.success) apiKeys.value = res.data || []
else error.value = res.message
loading.value = false
}
const createApiKey = async (data) => {
loading.value = true
error.value = null
try {
const response = await apiCreateApiKey(data)
if (response.success) {
await fetchApiKeys()
return response.data
} else {
throw new Error(response.message || '创建API Key失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
const res = await httpApis.createApiKeyApi(data)
if (res.success) await fetchApiKeys()
else error.value = res.message
loading.value = false
return res
}
const updateApiKey = async (id, data) => {
loading.value = true
error.value = null
try {
const response = await apiUpdateApiKey(id, data)
if (response.success) {
await fetchApiKeys()
return response
} else {
throw new Error(response.message || '更新API Key失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
const res = await httpApis.updateApiKeyApi(id, data)
if (res.success) await fetchApiKeys()
else error.value = res.message
loading.value = false
return res
}
const toggleApiKey = async (id) => {
loading.value = true
error.value = null
try {
const response = await apiToggleApiKey(id)
if (response.success) {
await fetchApiKeys()
return response
} else {
throw new Error(response.message || '切换状态失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
const res = await httpApis.toggleApiKeyApi(id)
if (res.success) await fetchApiKeys()
else error.value = res.message
loading.value = false
return res
}
const renewApiKey = async (id, data) => {
loading.value = true
error.value = null
try {
const response = await apiUpdateApiKey(id, data)
if (response.success) {
await fetchApiKeys()
return response
} else {
throw new Error(response.message || '续期失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
const renewApiKey = (id, data) => updateApiKey(id, data)
const deleteApiKey = async (id) => {
loading.value = true
error.value = null
try {
const response = await apiDeleteApiKey(id)
if (response.success) {
await fetchApiKeys()
return response
} else {
throw new Error(response.message || '删除失败')
}
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
const res = await httpApis.deleteApiKeyApi(id)
if (res.success) await fetchApiKeys()
else error.value = res.message
loading.value = false
return res
}
const fetchApiKeyStats = async (id, timeRange = 'all') => {
try {
const response = await getApiKeyStats(id, { timeRange })
if (response.success) {
return response.stats
} else {
throw new Error(response.message || '获取统计失败')
}
} catch (err) {
console.error('获取API Key统计失败:', err)
return null
}
const res = await httpApis.getApiKeyStatsApi(id, { timeRange })
return res.success ? res.stats : null
}
const fetchTags = async () => {
const res = await httpApis.getApiKeyTagsApi()
return res.success ? res.data || [] : []
}
const sortApiKeys = (field) => {
@@ -154,20 +76,6 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
}
}
const fetchTags = async () => {
try {
const response = await getApiKeyTags()
if (response.success) {
return response.data || []
} else {
throw new Error(response.message || '获取标签失败')
}
} catch (err) {
console.error('获取标签失败:', err)
return []
}
}
const reset = () => {
apiKeys.value = []
loading.value = false

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import * as httpApi from '@/utils/http_apis'
import * as httpApis from '@/utils/http_apis'
export const useApiStatsStore = defineStore('apistats', () => {
// 状态
@@ -36,6 +37,9 @@ export const useApiStatsStore = defineStore('apistats', () => {
// 服务倍率配置
const serviceRates = ref(null)
// Key 级别的服务倍率
const keyServiceRates = ref({})
// 计算属性
const currentPeriodData = computed(() => {
const defaultData = {
@@ -124,17 +128,20 @@ export const useApiStatsStore = defineStore('apistats', () => {
try {
// 获取 API Key ID
const idResult = await httpApi.getKeyId(trimmedKey)
const idResult = await httpApis.getKeyIdApi(trimmedKey)
if (idResult.success) {
apiId.value = idResult.data.id
// 使用 apiId 查询统计数据
const statsResult = await httpApi.getUserStats(apiId.value)
const statsResult = await httpApis.getUserStatsApi(apiId.value)
if (statsResult.success) {
statsData.value = statsResult.data
// 保存 Key 级别的服务倍率
keyServiceRates.value = statsResult.data.serviceRates || {}
// 同时加载今日和本月的统计数据
await loadAllPeriodStats()
@@ -182,9 +189,9 @@ export const useApiStatsStore = defineStore('apistats', () => {
try {
const [dailyResult, monthlyResult, alltimeResult] = await Promise.all([
httpApi.getUserModelStats(apiId.value, 'daily'),
httpApi.getUserModelStats(apiId.value, 'monthly'),
httpApi.getUserModelStats(apiId.value, 'alltime')
httpApis.getUserModelStatsApi(apiId.value, 'daily'),
httpApis.getUserModelStatsApi(apiId.value, 'monthly'),
httpApis.getUserModelStatsApi(apiId.value, 'alltime')
])
dailyModelStats.value = dailyResult.success ? dailyResult.data || [] : []
@@ -207,7 +214,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
// 加载指定时间段的统计数据
async function loadPeriodStats(period) {
try {
const result = await httpApi.getUserModelStats(apiId.value, period)
const result = await httpApis.getUserModelStatsApi(apiId.value, period)
if (result.success) {
// 计算汇总数据
@@ -258,7 +265,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
modelStatsLoading.value = true
try {
const result = await httpApi.getUserModelStats(apiId.value, period)
const result = await httpApis.getUserModelStatsApi(apiId.value, period)
if (result.success) {
modelStats.value = result.data || []
@@ -310,11 +317,14 @@ export const useApiStatsStore = defineStore('apistats', () => {
modelStats.value = []
try {
const result = await httpApi.getUserStats(apiId.value)
const result = await httpApis.getUserStatsApi(apiId.value)
if (result.success) {
statsData.value = result.data
// 保存 Key 级别的服务倍率
keyServiceRates.value = result.data.serviceRates || {}
// 调试:打印返回的限制数据
console.log('API Stats - Full response:', result.data)
console.log('API Stats - limits data:', result.data.limits)
@@ -343,7 +353,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
async function loadOemSettings() {
oemLoading.value = true
try {
const result = await httpApi.getOemSettings()
const result = await httpApis.getOemSettingsApi()
if (result && result.success && result.data) {
oemSettings.value = { ...oemSettings.value, ...result.data }
}
@@ -363,7 +373,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
// 加载服务倍率配置
async function loadServiceRates() {
try {
const result = await httpApi.getServiceRates()
const result = await httpApis.getServiceRatesApi()
if (result && result.success && result.data) {
serviceRates.value = result.data
}
@@ -428,10 +438,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
modelStats.value = []
apiKeys.value = keys
apiIds.value = []
keyServiceRates.value = {} // 多 Key 模式清理 Key 倍率
try {
// 批量获取 API Key IDs
const idResults = await Promise.allSettled(keys.map((key) => httpApi.getKeyId(key)))
const idResults = await Promise.allSettled(keys.map((key) => httpApis.getKeyIdApi(key)))
const validIds = []
const validKeys = []
@@ -453,7 +464,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
apiKeys.value = validKeys
// 批量查询统计数据
const batchResult = await httpApi.getBatchStats(validIds)
const batchResult = await httpApis.getBatchStatsApi(validIds)
if (batchResult.success) {
aggregatedStats.value = batchResult.data.aggregated
@@ -489,7 +500,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
modelStatsLoading.value = true
try {
const result = await httpApi.getBatchModelStats(apiIds.value, period)
const result = await httpApis.getBatchModelStatsApi(apiIds.value, period)
if (result.success) {
modelStats.value = result.data || []
@@ -546,6 +557,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
error.value = ''
statsPeriod.value = 'daily'
apiId.value = null
keyServiceRates.value = {}
// 清除多 Key 模式数据
apiKeys.value = []
apiIds.value = []
@@ -587,6 +599,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
individualStats,
invalidKeys,
serviceRates,
keyServiceRates,
// Computed
currentPeriodData,

View File

@@ -1,7 +1,8 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import router from '@/router'
import { login as apiLogin, getAuthUser, getOemSettings } from '@/utils/http_apis'
import { loginApi, getAuthUserApi, getOemSettingsApi } from '@/utils/http_apis'
export const useAuthStore = defineStore('auth', () => {
// 状态
@@ -29,7 +30,7 @@ export const useAuthStore = defineStore('auth', () => {
loginError.value = ''
try {
const result = await apiLogin(credentials)
const result = await loginApi(credentials)
if (result.success) {
authToken.value = result.token
@@ -66,7 +67,7 @@ export const useAuthStore = defineStore('auth', () => {
async function verifyToken() {
try {
const userResult = await getAuthUser()
const userResult = await getAuthUserApi()
if (!userResult.success || !userResult.user) {
logout()
return
@@ -80,7 +81,7 @@ export const useAuthStore = defineStore('auth', () => {
async function loadOemSettings() {
oemLoading.value = true
try {
const result = await getOemSettings()
const result = await getOemSettingsApi()
if (result.success && result.data) {
oemSettings.value = { ...oemSettings.value, ...result.data }

View File

@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { getSupportedClients } from '@/utils/http_apis'
import { getSupportedClientsApi } from '@/utils/http_apis'
export const useClientsStore = defineStore('clients', {
state: () => ({
@@ -10,31 +10,14 @@ export const useClientsStore = defineStore('clients', {
actions: {
async loadSupportedClients() {
if (this.supportedClients.length > 0) {
return this.supportedClients
}
if (this.supportedClients.length > 0) return this.supportedClients
this.loading = true
this.error = null
try {
const response = await getSupportedClients()
if (response.success) {
this.supportedClients = response.data || []
} else {
this.error = response.message || '加载支持的客户端失败'
console.error('Failed to load supported clients:', this.error)
}
return this.supportedClients
} catch (error) {
this.error = error.message || '加载支持的客户端失败'
console.error('Error loading supported clients:', error)
return []
} finally {
this.loading = false
}
const res = await getSupportedClientsApi()
if (res.success) this.supportedClients = res.data || []
else this.error = res.message
this.loading = false
return this.supportedClients
}
}
})

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { getDashboard, getUsageCosts, getUsageStats } from '@/utils/http_apis'
import { getDashboardApi, getUsageCostsApi, getUsageStatsApi } from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
export const useDashboardStore = defineStore('dashboard', () => {
@@ -51,7 +52,6 @@ export const useDashboardStore = defineStore('dashboard', () => {
totalCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } }
})
const modelStats = ref([])
const trendData = ref([])
const dashboardModelStats = ref([])
const apiKeysTrendData = ref({
@@ -136,9 +136,6 @@ export const useDashboardStore = defineStore('dashboard', () => {
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
const accountUsageGroup = ref('claude') // claude | openai | gemini
// 默认时间
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
// 计算属性
const formattedUptime = computed(() => {
const seconds = dashboardData.value.uptime
@@ -155,22 +152,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
})
// 辅助函数:基于系统时区计算时间
// function getDateInSystemTimezone(date = new Date()) {
// const offset = dashboardData.value.systemTimezone || 8
// // 将本地时间转换为UTC时间然后加上系统时区偏移
// const utcTime = date.getTime() + date.getTimezoneOffset() * 60000
// return new Date(utcTime + offset * 3600000)
// }
// 辅助函数获取系统时区某一天的起止UTC时间
// 输入:一个本地时间的日期对象(如用户选择的日期)
// 输出该日期在系统时区的0点/23:59对应的UTC时间
function getSystemTimezoneDay(localDate, startOfDay = true) {
// 固定使用UTC+8因为后端系统时区是UTC+8
// const systemTz = 8
// 获取本地日期的年月日(这是用户想要查看的日期)
const year = localDate.getFullYear()
const month = localDate.getMonth()
const day = localDate.getDate()
@@ -178,16 +164,46 @@ export const useDashboardStore = defineStore('dashboard', () => {
if (startOfDay) {
// 系统时区UTC+8的 YYYY-MM-DD 00:00:00
// 对应的UTC时间是前一天的16:00
// 例如UTC+8的2025-07-29 00:00:00 = UTC的2025-07-28 16:00:00
return new Date(Date.UTC(year, month, day - 1, 16, 0, 0, 0))
} else {
// 系统时区UTC+8的 YYYY-MM-DD 23:59:59
// 对应的UTC时间是当天的15:59:59
// 例如UTC+8的2025-07-29 23:59:59 = UTC的2025-07-29 15:59:59
return new Date(Date.UTC(year, month, day, 15, 59, 59, 999))
}
}
// 公共函数:根据预设计算时间范围
function getPresetTimeRange(preset) {
const now = new Date()
switch (preset) {
case 'today': {
return { start: getSystemTimezoneDay(now, true), end: getSystemTimezoneDay(now, false) }
}
case 'last24h': {
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: new Date(now) }
}
case 'yesterday': {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
return {
start: getSystemTimezoneDay(yesterday, true),
end: getSystemTimezoneDay(yesterday, false)
}
}
case 'dayBefore': {
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
return {
start: getSystemTimezoneDay(dayBefore, true),
end: getSystemTimezoneDay(dayBefore, false)
}
}
default: {
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: new Date(now) }
}
}
}
const persistDatePreferences = (
preset = dateFilter.value.preset,
granularity = trendGranularity.value
@@ -221,9 +237,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
}
const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([
getDashboard(),
getUsageCosts(costsParams.today),
getUsageCosts(costsParams.all)
getDashboardApi(),
getUsageCostsApi(costsParams.today),
getUsageCostsApi(costsParams.all)
])
if (dashboardResponse.success) {
@@ -302,68 +318,25 @@ export const useDashboardStore = defineStore('dashboard', () => {
let url = '/admin/usage-trend?'
if (granularity === 'hour') {
// 小时粒度,计算时间范围
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 使用自定义时间范围 - 直接按系统时区字符串传递,避免额外时区偏移导致窗口错位
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else if (dateFilter.value.type === 'preset') {
const { start, end } = getPresetTimeRange(dateFilter.value.preset)
url += `&startDate=${encodeURIComponent(start.toISOString())}`
url += `&endDate=${encodeURIComponent(end.toISOString())}`
} else {
// 使用预设计算时间范围与loadApiKeysTrend保持一致
const now = new Date()
let startTime, endTime
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
// 今日使用系统时区的当日0点-23:59
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
// 近24小时从当前时间往前推24小时
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startTime = getSystemTimezoneDay(yesterday, true)
endTime = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
// 前天:基于系统时区的前天
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startTime = getSystemTimezoneDay(dayBefore, true)
endTime = getSystemTimezoneDay(dayBefore, false)
break
}
default: {
// 默认近24小时
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
}
} else {
// 默认使用days参数计算
startTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
endTime = now
}
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
url += `&startDate=${encodeURIComponent(new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString())}`
url += `&endDate=${encodeURIComponent(now.toISOString())}`
}
} else {
// 天粒度,传递天数
url += `granularity=day&days=${days}`
}
const response = await getUsageStats(url)
const response = await getUsageStatsApi(url)
if (response.success) {
trendData.value = response.data
}
@@ -377,78 +350,37 @@ export const useDashboardStore = defineStore('dashboard', () => {
try {
let url = `/admin/model-stats?period=${period}`
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
if (dateFilter.value.type === 'custom' || currentGranularity === 'hour') {
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 按系统时区字符串直传,避免额外时区转换
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else if (currentGranularity === 'hour' && dateFilter.value.type === 'preset') {
// 小时粒度的预设时间范围
const now = new Date()
let startTime, endTime
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startTime = getSystemTimezoneDay(yesterday, true)
endTime = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startTime = getSystemTimezoneDay(dayBefore, true)
endTime = getSystemTimezoneDay(dayBefore, false)
break
}
default: {
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
}
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
const { start, end } = getPresetTimeRange(dateFilter.value.preset)
url += `&startDate=${encodeURIComponent(start.toISOString())}`
url += `&endDate=${encodeURIComponent(end.toISOString())}`
}
} else if (dateFilter.value.type === 'preset' && currentGranularity === 'day') {
// 天粒度的预设时间范围需要传递startDate和endDate参数
const now = new Date()
let startDate, endDate
const option = dateFilter.value.presetOptions.find(
(opt) => opt.value === dateFilter.value.preset
)
if (option) {
let startDate, endDate
if (dateFilter.value.preset === 'today') {
// 今日从系统时区的今天0点到23:59
startDate = getSystemTimezoneDay(now, true)
endDate = getSystemTimezoneDay(now, false)
} else {
// 7天或30天从N天前的0点到今天的23:59
const daysAgo = new Date()
daysAgo.setDate(daysAgo.getDate() - (option.days - 1))
startDate = getSystemTimezoneDay(daysAgo, true)
endDate = getSystemTimezoneDay(now, false)
}
url += `&startDate=${encodeURIComponent(startDate.toISOString())}`
url += `&endDate=${encodeURIComponent(endDate.toISOString())}`
}
}
const response = await getUsageStats(url)
const response = await getUsageStatsApi(url)
if (response.success) {
dashboardModelStats.value = response.data
}
@@ -464,64 +396,21 @@ export const useDashboardStore = defineStore('dashboard', () => {
let days = 7
if (currentGranularity === 'hour') {
// 小时粒度,计算时间范围
url += `granularity=hour`
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
// 使用自定义时间范围 - 按系统时区字符串直传
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else if (dateFilter.value.type === 'preset') {
const { start, end } = getPresetTimeRange(dateFilter.value.preset)
url += `&startDate=${encodeURIComponent(start.toISOString())}`
url += `&endDate=${encodeURIComponent(end.toISOString())}`
} else {
// 使用预设计算时间范围与setDateFilterPreset保持一致
const now = new Date()
let startTime, endTime
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
// 近24小时从当前时间往前推24小时
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
// 昨天:基于系统时区的昨天
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startTime = getSystemTimezoneDay(yesterday, true)
endTime = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
// 前天:基于系统时区的前天
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startTime = getSystemTimezoneDay(dayBefore, true)
endTime = getSystemTimezoneDay(dayBefore, false)
break
}
default: {
// 默认近24小时
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
}
} else {
// 默认近24小时
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
url += `&startDate=${encodeURIComponent(new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString())}`
url += `&endDate=${encodeURIComponent(now.toISOString())}`
}
} else {
// 天粒度,传递天数
days =
dateFilter.value.type === 'preset'
? dateFilter.value.preset === 'today'
@@ -535,7 +424,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
url += `&metric=${metric}`
const response = await getUsageStats(url)
const response = await getUsageStatsApi(url)
if (response.success) {
apiKeysTrendData.value = {
data: response.data || [],
@@ -560,49 +449,14 @@ export const useDashboardStore = defineStore('dashboard', () => {
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
} else if (dateFilter.value.type === 'preset') {
const { start, end } = getPresetTimeRange(dateFilter.value.preset)
url += `&startDate=${encodeURIComponent(start.toISOString())}`
url += `&endDate=${encodeURIComponent(end.toISOString())}`
} else {
const now = new Date()
let startTime
let endTime
if (dateFilter.value.type === 'preset') {
switch (dateFilter.value.preset) {
case 'today': {
startTime = getSystemTimezoneDay(now, true)
endTime = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
endTime = new Date(now)
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startTime = getSystemTimezoneDay(yesterday, true)
endTime = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startTime = getSystemTimezoneDay(dayBefore, true)
endTime = getSystemTimezoneDay(dayBefore, false)
break
}
default: {
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
}
} else {
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
endTime = now
}
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
url += `&startDate=${encodeURIComponent(new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString())}`
url += `&endDate=${encodeURIComponent(now.toISOString())}`
}
} else {
days =
@@ -618,7 +472,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
url += `&group=${group}`
const response = await getUsageStats(url)
const response = await getUsageStatsApi(url)
if (response.success) {
accountUsageTrendData.value = {
data: response.data || [],
@@ -643,40 +497,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
const option = dateFilter.value.presetOptions.find((opt) => opt.value === normalizedPreset)
const now = new Date()
let startDate
let endDate
let startDate, endDate
if (trendGranularity.value === 'hour') {
switch (normalizedPreset) {
case 'today': {
startDate = getSystemTimezoneDay(now, true)
endDate = getSystemTimezoneDay(now, false)
break
}
case 'last24h': {
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
break
}
case 'yesterday': {
const yesterday = new Date()
yesterday.setDate(yesterday.getDate() - 1)
startDate = getSystemTimezoneDay(yesterday, true)
endDate = getSystemTimezoneDay(yesterday, false)
break
}
case 'dayBefore': {
const dayBefore = new Date()
dayBefore.setDate(dayBefore.getDate() - 2)
startDate = getSystemTimezoneDay(dayBefore, true)
endDate = getSystemTimezoneDay(dayBefore, false)
break
}
default: {
endDate = new Date(now)
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
}
}
const range = getPresetTimeRange(normalizedPreset)
startDate = range.start
endDate = range.end
} else {
startDate = new Date(now)
endDate = new Date(now)
@@ -891,7 +717,6 @@ export const useDashboardStore = defineStore('dashboard', () => {
loading,
dashboardData,
costsData,
modelStats,
trendData,
dashboardModelStats,
apiKeysTrendData,
@@ -900,7 +725,6 @@ export const useDashboardStore = defineStore('dashboard', () => {
trendGranularity,
apiKeysTrendMetric,
accountUsageGroup,
defaultTime,
// 计算属性
formattedUptime,

View File

@@ -1,6 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getOemSettings, updateOemSettings } from '@/utils/http_apis'
import { getOemSettingsApi, updateOemSettingsApi } from '@/utils/http_apis'
export const useSettingsStore = defineStore('settings', () => {
// 状态
@@ -19,40 +20,24 @@ export const useSettingsStore = defineStore('settings', () => {
// Actions
const loadOemSettings = async () => {
loading.value = true
try {
const result = await getOemSettings()
if (result && result.success) {
oemSettings.value = { ...oemSettings.value, ...result.data }
applyOemSettings()
}
return result
} catch (error) {
console.error('Failed to load OEM settings:', error)
throw error
} finally {
loading.value = false
const res = await getOemSettingsApi()
if (res.success) {
oemSettings.value = { ...oemSettings.value, ...res.data }
applyOemSettings()
}
loading.value = false
return res
}
const saveOemSettings = async (settings) => {
saving.value = true
try {
const result = await updateOemSettings(settings)
if (result && result.success) {
oemSettings.value = { ...oemSettings.value, ...result.data }
applyOemSettings()
}
return result
} catch (error) {
console.error('Failed to save OEM settings:', error)
throw error
} finally {
saving.value = false
const res = await updateOemSettingsApi(settings)
if (res.success) {
oemSettings.value = { ...oemSettings.value, ...res.data }
applyOemSettings()
}
saving.value = false
return res
}
const resetOemSettings = async () => {

View File

@@ -1,9 +1,9 @@
import { defineStore } from 'pinia'
import axios from 'axios'
import { showToast } from '@/utils/tools'
import { API_PREFIX } from '@/utils/http_apis'
import { APP_CONFIG } from '@/utils/tools'
const API_BASE = `${API_PREFIX}/users`
const API_BASE = `${APP_CONFIG.apiPrefix}/users`
export const useUserStore = defineStore('user', {
state: () => ({

View File

@@ -1,262 +1,336 @@
import axios from 'axios'
import { APP_CONFIG, getLoginUrl } from '@/config/app'
export const API_PREFIX = APP_CONFIG.apiPrefix
const axiosInstance = axios.create({
baseURL: APP_CONFIG.apiPrefix,
timeout: 30000,
headers: { 'Content-Type': 'application/json' }
})
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('authToken')
if (token) config.headers['Authorization'] = `Bearer ${token}`
return config
})
axiosInstance.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
const path = window.location.pathname + window.location.hash
if (!path.includes('/login') && !path.endsWith('/')) {
localStorage.removeItem('authToken')
window.location.href = getLoginUrl()
}
}
return Promise.reject(error)
}
)
// 通用请求函数 - 只会 resolve调用方无需 try-catch
const request = async (config) => {
try {
return await axiosInstance(config)
} catch (error) {
console.error('Request failed:', error)
const data = error.response?.data
if (data && typeof data.success !== 'undefined') return data
const status = error.response?.status
const messages = {
401: '未授权,请重新登录',
403: '无权限访问',
404: '请求的资源不存在',
500: '服务器内部错误'
}
return { success: false, message: messages[status] || error.message || '请求失败' }
}
}
const get = (url, config) => request({ method: 'get', url, ...config })
const post = (url, data, config) => request({ method: 'post', url, data, ...config })
const put = (url, data, config) => request({ method: 'put', url, data, ...config })
const patch = (url, data, config) => request({ method: 'patch', url, data, ...config })
const del = (url, config) => request({ method: 'delete', url, ...config })
import request from '@/utils/request'
// 模型
export const getModels = () => get('/apiStats/models')
export const getModelsByService = (service) => get('/apiStats/models', { params: { service } })
// API Key 测试
export const testClaudeApiKey = (data) => post('/apiStats/api-key/test', data)
export const testGeminiApiKey = (data) => post('/apiStats/api-key/test-gemini', data)
export const testOpenAIApiKey = (data) => post('/apiStats/api-key/test-openai', data)
export const getModelsApi = () => request({ url: '/apiStats/models', method: 'GET' })
// API Stats
export const getKeyId = (apiKey) => post('/apiStats/api/get-key-id', { apiKey })
export const getUserStats = (apiId) => post('/apiStats/api/user-stats', { apiId })
export const getUserModelStats = (apiId, period = 'daily') =>
post('/apiStats/api/user-model-stats', { apiId, period })
export const getBatchStats = (apiIds) => post('/apiStats/api/batch-stats', { apiIds })
export const getBatchModelStats = (apiIds, period = 'daily') =>
post('/apiStats/api/batch-model-stats', { apiIds, period })
export const getKeyIdApi = (apiKey) =>
request({ url: '/apiStats/api/get-key-id', method: 'POST', data: { apiKey } })
export const getUserStatsApi = (apiId) =>
request({ url: '/apiStats/api/user-stats', method: 'POST', data: { apiId } })
export const getUserModelStatsApi = (apiId, period = 'daily') =>
request({ url: '/apiStats/api/user-model-stats', method: 'POST', data: { apiId, period } })
export const getBatchStatsApi = (apiIds) =>
request({ url: '/apiStats/api/batch-stats', method: 'POST', data: { apiIds } })
export const getBatchModelStatsApi = (apiIds, period = 'daily') =>
request({ url: '/apiStats/api/batch-model-stats', method: 'POST', data: { apiIds, period } })
// 认证
export const login = (credentials) => post('/web/auth/login', credentials)
export const getAuthUser = () => get('/web/auth/user')
export const loginApi = (data) => request({ url: '/web/auth/login', method: 'POST', data })
export const getAuthUserApi = () => request({ url: '/web/auth/user', method: 'GET' })
export const changePasswordApi = (data) =>
request({ url: '/web/auth/change-password', method: 'POST', data })
// OEM 设置
export const getOemSettings = () => get('/admin/oem-settings')
export const updateOemSettings = (data) => put('/admin/oem-settings', data)
export const getOemSettingsApi = () => request({ url: '/admin/oem-settings', method: 'GET' })
export const updateOemSettingsApi = (data) =>
request({ url: '/admin/oem-settings', method: 'PUT', data })
// 服务倍率配置(公开接口)
export const getServiceRates = () => get('/apiStats/service-rates')
export const getServiceRatesApi = () => request({ url: '/apiStats/service-rates', method: 'GET' })
// 额度卡兑换(公开接口)
export const redeemCardByApiIdApi = (data) =>
request({ url: '/apiStats/api/redeem-card', method: 'POST', data })
export const getRedemptionHistoryByApiIdApi = (apiId, params = {}) =>
request({ url: '/apiStats/api/redemption-history', method: 'GET', params: { apiId, ...params } })
// 仪表板
export const getDashboard = () => get('/admin/dashboard')
export const getUsageCosts = (period) => get(`/admin/usage-costs?period=${period}`)
export const getUsageStats = (url) => get(url)
export const getDashboardApi = () => request({ url: '/admin/dashboard', method: 'GET' })
export const getUsageCostsApi = (period) =>
request({ url: `/admin/usage-costs?period=${period}`, method: 'GET' })
export const getUsageStatsApi = (url) => request({ url, method: 'GET' })
// 客户端
export const getSupportedClients = () => get('/admin/supported-clients')
export const getSupportedClientsApi = () =>
request({ url: '/admin/supported-clients', method: 'GET' })
// API Keys
export const getApiKeys = () => get('/admin/api-keys')
export const getApiKeysWithParams = (params) => get(`/admin/api-keys?${params}`)
export const createApiKey = (data) => post('/admin/api-keys', data)
export const updateApiKey = (id, data) => put(`/admin/api-keys/${id}`, data)
export const toggleApiKey = (id) => put(`/admin/api-keys/${id}/toggle`)
export const deleteApiKey = (id) => del(`/admin/api-keys/${id}`)
export const getApiKeyStats = (id, params) => get(`/admin/api-keys/${id}/stats`, { params })
export const getApiKeyTags = () => get('/admin/api-keys/tags')
export const getApiKeyUsedModels = () => get('/admin/api-keys/used-models')
export const getApiKeysBatchStats = (data) => post('/admin/api-keys/batch-stats', data)
export const getApiKeysBatchLastUsage = (data) => post('/admin/api-keys/batch-last-usage', data)
export const getDeletedApiKeys = () => get('/admin/api-keys/deleted')
export const getApiKeysCostSortStatus = () => get('/admin/api-keys/cost-sort-status')
export const restoreApiKey = (id) => post(`/admin/api-keys/${id}/restore`)
export const permanentDeleteApiKey = (id) => del(`/admin/api-keys/${id}/permanent`)
export const clearAllDeletedApiKeys = () => del('/admin/api-keys/deleted/clear-all')
export const batchDeleteApiKeys = (data) => del('/admin/api-keys/batch', { data })
export const updateApiKeyExpiration = (id, data) =>
request({ method: 'patch', url: `/admin/api-keys/${id}/expiration`, data })
export const getApiKeysApi = () => request({ url: '/admin/api-keys', method: 'GET' })
export const getApiKeysWithParamsApi = (params) =>
request({ url: `/admin/api-keys?${params}`, method: 'GET' })
export const createApiKeyApi = (data) => request({ url: '/admin/api-keys', method: 'POST', data })
export const updateApiKeyApi = (id, data) =>
request({ url: `/admin/api-keys/${id}`, method: 'PUT', data })
export const toggleApiKeyApi = (id) =>
request({ url: `/admin/api-keys/${id}/toggle`, method: 'PUT' })
export const deleteApiKeyApi = (id) => request({ url: `/admin/api-keys/${id}`, method: 'DELETE' })
export const getApiKeyStatsApi = (id, params) =>
request({ url: `/admin/api-keys/${id}/stats`, method: 'GET', params })
export const getApiKeyModelStatsApi = (id, params) =>
request({ url: `/admin/api-keys/${id}/model-stats`, method: 'GET', params })
export const getApiKeyTagsApi = () => request({ url: '/admin/api-keys/tags', method: 'GET' })
export const getApiKeyTagsDetailsApi = () =>
request({ url: '/admin/api-keys/tags/details', method: 'GET' })
export const createApiKeyTagApi = (name) =>
request({ url: '/admin/api-keys/tags', method: 'POST', data: { name } })
export const deleteApiKeyTagApi = (tagName) =>
request({ url: `/admin/api-keys/tags/${encodeURIComponent(tagName)}`, method: 'DELETE' })
export const renameApiKeyTagApi = (tagName, newName) =>
request({
url: `/admin/api-keys/tags/${encodeURIComponent(tagName)}`,
method: 'PUT',
data: { newName }
})
export const getApiKeyUsedModelsApi = () =>
request({ url: '/admin/api-keys/used-models', method: 'GET' })
export const getApiKeysBatchStatsApi = (data) =>
request({ url: '/admin/api-keys/batch-stats', method: 'POST', data })
export const getApiKeysBatchLastUsageApi = (data) =>
request({ url: '/admin/api-keys/batch-last-usage', method: 'POST', data })
export const getDeletedApiKeysApi = () => request({ url: '/admin/api-keys/deleted', method: 'GET' })
export const getApiKeysCostSortStatusApi = () =>
request({ url: '/admin/api-keys/cost-sort-status', method: 'GET' })
export const restoreApiKeyApi = (id) =>
request({ url: `/admin/api-keys/${id}/restore`, method: 'POST' })
export const permanentDeleteApiKeyApi = (id) =>
request({ url: `/admin/api-keys/${id}/permanent`, method: 'DELETE' })
export const clearAllDeletedApiKeysApi = () =>
request({ url: '/admin/api-keys/deleted/clear-all', method: 'DELETE' })
export const batchDeleteApiKeysApi = (data) =>
request({ url: '/admin/api-keys/batch', method: 'DELETE', data })
export const updateApiKeyExpirationApi = (id, data) =>
request({ url: `/admin/api-keys/${id}/expiration`, method: 'PATCH', data })
export const batchCreateApiKeysApi = (data) =>
request({ url: '/admin/api-keys/batch', method: 'POST', data })
export const batchUpdateApiKeysApi = (data) =>
request({ url: '/admin/api-keys/batch', method: 'PUT', data })
export const getApiKeyUsageRecordsApi = (id, params) =>
request({ url: `/admin/api-keys/${id}/usage-records`, method: 'GET', params })
// Claude 账户
export const getClaudeAccounts = () => get('/admin/claude-accounts')
export const createClaudeAccount = (data) => post('/admin/claude-accounts', data)
export const updateClaudeAccount = (id, data) => put(`/admin/claude-accounts/${id}`, data)
export const deleteClaudeAccount = (id) => del(`/admin/claude-accounts/${id}`)
export const refreshClaudeAccount = (id) => post(`/admin/claude-accounts/${id}/refresh`)
export const generateClaudeAuthUrl = (data) =>
post('/admin/claude-accounts/generate-auth-url', data)
export const exchangeClaudeCode = (data) => post('/admin/claude-accounts/exchange-code', data)
export const generateClaudeSetupTokenUrl = (data) =>
post('/admin/claude-accounts/generate-setup-token-url', data)
export const exchangeClaudeSetupToken = (data) =>
post('/admin/claude-accounts/exchange-setup-token', data)
export const claudeOAuthWithCookie = (data) =>
post('/admin/claude-accounts/oauth-with-cookie', data)
export const claudeSetupTokenWithCookie = (data) =>
post('/admin/claude-accounts/setup-token-with-cookie', data)
export const generateClaudeWorkosAuthUrl = (data) =>
post('/admin/claude-accounts/generate-workos-auth-url', data)
export const getClaudeAccountsApi = () => request({ url: '/admin/claude-accounts', method: 'GET' })
export const createClaudeAccountApi = (data) =>
request({ url: '/admin/claude-accounts', method: 'POST', data })
export const updateClaudeAccountApi = (id, data) =>
request({ url: `/admin/claude-accounts/${id}`, method: 'PUT', data })
export const refreshClaudeAccountApi = (id) =>
request({ url: `/admin/claude-accounts/${id}/refresh`, method: 'POST' })
export const generateClaudeAuthUrlApi = (data) =>
request({ url: '/admin/claude-accounts/generate-auth-url', method: 'POST', data })
export const exchangeClaudeCodeApi = (data) =>
request({ url: '/admin/claude-accounts/exchange-code', method: 'POST', data })
export const generateClaudeSetupTokenUrlApi = (data) =>
request({ url: '/admin/claude-accounts/generate-setup-token-url', method: 'POST', data })
export const exchangeClaudeSetupTokenApi = (data) =>
request({ url: '/admin/claude-accounts/exchange-setup-token', method: 'POST', data })
export const claudeOAuthWithCookieApi = (data) =>
request({ url: '/admin/claude-accounts/oauth-with-cookie', method: 'POST', data })
export const claudeSetupTokenWithCookieApi = (data) =>
request({ url: '/admin/claude-accounts/setup-token-with-cookie', method: 'POST', data })
// Claude Console 账户
export const getClaudeConsoleAccounts = () => get('/admin/claude-console-accounts')
export const createClaudeConsoleAccount = (data) => post('/admin/claude-console-accounts', data)
export const updateClaudeConsoleAccount = (id, data) =>
put(`/admin/claude-console-accounts/${id}`, data)
export const deleteClaudeConsoleAccount = (id) => del(`/admin/claude-console-accounts/${id}`)
export const getClaudeConsoleAccountsApi = () =>
request({ url: '/admin/claude-console-accounts', method: 'GET' })
export const createClaudeConsoleAccountApi = (data) =>
request({ url: '/admin/claude-console-accounts', method: 'POST', data })
export const updateClaudeConsoleAccountApi = (id, data) =>
request({ url: `/admin/claude-console-accounts/${id}`, method: 'PUT', data })
// Bedrock 账户
export const getBedrockAccounts = () => get('/admin/bedrock-accounts')
export const createBedrockAccount = (data) => post('/admin/bedrock-accounts', data)
export const updateBedrockAccount = (id, data) => put(`/admin/bedrock-accounts/${id}`, data)
export const deleteBedrockAccount = (id) => del(`/admin/bedrock-accounts/${id}`)
export const getBedrockAccountsApi = () =>
request({ url: '/admin/bedrock-accounts', method: 'GET' })
export const createBedrockAccountApi = (data) =>
request({ url: '/admin/bedrock-accounts', method: 'POST', data })
export const updateBedrockAccountApi = (id, data) =>
request({ url: `/admin/bedrock-accounts/${id}`, method: 'PUT', data })
// Gemini 账户
export const getGeminiAccounts = () => get('/admin/gemini-accounts')
export const createGeminiAccount = (data) => post('/admin/gemini-accounts', data)
export const updateGeminiAccount = (id, data) => put(`/admin/gemini-accounts/${id}`, data)
export const deleteGeminiAccount = (id) => del(`/admin/gemini-accounts/${id}`)
export const generateGeminiAuthUrl = (data) =>
post('/admin/gemini-accounts/generate-auth-url', data)
export const exchangeGeminiCode = (data) => post('/admin/gemini-accounts/exchange-code', data)
export const getGeminiAccountsApi = () => request({ url: '/admin/gemini-accounts', method: 'GET' })
export const createGeminiAccountApi = (data) =>
request({ url: '/admin/gemini-accounts', method: 'POST', data })
export const updateGeminiAccountApi = (id, data) =>
request({ url: `/admin/gemini-accounts/${id}`, method: 'PUT', data })
export const generateGeminiAuthUrlApi = (data) =>
request({ url: '/admin/gemini-accounts/generate-auth-url', method: 'POST', data })
export const exchangeGeminiCodeApi = (data) =>
request({ url: '/admin/gemini-accounts/exchange-code', method: 'POST', data })
// Gemini API 账户
export const createGeminiApiAccount = (data) => post('/admin/gemini-api-accounts', data)
export const updateGeminiApiAccount = (id, data) => put(`/admin/gemini-api-accounts/${id}`, data)
export const getGeminiApiAccountsApi = () =>
request({ url: '/admin/gemini-api-accounts', method: 'GET' })
export const createGeminiApiAccountApi = (data) =>
request({ url: '/admin/gemini-api-accounts', method: 'POST', data })
export const updateGeminiApiAccountApi = (id, data) =>
request({ url: `/admin/gemini-api-accounts/${id}`, method: 'PUT', data })
// OpenAI 账户
export const getOpenAIAccounts = () => get('/admin/openai-accounts')
export const createOpenAIAccount = (data) => post('/admin/openai-accounts', data)
export const updateOpenAIAccount = (id, data) => put(`/admin/openai-accounts/${id}`, data)
export const deleteOpenAIAccount = (id) => del(`/admin/openai-accounts/${id}`)
export const generateOpenAIAuthUrl = (data) =>
post('/admin/openai-accounts/generate-auth-url', data)
export const exchangeOpenAICode = (data) => post('/admin/openai-accounts/exchange-code', data)
export const getOpenAIAccountsApi = () => request({ url: '/admin/openai-accounts', method: 'GET' })
export const createOpenAIAccountApi = (data) =>
request({ url: '/admin/openai-accounts', method: 'POST', data })
export const updateOpenAIAccountApi = (id, data) =>
request({ url: `/admin/openai-accounts/${id}`, method: 'PUT', data })
export const generateOpenAIAuthUrlApi = (data) =>
request({ url: '/admin/openai-accounts/generate-auth-url', method: 'POST', data })
export const exchangeOpenAICodeApi = (data) =>
request({ url: '/admin/openai-accounts/exchange-code', method: 'POST', data })
// OpenAI Responses 账户
export const getOpenAIResponsesAccounts = () => get('/admin/openai-responses-accounts')
export const createOpenAIResponsesAccount = (data) => post('/admin/openai-responses-accounts', data)
export const updateOpenAIResponsesAccount = (id, data) =>
put(`/admin/openai-responses-accounts/${id}`, data)
export const deleteOpenAIResponsesAccount = (id) => del(`/admin/openai-responses-accounts/${id}`)
export const getOpenAIResponsesAccountsApi = () =>
request({ url: '/admin/openai-responses-accounts', method: 'GET' })
export const createOpenAIResponsesAccountApi = (data) =>
request({ url: '/admin/openai-responses-accounts', method: 'POST', data })
export const updateOpenAIResponsesAccountApi = (id, data) =>
request({ url: `/admin/openai-responses-accounts/${id}`, method: 'PUT', data })
// Azure OpenAI 账户
export const getAzureOpenAIAccounts = () => get('/admin/azure-openai-accounts')
export const createAzureOpenAIAccount = (data) => post('/admin/azure-openai-accounts', data)
export const updateAzureOpenAIAccount = (id, data) =>
put(`/admin/azure-openai-accounts/${id}`, data)
export const deleteAzureOpenAIAccount = (id) => del(`/admin/azure-openai-accounts/${id}`)
export const getAzureOpenAIAccountsApi = () =>
request({ url: '/admin/azure-openai-accounts', method: 'GET' })
export const createAzureOpenAIAccountApi = (data) =>
request({ url: '/admin/azure-openai-accounts', method: 'POST', data })
export const updateAzureOpenAIAccountApi = (id, data) =>
request({ url: `/admin/azure-openai-accounts/${id}`, method: 'PUT', data })
// Droid 账户
export const getDroidAccounts = () => get('/admin/droid-accounts')
export const createDroidAccount = (data) => post('/admin/droid-accounts', data)
export const updateDroidAccount = (id, data) => put(`/admin/droid-accounts/${id}`, data)
export const deleteDroidAccount = (id) => del(`/admin/droid-accounts/${id}`)
export const generateDroidAuthUrl = (data) => post('/admin/droid-accounts/generate-auth-url', data)
export const exchangeDroidCode = (data) => post('/admin/droid-accounts/exchange-code', data)
export const getDroidAccountsApi = () => request({ url: '/admin/droid-accounts', method: 'GET' })
export const createDroidAccountApi = (data) =>
request({ url: '/admin/droid-accounts', method: 'POST', data })
export const updateDroidAccountApi = (id, data) =>
request({ url: `/admin/droid-accounts/${id}`, method: 'PUT', data })
export const generateDroidAuthUrlApi = (data) =>
request({ url: '/admin/droid-accounts/generate-auth-url', method: 'POST', data })
export const exchangeDroidCodeApi = (data) =>
request({ url: '/admin/droid-accounts/exchange-code', method: 'POST', data })
export const getDroidAccountByIdApi = (id) =>
request({ url: `/admin/droid-accounts/${id}`, method: 'GET' })
// CCR 账户
export const getCcrAccounts = () => get('/admin/ccr-accounts')
export const createCcrAccount = (data) => post('/admin/ccr-accounts', data)
export const updateCcrAccount = (id, data) => put(`/admin/ccr-accounts/${id}`, data)
export const deleteCcrAccount = (id) => del(`/admin/ccr-accounts/${id}`)
// Gemini API 账户
export const getGeminiApiAccounts = () => get('/admin/gemini-api-accounts')
export const getCcrAccountsApi = () => request({ url: '/admin/ccr-accounts', method: 'GET' })
export const createCcrAccountApi = (data) =>
request({ url: '/admin/ccr-accounts', method: 'POST', data })
export const updateCcrAccountApi = (id, data) =>
request({ url: `/admin/ccr-accounts/${id}`, method: 'PUT', data })
// 账户通用操作
export const toggleAccountStatus = (endpoint) => put(endpoint)
export const deleteAccountByEndpoint = (endpoint) => del(endpoint)
export const testAccountByEndpoint = (endpoint) => post(endpoint)
export const updateAccountByEndpoint = (endpoint, data) => put(endpoint, data)
export const toggleAccountStatusApi = (endpoint) => request({ url: endpoint, method: 'PUT' })
export const deleteAccountByEndpointApi = (endpoint) => request({ url: endpoint, method: 'DELETE' })
export const testAccountByEndpointApi = (endpoint) => request({ url: endpoint, method: 'POST' })
export const updateAccountByEndpointApi = (endpoint, data) =>
request({ url: endpoint, method: 'PUT', data })
// 账户使用统计
export const getClaudeAccountsUsage = () => get('/admin/claude-accounts/usage')
export const getAccountsBindingCounts = () => get('/admin/accounts/binding-counts')
export const getAccountUsageHistory = (id, platform, days = 30) =>
get(`/admin/accounts/${id}/usage-history?platform=${platform}&days=${days}`)
export const getClaudeAccountsUsageApi = () =>
request({ url: '/admin/claude-accounts/usage', method: 'GET' })
export const getAccountsBindingCountsApi = () =>
request({ url: '/admin/accounts/binding-counts', method: 'GET' })
export const getAccountUsageHistoryApi = (id, platform, days = 30) =>
request({
url: `/admin/accounts/${id}/usage-history?platform=${platform}&days=${days}`,
method: 'GET'
})
export const getClaudeConsoleAccountUsageApi = (id) =>
request({ url: `/admin/claude-console-accounts/${id}/usage`, method: 'GET' })
export const getAccountUsageRecordsByIdApi = (id, params) =>
request({ url: `/admin/accounts/${id}/usage-records`, method: 'GET', params })
// 账户组
export const getAccountGroups = () => get('/admin/account-groups')
export const createAccountGroup = (data) => post('/admin/account-groups', data)
export const updateAccountGroup = (id, data) => put(`/admin/account-groups/${id}`, data)
export const deleteAccountGroup = (id) => del(`/admin/account-groups/${id}`)
export const getAccountGroupsApi = () => request({ url: '/admin/account-groups', method: 'GET' })
export const createAccountGroupApi = (data) =>
request({ url: '/admin/account-groups', method: 'POST', data })
export const updateAccountGroupApi = (id, data) =>
request({ url: `/admin/account-groups/${id}`, method: 'PUT', data })
export const deleteAccountGroupApi = (id) =>
request({ url: `/admin/account-groups/${id}`, method: 'DELETE' })
export const getAccountGroupMembersApi = (id) =>
request({ url: `/admin/account-groups/${id}/members`, method: 'GET' })
// 用户管理
export const getUsers = () => get('/admin/users')
export const createUser = (data) => post('/admin/users', data)
export const updateUser = (id, data) => put(`/admin/users/${id}`, data)
export const deleteUser = (id) => del(`/admin/users/${id}`)
export const updateUserRole = (id, data) => put(`/admin/users/${id}/role`, data)
export const getUserUsageStats = (id, params) => get(`/admin/users/${id}/usage-stats`, { params })
// 使用记录
export const getApiKeyUsageRecords = (id, params) =>
get(`/admin/api-keys/${id}/usage-records`, { params })
export const getAccountUsageRecords = (type, id, params) =>
get(`/admin/${type}-accounts/${id}/usage-records`, { params })
// 系统日志
export const getSystemLogs = (params) => get('/admin/logs', { params })
// 用户管理(管理员)
export const getUsersApi = () => request({ url: '/admin/users', method: 'GET' })
// 配额卡片
export const getQuotaCards = () => get('/admin/quota-cards')
export const createQuotaCard = (data) => post('/admin/quota-cards', data)
export const updateQuotaCard = (id, data) => put(`/admin/quota-cards/${id}`, data)
export const deleteQuotaCard = (id) => del(`/admin/quota-cards/${id}`)
export const redeemQuotaCard = (data) => post('/admin/quota-cards/redeem', data)
export const createQuotaCardApi = (data) =>
request({ url: '/admin/quota-cards', method: 'POST', data })
export const deleteQuotaCardApi = (id) =>
request({ url: `/admin/quota-cards/${id}`, method: 'DELETE' })
export const getQuotaCardsWithParamsApi = (params) =>
request({ url: '/admin/quota-cards', method: 'GET', params })
export const getQuotaCardsStatsApi = () =>
request({ url: '/admin/quota-cards/stats', method: 'GET' })
export const getRedemptionsApi = () => request({ url: '/admin/redemptions', method: 'GET' })
export const revokeRedemptionApi = (id, data) =>
request({ url: `/admin/redemptions/${id}/revoke`, method: 'POST', data })
export const getQuotaCardLimitsApi = () =>
request({ url: '/admin/quota-cards/limits', method: 'GET' })
export const updateQuotaCardLimitsApi = (data) =>
request({ url: '/admin/quota-cards/limits', method: 'PUT', data })
// 账户测试
export const testAccount = (type, id) => post(`/admin/${type}-accounts/${id}/test`)
export const getAccountTestHistory = (type, id) => get(`/admin/${type}-accounts/${id}/test-history`)
// 账户余额
export const getAccountBalanceApi = (id, params) =>
request({ url: `/admin/accounts/${id}/balance`, method: 'GET', params })
export const refreshAccountBalanceApi = (id, data) =>
request({ url: `/admin/accounts/${id}/balance/refresh`, method: 'POST', data })
export const getBalanceSummaryApi = () =>
request({ url: '/admin/accounts/balance/summary', method: 'GET' })
export const getBalanceByPlatformApi = (platform, params) =>
request({ url: `/admin/accounts/balance/platform/${platform}`, method: 'GET', params })
// 定时测试
export const getScheduledTests = () => get('/admin/scheduled-tests')
export const createScheduledTest = (data) => post('/admin/scheduled-tests', data)
export const updateScheduledTest = (id, data) => put(`/admin/scheduled-tests/${id}`, data)
export const deleteScheduledTest = (id) => del(`/admin/scheduled-tests/${id}`)
// 账户余额脚本
export const getAccountBalanceScriptApi = (id, platform) =>
request({ url: `/admin/accounts/${id}/balance/script?platform=${platform}`, method: 'GET' })
export const updateAccountBalanceScriptApi = (id, platform, data) =>
request({ url: `/admin/accounts/${id}/balance/script?platform=${platform}`, method: 'PUT', data })
export const testAccountBalanceScriptApi = (id, platform, data) =>
request({
url: `/admin/accounts/${id}/balance/script/test?platform=${platform}`,
method: 'POST',
data
})
// 统一 User-Agent
export const getUnifiedUserAgent = () => get('/admin/unified-user-agent')
// 默认余额脚本
export const getDefaultBalanceScriptApi = () =>
request({ url: '/admin/balance-scripts/default', method: 'GET' })
export const updateDefaultBalanceScriptApi = (data) =>
request({ url: '/admin/balance-scripts/default', method: 'PUT', data })
export const testDefaultBalanceScriptApi = (data) =>
request({ url: '/admin/balance-scripts/default/test', method: 'POST', data })
// 账户 API Keys 管理
export const getAccountApiKeys = (type, id) => get(`/admin/${type}-accounts/${id}/api-keys`)
export const updateAccountApiKeys = (type, id, data) =>
put(`/admin/${type}-accounts/${id}/api-keys`, data)
// 前台用户管理
export const getFrontUsersApi = (params) => request({ url: '/users', method: 'GET', params })
export const getFrontUsersStatsOverviewApi = () =>
request({ url: '/users/stats/overview', method: 'GET' })
export const getFrontUserByIdApi = (id) => request({ url: `/users/${id}`, method: 'GET' })
export const updateFrontUserStatusApi = (id, data) =>
request({ url: `/users/${id}/status`, method: 'PATCH', data })
export const disableFrontUserKeysApi = (id) =>
request({ url: `/users/${id}/disable-keys`, method: 'POST' })
export const getFrontUserUsageStatsApi = (id, params) =>
request({ url: `/users/${id}/usage-stats`, method: 'GET', params })
export const updateFrontUserRoleApi = (id, data) =>
request({ url: `/users/${id}/role`, method: 'PATCH', data })
export default { get, post, put, patch, del, request }
export { get, post, put, patch, del }
// Webhook 配置
export const getWebhookConfigApi = (config) =>
request({ url: '/admin/webhook/config', method: 'GET', ...config })
export const updateWebhookConfigApi = (data, config) =>
request({ url: '/admin/webhook/config', method: 'POST', data, ...config })
export const createWebhookPlatformApi = (data, config) =>
request({ url: '/admin/webhook/platforms', method: 'POST', data, ...config })
export const deleteWebhookPlatformApi = (id, config) =>
request({ url: `/admin/webhook/platforms/${id}`, method: 'DELETE', ...config })
export const updateWebhookPlatformApi = (id, data, config) =>
request({ url: `/admin/webhook/platforms/${id}`, method: 'PUT', data, ...config })
export const toggleWebhookPlatformApi = (id, config) =>
request({ url: `/admin/webhook/platforms/${id}/toggle`, method: 'POST', ...config })
export const testWebhookApi = (data, config) =>
request({ url: '/admin/webhook/test', method: 'POST', data, ...config })
export const testWebhookNotificationApi = (config) =>
request({ url: '/admin/webhook/test-notification', method: 'POST', ...config })
// Claude Relay 配置
export const getClaudeRelayConfigApi = (config) =>
request({ url: '/admin/claude-relay-config', method: 'GET', ...config })
export const updateClaudeRelayConfigApi = (data, config) =>
request({ url: '/admin/claude-relay-config', method: 'PUT', data, ...config })
// 服务倍率配置(管理端)
export const getAdminServiceRatesApi = (config) =>
request({ url: '/admin/service-rates', method: 'GET', ...config })
export const updateAdminServiceRatesApi = (data, config) =>
request({ url: '/admin/service-rates', method: 'PUT', data, ...config })
// 系统
export const checkUpdatesApi = () => request({ url: '/admin/check-updates', method: 'GET' })
export const getClaudeCodeVersionApi = () =>
request({ url: '/admin/claude-code-version', method: 'GET' })
export const clearClaudeCodeVersionApi = () =>
request({ url: '/admin/claude-code-version/clear', method: 'POST' })

View File

@@ -0,0 +1,57 @@
import axios from 'axios'
import { APP_CONFIG, getLoginUrl } from '@/utils/tools'
const axiosInstance = axios.create({
baseURL: APP_CONFIG.apiPrefix,
timeout: 30000,
headers: { 'Content-Type': 'application/json' }
})
axiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('authToken')
if (token) config.headers['Authorization'] = `Bearer ${token}`
return config
})
axiosInstance.interceptors.response.use(
(response) => response.data,
(error) => {
if (error.response?.status === 401) {
const path = window.location.pathname + window.location.hash
// api-stats 和 user-login 是公开页面401 是业务错误不是认证错误
const isPublicPage = path.includes('/api-stats') || path.includes('/user-login')
if (!path.includes('/login') && !path.endsWith('/') && !isPublicPage) {
localStorage.removeItem('authToken')
window.location.href = getLoginUrl()
}
}
return Promise.reject(error)
}
)
// 通用请求函数 - 只会 resolve调用方无需 try-catch
const request = async (config) => {
try {
return await axiosInstance(config)
} catch (error) {
console.error('Request failed:', error)
const data = error.response?.data
// 如果后端返回了数据,直接返回(可能是 { success, message } 或 { error, message } 格式)
if (data) {
if (typeof data.success !== 'undefined') return data
// 处理 { error, message } 格式的响应
if (data.error || data.message) return { success: false, message: data.message || data.error }
}
const status = error.response?.status
const messages = {
401: '未授权,请重新登录',
403: '无权限访问',
404: '请求的资源不存在',
500: '服务器内部错误'
}
return { success: false, message: messages[status] || error.message || '请求失败' }
}
}
export default request

View File

@@ -1,3 +1,16 @@
// App 配置
export const APP_CONFIG = {
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
apiPrefix: import.meta.env.DEV ? '/webapi' : ''
}
export const getAppUrl = (path = '') => {
if (path && !path.startsWith('/')) path = '/' + path
return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path)
}
export const getLoginUrl = () => getAppUrl('/login')
// Toast 通知管理
let toastContainer = null
let toastId = 0
@@ -108,10 +121,12 @@ export const formatDate = (date, format = 'YYYY-MM-DD HH:mm:ss') => {
// 相对时间格式化
export const formatRelativeTime = (date) => {
if (!date) return ''
const diffMs = new Date() - new Date(date)
const d = new Date(date)
const diffMs = new Date() - d
const diffMins = Math.floor(diffMs / 60000)
const diffHours = Math.floor(diffMins / 60)
const diffDays = Math.floor(diffHours / 24)
if (diffDays >= 7) return d.toLocaleDateString('zh-CN')
if (diffDays > 0) return `${diffDays}天前`
if (diffHours > 0) return `${diffHours}小时前`
if (diffMins > 0) return `${diffMins}分钟前`
@@ -126,3 +141,24 @@ export const formatBytes = (bytes, decimals = 2) => {
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals < 0 ? 0 : decimals)) + ' ' + sizes[i]
}
// 日期时间格式化 (简化版)
export const formatDateTime = (date) => {
if (!date) return ''
return new Date(date).toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
})
}
// 金额格式化
export const formatCost = (value) => {
const num = Number(value || 0)
if (num === 0) return '$0.00'
if (num < 0.01) return `$${num.toFixed(6)}`
return `$${num.toFixed(2)}`
}

View File

@@ -301,9 +301,8 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import dayjs from 'dayjs'
import { useRoute, useRouter } from 'vue-router'
import * as httpApi from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
import { formatNumber } from '@/utils/tools'
import { getAccountUsageRecordsByIdApi } from '@/utils/http_apis'
import { showToast, formatNumber, formatDate } from '@/utils/tools'
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
const route = useRoute()
@@ -368,11 +367,6 @@ const dateRangeHint = computed(() => {
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
})
const formatDate = (value) => {
if (!value) return '--'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const formatCost = (value) => {
const num = typeof value === 'number' ? value : 0
if (num >= 1) return `$${num.toFixed(2)}`
@@ -437,9 +431,7 @@ const syncResponseState = (data) => {
const fetchRecords = async (page = pagination.currentPage) => {
loading.value = true
try {
const response = await httpApi.get(`/admin/accounts/${accountId.value}/usage-records`, {
params: buildParams(page)
})
const response = await getAccountUsageRecordsByIdApi(accountId.value, buildParams(page))
syncResponseState(response.data || {})
} catch (error) {
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
@@ -492,8 +484,9 @@ const exportCsv = async () => {
const maxPages = 50 // 50 * 200 = 10000超过后端 5000 上限已足够
while (page <= totalPages && page <= maxPages) {
const response = await httpApi.get(`/admin/accounts/${accountId.value}/usage-records`, {
params: { ...buildParams(page), pageSize: 200 }
const response = await getAccountUsageRecordsByIdApi(accountId.value, {
...buildParams(page),
pageSize: 200
})
const payload = response.data || {}
aggregated.push(...(payload.records || []))

View File

@@ -141,6 +141,28 @@
</el-tooltip>
</div>
<!-- 刷新余额按钮 -->
<div class="relative">
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
@click="refreshVisibleBalances"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i
:class="[
'fas relative text-blue-500',
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
]"
/>
<span class="relative">刷新余额</span>
</button>
</el-tooltip>
</div>
<!-- 选择/取消选择按钮 -->
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
@@ -279,6 +301,11 @@
>
今日使用
</th>
<th
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
余额/配额
</th>
<th
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
>
@@ -763,9 +790,9 @@
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-purple-500" />
<span class="text-xs text-gray-600 dark:text-gray-300"
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span
>
<span class="text-xs text-gray-600 dark:text-gray-300">{{
formatNumber(account.usage.daily.allTokens || 0)
}}</span>
</div>
<div class="flex items-center gap-2">
<div class="h-2 w-2 rounded-full bg-green-500" />
@@ -782,6 +809,31 @@
</div>
<div v-else class="text-xs text-gray-400">暂无数据</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<BalanceDisplay
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
:query-mode="
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
? 'auto'
: 'local'
"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
<div class="mt-1 text-xs">
<button
v-if="
!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')
"
class="text-blue-500 hover:underline dark:text-blue-300"
@click="openBalanceScriptModal(account)"
>
配置余额脚本
</button>
</div>
</td>
<td class="whitespace-nowrap px-3 py-4">
<div v-if="account.platform === 'claude'" class="space-y-2">
<!-- OAuth 账户:显示三窗口 OAuth usage -->
@@ -903,7 +955,7 @@
<div class="flex items-center gap-1">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<span class="font-medium text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}
</span>
</div>
<div class="flex items-center gap-1">
@@ -1415,7 +1467,7 @@
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<p class="text-xs text-gray-600 dark:text-gray-400">
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}M
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}
</p>
</div>
<div class="flex items-center gap-1.5">
@@ -1432,7 +1484,7 @@
<div class="flex items-center gap-1.5">
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}
</p>
</div>
<div class="flex items-center gap-1.5">
@@ -1446,6 +1498,32 @@
</div>
</div>
<!-- 余额/配额 -->
<div class="mb-3">
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
<BalanceDisplay
:account-id="account.id"
:initial-balance="account.balanceInfo"
:platform="account.platform"
:query-mode="
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
? 'auto'
: 'local'
"
@error="(error) => handleBalanceError(account.id, error)"
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
/>
<div class="mt-1 text-xs">
<button
v-if="!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')"
class="text-blue-500 hover:underline dark:text-blue-300"
@click="openBalanceScriptModal(account)"
>
配置余额脚本
</button>
</div>
</div>
<!-- 状态信息 -->
<div class="mb-3 space-y-2">
<!-- 会话窗口 -->
@@ -1927,6 +2005,13 @@
@saved="handleScheduledTestSaved"
/>
<AccountBalanceScriptModal
:account="selectedAccountForScript"
:show="showBalanceScriptModal"
@close="closeBalanceScriptModal"
@saved="handleBalanceScriptSaved"
/>
<!-- 账户统计弹窗 -->
<el-dialog
v-model="showAccountStatsModal"
@@ -2078,10 +2163,9 @@
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { showToast } from '@/utils/tools'
import { copyText } from '@/utils/tools'
import * as httpApi from '@/utils/http_apis'
import { useConfirm } from '@/composables/useConfirm'
import { showToast, copyText, formatNumber, formatRelativeTime } from '@/utils/tools'
import * as httpApis from '@/utils/http_apis'
import AccountForm from '@/components/accounts/AccountForm.vue'
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
@@ -2092,13 +2176,35 @@ import ConfirmModal from '@/components/common/ConfirmModal.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue'
import GroupManagementModal from '@/components/accounts/GroupManagementModal.vue'
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
// 使用确认弹窗
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
// 确认弹窗状态
const showConfirmModal = ref(false)
const confirmOptions = ref({ title: '', message: '', confirmText: '继续', cancelText: '取消' })
let confirmResolve = null
const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
return new Promise((resolve) => {
confirmOptions.value = { title, message, confirmText, cancelText }
confirmResolve = resolve
showConfirmModal.value = true
})
}
const handleConfirm = () => {
showConfirmModal.value = false
confirmResolve?.(true)
confirmResolve = null
}
const handleCancel = () => {
showConfirmModal.value = false
confirmResolve?.(false)
confirmResolve = null
}
// 数据状态
const accounts = ref([])
const accountsLoading = ref(false)
const refreshingBalances = ref(false)
const accountsSortBy = ref('name')
const accountsSortOrder = ref('asc')
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
@@ -2145,7 +2251,8 @@ const supportedUsagePlatforms = [
'openai-responses',
'gemini',
'droid',
'gemini-api'
'gemini-api',
'bedrock'
]
// 过期时间编辑弹窗状态
@@ -2237,16 +2344,16 @@ const platformGroupMap = {
// 平台请求处理器
const platformRequestHandlers = {
claude: () => httpApi.getClaudeAccounts(),
'claude-console': () => httpApi.getClaudeConsoleAccounts(),
bedrock: () => httpApi.getBedrockAccounts(),
gemini: () => httpApi.getGeminiAccounts(),
openai: () => httpApi.getOpenAIAccounts(),
azure_openai: () => httpApi.getAzureOpenAIAccounts(),
'openai-responses': () => httpApi.getOpenAIResponsesAccounts(),
ccr: () => httpApi.getCcrAccounts(),
droid: () => httpApi.getDroidAccounts(),
'gemini-api': () => httpApi.getGeminiApiAccounts()
claude: () => httpApis.getClaudeAccountsApi(),
'claude-console': () => httpApis.getClaudeConsoleAccountsApi(),
bedrock: () => httpApis.getBedrockAccountsApi(),
gemini: () => httpApis.getGeminiAccountsApi(),
openai: () => httpApis.getOpenAIAccountsApi(),
azure_openai: () => httpApis.getAzureOpenAIAccountsApi(),
'openai-responses': () => httpApis.getOpenAIResponsesAccountsApi(),
ccr: () => httpApis.getCcrAccountsApi(),
droid: () => httpApis.getDroidAccountsApi(),
'gemini-api': () => httpApis.getGeminiApiAccountsApi()
}
const allPlatformKeys = Object.keys(platformRequestHandlers)
@@ -2464,7 +2571,7 @@ const openAccountUsageModal = async (account) => {
accountUsageOverview.value = {}
accountUsageGeneratedAt.value = ''
const response = await httpApi.getAccountUsageHistory(account.id, account.platform, 30)
const response = await httpApis.getAccountUsageHistoryApi(account.id, account.platform, 30)
if (response.success) {
const data = response.data || {}
accountUsageHistory.value = data.history || []
@@ -2532,6 +2639,43 @@ const handleScheduledTestSaved = () => {
showToast('定时测试配置已保存', 'success')
}
// 余额脚本配置
const showBalanceScriptModal = ref(false)
const selectedAccountForScript = ref(null)
const openBalanceScriptModal = (account) => {
selectedAccountForScript.value = account
showBalanceScriptModal.value = true
}
const closeBalanceScriptModal = () => {
showBalanceScriptModal.value = false
selectedAccountForScript.value = null
}
const handleBalanceScriptSaved = async () => {
showToast('余额脚本已保存', 'success')
const account = selectedAccountForScript.value
closeBalanceScriptModal()
if (!account?.id || !account?.platform) {
return
}
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用"刷新余额"按钮)
try {
const res = await httpApis.getAccountBalanceApi(account.id, {
platform: account.platform,
queryApi: false
})
if (res?.success && res.data) {
handleBalanceRefreshed(account.id, res.data)
}
} catch (error) {
console.debug('Failed to reload balance after saving script:', error)
}
}
// 计算排序后的账户列表
const sortedAccounts = computed(() => {
let sourceAccounts = accounts.value
@@ -2802,6 +2946,104 @@ const paginatedAccounts = computed(() => {
return sortedAccounts.value.slice(start, end)
})
const canRefreshVisibleBalances = computed(() => {
const targets = paginatedAccounts.value
if (!Array.isArray(targets) || targets.length === 0) {
return false
}
return targets.some((account) => {
const info = account?.balanceInfo
return info?.scriptEnabled !== false && !!info?.scriptConfigured
})
})
const refreshBalanceTooltip = computed(() => {
if (accountsLoading.value) return '正在加载账户...'
if (refreshingBalances.value) return '刷新中...'
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
})
// 余额刷新成功回调
const handleBalanceRefreshed = (accountId, balanceInfo) => {
accounts.value = accounts.value.map((account) => {
if (account.id !== accountId) return account
return { ...account, balanceInfo }
})
}
// 余额请求错误回调(仅提示,不中断页面)
const handleBalanceError = (_accountId, error) => {
const message = error?.message || '余额查询失败'
showToast(message, 'error')
}
// 批量刷新当前页余额(触发查询)
const refreshVisibleBalances = async () => {
if (refreshingBalances.value) return
const targets = paginatedAccounts.value
if (!targets || targets.length === 0) {
return
}
const eligibleTargets = targets.filter((account) => {
const info = account?.balanceInfo
return info?.scriptEnabled !== false && !!info?.scriptConfigured
})
if (eligibleTargets.length === 0) {
showToast('当前页没有配置余额脚本的账户', 'warning')
return
}
const skippedCount = targets.length - eligibleTargets.length
refreshingBalances.value = true
try {
const results = await Promise.all(
eligibleTargets.map(async (account) => {
try {
const response = await httpApis.refreshAccountBalanceApi(account.id, {
platform: account.platform
})
return { id: account.id, success: !!response?.success, data: response?.data || null }
} catch (error) {
return { id: account.id, success: false, error: error?.message || '刷新失败' }
}
})
)
const updatedMap = results.reduce((map, item) => {
if (item.success && item.data) {
map[item.id] = item.data
}
return map
}, {})
const successCount = results.filter((r) => r.success).length
const failCount = results.length - successCount
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
if (Object.keys(updatedMap).length > 0) {
accounts.value = accounts.value.map((account) => {
const balanceInfo = updatedMap[account.id]
if (!balanceInfo) return account
return { ...account, balanceInfo }
})
}
if (failCount === 0) {
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
} else {
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
}
} finally {
refreshingBalances.value = false
}
}
const updateSelectAllState = () => {
const currentIds = paginatedAccounts.value.map((account) => account.id)
const selectedInCurrentPage = currentIds.filter((id) =>
@@ -2852,6 +3094,52 @@ const cleanupSelectedAccounts = () => {
updateSelectAllState()
}
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
const loadBalanceCacheForAccounts = async () => {
const current = accounts.value
if (!Array.isArray(current) || current.length === 0) {
return
}
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
if (platforms.length === 0) {
return
}
const responses = await Promise.all(
platforms.map(async (platform) => {
try {
const res = await httpApis.getBalanceByPlatformApi(platform, { queryApi: false })
return { platform, success: !!res?.success, data: res?.data || [] }
} catch (error) {
console.debug(`Failed to load balance cache for ${platform}:`, error)
return { platform, success: false, data: [] }
}
})
)
const balanceMap = responses.reduce((map, item) => {
if (!item.success) return map
const list = Array.isArray(item.data) ? item.data : []
list.forEach((entry) => {
const accountId = entry?.data?.accountId
if (accountId) {
map[accountId] = entry.data
}
})
return map
}, {})
if (Object.keys(balanceMap).length === 0) {
return
}
accounts.value = accounts.value.map((account) => ({
...account,
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
}))
}
// 加载账户列表
const loadAccounts = async (forceReload = false) => {
accountsLoading.value = true
@@ -3026,6 +3314,11 @@ const loadAccounts = async (forceReload = false) => {
console.debug('Claude usage loading failed:', err)
})
}
// 异步加载余额缓存(按平台批量)
loadBalanceCacheForAccounts().catch((err) => {
console.debug('Balance cache loading failed:', err)
})
} catch (error) {
showToast('加载账户失败', 'error')
} finally {
@@ -3035,7 +3328,7 @@ const loadAccounts = async (forceReload = false) => {
// 异步加载 Claude 账户的 Usage 数据
const loadClaudeUsage = async () => {
const response = await httpApi.getClaudeAccountsUsage()
const response = await httpApis.getClaudeAccountsUsageApi()
if (response.success && response.data) {
const usageMap = response.data
accounts.value = accounts.value.map((account) => {
@@ -3077,16 +3370,6 @@ const handleDropdownSort = (field) => {
}
// 格式化数字(与原版保持一致)
const formatNumber = (num) => {
if (num === null || num === undefined) return '0'
const number = Number(num)
if (number >= 1000000) {
return (number / 1000000).toFixed(2)
} else if (number >= 1000) {
return (number / 1000000).toFixed(4)
}
return (number / 1000000).toFixed(6)
}
// 格式化最后使用时间
const formatLastUsed = (dateString) => {
@@ -3112,7 +3395,7 @@ const clearSearch = () => {
// 加载绑定计数(轻量级接口,用于显示"绑定: X 个API Key"
const loadBindingCounts = async (forceReload = false) => {
if (!forceReload && bindingCountsLoaded.value) return
const response = await httpApi.getAccountsBindingCounts()
const response = await httpApis.getAccountsBindingCountsApi()
if (response.success) {
bindingCounts.value = response.data || {}
bindingCountsLoaded.value = true
@@ -3122,7 +3405,7 @@ const loadBindingCounts = async (forceReload = false) => {
// 加载API Keys列表保留用于其他功能如删除账户时显示绑定信息
const loadApiKeys = async (forceReload = false) => {
if (!forceReload && apiKeysLoaded.value) return
const response = await httpApi.getApiKeys()
const response = await httpApis.getApiKeysApi()
if (response.success) {
apiKeys.value = response.data?.items || response.data || []
apiKeysLoaded.value = true
@@ -3132,7 +3415,7 @@ const loadApiKeys = async (forceReload = false) => {
// 加载账户分组列表(缓存版本)
const loadAccountGroups = async (forceReload = false) => {
if (!forceReload && groupsLoaded.value) return
const response = await httpApi.getAccountGroups()
const response = await httpApis.getAccountGroupsApi()
if (response.success) {
accountGroups.value = response.data || []
groupsLoaded.value = true
@@ -3421,7 +3704,7 @@ const resolveAccountDeleteEndpoint = (account) => {
const performAccountDeletion = async (account) => {
const endpoint = resolveAccountDeleteEndpoint(account)
if (!endpoint) return { success: false, message: '不支持的账户类型' }
const data = await httpApi.deleteAccountByEndpoint(endpoint)
const data = await httpApis.deleteAccountByEndpointApi(endpoint)
if (data.success) return { success: true, data }
return { success: false, message: data.message || '删除失败' }
}
@@ -3591,7 +3874,7 @@ const resetAccountStatus = async (account) => {
return
}
const data = await httpApi.testAccountByEndpoint(endpoint)
const data = await httpApis.testAccountByEndpointApi(endpoint)
if (data.success) {
showToast('账户状态已重置', 'success')
loadAccounts(true)
@@ -3637,7 +3920,7 @@ const toggleSchedulable = async (account) => {
return
}
const data = await httpApi.toggleAccountStatus(endpoint)
const data = await httpApis.toggleAccountStatusApi(endpoint)
if (data.success) {
account.schedulable = data.schedulable
showToast(data.schedulable ? '已启用调度' : '已禁用调度', 'success')
@@ -4009,9 +4292,6 @@ const getAccountStatusDotClass = (account) => {
// }
// 格式化相对时间
const formatRelativeTime = (dateString) => {
return formatLastUsed(dateString)
}
// 获取会话窗口进度条的样式类
const getSessionProgressBarClass = (status, account = null) => {
@@ -4442,7 +4722,9 @@ const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
return
}
const data = await httpApi.updateAccountByEndpoint(endpoint, { expiresAt: expiresAt || null })
const data = await httpApis.updateAccountByEndpointApi(endpoint, {
expiresAt: expiresAt || null
})
if (data.success) {
showToast('账户到期时间已更新', 'success')
account.expiresAt = expiresAt || null

View File

@@ -295,9 +295,8 @@
import { computed, onMounted, reactive, ref, watch } from 'vue'
import dayjs from 'dayjs'
import { useRoute, useRouter } from 'vue-router'
import * as httpApi from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
import { formatNumber } from '@/utils/tools'
import { getApiKeyUsageRecordsApi } from '@/utils/http_apis'
import { showToast, formatNumber, formatDate } from '@/utils/tools'
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
const route = useRoute()
@@ -345,11 +344,6 @@ const dateRangeHint = computed(() => {
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
})
const formatDate = (value) => {
if (!value) return '--'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
}
const formatCost = (value) => {
const num = typeof value === 'number' ? value : 0
if (num >= 1) return `$${num.toFixed(2)}`
@@ -410,9 +404,7 @@ const syncResponseState = (data) => {
const fetchRecords = async (page = pagination.currentPage) => {
loading.value = true
try {
const response = await httpApi.get(`/admin/api-keys/${keyId.value}/usage-records`, {
params: buildParams(page)
})
const response = await getApiKeyUsageRecordsApi(keyId.value, buildParams(page))
syncResponseState(response.data || {})
} catch (error) {
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
@@ -465,8 +457,9 @@ const exportCsv = async () => {
const maxPages = 50 // 50 * 200 = 10000超过后端 5000 上限已足够
while (page <= totalPages && page <= maxPages) {
const response = await httpApi.get(`/admin/api-keys/${keyId.value}/usage-records`, {
params: { ...buildParams(page), pageSize: 200 }
const response = await getApiKeyUsageRecordsApi(keyId.value, {
...buildParams(page),
pageSize: 200
})
const payload = response.data || {}
aggregated.push(...(payload.records || []))

View File

@@ -221,6 +221,18 @@
<span class="relative">导出数据</span>
</button>
<!-- 管理标签按钮 -->
<button
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 sm:w-auto"
@click="showTagManagementModal = true"
>
<div
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
></div>
<i class="fas fa-tags relative text-purple-500" />
<span class="relative">管理标签</span>
</button>
<!-- 批量编辑按钮 - 移到刷新按钮旁边 -->
<button
v-if="selectedApiKeys.length > 0"
@@ -2118,6 +2130,12 @@
@open-timeline="openTimeline"
/>
<TagManagementModal
:show="showTagManagementModal"
@close="showTagManagementModal = false"
@updated="loadApiKeys"
/>
<ConfirmModal
:cancel-text="confirmModalConfig.cancelText"
:confirm-text="confirmModalConfig.confirmText"
@@ -2134,9 +2152,9 @@
<script setup>
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { showToast } from '@/utils/tools'
import { copyText } from '@/utils/tools'
import * as httpApi from '@/utils/http_apis'
import { showToast, copyText, formatNumber, formatDate } from '@/utils/tools'
import * as httpApis from '@/utils/http_apis'
import { useAuthStore } from '@/stores/auth'
import * as XLSX from 'xlsx-js-style'
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
@@ -2147,6 +2165,7 @@ import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
import BatchEditApiKeyModal from '@/components/apikeys/BatchEditApiKeyModal.vue'
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
import TagManagementModal from '@/components/apikeys/TagManagementModal.vue'
import LimitProgressBar from '@/components/apikeys/LimitProgressBar.vue'
import CustomDropdown from '@/components/common/CustomDropdown.vue'
import ActionDropdown from '@/components/common/ActionDropdown.vue'
@@ -2315,6 +2334,7 @@ const showRenewApiKeyModal = ref(false)
const showNewApiKeyModal = ref(false)
const showBatchApiKeyModal = ref(false)
const showBatchEditModal = ref(false)
const showTagManagementModal = ref(false)
const editingApiKey = ref(null)
const renewingApiKey = ref(null)
const newApiKeyData = ref(null)
@@ -2445,15 +2465,15 @@ const loadAccounts = async (forceRefresh = false) => {
droidData,
groupsData
] = await Promise.all([
httpApi.getClaudeAccounts(),
httpApi.getClaudeConsoleAccounts(),
httpApi.getGeminiAccounts(),
httpApi.getGeminiApiAccounts(),
httpApi.getOpenAIAccounts(),
httpApi.getOpenAIResponsesAccounts(),
httpApi.getBedrockAccounts(),
httpApi.getDroidAccounts(),
httpApi.getAccountGroups()
httpApis.getClaudeAccountsApi(),
httpApis.getClaudeConsoleAccountsApi(),
httpApis.getGeminiAccountsApi(),
httpApis.getGeminiApiAccountsApi(),
httpApis.getOpenAIAccountsApi(),
httpApis.getOpenAIResponsesAccountsApi(),
httpApis.getBedrockAccountsApi(),
httpApis.getDroidAccountsApi(),
httpApis.getAccountGroupsApi()
])
// 合并Claude OAuth账户和Claude Console账户
@@ -2559,7 +2579,7 @@ const loadAccounts = async (forceRefresh = false) => {
// 加载已使用的模型列表
const loadUsedModels = async () => {
try {
const data = await httpApi.get('/admin/api-keys/used-models')
const data = await httpApis.getApiKeyUsedModelsApi()
if (data.success) {
availableModels.value = data.data || []
}
@@ -2648,7 +2668,7 @@ const loadApiKeys = async (clearStatsCache = true) => {
params.set('timeRange', globalDateFilter.preset)
}
const data = await httpApi.getApiKeysWithParams(params.toString())
const data = await httpApis.getApiKeysWithParamsApi(params.toString())
if (data.success) {
// 更新数据
apiKeys.value = data.data?.items || []
@@ -2729,7 +2749,7 @@ const loadPageStats = async () => {
requestBody.endDate = endDate
}
const response = await httpApi.post('/admin/api-keys/batch-stats', requestBody)
const response = await httpApis.getApiKeysBatchStatsApi(requestBody)
if (response.success && response.data) {
// 更新缓存
@@ -2783,7 +2803,7 @@ const loadPageLastUsage = async () => {
keyIds.forEach((id) => lastUsageLoading.value.add(id))
try {
const response = await httpApi.post('/admin/api-keys/batch-last-usage', { keyIds })
const response = await httpApis.getApiKeysBatchLastUsageApi({ keyIds })
if (response.success && response.data) {
// 更新缓存
@@ -2814,7 +2834,7 @@ const loadDeletedApiKeys = async () => {
activeTab.value = 'deleted'
deletedApiKeysLoading.value = true
try {
const data = await httpApi.get('/admin/api-keys/deleted')
const data = await httpApis.getDeletedApiKeysApi()
if (data.success) {
deletedApiKeys.value = data.apiKeys || []
}
@@ -2893,7 +2913,7 @@ let costSortStatusTimer = null
// 获取费用排序索引状态
const fetchCostSortStatus = async () => {
try {
const data = await httpApi.get('/admin/api-keys/cost-sort-status')
const data = await httpApis.getApiKeysCostSortStatusApi()
if (data.success) {
costSortStatus.value = data.data || {}
@@ -2924,10 +2944,6 @@ const scheduleNextCostSortStatusRefresh = () => {
}
// 格式化数字
const formatNumber = (num) => {
if (!num && num !== 0) return '0'
return num.toLocaleString('zh-CN')
}
// 格式化Token数量
const formatTokenCount = (count) => {
@@ -3227,22 +3243,18 @@ const loadApiKeyModelStats = async (keyId, forceReload = false) => {
const filter = getApiKeyDateFilter(keyId)
try {
let url = `/admin/api-keys/${keyId}/model-stats`
const params = new URLSearchParams()
const params = {}
if (filter.customStart && filter.customEnd) {
params.append('startDate', filter.customStart)
params.append('endDate', filter.customEnd)
params.append('period', 'custom')
params.startDate = filter.customStart
params.endDate = filter.customEnd
params.period = 'custom'
} else {
const period =
params.period =
filter.preset === 'today' ? 'daily' : filter.preset === '7days' ? 'daily' : 'monthly'
params.append('period', period)
}
url += '?' + params.toString()
const data = await httpApi.get(url)
const data = await httpApis.getApiKeyModelStatsApi(keyId, params)
if (data.success) {
apiKeyModelStats.value[keyId] = data.data || []
}
@@ -3906,7 +3918,7 @@ const toggleApiKeyStatus = async (key) => {
if (!confirmed) return
try {
const data = await httpApi.updateApiKey(key.id, { isActive: !key.isActive })
const data = await httpApis.updateApiKeyApi(key.id, { isActive: !key.isActive })
if (data.success) {
showToast(`API Key ${key.isActive ? '禁用' : '激活'}`, 'success')
@@ -3937,7 +3949,7 @@ const deleteApiKey = async (keyId) => {
if (!confirmed) return
try {
const data = await httpApi.deleteApiKey(keyId)
const data = await httpApis.deleteApiKeyApi(keyId)
if (data.success) {
showToast('API Key 已删除', 'success')
// 从选中列表中移除
@@ -3968,7 +3980,7 @@ const restoreApiKey = async (keyId) => {
if (!confirmed) return
try {
const data = await httpApi.restoreApiKey(keyId)
const data = await httpApis.restoreApiKeyApi(keyId)
if (data.success) {
showToast('API Key 已成功恢复', 'success')
// 刷新已删除列表
@@ -3996,7 +4008,7 @@ const permanentDeleteApiKey = async (keyId) => {
if (!confirmed) return
try {
const data = await httpApi.permanentDeleteApiKey(keyId)
const data = await httpApis.permanentDeleteApiKeyApi(keyId)
if (data.success) {
showToast('API Key 已彻底删除', 'success')
// 刷新已删除列表
@@ -4028,7 +4040,7 @@ const clearAllDeletedApiKeys = async () => {
if (!confirmed) return
try {
const data = await httpApi.del('/admin/api-keys/deleted/clear-all')
const data = await httpApis.clearAllDeletedApiKeysApi()
if (data.success) {
showToast(data.message || '已清空所有已删除的 API Keys', 'success')
@@ -4070,9 +4082,7 @@ const batchDeleteApiKeys = async () => {
const keyIds = [...selectedApiKeys.value]
try {
const data = await httpApi.del('/admin/api-keys/batch', {
data: { keyIds }
})
const data = await httpApis.batchDeleteApiKeysApi({ keyIds })
if (data.success) {
const { successCount, failedCount, errors } = data.data
@@ -4152,7 +4162,7 @@ const closeExpiryEdit = () => {
const handleSaveExpiry = async ({ keyId, expiresAt, activateNow }) => {
try {
// 使用新的PATCH端点来修改过期时间
const data = await httpApi.updateApiKeyExpiration(keyId, {
const data = await httpApis.updateApiKeyExpirationApi(keyId, {
expiresAt: expiresAt || null,
activateNow: activateNow || false
})
@@ -4190,21 +4200,6 @@ const handleSaveExpiry = async ({ keyId, expiresAt, activateNow }) => {
}
}
// 格式化日期时间
const formatDate = (dateString) => {
if (!dateString) return ''
const date = new Date(dateString)
return date
.toLocaleDateString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
.replace(/\//g, '-')
}
// 格式化时间窗口倒计时
const formatWindowTime = (seconds) => {
if (seconds === null || seconds === undefined) return '--:--'
@@ -4472,18 +4467,12 @@ const exportToExcel = () => {
过期时间: key.expiresAt ? formatDate(key.expiresAt) : '',
// 权限配置
服务权限:
key.permissions === 'all'
? '全部服务'
: key.permissions === 'claude'
? '仅Claude'
: key.permissions === 'gemini'
? '仅Gemini'
: key.permissions === 'openai'
? '仅OpenAI'
: key.permissions === 'droid'
? '仅Droid'
: key.permissions || '',
服务权限: (() => {
const p = key.permissions
if (!p || p === 'all') return '全部服务'
if (Array.isArray(p)) return p.length === 0 ? '全部服务' : p.join(', ')
return p
})(),
// 限制配置
令牌限制: key.tokenLimit === '0' || key.tokenLimit === 0 ? '无限制' : key.tokenLimit || '',

View File

@@ -11,7 +11,13 @@
<LogoTitle
:loading="oemLoading"
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
:subtitle="
currentTab === 'stats'
? 'API Key 使用统计'
: currentTab === 'quota'
? '额度卡'
: '使用教程'
"
:title="oemSettings.siteName"
/>
<div class="flex items-center gap-2 md:gap-4">
@@ -52,7 +58,7 @@
<div class="mb-4 sm:mb-6 md:mb-8">
<div class="flex justify-center">
<div
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl sm:w-auto"
class="inline-flex w-full max-w-2xl flex-wrap justify-center gap-1 rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl sm:w-auto sm:flex-nowrap"
>
<button
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
@@ -61,6 +67,13 @@
<i class="fas fa-chart-line mr-1 md:mr-2" />
<span class="text-sm md:text-base">统计查询</span>
</button>
<button
:class="['tab-pill-button', currentTab === 'quota' ? 'active' : '']"
@click="switchToQuota"
>
<i class="fas fa-ticket-alt mr-1 md:mr-2" />
<span class="text-sm md:text-base">额度卡</span>
</button>
<button
:class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']"
@click="currentTab = 'tutorial'"
@@ -221,6 +234,239 @@
</div>
</div>
<!-- 额度卡内容含二级 tab -->
<div v-if="currentTab === 'quota'" class="tab-content">
<div class="glass-strong rounded-2xl p-4 shadow-xl sm:rounded-3xl sm:p-6 md:p-8">
<!-- 二级 Tab -->
<div
class="mb-4 flex gap-2 border-b border-gray-200 pb-4 dark:border-gray-700 md:mb-6 md:pb-6"
>
<button
:class="[
'rounded-lg px-4 py-2 text-sm font-medium transition-all',
quotaSubTab === 'redeem'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
]"
@click="quotaSubTab = 'redeem'"
>
<i class="fas fa-ticket-alt mr-2" />
兑换额度卡
</button>
<button
:class="[
'rounded-lg px-4 py-2 text-sm font-medium transition-all',
quotaSubTab === 'history'
? 'bg-blue-500 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
]"
@click="switchToHistorySubTab"
>
<i class="fas fa-history mr-2" />
兑换记录
</button>
</div>
<!-- 兑换额度卡子内容 -->
<div v-if="quotaSubTab === 'redeem'">
<!-- 需要先输入 API Key -->
<div v-if="!apiId" class="py-8 text-center">
<div class="mb-4 text-gray-500 dark:text-gray-400">
<i class="fas fa-key mb-4 block text-4xl opacity-50" />
<p>请先在统计查询页面输入您的 API Key</p>
</div>
<button
class="rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-2.5 font-medium text-white transition-all hover:from-blue-600 hover:to-cyan-600"
@click="currentTab = 'stats'"
>
前往输入 API Key
</button>
</div>
<!-- 兑换表单 -->
<div v-else>
<div class="mb-6 rounded-xl bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-300">
<i class="fas fa-info-circle mr-2" />
当前 API Key: <span class="font-medium">{{ statsData?.name || apiId }}</span>
</p>
</div>
<div class="space-y-4">
<div>
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
额度卡卡号
</label>
<input
v-model="redeemCode"
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
placeholder="请输入额度卡卡号"
type="text"
@keyup.enter="handleRedeem"
/>
</div>
<button
class="w-full rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 px-6 py-3 font-medium text-white transition-all hover:from-green-600 hover:to-emerald-600 disabled:cursor-not-allowed disabled:opacity-50"
:disabled="!redeemCode.trim() || redeemLoading"
@click="handleRedeem"
>
<i v-if="redeemLoading" class="fas fa-spinner fa-spin mr-2" />
<i v-else class="fas fa-check-circle mr-2" />
{{ redeemLoading ? '兑换中...' : '立即兑换' }}
</button>
</div>
<!-- 兑换结果 -->
<div v-if="redeemResult" class="mt-6">
<div
:class="[
'rounded-xl p-4',
redeemResult.success
? redeemResult.hasWarnings
? 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300'
: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-300'
: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300'
]"
>
<div class="flex items-start gap-3">
<i
:class="[
'mt-0.5 text-lg',
redeemResult.success
? redeemResult.hasWarnings
? 'fas fa-exclamation-triangle'
: 'fas fa-check-circle'
: 'fas fa-times-circle'
]"
/>
<div>
<p class="font-medium">
{{
redeemResult.success
? redeemResult.hasWarnings
? '兑换成功(部分截断)'
: '兑换成功'
: '兑换失败'
}}
</p>
<p class="mt-1 text-sm opacity-90">{{ redeemResult.message }}</p>
<div v-if="redeemResult.success && redeemResult.data" class="mt-2 text-sm">
<p v-if="redeemResult.data.quotaAdded">
额度增加:
<span class="font-medium">${{ redeemResult.data.quotaAdded }}</span>
</p>
<p v-if="redeemResult.data.timeAdded">
有效期延长:
<span class="font-medium"
>{{ redeemResult.data.timeAdded
}}{{
redeemResult.data.timeUnit === 'days'
? '天'
: redeemResult.data.timeUnit === 'hours'
? '小时'
: '月'
}}</span
>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 兑换记录子内容 -->
<div v-if="quotaSubTab === 'history'">
<!-- 需要先输入 API Key -->
<div v-if="!apiId" class="py-8 text-center">
<div class="mb-4 text-gray-500 dark:text-gray-400">
<i class="fas fa-key mb-4 block text-4xl opacity-50" />
<p>请先在统计查询页面输入您的 API Key</p>
</div>
<button
class="rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-2.5 font-medium text-white transition-all hover:from-blue-600 hover:to-cyan-600"
@click="currentTab = 'stats'"
>
前往输入 API Key
</button>
</div>
<!-- 记录列表 -->
<div v-else>
<div v-if="historyLoading" class="py-8 text-center">
<i class="fas fa-spinner fa-spin text-2xl text-gray-400" />
<p class="mt-2 text-gray-500 dark:text-gray-400">加载中...</p>
</div>
<div v-else-if="redemptionHistory.length === 0" class="py-8 text-center">
<i class="fas fa-inbox text-4xl text-gray-300 dark:text-gray-600" />
<p class="mt-2 text-gray-500 dark:text-gray-400">暂无兑换记录</p>
</div>
<div v-else class="space-y-3">
<div
v-for="record in redemptionHistory"
:key="record.id"
class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
>
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="mb-1 flex items-center gap-2">
<span
:class="[
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
record.cardType === 'quota'
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
: record.cardType === 'time'
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
]"
>
{{
record.cardType === 'quota'
? '额度卡'
: record.cardType === 'time'
? '时间卡'
: '组合卡'
}}
</span>
<span
v-if="record.status === 'revoked'"
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300"
>
已撤销
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-300">
<span v-if="record.quotaAdded">额度 +${{ record.quotaAdded }}</span>
<span v-if="record.quotaAdded && record.timeAdded"> · </span>
<span v-if="record.timeAdded"
>有效期 +{{ record.timeAmount
}}{{
record.timeUnit === 'days'
? '天'
: record.timeUnit === 'hours'
? '小时'
: '月'
}}</span
>
</p>
</div>
<div
class="whitespace-nowrap text-right text-xs text-gray-500 dark:text-gray-400"
>
{{ formatDateTime(record.redeemedAt) }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- API Key 测试弹窗 -->
<ApiKeyTestModal
:api-key-name="statsData?.name || ''"
@@ -284,6 +530,8 @@ import { useRoute } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useApiStatsStore } from '@/stores/apistats'
import { useThemeStore } from '@/stores/theme'
import { redeemCardByApiIdApi, getRedemptionHistoryByApiIdApi } from '@/utils/http_apis'
import { formatDateTime, showToast } from '@/utils/tools'
import LogoTitle from '@/components/common/LogoTitle.vue'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
@@ -338,25 +586,116 @@ const showNotice = ref(false)
const dontShowAgain = ref(false)
const NOTICE_STORAGE_KEY = 'apiStatsNoticeRead'
// 检查是否可以测试 Claude权限包含 claude 或 all
// 额度卡兑换相关状态
const quotaSubTab = ref('redeem')
const redeemCode = ref('')
const redeemLoading = ref(false)
const redeemResult = ref(null)
const redemptionHistory = ref([])
const historyLoading = ref(false)
// 兑换额度卡
const handleRedeem = async () => {
if (!redeemCode.value.trim() || !apiId.value) return
redeemLoading.value = true
redeemResult.value = null
const res = await redeemCardByApiIdApi({
apiId: apiId.value,
code: redeemCode.value.trim()
})
redeemLoading.value = false
if (res.success) {
const warnings = res.data?.warnings || []
const hasWarnings = warnings.length > 0
redeemResult.value = {
success: true,
message: hasWarnings ? warnings.join('') : '额度卡兑换成功!',
data: res.data,
hasWarnings
}
redeemCode.value = ''
showToast(
hasWarnings ? '兑换成功(部分截断)' : '兑换成功',
hasWarnings ? 'warning' : 'success'
)
// 刷新统计数据
loadStatsWithApiId()
} else {
redeemResult.value = {
success: false,
message: res.error || res.message || '兑换失败'
}
showToast(res.error || res.message || '兑换失败', 'error')
}
}
// 加载兑换记录
const loadRedemptionHistory = async () => {
if (!apiId.value) return
historyLoading.value = true
const res = await getRedemptionHistoryByApiIdApi(apiId.value)
historyLoading.value = false
if (res.success) {
redemptionHistory.value = res.data?.records || res.data || []
}
}
// 切换到额度卡 Tab
const switchToQuota = () => {
currentTab.value = 'quota'
// 如果子标签是记录,刷新数据
if (quotaSubTab.value === 'history') {
loadRedemptionHistory()
}
}
// 切换到兑换记录子 Tab
const switchToHistorySubTab = () => {
quotaSubTab.value = 'history'
loadRedemptionHistory()
}
// 解析 permissions可能是 JSON 字符串或数组)
const parsePermissions = (permissions) => {
if (!permissions) return []
if (Array.isArray(permissions)) return permissions
if (typeof permissions === 'string') {
if (permissions === 'all') return []
try {
const parsed = JSON.parse(permissions)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
return []
}
// 检查是否可以测试 Claude权限包含 claude 或全部)
const canTestClaude = computed(() => {
const permissions = statsData.value?.permissions
if (!permissions) return true // 默认允许
return permissions === 'all' || permissions.includes('claude')
const permissions = parsePermissions(statsData.value?.permissions)
if (permissions.length === 0) return true
return permissions.includes('claude')
})
// 检查是否可以测试 Gemini
const canTestGemini = computed(() => {
const permissions = statsData.value?.permissions
if (!permissions) return true
return permissions === 'all' || permissions.includes('gemini')
const permissions = parsePermissions(statsData.value?.permissions)
if (permissions.length === 0) return true
return permissions.includes('gemini')
})
// 检查是否可以测试 OpenAI
const canTestOpenAI = computed(() => {
const permissions = statsData.value?.permissions
if (!permissions) return true
return permissions === 'all' || permissions.includes('openai')
const permissions = parsePermissions(statsData.value?.permissions)
if (permissions.length === 0) return true
return permissions.includes('openai')
})
// 检查是否有任何测试权限
@@ -366,18 +705,15 @@ const hasAnyTestPermission = computed(() => {
// 可用服务文本
const availableServicesText = computed(() => {
const permissions = statsData.value?.permissions
if (!permissions || permissions === 'all') return '全部服务'
const permissions = parsePermissions(statsData.value?.permissions)
if (permissions.length === 0) return '全部服务'
const serviceNames = {
claude: 'Claude',
gemini: 'Gemini',
openai: 'OpenAI',
droid: 'Droid'
}
return permissions
.split(',')
.map((s) => serviceNames[s.trim()] || s.trim())
.join(', ')
return permissions.map((s) => serviceNames[s] || s).join(', ')
})
// 切换测试菜单

View File

@@ -0,0 +1,302 @@
<template>
<div class="space-y-6">
<div class="flex flex-col gap-4 lg:flex-row">
<div class="glass-strong flex-1 rounded-2xl p-4 shadow-lg">
<div class="mb-3 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">脚本余额配置</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
使用自定义脚本 + 模板变量适配任意余额接口
</div>
</div>
<div class="flex gap-2">
<button
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
@click="loadConfig"
>
重新加载
</button>
<button
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
:disabled="saving"
@click="saveConfig"
>
<span v-if="saving">保存中...</span>
<span v-else>保存配置</span>
</button>
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
<input v-model="form.apiKey" class="input-text" placeholder="sk-xxxx" type="text" />
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
请求地址baseUrl
</label>
<input
v-model="form.baseUrl"
class="input-text"
placeholder="https://api.example.com"
type="text"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>Token可选</label
>
<input v-model="form.token" class="input-text" placeholder="Bearer token" type="text" />
</div>
<div class="grid grid-cols-2 gap-3">
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>超时时间()</label
>
<input
v-model.number="form.timeoutSeconds"
class="input-text"
min="1"
type="number"
/>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
自动查询间隔(分钟)
</label>
<input
v-model.number="form.autoIntervalMinutes"
class="input-text"
min="0"
type="number"
/>
</div>
</div>
<div class="md:col-span-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">模板变量</label>
<p class="text-xs text-gray-500 dark:text-gray-400">
可用变量{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}apiKey{{ '}'
}}{{ '}' }}{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}accountId{{
'}'
}}{{ '}' }}{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}{{ '{' }}{{ '{' }}extra{{
'}'
}}{{ '}' }}
</p>
</div>
</div>
</div>
<div class="glass-strong w-full max-w-xl rounded-2xl p-4 shadow-lg">
<div class="mb-3 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">测试脚本</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
填入账号上下文可选调试 extractor 输出
</div>
</div>
<button
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
:disabled="testing"
@click="testScript"
>
<span v-if="testing">测试中...</span>
<span v-else>测试脚本</span>
</button>
</div>
<div class="grid gap-3">
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">平台</label>
<input v-model="testForm.platform" class="input-text" placeholder="例如 claude" />
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">账号ID</label>
<input v-model="testForm.accountId" class="input-text" placeholder="账号标识,可选" />
</div>
<div class="space-y-1">
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
>额外参数 (extra)</label
>
<input v-model="testForm.extra" class="input-text" placeholder="可选" />
</div>
</div>
<div v-if="testResult" class="mt-4 space-y-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800/60">
<div class="flex items-center justify-between text-sm">
<span class="font-semibold text-gray-800 dark:text-gray-100">测试结果</span>
<span
:class="[
'rounded px-2 py-0.5 text-xs',
testResult.mapped?.status === 'success'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
]"
>
{{ testResult.mapped?.status || 'unknown' }}
</span>
</div>
<div class="text-xs text-gray-600 dark:text-gray-300">
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
错误: {{ testResult.mapped.errorMessage }}
</div>
<div v-if="testResult.mapped?.quota">
配额: {{ JSON.stringify(testResult.mapped.quota) }}
</div>
</div>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看 extractor 输出</summary>
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
>{{ formatJson(testResult.extracted) }}
</pre
>
</details>
<details class="text-xs text-gray-500 dark:text-gray-400">
<summary class="cursor-pointer">查看原始响应</summary>
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
>{{ formatJson(testResult.response) }}
</pre
>
</details>
</div>
</div>
</div>
<div class="glass-strong rounded-2xl p-4 shadow-lg">
<div class="mb-2 flex items-center justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">提取器代码</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
返回对象需包含 requestextractor支持模板变量替换
</div>
</div>
<button
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
@click="applyPreset"
>
使用示例模板
</button>
</div>
<textarea
v-model="form.scriptBody"
class="min-h-[320px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
spellcheck="false"
></textarea>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
extractor
返回字段可选isValidinvalidMessageremainingunitplanNametotalusedextra
</div>
</div>
</div>
</template>
<script setup>
import { onMounted, reactive, ref } from 'vue'
import {
getDefaultBalanceScriptApi,
updateDefaultBalanceScriptApi,
testDefaultBalanceScriptApi
} from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
const form = reactive({
baseUrl: '',
apiKey: '',
token: '',
timeoutSeconds: 10,
autoIntervalMinutes: 0,
scriptBody: ''
})
const testForm = reactive({
platform: '',
accountId: '',
extra: ''
})
const saving = ref(false)
const testing = ref(false)
const testResult = ref(null)
const presetScript = `({
request: {
url: "{{baseUrl}}/user/balance",
method: "GET",
headers: {
"Authorization": "Bearer {{apiKey}}",
"User-Agent": "cc-switch/1.0"
}
},
extractor: function(response) {
return {
isValid: response.is_active || true,
remaining: response.balance,
unit: "USD",
planName: response.plan || "默认套餐"
};
}
})`
const loadConfig = async () => {
const res = await getDefaultBalanceScriptApi()
if (res?.success && res.data) {
Object.assign(form, res.data)
}
}
const saveConfig = async () => {
saving.value = true
const res = await updateDefaultBalanceScriptApi({ ...form })
if (res?.success) {
showToast('配置已保存', 'success')
} else {
showToast(res?.message || '保存失败', 'error')
}
saving.value = false
}
const testScript = async () => {
testing.value = true
testResult.value = null
const payload = { ...form, ...testForm, scriptBody: form.scriptBody }
const res = await testDefaultBalanceScriptApi(payload)
if (res?.success) {
testResult.value = res.data
showToast('测试完成', 'success')
} else {
showToast(res?.error || '测试失败', 'error')
}
testing.value = false
}
const applyPreset = () => {
form.scriptBody = presetScript
}
const displayAmount = (val) => {
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
return Number(val).toFixed(2)
}
const formatJson = (data) => {
try {
return JSON.stringify(data, null, 2)
} catch (error) {
return String(data)
}
}
onMounted(() => {
applyPreset()
loadConfig()
})
</script>
<style scoped>
.input-text {
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
}
</style>

View File

@@ -196,6 +196,105 @@
</div>
</div>
<!-- 账户余额/配额汇总 -->
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
<div class="stat-card">
<div class="flex items-center justify-between">
<div>
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
账户余额/配额
</p>
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
</p>
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
</p>
</div>
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
<i class="fas fa-wallet" />
</div>
</div>
<div class="mt-3 flex items-center justify-between gap-3">
<p class="text-xs text-gray-500 dark:text-gray-400">
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
</p>
<button
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
:disabled="loadingBalanceSummary"
@click="loadBalanceSummary"
>
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
刷新
</button>
</div>
</div>
<div class="card p-4 sm:p-6">
<div class="mb-3 flex items-center justify-between">
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ lowBalanceAccounts.length }} 个
</span>
</div>
<div
v-if="loadingBalanceSummary"
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
>
正在加载...
</div>
<div
v-else-if="lowBalanceAccounts.length === 0"
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
>
全部正常
</div>
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
<div
v-for="account in lowBalanceAccounts"
:key="account.accountId"
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
>
<div class="flex items-center justify-between gap-2">
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{{ account.name || account.accountId }}
</div>
<span
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
>
{{ getBalancePlatformLabel(account.platform) }}
</span>
</div>
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
<span v-else
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
>
</div>
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
<div
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
>
<span>配额使用</span>
<span class="text-red-600 dark:text-red-400">
{{ account.quota.percentage.toFixed(1) }}%
</span>
</div>
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
<div
class="h-2 rounded-full bg-red-500"
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Token统计和性能指标 -->
<div
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
@@ -679,9 +778,13 @@
<script setup>
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
import { storeToRefs } from 'pinia'
import Chart from 'chart.js/auto'
import { useDashboardStore } from '@/stores/dashboard'
import { useThemeStore } from '@/stores/theme'
import Chart from 'chart.js/auto'
import { formatNumber, showToast } from '@/utils/tools'
import { getBalanceSummaryApi } from '@/utils/http_apis'
const dashboardStore = useDashboardStore()
const themeStore = useThemeStore()
@@ -698,8 +801,7 @@ const {
formattedUptime,
dateFilter,
trendGranularity,
apiKeysTrendMetric,
defaultTime
apiKeysTrendMetric
} = storeToRefs(dashboardStore)
const {
@@ -713,6 +815,9 @@ const {
disabledDate
} = dashboardStore
// 日期选择器默认时间
const defaultTime = [new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]
// Chart 实例
const modelUsageChart = ref(null)
const usageTrendChart = ref(null)
@@ -732,6 +837,94 @@ const accountGroupOptions = [
const accountTrendUpdating = ref(false)
// 余额/配额汇总
const balanceSummary = ref({
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
})
const loadingBalanceSummary = ref(false)
const balanceSummaryUpdatedAt = ref(null)
const getBalancePlatformLabel = (platform) => {
const map = {
claude: 'Claude',
'claude-console': 'Claude Console',
gemini: 'Gemini',
'gemini-api': 'Gemini API',
openai: 'OpenAI',
'openai-responses': 'OpenAI Responses',
azure_openai: 'Azure OpenAI',
bedrock: 'Bedrock',
droid: 'Droid',
ccr: 'CCR'
}
return map[platform] || platform
}
const lowBalanceAccounts = computed(() => {
const result = []
const platforms = balanceSummary.value?.platforms || {}
Object.entries(platforms).forEach(([platform, data]) => {
const list = Array.isArray(data?.accounts) ? data.accounts : []
list.forEach((entry) => {
const accountData = entry?.data
if (!accountData) return
const amount = accountData.balance?.amount
const percentage = accountData.quota?.percentage
const isLowBalance = typeof amount === 'number' && amount < 10
const isHighUsage = typeof percentage === 'number' && percentage > 90
if (isLowBalance || isHighUsage) {
result.push({
...accountData,
name: entry?.name || accountData.accountId,
platform: accountData.platform || platform
})
}
})
})
return result
})
const formatCurrencyUsd = (amount) => {
const value = Number(amount)
if (!Number.isFinite(value)) return '$0.00'
if (value >= 1) return `$${value.toFixed(2)}`
if (value >= 0.01) return `$${value.toFixed(3)}`
return `$${value.toFixed(6)}`
}
const formatLastUpdate = (isoString) => {
if (!isoString) return '未知'
const date = new Date(isoString)
if (Number.isNaN(date.getTime())) return '未知'
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
}
const loadBalanceSummary = async () => {
loadingBalanceSummary.value = true
const response = await getBalanceSummaryApi()
if (response?.success) {
balanceSummary.value = response.data || {
totalBalance: 0,
totalCost: 0,
lowBalanceCount: 0,
platforms: {}
}
balanceSummaryUpdatedAt.value = new Date().toISOString()
} else if (response?.message) {
console.debug('加载余额汇总失败:', response.message)
showToast('加载余额汇总失败', 'error')
}
loadingBalanceSummary.value = false
}
// 自动刷新相关
const autoRefreshEnabled = ref(false)
const autoRefreshInterval = ref(30) // 秒
@@ -753,16 +946,6 @@ const chartColors = computed(() => ({
legend: isDarkMode.value ? '#e5e7eb' : '#374151'
}))
// 格式化数字
function formatNumber(num) {
if (num >= 1000000) {
return (num / 1000000).toFixed(2) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(2) + 'K'
}
return num.toString()
}
function formatCostValue(cost) {
if (!Number.isFinite(cost)) {
return '$0.000000'
@@ -1488,7 +1671,7 @@ async function refreshAllData() {
isRefreshing.value = true
try {
await Promise.all([loadDashboardData(), refreshChartsData()])
await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
} finally {
isRefreshing.value = false
}

View File

@@ -121,7 +121,3 @@ const handleLogin = async () => {
await authStore.login(loginForm.value)
}
</script>
<style scoped>
/* 组件特定样式已经在全局样式中定义 */
</style>

View File

@@ -88,6 +88,52 @@
</div>
</div>
<!-- Limits Config Card -->
<div
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50"
>
<div class="flex flex-wrap items-center gap-4">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">兑换上限保护</span>
<label class="relative inline-flex cursor-pointer items-center">
<input
v-model="limitsConfig.enabled"
class="peer sr-only"
type="checkbox"
@change="saveLimitsConfig"
/>
<div
class="peer h-5 w-9 rounded-full bg-gray-300 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full dark:bg-gray-600"
/>
</label>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">最大额度</span>
<input
v-model.number="limitsConfig.maxTotalCostLimit"
class="w-24 rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
:disabled="!limitsConfig.enabled"
min="0"
type="number"
@change="saveLimitsConfig"
/>
<span class="text-sm text-gray-500 dark:text-gray-400">$</span>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-600 dark:text-gray-400">最大有效期</span>
<input
v-model.number="limitsConfig.maxExpiryDays"
class="w-20 rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
:disabled="!limitsConfig.enabled"
min="0"
type="number"
@change="saveLimitsConfig"
/>
<span class="text-sm text-gray-500 dark:text-gray-400"></span>
</div>
</div>
</div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 dark:border-gray-700">
<nav aria-label="Tabs" class="-mb-px flex space-x-8">
@@ -233,7 +279,7 @@
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
<span v-if="card.type === 'quota' || card.type === 'combo'"
>{{ card.quotaAmount }} CC</span
>${{ card.quotaAmount }}</span
>
<span v-if="card.type === 'combo'"> + </span>
<span v-if="card.type === 'time' || card.type === 'combo'">
@@ -410,7 +456,7 @@
</span>
</td>
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
<span v-if="redemption.quotaAdded > 0">{{ redemption.quotaAdded }} CC</span>
<span v-if="redemption.quotaAdded > 0">${{ redemption.quotaAdded }}</span>
<span v-if="redemption.quotaAdded > 0 && redemption.timeAdded > 0"> + </span>
<span v-if="redemption.timeAdded > 0">
{{ redemption.timeAdded }}
@@ -505,7 +551,7 @@
<div v-if="newCard.type === 'quota' || newCard.type === 'combo'">
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
>额度数量 (CC)</label
>额度数量 (美元)</label
>
<input
v-model.number="newCard.quotaAmount"
@@ -630,7 +676,7 @@
</div>
<span class="text-xs text-gray-500 dark:text-gray-400">
<template v-if="card.type === 'quota' || card.type === 'combo'">
{{ card.quotaAmount }} CC
${{ card.quotaAmount }}
</template>
<template v-if="card.type === 'combo'"> + </template>
<template v-if="card.type === 'time' || card.type === 'combo'">
@@ -675,6 +721,44 @@
</div>
</div>
</Teleport>
<!-- Revoke Modal -->
<Teleport to="body">
<div
v-if="showRevokeModal"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
@click.self="showRevokeModal = false"
>
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-800">
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">撤销核销</h3>
<div class="mb-4">
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
撤销原因可选
</label>
<input
v-model="revokeReason"
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
placeholder="请输入撤销原因"
type="text"
/>
</div>
<div class="flex justify-end gap-3">
<button
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
@click="showRevokeModal = false"
>
取消
</button>
<button
class="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
@click="executeRevoke"
>
确认撤销
</button>
</div>
</div>
</div>
</Teleport>
<!-- Confirm Modal -->
<ConfirmModal
:cancel-text="confirmModalConfig.cancelText"
@@ -692,9 +776,9 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
import * as httpApi from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
import { copyText } from '@/utils/tools'
import * as httpApis from '@/utils/http_apis'
import { showToast, copyText, formatDate } from '@/utils/tools'
const loading = ref(false)
const creating = ref(false)
@@ -710,6 +794,9 @@ const confirmModalConfig = ref({
})
const confirmResolve = ref(null)
const createdCards = ref([])
const showRevokeModal = ref(false)
const revokeReason = ref('')
const revokingRedemption = ref(null)
const activeTab = ref('cards')
const selectedCards = ref([])
@@ -732,6 +819,12 @@ const stats = ref({
expired: 0
})
const limitsConfig = ref({
enabled: true,
maxExpiryDays: 90,
maxTotalCostLimit: 1000
})
const cards = ref([])
const redemptions = ref([])
@@ -777,11 +870,6 @@ const newCard = ref({
note: ''
})
const formatDate = (dateStr) => {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString('zh-CN')
}
const showConfirm = (
title,
message,
@@ -806,23 +894,30 @@ const handleCancelModal = () => {
const loadCards = async () => {
loading.value = true
try {
const offset = (currentPage.value - 1) * pageSize.value
const [cardsData, statsData, redemptionsData] = await Promise.all([
httpApi.get(`/admin/quota-cards?limit=${pageSize.value}&offset=${offset}`),
httpApi.get('/admin/quota-cards/stats'),
httpApi.get('/admin/redemptions')
])
const offset = (currentPage.value - 1) * pageSize.value
const [cardsData, statsData, redemptionsData] = await Promise.all([
httpApis.getQuotaCardsWithParamsApi({ limit: pageSize.value, offset }),
httpApis.getQuotaCardsStatsApi(),
httpApis.getRedemptionsApi()
])
cards.value = cardsData.data?.cards || []
totalCards.value = cardsData.data?.total || 0
stats.value = statsData.data || stats.value
redemptions.value = redemptionsData.data?.redemptions || []
} catch (error) {
console.error('Failed to load cards:', error)
showToast('加载卡片数据失败', 'error')
} finally {
loading.value = false
// 单独获取 limits 配置,兼容老后端
const limitsData = await httpApis.getQuotaCardLimitsApi().catch(() => ({ data: null }))
cards.value = cardsData.data?.cards || []
totalCards.value = cardsData.data?.total || 0
stats.value = statsData.data || stats.value
redemptions.value = redemptionsData.data?.redemptions || []
if (limitsData.data) {
limitsConfig.value = limitsData.data
}
loading.value = false
}
const saveLimitsConfig = async () => {
const result = await httpApis.updateQuotaCardLimitsApi(limitsConfig.value)
if (result.success) {
showToast('配置已保存', 'success')
}
}
@@ -845,8 +940,8 @@ const changePageSize = () => {
const createCard = async () => {
creating.value = true
try {
const result = await httpApi.post('/admin/quota-cards', newCard.value)
const result = await httpApis.createQuotaCardApi(newCard.value)
if (result.success) {
showCreateModal.value = false
// 处理返回的卡片数据
@@ -866,12 +961,10 @@ const createCard = async () => {
showToast(`成功创建 ${createdCards.value.length} 张卡片`, 'success')
loadCards()
} catch (error) {
console.error('Failed to create card:', error)
showToast(error.message || '创建卡片失败', 'error')
} finally {
creating.value = false
} else {
showToast(result.message || '创建卡片失败', 'error')
}
creating.value = false
}
// 下载卡片
@@ -882,7 +975,7 @@ const downloadCards = () => {
.map((card) => {
let label = ''
if (card.type === 'quota' || card.type === 'combo') {
label += `${card.quotaAmount}CC`
label += `$${card.quotaAmount}`
}
if (card.type === 'combo') {
label += '_'
@@ -936,14 +1029,9 @@ const deleteCard = async (card) => {
)
if (!confirmed) return
try {
await httpApi.del(`/admin/quota-cards/${card.id}`)
showToast('卡片已删除', 'success')
loadCards()
} catch (error) {
console.error('Failed to delete card:', error)
showToast(error.message || '删除卡片失败', 'error')
}
await httpApis.deleteQuotaCardApi(card.id)
showToast('卡片已删除', 'success')
loadCards()
}
const deleteSelectedCards = async () => {
@@ -956,29 +1044,25 @@ const deleteSelectedCards = async () => {
)
if (!confirmed) return
try {
await Promise.all(selectedCards.value.map((id) => httpApi.del(`/admin/quota-cards/${id}`)))
showToast(`已删除 ${selectedCards.value.length} 张卡片`, 'success')
selectedCards.value = []
loadCards()
} catch (error) {
console.error('Failed to delete cards:', error)
showToast(error.message || '批量删除失败', 'error')
}
await Promise.all(selectedCards.value.map((id) => httpApis.deleteQuotaCardApi(id)))
showToast(`已删除 ${selectedCards.value.length} 张卡片`, 'success')
selectedCards.value = []
loadCards()
}
const revokeRedemption = async (redemption) => {
const reason = prompt('撤销原因(可选):')
if (reason === null) return
const revokeRedemption = (redemption) => {
revokingRedemption.value = redemption
revokeReason.value = ''
showRevokeModal.value = true
}
try {
await httpApi.post(`/admin/redemptions/${redemption.id}/revoke`, { reason })
showToast('核销已撤销', 'success')
loadCards()
} catch (error) {
console.error('Failed to revoke redemption:', error)
showToast(error.message || '撤销核销失败', 'error')
}
const executeRevoke = async () => {
if (!revokingRedemption.value) return
await httpApis.revokeRedemptionApi(revokingRedemption.value.id, { reason: revokeReason.value })
showToast('核销已撤销', 'success')
showRevokeModal.value = false
revokingRedemption.value = null
loadCards()
}
onMounted(() => {

View File

@@ -1124,10 +1124,10 @@
服务倍率说明
</h3>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
服务倍率用于计算不同服务消耗的虚拟额度CC
服务倍率用于计算不同服务的计费费用
<strong>{{ serviceRates.baseService || 'claude' }}</strong>
为基准倍率 1.0其他服务按倍率换算例如Gemini 倍率 0.5 表示消耗 1 USD
只扣除 0.5 CC 额度
为基准倍率 1.0其他服务按倍率换算例如Gemini 倍率 0.5 表示消耗 $1 只扣除
$0.5 额度
</p>
</div>
</div>
@@ -1804,7 +1804,8 @@ import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
import { storeToRefs } from 'pinia'
import { showToast } from '@/utils/tools'
import { useSettingsStore } from '@/stores/settings'
import * as httpApi from '@/utils/http_apis'
import * as httpApis from '@/utils/http_apis'
import ConfirmModal from '@/components/common/ConfirmModal.vue'
// 定义组件名称用于keep-alive排除
@@ -2140,7 +2141,7 @@ onBeforeUnmount(() => {
const loadWebhookConfig = async () => {
if (!isMounted.value) return
try {
const response = await httpApi.get('/admin/webhook/config', {
const response = await httpApis.getWebhookConfigApi({
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
@@ -2173,7 +2174,7 @@ const saveWebhookConfig = async () => {
}
}
const response = await httpApi.post('/admin/webhook/config', payload, {
const response = await httpApis.updateWebhookConfigApi(payload, {
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
@@ -2193,7 +2194,7 @@ const loadClaudeConfig = async () => {
if (!isMounted.value) return
claudeConfigLoading.value = true
try {
const response = await httpApi.get('/admin/claude-relay-config', {
const response = await httpApis.getClaudeRelayConfigApi({
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
@@ -2246,7 +2247,7 @@ const saveClaudeConfig = async () => {
concurrentRequestQueueTimeoutMs: claudeConfig.value.concurrentRequestQueueTimeoutMs
}
const response = await httpApi.put('/admin/claude-relay-config', payload, {
const response = await httpApis.updateClaudeRelayConfigApi(payload, {
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
@@ -2270,7 +2271,7 @@ const loadServiceRates = async () => {
if (!isMounted.value) return
serviceRatesLoading.value = true
try {
const response = await httpApi.get('/admin/service-rates', {
const response = await httpApis.getAdminServiceRatesApi({
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
@@ -2297,8 +2298,7 @@ const saveServiceRates = async () => {
if (!isMounted.value) return
serviceRatesSaving.value = true
try {
const response = await httpApi.put(
'/admin/service-rates',
const response = await httpApis.updateAdminServiceRatesApi(
{
rates: serviceRates.value.rates,
baseService: serviceRates.value.baseService
@@ -2473,14 +2473,16 @@ const savePlatform = async () => {
let response
if (editingPlatform.value) {
// 更新平台
response = await httpApi.put(
`/admin/webhook/platforms/${editingPlatform.value.id}`,
response = await httpApis.updateWebhookPlatformApi(
editingPlatform.value.id,
platformForm.value,
{ signal: abortController.value.signal }
{
signal: abortController.value.signal
}
)
} else {
// 添加平台
response = await httpApi.post('/admin/webhook/platforms', platformForm.value, {
response = await httpApis.createWebhookPlatformApi(platformForm.value, {
signal: abortController.value.signal
})
}
@@ -2545,7 +2547,7 @@ const deletePlatform = async (id) => {
}
try {
const response = await httpApi.del(`/admin/webhook/platforms/${id}`, {
const response = await httpApis.deleteWebhookPlatformApi(id, {
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
@@ -2565,13 +2567,9 @@ const togglePlatform = async (id) => {
if (!isMounted.value) return
try {
const response = await httpApi.post(
`/admin/webhook/platforms/${id}/toggle`,
{},
{
signal: abortController.value.signal
}
)
const response = await httpApis.toggleWebhookPlatformApi(id, {
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
showToast(response.message, 'success')
await loadWebhookConfig()
@@ -2620,7 +2618,7 @@ const testPlatform = async (platform) => {
testData.url = platform.url
}
const response = await httpApi.post('/admin/webhook/test', testData, {
const response = await httpApis.testWebhookApi(testData, {
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
@@ -2643,7 +2641,7 @@ const testPlatformForm = async () => {
testingConnection.value = true
try {
const response = await httpApi.post('/admin/webhook/test', platformForm.value, {
const response = await httpApis.testWebhookApi(platformForm.value, {
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
@@ -2666,13 +2664,9 @@ const sendTestNotification = async () => {
if (!isMounted.value) return
try {
const response = await httpApi.post(
'/admin/webhook/test-notification',
{},
{
signal: abortController.value.signal
}
)
const response = await httpApis.testWebhookNotificationApi({
signal: abortController.value.signal
})
if (response.success && isMounted.value) {
showToast('测试通知已发送', 'success')
}

View File

@@ -340,7 +340,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { useThemeStore } from '@/stores/theme'
import { showToast } from '@/utils/tools'
import { showToast, formatNumber, formatDate } from '@/utils/tools'
import ThemeToggle from '@/components/common/ThemeToggle.vue'
import UserApiKeysManager from '@/components/user/UserApiKeysManager.vue'
import UserUsageStats from '@/components/user/UserUsageStats.vue'
@@ -354,26 +354,6 @@ const activeTab = ref('overview')
const userProfile = ref(null)
const apiKeysStats = ref({ active: 0, deleted: 0 })
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const handleTabChange = (tab) => {
activeTab.value = tab
// Refresh API keys stats when switching to overview tab
@@ -430,7 +410,3 @@ onMounted(() => {
loadApiKeysStats()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -195,7 +195,3 @@ onMounted(() => {
themeStore.initTheme()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>

View File

@@ -476,8 +476,9 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import * as httpApi from '@/utils/http_apis'
import { showToast } from '@/utils/tools'
import * as httpApis from '@/utils/http_apis'
import { showToast, formatNumber, formatDate } from '@/utils/tools'
import { debounce } from 'lodash-es'
import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
@@ -531,26 +532,6 @@ const filteredUsers = computed(() => {
return filtered
})
const formatNumber = (num) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M'
} else if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K'
}
return num.toString()
}
const formatDate = (dateString) => {
if (!dateString) return null
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
}
const loadUsers = async () => {
loading.value = true
try {
@@ -564,8 +545,8 @@ const loadUsers = async () => {
}
const [usersResponse, statsResponse] = await Promise.all([
httpApi.get('/users', { params }),
httpApi.get('/users/stats/overview')
httpApis.getFrontUsersApi(params),
httpApis.getFrontUsersStatsOverviewApi()
])
if (usersResponse.success) {
@@ -631,7 +612,7 @@ const handleConfirmAction = async () => {
try {
if (action === 'toggleStatus') {
const response = await httpApi.patch(`/users/${user.id}/status`, {
const response = await httpApis.updateFrontUserStatusApi(user.id, {
isActive: !user.isActive
})
@@ -643,7 +624,7 @@ const handleConfirmAction = async () => {
showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
}
} else if (action === 'disableKeys') {
const response = await httpApi.post(`/users/${user.id}/disable-keys`)
const response = await httpApis.disableFrontUserKeysApi(user.id)
if (response.success) {
showToast(`Disabled ${response.disabledCount} API keys`, 'success')
@@ -669,7 +650,3 @@ onMounted(() => {
loadUsers()
})
</script>
<style scoped>
/* 组件特定样式 */
</style>