Files
mp-10579/src/views/credit/creditMpCustomer/index.vue
赵忠林 ce3f22f4ae feat(credit): 添加客户列表跟进人字段显示
- 在表格列配置中增加跟进人列定义
- 设置跟进人列的数据索引为 realName
- 配置跟进人列宽度为 120px 并居中对齐
- 将 realName 字段添加到列表查询参数中
2026-03-19 17:35:57 +08:00

590 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<a-page-header :title="getPageTitle()" @back="() => $router.go(-1)">
<a-card :bordered="false" :body-style="{ padding: '16px' }">
<ele-pro-table
ref="tableRef"
row-key="id"
:columns="columns"
:datasource="datasource"
:customRow="customRow"
v-model:selection="selection"
tool-class="ele-toolbar-form"
class="sys-org-table"
>
<template #toolbar>
<search
@search="reload"
:selection="selection"
@add="openEdit"
@remove="removeBatch"
@batchMove="openMove"
@exportData="exportData"
/>
</template>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'userInfo'">
<div class="user-info">
<a-avatar :size="42" :src="record.avatar">
<template v-if="!record.avatar" #icon>
<UserOutlined/>
</template>
</a-avatar>
<div class="user-details">
<h4 class="username">{{ record.nickname }}</h4>
<p class="user-phone">{{ record.phone }}</p>
</div>
</div>
</template>
<template v-if="column.key === 'image'">
<a-image :src="record.image" :width="50"/>
</template>
<template v-if="column.key === 'files'">
<a-space :size="8" style="flex-wrap: wrap">
<template v-for="(file, index) in normalizeFiles(record.files)" :key="`${file.url}-${index}`">
<a-image
v-if="file.isImage"
:src="file.thumbnail || file.url"
:width="50"
:preview="{ src: file.url }"
/>
<a
v-else
:href="sanitizeFileUrl(file.url, file.isImage)"
target="_blank"
rel="noopener noreferrer"
>
{{ file.name || `附件${index + 1}` }}
</a>
</template>
<span v-if="!normalizeFiles(record.files).length">-</span>
</a-space>
</template>
<template v-if="column.key === 'step'">
<a-tag :color="getStepColor(record.step)">
{{ getStepText(record.step) }}
</a-tag>
</template>
<template v-if="column.key === 'status'">
<a-tag v-if="record.status === 0" color="green">显示</a-tag>
<a-tag v-if="record.status === 1" color="red">隐藏</a-tag>
</template>
<template v-if="column.key === 'action'">
<a @click="openEdit(record)">修改</a>
<a-divider type="vertical"/>
<a-popconfirm
title="确定要删除此记录吗?"
@confirm="remove(record)"
>
<a class="ele-text-danger">删除</a>
</a-popconfirm>
</template>
</template>
</ele-pro-table>
</a-card>
<!-- 编辑弹窗 -->
<CreditMpCustomerEdit v-model:visible="showEdit" :data="current" @done="reload"/>
</a-page-header>
</template>
<script lang="ts" setup>
import {createVNode, ref, computed} from 'vue';
import {message, Modal} from 'ant-design-vue';
import {ExclamationCircleOutlined, UserOutlined} from '@ant-design/icons-vue';
import type {EleProTable} from 'ele-admin-pro';
import {toDateString} from 'ele-admin-pro';
import type {
DatasourceFunction,
ColumnItem
} from 'ele-admin-pro/es/ele-pro-table/types';
import Search from './components/search.vue';
import {getPageTitle} from '@/utils/common';
import CreditMpCustomerEdit from './components/creditMpCustomerEdit.vue';
import {
pageCreditMpCustomer,
listCreditMpCustomer,
removeCreditMpCustomer,
removeBatchCreditMpCustomer
} from '@/api/credit/creditMpCustomer';
import type {CreditMpCustomer, CreditMpCustomerParam} from '@/api/credit/creditMpCustomer/model';
import {exportCreditData} from '../utils/export';
import { stripOssImageProcess } from '@/utils/common';
type NormalizedFile = {
name?: string;
url: string;
thumbnail?: string;
isImage: boolean;
};
// 表格实例
const tableRef = ref<InstanceType<typeof EleProTable> | null>(null);
// 表格选中数据
const selection = ref<CreditMpCustomer[]>([]);
// 当前编辑数据
const current = ref<CreditMpCustomer | null>(null);
// 是否显示编辑弹窗
const showEdit = ref(false);
// 是否显示批量移动弹窗
const showMove = ref(false);
// 加载状态
const loading = ref(true);
// 搜索关键词
const searchText = ref('');
const searchStep = ref<number | undefined>(undefined);
// 表格数据源
const datasource: DatasourceFunction = ({
page,
limit,
where = {},
orders,
filters
}) => {
const params: CreditMpCustomerParam = {...(where as CreditMpCustomerParam)};
if (filters) {
const filterStatus = (filters as any).status;
if (Array.isArray(filterStatus)) {
if (filterStatus.length) {
(params as any).status = filterStatus[0];
}
} else if (filterStatus !== undefined && filterStatus !== null) {
(params as any).status = filterStatus;
}
const stepFilter = (filters as any).step;
if (Array.isArray(stepFilter)) {
if (stepFilter.length) {
(params as any).step = stepFilter[0];
}
} else if (stepFilter !== undefined && stepFilter !== null) {
(params as any).step = stepFilter;
}
}
return pageCreditMpCustomer({
...params,
...orders,
page,
limit
});
};
const stepOptions = [
{ value: 0, text: '未受理', color: 'default' },
{ value: 1, text: '已受理', color: 'blue' },
{ value: 2, text: '材料提交', color: 'cyan' },
{ value: 3, text: '合同签订', color: 'purple' },
{ value: 4, text: '执行回款', color: 'orange' },
{ value: 5, text: '完结', color: 'green' }
] as const;
const getStepText = (step: unknown) => {
const value = typeof step === 'string' ? Number(step) : (step as number | undefined);
const match = stepOptions.find((d) => d.value === value);
return match?.text ?? '-';
};
const getStepColor = (step: unknown) => {
const value = typeof step === 'string' ? Number(step) : (step as number | undefined);
const match = stepOptions.find((d) => d.value === value);
return match?.color ?? 'default';
};
const isImageUrl = (url: string) => {
const cleanUrl = url.split('?')[0] ?? '';
return /\.(png|jpe?g|gif|webp|bmp|svg)$/i.test(cleanUrl);
};
const guessNameFromUrl = (url: string) => {
const cleanUrl = (url.split('?')[0] ?? '').trim();
const last = cleanUrl.split('/').pop() || '';
try {
return decodeURIComponent(last) || url;
} catch {
return last || url;
}
};
const tryParseJson = (value: string) => {
try {
return JSON.parse(value);
} catch {
return undefined;
}
};
const sanitizeFileUrl = (url: string, isImage: boolean) => {
return isImage ? url : (stripOssImageProcess(url) as string);
};
const filesCache = new Map<string, NormalizedFile[]>();
const normalizeFiles = (raw: unknown): NormalizedFile[] => {
if (!raw) return [];
if (Array.isArray(raw)) {
return raw
.map((item: any) => {
if (!item) return null;
if (typeof item === 'string') {
const url = sanitizeFileUrl(item, isImageUrl(item));
return {
url,
thumbnail: url,
name: guessNameFromUrl(url),
isImage: isImageUrl(url)
} satisfies NormalizedFile;
}
const rawUrl = item.url || item.path || item.href;
const url = typeof rawUrl === 'string' ? rawUrl : undefined;
if (!url) return null;
const thumbnail = typeof item.thumbnail === 'string' ? item.thumbnail : undefined;
const name = typeof item.name === 'string' ? item.name : guessNameFromUrl(url);
const isImage = typeof item.isImage === 'boolean' ? item.isImage : isImageUrl(url);
return {url: sanitizeFileUrl(url, isImage), thumbnail, name, isImage} satisfies NormalizedFile;
})
.filter(Boolean) as NormalizedFile[];
}
if (typeof raw === 'string') {
const text = raw.trim();
if (!text) return [];
const cached = filesCache.get(text);
if (cached) return cached;
// 兼容:后端返回 JSON 数组字符串(示例:"[{\"name\":\"...\",\"url\":\"...\"}]")
let parsed: any = tryParseJson(text);
if (typeof parsed === 'string') {
parsed = tryParseJson(parsed) ?? parsed;
}
if (Array.isArray(parsed)) {
const result = normalizeFiles(parsed);
if (filesCache.size > 500) filesCache.clear();
filesCache.set(text, result);
return result;
}
// 兜底:单个 url 或逗号分隔 url
const parts = text.includes(',') ? text.split(',') : [text];
const result = normalizeFiles(parts.map((p) => p.trim()).filter(Boolean));
if (filesCache.size > 500) filesCache.clear();
filesCache.set(text, result);
return result;
}
return [];
};
// 完整的列配置(包含所有字段)
const allColumns = ref<ColumnItem[]>([
{
title: 'ID',
dataIndex: 'id',
key: 'id',
width: 90,
},
{
title: '用户信息',
dataIndex: 'userInfo',
key: 'userInfo',
width: 240,
},
{
title: '拖欠方',
dataIndex: 'toUser',
key: 'toUser'
},
{
title: '拖欠金额',
dataIndex: 'price',
key: 'price',
width: 120,
align: 'center',
customRender: ({ text }) => '¥' + text
},
{
title: '拖欠年数',
dataIndex: 'years',
key: 'years',
align: 'center'
},
// {
// title: '链接',
// dataIndex: 'url',
// key: 'url',
// ellipsis: true
// },
// {
// title: '状态',
// dataIndex: 'statusTxt',
// key: 'statusTxt'
// },
// {
// title: '所在省份',
// dataIndex: 'province',
// key: 'province',
// ellipsis: true
// },
{
title: '所在城市',
dataIndex: 'city',
key: 'city',
align: 'center'
},
{
title: '步骤',
dataIndex: 'step',
key: 'step',
width: 120,
align: 'center',
filters: stepOptions.map((d) => ({ text: d.text, value: d.value }))
},
// {
// title: '所在辖区',
// dataIndex: 'region',
// key: 'region',
// ellipsis: true
// },
{
title: '附件',
dataIndex: 'files',
key: 'files',
align: 'center'
},
// {
// title: '是否有数据',
// dataIndex: 'hasData',
// key: 'hasData',
// width: 120
// },
// {
// title: '备注',
// dataIndex: 'comments',
// key: 'comments',
// ellipsis: true
// },
// {
// title: '是否推荐',
// dataIndex: 'recommend',
// key: 'recommend',
// width: 120
// },
{
title: '排序',
dataIndex: 'sortNumber',
key: 'sortNumber',
width: 120
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 120
},
{
title: '创建时间',
dataIndex: 'createTime',
key: 'createTime',
width: 200,
align: 'center',
sorter: true,
ellipsis: true,
customRender: ({text}) => toDateString(text, 'yyyy-MM-dd HH:mm:ss')
},
{
title: '跟进人',
dataIndex: 'realName',
key: 'realName',
width: 120,
align: 'center'
},
{
title: '操作',
key: 'action',
width: 120,
fixed: 'right',
align: 'center',
hideInSetting: true
}
]);
// 默认显示的核心列最多5个主要字段
const defaultVisibleColumns = [
'id',
'userInfo',
'toUser',
'price',
'years',
'city',
'realName',
'step',
'files',
// 'status',
'createTime',
'action'
];
// 根据默认可见列过滤显示的列
const columns = computed(() => {
return allColumns.value.filter(col =>
defaultVisibleColumns.includes(col.dataIndex) || col.key === 'action'
);
});
/* 搜索 */
const reload = (where?: CreditMpCustomerParam) => {
if (where && Object.prototype.hasOwnProperty.call(where, 'keywords')) {
searchText.value = where.keywords ?? '';
}
if (where && Object.prototype.hasOwnProperty.call(where, 'step')) {
searchStep.value = where.step;
}
const targetWhere = where ?? {keywords: searchText.value || undefined, step: searchStep.value};
selection.value = [];
tableRef?.value?.reload({where: targetWhere});
};
/* 打开编辑弹窗 */
const openEdit = (row?: CreditMpCustomer) => {
current.value = row ?? null;
showEdit.value = true;
};
/* 打开批量移动弹窗 */
const openMove = () => {
showMove.value = true;
};
/* 导出 */
const exportData = () => {
exportCreditData<CreditMpCustomer>({
filename: '小程序端客户列表',
includeCompanyName: false,
columns: [
{title: 'ID', dataIndex: 'id'},
{title: '拖欠方', dataIndex: 'toUser'},
{title: '拖欠金额', dataIndex: 'price'},
{title: '拖欠年数', dataIndex: 'years'},
{title: '链接', dataIndex: 'url'},
{title: '状态', dataIndex: 'statusTxt'},
{title: '所在省份', dataIndex: 'province'},
{title: '所在城市', dataIndex: 'city'},
{title: '所在辖区', dataIndex: 'region'},
{title: '步骤', dataIndex: 'step'},
{title: '文件路径', dataIndex: 'files'},
{title: '是否有数据', dataIndex: 'hasData'},
{title: '备注', dataIndex: 'comments'},
{title: '是否推荐', dataIndex: 'recommend'},
{title: '排序(数字越小越靠前)', dataIndex: 'sortNumber'},
{title: '状态, 0正常, 1冻结', dataIndex: 'status'},
{title: '是否删除, 0否, 1是', dataIndex: 'deleted'},
{title: '用户ID', dataIndex: 'userId'},
{title: '创建时间', dataIndex: 'createTime'},
{title: '修改时间', dataIndex: 'updateTime'}
],
fetchData: () =>
listCreditMpCustomer({
keywords: searchText.value || undefined,
step: searchStep.value
})
});
};
/* 删除单个 */
const remove = (row: CreditMpCustomer) => {
const hide = message.loading('请求中..', 0);
removeCreditMpCustomer(row.id)
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
};
/* 批量删除 */
const removeBatch = () => {
if (!selection.value.length) {
message.error('请至少选择一条数据');
return;
}
Modal.confirm({
title: '提示',
content: '确定要删除选中的记录吗?',
icon: createVNode(ExclamationCircleOutlined),
maskClosable: true,
onOk: () => {
const hide = message.loading('请求中..', 0);
removeBatchCreditMpCustomer(selection.value.map((d) => d.id))
.then((msg) => {
hide();
message.success(msg);
reload();
})
.catch((e) => {
hide();
message.error(e.message);
});
}
});
};
/* 查询 */
const query = () => {
loading.value = true;
};
/* 自定义行属性 */
const customRow = (record: CreditMpCustomer) => {
return {
// 行点击事件
onClick: () => {
// console.log(record);
},
// 行双击事件
onDblclick: () => {
openEdit(record);
}
};
};
query();
</script>
<script lang="ts">
export default {
name: 'CreditMpCustomer'
};
</script>
<style lang="less" scoped>
.user-info {
display: flex;
align-items: center;
padding: 6px;
.user-details {
margin-left: 6px;
flex: 1;
.username {
margin: 0;
color: #333;
font-size: 16px;
font-weight: 500;
}
.user-phone {
margin: 0;
color: #666;
font-size: 14px;
}
}
}
</style>