mirror of
https://github.com/Wei-Shaw/claude-relay-service.git
synced 2026-01-23 00:53:33 +00:00
1
This commit is contained in:
@@ -5,20 +5,6 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Claude Relay Service - 管理后台</title>
|
||||
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- 预连接到CDN域名,加速资源加载 -->
|
||||
<link rel="preconnect" href="https://cdn.jsdelivr.net" crossorigin>
|
||||
<link rel="preconnect" href="https://cdnjs.cloudflare.com" crossorigin>
|
||||
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net">
|
||||
<link rel="dns-prefetch" href="https://cdnjs.cloudflare.com">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
15
web/admin-spa/package-lock.json
generated
15
web/admin-spa/package-lock.json
generated
@@ -1157,6 +1157,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
|
||||
"integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/lodash": "*"
|
||||
}
|
||||
@@ -1351,6 +1352,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -1587,6 +1589,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"caniuse-lite": "^1.0.30001726",
|
||||
"electron-to-chromium": "^1.5.173",
|
||||
@@ -3060,13 +3063,15 @@
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/lodash-unified": {
|
||||
"version": "1.0.3",
|
||||
@@ -3618,6 +3623,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3764,6 +3770,7 @@
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -4028,6 +4035,7 @@
|
||||
"integrity": "sha512-33xGNBsDJAkzt0PvninskHlWnTIPgDtTwhg0U38CUoNP/7H6wI2Cz6dUeoNPbjdTdsYTGuiFFASuUOWovH0SyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -4525,6 +4533,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -4915,6 +4924,7 @@
|
||||
"integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.21.3",
|
||||
"postcss": "^8.4.43",
|
||||
@@ -5115,6 +5125,7 @@
|
||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.18.tgz",
|
||||
"integrity": "sha512-7W4Y4ZbMiQ3SEo+m9lnoNpV9xG7QVMLa+/0RFwwiAVkeYoyGXqWE85jabU4pllJNUzqfLShJ5YLptewhCWUgNA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.5.18",
|
||||
"@vue/compiler-sfc": "3.5.18",
|
||||
|
||||
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Bold.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Light.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Medium.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-Regular.woff2
Normal file
Binary file not shown.
BIN
web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2
Normal file
BIN
web/admin-spa/src/assets/fonts/inter/Inter-SemiBold.woff2
Normal file
Binary file not shown.
46
web/admin-spa/src/assets/fonts/inter/inter.css
Normal file
46
web/admin-spa/src/assets/fonts/inter/inter.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/* Inter 字体本地化 - 仅包含项目使用的字重 (300, 400, 500, 600, 700) */
|
||||
|
||||
/* Inter Light - 300 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url('./Inter-Light.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Regular - 400 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('./Inter-Regular.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Medium - 500 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url('./Inter-Medium.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter SemiBold - 600 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('./Inter-SemiBold.woff2') format('woff2');
|
||||
}
|
||||
|
||||
/* Inter Bold - 700 */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('./Inter-Bold.woff2') format('woff2');
|
||||
}
|
||||
@@ -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 可返回:isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||
</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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
361
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal file
361
web/admin-spa/src/components/accounts/BalanceDisplay.vue
Normal 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>
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.parse(Redis 可能返回 "[]" 或 "[\"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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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)}`
|
||||
|
||||
@@ -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>
|
||||
|
||||
292
web/admin-spa/src/components/apikeys/TagManagementModal.vue
Normal file
292
web/admin-spa/src/components/apikeys/TagManagementModal.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
@@ -59,7 +59,3 @@ const iconBgClass = computed(() => {
|
||||
return colorMap[props.iconColor] || colorMap.primary
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 使用全局样式中定义的 .stat-card 和 .stat-icon 类 */
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useTutorialUrls } from '@/composables/useTutorialUrls'
|
||||
import { useTutorialUrls } from '@/utils/useTutorialUrls'
|
||||
import NodeInstallTutorial from './NodeInstallTutorial.vue'
|
||||
|
||||
defineProps({
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -259,7 +259,3 @@ watch(
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmOptions = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
confirmText: '继续',
|
||||
cancelText: '取消'
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
|
||||
export function useConfirm() {
|
||||
const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
|
||||
return new Promise((resolve) => {
|
||||
confirmOptions.value = {
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText
|
||||
}
|
||||
confirmResolve.value = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirm = () => {
|
||||
showConfirmModal.value = false
|
||||
if (confirmResolve.value) {
|
||||
confirmResolve.value(true)
|
||||
confirmResolve.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
showConfirmModal.value = false
|
||||
if (confirmResolve.value) {
|
||||
confirmResolve.value(false)
|
||||
confirmResolve.value = null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showConfirmModal,
|
||||
confirmOptions,
|
||||
showConfirm,
|
||||
handleConfirm,
|
||||
handleCancel
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export const APP_CONFIG = {
|
||||
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
|
||||
apiPrefix: import.meta.env.DEV ? '/webapi' : ''
|
||||
}
|
||||
|
||||
export function getAppUrl(path = '') {
|
||||
if (path && !path.startsWith('/')) path = '/' + path
|
||||
return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path)
|
||||
}
|
||||
|
||||
export function getLoginUrl() {
|
||||
return getAppUrl('/login')
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
|
||||
import 'element-plus/dist/index.css'
|
||||
import 'element-plus/theme-chalk/dark/css-vars.css'
|
||||
import './assets/fonts/inter/inter.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { useUserStore } from './stores/user'
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { APP_CONFIG } from '@/config/app'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { APP_CONFIG, showToast } from '@/utils/tools'
|
||||
|
||||
// 路由懒加载
|
||||
const LoginView = () => import('@/views/LoginView.vue')
|
||||
@@ -15,7 +14,6 @@ const ApiKeysView = () => import('@/views/ApiKeysView.vue')
|
||||
const ApiKeyUsageRecordsView = () => import('@/views/ApiKeyUsageRecordsView.vue')
|
||||
const AccountsView = () => import('@/views/AccountsView.vue')
|
||||
const AccountUsageRecordsView = () => import('@/views/AccountUsageRecordsView.vue')
|
||||
const TutorialView = () => import('@/views/TutorialView.vue')
|
||||
const SettingsView = () => import('@/views/SettingsView.vue')
|
||||
const ApiStatsView = () => import('@/views/ApiStatsView.vue')
|
||||
const QuotaCardsView = () => import('@/views/QuotaCardsView.vue')
|
||||
@@ -125,18 +123,6 @@ const routes = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/tutorial',
|
||||
component: MainLayout,
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'Tutorial',
|
||||
component: TutorialView
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: MainLayout,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import {
|
||||
getApiKeys,
|
||||
createApiKey as apiCreateApiKey,
|
||||
updateApiKey as apiUpdateApiKey,
|
||||
toggleApiKey as apiToggleApiKey,
|
||||
deleteApiKey as apiDeleteApiKey,
|
||||
getApiKeyStats,
|
||||
getApiKeyTags
|
||||
} from '@/utils/http_apis'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
|
||||
export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
const apiKeys = ref([])
|
||||
@@ -20,129 +13,58 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
|
||||
const fetchApiKeys = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await getApiKeys()
|
||||
if (response.success) {
|
||||
apiKeys.value = response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || '获取API Keys失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
const res = await httpApis.getApiKeysApi()
|
||||
if (res.success) apiKeys.value = res.data || []
|
||||
else error.value = res.message
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const createApiKey = async (data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiCreateApiKey(data)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response.data
|
||||
} else {
|
||||
throw new Error(response.message || '创建API Key失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
const res = await httpApis.createApiKeyApi(data)
|
||||
if (res.success) await fetchApiKeys()
|
||||
else error.value = res.message
|
||||
loading.value = false
|
||||
return res
|
||||
}
|
||||
|
||||
const updateApiKey = async (id, data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiUpdateApiKey(id, data)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '更新API Key失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
const res = await httpApis.updateApiKeyApi(id, data)
|
||||
if (res.success) await fetchApiKeys()
|
||||
else error.value = res.message
|
||||
loading.value = false
|
||||
return res
|
||||
}
|
||||
|
||||
const toggleApiKey = async (id) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiToggleApiKey(id)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '切换状态失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
const res = await httpApis.toggleApiKeyApi(id)
|
||||
if (res.success) await fetchApiKeys()
|
||||
else error.value = res.message
|
||||
loading.value = false
|
||||
return res
|
||||
}
|
||||
|
||||
const renewApiKey = async (id, data) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiUpdateApiKey(id, data)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '续期失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const renewApiKey = (id, data) => updateApiKey(id, data)
|
||||
|
||||
const deleteApiKey = async (id) => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const response = await apiDeleteApiKey(id)
|
||||
if (response.success) {
|
||||
await fetchApiKeys()
|
||||
return response
|
||||
} else {
|
||||
throw new Error(response.message || '删除失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
const res = await httpApis.deleteApiKeyApi(id)
|
||||
if (res.success) await fetchApiKeys()
|
||||
else error.value = res.message
|
||||
loading.value = false
|
||||
return res
|
||||
}
|
||||
|
||||
const fetchApiKeyStats = async (id, timeRange = 'all') => {
|
||||
try {
|
||||
const response = await getApiKeyStats(id, { timeRange })
|
||||
if (response.success) {
|
||||
return response.stats
|
||||
} else {
|
||||
throw new Error(response.message || '获取统计失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取API Key统计失败:', err)
|
||||
return null
|
||||
}
|
||||
const res = await httpApis.getApiKeyStatsApi(id, { timeRange })
|
||||
return res.success ? res.stats : null
|
||||
}
|
||||
|
||||
const fetchTags = async () => {
|
||||
const res = await httpApis.getApiKeyTagsApi()
|
||||
return res.success ? res.data || [] : []
|
||||
}
|
||||
|
||||
const sortApiKeys = (field) => {
|
||||
@@ -154,20 +76,6 @@ export const useApiKeysStore = defineStore('apiKeys', () => {
|
||||
}
|
||||
}
|
||||
|
||||
const fetchTags = async () => {
|
||||
try {
|
||||
const response = await getApiKeyTags()
|
||||
if (response.success) {
|
||||
return response.data || []
|
||||
} else {
|
||||
throw new Error(response.message || '获取标签失败')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取标签失败:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
apiKeys.value = []
|
||||
loading.value = false
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
|
||||
export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 状态
|
||||
@@ -36,6 +37,9 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 服务倍率配置
|
||||
const serviceRates = ref(null)
|
||||
|
||||
// Key 级别的服务倍率
|
||||
const keyServiceRates = ref({})
|
||||
|
||||
// 计算属性
|
||||
const currentPeriodData = computed(() => {
|
||||
const defaultData = {
|
||||
@@ -124,17 +128,20 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
|
||||
try {
|
||||
// 获取 API Key ID
|
||||
const idResult = await httpApi.getKeyId(trimmedKey)
|
||||
const idResult = await httpApis.getKeyIdApi(trimmedKey)
|
||||
|
||||
if (idResult.success) {
|
||||
apiId.value = idResult.data.id
|
||||
|
||||
// 使用 apiId 查询统计数据
|
||||
const statsResult = await httpApi.getUserStats(apiId.value)
|
||||
const statsResult = await httpApis.getUserStatsApi(apiId.value)
|
||||
|
||||
if (statsResult.success) {
|
||||
statsData.value = statsResult.data
|
||||
|
||||
// 保存 Key 级别的服务倍率
|
||||
keyServiceRates.value = statsResult.data.serviceRates || {}
|
||||
|
||||
// 同时加载今日和本月的统计数据
|
||||
await loadAllPeriodStats()
|
||||
|
||||
@@ -182,9 +189,9 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
|
||||
try {
|
||||
const [dailyResult, monthlyResult, alltimeResult] = await Promise.all([
|
||||
httpApi.getUserModelStats(apiId.value, 'daily'),
|
||||
httpApi.getUserModelStats(apiId.value, 'monthly'),
|
||||
httpApi.getUserModelStats(apiId.value, 'alltime')
|
||||
httpApis.getUserModelStatsApi(apiId.value, 'daily'),
|
||||
httpApis.getUserModelStatsApi(apiId.value, 'monthly'),
|
||||
httpApis.getUserModelStatsApi(apiId.value, 'alltime')
|
||||
])
|
||||
|
||||
dailyModelStats.value = dailyResult.success ? dailyResult.data || [] : []
|
||||
@@ -207,7 +214,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 加载指定时间段的统计数据
|
||||
async function loadPeriodStats(period) {
|
||||
try {
|
||||
const result = await httpApi.getUserModelStats(apiId.value, period)
|
||||
const result = await httpApis.getUserModelStatsApi(apiId.value, period)
|
||||
|
||||
if (result.success) {
|
||||
// 计算汇总数据
|
||||
@@ -258,7 +265,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
modelStatsLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await httpApi.getUserModelStats(apiId.value, period)
|
||||
const result = await httpApis.getUserModelStatsApi(apiId.value, period)
|
||||
|
||||
if (result.success) {
|
||||
modelStats.value = result.data || []
|
||||
@@ -310,11 +317,14 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
modelStats.value = []
|
||||
|
||||
try {
|
||||
const result = await httpApi.getUserStats(apiId.value)
|
||||
const result = await httpApis.getUserStatsApi(apiId.value)
|
||||
|
||||
if (result.success) {
|
||||
statsData.value = result.data
|
||||
|
||||
// 保存 Key 级别的服务倍率
|
||||
keyServiceRates.value = result.data.serviceRates || {}
|
||||
|
||||
// 调试:打印返回的限制数据
|
||||
console.log('API Stats - Full response:', result.data)
|
||||
console.log('API Stats - limits data:', result.data.limits)
|
||||
@@ -343,7 +353,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
async function loadOemSettings() {
|
||||
oemLoading.value = true
|
||||
try {
|
||||
const result = await httpApi.getOemSettings()
|
||||
const result = await httpApis.getOemSettingsApi()
|
||||
if (result && result.success && result.data) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
}
|
||||
@@ -363,7 +373,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
// 加载服务倍率配置
|
||||
async function loadServiceRates() {
|
||||
try {
|
||||
const result = await httpApi.getServiceRates()
|
||||
const result = await httpApis.getServiceRatesApi()
|
||||
if (result && result.success && result.data) {
|
||||
serviceRates.value = result.data
|
||||
}
|
||||
@@ -428,10 +438,11 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
modelStats.value = []
|
||||
apiKeys.value = keys
|
||||
apiIds.value = []
|
||||
keyServiceRates.value = {} // 多 Key 模式清理 Key 倍率
|
||||
|
||||
try {
|
||||
// 批量获取 API Key IDs
|
||||
const idResults = await Promise.allSettled(keys.map((key) => httpApi.getKeyId(key)))
|
||||
const idResults = await Promise.allSettled(keys.map((key) => httpApis.getKeyIdApi(key)))
|
||||
|
||||
const validIds = []
|
||||
const validKeys = []
|
||||
@@ -453,7 +464,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
apiKeys.value = validKeys
|
||||
|
||||
// 批量查询统计数据
|
||||
const batchResult = await httpApi.getBatchStats(validIds)
|
||||
const batchResult = await httpApis.getBatchStatsApi(validIds)
|
||||
|
||||
if (batchResult.success) {
|
||||
aggregatedStats.value = batchResult.data.aggregated
|
||||
@@ -489,7 +500,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
modelStatsLoading.value = true
|
||||
|
||||
try {
|
||||
const result = await httpApi.getBatchModelStats(apiIds.value, period)
|
||||
const result = await httpApis.getBatchModelStatsApi(apiIds.value, period)
|
||||
|
||||
if (result.success) {
|
||||
modelStats.value = result.data || []
|
||||
@@ -546,6 +557,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
error.value = ''
|
||||
statsPeriod.value = 'daily'
|
||||
apiId.value = null
|
||||
keyServiceRates.value = {}
|
||||
// 清除多 Key 模式数据
|
||||
apiKeys.value = []
|
||||
apiIds.value = []
|
||||
@@ -587,6 +599,7 @@ export const useApiStatsStore = defineStore('apistats', () => {
|
||||
individualStats,
|
||||
invalidKeys,
|
||||
serviceRates,
|
||||
keyServiceRates,
|
||||
|
||||
// Computed
|
||||
currentPeriodData,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import router from '@/router'
|
||||
import { login as apiLogin, getAuthUser, getOemSettings } from '@/utils/http_apis'
|
||||
|
||||
import { loginApi, getAuthUserApi, getOemSettingsApi } from '@/utils/http_apis'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 状态
|
||||
@@ -29,7 +30,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
loginError.value = ''
|
||||
|
||||
try {
|
||||
const result = await apiLogin(credentials)
|
||||
const result = await loginApi(credentials)
|
||||
|
||||
if (result.success) {
|
||||
authToken.value = result.token
|
||||
@@ -66,7 +67,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
|
||||
async function verifyToken() {
|
||||
try {
|
||||
const userResult = await getAuthUser()
|
||||
const userResult = await getAuthUserApi()
|
||||
if (!userResult.success || !userResult.user) {
|
||||
logout()
|
||||
return
|
||||
@@ -80,7 +81,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
async function loadOemSettings() {
|
||||
oemLoading.value = true
|
||||
try {
|
||||
const result = await getOemSettings()
|
||||
const result = await getOemSettingsApi()
|
||||
if (result.success && result.data) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getSupportedClients } from '@/utils/http_apis'
|
||||
import { getSupportedClientsApi } from '@/utils/http_apis'
|
||||
|
||||
export const useClientsStore = defineStore('clients', {
|
||||
state: () => ({
|
||||
@@ -10,31 +10,14 @@ export const useClientsStore = defineStore('clients', {
|
||||
|
||||
actions: {
|
||||
async loadSupportedClients() {
|
||||
if (this.supportedClients.length > 0) {
|
||||
return this.supportedClients
|
||||
}
|
||||
if (this.supportedClients.length > 0) return this.supportedClients
|
||||
|
||||
this.loading = true
|
||||
this.error = null
|
||||
|
||||
try {
|
||||
const response = await getSupportedClients()
|
||||
|
||||
if (response.success) {
|
||||
this.supportedClients = response.data || []
|
||||
} else {
|
||||
this.error = response.message || '加载支持的客户端失败'
|
||||
console.error('Failed to load supported clients:', this.error)
|
||||
}
|
||||
|
||||
return this.supportedClients
|
||||
} catch (error) {
|
||||
this.error = error.message || '加载支持的客户端失败'
|
||||
console.error('Error loading supported clients:', error)
|
||||
return []
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
const res = await getSupportedClientsApi()
|
||||
if (res.success) this.supportedClients = res.data || []
|
||||
else this.error = res.message
|
||||
this.loading = false
|
||||
return this.supportedClients
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { getDashboard, getUsageCosts, getUsageStats } from '@/utils/http_apis'
|
||||
|
||||
import { getDashboardApi, getUsageCostsApi, getUsageStatsApi } from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
export const useDashboardStore = defineStore('dashboard', () => {
|
||||
@@ -51,7 +52,6 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
totalCosts: { totalCost: 0, formatted: { totalCost: '$0.000000' } }
|
||||
})
|
||||
|
||||
const modelStats = ref([])
|
||||
const trendData = ref([])
|
||||
const dashboardModelStats = ref([])
|
||||
const apiKeysTrendData = ref({
|
||||
@@ -136,9 +136,6 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
const apiKeysTrendMetric = ref('requests') // 'requests' 或 'tokens'
|
||||
const accountUsageGroup = ref('claude') // claude | openai | gemini
|
||||
|
||||
// 默认时间
|
||||
const defaultTime = ref([new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)])
|
||||
|
||||
// 计算属性
|
||||
const formattedUptime = computed(() => {
|
||||
const seconds = dashboardData.value.uptime
|
||||
@@ -155,22 +152,11 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
})
|
||||
|
||||
// 辅助函数:基于系统时区计算时间
|
||||
// function getDateInSystemTimezone(date = new Date()) {
|
||||
// const offset = dashboardData.value.systemTimezone || 8
|
||||
// // 将本地时间转换为UTC时间,然后加上系统时区偏移
|
||||
// const utcTime = date.getTime() + date.getTimezoneOffset() * 60000
|
||||
// return new Date(utcTime + offset * 3600000)
|
||||
// }
|
||||
|
||||
// 辅助函数:获取系统时区某一天的起止UTC时间
|
||||
// 输入:一个本地时间的日期对象(如用户选择的日期)
|
||||
// 输出:该日期在系统时区的0点/23:59对应的UTC时间
|
||||
function getSystemTimezoneDay(localDate, startOfDay = true) {
|
||||
// 固定使用UTC+8,因为后端系统时区是UTC+8
|
||||
// const systemTz = 8
|
||||
|
||||
// 获取本地日期的年月日(这是用户想要查看的日期)
|
||||
const year = localDate.getFullYear()
|
||||
const month = localDate.getMonth()
|
||||
const day = localDate.getDate()
|
||||
@@ -178,16 +164,46 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
if (startOfDay) {
|
||||
// 系统时区(UTC+8)的 YYYY-MM-DD 00:00:00
|
||||
// 对应的UTC时间是前一天的16:00
|
||||
// 例如:UTC+8的2025-07-29 00:00:00 = UTC的2025-07-28 16:00:00
|
||||
return new Date(Date.UTC(year, month, day - 1, 16, 0, 0, 0))
|
||||
} else {
|
||||
// 系统时区(UTC+8)的 YYYY-MM-DD 23:59:59
|
||||
// 对应的UTC时间是当天的15:59:59
|
||||
// 例如:UTC+8的2025-07-29 23:59:59 = UTC的2025-07-29 15:59:59
|
||||
return new Date(Date.UTC(year, month, day, 15, 59, 59, 999))
|
||||
}
|
||||
}
|
||||
|
||||
// 公共函数:根据预设计算时间范围
|
||||
function getPresetTimeRange(preset) {
|
||||
const now = new Date()
|
||||
switch (preset) {
|
||||
case 'today': {
|
||||
return { start: getSystemTimezoneDay(now, true), end: getSystemTimezoneDay(now, false) }
|
||||
}
|
||||
case 'last24h': {
|
||||
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: new Date(now) }
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
return {
|
||||
start: getSystemTimezoneDay(yesterday, true),
|
||||
end: getSystemTimezoneDay(yesterday, false)
|
||||
}
|
||||
}
|
||||
case 'dayBefore': {
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
return {
|
||||
start: getSystemTimezoneDay(dayBefore, true),
|
||||
end: getSystemTimezoneDay(dayBefore, false)
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return { start: new Date(now.getTime() - 24 * 60 * 60 * 1000), end: new Date(now) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const persistDatePreferences = (
|
||||
preset = dateFilter.value.preset,
|
||||
granularity = trendGranularity.value
|
||||
@@ -221,9 +237,9 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
}
|
||||
|
||||
const [dashboardResponse, todayCostsResponse, totalCostsResponse] = await Promise.all([
|
||||
getDashboard(),
|
||||
getUsageCosts(costsParams.today),
|
||||
getUsageCosts(costsParams.all)
|
||||
getDashboardApi(),
|
||||
getUsageCostsApi(costsParams.today),
|
||||
getUsageCostsApi(costsParams.all)
|
||||
])
|
||||
|
||||
if (dashboardResponse.success) {
|
||||
@@ -302,68 +318,25 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
let url = '/admin/usage-trend?'
|
||||
|
||||
if (granularity === 'hour') {
|
||||
// 小时粒度,计算时间范围
|
||||
url += `granularity=hour`
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 使用自定义时间范围 - 直接按系统时区字符串传递,避免额外时区偏移导致窗口错位
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else if (dateFilter.value.type === 'preset') {
|
||||
const { start, end } = getPresetTimeRange(dateFilter.value.preset)
|
||||
url += `&startDate=${encodeURIComponent(start.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(end.toISOString())}`
|
||||
} else {
|
||||
// 使用预设计算时间范围,与loadApiKeysTrend保持一致
|
||||
const now = new Date()
|
||||
let startTime, endTime
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
// 今日:使用系统时区的当日0点-23:59
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startTime = getSystemTimezoneDay(yesterday, true)
|
||||
endTime = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
// 前天:基于系统时区的前天
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startTime = getSystemTimezoneDay(dayBefore, true)
|
||||
endTime = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// 默认近24小时
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 默认使用days参数计算
|
||||
startTime = new Date(now.getTime() - days * 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
url += `&startDate=${encodeURIComponent(new Date(now.getTime() - days * 24 * 60 * 60 * 1000).toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(now.toISOString())}`
|
||||
}
|
||||
} else {
|
||||
// 天粒度,传递天数
|
||||
url += `granularity=day&days=${days}`
|
||||
}
|
||||
|
||||
const response = await getUsageStats(url)
|
||||
const response = await getUsageStatsApi(url)
|
||||
if (response.success) {
|
||||
trendData.value = response.data
|
||||
}
|
||||
@@ -377,78 +350,37 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
try {
|
||||
let url = `/admin/model-stats?period=${period}`
|
||||
|
||||
// 如果是自定义时间范围或小时粒度,传递具体的时间参数
|
||||
if (dateFilter.value.type === 'custom' || currentGranularity === 'hour') {
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 按系统时区字符串直传,避免额外时区转换
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else if (currentGranularity === 'hour' && dateFilter.value.type === 'preset') {
|
||||
// 小时粒度的预设时间范围
|
||||
const now = new Date()
|
||||
let startTime, endTime
|
||||
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startTime = getSystemTimezoneDay(yesterday, true)
|
||||
endTime = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startTime = getSystemTimezoneDay(dayBefore, true)
|
||||
endTime = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
const { start, end } = getPresetTimeRange(dateFilter.value.preset)
|
||||
url += `&startDate=${encodeURIComponent(start.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(end.toISOString())}`
|
||||
}
|
||||
} else if (dateFilter.value.type === 'preset' && currentGranularity === 'day') {
|
||||
// 天粒度的预设时间范围,需要传递startDate和endDate参数
|
||||
const now = new Date()
|
||||
let startDate, endDate
|
||||
|
||||
const option = dateFilter.value.presetOptions.find(
|
||||
(opt) => opt.value === dateFilter.value.preset
|
||||
)
|
||||
if (option) {
|
||||
let startDate, endDate
|
||||
if (dateFilter.value.preset === 'today') {
|
||||
// 今日:从系统时区的今天0点到23:59
|
||||
startDate = getSystemTimezoneDay(now, true)
|
||||
endDate = getSystemTimezoneDay(now, false)
|
||||
} else {
|
||||
// 7天或30天:从N天前的0点到今天的23:59
|
||||
const daysAgo = new Date()
|
||||
daysAgo.setDate(daysAgo.getDate() - (option.days - 1))
|
||||
startDate = getSystemTimezoneDay(daysAgo, true)
|
||||
endDate = getSystemTimezoneDay(now, false)
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startDate.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endDate.toISOString())}`
|
||||
}
|
||||
}
|
||||
|
||||
const response = await getUsageStats(url)
|
||||
const response = await getUsageStatsApi(url)
|
||||
if (response.success) {
|
||||
dashboardModelStats.value = response.data
|
||||
}
|
||||
@@ -464,64 +396,21 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
let days = 7
|
||||
|
||||
if (currentGranularity === 'hour') {
|
||||
// 小时粒度,计算时间范围
|
||||
url += `granularity=hour`
|
||||
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
// 使用自定义时间范围 - 按系统时区字符串直传
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else if (dateFilter.value.type === 'preset') {
|
||||
const { start, end } = getPresetTimeRange(dateFilter.value.preset)
|
||||
url += `&startDate=${encodeURIComponent(start.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(end.toISOString())}`
|
||||
} else {
|
||||
// 使用预设计算时间范围,与setDateFilterPreset保持一致
|
||||
const now = new Date()
|
||||
let startTime, endTime
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
// 近24小时:从当前时间往前推24小时
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
// 昨天:基于系统时区的昨天
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startTime = getSystemTimezoneDay(yesterday, true)
|
||||
endTime = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
// 前天:基于系统时区的前天
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startTime = getSystemTimezoneDay(dayBefore, true)
|
||||
endTime = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
// 默认近24小时
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 默认近24小时
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
url += `&startDate=${encodeURIComponent(new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(now.toISOString())}`
|
||||
}
|
||||
} else {
|
||||
// 天粒度,传递天数
|
||||
days =
|
||||
dateFilter.value.type === 'preset'
|
||||
? dateFilter.value.preset === 'today'
|
||||
@@ -535,7 +424,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
url += `&metric=${metric}`
|
||||
|
||||
const response = await getUsageStats(url)
|
||||
const response = await getUsageStatsApi(url)
|
||||
if (response.success) {
|
||||
apiKeysTrendData.value = {
|
||||
data: response.data || [],
|
||||
@@ -560,49 +449,14 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
if (dateFilter.value.customRange && dateFilter.value.customRange.length === 2) {
|
||||
url += `&startDate=${encodeURIComponent(dateFilter.value.customRange[0])}`
|
||||
url += `&endDate=${encodeURIComponent(dateFilter.value.customRange[1])}`
|
||||
} else if (dateFilter.value.type === 'preset') {
|
||||
const { start, end } = getPresetTimeRange(dateFilter.value.preset)
|
||||
url += `&startDate=${encodeURIComponent(start.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(end.toISOString())}`
|
||||
} else {
|
||||
const now = new Date()
|
||||
let startTime
|
||||
let endTime
|
||||
|
||||
if (dateFilter.value.type === 'preset') {
|
||||
switch (dateFilter.value.preset) {
|
||||
case 'today': {
|
||||
startTime = getSystemTimezoneDay(now, true)
|
||||
endTime = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
endTime = new Date(now)
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startTime = getSystemTimezoneDay(yesterday, true)
|
||||
endTime = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startTime = getSystemTimezoneDay(dayBefore, true)
|
||||
endTime = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
endTime = now
|
||||
}
|
||||
|
||||
url += `&startDate=${encodeURIComponent(startTime.toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(endTime.toISOString())}`
|
||||
url += `&startDate=${encodeURIComponent(new Date(now.getTime() - 24 * 60 * 60 * 1000).toISOString())}`
|
||||
url += `&endDate=${encodeURIComponent(now.toISOString())}`
|
||||
}
|
||||
} else {
|
||||
days =
|
||||
@@ -618,7 +472,7 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
url += `&group=${group}`
|
||||
|
||||
const response = await getUsageStats(url)
|
||||
const response = await getUsageStatsApi(url)
|
||||
if (response.success) {
|
||||
accountUsageTrendData.value = {
|
||||
data: response.data || [],
|
||||
@@ -643,40 +497,12 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
|
||||
const option = dateFilter.value.presetOptions.find((opt) => opt.value === normalizedPreset)
|
||||
const now = new Date()
|
||||
let startDate
|
||||
let endDate
|
||||
let startDate, endDate
|
||||
|
||||
if (trendGranularity.value === 'hour') {
|
||||
switch (normalizedPreset) {
|
||||
case 'today': {
|
||||
startDate = getSystemTimezoneDay(now, true)
|
||||
endDate = getSystemTimezoneDay(now, false)
|
||||
break
|
||||
}
|
||||
case 'last24h': {
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
break
|
||||
}
|
||||
case 'yesterday': {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(yesterday.getDate() - 1)
|
||||
startDate = getSystemTimezoneDay(yesterday, true)
|
||||
endDate = getSystemTimezoneDay(yesterday, false)
|
||||
break
|
||||
}
|
||||
case 'dayBefore': {
|
||||
const dayBefore = new Date()
|
||||
dayBefore.setDate(dayBefore.getDate() - 2)
|
||||
startDate = getSystemTimezoneDay(dayBefore, true)
|
||||
endDate = getSystemTimezoneDay(dayBefore, false)
|
||||
break
|
||||
}
|
||||
default: {
|
||||
endDate = new Date(now)
|
||||
startDate = new Date(now.getTime() - 24 * 60 * 60 * 1000)
|
||||
}
|
||||
}
|
||||
const range = getPresetTimeRange(normalizedPreset)
|
||||
startDate = range.start
|
||||
endDate = range.end
|
||||
} else {
|
||||
startDate = new Date(now)
|
||||
endDate = new Date(now)
|
||||
@@ -891,7 +717,6 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
loading,
|
||||
dashboardData,
|
||||
costsData,
|
||||
modelStats,
|
||||
trendData,
|
||||
dashboardModelStats,
|
||||
apiKeysTrendData,
|
||||
@@ -900,7 +725,6 @@ export const useDashboardStore = defineStore('dashboard', () => {
|
||||
trendGranularity,
|
||||
apiKeysTrendMetric,
|
||||
accountUsageGroup,
|
||||
defaultTime,
|
||||
|
||||
// 计算属性
|
||||
formattedUptime,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { getOemSettings, updateOemSettings } from '@/utils/http_apis'
|
||||
|
||||
import { getOemSettingsApi, updateOemSettingsApi } from '@/utils/http_apis'
|
||||
|
||||
export const useSettingsStore = defineStore('settings', () => {
|
||||
// 状态
|
||||
@@ -19,40 +20,24 @@ export const useSettingsStore = defineStore('settings', () => {
|
||||
// Actions
|
||||
const loadOemSettings = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await getOemSettings()
|
||||
|
||||
if (result && result.success) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
applyOemSettings()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to load OEM settings:', error)
|
||||
throw error
|
||||
} finally {
|
||||
loading.value = false
|
||||
const res = await getOemSettingsApi()
|
||||
if (res.success) {
|
||||
oemSettings.value = { ...oemSettings.value, ...res.data }
|
||||
applyOemSettings()
|
||||
}
|
||||
loading.value = false
|
||||
return res
|
||||
}
|
||||
|
||||
const saveOemSettings = async (settings) => {
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await updateOemSettings(settings)
|
||||
|
||||
if (result && result.success) {
|
||||
oemSettings.value = { ...oemSettings.value, ...result.data }
|
||||
applyOemSettings()
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Failed to save OEM settings:', error)
|
||||
throw error
|
||||
} finally {
|
||||
saving.value = false
|
||||
const res = await updateOemSettingsApi(settings)
|
||||
if (res.success) {
|
||||
oemSettings.value = { ...oemSettings.value, ...res.data }
|
||||
applyOemSettings()
|
||||
}
|
||||
saving.value = false
|
||||
return res
|
||||
}
|
||||
|
||||
const resetOemSettings = async () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import axios from 'axios'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { API_PREFIX } from '@/utils/http_apis'
|
||||
import { APP_CONFIG } from '@/utils/tools'
|
||||
|
||||
const API_BASE = `${API_PREFIX}/users`
|
||||
const API_BASE = `${APP_CONFIG.apiPrefix}/users`
|
||||
|
||||
export const useUserStore = defineStore('user', {
|
||||
state: () => ({
|
||||
|
||||
@@ -1,262 +1,336 @@
|
||||
import axios from 'axios'
|
||||
import { APP_CONFIG, getLoginUrl } from '@/config/app'
|
||||
|
||||
export const API_PREFIX = APP_CONFIG.apiPrefix
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: APP_CONFIG.apiPrefix,
|
||||
timeout: 30000,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (token) config.headers['Authorization'] = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const path = window.location.pathname + window.location.hash
|
||||
if (!path.includes('/login') && !path.endsWith('/')) {
|
||||
localStorage.removeItem('authToken')
|
||||
window.location.href = getLoginUrl()
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 通用请求函数 - 只会 resolve,调用方无需 try-catch
|
||||
const request = async (config) => {
|
||||
try {
|
||||
return await axiosInstance(config)
|
||||
} catch (error) {
|
||||
console.error('Request failed:', error)
|
||||
const data = error.response?.data
|
||||
if (data && typeof data.success !== 'undefined') return data
|
||||
const status = error.response?.status
|
||||
const messages = {
|
||||
401: '未授权,请重新登录',
|
||||
403: '无权限访问',
|
||||
404: '请求的资源不存在',
|
||||
500: '服务器内部错误'
|
||||
}
|
||||
return { success: false, message: messages[status] || error.message || '请求失败' }
|
||||
}
|
||||
}
|
||||
|
||||
const get = (url, config) => request({ method: 'get', url, ...config })
|
||||
const post = (url, data, config) => request({ method: 'post', url, data, ...config })
|
||||
const put = (url, data, config) => request({ method: 'put', url, data, ...config })
|
||||
const patch = (url, data, config) => request({ method: 'patch', url, data, ...config })
|
||||
const del = (url, config) => request({ method: 'delete', url, ...config })
|
||||
import request from '@/utils/request'
|
||||
|
||||
// 模型
|
||||
export const getModels = () => get('/apiStats/models')
|
||||
export const getModelsByService = (service) => get('/apiStats/models', { params: { service } })
|
||||
|
||||
// API Key 测试
|
||||
export const testClaudeApiKey = (data) => post('/apiStats/api-key/test', data)
|
||||
export const testGeminiApiKey = (data) => post('/apiStats/api-key/test-gemini', data)
|
||||
export const testOpenAIApiKey = (data) => post('/apiStats/api-key/test-openai', data)
|
||||
export const getModelsApi = () => request({ url: '/apiStats/models', method: 'GET' })
|
||||
|
||||
// API Stats
|
||||
export const getKeyId = (apiKey) => post('/apiStats/api/get-key-id', { apiKey })
|
||||
export const getUserStats = (apiId) => post('/apiStats/api/user-stats', { apiId })
|
||||
export const getUserModelStats = (apiId, period = 'daily') =>
|
||||
post('/apiStats/api/user-model-stats', { apiId, period })
|
||||
export const getBatchStats = (apiIds) => post('/apiStats/api/batch-stats', { apiIds })
|
||||
export const getBatchModelStats = (apiIds, period = 'daily') =>
|
||||
post('/apiStats/api/batch-model-stats', { apiIds, period })
|
||||
export const getKeyIdApi = (apiKey) =>
|
||||
request({ url: '/apiStats/api/get-key-id', method: 'POST', data: { apiKey } })
|
||||
export const getUserStatsApi = (apiId) =>
|
||||
request({ url: '/apiStats/api/user-stats', method: 'POST', data: { apiId } })
|
||||
export const getUserModelStatsApi = (apiId, period = 'daily') =>
|
||||
request({ url: '/apiStats/api/user-model-stats', method: 'POST', data: { apiId, period } })
|
||||
export const getBatchStatsApi = (apiIds) =>
|
||||
request({ url: '/apiStats/api/batch-stats', method: 'POST', data: { apiIds } })
|
||||
export const getBatchModelStatsApi = (apiIds, period = 'daily') =>
|
||||
request({ url: '/apiStats/api/batch-model-stats', method: 'POST', data: { apiIds, period } })
|
||||
|
||||
// 认证
|
||||
export const login = (credentials) => post('/web/auth/login', credentials)
|
||||
export const getAuthUser = () => get('/web/auth/user')
|
||||
export const loginApi = (data) => request({ url: '/web/auth/login', method: 'POST', data })
|
||||
export const getAuthUserApi = () => request({ url: '/web/auth/user', method: 'GET' })
|
||||
export const changePasswordApi = (data) =>
|
||||
request({ url: '/web/auth/change-password', method: 'POST', data })
|
||||
|
||||
// OEM 设置
|
||||
export const getOemSettings = () => get('/admin/oem-settings')
|
||||
export const updateOemSettings = (data) => put('/admin/oem-settings', data)
|
||||
export const getOemSettingsApi = () => request({ url: '/admin/oem-settings', method: 'GET' })
|
||||
export const updateOemSettingsApi = (data) =>
|
||||
request({ url: '/admin/oem-settings', method: 'PUT', data })
|
||||
|
||||
// 服务倍率配置(公开接口)
|
||||
export const getServiceRates = () => get('/apiStats/service-rates')
|
||||
export const getServiceRatesApi = () => request({ url: '/apiStats/service-rates', method: 'GET' })
|
||||
|
||||
// 额度卡兑换(公开接口)
|
||||
export const redeemCardByApiIdApi = (data) =>
|
||||
request({ url: '/apiStats/api/redeem-card', method: 'POST', data })
|
||||
export const getRedemptionHistoryByApiIdApi = (apiId, params = {}) =>
|
||||
request({ url: '/apiStats/api/redemption-history', method: 'GET', params: { apiId, ...params } })
|
||||
|
||||
// 仪表板
|
||||
export const getDashboard = () => get('/admin/dashboard')
|
||||
export const getUsageCosts = (period) => get(`/admin/usage-costs?period=${period}`)
|
||||
export const getUsageStats = (url) => get(url)
|
||||
export const getDashboardApi = () => request({ url: '/admin/dashboard', method: 'GET' })
|
||||
export const getUsageCostsApi = (period) =>
|
||||
request({ url: `/admin/usage-costs?period=${period}`, method: 'GET' })
|
||||
export const getUsageStatsApi = (url) => request({ url, method: 'GET' })
|
||||
|
||||
// 客户端
|
||||
export const getSupportedClients = () => get('/admin/supported-clients')
|
||||
export const getSupportedClientsApi = () =>
|
||||
request({ url: '/admin/supported-clients', method: 'GET' })
|
||||
|
||||
// API Keys
|
||||
export const getApiKeys = () => get('/admin/api-keys')
|
||||
export const getApiKeysWithParams = (params) => get(`/admin/api-keys?${params}`)
|
||||
export const createApiKey = (data) => post('/admin/api-keys', data)
|
||||
export const updateApiKey = (id, data) => put(`/admin/api-keys/${id}`, data)
|
||||
export const toggleApiKey = (id) => put(`/admin/api-keys/${id}/toggle`)
|
||||
export const deleteApiKey = (id) => del(`/admin/api-keys/${id}`)
|
||||
export const getApiKeyStats = (id, params) => get(`/admin/api-keys/${id}/stats`, { params })
|
||||
export const getApiKeyTags = () => get('/admin/api-keys/tags')
|
||||
export const getApiKeyUsedModels = () => get('/admin/api-keys/used-models')
|
||||
export const getApiKeysBatchStats = (data) => post('/admin/api-keys/batch-stats', data)
|
||||
export const getApiKeysBatchLastUsage = (data) => post('/admin/api-keys/batch-last-usage', data)
|
||||
export const getDeletedApiKeys = () => get('/admin/api-keys/deleted')
|
||||
export const getApiKeysCostSortStatus = () => get('/admin/api-keys/cost-sort-status')
|
||||
export const restoreApiKey = (id) => post(`/admin/api-keys/${id}/restore`)
|
||||
export const permanentDeleteApiKey = (id) => del(`/admin/api-keys/${id}/permanent`)
|
||||
export const clearAllDeletedApiKeys = () => del('/admin/api-keys/deleted/clear-all')
|
||||
export const batchDeleteApiKeys = (data) => del('/admin/api-keys/batch', { data })
|
||||
export const updateApiKeyExpiration = (id, data) =>
|
||||
request({ method: 'patch', url: `/admin/api-keys/${id}/expiration`, data })
|
||||
export const getApiKeysApi = () => request({ url: '/admin/api-keys', method: 'GET' })
|
||||
export const getApiKeysWithParamsApi = (params) =>
|
||||
request({ url: `/admin/api-keys?${params}`, method: 'GET' })
|
||||
export const createApiKeyApi = (data) => request({ url: '/admin/api-keys', method: 'POST', data })
|
||||
export const updateApiKeyApi = (id, data) =>
|
||||
request({ url: `/admin/api-keys/${id}`, method: 'PUT', data })
|
||||
export const toggleApiKeyApi = (id) =>
|
||||
request({ url: `/admin/api-keys/${id}/toggle`, method: 'PUT' })
|
||||
export const deleteApiKeyApi = (id) => request({ url: `/admin/api-keys/${id}`, method: 'DELETE' })
|
||||
export const getApiKeyStatsApi = (id, params) =>
|
||||
request({ url: `/admin/api-keys/${id}/stats`, method: 'GET', params })
|
||||
export const getApiKeyModelStatsApi = (id, params) =>
|
||||
request({ url: `/admin/api-keys/${id}/model-stats`, method: 'GET', params })
|
||||
export const getApiKeyTagsApi = () => request({ url: '/admin/api-keys/tags', method: 'GET' })
|
||||
export const getApiKeyTagsDetailsApi = () =>
|
||||
request({ url: '/admin/api-keys/tags/details', method: 'GET' })
|
||||
export const createApiKeyTagApi = (name) =>
|
||||
request({ url: '/admin/api-keys/tags', method: 'POST', data: { name } })
|
||||
export const deleteApiKeyTagApi = (tagName) =>
|
||||
request({ url: `/admin/api-keys/tags/${encodeURIComponent(tagName)}`, method: 'DELETE' })
|
||||
export const renameApiKeyTagApi = (tagName, newName) =>
|
||||
request({
|
||||
url: `/admin/api-keys/tags/${encodeURIComponent(tagName)}`,
|
||||
method: 'PUT',
|
||||
data: { newName }
|
||||
})
|
||||
export const getApiKeyUsedModelsApi = () =>
|
||||
request({ url: '/admin/api-keys/used-models', method: 'GET' })
|
||||
export const getApiKeysBatchStatsApi = (data) =>
|
||||
request({ url: '/admin/api-keys/batch-stats', method: 'POST', data })
|
||||
export const getApiKeysBatchLastUsageApi = (data) =>
|
||||
request({ url: '/admin/api-keys/batch-last-usage', method: 'POST', data })
|
||||
export const getDeletedApiKeysApi = () => request({ url: '/admin/api-keys/deleted', method: 'GET' })
|
||||
export const getApiKeysCostSortStatusApi = () =>
|
||||
request({ url: '/admin/api-keys/cost-sort-status', method: 'GET' })
|
||||
export const restoreApiKeyApi = (id) =>
|
||||
request({ url: `/admin/api-keys/${id}/restore`, method: 'POST' })
|
||||
export const permanentDeleteApiKeyApi = (id) =>
|
||||
request({ url: `/admin/api-keys/${id}/permanent`, method: 'DELETE' })
|
||||
export const clearAllDeletedApiKeysApi = () =>
|
||||
request({ url: '/admin/api-keys/deleted/clear-all', method: 'DELETE' })
|
||||
export const batchDeleteApiKeysApi = (data) =>
|
||||
request({ url: '/admin/api-keys/batch', method: 'DELETE', data })
|
||||
export const updateApiKeyExpirationApi = (id, data) =>
|
||||
request({ url: `/admin/api-keys/${id}/expiration`, method: 'PATCH', data })
|
||||
export const batchCreateApiKeysApi = (data) =>
|
||||
request({ url: '/admin/api-keys/batch', method: 'POST', data })
|
||||
export const batchUpdateApiKeysApi = (data) =>
|
||||
request({ url: '/admin/api-keys/batch', method: 'PUT', data })
|
||||
export const getApiKeyUsageRecordsApi = (id, params) =>
|
||||
request({ url: `/admin/api-keys/${id}/usage-records`, method: 'GET', params })
|
||||
|
||||
// Claude 账户
|
||||
export const getClaudeAccounts = () => get('/admin/claude-accounts')
|
||||
export const createClaudeAccount = (data) => post('/admin/claude-accounts', data)
|
||||
export const updateClaudeAccount = (id, data) => put(`/admin/claude-accounts/${id}`, data)
|
||||
export const deleteClaudeAccount = (id) => del(`/admin/claude-accounts/${id}`)
|
||||
export const refreshClaudeAccount = (id) => post(`/admin/claude-accounts/${id}/refresh`)
|
||||
export const generateClaudeAuthUrl = (data) =>
|
||||
post('/admin/claude-accounts/generate-auth-url', data)
|
||||
export const exchangeClaudeCode = (data) => post('/admin/claude-accounts/exchange-code', data)
|
||||
export const generateClaudeSetupTokenUrl = (data) =>
|
||||
post('/admin/claude-accounts/generate-setup-token-url', data)
|
||||
export const exchangeClaudeSetupToken = (data) =>
|
||||
post('/admin/claude-accounts/exchange-setup-token', data)
|
||||
export const claudeOAuthWithCookie = (data) =>
|
||||
post('/admin/claude-accounts/oauth-with-cookie', data)
|
||||
export const claudeSetupTokenWithCookie = (data) =>
|
||||
post('/admin/claude-accounts/setup-token-with-cookie', data)
|
||||
export const generateClaudeWorkosAuthUrl = (data) =>
|
||||
post('/admin/claude-accounts/generate-workos-auth-url', data)
|
||||
export const getClaudeAccountsApi = () => request({ url: '/admin/claude-accounts', method: 'GET' })
|
||||
export const createClaudeAccountApi = (data) =>
|
||||
request({ url: '/admin/claude-accounts', method: 'POST', data })
|
||||
export const updateClaudeAccountApi = (id, data) =>
|
||||
request({ url: `/admin/claude-accounts/${id}`, method: 'PUT', data })
|
||||
export const refreshClaudeAccountApi = (id) =>
|
||||
request({ url: `/admin/claude-accounts/${id}/refresh`, method: 'POST' })
|
||||
export const generateClaudeAuthUrlApi = (data) =>
|
||||
request({ url: '/admin/claude-accounts/generate-auth-url', method: 'POST', data })
|
||||
export const exchangeClaudeCodeApi = (data) =>
|
||||
request({ url: '/admin/claude-accounts/exchange-code', method: 'POST', data })
|
||||
export const generateClaudeSetupTokenUrlApi = (data) =>
|
||||
request({ url: '/admin/claude-accounts/generate-setup-token-url', method: 'POST', data })
|
||||
export const exchangeClaudeSetupTokenApi = (data) =>
|
||||
request({ url: '/admin/claude-accounts/exchange-setup-token', method: 'POST', data })
|
||||
export const claudeOAuthWithCookieApi = (data) =>
|
||||
request({ url: '/admin/claude-accounts/oauth-with-cookie', method: 'POST', data })
|
||||
export const claudeSetupTokenWithCookieApi = (data) =>
|
||||
request({ url: '/admin/claude-accounts/setup-token-with-cookie', method: 'POST', data })
|
||||
|
||||
// Claude Console 账户
|
||||
export const getClaudeConsoleAccounts = () => get('/admin/claude-console-accounts')
|
||||
export const createClaudeConsoleAccount = (data) => post('/admin/claude-console-accounts', data)
|
||||
export const updateClaudeConsoleAccount = (id, data) =>
|
||||
put(`/admin/claude-console-accounts/${id}`, data)
|
||||
export const deleteClaudeConsoleAccount = (id) => del(`/admin/claude-console-accounts/${id}`)
|
||||
export const getClaudeConsoleAccountsApi = () =>
|
||||
request({ url: '/admin/claude-console-accounts', method: 'GET' })
|
||||
export const createClaudeConsoleAccountApi = (data) =>
|
||||
request({ url: '/admin/claude-console-accounts', method: 'POST', data })
|
||||
export const updateClaudeConsoleAccountApi = (id, data) =>
|
||||
request({ url: `/admin/claude-console-accounts/${id}`, method: 'PUT', data })
|
||||
|
||||
// Bedrock 账户
|
||||
export const getBedrockAccounts = () => get('/admin/bedrock-accounts')
|
||||
export const createBedrockAccount = (data) => post('/admin/bedrock-accounts', data)
|
||||
export const updateBedrockAccount = (id, data) => put(`/admin/bedrock-accounts/${id}`, data)
|
||||
export const deleteBedrockAccount = (id) => del(`/admin/bedrock-accounts/${id}`)
|
||||
export const getBedrockAccountsApi = () =>
|
||||
request({ url: '/admin/bedrock-accounts', method: 'GET' })
|
||||
export const createBedrockAccountApi = (data) =>
|
||||
request({ url: '/admin/bedrock-accounts', method: 'POST', data })
|
||||
export const updateBedrockAccountApi = (id, data) =>
|
||||
request({ url: `/admin/bedrock-accounts/${id}`, method: 'PUT', data })
|
||||
|
||||
// Gemini 账户
|
||||
export const getGeminiAccounts = () => get('/admin/gemini-accounts')
|
||||
export const createGeminiAccount = (data) => post('/admin/gemini-accounts', data)
|
||||
export const updateGeminiAccount = (id, data) => put(`/admin/gemini-accounts/${id}`, data)
|
||||
export const deleteGeminiAccount = (id) => del(`/admin/gemini-accounts/${id}`)
|
||||
export const generateGeminiAuthUrl = (data) =>
|
||||
post('/admin/gemini-accounts/generate-auth-url', data)
|
||||
export const exchangeGeminiCode = (data) => post('/admin/gemini-accounts/exchange-code', data)
|
||||
export const getGeminiAccountsApi = () => request({ url: '/admin/gemini-accounts', method: 'GET' })
|
||||
export const createGeminiAccountApi = (data) =>
|
||||
request({ url: '/admin/gemini-accounts', method: 'POST', data })
|
||||
export const updateGeminiAccountApi = (id, data) =>
|
||||
request({ url: `/admin/gemini-accounts/${id}`, method: 'PUT', data })
|
||||
export const generateGeminiAuthUrlApi = (data) =>
|
||||
request({ url: '/admin/gemini-accounts/generate-auth-url', method: 'POST', data })
|
||||
export const exchangeGeminiCodeApi = (data) =>
|
||||
request({ url: '/admin/gemini-accounts/exchange-code', method: 'POST', data })
|
||||
|
||||
// Gemini API 账户
|
||||
export const createGeminiApiAccount = (data) => post('/admin/gemini-api-accounts', data)
|
||||
export const updateGeminiApiAccount = (id, data) => put(`/admin/gemini-api-accounts/${id}`, data)
|
||||
export const getGeminiApiAccountsApi = () =>
|
||||
request({ url: '/admin/gemini-api-accounts', method: 'GET' })
|
||||
export const createGeminiApiAccountApi = (data) =>
|
||||
request({ url: '/admin/gemini-api-accounts', method: 'POST', data })
|
||||
export const updateGeminiApiAccountApi = (id, data) =>
|
||||
request({ url: `/admin/gemini-api-accounts/${id}`, method: 'PUT', data })
|
||||
|
||||
// OpenAI 账户
|
||||
export const getOpenAIAccounts = () => get('/admin/openai-accounts')
|
||||
export const createOpenAIAccount = (data) => post('/admin/openai-accounts', data)
|
||||
export const updateOpenAIAccount = (id, data) => put(`/admin/openai-accounts/${id}`, data)
|
||||
export const deleteOpenAIAccount = (id) => del(`/admin/openai-accounts/${id}`)
|
||||
export const generateOpenAIAuthUrl = (data) =>
|
||||
post('/admin/openai-accounts/generate-auth-url', data)
|
||||
export const exchangeOpenAICode = (data) => post('/admin/openai-accounts/exchange-code', data)
|
||||
export const getOpenAIAccountsApi = () => request({ url: '/admin/openai-accounts', method: 'GET' })
|
||||
export const createOpenAIAccountApi = (data) =>
|
||||
request({ url: '/admin/openai-accounts', method: 'POST', data })
|
||||
export const updateOpenAIAccountApi = (id, data) =>
|
||||
request({ url: `/admin/openai-accounts/${id}`, method: 'PUT', data })
|
||||
export const generateOpenAIAuthUrlApi = (data) =>
|
||||
request({ url: '/admin/openai-accounts/generate-auth-url', method: 'POST', data })
|
||||
export const exchangeOpenAICodeApi = (data) =>
|
||||
request({ url: '/admin/openai-accounts/exchange-code', method: 'POST', data })
|
||||
|
||||
// OpenAI Responses 账户
|
||||
export const getOpenAIResponsesAccounts = () => get('/admin/openai-responses-accounts')
|
||||
export const createOpenAIResponsesAccount = (data) => post('/admin/openai-responses-accounts', data)
|
||||
export const updateOpenAIResponsesAccount = (id, data) =>
|
||||
put(`/admin/openai-responses-accounts/${id}`, data)
|
||||
export const deleteOpenAIResponsesAccount = (id) => del(`/admin/openai-responses-accounts/${id}`)
|
||||
export const getOpenAIResponsesAccountsApi = () =>
|
||||
request({ url: '/admin/openai-responses-accounts', method: 'GET' })
|
||||
export const createOpenAIResponsesAccountApi = (data) =>
|
||||
request({ url: '/admin/openai-responses-accounts', method: 'POST', data })
|
||||
export const updateOpenAIResponsesAccountApi = (id, data) =>
|
||||
request({ url: `/admin/openai-responses-accounts/${id}`, method: 'PUT', data })
|
||||
|
||||
// Azure OpenAI 账户
|
||||
export const getAzureOpenAIAccounts = () => get('/admin/azure-openai-accounts')
|
||||
export const createAzureOpenAIAccount = (data) => post('/admin/azure-openai-accounts', data)
|
||||
export const updateAzureOpenAIAccount = (id, data) =>
|
||||
put(`/admin/azure-openai-accounts/${id}`, data)
|
||||
export const deleteAzureOpenAIAccount = (id) => del(`/admin/azure-openai-accounts/${id}`)
|
||||
export const getAzureOpenAIAccountsApi = () =>
|
||||
request({ url: '/admin/azure-openai-accounts', method: 'GET' })
|
||||
export const createAzureOpenAIAccountApi = (data) =>
|
||||
request({ url: '/admin/azure-openai-accounts', method: 'POST', data })
|
||||
export const updateAzureOpenAIAccountApi = (id, data) =>
|
||||
request({ url: `/admin/azure-openai-accounts/${id}`, method: 'PUT', data })
|
||||
|
||||
// Droid 账户
|
||||
export const getDroidAccounts = () => get('/admin/droid-accounts')
|
||||
export const createDroidAccount = (data) => post('/admin/droid-accounts', data)
|
||||
export const updateDroidAccount = (id, data) => put(`/admin/droid-accounts/${id}`, data)
|
||||
export const deleteDroidAccount = (id) => del(`/admin/droid-accounts/${id}`)
|
||||
export const generateDroidAuthUrl = (data) => post('/admin/droid-accounts/generate-auth-url', data)
|
||||
export const exchangeDroidCode = (data) => post('/admin/droid-accounts/exchange-code', data)
|
||||
export const getDroidAccountsApi = () => request({ url: '/admin/droid-accounts', method: 'GET' })
|
||||
export const createDroidAccountApi = (data) =>
|
||||
request({ url: '/admin/droid-accounts', method: 'POST', data })
|
||||
export const updateDroidAccountApi = (id, data) =>
|
||||
request({ url: `/admin/droid-accounts/${id}`, method: 'PUT', data })
|
||||
export const generateDroidAuthUrlApi = (data) =>
|
||||
request({ url: '/admin/droid-accounts/generate-auth-url', method: 'POST', data })
|
||||
export const exchangeDroidCodeApi = (data) =>
|
||||
request({ url: '/admin/droid-accounts/exchange-code', method: 'POST', data })
|
||||
export const getDroidAccountByIdApi = (id) =>
|
||||
request({ url: `/admin/droid-accounts/${id}`, method: 'GET' })
|
||||
|
||||
// CCR 账户
|
||||
export const getCcrAccounts = () => get('/admin/ccr-accounts')
|
||||
export const createCcrAccount = (data) => post('/admin/ccr-accounts', data)
|
||||
export const updateCcrAccount = (id, data) => put(`/admin/ccr-accounts/${id}`, data)
|
||||
export const deleteCcrAccount = (id) => del(`/admin/ccr-accounts/${id}`)
|
||||
|
||||
// Gemini API 账户
|
||||
export const getGeminiApiAccounts = () => get('/admin/gemini-api-accounts')
|
||||
export const getCcrAccountsApi = () => request({ url: '/admin/ccr-accounts', method: 'GET' })
|
||||
export const createCcrAccountApi = (data) =>
|
||||
request({ url: '/admin/ccr-accounts', method: 'POST', data })
|
||||
export const updateCcrAccountApi = (id, data) =>
|
||||
request({ url: `/admin/ccr-accounts/${id}`, method: 'PUT', data })
|
||||
|
||||
// 账户通用操作
|
||||
export const toggleAccountStatus = (endpoint) => put(endpoint)
|
||||
export const deleteAccountByEndpoint = (endpoint) => del(endpoint)
|
||||
export const testAccountByEndpoint = (endpoint) => post(endpoint)
|
||||
export const updateAccountByEndpoint = (endpoint, data) => put(endpoint, data)
|
||||
export const toggleAccountStatusApi = (endpoint) => request({ url: endpoint, method: 'PUT' })
|
||||
export const deleteAccountByEndpointApi = (endpoint) => request({ url: endpoint, method: 'DELETE' })
|
||||
export const testAccountByEndpointApi = (endpoint) => request({ url: endpoint, method: 'POST' })
|
||||
export const updateAccountByEndpointApi = (endpoint, data) =>
|
||||
request({ url: endpoint, method: 'PUT', data })
|
||||
|
||||
// 账户使用统计
|
||||
export const getClaudeAccountsUsage = () => get('/admin/claude-accounts/usage')
|
||||
export const getAccountsBindingCounts = () => get('/admin/accounts/binding-counts')
|
||||
export const getAccountUsageHistory = (id, platform, days = 30) =>
|
||||
get(`/admin/accounts/${id}/usage-history?platform=${platform}&days=${days}`)
|
||||
export const getClaudeAccountsUsageApi = () =>
|
||||
request({ url: '/admin/claude-accounts/usage', method: 'GET' })
|
||||
export const getAccountsBindingCountsApi = () =>
|
||||
request({ url: '/admin/accounts/binding-counts', method: 'GET' })
|
||||
export const getAccountUsageHistoryApi = (id, platform, days = 30) =>
|
||||
request({
|
||||
url: `/admin/accounts/${id}/usage-history?platform=${platform}&days=${days}`,
|
||||
method: 'GET'
|
||||
})
|
||||
export const getClaudeConsoleAccountUsageApi = (id) =>
|
||||
request({ url: `/admin/claude-console-accounts/${id}/usage`, method: 'GET' })
|
||||
export const getAccountUsageRecordsByIdApi = (id, params) =>
|
||||
request({ url: `/admin/accounts/${id}/usage-records`, method: 'GET', params })
|
||||
|
||||
// 账户组
|
||||
export const getAccountGroups = () => get('/admin/account-groups')
|
||||
export const createAccountGroup = (data) => post('/admin/account-groups', data)
|
||||
export const updateAccountGroup = (id, data) => put(`/admin/account-groups/${id}`, data)
|
||||
export const deleteAccountGroup = (id) => del(`/admin/account-groups/${id}`)
|
||||
export const getAccountGroupsApi = () => request({ url: '/admin/account-groups', method: 'GET' })
|
||||
export const createAccountGroupApi = (data) =>
|
||||
request({ url: '/admin/account-groups', method: 'POST', data })
|
||||
export const updateAccountGroupApi = (id, data) =>
|
||||
request({ url: `/admin/account-groups/${id}`, method: 'PUT', data })
|
||||
export const deleteAccountGroupApi = (id) =>
|
||||
request({ url: `/admin/account-groups/${id}`, method: 'DELETE' })
|
||||
export const getAccountGroupMembersApi = (id) =>
|
||||
request({ url: `/admin/account-groups/${id}/members`, method: 'GET' })
|
||||
|
||||
// 用户管理
|
||||
export const getUsers = () => get('/admin/users')
|
||||
export const createUser = (data) => post('/admin/users', data)
|
||||
export const updateUser = (id, data) => put(`/admin/users/${id}`, data)
|
||||
export const deleteUser = (id) => del(`/admin/users/${id}`)
|
||||
export const updateUserRole = (id, data) => put(`/admin/users/${id}/role`, data)
|
||||
export const getUserUsageStats = (id, params) => get(`/admin/users/${id}/usage-stats`, { params })
|
||||
|
||||
// 使用记录
|
||||
export const getApiKeyUsageRecords = (id, params) =>
|
||||
get(`/admin/api-keys/${id}/usage-records`, { params })
|
||||
export const getAccountUsageRecords = (type, id, params) =>
|
||||
get(`/admin/${type}-accounts/${id}/usage-records`, { params })
|
||||
|
||||
// 系统日志
|
||||
export const getSystemLogs = (params) => get('/admin/logs', { params })
|
||||
// 用户管理(管理员)
|
||||
export const getUsersApi = () => request({ url: '/admin/users', method: 'GET' })
|
||||
|
||||
// 配额卡片
|
||||
export const getQuotaCards = () => get('/admin/quota-cards')
|
||||
export const createQuotaCard = (data) => post('/admin/quota-cards', data)
|
||||
export const updateQuotaCard = (id, data) => put(`/admin/quota-cards/${id}`, data)
|
||||
export const deleteQuotaCard = (id) => del(`/admin/quota-cards/${id}`)
|
||||
export const redeemQuotaCard = (data) => post('/admin/quota-cards/redeem', data)
|
||||
export const createQuotaCardApi = (data) =>
|
||||
request({ url: '/admin/quota-cards', method: 'POST', data })
|
||||
export const deleteQuotaCardApi = (id) =>
|
||||
request({ url: `/admin/quota-cards/${id}`, method: 'DELETE' })
|
||||
export const getQuotaCardsWithParamsApi = (params) =>
|
||||
request({ url: '/admin/quota-cards', method: 'GET', params })
|
||||
export const getQuotaCardsStatsApi = () =>
|
||||
request({ url: '/admin/quota-cards/stats', method: 'GET' })
|
||||
export const getRedemptionsApi = () => request({ url: '/admin/redemptions', method: 'GET' })
|
||||
export const revokeRedemptionApi = (id, data) =>
|
||||
request({ url: `/admin/redemptions/${id}/revoke`, method: 'POST', data })
|
||||
export const getQuotaCardLimitsApi = () =>
|
||||
request({ url: '/admin/quota-cards/limits', method: 'GET' })
|
||||
export const updateQuotaCardLimitsApi = (data) =>
|
||||
request({ url: '/admin/quota-cards/limits', method: 'PUT', data })
|
||||
|
||||
// 账户测试
|
||||
export const testAccount = (type, id) => post(`/admin/${type}-accounts/${id}/test`)
|
||||
export const getAccountTestHistory = (type, id) => get(`/admin/${type}-accounts/${id}/test-history`)
|
||||
// 账户余额
|
||||
export const getAccountBalanceApi = (id, params) =>
|
||||
request({ url: `/admin/accounts/${id}/balance`, method: 'GET', params })
|
||||
export const refreshAccountBalanceApi = (id, data) =>
|
||||
request({ url: `/admin/accounts/${id}/balance/refresh`, method: 'POST', data })
|
||||
export const getBalanceSummaryApi = () =>
|
||||
request({ url: '/admin/accounts/balance/summary', method: 'GET' })
|
||||
export const getBalanceByPlatformApi = (platform, params) =>
|
||||
request({ url: `/admin/accounts/balance/platform/${platform}`, method: 'GET', params })
|
||||
|
||||
// 定时测试
|
||||
export const getScheduledTests = () => get('/admin/scheduled-tests')
|
||||
export const createScheduledTest = (data) => post('/admin/scheduled-tests', data)
|
||||
export const updateScheduledTest = (id, data) => put(`/admin/scheduled-tests/${id}`, data)
|
||||
export const deleteScheduledTest = (id) => del(`/admin/scheduled-tests/${id}`)
|
||||
// 账户余额脚本
|
||||
export const getAccountBalanceScriptApi = (id, platform) =>
|
||||
request({ url: `/admin/accounts/${id}/balance/script?platform=${platform}`, method: 'GET' })
|
||||
export const updateAccountBalanceScriptApi = (id, platform, data) =>
|
||||
request({ url: `/admin/accounts/${id}/balance/script?platform=${platform}`, method: 'PUT', data })
|
||||
export const testAccountBalanceScriptApi = (id, platform, data) =>
|
||||
request({
|
||||
url: `/admin/accounts/${id}/balance/script/test?platform=${platform}`,
|
||||
method: 'POST',
|
||||
data
|
||||
})
|
||||
|
||||
// 统一 User-Agent
|
||||
export const getUnifiedUserAgent = () => get('/admin/unified-user-agent')
|
||||
// 默认余额脚本
|
||||
export const getDefaultBalanceScriptApi = () =>
|
||||
request({ url: '/admin/balance-scripts/default', method: 'GET' })
|
||||
export const updateDefaultBalanceScriptApi = (data) =>
|
||||
request({ url: '/admin/balance-scripts/default', method: 'PUT', data })
|
||||
export const testDefaultBalanceScriptApi = (data) =>
|
||||
request({ url: '/admin/balance-scripts/default/test', method: 'POST', data })
|
||||
|
||||
// 账户 API Keys 管理
|
||||
export const getAccountApiKeys = (type, id) => get(`/admin/${type}-accounts/${id}/api-keys`)
|
||||
export const updateAccountApiKeys = (type, id, data) =>
|
||||
put(`/admin/${type}-accounts/${id}/api-keys`, data)
|
||||
// 前台用户管理
|
||||
export const getFrontUsersApi = (params) => request({ url: '/users', method: 'GET', params })
|
||||
export const getFrontUsersStatsOverviewApi = () =>
|
||||
request({ url: '/users/stats/overview', method: 'GET' })
|
||||
export const getFrontUserByIdApi = (id) => request({ url: `/users/${id}`, method: 'GET' })
|
||||
export const updateFrontUserStatusApi = (id, data) =>
|
||||
request({ url: `/users/${id}/status`, method: 'PATCH', data })
|
||||
export const disableFrontUserKeysApi = (id) =>
|
||||
request({ url: `/users/${id}/disable-keys`, method: 'POST' })
|
||||
export const getFrontUserUsageStatsApi = (id, params) =>
|
||||
request({ url: `/users/${id}/usage-stats`, method: 'GET', params })
|
||||
export const updateFrontUserRoleApi = (id, data) =>
|
||||
request({ url: `/users/${id}/role`, method: 'PATCH', data })
|
||||
|
||||
export default { get, post, put, patch, del, request }
|
||||
export { get, post, put, patch, del }
|
||||
// Webhook 配置
|
||||
export const getWebhookConfigApi = (config) =>
|
||||
request({ url: '/admin/webhook/config', method: 'GET', ...config })
|
||||
export const updateWebhookConfigApi = (data, config) =>
|
||||
request({ url: '/admin/webhook/config', method: 'POST', data, ...config })
|
||||
export const createWebhookPlatformApi = (data, config) =>
|
||||
request({ url: '/admin/webhook/platforms', method: 'POST', data, ...config })
|
||||
export const deleteWebhookPlatformApi = (id, config) =>
|
||||
request({ url: `/admin/webhook/platforms/${id}`, method: 'DELETE', ...config })
|
||||
export const updateWebhookPlatformApi = (id, data, config) =>
|
||||
request({ url: `/admin/webhook/platforms/${id}`, method: 'PUT', data, ...config })
|
||||
export const toggleWebhookPlatformApi = (id, config) =>
|
||||
request({ url: `/admin/webhook/platforms/${id}/toggle`, method: 'POST', ...config })
|
||||
export const testWebhookApi = (data, config) =>
|
||||
request({ url: '/admin/webhook/test', method: 'POST', data, ...config })
|
||||
export const testWebhookNotificationApi = (config) =>
|
||||
request({ url: '/admin/webhook/test-notification', method: 'POST', ...config })
|
||||
|
||||
// Claude Relay 配置
|
||||
export const getClaudeRelayConfigApi = (config) =>
|
||||
request({ url: '/admin/claude-relay-config', method: 'GET', ...config })
|
||||
export const updateClaudeRelayConfigApi = (data, config) =>
|
||||
request({ url: '/admin/claude-relay-config', method: 'PUT', data, ...config })
|
||||
|
||||
// 服务倍率配置(管理端)
|
||||
export const getAdminServiceRatesApi = (config) =>
|
||||
request({ url: '/admin/service-rates', method: 'GET', ...config })
|
||||
export const updateAdminServiceRatesApi = (data, config) =>
|
||||
request({ url: '/admin/service-rates', method: 'PUT', data, ...config })
|
||||
|
||||
// 系统
|
||||
export const checkUpdatesApi = () => request({ url: '/admin/check-updates', method: 'GET' })
|
||||
export const getClaudeCodeVersionApi = () =>
|
||||
request({ url: '/admin/claude-code-version', method: 'GET' })
|
||||
export const clearClaudeCodeVersionApi = () =>
|
||||
request({ url: '/admin/claude-code-version/clear', method: 'POST' })
|
||||
|
||||
57
web/admin-spa/src/utils/request.js
Normal file
57
web/admin-spa/src/utils/request.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import axios from 'axios'
|
||||
|
||||
import { APP_CONFIG, getLoginUrl } from '@/utils/tools'
|
||||
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: APP_CONFIG.apiPrefix,
|
||||
timeout: 30000,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('authToken')
|
||||
if (token) config.headers['Authorization'] = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => response.data,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
const path = window.location.pathname + window.location.hash
|
||||
// api-stats 和 user-login 是公开页面,401 是业务错误不是认证错误
|
||||
const isPublicPage = path.includes('/api-stats') || path.includes('/user-login')
|
||||
if (!path.includes('/login') && !path.endsWith('/') && !isPublicPage) {
|
||||
localStorage.removeItem('authToken')
|
||||
window.location.href = getLoginUrl()
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
// 通用请求函数 - 只会 resolve,调用方无需 try-catch
|
||||
const request = async (config) => {
|
||||
try {
|
||||
return await axiosInstance(config)
|
||||
} catch (error) {
|
||||
console.error('Request failed:', error)
|
||||
const data = error.response?.data
|
||||
// 如果后端返回了数据,直接返回(可能是 { success, message } 或 { error, message } 格式)
|
||||
if (data) {
|
||||
if (typeof data.success !== 'undefined') return data
|
||||
// 处理 { error, message } 格式的响应
|
||||
if (data.error || data.message) return { success: false, message: data.message || data.error }
|
||||
}
|
||||
const status = error.response?.status
|
||||
const messages = {
|
||||
401: '未授权,请重新登录',
|
||||
403: '无权限访问',
|
||||
404: '请求的资源不存在',
|
||||
500: '服务器内部错误'
|
||||
}
|
||||
return { success: false, message: messages[status] || error.message || '请求失败' }
|
||||
}
|
||||
}
|
||||
|
||||
export default request
|
||||
@@ -1,3 +1,16 @@
|
||||
// App 配置
|
||||
export const APP_CONFIG = {
|
||||
basePath: import.meta.env.VITE_APP_BASE_URL || (import.meta.env.DEV ? '/admin/' : '/web/admin/'),
|
||||
apiPrefix: import.meta.env.DEV ? '/webapi' : ''
|
||||
}
|
||||
|
||||
export const getAppUrl = (path = '') => {
|
||||
if (path && !path.startsWith('/')) path = '/' + path
|
||||
return APP_CONFIG.basePath + (path.startsWith('#') ? path : '#' + path)
|
||||
}
|
||||
|
||||
export const getLoginUrl = () => getAppUrl('/login')
|
||||
|
||||
// Toast 通知管理
|
||||
let toastContainer = null
|
||||
let toastId = 0
|
||||
@@ -108,10 +121,12 @@ export const formatDate = (date, format = 'YYYY-MM-DD HH:mm:ss') => {
|
||||
// 相对时间格式化
|
||||
export const formatRelativeTime = (date) => {
|
||||
if (!date) return ''
|
||||
const diffMs = new Date() - new Date(date)
|
||||
const d = new Date(date)
|
||||
const diffMs = new Date() - d
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
if (diffDays >= 7) return d.toLocaleDateString('zh-CN')
|
||||
if (diffDays > 0) return `${diffDays}天前`
|
||||
if (diffHours > 0) return `${diffHours}小时前`
|
||||
if (diffMins > 0) return `${diffMins}分钟前`
|
||||
@@ -126,3 +141,24 @@ export const formatBytes = (bytes, decimals = 2) => {
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals < 0 ? 0 : decimals)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
// 日期时间格式化 (简化版)
|
||||
export const formatDateTime = (date) => {
|
||||
if (!date) return ''
|
||||
return new Date(date).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 金额格式化
|
||||
export const formatCost = (value) => {
|
||||
const num = Number(value || 0)
|
||||
if (num === 0) return '$0.00'
|
||||
if (num < 0.01) return `$${num.toFixed(6)}`
|
||||
return `$${num.toFixed(2)}`
|
||||
}
|
||||
|
||||
@@ -301,9 +301,8 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { formatNumber } from '@/utils/tools'
|
||||
import { getAccountUsageRecordsByIdApi } from '@/utils/http_apis'
|
||||
import { showToast, formatNumber, formatDate } from '@/utils/tools'
|
||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -368,11 +367,6 @@ const dateRangeHint = computed(() => {
|
||||
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatCost = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
@@ -437,9 +431,7 @@ const syncResponseState = (data) => {
|
||||
const fetchRecords = async (page = pagination.currentPage) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await httpApi.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||
params: buildParams(page)
|
||||
})
|
||||
const response = await getAccountUsageRecordsByIdApi(accountId.value, buildParams(page))
|
||||
syncResponseState(response.data || {})
|
||||
} catch (error) {
|
||||
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||
@@ -492,8 +484,9 @@ const exportCsv = async () => {
|
||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||
|
||||
while (page <= totalPages && page <= maxPages) {
|
||||
const response = await httpApi.get(`/admin/accounts/${accountId.value}/usage-records`, {
|
||||
params: { ...buildParams(page), pageSize: 200 }
|
||||
const response = await getAccountUsageRecordsByIdApi(accountId.value, {
|
||||
...buildParams(page),
|
||||
pageSize: 200
|
||||
})
|
||||
const payload = response.data || {}
|
||||
aggregated.push(...(payload.records || []))
|
||||
|
||||
@@ -141,6 +141,28 @@
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 刷新余额按钮 -->
|
||||
<div class="relative">
|
||||
<el-tooltip :content="refreshBalanceTooltip" effect="dark" placement="bottom">
|
||||
<button
|
||||
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500 sm:w-auto"
|
||||
:disabled="accountsLoading || refreshingBalances || !canRefreshVisibleBalances"
|
||||
@click="refreshVisibleBalances"
|
||||
>
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-blue-500 to-indigo-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<i
|
||||
:class="[
|
||||
'fas relative text-blue-500',
|
||||
refreshingBalances ? 'fa-spinner fa-spin' : 'fa-wallet'
|
||||
]"
|
||||
/>
|
||||
<span class="relative">刷新余额</span>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- 选择/取消选择按钮 -->
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:bg-gray-50 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
@@ -279,6 +301,11 @@
|
||||
>
|
||||
今日使用
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[220px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
余额/配额
|
||||
</th>
|
||||
<th
|
||||
class="min-w-[210px] px-3 py-4 text-left text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
@@ -763,9 +790,9 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-purple-500" />
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300"
|
||||
>{{ formatNumber(account.usage.daily.allTokens || 0) }}M</span
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-300">{{
|
||||
formatNumber(account.usage.daily.allTokens || 0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="h-2 w-2 rounded-full bg-green-500" />
|
||||
@@ -782,6 +809,31 @@
|
||||
</div>
|
||||
<div v-else class="text-xs text-gray-400">暂无数据</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<BalanceDisplay
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
:query-mode="
|
||||
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
|
||||
? 'auto'
|
||||
: 'local'
|
||||
"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
v-if="
|
||||
!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')
|
||||
"
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
配置余额脚本
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-3 py-4">
|
||||
<div v-if="account.platform === 'claude'" class="space-y-2">
|
||||
<!-- OAuth 账户:显示三窗口 OAuth usage -->
|
||||
@@ -903,7 +955,7 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||||
<span class="font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
|
||||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
@@ -1415,7 +1467,7 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}M
|
||||
{{ formatNumber(account.usage?.daily?.allTokens || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -1432,7 +1484,7 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-purple-500" />
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}M
|
||||
{{ formatNumber(account.usage.sessionWindow.totalTokens) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
@@ -1446,6 +1498,32 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 余额/配额 -->
|
||||
<div class="mb-3">
|
||||
<p class="mb-1 text-xs text-gray-500 dark:text-gray-400">余额/配额</p>
|
||||
<BalanceDisplay
|
||||
:account-id="account.id"
|
||||
:initial-balance="account.balanceInfo"
|
||||
:platform="account.platform"
|
||||
:query-mode="
|
||||
account.platform === 'gemini' && account.oauthProvider === 'antigravity'
|
||||
? 'auto'
|
||||
: 'local'
|
||||
"
|
||||
@error="(error) => handleBalanceError(account.id, error)"
|
||||
@refreshed="(data) => handleBalanceRefreshed(account.id, data)"
|
||||
/>
|
||||
<div class="mt-1 text-xs">
|
||||
<button
|
||||
v-if="!(account.platform === 'gemini' && account.oauthProvider === 'antigravity')"
|
||||
class="text-blue-500 hover:underline dark:text-blue-300"
|
||||
@click="openBalanceScriptModal(account)"
|
||||
>
|
||||
配置余额脚本
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态信息 -->
|
||||
<div class="mb-3 space-y-2">
|
||||
<!-- 会话窗口 -->
|
||||
@@ -1927,6 +2005,13 @@
|
||||
@saved="handleScheduledTestSaved"
|
||||
/>
|
||||
|
||||
<AccountBalanceScriptModal
|
||||
:account="selectedAccountForScript"
|
||||
:show="showBalanceScriptModal"
|
||||
@close="closeBalanceScriptModal"
|
||||
@saved="handleBalanceScriptSaved"
|
||||
/>
|
||||
|
||||
<!-- 账户统计弹窗 -->
|
||||
<el-dialog
|
||||
v-model="showAccountStatsModal"
|
||||
@@ -2078,10 +2163,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { copyText } from '@/utils/tools'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { showToast, copyText, formatNumber, formatRelativeTime } from '@/utils/tools'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
import AccountForm from '@/components/accounts/AccountForm.vue'
|
||||
import CcrAccountForm from '@/components/accounts/CcrAccountForm.vue'
|
||||
import AccountUsageDetailModal from '@/components/accounts/AccountUsageDetailModal.vue'
|
||||
@@ -2092,13 +2176,35 @@ import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
import GroupManagementModal from '@/components/accounts/GroupManagementModal.vue'
|
||||
import BalanceDisplay from '@/components/accounts/BalanceDisplay.vue'
|
||||
import AccountBalanceScriptModal from '@/components/accounts/AccountBalanceScriptModal.vue'
|
||||
|
||||
// 使用确认弹窗
|
||||
const { showConfirmModal, confirmOptions, showConfirm, handleConfirm, handleCancel } = useConfirm()
|
||||
// 确认弹窗状态
|
||||
const showConfirmModal = ref(false)
|
||||
const confirmOptions = ref({ title: '', message: '', confirmText: '继续', cancelText: '取消' })
|
||||
let confirmResolve = null
|
||||
const showConfirm = (title, message, confirmText = '继续', cancelText = '取消') => {
|
||||
return new Promise((resolve) => {
|
||||
confirmOptions.value = { title, message, confirmText, cancelText }
|
||||
confirmResolve = resolve
|
||||
showConfirmModal.value = true
|
||||
})
|
||||
}
|
||||
const handleConfirm = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve?.(true)
|
||||
confirmResolve = null
|
||||
}
|
||||
const handleCancel = () => {
|
||||
showConfirmModal.value = false
|
||||
confirmResolve?.(false)
|
||||
confirmResolve = null
|
||||
}
|
||||
|
||||
// 数据状态
|
||||
const accounts = ref([])
|
||||
const accountsLoading = ref(false)
|
||||
const refreshingBalances = ref(false)
|
||||
const accountsSortBy = ref('name')
|
||||
const accountsSortOrder = ref('asc')
|
||||
const apiKeys = ref([]) // 保留用于其他功能(如删除账户时显示绑定信息)
|
||||
@@ -2145,7 +2251,8 @@ const supportedUsagePlatforms = [
|
||||
'openai-responses',
|
||||
'gemini',
|
||||
'droid',
|
||||
'gemini-api'
|
||||
'gemini-api',
|
||||
'bedrock'
|
||||
]
|
||||
|
||||
// 过期时间编辑弹窗状态
|
||||
@@ -2237,16 +2344,16 @@ const platformGroupMap = {
|
||||
|
||||
// 平台请求处理器
|
||||
const platformRequestHandlers = {
|
||||
claude: () => httpApi.getClaudeAccounts(),
|
||||
'claude-console': () => httpApi.getClaudeConsoleAccounts(),
|
||||
bedrock: () => httpApi.getBedrockAccounts(),
|
||||
gemini: () => httpApi.getGeminiAccounts(),
|
||||
openai: () => httpApi.getOpenAIAccounts(),
|
||||
azure_openai: () => httpApi.getAzureOpenAIAccounts(),
|
||||
'openai-responses': () => httpApi.getOpenAIResponsesAccounts(),
|
||||
ccr: () => httpApi.getCcrAccounts(),
|
||||
droid: () => httpApi.getDroidAccounts(),
|
||||
'gemini-api': () => httpApi.getGeminiApiAccounts()
|
||||
claude: () => httpApis.getClaudeAccountsApi(),
|
||||
'claude-console': () => httpApis.getClaudeConsoleAccountsApi(),
|
||||
bedrock: () => httpApis.getBedrockAccountsApi(),
|
||||
gemini: () => httpApis.getGeminiAccountsApi(),
|
||||
openai: () => httpApis.getOpenAIAccountsApi(),
|
||||
azure_openai: () => httpApis.getAzureOpenAIAccountsApi(),
|
||||
'openai-responses': () => httpApis.getOpenAIResponsesAccountsApi(),
|
||||
ccr: () => httpApis.getCcrAccountsApi(),
|
||||
droid: () => httpApis.getDroidAccountsApi(),
|
||||
'gemini-api': () => httpApis.getGeminiApiAccountsApi()
|
||||
}
|
||||
|
||||
const allPlatformKeys = Object.keys(platformRequestHandlers)
|
||||
@@ -2464,7 +2571,7 @@ const openAccountUsageModal = async (account) => {
|
||||
accountUsageOverview.value = {}
|
||||
accountUsageGeneratedAt.value = ''
|
||||
|
||||
const response = await httpApi.getAccountUsageHistory(account.id, account.platform, 30)
|
||||
const response = await httpApis.getAccountUsageHistoryApi(account.id, account.platform, 30)
|
||||
if (response.success) {
|
||||
const data = response.data || {}
|
||||
accountUsageHistory.value = data.history || []
|
||||
@@ -2532,6 +2639,43 @@ const handleScheduledTestSaved = () => {
|
||||
showToast('定时测试配置已保存', 'success')
|
||||
}
|
||||
|
||||
// 余额脚本配置
|
||||
const showBalanceScriptModal = ref(false)
|
||||
const selectedAccountForScript = ref(null)
|
||||
|
||||
const openBalanceScriptModal = (account) => {
|
||||
selectedAccountForScript.value = account
|
||||
showBalanceScriptModal.value = true
|
||||
}
|
||||
|
||||
const closeBalanceScriptModal = () => {
|
||||
showBalanceScriptModal.value = false
|
||||
selectedAccountForScript.value = null
|
||||
}
|
||||
|
||||
const handleBalanceScriptSaved = async () => {
|
||||
showToast('余额脚本已保存', 'success')
|
||||
const account = selectedAccountForScript.value
|
||||
closeBalanceScriptModal()
|
||||
|
||||
if (!account?.id || !account?.platform) {
|
||||
return
|
||||
}
|
||||
|
||||
// 重新拉取一次余额信息,用于刷新 scriptConfigured 状态(启用"刷新余额"按钮)
|
||||
try {
|
||||
const res = await httpApis.getAccountBalanceApi(account.id, {
|
||||
platform: account.platform,
|
||||
queryApi: false
|
||||
})
|
||||
if (res?.success && res.data) {
|
||||
handleBalanceRefreshed(account.id, res.data)
|
||||
}
|
||||
} catch (error) {
|
||||
console.debug('Failed to reload balance after saving script:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算排序后的账户列表
|
||||
const sortedAccounts = computed(() => {
|
||||
let sourceAccounts = accounts.value
|
||||
@@ -2802,6 +2946,104 @@ const paginatedAccounts = computed(() => {
|
||||
return sortedAccounts.value.slice(start, end)
|
||||
})
|
||||
|
||||
const canRefreshVisibleBalances = computed(() => {
|
||||
const targets = paginatedAccounts.value
|
||||
if (!Array.isArray(targets) || targets.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return targets.some((account) => {
|
||||
const info = account?.balanceInfo
|
||||
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||
})
|
||||
})
|
||||
|
||||
const refreshBalanceTooltip = computed(() => {
|
||||
if (accountsLoading.value) return '正在加载账户...'
|
||||
if (refreshingBalances.value) return '刷新中...'
|
||||
if (!canRefreshVisibleBalances.value) return '当前页未配置余额脚本,无法刷新'
|
||||
return '刷新当前页余额(仅对已配置余额脚本的账户生效)'
|
||||
})
|
||||
|
||||
// 余额刷新成功回调
|
||||
const handleBalanceRefreshed = (accountId, balanceInfo) => {
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
if (account.id !== accountId) return account
|
||||
return { ...account, balanceInfo }
|
||||
})
|
||||
}
|
||||
|
||||
// 余额请求错误回调(仅提示,不中断页面)
|
||||
const handleBalanceError = (_accountId, error) => {
|
||||
const message = error?.message || '余额查询失败'
|
||||
showToast(message, 'error')
|
||||
}
|
||||
|
||||
// 批量刷新当前页余额(触发查询)
|
||||
const refreshVisibleBalances = async () => {
|
||||
if (refreshingBalances.value) return
|
||||
|
||||
const targets = paginatedAccounts.value
|
||||
if (!targets || targets.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const eligibleTargets = targets.filter((account) => {
|
||||
const info = account?.balanceInfo
|
||||
return info?.scriptEnabled !== false && !!info?.scriptConfigured
|
||||
})
|
||||
|
||||
if (eligibleTargets.length === 0) {
|
||||
showToast('当前页没有配置余额脚本的账户', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
const skippedCount = targets.length - eligibleTargets.length
|
||||
|
||||
refreshingBalances.value = true
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
eligibleTargets.map(async (account) => {
|
||||
try {
|
||||
const response = await httpApis.refreshAccountBalanceApi(account.id, {
|
||||
platform: account.platform
|
||||
})
|
||||
return { id: account.id, success: !!response?.success, data: response?.data || null }
|
||||
} catch (error) {
|
||||
return { id: account.id, success: false, error: error?.message || '刷新失败' }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const updatedMap = results.reduce((map, item) => {
|
||||
if (item.success && item.data) {
|
||||
map[item.id] = item.data
|
||||
}
|
||||
return map
|
||||
}, {})
|
||||
|
||||
const successCount = results.filter((r) => r.success).length
|
||||
const failCount = results.length - successCount
|
||||
|
||||
const skippedText = skippedCount > 0 ? `,跳过 ${skippedCount} 个未配置脚本` : ''
|
||||
if (Object.keys(updatedMap).length > 0) {
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
const balanceInfo = updatedMap[account.id]
|
||||
if (!balanceInfo) return account
|
||||
return { ...account, balanceInfo }
|
||||
})
|
||||
}
|
||||
|
||||
if (failCount === 0) {
|
||||
showToast(`成功刷新 ${successCount} 个账户余额${skippedText}`, 'success')
|
||||
} else {
|
||||
showToast(`刷新完成:${successCount} 成功,${failCount} 失败${skippedText}`, 'warning')
|
||||
}
|
||||
} finally {
|
||||
refreshingBalances.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateSelectAllState = () => {
|
||||
const currentIds = paginatedAccounts.value.map((account) => account.id)
|
||||
const selectedInCurrentPage = currentIds.filter((id) =>
|
||||
@@ -2852,6 +3094,52 @@ const cleanupSelectedAccounts = () => {
|
||||
updateSelectAllState()
|
||||
}
|
||||
|
||||
// 异步加载余额缓存(按平台批量拉取,避免逐行请求)
|
||||
const loadBalanceCacheForAccounts = async () => {
|
||||
const current = accounts.value
|
||||
if (!Array.isArray(current) || current.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const platforms = Array.from(new Set(current.map((acc) => acc.platform).filter(Boolean)))
|
||||
if (platforms.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const responses = await Promise.all(
|
||||
platforms.map(async (platform) => {
|
||||
try {
|
||||
const res = await httpApis.getBalanceByPlatformApi(platform, { queryApi: false })
|
||||
return { platform, success: !!res?.success, data: res?.data || [] }
|
||||
} catch (error) {
|
||||
console.debug(`Failed to load balance cache for ${platform}:`, error)
|
||||
return { platform, success: false, data: [] }
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
const balanceMap = responses.reduce((map, item) => {
|
||||
if (!item.success) return map
|
||||
const list = Array.isArray(item.data) ? item.data : []
|
||||
list.forEach((entry) => {
|
||||
const accountId = entry?.data?.accountId
|
||||
if (accountId) {
|
||||
map[accountId] = entry.data
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, {})
|
||||
|
||||
if (Object.keys(balanceMap).length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
accounts.value = accounts.value.map((account) => ({
|
||||
...account,
|
||||
balanceInfo: balanceMap[account.id] || account.balanceInfo || null
|
||||
}))
|
||||
}
|
||||
|
||||
// 加载账户列表
|
||||
const loadAccounts = async (forceReload = false) => {
|
||||
accountsLoading.value = true
|
||||
@@ -3026,6 +3314,11 @@ const loadAccounts = async (forceReload = false) => {
|
||||
console.debug('Claude usage loading failed:', err)
|
||||
})
|
||||
}
|
||||
|
||||
// 异步加载余额缓存(按平台批量)
|
||||
loadBalanceCacheForAccounts().catch((err) => {
|
||||
console.debug('Balance cache loading failed:', err)
|
||||
})
|
||||
} catch (error) {
|
||||
showToast('加载账户失败', 'error')
|
||||
} finally {
|
||||
@@ -3035,7 +3328,7 @@ const loadAccounts = async (forceReload = false) => {
|
||||
|
||||
// 异步加载 Claude 账户的 Usage 数据
|
||||
const loadClaudeUsage = async () => {
|
||||
const response = await httpApi.getClaudeAccountsUsage()
|
||||
const response = await httpApis.getClaudeAccountsUsageApi()
|
||||
if (response.success && response.data) {
|
||||
const usageMap = response.data
|
||||
accounts.value = accounts.value.map((account) => {
|
||||
@@ -3077,16 +3370,6 @@ const handleDropdownSort = (field) => {
|
||||
}
|
||||
|
||||
// 格式化数字(与原版保持一致)
|
||||
const formatNumber = (num) => {
|
||||
if (num === null || num === undefined) return '0'
|
||||
const number = Number(num)
|
||||
if (number >= 1000000) {
|
||||
return (number / 1000000).toFixed(2)
|
||||
} else if (number >= 1000) {
|
||||
return (number / 1000000).toFixed(4)
|
||||
}
|
||||
return (number / 1000000).toFixed(6)
|
||||
}
|
||||
|
||||
// 格式化最后使用时间
|
||||
const formatLastUsed = (dateString) => {
|
||||
@@ -3112,7 +3395,7 @@ const clearSearch = () => {
|
||||
// 加载绑定计数(轻量级接口,用于显示"绑定: X 个API Key")
|
||||
const loadBindingCounts = async (forceReload = false) => {
|
||||
if (!forceReload && bindingCountsLoaded.value) return
|
||||
const response = await httpApi.getAccountsBindingCounts()
|
||||
const response = await httpApis.getAccountsBindingCountsApi()
|
||||
if (response.success) {
|
||||
bindingCounts.value = response.data || {}
|
||||
bindingCountsLoaded.value = true
|
||||
@@ -3122,7 +3405,7 @@ const loadBindingCounts = async (forceReload = false) => {
|
||||
// 加载API Keys列表(保留用于其他功能,如删除账户时显示绑定信息)
|
||||
const loadApiKeys = async (forceReload = false) => {
|
||||
if (!forceReload && apiKeysLoaded.value) return
|
||||
const response = await httpApi.getApiKeys()
|
||||
const response = await httpApis.getApiKeysApi()
|
||||
if (response.success) {
|
||||
apiKeys.value = response.data?.items || response.data || []
|
||||
apiKeysLoaded.value = true
|
||||
@@ -3132,7 +3415,7 @@ const loadApiKeys = async (forceReload = false) => {
|
||||
// 加载账户分组列表(缓存版本)
|
||||
const loadAccountGroups = async (forceReload = false) => {
|
||||
if (!forceReload && groupsLoaded.value) return
|
||||
const response = await httpApi.getAccountGroups()
|
||||
const response = await httpApis.getAccountGroupsApi()
|
||||
if (response.success) {
|
||||
accountGroups.value = response.data || []
|
||||
groupsLoaded.value = true
|
||||
@@ -3421,7 +3704,7 @@ const resolveAccountDeleteEndpoint = (account) => {
|
||||
const performAccountDeletion = async (account) => {
|
||||
const endpoint = resolveAccountDeleteEndpoint(account)
|
||||
if (!endpoint) return { success: false, message: '不支持的账户类型' }
|
||||
const data = await httpApi.deleteAccountByEndpoint(endpoint)
|
||||
const data = await httpApis.deleteAccountByEndpointApi(endpoint)
|
||||
if (data.success) return { success: true, data }
|
||||
return { success: false, message: data.message || '删除失败' }
|
||||
}
|
||||
@@ -3591,7 +3874,7 @@ const resetAccountStatus = async (account) => {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await httpApi.testAccountByEndpoint(endpoint)
|
||||
const data = await httpApis.testAccountByEndpointApi(endpoint)
|
||||
if (data.success) {
|
||||
showToast('账户状态已重置', 'success')
|
||||
loadAccounts(true)
|
||||
@@ -3637,7 +3920,7 @@ const toggleSchedulable = async (account) => {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await httpApi.toggleAccountStatus(endpoint)
|
||||
const data = await httpApis.toggleAccountStatusApi(endpoint)
|
||||
if (data.success) {
|
||||
account.schedulable = data.schedulable
|
||||
showToast(data.schedulable ? '已启用调度' : '已禁用调度', 'success')
|
||||
@@ -4009,9 +4292,6 @@ const getAccountStatusDotClass = (account) => {
|
||||
// }
|
||||
|
||||
// 格式化相对时间
|
||||
const formatRelativeTime = (dateString) => {
|
||||
return formatLastUsed(dateString)
|
||||
}
|
||||
|
||||
// 获取会话窗口进度条的样式类
|
||||
const getSessionProgressBarClass = (status, account = null) => {
|
||||
@@ -4442,7 +4722,9 @@ const handleSaveAccountExpiry = async ({ accountId, expiresAt }) => {
|
||||
return
|
||||
}
|
||||
|
||||
const data = await httpApi.updateAccountByEndpoint(endpoint, { expiresAt: expiresAt || null })
|
||||
const data = await httpApis.updateAccountByEndpointApi(endpoint, {
|
||||
expiresAt: expiresAt || null
|
||||
})
|
||||
if (data.success) {
|
||||
showToast('账户到期时间已更新', 'success')
|
||||
account.expiresAt = expiresAt || null
|
||||
|
||||
@@ -295,9 +295,8 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { formatNumber } from '@/utils/tools'
|
||||
import { getApiKeyUsageRecordsApi } from '@/utils/http_apis'
|
||||
import { showToast, formatNumber, formatDate } from '@/utils/tools'
|
||||
import RecordDetailModal from '@/components/apikeys/RecordDetailModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -345,11 +344,6 @@ const dateRangeHint = computed(() => {
|
||||
return `${formatDate(filters.dateRange[0])} ~ ${formatDate(filters.dateRange[1])}`
|
||||
})
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '--'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatCost = (value) => {
|
||||
const num = typeof value === 'number' ? value : 0
|
||||
if (num >= 1) return `$${num.toFixed(2)}`
|
||||
@@ -410,9 +404,7 @@ const syncResponseState = (data) => {
|
||||
const fetchRecords = async (page = pagination.currentPage) => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await httpApi.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||
params: buildParams(page)
|
||||
})
|
||||
const response = await getApiKeyUsageRecordsApi(keyId.value, buildParams(page))
|
||||
syncResponseState(response.data || {})
|
||||
} catch (error) {
|
||||
showToast(`加载请求记录失败:${error.message || '未知错误'}`, 'error')
|
||||
@@ -465,8 +457,9 @@ const exportCsv = async () => {
|
||||
const maxPages = 50 // 50 * 200 = 10000,超过后端 5000 上限已足够
|
||||
|
||||
while (page <= totalPages && page <= maxPages) {
|
||||
const response = await httpApi.get(`/admin/api-keys/${keyId.value}/usage-records`, {
|
||||
params: { ...buildParams(page), pageSize: 200 }
|
||||
const response = await getApiKeyUsageRecordsApi(keyId.value, {
|
||||
...buildParams(page),
|
||||
pageSize: 200
|
||||
})
|
||||
const payload = response.data || {}
|
||||
aggregated.push(...(payload.records || []))
|
||||
|
||||
@@ -221,6 +221,18 @@
|
||||
<span class="relative">导出数据</span>
|
||||
</button>
|
||||
|
||||
<!-- 管理标签按钮 -->
|
||||
<button
|
||||
class="group relative flex items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 sm:w-auto"
|
||||
@click="showTagManagementModal = true"
|
||||
>
|
||||
<div
|
||||
class="absolute -inset-0.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 opacity-0 blur transition duration-300 group-hover:opacity-20"
|
||||
></div>
|
||||
<i class="fas fa-tags relative text-purple-500" />
|
||||
<span class="relative">管理标签</span>
|
||||
</button>
|
||||
|
||||
<!-- 批量编辑按钮 - 移到刷新按钮旁边 -->
|
||||
<button
|
||||
v-if="selectedApiKeys.length > 0"
|
||||
@@ -2118,6 +2130,12 @@
|
||||
@open-timeline="openTimeline"
|
||||
/>
|
||||
|
||||
<TagManagementModal
|
||||
:show="showTagManagementModal"
|
||||
@close="showTagManagementModal = false"
|
||||
@updated="loadApiKeys"
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
:confirm-text="confirmModalConfig.confirmText"
|
||||
@@ -2134,9 +2152,9 @@
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { copyText } from '@/utils/tools'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { showToast, copyText, formatNumber, formatDate } from '@/utils/tools'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import * as XLSX from 'xlsx-js-style'
|
||||
import CreateApiKeyModal from '@/components/apikeys/CreateApiKeyModal.vue'
|
||||
@@ -2147,6 +2165,7 @@ import BatchApiKeyModal from '@/components/apikeys/BatchApiKeyModal.vue'
|
||||
import BatchEditApiKeyModal from '@/components/apikeys/BatchEditApiKeyModal.vue'
|
||||
import ExpiryEditModal from '@/components/apikeys/ExpiryEditModal.vue'
|
||||
import UsageDetailModal from '@/components/apikeys/UsageDetailModal.vue'
|
||||
import TagManagementModal from '@/components/apikeys/TagManagementModal.vue'
|
||||
import LimitProgressBar from '@/components/apikeys/LimitProgressBar.vue'
|
||||
import CustomDropdown from '@/components/common/CustomDropdown.vue'
|
||||
import ActionDropdown from '@/components/common/ActionDropdown.vue'
|
||||
@@ -2315,6 +2334,7 @@ const showRenewApiKeyModal = ref(false)
|
||||
const showNewApiKeyModal = ref(false)
|
||||
const showBatchApiKeyModal = ref(false)
|
||||
const showBatchEditModal = ref(false)
|
||||
const showTagManagementModal = ref(false)
|
||||
const editingApiKey = ref(null)
|
||||
const renewingApiKey = ref(null)
|
||||
const newApiKeyData = ref(null)
|
||||
@@ -2445,15 +2465,15 @@ const loadAccounts = async (forceRefresh = false) => {
|
||||
droidData,
|
||||
groupsData
|
||||
] = await Promise.all([
|
||||
httpApi.getClaudeAccounts(),
|
||||
httpApi.getClaudeConsoleAccounts(),
|
||||
httpApi.getGeminiAccounts(),
|
||||
httpApi.getGeminiApiAccounts(),
|
||||
httpApi.getOpenAIAccounts(),
|
||||
httpApi.getOpenAIResponsesAccounts(),
|
||||
httpApi.getBedrockAccounts(),
|
||||
httpApi.getDroidAccounts(),
|
||||
httpApi.getAccountGroups()
|
||||
httpApis.getClaudeAccountsApi(),
|
||||
httpApis.getClaudeConsoleAccountsApi(),
|
||||
httpApis.getGeminiAccountsApi(),
|
||||
httpApis.getGeminiApiAccountsApi(),
|
||||
httpApis.getOpenAIAccountsApi(),
|
||||
httpApis.getOpenAIResponsesAccountsApi(),
|
||||
httpApis.getBedrockAccountsApi(),
|
||||
httpApis.getDroidAccountsApi(),
|
||||
httpApis.getAccountGroupsApi()
|
||||
])
|
||||
|
||||
// 合并Claude OAuth账户和Claude Console账户
|
||||
@@ -2559,7 +2579,7 @@ const loadAccounts = async (forceRefresh = false) => {
|
||||
// 加载已使用的模型列表
|
||||
const loadUsedModels = async () => {
|
||||
try {
|
||||
const data = await httpApi.get('/admin/api-keys/used-models')
|
||||
const data = await httpApis.getApiKeyUsedModelsApi()
|
||||
if (data.success) {
|
||||
availableModels.value = data.data || []
|
||||
}
|
||||
@@ -2648,7 +2668,7 @@ const loadApiKeys = async (clearStatsCache = true) => {
|
||||
params.set('timeRange', globalDateFilter.preset)
|
||||
}
|
||||
|
||||
const data = await httpApi.getApiKeysWithParams(params.toString())
|
||||
const data = await httpApis.getApiKeysWithParamsApi(params.toString())
|
||||
if (data.success) {
|
||||
// 更新数据
|
||||
apiKeys.value = data.data?.items || []
|
||||
@@ -2729,7 +2749,7 @@ const loadPageStats = async () => {
|
||||
requestBody.endDate = endDate
|
||||
}
|
||||
|
||||
const response = await httpApi.post('/admin/api-keys/batch-stats', requestBody)
|
||||
const response = await httpApis.getApiKeysBatchStatsApi(requestBody)
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 更新缓存
|
||||
@@ -2783,7 +2803,7 @@ const loadPageLastUsage = async () => {
|
||||
keyIds.forEach((id) => lastUsageLoading.value.add(id))
|
||||
|
||||
try {
|
||||
const response = await httpApi.post('/admin/api-keys/batch-last-usage', { keyIds })
|
||||
const response = await httpApis.getApiKeysBatchLastUsageApi({ keyIds })
|
||||
|
||||
if (response.success && response.data) {
|
||||
// 更新缓存
|
||||
@@ -2814,7 +2834,7 @@ const loadDeletedApiKeys = async () => {
|
||||
activeTab.value = 'deleted'
|
||||
deletedApiKeysLoading.value = true
|
||||
try {
|
||||
const data = await httpApi.get('/admin/api-keys/deleted')
|
||||
const data = await httpApis.getDeletedApiKeysApi()
|
||||
if (data.success) {
|
||||
deletedApiKeys.value = data.apiKeys || []
|
||||
}
|
||||
@@ -2893,7 +2913,7 @@ let costSortStatusTimer = null
|
||||
// 获取费用排序索引状态
|
||||
const fetchCostSortStatus = async () => {
|
||||
try {
|
||||
const data = await httpApi.get('/admin/api-keys/cost-sort-status')
|
||||
const data = await httpApis.getApiKeysCostSortStatusApi()
|
||||
if (data.success) {
|
||||
costSortStatus.value = data.data || {}
|
||||
|
||||
@@ -2924,10 +2944,6 @@ const scheduleNextCostSortStatusRefresh = () => {
|
||||
}
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num) => {
|
||||
if (!num && num !== 0) return '0'
|
||||
return num.toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
// 格式化Token数量
|
||||
const formatTokenCount = (count) => {
|
||||
@@ -3227,22 +3243,18 @@ const loadApiKeyModelStats = async (keyId, forceReload = false) => {
|
||||
const filter = getApiKeyDateFilter(keyId)
|
||||
|
||||
try {
|
||||
let url = `/admin/api-keys/${keyId}/model-stats`
|
||||
const params = new URLSearchParams()
|
||||
const params = {}
|
||||
|
||||
if (filter.customStart && filter.customEnd) {
|
||||
params.append('startDate', filter.customStart)
|
||||
params.append('endDate', filter.customEnd)
|
||||
params.append('period', 'custom')
|
||||
params.startDate = filter.customStart
|
||||
params.endDate = filter.customEnd
|
||||
params.period = 'custom'
|
||||
} else {
|
||||
const period =
|
||||
params.period =
|
||||
filter.preset === 'today' ? 'daily' : filter.preset === '7days' ? 'daily' : 'monthly'
|
||||
params.append('period', period)
|
||||
}
|
||||
|
||||
url += '?' + params.toString()
|
||||
|
||||
const data = await httpApi.get(url)
|
||||
const data = await httpApis.getApiKeyModelStatsApi(keyId, params)
|
||||
if (data.success) {
|
||||
apiKeyModelStats.value[keyId] = data.data || []
|
||||
}
|
||||
@@ -3906,7 +3918,7 @@ const toggleApiKeyStatus = async (key) => {
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const data = await httpApi.updateApiKey(key.id, { isActive: !key.isActive })
|
||||
const data = await httpApis.updateApiKeyApi(key.id, { isActive: !key.isActive })
|
||||
|
||||
if (data.success) {
|
||||
showToast(`API Key 已${key.isActive ? '禁用' : '激活'}`, 'success')
|
||||
@@ -3937,7 +3949,7 @@ const deleteApiKey = async (keyId) => {
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const data = await httpApi.deleteApiKey(keyId)
|
||||
const data = await httpApis.deleteApiKeyApi(keyId)
|
||||
if (data.success) {
|
||||
showToast('API Key 已删除', 'success')
|
||||
// 从选中列表中移除
|
||||
@@ -3968,7 +3980,7 @@ const restoreApiKey = async (keyId) => {
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const data = await httpApi.restoreApiKey(keyId)
|
||||
const data = await httpApis.restoreApiKeyApi(keyId)
|
||||
if (data.success) {
|
||||
showToast('API Key 已成功恢复', 'success')
|
||||
// 刷新已删除列表
|
||||
@@ -3996,7 +4008,7 @@ const permanentDeleteApiKey = async (keyId) => {
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const data = await httpApi.permanentDeleteApiKey(keyId)
|
||||
const data = await httpApis.permanentDeleteApiKeyApi(keyId)
|
||||
if (data.success) {
|
||||
showToast('API Key 已彻底删除', 'success')
|
||||
// 刷新已删除列表
|
||||
@@ -4028,7 +4040,7 @@ const clearAllDeletedApiKeys = async () => {
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
const data = await httpApi.del('/admin/api-keys/deleted/clear-all')
|
||||
const data = await httpApis.clearAllDeletedApiKeysApi()
|
||||
if (data.success) {
|
||||
showToast(data.message || '已清空所有已删除的 API Keys', 'success')
|
||||
|
||||
@@ -4070,9 +4082,7 @@ const batchDeleteApiKeys = async () => {
|
||||
const keyIds = [...selectedApiKeys.value]
|
||||
|
||||
try {
|
||||
const data = await httpApi.del('/admin/api-keys/batch', {
|
||||
data: { keyIds }
|
||||
})
|
||||
const data = await httpApis.batchDeleteApiKeysApi({ keyIds })
|
||||
|
||||
if (data.success) {
|
||||
const { successCount, failedCount, errors } = data.data
|
||||
@@ -4152,7 +4162,7 @@ const closeExpiryEdit = () => {
|
||||
const handleSaveExpiry = async ({ keyId, expiresAt, activateNow }) => {
|
||||
try {
|
||||
// 使用新的PATCH端点来修改过期时间
|
||||
const data = await httpApi.updateApiKeyExpiration(keyId, {
|
||||
const data = await httpApis.updateApiKeyExpirationApi(keyId, {
|
||||
expiresAt: expiresAt || null,
|
||||
activateNow: activateNow || false
|
||||
})
|
||||
@@ -4190,21 +4200,6 @@ const handleSaveExpiry = async ({ keyId, expiresAt, activateNow }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化日期时间
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date
|
||||
.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
.replace(/\//g, '-')
|
||||
}
|
||||
|
||||
// 格式化时间窗口倒计时
|
||||
const formatWindowTime = (seconds) => {
|
||||
if (seconds === null || seconds === undefined) return '--:--'
|
||||
@@ -4472,18 +4467,12 @@ const exportToExcel = () => {
|
||||
过期时间: key.expiresAt ? formatDate(key.expiresAt) : '',
|
||||
|
||||
// 权限配置
|
||||
服务权限:
|
||||
key.permissions === 'all'
|
||||
? '全部服务'
|
||||
: key.permissions === 'claude'
|
||||
? '仅Claude'
|
||||
: key.permissions === 'gemini'
|
||||
? '仅Gemini'
|
||||
: key.permissions === 'openai'
|
||||
? '仅OpenAI'
|
||||
: key.permissions === 'droid'
|
||||
? '仅Droid'
|
||||
: key.permissions || '',
|
||||
服务权限: (() => {
|
||||
const p = key.permissions
|
||||
if (!p || p === 'all') return '全部服务'
|
||||
if (Array.isArray(p)) return p.length === 0 ? '全部服务' : p.join(', ')
|
||||
return p
|
||||
})(),
|
||||
|
||||
// 限制配置
|
||||
令牌限制: key.tokenLimit === '0' || key.tokenLimit === 0 ? '无限制' : key.tokenLimit || '',
|
||||
|
||||
@@ -11,7 +11,13 @@
|
||||
<LogoTitle
|
||||
:loading="oemLoading"
|
||||
:logo-src="oemSettings.siteIconData || oemSettings.siteIcon"
|
||||
:subtitle="currentTab === 'stats' ? 'API Key 使用统计' : '使用教程'"
|
||||
:subtitle="
|
||||
currentTab === 'stats'
|
||||
? 'API Key 使用统计'
|
||||
: currentTab === 'quota'
|
||||
? '额度卡'
|
||||
: '使用教程'
|
||||
"
|
||||
:title="oemSettings.siteName"
|
||||
/>
|
||||
<div class="flex items-center gap-2 md:gap-4">
|
||||
@@ -52,7 +58,7 @@
|
||||
<div class="mb-4 sm:mb-6 md:mb-8">
|
||||
<div class="flex justify-center">
|
||||
<div
|
||||
class="inline-flex w-full max-w-md rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl sm:w-auto"
|
||||
class="inline-flex w-full max-w-2xl flex-wrap justify-center gap-1 rounded-full border border-white/20 bg-white/10 p-1 shadow-lg backdrop-blur-xl sm:w-auto sm:flex-nowrap"
|
||||
>
|
||||
<button
|
||||
:class="['tab-pill-button', currentTab === 'stats' ? 'active' : '']"
|
||||
@@ -61,6 +67,13 @@
|
||||
<i class="fas fa-chart-line mr-1 md:mr-2" />
|
||||
<span class="text-sm md:text-base">统计查询</span>
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-pill-button', currentTab === 'quota' ? 'active' : '']"
|
||||
@click="switchToQuota"
|
||||
>
|
||||
<i class="fas fa-ticket-alt mr-1 md:mr-2" />
|
||||
<span class="text-sm md:text-base">额度卡</span>
|
||||
</button>
|
||||
<button
|
||||
:class="['tab-pill-button', currentTab === 'tutorial' ? 'active' : '']"
|
||||
@click="currentTab = 'tutorial'"
|
||||
@@ -221,6 +234,239 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额度卡内容(含二级 tab) -->
|
||||
<div v-if="currentTab === 'quota'" class="tab-content">
|
||||
<div class="glass-strong rounded-2xl p-4 shadow-xl sm:rounded-3xl sm:p-6 md:p-8">
|
||||
<!-- 二级 Tab -->
|
||||
<div
|
||||
class="mb-4 flex gap-2 border-b border-gray-200 pb-4 dark:border-gray-700 md:mb-6 md:pb-6"
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
quotaSubTab === 'redeem'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
]"
|
||||
@click="quotaSubTab = 'redeem'"
|
||||
>
|
||||
<i class="fas fa-ticket-alt mr-2" />
|
||||
兑换额度卡
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'rounded-lg px-4 py-2 text-sm font-medium transition-all',
|
||||
quotaSubTab === 'history'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600'
|
||||
]"
|
||||
@click="switchToHistorySubTab"
|
||||
>
|
||||
<i class="fas fa-history mr-2" />
|
||||
兑换记录
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 兑换额度卡子内容 -->
|
||||
<div v-if="quotaSubTab === 'redeem'">
|
||||
<!-- 需要先输入 API Key -->
|
||||
<div v-if="!apiId" class="py-8 text-center">
|
||||
<div class="mb-4 text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-key mb-4 block text-4xl opacity-50" />
|
||||
<p>请先在「统计查询」页面输入您的 API Key</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-2.5 font-medium text-white transition-all hover:from-blue-600 hover:to-cyan-600"
|
||||
@click="currentTab = 'stats'"
|
||||
>
|
||||
前往输入 API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 兑换表单 -->
|
||||
<div v-else>
|
||||
<div class="mb-6 rounded-xl bg-blue-50 p-4 dark:bg-blue-900/20">
|
||||
<p class="text-sm text-blue-700 dark:text-blue-300">
|
||||
<i class="fas fa-info-circle mr-2" />
|
||||
当前 API Key: <span class="font-medium">{{ statsData?.name || apiId }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
额度卡卡号
|
||||
</label>
|
||||
<input
|
||||
v-model="redeemCode"
|
||||
class="w-full rounded-xl border border-gray-300 bg-white px-4 py-3 text-gray-900 placeholder-gray-400 transition-all focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
placeholder="请输入额度卡卡号"
|
||||
type="text"
|
||||
@keyup.enter="handleRedeem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="w-full rounded-xl bg-gradient-to-r from-green-500 to-emerald-500 px-6 py-3 font-medium text-white transition-all hover:from-green-600 hover:to-emerald-600 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="!redeemCode.trim() || redeemLoading"
|
||||
@click="handleRedeem"
|
||||
>
|
||||
<i v-if="redeemLoading" class="fas fa-spinner fa-spin mr-2" />
|
||||
<i v-else class="fas fa-check-circle mr-2" />
|
||||
{{ redeemLoading ? '兑换中...' : '立即兑换' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 兑换结果 -->
|
||||
<div v-if="redeemResult" class="mt-6">
|
||||
<div
|
||||
:class="[
|
||||
'rounded-xl p-4',
|
||||
redeemResult.success
|
||||
? redeemResult.hasWarnings
|
||||
? 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/20 dark:text-yellow-300'
|
||||
: 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-300'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<i
|
||||
:class="[
|
||||
'mt-0.5 text-lg',
|
||||
redeemResult.success
|
||||
? redeemResult.hasWarnings
|
||||
? 'fas fa-exclamation-triangle'
|
||||
: 'fas fa-check-circle'
|
||||
: 'fas fa-times-circle'
|
||||
]"
|
||||
/>
|
||||
<div>
|
||||
<p class="font-medium">
|
||||
{{
|
||||
redeemResult.success
|
||||
? redeemResult.hasWarnings
|
||||
? '兑换成功(部分截断)'
|
||||
: '兑换成功'
|
||||
: '兑换失败'
|
||||
}}
|
||||
</p>
|
||||
<p class="mt-1 text-sm opacity-90">{{ redeemResult.message }}</p>
|
||||
<div v-if="redeemResult.success && redeemResult.data" class="mt-2 text-sm">
|
||||
<p v-if="redeemResult.data.quotaAdded">
|
||||
额度增加:
|
||||
<span class="font-medium">${{ redeemResult.data.quotaAdded }}</span>
|
||||
</p>
|
||||
<p v-if="redeemResult.data.timeAdded">
|
||||
有效期延长:
|
||||
<span class="font-medium"
|
||||
>{{ redeemResult.data.timeAdded
|
||||
}}{{
|
||||
redeemResult.data.timeUnit === 'days'
|
||||
? '天'
|
||||
: redeemResult.data.timeUnit === 'hours'
|
||||
? '小时'
|
||||
: '月'
|
||||
}}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 兑换记录子内容 -->
|
||||
<div v-if="quotaSubTab === 'history'">
|
||||
<!-- 需要先输入 API Key -->
|
||||
<div v-if="!apiId" class="py-8 text-center">
|
||||
<div class="mb-4 text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-key mb-4 block text-4xl opacity-50" />
|
||||
<p>请先在「统计查询」页面输入您的 API Key</p>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 px-6 py-2.5 font-medium text-white transition-all hover:from-blue-600 hover:to-cyan-600"
|
||||
@click="currentTab = 'stats'"
|
||||
>
|
||||
前往输入 API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 记录列表 -->
|
||||
<div v-else>
|
||||
<div v-if="historyLoading" class="py-8 text-center">
|
||||
<i class="fas fa-spinner fa-spin text-2xl text-gray-400" />
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="redemptionHistory.length === 0" class="py-8 text-center">
|
||||
<i class="fas fa-inbox text-4xl text-gray-300 dark:text-gray-600" />
|
||||
<p class="mt-2 text-gray-500 dark:text-gray-400">暂无兑换记录</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="record in redemptionHistory"
|
||||
:key="record.id"
|
||||
class="rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="mb-1 flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
||||
record.cardType === 'quota'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300'
|
||||
: record.cardType === 'time'
|
||||
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
record.cardType === 'quota'
|
||||
? '额度卡'
|
||||
: record.cardType === 'time'
|
||||
? '时间卡'
|
||||
: '组合卡'
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="record.status === 'revoked'"
|
||||
class="inline-flex items-center rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||
>
|
||||
已撤销
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300">
|
||||
<span v-if="record.quotaAdded">额度 +${{ record.quotaAdded }}</span>
|
||||
<span v-if="record.quotaAdded && record.timeAdded"> · </span>
|
||||
<span v-if="record.timeAdded"
|
||||
>有效期 +{{ record.timeAmount
|
||||
}}{{
|
||||
record.timeUnit === 'days'
|
||||
? '天'
|
||||
: record.timeUnit === 'hours'
|
||||
? '小时'
|
||||
: '月'
|
||||
}}</span
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap text-right text-xs text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{{ formatDateTime(record.redeemedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key 测试弹窗 -->
|
||||
<ApiKeyTestModal
|
||||
:api-key-name="statsData?.name || ''"
|
||||
@@ -284,6 +530,8 @@ import { useRoute } from 'vue-router'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useApiStatsStore } from '@/stores/apistats'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { redeemCardByApiIdApi, getRedemptionHistoryByApiIdApi } from '@/utils/http_apis'
|
||||
import { formatDateTime, showToast } from '@/utils/tools'
|
||||
import LogoTitle from '@/components/common/LogoTitle.vue'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
import ApiKeyInput from '@/components/apistats/ApiKeyInput.vue'
|
||||
@@ -338,25 +586,116 @@ const showNotice = ref(false)
|
||||
const dontShowAgain = ref(false)
|
||||
const NOTICE_STORAGE_KEY = 'apiStatsNoticeRead'
|
||||
|
||||
// 检查是否可以测试 Claude(权限包含 claude 或 all)
|
||||
// 额度卡兑换相关状态
|
||||
const quotaSubTab = ref('redeem')
|
||||
const redeemCode = ref('')
|
||||
const redeemLoading = ref(false)
|
||||
const redeemResult = ref(null)
|
||||
const redemptionHistory = ref([])
|
||||
const historyLoading = ref(false)
|
||||
|
||||
// 兑换额度卡
|
||||
const handleRedeem = async () => {
|
||||
if (!redeemCode.value.trim() || !apiId.value) return
|
||||
|
||||
redeemLoading.value = true
|
||||
redeemResult.value = null
|
||||
|
||||
const res = await redeemCardByApiIdApi({
|
||||
apiId: apiId.value,
|
||||
code: redeemCode.value.trim()
|
||||
})
|
||||
|
||||
redeemLoading.value = false
|
||||
|
||||
if (res.success) {
|
||||
const warnings = res.data?.warnings || []
|
||||
const hasWarnings = warnings.length > 0
|
||||
redeemResult.value = {
|
||||
success: true,
|
||||
message: hasWarnings ? warnings.join(';') : '额度卡兑换成功!',
|
||||
data: res.data,
|
||||
hasWarnings
|
||||
}
|
||||
redeemCode.value = ''
|
||||
showToast(
|
||||
hasWarnings ? '兑换成功(部分截断)' : '兑换成功',
|
||||
hasWarnings ? 'warning' : 'success'
|
||||
)
|
||||
// 刷新统计数据
|
||||
loadStatsWithApiId()
|
||||
} else {
|
||||
redeemResult.value = {
|
||||
success: false,
|
||||
message: res.error || res.message || '兑换失败'
|
||||
}
|
||||
showToast(res.error || res.message || '兑换失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 加载兑换记录
|
||||
const loadRedemptionHistory = async () => {
|
||||
if (!apiId.value) return
|
||||
|
||||
historyLoading.value = true
|
||||
const res = await getRedemptionHistoryByApiIdApi(apiId.value)
|
||||
historyLoading.value = false
|
||||
|
||||
if (res.success) {
|
||||
redemptionHistory.value = res.data?.records || res.data || []
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到额度卡 Tab
|
||||
const switchToQuota = () => {
|
||||
currentTab.value = 'quota'
|
||||
// 如果子标签是记录,刷新数据
|
||||
if (quotaSubTab.value === 'history') {
|
||||
loadRedemptionHistory()
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到兑换记录子 Tab
|
||||
const switchToHistorySubTab = () => {
|
||||
quotaSubTab.value = 'history'
|
||||
loadRedemptionHistory()
|
||||
}
|
||||
|
||||
// 解析 permissions(可能是 JSON 字符串或数组)
|
||||
const parsePermissions = (permissions) => {
|
||||
if (!permissions) return []
|
||||
if (Array.isArray(permissions)) return permissions
|
||||
if (typeof permissions === 'string') {
|
||||
if (permissions === 'all') return []
|
||||
try {
|
||||
const parsed = JSON.parse(permissions)
|
||||
return Array.isArray(parsed) ? parsed : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
// 检查是否可以测试 Claude(权限包含 claude 或全部)
|
||||
const canTestClaude = computed(() => {
|
||||
const permissions = statsData.value?.permissions
|
||||
if (!permissions) return true // 默认允许
|
||||
return permissions === 'all' || permissions.includes('claude')
|
||||
const permissions = parsePermissions(statsData.value?.permissions)
|
||||
if (permissions.length === 0) return true
|
||||
return permissions.includes('claude')
|
||||
})
|
||||
|
||||
// 检查是否可以测试 Gemini
|
||||
const canTestGemini = computed(() => {
|
||||
const permissions = statsData.value?.permissions
|
||||
if (!permissions) return true
|
||||
return permissions === 'all' || permissions.includes('gemini')
|
||||
const permissions = parsePermissions(statsData.value?.permissions)
|
||||
if (permissions.length === 0) return true
|
||||
return permissions.includes('gemini')
|
||||
})
|
||||
|
||||
// 检查是否可以测试 OpenAI
|
||||
const canTestOpenAI = computed(() => {
|
||||
const permissions = statsData.value?.permissions
|
||||
if (!permissions) return true
|
||||
return permissions === 'all' || permissions.includes('openai')
|
||||
const permissions = parsePermissions(statsData.value?.permissions)
|
||||
if (permissions.length === 0) return true
|
||||
return permissions.includes('openai')
|
||||
})
|
||||
|
||||
// 检查是否有任何测试权限
|
||||
@@ -366,18 +705,15 @@ const hasAnyTestPermission = computed(() => {
|
||||
|
||||
// 可用服务文本
|
||||
const availableServicesText = computed(() => {
|
||||
const permissions = statsData.value?.permissions
|
||||
if (!permissions || permissions === 'all') return '全部服务'
|
||||
const permissions = parsePermissions(statsData.value?.permissions)
|
||||
if (permissions.length === 0) return '全部服务'
|
||||
const serviceNames = {
|
||||
claude: 'Claude',
|
||||
gemini: 'Gemini',
|
||||
openai: 'OpenAI',
|
||||
droid: 'Droid'
|
||||
}
|
||||
return permissions
|
||||
.split(',')
|
||||
.map((s) => serviceNames[s.trim()] || s.trim())
|
||||
.join(', ')
|
||||
return permissions.map((s) => serviceNames[s] || s).join(', ')
|
||||
})
|
||||
|
||||
// 切换测试菜单
|
||||
|
||||
302
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
302
web/admin-spa/src/views/BalanceScriptsView.vue
Normal file
@@ -0,0 +1,302 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row">
|
||||
<div class="glass-strong flex-1 rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">脚本余额配置</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
使用自定义脚本 + 模板变量适配任意余额接口
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="loadConfig"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-indigo-700"
|
||||
:disabled="saving"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<span v-if="saving">保存中...</span>
|
||||
<span v-else>保存配置</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">API Key</label>
|
||||
<input v-model="form.apiKey" class="input-text" placeholder="sk-xxxx" type="text" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
请求地址(baseUrl)
|
||||
</label>
|
||||
<input
|
||||
v-model="form.baseUrl"
|
||||
class="input-text"
|
||||
placeholder="https://api.example.com"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>Token(可选)</label
|
||||
>
|
||||
<input v-model="form.token" class="input-text" placeholder="Bearer token" type="text" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>超时时间(秒)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="form.timeoutSeconds"
|
||||
class="input-text"
|
||||
min="1"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
自动查询间隔(分钟)
|
||||
</label>
|
||||
<input
|
||||
v-model.number="form.autoIntervalMinutes"
|
||||
class="input-text"
|
||||
min="0"
|
||||
type="number"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">模板变量</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
可用变量:{{ '{' }}{{ '{' }}baseUrl{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}apiKey{{ '}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}token{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}accountId{{
|
||||
'}'
|
||||
}}{{ '}' }}、{{ '{' }}{{ '{' }}platform{{ '}' }}{{ '}' }}、{{ '{' }}{{ '{' }}extra{{
|
||||
'}'
|
||||
}}{{ '}' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-strong w-full max-w-xl rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">测试脚本</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
填入账号上下文(可选),调试 extractor 输出
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
:disabled="testing"
|
||||
@click="testScript"
|
||||
>
|
||||
<span v-if="testing">测试中...</span>
|
||||
<span v-else>测试脚本</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">平台</label>
|
||||
<input v-model="testForm.platform" class="input-text" placeholder="例如 claude" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200">账号ID</label>
|
||||
<input v-model="testForm.accountId" class="input-text" placeholder="账号标识,可选" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-200"
|
||||
>额外参数 (extra)</label
|
||||
>
|
||||
<input v-model="testForm.extra" class="input-text" placeholder="可选" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult" class="mt-4 space-y-2 rounded-xl bg-gray-50 p-3 dark:bg-gray-800/60">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="font-semibold text-gray-800 dark:text-gray-100">测试结果</span>
|
||||
<span
|
||||
:class="[
|
||||
'rounded px-2 py-0.5 text-xs',
|
||||
testResult.mapped?.status === 'success'
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200'
|
||||
]"
|
||||
>
|
||||
{{ testResult.mapped?.status || 'unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">
|
||||
<div>余额: {{ displayAmount(testResult.mapped?.balance) }}</div>
|
||||
<div>单位: {{ testResult.mapped?.currency || '—' }}</div>
|
||||
<div v-if="testResult.mapped?.planName">套餐: {{ testResult.mapped.planName }}</div>
|
||||
<div v-if="testResult.mapped?.errorMessage" class="text-red-500">
|
||||
错误: {{ testResult.mapped.errorMessage }}
|
||||
</div>
|
||||
<div v-if="testResult.mapped?.quota">
|
||||
配额: {{ JSON.stringify(testResult.mapped.quota) }}
|
||||
</div>
|
||||
</div>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看 extractor 输出</summary>
|
||||
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||
>{{ formatJson(testResult.extracted) }}
|
||||
</pre
|
||||
>
|
||||
</details>
|
||||
<details class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<summary class="cursor-pointer">查看原始响应</summary>
|
||||
<pre class="mt-2 overflow-auto rounded bg-black/70 p-2 text-[11px] text-gray-100"
|
||||
>{{ formatJson(testResult.response) }}
|
||||
</pre
|
||||
>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-strong rounded-2xl p-4 shadow-lg">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-100">提取器代码</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
返回对象需包含 request、extractor;支持模板变量替换
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
|
||||
@click="applyPreset"
|
||||
>
|
||||
使用示例模板
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.scriptBody"
|
||||
class="min-h-[320px] w-full rounded-xl bg-gray-900 font-mono text-sm text-gray-100 shadow-inner focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
spellcheck="false"
|
||||
></textarea>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
extractor
|
||||
返回字段(可选):isValid、invalidMessage、remaining、unit、planName、total、used、extra
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
|
||||
import {
|
||||
getDefaultBalanceScriptApi,
|
||||
updateDefaultBalanceScriptApi,
|
||||
testDefaultBalanceScriptApi
|
||||
} from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
const form = reactive({
|
||||
baseUrl: '',
|
||||
apiKey: '',
|
||||
token: '',
|
||||
timeoutSeconds: 10,
|
||||
autoIntervalMinutes: 0,
|
||||
scriptBody: ''
|
||||
})
|
||||
|
||||
const testForm = reactive({
|
||||
platform: '',
|
||||
accountId: '',
|
||||
extra: ''
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
const testing = ref(false)
|
||||
const testResult = ref(null)
|
||||
|
||||
const presetScript = `({
|
||||
request: {
|
||||
url: "{{baseUrl}}/user/balance",
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": "Bearer {{apiKey}}",
|
||||
"User-Agent": "cc-switch/1.0"
|
||||
}
|
||||
},
|
||||
extractor: function(response) {
|
||||
return {
|
||||
isValid: response.is_active || true,
|
||||
remaining: response.balance,
|
||||
unit: "USD",
|
||||
planName: response.plan || "默认套餐"
|
||||
};
|
||||
}
|
||||
})`
|
||||
|
||||
const loadConfig = async () => {
|
||||
const res = await getDefaultBalanceScriptApi()
|
||||
if (res?.success && res.data) {
|
||||
Object.assign(form, res.data)
|
||||
}
|
||||
}
|
||||
|
||||
const saveConfig = async () => {
|
||||
saving.value = true
|
||||
const res = await updateDefaultBalanceScriptApi({ ...form })
|
||||
if (res?.success) {
|
||||
showToast('配置已保存', 'success')
|
||||
} else {
|
||||
showToast(res?.message || '保存失败', 'error')
|
||||
}
|
||||
saving.value = false
|
||||
}
|
||||
|
||||
const testScript = async () => {
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
const payload = { ...form, ...testForm, scriptBody: form.scriptBody }
|
||||
const res = await testDefaultBalanceScriptApi(payload)
|
||||
if (res?.success) {
|
||||
testResult.value = res.data
|
||||
showToast('测试完成', 'success')
|
||||
} else {
|
||||
showToast(res?.error || '测试失败', 'error')
|
||||
}
|
||||
testing.value = false
|
||||
}
|
||||
|
||||
const applyPreset = () => {
|
||||
form.scriptBody = presetScript
|
||||
}
|
||||
|
||||
const displayAmount = (val) => {
|
||||
if (val === null || val === undefined || Number.isNaN(Number(val))) return '—'
|
||||
return Number(val).toFixed(2)
|
||||
}
|
||||
|
||||
const formatJson = (data) => {
|
||||
try {
|
||||
return JSON.stringify(data, null, 2)
|
||||
} catch (error) {
|
||||
return String(data)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
applyPreset()
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-text {
|
||||
@apply w-full rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm text-gray-800 shadow-sm transition focus:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-200 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:focus:border-indigo-500 dark:focus:ring-indigo-600;
|
||||
}
|
||||
</style>
|
||||
@@ -196,6 +196,105 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 账户余额/配额汇总 -->
|
||||
<div class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6">
|
||||
<div class="stat-card">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold text-gray-600 dark:text-gray-400 sm:text-sm">
|
||||
账户余额/配额
|
||||
</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-gray-100 sm:text-3xl">
|
||||
{{ formatCurrencyUsd(balanceSummary.totalBalance || 0) }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
低余额: {{ balanceSummary.lowBalanceCount || 0 }} | 总成本:
|
||||
{{ formatCurrencyUsd(balanceSummary.totalCost || 0) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="stat-icon flex-shrink-0 bg-gradient-to-br from-emerald-500 to-green-600">
|
||||
<i class="fas fa-wallet" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center justify-between gap-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||
更新时间: {{ formatLastUpdate(balanceSummaryUpdatedAt) }}
|
||||
</p>
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-all duration-200 hover:border-gray-300 hover:shadow-md disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-gray-500"
|
||||
:disabled="loadingBalanceSummary"
|
||||
@click="loadBalanceSummary"
|
||||
>
|
||||
<i :class="['fas', loadingBalanceSummary ? 'fa-spinner fa-spin' : 'fa-sync-alt']" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card p-4 sm:p-6">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100">低余额账户</h3>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ lowBalanceAccounts.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingBalanceSummary"
|
||||
class="py-6 text-center text-sm text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
正在加载...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="lowBalanceAccounts.length === 0"
|
||||
class="py-6 text-center text-sm text-green-600 dark:text-green-400"
|
||||
>
|
||||
全部正常
|
||||
</div>
|
||||
<div v-else class="max-h-64 space-y-2 overflow-y-auto">
|
||||
<div
|
||||
v-for="account in lowBalanceAccounts"
|
||||
:key="account.accountId"
|
||||
class="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-900/60 dark:bg-red-900/20"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ account.name || account.accountId }}
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600 dark:bg-gray-700 dark:text-gray-300"
|
||||
>
|
||||
{{ getBalancePlatformLabel(account.platform) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span v-if="account.balance">余额: {{ account.balance.formattedAmount }}</span>
|
||||
<span v-else
|
||||
>今日成本: {{ formatCurrencyUsd(account.statistics?.dailyCost || 0) }}</span
|
||||
>
|
||||
</div>
|
||||
<div v-if="account.quota && typeof account.quota.percentage === 'number'" class="mt-2">
|
||||
<div
|
||||
class="mb-1 flex items-center justify-between text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<span>配额使用</span>
|
||||
<span class="text-red-600 dark:text-red-400">
|
||||
{{ account.quota.percentage.toFixed(1) }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-gray-200 dark:bg-gray-700">
|
||||
<div
|
||||
class="h-2 rounded-full bg-red-500"
|
||||
:style="{ width: `${Math.min(100, account.quota.percentage)}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token统计和性能指标 -->
|
||||
<div
|
||||
class="mb-4 grid grid-cols-1 gap-3 sm:mb-6 sm:grid-cols-2 sm:gap-4 md:mb-8 md:gap-6 lg:grid-cols-4"
|
||||
@@ -679,9 +778,13 @@
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Chart from 'chart.js/auto'
|
||||
|
||||
import { useDashboardStore } from '@/stores/dashboard'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import Chart from 'chart.js/auto'
|
||||
import { formatNumber, showToast } from '@/utils/tools'
|
||||
|
||||
import { getBalanceSummaryApi } from '@/utils/http_apis'
|
||||
|
||||
const dashboardStore = useDashboardStore()
|
||||
const themeStore = useThemeStore()
|
||||
@@ -698,8 +801,7 @@ const {
|
||||
formattedUptime,
|
||||
dateFilter,
|
||||
trendGranularity,
|
||||
apiKeysTrendMetric,
|
||||
defaultTime
|
||||
apiKeysTrendMetric
|
||||
} = storeToRefs(dashboardStore)
|
||||
|
||||
const {
|
||||
@@ -713,6 +815,9 @@ const {
|
||||
disabledDate
|
||||
} = dashboardStore
|
||||
|
||||
// 日期选择器默认时间
|
||||
const defaultTime = [new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 2, 1, 23, 59, 59)]
|
||||
|
||||
// Chart 实例
|
||||
const modelUsageChart = ref(null)
|
||||
const usageTrendChart = ref(null)
|
||||
@@ -732,6 +837,94 @@ const accountGroupOptions = [
|
||||
|
||||
const accountTrendUpdating = ref(false)
|
||||
|
||||
// 余额/配额汇总
|
||||
const balanceSummary = ref({
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
})
|
||||
const loadingBalanceSummary = ref(false)
|
||||
const balanceSummaryUpdatedAt = ref(null)
|
||||
|
||||
const getBalancePlatformLabel = (platform) => {
|
||||
const map = {
|
||||
claude: 'Claude',
|
||||
'claude-console': 'Claude Console',
|
||||
gemini: 'Gemini',
|
||||
'gemini-api': 'Gemini API',
|
||||
openai: 'OpenAI',
|
||||
'openai-responses': 'OpenAI Responses',
|
||||
azure_openai: 'Azure OpenAI',
|
||||
bedrock: 'Bedrock',
|
||||
droid: 'Droid',
|
||||
ccr: 'CCR'
|
||||
}
|
||||
return map[platform] || platform
|
||||
}
|
||||
|
||||
const lowBalanceAccounts = computed(() => {
|
||||
const result = []
|
||||
const platforms = balanceSummary.value?.platforms || {}
|
||||
|
||||
Object.entries(platforms).forEach(([platform, data]) => {
|
||||
const list = Array.isArray(data?.accounts) ? data.accounts : []
|
||||
list.forEach((entry) => {
|
||||
const accountData = entry?.data
|
||||
if (!accountData) return
|
||||
|
||||
const amount = accountData.balance?.amount
|
||||
const percentage = accountData.quota?.percentage
|
||||
|
||||
const isLowBalance = typeof amount === 'number' && amount < 10
|
||||
const isHighUsage = typeof percentage === 'number' && percentage > 90
|
||||
|
||||
if (isLowBalance || isHighUsage) {
|
||||
result.push({
|
||||
...accountData,
|
||||
name: entry?.name || accountData.accountId,
|
||||
platform: accountData.platform || platform
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const formatCurrencyUsd = (amount) => {
|
||||
const value = Number(amount)
|
||||
if (!Number.isFinite(value)) return '$0.00'
|
||||
if (value >= 1) return `$${value.toFixed(2)}`
|
||||
if (value >= 0.01) return `$${value.toFixed(3)}`
|
||||
return `$${value.toFixed(6)}`
|
||||
}
|
||||
|
||||
const formatLastUpdate = (isoString) => {
|
||||
if (!isoString) return '未知'
|
||||
const date = new Date(isoString)
|
||||
if (Number.isNaN(date.getTime())) return '未知'
|
||||
return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const loadBalanceSummary = async () => {
|
||||
loadingBalanceSummary.value = true
|
||||
const response = await getBalanceSummaryApi()
|
||||
if (response?.success) {
|
||||
balanceSummary.value = response.data || {
|
||||
totalBalance: 0,
|
||||
totalCost: 0,
|
||||
lowBalanceCount: 0,
|
||||
platforms: {}
|
||||
}
|
||||
balanceSummaryUpdatedAt.value = new Date().toISOString()
|
||||
} else if (response?.message) {
|
||||
console.debug('加载余额汇总失败:', response.message)
|
||||
showToast('加载余额汇总失败', 'error')
|
||||
}
|
||||
loadingBalanceSummary.value = false
|
||||
}
|
||||
|
||||
// 自动刷新相关
|
||||
const autoRefreshEnabled = ref(false)
|
||||
const autoRefreshInterval = ref(30) // 秒
|
||||
@@ -753,16 +946,6 @@ const chartColors = computed(() => ({
|
||||
legend: isDarkMode.value ? '#e5e7eb' : '#374151'
|
||||
}))
|
||||
|
||||
// 格式化数字
|
||||
function formatNumber(num) {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(2) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(2) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
function formatCostValue(cost) {
|
||||
if (!Number.isFinite(cost)) {
|
||||
return '$0.000000'
|
||||
@@ -1488,7 +1671,7 @@ async function refreshAllData() {
|
||||
|
||||
isRefreshing.value = true
|
||||
try {
|
||||
await Promise.all([loadDashboardData(), refreshChartsData()])
|
||||
await Promise.all([loadDashboardData(), refreshChartsData(), loadBalanceSummary()])
|
||||
} finally {
|
||||
isRefreshing.value = false
|
||||
}
|
||||
|
||||
@@ -121,7 +121,3 @@ const handleLogin = async () => {
|
||||
await authStore.login(loginForm.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式已经在全局样式中定义 */
|
||||
</style>
|
||||
|
||||
@@ -88,6 +88,52 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Limits Config Card -->
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50"
|
||||
>
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">兑换上限保护</span>
|
||||
<label class="relative inline-flex cursor-pointer items-center">
|
||||
<input
|
||||
v-model="limitsConfig.enabled"
|
||||
class="peer sr-only"
|
||||
type="checkbox"
|
||||
@change="saveLimitsConfig"
|
||||
/>
|
||||
<div
|
||||
class="peer h-5 w-9 rounded-full bg-gray-300 after:absolute after:left-[2px] after:top-[2px] after:h-4 after:w-4 after:rounded-full after:bg-white after:transition-all after:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full dark:bg-gray-600"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">最大额度</span>
|
||||
<input
|
||||
v-model.number="limitsConfig.maxTotalCostLimit"
|
||||
class="w-24 rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
:disabled="!limitsConfig.enabled"
|
||||
min="0"
|
||||
type="number"
|
||||
@change="saveLimitsConfig"
|
||||
/>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">$</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">最大有效期</span>
|
||||
<input
|
||||
v-model.number="limitsConfig.maxExpiryDays"
|
||||
class="w-20 rounded border border-gray-300 bg-white px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
:disabled="!limitsConfig.enabled"
|
||||
min="0"
|
||||
type="number"
|
||||
@change="saveLimitsConfig"
|
||||
/>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">天</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav aria-label="Tabs" class="-mb-px flex space-x-8">
|
||||
@@ -233,7 +279,7 @@
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
<span v-if="card.type === 'quota' || card.type === 'combo'"
|
||||
>{{ card.quotaAmount }} CC</span
|
||||
>${{ card.quotaAmount }}</span
|
||||
>
|
||||
<span v-if="card.type === 'combo'"> + </span>
|
||||
<span v-if="card.type === 'time' || card.type === 'combo'">
|
||||
@@ -410,7 +456,7 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
<span v-if="redemption.quotaAdded > 0">{{ redemption.quotaAdded }} CC</span>
|
||||
<span v-if="redemption.quotaAdded > 0">${{ redemption.quotaAdded }}</span>
|
||||
<span v-if="redemption.quotaAdded > 0 && redemption.timeAdded > 0"> + </span>
|
||||
<span v-if="redemption.timeAdded > 0">
|
||||
{{ redemption.timeAdded }}
|
||||
@@ -505,7 +551,7 @@
|
||||
|
||||
<div v-if="newCard.type === 'quota' || newCard.type === 'combo'">
|
||||
<label class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>额度数量 (CC)</label
|
||||
>额度数量 (美元)</label
|
||||
>
|
||||
<input
|
||||
v-model.number="newCard.quotaAmount"
|
||||
@@ -630,7 +676,7 @@
|
||||
</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
<template v-if="card.type === 'quota' || card.type === 'combo'">
|
||||
{{ card.quotaAmount }} CC
|
||||
${{ card.quotaAmount }}
|
||||
</template>
|
||||
<template v-if="card.type === 'combo'"> + </template>
|
||||
<template v-if="card.type === 'time' || card.type === 'combo'">
|
||||
@@ -675,6 +721,44 @@
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
<!-- Revoke Modal -->
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="showRevokeModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4 backdrop-blur-sm"
|
||||
@click.self="showRevokeModal = false"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-900 dark:text-white">撤销核销</h3>
|
||||
<div class="mb-4">
|
||||
<label class="mb-2 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
撤销原因(可选)
|
||||
</label>
|
||||
<input
|
||||
v-model="revokeReason"
|
||||
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
|
||||
placeholder="请输入撤销原因"
|
||||
type="text"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-lg bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
@click="showRevokeModal = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600"
|
||||
@click="executeRevoke"
|
||||
>
|
||||
确认撤销
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<!-- Confirm Modal -->
|
||||
<ConfirmModal
|
||||
:cancel-text="confirmModalConfig.cancelText"
|
||||
@@ -692,9 +776,9 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { copyText } from '@/utils/tools'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
import { showToast, copyText, formatDate } from '@/utils/tools'
|
||||
|
||||
const loading = ref(false)
|
||||
const creating = ref(false)
|
||||
@@ -710,6 +794,9 @@ const confirmModalConfig = ref({
|
||||
})
|
||||
const confirmResolve = ref(null)
|
||||
const createdCards = ref([])
|
||||
const showRevokeModal = ref(false)
|
||||
const revokeReason = ref('')
|
||||
const revokingRedemption = ref(null)
|
||||
const activeTab = ref('cards')
|
||||
const selectedCards = ref([])
|
||||
|
||||
@@ -732,6 +819,12 @@ const stats = ref({
|
||||
expired: 0
|
||||
})
|
||||
|
||||
const limitsConfig = ref({
|
||||
enabled: true,
|
||||
maxExpiryDays: 90,
|
||||
maxTotalCostLimit: 1000
|
||||
})
|
||||
|
||||
const cards = ref([])
|
||||
const redemptions = ref([])
|
||||
|
||||
@@ -777,11 +870,6 @@ const newCard = ref({
|
||||
note: ''
|
||||
})
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString('zh-CN')
|
||||
}
|
||||
|
||||
const showConfirm = (
|
||||
title,
|
||||
message,
|
||||
@@ -806,23 +894,30 @@ const handleCancelModal = () => {
|
||||
|
||||
const loadCards = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const offset = (currentPage.value - 1) * pageSize.value
|
||||
const [cardsData, statsData, redemptionsData] = await Promise.all([
|
||||
httpApi.get(`/admin/quota-cards?limit=${pageSize.value}&offset=${offset}`),
|
||||
httpApi.get('/admin/quota-cards/stats'),
|
||||
httpApi.get('/admin/redemptions')
|
||||
])
|
||||
const offset = (currentPage.value - 1) * pageSize.value
|
||||
const [cardsData, statsData, redemptionsData] = await Promise.all([
|
||||
httpApis.getQuotaCardsWithParamsApi({ limit: pageSize.value, offset }),
|
||||
httpApis.getQuotaCardsStatsApi(),
|
||||
httpApis.getRedemptionsApi()
|
||||
])
|
||||
|
||||
cards.value = cardsData.data?.cards || []
|
||||
totalCards.value = cardsData.data?.total || 0
|
||||
stats.value = statsData.data || stats.value
|
||||
redemptions.value = redemptionsData.data?.redemptions || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load cards:', error)
|
||||
showToast('加载卡片数据失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
// 单独获取 limits 配置,兼容老后端
|
||||
const limitsData = await httpApis.getQuotaCardLimitsApi().catch(() => ({ data: null }))
|
||||
|
||||
cards.value = cardsData.data?.cards || []
|
||||
totalCards.value = cardsData.data?.total || 0
|
||||
stats.value = statsData.data || stats.value
|
||||
redemptions.value = redemptionsData.data?.redemptions || []
|
||||
if (limitsData.data) {
|
||||
limitsConfig.value = limitsData.data
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const saveLimitsConfig = async () => {
|
||||
const result = await httpApis.updateQuotaCardLimitsApi(limitsConfig.value)
|
||||
if (result.success) {
|
||||
showToast('配置已保存', 'success')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,8 +940,8 @@ const changePageSize = () => {
|
||||
|
||||
const createCard = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
const result = await httpApi.post('/admin/quota-cards', newCard.value)
|
||||
const result = await httpApis.createQuotaCardApi(newCard.value)
|
||||
if (result.success) {
|
||||
showCreateModal.value = false
|
||||
|
||||
// 处理返回的卡片数据
|
||||
@@ -866,12 +961,10 @@ const createCard = async () => {
|
||||
|
||||
showToast(`成功创建 ${createdCards.value.length} 张卡片`, 'success')
|
||||
loadCards()
|
||||
} catch (error) {
|
||||
console.error('Failed to create card:', error)
|
||||
showToast(error.message || '创建卡片失败', 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
} else {
|
||||
showToast(result.message || '创建卡片失败', 'error')
|
||||
}
|
||||
creating.value = false
|
||||
}
|
||||
|
||||
// 下载卡片
|
||||
@@ -882,7 +975,7 @@ const downloadCards = () => {
|
||||
.map((card) => {
|
||||
let label = ''
|
||||
if (card.type === 'quota' || card.type === 'combo') {
|
||||
label += `${card.quotaAmount}CC`
|
||||
label += `$${card.quotaAmount}`
|
||||
}
|
||||
if (card.type === 'combo') {
|
||||
label += '_'
|
||||
@@ -936,14 +1029,9 @@ const deleteCard = async (card) => {
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await httpApi.del(`/admin/quota-cards/${card.id}`)
|
||||
showToast('卡片已删除', 'success')
|
||||
loadCards()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete card:', error)
|
||||
showToast(error.message || '删除卡片失败', 'error')
|
||||
}
|
||||
await httpApis.deleteQuotaCardApi(card.id)
|
||||
showToast('卡片已删除', 'success')
|
||||
loadCards()
|
||||
}
|
||||
|
||||
const deleteSelectedCards = async () => {
|
||||
@@ -956,29 +1044,25 @@ const deleteSelectedCards = async () => {
|
||||
)
|
||||
if (!confirmed) return
|
||||
|
||||
try {
|
||||
await Promise.all(selectedCards.value.map((id) => httpApi.del(`/admin/quota-cards/${id}`)))
|
||||
showToast(`已删除 ${selectedCards.value.length} 张卡片`, 'success')
|
||||
selectedCards.value = []
|
||||
loadCards()
|
||||
} catch (error) {
|
||||
console.error('Failed to delete cards:', error)
|
||||
showToast(error.message || '批量删除失败', 'error')
|
||||
}
|
||||
await Promise.all(selectedCards.value.map((id) => httpApis.deleteQuotaCardApi(id)))
|
||||
showToast(`已删除 ${selectedCards.value.length} 张卡片`, 'success')
|
||||
selectedCards.value = []
|
||||
loadCards()
|
||||
}
|
||||
|
||||
const revokeRedemption = async (redemption) => {
|
||||
const reason = prompt('撤销原因(可选):')
|
||||
if (reason === null) return
|
||||
const revokeRedemption = (redemption) => {
|
||||
revokingRedemption.value = redemption
|
||||
revokeReason.value = ''
|
||||
showRevokeModal.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
await httpApi.post(`/admin/redemptions/${redemption.id}/revoke`, { reason })
|
||||
showToast('核销已撤销', 'success')
|
||||
loadCards()
|
||||
} catch (error) {
|
||||
console.error('Failed to revoke redemption:', error)
|
||||
showToast(error.message || '撤销核销失败', 'error')
|
||||
}
|
||||
const executeRevoke = async () => {
|
||||
if (!revokingRedemption.value) return
|
||||
await httpApis.revokeRedemptionApi(revokingRedemption.value.id, { reason: revokeReason.value })
|
||||
showToast('核销已撤销', 'success')
|
||||
showRevokeModal.value = false
|
||||
revokingRedemption.value = null
|
||||
loadCards()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
|
||||
@@ -1124,10 +1124,10 @@
|
||||
服务倍率说明
|
||||
</h3>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
服务倍率用于计算不同服务消耗的虚拟额度(CC)。以
|
||||
服务倍率用于计算不同服务的计费费用。以
|
||||
<strong>{{ serviceRates.baseService || 'claude' }}</strong>
|
||||
为基准(倍率 1.0),其他服务按倍率换算。例如:Gemini 倍率 0.5 表示消耗 1 USD
|
||||
只扣除 0.5 CC 额度。
|
||||
为基准(倍率 1.0),其他服务按倍率换算。例如:Gemini 倍率 0.5 表示消耗 $1 只扣除
|
||||
$0.5 额度。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1804,7 +1804,8 @@ import { ref, onMounted, onBeforeUnmount, watch, computed } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
import ConfirmModal from '@/components/common/ConfirmModal.vue'
|
||||
|
||||
// 定义组件名称,用于keep-alive排除
|
||||
@@ -2140,7 +2141,7 @@ onBeforeUnmount(() => {
|
||||
const loadWebhookConfig = async () => {
|
||||
if (!isMounted.value) return
|
||||
try {
|
||||
const response = await httpApi.get('/admin/webhook/config', {
|
||||
const response = await httpApis.getWebhookConfigApi({
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
@@ -2173,7 +2174,7 @@ const saveWebhookConfig = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const response = await httpApi.post('/admin/webhook/config', payload, {
|
||||
const response = await httpApis.updateWebhookConfigApi(payload, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
@@ -2193,7 +2194,7 @@ const loadClaudeConfig = async () => {
|
||||
if (!isMounted.value) return
|
||||
claudeConfigLoading.value = true
|
||||
try {
|
||||
const response = await httpApi.get('/admin/claude-relay-config', {
|
||||
const response = await httpApis.getClaudeRelayConfigApi({
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
@@ -2246,7 +2247,7 @@ const saveClaudeConfig = async () => {
|
||||
concurrentRequestQueueTimeoutMs: claudeConfig.value.concurrentRequestQueueTimeoutMs
|
||||
}
|
||||
|
||||
const response = await httpApi.put('/admin/claude-relay-config', payload, {
|
||||
const response = await httpApis.updateClaudeRelayConfigApi(payload, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
@@ -2270,7 +2271,7 @@ const loadServiceRates = async () => {
|
||||
if (!isMounted.value) return
|
||||
serviceRatesLoading.value = true
|
||||
try {
|
||||
const response = await httpApi.get('/admin/service-rates', {
|
||||
const response = await httpApis.getAdminServiceRatesApi({
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
@@ -2297,8 +2298,7 @@ const saveServiceRates = async () => {
|
||||
if (!isMounted.value) return
|
||||
serviceRatesSaving.value = true
|
||||
try {
|
||||
const response = await httpApi.put(
|
||||
'/admin/service-rates',
|
||||
const response = await httpApis.updateAdminServiceRatesApi(
|
||||
{
|
||||
rates: serviceRates.value.rates,
|
||||
baseService: serviceRates.value.baseService
|
||||
@@ -2473,14 +2473,16 @@ const savePlatform = async () => {
|
||||
let response
|
||||
if (editingPlatform.value) {
|
||||
// 更新平台
|
||||
response = await httpApi.put(
|
||||
`/admin/webhook/platforms/${editingPlatform.value.id}`,
|
||||
response = await httpApis.updateWebhookPlatformApi(
|
||||
editingPlatform.value.id,
|
||||
platformForm.value,
|
||||
{ signal: abortController.value.signal }
|
||||
{
|
||||
signal: abortController.value.signal
|
||||
}
|
||||
)
|
||||
} else {
|
||||
// 添加平台
|
||||
response = await httpApi.post('/admin/webhook/platforms', platformForm.value, {
|
||||
response = await httpApis.createWebhookPlatformApi(platformForm.value, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
}
|
||||
@@ -2545,7 +2547,7 @@ const deletePlatform = async (id) => {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await httpApi.del(`/admin/webhook/platforms/${id}`, {
|
||||
const response = await httpApis.deleteWebhookPlatformApi(id, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
@@ -2565,13 +2567,9 @@ const togglePlatform = async (id) => {
|
||||
if (!isMounted.value) return
|
||||
|
||||
try {
|
||||
const response = await httpApi.post(
|
||||
`/admin/webhook/platforms/${id}/toggle`,
|
||||
{},
|
||||
{
|
||||
signal: abortController.value.signal
|
||||
}
|
||||
)
|
||||
const response = await httpApis.toggleWebhookPlatformApi(id, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
showToast(response.message, 'success')
|
||||
await loadWebhookConfig()
|
||||
@@ -2620,7 +2618,7 @@ const testPlatform = async (platform) => {
|
||||
testData.url = platform.url
|
||||
}
|
||||
|
||||
const response = await httpApi.post('/admin/webhook/test', testData, {
|
||||
const response = await httpApis.testWebhookApi(testData, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
@@ -2643,7 +2641,7 @@ const testPlatformForm = async () => {
|
||||
|
||||
testingConnection.value = true
|
||||
try {
|
||||
const response = await httpApi.post('/admin/webhook/test', platformForm.value, {
|
||||
const response = await httpApis.testWebhookApi(platformForm.value, {
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
@@ -2666,13 +2664,9 @@ const sendTestNotification = async () => {
|
||||
if (!isMounted.value) return
|
||||
|
||||
try {
|
||||
const response = await httpApi.post(
|
||||
'/admin/webhook/test-notification',
|
||||
{},
|
||||
{
|
||||
signal: abortController.value.signal
|
||||
}
|
||||
)
|
||||
const response = await httpApis.testWebhookNotificationApi({
|
||||
signal: abortController.value.signal
|
||||
})
|
||||
if (response.success && isMounted.value) {
|
||||
showToast('测试通知已发送', 'success')
|
||||
}
|
||||
|
||||
@@ -340,7 +340,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import { showToast } from '@/utils/tools'
|
||||
import { showToast, formatNumber, formatDate } from '@/utils/tools'
|
||||
import ThemeToggle from '@/components/common/ThemeToggle.vue'
|
||||
import UserApiKeysManager from '@/components/user/UserApiKeysManager.vue'
|
||||
import UserUsageStats from '@/components/user/UserUsageStats.vue'
|
||||
@@ -354,26 +354,6 @@ const activeTab = ref('overview')
|
||||
const userProfile = ref(null)
|
||||
const apiKeysStats = ref({ active: 0, deleted: 0 })
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const handleTabChange = (tab) => {
|
||||
activeTab.value = tab
|
||||
// Refresh API keys stats when switching to overview tab
|
||||
@@ -430,7 +410,3 @@ onMounted(() => {
|
||||
loadApiKeysStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
|
||||
@@ -195,7 +195,3 @@ onMounted(() => {
|
||||
themeStore.initTheme()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
|
||||
@@ -476,8 +476,9 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import * as httpApi from '@/utils/http_apis'
|
||||
import { showToast } from '@/utils/tools'
|
||||
|
||||
import * as httpApis from '@/utils/http_apis'
|
||||
import { showToast, formatNumber, formatDate } from '@/utils/tools'
|
||||
import { debounce } from 'lodash-es'
|
||||
import UserUsageStatsModal from '@/components/admin/UserUsageStatsModal.vue'
|
||||
import ChangeRoleModal from '@/components/admin/ChangeRoleModal.vue'
|
||||
@@ -531,26 +532,6 @@ const filteredUsers = computed(() => {
|
||||
return filtered
|
||||
})
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1) + 'M'
|
||||
} else if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'K'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return null
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -564,8 +545,8 @@ const loadUsers = async () => {
|
||||
}
|
||||
|
||||
const [usersResponse, statsResponse] = await Promise.all([
|
||||
httpApi.get('/users', { params }),
|
||||
httpApi.get('/users/stats/overview')
|
||||
httpApis.getFrontUsersApi(params),
|
||||
httpApis.getFrontUsersStatsOverviewApi()
|
||||
])
|
||||
|
||||
if (usersResponse.success) {
|
||||
@@ -631,7 +612,7 @@ const handleConfirmAction = async () => {
|
||||
|
||||
try {
|
||||
if (action === 'toggleStatus') {
|
||||
const response = await httpApi.patch(`/users/${user.id}/status`, {
|
||||
const response = await httpApis.updateFrontUserStatusApi(user.id, {
|
||||
isActive: !user.isActive
|
||||
})
|
||||
|
||||
@@ -643,7 +624,7 @@ const handleConfirmAction = async () => {
|
||||
showToast(`User ${user.isActive ? 'disabled' : 'enabled'} successfully`, 'success')
|
||||
}
|
||||
} else if (action === 'disableKeys') {
|
||||
const response = await httpApi.post(`/users/${user.id}/disable-keys`)
|
||||
const response = await httpApis.disableFrontUserKeysApi(user.id)
|
||||
|
||||
if (response.success) {
|
||||
showToast(`Disabled ${response.disabledCount} API keys`, 'success')
|
||||
@@ -669,7 +650,3 @@ onMounted(() => {
|
||||
loadUsers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 组件特定样式 */
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user