admin_new/src/modules/openapi/ApplyApiKeyDialog.vue
Sisyphus cf00136340 feat(F006): APIKey申请结果+详情弹窗显示MCP地址
后端 apply/detail 返回 mcpEndpoint (从 -Dmcp.endpoint 注入),
前端申请弹窗加 MCP地址列, 详情弹窗加 MCP地址行,
用户复制 appId/appSecret/MCP地址 三值到平台助手配置
2026-07-10 11:54:04 +08:00

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>