This commit is contained in:
张成
2026-04-01 13:26:41 +08:00
parent fa9abf83ae
commit d03916290a
7 changed files with 315 additions and 30 deletions

View File

@@ -26,6 +26,10 @@ class PlanServer {
async exportRows(row) {
return window.framework.http.post("/biz_plan/export", row);
}
async proxyApiCatalog() {
return window.framework.http.post("/biz_plan/proxy_api_catalog", {});
}
}
export default new PlanServer();

View File

@@ -45,7 +45,7 @@
</div>
</div>
<Modal v-model="modal" :title="form.id ? '编辑套餐' : '新增套餐'" width="720" :loading="saving" @on-ok="save">
<Modal v-model="modal" :title="form.id ? '编辑套餐' : '新增套餐'" width="900" :loading="saving" @on-ok="save">
<Form ref="formRef" :model="form" :rules="rules" :label-width="120">
<FormItem label="套餐编码" prop="plan_code">
<Input v-model="form.plan_code" :disabled="!!form.id" />
@@ -83,8 +83,58 @@
<FormItem label="功能点 JSON">
<Input v-model="featuresText" type="textarea" :rows="4" placeholder='如 {"msg":true} 或 ["msg","mass"]' />
</FormItem>
<FormItem label="可用接口 JSON">
<Input v-model="allowedApisText" type="textarea" :rows="4" placeholder='接口路径数组,如 ["/user/GetProfile","/message/SendText"],留空=不限制' />
<FormItem label="API 权限">
<RadioGroup v-model="api_permission_mode">
<Radio label="all">不限制Swagger 内转发接口均可调用</Radio>
<Radio label="whitelist">仅允许勾选的接口路径白名单</Radio>
</RadioGroup>
<p class="form-hint">白名单与鉴权路径一致 /user/GetProfile目录来自 swagger.json</p>
</FormItem>
<FormItem v-if="api_permission_mode === 'whitelist'" label=" ">
<Alert v-if="selected_api_paths.length === 0" type="warning" show-icon class="mb12">
未勾选任何接口时保存后该套餐将无法调用任何转发 API
</Alert>
<div v-if="proxy_catalog.tags.length === 0" class="text-muted">暂无接口目录请检查 swagger可联系后台配置</div>
<div v-else class="api-catalog-wrap">
<div class="api-catalog-toolbar">
<Button type="default" size="small" @click="select_all_catalog">全选</Button>
<Button type="default" size="small" class="ml8" @click="clear_all_catalog">全不选</Button>
<span class="api-catalog-stats ml10">已选 {{ selected_api_paths.length }} / {{ catalog_path_count }}</span>
</div>
<Collapse v-model="api_collapse">
<Panel v-for="tag in proxy_catalog.tags" :key="tag" :name="tag">
{{ tag }}{{ count_selected_in_tag(tag) }}/{{ (proxy_catalog.groups[tag] || []).length }}
<div slot="content">
<div class="panel-actions mb8">
<Button type="text" size="small" @click="select_all_in_tag(tag)">本组全选</Button>
<Button type="text" size="small" @click="clear_tag(tag)">本组清空</Button>
</div>
<div class="api-check-grid">
<Checkbox
v-for="it in (proxy_catalog.groups[tag] || [])"
:key="it.path"
:value="selected_api_paths.includes(it.path)"
class="api-check-item"
@on-change="(v) => set_api_path_checked(it.path, v)"
>
<span class="api-path-text">{{ it.path }}</span>
<span class="api-meta">{{ it.methods.join(', ') }}</span>
<span v-if="it.summary" class="api-summary">{{ it.summary }}</span>
</Checkbox>
</div>
</div>
</Panel>
</Collapse>
<div v-if="orphan_api_paths.length" class="orphan-apis mt12">
<p class="sub-label">以下路径不在当前目录中(仍会计入白名单,可点 × 移除)</p>
<Tag
v-for="p in orphan_api_paths"
:key="p"
closable
@on-close="() => remove_orphan_path(p)"
>{{ p }}</Tag>
</div>
</div>
</FormItem>
<FormItem label="状态" prop="status">
<Select v-model="form.status" style="width: 100%">
@@ -115,7 +165,10 @@ export default {
saving: false,
form: {},
featuresText: '{}',
allowedApisText: '',
api_permission_mode: 'all',
selected_api_paths: [],
proxy_catalog: { items: [], groups: {}, tags: [] },
api_collapse: [],
rules: {
plan_code: [{ required: true, message: '必填', trigger: 'blur' }],
plan_name: [{ required: true, message: '必填', trigger: 'blur' }],
@@ -124,6 +177,15 @@ export default {
}
},
computed: {
catalog_path_set() {
return new Set((this.proxy_catalog.items || []).map((x) => x.path))
},
catalog_path_count() {
return (this.proxy_catalog.items || []).length
},
orphan_api_paths() {
return this.selected_api_paths.filter((p) => !this.catalog_path_set.has(p))
},
columns() {
return [
{ title: 'ID', key: 'id', width: 70 },
@@ -131,6 +193,26 @@ export default {
{ title: '名称', key: 'plan_name', minWidth: 140 },
{ title: '月费', key: 'monthly_price', width: 90 },
{ title: 'API调用上限', key: 'api_call_quota', width: 120, render: (h, p) => h('span', p.row.api_call_quota > 0 ? p.row.api_call_quota : '不限') },
{
title: 'API权限',
key: 'api_perm',
width: 108,
render: (h, p) => {
const a = p.row.allowed_apis
if (a == null || a === '') return h('span', '不限制')
let list = a
if (typeof list === 'string') {
try {
list = JSON.parse(list)
} catch (e) {
return h('span', '—')
}
}
if (!Array.isArray(list)) return h('span', '—')
if (list.length === 0) return h('span', { class: 'text-danger' }, '全禁')
return h('span', `${list.length} 项`)
},
},
{ title: '状态', key: 'status', width: 90 },
{
title: '操作',
@@ -174,8 +256,66 @@ export default {
},
mounted() {
this.load(1)
this.load_proxy_catalog()
},
methods: {
async load_proxy_catalog() {
try {
const res = await planServer.proxyApiCatalog()
if (res && res.code === 0 && res.data) {
this.proxy_catalog = {
items: res.data.items || [],
groups: res.data.groups || {},
tags: res.data.tags || [],
}
}
} catch (e) {
this.$Message.error('加载转发接口目录失败')
}
},
normalize_allowed_apis_raw(raw) {
if (raw == null || raw === '') return []
let list = raw
if (typeof list === 'string') {
try {
list = JSON.parse(list)
} catch (e) {
return []
}
}
return Array.isArray(list) ? [...list] : []
},
count_selected_in_tag(tag) {
const paths = new Set((this.proxy_catalog.groups[tag] || []).map((x) => x.path))
return this.selected_api_paths.filter((p) => paths.has(p)).length
},
set_api_path_checked(path, checked) {
const set = new Set(this.selected_api_paths)
if (checked) set.add(path)
else set.delete(path)
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
},
select_all_in_tag(tag) {
const set = new Set(this.selected_api_paths)
for (const it of this.proxy_catalog.groups[tag] || []) set.add(it.path)
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
},
clear_tag(tag) {
const rm = new Set((this.proxy_catalog.groups[tag] || []).map((x) => x.path))
this.selected_api_paths = this.selected_api_paths.filter((p) => !rm.has(p))
},
select_all_catalog() {
const set = new Set(this.selected_api_paths)
for (const it of this.proxy_catalog.items || []) set.add(it.path)
this.selected_api_paths = Array.from(set).sort((a, b) => a.localeCompare(b))
},
clear_all_catalog() {
const keep = new Set(this.orphan_api_paths)
this.selected_api_paths = Array.from(keep).sort((a, b) => a.localeCompare(b))
},
remove_orphan_path(path) {
this.selected_api_paths = this.selected_api_paths.filter((p) => p !== path)
},
async load(page) {
if (page) this.param.pageOption.page = page
const res = await planServer.page({ param: this.param })
@@ -203,12 +343,13 @@ export default {
: typeof row.enabled_features === 'string'
? row.enabled_features
: JSON.stringify(row.enabled_features, null, 2)
this.allowedApisText =
row.allowed_apis == null
? ''
: typeof row.allowed_apis === 'string'
? row.allowed_apis
: JSON.stringify(row.allowed_apis, null, 2)
if (row.allowed_apis == null || row.allowed_apis === '') {
this.api_permission_mode = 'all'
this.selected_api_paths = []
} else {
this.api_permission_mode = 'whitelist'
this.selected_api_paths = this.normalize_allowed_apis_raw(row.allowed_apis)
}
} else {
this.form = {
plan_code: '',
@@ -225,8 +366,10 @@ export default {
status: 'active',
}
this.featuresText = '{}'
this.allowedApisText = ''
this.api_permission_mode = 'all'
this.selected_api_paths = []
}
this.api_collapse = this.proxy_catalog.tags.length ? [...this.proxy_catalog.tags] : []
this.modal = true
},
save() {
@@ -248,20 +391,8 @@ export default {
}
}
let allowed_apis = null
const apisStr = (this.allowedApisText || '').trim()
if (apisStr) {
try {
allowed_apis = JSON.parse(apisStr)
if (!Array.isArray(allowed_apis)) {
this.$Message.error('可用接口必须是 JSON 数组')
this.saving = false
return
}
} catch (e) {
this.$Message.error('可用接口 JSON 格式错误')
this.saving = false
return
}
if (this.api_permission_mode === 'whitelist') {
allowed_apis = [...this.selected_api_paths]
}
const payload = { ...this.form, enabled_features, allowed_apis }
try {
@@ -343,4 +474,79 @@ export default {
margin-top: 12px;
text-align: right;
}
.form-hint {
color: #808695;
font-size: 12px;
margin-top: 6px;
}
.text-muted {
color: #808695;
}
.text-danger {
color: #ed4014;
}
.mb12 {
margin-bottom: 12px;
}
.mt12 {
margin-top: 12px;
}
.api-catalog-wrap {
max-height: 420px;
overflow: auto;
padding: 12px;
border: 1px solid #e8eaec;
border-radius: 4px;
}
.api-catalog-toolbar {
margin-bottom: 10px;
}
.api-catalog-stats {
color: #808695;
font-size: 12px;
}
.panel-actions .ivu-btn-text + .ivu-btn-text {
margin-left: 4px;
}
.api-check-grid {
display: flex;
flex-direction: column;
gap: 4px;
}
.api-path-text {
font-weight: 500;
margin-right: 8px;
}
.api-meta {
color: #808695;
font-size: 12px;
margin-right: 8px;
}
.api-summary {
color: #515a6e;
font-size: 12px;
}
.sub-label {
font-size: 12px;
color: #808695;
margin-bottom: 8px;
}
.orphan-apis .ivu-tag {
margin: 0 8px 8px 0;
}
</style>