feat: API key management improvements
- Rename routes: /dashboard/tokens → /dashboard/apikeys, /dashboard/manager/keys → /dashboard/manager/channels - Add KeyPlain field to store plaintext API keys for re-viewing - API key list shows masked key (sk-ot-123456****abcd) with eye toggle to reveal - Copy button with 2s feedback on key list - TokenNew shows full key + copy after creation - Increase key prefix display to 12 characters - Fix SQLite driver: replace gorm.io/driver/sqlite with ncruces/go-sqlite3/gormlite - Fix user.status === 'active' checks across frontend views - Add channel store for new channels API - Update Makefile frontend build to work reliably BREAKING CHANGE: Existing API keys created before this change will not show their plaintext value (only prefix visible).
This commit is contained in:
@@ -41,15 +41,15 @@ const route = useRoute();
|
||||
const crumbsByRouteName: Record<string, Crumb[]> = {
|
||||
// 管理后台列表页:区域根
|
||||
User: [{ label: '管理后台', path: '/dashboard/manager/users' }],
|
||||
ApiKey: [{ label: '管理后台', path: '/dashboard/manager/users' }],
|
||||
Channels: [{ label: '管理后台', path: '/dashboard/manager/users' }],
|
||||
// 详情页:区域根 / 列表页(末级为当前页标题)
|
||||
UserView: [
|
||||
{ label: '管理后台', path: '/dashboard/manager/users' },
|
||||
{ label: '用户管理', path: '/dashboard/manager/users' },
|
||||
],
|
||||
ApiKeyView: [
|
||||
ChannelView: [
|
||||
{ label: '管理后台', path: '/dashboard/manager/users' },
|
||||
{ label: '渠道管理', path: '/dashboard/manager/keys' },
|
||||
{ label: '渠道管理', path: '/dashboard/manager/channels' },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ const router = createRouter({
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
const isAuthenticated = localStorage.getItem('token')
|
||||
if (to.meta.requiresAuth && !isAuthenticated) {
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
if (requiresAuth && !isAuthenticated) {
|
||||
next('/login')
|
||||
} else {
|
||||
next()
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref } from 'vue';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
import request from '@/api/client';
|
||||
|
||||
export type Channel = {
|
||||
id: number
|
||||
name: string
|
||||
provider: string
|
||||
base_url: string
|
||||
weight: number
|
||||
priority: number
|
||||
timeout_ms: number
|
||||
max_concurrency: number
|
||||
health_status: string
|
||||
enabled: boolean
|
||||
formats?: string[]
|
||||
created_at?: string
|
||||
updated_at?: string
|
||||
selected?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type NewChannelPayload = {
|
||||
name: string
|
||||
provider: string
|
||||
base_url: string
|
||||
api_key: string
|
||||
priority?: number
|
||||
weight?: number
|
||||
formats?: string[]
|
||||
}
|
||||
|
||||
export const useChannelStore = defineStore('channel', () => {
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const totalChannels = ref(0);
|
||||
const channels = ref<Channel[]>([]);
|
||||
const channel = ref<Channel | null>(null);
|
||||
|
||||
const fetchChannels = async (pageSize = 20, page = 1) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await request.get('/channels', {
|
||||
params: { pageSize, page },
|
||||
});
|
||||
channels.value = response.data.data ?? [];
|
||||
totalChannels.value = response.data.total ?? 0;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch channels';
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchChannel = async (id: number | string) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await request.get(`/channels/${id}`);
|
||||
channel.value = response.data.data;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch channel';
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createChannel = async (data: NewChannelPayload) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response: AxiosResponse = await request.post('/channels', data);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to create channel';
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const updateChannel = async (id: number | string, data: Partial<Channel> & { api_key?: string }) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response: AxiosResponse = await request.put(`/channels/${id}`, data);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to update channel';
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deleteChannel = async (id: number | string) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response: AxiosResponse = await request.delete(`/channels/${id}`);
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to delete channel';
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const batchChannels = async (option: string, ids: (number | string)[]) => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response: AxiosResponse = await request.delete(`/channels/batch/${option}`, { data: { ids } });
|
||||
return response;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Batch operation failed';
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
loading, error,
|
||||
channel, channels, totalChannels,
|
||||
fetchChannels,
|
||||
fetchChannel,
|
||||
createChannel,
|
||||
updateChannel,
|
||||
deleteChannel,
|
||||
batchChannels,
|
||||
};
|
||||
});
|
||||
@@ -24,8 +24,8 @@ export const useKeyStore = defineStore('key', () => {
|
||||
},
|
||||
});
|
||||
|
||||
keys.value = response.data.data?.keys ?? [];
|
||||
totalKeys.value = response.data.data?.total ?? 0;
|
||||
keys.value = response.data.data ?? [];
|
||||
totalKeys.value = response.data.total ?? 0;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || '获取ApiKeys失败';
|
||||
throw error
|
||||
|
||||
@@ -37,8 +37,8 @@ export const useUserStore = defineStore('user', () => {
|
||||
active,
|
||||
},
|
||||
});
|
||||
users.value = response.data.data?.users ?? [];
|
||||
totalUsers.value = response.data.data?.total ?? 0;
|
||||
users.value = response.data.data ?? [];
|
||||
totalUsers.value = response.data.total ?? 0;
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || '获取用户列表失败';
|
||||
throw err;
|
||||
|
||||
+20
-14
@@ -8,7 +8,8 @@ export type UserInfo = {
|
||||
avatar_url?: string
|
||||
avatar?: string
|
||||
role: number
|
||||
active: boolean
|
||||
status?: string
|
||||
active?: boolean
|
||||
email_verified?: boolean
|
||||
timezone?: string
|
||||
language?: string
|
||||
@@ -41,10 +42,23 @@ export type TokenInfo = {
|
||||
|
||||
export type ApiKey = {
|
||||
id: number
|
||||
type: string
|
||||
user_id?: number
|
||||
name: string
|
||||
key_hash?: string
|
||||
key_plain?: string
|
||||
key_prefix?: string
|
||||
quota_tokens_per_day?: number
|
||||
quota_requests_per_day?: number
|
||||
allowed_models?: string[]
|
||||
expires_at?: number
|
||||
status: string
|
||||
last_used_at?: number
|
||||
created_at?: number
|
||||
updated_at?: number
|
||||
// 兼容旧字段
|
||||
type?: string
|
||||
apikey?: string
|
||||
active: boolean
|
||||
active?: boolean
|
||||
endpoint?: string
|
||||
resource_name?: string
|
||||
api_secret?: string
|
||||
@@ -85,17 +99,8 @@ export type TokenPayload = {
|
||||
|
||||
export type NewApiKeyPayload = {
|
||||
name: string
|
||||
type: string
|
||||
apikey: string
|
||||
active: boolean
|
||||
endpoint?: string
|
||||
resource_name?: string
|
||||
api_secret?: string
|
||||
model_prefix?: string
|
||||
model_alias?: string
|
||||
parameters?: string
|
||||
support_models?: string
|
||||
support_models_array?: string[]
|
||||
quota_tokens_per_day?: number
|
||||
quota_requests_per_day?: number
|
||||
}
|
||||
|
||||
export type NewUserPayload = {
|
||||
@@ -104,6 +109,7 @@ export type NewUserPayload = {
|
||||
email?: string
|
||||
name?: string
|
||||
role?: number
|
||||
status?: string
|
||||
active?: boolean
|
||||
quota?: number
|
||||
unlimited_quota?: boolean
|
||||
|
||||
@@ -38,7 +38,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
redirect: '/dashboard/overview',
|
||||
children: [
|
||||
{ path: 'overview', name: 'Overview', component: () => import('@/views/dashboard/Overview.vue'), meta: { title: '仪表盘' } },
|
||||
{ path: 'tokens', name: 'Tokens', component: () => import('@/views/dashboard/Tokens.vue'), meta: { title: 'API Keys' } },
|
||||
{ path: 'apikeys', name: 'ApiKeys', component: () => import('@/views/dashboard/ApiKeys.vue'), meta: { title: 'API Keys' } },
|
||||
{
|
||||
path: 'manager',
|
||||
name: 'Manager',
|
||||
@@ -48,8 +48,8 @@ export const routes: RouteRecordRaw[] = [
|
||||
{ path: 'users', name: 'User', component: () => import('@/views/dashboard/User.vue'), meta: { title: '用户管理' } },
|
||||
{ path: 'users/new', name: 'UserNew', component: () => import('@/views/dashboard/UserNew.vue'), meta: { title: '新建用户' } },
|
||||
{ path: 'users/view', name: 'UserView', component: () => import('@/views/dashboard/UserView.vue'), meta: { title: '用户详情' } },
|
||||
{ path: 'keys', name: 'ApiKey', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } },
|
||||
{ path: 'keys/view', name: 'ApiKeyView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } },
|
||||
{ path: 'channels', name: 'Channels', component: () => import('@/views/dashboard/Keys.vue'), meta: { title: '渠道管理' } },
|
||||
{ path: 'channels/view', name: 'ChannelView', component: () => import('@/views/dashboard/KeyView.vue'), meta: { title: '渠道详情' } },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -68,12 +68,12 @@ export const routes: RouteRecordRaw[] = [
|
||||
// 控制台菜单(所有登录用户)
|
||||
export const consoleMenu: MenuLink[] = [
|
||||
{ label: '仪表盘', to: '/dashboard/overview', icon: GaugeIcon },
|
||||
{ label: 'API Keys', to: '/dashboard/tokens', icon: KeyRoundIcon },
|
||||
{ label: 'API Keys', to: '/dashboard/apikeys', icon: KeyRoundIcon },
|
||||
{ label: '账户设置', to: '/dashboard/settings/profile', icon: SettingsIcon },
|
||||
]
|
||||
|
||||
// 管理后台菜单(role >= 10)
|
||||
export const adminMenu: MenuLink[] = [
|
||||
{ label: '用户管理', to: '/dashboard/manager/users', icon: UsersRoundIcon },
|
||||
{ label: '渠道管理', to: '/dashboard/manager/keys', icon: GlobeIcon },
|
||||
{ label: '渠道管理', to: '/dashboard/manager/channels', icon: GlobeIcon },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
<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">API keys authenticate OpenAI-compatible clients with your team.</p>
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new API key">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New API Key
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto" v-if="keys.length">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">Name</th>
|
||||
<th>Key</th>
|
||||
<th>Status</th>
|
||||
<th class="text-right">Quota/Day</th>
|
||||
<th class="text-right">Requests/Day</th>
|
||||
<th>Created</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in keys" :key="key.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4 font-medium">{{ key.name }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="font-mono text-xs text-base-content/60">
|
||||
{{ isRevealed(key.id) ? key.key_plain : maskedKey(key) }}
|
||||
</span>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
@click="toggleReveal(key.id)"
|
||||
:aria-label="isRevealed(key.id) ? 'Hide key' : 'Reveal key'"
|
||||
>
|
||||
<EyeOffIcon v-if="isRevealed(key.id)" class="h-3.5 w-3.5" />
|
||||
<EyeIcon v-else class="h-3.5 w-3.5 text-base-content/40" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square"
|
||||
:class="copiedKeyId === key.id ? 'text-success' : ''"
|
||||
@click="copyKey(key)"
|
||||
aria-label="Copy key"
|
||||
>
|
||||
<CheckIcon v-if="copiedKeyId === key.id" class="h-3.5 w-3.5" />
|
||||
<ClipboardCopyIcon v-else class="h-3.5 w-3.5 text-base-content/40" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="key.status !== 'active' && 'toggle-error'" :checked="key.status === 'active'"
|
||||
@change="updateStatus(key)" :aria-label="`Toggle key ${key.name}`" />
|
||||
</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<template v-if="key.quota_tokens_per_day">{{ key.quota_tokens_per_day }}</template>
|
||||
<template v-else class="text-base-content/40">—</template>
|
||||
</td>
|
||||
<td class="text-right tabular-nums">
|
||||
<template v-if="key.quota_requests_per_day">{{ key.quota_requests_per_day }}</template>
|
||||
<template v-else class="text-base-content/40">—</template>
|
||||
</td>
|
||||
<td class="tabular-nums text-base-content/70 text-sm">{{ formatDate(key.created_at) }}</td>
|
||||
<td class="pr-3 text-right">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button v-if="key.status !== 'revoked'" class="btn btn-ghost btn-xs btn-square text-error"
|
||||
@click="confirmDeleteKey(key)" aria-label="Revoke key">
|
||||
<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">
|
||||
<KeyRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No API keys yet</h2>
|
||||
<p class="max-w-xs text-sm text-base-content/60">
|
||||
Create an API key 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 API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||
|
||||
<!-- New key 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import Pagination from '@/components/common/Pagination.vue';
|
||||
import TokenNew from '@/views/dashboard/TokenNew.vue';
|
||||
import { useKeyStore } from '@/stores/key';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { ApiKey } from '@/types';
|
||||
import {
|
||||
PlusIcon, TrashIcon, KeyRoundIcon,
|
||||
EyeIcon, EyeOffIcon, ClipboardCopyIcon, CheckIcon
|
||||
} from '@lucide/vue';
|
||||
|
||||
const keyStore = useKeyStore();
|
||||
const { setToast } = useToast();
|
||||
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const totalItems = computed(() => keyStore.totalKeys);
|
||||
const keys = computed(() => keyStore.keys);
|
||||
|
||||
const revealedKeys = ref<Set<number>>(new Set());
|
||||
const copiedKeyId = ref<number | null>(null);
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchKeys();
|
||||
})
|
||||
|
||||
const fetchKeys = async (size?: number, page?: number) => {
|
||||
currentPage.value = page || currentPage.value;
|
||||
await keyStore.fetchKeys(size ?? pageSize.value, currentPage.value);
|
||||
}
|
||||
|
||||
const changePage = async (page: number, size: number) => {
|
||||
if (page == currentPage.value && size == pageSize.value) {
|
||||
return
|
||||
}
|
||||
currentPage.value = page;
|
||||
pageSize.value = size;
|
||||
await fetchKeys();
|
||||
};
|
||||
|
||||
const isRevealed = (id: number) => revealedKeys.value.has(id);
|
||||
|
||||
const toggleReveal = (id: number) => {
|
||||
if (revealedKeys.value.has(id)) {
|
||||
revealedKeys.value.delete(id);
|
||||
} else {
|
||||
revealedKeys.value.add(id);
|
||||
}
|
||||
// Force reactivity update
|
||||
revealedKeys.value = new Set(revealedKeys.value);
|
||||
};
|
||||
|
||||
const maskedKey = (key: ApiKey) => {
|
||||
const plain = key.key_plain;
|
||||
if (!plain) return key.key_prefix + '...';
|
||||
if (plain.length <= 16) return plain.slice(0, 4) + '****' + plain.slice(-4);
|
||||
return plain.slice(0, 12) + '****' + plain.slice(-4);
|
||||
};
|
||||
|
||||
const copyKey = async (key: ApiKey) => {
|
||||
const value = key.key_plain;
|
||||
if (!value) {
|
||||
setToast('Key value not available', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
copiedKeyId.value = key.id;
|
||||
setToast(`Key "${key.name}" copied`, 'success');
|
||||
setTimeout(() => { copiedKeyId.value = null; }, 2000);
|
||||
} catch {
|
||||
setToast('Failed to copy', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const updateStatus = async (key: any) => {
|
||||
try {
|
||||
const action = key.status === 'active' ? 'disable' : 'enable';
|
||||
const res = await keyStore.keyOption(action, [key.id]);
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`Key ${key.name} has been ${action}`, 'success');
|
||||
}
|
||||
await fetchKeys();
|
||||
} catch (error: any) {
|
||||
console.error('Status update failed:', error);
|
||||
setToast('Status update failed', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDeleteKey = async (key: any) => {
|
||||
if (confirm(`Revoke key "${key.name}"? This cannot be undone.`)) {
|
||||
await deleteKey(key);
|
||||
}
|
||||
}
|
||||
|
||||
const deleteKey = async (key: any) => {
|
||||
try {
|
||||
const res = await keyStore.keyOption('delete', [key.id]);
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`Key ${key.name} revoked`, 'success');
|
||||
}
|
||||
await fetchKeys();
|
||||
} catch (error: any) {
|
||||
setToast('Failed to revoke key', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
const formatDate = (dateString?: string | number): string => {
|
||||
if (!dateString) return '—';
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return '—';
|
||||
}
|
||||
};
|
||||
|
||||
// Modal
|
||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||
const openModal = () => {
|
||||
modalRef.value?.showModal();
|
||||
};
|
||||
const closeModal = async () => {
|
||||
if (modalRef.value) {
|
||||
modalRef.value.close();
|
||||
}
|
||||
await fetchKeys();
|
||||
};
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<header class="mb-4 pr-8">
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New API Key</h2>
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New Channel</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">Connect an upstream LLM provider to your team.</p>
|
||||
</header>
|
||||
|
||||
@@ -11,53 +11,48 @@
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createApiKey">
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createChannel">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
<label for="ch-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-name" name="name" type="text" v-model="newApiKey.name" placeholder="e.g. prod-openai"
|
||||
<input id="ch-name" name="name" type="text" v-model="newChannel.name" placeholder="e.g. prod-openai"
|
||||
autocomplete="off" spellcheck="false" class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-type" class="mb-1 block text-sm font-medium">
|
||||
Type <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-provider" class="mb-1 block text-sm font-medium">
|
||||
Provider <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select id="key-type" name="type" v-model="newApiKey.type" required
|
||||
class="select select-bordered w-full pl-10">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="azure">Azure</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="openai-compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
<img :src="apiKeyImageUrl(newApiKey.type)" alt="" width="20" height="20"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||
</div>
|
||||
<select id="ch-provider" name="provider" v-model="newChannel.provider" required
|
||||
class="select select-bordered w-full">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-apikey" class="mb-1 block text-sm font-medium">
|
||||
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-base-url" class="mb-1 block text-sm font-medium">
|
||||
Base URL <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-apikey" name="apikey" type="text" v-model="newApiKey.apikey"
|
||||
placeholder="sk-…" autocomplete="off" spellcheck="false"
|
||||
<input id="ch-base-url" name="base_url" type="url" v-model="newChannel.base_url"
|
||||
placeholder="https://api.openai.com" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||
<input id="key-endpoint" name="endpoint" type="url" v-model="newApiKey.endpoint"
|
||||
placeholder="https://api.example.com/v1" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
<label for="ch-api-key" class="mb-1 block text-sm font-medium">
|
||||
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="ch-api-key" name="api_key" type="password" v-model="newChannel.api_key"
|
||||
placeholder="sk-…" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -70,57 +65,14 @@
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||
<input id="key-resource" name="resource_name" type="text" v-model="newApiKey.resource_name"
|
||||
placeholder="Azure resource name" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" />
|
||||
<label for="ch-priority" class="mb-1 block text-sm font-medium">Priority</label>
|
||||
<input id="ch-priority" name="priority" type="number" v-model.number="newChannel.priority"
|
||||
placeholder="0" autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||
<input id="key-secret" name="api_secret" type="text" v-model="newApiKey.api_secret"
|
||||
placeholder="Optional secret" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||
<input id="key-prefix" name="model_prefix" type="text" v-model="newApiKey.model_prefix"
|
||||
placeholder="e.g. azure-gpt" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||
<textarea id="key-alias" name="model_alias" v-model="newApiKey.model_alias" rows="2"
|
||||
placeholder='{"gpt-4o": "my-gpt4o"}' spellcheck="false"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||
<textarea id="key-parameters" name="parameters" v-model="newApiKey.parameters" rows="2"
|
||||
placeholder="{}" spellcheck="false"
|
||||
class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||
<TagInput v-model="newApiKey.support_models_array" clearable
|
||||
placeholder="Type a model and press Enter" @change="onchange_supportmodel" />
|
||||
<span class="mt-1 block text-xs text-base-content/50">Restrict which models this key can serve. Empty allows all.</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newApiKey.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newApiKey.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="Key active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newApiKey.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<label for="ch-weight" class="mb-1 block text-sm font-medium">Weight</label>
|
||||
<input id="ch-weight" name="weight" type="number" v-model.number="newChannel.weight"
|
||||
placeholder="1" autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -130,7 +82,7 @@
|
||||
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Create API Key
|
||||
Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -140,129 +92,74 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { useKeyStore } from '@/stores/key';
|
||||
import { CircleAlert } from '@lucide/vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { NewApiKeyPayload } from '@/types';
|
||||
import { useChannelStore, type NewChannelPayload } from '@/stores/channel'
|
||||
import { CircleAlert } from '@lucide/vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
|
||||
const keyStore = useKeyStore()
|
||||
const channelStore = useChannelStore()
|
||||
const { setToast } = useToast()
|
||||
const error = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
|
||||
// Control advanced options visibility
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
// Initialize API key object
|
||||
const newApiKey = ref<NewApiKeyPayload>({
|
||||
const newChannel = ref<NewChannelPayload>({
|
||||
name: '',
|
||||
type: '',
|
||||
apikey: '',
|
||||
active: true,
|
||||
endpoint: '',
|
||||
resource_name: '',
|
||||
// deployment_name: '',
|
||||
api_secret: '',
|
||||
model_prefix: '',
|
||||
model_alias: '',
|
||||
parameters: '{}',
|
||||
support_models: '[]',
|
||||
support_models_array: [],
|
||||
provider: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
})
|
||||
|
||||
const resetNewApiKey = () => {
|
||||
newApiKey.value = {
|
||||
const resetNewChannel = () => {
|
||||
newChannel.value = {
|
||||
name: '',
|
||||
type: '',
|
||||
apikey: '',
|
||||
active: true,
|
||||
endpoint: '',
|
||||
resource_name: '',
|
||||
// deployment_name: '',
|
||||
api_secret: '',
|
||||
model_prefix: '',
|
||||
model_alias: '',
|
||||
parameters: '{}',
|
||||
support_models: '[]',
|
||||
support_models_array: [],
|
||||
provider: '',
|
||||
base_url: '',
|
||||
api_key: '',
|
||||
priority: 0,
|
||||
weight: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const onchange_supportmodel = () => {
|
||||
newApiKey.value.support_models = JSON.stringify(newApiKey.value.support_models_array)
|
||||
}
|
||||
|
||||
// Form validation
|
||||
const isFormValid = computed(() => {
|
||||
return newApiKey.value.name &&
|
||||
newApiKey.value.type &&
|
||||
newApiKey.value.apikey
|
||||
return newChannel.value.name &&
|
||||
newChannel.value.provider &&
|
||||
newChannel.value.base_url &&
|
||||
newChannel.value.api_key
|
||||
})
|
||||
|
||||
const cancel = () => {
|
||||
resetNewApiKey()
|
||||
resetNewChannel()
|
||||
emit('closeModal', true)
|
||||
}
|
||||
|
||||
const apiKeyImageMap: Record<string, string> = {
|
||||
'openai': '/assets/openai.svg',
|
||||
'claude': '/assets/claude.svg',
|
||||
'gemini': '/assets/gemini.svg',
|
||||
'azure': '/assets/azure.svg',
|
||||
'github': '/assets/github.svg'
|
||||
|
||||
};
|
||||
|
||||
const apiKeyImageUrl = (keytype: string) => {
|
||||
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
||||
};
|
||||
|
||||
const createApiKey = async () => {
|
||||
const createChannel = async () => {
|
||||
if (!isFormValid.value) {
|
||||
setToast('Please fill in all required fields (Name, Type, API Key).', 'error')
|
||||
setToast('Please fill in all required fields.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
try {
|
||||
if (!Array.isArray(newApiKey.value.support_models_array)) {
|
||||
setToast('Support Models must be a JSON array.', 'error');
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
setToast('Invalid JSON format for Support Models.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to parse parameters JSON
|
||||
try {
|
||||
JSON.parse(newApiKey.value.parameters || '{}');
|
||||
} catch (e) {
|
||||
setToast('Invalid JSON format for Parameters.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await keyStore.createKey(newApiKey.value);
|
||||
if (res.data?.code === 200) {
|
||||
error.value = null;
|
||||
resetNewApiKey();
|
||||
setToast('API Key created successfully.', 'success')
|
||||
const res = await channelStore.createChannel(newChannel.value)
|
||||
if (res.data?.id) {
|
||||
error.value = null
|
||||
resetNewChannel()
|
||||
setToast('Channel created successfully.', 'success')
|
||||
emit('closeModal', true)
|
||||
} else {
|
||||
setToast(res.data?.error || res.data?.message || 'Failed to create API Key', 'error')
|
||||
setToast(res.data?.error || 'Failed to create channel', 'error')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.log('createApiKey error:', err)
|
||||
error.value = err?.message || String(err) || 'Failed to create API Key'
|
||||
error.value = err.response?.data?.error || 'Failed to create channel'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'closeModal', value: boolean): void
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,52 +1,40 @@
|
||||
<template>
|
||||
<div class="space-y-5">
|
||||
<BreadcrumbHeader title="渠道详情" />
|
||||
<BreadcrumbHeader title="Channel Details" />
|
||||
|
||||
<div v-if="key" class="space-y-5">
|
||||
<div v-if="ch" class="space-y-5">
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<form @submit.prevent="updateKey" class="card-body gap-5 p-4 sm:p-6">
|
||||
<form @submit.prevent="updateCh" class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h2>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
<label for="ch-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-name" name="name" type="text" v-model="key.name" autocomplete="off" spellcheck="false"
|
||||
<input id="ch-name" name="name" type="text" v-model="ch.name" autocomplete="off" spellcheck="false"
|
||||
class="input input-bordered w-full" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-type" class="mb-1 block text-sm font-medium">
|
||||
Type <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-provider" class="mb-1 block text-sm font-medium">
|
||||
Provider
|
||||
</label>
|
||||
<div class="relative">
|
||||
<select id="key-type" name="type" v-model="key.type" required
|
||||
class="select select-bordered w-full pl-10">
|
||||
<option disabled value="">Select provider</option>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="claude">Claude</option>
|
||||
<option value="gemini">Gemini</option>
|
||||
<option value="azure">Azure</option>
|
||||
<option value="github">GitHub</option>
|
||||
<option value="openai-compatible">OpenAI Compatible</option>
|
||||
</select>
|
||||
<img :src="apiKeyImageUrl(key.type)" alt="" width="20" height="20"
|
||||
class="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 rounded-full bg-base-200 p-0.5" />
|
||||
</div>
|
||||
<div class="input input-bordered w-full bg-base-200">{{ ch.provider }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-apikey" class="mb-1 block text-sm font-medium">
|
||||
API Key <span class="text-error" aria-hidden="true">*</span>
|
||||
<label for="ch-base-url" class="mb-1 block text-sm font-medium">
|
||||
Base URL <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="key-apikey" name="apikey" type="text" v-model="key.apikey" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full font-mono text-sm" required />
|
||||
<input id="ch-base-url" name="base_url" type="url" v-model="ch.base_url" spellcheck="false"
|
||||
class="input input-bordered w-full font-mono text-sm" required />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-endpoint" class="mb-1 block text-sm font-medium">Endpoint</label>
|
||||
<input id="key-endpoint" name="endpoint" type="url" v-model="key.endpoint" spellcheck="false"
|
||||
<label for="ch-api-key" class="mb-1 block text-sm font-medium">API Key</label>
|
||||
<input id="ch-api-key" name="api_key" type="password" v-model="api_key" autocomplete="off"
|
||||
spellcheck="false" placeholder="Leave blank to keep current"
|
||||
class="input input-bordered w-full font-mono text-sm" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -60,48 +48,36 @@
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="key-resource" class="mb-1 block text-sm font-medium">Resource Name</label>
|
||||
<input id="key-resource" name="resource_name" type="text" v-model="key.resource_name"
|
||||
autocomplete="off" spellcheck="false" class="input input-bordered w-full" />
|
||||
<label for="ch-priority" class="mb-1 block text-sm font-medium">Priority</label>
|
||||
<input id="ch-priority" name="priority" type="number" v-model.number="ch.priority"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-secret" class="mb-1 block text-sm font-medium">API Secret</label>
|
||||
<input id="key-secret" name="api_secret" type="text" v-model="key.api_secret" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full font-mono text-sm" />
|
||||
<label for="ch-weight" class="mb-1 block text-sm font-medium">Weight</label>
|
||||
<input id="ch-weight" name="weight" type="number" v-model.number="ch.weight"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-prefix" class="mb-1 block text-sm font-medium">Model Prefix</label>
|
||||
<input id="key-prefix" name="model_prefix" type="text" v-model="key.model_prefix" autocomplete="off"
|
||||
spellcheck="false" class="input input-bordered w-full" />
|
||||
<label for="ch-timeout" class="mb-1 block text-sm font-medium">Timeout (ms)</label>
|
||||
<input id="ch-timeout" name="timeout_ms" type="number" v-model.number="ch.timeout_ms"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-alias" class="mb-1 block text-sm font-medium">Model Alias</label>
|
||||
<textarea id="key-alias" name="model_alias" v-model="key.model_alias" rows="2" placeholder='{}'
|
||||
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
<label for="ch-max-concurrency" class="mb-1 block text-sm font-medium">Max Concurrency</label>
|
||||
<input id="ch-max-concurrency" name="max_concurrency" type="number" v-model.number="ch.max_concurrency"
|
||||
autocomplete="off" class="input input-bordered w-full" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="key-parameters" class="mb-1 block text-sm font-medium">Parameters (JSON)</label>
|
||||
<textarea id="key-parameters" name="parameters" v-model="key.parameters" rows="2" placeholder="{}"
|
||||
spellcheck="false" class="textarea textarea-bordered w-full font-mono text-sm"></textarea>
|
||||
</div>
|
||||
|
||||
<label class="block">
|
||||
<span class="mb-1 block text-sm font-medium">Support Models</span>
|
||||
<TagInput v-model="key.support_models_array" clearable placeholder="Type a model and press Enter"
|
||||
@change="onchange_supportmodel" />
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="key.active" role="switch" class="toggle toggle-sm"
|
||||
:class="key.active ? 'toggle-success' : 'toggle-error'" aria-label="Key active" />
|
||||
<input type="checkbox" name="enabled" :checked="ch.enabled" role="switch" class="toggle toggle-sm"
|
||||
:class="ch.enabled ? 'toggle-success' : 'toggle-error'" @change="toggleEnabled" aria-label="Channel enabled" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ key.active ? 'Active' : 'Inactive' }}
|
||||
{{ ch.enabled ? 'Enabled' : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +86,7 @@
|
||||
</section>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-base-300/40 pt-4">
|
||||
<button type="button" @click="cancel" class="btn btn-ghost btn-sm">Back</button>
|
||||
<button type="button" @click="goBack" class="btn btn-ghost btn-sm">Back</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="updating">
|
||||
<span v-if="updating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Save Changes
|
||||
@@ -123,7 +99,7 @@
|
||||
<!-- Loading state -->
|
||||
<div v-else class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading key details">
|
||||
<div class="flex items-center justify-center py-10" role="status" aria-label="Loading channel details">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -132,76 +108,62 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useKeyStore } from '../../stores/key';
|
||||
import { useChannelStore, type Channel } from '../../stores/channel';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import TagInput from '@/components/common/TagInput.vue';
|
||||
import { useToast } from '@/composables/toast';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const keyStore = useKeyStore();
|
||||
const channelStore = useChannelStore();
|
||||
const { setToast } = useToast();
|
||||
const updating = ref(false);
|
||||
const api_key = ref('');
|
||||
|
||||
const keyId = computed(() => route.query.id);
|
||||
|
||||
const key = computed(() => keyStore.key);
|
||||
const loading = computed(() => keyStore.loading);
|
||||
const channelId = computed(() => route.query.id);
|
||||
const ch = computed(() => channelStore.channel);
|
||||
|
||||
onMounted(async () => {
|
||||
console.log('keyId', keyId.value)
|
||||
if (keyId.value) {
|
||||
await keyStore.fetchKey(keyId.value as string);
|
||||
if (channelId.value) {
|
||||
await channelStore.fetchChannel(channelId.value as string);
|
||||
}
|
||||
});
|
||||
|
||||
const keyOption = reactive([
|
||||
{name: 'openai', label: 'OpenAI'},
|
||||
{name: 'claude', label: 'Claude'},
|
||||
{name: 'gemini', label: 'Gemini'},
|
||||
{name: 'azure', label: 'Azure'},
|
||||
{name: 'github', label: 'Github'},
|
||||
{name: 'openai-compatible', label: 'OpenAI Compatible'}
|
||||
])
|
||||
|
||||
const apiKeyImageMap: Record<string, string> = {
|
||||
'openai': '/assets/openai.svg',
|
||||
'claude': '/assets/claude.svg',
|
||||
'gemini': '/assets/gemini.svg',
|
||||
'azure': '/assets/azure.svg',
|
||||
'github': '/assets/github.svg'
|
||||
const toggleEnabled = () => {
|
||||
if (!ch.value) return;
|
||||
ch.value.enabled = !ch.value.enabled;
|
||||
};
|
||||
|
||||
const apiKeyImageUrl = (keytype: string) => {
|
||||
return apiKeyImageMap[keytype] || '/assets/logo.svg';
|
||||
};
|
||||
|
||||
const onchange_supportmodel = () => {
|
||||
if (!key.value) return;
|
||||
key.value.support_models = JSON.stringify(key.value.support_models_array)
|
||||
}
|
||||
|
||||
const updateKey = async () => {
|
||||
if (!key.value) return;
|
||||
const updateCh = async () => {
|
||||
if (!ch.value) return;
|
||||
updating.value = true;
|
||||
try {
|
||||
const res = await keyStore.updateKey(key.value);
|
||||
console.log('updateKey', res)
|
||||
if (res.data?.code == 200) {
|
||||
setToast(`Key ${key.value.name} updated`, 'success');
|
||||
const payload: Partial<Channel> & { api_key?: string } = {
|
||||
name: ch.value.name,
|
||||
base_url: ch.value.base_url,
|
||||
priority: ch.value.priority,
|
||||
weight: ch.value.weight,
|
||||
timeout_ms: ch.value.timeout_ms,
|
||||
max_concurrency: ch.value.max_concurrency,
|
||||
enabled: ch.value.enabled,
|
||||
};
|
||||
if (api_key.value) {
|
||||
payload.api_key = api_key.value;
|
||||
}
|
||||
await keyStore.refreshKey(key.value.id);
|
||||
const res = await channelStore.updateChannel(ch.value.id, payload);
|
||||
if (res.data?.id) {
|
||||
setToast(`Channel ${ch.value.name} updated`, 'success');
|
||||
}
|
||||
await channelStore.fetchChannel(ch.value.id);
|
||||
} catch (err: any) {
|
||||
console.error('Error updating key:', err);
|
||||
console.error('Error updating channel:', err);
|
||||
} finally {
|
||||
updating.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
router.push({ name: 'ApiKey' });
|
||||
}
|
||||
|
||||
const goBack = () => {
|
||||
router.push({ name: 'Channels' });
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<BreadcrumbHeader />
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-base-content/60">Upstream provider keys used to serve model requests.</p>
|
||||
<p class="text-sm text-base-content/60">Upstream provider channels used to serve model requests.</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="dropdown dropdown-end">
|
||||
<button tabindex="0" class="btn btn-ghost btn-square btn-sm" aria-label="Batch actions">
|
||||
@@ -27,71 +27,62 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new API key">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New API Key
|
||||
<button class="btn btn-primary btn-sm" @click="openModal" aria-label="Create new channel">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />New Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status filter -->
|
||||
<details class="dropdown dropdown-end" v-if="keys.length">
|
||||
<summary class="btn btn-outline btn-sm h-8 min-h-8 border-dashed">
|
||||
<ListFilterIcon class="h-4 w-4" aria-hidden="true" />
|
||||
Status
|
||||
<span v-if="selectedStatuses.length" class="badge badge-primary badge-sm">{{ selectedStatuses.length }}</span>
|
||||
</summary>
|
||||
<ul tabindex="0" class="menu dropdown-content z-30 mt-2 w-32 rounded-box border border-base-300/60 bg-base-100 p-1.5 shadow-lg">
|
||||
<li v-for="status in statusOptions" :key="status">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" :checked="selectedStatuses.some(item => item.status === status)"
|
||||
@change="toggleStatusFilter(status)" />
|
||||
{{ status }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="card border border-base-300/60 bg-base-100 shadow-sm">
|
||||
<div class="overflow-x-auto" v-if="keys.length">
|
||||
<div class="overflow-x-auto" v-if="channels.length">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="text-xs uppercase tracking-wider text-base-content/50">
|
||||
<th class="pl-4">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="selectAll" @change="toggleSelectAll"
|
||||
aria-label="Select all keys" />
|
||||
aria-label="Select all channels" />
|
||||
</th>
|
||||
<th>Type</th>
|
||||
<th>Name</th>
|
||||
<th>Active</th>
|
||||
<th>Provider</th>
|
||||
<th>Base URL</th>
|
||||
<th>Health</th>
|
||||
<th>Status</th>
|
||||
<th class="pr-4 text-right"><span class="sr-only">Actions</span></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="key in keys" :key="key.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<tr v-for="ch in channels" :key="ch.id" class="border-base-300/40 hover:bg-base-200/50">
|
||||
<td class="pl-4">
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="key.selected"
|
||||
@change="toggleUserSelection(key)" :aria-label="`Select key ${key.name}`" />
|
||||
<input type="checkbox" class="checkbox checkbox-xs" v-model="ch.selected"
|
||||
@change="toggleSelection(ch)" :aria-label="`Select channel ${ch.name}`" />
|
||||
</td>
|
||||
<td class="max-w-40 truncate font-medium">{{ ch.name }}</td>
|
||||
<td>
|
||||
<div class="flex items-center gap-2">
|
||||
<img :src="displayIcon(key.type)" alt="" width="20" height="20" class="h-5 w-5 rounded-full bg-base-200 p-0.5" />
|
||||
<span class="text-sm capitalize">{{ key.type }}</span>
|
||||
<img :src="providerIcon(ch.provider)" alt="" width="20" height="20" class="h-5 w-5 rounded-full bg-base-200 p-0.5" />
|
||||
<span class="text-sm capitalize">{{ ch.provider }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="max-w-40 truncate font-medium">{{ key.name }}</td>
|
||||
<td class="max-w-48 truncate font-mono text-xs text-base-content/60">{{ ch.base_url }}</td>
|
||||
<td>
|
||||
<span class="badge badge-xs"
|
||||
:class="ch.health_status === 'healthy' ? 'badge-success badge-soft' : ch.health_status === 'cooldown' ? 'badge-error badge-soft' : 'badge-warning badge-soft'">
|
||||
{{ ch.health_status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="!key.active && 'toggle-error'" v-model="key.active" @change="updateStatus(key)"
|
||||
:aria-label="`Toggle key ${key.name}`" />
|
||||
:class="!ch.enabled && 'toggle-error'" :checked="ch.enabled" @change="toggleEnabled(ch)"
|
||||
:aria-label="`Toggle channel ${ch.name}`" />
|
||||
</td>
|
||||
<td class="pr-3">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewKey(key)" aria-label="View key details">
|
||||
<button class="btn btn-ghost btn-xs btn-square" @click="viewChannel(ch)" aria-label="View channel details">
|
||||
<EyeIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteKey(key)"
|
||||
aria-label="Delete key">
|
||||
<button class="btn btn-ghost btn-xs btn-square text-error" @click="confirmDeleteChannel(ch)"
|
||||
aria-label="Delete channel">
|
||||
<TrashIcon class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -103,13 +94,13 @@
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else class="flex flex-col items-center gap-2 px-4 py-14 text-center">
|
||||
<KeyRoundIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No API keys yet</h2>
|
||||
<GlobeIcon class="h-10 w-10 text-base-content/20" aria-hidden="true" />
|
||||
<h2 class="text-sm font-semibold">No channels yet</h2>
|
||||
<p class="max-w-xs text-sm text-base-content/60">
|
||||
Add an OpenAI, Claude, Gemini or compatible provider key to start serving requests.
|
||||
Add an upstream provider channel to start serving model requests.
|
||||
</p>
|
||||
<button class="btn btn-primary btn-sm mt-2" @click="openModal">
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create API Key
|
||||
<PlusIcon class="h-4 w-4" aria-hidden="true" />Create Channel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +109,7 @@
|
||||
<Pagination v-if="totalItems > 0" :currentPage="currentPage" :totalItems="totalItems" :pageSize="pageSize"
|
||||
:pageSizeOptions="[10, 20, 50, 100]" @changePage="changePage" />
|
||||
|
||||
<!-- New key modal -->
|
||||
<!-- New channel modal -->
|
||||
<dialog ref="modalRef" class="modal">
|
||||
<div class="modal-box max-w-3xl px-0 sm:px-6">
|
||||
<form method="dialog">
|
||||
@@ -134,193 +125,125 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import BreadcrumbHeader from '@/components/dashboard/BreadcrumbHeader.vue';
|
||||
import Pagination from '@/components/common/Pagination.vue';
|
||||
import KeyNew from '@/views/dashboard/KeyNew.vue';
|
||||
import { useKeyStore } from '@/stores/key';
|
||||
import { useChannelStore, type Channel } from '@/stores/channel';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { ApiKey } from '@/types';
|
||||
|
||||
import {
|
||||
BadgeXIcon, BadgeCheckIcon, EyeIcon, PlusIcon, Settings2Icon,
|
||||
TrashIcon, KeyRoundIcon, ListFilterIcon
|
||||
TrashIcon, GlobeIcon
|
||||
} from '@lucide/vue';
|
||||
|
||||
const router = useRouter();
|
||||
const keyStore = useKeyStore();
|
||||
const channelStore = useChannelStore();
|
||||
const { setToast } = useToast();
|
||||
|
||||
onMounted(async () => {
|
||||
await keyStore.fetchKeys();
|
||||
await fetchChannels();
|
||||
})
|
||||
|
||||
const keys = computed(() => keyStore.keys);
|
||||
const channels = computed(() => channelStore.channels);
|
||||
|
||||
// 用户数据
|
||||
const currentPage = ref(1);
|
||||
const pageSize = ref(10);
|
||||
const totalItems = computed(() => keyStore.totalKeys);
|
||||
const pageSize = ref(20);
|
||||
const totalItems = computed(() => channelStore.totalChannels);
|
||||
|
||||
|
||||
|
||||
// 封装公共的用户列表获取方法
|
||||
const fetchKeys = async (size?: number, page?: number, active?: boolean[] | boolean) => {
|
||||
const fetchChannels = async (size?: number, page?: number) => {
|
||||
currentPage.value = page || currentPage.value;
|
||||
await keyStore.fetchKeys(size ?? pageSize.value, page ?? currentPage.value, active ?? selectedStatuses.map(status => status.value));
|
||||
await channelStore.fetchChannels(size ?? pageSize.value, currentPage.value);
|
||||
};
|
||||
|
||||
// 分页与页面大小变化
|
||||
const changePage = async (page: number, size: number) => {
|
||||
if (page == currentPage.value && size == pageSize.value) {
|
||||
return
|
||||
}
|
||||
if (page == currentPage.value && size == pageSize.value) return;
|
||||
currentPage.value = page;
|
||||
pageSize.value = size;
|
||||
await fetchKeys();
|
||||
await fetchChannels();
|
||||
};
|
||||
|
||||
// 复选框选择状态
|
||||
const selectAll = ref(false)
|
||||
const selectedKeys = ref<ApiKey[]>([])
|
||||
const selectAll = ref(false);
|
||||
const selectedChannels = ref<Channel[]>([]);
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (keys.value.length === 0) {
|
||||
return
|
||||
}
|
||||
keys.value.forEach(key => key.selected = selectAll.value)
|
||||
|
||||
if (selectAll.value) {
|
||||
// Select all on the current page
|
||||
selectedKeys.value = keys.value.map(key => key)
|
||||
} else {
|
||||
// Clear all selections
|
||||
selectedKeys.value = []
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const toggleUserSelection = (key: ApiKey) => {
|
||||
if (selectedKeys.value.includes(key)) {
|
||||
selectedKeys.value = selectedKeys.value.filter(selected => selected !== key);
|
||||
} else {
|
||||
selectedKeys.value.push(key);
|
||||
}
|
||||
selectAll.value = selectedKeys.value.length === keys.value.length;
|
||||
channels.value.forEach(ch => ch.selected = selectAll.value);
|
||||
selectedChannels.value = selectAll.value ? [...channels.value] : [];
|
||||
};
|
||||
|
||||
// 状态筛选
|
||||
const statusOptions = ['Active', 'Inactive'];
|
||||
const selectedStatuses = reactive<{ status: string; value: boolean }[]>([]);
|
||||
|
||||
const toggleStatusFilter = async (status: string) => {
|
||||
const statusValue = status === 'Active';
|
||||
const index = selectedStatuses.findIndex(item => item.status === status);
|
||||
|
||||
if (index > -1) {
|
||||
selectedStatuses.splice(index, 1);
|
||||
const toggleSelection = (ch: Channel) => {
|
||||
if (selectedChannels.value.includes(ch)) {
|
||||
selectedChannels.value = selectedChannels.value.filter(s => s !== ch);
|
||||
} else {
|
||||
selectedStatuses.push({ status, value: statusValue });
|
||||
selectedChannels.value.push(ch);
|
||||
}
|
||||
|
||||
await fetchKeys(undefined, 1, undefined);
|
||||
selectAll.value = selectedChannels.value.length === channels.value.length;
|
||||
};
|
||||
|
||||
// 处理批量操作
|
||||
const handleBatchAction = async (action: string) => {
|
||||
if (selectedKeys.value.length === 0) {
|
||||
return setToast('请选择数据', 'error');
|
||||
if (selectedChannels.value.length === 0) {
|
||||
return setToast('Please select channels first', 'error');
|
||||
}
|
||||
if (!['enable', 'disable', 'delete'].includes(action)) {
|
||||
return setToast(`无效的操作 ${action}`, 'error');
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await keyStore.keyOption(action, selectedKeys.value.map(item => item.id));
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`Key ${action} Success`, 'success');
|
||||
const ids = selectedChannels.value.map(ch => ch.id);
|
||||
if (action === 'delete') {
|
||||
for (const id of ids) {
|
||||
await channelStore.deleteChannel(id);
|
||||
}
|
||||
} else {
|
||||
setToast(res.data.error || `${action} Failed`, 'error');
|
||||
for (const id of ids) {
|
||||
await channelStore.updateChannel(id, { enabled: action === 'enable' });
|
||||
}
|
||||
}
|
||||
selectedKeys.value = [];
|
||||
setToast(`Channels ${action} succeeded`, 'success');
|
||||
selectedChannels.value = [];
|
||||
selectAll.value = false;
|
||||
await fetchKeys();
|
||||
|
||||
await fetchChannels();
|
||||
} catch (error: any) {
|
||||
console.error(`批量操作 ${action} 失败:`, error);
|
||||
setToast('批量操作失败', 'error');
|
||||
setToast(`Batch ${action} failed`, 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// 更新用户状态
|
||||
const updateStatus = async (key: ApiKey) => {
|
||||
const toggleEnabled = async (ch: Channel) => {
|
||||
try {
|
||||
const action = key.active ? 'enable' : 'disable';
|
||||
const res = await keyStore.keyOption(action, [key.id]);
|
||||
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`Key ${key.name} has been ${action}`, 'success');
|
||||
}
|
||||
await fetchKeys();
|
||||
await channelStore.updateChannel(ch.id, { enabled: !ch.enabled });
|
||||
setToast(`Channel ${ch.name} ${ch.enabled ? 'disabled' : 'enabled'}`, 'success');
|
||||
await fetchChannels();
|
||||
} catch (error: any) {
|
||||
console.error('状态更新失败:', error);
|
||||
setToast('状态更新失败', 'error');
|
||||
setToast('Status update failed', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const viewKey = (key: ApiKey) => {
|
||||
router.push({ name: 'ApiKeyView', query: { id: key.id } });
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
const confirmDeleteKey = async (key: ApiKey) => {
|
||||
if (confirm(`确认删除 ${key.name}?`)) {
|
||||
await deleteKey(key);
|
||||
}
|
||||
const viewChannel = (ch: Channel) => {
|
||||
router.push({ name: 'ChannelView', query: { id: ch.id } });
|
||||
};
|
||||
|
||||
const deleteKey = async (key: ApiKey) => {
|
||||
try {
|
||||
const res = await keyStore.keyOption('delete', [key.id]);
|
||||
if (res.data?.code === 200) {
|
||||
setToast('删除成功', 'success');
|
||||
const confirmDeleteChannel = async (ch: Channel) => {
|
||||
if (confirm(`Delete channel "${ch.name}"?`)) {
|
||||
try {
|
||||
await channelStore.deleteChannel(ch.id);
|
||||
setToast(`Channel ${ch.name} deleted`, 'success');
|
||||
await fetchChannels();
|
||||
} catch (error: any) {
|
||||
setToast('Delete failed', 'error');
|
||||
}
|
||||
|
||||
await fetchKeys();
|
||||
} catch (error: any) {
|
||||
console.error('删除失败:', error);
|
||||
setToast('删除失败', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const displayIcon = (apitype: string) => {
|
||||
switch (apitype) {
|
||||
case 'openai':
|
||||
return '/assets/openai.svg';
|
||||
case 'claude':
|
||||
return '/assets/claude.svg';
|
||||
case 'gemini':
|
||||
return '/assets/gemini.svg'
|
||||
case 'azure':
|
||||
return '/assets/azure.svg';
|
||||
case 'github':
|
||||
return '/assets/github.svg';
|
||||
default:
|
||||
return '/assets/logo.svg';
|
||||
}
|
||||
const providerIcon = (provider: string) => {
|
||||
const map: Record<string, string> = {
|
||||
openai: '/assets/openai.svg',
|
||||
anthropic: '/assets/claude.svg',
|
||||
compatible: '/assets/logo.svg',
|
||||
};
|
||||
return map[provider] || '/assets/logo.svg';
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
// 关闭模态框
|
||||
const modalRef = ref<HTMLDialogElement | null>(null);
|
||||
const openModal = () => {
|
||||
modalRef.value?.showModal();
|
||||
};
|
||||
const openModal = () => { modalRef.value?.showModal(); };
|
||||
const closeModal = async () => {
|
||||
if (modalRef.value) {
|
||||
modalRef.value.close();
|
||||
}
|
||||
await fetchKeys();
|
||||
modalRef.value?.close();
|
||||
await fetchChannels();
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
<dt class="shrink-0 text-base-content/60">状态</dt>
|
||||
<dd>
|
||||
<span class="badge badge-sm"
|
||||
:class="user?.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user?.active ? 'Active' : 'Inactive' }}
|
||||
:class="user?.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user?.status === 'active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
@@ -24,8 +24,8 @@
|
||||
</div>
|
||||
<div class="ml-auto hidden flex-col items-end gap-1 text-sm sm:flex">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
:class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.status === 'active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="flex items-center gap-1 text-base-content/60">
|
||||
Quota:
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
:class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.status === 'active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="badge badge-sm"
|
||||
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="mx-auto w-full max-w-3xl">
|
||||
<header class="mb-4 pr-8">
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New Token</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">Tokens authenticate OpenAI-compatible clients with your team.</p>
|
||||
<h2 class="text-lg font-semibold tracking-tight">Create New API Key</h2>
|
||||
<p class="mt-0.5 text-sm text-base-content/60">API keys authenticate OpenAI-compatible clients with your team.</p>
|
||||
</header>
|
||||
|
||||
<div v-if="error" role="alert" class="alert alert-error mb-4 text-sm">
|
||||
@@ -11,16 +11,60 @@
|
||||
<button type="button" class="btn btn-ghost btn-xs" aria-label="Dismiss error" @click="error = null">✕</button>
|
||||
</div>
|
||||
|
||||
<form class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createToken">
|
||||
<!-- Success State: Show created key -->
|
||||
<div v-if="createdKey" class="card border border-success/30 bg-success/5 shadow-sm">
|
||||
<div class="card-body gap-4 p-4 sm:p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex h-10 w-10 items-center justify-center rounded-full bg-success/10">
|
||||
<CheckCircleIcon class="h-5 w-5 text-success" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">API Key Created</h3>
|
||||
<p class="text-xs text-base-content/60">Copy this key now. It won't be shown again.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-xs font-medium">Your API Key</span>
|
||||
</label>
|
||||
<div class="join w-full">
|
||||
<input
|
||||
type="text"
|
||||
:value="createdKey"
|
||||
readonly
|
||||
class="input input-bordered join-item flex-1 font-mono text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-success join-item gap-1.5"
|
||||
@click="copyToClipboard"
|
||||
>
|
||||
<ClipboardCopyIcon class="h-4 w-4" />
|
||||
{{ copied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end border-t border-base-300/40 pt-4">
|
||||
<button type="button" class="btn btn-ghost btn-sm" @click="closeAfterCreate">
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Form -->
|
||||
<form v-else class="card border border-base-300/60 bg-base-100 shadow-sm" @submit.prevent="createKey">
|
||||
<div class="card-body gap-5 p-4 sm:p-6">
|
||||
<section class="space-y-4">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wider text-base-content/50">Basic Information</h3>
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="token-name" class="mb-1 block text-sm font-medium">
|
||||
<label for="key-name" class="mb-1 block text-sm font-medium">
|
||||
Name <span class="text-error" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input id="token-name" name="name" type="text" v-model="newToken.name"
|
||||
<input id="key-name" name="name" type="text" v-model="newKey.name"
|
||||
placeholder="e.g. my-laptop" autocomplete="off"
|
||||
class="input input-bordered w-full" required />
|
||||
</div>
|
||||
@@ -35,56 +79,19 @@
|
||||
<div class="collapse-content px-3">
|
||||
<div class="grid grid-cols-1 gap-x-4 gap-y-4 pt-3 md:grid-cols-2">
|
||||
<div>
|
||||
<label for="token-key" class="mb-1 block text-sm font-medium">Key</label>
|
||||
<div class="relative">
|
||||
<input id="token-key" name="key" :type="isTokenVisible ? 'text' : 'password'" v-model="newToken.key"
|
||||
autocomplete="off" spellcheck="false" placeholder="Leave blank to generate"
|
||||
class="input input-bordered w-full pr-10" />
|
||||
<button type="button" @click="toggleTokenVisibility"
|
||||
class="absolute inset-y-0 right-0 flex items-center px-3 text-base-content/60 hover:text-base-content"
|
||||
:aria-label="isTokenVisible ? 'Hide token key' : 'Show token key'"
|
||||
id="token-visibility-toggle">
|
||||
<EyeOff v-if="!isTokenVisible" class="h-4 w-4" aria-hidden="true" />
|
||||
<Eye v-else class="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
<label for="key-quota-tokens" class="mb-1 block text-sm font-medium">Quota Tokens/Day</label>
|
||||
<input id="key-quota-tokens" name="quota_tokens_per_day" type="number" inputmode="numeric"
|
||||
v-model.number="newKey.quota_tokens_per_day"
|
||||
placeholder="0 = unlimited" autocomplete="off"
|
||||
class="input input-bordered w-full" :min="0" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="token-expired" class="mb-1 block text-sm font-medium">Expired at</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="token-expired" name="expired_at" type="date" v-model="newToken.format_expired_at"
|
||||
class="input input-bordered w-full" :disabled="newToken.never_expired" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="never_expired" v-model="newToken.never_expired" class="checkbox checkbox-sm" />
|
||||
Never
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="token-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="token-quota" name="quota" type="number" inputmode="numeric" v-model="newToken.quota"
|
||||
placeholder="e.g. 10" autocomplete="off"
|
||||
class="input input-bordered w-full flex-grow" :disabled="newToken.unlimited_quota" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="unlimited_quota" v-model="newToken.unlimited_quota" class="checkbox checkbox-sm" />
|
||||
Unlimited
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newToken.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newToken.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="Token active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newToken.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
<label for="key-quota-requests" class="mb-1 block text-sm font-medium">Quota Requests/Day</label>
|
||||
<input id="key-quota-requests" name="quota_requests_per_day" type="number" inputmode="numeric"
|
||||
v-model.number="newKey.quota_requests_per_day"
|
||||
placeholder="0 = unlimited" autocomplete="off"
|
||||
class="input input-bordered w-full" :min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +103,7 @@
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary btn-sm px-5" :disabled="!isFormValid || creating">
|
||||
<span v-if="creating" class="loading loading-spinner loading-xs" aria-hidden="true"></span>
|
||||
Create Token
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,111 +112,86 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { Eye, EyeOff, CircleAlert } from '@lucide/vue'
|
||||
import { dateToUnix } from '@/utils/format-date';
|
||||
import { useToast } from '@/composables/toast';
|
||||
import type { TokenPayload } from '@/types';
|
||||
import { ref, computed } from 'vue'
|
||||
import { useKeyStore } from '@/stores/key'
|
||||
import { CircleAlert, CheckCircleIcon, ClipboardCopyIcon } from '@lucide/vue'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import type { NewApiKeyPayload } from '@/types'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const keyStore = useKeyStore()
|
||||
const { setToast } = useToast()
|
||||
const error = ref<string | null>(null)
|
||||
const creating = ref(false)
|
||||
const user = computed(() => authStore.user);
|
||||
const createdKey = ref<string | null>(null)
|
||||
const copied = ref(false)
|
||||
|
||||
const showAdvancedOptions = ref(false)
|
||||
|
||||
|
||||
const newToken = ref<TokenPayload>({
|
||||
const newKey = ref<NewApiKeyPayload>({
|
||||
name: '',
|
||||
key: '',
|
||||
user_id: user.value?.user_id as number | undefined,
|
||||
active: true,
|
||||
quota: 0,
|
||||
unlimited_quota: true,
|
||||
expired_at: 0,
|
||||
format_expired_at: '',
|
||||
never_expired: true,
|
||||
quota_tokens_per_day: undefined,
|
||||
quota_requests_per_day: undefined,
|
||||
})
|
||||
|
||||
const resetnewToken = () => {
|
||||
newToken.value = {
|
||||
const resetNewKey = () => {
|
||||
newKey.value = {
|
||||
name: '',
|
||||
key: '',
|
||||
user_id: '',
|
||||
active: true,
|
||||
quota: 0,
|
||||
unlimited_quota: true,
|
||||
expired_at: 0,
|
||||
format_expired_at: '',
|
||||
never_expired: true,
|
||||
quota_tokens_per_day: undefined,
|
||||
quota_requests_per_day: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => newToken.value.never_expired,
|
||||
(newNeverExpiredValue) => {
|
||||
if (newNeverExpiredValue) {
|
||||
newToken.value.expired_at = 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
watch(
|
||||
() => newToken.value.format_expired_at,
|
||||
(format_expired_at) => {
|
||||
if (!newToken.value.never_expired && format_expired_at) {
|
||||
newToken.value.expired_at = dateToUnix(format_expired_at);
|
||||
} else {
|
||||
newToken.value.expired_at = 0;
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
return newToken.value.name
|
||||
return newKey.value.name
|
||||
})
|
||||
|
||||
const createToken = async () => {
|
||||
const createKey = async () => {
|
||||
if (!isFormValid.value) {
|
||||
setToast('Please fill in all required fields Name.', 'error')
|
||||
setToast('Please fill in the name field.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await authStore.createToken(newToken.value)
|
||||
if (res.data?.code === 200) {
|
||||
error.value = null;
|
||||
resetnewToken();
|
||||
setToast('Token created successfully.', 'success')
|
||||
emit('closeModal', true)
|
||||
const res = await keyStore.createKey(newKey.value)
|
||||
if (res.data?.key) {
|
||||
error.value = null
|
||||
createdKey.value = res.data.key
|
||||
resetNewKey()
|
||||
} else {
|
||||
console.log(res)
|
||||
error.value = res.data?.error || 'Failed to create token'
|
||||
error.value = res.data?.error || 'Failed to create API key'
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.error || 'Failed to create token'
|
||||
|
||||
error.value = err.response?.data?.error || 'Failed to create API key'
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
resetnewToken()
|
||||
emit('closeModal', false)
|
||||
const copyToClipboard = async () => {
|
||||
if (!createdKey.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdKey.value)
|
||||
copied.value = true
|
||||
setToast('API Key copied to clipboard', 'success')
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
} catch {
|
||||
setToast('Failed to copy. Please select and copy manually.', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 显示密码
|
||||
const isTokenVisible = ref(false);
|
||||
const closeAfterCreate = () => {
|
||||
createdKey.value = null
|
||||
copied.value = false
|
||||
emit('closeModal', true)
|
||||
}
|
||||
|
||||
function toggleTokenVisibility() {
|
||||
isTokenVisible.value = !isTokenVisible.value;
|
||||
const cancel = () => {
|
||||
resetNewKey()
|
||||
emit('closeModal', false)
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'closeModal', value: boolean): void
|
||||
}>()
|
||||
|
||||
</script>
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
<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>
|
||||
@@ -89,7 +89,7 @@
|
||||
</td>
|
||||
<td>
|
||||
<input type="checkbox" class="toggle toggle-success toggle-sm"
|
||||
:class="!user.active && 'toggle-error'" v-model="user.active" @change="updateStatus(user)"
|
||||
:class="user.status !== 'active' && 'toggle-error'" :checked="user.status === 'active'" @change="updateStatus(user)"
|
||||
:aria-label="`Toggle user ${user.username}`" />
|
||||
</td>
|
||||
<td class="text-right tabular-nums">
|
||||
@@ -265,7 +265,7 @@ const handleBatchAction = async (action: string) => {
|
||||
// 更新用户状态
|
||||
const updateStatus = async (user: UserInfo) => {
|
||||
try {
|
||||
const action = user.active ? 'enable' : 'disable';
|
||||
const action = user.status === 'active' ? 'disable' : 'enable';
|
||||
const res = await userStore.userOption(action, [user.id]);
|
||||
|
||||
if (res.data?.code === 200) {
|
||||
|
||||
@@ -70,39 +70,6 @@
|
||||
<option :value="10">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-language" class="mb-1 block text-sm font-medium">Language</label>
|
||||
<select id="user-language" name="language" v-model="newUser.language" class="select select-bordered w-full">
|
||||
<option value="en">English</option>
|
||||
<option value="zh">中文</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="user-quota" class="mb-1 block text-sm font-medium">Quota</label>
|
||||
<div class="flex items-center gap-3">
|
||||
<input id="user-quota" name="quota" type="number" inputmode="numeric" v-model="newUser.quota"
|
||||
placeholder="e.g. 10" autocomplete="off"
|
||||
class="input input-bordered w-full flex-grow" :disabled="newUser.unlimited_quota" />
|
||||
<label class="flex cursor-pointer items-center gap-1.5 whitespace-nowrap text-sm">
|
||||
<input type="checkbox" name="unlimited_quota" v-model="newUser.unlimited_quota" class="checkbox checkbox-sm" />
|
||||
Unlimited
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="mb-1 block text-sm font-medium">Status</span>
|
||||
<div class="flex h-9 items-center gap-3">
|
||||
<input type="checkbox" name="active" v-model="newUser.active" role="switch"
|
||||
class="toggle toggle-sm" :class="newUser.active ? 'toggle-success' : 'toggle-error'"
|
||||
aria-label="User active" />
|
||||
<span class="text-sm text-base-content/70">
|
||||
{{ newUser.active ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -135,28 +102,18 @@ const showAdvancedOptions = ref(false)
|
||||
|
||||
// Initialize user object
|
||||
const newUser = ref<NewUserPayload>({
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 0, // Default to Regular User
|
||||
active: true, // Default to Active
|
||||
quota: 0, // Default quota value (relevant if not unlimited)
|
||||
unlimited_quota: true, // Default to unlimited
|
||||
language: 'en', // Default language
|
||||
role: 0,
|
||||
})
|
||||
|
||||
const resetNewUser = () => {
|
||||
newUser.value = {
|
||||
name: '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
role: 0, // Default to Regular User
|
||||
active: true, // Default to Active
|
||||
quota: 0, // Default quota value (relevant if not unlimited)
|
||||
unlimited_quota: true, // Default to unlimited
|
||||
language: 'en', // Default language
|
||||
role: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,13 +136,8 @@ const createUser = async () => {
|
||||
username: newUser.value.username,
|
||||
password: newUser.value.password,
|
||||
email: newUser.value.email,
|
||||
name: newUser.value.name || newUser.value.username, // Use username if name is empty
|
||||
role: newUser.value.role,
|
||||
active: newUser.value.active,
|
||||
quota: newUser.value.quota,
|
||||
unlimited_quota: newUser.value.unlimited_quota,
|
||||
language: newUser.value.language
|
||||
});
|
||||
role: newUser.value.role === 10 ? 'admin' : 'user',
|
||||
} as any);
|
||||
|
||||
if (res.data?.code === 200) {
|
||||
error.value = null;
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
<h2 class="truncate text-lg font-semibold tracking-tight">{{ user?.name || user?.username }}</h2>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2">
|
||||
<span class="badge badge-sm"
|
||||
:class="user.active ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.active ? 'Active' : 'Inactive' }}
|
||||
:class="user.status === 'active' ? 'badge-success badge-soft' : 'badge-error badge-soft'">
|
||||
{{ user.status === 'active' ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
<span class="badge badge-sm"
|
||||
:class="user.role > 0 ? 'badge-warning badge-soft' : 'badge-ghost'">
|
||||
@@ -31,8 +31,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" class="toggle toggle-md" :class="user.active ? 'toggle-success' : 'toggle-error'"
|
||||
v-model="user.active" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" />
|
||||
<input type="checkbox" class="toggle toggle-md" :class="user.status === 'active' ? 'toggle-success' : 'toggle-error'"
|
||||
:checked="user.status === 'active'" @change="updateStatus(user)" :aria-label="`Toggle user ${user.username} status`" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -226,7 +226,7 @@ const loading = computed(() => userStore.loading); // Access loading state
|
||||
// 更新状态
|
||||
const updateStatus = async (user: UserInfo) => {
|
||||
try {
|
||||
const action = user.active ? 'enable' : 'disable';
|
||||
const action = user.status === 'active' ? 'disable' : 'enable';
|
||||
const res = await userStore.userOption(action, [user.id]);
|
||||
if (res.data?.code === 200) {
|
||||
setToast(`User ${user.id} ${action} Success`, 'success');
|
||||
@@ -235,7 +235,6 @@ const updateStatus = async (user: UserInfo) => {
|
||||
}
|
||||
await userStore.refreshUser(user.id);
|
||||
} catch (error: any) {
|
||||
user.active = !user.active;
|
||||
console.error('状态更新失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user