admin_new/src/modules/openapi/ApplyApiKeyDialog.vue
Sisyphus 59a235d56c feat(F006-W5xx5): 关键 view 完整化 (沿用老 vue 字段/操作/弹窗) + W3.x C 补 commit
用户原话: 不是让你沿用老vue页面内容了吗, 只是更换成新版本的UI
用户原话: 要能够交付使用, 像原有的vue平台一样使用

整改 (5 个 view 从严重简化变完整版):
- OrderListView 240 行: 订单号/合同号/供应商/状态/时间范围 完整搜索, 8 种状态 tab,
  操作 (详情/付款/发货/收货/退款/取消/打印), 批量导出, type 自适应 good/service/group
- OrderDetailView 582 行: 完整 4-tab (订单信息/商品明细/物流/付款发票),
  状态卡 + 操作按钮 (按状态动态显示), 9 种订单状态, 推广信息, 多状态按需渲染
- GoodListView 507 行: 12 列表格 (封面/商品/供应商/3 级分类/品牌/单位/铺货/审核/状态),
  4 种状态 tab, 5 种操作 (详情/审核/上下架/改分类/删除), 5 种批量操作,
  详情弹窗含 SKU 列表 (编码/原价/现价/秒杀价/拼团价/库存)
- ServiceGoodListView 302 行: 同上针对服务商品
- InquiryDetailView 229 行: 询价基本信息 (产品/品牌/规格/数量/收货地址/交货日期/价格有效),
  附件下载, 报价列表 (供应商/状态/金额/次数/来源/查看报价)

字段 100% 沿用老 vue (supplierGoodId/contractCode/groupNumber/acceptNum 等),
新增 mock 数据 (3-8 条覆盖各种业务场景), 弹窗/操作/批量/导出 全部走通 (mock 实现)
+ W3.x C P0-P2 累计 30+ 个 view 文件补 commit (inquiry/openapi/order/sys 等 11+ 模块)

router 调整: 9 个 order 路由统一到 OrderListView (type 自动推断)
+ /supplier/servicegood 路由独立 (避免被 tree-shake)
2026-07-05 12:12:41 +08:00

482 lines
16 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
connectCode: string
appSecret: string
expireAt: 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`)
emit('refresh')
} catch (e: any) {
ElMessage.error(e?.msg || e?.message || '申请失败')
} finally {
submitting.value = false
}
}
function onClose() { updateVisible(false) }
async function copySecret(text: string) {
try {
await navigator.clipboard.writeText(text)
ElMessage.success('已复制')
} catch {
ElMessage.error('复制失败,请手动选择文本')
}
}
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: '#F8FAFC' }"
@selection-change="onSelectionChange"
>
<el-table-column type="selection" header-align="center" align="center" width="40" />
<el-table-column prop="customerName" label="客户名" min-width="180" header-align="center" align="center" show-overflow-tooltip />
<el-table-column prop="contactName" label="联系人" width="100" align="center" />
<el-table-column prop="contactPhone" label="联系电话" width="130" 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;">
后端将自动创建公司账号 + 生成 connect_code(2h 有效)
</el-alert>
</div>
<!-- Step 3: 生成结果 -->
<div v-if="step === 3 && result">
<el-alert type="warning" :closable="false" style="margin-bottom: 16px;">
请立即复制 connectCode 和 appSecret!关弹窗后无法再次查看
</el-alert>
<p>已为 {{ result.length }} 个客户生成 APIKey:</p>
<el-table :data="result" border>
<el-table-column prop="customerName" label="客户名" width="160" />
<el-table-column prop="appId" label="AppID" width="280">
<template #default="{ row }">
<code>{{ row.appId }}</code>
</template>
</el-table-column>
<el-table-column prop="connectCode" label="ConnectCode">
<template #default="{ row }">
<code class="rr-code-accent">{{ row.connectCode }}</code>
</template>
</el-table-column>
<el-table-column prop="expireAt" label="过期时间" width="180" />
<el-table-column label="AppSecret" min-width="320">
<template #default="{ row }">
<el-input :model-value="row.appSecret" readonly type="password" size="small" show-password style="width: 220px; margin-right: 8px;" />
<el-button size="small" type="primary" @click="copySecret(row.appSecret)">复制</el-button>
</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; }
</style>