🎉 初始化项目

This commit is contained in:
2026-03-03 06:05:51 +08:00
commit e1c70fe218
241 changed files with 148285 additions and 0 deletions

24
web/src/App.vue Normal file
View File

@@ -0,0 +1,24 @@
<template>
<el-config-provider :locale="zhCn">
<router-view />
</el-config-provider>
</template>
<script setup>
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'
</script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
height: 100vh;
}
</style>

53
web/src/api/preset.js Normal file
View File

@@ -0,0 +1,53 @@
import request from '@/utils/request'
// 获取预设列表
export const getPresetList = (params) => {
return request({
url: '/app/preset/list',
method: 'get',
params
})
}
// 创建预设
export const createPreset = (data) => {
return request({
url: '/app/preset',
method: 'post',
data
})
}
// 更新预设
export const updatePreset = (data) => {
return request({
url: '/app/preset',
method: 'put',
data
})
}
// 删除预设
export const deletePreset = (id) => {
return request({
url: `/app/preset/${id}`,
method: 'delete'
})
}
// 导入预设
export const importPreset = (data) => {
return request({
url: '/app/preset/import',
method: 'post',
data
})
}
// 导出预设
export const exportPreset = (id) => {
return request({
url: `/app/preset/${id}/export`,
method: 'get'
})
}

53
web/src/api/provider.js Normal file
View File

@@ -0,0 +1,53 @@
import request from '@/utils/request'
// 获取提供商列表
export const getProviderList = () => {
return request({
url: '/app/provider/list',
method: 'get'
})
}
// 创建提供商
export const createProvider = (data) => {
return request({
url: '/app/provider',
method: 'post',
data
})
}
// 更新提供商
export const updateProvider = (data) => {
return request({
url: '/app/provider',
method: 'put',
data
})
}
// 删除提供商
export const deleteProvider = (id) => {
return request({
url: `/app/provider/${id}`,
method: 'delete'
})
}
// 测试连接
export const testConnection = (data) => {
return request({
url: '/app/provider/test',
method: 'post',
data
})
}
// 获取模型列表
export const getModels = (data) => {
return request({
url: '/app/provider/models',
method: 'post',
data
})
}

53
web/src/api/user.js Normal file
View File

@@ -0,0 +1,53 @@
import request from '@/utils/request'
// 登录
export const login = (data) => {
return request({
url: '/v1/system/user/login',
method: 'post',
data
})
}
// 获取用户信息
export const getUserInfo = () => {
return request({
url: '/v1/system/user/info',
method: 'get'
})
}
// 获取用户列表
export const getUserList = (params) => {
return request({
url: '/v1/system/user/list',
method: 'get',
params
})
}
// 创建用户
export const createUser = (data) => {
return request({
url: '/v1/system/user',
method: 'post',
data
})
}
// 更新用户
export const updateUser = (data) => {
return request({
url: '/v1/system/user',
method: 'put',
data
})
}
// 删除用户
export const deleteUser = (id) => {
return request({
url: `/v1/system/user/${id}`,
method: 'delete'
})
}

163
web/src/layout/index.vue Normal file
View File

@@ -0,0 +1,163 @@
<template>
<div class="layout">
<el-container>
<!-- 侧边栏 -->
<el-aside width="200px">
<div class="logo">
<h2>AI 中转代理</h2>
</div>
<el-menu
:default-active="activeMenu"
:router="true"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409EFF"
>
<template v-for="route in routes" :key="route.path">
<el-sub-menu v-if="route.children && route.children.length > 1" :index="route.path">
<template #title>
<el-icon><component :is="route.meta.icon" /></el-icon>
<span>{{ route.meta.title }}</span>
</template>
<el-menu-item
v-for="child in route.children"
:key="child.path"
:index="route.path + '/' + child.path"
>
<el-icon><component :is="child.meta.icon" /></el-icon>
<span>{{ child.meta.title }}</span>
</el-menu-item>
</el-sub-menu>
<el-menu-item v-else :index="route.path">
<el-icon><component :is="route.meta?.icon" /></el-icon>
<span>{{ route.meta?.title }}</span>
</el-menu-item>
</template>
</el-menu>
</el-aside>
<!-- 主内容区 -->
<el-container>
<!-- 顶部导航 -->
<el-header>
<div class="header-content">
<div class="breadcrumb">
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/' }">首页</el-breadcrumb-item>
<el-breadcrumb-item>{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="user-info">
<el-dropdown @command="handleCommand">
<span class="el-dropdown-link">
<el-icon><User /></el-icon>
{{ userInfo.username || 'Admin' }}
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-header>
<!-- 内容区 -->
<el-main>
<router-view />
</el-main>
</el-container>
</el-container>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
const router = useRouter()
const route = useRoute()
const userInfo = ref({
username: localStorage.getItem('username') || 'Admin'
})
const routes = computed(() => {
return router.options.routes.filter(r => r.path !== '/login' && r.meta)
})
const activeMenu = computed(() => route.path)
const currentTitle = computed(() => route.meta.title || '')
const handleCommand = (command) => {
if (command === 'logout') {
localStorage.removeItem('token')
localStorage.removeItem('username')
ElMessage.success('退出成功')
router.push('/login')
}
}
</script>
<style scoped>
.layout {
height: 100vh;
}
.el-container {
height: 100%;
}
.el-aside {
background-color: #304156;
color: #fff;
}
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: #2b3a4b;
}
.logo h2 {
color: #fff;
font-size: 18px;
margin: 0;
}
.el-menu {
border-right: none;
}
.el-header {
background-color: #fff;
box-shadow: 0 1px 4px rgba(0,21,41,.08);
display: flex;
align-items: center;
padding: 0 20px;
}
.header-content {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
}
.el-dropdown-link {
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
}
.el-main {
background-color: #f0f2f5;
padding: 20px;
}
</style>

21
web/src/main.js Normal file
View File

@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import App from './App.vue'
import router from './router'
const app = createApp(App)
const pinia = createPinia()
// 注册所有图标
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.use(pinia)
app.use(router)
app.use(ElementPlus)
app.mount('#app')

92
web/src/router/index.js Normal file
View File

@@ -0,0 +1,92 @@
import { createRouter, createWebHistory } from 'vue-router'
import Layout from '@/layout/index.vue'
const routes = [
{
path: '/login',
name: 'Login',
component: () => import('@/views/login/index.vue'),
meta: { title: '登录' }
},
{
path: '/',
component: Layout,
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: { title: '仪表盘', icon: 'DataLine' }
}
]
},
{
path: '/system',
component: Layout,
redirect: '/system/user',
meta: { title: '系统管理', icon: 'Setting' },
children: [
{
path: 'user',
name: 'User',
component: () => import('@/views/system/user/index.vue'),
meta: { title: '用户管理', icon: 'User' }
},
{
path: 'api',
name: 'Api',
component: () => import('@/views/system/api/index.vue'),
meta: { title: 'API管理', icon: 'Connection' }
}
]
},
{
path: '/ai',
component: Layout,
redirect: '/ai/preset',
meta: { title: 'AI管理', icon: 'MagicStick' },
children: [
{
path: 'preset',
name: 'Preset',
component: () => import('@/views/ai/preset/index.vue'),
meta: { title: '预设管理', icon: 'Document' }
},
{
path: 'provider',
name: 'Provider',
component: () => import('@/views/ai/provider/index.vue'),
meta: { title: '提供商管理', icon: 'Platform' }
},
{
path: 'binding',
name: 'Binding',
component: () => import('@/views/ai/binding/index.vue'),
meta: { title: '预设绑定', icon: 'Link' }
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
// 路由守卫
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.path === '/login') {
next()
} else {
if (!token) {
next('/login')
} else {
next()
}
}
})
export default router

48
web/src/utils/request.js Normal file
View File

@@ -0,0 +1,48 @@
import axios from 'axios'
import { ElMessage } from 'element-plus'
const request = axios.create({
baseURL: '/api',
timeout: 30000
})
// 请求拦截器
request.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
config.headers['x-token'] = token
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
response => {
const res = response.data
if (res.code !== 0) {
ElMessage.error(res.msg || '请求失败')
if (res.code === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(new Error(res.msg || '请求失败'))
}
return res.data
},
error => {
ElMessage.error(error.message || '网络错误')
return Promise.reject(error)
}
)
export default request

View File

@@ -0,0 +1,240 @@
<template>
<div class="binding-container">
<el-card>
<template #header>
<div class="card-header">
<span>预设绑定配置</span>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建绑定
</el-button>
</div>
</template>
<el-alert
title="说明"
type="info"
:closable="false"
style="margin-bottom: 20px"
>
为不同的 AI 提供商配置不同的预设实现灵活的 AI 调用策略
</el-alert>
<el-table :data="bindingList" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column label="预设" width="200">
<template #default="{ row }">
<el-tag>{{ row.presetName || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="提供商" width="200">
<template #default="{ row }">
<el-tag type="success">{{ row.providerName || '-' }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="priority" label="优先级" width="100">
<template #default="{ row }">
<el-tag type="warning">{{ row.priority }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'info'">
{{ row.isActive ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="500px"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="100px">
<el-form-item label="预设" prop="presetId">
<el-select v-model="form.presetId" placeholder="请选择预设" style="width: 100%">
<el-option
v-for="preset in presetOptions"
:key="preset.id"
:label="preset.name"
:value="preset.id"
>
<span>{{ preset.name }}</span>
<span v-if="preset.description" style="color: #8492a6; font-size: 12px; margin-left: 8px">
- {{ preset.description }}
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="提供商" prop="providerId">
<el-select v-model="form.providerId" placeholder="请选择提供商" style="width: 100%">
<el-option
v-for="provider in providerOptions"
:key="provider.id"
:label="provider.name"
:value="provider.id"
>
<span>{{ provider.name }}</span>
<span style="color: #8492a6; font-size: 12px; margin-left: 8px">
- {{ provider.model }}
</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="优先级" prop="priority">
<el-input-number v-model="form.priority" :min="0" :max="100" style="width: 100%" />
<el-text size="small" type="info" style="margin-top: 5px">
数字越小优先级越高多个预设时按优先级顺序执行
</el-text>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.isActive" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus } from '@element-plus/icons-vue'
import { getBindingList, createBinding, updateBinding, deleteBinding } from '@/api/binding'
import { getPresetList } from '@/api/preset'
import { getProviderList } from '@/api/provider'
const loading = ref(false)
const bindingList = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新建绑定')
const formRef = ref()
const presetOptions = ref([])
const providerOptions = ref([])
const form = reactive({
id: null,
presetId: null,
providerId: null,
priority: 0,
isActive: true
})
const rules = {
presetId: [{ required: true, message: '请选择预设', trigger: 'change' }],
providerId: [{ required: true, message: '请选择提供商', trigger: 'change' }],
priority: [{ required: true, message: '请输入优先级', trigger: 'blur' }]
}
const fetchData = async () => {
loading.value = true
try {
const res = await getBindingList({ page: 1, pageSize: 100 })
bindingList.value = res.list || []
} catch (error) {
ElMessage.error('获取绑定列表失败')
} finally {
loading.value = false
}
}
const loadOptions = async () => {
try {
// 加载预设列表
const presetRes = await getPresetList({ page: 1, pageSize: 100 })
presetOptions.value = presetRes.list || []
// 加载提供商列表
const providerRes = await getProviderList()
providerOptions.value = providerRes || []
} catch (error) {
ElMessage.error('加载选项失败')
}
}
const handleCreate = async () => {
dialogTitle.value = '新建绑定'
Object.assign(form, {
id: null,
presetId: null,
providerId: null,
priority: 0,
isActive: true
})
await loadOptions()
dialogVisible.value = true
}
const handleEdit = async (row) => {
dialogTitle.value = '编辑绑定'
Object.assign(form, {
id: row.id,
presetId: row.presetId,
providerId: row.providerId,
priority: row.priority,
isActive: row.isActive
})
await loadOptions()
dialogVisible.value = true
}
const handleSubmit = async () => {
await formRef.value.validate()
try {
if (form.id) {
await updateBinding(form)
ElMessage.success('更新成功')
} else {
await createBinding(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
ElMessage.error('操作失败')
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该绑定吗?', '提示', {
type: 'warning'
})
await deleteBinding(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,274 @@
<template>
<div class="preset-container">
<el-card>
<template #header>
<div class="card-header">
<span>AI 预设管理</span>
<div>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建预设
</el-button>
<el-button type="success" @click="handleImport">
<el-icon><Upload /></el-icon>
导入预设
</el-button>
</div>
</div>
</template>
<el-table :data="presetList" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="预设名称" />
<el-table-column prop="description" label="描述" show-overflow-tooltip />
<el-table-column prop="temperature" label="温度" width="100" />
<el-table-column prop="maxTokens" label="最大Token" width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag v-if="row.isDefault" type="success">默认</el-tag>
<el-tag v-else-if="row.isPublic" type="info">公开</el-tag>
<el-tag v-else>私有</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="success" @click="handleExport(row)">导出</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
layout="total, sizes, prev, pager, next"
@current-change="fetchData"
@size-change="fetchData"
style="margin-top: 20px; justify-content: flex-end"
/>
</el-card>
<!-- 导入对话框 -->
<el-dialog v-model="importDialogVisible" title="导入预设" width="600px">
<el-upload
ref="uploadRef"
:auto-upload="false"
:limit="1"
accept=".json"
:on-change="handleFileChange"
:on-exceed="handleExceed"
drag
>
<el-icon class="el-icon--upload"><upload-filled /></el-icon>
<div class="el-upload__text">
JSON 文件拖到此处<em>点击上传</em>
</div>
<template #tip>
<div class="el-upload__tip">
只能上传 .json 文件支持 SillyTavern 预设格式
</div>
</template>
</el-upload>
<el-form v-if="importForm.name" :model="importForm" label-width="100px" style="margin-top: 20px">
<el-form-item label="预设名称">
<el-input v-model="importForm.name" placeholder="请输入预设名称" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="importForm.description" placeholder="预设描述(可选)" />
</el-form-item>
<el-alert
title="预览"
type="info"
:closable="false"
style="margin-bottom: 10px"
>
<div>提示词数量: {{ importForm.promptsCount }}</div>
<div>正则脚本数量: {{ importForm.scriptsCount }}</div>
</el-alert>
</el-form>
<template #footer>
<el-button @click="handleCancelImport">取消</el-button>
<el-button type="primary" @click="confirmImport" :disabled="!importForm.jsonData">确定导入</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getPresetList, deletePreset, importPreset, exportPreset } from '@/api/preset'
const loading = ref(false)
const presetList = ref([])
const pagination = ref({
page: 1,
pageSize: 10,
total: 0
})
const importDialogVisible = ref(false)
const uploadRef = ref()
const importForm = ref({
name: '',
description: '',
jsonData: '',
promptsCount: 0,
scriptsCount: 0
})
const fetchData = async () => {
loading.value = true
try {
const res = await getPresetList(pagination.value)
presetList.value = res.list
pagination.value.total = res.total
} catch (error) {
ElMessage.error('获取预设列表失败')
} finally {
loading.value = false
}
}
const handleCreate = () => {
ElMessage.info('创建预设功能开发中')
}
const handleEdit = (row) => {
ElMessage.info('编辑预设功能开发中')
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该预设吗?', '提示', {
type: 'warning'
})
await deletePreset(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
const handleImport = () => {
importDialogVisible.value = true
importForm.value = {
name: '',
description: '',
jsonData: '',
promptsCount: 0,
scriptsCount: 0
}
}
const handleFileChange = (file) => {
const reader = new FileReader()
reader.onload = (e) => {
try {
const jsonData = JSON.parse(e.target.result)
// 自动提取预设名称
importForm.value.name = jsonData.name || file.name.replace('.json', '')
importForm.value.description = jsonData.description || ''
importForm.value.jsonData = e.target.result
// 统计提示词和脚本数量
importForm.value.promptsCount = jsonData.prompts?.length || 0
importForm.value.scriptsCount = jsonData.regex_scripts?.length || 0
ElMessage.success('文件读取成功')
} catch (error) {
ElMessage.error('JSON 格式错误,请检查文件')
importForm.value = {
name: '',
description: '',
jsonData: '',
promptsCount: 0,
scriptsCount: 0
}
}
}
reader.readAsText(file.raw)
}
const handleExceed = () => {
ElMessage.warning('只能上传一个文件')
}
const handleCancelImport = () => {
importDialogVisible.value = false
uploadRef.value?.clearFiles()
}
const confirmImport = async () => {
if (!importForm.value.name) {
ElMessage.warning('请输入预设名称')
return
}
try {
const data = JSON.parse(importForm.value.jsonData)
await importPreset({
name: importForm.value.name,
data
})
ElMessage.success('导入成功')
importDialogVisible.value = false
uploadRef.value?.clearFiles()
fetchData()
} catch (error) {
ElMessage.error('导入失败: ' + (error.message || '请检查 JSON 格式'))
}
}
const handleExport = async (row) => {
try {
const data = await exportPreset(row.id)
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${row.name}.json`
a.click()
URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
ElMessage.error('导出失败')
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.el-icon--upload {
font-size: 67px;
color: #409eff;
margin-bottom: 16px;
}
.el-upload__text {
font-size: 14px;
color: #606266;
}
.el-upload__text em {
color: #409eff;
font-style: normal;
}
</style>

View File

@@ -0,0 +1,317 @@
<template>
<div class="provider-container">
<el-card>
<template #header>
<div class="card-header">
<span>AI 提供商管理</span>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建提供商
</el-button>
</div>
</template>
<el-table :data="providerList" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="name" label="名称" width="150" />
<el-table-column prop="baseUrl" label="API地址" show-overflow-tooltip />
<el-table-column prop="model" label="模型" width="150" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.isActive ? 'success' : 'info'">
{{ row.isActive ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="280" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="success" @click="handleTest(row)">测试</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 创建/编辑对话框 -->
<el-dialog
v-model="dialogVisible"
:title="dialogTitle"
width="600px"
>
<el-form :model="form" :rules="rules" ref="formRef" label-width="120px">
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" placeholder="如: OpenAI" />
</el-form-item>
<el-form-item label="提供商类型" prop="type">
<el-select v-model="form.type" placeholder="请选择类型" style="width: 100%">
<el-option label="OpenAI" value="openai" />
<el-option label="Claude" value="claude" />
<el-option label="Gemini" value="gemini" />
<el-option label="其他" value="other" />
</el-select>
</el-form-item>
<el-form-item label="API地址" prop="baseUrl">
<el-input v-model="form.baseUrl" placeholder="https://api.openai.com" />
</el-form-item>
<el-form-item label="API端点" prop="endpoint">
<el-input v-model="form.endpoint" placeholder="/v1/chat/completions" />
</el-form-item>
<el-form-item label="上游API密钥" prop="upstreamKey">
<el-input v-model="form.upstreamKey" type="password" show-password placeholder="sk-..." />
</el-form-item>
<el-form-item label="代理访问密钥" prop="proxyKey">
<el-input v-model="form.proxyKey" placeholder="用户访问本代理时使用的密钥" />
<el-button size="small" @click="generateProxyKey" style="margin-top: 5px">生成随机密钥</el-button>
</el-form-item>
<el-form-item label="默认模型" prop="model">
<div style="display: flex; gap: 10px; align-items: flex-start; flex-direction: column; width: 100%">
<div style="display: flex; gap: 10px; width: 100%">
<el-select
v-if="availableModels.length > 0"
v-model="form.model"
placeholder="选择模型"
style="flex: 1"
filterable
allow-create
>
<el-option
v-for="model in availableModels"
:key="model.id"
:label="model.name || model.id"
:value="model.id"
>
<span>{{ model.name || model.id }}</span>
<span v-if="model.ownedBy" style="color: #8492a6; font-size: 12px; margin-left: 8px">
({{ model.ownedBy }})
</span>
</el-option>
</el-select>
<el-input
v-else
v-model="form.model"
placeholder="gpt-4"
style="flex: 1"
/>
<el-button @click="handleGetModels" :loading="modelsLoading">
{{ availableModels.length > 0 ? '刷新模型' : '获取可用模型' }}
</el-button>
</div>
<el-text v-if="availableModels.length > 0" size="small" type="info">
已获取 {{ availableModels.length }} 个可用模型可选择或手动输入
</el-text>
</div>
</el-form-item>
<el-form-item label="状态">
<el-switch v-model="form.isActive" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getProviderList, createProvider, updateProvider, deleteProvider, testConnection, getModels } from '@/api/provider'
const loading = ref(false)
const providerList = ref([])
const dialogVisible = ref(false)
const dialogTitle = ref('新建提供商')
const formRef = ref()
const form = reactive({
id: null,
name: '',
type: '',
baseUrl: '',
endpoint: '',
upstreamKey: '',
proxyKey: '',
model: '',
isActive: true
})
const modelsLoading = ref(false)
const availableModels = ref([])
const rules = {
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
type: [{ required: true, message: '请选择提供商类型', trigger: 'change' }],
baseUrl: [{ required: true, message: '请输入API地址', trigger: 'blur' }],
upstreamKey: [{ required: true, message: '请输入上游API密钥', trigger: 'blur' }],
proxyKey: [{ required: true, message: '请输入代理访问密钥', trigger: ['blur', 'change'] }],
model: [{ required: true, message: '请选择或输入模型', trigger: ['blur', 'change'] }]
}
const fetchData = async () => {
loading.value = true
try {
const res = await getProviderList()
providerList.value = res
} catch (error) {
ElMessage.error('获取提供商列表失败')
} finally {
loading.value = false
}
}
const handleCreate = () => {
dialogTitle.value = '新建提供商'
Object.assign(form, {
id: null,
name: '',
type: '',
baseUrl: '',
endpoint: '',
upstreamKey: '',
proxyKey: '',
model: '',
isActive: true
})
availableModels.value = []
dialogVisible.value = true
}
const generateProxyKey = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
let key = 'pk-'
for (let i = 0; i < 32; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length))
}
form.proxyKey = key
// 手动触发验证
formRef.value?.validateField('proxyKey')
}
const handleGetModels = async () => {
if (!form.baseUrl || !form.upstreamKey || !form.type) {
ElMessage.warning('请先填写API地址、上游密钥和提供商类型')
return
}
modelsLoading.value = true
try {
const res = await testConnection({
baseUrl: form.baseUrl,
upstreamKey: form.upstreamKey,
type: form.type
})
if (res.success) {
ElMessage.success(`连接成功!延迟: ${res.latency}ms`)
// 获取模型列表
const models = await getModels({
baseUrl: form.baseUrl,
upstreamKey: form.upstreamKey,
type: form.type
})
if (models && models.length > 0) {
availableModels.value = models
// 自动选择第一个模型
form.model = models[0].id
ElMessage.success(`获取到 ${models.length} 个可用模型`)
} else {
ElMessage.warning('未找到可用模型')
}
} else {
ElMessage.error(`连接失败: ${res.message}`)
}
} catch (error) {
console.error(error)
ElMessage.error('获取模型失败')
} finally {
modelsLoading.value = false
}
}
const handleEdit = (row) => {
dialogTitle.value = '编辑提供商'
Object.assign(form, row)
availableModels.value = []
dialogVisible.value = true
}
const handleTest = async (row) => {
if (!row.baseUrl || !row.upstreamKey || !row.type) {
ElMessage.warning('提供商配置不完整,无法测试')
return
}
const loading = ElMessage({
message: '正在测试连接...',
type: 'info',
duration: 0
})
try {
const res = await testConnection({
baseUrl: row.baseUrl,
upstreamKey: row.upstreamKey,
type: row.type
})
loading.close()
if (res.success) {
ElMessage.success(`连接成功!延迟: ${res.latency}ms`)
} else {
ElMessage.error(`连接失败: ${res.message}`)
}
} catch (error) {
loading.close()
ElMessage.error('测试失败: ' + (error.message || '未知错误'))
}
}
const handleSubmit = async () => {
await formRef.value.validate()
try {
if (form.id) {
await updateProvider(form)
ElMessage.success('更新成功')
} else {
await createProvider(form)
ElMessage.success('创建成功')
}
dialogVisible.value = false
fetchData()
} catch (error) {
ElMessage.error('操作失败')
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该提供商吗?', '提示', {
type: 'warning'
})
await deleteProvider(row.id)
ElMessage.success('删除成功')
fetchData()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>

View File

@@ -0,0 +1,96 @@
<template>
<div class="dashboard">
<el-row :gutter="20">
<el-col :span="6">
<el-card>
<el-statistic title="总用户数" :value="stats.totalUsers">
<template #prefix>
<el-icon><User /></el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic title="AI预设数" :value="stats.totalPresets">
<template #prefix>
<el-icon><Document /></el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic title="AI提供商" :value="stats.totalProviders">
<template #prefix>
<el-icon><Platform /></el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card>
<el-statistic title="今日请求" :value="stats.todayRequests">
<template #prefix>
<el-icon><DataLine /></el-icon>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<el-row :gutter="20" style="margin-top: 20px">
<el-col :span="24">
<el-card>
<template #header>
<span>快速开始</span>
</template>
<el-space wrap>
<el-button type="primary" @click="$router.push('/ai/preset')">
<el-icon><Document /></el-icon>
管理预设
</el-button>
<el-button type="success" @click="$router.push('/ai/provider')">
<el-icon><Platform /></el-icon>
配置提供商
</el-button>
<el-button type="warning" @click="$router.push('/ai/binding')">
<el-icon><Link /></el-icon>
绑定配置
</el-button>
</el-space>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const stats = ref({
totalUsers: 0,
totalPresets: 0,
totalProviders: 0,
todayRequests: 0
})
onMounted(() => {
// TODO: 从API获取统计数据
stats.value = {
totalUsers: 10,
totalPresets: 5,
totalProviders: 3,
todayRequests: 128
}
})
</script>
<style scoped>
.dashboard {
padding: 20px;
}
</style>

View File

@@ -0,0 +1,106 @@
<template>
<div class="login-container">
<el-card class="login-card">
<template #header>
<div class="card-header">
<h2>AI 中转代理管理后台</h2>
</div>
</template>
<el-form :model="loginForm" :rules="rules" ref="loginFormRef">
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名"
prefix-icon="User"
size="large"
/>
</el-form-item>
<el-form-item prop="password">
<el-input
v-model="loginForm.password"
type="password"
placeholder="密码"
prefix-icon="Lock"
size="large"
@keyup.enter="handleLogin"
/>
</el-form-item>
<el-form-item>
<el-button
type="primary"
size="large"
style="width: 100%"
:loading="loading"
@click="handleLogin"
>
登录
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { login } from '@/api/user'
const router = useRouter()
const loginFormRef = ref()
const loading = ref(false)
const loginForm = reactive({
username: '',
password: ''
})
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
const handleLogin = async () => {
await loginFormRef.value.validate()
loading.value = true
try {
const res = await login(loginForm)
localStorage.setItem('token', res.token)
localStorage.setItem('username', loginForm.username)
ElMessage.success('登录成功')
router.push('/')
} catch (error) {
ElMessage.error(error.message || '登录失败')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.login-card {
width: 400px;
}
.card-header {
text-align: center;
}
.card-header h2 {
margin: 0;
color: #303133;
}
</style>

View File

@@ -0,0 +1,81 @@
<template>
<div class="api-container">
<el-card>
<template #header>
<span>API 管理</span>
</template>
<el-alert
title="API 密钥"
type="info"
:closable="false"
style="margin-bottom: 20px"
>
<p>您的 API 密钥: <el-tag>{{ apiKey }}</el-tag></p>
<el-button size="small" type="primary" @click="regenerateKey" style="margin-top: 10px">
重新生成密钥
</el-button>
</el-alert>
<el-divider />
<h3>API 文档</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="聊天补全">
<code>POST /v1/chat/completions</code>
</el-descriptions-item>
<el-descriptions-item label="预设列表">
<code>GET /app/preset/list</code>
</el-descriptions-item>
<el-descriptions-item label="提供商列表">
<code>GET /app/provider/list</code>
</el-descriptions-item>
</el-descriptions>
<el-divider />
<h3>使用示例</h3>
<el-code-block>
<pre>
curl -X POST http://localhost:8080/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"messages": [{"role": "user", "content": "你好"}],
"presetId": 1
}'
</pre>
</el-code-block>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
const apiKey = ref('ak-xxxxxxxxxxxxxxxx')
const regenerateKey = async () => {
try {
await ElMessageBox.confirm('确定要重新生成 API 密钥吗?旧密钥将失效', '提示', {
type: 'warning'
})
// TODO: 调用 API 重新生成密钥
ElMessage.success('密钥已重新生成')
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('操作失败')
}
}
}
</script>
<style scoped>
.api-container pre {
background-color: #f5f7fa;
padding: 15px;
border-radius: 4px;
overflow-x: auto;
}
</style>

View File

@@ -0,0 +1,87 @@
<template>
<div class="user-container">
<el-card>
<template #header>
<div class="card-header">
<span>用户管理</span>
<el-button type="primary" @click="handleCreate">
<el-icon><Plus /></el-icon>
新建用户
</el-button>
</div>
</template>
<el-table :data="userList" v-loading="loading">
<el-table-column prop="id" label="ID" width="80" />
<el-table-column prop="username" label="用户名" />
<el-table-column prop="email" label="邮箱" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">
<el-tag :type="row.role === 'admin' ? 'danger' : 'info'">
{{ row.role === 'admin' ? '管理员' : '普通用户' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ row.status === 'active' ? '正常' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="180" />
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="handleEdit(row)">编辑</el-button>
<el-button size="small" type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { getUserList } from '@/api/user'
const loading = ref(false)
const userList = ref([])
const fetchData = async () => {
loading.value = true
try {
const res = await getUserList()
userList.value = res.list || []
} catch (error) {
ElMessage.error('获取用户列表失败')
} finally {
loading.value = false
}
}
const handleCreate = () => {
ElMessage.info('新建用户功能开发中')
}
const handleEdit = (row) => {
ElMessage.info('编辑用户功能开发中')
}
const handleDelete = (row) => {
ElMessage.info('删除用户功能开发中')
}
onMounted(() => {
fetchData()
})
</script>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
</style>