feat: 新增后端模型管理,第三方快捷登录

This commit is contained in:
npc0-hue
2026-02-11 17:41:59 +08:00
parent 7947b7976b
commit 9226a3d795
49 changed files with 2724 additions and 99 deletions
+1
View File
@@ -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
+57
View File
@@ -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
})
}
+27
View File
@@ -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_tokenExtend: 兼容 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
View File
@@ -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
+1
View File
@@ -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",
+18 -2
View File
@@ -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
}
+1 -1
View File
@@ -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',
+106 -29
View File
@@ -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>
+93 -29
View File
@@ -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>