feat(vbill): 随行付通道管理端(服务商配置/商户进件 API与视图 + i18n)

This commit is contained in:
DaxPay Dev
2026-07-07 21:04:26 +08:00
parent c392c1a31e
commit a266a28662
9 changed files with 761 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
import type { Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 随行付(天阙科技)通道商户绑定 API
*/
export const VbillChannelMerchantApi = {
/**
* 根据通道商户号查询随行付通道商户配置
*/
findByChannelMchNo(channelMchNo: string): Promise<Result<VbillIsvChannelMerchant>> {
return defHttp.get({
url: '/admin/vbill/isv-channel-merchant/find-by-channel-mch-no',
params: { channelMchNo },
});
},
/**
* 创建随行付通道商户
*/
create(data: VbillIsvChannelMerchantCreateParam): Promise<Result<void>> {
return defHttp.post({ url: '/admin/vbill/isv-channel-merchant/create', data });
},
};
/** 随行付通道商户绑定 */
export interface VbillIsvChannelMerchant {
/** 主键 */
id?: string;
/** 通道商户号 */
channelMchNo?: string;
/** 所属支付产品 */
product?: string;
/** 天阙商户号(mno) */
vbillMchNo?: string;
}
/** 随行付通道商户创建参数 */
export interface VbillIsvChannelMerchantCreateParam {
/** 商户号 */
mchNo: string;
/** 通道商户名称 */
channelMerchantName: string;
/** 所属支付产品 */
product: string;
/** 天阙商户号(mno) */
vbillMchNo: string;
}

View File

@@ -0,0 +1,3 @@
/** 随行付(天阙科技)渠道 barrel */
export * from './channel-merchant.api';
export * from './pay-config.api';

View File

@@ -0,0 +1,38 @@
import type { Result } from '#/types/web';
import { defHttp } from '#/api/request';
/**
* 随行付(天阙科技)服务商密钥配置 API
*/
export const VbillPayConfigApi = {
/**
* 查询随行付服务商密钥配置
* 平台为唯一服务商,密钥全局唯一
*/
findConfig(product: string): Promise<Result<VbillIsvKeyConfig>> {
return defHttp.get({ url: '/admin/vbill/isv-key-config/find-config', params: { product } });
},
/**
* 保存随行付服务商密钥配置
*/
saveConfig(data: VbillIsvKeyConfig): Promise<Result<void>> {
return defHttp.post({ url: '/admin/vbill/isv-key-config/save-config', data });
},
};
/** 随行付服务商密钥配置 */
export interface VbillIsvKeyConfig {
/** 主键 */
id?: string;
/** 产品编码 */
product?: string;
/** 天阙合作机构ID(orgId) */
orgId?: string;
/** 天阙RSA公钥(X509 Base64, 回调验签用) */
publicKey?: string;
/** 商户RSA私钥(PKCS8 Base64, SHA1withRSA签名) */
privateKey?: string;
/** 是否沙箱环境 */
sandbox?: boolean;
}

View File

@@ -0,0 +1,29 @@
{
"title": "VBill ISV Configuration",
"groupConfig": "Configuration",
"configTitle": "VBill ISV Key Configuration",
"manageTitle": "VBill Merchant Management",
"basicConfig": "Basic Config",
"keyConfig": "Key Config",
"cardMchKey": "ISV Key",
"cardMchKeyDesc": "Configure VBill org ID, RSA public/private keys",
"orgId": "Tianque Org ID",
"orgIdPlaceholder": "Please enter Tianque partner org ID (orgId)",
"publicKey": "Tianque RSA Public Key",
"publicKeyTooltip": "Tianque RSA public key (X509 Base64), for response/callback signature verification",
"privateKey": "Merchant RSA Private Key",
"privateKeyTooltip": "Merchant RSA private key (PKCS8 Base64), SHA1withRSA signing",
"sandbox": "Sandbox",
"groupMchConfig": "Merchant Config",
"cardMchConfig": "Merchant Config",
"cardMchConfigDesc": "View Tianque merchant number",
"mchConfigTitle": "Merchant Config",
"vbillMchNo": "Tianque Merchant No",
"vbillMchNoPlaceholder": "Please enter Tianque merchant No (mno)",
"validation": {
"orgId": "Please enter Tianque org ID",
"publicKey": "Please enter Tianque RSA public key",
"privateKey": "Please enter merchant RSA private key",
"vbillMchNo": "Please enter Tianque merchant No"
}
}

View File

@@ -0,0 +1,29 @@
{
"title": "随行付服务商配置",
"groupConfig": "配置管理",
"configTitle": "随行付服务商密钥配置",
"manageTitle": "随行付商户管理",
"basicConfig": "基础配置",
"keyConfig": "密钥配置",
"cardMchKey": "服务商密钥",
"cardMchKeyDesc": "配置随行付天阙机构ID、RSA公私钥",
"orgId": "天阙机构ID",
"orgIdPlaceholder": "请输入天阙合作机构ID(orgId)",
"publicKey": "天阙RSA公钥",
"publicKeyTooltip": "天阙平台RSA公钥(X509 Base64), 用于响应/回调验签",
"privateKey": "商户RSA私钥",
"privateKeyTooltip": "商户RSA私钥(PKCS8 Base64), SHA1withRSA 签名",
"sandbox": "沙箱环境",
"groupMchConfig": "商户配置",
"cardMchConfig": "商户配置",
"cardMchConfigDesc": "查看天阙商户号",
"mchConfigTitle": "商户配置",
"vbillMchNo": "天阙商户号",
"vbillMchNoPlaceholder": "请输入天阙商户号(mno)",
"validation": {
"orgId": "请输入天阙机构ID",
"publicKey": "请输入天阙RSA公钥",
"privateKey": "请输入商户RSA私钥",
"vbillMchNo": "请输入天阙商户号"
}
}

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue';
import { $t } from '@vben/locales';
import {
type VbillIsvKeyConfig,
VbillPayConfigApi,
} from '#/api/payment/channel/vbill/pay-config.api';
import { PermCodes } from '#/constants/perm-codes';
import { ProductEnum } from '#/enums/payment/productEnum';
import { useFormEdit } from '#/hooks/useFormEdit';
import { useMessage } from '#/hooks/useMessage';
import { usePermission } from '#/hooks/usePermission';
defineOptions({ name: 'VbillIsvConfigEdit' });
const emit = defineEmits<{
(e: 'saved'): void;
}>();
const { labelCol, wrapperCol, confirmLoading, visible, handleCancel, diffForm } = useFormEdit();
const { message } = useMessage();
const { hasPermission } = usePermission();
const formRef = ref();
const form = ref<VbillIsvKeyConfig>({} as VbillIsvKeyConfig);
let rawForm: Record<string, any> = {};
const canEdit = computed(() => hasPermission(PermCodes.Payment.Vbill.MANAGE));
const drawerTitle = $t('payment.channel.vbillIsv.configTitle');
const rules = {
orgId: [{ required: true, message: $t('payment.channel.vbillIsv.validation.orgId') }],
// 国际化: 脱敏字段始终必填, 编辑时预填脱敏值, 未修改由 diffForm 比对跳过更新
publicKey: [{ required: true, message: $t('payment.channel.vbillIsv.validation.publicKey') }],
privateKey: [{ required: true, message: $t('payment.channel.vbillIsv.validation.privateKey') }],
};
/** 打开抽屉并加载随行付服务商密钥配置(平台为唯一服务商,密钥全局唯一) */
function init() {
visible.value = true;
resetForm();
loadConfig();
}
function loadConfig() {
confirmLoading.value = true;
VbillPayConfigApi.findConfig(ProductEnum.VBILL_PAY)
.then(({ data }) => {
rawForm = { ...data };
form.value = {
product: ProductEnum.VBILL_PAY,
...data,
} as VbillIsvKeyConfig;
})
.finally(() => {
confirmLoading.value = false;
});
}
function handleOk() {
formRef.value?.validate().then(() => {
confirmLoading.value = true;
VbillPayConfigApi.saveConfig({
...form.value,
...diffForm(
rawForm,
form.value,
'privateKey',
'publicKey',
),
product: ProductEnum.VBILL_PAY,
})
.then(() => {
message.success($t('common.saveSuccess'));
handleCancel();
emit('saved');
})
.finally(() => {
confirmLoading.value = false;
});
}).catch(() => {});
}
function resetForm() {
nextTick(() => {
formRef.value?.resetFields();
});
}
defineExpose({ init });
</script>
<template>
<a-drawer
v-model:open="visible"
:title="drawerTitle"
size="large"
:styles="{ footer: { textAlign: 'right' } }"
:mask-closable="false"
destroy-on-hidden
@close="handleCancel"
>
<a-spin :spinning="confirmLoading">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="labelCol"
:wrapper-col="wrapperCol"
:validate-trigger="['blur', 'change']"
>
<a-divider orientation="left">{{ $t('payment.channel.vbillIsv.basicConfig') }}</a-divider>
<!-- 国际化: 天阙机构ID -->
<a-form-item :label="$t('payment.channel.vbillIsv.orgId')" name="orgId">
<a-input
v-model:value="form.orgId"
:disabled="!canEdit"
:placeholder="$t('payment.channel.vbillIsv.orgIdPlaceholder')"
/>
</a-form-item>
<a-divider orientation="left">{{ $t('payment.channel.vbillIsv.keyConfig') }}</a-divider>
<!-- 国际化: 天阙RSA公钥 -->
<a-form-item
:label="$t('payment.channel.vbillIsv.publicKey')"
name="publicKey"
:tooltip="$t('payment.channel.vbillIsv.publicKeyTooltip')"
>
<a-textarea
v-model:value="form.publicKey"
:disabled="!canEdit"
:rows="3"
:placeholder="$t('payment.channel.vbillIsv.publicKeyTooltip')"
/>
</a-form-item>
<!-- 国际化: 商户RSA私钥 -->
<a-form-item
:label="$t('payment.channel.vbillIsv.privateKey')"
name="privateKey"
:tooltip="$t('payment.channel.vbillIsv.privateKeyTooltip')"
>
<a-textarea
v-model:value="form.privateKey"
:disabled="!canEdit"
:rows="3"
:placeholder="$t('payment.channel.vbillIsv.privateKeyTooltip')"
/>
</a-form-item>
</a-form>
</a-spin>
<template #footer>
<a-space>
<a-button @click="handleCancel">{{ $t('common.cancel') }}</a-button>
<a-button v-if="canEdit" type="primary" :loading="confirmLoading" @click="handleOk">
{{ $t('common.save') }}
</a-button>
</a-space>
</template>
</a-drawer>
</template>

View File

@@ -0,0 +1,168 @@
<script lang="ts" setup>
import { computed, nextTick, ref } from 'vue';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
import { VbillChannelMerchantApi, type VbillIsvChannelMerchantCreateParam } from '#/api/payment/channel/vbill/channel-merchant.api';
import ChannelLogo from '#/components/channel/ChannelLogo.vue';
import { productI18nMap, productNameMap } from '#/enums/payment';
import { useMessage } from '#/hooks/useMessage';
defineOptions({ name: 'VbillMchCreateConfig' });
const emit = defineEmits<{
(e: 'prev'): void;
(e: 'close'): void;
}>();
const { message } = useMessage();
const mchNo = ref('');
const productCode = ref('');
const channelCode = ref('');
const formRef = ref();
const form = ref({
channelMerchantName: '',
vbillMchNo: '',
});
const visible = ref(false);
const createSuccess = ref(false);
const submitLoading = ref(false);
/** 支付产品展示名称 */
const productDisplayName = computed(() => {
const product = productCode.value;
if (!product) return '-';
const i18nKey = productI18nMap[product];
if (i18nKey) {
return $t(i18nKey);
}
return productNameMap[product] || product;
});
const rules = computed(() => ({
channelMerchantName: [{ required: true, message: $t('payment.merchant.channelMerchant.channelMerchantNameRequired') }],
vbillMchNo: [{ required: true, message: $t('payment.channel.vbillIsv.validation.vbillMchNo') }],
}));
function init(no: string, product: string, channel: string) {
mchNo.value = no;
productCode.value = product;
channelCode.value = channel;
visible.value = true;
createSuccess.value = false;
resetForm();
}
function validate(): boolean {
let valid = false;
formRef.value?.validate((errors: any) => {
valid = !errors;
});
return valid;
}
function getData(): Record<string, any> {
return { ...form.value };
}
function submit(param: Record<string, any>): Promise<any> {
return VbillChannelMerchantApi.create(param as VbillIsvChannelMerchantCreateParam);
}
function handlePrev() {
emit('prev');
}
function handleSubmit() {
formRef.value?.validate().then(() => {
submitLoading.value = true;
const param = {
mchNo: mchNo.value,
product: productCode.value,
...form.value,
};
submit(param)
.then(() => {
createSuccess.value = true;
message.success($t('payment.merchant.channelMerchant.createSuccess'));
})
.finally(() => {
submitLoading.value = false;
});
}).catch(() => {});
}
function resetForm() {
nextTick(() => {
formRef.value?.resetFields();
});
}
defineExpose({ init, validate, getData, submit });
</script>
<template>
<div v-if="visible">
<div v-if="!createSuccess">
<a-spin :spinning="submitLoading">
<a-form
ref="formRef"
:model="form"
:rules="rules"
:label-col="{ span: 6 }"
:wrapper-col="{ span: 16 }"
:validate-trigger="['blur', 'change']"
>
<!-- 国际化: 支付产品 -->
<a-form-item :label="$t('payment.merchant.channelMerchant.product')">
<div class="flex items-center gap-2 h-8">
<ChannelLogo v-if="channelCode" :channel="channelCode" :size="24" />
<span class="text-foreground">{{ productDisplayName }}</span>
</div>
</a-form-item>
<!-- 国际化: 商户名称 -->
<a-form-item :label="$t('payment.merchant.channelMerchant.channelMerchantName')" name="channelMerchantName">
<a-input
v-model:value="form.channelMerchantName"
:placeholder="$t('payment.merchant.channelMerchant.pleaseInputName')"
/>
</a-form-item>
<!-- 国际化: 天阙商户号 -->
<a-form-item :label="$t('payment.channel.vbillIsv.vbillMchNo')" name="vbillMchNo">
<a-input
v-model:value="form.vbillMchNo"
:placeholder="$t('payment.channel.vbillIsv.vbillMchNoPlaceholder')"
/>
</a-form-item>
<div class="flex justify-center gap-4 mt-8 pt-6 border-t border-border">
<a-button @click="handlePrev">
{{ $t('payment.merchant.channelMerchant.prevStep') }}
</a-button>
<a-button type="primary" :loading="submitLoading" @click="handleSubmit">
{{ $t('payment.merchant.channelMerchant.create') }}
</a-button>
</div>
</a-form>
</a-spin>
</div>
<div v-else class="flex flex-col items-center justify-center py-12">
<div class="w-20 h-20 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center mb-6">
<IconifyIcon icon="ant-design:check-circle-filled" class="text-4xl text-green-500" />
</div>
<div class="text-xl font-bold text-foreground mb-2">{{
$t('payment.merchant.channelMerchant.createSuccess')
}}</div>
<div class="text-sm text-muted-foreground mb-8">{{
$t('payment.merchant.channelMerchant.createSuccessDesc')
}}</div>
<a-button type="primary" @click="emit('close')">
{{ $t('payment.merchant.channelMerchant.closePage') }}
</a-button>
</div>
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script lang="ts" setup>
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
import VbillIsvConfigEdit from '#/views/payment/channel/vbill/config/VbillIsvConfigEdit.vue';
defineOptions({ name: 'VbillManage' });
const mchKeyEditRef = ref<InstanceType<typeof VbillIsvConfigEdit> | null>(null);
/**
* 功能卡片配置
* 随行付为聚合服务商模式, 密钥全局唯一, 不区分应用/能力绑定, 仅一个密钥配置入口
*/
const functionCards = computed(() => [
{
group: $t('payment.channel.vbillIsv.groupConfig'),
color: 'blue',
cards: [
{
key: 'mchKey',
title: $t('payment.channel.vbillIsv.cardMchKey'),
icon: 'ant-design:key-outlined',
description: $t('payment.channel.vbillIsv.cardMchKeyDesc'),
},
],
},
]);
function getIconBgClass(color: string) {
const map: Record<string, string> = {
green: 'bg-success/10 text-success',
blue: 'bg-primary/10 text-primary',
};
return map[color] || 'bg-muted text-muted-foreground';
}
function getGroupColorClass(color: string) {
const map: Record<string, string> = {
green: 'bg-emerald-500',
blue: 'bg-blue-500',
};
return map[color] || 'bg-gray-500';
}
/** 初始化(由分发页调用, 平台为唯一服务商, 无需服务商号) */
function init() {
}
function handleCardClick(card: { key: string }) {
if (card.key === 'mchKey') {
mchKeyEditRef.value?.init();
}
}
defineExpose({ init });
</script>
<template>
<div class="space-y-12 py-4">
<div v-for="group in functionCards" :key="group.group">
<div class="mb-6 flex items-center gap-3 px-2">
<div class="h-6 w-1.5 rounded-full shadow-sm" :class="getGroupColorClass(group.color)"></div>
<span class="text-xl font-extrabold tracking-tight text-foreground">{{ group.group }}</span>
</div>
<div class="card-grid">
<a-card
v-for="card in group.cards"
:key="card.key"
hoverable
class="isv-card group relative overflow-hidden rounded-2xl border-none bg-card shadow-md transition-all duration-300 hover:-translate-y-1.5 hover:shadow-xl"
:styles="{ body: { padding: '24px 20px' } }"
@click="handleCardClick(card)"
>
<div class="flex flex-col items-center text-center">
<div
class="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl shadow-sm transition-all duration-300 group-hover:scale-110 group-hover:shadow-md"
:class="getIconBgClass(group.color)"
>
<IconifyIcon :icon="card.icon" class="h-7 w-7" />
</div>
<div
class="mb-1.5 text-base font-bold text-foreground group-hover:text-primary transition-colors duration-300"
>{{ card.title }}</div>
<a-tooltip :title="card.description" placement="bottom">
<div class="line-clamp-1 text-xs leading-relaxed text-muted-foreground">{{ card.description }}</div>
</a-tooltip>
</div>
<div
class="absolute bottom-0 left-0 h-1.5 w-0 transition-all duration-300 group-hover:w-full"
:class="getGroupColorClass(group.color)"
></div>
</a-card>
</div>
</div>
<VbillIsvConfigEdit ref="mchKeyEditRef" />
</div>
</template>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 220px);
gap: 24px;
justify-content: center;
}
.isv-card {
max-height: 200px;
}
</style>

View File

@@ -0,0 +1,164 @@
<script lang="ts" setup>
import type { ChannelMerchantResult } from '#/api/payment/channel/channel-merchant.api';
import type { VbillIsvChannelMerchant } from '#/api/payment/channel/vbill/channel-merchant.api';
import { computed, ref } from 'vue';
import { $t } from '@vben/locales';
import { IconifyIcon } from '@vben-core/icons';
import { VbillChannelMerchantApi } from '#/api/payment/channel/vbill/channel-merchant.api';
import ChannelMerchantNameEditModal from '#/views/payment/merchant/channel-merchant/detail/ChannelMerchantNameEditModal.vue';
import CommonChannelMerchantBasicInfo from '#/views/payment/merchant/channel-merchant/detail/CommonChannelMerchantBasicInfo.vue';
defineOptions({ name: 'VbillMchManage' });
const emit = defineEmits<{
(e: 'success'): void;
}>();
const mchNo = ref('');
const channelMchNo = ref('');
const channelMerchant = ref<ChannelMerchantResult>({});
const channelConfig = ref<VbillIsvChannelMerchant>({});
const basicInfoRef = ref<InstanceType<typeof CommonChannelMerchantBasicInfo>>();
const editNameRef = ref<InstanceType<typeof ChannelMerchantNameEditModal>>();
/** 通道专属字段(基本信息抽屉展示) */
const extraFields = computed(() => [
{ label: $t('payment.channel.vbillIsv.vbillMchNo'), value: channelConfig.value.vbillMchNo || '-' },
]);
/**
* 功能卡片配置
* 随行付服务商模式, 商户仅有天阙商户号(mno), 创建后无需额外编辑
*/
const functionCards = computed(() => [
{
// 国际化: 基础管理
group: $t('payment.merchant.channelMerchant.groupBasic'),
color: 'blue',
cards: [
{
key: 'basicInfo',
// 国际化: 基本信息
title: $t('payment.merchant.channelMerchant.cardBasicInfo'),
icon: 'ant-design:info-circle-outlined',
// 国际化: 查看通道商户基础配置信息
description: $t('payment.merchant.channelMerchant.cardBasicInfoDesc'),
},
{
key: 'editMerchantName',
// 国际化: 修改商户名称
title: $t('payment.merchant.channelMerchant.cardEditMerchantName'),
icon: 'ant-design:edit-outlined',
description: $t('payment.merchant.channelMerchant.cardEditMerchantNameDesc'),
},
],
},
]);
function getGroupColorClass(color: string) {
const map: Record<string, string> = {
blue: 'bg-blue-500',
green: 'bg-emerald-500',
};
return map[color] || 'bg-gray-500';
}
function getIconBgClass(color: string) {
const map: Record<string, string> = {
blue: 'bg-primary/10 text-primary',
green: 'bg-success/10 text-success',
};
return map[color] || 'bg-muted text-muted-foreground';
}
/** 初始化(由 DetailDispatch 调用) */
function init(no: string, mchChannelNo: string, summary: ChannelMerchantResult) {
mchNo.value = no;
channelMchNo.value = mchChannelNo;
channelMerchant.value = summary;
}
function handleCardClick(card: { key: string }) {
if (card.key === 'basicInfo') {
// 先加载随行付专属配置, 再打开基本信息抽屉
VbillChannelMerchantApi.findByChannelMchNo(channelMchNo.value).then(({ data }) => {
channelConfig.value = data || {};
basicInfoRef.value?.open();
});
return;
}
if (card.key === 'editMerchantName') {
editNameRef.value?.open();
}
}
defineExpose({ init });
</script>
<template>
<div class="space-y-12 py-4">
<div v-for="group in functionCards" :key="group.group">
<div class="mb-6 flex items-center gap-3 px-2">
<div class="h-6 w-1.5 rounded-full shadow-sm" :class="getGroupColorClass(group.color)"></div>
<span class="text-xl font-extrabold tracking-tight text-foreground">{{ group.group }}</span>
</div>
<div class="card-grid">
<a-card
v-for="card in group.cards"
:key="card.key"
hoverable
class="isv-card group relative overflow-hidden rounded-2xl border-none bg-card shadow-md transition-all duration-300 hover:-translate-y-1.5 hover:shadow-xl"
:styles="{ body: { padding: '24px 20px' } }"
@click="handleCardClick(card)"
>
<div class="flex flex-col items-center text-center">
<div
class="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl shadow-sm transition-all duration-300 group-hover:scale-110 group-hover:shadow-md"
:class="getIconBgClass(group.color)"
>
<IconifyIcon :icon="card.icon" class="h-7 w-7" />
</div>
<div
class="mb-1.5 text-base font-bold text-foreground group-hover:text-primary transition-colors duration-300"
>
{{ card.title }}
</div>
<a-tooltip :title="card.description" placement="bottom">
<div class="line-clamp-1 text-xs leading-relaxed text-muted-foreground">{{ card.description }}</div>
</a-tooltip>
</div>
<div
class="absolute bottom-0 left-0 h-1.5 w-0 transition-all duration-300 group-hover:w-full"
:class="getGroupColorClass(group.color)"
></div>
</a-card>
</div>
</div>
<CommonChannelMerchantBasicInfo
ref="basicInfoRef"
:channel-mch-no="channelMchNo"
:channel-merchant="channelMerchant"
:extra-fields="extraFields"
/>
<ChannelMerchantNameEditModal ref="editNameRef" :channel-merchant="channelMerchant" @success="emit('success')" />
</div>
</template>
<style scoped>
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, 220px);
gap: 24px;
justify-content: center;
}
.isv-card {
max-height: 200px;
}
</style>