Files
ai-gateway-go/web/apps/admin/src/views/system/user/index.vue
T
LLMGuardX Dev 87c2b04174 0.11.3: 旗舰版第四轮完善(统一审批中心/工具治理/平台环境变量/数字员工入口/个人渠道/报表多维/租户配额)
- 统一审批中心:模型/资源/渠道/工具四类申请聚合审批,通过自动开通
  (marketplace 安装/渠道授权),outbox 双向站内信;门户可发起/撤回。
- 工具治理:rate_limit_rpm(固定窗口原子 upsert,多实例共享)+ approval_required
  (首次调用自动发起审批,批准前一律拒绝)。
- 平台环境变量:平台级注入 skill/MCP 运行时,个人可覆盖;系统管理员可写。
- 数字员工会话入口:门户列表/对话/调用记录,复用用户运行时凭据。
- 个人渠道:webhook 入站令牌 SHA-256 摘要 + constant-time 校验,绑定已批准
  模型,用量归属用户 Key。
- 报表多维:工具调用/审批授权/安全事件三组统计端点与页面。
- 租户配额:部门 Key/月 Token 上限,运行时凭据开通强制校验,概览展示用量。
- 迁移 000042-000045;修复渠道空 API Key NOT NULL 违约与 inet 扫描;
  25 包测试通过,前后端构建通过,端到端验证完成。
2026-08-13 13:41:22 +08:00

671 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="page-content">
<div class="mb-5 flex items-center justify-between gap-4">
<div>
<h2 class="text-xl font-semibold">账号与权限</h2>
<p class="text-g-500 mt-1 text-sm">管理员和门户账号统一管理权限变更即时生效</p>
</div>
<ElButton type="primary" @click="openCreate">
{{ activeTab === 'department' ? '新增部门' : activeTab === 'oidc' || activeTab === 'saml' || activeTab === 'social' ? '新增身份源' : '新增账号' }}
</ElButton>
</div>
<ElAlert
class="mb-4"
type="info"
:closable="false"
title="内置角色提供默认权限;直接权限使用 resource:action 格式并与角色权限合并。"
/>
<ElTabs v-model="activeTab" @tab-change="load">
<ElTabPane label="管理员" name="admin" />
<ElTabPane label="门户用户" name="portal" />
<ElTabPane label="部门" name="department" />
<ElTabPane label="OIDC 身份源" name="oidc" />
<ElTabPane label="SAML 身份源" name="saml" />
<ElTabPane label="扫码登录" name="social" />
</ElTabs>
<ElTable v-if="activeTab === 'admin' || activeTab === 'portal'" v-loading="loading" :data="records" row-key="id">
<ElTableColumn prop="login" label="账号" min-width="170" />
<ElTableColumn prop="display_name" label="显示名称" min-width="150">
<template #default="{ row }">{{ row.display_name || '-' }}</template>
</ElTableColumn>
<ElTableColumn prop="role" label="角色" width="130" />
<ElTableColumn v-if="activeTab === 'portal'" prop="department_name" label="部门" min-width="150">
<template #default="{ row }">{{ row.department_name || '未分配' }}</template>
</ElTableColumn>
<ElTableColumn label="有效权限" min-width="280">
<template #default="{ row }">
<div class="flex flex-wrap gap-1">
<ElTag v-for="permission in row.effective_permissions" :key="permission" size="small">
{{ permission }}
</ElTag>
<span v-if="row.effective_permissions.length === 0">-</span>
</div>
</template>
</ElTableColumn>
<ElTableColumn label="TOTP" width="90">
<template #default="{ row }">
<ElTag :type="row.totp_enabled ? 'success' : 'info'">
{{ row.totp_enabled ? '已启用' : '未启用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="90">
<template #default="{ row }">
<ElTag :type="row.active ? 'success' : 'danger'">
{{ row.active ? '启用' : '停用' }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="90" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElTable v-else-if="activeTab === 'department'" v-loading="loading" :data="departments" row-key="id">
<ElTableColumn prop="code" label="代码" min-width="150" />
<ElTableColumn prop="name" label="名称" min-width="170" />
<ElTableColumn prop="parent_name" label="上级部门" min-width="160">
<template #default="{ row }">{{ row.parent_name || '根部门' }}</template>
</ElTableColumn>
<ElTableColumn prop="description" label="描述" min-width="220" show-overflow-tooltip />
<ElTableColumn prop="user_count" label="启用用户" width="100" />
<ElTableColumn label="状态" width="90">
<template #default="{ row }">
<ElTag :type="row.active ? 'success' : 'info'">{{ row.active ? '启用' : '停用' }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="90" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" @click="openDepartmentEdit(row)">编辑</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElTable v-else-if="activeTab === 'oidc'" v-loading="loading" :data="identityProviders" row-key="id">
<ElTableColumn prop="code" label="代码" min-width="130" />
<ElTableColumn prop="display_name" label="名称" min-width="150" />
<ElTableColumn prop="issuer_url" label="Issuer" min-width="260" show-overflow-tooltip />
<ElTableColumn prop="client_id" label="Client ID" min-width="170" show-overflow-tooltip />
<ElTableColumn label="自动开户" width="100">
<template #default="{ row }">{{ row.auto_provision ? '启用' : '关闭' }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="90">
<template #default="{ row }"><ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag></template>
</ElTableColumn>
<ElTableColumn label="操作" width="90" fixed="right">
<template #default="{ row }"><ElButton link type="primary" @click="openIdentityProviderEdit(row)">编辑</ElButton></template>
</ElTableColumn>
</ElTable>
<ElTable v-else-if="activeTab === 'social'" v-loading="loading" :data="socialProviders" row-key="kind">
<ElTableColumn label="平台" width="110">
<template #default="{ row }">{{ socialKindName(row.kind) }}</template>
</ElTableColumn>
<ElTableColumn prop="code" label="代码" min-width="120" />
<ElTableColumn prop="display_name" label="名称" min-width="140" />
<ElTableColumn prop="client_id" label="AppID" min-width="180" show-overflow-tooltip />
<ElTableColumn v-if="socialProviders.some((p: SocialProviderRecord) => p.kind === 'wecom')" prop="agent_id" label="AgentID" min-width="110" />
<ElTableColumn label="密钥" width="80">
<template #default="{ row }"><ElTag :type="row.secret_configured ? 'success' : 'danger'">{{ row.secret_configured ? '已配置' : '缺失' }}</ElTag></template>
</ElTableColumn>
<ElTableColumn label="自动开户" width="100">
<template #default="{ row }">{{ row.auto_provision ? '启用' : '关闭' }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="90">
<template #default="{ row }"><ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag></template>
</ElTableColumn>
<ElTableColumn label="操作" width="140" fixed="right">
<template #default="{ row }">
<ElButton link type="primary" @click="openSocialProviderEdit(row)">编辑</ElButton>
<ElButton link type="danger" @click="removeSocialProvider(row)">删除</ElButton>
</template>
</ElTableColumn>
</ElTable>
<ElTable v-else v-loading="loading" :data="samlProviders" row-key="id">
<ElTableColumn prop="code" label="代码" min-width="130" />
<ElTableColumn prop="display_name" label="名称" min-width="150" />
<ElTableColumn prop="metadata_url" label="IdP Metadata" min-width="280" show-overflow-tooltip />
<ElTableColumn prop="sp_entity_id" label="SP Entity ID" min-width="220" show-overflow-tooltip />
<ElTableColumn label="自动开户" width="100">
<template #default="{ row }">{{ row.auto_provision ? '启用' : '关闭' }}</template>
</ElTableColumn>
<ElTableColumn label="状态" width="90">
<template #default="{ row }"><ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag></template>
</ElTableColumn>
<ElTableColumn label="操作" width="90" fixed="right">
<template #default="{ row }"><ElButton link type="primary" @click="openSAMLProviderEdit(row)">编辑</ElButton></template>
</ElTableColumn>
</ElTable>
<ElDialog v-model="dialogVisible" :title="editingId ? '编辑账号' : '新增账号'" width="640px">
<ElForm ref="formRef" :model="form" :rules="rules" label-width="100px">
<ElFormItem label="账号" prop="login">
<ElInput v-model="form.login" placeholder="登录账号" />
</ElFormItem>
<ElFormItem label="显示名称">
<ElInput v-model="form.display_name" maxlength="64" />
</ElFormItem>
<ElFormItem label="角色" prop="role">
<ElSelect v-model="form.role" class="w-full">
<ElOption
v-for="role in roleOptions"
:key="role.value"
:label="role.label"
:value="role.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem :label="editingId ? '新口令' : '初始口令'" prop="password">
<ElInput
v-model="form.password"
type="password"
show-password
:placeholder="editingId ? '留空表示不修改' : '至少 12 个字符'"
/>
</ElFormItem>
<ElFormItem label="直接权限">
<ElSelect
v-model="form.permissions"
multiple
filterable
allow-create
default-first-option
class="w-full"
placeholder="选择或输入 resource:action"
>
<ElOption v-for="item in permissionOptions" :key="item" :label="item" :value="item" />
</ElSelect>
</ElFormItem>
<ElFormItem v-if="activeTab === 'portal'" label="所属部门">
<ElSelect v-model="form.department_id" clearable class="w-full" placeholder="可不分配">
<ElOption
v-for="department in activeDepartments"
:key="department.id"
:label="department.name"
:value="department.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="启用">
<ElSwitch v-model="form.active" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
</template>
</ElDialog>
<ElDialog v-model="departmentDialogVisible" :title="departmentEditingId ? '编辑部门' : '新增部门'" width="600px">
<ElForm ref="departmentFormRef" :model="departmentForm" label-width="100px">
<ElFormItem label="代码" required>
<ElInput v-model="departmentForm.code" placeholder="例如 platform" />
</ElFormItem>
<ElFormItem label="名称" required>
<ElInput v-model="departmentForm.name" maxlength="128" />
</ElFormItem>
<ElFormItem label="上级部门">
<ElSelect v-model="departmentForm.parent_id" clearable class="w-full">
<ElOption
v-for="department in availableParents"
:key="department.id"
:label="department.name"
:value="department.id"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="描述">
<ElInput v-model="departmentForm.description" type="textarea" maxlength="1024" show-word-limit />
</ElFormItem>
<ElFormItem label="Key 配额">
<ElInputNumber v-model="departmentForm.max_api_keys" :min="0" :max="1000000" class="w-full" />
<div class="text-g-400 text-xs">该租户(部门)可绑定的网关 API Key 上限0 = 不限</div>
</ElFormItem>
<ElFormItem label="月 Token 配额">
<ElInputNumber v-model="departmentForm.max_monthly_tokens" :min="0" :max="1000000000000000" class="w-full" />
<div class="text-g-400 text-xs">该租户每月 Token 用量上限0 = 不限</div>
</ElFormItem>
<ElFormItem label="启用">
<ElSwitch v-model="departmentForm.active" />
</ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="departmentDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="submitDepartment">保存</ElButton>
</template>
</ElDialog>
<ElDialog v-model="idpDialogVisible" :title="idpEditingId ? '编辑 OIDC 身份源' : '新增 OIDC 身份源'" width="720px">
<ElForm :model="idpForm" label-width="130px">
<ElFormItem label="代码" required><ElInput v-model="idpForm.code" placeholder="例如 corp_oidc" /></ElFormItem>
<ElFormItem label="显示名称" required><ElInput v-model="idpForm.display_name" /></ElFormItem>
<ElFormItem label="Issuer URL" required><ElInput v-model="idpForm.issuer_url" placeholder="https://id.example.com" /></ElFormItem>
<ElFormItem label="Client ID" required><ElInput v-model="idpForm.client_id" /></ElFormItem>
<ElFormItem label="Client Secret" :required="!idpEditingId">
<ElInput v-model="idpForm.client_secret" type="password" show-password :placeholder="idpEditingId ? '留空表示不更换' : ''" />
</ElFormItem>
<ElFormItem label="网关回调 URL" required><ElInput v-model="idpForm.redirect_uri" /></ElFormItem>
<ElFormItem label="门户返回 URL" required><ElInput v-model="idpForm.portal_return_url" /></ElFormItem>
<ElFormItem label="Scopes"><ElSelect v-model="idpForm.scopes" multiple allow-create filterable class="w-full" /></ElFormItem>
<ElFormItem label="默认部门">
<ElSelect v-model="idpForm.default_department_id" clearable class="w-full">
<ElOption v-for="department in activeDepartments" :key="department.id" :label="department.name" :value="department.id" />
</ElSelect>
</ElFormItem>
<ElFormItem label="自动开户"><ElSwitch v-model="idpForm.auto_provision" /></ElFormItem>
<ElFormItem label="启用"><ElSwitch v-model="idpForm.enabled" /></ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="idpDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="submitIdentityProvider">保存</ElButton>
</template>
</ElDialog>
<ElDialog v-model="samlDialogVisible" :title="samlEditingId ? '编辑 SAML 身份源' : '新增 SAML 身份源'" width="760px">
<ElAlert class="mb-4" type="info" :closable="false" title="启用时服务端会拉取 IdP metadata,并验证 Redirect SSO 端点和当前有效的签名证书。" />
<ElForm :model="samlForm" label-width="150px">
<ElFormItem label="代码" required><ElInput v-model="samlForm.code" placeholder="例如 corp_saml" /></ElFormItem>
<ElFormItem label="显示名称" required><ElInput v-model="samlForm.display_name" /></ElFormItem>
<ElFormItem label="IdP Metadata URL" required><ElInput v-model="samlForm.metadata_url" placeholder="https://idp.example.com/metadata" /></ElFormItem>
<ElFormItem label="SP Entity ID" required><ElInput v-model="samlForm.sp_entity_id" placeholder="urn:example:ai-gateway" /></ElFormItem>
<ElFormItem label="ACS URL" required><ElInput v-model="samlForm.acs_url" placeholder="https://gateway.example.com/api/v1/portal/sso/corp_saml/callback" /></ElFormItem>
<ElFormItem label="门户返回 URL" required><ElInput v-model="samlForm.portal_return_url" /></ElFormItem>
<ElFormItem label="邮箱属性"><ElInput v-model="samlForm.email_attribute" placeholder="mail 或属性 URI" /></ElFormItem>
<ElFormItem label="姓名属性"><ElInput v-model="samlForm.name_attribute" placeholder="cn 或属性 URI" /></ElFormItem>
<ElFormItem label="默认部门">
<ElSelect v-model="samlForm.default_department_id" clearable class="w-full">
<ElOption v-for="department in activeDepartments" :key="department.id" :label="department.name" :value="department.id" />
</ElSelect>
</ElFormItem>
<ElFormItem label="自动开户"><ElSwitch v-model="samlForm.auto_provision" /></ElFormItem>
<ElFormItem label="启用"><ElSwitch v-model="samlForm.enabled" /></ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="samlDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="submitSAMLProvider">保存</ElButton>
</template>
</ElDialog>
<ElDialog v-model="socialDialogVisible" :title="socialEditingKind ? '编辑扫码登录' : '新增扫码登录'" width="720px">
<ElAlert class="mb-4" type="info" :closable="false" title="在企业微信/钉钉/飞书开放平台创建应用后填写;回调地址需配置为下方「回调 URL」。" />
<ElForm :model="socialForm" label-width="150px">
<ElFormItem label="平台" required>
<ElSelect v-model="socialForm.kind" class="w-full" :disabled="!!socialEditingKind">
<ElOption label="企业微信" value="wecom" />
<ElOption label="钉钉" value="dingtalk" />
<ElOption label="飞书" value="feishu" />
</ElSelect>
</ElFormItem>
<ElFormItem label="代码" required><ElInput v-model="socialForm.code" :placeholder="`例如 corp_${socialForm.kind || 'wecom'}`" :disabled="!!socialEditingKind" /></ElFormItem>
<ElFormItem label="显示名称" required><ElInput v-model="socialForm.display_name" :placeholder="socialKindName(socialForm.kind)" /></ElFormItem>
<ElFormItem v-if="socialForm.kind === 'wecom'" label="CorpID" required><ElInput v-model="socialForm.client_id" placeholder="企业微信 CorpID" /></ElFormItem>
<ElFormItem v-else-if="socialForm.kind === 'dingtalk'" label="AppKey" required><ElInput v-model="socialForm.client_id" placeholder="钉钉应用 AppKey" /></ElFormItem>
<ElFormItem v-else label="AppID" required><ElInput v-model="socialForm.client_id" placeholder="飞书应用 AppID" /></ElFormItem>
<ElFormItem v-if="socialForm.kind === 'wecom'" label="AgentID" required><ElInput v-model="socialForm.agent_id" placeholder="企业微信应用 AgentID" /></ElFormItem>
<ElFormItem label="AppSecret" :required="!socialEditingKind">
<ElInput v-model="socialForm.secret" type="password" show-password :placeholder="socialEditingKind ? '留空则不修改' : '应用 AppSecret'" />
</ElFormItem>
<ElFormItem label="回调 URL" required>
<ElInput v-model="socialForm.redirect_uri" :placeholder="`https://你的域名/api/v1/portal/sso/${socialForm.code || 'corp_wecom'}/callback`" />
</ElFormItem>
<ElFormItem label="门户返回 URL" required><ElInput v-model="socialForm.portal_return_url" placeholder="例如 https://你的域名/#/auth/login" /></ElFormItem>
<ElFormItem label="默认部门">
<ElSelect v-model="socialForm.default_department_id" clearable class="w-full">
<ElOption v-for="department in activeDepartments" :key="department.id" :label="department.name" :value="department.id" />
</ElSelect>
</ElFormItem>
<ElFormItem label="自动开户"><ElSwitch v-model="socialForm.auto_provision" /></ElFormItem>
<ElFormItem label="启用"><ElSwitch v-model="socialForm.enabled" /></ElFormItem>
</ElForm>
<template #footer>
<ElButton @click="socialDialogVisible = false">取消</ElButton>
<ElButton type="primary" :loading="saving" @click="submitSocialProvider">保存</ElButton>
</template>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox, FormInstance, FormRules, TabsPaneContext } from 'element-plus'
import {
createDepartment,
createIdentityProvider,
createSAMLProvider,
createIdentity,
createSocialProvider,
deleteSocialProvider,
DepartmentInput,
DepartmentRecord,
fetchDepartments,
fetchIdentityProviders,
fetchSAMLProviders,
fetchIdentities,
fetchSocialProviders,
IdentityInput,
IdentityKind,
IdentityRecord,
IdentityProviderInput,
IdentityProviderRecord,
SAMLProviderInput,
SAMLProviderRecord,
SocialProviderInput,
SocialProviderRecord,
updateDepartment,
updateIdentityProvider,
updateSAMLProvider,
updateIdentity,
updateSocialProvider
} from '@/api/identities'
defineOptions({ name: 'User' })
const permissionOptions = [
'identity:manage',
'provider:read',
'provider:manage',
'api_key:read',
'api_key:manage'
]
type ManagementTab = IdentityKind | 'department' | 'oidc' | 'saml' | 'social'
const activeTab = ref<ManagementTab>('admin')
const records = ref<IdentityRecord[]>([])
const loading = ref(false)
const saving = ref(false)
const dialogVisible = ref(false)
const editingId = ref('')
const departments = ref<DepartmentRecord[]>([])
const identityProviders = ref<IdentityProviderRecord[]>([])
const samlProviders = ref<SAMLProviderRecord[]>([])
const socialProviders = ref<SocialProviderRecord[]>([])
const departmentDialogVisible = ref(false)
const departmentEditingId = ref('')
const idpDialogVisible = ref(false)
const idpEditingId = ref('')
const samlDialogVisible = ref(false)
const samlEditingId = ref('')
const socialDialogVisible = ref(false)
const socialEditingKind = ref('')
const formRef = ref<FormInstance>()
const departmentFormRef = ref<FormInstance>()
const form = reactive<IdentityInput>({
login: '',
display_name: '',
role: 'operator',
password: '',
permissions: [],
active: true,
department_id: undefined
})
const departmentForm = reactive<DepartmentInput>({
code: '', name: '', description: '', parent_id: undefined, active: true, max_api_keys: 0, max_monthly_tokens: 0
})
const idpForm = reactive<IdentityProviderInput>({
code: '', display_name: '', issuer_url: '', client_id: '', client_secret: '',
redirect_uri: '', portal_return_url: '', scopes: ['openid', 'profile', 'email'],
auto_provision: false, default_department_id: undefined, enabled: false
})
const samlForm = reactive<SAMLProviderInput>({
code: '', display_name: '', metadata_url: '', sp_entity_id: '', acs_url: '',
portal_return_url: '', email_attribute: 'mail', name_attribute: 'cn',
auto_provision: false, default_department_id: undefined, enabled: false
})
const socialForm = reactive<SocialProviderInput & { kind: string }>({
kind: 'wecom', code: '', display_name: '', client_id: '', agent_id: '',
secret: '', redirect_uri: '', portal_return_url: '',
auto_provision: false, default_department_id: undefined, enabled: false
})
const activeDepartments = computed(() => departments.value.filter((item) => item.active))
const availableParents = computed(() =>
activeDepartments.value.filter((item) => item.id !== departmentEditingId.value)
)
const roleOptions = computed(() =>
activeTab.value === 'admin'
? [
{ label: '超级管理员', value: 'superadmin' },
{ label: '运维管理员', value: 'operator' },
{ label: '审计员', value: 'auditor' }
]
: [{ label: '普通成员', value: 'member' }]
)
const rules: FormRules = {
login: [
{ required: true, message: '请输入账号', trigger: 'blur' },
{ min: 2, max: 128, message: '长度必须为 2 至 128 个字符', trigger: 'blur' }
],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
async function load(_pane?: TabsPaneContext | string | number) {
loading.value = true
try {
departments.value = await fetchDepartments()
if (activeTab.value === 'admin' || activeTab.value === 'portal') records.value = await fetchIdentities(activeTab.value)
if (activeTab.value === 'oidc') identityProviders.value = await fetchIdentityProviders()
if (activeTab.value === 'saml') samlProviders.value = await fetchSAMLProviders()
if (activeTab.value === 'social') socialProviders.value = await fetchSocialProviders()
} finally {
loading.value = false
}
}
function resetForm() {
Object.assign(form, {
login: '',
display_name: '',
role: activeTab.value === 'admin' ? 'operator' : 'member',
password: '',
permissions: [],
active: true,
department_id: undefined
})
}
function openCreate() {
if (activeTab.value === 'department') {
departmentEditingId.value = ''
Object.assign(departmentForm, { code: '', name: '', description: '', parent_id: undefined, active: true, max_api_keys: 0, max_monthly_tokens: 0 })
departmentDialogVisible.value = true
return
}
if (activeTab.value === 'oidc') {
idpEditingId.value = ''
Object.assign(idpForm, { code: '', display_name: '', issuer_url: '', client_id: '', client_secret: '', redirect_uri: '', portal_return_url: '', scopes: ['openid', 'profile', 'email'], auto_provision: false, default_department_id: undefined, enabled: false })
idpDialogVisible.value = true
return
}
if (activeTab.value === 'saml') {
samlEditingId.value = ''
Object.assign(samlForm, { code: '', display_name: '', metadata_url: '', sp_entity_id: '', acs_url: '', portal_return_url: '', email_attribute: 'mail', name_attribute: 'cn', auto_provision: false, default_department_id: undefined, enabled: false })
samlDialogVisible.value = true
return
}
if (activeTab.value === 'social') {
socialEditingKind.value = ''
Object.assign(socialForm, { kind: 'wecom', code: '', display_name: '', client_id: '', agent_id: '', secret: '', redirect_uri: '', portal_return_url: '', auto_provision: false, default_department_id: undefined, enabled: false })
socialDialogVisible.value = true
return
}
editingId.value = ''
resetForm()
dialogVisible.value = true
}
function openEdit(record: IdentityRecord) {
editingId.value = record.id
Object.assign(form, {
login: record.login,
display_name: record.display_name,
role: record.role,
password: '',
permissions: [...record.permissions],
active: record.active,
department_id: record.department_id
})
dialogVisible.value = true
}
function openDepartmentEdit(record: DepartmentRecord) {
departmentEditingId.value = record.id
Object.assign(departmentForm, {
code: record.code, name: record.name, description: record.description,
parent_id: record.parent_id, active: record.active, max_api_keys: record.max_api_keys || 0, max_monthly_tokens: record.max_monthly_tokens || 0
})
departmentDialogVisible.value = true
}
async function submitDepartment() {
if (!departmentForm.code || !departmentForm.name) {
ElMessage.warning('部门代码和名称不能为空')
return
}
saving.value = true
try {
const payload: DepartmentInput = { ...departmentForm }
if (!payload.parent_id) delete payload.parent_id
if (departmentEditingId.value) await updateDepartment(departmentEditingId.value, payload)
else await createDepartment(payload)
ElMessage.success('部门保存成功')
departmentDialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
function openIdentityProviderEdit(record: IdentityProviderRecord) {
idpEditingId.value = record.id
Object.assign(idpForm, { code: record.code, display_name: record.display_name, issuer_url: record.issuer_url, client_id: record.client_id, client_secret: '', redirect_uri: record.redirect_uri, portal_return_url: record.portal_return_url, scopes: [...record.scopes], auto_provision: record.auto_provision, default_department_id: record.default_department_id, enabled: record.enabled })
idpDialogVisible.value = true
}
async function submitIdentityProvider() {
if (!idpForm.code || !idpForm.display_name || !idpForm.issuer_url || !idpForm.client_id || !idpForm.redirect_uri || !idpForm.portal_return_url || (!idpEditingId.value && !idpForm.client_secret)) {
ElMessage.warning('请填写所有必填 OIDC 配置')
return
}
saving.value = true
try {
const payload: IdentityProviderInput = { ...idpForm, scopes: [...idpForm.scopes] }
if (!payload.client_secret) delete payload.client_secret
if (!payload.default_department_id) delete payload.default_department_id
if (idpEditingId.value) await updateIdentityProvider(idpEditingId.value, payload)
else await createIdentityProvider(payload)
ElMessage.success('OIDC 身份源保存成功')
idpDialogVisible.value = false
await load()
} finally { saving.value = false }
}
function openSAMLProviderEdit(record: SAMLProviderRecord) {
samlEditingId.value = record.id
Object.assign(samlForm, {
code: record.code, display_name: record.display_name, metadata_url: record.metadata_url,
sp_entity_id: record.sp_entity_id, acs_url: record.acs_url,
portal_return_url: record.portal_return_url, email_attribute: record.email_attribute,
name_attribute: record.name_attribute, auto_provision: record.auto_provision,
default_department_id: record.default_department_id, enabled: record.enabled
})
samlDialogVisible.value = true
}
async function submitSAMLProvider() {
if (!samlForm.code || !samlForm.display_name || !samlForm.metadata_url || !samlForm.sp_entity_id || !samlForm.acs_url || !samlForm.portal_return_url) {
ElMessage.warning('请填写所有必填 SAML 配置')
return
}
saving.value = true
try {
const payload: SAMLProviderInput = { ...samlForm }
if (!payload.default_department_id) delete payload.default_department_id
if (samlEditingId.value) await updateSAMLProvider(samlEditingId.value, payload)
else await createSAMLProvider(payload)
ElMessage.success('SAML 身份源保存成功')
samlDialogVisible.value = false
await load()
} finally { saving.value = false }
}
function socialKindName(kind: string) {
return ({ wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' } as Record<string, string>)[kind] || kind
}
function openSocialProviderEdit(record: SocialProviderRecord) {
socialEditingKind.value = record.kind
Object.assign(socialForm, {
kind: record.kind, code: record.code, display_name: record.display_name,
client_id: record.client_id, agent_id: record.agent_id || '',
secret: '', redirect_uri: record.redirect_uri, portal_return_url: record.portal_return_url,
auto_provision: record.auto_provision, default_department_id: record.default_department_id, enabled: record.enabled
})
socialDialogVisible.value = true
}
async function submitSocialProvider() {
const form = socialForm
if (!form.code || !form.display_name || !form.client_id || !form.redirect_uri || !form.portal_return_url || (form.kind === 'wecom' && !form.agent_id)) {
ElMessage.warning('请填写所有必填扫码登录配置')
return
}
saving.value = true
try {
const payload: SocialProviderInput = {
code: form.code, display_name: form.display_name, client_id: form.client_id,
agent_id: form.agent_id, secret: form.secret || undefined,
redirect_uri: form.redirect_uri, portal_return_url: form.portal_return_url,
auto_provision: form.auto_provision, default_department_id: form.default_department_id, enabled: form.enabled
}
if (!payload.default_department_id) delete payload.default_department_id
if (socialEditingKind.value) {
if (!payload.secret) delete payload.secret
await updateSocialProvider(socialEditingKind.value, payload)
} else {
await createSocialProvider(form.kind, payload)
}
ElMessage.success('扫码登录身份源保存成功')
socialDialogVisible.value = false
await load()
} finally { saving.value = false }
}
async function removeSocialProvider(record: SocialProviderRecord) {
await ElMessageBox.confirm(`删除后该平台的所有扫码绑定将失效,确定删除 ${socialKindName(record.kind)} 身份源?`, '删除身份源', { type: 'warning' })
await deleteSocialProvider(record.kind)
socialProviders.value = socialProviders.value.filter((item) => item.kind !== record.kind)
ElMessage.success('已删除')
}
async function submit() {
if (!(await formRef.value?.validate())) return
if (!editingId.value && (!form.password || form.password.length < 12)) {
ElMessage.warning('初始口令至少需要 12 个字符')
return
}
if (form.password && form.password.length < 12) {
ElMessage.warning('新口令至少需要 12 个字符')
return
}
saving.value = true
try {
const payload: IdentityInput = { ...form, permissions: [...form.permissions] }
if (editingId.value && !payload.password) delete payload.password
if (!payload.department_id) delete payload.department_id
const kind = activeTab.value as IdentityKind
if (editingId.value) await updateIdentity(kind, editingId.value, payload)
else await createIdentity(kind, payload)
ElMessage.success('保存成功,权限状态已即时生效')
dialogVisible.value = false
await load()
} finally {
saving.value = false
}
}
onMounted(load)
</script>