Files
yunzerwebsiteallinone/backend/src/views/apps/crm/contact/index.vue
T

464 lines
14 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>
<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>