464 lines
14 KiB
Vue
464 lines
14 KiB
Vue
<template>
|
||
<div class="crm-page">
|
||
<div class="page-header">
|
||
<div>
|
||
<h2>联系人管理</h2>
|
||
<p>统一管理客户与供应商的对接人,支持设置主联系人</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
|
||
<el-button type="primary" :icon="Plus" @click="openCreate">新增联系人</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filter-bar">
|
||
<el-form :inline="true" :model="filters" @submit.prevent>
|
||
<el-form-item>
|
||
<el-input
|
||
v-model="filters.keyword"
|
||
clearable
|
||
placeholder="搜索姓名 / 手机号 / 邮箱"
|
||
:prefix-icon="Search"
|
||
style="width: 240px"
|
||
@keyup.enter="handleSearch"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="关联类型">
|
||
<el-select v-model="filters.related_type" clearable placeholder="全部" style="width: 120px">
|
||
<el-option
|
||
v-for="item in RELATED_TYPE_OPTIONS"
|
||
:key="item.value"
|
||
:label="item.label"
|
||
:value="item.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="主联系人">
|
||
<el-select v-model="filters.is_primary" clearable placeholder="全部" style="width: 110px">
|
||
<el-option label="是" :value="1" />
|
||
<el-option label="否" :value="0" />
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
|
||
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
</div>
|
||
|
||
<div class="letter-bar">
|
||
<span
|
||
v-for="item in letterBar"
|
||
:key="item"
|
||
class="letter-item"
|
||
:class="{ active: activeLetter === item, empty: item !== '全部' && !letterCounts[item] }"
|
||
@click="handleLetterClick(item)"
|
||
>
|
||
{{ item }}
|
||
</span>
|
||
</div>
|
||
|
||
<div class="table-container" v-loading="loading">
|
||
<el-table :data="pagedContacts" stripe border>
|
||
<el-table-column label="姓名" min-width="140" show-overflow-tooltip>
|
||
<template #default="{ row }">
|
||
<div class="name-cell-wrapper">
|
||
<span class="name-cell name-link" @click="openDetail(row)">{{ row.contact_name }}</span>
|
||
<el-tag v-if="Number(row.is_primary) === 1" type="danger" size="small" effect="plain">
|
||
主联系人
|
||
</el-tag>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="性别" width="70" align="center">
|
||
<template #default="{ row }">{{ genderText(row.gender) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="mobile" label="手机号" width="130" />
|
||
<el-table-column label="关联类型" width="100" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag size="small" :type="Number(row.related_type) === 1 ? 'primary' : 'warning'">
|
||
{{ relatedTypeText(row.related_type) }}
|
||
</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="关联对象" min-width="180" show-overflow-tooltip>
|
||
<template #default="{ row }">{{ row.related_name || '-' }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="department" label="部门" width="120" show-overflow-tooltip />
|
||
<el-table-column prop="position" label="职位" width="130" show-overflow-tooltip />
|
||
<el-table-column label="更新时间" width="160" align="center">
|
||
<template #default="{ row }">{{ formatDateTime(row.update_time) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="操作" width="140" align="center" fixed="right">
|
||
<template #default="{ row }">
|
||
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
|
||
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
<template #empty>
|
||
<el-empty description="暂无联系人" :image-size="80" />
|
||
</template>
|
||
</el-table>
|
||
|
||
<div class="pagination">
|
||
<el-pagination
|
||
v-model:current-page="pagination.page"
|
||
v-model:page-size="pagination.pageSize"
|
||
:total="totalCount"
|
||
:page-sizes="[10, 20, 50, 100]"
|
||
layout="total, sizes, prev, pager, next, jumper"
|
||
@size-change="handlePageSizeChange"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<ContactEdit
|
||
v-model:visible="editVisible"
|
||
:edit-data="currentEditData"
|
||
@success="fetchList"
|
||
/>
|
||
|
||
<!-- 联系人详情 -->
|
||
<el-drawer
|
||
v-model="detailVisible"
|
||
:title="detailData?.contact_name || '联系人详情'"
|
||
direction="rtl"
|
||
size="540px"
|
||
>
|
||
<div v-if="detailData">
|
||
<el-descriptions title="基本信息" :column="2" border size="small">
|
||
<el-descriptions-item label="姓名">{{ detailData.contact_name }}</el-descriptions-item>
|
||
<el-descriptions-item label="性别">{{ genderText(detailData.gender) }}</el-descriptions-item>
|
||
<el-descriptions-item label="关联类型">
|
||
<el-tag size="small" :type="Number(detailData.related_type) === 1 ? 'primary' : 'warning'">
|
||
{{ relatedTypeText(detailData.related_type) }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="关联对象">{{ detailData.related_name || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="部门">{{ detailData.department || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="职位">{{ detailData.position || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="手机号">{{ detailData.mobile || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="座机">{{ detailData.phone || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="邮箱">{{ detailData.email || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="微信">{{ detailData.wechat || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="QQ">{{ detailData.qq || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="钉钉">{{ detailData.dingtalk || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="主联系人">
|
||
<el-tag v-if="Number(detailData.is_primary) === 1" type="danger" size="small">是</el-tag>
|
||
<span v-else>否</span>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="在职状态">
|
||
<el-tag :type="Number(detailData.status) === 1 ? 'success' : 'info'" size="small">
|
||
{{ Number(detailData.status) === 1 ? '在职' : '离职' }}
|
||
</el-tag>
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="家庭住址" :span="2">
|
||
{{ detailData.home_address || '-' }}
|
||
</el-descriptions-item>
|
||
<el-descriptions-item label="备注" :span="2">{{ detailData.remark || '-' }}</el-descriptions-item>
|
||
<el-descriptions-item label="更新时间" :span="2">
|
||
{{ formatDateTime(detailData.update_time) }}
|
||
</el-descriptions-item>
|
||
</el-descriptions>
|
||
</div>
|
||
</el-drawer>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted } from "vue";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||
import { pinyin } from "pinyin-pro";
|
||
import { listContacts, deleteContact } from "@/api/contact";
|
||
import ContactEdit from "./components/edit.vue";
|
||
import {
|
||
RELATED_TYPE_OPTIONS,
|
||
genderText,
|
||
relatedTypeText,
|
||
formatDateTime,
|
||
} from "../dict";
|
||
|
||
const loading = ref(false);
|
||
const editVisible = ref(false);
|
||
const currentEditData = ref(null);
|
||
|
||
// 详情
|
||
const detailVisible = ref(false);
|
||
const detailData = ref(null);
|
||
|
||
// 全量联系人(用于本地 A-Z 排序 / 首字母筛选)
|
||
const allContacts = ref([]);
|
||
|
||
// A-Z 索引
|
||
const collator = new Intl.Collator("zh-Hans-CN", { sensitivity: "base" });
|
||
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||
.split("")
|
||
.filter((l) => !["I", "U", "V"].includes(l));
|
||
const activeLetter = ref("全部");
|
||
|
||
const filters = reactive({
|
||
keyword: "",
|
||
related_type: "",
|
||
is_primary: "",
|
||
});
|
||
|
||
const pagination = reactive({
|
||
page: 1,
|
||
pageSize: 20,
|
||
});
|
||
|
||
/** 取姓名首字母:中文按拼音、英文按字母,数字/符号/无法识别归 # */
|
||
function initialOf(name) {
|
||
const s = String(name || "").trim();
|
||
if (!s) return "#";
|
||
const first = s.charAt(0);
|
||
if (/[a-zA-Z]/.test(first)) return first.toUpperCase();
|
||
if (!/[\u4e00-\u9fa5]/.test(first)) return "#";
|
||
try {
|
||
const py = pinyin(first, { pattern: "first", toneType: "none" });
|
||
const letter = String(py || "").charAt(0).toUpperCase();
|
||
return /[A-Z]/.test(letter) ? letter : "#";
|
||
} catch (e) {
|
||
return "#";
|
||
}
|
||
}
|
||
|
||
function rankOf(letter) {
|
||
return letter === "#" ? 99 : letter.charCodeAt(0) - 65;
|
||
}
|
||
|
||
/** A-Z 排序(「全部」视图同样按 A-Z) */
|
||
const sortedContacts = computed(() => {
|
||
const list = [...allContacts.value];
|
||
list.sort((a, b) => {
|
||
const la = initialOf(a.contact_name);
|
||
const lb = initialOf(b.contact_name);
|
||
if (la !== lb) return rankOf(la) - rankOf(lb);
|
||
return collator.compare(a.contact_name || "", b.contact_name || "");
|
||
});
|
||
return list;
|
||
});
|
||
|
||
/** 各首字母数量 */
|
||
const letterCounts = computed(() => {
|
||
const map = {};
|
||
for (const c of allContacts.value) {
|
||
const l = initialOf(c.contact_name);
|
||
map[l] = (map[l] || 0) + 1;
|
||
}
|
||
return map;
|
||
});
|
||
|
||
/** 顶部索引:全部 + A-Z(存在 # 数据时追加 #) */
|
||
const letterBar = computed(() => [
|
||
"全部",
|
||
...LETTERS,
|
||
...(letterCounts.value["#"] ? ["#"] : []),
|
||
]);
|
||
|
||
/** 按首字母筛选(全部 → 不筛选) */
|
||
const filteredContacts = computed(() => {
|
||
if (activeLetter.value === "全部") return sortedContacts.value;
|
||
return sortedContacts.value.filter((c) => initialOf(c.contact_name) === activeLetter.value);
|
||
});
|
||
|
||
/** 本地分页 */
|
||
const pagedContacts = computed(() => {
|
||
const start = (pagination.page - 1) * pagination.pageSize;
|
||
return filteredContacts.value.slice(start, start + pagination.pageSize);
|
||
});
|
||
|
||
const totalCount = computed(() => filteredContacts.value.length);
|
||
|
||
onMounted(() => {
|
||
fetchList();
|
||
});
|
||
|
||
/** 拉取全量联系人(分页循环,最多 50 页),本地做 A-Z 排序与筛选 */
|
||
async function fetchList() {
|
||
loading.value = true;
|
||
try {
|
||
const pageSize = 100;
|
||
let all = [];
|
||
let total = 0;
|
||
for (let page = 1; page <= 50; page++) {
|
||
const res = await listContacts({ page, pageSize, ...filters });
|
||
const data = res?.data || {};
|
||
const list = Array.isArray(data) ? data : data.list || [];
|
||
all = all.concat(list);
|
||
total = data.total || all.length;
|
||
if (list.length === 0 || all.length >= total) break;
|
||
}
|
||
allContacts.value = all;
|
||
} catch (e) {
|
||
allContacts.value = [];
|
||
ElMessage.error(e.message || "查询失败");
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
function handleSearch() {
|
||
pagination.page = 1;
|
||
fetchList();
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters.keyword = "";
|
||
filters.related_type = "";
|
||
filters.is_primary = "";
|
||
activeLetter.value = "全部";
|
||
handleSearch();
|
||
}
|
||
|
||
function handleLetterClick(item) {
|
||
activeLetter.value = item;
|
||
pagination.page = 1;
|
||
}
|
||
|
||
function handlePageSizeChange() {
|
||
pagination.page = 1;
|
||
}
|
||
|
||
function openCreate() {
|
||
currentEditData.value = null;
|
||
editVisible.value = true;
|
||
}
|
||
|
||
function openDetail(row) {
|
||
detailData.value = { ...row };
|
||
detailVisible.value = true;
|
||
}
|
||
|
||
function openEdit(row) {
|
||
currentEditData.value = { ...row };
|
||
editVisible.value = true;
|
||
}
|
||
|
||
async function handleDelete(row) {
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
`确定要删除联系人「${row.contact_name}」吗?删除后不可恢复。`,
|
||
"删除确认",
|
||
{ type: "warning" }
|
||
);
|
||
await deleteContact({ id: row.id });
|
||
ElMessage.success("删除成功");
|
||
fetchList();
|
||
} catch (e) {
|
||
if (e !== "cancel") {
|
||
ElMessage.error(e.message || "删除失败");
|
||
}
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style lang="less" scoped>
|
||
.crm-page {
|
||
padding: 20px;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
margin-bottom: 16px;
|
||
|
||
h2 {
|
||
margin: 0 0 6px 0;
|
||
font-size: 20px;
|
||
font-weight: 600;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
p {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
}
|
||
|
||
.filter-bar {
|
||
background: var(--el-bg-color);
|
||
padding: 16px 16px 0;
|
||
border-radius: 6px;
|
||
margin-bottom: 12px;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
}
|
||
|
||
.table-container {
|
||
background: var(--el-bg-color);
|
||
padding: 16px;
|
||
border-radius: 6px;
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
}
|
||
|
||
.letter-bar {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 8px 12px;
|
||
margin-bottom: 12px;
|
||
background: var(--el-bg-color);
|
||
border: 1px solid var(--el-border-color-lighter);
|
||
border-radius: 6px;
|
||
|
||
.letter-item {
|
||
min-width: 28px;
|
||
height: 26px;
|
||
padding: 0 8px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 4px;
|
||
font-size: 13px;
|
||
color: var(--el-text-color-regular);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
transition: all 0.15s;
|
||
|
||
&:hover {
|
||
color: var(--el-color-primary);
|
||
background: var(--el-color-primary-light-9);
|
||
}
|
||
|
||
&.active {
|
||
color: #fff;
|
||
background: var(--el-color-primary);
|
||
}
|
||
|
||
&.empty {
|
||
color: var(--el-text-color-placeholder);
|
||
}
|
||
}
|
||
}
|
||
|
||
.name-cell-wrapper {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
|
||
.name-cell {
|
||
font-weight: 500;
|
||
color: var(--el-text-color-primary);
|
||
}
|
||
|
||
.name-link {
|
||
cursor: pointer;
|
||
color: var(--el-color-primary);
|
||
|
||
&:hover {
|
||
text-decoration: underline;
|
||
}
|
||
}
|
||
|
||
.pagination {
|
||
margin-top: 16px;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
}
|
||
</style>
|