Major rewrite of the Go backend to support:
- Three API format imports: openai, anthropic, compatible
- Three protocol conversions: Chat Completions, Responses, Messages
- Hub-and-spoke architecture with Chat as intermediate format
Deleted:
- opencat.go (old entry)
- store/, team/, pkg/team/, pkg/store/ (old data layer)
- internal/model/, internal/consts/ (old types)
- internal/service/team/, internal/controller/team/ (old handlers)
- llm/ (removed LLM client library, pure proxy mode)
- dist/, assets/ (old build artifacts)
Added:
- internal/store/ — 9 GORM models + multi-DB support
- internal/pkg/ — crypto (AES-GCM), apikey, jwt, ratelimit, resp, tokenizer
- internal/channel/ — channel selection, weighted LB, health checks
- internal/proxy/convert/ — 6 protocol conversion functions + SSE streaming
- internal/proxy/ — gateway with request dispatch and upstream selection
- internal/usage/ — async usage recorder with batch writes
- internal/api/ — management API (auth, users, keys, channels, models)
- Makefile for build/test/deploy
Fixed API to match frontend expectations:
- Login response wraps token in { data: { token } }
- GET /api/profile route added
- Profile response wraps user in { code, data }
- Role returned as number (10=admin, 1=user)
214 lines
7.7 KiB
Vue
214 lines
7.7 KiB
Vue
<template>
|
|
<div class="space-y-5">
|
|
<BreadcrumbHeader />
|
|
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<p class="text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
|
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new token">
|
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Token
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Table -->
|
|
<div v-if="user" class="card border border-base-300/60 bg-base-100 shadow-sm">
|
|
<div class="overflow-x-auto" v-if="user.tokens && user.tokens.length">
|
|
<table class="table table-sm">
|
|
<thead>
|
|
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
|
<th class="pl-4">Token</th>
|
|
<th>Status</th>
|
|
<th>Expired</th>
|
|
<th class="text-right">Quota</th>
|
|
<th class="text-right">Used</th>
|
|
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="token in user.tokens" :key="token.id" class="border-base-300/40 hover:bg-base-200/50">
|
|
<td class="pl-4 font-medium truncate max-w-[120px] sm:max-w-[180px]">{{ token.name }}</td>
|
|
<td>
|
|
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
|
:class="!token.active && 'toggle-error'" v-model="token.active"
|
|
@change="updateStatus(token)" :aria-label="`Toggle token ${token.name}`" />
|
|
</td>
|
|
<td class="tabular-nums text-base-content/70">{{ token.expired_at == 0 ? 'Never' : unixToDate(token.expired_at ?? 0) }}</td>
|
|
<td class="text-right tabular-nums">
|
|
<template v-if="token.unlimited_quota">
|
|
<Infinity class="inline h-4 w-4 text-base-content/60" aria-label="Unlimited quota" />
|
|
<span class="sr-only">Unlimited</span>
|
|
</template>
|
|
<template v-else>{{ token.quota }}</template>
|
|
</td>
|
|
<td class="text-right tabular-nums">{{ token.used_quota }}</td>
|
|
<td class="pr-3 text-right">
|
|
<div class="flex items-center justify-end gap-1">
|
|
<button class="btn btn-ghost btn-xs btn-square" @click="viewToken(token)" aria-label="Preview token key">
|
|
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
<button v-if="(token.used_quota ?? 0) > 0" class="btn btn-ghost btn-xs btn-square text-info"
|
|
@click="cleanUsedToken(token)" aria-label="Reset used quota">
|
|
<Eraser class="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
<button v-if="token.name !== 'default'" class="btn btn-ghost btn-xs btn-square text-error"
|
|
@click="confirmRevokeToken(token)" aria-label="Revoke token">
|
|
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Empty state -->
|
|
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
|
<Braces class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
|
<h2 class="text-sm font-semibold">No tokens yet</h2>
|
|
<p class="max-w-xs text-sm text-base-content/60">
|
|
Create a token to connect OpenCat, BotGem and other OpenAI-compatible clients.
|
|
</p>
|
|
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
|
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Token
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- New token modal -->
|
|
<dialog ref="modalRef" class="modal">
|
|
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
|
<form method="dialog">
|
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
|
</form>
|
|
<TokenNew @closeModal="closeModal" />
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button aria-label="Close dialog">close</button>
|
|
</form>
|
|
</dialog>
|
|
|
|
<!-- Token QR modal -->
|
|
<dialog ref="tokenRef" class="modal">
|
|
<div class="modal-box max-w-sm px-0 sm:px-6">
|
|
<form method="dialog">
|
|
<button class="btn btn-circle btn-ghost btn-sm absolute right-2 top-2" aria-label="Close dialog">✕</button>
|
|
</form>
|
|
<QRCodeCard :value="qrCodeValue" :size="120" />
|
|
</div>
|
|
<form method="dialog" class="modal-backdrop">
|
|
<button aria-label="Close dialog">close</button>
|
|
</form>
|
|
</dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, onMounted, watch, computed } from 'vue';
|
|
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
|
import QRCodeCard from '@/components/common/QRCodeCard.vue';
|
|
import TokenNew from '@/views/dashboard/TokenNew.vue';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
import {
|
|
EyeIcon, PlusIcon, TrashIcon, Infinity, Eraser, Braces
|
|
} from '@lucide/vue';
|
|
import { unixToDate } from '@/utils/format-date';
|
|
import { useToast } from '@/composables/toast';
|
|
import type { TokenInfo } from '@/types';
|
|
|
|
const authStore = useAuthStore();
|
|
const user = computed(() => authStore.user);
|
|
const { setToast } = useToast();
|
|
|
|
onMounted(async () => {
|
|
await authStore.refreshProfile();
|
|
})
|
|
|
|
watch(() => authStore.user, (newUser) => {
|
|
if (newUser && newUser.expired_at && newUser.expired_at > 0) {
|
|
newUser.format_expired_at = unixToDate(newUser.expired_at);
|
|
}
|
|
})
|
|
|
|
const updateStatus = async (token: TokenInfo) => {
|
|
console.log(token);
|
|
try {
|
|
const res = await authStore.updateToken({ userid: token.userid, id: token.id, name: token.name, active: token.active });
|
|
if (res.data?.code == 200) {
|
|
setToast(`Token ${token.name} updated`, 'success');
|
|
}
|
|
} catch (error: any) {
|
|
token.active = !token.active
|
|
console.log(error.response.data.error);
|
|
setToast(error.response.data.error, 'error');
|
|
}
|
|
}
|
|
|
|
const confirmRevokeToken = async (token: TokenInfo) => {
|
|
if (confirm(`确认删除 ${token.name}?`)) {
|
|
await revokeToken(token);
|
|
}
|
|
}
|
|
|
|
const revokeToken = async (token: TokenInfo) => {
|
|
try {
|
|
const res = await authStore.deleteToken(token.id);
|
|
if (res.data?.code == 200) {
|
|
setToast(`Token ${token.name} revoked`, 'success');
|
|
}
|
|
await authStore.refreshProfile();
|
|
|
|
} catch (error: any) {
|
|
setToast(error.response.data.error, 'error');
|
|
}
|
|
}
|
|
|
|
const cleanUsedToken = async (token: TokenInfo) => {
|
|
|
|
if (token.used_quota == 0 || token.used_quota == null) {
|
|
return;
|
|
}
|
|
try {
|
|
const res = await authStore.resetToken(token.id);
|
|
console.log('cleanUsedToken', res);
|
|
if (res.data?.code == 200) {
|
|
setToast(`Token ${token.name} used quota reset`, 'success');
|
|
}
|
|
await authStore.refreshProfile();
|
|
} catch (error: any) {
|
|
console.log(error);
|
|
setToast(error, 'error');
|
|
}
|
|
}
|
|
|
|
const showTokenModel = ref(false);
|
|
const tokenRef = ref<HTMLDialogElement | null>(null);
|
|
const viewToken = (token: TokenInfo) => {
|
|
const dialog = tokenRef.value;
|
|
if (dialog) {
|
|
if (!dialog.hasAttribute('open')) {
|
|
qrCodeValue.value = token.key || '';
|
|
dialog.showModal();
|
|
} else {
|
|
if (dialog.hasAttribute('open')) {
|
|
dialog.close();
|
|
}
|
|
}
|
|
}
|
|
showTokenModel.value = !showTokenModel.value
|
|
}
|
|
|
|
const qrCodeValue = ref('');
|
|
|
|
|
|
// 关闭模态框
|
|
const modalRef = ref<HTMLDialogElement | null>(null);
|
|
const openModal = () => {
|
|
modalRef.value?.showModal();
|
|
};
|
|
const closeModal = async () => {
|
|
if (modalRef.value) {
|
|
modalRef.value.close();
|
|
}
|
|
await authStore.refreshProfile();
|
|
};
|
|
</script>
|