0.11.6: 渠道权限管控(部门范围 + 用户级授权)
- 迁移 000047:channels.department_ids(空=全局) + channel_grants 用户级授权 (source=manual/approval 区分来源)。 - 管理端:渠道部门范围配置 + 授权管理(列表/授予/撤销);渠道列表显示范围。 - 门户:我的渠道端点(/api/v1/portal/channels)按部门可见或明确授权返回, 「个人渠道」页新增可使用渠道区(授权方式标识)。 - 审批流:资源申请中的渠道类型通过后自动写 channel_grants(source=approval), 取代'批准记录即授权'的弱语义。 - 端到端验证:部门隔离(demo 无部门看不到)→手动授予→可见→撤销→不可见; 审批通过自动授权。修复 JOIN 列歧义与 uuid/text 比较。
This commit is contained in:
@@ -24,6 +24,12 @@
|
||||
<ElTag :type="row.has_api_key ? 'success' : 'info'">{{ row.has_api_key ? '已配置' : '未配置' }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="部门范围" min-width="150">
|
||||
<template #default="{ row }">
|
||||
<ElTag v-if="!row.department_ids || !row.department_ids.length" type="info">全局</ElTag>
|
||||
<ElTag v-else type="primary">{{ row.department_ids.length }} 个部门</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag>
|
||||
@@ -32,6 +38,7 @@
|
||||
<ElTableColumn label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="success" :loading="testingId === row.id" @click="test(row)">测试</ElButton>
|
||||
<ElButton link type="primary" @click="openGrants(row)">授权</ElButton>
|
||||
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template>
|
||||
@@ -58,6 +65,12 @@
|
||||
<ElInput v-model="form.binding_model" placeholder="模型名,如 gpt-4o-mini" />
|
||||
</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="部门范围">
|
||||
<ElSelect v-model="form.department_ids" multiple filterable class="w-full" placeholder="留空 = 全局渠道(所有部门可见)">
|
||||
<ElOption v-for="department in departments" :key="department.id" :label="department.name" :value="department.id" />
|
||||
</ElSelect>
|
||||
<div class="text-g-400 text-xs">仅列出的部门可见此渠道;用户级授权在「授权」中单独管理</div>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="网关 API Key">
|
||||
<ElInput v-model="form.api_key" type="password" show-password :placeholder="editingId ? '留空不更换' : '必填'" />
|
||||
</ElFormItem>
|
||||
@@ -82,6 +95,25 @@
|
||||
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog v-model="grantsVisible" :title="`渠道授权 · ${grantsChannel?.name || ''}`" width="640px">
|
||||
<div class="mb-3 flex gap-2">
|
||||
<ElSelect v-model="grantUserID" filterable class="flex-1" placeholder="选择门户用户">
|
||||
<ElOption v-for="user in portalUsers" :key="user.id" :label="`${user.display_name || user.login} (${user.login})`" :value="user.id" />
|
||||
</ElSelect>
|
||||
<ElButton type="primary" :loading="granting" :disabled="!grantUserID" @click="grant">授予</ElButton>
|
||||
</div>
|
||||
<ElTable :data="grants" row-key="portal_user_id" max-height="360">
|
||||
<ElTableColumn prop="user_login" label="用户" min-width="160" />
|
||||
<ElTableColumn label="来源" width="100">
|
||||
<template #default="{ row }"><ElTag size="small" :type="row.source === 'approval' ? 'warning' : 'primary'">{{ row.source === 'approval' ? '审批' : '手动' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="created_at" label="授权时间" width="180" />
|
||||
<ElTableColumn label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }"><ElButton link type="danger" @click="revoke(row)">撤销</ElButton></template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -95,9 +127,13 @@
|
||||
name: string
|
||||
kind: string
|
||||
model_binding: Record<string, any>
|
||||
department_ids: string[]
|
||||
has_api_key: boolean
|
||||
enabled: boolean
|
||||
}
|
||||
interface ChannelGrant { channel_id: string; channel_code: string; channel_name: string; portal_user_id: string; user_login: string; source: string; created_at: string }
|
||||
interface Department { id: string; name: string }
|
||||
interface PortalUser { id: string; login: string; display_name: string }
|
||||
|
||||
const kindMap: Record<string, string> = { webhook: 'Webhook', wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' }
|
||||
const kindLabel = (kind: string) => kindMap[kind] || kind
|
||||
@@ -109,15 +145,30 @@
|
||||
const testingId = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
const departments = ref<Department[]>([])
|
||||
const portalUsers = ref<PortalUser[]>([])
|
||||
const grantsVisible = ref(false)
|
||||
const grantsChannel = ref<Channel>()
|
||||
const grants = ref<ChannelGrant[]>([])
|
||||
const grantUserID = ref('')
|
||||
const granting = ref(false)
|
||||
const form = reactive({
|
||||
code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '',
|
||||
inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: ''
|
||||
inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '',
|
||||
department_ids: [] as string[]
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
channels.value = await request.get<Channel[]>({ url: '/api/v1/admin/channels' })
|
||||
const [channelList, departmentList, userList] = await Promise.all([
|
||||
request.get<Channel[]>({ url: '/api/v1/admin/channels' }),
|
||||
request.get<Department[]>({ url: '/api/v1/admin/departments' }),
|
||||
request.get<PortalUser[]>({ url: '/api/v1/admin/identities/portal-users' })
|
||||
])
|
||||
channels.value = channelList
|
||||
departments.value = departmentList
|
||||
portalUsers.value = userList
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -125,7 +176,7 @@
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = ''
|
||||
Object.assign(form, { code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '' })
|
||||
Object.assign(form, { code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '', department_ids: [] })
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
@@ -133,7 +184,8 @@
|
||||
editingId.value = row.id
|
||||
Object.assign(form, {
|
||||
code: row.code, name: row.name, kind: row.kind, api_key: '',
|
||||
binding_provider: row.model_binding?.provider || '', binding_model: row.model_binding?.model || ''
|
||||
binding_provider: row.model_binding?.provider || '', binding_model: row.model_binding?.model || '',
|
||||
department_ids: [...(row.department_ids || [])]
|
||||
})
|
||||
dialogVisible.value = true
|
||||
}
|
||||
@@ -160,6 +212,7 @@
|
||||
const payload = {
|
||||
code: form.code, name: form.name, kind: form.kind, config,
|
||||
model_binding: { provider: form.binding_provider || undefined, model: form.binding_model || undefined },
|
||||
department_ids: form.department_ids,
|
||||
api_key: form.api_key
|
||||
}
|
||||
if (editingId.value) {
|
||||
@@ -186,6 +239,38 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openGrants(row: Channel) {
|
||||
grantsChannel.value = row
|
||||
grantUserID.value = ''
|
||||
grants.value = await request.get<ChannelGrant[]>({ url: `/api/v1/admin/channels/${row.id}/grants` })
|
||||
grantsVisible.value = true
|
||||
}
|
||||
|
||||
async function grant() {
|
||||
if (!grantUserID.value || !grantsChannel.value) return
|
||||
granting.value = true
|
||||
try {
|
||||
await request.post({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants`, params: { portal_user_id: grantUserID.value } })
|
||||
ElMessage.success('已授予')
|
||||
grantUserID.value = ''
|
||||
grants.value = await request.get<ChannelGrant[]>({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants` })
|
||||
} finally {
|
||||
granting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(row: ChannelGrant) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`撤销「${row.user_login}」的渠道使用权限?`, '撤销授权', { type: 'warning' })
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (!grantsChannel.value) return
|
||||
await request.del({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants/${row.portal_user_id}` })
|
||||
grants.value = grants.value.filter((item) => item.portal_user_id !== row.portal_user_id)
|
||||
ElMessage.success('已撤销')
|
||||
}
|
||||
|
||||
async function remove(row: Channel) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除渠道「${row.name}」?`, '删除渠道', { type: 'warning' })
|
||||
|
||||
@@ -114,3 +114,7 @@ export const fetchMyEmployeeRuns=(limit=20)=>request.get<EmployeeRun[]>({url:'/a
|
||||
export interface AgentPolicy { auto_approve_tools:boolean;rate_limit_multiplier:number }
|
||||
export const fetchAgentPolicy=()=>request.get<AgentPolicy>({url:'/api/v1/portal/agent-policy'})
|
||||
export const setAgentPolicy=(params:AgentPolicy)=>request.put<{saved:boolean}>({url:'/api/v1/portal/agent-policy',params})
|
||||
|
||||
// --- 我的渠道(部门可见或已授权) ---
|
||||
export interface VisibleChannel { id:string;code:string;name:string;kind:string;model_binding?:Record<string,unknown>;department_ids:string[];enabled:boolean }
|
||||
export const fetchMyChannels=()=>request.get<{channels:VisibleChannel[];granted_codes:string[]}>({url:'/api/v1/portal/channels'})
|
||||
|
||||
@@ -28,6 +28,31 @@
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElCard shadow="never" class="mb-5">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="font-medium">可使用渠道</div>
|
||||
<span class="text-g-400 text-xs">部门可见或已获授权;需要更多渠道请到「我的申请」发起申请</span>
|
||||
</div>
|
||||
</template>
|
||||
<ElTable v-loading="loading" :data="visibleChannels" row-key="code">
|
||||
<ElTableColumn prop="name" label="名称" min-width="150" />
|
||||
<ElTableColumn prop="code" label="代码" width="150" />
|
||||
<ElTableColumn label="类型" width="110">
|
||||
<template #default="{ row }">{{ kindLabel(row.kind) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="绑定模型" min-width="170">
|
||||
<template #default="{ row }">{{ row.model_binding?.model || '—' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="授权方式" width="110">
|
||||
<template #default="{ row }">
|
||||
<ElTag :type="grantedCodes.includes(row.code) ? 'success' : 'info'">{{ grantedCodes.includes(row.code) ? '已授权' : '部门可见' }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
<ElEmpty v-if="!visibleChannels.length && !loading" description="暂无可用渠道" :image-size="60" />
|
||||
</ElCard>
|
||||
|
||||
<ElDialog v-model="visible" title="新建 Webhook 渠道" width="560px">
|
||||
<ElForm label-width="100px">
|
||||
<ElFormItem label="名称" required><ElInput v-model="form.name" maxlength="128" placeholder="例如 报表机器人" /></ElFormItem>
|
||||
@@ -69,9 +94,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChatModel, PersonalChannel,
|
||||
ChatModel, PersonalChannel, VisibleChannel,
|
||||
createPersonalChannel, deletePersonalChannel, fetchChatModels,
|
||||
fetchPersonalChannels, regeneratePersonalToken
|
||||
fetchMyChannels, fetchPersonalChannels, regeneratePersonalToken
|
||||
} from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
@@ -79,6 +104,8 @@ const saving = ref(false)
|
||||
const visible = ref(false)
|
||||
const inboundVisible = ref(false)
|
||||
const items = ref<PersonalChannel[]>([])
|
||||
const visibleChannels = ref<VisibleChannel[]>([])
|
||||
const grantedCodes = ref<string[]>([])
|
||||
const models = ref<ChatModel[]>([])
|
||||
const selectedModel = ref('')
|
||||
const current = ref<PersonalChannel>()
|
||||
@@ -97,10 +124,16 @@ const modelGroups = computed(() => {
|
||||
return groups
|
||||
})
|
||||
|
||||
const kindMap: Record<string, string> = { webhook: 'Webhook', wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' }
|
||||
const kindLabel = (kind: string) => kindMap[kind] || kind
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await fetchPersonalChannels()
|
||||
const [personal, visible] = await Promise.all([fetchPersonalChannels(), fetchMyChannels()])
|
||||
items.value = personal
|
||||
visibleChannels.value = visible.channels || []
|
||||
grantedCodes.value = visible.granted_codes || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user