通知板块 及 功能调整

This commit is contained in:
浅念
2025-08-22 18:27:26 +08:00
parent b319944007
commit 329acaf68d
22 changed files with 1771 additions and 218 deletions
+25
View File
@@ -175,6 +175,15 @@
>
<i class="iconfont-sys"> &#xe64e;</i>跟进记录
</el-link>
<el-link
type="danger"
underline="never"
style="margin-right: 8px"
@click="delectfn(scope.row)"
v-if="hasAuth('customer@delete')"
>
<i class="iconfont-sys">&#xe6d4;</i>删除
</el-link>
</template>
</el-table-column>
</template>
@@ -555,6 +564,22 @@
plzyDialogVisible.value = false
gettableList()
}
const delectfn = (row: any) => {
ElMessageBox.confirm('确定要删除此客户吗?删除后将无法恢复!', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await manageService.crmCustomerDelete({ customer_id: row.id, scene: 'list' })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
</script>
<style scoped lang="scss">
+573
View File
@@ -0,0 +1,573 @@
<template>
<div class="customer-page">
<!-- 顶部操作栏 -->
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
<!-- <el-button type="primary" @click="exportfn" v-if="hasAuth('customer@export')">
<i class="iconfont-sys">&#xe89f;</i> 导出
</el-button> -->
</div>
<div class="boxshadow">
<!-- 表格 -->
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@selection-change="handleSelectionChange"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
lass="customer-table"
>
<template #default>
<el-table-column type="selection" width="55px" align="center"></el-table-column>
<el-table-column
prop="code"
label="编号"
align="center"
width="170px"
show-overflow-tooltip
/>
<el-table-column prop="contacts" label="客户名称" align="center" />
<el-table-column prop="magnitude_txt" label="客户商机" align="center" />
<el-table-column prop="channel_name" label="渠道" align="center" />
<el-table-column prop="country_name" label="国家" align="center" />
<!-- <el-table-column prop="phone" label="电话" align="center" />
<el-table-column prop="WhatsApp" label="WhatsApp" align="center" />
<el-table-column prop="email" label="邮箱" align="center" /> -->
<el-table-column prop="follow" label="联系方式" width="240px" show-overflow-tooltip>
<template #default="scope">
<div
>WhatsApp:{{
scope.row.WhatsApp == '' ? ' --' : '【' + scope.row.WhatsApp + ' 】'
}}</div
>
<div>电话:{{ scope.row.phone == '' ? ' --' : '【' + scope.row.phone + ' 】' }}</div>
<div>邮箱:{{ scope.row.email == '' ? ' --' : '【' + scope.row.email + ' 】' }}</div>
</template>
</el-table-column>
<el-table-column prop="grade_txt" label="等级" align="center" />
<!-- <el-table-column prop="follow" label="状态" align="center">
<template #default="scope">
<span :class="statusClass(scope.row.follow)">
{{ scope.row.follow_txt }}
</span>
</template>
</el-table-column> -->
<el-table-column prop="enter_user_name" label="录单人"></el-table-column>
<el-table-column prop="user_name" label="跟单人"></el-table-column>
<el-table-column prop="del_source" label="来源"></el-table-column>
<el-table-column prop="follow_last" label="上次跟进"></el-table-column>
<el-table-column prop="follow_next" label="下次跟进"></el-table-column>
<el-table-column prop="created_at" label="创建时间"></el-table-column>
<el-table-column fixed="right" label="操作" width="200px" align="center">
<template #default="scope">
<el-link
type="success"
underline="never"
style="margin-right: 8px"
@click="onDetail(scope.row)"
v-if="hasAuth('recycle@detail')"
>
<i class="iconfont-sys"> &#xe70f;</i>详情
</el-link>
<el-link
type="primary"
underline="never"
style="margin-right: 8px"
@click="recoverfn(scope.row)"
v-if="hasAuth('recycle@recover')"
>
<i class="iconfont-sys"> &#xe794;</i>恢复
</el-link>
<el-link
type="danger"
underline="never"
style="margin-right: 8px"
@click="delectfn(scope.row)"
v-if="hasAuth('recycle@delete')"
>
<i class="iconfont-sys">&#xe6d4;</i>删除
</el-link>
</template>
</el-table-column>
</template>
</ArtTable>
</div>
</div>
<!-- 详情对话框 -->
<el-dialog v-model="detailDialogVisible" title="详情" width="800px">
<div class="detail-info-table">
<div class="row">
<div>客户名称:{{ detailRow.contacts }}</div>
<div>电话:{{ detailRow.phone }}</div>
<div>国家:{{ detailRow.country_name }}</div>
</div>
<div class="row">
<div>渠道:{{ detailRow.channel_name }}</div>
<div>邮箱:{{ detailRow.email }}</div>
<div>WhatsApp:{{ detailRow.WhatsApp }}</div>
</div>
<div class="row">
<div>发布人:{{ detailRow.enter_user_name }}</div>
<div>跟单人:{{ detailRow.user_name }}</div>
<div>录单时间:{{ detailRow.created_at }}</div>
</div>
<div class="row">
<div>
等级:
<span>
{{ detailRow.magnitude_txt }}
</span>
</div>
<div>
状态:<span>{{ detailRow.grade_txt }}</span>
</div>
<div>
跟进状态:<span>{{ detailRow.follow_txt }}</span>
</div>
</div>
<div class="row">
<div>备注:{{ detailRow.describe }}</div>
</div>
</div>
<div v-if="detailRow.desc_collect != ''" class="dtitle">详情内容</div>
<div v-if="detailRow" v-html="detailRow.desc_collect" class="detils-content"></div>
</el-dialog>
<!-- 图片预览对话框 -->
<el-image-viewer
v-if="imageViewerVisible"
:url-list="imageList"
:initial-index="imageViewerIndex"
@close="closeImageViewer"
/>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
const selectValue = ref<string[]>([])
const loading = ref(false)
const detailDialogVisible = ref(false)
const zyDialogVisible = ref(false)
const detailRow = ref<any>(null)
const keyword = ref<string>('')
// 图片预览相关
const imageViewerVisible = ref(false)
const imageList = ref<string[]>([])
const imageViewerIndex = ref(0)
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
const zyForm = reactive({
customer_id: '',
user_id: ''
})
const userOptions = ref<any>()
const getuserList = async () => {
// 这里调用获取用户列表的接口
const res = await manageService.presetusers()
userOptions.value = res || []
// return res || []
}
// 表格数据
const tableData = ref<any[]>([])
onMounted(() => {
getuserList()
gettableList()
})
// tabs 切换联动
watch([keyword], () => {
pagination.currentPage = 1
gettableList()
})
const gettableList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const params: any = {
current: currentPage,
size: pageSize,
scene: 'recyclea'
}
if (keyword.value) {
params.keyword = keyword.value
}
const { data, current, size, total } = await manageService.getcrmCustomerList(params)
tableData.value = data.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取用户列表失败:', error)
} finally {
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
gettableList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
gettableList()
}
//导出
function processImages(html: string) {
const parser = new DOMParser()
const doc = parser.parseFromString(html, 'text/html')
doc.querySelectorAll('img').forEach((img) => {
img.style.maxWidth = '100%'
img.style.height = 'auto'
})
return doc.body.innerHTML
}
function onDetail(row: any) {
row.desc_collect = processImages(row.desc_collect) // 处理图片
detailRow.value = row
detailDialogVisible.value = true
}
const recoverfn = (row: any) => {
ElMessageBox.confirm('确定要恢复此条数据吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await manageService.customerRestore({ id: row.id })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
const delectfn = (row: any) => {
ElMessageBox.confirm('确定要删除此客户吗?删除后将无法恢复!', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
const res = await manageService.crmCustomerDelete({ customer_id: row.id, scene: 'force' })
ElMessage.success(res?.message || '提交成功!')
gettableList()
// 你可以在这里调用接口或做后续处理
})
.catch(() => {
// 用户点击了取消
})
}
const closeImageViewer = () => {
imageViewerVisible.value = false
imageList.value = []
imageViewerIndex.value = 0
}
// 选中的行数据
const selectedRows = ref<any[]>([])
// 处理表格行选择变化
const handleSelectionChange = (selection: any[]) => {
selectedRows.value = selection
}
</script>
<style scoped lang="scss">
.timeline-img {
width: 180px;
cursor: pointer;
transition: transform 0.2s ease;
&:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
}
:deep(.el-badge) {
--el-badge-size: 16px;
}
.itemtag {
margin-top: -16px;
margin-left: -13px;
}
:deep(.el-table__header th) {
background: #f7f9fa !important;
color: #333;
font-weight: bold;
}
:deep(.el-table__empty-block) {
height: 450px !important;
}
:deep(.el-table__body tr.custom-table-row td) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__body tr td:last-child) {
border-right: none !important;
}
:deep(.el-table__body tr:last-child td) {
border-bottom: none !important;
}
:deep(.el-table__header-wrapper th) {
border-bottom: 1px solid #f7f9fa !important;
border-right: 1px solid #f7f9fa !important;
}
:deep(.el-table__header-wrapper th:last-child) {
border-right: none !important;
}
:deep(.el-table) {
border: 1px solid #f7f9fa !important;
border-radius: 6px;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.customer-tags {
background: var(--art-main-bg-color);
border-radius: 8px;
padding: 16px 20px;
margin-bottom: 16px;
}
.filter-row {
display: flex;
align-items: center;
margin-bottom: 14px;
}
.filter-label {
width: 120px;
color: #666;
margin-right: 8px;
flex-shrink: 0;
font-size: 14px;
}
.filter-tags {
flex: 1;
color: #333;
font-weight: 500;
font-size: 14px;
}
.customer-tabs {
margin-bottom: 8px;
}
.status-following {
color: #00a43f;
font-weight: bold;
}
.status-dealed {
color: #3471ff;
font-weight: bold;
}
.status-lost {
color: #ff0000;
font-weight: bold;
}
.customer-table {
background: var(--art-main-bg-color);
border-radius: 8px;
margin-top: 0;
}
.filter-more-btn {
text-align: center;
margin-top: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.tag-items {
cursor: pointer;
border-radius: 4px;
padding: 5px 10px;
transition: background 0.2s;
min-width: 100px;
display: inline-block;
margin-right: 30px;
}
.tag-item {
border-radius: 4px;
padding: 5px 10px;
}
.tag-itemall {
border-radius: 4px;
padding: 5px 10px;
margin-right: 20px;
}
.tag-active {
background: #3471ff;
color: #fff;
}
.tag-active :deep(.el-badge__content) {
color: #3471ff !important; /* 文字颜色 */
background-color: #fff !important; /* 背景色 */
}
.detail-info-table {
padding-left: 15px;
.row {
display: flex;
justify-content: flex-start;
margin-bottom: 6px;
> div {
flex: 1;
min-width: 0;
margin-right: 16px;
overflow: hidden;
font-size: 14px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.dtitle {
line-height: 40px;
color: #333;
font-size: 16px;
font-weight: bold;
padding-left: 15px;
}
.detils-content {
padding-left: 15px;
img {
max-width: 100% !important;
}
}
.follow-record-header {
padding: 15px;
background-color: #f5f7fa;
border-radius: 4px;
margin-bottom: 15px;
.customer-info {
display: flex;
align-items: center;
.label {
font-size: 14px;
color: #606266;
margin-right: 5px;
}
.value {
font-size: 14px;
color: #303133;
font-weight: bold;
}
}
}
.follow-record-list {
padding: 15px;
max-height: 500px;
overflow-y: auto;
.list-title {
font-size: 16px;
font-weight: bold;
color: #303133;
margin-bottom: 15px;
padding-left: 10px;
}
.record-item {
background-color: #f5f7fa;
border-radius: 4px;
padding: 10px 15px;
margin-bottom: 10px;
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
.record-type {
font-size: 14px;
color: #3471ff;
font-weight: bold;
background-color: #e6f7ff;
padding: 2px 8px;
border-radius: 4px;
}
.record-time {
font-size: 14px;
color: #909399;
}
.record-user {
font-size: 14px;
color: #3471ff;
font-weight: bold;
}
}
.record-content {
font-size: 14px;
color: #303133;
line-height: 1.6;
margin-bottom: 5px;
}
.record-footer {
.next-follow {
font-size: 14px;
color: #909399;
}
}
}
.no-records {
text-align: center;
color: #909399;
padding: 20px 0;
}
}
</style>
+11 -1
View File
@@ -15,6 +15,8 @@
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="ID" prop="id" />
@@ -123,7 +125,15 @@
onMounted(() => {
getWorkList()
})
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
// 获取列表数据
const getWorkList = async () => {
loading.value = true
+11 -1
View File
@@ -21,6 +21,8 @@
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="ID" prop="id" width="80" />
@@ -209,7 +211,6 @@
getcompanyList()
getsystemsList()
})
// 获取列表数据
const getWorkList = async () => {
loading.value = true
@@ -230,6 +231,15 @@
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
const dialogType = ref('add')
-1
View File
@@ -496,7 +496,6 @@
{ required: true, message: '请输入菜单名称', trigger: 'blur' },
{ min: 2, max: 20, message: '长度在 2 到 20 个字符', trigger: 'blur' }
],
system_id: [{ required: true, message: '请选择系统', trigger: 'blur' }],
type: [{ required: true, message: '请选择资源类型', trigger: 'blur' }],
code: [{ required: true, message: '输入权限标识', trigger: 'blur' }]
})
+266
View File
@@ -0,0 +1,266 @@
<template>
<div class="customer-page">
<div class="customer-toolbar boxshadow">
<div class="customer-search-group">
<el-form-item label="关键字:" label-width="70px">
<el-input style="min-width: 120px" v-model="keyword" placeholder="请输入关键字" />
</el-form-item>
</div>
</div>
<div class="boxshadow">
<ElRow>
<div style="width: 12px"></div>
<ElCol :xs="24" :sm="12" :lg="6" class="el-col2">
<ElButton @click="showDialog('add')" v-ripple v-if="hasAuth('system@notice_add')"
>新增</ElButton
>
</ElCol>
</ElRow>
<ArtTable
ref="tableRef"
row-key="id"
:loading="loading"
:data="tableData"
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="编号" prop="msg_no" width="240px" />
<ElTableColumn label="通知标题" prop="title" />
<ElTableColumn label="通知内容" prop="content" />
<ElTableColumn label="推送类型" prop="push_method_name" />
<ElTableColumn label="场景" prop="scene_name" />
<ElTableColumn label="创建时间" prop="created_at" />
<ElTableColumn fixed="right" label="操作" width="200px">
<template #default="scope">
<ElRow>
<el-button link @click="showfn(scope.row)"> 详情 </el-button>
<el-button
type="danger"
link
@click="deleteRole(scope.row)"
v-if="hasAuth('system@notice_destroy')"
>
删除
</el-button>
</ElRow>
</template>
</ElTableColumn>
</template>
</ArtTable>
</div>
<ElDialog v-model="dialogVisible" title="新增通知" align-center>
<ElForm ref="formRef" :model="form" :rules="rules" label-width="80px" class="form">
<ElFormItem label="通知标题" prop="title">
<ElInput v-model="form.title" />
</ElFormItem>
<ElFormItem label="通知内容" prop="content">
<ElInput v-model="form.content" type="textarea" :rows="3" placeholder="请输入通知内容" />
</ElFormItem>
<ElFormItem label="通知用户" prop="recipient">
<el-select
v-model="form.recipient"
multiple
filterable
placeholder="请选择要通知的用户"
style="width: 100%"
>
<el-option
v-for="item in companyUserList"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
<el-text class="mx-1" type="warning">不选择用户,代表通知所有用户!</el-text>
</ElFormItem>
</ElForm>
<template #footer>
<div class="dialog-footer">
<ElButton @click="dialogVisible = false">取消</ElButton>
<ElButton type="primary" @click="handleSubmit(formRef)">提交</ElButton>
</div>
</template>
</ElDialog>
<ElDialog v-model="dialogVisiblexq" title="通知详情" align-center>
<div class="detail-info-table">
<div class="row">
<div>发送人:{{ noticeDetail.sender_name }}</div>
</div>
<div class="row">
<div>通知标题:{{ noticeDetail.title }}</div>
</div>
<div class="row">
<div>
通知内容:
<div class="message-text" v-html="noticeDetail.content"></div>
</div>
</div>
</div>
</ElDialog>
</div>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from 'element-plus'
import type { FormInstance, FormRules } from 'element-plus'
import { systemService } from '@/api/systemApi'
import { manageService } from '@/api/adminApi'
import { useAuth } from '@/composables/useAuth'
const { hasAuth } = useAuth()
defineOptions({ name: 'Type ' })
const loading = ref(false)
const dialogVisible = ref(false)
const dialogVisiblexq = ref(false)
const formRef = ref<FormInstance>()
const { width } = useWindowSize()
const keyword = ref<string>('')
const isMobile = computed(() => width.value < 500)
const rules = reactive<FormRules>({
name: [{ required: true, message: '请输入公司名称', trigger: 'blur' }],
abbr: [{ required: true, message: '请输入公司简称', trigger: 'blur' }]
// tenant_key: [{ required: true, message: '请输入飞书标识', trigger: 'blur' }]
})
const companyUserList = ref<any>()
// 获取用户列表
const getCompanyUserList = async () => {
const response = await manageService.presetusers()
companyUserList.value = response || []
}
const form = reactive<any>({
title: '',
content: '',
recipient: []
})
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0
})
// 表格数据
const tableData = ref<any[]>([])
// 表格实例引用
const tableRef = ref()
onMounted(() => {
getWorkList()
})
watch([keyword], () => {
pagination.currentPage = 1
getWorkList()
})
const noticeDetail = ref<any>({})
const showfn = async (row: any) => {
dialogVisiblexq.value = true
await systemService.getNoticeDetail({ id: row.id }).then((res: any) => {
noticeDetail.value = res
console.log(res)
})
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
// 获取列表数据
const getWorkList = async () => {
loading.value = true
try {
const { currentPage, pageSize } = pagination
const { records, current, size, total } = await systemService.getNoticeList({
current: currentPage,
size: pageSize
})
tableData.value = records.map((item: any) => ({
...item
}))
Object.assign(pagination, { currentPage: current, pageSize: size, total })
} catch (error) {
console.error('获取列表失败:', error)
} finally {
loading.value = false
}
}
const dialogType = ref('add')
const showDialog = (type: string) => {
getCompanyUserList()
dialogVisible.value = true
dialogType.value = type
form.title = ''
form.content = ''
form.recipient = []
}
const deleteRole = (row?: any) => {
ElMessageBox.confirm('确定删除吗?', '删除确认', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'error'
}).then(async () => {
const { message } = await systemService.delNotice({
id: row.id
})
ElMessage.success(message)
getWorkList()
})
}
const handleSubmit = async (formEl: FormInstance | undefined) => {
if (!formEl) return
const { message } = await systemService.sendNotice(form)
ElMessage.success(message)
dialogVisible.value = false
getWorkList()
}
</script>
<style lang="scss" scoped>
:deep(.el-table__empty-block) {
height: 100% !important;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
}
.customer-page {
background: var(--art-main-bg-color);
min-height: 100vh;
}
.page-content {
.svg-icon {
width: 1.8em;
height: 1.8em;
overflow: hidden;
vertical-align: -8px;
fill: currentcolor;
}
}
.customer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
padding: 20px 20px;
background: var(--art-main-bg-color);
}
.customer-search-group {
display: flex;
gap: 12px;
}
.message-text {
padding: 10px 14px;
line-height: 1.4;
white-space: pre-line;
}
</style>
+11 -1
View File
@@ -20,6 +20,8 @@
:currentPage="pagination.currentPage"
:pageSize="pagination.pageSize"
:total="pagination.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
>
<template #default>
<ElTableColumn label="ID" prop="id" width="80" />
@@ -236,7 +238,15 @@
loading.value = false
}
}
// 处理表格分页变化
const handleSizeChange = (newPageSize: number) => {
pagination.pageSize = newPageSize
getWorkList()
}
const handleCurrentChange = (newCurrentPage: number) => {
pagination.currentPage = newCurrentPage
getWorkList()
}
// 系统类型相关辅助函数
const getTypeText = (type: string) => {
const typeMap: { [key: string]: string } = {