后端 apply/detail 返回 mcpEndpoint (从 -Dmcp.endpoint 注入), 前端申请弹窗加 MCP地址列, 详情弹窗加 MCP地址行, 用户复制 appId/appSecret/MCP地址 三值到平台助手配置
545 lines
18 KiB
Vue
545 lines
18 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* F006 集成管理 - 申请 APIKey 向导(3 步)
|
|
* W4.1-revert v2: 用户原话"申请 apikey 是三步操作:选客户、填写信息、生成 apikey"
|
|
* Step 1: 选择客户(多选 + 搜索过滤)
|
|
* Step 2: 填写信息(appName/appType/scopes/ipWhitelist/remark,共 5 字段,可选)
|
|
* Step 3: 生成 APIKey(确认摘要 + 生成结果表格)
|
|
*
|
|
* 后端 DTO: OpenApiAppApplyDTO
|
|
* - customerCodes(必填,Step 1 选)
|
|
* - appName(可选,Step 2)
|
|
* - appType(可选,Step 2)
|
|
* - scopes(可选,Step 2)
|
|
* - ipWhitelist(可选,Step 2)
|
|
* - remark(可选,Step 2)
|
|
*
|
|
* 后端调 /sys/openapi/app/apply
|
|
*/
|
|
import { ref, reactive, computed, watch } from 'vue'
|
|
import { ElMessage } from 'element-plus'
|
|
import { httpGet, httpPost } from '@/utils/request'
|
|
|
|
interface Customer {
|
|
customerCode: string
|
|
customerName: string
|
|
contactName?: string
|
|
contactPhone?: string
|
|
currentAppCount?: number
|
|
}
|
|
|
|
interface ApplyResult {
|
|
customerName: string
|
|
customerCode: string
|
|
appId: string
|
|
appSecret: string
|
|
mcpEndpoint: string
|
|
}
|
|
|
|
const props = defineProps<{ modelValue: boolean }>()
|
|
const emit = defineEmits<{
|
|
(e: 'update:modelValue', v: boolean): void
|
|
(e: 'refresh'): void
|
|
}>()
|
|
|
|
const visible = ref(props.modelValue)
|
|
watch(() => props.modelValue, v => {
|
|
visible.value = v
|
|
if (v) { reset() }
|
|
})
|
|
function updateVisible(v: boolean) {
|
|
visible.value = v
|
|
emit('update:modelValue', v)
|
|
}
|
|
|
|
const step = ref<1 | 2 | 3>(1)
|
|
const submitting = ref(false)
|
|
const result = ref<ApplyResult[] | null>(null)
|
|
const customerTableRef = ref()
|
|
|
|
const form = reactive({
|
|
searchKey: '',
|
|
selected: [] as Customer[],
|
|
appName: '',
|
|
appType: 'both' as 'mcp' | 'rest' | 'both',
|
|
scopes: [] as string[],
|
|
ipWhitelist: '',
|
|
remark: ''
|
|
})
|
|
|
|
const customers = ref<Customer[]>([])
|
|
const loadingCustomers = ref(false)
|
|
|
|
async function loadCustomers() {
|
|
loadingCustomers.value = true
|
|
try {
|
|
const data = await httpGet<Customer[]>('/sys/openapi/customer/list')
|
|
customers.value = Array.isArray(data) ? data : []
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.msg || e?.message || '加载客户列表失败')
|
|
customers.value = []
|
|
} finally {
|
|
loadingCustomers.value = false
|
|
}
|
|
}
|
|
|
|
watch(visible, v => { if (v) loadCustomers() })
|
|
|
|
const filteredCustomers = computed(() => {
|
|
if (!form.searchKey) return customers.value
|
|
const k = form.searchKey.toLowerCase()
|
|
return customers.value.filter(c =>
|
|
(c.customerName || '').toLowerCase().includes(k) ||
|
|
(c.customerCode || '').toLowerCase().includes(k)
|
|
)
|
|
})
|
|
|
|
function onSelectionChange(rows: Customer[]) {
|
|
form.selected = rows
|
|
}
|
|
|
|
const SCOPE_OPTIONS = [
|
|
{ value: 'mcp:*', label: 'MCP 全部权限' },
|
|
{ value: 'rest:*', label: 'REST 全部权限' },
|
|
{ value: 'webhook:read', label: 'Webhook 读取' },
|
|
{ value: 'webhook:write', label: 'Webhook 写入' },
|
|
{ value: 'inquiry:read', label: '询价读取' },
|
|
{ value: 'inquiry:write', label: '询价写入' },
|
|
{ value: 'order:read', label: '订单读取' },
|
|
{ value: 'order:write', label: '订单写入' }
|
|
]
|
|
|
|
async function submit() {
|
|
if (form.selected.length === 0) {
|
|
ElMessage.warning('请至少选择一个客户')
|
|
return
|
|
}
|
|
submitting.value = true
|
|
try {
|
|
const payload: any = {
|
|
customerCodes: form.selected.map(c => c.customerCode)
|
|
}
|
|
if (form.appName) payload.appName = form.appName
|
|
if (form.appType) payload.appType = form.appType
|
|
if (form.scopes.length > 0) payload.scopes = form.scopes
|
|
if (form.ipWhitelist) payload.ipWhitelist = form.ipWhitelist
|
|
if (form.remark) payload.remark = form.remark
|
|
|
|
const res = await httpPost<ApplyResult[]>('/sys/openapi/app/apply', payload)
|
|
result.value = Array.isArray(res) ? res : []
|
|
ElMessage.success(`已为 ${result.value.length} 个客户生成 APIKey`)
|
|
} catch (e: any) {
|
|
ElMessage.error(e?.msg || e?.message || '申请失败')
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
function onClose() { updateVisible(false); if (result.value) emit("refresh") }
|
|
|
|
async function copySecret(text: string) {
|
|
// 优先用现代 Clipboard API (HTTPS / localhost 才允许)
|
|
// frp 走 http://direv.cloud:8080 是非安全上下文, clipboard API 不可用, 需降级
|
|
try {
|
|
if (navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(text)
|
|
ElMessage.success('已复制')
|
|
return
|
|
}
|
|
throw new Error('no clipboard api')
|
|
} catch {
|
|
// 降级方案: document.execCommand('copy') - HTTP 下也能用
|
|
try {
|
|
const ta = document.createElement('textarea')
|
|
ta.value = text
|
|
ta.style.position = 'fixed'
|
|
ta.style.opacity = '0'
|
|
ta.style.pointerEvents = 'none'
|
|
document.body.appendChild(ta)
|
|
ta.select()
|
|
const ok = document.execCommand('copy')
|
|
document.body.removeChild(ta)
|
|
if (ok) {
|
|
ElMessage.success('已复制')
|
|
} else {
|
|
ElMessage.warning('浏览器阻止自动复制,请手动双击输入框选中后 Cmd/Ctrl+C')
|
|
}
|
|
} catch {
|
|
ElMessage.warning('浏览器阻止自动复制,请手动双击输入框选中后 Cmd/Ctrl+C')
|
|
}
|
|
}
|
|
}
|
|
|
|
function reset() {
|
|
step.value = 1
|
|
result.value = null
|
|
form.searchKey = ''
|
|
form.selected = []
|
|
form.appName = ''
|
|
form.appType = 'both'
|
|
form.scopes = []
|
|
form.ipWhitelist = ''
|
|
form.remark = ''
|
|
}
|
|
|
|
function nextStep() {
|
|
if (step.value === 1 && form.selected.length === 0) {
|
|
ElMessage.warning('请至少选择一个客户')
|
|
return
|
|
}
|
|
if (step.value < 3) {
|
|
step.value = (step.value + 1) as 1 | 2 | 3
|
|
}
|
|
}
|
|
function prevStep() {
|
|
if (step.value > 1) {
|
|
step.value = (step.value - 1) as 1 | 2 | 3
|
|
}
|
|
}
|
|
|
|
function notifyContact() {
|
|
ElMessage.success('已通知联系人(占位)')
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<el-dialog
|
|
:model-value="visible"
|
|
@update:model-value="updateVisible"
|
|
title="申请 APIKey"
|
|
width="640px"
|
|
:close-on-click-modal="false"
|
|
@close="onClose"
|
|
class="rr-apply-dialog"
|
|
>
|
|
<!-- 步骤条(3 步:选择客户 → 填写信息 → 生成 APIKey) -->
|
|
<div class="rr-wizard-steps">
|
|
<div :class="['rr-wizard-step', step >= 1 ? 'active' : '', step > 1 ? 'done' : '']">
|
|
<span class="rr-wizard-num">1</span>
|
|
<span class="rr-wizard-label">选择客户</span>
|
|
</div>
|
|
<div :class="['rr-wizard-step', step >= 2 ? 'active' : '', step > 2 ? 'done' : '']">
|
|
<span class="rr-wizard-num">2</span>
|
|
<span class="rr-wizard-label">填写信息</span>
|
|
</div>
|
|
<div :class="['rr-wizard-step', step >= 3 ? 'active' : '']">
|
|
<span class="rr-wizard-num">3</span>
|
|
<span class="rr-wizard-label">生成 APIKey</span>
|
|
</div>
|
|
</div>
|
|
|
|
<el-alert v-if="step === 1" type="info" :closable="false" style="margin-bottom: 16px;">
|
|
可选择多个客户,后端会为每个客户生成独立的 APIKey + 公司账号
|
|
</el-alert>
|
|
<el-alert v-if="step === 2" type="info" :closable="false" style="margin-bottom: 16px;">
|
|
以下信息均可选,不填则使用后端默认值
|
|
</el-alert>
|
|
|
|
<!-- Step 1: 选客户 -->
|
|
<div v-if="step === 1">
|
|
<el-input
|
|
v-model="form.searchKey"
|
|
placeholder="搜索客户名称 / 客户编码"
|
|
clearable
|
|
size="small"
|
|
style="width: 280px; margin-bottom: 10px;"
|
|
/>
|
|
<el-table
|
|
ref="customerTableRef"
|
|
:data="filteredCustomers"
|
|
border
|
|
size="small"
|
|
max-height="320"
|
|
:loading="loadingCustomers"
|
|
:row-style="{ height: '36px' }"
|
|
:cell-style="{ padding: '4px 8px', fontSize: '12.5px' }"
|
|
:header-cell-style="{ padding: '6px 8px', fontSize: '12px', height: '32px', background: 'var(--surface-2)' }"
|
|
class="rr-apply-table"
|
|
@selection-change="onSelectionChange"
|
|
>
|
|
<el-table-column type="selection" header-align="center" align="center" width="48" />
|
|
<el-table-column prop="customerName" label="公司名称" min-width="200" header-align="left" align="left" show-overflow-tooltip />
|
|
<el-table-column prop="contactName" label="联系人" width="100" header-align="center" align="center" />
|
|
<el-table-column prop="contactPhone" label="联系电话" width="140" header-align="center" align="center" />
|
|
</el-table>
|
|
</div>
|
|
|
|
<!-- Step 2: 填写信息(5 字段表单,均可选) -->
|
|
<div v-if="step === 2">
|
|
<el-form :model="form" label-width="120px" label-position="right">
|
|
<el-form-item label="应用名称">
|
|
<el-input
|
|
v-model="form.appName"
|
|
placeholder="不填则后端自动生成(客户名 + 智能体集成)"
|
|
maxlength="50"
|
|
show-word-limit
|
|
/>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="应用类型">
|
|
<el-radio-group v-model="form.appType">
|
|
<el-radio value="mcp">MCP(Model Context Protocol)</el-radio>
|
|
<el-radio value="rest">REST API</el-radio>
|
|
<el-radio value="both">Both(MCP + REST)</el-radio>
|
|
</el-radio-group>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="权限范围">
|
|
<el-select
|
|
v-model="form.scopes"
|
|
multiple
|
|
collapse-tags
|
|
collapse-tags-tooltip
|
|
placeholder="不选则后端默认全权限"
|
|
style="width: 100%;"
|
|
>
|
|
<el-option
|
|
v-for="s in SCOPE_OPTIONS"
|
|
:key="s.value"
|
|
:label="s.label"
|
|
:value="s.value"
|
|
/>
|
|
</el-select>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="IP 白名单">
|
|
<el-input
|
|
v-model="form.ipWhitelist"
|
|
type="textarea"
|
|
:rows="2"
|
|
placeholder="多 IP 用英文逗号分隔,如 192.168.1.1,10.0.0.0/24。不填则不限制 IP"
|
|
/>
|
|
</el-form-item>
|
|
|
|
<el-form-item label="备注">
|
|
<el-input
|
|
v-model="form.remark"
|
|
type="textarea"
|
|
:rows="2"
|
|
placeholder="申请原因 / 用途说明(选填)"
|
|
maxlength="200"
|
|
show-word-limit
|
|
/>
|
|
</el-form-item>
|
|
</el-form>
|
|
</div>
|
|
|
|
<!-- Step 3: 确认 + 生成 -->
|
|
<div v-if="step === 3 && !result">
|
|
<h3 style="margin: 0 0 16px 0;">确认信息</h3>
|
|
<el-descriptions :column="1" border>
|
|
<el-descriptions-item label="已选客户">
|
|
<span v-for="c in form.selected" :key="c.customerCode" class="rr-customer-tag">{{ c.customerName }}</span>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="应用名称">{{ form.appName || '(后端自动生成)' }}</el-descriptions-item>
|
|
<el-descriptions-item label="应用类型">{{ form.appType }}</el-descriptions-item>
|
|
<el-descriptions-item label="权限范围">
|
|
<span v-if="form.scopes.length === 0">(后端默认全权限)</span>
|
|
<span v-else>
|
|
<span v-for="s in form.scopes" :key="s" class="rr-scope-tag">{{ s }}</span>
|
|
</span>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="IP 白名单">{{ form.ipWhitelist || '(不限制)' }}</el-descriptions-item>
|
|
<el-descriptions-item label="备注">{{ form.remark || '—' }}</el-descriptions-item>
|
|
</el-descriptions>
|
|
<el-alert type="info" :closable="false" style="margin-top: 16px;">
|
|
后端将自动创建公司账号 + 生成 APIKey
|
|
</el-alert>
|
|
</div>
|
|
|
|
<!-- Step 3: 生成结果 -->
|
|
<div v-if="step === 3 && result">
|
|
<el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
|
|
请立即复制 MCP地址、appId 和 appSecret!关弹窗后 appSecret 无法再次查看
|
|
</el-alert>
|
|
<p>已为 {{ result.length }} 个客户生成 APIKey:</p>
|
|
<el-table :data="result" border class="rr-apply-table rr-result-table" size="small">
|
|
<el-table-column prop="customerName" label="公司名称" width="120" show-overflow-tooltip />
|
|
<el-table-column prop="appId" label="AppID" min-width="180" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
<code class="rr-cell-code">{{ row.appId }}</code>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="mcpEndpoint" label="MCP地址" min-width="220" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
<code class="rr-cell-code">{{ row.mcpEndpoint || '—' }}</code>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="AppSecret" min-width="200">
|
|
<template #default="{ row }">
|
|
<div class="rr-secret-cell">
|
|
<el-input :model-value="row.appSecret" readonly type="password" size="small" show-password />
|
|
<el-button size="small" type="primary" @click="copySecret(row.appSecret)">复制</el-button>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
<el-alert type="warning" :closable="false" style="margin-top: 16px;">
|
|
AppSecret 仅在此次显示,关弹窗后无法再次查看
|
|
</el-alert>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<template v-if="step === 1">
|
|
<el-button @click="onClose">取消</el-button>
|
|
<el-button type="primary" :disabled="form.selected.length === 0" @click="nextStep">下一步 →</el-button>
|
|
</template>
|
|
<template v-else-if="step === 2 && !result">
|
|
<el-button @click="prevStep">← 上一步</el-button>
|
|
<el-button @click="onClose">取消</el-button>
|
|
<el-button type="primary" @click="nextStep">下一步 →</el-button>
|
|
</template>
|
|
<template v-else-if="step === 3 && !result">
|
|
<el-button @click="prevStep">← 上一步</el-button>
|
|
<el-button type="primary" :loading="submitting" @click="submit">生成 APIKey</el-button>
|
|
</template>
|
|
<template v-else>
|
|
<el-button type="primary" @click="notifyContact">通知联系人</el-button>
|
|
<el-button @click="onClose">关闭并刷新</el-button>
|
|
</template>
|
|
</template>
|
|
</el-dialog>
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
/* 弹窗圆角 12px + 紧凑 padding(套设计规范 §7.1 + §17 v3) */
|
|
:deep(.rr-apply-dialog .el-dialog) {
|
|
border-radius: 12px !important;
|
|
overflow: hidden;
|
|
}
|
|
:deep(.rr-apply-dialog .el-dialog__header) {
|
|
padding: 14px 20px;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
:deep(.rr-apply-dialog .el-dialog__header .el-dialog__title) {
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
}
|
|
:deep(.rr-apply-dialog .el-dialog__body) {
|
|
padding: 16px 20px;
|
|
}
|
|
:deep(.rr-apply-dialog .el-dialog__footer) {
|
|
padding: 12px 20px;
|
|
border-top: 1px solid var(--border);
|
|
}
|
|
|
|
/* 输入框圆角 8px + 紧凑(套设计规范 §11) */
|
|
:deep(.rr-apply-dialog .el-input__wrapper),
|
|
:deep(.rr-apply-dialog .el-textarea__inner),
|
|
:deep(.rr-apply-dialog .el-select__wrapper) {
|
|
border-radius: 8px !important;
|
|
}
|
|
|
|
/* 弹窗内 el-alert 紧凑 */
|
|
:deep(.rr-apply-dialog .el-alert) {
|
|
padding: 6px 12px;
|
|
}
|
|
:deep(.rr-apply-dialog .el-alert__content) {
|
|
padding: 0;
|
|
}
|
|
:deep(.rr-apply-dialog .el-alert .el-alert__title) {
|
|
font-size: 12px;
|
|
font-weight: 400;
|
|
}
|
|
|
|
/* 弹窗内 h3 紧凑 */
|
|
:deep(.rr-apply-dialog h3) {
|
|
font-size: 13px !important;
|
|
font-weight: 600 !important;
|
|
margin: 0 0 10px 0 !important;
|
|
}
|
|
|
|
/* 步骤条(紧凑版) */
|
|
.rr-wizard-steps { display: flex; margin-bottom: 16px; }
|
|
.rr-wizard-step {
|
|
flex: 1; text-align: center; position: relative;
|
|
padding: 0 8px;
|
|
}
|
|
.rr-wizard-num {
|
|
width: 26px; height: 26px; line-height: 24px;
|
|
border-radius: 50%; background: var(--surface);
|
|
border: 1.5px solid var(--border);
|
|
display: inline-block; color: var(--ink-3);
|
|
font-size: 12.5px; font-weight: 500;
|
|
transition: all 0.2s ease;
|
|
}
|
|
.rr-wizard-step.active .rr-wizard-num {
|
|
background: var(--brand);
|
|
border-color: var(--brand);
|
|
color: white;
|
|
box-shadow: 0 0 0 3px var(--brand-soft);
|
|
}
|
|
.rr-wizard-step.done .rr-wizard-num {
|
|
background: var(--green);
|
|
border-color: var(--green);
|
|
color: white;
|
|
}
|
|
.rr-wizard-label { display: block; margin-top: 6px; color: var(--ink-3); font-size: 12px; }
|
|
.rr-wizard-step.active .rr-wizard-label { color: var(--brand); font-weight: 500; }
|
|
.rr-wizard-step.done .rr-wizard-label { color: var(--green); font-weight: 500; }
|
|
|
|
/* 步骤连接线(已完成的步骤变绿色) */
|
|
.rr-wizard-step:not(:last-child)::after {
|
|
content: ""; position: absolute;
|
|
top: 13px; left: calc(50% + 16px); right: calc(-50% + 16px);
|
|
height: 1.5px; background: var(--border); z-index: -1;
|
|
}
|
|
.rr-wizard-step.done:not(:last-child)::after { background: var(--green); }
|
|
|
|
.rr-customer-tag {
|
|
display: inline-block; padding: 3px 10px;
|
|
background: var(--brand-soft); color: var(--brand);
|
|
border-radius: 6px; margin-right: 8px; margin-bottom: 4px;
|
|
font-size: 12.5px;
|
|
}
|
|
|
|
.rr-scope-tag {
|
|
display: inline-block; padding: 2px 8px;
|
|
background: var(--surface-2); color: var(--ink-2);
|
|
border-radius: 4px; margin-right: 6px;
|
|
font-family: ui-monospace, monospace;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.rr-code-accent { color: var(--red); font-family: ui-monospace, monospace; }
|
|
|
|
/* 弹窗内表格圆角 + 边框颜色统一 (修复圆角缺失) */
|
|
:deep(.rr-apply-dialog .rr-apply-table) {
|
|
border-radius: 8px;
|
|
overflow: hidden;
|
|
border: 1px solid var(--border) !important;
|
|
}
|
|
:deep(.rr-apply-dialog .rr-apply-table .el-table__inner-wrapper) {
|
|
border-radius: 8px;
|
|
}
|
|
/* selection 列 checkbox 居中 */
|
|
:deep(.rr-apply-dialog .rr-apply-table .el-table-column--selection .cell) {
|
|
text-align: center;
|
|
padding: 0;
|
|
}
|
|
:deep(.rr-apply-dialog .rr-apply-table th.el-table-column--selection .cell) {
|
|
text-align: center;
|
|
}
|
|
|
|
/* Step3 结果表格: AppSecret 单元格紧凑布局 */
|
|
.rr-secret-cell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
padding: 2px 0;
|
|
}
|
|
.rr-secret-cell .el-input {
|
|
width: 100%;
|
|
}
|
|
/* code 文本省略, 不撑列宽 */
|
|
.rr-cell-code {
|
|
display: inline-block;
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
font-size: 12px;
|
|
vertical-align: middle;
|
|
}
|
|
</style>
|