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

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>