feat(web): admin views for LLM endpoints/models + usage dashboard

This commit is contained in:
2026-05-04 20:11:55 +08:00
parent f294ac649c
commit 372af06b01
3 changed files with 715 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
<template>
<AppShell>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold">
LLM Endpoints
</h2>
<NButton
type="primary"
@click="openCreate"
>
+ 新增
</NButton>
</div>
<NDataTable
:data="endpoints"
:columns="columns"
:row-key="rowKey"
/>
<NModal
v-model:show="showForm"
preset="card"
title="LLM Endpoint"
style="width: 520px"
>
<NForm>
<NFormItem label="Provider">
<NSelect
v-model:value="form.provider"
:options="providerOpts"
:disabled="!!editing"
@update:value="onProviderChange"
/>
</NFormItem>
<NFormItem label="显示名">
<NInput v-model:value="form.display_name" />
</NFormItem>
<NFormItem label="Base URL">
<NInput v-model:value="form.base_url" />
</NFormItem>
<NFormItem :label="editing ? 'API Key(留空则不修改)' : 'API Key'">
<NInput
v-model:value="form.api_key"
type="password"
show-password-on="click"
/>
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="showForm = false">
取消
</NButton>
<NButton
type="primary"
:loading="saving"
@click="save"
>
保存
</NButton>
</div>
</template>
</NModal>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { h, onMounted, ref } from 'vue'
import {
NButton,
NDataTable,
NForm,
NFormItem,
NInput,
NModal,
NSelect,
useDialog,
useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import {
llmAdminApi,
type LLMEndpoint,
type LLMProvider,
} from '@/api/llmAdmin'
const endpoints = ref<LLMEndpoint[]>([])
const showForm = ref(false)
const editing = ref<LLMEndpoint | null>(null)
const form = ref<{
provider: LLMProvider
display_name: string
base_url: string
api_key: string
}>({
provider: 'anthropic',
display_name: '',
base_url: '',
api_key: '',
})
const saving = ref(false)
const msg = useMessage()
const dlg = useDialog()
const providerOpts: { label: string; value: LLMProvider }[] = [
{ label: 'Anthropic', value: 'anthropic' },
{ label: 'OpenAI / Compatible', value: 'openai' },
{ label: 'Gemini', value: 'gemini' },
]
const defaultBaseURL: Record<LLMProvider, string> = {
anthropic: 'https://api.anthropic.com',
openai: 'https://api.openai.com/v1',
gemini: 'https://generativelanguage.googleapis.com/v1beta',
}
const columns: DataTableColumns<LLMEndpoint> = [
{ title: '显示名', key: 'display_name' },
{ title: 'Provider', key: 'provider' },
{ title: 'Base URL', key: 'base_url' },
{
title: '操作',
key: 'actions',
render: (row) =>
h('div', { class: 'flex gap-1' }, [
h(
NButton,
{ size: 'small', onClick: () => testEndpoint(row.id) },
{ default: () => '测试' },
),
h(
NButton,
{ size: 'small', onClick: () => openEdit(row) },
{ default: () => '编辑' },
),
h(
NButton,
{ size: 'small', type: 'error', onClick: () => del(row) },
{ default: () => '删除' },
),
]),
},
]
const rowKey = (row: LLMEndpoint) => row.id
onMounted(reload)
async function reload() {
endpoints.value = await llmAdminApi.listEndpoints()
}
function openCreate() {
editing.value = null
form.value = {
provider: 'anthropic',
display_name: '',
base_url: defaultBaseURL.anthropic,
api_key: '',
}
showForm.value = true
}
function openEdit(ep: LLMEndpoint) {
editing.value = ep
form.value = {
provider: ep.provider,
display_name: ep.display_name,
base_url: ep.base_url,
api_key: '',
}
showForm.value = true
}
function onProviderChange(v: LLMProvider) {
form.value.base_url = defaultBaseURL[v]
}
async function save() {
saving.value = true
try {
if (editing.value) {
await llmAdminApi.updateEndpoint(editing.value.id, {
display_name: form.value.display_name,
base_url: form.value.base_url,
api_key: form.value.api_key || undefined,
})
} else {
await llmAdminApi.createEndpoint({
provider: form.value.provider,
display_name: form.value.display_name,
base_url: form.value.base_url,
api_key: form.value.api_key,
})
}
showForm.value = false
await reload()
} catch (e) {
msg.error(e instanceof Error ? e.message : String(e))
} finally {
saving.value = false
}
}
async function testEndpoint(id: string) {
try {
await llmAdminApi.testEndpoint(id)
msg.success('连通正常')
} catch (e) {
msg.error(e instanceof Error ? e.message : String(e))
}
}
function del(ep: LLMEndpoint) {
dlg.warning({
title: `删除 ${ep.display_name}`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await llmAdminApi.deleteEndpoint(ep.id)
await reload()
},
})
}
</script>
+370
View File
@@ -0,0 +1,370 @@
<template>
<AppShell>
<div class="space-y-4">
<div class="flex justify-between items-center">
<h2 class="text-lg font-semibold">
LLM Models
</h2>
<NButton
type="primary"
@click="openCreate"
>
+ 新增
</NButton>
</div>
<NDataTable
:data="models"
:columns="columns"
:row-key="rowKey"
/>
<NModal
v-model:show="showForm"
preset="card"
title="LLM Model"
style="width: 720px"
>
<NForm>
<NFormItem label="Endpoint">
<NSelect
v-model:value="form.endpoint_id"
:options="endpointOpts"
:disabled="!!editing"
/>
</NFormItem>
<NFormItem label="Model ID(provider 侧)">
<NInput
v-model:value="form.model_id"
:disabled="!!editing"
/>
</NFormItem>
<NFormItem label="显示名">
<NInput v-model:value="form.display_name" />
</NFormItem>
<NFormItem label="能力">
<div class="flex flex-wrap gap-3">
<NCheckbox v-model:checked="form.capabilities.image">
Image
</NCheckbox>
<NCheckbox v-model:checked="form.capabilities.pdf">
PDF
</NCheckbox>
<NCheckbox v-model:checked="form.capabilities.audio">
Audio
</NCheckbox>
<NCheckbox v-model:checked="form.capabilities.reasoning">
Reasoning
</NCheckbox>
<NCheckbox v-model:checked="form.capabilities.tools">
Tools
</NCheckbox>
</div>
</NFormItem>
<div class="grid grid-cols-2 gap-3">
<NFormItem label="Context Window">
<NInputNumber
v-model:value="form.context_window"
:min="1"
/>
</NFormItem>
<NFormItem label="Max Output Tokens">
<NInputNumber
v-model:value="form.max_output_tokens"
:min="1"
/>
</NFormItem>
<NFormItem label="Prompt Price (USD / 1M)">
<NInputNumber
v-model:value="form.prompt_price_per_million_usd"
:min="0"
:step="0.01"
/>
</NFormItem>
<NFormItem label="Completion Price (USD / 1M)">
<NInputNumber
v-model:value="form.completion_price_per_million_usd"
:min="0"
:step="0.01"
/>
</NFormItem>
<NFormItem label="Thinking Price (USD / 1M)">
<NInputNumber
v-model:value="form.thinking_price_per_million_usd"
:min="0"
:step="0.01"
/>
</NFormItem>
<NFormItem label="Sort Order">
<NInputNumber
v-model:value="form.sort_order"
:min="0"
/>
</NFormItem>
</div>
<div class="flex gap-4 mt-2">
<NCheckbox v-model:checked="form.enabled">
Enabled
</NCheckbox>
<NCheckbox v-model:checked="form.is_title_generator">
Title Generator
</NCheckbox>
</div>
</NForm>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="showForm = false">
取消
</NButton>
<NButton
type="primary"
:loading="saving"
@click="save"
>
保存
</NButton>
</div>
</template>
</NModal>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, h, onMounted, ref } from 'vue'
import {
NButton,
NCheckbox,
NDataTable,
NForm,
NFormItem,
NInput,
NInputNumber,
NModal,
NSelect,
NTag,
useDialog,
useMessage,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import {
llmAdminApi,
type CreateModelBody,
type LLMEndpoint,
type LLMModel,
type ModelCapabilities,
} from '@/api/llmAdmin'
const models = ref<LLMModel[]>([])
const endpoints = ref<LLMEndpoint[]>([])
const showForm = ref(false)
const editing = ref<LLMModel | null>(null)
const saving = ref(false)
const msg = useMessage()
const dlg = useDialog()
interface ModelForm {
endpoint_id: string
model_id: string
display_name: string
capabilities: ModelCapabilities
context_window: number
max_output_tokens: number
prompt_price_per_million_usd: number
completion_price_per_million_usd: number
thinking_price_per_million_usd: number
is_title_generator: boolean
enabled: boolean
sort_order: number
}
function blankForm(): ModelForm {
return {
endpoint_id: '',
model_id: '',
display_name: '',
capabilities: {
image: false,
pdf: false,
audio: false,
reasoning: false,
tools: false,
},
context_window: 200000,
max_output_tokens: 8192,
prompt_price_per_million_usd: 0,
completion_price_per_million_usd: 0,
thinking_price_per_million_usd: 0,
is_title_generator: false,
enabled: true,
sort_order: 0,
}
}
const form = ref<ModelForm>(blankForm())
const endpointOpts = computed(() =>
endpoints.value.map((e) => ({
label: `${e.display_name} (${e.provider})`,
value: e.id,
})),
)
const endpointName = (id: string) =>
endpoints.value.find((e) => e.id === id)?.display_name ?? id
const capLabel = (c: ModelCapabilities) => {
const parts: string[] = []
if (c.image) parts.push('I')
if (c.pdf) parts.push('P')
if (c.audio) parts.push('A')
if (c.reasoning) parts.push('R')
if (c.tools) parts.push('T')
return parts.join('') || '-'
}
const columns: DataTableColumns<LLMModel> = [
{ title: '显示名', key: 'display_name' },
{ title: 'Model ID', key: 'model_id' },
{
title: 'Endpoint',
key: 'endpoint_id',
render: (row) => endpointName(row.endpoint_id),
},
{
title: '能力',
key: 'capabilities',
render: (row) => capLabel(row.capabilities),
},
{ title: 'Ctx', key: 'context_window' },
{
title: '状态',
key: 'enabled',
render: (row) =>
h(
NTag,
{ type: row.enabled ? 'success' : 'default', size: 'small' },
{ default: () => (row.enabled ? '启用' : '停用') },
),
},
{
title: 'Title?',
key: 'is_title_generator',
render: (row) => (row.is_title_generator ? '✓' : ''),
},
{
title: '操作',
key: 'actions',
render: (row) =>
h('div', { class: 'flex gap-1' }, [
h(
NButton,
{ size: 'small', onClick: () => openEdit(row) },
{ default: () => '编辑' },
),
h(
NButton,
{ size: 'small', type: 'error', onClick: () => del(row) },
{ default: () => '删除' },
),
]),
},
]
const rowKey = (row: LLMModel) => row.id
onMounted(async () => {
endpoints.value = await llmAdminApi.listEndpoints()
await reload()
})
async function reload() {
models.value = await llmAdminApi.listModels(false)
}
function openCreate() {
editing.value = null
form.value = blankForm()
if (endpoints.value.length > 0) {
form.value.endpoint_id = endpoints.value[0].id
}
showForm.value = true
}
function openEdit(m: LLMModel) {
editing.value = m
form.value = {
endpoint_id: m.endpoint_id,
model_id: m.model_id,
display_name: m.display_name,
capabilities: { ...m.capabilities },
context_window: m.context_window,
max_output_tokens: m.max_output_tokens,
prompt_price_per_million_usd: m.prompt_price_per_million_usd,
completion_price_per_million_usd: m.completion_price_per_million_usd,
thinking_price_per_million_usd: m.thinking_price_per_million_usd,
is_title_generator: m.is_title_generator,
enabled: m.enabled,
sort_order: m.sort_order,
}
showForm.value = true
}
async function save() {
saving.value = true
try {
if (editing.value) {
await llmAdminApi.updateModel(editing.value.id, {
display_name: form.value.display_name,
capabilities: form.value.capabilities,
context_window: form.value.context_window,
max_output_tokens: form.value.max_output_tokens,
prompt_price_per_million_usd: form.value.prompt_price_per_million_usd,
completion_price_per_million_usd:
form.value.completion_price_per_million_usd,
thinking_price_per_million_usd:
form.value.thinking_price_per_million_usd,
is_title_generator: form.value.is_title_generator,
enabled: form.value.enabled,
sort_order: form.value.sort_order,
})
} else {
const body: CreateModelBody = {
endpoint_id: form.value.endpoint_id,
model_id: form.value.model_id,
display_name: form.value.display_name,
capabilities: form.value.capabilities,
context_window: form.value.context_window,
max_output_tokens: form.value.max_output_tokens,
prompt_price_per_million_usd: form.value.prompt_price_per_million_usd,
completion_price_per_million_usd:
form.value.completion_price_per_million_usd,
thinking_price_per_million_usd:
form.value.thinking_price_per_million_usd,
is_title_generator: form.value.is_title_generator,
enabled: form.value.enabled,
sort_order: form.value.sort_order,
}
await llmAdminApi.createModel(body)
}
showForm.value = false
await reload()
} catch (e) {
msg.error(e instanceof Error ? e.message : String(e))
} finally {
saving.value = false
}
}
function del(m: LLMModel) {
dlg.warning({
title: `删除 ${m.display_name}`,
positiveText: '删除',
negativeText: '取消',
onPositiveClick: async () => {
await llmAdminApi.deleteModel(m.id)
await reload()
},
})
}
</script>
+117
View File
@@ -0,0 +1,117 @@
<template>
<AppShell>
<div class="space-y-4">
<h2 class="text-lg font-semibold">
{{ adminMode ? '用量(全局)' : '我的用量' }}
</h2>
<div class="flex gap-2 items-center flex-wrap">
<NDatePicker
v-model:value="range"
type="daterange"
clearable
/>
<NSelect
v-if="adminMode"
v-model:value="groupBy"
:options="groupOpts"
style="width: 160px"
/>
</div>
<NDataTable
:data="rows"
:columns="columns"
/>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { NDataTable, NDatePicker, NSelect } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import AppShell from '@/layouts/AppShell.vue'
import {
llmAdminApi,
type AdminUsageGroupBy,
type AdminUsageItem,
} from '@/api/llmAdmin'
import { chatApi, type UsageDailyItem } from '@/api/chat'
interface Props {
adminMode?: boolean
}
const props = withDefaults(defineProps<Props>(), { adminMode: false })
const now = Date.now()
const monthAgo = now - 30 * 86400_000
const range = ref<[number, number] | null>([monthAgo, now])
const groupBy = ref<AdminUsageGroupBy>('day')
const groupOpts: { label: string; value: AdminUsageGroupBy }[] = [
{ label: '按用户', value: 'user' },
{ label: '按模型', value: 'model' },
{ label: '按天', value: 'day' },
]
type Row = AdminUsageItem | UsageDailyItem
const rows = ref<Row[]>([])
const columns = computed<DataTableColumns<Row>>(() => {
const tokenCols: DataTableColumns<Row> = [
{ title: 'Prompt', key: 'prompt_tokens' },
{ title: 'Completion', key: 'completion_tokens' },
{ title: 'Thinking', key: 'thinking_tokens' },
{
title: 'Cost USD',
key: 'cost_usd',
render: (r) => Number(r.cost_usd ?? 0).toFixed(4),
},
]
if (props.adminMode) {
const labelCol: DataTableColumns<Row>[number] = {
title:
groupBy.value === 'user'
? '用户'
: groupBy.value === 'model'
? '模型'
: '日期',
key: 'key',
render: (r) => {
const k = (r as AdminUsageItem).key
if (groupBy.value === 'day') {
return new Date(k).toLocaleDateString('zh-CN')
}
return k
},
}
return [labelCol, ...tokenCols]
}
return [
{
title: '日期',
key: 'day',
render: (r) => new Date((r as UsageDailyItem).day).toLocaleDateString('zh-CN'),
},
...tokenCols,
]
})
async function reload() {
const r = range.value
if (!r) {
rows.value = []
return
}
const from = new Date(r[0]).toISOString()
const to = new Date(r[1]).toISOString()
if (props.adminMode) {
const data = await llmAdminApi.adminUsage(from, to, groupBy.value)
rows.value = data.items
} else {
rows.value = await chatApi.myDailyUsage(from, to)
}
}
onMounted(reload)
watch([range, groupBy], reload)
</script>