官网开发 0810

This commit is contained in:
eafonyang
2026-08-10 14:56:44 +08:00
parent c9a1d9c576
commit 48510739a7
20 changed files with 636 additions and 53 deletions
+11 -2
View File
@@ -1,5 +1,5 @@
import { Router, type Request, type Response } from 'express';
import { eq, and } from 'drizzle-orm';
import { eq, and, max } from 'drizzle-orm';
import { db } from '../../config/database.js';
import { wwwSectionBlocks, wwwSpecGroups, wwwSpecItems } from '../../schemas/index.js';
import logger from '../../config/logger.js';
@@ -77,6 +77,15 @@ router.post('/api/www/sections', async (req: Request, res: Response) => {
return;
}
// 未指定排序时,自动追加到同 page_type(及同产品)已有区块的最后,避免默认 0 导致乱序
let sortOrder = Number(b.sort_order) || 0;
if (b.sort_order === undefined || b.sort_order === null) {
const scope = [eq(wwwSectionBlocks.pageType, b.page_type)];
if (b.product_id) scope.push(eq(wwwSectionBlocks.productId, b.product_id));
const [agg] = await db.select({ max: max(wwwSectionBlocks.sortOrder) }).from(wwwSectionBlocks).where(and(...scope));
sortOrder = (agg?.max ?? -1) + 1;
}
const result = await db.insert(wwwSectionBlocks).values({
pageType: b.page_type,
productId: b.product_id || null,
@@ -108,7 +117,7 @@ router.post('/api/www/sections', async (req: Request, res: Response) => {
videoUrl: b.video_url || '',
config: b.config || null,
isVisible: b.is_visible !== undefined ? (b.is_visible ? 1 : 0) : 1,
sortOrder: b.sort_order || 0,
sortOrder,
});
const newId = result[0].insertId;
@@ -246,7 +246,7 @@ export const generatedRoutes: GeneratedRoute[] = [
meta: {
title: 'www',
i18nKey: 'route.www',
icon: 'mdi:globe-model',
icon: 'iconoir:www',
order: 7
},
children: [
@@ -35,7 +35,7 @@ export function fetchDuplicateSection(id: number) {
// ===== 参数分组 =====
export function fetchGetSpecGroups(sectionId: number) {
return request<Api.Www.SpecGroup[]>({ url: `/www/sections/${sectionId}/spec-groups`, method: 'get' });
return request<{ items: Api.Www.SpecGroup[]; total: number }>({ url: `/www/sections/${sectionId}/spec-groups`, method: 'get' });
}
export function fetchCreateSpecGroup(sectionId: number, data: { title_zh: string; title_en: string; default_visible?: number }) {
@@ -56,7 +56,7 @@ export function fetchSortSpecGroups(sectionId: number, data: Array<{ id: number;
// ===== 参数项 =====
export function fetchGetSpecItems(groupId: number) {
return request<Api.Www.SpecItem[]>({ url: `/www/spec-groups/${groupId}/items`, method: 'get' });
return request<{ items: Api.Www.SpecItem[]; total: number }>({ url: `/www/spec-groups/${groupId}/items`, method: 'get' });
}
export function fetchCreateSpecItem(groupId: number, data: { name_zh: string; name_en: string; value_zh: string; value_en: string }) {
+2
View File
@@ -147,6 +147,8 @@ declare namespace Api {
title_en: string;
default_visible: number;
sort_order: number;
/** GET spec-groups 列表接口内联返回的参数项 */
items?: SpecItem[];
}
interface SpecItem {
@@ -4,6 +4,7 @@ import { NButton, NSpace, NSwitch, NTag, type DataTableColumns, type FormInst, t
import ColorInput from '../page/home/components/ColorInput.vue';
import MediaSelectInput from './MediaSelectInput.vue';
import RichTextEditor from './RichTextEditor.vue';
import SpecManager from './SpecManager.vue';
import {
fetchCreateSection,
fetchDeleteSection,
@@ -110,7 +111,7 @@ const formData = reactive({
media_url_en: '',
video_url: '',
is_visible: true,
config: { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null } as Record<string, unknown>
config: { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, margin_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', split_ratio: 50, mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null } as Record<string, unknown>
});
// 背景图/视频显示模式选项(bg_type 为图片/视频时生效)
@@ -128,6 +129,20 @@ const containerWidthOptions = [
{ label: '全宽', value: 'full' }
];
// 图文分栏比例选项(值为素材栏宽占比);标签顺序随「媒体位置」联动:
// 素材在左 → 「素材 : 文案」,素材在右 → 「文案 : 素材」(与视觉左右顺序一致,底层存值不变)
const splitRatioOptions = computed(() => {
const mediaRight = formData.config.media_position === 'right';
const label = (media: number) => (mediaRight ? `${100 - media} : ${media}` : `${media} : ${100 - media}`);
return [
{ label: `${label(30)}`, value: 30 },
{ label: `${label(40)}`, value: 40 },
{ label: `${label(50)}(默认)`, value: 50 },
{ label: `${label(60)}`, value: 60 },
{ label: `${label(70)}`, value: 70 }
];
});
// ===== 马赛克拼贴(layout=mosaic):固定版式 + 槽位素材,存 config.mosaic_pattern / config.mosaic_items =====
interface MosaicItemForm {
type: 'image' | 'video';
@@ -247,11 +262,15 @@ const columns = computed((): DataTableColumns<Api.Www.SectionBlock> => [
{
title: '操作',
key: 'actions',
width: 240,
width: 290,
fixed: 'right',
render(row) {
return h('div', { class: 'flex-y-center gap-8px' }, [
h(NButton, { size: 'small', type: 'primary', onClick: () => handleEdit(row) }, { default: () => '编辑' }),
// spec_table 布局:管理参数分组与参数项
row.layout === 'spec_table'
? h(NButton, { size: 'small', type: 'info', onClick: () => handleSpecManage(row) }, { default: () => '参数' })
: null,
h(NButton, { size: 'small', onClick: () => handleCopy(row) }, { default: () => '复制' }),
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row) }, { default: () => '删除' })
]);
@@ -315,7 +334,7 @@ function handleAdd() {
formData.media_url_en = '';
formData.video_url = '';
formData.is_visible = true;
formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null };
formData.config = { text_position: 'middle_left', width_percent: 100, container_width: '1200', border_radius: 0, padding_y: null, margin_y: null, title_font_size: null, bg_fit: 'cover', media_position: 'left', split_ratio: 50, mosaic_pattern: 'big_3', mosaic_items: emptyMosaicItems(4), min_height: null };
dialogVisible.value = true;
}
@@ -356,9 +375,11 @@ function fillFormFromRow(row: Api.Www.SectionBlock) {
container_width: ['1400', '1600', 'full'].includes(String(cfg.container_width)) ? String(cfg.container_width) : '1200',
border_radius: typeof cfg.border_radius === 'number' && cfg.border_radius > 0 ? cfg.border_radius : 0,
padding_y: typeof cfg.padding_y === 'number' && cfg.padding_y >= 0 ? cfg.padding_y : null,
margin_y: typeof cfg.margin_y === 'number' && cfg.margin_y >= 0 ? cfg.margin_y : null,
title_font_size: typeof cfg.title_font_size === 'number' && cfg.title_font_size > 0 ? cfg.title_font_size : null,
bg_fit: cfg.bg_fit === 'contain' || cfg.bg_fit === 'auto' ? cfg.bg_fit : 'cover',
media_position: cfg.media_position === 'right' ? 'right' : 'left',
split_ratio: [30, 40, 50, 60, 70].includes(Number(cfg.split_ratio)) ? Number(cfg.split_ratio) : 50,
min_height: typeof cfg.min_height === 'number' && cfg.min_height >= 0 ? cfg.min_height : null,
mosaic_pattern: ['big_3', 'half_2', 'big_4', 'big_2'].includes(String(cfg.mosaic_pattern)) ? String(cfg.mosaic_pattern) : 'big_3'
};
@@ -404,7 +425,14 @@ function handleDelete(row: Api.Www.SectionBlock) {
});
}
async function handleSubmit() {
// 参数管理(spec_table 布局):唤起 SpecManager
const specManagerRef = ref<InstanceType<typeof SpecManager> | null>(null);
function handleSpecManage(row: Api.Www.SectionBlock) {
specManagerRef.value?.open(row);
}
async function handleSubmit(closeAfter = true) {
submitLoading.value = true;
const { id, ...payload } = formData;
const reqData = {
@@ -412,13 +440,15 @@ async function handleSubmit() {
page_type: props.pageType,
product_id: props.productId || null
};
const { error } = id
const { data, error } = id
? await fetchUpdateSection(id, reqData)
: await fetchCreateSection(reqData);
submitLoading.value = false;
if (!error) {
// 「应用」新建后回填新 id,后续保存走更新接口,避免重复创建
if (!id && data?.id) formData.id = data.id;
window.$message?.success(id ? '更新成功' : '创建成功');
dialogVisible.value = false;
if (closeAfter) dialogVisible.value = false;
loadData();
}
}
@@ -447,6 +477,8 @@ onMounted(loadData);
:title="dialogTitle"
class="w-1000px"
:mask-closable="false"
:segmented="{ content: true, footer: 'soft' }"
content-style="max-height: calc(85vh - 130px); overflow-y: auto;"
>
<NForm ref="formRef" :model="formData" label-placement="left" label-width="120">
<NDivider>基础配置</NDivider>
@@ -515,6 +547,23 @@ onMounted(loadData);
<span class="text-12px opacity-60">内容区与区块上下边缘的间距留空 = 布局默认多数 96px / 全幅横幅 120px / 主视觉分屏 64px0 = 完全贴边</span>
</div>
</NFormItem>
<NFormItem label="上下外边距">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
:value="(formData.config.margin_y as number | null) ?? null"
:min="0"
:max="240"
:step="8"
class="w-full"
clearable
placeholder="留空 = 紧贴相邻区块"
@update:value="v => (formData.config.margin_y = v)"
>
<template #suffix>px</template>
</NInputNumber>
<span class="text-12px opacity-60">区块与上下相邻区块之间的间隙露出页面背景色适合区块宽度 &lt; 100 + 圆角的卡片式区块留空/0 = 紧贴默认</span>
</div>
</NFormItem>
<NFormItem label="标题字号">
<div class="w-full flex flex-col gap-8px">
<NInputNumber
@@ -596,10 +645,20 @@ onMounted(loadData);
</NFormItem>
<NFormItem v-if="formData.layout === 'split_content'" label="媒体位置">
<NRadioGroup :value="(formData.config.media_position as string) ?? 'left'" @update:value="v => (formData.config.media_position = v)">
<NRadio value="left">图片/视频在左</NRadio>
<NRadio value="right">图片/视频在右</NRadio>
<NRadio value="left">素材文案</NRadio>
<NRadio value="right">文案素材</NRadio>
</NRadioGroup>
</NFormItem>
<NFormItem v-if="formData.layout === 'split_content'" label="分栏比例">
<div class="w-full flex flex-col gap-8px">
<NSelect
:value="(formData.config.split_ratio as number) ?? 50"
:options="splitRatioOptions"
@update:value="v => (formData.config.split_ratio = v)"
/>
<span class="text-12px opacity-60">按页面视觉左右顺序标注{{ formData.config.media_position === 'right' ? '文案 : 素材' : '素材 : 文案' }}默认五五等分移动端小屏始终单列堆叠不受影响</span>
</div>
</NFormItem>
<NFormItem v-if="formData.layout === 'mosaic'" label="拼贴版式">
<div class="w-full flex flex-col gap-8px">
<NRadioGroup :value="(formData.config.mosaic_pattern as string) ?? 'big_3'" @update:value="handleMosaicPatternChange">
@@ -700,10 +759,14 @@ onMounted(loadData);
<template #footer>
<NSpace justify="end">
<NButton @click="dialogVisible = false">取消</NButton>
<NButton type="primary" :loading="submitLoading" @click="handleSubmit">确定</NButton>
<NButton type="success" secondary :loading="submitLoading" @click="handleSubmit(false)">应用</NButton>
<NButton type="primary" :loading="submitLoading" @click="handleSubmit()">确定</NButton>
</NSpace>
</template>
</NModal>
<!-- 参数规格管理spec_table 布局专用 -->
<SpecManager ref="specManagerRef" />
</div>
</template>
@@ -0,0 +1,306 @@
<script setup lang="ts">
import { ref } from 'vue';
import { NButton, NCollapse, NCollapseItem, NEmpty, NForm, NFormItem, NInput, NInputNumber, NModal, NSpace } from 'naive-ui';
import {
fetchCreateSpecGroup,
fetchCreateSpecItem,
fetchDeleteSpecGroup,
fetchDeleteSpecItem,
fetchGetSpecGroups,
fetchSortSpecGroups,
fetchSortSpecItems,
fetchUpdateSpecGroup,
fetchUpdateSpecItem
} from '@/service/api';
/**
* 参数规格管理(spec_table 布局专用)
*
* 管理区块下的参数分组(title_zh/en、默认显示行数)与参数项(中英名称/值),
* 由 SectionManager 操作列的「参数」按钮唤起。
*/
defineOptions({ name: 'SpecManager' });
const visible = ref(false);
const loading = ref(false);
const sectionId = ref<number | null>(null);
const sectionTitle = ref('');
const groups = ref<Api.Www.SpecGroup[]>([]);
const expandedNames = ref<number[]>([]);
async function open(row: Api.Www.SectionBlock) {
sectionId.value = row.id;
sectionTitle.value = row.title_zh || '参数规格';
visible.value = true;
await loadData();
// 默认展开首个分组
expandedNames.value = groups.value.length ? [groups.value[0].id] : [];
}
async function loadData() {
if (!sectionId.value) return;
loading.value = true;
const { data, error } = await fetchGetSpecGroups(sectionId.value);
loading.value = false;
if (!error && data) {
groups.value = data.items || [];
}
}
defineExpose({ open });
// ===== 分组排序 =====
async function moveGroup(index: number, direction: -1 | 1) {
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= groups.value.length || !sectionId.value) return;
const newData = [...groups.value];
[newData[index], newData[swapIndex]] = [newData[swapIndex], newData[index]];
const { error } = await fetchSortSpecGroups(
sectionId.value,
newData.map((g, i) => ({ id: g.id, sort_order: i }))
);
if (!error) {
groups.value = newData;
window.$message?.success('排序已更新');
}
}
// ===== 分组编辑 =====
const groupDialogVisible = ref(false);
const groupSubmitting = ref(false);
const groupForm = ref<{ id: number | null; title_zh: string; title_en: string; default_visible: number }>({
id: null,
title_zh: '',
title_en: '',
default_visible: 5
});
function handleAddGroup() {
groupForm.value = { id: null, title_zh: '', title_en: '', default_visible: 5 };
groupDialogVisible.value = true;
}
function handleEditGroup(group: Api.Www.SpecGroup) {
groupForm.value = {
id: group.id,
title_zh: group.title_zh,
title_en: group.title_en,
default_visible: group.default_visible || 5
};
groupDialogVisible.value = true;
}
async function handleGroupSubmit() {
if (!sectionId.value) return;
if (!groupForm.value.title_zh || !groupForm.value.title_en) {
window.$message?.warning('请填写中英文标题');
return;
}
groupSubmitting.value = true;
const payload = {
title_zh: groupForm.value.title_zh,
title_en: groupForm.value.title_en,
default_visible: groupForm.value.default_visible
};
const { error } = groupForm.value.id
? await fetchUpdateSpecGroup(sectionId.value, groupForm.value.id, payload)
: await fetchCreateSpecGroup(sectionId.value, payload);
groupSubmitting.value = false;
if (!error) {
window.$message?.success(groupForm.value.id ? '更新成功' : '创建成功');
groupDialogVisible.value = false;
await loadData();
}
}
function handleDeleteGroup(group: Api.Www.SpecGroup) {
window.$dialog?.warning({
title: '警告',
content: `确定要删除参数分组「${group.title_zh}」吗?其下所有参数项将一并删除。`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
if (!sectionId.value) return;
const { error } = await fetchDeleteSpecGroup(sectionId.value, group.id);
if (!error) {
window.$message?.success('删除成功');
await loadData();
}
}
});
}
// ===== 参数项编辑 =====
const itemDialogVisible = ref(false);
const itemSubmitting = ref(false);
const itemForm = ref<{ groupId: number; id: number | null; name_zh: string; name_en: string; value_zh: string; value_en: string }>({
groupId: 0,
id: null,
name_zh: '',
name_en: '',
value_zh: '',
value_en: ''
});
function handleAddItem(group: Api.Www.SpecGroup) {
itemForm.value = { groupId: group.id, id: null, name_zh: '', name_en: '', value_zh: '', value_en: '' };
itemDialogVisible.value = true;
}
function handleEditItem(group: Api.Www.SpecGroup, item: Api.Www.SpecItem) {
itemForm.value = {
groupId: group.id,
id: item.id,
name_zh: item.name_zh,
name_en: item.name_en,
value_zh: item.value_zh,
value_en: item.value_en
};
itemDialogVisible.value = true;
}
async function handleItemSubmit() {
const f = itemForm.value;
if (!f.name_zh || !f.name_en || !f.value_zh || !f.value_en) {
window.$message?.warning('请完整填写中英文参数名与参数值');
return;
}
itemSubmitting.value = true;
const payload = { name_zh: f.name_zh, name_en: f.name_en, value_zh: f.value_zh, value_en: f.value_en };
const { error } = f.id ? await fetchUpdateSpecItem(f.id, payload) : await fetchCreateSpecItem(f.groupId, payload);
itemSubmitting.value = false;
if (!error) {
window.$message?.success(f.id ? '更新成功' : '创建成功');
itemDialogVisible.value = false;
await loadData();
}
}
function handleDeleteItem(item: Api.Www.SpecItem) {
window.$dialog?.warning({
title: '警告',
content: `确定要删除参数「${item.name_zh}」吗?`,
positiveText: '确定',
negativeText: '取消',
onPositiveClick: async () => {
const { error } = await fetchDeleteSpecItem(item.id);
if (!error) {
window.$message?.success('删除成功');
await loadData();
}
}
});
}
// ===== 参数项排序 =====
async function moveItem(group: Api.Www.SpecGroup, index: number, direction: -1 | 1) {
const items = [...(group.items || [])];
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= items.length) return;
[items[index], items[swapIndex]] = [items[swapIndex], items[index]];
const { error } = await fetchSortSpecItems(
group.id,
items.map((it, i) => ({ id: it.id, sort_order: i }))
);
if (!error) {
await loadData();
window.$message?.success('排序已更新');
}
}
</script>
<template>
<NModal v-model:show="visible" preset="card" :title="`参数管理:${sectionTitle}`" class="w-900px" :mask-closable="false">
<NSpace justify="space-between" align="center" class="mb-12px">
<span class="text-12px opacity-60">前台按分组顺序渲染参数表默认显示行数之外的参数折叠用户可自行展开</span>
<NButton size="small" type="primary" @click="handleAddGroup">新增分组</NButton>
</NSpace>
<NEmpty v-if="!loading && groups.length === 0" description="暂无参数分组,请先新增分组" class="py-40px" />
<NCollapse v-else :default-expanded-names="expandedNames" arrow-placement="left">
<NCollapseItem v-for="(group, gIdx) in groups" :key="group.id" :name="group.id">
<template #header>
<span class="font-medium">{{ group.title_zh }}</span>
<span class="ml-8px text-12px opacity-60">{{ group.title_en }} · 默认显示 {{ group.default_visible || 5 }} · {{ group.items?.length || 0 }} </span>
</template>
<template #header-extra>
<div class="flex-y-center gap-4px" @click.stop>
<NButton text size="tiny" type="primary" :disabled="gIdx === 0" @click="moveGroup(gIdx, -1)"></NButton>
<NButton text size="tiny" type="primary" :disabled="gIdx === groups.length - 1" @click="moveGroup(gIdx, 1)"></NButton>
<NButton text size="tiny" type="primary" @click="handleEditGroup(group)">编辑</NButton>
<NButton text size="tiny" type="error" @click="handleDeleteGroup(group)">删除</NButton>
</div>
</template>
<div class="flex flex-col gap-8px">
<div
v-for="(item, iIdx) in group.items || []"
:key="item.id"
class="flex items-center gap-12px rounded-8px bg-gray-50 px-12px py-8px dark:bg-black/10"
>
<span class="w-25% shrink-0 text-13px font-medium">{{ item.name_zh }}</span>
<span class="flex-1 truncate text-13px opacity-80" :title="item.value_zh">{{ item.value_zh }}</span>
<span class="hidden w-30% truncate text-12px opacity-50 md:block" :title="`${item.name_en} / ${item.value_en}`">
{{ item.name_en }} / {{ item.value_en }}
</span>
<NSpace :size="4" class="shrink-0">
<NButton text size="tiny" type="primary" :disabled="iIdx === 0" @click="moveItem(group, iIdx, -1)"></NButton>
<NButton text size="tiny" type="primary" :disabled="iIdx === (group.items?.length || 0) - 1" @click="moveItem(group, iIdx, 1)"></NButton>
<NButton text size="tiny" type="primary" @click="handleEditItem(group, item)">编辑</NButton>
<NButton text size="tiny" type="error" @click="handleDeleteItem(item)">删除</NButton>
</NSpace>
</div>
<NButton dashed size="small" @click="handleAddItem(group)">+ 新增参数项</NButton>
</div>
</NCollapseItem>
</NCollapse>
<!-- 分组编辑弹窗 -->
<NModal v-model:show="groupDialogVisible" preset="card" :title="groupForm.id ? '编辑参数分组' : '新增参数分组'" class="w-560px" :mask-closable="false">
<NForm :model="groupForm" label-placement="left" label-width="100">
<NFormItem label="标题(中)" required>
<NInput v-model:value="groupForm.title_zh" maxlength="100" placeholder="如:基本参数" />
</NFormItem>
<NFormItem label="标题(英)" required>
<NInput v-model:value="groupForm.title_en" maxlength="100" placeholder="e.g. Basic Parameters" />
</NFormItem>
<NFormItem label="默认显示行数">
<NInputNumber v-model:value="groupForm.default_visible" :min="1" :max="50" class="w-full" />
<span class="ml-8px text-12px opacity-60">前台默认显示的行数超出的折叠由用户展开</span>
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="groupDialogVisible = false">取消</NButton>
<NButton type="primary" :loading="groupSubmitting" @click="handleGroupSubmit">确定</NButton>
</NSpace>
</template>
</NModal>
<!-- 参数项编辑弹窗 -->
<NModal v-model:show="itemDialogVisible" preset="card" :title="itemForm.id ? '编辑参数项' : '新增参数项'" class="w-640px" :mask-closable="false">
<NForm :model="itemForm" label-placement="left" label-width="100">
<NFormItem label="参数名(中)" required>
<NInput v-model:value="itemForm.name_zh" maxlength="100" placeholder="如:型号" />
</NFormItem>
<NFormItem label="参数名(英)" required>
<NInput v-model:value="itemForm.name_en" maxlength="100" placeholder="e.g. Model" />
</NFormItem>
<NFormItem label="参数值(中)" required>
<NInput v-model:value="itemForm.value_zh" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="如:Luxsin X8" />
</NFormItem>
<NFormItem label="参数值(英)" required>
<NInput v-model:value="itemForm.value_en" type="textarea" :autosize="{ minRows: 1, maxRows: 4 }" placeholder="e.g. Luxsin X8" />
</NFormItem>
</NForm>
<template #footer>
<NSpace justify="end">
<NButton @click="itemDialogVisible = false">取消</NButton>
<NButton type="primary" :loading="itemSubmitting" @click="handleItemSubmit">确定</NButton>
</NSpace>
</template>
</NModal>
</NModal>
</template>