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 包测试通过,前后端构建通过,端到端验证完成。
This commit is contained in:
@@ -13,3 +13,30 @@ export const updateFactPolicy=(id:string,params:FactPolicy)=>request.put<FactPol
|
||||
export const deleteFactPolicy=(id:string)=>request.del({url:`/api/v1/admin/fact-check/policies/${id}`})
|
||||
export const fetchFactEvents=()=>request.get<{items:FactEvent[]}>({url:'/api/v1/admin/fact-check/events'})
|
||||
|
||||
|
||||
// --- 0.11.3 治理:资源/渠道申请、工具审批、报表多维、平台环境变量 ---
|
||||
export interface ResourceRequest {
|
||||
id: string; portal_user_id: string; user_login: string; resource_type: string
|
||||
resource_code: string; reason: string; status: string; decision_note: string
|
||||
decided_at?: string; created_at: string; updated_at: string
|
||||
}
|
||||
export interface ToolApproval {
|
||||
id: string; tool_code: string; tool_name: string; status: string
|
||||
reason: string; decision_note: string; created_at?: string; decided_at?: string; decided_by?: string
|
||||
}
|
||||
export const fetchResourceRequests=(status='')=>request.get<ResourceRequest[]>({url:'/api/v1/admin/resource-requests',params:{status}})
|
||||
export const decideResourceRequest=(id:string,status:'approve'|'reject',note:string)=>request.post<ResourceRequest>({url:`/api/v1/admin/resource-requests/${id}/${status}`,params:{note}})
|
||||
export const fetchToolApprovals=(status='')=>request.get<ToolApproval[]>({url:'/api/v1/admin/tool-approvals',params:{status}})
|
||||
export const decideToolApproval=(id:string,status:'approved'|'rejected',note:string)=>request.post<ToolApproval>({url:`/api/v1/admin/tool-approvals/${id}/decide`,params:{status,note}})
|
||||
|
||||
export interface ToolUsageRow { code:string;name:string;requests:number;success:number;failed:number;avg_latency_ms:number }
|
||||
export interface ApprovalStatRow { kind:string;status:string;count:number }
|
||||
export interface SecurityReport { login_stats:Array<{success:boolean;count:number;distinct_ips:number}>;top_ips:Array<{ip:string;count:number}> }
|
||||
export const fetchToolUsageReport=(params:{from:string;to:string})=>request.get<ToolUsageRow[]>({url:'/api/v1/admin/reports/tools',params})
|
||||
export const fetchApprovalReport=(params:{from:string;to:string})=>request.get<ApprovalStatRow[]>({url:'/api/v1/admin/reports/approvals',params})
|
||||
export const fetchSecurityReport=(params:{from:string;to:string})=>request.get<SecurityReport>({url:'/api/v1/admin/reports/security',params})
|
||||
|
||||
export interface PlatformEnvVar { key:string;configured:boolean;description:string;updated_at:string }
|
||||
export const fetchPlatformEnvVars=()=>request.get<PlatformEnvVar[]>({url:'/api/v1/admin/env-vars'})
|
||||
export const upsertPlatformEnvVar=(key:string,value:string,description:string)=>request.put<{saved:boolean}>({url:`/api/v1/admin/env-vars/${key}`,params:{value,description}})
|
||||
export const deletePlatformEnvVar=(key:string)=>request.del({url:`/api/v1/admin/env-vars/${key}`})
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface DepartmentRecord {
|
||||
parent_id?: string
|
||||
parent_name?: string
|
||||
active: boolean
|
||||
max_api_keys: number
|
||||
max_monthly_tokens: number
|
||||
user_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -49,6 +51,8 @@ export interface DepartmentInput {
|
||||
description: string
|
||||
parent_id?: string
|
||||
active: boolean
|
||||
max_api_keys?: number
|
||||
max_monthly_tokens?: number
|
||||
}
|
||||
|
||||
export interface IdentityProviderRecord {
|
||||
|
||||
@@ -29,8 +29,8 @@ export const addKnowledgeDocument=(id:string,params:{title:string;source_type:st
|
||||
export const deleteKnowledgeDocument=(kb:string,id:string)=>request.del({url:`/api/v1/admin/knowledge-bases/${kb}/documents/${id}`})
|
||||
export const searchKnowledge=(id:string,params:{query:string;top_k:number})=>request.post<SearchHit[]>({url:`/api/v1/admin/knowledge-bases/${id}/search`,params})
|
||||
|
||||
export interface ToolDefinition {id:string;code:string;name:string;description:string;endpoint_url:string;http_method:string;input_schema:Record<string,unknown>;timeout_seconds:number;department_ids:string[];enabled:boolean;has_secret_headers:boolean;revision:number}
|
||||
export interface ToolInput {code:string;name:string;description:string;endpoint_url:string;http_method:string;headers?:Record<string,string>;input_schema:Record<string,unknown>;timeout_seconds:number;department_ids:string[];enabled:boolean}
|
||||
export interface ToolDefinition {id:string;code:string;name:string;description:string;endpoint_url:string;http_method:string;input_schema:Record<string,unknown>;timeout_seconds:number;department_ids:string[];rate_limit_rpm:number;approval_required:boolean;enabled:boolean;has_secret_headers:boolean;revision:number}
|
||||
export interface ToolInput {code:string;name:string;description:string;endpoint_url:string;http_method:string;headers?:Record<string,string>;input_schema:Record<string,unknown>;timeout_seconds:number;department_ids:string[];rate_limit_rpm:number;approval_required:boolean;enabled:boolean}
|
||||
export const fetchTools=()=>request.get<ToolDefinition[]>({url:'/api/v1/admin/tools'})
|
||||
export const createTool=(params:ToolInput)=>request.post<ToolDefinition>({url:'/api/v1/admin/tools',params})
|
||||
export const updateTool=(id:string,params:ToolInput)=>request.put<ToolDefinition>({url:`/api/v1/admin/tools/${id}`,params})
|
||||
|
||||
@@ -59,12 +59,45 @@
|
||||
<ElTableColumn prop="cost" label="成本" width="140" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="工具调用" name="tools">
|
||||
<ElTable v-loading="loading" :data="toolRows" row-key="code">
|
||||
<ElTableColumn prop="name" label="工具" min-width="160" />
|
||||
<ElTableColumn prop="code" label="编码" min-width="140" />
|
||||
<ElTableColumn prop="requests" label="调用数" width="110" />
|
||||
<ElTableColumn prop="success" label="成功" width="100" />
|
||||
<ElTableColumn prop="failed" label="失败" width="100" />
|
||||
<ElTableColumn prop="avg_latency_ms" label="平均延迟(ms)" width="130" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="审批授权" name="approvals">
|
||||
<ElTable v-loading="loading" :data="approvalRows" row-key="key">
|
||||
<ElTableColumn label="申请类型" width="140">
|
||||
<template #default="{ row }">{{ ({ model: '模型', resource: '资源/渠道', tool: '工具' } as Record<string, string>)[row.kind as string] || row.kind }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="120">
|
||||
<template #default="{ row }">{{ ({ pending: '待审批', approved: '已通过', rejected: '已驳回', cancelled: '已取消' } as Record<string, string>)[row.status as string] || row.status }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="count" label="数量" width="110" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
<ElTabPane label="安全事件" name="security">
|
||||
<div class="mb-4 grid grid-cols-3 gap-4">
|
||||
<ElCard shadow="never"><div class="text-g-500 text-sm">登录成功</div><b class="mt-2 block text-2xl">{{ securityStats.success || 0 }}</b></ElCard>
|
||||
<ElCard shadow="never"><div class="text-g-500 text-sm">登录失败</div><b class="mt-2 block text-2xl">{{ securityStats.failed || 0 }}</b></ElCard>
|
||||
<ElCard shadow="never"><div class="text-g-500 text-sm">来源 IP 数</div><b class="mt-2 block text-2xl">{{ securityStats.ips || 0 }}</b></ElCard>
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="securityTopIPs" row-key="ip">
|
||||
<ElTableColumn prop="ip" label="来源 IP" min-width="220" />
|
||||
<ElTableColumn prop="count" label="登录次数" width="140" />
|
||||
</ElTable>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { fetchDailyUsage } from '@/api/audit'
|
||||
import { fetchApprovalReport, fetchSecurityReport, fetchToolUsageReport } from '@/api/governance'
|
||||
|
||||
const loading = ref(false)
|
||||
const activeTab = ref('provider')
|
||||
@@ -137,10 +170,27 @@
|
||||
return [...map.values()].sort((a, b) => b.date.localeCompare(a.date))
|
||||
})
|
||||
|
||||
const toolRows = ref<any[]>([])
|
||||
const approvalRows = ref<any[]>([])
|
||||
const securityTopIPs = ref<any[]>([])
|
||||
const securityStats = ref<{ success: number; failed: number; ips: number }>({ success: 0, failed: 0, ips: 0 })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
dailyUsage.value = await fetchDailyUsage({ from: range.value[0], to: range.value[1] })
|
||||
const params = { from: range.value[0], to: range.value[1] }
|
||||
dailyUsage.value = await fetchDailyUsage(params)
|
||||
toolRows.value = await fetchToolUsageReport(params)
|
||||
approvalRows.value = await fetchApprovalReport(params)
|
||||
const security = await fetchSecurityReport(params)
|
||||
securityTopIPs.value = security.top_ips || []
|
||||
const stats = { success: 0, failed: 0, ips: 0 }
|
||||
for (const item of security.login_stats || []) {
|
||||
if (item.success) stats.success += item.count
|
||||
else stats.failed += item.count
|
||||
stats.ips += item.distinct_ips
|
||||
}
|
||||
securityStats.value = stats
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -9,10 +9,19 @@
|
||||
</div>
|
||||
<ElTable v-loading="loading" :data="tenants" row-key="id">
|
||||
<ElTableColumn prop="name" label="租户(部门)" min-width="200" />
|
||||
<ElTableColumn prop="portal_users" label="门户账号" width="110" />
|
||||
<ElTableColumn prop="enabled_api_keys" label="启用 Key" width="110" />
|
||||
<ElTableColumn prop="today_requests" label="今日请求" width="120" />
|
||||
<ElTableColumn prop="today_tokens" label="今日 Tokens" width="140" />
|
||||
<ElTableColumn prop="portal_users" label="门户账号" width="100" />
|
||||
<ElTableColumn label="Key 配额" width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.enabled_api_keys }} / {{ row.max_api_keys || '∞' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="月 Token 配额" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ (row.month_tokens || 0).toLocaleString() }} / {{ row.max_monthly_tokens ? row.max_monthly_tokens.toLocaleString() : '∞' }}
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="today_requests" label="今日请求" width="110" />
|
||||
<ElTableColumn prop="today_tokens" label="今日 Tokens" width="130" />
|
||||
</ElTable>
|
||||
<div v-if="!loading && !tenants.length" class="py-10 text-center text-g-400">暂无租户数据</div>
|
||||
</div>
|
||||
@@ -24,10 +33,13 @@
|
||||
interface TenantRow {
|
||||
id: string
|
||||
name: string
|
||||
max_api_keys: number
|
||||
max_monthly_tokens: number
|
||||
portal_users: number
|
||||
enabled_api_keys: number
|
||||
today_requests: number
|
||||
today_tokens: number
|
||||
month_tokens: number
|
||||
}
|
||||
|
||||
const loading = ref(false)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<template><div class="page-content"><div class="mb-5 flex items-start justify-between"><div><h2 class="text-xl font-semibold">工具中心</h2><p class="text-g-500 mt-1 text-sm">只注册受控 HTTP 工具,不允许任意命令或 SQL;运行时执行 DNS/SSRF 复核和响应大小限制</p></div><ElButton type="primary" @click="openCreate">注册工具</ElButton></div><ElTable v-loading="loading" :data="items" row-key="id"><ElTableColumn prop="code" label="编码" min-width="150"/><ElTableColumn prop="name" label="名称" min-width="130"/><ElTableColumn label="端点" min-width="240"><template #default="{row}"><ElTag>{{row.http_method}}</ElTag><span class="ml-2">{{row.endpoint_url}}</span></template></ElTableColumn><ElTableColumn label="凭据头" width="100"><template #default="{row}">{{row.has_secret_headers?'已加密':'无'}}</template></ElTableColumn><ElTableColumn label="范围" width="110"><template #default="{row}">{{row.department_ids.length?`${row.department_ids.length} 部门`:'未公开'}}</template></ElTableColumn><ElTableColumn label="状态" width="90"><template #default="{row}"><ElTag :type="row.enabled?'success':'info'">{{row.enabled?'启用':'停用'}}</ElTag></template></ElTableColumn><ElTableColumn label="操作" width="190" fixed="right"><template #default="{row}"><ElButton link type="primary" @click="openTest(row)">测试</ElButton><ElButton link type="primary" @click="openEdit(row)">编辑</ElButton><ElButton link type="danger" @click="remove(row)">删除</ElButton></template></ElTableColumn></ElTable>
|
||||
<ElDialog v-model="visible" :title="editing?'编辑工具':'注册工具'" width="760px"><ElForm label-width="110px"><div class="grid grid-cols-2 gap-x-4"><ElFormItem label="编码"><ElInput v-model="form.code" placeholder="weather_lookup"/></ElFormItem><ElFormItem label="名称"><ElInput v-model="form.name"/></ElFormItem></div><ElFormItem label="描述"><ElInput v-model="form.description" type="textarea"/></ElFormItem><div class="grid grid-cols-[120px_1fr] gap-x-4"><ElFormItem label="方法"><ElSelect v-model="form.http_method"><ElOption v-for="m in methods" :key="m" :label="m" :value="m"/></ElSelect></ElFormItem><ElFormItem label="端点"><ElInput v-model="form.endpoint_url"/></ElFormItem></div><ElFormItem label="请求头 JSON"><ElInput v-model="headersJSON" type="textarea" :rows="3" :placeholder="editing&¤tHasHeaders?'留空以保留现有加密请求头':'{"Authorization":"Bearer ..."}'"/></ElFormItem><ElFormItem label="输入 Schema"><ElInput v-model="schemaJSON" type="textarea" :rows="6"/></ElFormItem><ElFormItem label="部门范围"><ElSelect v-model="form.department_ids" multiple allow-create filterable class="w-full" placeholder="为安全起见,留空时不会向普通 API Key 公开"/></ElFormItem><div class="grid grid-cols-2"><ElFormItem label="超时秒数"><ElInputNumber v-model="form.timeout_seconds" :min="1" :max="120"/></ElFormItem><ElFormItem label="启用"><ElSwitch v-model="form.enabled"/></ElFormItem></div></ElForm><template #footer><ElButton @click="visible=false">取消</ElButton><ElButton type="primary" @click="save">保存</ElButton></template></ElDialog>
|
||||
<template><div class="page-content"><div class="mb-5 flex items-start justify-between"><div><h2 class="text-xl font-semibold">工具中心</h2><p class="text-g-500 mt-1 text-sm">只注册受控 HTTP 工具,不允许任意命令或 SQL;运行时执行 DNS/SSRF 复核和响应大小限制</p></div><ElButton type="primary" @click="openCreate">注册工具</ElButton></div><ElTable v-loading="loading" :data="items" row-key="id"><ElTableColumn prop="code" label="编码" min-width="150"/><ElTableColumn prop="name" label="名称" min-width="130"/><ElTableColumn label="端点" min-width="240"><template #default="{row}"><ElTag>{{row.http_method}}</ElTag><span class="ml-2">{{row.endpoint_url}}</span></template></ElTableColumn><ElTableColumn label="凭据头" width="100"><template #default="{row}">{{row.has_secret_headers?'已加密':'无'}}</template></ElTableColumn><ElTableColumn label="范围" width="110"><template #default="{row}">{{row.department_ids.length?`${row.department_ids.length} 部门`:'未公开'}}</template></ElTableColumn><ElTableColumn label="限流" width="90"><template #default="{row}">{{row.rate_limit_rpm?`${row.rate_limit_rpm} rpm`:'不限'}}</template></ElTableColumn><ElTableColumn label="审批" width="80"><template #default="{row}"><ElTag :type="row.approval_required?'warning':'info'" size="small">{{row.approval_required?'需审批':'无需'}}</ElTag></template></ElTableColumn><ElTableColumn label="状态" width="90"><template #default="{row}"><ElTag :type="row.enabled?'success':'info'">{{row.enabled?'启用':'停用'}}</ElTag></template></ElTableColumn><ElTableColumn label="操作" width="190" fixed="right"><template #default="{row}"><ElButton link type="primary" @click="openTest(row)">测试</ElButton><ElButton link type="primary" @click="openEdit(row)">编辑</ElButton><ElButton link type="danger" @click="remove(row)">删除</ElButton></template></ElTableColumn></ElTable>
|
||||
<ElDialog v-model="visible" :title="editing?'编辑工具':'注册工具'" width="760px"><ElForm label-width="110px"><div class="grid grid-cols-2 gap-x-4"><ElFormItem label="编码"><ElInput v-model="form.code" placeholder="weather_lookup"/></ElFormItem><ElFormItem label="名称"><ElInput v-model="form.name"/></ElFormItem></div><ElFormItem label="描述"><ElInput v-model="form.description" type="textarea"/></ElFormItem><div class="grid grid-cols-[120px_1fr] gap-x-4"><ElFormItem label="方法"><ElSelect v-model="form.http_method"><ElOption v-for="m in methods" :key="m" :label="m" :value="m"/></ElSelect></ElFormItem><ElFormItem label="端点"><ElInput v-model="form.endpoint_url"/></ElFormItem></div><ElFormItem label="请求头 JSON"><ElInput v-model="headersJSON" type="textarea" :rows="3" :placeholder="editing&¤tHasHeaders?'留空以保留现有加密请求头':'{"Authorization":"Bearer ..."}'"/></ElFormItem><ElFormItem label="输入 Schema"><ElInput v-model="schemaJSON" type="textarea" :rows="6"/></ElFormItem><ElFormItem label="部门范围"><ElSelect v-model="form.department_ids" multiple allow-create filterable class="w-full" placeholder="为安全起见,留空时不会向普通 API Key 公开"/></ElFormItem><div class="grid grid-cols-2"><ElFormItem label="超时秒数"><ElInputNumber v-model="form.timeout_seconds" :min="1" :max="120"/></ElFormItem><ElFormItem label="启用"><ElSwitch v-model="form.enabled"/></ElFormItem></div><div class="grid grid-cols-2"><ElFormItem label="限流 (RPM)"><ElInputNumber v-model="form.rate_limit_rpm" :min="0" :max="100000"/></ElFormItem><ElFormItem label="需审批"><ElSwitch v-model="form.approval_required"/></ElFormItem></div></ElForm><template #footer><ElButton @click="visible=false">取消</ElButton><ElButton type="primary" @click="save">保存</ElButton></template></ElDialog>
|
||||
<ElDialog v-model="testVisible" title="测试工具" width="650px"><ElInput v-model="testInput" type="textarea" :rows="8"/><div class="my-3"><ElButton type="primary" @click="runTest">执行</ElButton></div><pre class="max-h-80 overflow-auto rounded bg-black p-3 text-xs text-white">{{testResult}}</pre></ElDialog></div></template>
|
||||
<script setup lang="ts">import{ToolDefinition,ToolInput,createTool,deleteTool,fetchTools,testTool,updateTool}from'@/api/workbench';import{ElMessage,ElMessageBox}from'element-plus';const methods=['GET','POST','PUT','PATCH','DELETE'];const loading=ref(false),visible=ref(false),testVisible=ref(false),editing=ref(''),currentHasHeaders=ref(false);const items=ref<ToolDefinition[]>([]);const blank=():ToolInput=>({code:'',name:'',description:'',endpoint_url:'',http_method:'POST',headers:{},input_schema:{type:'object',properties:{}},timeout_seconds:15,department_ids:[],enabled:true});const form=reactive<ToolInput>(blank()),headersJSON=ref('{}'),schemaJSON=ref(JSON.stringify(blank().input_schema,null,2)),testInput=ref('{}'),testResult=ref(''),testing=ref<ToolDefinition>();async function load(){loading.value=true;try{items.value=await fetchTools()}finally{loading.value=false}}function openCreate(){editing.value='';currentHasHeaders.value=false;Object.assign(form,blank());headersJSON.value='{}';schemaJSON.value=JSON.stringify(form.input_schema,null,2);visible.value=true}function openEdit(row:ToolDefinition){editing.value=row.id;currentHasHeaders.value=row.has_secret_headers;Object.assign(form,{code:row.code,name:row.name,description:row.description,endpoint_url:row.endpoint_url,http_method:row.http_method,timeout_seconds:row.timeout_seconds,department_ids:[...row.department_ids],enabled:row.enabled});headersJSON.value='';schemaJSON.value=JSON.stringify(row.input_schema,null,2);visible.value=true}async function save(){try{form.input_schema=JSON.parse(schemaJSON.value);if(headersJSON.value.trim())form.headers=JSON.parse(headersJSON.value);else delete form.headers}catch{ElMessage.error('JSON 格式无效');return}editing.value?await updateTool(editing.value,form):await createTool(form);visible.value=false;await load()}async function remove(row:ToolDefinition){await ElMessageBox.confirm(`删除工具“${row.name}”?`,'确认',{type:'warning'});await deleteTool(row.id);await load()}function openTest(row:ToolDefinition){testing.value=row;testInput.value='{}';testResult.value='';testVisible.value=true}async function runTest(){if(!testing.value)return;try{const result=await testTool(testing.value.id,JSON.parse(testInput.value));testResult.value=JSON.stringify(result,null,2)}catch(error){testResult.value=String(error)}}onMounted(load)</script>
|
||||
<script setup lang="ts">import{ToolDefinition,ToolInput,createTool,deleteTool,fetchTools,testTool,updateTool}from'@/api/workbench';import{ElMessage,ElMessageBox}from'element-plus';const methods=['GET','POST','PUT','PATCH','DELETE'];const loading=ref(false),visible=ref(false),testVisible=ref(false),editing=ref(''),currentHasHeaders=ref(false);const items=ref<ToolDefinition[]>([]);const blank=():ToolInput=>({code:'',name:'',description:'',endpoint_url:'',http_method:'POST',headers:{},input_schema:{type:'object',properties:{}},timeout_seconds:15,department_ids:[],rate_limit_rpm:0,approval_required:false,enabled:true});const form=reactive<ToolInput>(blank()),headersJSON=ref('{}'),schemaJSON=ref(JSON.stringify(blank().input_schema,null,2)),testInput=ref('{}'),testResult=ref(''),testing=ref<ToolDefinition>();async function load(){loading.value=true;try{items.value=await fetchTools()}finally{loading.value=false}}function openCreate(){editing.value='';currentHasHeaders.value=false;Object.assign(form,blank());headersJSON.value='{}';schemaJSON.value=JSON.stringify(form.input_schema,null,2);visible.value=true}function openEdit(row:ToolDefinition){editing.value=row.id;currentHasHeaders.value=row.has_secret_headers;Object.assign(form,{code:row.code,name:row.name,description:row.description,endpoint_url:row.endpoint_url,http_method:row.http_method,timeout_seconds:row.timeout_seconds,department_ids:[...row.department_ids],rate_limit_rpm:row.rate_limit_rpm||0,approval_required:!!row.approval_required,enabled:row.enabled});headersJSON.value='';schemaJSON.value=JSON.stringify(row.input_schema,null,2);visible.value=true}async function save(){try{form.input_schema=JSON.parse(schemaJSON.value);if(headersJSON.value.trim())form.headers=JSON.parse(headersJSON.value);else delete form.headers}catch{ElMessage.error('JSON 格式无效');return}editing.value?await updateTool(editing.value,form):await createTool(form);visible.value=false;await load()}async function remove(row:ToolDefinition){await ElMessageBox.confirm(`删除工具“${row.name}”?`,'确认',{type:'warning'});await deleteTool(row.id);await load()}function openTest(row:ToolDefinition){testing.value=row;testInput.value='{}';testResult.value='';testVisible.value=true}async function runTest(){if(!testing.value)return;try{const result=await testTool(testing.value.id,JSON.parse(testInput.value));testResult.value=JSON.stringify(result,null,2)}catch(error){testResult.value=String(error)}}onMounted(load)</script>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-semibold">审批中心</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">统一处理模型访问、资源/渠道权限与工具使用申请,审批通过后自动开通</p>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab" @tab-change="load">
|
||||
<ElTabPane label="模型申请" name="model" />
|
||||
<ElTabPane label="资源 / 渠道申请" name="resource" />
|
||||
<ElTabPane label="工具审批" name="tool" />
|
||||
</ElTabs>
|
||||
|
||||
<ElTable v-if="activeTab === 'model'" v-loading="loading" :data="modelRequests" row-key="id">
|
||||
<ElTableColumn prop="user_login" label="申请人" width="140" />
|
||||
<ElTableColumn prop="model" label="模型" min-width="180" />
|
||||
<ElTableColumn prop="provider_code" label="供应商" width="120" />
|
||||
<ElTableColumn prop="reason" label="理由" min-width="200" show-overflow-tooltip />
|
||||
<ElTableColumn prop="requested_rpm" label="RPM" width="90" />
|
||||
<ElTableColumn prop="requested_monthly_tokens" label="月 Token" width="120" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusText(row.status) }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<ElButton link type="success" @click="decide('model', row, 'approve')">通过</ElButton>
|
||||
<ElButton link type="danger" @click="decide('model', row, 'reject')">驳回</ElButton>
|
||||
</template>
|
||||
<span v-else class="text-g-400 text-xs">{{ row.decision_note || '—' }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElTable v-else-if="activeTab === 'resource'" v-loading="loading" :data="resourceRequests" row-key="id">
|
||||
<ElTableColumn prop="user_login" label="申请人" width="140" />
|
||||
<ElTableColumn label="类型" width="130">
|
||||
<template #default="{ row }">{{ typeName(row.resource_type) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="resource_code" label="资源" min-width="180" />
|
||||
<ElTableColumn prop="reason" label="理由" min-width="200" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="申请时间" width="180" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusText(row.status) }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<ElButton link type="success" @click="decide('resource', row, 'approve')">通过并开通</ElButton>
|
||||
<ElButton link type="danger" @click="decide('resource', row, 'reject')">驳回</ElButton>
|
||||
</template>
|
||||
<span v-else class="text-g-400 text-xs">{{ row.decision_note || '—' }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElTable v-else v-loading="loading" :data="toolApprovals" row-key="id">
|
||||
<ElTableColumn prop="tool_name" label="工具" min-width="180" />
|
||||
<ElTableColumn prop="tool_code" label="编码" width="160" />
|
||||
<ElTableColumn prop="reason" label="原因" min-width="220" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="申请时间" width="180" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusText(row.status) }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="170" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending'">
|
||||
<ElButton link type="success" @click="decide('tool', row, 'approve')">通过</ElButton>
|
||||
<ElButton link type="danger" @click="decide('tool', row, 'reject')">驳回</ElButton>
|
||||
</template>
|
||||
<span v-else class="text-g-400 text-xs">{{ row.decision_note || '—' }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ModelRequest, ResourceRequest, ToolApproval,
|
||||
decideModelRequest, decideResourceRequest, decideToolApproval,
|
||||
fetchModelRequests, fetchResourceRequests, fetchToolApprovals
|
||||
} from '@/api/governance'
|
||||
|
||||
const activeTab = ref('model')
|
||||
const loading = ref(false)
|
||||
const modelRequests = ref<ModelRequest[]>([])
|
||||
const resourceRequests = ref<ResourceRequest[]>([])
|
||||
const toolApprovals = ref<ToolApproval[]>([])
|
||||
|
||||
const statusText = (v: string) => ({ pending: '待审批', approved: '已通过', rejected: '已驳回', cancelled: '已取消' }[v] || v)
|
||||
const statusType = (v: string) => (v === 'approved' ? 'success' : v === 'rejected' || v === 'cancelled' ? 'danger' : 'warning')
|
||||
const typeName = (v: string) => ({ mcp_server: 'MCP 服务器', skill: 'Skill', digital_employee: '数字员工', channel: '渠道' }[v] || v)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'model') modelRequests.value = await fetchModelRequests('pending')
|
||||
if (activeTab.value === 'resource') resourceRequests.value = await fetchResourceRequests('pending')
|
||||
if (activeTab.value === 'tool') toolApprovals.value = await fetchToolApprovals('pending')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function decide(kind: 'model' | 'resource' | 'tool', row: any, action: 'approve' | 'reject') {
|
||||
const { value } = await ElMessageBox.prompt(
|
||||
action === 'approve' ? '通过后申请自动开通,可填写审批备注' : '填写驳回原因(将通知申请人)',
|
||||
action === 'approve' ? '通过申请' : '驳回申请',
|
||||
{ inputPlaceholder: '审批备注(可选)' }
|
||||
)
|
||||
const note = (value as string) || ''
|
||||
try {
|
||||
if (kind === 'model') await decideModelRequest(row.id, action, note)
|
||||
if (kind === 'resource') await decideResourceRequest(row.id, action, note)
|
||||
if (kind === 'tool') await decideToolApproval(row.id, action === 'approve' ? 'approved' : 'rejected', note)
|
||||
ElMessage.success(action === 'approve' ? '已通过' : '已驳回')
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error)?.message || '操作失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,105 @@
|
||||
<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">平台级配置注入到 skill / MCP 运行时;个人环境变量可覆盖同名平台变量</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="openCreate">新增变量</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="items" row-key="key">
|
||||
<ElTableColumn prop="key" label="变量名" min-width="200">
|
||||
<template #default="{ row }"><code class="text-xs">{{ row.key }}</code></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="description" label="说明" min-width="260" show-overflow-tooltip />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="row.configured ? 'success' : 'info'">{{ row.configured ? '已配置' : '无值' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="updated_at" label="更新时间" width="180" />
|
||||
<ElTableColumn label="操作" width="140" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="openEdit(row)">编辑</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="visible" :title="editingKey ? `编辑 ${editingKey}` : '新增环境变量'" width="520px">
|
||||
<ElForm label-width="90px">
|
||||
<ElFormItem label="变量名" required>
|
||||
<ElInput v-model="form.key" :disabled="!!editingKey" placeholder="例如 LLM_API_BASE" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="值" required>
|
||||
<ElInput v-model="form.value" type="textarea" :rows="3" placeholder="变量值(加密存储)" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="说明">
|
||||
<ElInput v-model="form.description" maxlength="512" placeholder="用途说明" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submit">保存</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { PlatformEnvVar, deletePlatformEnvVar, fetchPlatformEnvVars, upsertPlatformEnvVar } from '@/api/governance'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const visible = ref(false)
|
||||
const editingKey = ref('')
|
||||
const items = ref<PlatformEnvVar[]>([])
|
||||
const form = reactive({ key: '', value: '', description: '' })
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await fetchPlatformEnvVars()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingKey.value = ''
|
||||
Object.assign(form, { key: '', value: '', description: '' })
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: PlatformEnvVar) {
|
||||
editingKey.value = row.key
|
||||
Object.assign(form, { key: row.key, value: '', description: row.description || '' })
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.key.trim() || !form.value.trim()) {
|
||||
ElMessage.warning('变量名与值不能为空')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await upsertPlatformEnvVar(form.key.trim(), form.value, form.description)
|
||||
ElMessage.success('已保存')
|
||||
visible.value = false
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error)?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(row: PlatformEnvVar) {
|
||||
await ElMessageBox.confirm(`删除后运行时将不再注入 ${row.key},确定删除?`, '删除变量', { type: 'warning' })
|
||||
await deletePlatformEnvVar(row.key)
|
||||
items.value = items.value.filter((item) => item.key !== row.key)
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -223,6 +223,14 @@
|
||||
<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>
|
||||
@@ -395,7 +403,7 @@
|
||||
department_id: undefined
|
||||
})
|
||||
const departmentForm = reactive<DepartmentInput>({
|
||||
code: '', name: '', description: '', parent_id: undefined, active: true
|
||||
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: '',
|
||||
@@ -461,7 +469,7 @@
|
||||
function openCreate() {
|
||||
if (activeTab.value === 'department') {
|
||||
departmentEditingId.value = ''
|
||||
Object.assign(departmentForm, { code: '', name: '', description: '', parent_id: undefined, active: true })
|
||||
Object.assign(departmentForm, { code: '', name: '', description: '', parent_id: undefined, active: true, max_api_keys: 0, max_monthly_tokens: 0 })
|
||||
departmentDialogVisible.value = true
|
||||
return
|
||||
}
|
||||
@@ -506,7 +514,7 @@
|
||||
departmentEditingId.value = record.id
|
||||
Object.assign(departmentForm, {
|
||||
code: record.code, name: record.name, description: record.description,
|
||||
parent_id: record.parent_id, active: record.active
|
||||
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
|
||||
}
|
||||
|
||||
@@ -89,3 +89,23 @@ export const setSecurityPrefs=(login_notify:boolean)=>request.put<{login_notify:
|
||||
export const fetchProviderBindings=()=>request.get<ProviderBinding[]>({url:'/api/v1/portal/social/bindings'})
|
||||
export const startSocialBind=(kind:string)=>request.post<{redirect_url:string}>({url:`/api/v1/portal/social/${kind}/bind/start`})
|
||||
export const unbindSocial=(kind:string)=>request.del({url:`/api/v1/portal/social/${kind}/bind`})
|
||||
|
||||
// --- 资源/渠道权限申请 ---
|
||||
export interface ResourceRequest { id:string;user_login:string;resource_type:string;resource_code:string;reason:string;status:string;decision_note:string;created_at:string;updated_at:string }
|
||||
export const fetchMyResourceRequests=()=>request.get<ResourceRequest[]>({url:'/api/v1/portal/resource-requests'})
|
||||
export const createResourceRequest=(params:{resource_type:string;resource_code:string;reason:string})=>request.post<ResourceRequest>({url:'/api/v1/portal/resource-requests',params})
|
||||
export const cancelResourceRequest=(id:string)=>request.del({url:`/api/v1/portal/resource-requests/${id}`})
|
||||
|
||||
// --- 个人渠道 ---
|
||||
export interface PersonalChannel { id:string;code:string;name:string;kind:string;provider_code:string;model:string;enabled:boolean;last_used_at?:string;created_at:string;updated_at:string }
|
||||
export const fetchPersonalChannels=()=>request.get<PersonalChannel[]>({url:'/api/v1/portal/personal-channels'})
|
||||
export const createPersonalChannel=(params:{code:string;name:string;provider_code:string;model:string})=>request.post<{channel:PersonalChannel;inbound_token:string;inbound_url:string}>({url:'/api/v1/portal/personal-channels',params})
|
||||
export const regeneratePersonalToken=(id:string)=>request.post<{inbound_token:string}>({url:`/api/v1/portal/personal-channels/${id}/token`})
|
||||
export const deletePersonalChannel=(id:string)=>request.del({url:`/api/v1/portal/personal-channels/${id}`})
|
||||
|
||||
// --- 数字员工 ---
|
||||
export interface DigitalEmployee { code:string;name:string;description:string;installed:boolean }
|
||||
export interface EmployeeRun { employee_code:string;employee_name:string;status:string;latency_ms:number;retrieval_count:number;tool_count:number;error:string;created_at:string }
|
||||
export const fetchDigitalEmployees=()=>request.get<DigitalEmployee[]>({url:'/api/v1/portal/digital-employees'})
|
||||
export const runDigitalEmployee=(code:string,message:string)=>request.post<Record<string,unknown>>({url:`/api/v1/portal/digital-employees/${code}/chat`,params:{message}})
|
||||
export const fetchMyEmployeeRuns=(limit=20)=>request.get<EmployeeRun[]>({url:'/api/v1/portal/digital-employees/runs',params:{limit}})
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="page-content">
|
||||
<div class="mb-5">
|
||||
<h2 class="text-xl font-semibold">数字员工</h2>
|
||||
<p class="text-g-500 mt-1 text-sm">选择已授权数字员工直接对话;未安装的员工可在「资源市场」安装或「我的申请」申请权限</p>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab" @tab-change="load">
|
||||
<ElTabPane label="员工列表" name="list" />
|
||||
<ElTabPane label="调用记录" name="runs" />
|
||||
</ElTabs>
|
||||
|
||||
<div v-if="activeTab === 'list'" v-loading="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||
<ElCard v-for="item in employees" :key="item.code" shadow="hover">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<b>{{ item.name }}</b>
|
||||
<ElTag v-if="item.installed" type="success" size="small" class="ml-2">已安装</ElTag>
|
||||
</div>
|
||||
<code class="text-xs">{{ item.code }}</code>
|
||||
</div>
|
||||
<p class="text-g-500 mt-2 min-h-10 text-sm">{{ item.description || '暂无描述' }}</p>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<ElButton type="primary" size="small" :loading="chatting === item.code" @click="openChat(item)">开始对话</ElButton>
|
||||
<ElButton v-if="!item.installed" size="small" @click="$router.push('/portal/marketplace')">去市场安装</ElButton>
|
||||
</div>
|
||||
</ElCard>
|
||||
<ElEmpty v-if="!loading && !employees.length" description="暂无可用的数字员工" class="col-span-full" />
|
||||
</div>
|
||||
|
||||
<ElTable v-else v-loading="loading" :data="runs" row-key="created_at">
|
||||
<ElTableColumn prop="employee_name" label="数字员工" min-width="160" />
|
||||
<ElTableColumn prop="employee_code" label="编码" width="140" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="row.status === 'success' ? 'success' : 'danger'">{{ row.status }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="latency_ms" label="耗时(ms)" width="100" />
|
||||
<ElTableColumn prop="retrieval_count" label="检索" width="80" />
|
||||
<ElTableColumn prop="tool_count" label="工具" width="80" />
|
||||
<ElTableColumn prop="error" label="错误" min-width="160" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="时间" width="180" />
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="chatVisible" :title="`对话 · ${chatTarget?.name || ''}`" width="640px">
|
||||
<div class="max-h-80 space-y-3 overflow-auto">
|
||||
<ElEmpty v-if="!chatMessages.length" description="发送第一条消息开始对话" :image-size="60" />
|
||||
<div v-for="(message, index) in chatMessages" :key="index" class="flex" :class="message.role === 'user' ? 'justify-end' : 'justify-start'">
|
||||
<div
|
||||
class="max-w-[85%] whitespace-pre-wrap break-words rounded-lg px-3 py-2 text-sm"
|
||||
:class="message.role === 'user' ? 'bg-primary-600 text-white' : 'bg-g-100 text-g-800'"
|
||||
>{{ message.content }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex gap-2">
|
||||
<ElInput v-model="chatDraft" placeholder="输入消息" @keydown.enter.exact.prevent="sendChat" />
|
||||
<ElButton type="primary" :loading="chatting === chatTarget?.code" @click="sendChat">发送</ElButton>
|
||||
</div>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
DigitalEmployee, EmployeeRun, fetchDigitalEmployees, fetchMyEmployeeRuns, runDigitalEmployee
|
||||
} from '@/api/portal'
|
||||
|
||||
const activeTab = ref('list')
|
||||
const loading = ref(false)
|
||||
const employees = ref<DigitalEmployee[]>([])
|
||||
const runs = ref<EmployeeRun[]>([])
|
||||
const chatVisible = ref(false)
|
||||
const chatTarget = ref<DigitalEmployee>()
|
||||
const chatMessages = ref<Array<{ role: string; content: string }>>([])
|
||||
const chatDraft = ref('')
|
||||
const chatting = ref('')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (activeTab.value === 'list') employees.value = await fetchDigitalEmployees()
|
||||
if (activeTab.value === 'runs') runs.value = await fetchMyEmployeeRuns()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openChat(item: DigitalEmployee) {
|
||||
chatTarget.value = item
|
||||
chatMessages.value = []
|
||||
chatDraft.value = ''
|
||||
chatVisible.value = true
|
||||
}
|
||||
|
||||
async function sendChat() {
|
||||
const text = chatDraft.value.trim()
|
||||
if (!text || !chatTarget.value || chatting.value) return
|
||||
chatting.value = chatTarget.value.code
|
||||
chatMessages.value.push({ role: 'user', content: text })
|
||||
chatDraft.value = ''
|
||||
try {
|
||||
const response = await runDigitalEmployee(chatTarget.value.code, text)
|
||||
const choices = (response.choices as Array<{ message?: { content?: string } }>) || []
|
||||
const answer = choices[0]?.message?.content || '(无文本回答)'
|
||||
chatMessages.value.push({ role: 'assistant', content: answer })
|
||||
} catch (error) {
|
||||
chatMessages.value.push({ role: 'assistant', content: (error as Error)?.message || '调用失败' })
|
||||
} finally {
|
||||
chatting.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,173 @@
|
||||
<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">自建 Webhook 渠道绑定已批准模型,外部系统可直接调用;用量归属你的账号</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="openCreate">新建渠道</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="items" row-key="id">
|
||||
<ElTableColumn prop="name" label="名称" min-width="140" />
|
||||
<ElTableColumn prop="code" label="代码" width="150" />
|
||||
<ElTableColumn prop="model" label="绑定模型" min-width="160" />
|
||||
<ElTableColumn prop="provider_code" label="供应商" width="120" />
|
||||
<ElTableColumn prop="last_used_at" label="最近调用" width="180">
|
||||
<template #default="{ row }">{{ row.last_used_at || '—' }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="状态" width="90">
|
||||
<template #default="{ row }"><ElTag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '停用' }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton link type="primary" @click="showInbound(row)">调用地址</ElButton>
|
||||
<ElButton link type="warning" @click="regenerate(row)">重置令牌</ElButton>
|
||||
<ElButton link type="danger" @click="remove(row)">删除</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="visible" title="新建 Webhook 渠道" width="560px">
|
||||
<ElForm label-width="100px">
|
||||
<ElFormItem label="名称" required><ElInput v-model="form.name" maxlength="128" placeholder="例如 报表机器人" /></ElFormItem>
|
||||
<ElFormItem label="代码" required><ElInput v-model="form.code" placeholder="例如 my_bot(小写字母开头)" /></ElFormItem>
|
||||
<ElFormItem label="绑定模型" required>
|
||||
<ElSelect v-model="selectedModel" filterable class="w-full" placeholder="选择已批准的模型">
|
||||
<ElOptionGroup v-for="group in modelGroups" :key="group.provider" :label="group.provider">
|
||||
<ElOption v-for="m in group.models" :key="m.model" :label="m.model" :value="`${m.provider_code}\n${m.model}`" />
|
||||
</ElOptionGroup>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submit">创建</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
|
||||
<ElDialog v-model="inboundVisible" :title="`调用地址 · ${current?.name || ''}`" width="620px">
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<div class="text-g-500 mb-1">入站 URL(POST JSON:{"message":"你好"})</div>
|
||||
<ElInput :model-value="inboundURL" readonly>
|
||||
<template #append><ElButton @click="copy(inboundURL)">复制</ElButton></template>
|
||||
</ElInput>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-g-500 mb-1">入站令牌(请求头 X-Inbound-Token 或 ?token=,只显示一次)</div>
|
||||
<ElInput :model-value="inboundToken" readonly>
|
||||
<template #append><ElButton @click="copy(inboundToken)">复制</ElButton></template>
|
||||
</ElInput>
|
||||
<p class="text-g-400 mt-1 text-xs">令牌仅创建/重置时显示;丢失请在列表中重置</p>
|
||||
</div>
|
||||
<ElAlert type="info" :closable="false" title="示例:curl -X POST https://你的域名/v1/personal-channels/CODE/inbound -H 'Content-Type: application/json' -H 'X-Inbound-Token: TOKEN' -d 「{"message":"你好"}」" />
|
||||
</div>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
ChatModel, PersonalChannel,
|
||||
createPersonalChannel, deletePersonalChannel, fetchChatModels,
|
||||
fetchPersonalChannels, regeneratePersonalToken
|
||||
} from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const visible = ref(false)
|
||||
const inboundVisible = ref(false)
|
||||
const items = ref<PersonalChannel[]>([])
|
||||
const models = ref<ChatModel[]>([])
|
||||
const selectedModel = ref('')
|
||||
const current = ref<PersonalChannel>()
|
||||
const inboundURL = ref('')
|
||||
const inboundToken = ref('')
|
||||
const form = reactive({ name: '', code: '' })
|
||||
|
||||
const modelGroups = computed(() => {
|
||||
const groups: { provider: string; models: ChatModel[] }[] = []
|
||||
const index = new Map<string, ChatModel[]>()
|
||||
for (const m of models.value) {
|
||||
if (!index.has(m.provider_code)) index.set(m.provider_code, [])
|
||||
index.get(m.provider_code)!.push(m)
|
||||
}
|
||||
for (const [provider, list] of index) groups.push({ provider, models: list })
|
||||
return groups
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await fetchPersonalChannels()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
models.value = await fetchChatModels()
|
||||
if (!models.value.length) {
|
||||
ElMessage.warning('暂无已批准的模型,请先在「模型权限」申请')
|
||||
return
|
||||
}
|
||||
form.name = ''
|
||||
form.code = ''
|
||||
selectedModel.value = ''
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.name.trim() || !form.code.trim() || !selectedModel.value) {
|
||||
ElMessage.warning('请填写名称、代码并选择模型')
|
||||
return
|
||||
}
|
||||
const [provider_code, model] = selectedModel.value.split('\n')
|
||||
saving.value = true
|
||||
try {
|
||||
const result = await createPersonalChannel({ name: form.name.trim(), code: form.code.trim(), provider_code, model })
|
||||
visible.value = false
|
||||
await load()
|
||||
current.value = result.channel
|
||||
inboundURL.value = window.location.origin + result.inbound_url
|
||||
inboundToken.value = result.inbound_token
|
||||
inboundVisible.value = true
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error)?.message || '创建失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showInbound(row: PersonalChannel) {
|
||||
current.value = row
|
||||
inboundURL.value = `${window.location.origin}/v1/personal-channels/${row.code}/inbound`
|
||||
inboundToken.value = ''
|
||||
inboundVisible.value = true
|
||||
}
|
||||
|
||||
async function regenerate(row: PersonalChannel) {
|
||||
await ElMessageBox.confirm('重置后旧令牌立即失效,确定重置?', '重置入站令牌', { type: 'warning' })
|
||||
const result = await regeneratePersonalToken(row.id)
|
||||
current.value = row
|
||||
inboundURL.value = `${window.location.origin}/v1/personal-channels/${row.code}/inbound`
|
||||
inboundToken.value = result.inbound_token
|
||||
inboundVisible.value = true
|
||||
ElMessage.success('令牌已重置')
|
||||
}
|
||||
|
||||
async function remove(row: PersonalChannel) {
|
||||
await ElMessageBox.confirm('删除后该渠道立即停止响应,确定删除?', '删除渠道', { type: 'warning' })
|
||||
await deletePersonalChannel(row.id)
|
||||
items.value = items.value.filter((item) => item.id !== row.id)
|
||||
ElMessage.success('已删除')
|
||||
}
|
||||
|
||||
async function copy(value: string) {
|
||||
await navigator.clipboard.writeText(value)
|
||||
ElMessage.success('已复制')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -0,0 +1,104 @@
|
||||
<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">资源(MCP/Skill/数字员工)与渠道使用权限申请,审批通过后自动开通</p>
|
||||
</div>
|
||||
<ElButton type="primary" @click="visible = true">发起申请</ElButton>
|
||||
</div>
|
||||
|
||||
<ElTable v-loading="loading" :data="items" row-key="id">
|
||||
<ElTableColumn label="类型" width="130">
|
||||
<template #default="{ row }">{{ typeName(row.resource_type) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="resource_code" label="资源 / 渠道" min-width="180" />
|
||||
<ElTableColumn prop="reason" label="申请理由" min-width="220" show-overflow-tooltip />
|
||||
<ElTableColumn prop="created_at" label="申请时间" width="180" />
|
||||
<ElTableColumn label="状态" width="100">
|
||||
<template #default="{ row }"><ElTag :type="statusType(row.status)">{{ statusText(row.status) }}</ElTag></template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="decision_note" label="审批意见" min-width="150" />
|
||||
<ElTableColumn label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<ElButton v-if="row.status === 'pending'" link type="danger" @click="cancel(row)">撤回</ElButton>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<ElDialog v-model="visible" title="申请资源 / 渠道权限" width="560px">
|
||||
<ElForm label-width="110px">
|
||||
<ElFormItem label="类型" required>
|
||||
<ElSelect v-model="form.resource_type" class="w-full">
|
||||
<ElOption label="MCP 服务器" value="mcp_server" />
|
||||
<ElOption label="Skill" value="skill" />
|
||||
<ElOption label="数字员工" value="digital_employee" />
|
||||
<ElOption label="渠道" value="channel" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="资源代码" required>
|
||||
<ElInput v-model="form.resource_code" placeholder="例如 resource_code(需与管理员确认的唯一代码)" />
|
||||
</ElFormItem>
|
||||
<ElFormItem label="申请理由" required>
|
||||
<ElInput v-model="form.reason" type="textarea" :rows="4" maxlength="4000" show-word-limit />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
<template #footer>
|
||||
<ElButton @click="visible = false">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" @click="submit">提交</ElButton>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ResourceRequest, cancelResourceRequest, createResourceRequest, fetchMyResourceRequests } from '@/api/portal'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const visible = ref(false)
|
||||
const items = ref<ResourceRequest[]>([])
|
||||
const form = reactive({ resource_type: 'mcp_server', resource_code: '', reason: '' })
|
||||
|
||||
const typeName = (v: string) => ({ mcp_server: 'MCP 服务器', skill: 'Skill', digital_employee: '数字员工', channel: '渠道' }[v] || v)
|
||||
const statusText = (v: string) => ({ pending: '待审批', approved: '已通过', rejected: '已驳回', cancelled: '已取消' }[v] || v)
|
||||
const statusType = (v: string) => (v === 'approved' ? 'success' : v === 'rejected' || v === 'cancelled' ? 'danger' : 'warning')
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
items.value = await fetchMyResourceRequests()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!form.resource_code.trim() || !form.reason.trim()) {
|
||||
ElMessage.warning('请填写资源代码与申请理由')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await createResourceRequest({ ...form, resource_code: form.resource_code.trim(), reason: form.reason.trim() })
|
||||
ElMessage.success('申请已提交,等待管理员审批')
|
||||
visible.value = false
|
||||
form.resource_code = ''
|
||||
form.reason = ''
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error((error as Error)?.message || '提交失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel(row: ResourceRequest) {
|
||||
await ElMessageBox.confirm('撤回后需重新提交,确定撤回该申请?', '撤回申请', { type: 'warning' })
|
||||
await cancelResourceRequest(row.id)
|
||||
row.status = 'cancelled'
|
||||
ElMessage.success('已撤回')
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
Reference in New Issue
Block a user