mirror of
https://github.com/YFGaia/dify-plus.git
synced 2026-06-12 18:11:42 +08:00
feat: 新增后端模型管理,第三方快捷登录
This commit is contained in:
@@ -16,3 +16,4 @@ COPY --from=0 /gva_web/dist /usr/share/nginx/html/admin/
|
||||
RUN cat /etc/nginx/nginx.conf
|
||||
RUN cat /etc/nginx/conf.d/my.conf
|
||||
RUN ls -al /usr/share/nginx/html
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import service from '@/utils/request'
|
||||
|
||||
// 获取提供商配置列表
|
||||
export const getProviderListApi = () => {
|
||||
return service({
|
||||
url: '/gaia/model-provider/list',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 更新提供商配置
|
||||
export const updateProviderConfigApi = (data) => {
|
||||
return service({
|
||||
url: '/gaia/model-provider/update',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取可用模型
|
||||
export const getAvailableModelsApi = (providerName) => {
|
||||
return service({
|
||||
url: '/gaia/model-provider/available-models',
|
||||
method: 'get',
|
||||
params: {
|
||||
provider_name: providerName
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 测试提供商凭证
|
||||
export const testProviderCredentialsApi = (providerName) => {
|
||||
return service({
|
||||
url: '/gaia/model-provider/test-credentials',
|
||||
method: 'get',
|
||||
params: {
|
||||
provider_name: providerName
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 获取开启的模型列表(OpenAI格式)
|
||||
export const getEnabledModelsApi = () => {
|
||||
return service({
|
||||
url: '/gaia/models',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
// 获取代理日志
|
||||
export const getProxyLogsApi = (params) => {
|
||||
return service({
|
||||
url: '/gaia/model-provider/logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
@@ -11,3 +11,30 @@ export const oaLogin = (data) => {
|
||||
data: data
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 Gaia 登录方式(钉钉/OAuth2 是否启用及授权地址)
|
||||
export const getGaiaLoginOptions = (params) => {
|
||||
return service({
|
||||
url: '/base/gaiaLoginOptions',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
// Gaia OAuth2 登录:传 code 或 access_token(Extend: 兼容 casdoor implicit/hybrid 仅回传 access_token)
|
||||
export const gaiaOAuth2Login = (data) => {
|
||||
return service({
|
||||
url: '/base/gaiaOAuth2Login',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
// 钉钉 code 登录
|
||||
export const dingtalkLogin = (data) => {
|
||||
return service({
|
||||
url: '/base/dingtalkLogin',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
+31
-6
@@ -12,6 +12,7 @@ import '@/permission'
|
||||
import run from '@/core/gin-vue-admin.js'
|
||||
import auth from '@/directive/auth'
|
||||
import { store } from '@/pinia'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import App from './App.vue'
|
||||
// 消除警告
|
||||
import 'default-passive-events'
|
||||
@@ -20,10 +21,34 @@ const app = createApp(App)
|
||||
app.config.productionTip = false
|
||||
|
||||
app
|
||||
.use(run)
|
||||
.use(ElementPlus)
|
||||
.use(store)
|
||||
.use(auth)
|
||||
.use(router)
|
||||
.mount('#app')
|
||||
.use(run)
|
||||
.use(ElementPlus)
|
||||
.use(store)
|
||||
.use(auth)
|
||||
.use(router)
|
||||
.mount('#app')
|
||||
|
||||
// 如果当前 URL 上带有 clear_cache=true,则清空本地缓存与 Cookie
|
||||
const hasClearCacheFlag = () => {
|
||||
// 主 URL query(?a=1&clear_cache=true)
|
||||
const searchParams = new URLSearchParams(window.location.search || '')
|
||||
if (searchParams.get('clear_cache') === 'true') return true
|
||||
|
||||
// hash 部分 query(/#/login?redirect_uri=...&clear_cache=true)
|
||||
const hash = window.location.hash || ''
|
||||
const idx = hash.indexOf('?')
|
||||
if (idx !== -1) {
|
||||
const hashQuery = hash.substring(idx + 1)
|
||||
const hashParams = new URLSearchParams(hashQuery)
|
||||
if (hashParams.get('clear_cache') === 'true') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
if (hasClearCacheFlag()) {
|
||||
const userStore = useUserStore()
|
||||
// 统一使用 store 的清理逻辑:清 token、sessionStorage、localStorage 部分键、cookie 等
|
||||
userStore.ClearStorage && userStore.ClearStorage()
|
||||
}
|
||||
|
||||
export default app
|
||||
|
||||
@@ -64,6 +64,7 @@
|
||||
"/src/view/system/state.vue": "State",
|
||||
"/src/view/systemIntegrated/dingTalk/index.vue": "IntegratedDingTalk",
|
||||
"/src/view/systemIntegrated/index.vue": "SystemIntegrated",
|
||||
"/src/view/systemIntegrated/modelManagement/index.vue": "IntegratedModelManagement",
|
||||
"/src/view/systemIntegrated/oauth2/index.vue": "IntegratedOAuth2",
|
||||
"/src/view/systemTools/autoCode/component/fieldDialog.vue": "FieldDialog",
|
||||
"/src/view/systemTools/autoCode/component/previewCodeDialog.vue": "PreviewCodeDialog",
|
||||
|
||||
@@ -55,8 +55,11 @@ export const useUserStore = defineStore('user', () => {
|
||||
}
|
||||
return res
|
||||
}
|
||||
/* 登录*/
|
||||
const LoginIn = async(loginInfo) => {
|
||||
/* 登录
|
||||
* @param loginInfo 账号密码等
|
||||
* @param opts 可选 { redirect_uri, state },第三方带回调时:登录成功后跳回 redirect_uri 并带上 token 与 state,不再进入后台
|
||||
*/
|
||||
const LoginIn = async(loginInfo, opts = {}) => {
|
||||
loadingInstance.value = ElLoading.service({
|
||||
fullscreen: true,
|
||||
text: '登录中,请稍候...',
|
||||
@@ -74,6 +77,18 @@ export const useUserStore = defineStore('user', () => {
|
||||
setUserInfo(res.data.user)
|
||||
setToken(res.data.token)
|
||||
|
||||
const redirectUri = opts.redirect_uri && opts.redirect_uri.trim()
|
||||
const thirdPartyState = opts.state != null ? String(opts.state) : ''
|
||||
|
||||
// 第三方回调:带 token 跳回第三方,不进入后台
|
||||
if (redirectUri) {
|
||||
loadingInstance.value.close()
|
||||
const sep = redirectUri.includes('?') ? '&' : '?'
|
||||
const url = redirectUri + sep + 'token=' + encodeURIComponent(res.data.token) + (thirdPartyState ? '&state=' + encodeURIComponent(thirdPartyState) : '')
|
||||
window.location.href = url
|
||||
return true
|
||||
}
|
||||
|
||||
// 初始化路由信息
|
||||
const routerStore = useRouterStore()
|
||||
await routerStore.SetAsyncRouter()
|
||||
@@ -188,6 +203,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
OaLoginIn,
|
||||
LoginOut,
|
||||
setToken,
|
||||
setUserInfo,
|
||||
loadingInstance,
|
||||
ClearStorage
|
||||
}
|
||||
|
||||
@@ -194,7 +194,7 @@ const out = ref(false)
|
||||
const form = reactive({
|
||||
adminPassword: '123456',
|
||||
dbType: 'pgsql',
|
||||
host: 'db',
|
||||
host: 'db_postgres',
|
||||
port: '5432',
|
||||
userName: 'postgres',
|
||||
password: 'difyai123456',
|
||||
|
||||
@@ -1,46 +1,123 @@
|
||||
<template>
|
||||
<div v-show="false">该页面用于对接oa-oauth2.0回调登录</div>
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<span>登录中...</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
import { useRouterStore } from '@/pinia/modules/router'
|
||||
import router from '@/router'
|
||||
import { gaiaOAuth2Login, dingtalkLogin } from '@/api/user_extend'
|
||||
|
||||
defineOptions({
|
||||
name: 'LoginCallback',
|
||||
})
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
const oaLogin = async() => {
|
||||
return await userStore.OaLoginIn(route.query.code)
|
||||
|
||||
const redirectToThirdParty = (token, redirectUri, state) => {
|
||||
if (!redirectUri) return false
|
||||
sessionStorage.removeItem('gaia_login_redirect_uri')
|
||||
sessionStorage.removeItem('gaia_login_state')
|
||||
const sep = redirectUri.includes('?') ? '&' : '?'
|
||||
const url = redirectUri + sep + 'token=' + encodeURIComponent(token) + (state ? '&state=' + encodeURIComponent(state) : '')
|
||||
window.location.href = url
|
||||
window.location.href = "/"
|
||||
return true
|
||||
}
|
||||
const callback = async() => {
|
||||
if (route.query.code === undefined || route.query.code === '') {
|
||||
ElMessage({
|
||||
type: 'error',
|
||||
message: '登录失败,授权码缺失,3秒后跳转到登录页',
|
||||
showClose: true,
|
||||
})
|
||||
// 3秒后跳转登录页
|
||||
setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 3000)
|
||||
return false
|
||||
}
|
||||
const flag = await oaLogin()
|
||||
if (!flag) {
|
||||
ElMessage({
|
||||
type: 'error',
|
||||
message: '登录失败,3秒后跳转到登录页',
|
||||
showClose: true,
|
||||
})
|
||||
// 3秒后跳转登录页
|
||||
setTimeout(() => {
|
||||
window.location.href = '/'
|
||||
}, 3000)
|
||||
}
|
||||
return
|
||||
|
||||
const goToDashboard = async () => {
|
||||
const routerStore = useRouterStore()
|
||||
await routerStore.SetAsyncRouter()
|
||||
routerStore.asyncRouters.forEach(r => router.addRoute(r))
|
||||
const name = userStore.userInfo?.authority?.defaultRouter || 'gaiaDashboard'
|
||||
await router.replace({ name: name || 'gaiaDashboard' })
|
||||
}
|
||||
|
||||
const failAndBackToLogin = (msg) => {
|
||||
ElMessage({ type: 'error', message: msg || '登录失败,3秒后跳转到登录页', showClose: true })
|
||||
setTimeout(() => { window.location.href = '/#/login' }, 3000)
|
||||
}
|
||||
|
||||
// 钉钉/OAuth 回调时 code、authCode 可能在 hash 前的主 URL query 中(如 /admin/?code=xx&authCode=xx&state=dingtalk#/loginCallback?provider=dingtalk)
|
||||
const getQueryParam = (name) => {
|
||||
const fromRoute = route.query[name]
|
||||
if (fromRoute) return fromRoute
|
||||
const search = window.location.search
|
||||
if (!search) return ''
|
||||
const params = new URLSearchParams(search)
|
||||
return params.get(name) || ''
|
||||
}
|
||||
|
||||
const callback = async () => {
|
||||
const provider = getQueryParam('provider') || route.query.provider
|
||||
const code = getQueryParam('code') || getQueryParam('authCode') || route.query.code || route.query.authCode
|
||||
// Extend Start: 兼容 casdoor(部分 OAuth 如 Casdoor 可能通过 implicit/hybrid 直接回传 access_token,无 code)
|
||||
const accessTokenFromQuery = getQueryParam('access_token') || route.query.access_token || ''
|
||||
const hasCode = !!code
|
||||
const hasAccessToken = !!accessTokenFromQuery
|
||||
if (!hasCode && !hasAccessToken) {
|
||||
failAndBackToLogin('授权码或 access_token 缺失,3秒后跳转到登录页')
|
||||
return
|
||||
}
|
||||
// Extend Stop: 兼容 casdoor
|
||||
|
||||
const redirectUri = sessionStorage.getItem('gaia_login_redirect_uri') || ''
|
||||
const state = sessionStorage.getItem('gaia_login_state') || getQueryParam('state') || ''
|
||||
|
||||
try {
|
||||
if (provider === 'dingtalk') {
|
||||
if (!hasCode) {
|
||||
failAndBackToLogin('钉钉登录需要授权码')
|
||||
return
|
||||
}
|
||||
const res = await dingtalkLogin({ auth_code: code, redirect_uri: redirectUri, state })
|
||||
if (res?.code === 0 && res.data?.token) {
|
||||
userStore.setUserInfo(res.data.user)
|
||||
userStore.setToken(res.data.token)
|
||||
// 优先用接口返回的 redirect_uri/state(用户可能从应用直接跳到钉钉,未经过登录页,sessionStorage 为空)
|
||||
const finalRedirect = res.data.redirect_uri || redirectUri
|
||||
const finalState = res.data.state ?? state
|
||||
if (redirectToThirdParty(res.data.token, finalRedirect, finalState)) return
|
||||
await goToDashboard()
|
||||
return
|
||||
}
|
||||
} else if (provider === 'oauth2') {
|
||||
// Extend Start: 兼容 casdoor(支持仅带 access_token 的回调)
|
||||
const payload = hasCode
|
||||
? { code, redirect_uri: redirectUri, state }
|
||||
: { access_token: accessTokenFromQuery, redirect_uri: redirectUri, state }
|
||||
const res = await gaiaOAuth2Login(payload)
|
||||
// Extend Stop: 兼容 casdoor
|
||||
if (res?.code === 0 && res.data?.token) {
|
||||
userStore.setUserInfo(res.data.user)
|
||||
userStore.setToken(res.data.token)
|
||||
const finalRedirect = res.data.redirect_uri || redirectUri
|
||||
const finalState = res.data.state ?? state
|
||||
if (redirectToThirdParty(res.data.token, finalRedirect, finalState)) return
|
||||
await goToDashboard()
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (!hasCode) {
|
||||
failAndBackToLogin('该登录方式需要授权码')
|
||||
return
|
||||
}
|
||||
const flag = await userStore.OaLoginIn(code)
|
||||
if (flag) {
|
||||
if (redirectToThirdParty(userStore.token, redirectUri, state)) return
|
||||
await goToDashboard()
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
failAndBackToLogin('登录失败,3秒后跳转到登录页')
|
||||
}
|
||||
|
||||
callback()
|
||||
</script>
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
</div>
|
||||
<div class="mb-9">
|
||||
<p class="text-center text-4xl font-bold">{{ $GIN_VUE_ADMIN.appName }}</p>
|
||||
<p class="text-center text-sm font-normal text-gray-500 mt-2.5">A management platform for Dify-Plus
|
||||
</p>
|
||||
<p class="text-center text-sm font-normal text-gray-500 mt-2.5">A management platform for Dify-Plus</p>
|
||||
<p v-if="redirectUri" class="text-center text-xs text-blue-600 mt-2">登录后将跳回第三方应用</p>
|
||||
</div>
|
||||
<el-form
|
||||
ref="loginForm"
|
||||
@@ -87,7 +87,32 @@
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="submitForm"
|
||||
>登 录</el-button>
|
||||
>账号密码登录</el-button>
|
||||
</el-form-item>
|
||||
<!-- 钉钉 / OAuth2 登录:仅在有 redirect_uri(第三方回调)时显示 -->
|
||||
<el-form-item
|
||||
v-if="loginOptions.dingtalk.enabled && redirectUri"
|
||||
class="mb-6"
|
||||
>
|
||||
<el-button
|
||||
class="shadow h-11 w-full"
|
||||
size="large"
|
||||
@click="dingtalkLoginJump"
|
||||
>
|
||||
钉钉登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="loginOptions.oauth2.enabled && redirectUri"
|
||||
class="mb-6"
|
||||
>
|
||||
<el-button
|
||||
class="shadow shadow-blue-600 h-11 w-full"
|
||||
size="large"
|
||||
@click="oauth2LoginJump"
|
||||
>
|
||||
OAuth2 登录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<!-- 新增是否已经初始化判断 End -->
|
||||
@@ -103,19 +128,6 @@
|
||||
>前往初始化</el-button>
|
||||
|
||||
</el-form-item>
|
||||
<!-- 新增OA登录 Begin -->
|
||||
<el-form-item class="mb-6">
|
||||
<el-button
|
||||
class="shadow shadow-blue-600 h-11 w-full"
|
||||
type="primary"
|
||||
size="large"
|
||||
disabled
|
||||
@click="oaLoginJump"
|
||||
>
|
||||
Oauth2 登录(敬请期待)
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
<!-- 新增OA登录 End -->
|
||||
</el-form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,10 +189,11 @@
|
||||
<script setup>
|
||||
import { captcha } from '@/api/user'
|
||||
import { checkDB } from '@/api/initdb'
|
||||
import { getGaiaLoginOptions } from '@/api/user_extend'
|
||||
import BottomInfo from '@/components/bottomInfo/bottomInfo.vue'
|
||||
import { reactive, ref } from 'vue'
|
||||
import { reactive, ref, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/pinia/modules/user'
|
||||
|
||||
defineOptions({
|
||||
@@ -188,6 +201,17 @@ defineOptions({
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
// 第三方回调参数(用于登录成功后跳回第三方并带 token)
|
||||
const redirectUri = ref(route.query.redirect_uri || '')
|
||||
const thirdPartyState = ref(route.query.state || '')
|
||||
|
||||
// Gaia 登录方式(钉钉/OAuth2)
|
||||
const loginOptions = reactive({
|
||||
dingtalk: { enabled: false, auth_url: '' },
|
||||
oauth2: { enabled: false, auth_url: '' }
|
||||
})
|
||||
const showInit = ref(false)
|
||||
// 验证函数
|
||||
const checkUsername = (rule, value, callback) => {
|
||||
@@ -243,7 +267,10 @@ const rules = reactive({
|
||||
|
||||
const userStore = useUserStore()
|
||||
const login = async() => {
|
||||
return await userStore.LoginIn(loginFormData)
|
||||
return await userStore.LoginIn(loginFormData, {
|
||||
redirect_uri: redirectUri.value || undefined,
|
||||
state: thirdPartyState.value || undefined,
|
||||
})
|
||||
}
|
||||
const submitForm = () => {
|
||||
loginForm.value.validate(async(v) => {
|
||||
@@ -298,16 +325,53 @@ const showInitExtend = async() => {
|
||||
}
|
||||
showInitExtend()
|
||||
|
||||
// 跳转oa登录链接
|
||||
const oaLoginJump = () => {
|
||||
const clientId = import.meta.env.VITE_OA_LOGIN_CLINET_ID
|
||||
const oaUrl = import.meta.env.VITE_OA_URL
|
||||
const redirect_uri = window.location.origin + '#/loginCallback'
|
||||
// 获取loginCallback该路由的完整url
|
||||
|
||||
const jumpUrl = oaUrl + '?client_id=' + clientId + '&redirect_uri=' + encodeURIComponent(redirect_uri) + '&state='
|
||||
console.log(jumpUrl)
|
||||
window.location.href = jumpUrl
|
||||
// 已登录且带 redirect_uri 时直接回调第三方
|
||||
const tryRedirectWithToken = async () => {
|
||||
if (!redirectUri.value || !userStore.token) return false
|
||||
const res = await userStore.GetUserInfo()
|
||||
if (res?.code === 0) {
|
||||
const sep = redirectUri.value.includes('?') ? '&' : '?'
|
||||
const url = redirectUri.value + sep + 'token=' + encodeURIComponent(userStore.token) + (thirdPartyState.value ? '&state=' + encodeURIComponent(thirdPartyState.value) : '')
|
||||
window.location.href = url
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 拉取登录方式并检测已登录回调
|
||||
const loadLoginOptionsAndMaybeRedirect = async () => {
|
||||
const didRedirect = await tryRedirectWithToken()
|
||||
if (didRedirect) return
|
||||
try {
|
||||
const res = await getGaiaLoginOptions({ origin: window.location.origin })
|
||||
if (res?.code === 0 && res.data) {
|
||||
if (res.data.dingtalk) {
|
||||
loginOptions.dingtalk.enabled = res.data.dingtalk.enabled
|
||||
loginOptions.dingtalk.auth_url = res.data.dingtalk.auth_url || ''
|
||||
}
|
||||
if (res.data.oauth2) {
|
||||
loginOptions.oauth2.enabled = res.data.oauth2.enabled
|
||||
loginOptions.oauth2.auth_url = res.data.oauth2.auth_url || ''
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// 钉钉登录:保存回调参数并跳转钉钉授权
|
||||
const dingtalkLoginJump = () => {
|
||||
sessionStorage.setItem('gaia_login_redirect_uri', redirectUri.value)
|
||||
sessionStorage.setItem('gaia_login_state', thirdPartyState.value)
|
||||
if (loginOptions.dingtalk.auth_url) window.location.href = loginOptions.dingtalk.auth_url
|
||||
}
|
||||
|
||||
// OAuth2 登录:保存回调参数并跳转 OAuth2 授权
|
||||
const oauth2LoginJump = () => {
|
||||
sessionStorage.setItem('gaia_login_redirect_uri', redirectUri.value)
|
||||
sessionStorage.setItem('gaia_login_state', thirdPartyState.value)
|
||||
if (loginOptions.oauth2.auth_url) window.location.href = loginOptions.oauth2.auth_url
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadLoginOptionsAndMaybeRedirect()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<template>
|
||||
<div class="model-management">
|
||||
<el-card class="box-card">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span class="title">模型管理</span>
|
||||
<el-button
|
||||
type="primary"
|
||||
:loading="saving"
|
||||
@click="saveAll"
|
||||
>
|
||||
保存配置
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-loading="loading" class="provider-list">
|
||||
<el-empty
|
||||
v-if="!loading && providerList.length === 0"
|
||||
description="暂无提供商配置"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-for="provider in providerList"
|
||||
:key="provider.provider_name"
|
||||
class="provider-item"
|
||||
>
|
||||
<div class="provider-header">
|
||||
<div class="provider-info">
|
||||
<el-icon class="provider-icon">
|
||||
<cpu />
|
||||
</el-icon>
|
||||
<span class="provider-name">{{ getProviderDisplayName(provider.provider_name) }}</span>
|
||||
<el-tag
|
||||
v-if="provider.enabled"
|
||||
type="success"
|
||||
size="small"
|
||||
>
|
||||
已开启
|
||||
</el-tag>
|
||||
<el-tag
|
||||
v-else
|
||||
type="info"
|
||||
size="small"
|
||||
>
|
||||
已关闭
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="provider-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
:type="provider.enabled ? 'danger' : 'success'"
|
||||
@click="toggleProvider(provider)"
|
||||
>
|
||||
{{ provider.enabled ? '关闭' : '开启' }}
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
@click="testCredentials(provider.provider_name)"
|
||||
>
|
||||
测试凭证
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-collapse-transition>
|
||||
<div v-if="provider.enabled" class="provider-models">
|
||||
<div class="models-header">
|
||||
<span class="models-title">可用模型</span>
|
||||
<div class="models-actions">
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="primary"
|
||||
@click="selectAllModels(provider)"
|
||||
>
|
||||
全选
|
||||
</el-button>
|
||||
<el-button
|
||||
size="small"
|
||||
text
|
||||
type="info"
|
||||
@click="clearAllModels(provider)"
|
||||
>
|
||||
清空
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="provider.available_models && provider.available_models.length > 0" class="models-select-wrapper">
|
||||
<el-select
|
||||
v-model="provider.models"
|
||||
multiple
|
||||
filterable
|
||||
collapse-tags
|
||||
collapse-tags-tooltip
|
||||
:max-collapse-tags="5"
|
||||
placeholder="请选择模型"
|
||||
class="models-select"
|
||||
@change="onModelSelectChange(provider)"
|
||||
>
|
||||
<el-option
|
||||
v-for="model in provider.available_models"
|
||||
:key="model.id"
|
||||
:label="model.name"
|
||||
:value="model.id"
|
||||
/>
|
||||
</el-select>
|
||||
<div class="selected-count">
|
||||
已选择 {{ provider.models?.length || 0 }} / {{ provider.available_models.length }} 个模型
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty
|
||||
v-else
|
||||
description="未找到可用模型,请先在Dify中配置该提供商"
|
||||
:image-size="80"
|
||||
/>
|
||||
</div>
|
||||
</el-collapse-transition>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Cpu } from '@element-plus/icons-vue'
|
||||
import {
|
||||
getProviderListApi,
|
||||
updateProviderConfigApi,
|
||||
testProviderCredentialsApi
|
||||
} from '@/api/modelProvider'
|
||||
|
||||
defineOptions({
|
||||
name: 'IntegratedModelManagement'
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const providerList = ref([])
|
||||
|
||||
// 提供商显示名称映射
|
||||
const providerDisplayNames = {
|
||||
openai: 'OpenAI',
|
||||
tongyi: '千问(通义)',
|
||||
google: 'Google Gemini'
|
||||
}
|
||||
|
||||
const getProviderDisplayName = (providerName) => {
|
||||
return providerDisplayNames[providerName] || providerName
|
||||
}
|
||||
|
||||
// 获取提供商列表
|
||||
const getProviderList = async() => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getProviderListApi()
|
||||
if (res.code === 0) {
|
||||
// 处理数据,添加selectedModelsSet用于checkbox绑定
|
||||
providerList.value = res.data.map(provider => {
|
||||
const selectedModelsSet = {}
|
||||
if (provider.models && Array.isArray(provider.models)) {
|
||||
provider.models.forEach(modelId => {
|
||||
selectedModelsSet[modelId] = true
|
||||
})
|
||||
}
|
||||
return {
|
||||
...provider,
|
||||
selectedModelsSet
|
||||
}
|
||||
})
|
||||
} else {
|
||||
ElMessage.error(res.msg || '获取提供商列表失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取提供商列表失败', error)
|
||||
ElMessage.error('获取提供商列表失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换提供商开关
|
||||
const toggleProvider = (provider) => {
|
||||
provider.enabled = !provider.enabled
|
||||
if (!provider.enabled) {
|
||||
// 关闭时清空选中的模型
|
||||
provider.selectedModelsSet = {}
|
||||
provider.models = []
|
||||
}
|
||||
}
|
||||
|
||||
// 下拉框选择变化
|
||||
const onModelSelectChange = (provider) => {
|
||||
// 同步更新 selectedModelsSet(保持兼容性)
|
||||
provider.selectedModelsSet = {}
|
||||
if (provider.models && Array.isArray(provider.models)) {
|
||||
provider.models.forEach(modelId => {
|
||||
provider.selectedModelsSet[modelId] = true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 全选模型
|
||||
const selectAllModels = (provider) => {
|
||||
if (provider.available_models && provider.available_models.length > 0) {
|
||||
provider.models = provider.available_models.map(model => model.id)
|
||||
onModelSelectChange(provider)
|
||||
}
|
||||
}
|
||||
|
||||
// 清空模型选择
|
||||
const clearAllModels = (provider) => {
|
||||
provider.selectedModelsSet = {}
|
||||
provider.models = []
|
||||
}
|
||||
|
||||
// 保存所有配置
|
||||
const saveAll = async() => {
|
||||
saving.value = true
|
||||
try {
|
||||
// 逐个保存提供商配置
|
||||
for (const provider of providerList.value) {
|
||||
await updateProviderConfigApi({
|
||||
provider_name: provider.provider_name,
|
||||
enabled: provider.enabled,
|
||||
models: provider.models || []
|
||||
})
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
} catch (error) {
|
||||
console.error('保存配置失败', error)
|
||||
ElMessage.error('保存配置失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试凭证
|
||||
const testCredentials = async(providerName) => {
|
||||
try {
|
||||
const res = await testProviderCredentialsApi(providerName)
|
||||
if (res.code === 0) {
|
||||
ElMessageBox.alert(
|
||||
`提供商: ${res.data.provider}\nAPI Key: ${res.data.api_key}\n凭证状态: ${res.data.has_api_key ? '已配置' : '未配置'}`,
|
||||
'凭证测试结果',
|
||||
{
|
||||
confirmButtonText: '确定',
|
||||
type: 'success'
|
||||
}
|
||||
)
|
||||
} else {
|
||||
ElMessage.error(res.msg || '测试失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('测试凭证失败', error)
|
||||
ElMessage.error('测试凭证失败:' + (error.message || '未知错误'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
getProviderList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.model-management {
|
||||
padding: 20px;
|
||||
|
||||
.box-card {
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.provider-list {
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.provider-item {
|
||||
border: 1px solid #e4e7ed;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.provider-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.provider-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
|
||||
.provider-icon {
|
||||
font-size: 24px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.provider-name {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-models {
|
||||
background-color: #f5f7fa;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
|
||||
.models-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.models-title {
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.models-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.models-select-wrapper {
|
||||
.models-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.selected-count {
|
||||
margin-top: 12px;
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 优化下拉框样式
|
||||
:deep(.el-select) {
|
||||
.el-select__tags {
|
||||
flex-wrap: wrap;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.el-tag {
|
||||
margin: 2px 4px 2px 0;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.el-select-dropdown) {
|
||||
.el-select-dropdown__item {
|
||||
padding: 8px 16px;
|
||||
|
||||
&.is-selected {
|
||||
font-weight: 600;
|
||||
color: #409eff;
|
||||
background-color: #ecf5ff;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user