Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions apps/portal/migrations/0001_initial.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,6 @@ class Migration(migrations.Migration):
("name", models.CharField(default="智能体门户", max_length=64, verbose_name="门户名称")),
("description", models.TextField(blank=True, max_length=256, null=True, verbose_name="门户描述")),
("logo", models.CharField(blank=True, max_length=512, null=True, verbose_name="门户Logo地址")),
(
"tab_logo",
models.CharField(blank=True, max_length=512, null=True, verbose_name="浏览器Tab Logo地址"),
),
("enable_public_access", models.BooleanField(default=True, verbose_name="是否开启公开访问")),
("enable_api", models.BooleanField(default=True, verbose_name="是否开启API服务")),
("enable_knowledge_base_api", models.BooleanField(default=True, verbose_name="是否开启知识库API")),
Expand Down
2 changes: 0 additions & 2 deletions apps/portal/models/portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@ class Portal(AppModelMixin):

logo = models.CharField(max_length=512, null=True, blank=True, verbose_name="门户Logo地址")

tab_logo = models.CharField(max_length=512, null=True, blank=True, verbose_name="浏览器Tab Logo地址")

enable_public_access = models.BooleanField(default=True, verbose_name="是否开启公开访问")
# API服务配置
enable_api = models.BooleanField(default=True, verbose_name="是否开启API服务")
Expand Down
11 changes: 3 additions & 8 deletions apps/portal/serializers/portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,6 @@ class PortalSerializer(serializers.Serializer):
logo = serializers.CharField(
required=False, allow_null=True, allow_blank=True, label=_("portal logo"), help_text=_("portal logo")
)
tab_logo = serializers.CharField(
required=False, allow_null=True, allow_blank=True, label=_("tab logo"), help_text=_("tab logo")
)
enable_public_access = serializers.BooleanField(
required=False, label=_("enable public access"), help_text=_("enable public access")
)
Expand All @@ -65,9 +62,7 @@ class Meta:
model = Portal
fields = "__all__"

def one(self, with_valid=True):
if with_valid:
self.is_valid(raise_exception=True)
def one(self):
portal = Portal.objects.first()
if portal is None:
raise AppApiException(500, _("Portal configuration does not exist"))
Expand Down Expand Up @@ -101,11 +96,11 @@ def edit(self, instance, with_valid=True):
portal = Portal.objects.first()
if portal is None:
raise AppApiException(500, _("Portal configuration does not exist"))
file_fields = ["logo", "tab_logo", "id", "create_time", "update_time"]
file_fields = ["logo", "id", "create_time", "update_time"]
for field, value in instance.items():
if hasattr(portal, field) and field not in file_fields:
setattr(portal, field, value)
for field_name in ["logo", "tab_logo"]:
for field_name in ["logo"]:
if field_name in instance:
self._handle_file_field(portal, field_name, instance.get(field_name))
portal.save()
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/views/portal.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ def get(self, request: Request):
@log(menu="Portal", operate="Save portal configuration")
@has_permissions(PermissionConstants.PORTAL_EDIT, RoleConstants.ADMIN)
def put(self, request: Request):
return result.success(PortalSerializer().edit(request.data))
return result.success(PortalSerializer(data=request.data).edit(request.data))


class PortalLoginView(APIView):
Expand Down
9 changes: 6 additions & 3 deletions ui/src/api/API_README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,12 @@ src/api/

## 业务接口组织

`admin/system/portal.ts` 通过 Admin `/portal` 读取和局部保存门户配置,公共类型维护在
`types/portal.ts`。JSON 用于访问开关和认证配置,门户名称与 Logo 使用 FormData;保存返回完整配置。
认证配置保留未编辑字段,跨域 `cors_config` 暂按不透明对象保留,待后端定义其字段与执行协议。
`admin/system/chat-management/portal-setting.ts` 通过 Admin `/portal` 读取和局部保存门户配置,
公共类型维护在 `types/portal.ts`。JSON 用于访问开关和认证、跨域配置,门户名称与 Logo 使用
FormData;保存返回完整配置,页面以该返回值作为唯一数据来源。
认证配置保留未编辑字段,仅提交后端读取的 `login_value`、`max_attempts`、`failed_attempts`、
`lock_time`;跨域配置写入 `cross_domain_list`(对齐后端 `ApplicationApiKey.cross_domain_list`),
并保留 `cors_config` 中的其他字段。

- 业务 API 先按 Admin 入口下的 `auth`、`workspace`、`system` 等一级业务域归类。Workspace 和
System 内部可继续按明确的功能域建立子目录,例如 `workspace/application/`、
Expand Down
9 changes: 6 additions & 3 deletions ui/src/api/types/portal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,28 @@ import type { Dict } from './common'

export interface PortalAuthConfig extends Dict<unknown> {
login_value?: string[]
default_value?: string
max_attempts?: number
failed_attempts?: number
lock_time?: number
}

export interface PortalCorsConfig extends Dict<unknown> {
/** 允许跨域访问的地址列表,对齐后端 cross_domain_list 字段。 */
cross_domain_list?: string[]
}

export interface PortalSetting {
id: string
name: string
description: string | null
logo: string | null
tab_logo: string | null
enable_public_access: boolean
enable_api: boolean
enable_knowledge_base_api: boolean
enable_auth: boolean
auth_config: PortalAuthConfig
enable_cors: boolean
cors_config: Dict<unknown>
cors_config: PortalCorsConfig
}

export type PortalSettingPayload = Partial<Omit<PortalSetting, 'id'>>
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
<script setup lang="ts">
import { computed, reactive, ref, useTemplateRef } from 'vue'
import { computed, onMounted, reactive, ref, useTemplateRef } from 'vue'
import PortalApi from '@/api/admin/system/chat-management/portal-setting.ts'
import type { PortalSetting, PortalSettingPayload } from '@/api/types'
import LogoIcon from '@/components/mk-logo/LogoIcon.vue'
import { copyText } from '@/utils/clipboard'
import { resetUrl } from '@/utils/icon'
import { MsgSuccess } from '@/utils/message'
import PortalPreview from './components/PortalPreview.vue'
import ButtonEditPortal from './components/ButtonEditPortal.vue'
import ButtonPortalAuthSetting from './components/ButtonPortalAuthSetting.vue'
import ButtonPortalCorsSetting from './components/ButtonPortalCorsSetting.vue'
import perm from '@/permission/index.ts'

type AccessField = 'enable_public_access' | 'enable_api' | 'enable_knowledge_base_api' | 'enable_auth' | 'enable_cors'

Expand All @@ -18,7 +18,6 @@ const portalSetting = reactive<PortalSetting>({
name: '智能体门户',
description: '',
logo: '',
tab_logo: '',
enable_public_access: true,
enable_api: true,
enable_knowledge_base_api: true,
Expand All @@ -32,52 +31,76 @@ const portalSetting = reactive<PortalSetting>({
enable_cors: false,
cors_config: {},
})
const loading = ref(false)
const saving = ref(false)
// 跨域地址暂存于页面,待接口协议确定后接入 cors_config。
const portalCorsOrigins = ref<string[]>([])

const portalAccessUrl = new URL('/portal', window.location.origin).href
const portalApiUrl = new URL('/api/portal', window.location.origin).href

/* 接口尚未接入,页面使用默认配置;保存回填时保留现有配置对象。 */
function savePortalSetting(payload: PortalSettingPayload | FormData) {
// saving.value = true
// return PortalApi.putPortalSetting(payload)
// .then((setting) => {
// Object.assign(portalSetting, setting)
// MsgSuccess('保存成功')
// })
// .finally(() => {
// saving.value = false
// })
/* 进入页面加载门户配置,失败时保留默认配置 */
function loadPortalSetting() {
loading.value = true
return PortalApi.getPortalSetting()
.then((setting) => Object.assign(portalSetting, setting))
.finally(() => {
loading.value = false
})
}

/* 保存门户配置:接口返回完整配置,成功后整体回填作为页面唯一数据来源 */
function savePortalSetting(payload: PortalSettingPayload | FormData): Promise<void> {
saving.value = true
return PortalApi.putPortalSetting(payload)
.then((setting) => {
Object.assign(portalSetting, setting)
MsgSuccess('保存成功')
})
.finally(() => {
saving.value = false
})
}

function handleAccessChange(field: AccessField, value: string | number | boolean) {
if (saving.value) return
if (field === 'enable_auth' && value && !portalSetting.auth_config.login_value?.length) {
authSettingButtonRef.value?.open(true)
handleOpenAuthSetting(true)
return
}
// 使用服务端确认的值渲染开关,失败时保留原配置。
return savePortalSetting({ [field]: Boolean(value) })
}

/* 认证接口暂未接入,保存到当前页面配置供再次打开回填。 */
/* 认证配置保存,payload 已保留未编辑字段 */
function savePortalAuthSetting(payload: PortalSettingPayload) {
Object.assign(portalSetting, payload)
MsgSuccess('已应用到当前页面')
return Promise.resolve()
return savePortalSetting(payload)
}

/* 跨域地址保存:写入 cors_config 的 cross_domain_list,保留其余字段 */
function savePortalCorsSetting(origins: string[]) {
return savePortalSetting({ cors_config: { ...portalSetting.cors_config, cross_domain_list: origins } })
}

function handleOpenAuthSetting(enableAfterSave = false) {
authSettingButtonRef.value?.open(enableAfterSave)
}

/* 编辑草稿预览与认证入口联动 */

const authSettingButtonRef = useTemplateRef<InstanceType<typeof ButtonPortalAuthSetting>>('authSettingButtonRef')
const portalCorsOrigins = computed({
get: () => portalSetting.cors_config.cross_domain_list ?? [],
set: (origins: string[]) => {
portalSetting.cors_config = { ...portalSetting.cors_config, cross_domain_list: origins }
},
})
const previewName = computed(() => portalSetting.name)
const previewLogo = computed(() => portalSetting.logo)
const previewLogo = computed(() => resetUrl(portalSetting.logo))

onMounted(() => loadPortalSetting())
</script>

<template>
<MkViewLayout title="门户访问设置">
<MkViewLayout title="门户访问设置" :loading="loading">
<div class="flex min-w-215 flex-1 gap-4">
<div class="w-95 shrink-0 space-y-4">
<el-card shadow="never">
Expand All @@ -88,7 +111,7 @@ const previewLogo = computed(() => portalSetting.logo)
<h4 class="truncate" :title="previewName">{{ previewName }}</h4>
</div>
<!-- 编辑门户名称与 Logo -->
<ButtonEditPortal ref="editPortalButtonRef" :setting="portalSetting" :save="savePortalSetting" />
<ButtonEditPortal :setting="portalSetting" :save="savePortalSetting" />
</div>
</el-card>
<el-card shadow="never">
Expand Down Expand Up @@ -151,15 +174,15 @@ const previewLogo = computed(() => portalSetting.logo)
<span>身份认证</span>
<div class="flex-align-center gap-2">
<!-- 配置身份认证 -->
<ButtonPortalAuthSetting ref="authSettingButtonRef" :setting="portalSetting" :save="savePortalAuthSetting" />
<ButtonPortalAuthSetting ref="authSettingButtonRef" :setting="portalSetting" :saving="saving" :save="savePortalAuthSetting" />
<el-switch :model-value="portalSetting.enable_auth" size="small" @change="handleAccessChange('enable_auth', $event)" />
</div>
</div>
<div class="flex-between gap-2">
<span>跨域设置</span>
<div class="flex-align-center gap-2">
<!-- 配置跨域地址 -->
<ButtonPortalCorsSetting v-model="portalCorsOrigins" />
<ButtonPortalCorsSetting v-model="portalCorsOrigins" :saving="saving" :save="savePortalCorsSetting" />
<el-switch :model-value="portalSetting.enable_cors" size="small" @change="handleAccessChange('enable_cors', $event)" />
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, reactive, ref, useTemplateRef } from 'vue'
import { reactive, ref, useTemplateRef } from 'vue'
import type { FormInstance, FormRules } from 'element-plus'
import { LOGIN_METHOD } from '@/api/enums'
import AuthSettingApi from '@/api/admin/system/settings/auth-setting'
Expand Down Expand Up @@ -27,21 +27,12 @@ const enableAuthAfterSave = ref(false)
const authFormRef = useTemplateRef<FormInstance>('authFormRef')
const authForm = reactive({
loginMethods: [LOGIN_METHOD.LOCAL] as string[],
defaultLoginMethod: LOGIN_METHOD.LOCAL as string,
maxAttempts: 1,
failedAttempts: 5,
lockTime: 10,
})
const defaultLoginMethodOptions = computed(() => loginMethodOptions.value.filter((method) => authForm.loginMethods.includes(method)))
const authRules: FormRules<typeof authForm> = {
loginMethods: [{ type: 'array', required: true, min: 1, message: '请选择至少一种登录方式', trigger: 'change' }],
defaultLoginMethod: [{ required: true, message: '请选择默认登录方式', trigger: 'change' }],
}

function handleLoginMethodsChange() {
if (!authForm.loginMethods.includes(authForm.defaultLoginMethod)) {
authForm.defaultLoginMethod = defaultLoginMethodOptions.value[0] ?? ''
}
}

/* 抽屉回填与重置,草稿不直接修改门户配置 */
Expand All @@ -50,12 +41,10 @@ function open(enableAfterSave = false) {
const config = props.setting.auth_config
Object.assign(authForm, {
loginMethods: [...(config.login_value ?? [LOGIN_METHOD.LOCAL])],
defaultLoginMethod: config.default_value ?? LOGIN_METHOD.LOCAL,
maxAttempts: config.max_attempts ?? 1,
failedAttempts: config.failed_attempts ?? 5,
lockTime: config.lock_time ?? 10,
})
handleLoginMethodsChange()
authVisible.value = true
loadLoginMethodOptions()
}
Expand All @@ -68,15 +57,14 @@ function handleClosed() {
enableAuthAfterSave.value = false
Object.assign(authForm, {
loginMethods: [LOGIN_METHOD.LOCAL],
defaultLoginMethod: LOGIN_METHOD.LOCAL,
maxAttempts: 1,
failedAttempts: 5,
lockTime: 10,
})
authFormRef.value?.clearValidate()
}

/* 保存认证配置,保留其他配置字段 */
/* 保存认证配置,仅提交后端读取的字段并保留其余配置 */
function handleSaveAuthSetting() {
authFormRef.value?.validate((valid) => {
if (!valid || props.saving || loginMethodsLoading.value) return
Expand All @@ -86,7 +74,6 @@ function handleSaveAuthSetting() {
auth_config: {
...props.setting.auth_config,
login_value: [...authForm.loginMethods],
default_value: authForm.defaultLoginMethod,
max_attempts: authForm.maxAttempts,
failed_attempts: authForm.failedAttempts,
lock_time: authForm.lockTime,
Expand Down Expand Up @@ -117,22 +104,12 @@ defineExpose({ open })
@submit.prevent
>
<el-form-item label="登录方式" prop="loginMethods">
<el-checkbox-group v-model="authForm.loginMethods" class="flex-wrap" @change="handleLoginMethodsChange">
<el-checkbox-group v-model="authForm.loginMethods" class="flex-wrap">
<template v-for="loginMethod in loginMethodOptions" :key="loginMethod">
<el-checkbox :value="loginMethod" class="w-32">{{ LOGIN_METHOD_LABELS[loginMethod as LoginMethod] || loginMethod }}</el-checkbox>
</template>
</el-checkbox-group>
</el-form-item>
<el-form-item label="默认登录方式" prop="defaultLoginMethod">
<el-select v-model="authForm.defaultLoginMethod" placeholder="请选择默认登录方式">
<el-option
v-for="loginMethod in defaultLoginMethodOptions"
:key="loginMethod"
:value="loginMethod"
:label="LOGIN_METHOD_LABELS[loginMethod as LoginMethod] || loginMethod"
/>
</el-select>
</el-form-item>
<el-form-item label="账号登录验证码设置" required>
<div class="mk-gray-card-lg w-full space-y-4">
<div class="flex-align-center gap-2">
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { MsgSuccess } from '@/utils/message'

defineProps<{ disabled?: boolean }>()
const props = defineProps<{ saving?: boolean; save: (origins: string[]) => Promise<void> }>()
const allowedOrigins = defineModel<string[]>({ default: () => [] })

/* 跨域地址草稿,取消不修改已保存配置。 */
Expand All @@ -16,9 +15,19 @@ function handleOpenCorsSetting() {
corsVisible.value = true
}

/* 按行解析跨域地址,去掉行首尾空白与空行 */
function parseCorsOrigins(value: string) {
return value
.split('\n')
.map((origin) => origin.trim())
.filter(Boolean)
}

function handleSaveCorsSetting() {
// TODO
corsVisible.value = false
if (props.saving) return
return props.save(parseCorsOrigins(corsAddressDraft.value)).then(() => {
corsVisible.value = false
})
}

function handleClosed() {
Expand All @@ -35,9 +44,9 @@ function handleClosed() {
<el-input v-model="corsAddressDraft" type="textarea" :rows="8" :placeholder="corsAddressPlaceholder" />
<template #footer>
<!-- 取消跨域设置 -->
<el-button plain @click="corsVisible = false">取消</el-button>
<el-button plain :disabled="saving" @click="corsVisible = false">取消</el-button>
<!-- 保存跨域地址 -->
<el-button type="primary" :disabled="disabled" @click="handleSaveCorsSetting">保存</el-button>
<el-button type="primary" :loading="saving" @click="handleSaveCorsSetting">保存</el-button>
</template>
</MkDialog>
</template>
Loading