批量更新功能

This commit is contained in:
2026-09-14 16:15:44 +08:00
parent 6f106e43af
commit a26c98b92b
79 changed files with 4529 additions and 724 deletions
+3 -3
View File
@@ -72,7 +72,7 @@ export function getContactOrgTree(params) {
})
}
// 获取组织架构列表(用于选择器)
// 获取组织架构列表
export function getOrganizationList(params) {
return request({
url: '/backend/erp/getOrganization',
@@ -81,7 +81,7 @@ export function getOrganizationList(params) {
})
}
// 获取职位列表(用于选择器,可按部门筛选)
// 获取职位列表
export function getPositionList(params) {
return request({
url: '/backend/erp/getPosition',
@@ -90,7 +90,7 @@ export function getPositionList(params) {
})
}
// 获取职位详情(编辑联系人时,已选职位若不在当前部门列表内用于回显)
// 获取职位详情
export function getPositionDetail(id) {
return request({
url: `/backend/erp/getPositionDetail/${id}`,
+16
View File
@@ -39,6 +39,14 @@ export function toggleCrmCustomerStatus(id, status) {
});
}
/**
* 客户转供应商:把客户档案(含联系人)复制一套到供应商库。
* 同一家单位可能既是客户又是供应商,因此原客户记录保留;供应商库已有同名企业时不重复建档,只补同步联系人。
*/
export function convertCustomerToSupplier(id) {
return request({ url: `/backend/erp/customer/${id}/convert-supplier`, method: "post" });
}
/* -------------------------------- 供应商 -------------------------------- */
export function getCrmSupplierList(params) {
@@ -69,6 +77,14 @@ export function toggleCrmSupplierStatus(id, status) {
});
}
/**
* 供应商转客户:把供应商档案(含联系人)复制一套到客户库。
* 同一家单位可能既是供应商又是客户,因此原供应商记录保留;客户库已有同名企业时不重复建档,只补同步联系人。
*/
export function convertSupplierToCustomer(id) {
return request({ url: `/backend/erp/supplier/${id}/convert-customer`, method: "post" });
}
/* ------------------------------- 客户公海 ------------------------------- */
/** 公海列表:租户内共享,所有成员可见 */
+2 -2
View File
@@ -5,7 +5,7 @@ import request from "@/utils/request";
*
* 说明:
* - 合同绑定项目:project_id 为空即为「无头合同」,否则为「项目合同」;
* - 合同状态 status:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常;
* - 合同状态 status:1=草稿 2=已完成 3=已作废 4=履约中 5=异常;
* - 参与方 parties / 产品清单 products / 金额汇总 summary 随主表一并以 JSON 提交,
* 后端按需落库;step 记录进度式创建所到达的步骤,支持每步保存续填。
*/
@@ -35,7 +35,7 @@ export function deleteContract(id) {
return request({ url: `/backend/crm/contract/${id}`, method: "delete" });
}
/** 合同状态流转:status 1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常 */
/** 合同状态流转:status 1=草稿 2=已完成 3=已作废 4=履约中 5=异常 */
export function changeContractStatus(id, status) {
return request({ url: `/backend/crm/contract/${id}/status`, method: "post", data: { status } });
}
+15
View File
@@ -82,6 +82,21 @@ export function deleteProject(id) {
return request({ url: `/backend/crm/project/${id}`, method: "delete" });
}
/** 项目状态流转:仅更新状态(1未开始 / 2进行中 / 3已完成 / 4已暂停 / 5异常) */
export function changeProjectStatus(id, status) {
return request({ url: `/backend/crm/project/${id}/status`, method: "post", data: { status } });
}
/**
* 保存项目联系人:从对应企业通讯录中挑选的联系人ID集合(多选)。
* - upstream_contact_ids:上游(客户方)选中的联系人ID数组;
* - downstream_contact_ids:下游/中游(对方主体方)选中的联系人ID数组;
* - 单一项目后端会自动清空下游集合,传入不属于对应企业的ID会被过滤。
*/
export function saveProjectContacts(id, data) {
return request({ url: `/backend/crm/project/${id}/contacts`, method: "post", data });
}
/* --------------------------- 项目文档(OA 文档库) --------------------------- */
/**
+12
View File
@@ -45,3 +45,15 @@ export function toggleCustomerStatus(id, status) {
data: { status }
})
}
/**
* 客户转供应商:把客户档案(含联系人)复制一套到供应商库。
* 同一家单位可能既是客户又是供应商,因此原客户记录保留;
* 供应商库已有同名企业时不重复建档,只补同步联系人。
*/
export function convertCustomerToSupplier(id) {
return request({
url: `/backend/erp/customer/${id}/convert-supplier`,
method: 'post'
})
}
+12
View File
@@ -45,3 +45,15 @@ export function toggleSupplierStatus(id, status) {
data: { status }
})
}
/**
* 供应商转客户:把供应商档案(含联系人)复制一套到客户库。
* 同一家单位可能既是供应商又是客户,因此原供应商记录保留;
* 客户库已有同名企业时不重复建档,只补同步联系人。
*/
export function convertSupplierToCustomer(id) {
return request({
url: `/backend/erp/supplier/${id}/convert-customer`,
method: 'post'
})
}
@@ -1,8 +1,11 @@
<template>
<div class="detail-attach">
<div class="tab-toolbar">
<el-upload :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
<!-- multiple:支持一次选择多个文件批量上传,全部结束后统一提示并刷新列表 -->
<el-upload multiple :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-tooltip content="支持多选:可一次选择多个文件批量上传" placement="top">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
</el-tooltip>
</el-upload>
<span v-if="tip" class="tip">{{ tip }}</span>
</div>
@@ -61,6 +64,10 @@ const emit = defineEmits(["count"]);
const loading = ref(false);
const uploading = ref(false);
const list = ref([]);
// 批量上传:并发计数 + 结果汇总(全部结束后统一提示、统一刷新)
const pending = ref(0);
const okCount = ref(0);
const failed = ref([]);
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
@@ -86,9 +93,20 @@ async function reload() {
emit("count", list.value.length);
}
/**
* 单文件上传:多选时 el-upload 会为每个文件调用一次(并发),
* 这里用 pending 计数保证全部结束后只提示一次、只刷新一次列表。
*/
async function handleUpload(options) {
const file = options.file;
uploading.value = true;
if (!file) return;
// 新一轮批量上传:重置汇总,并保持按钮 loading 直到最后一个文件结束
if (pending.value === 0) {
okCount.value = 0;
failed.value = [];
uploading.value = true;
}
pending.value += 1;
try {
const fd = new FormData();
fd.append("file", file);
@@ -102,12 +120,26 @@ async function handleUpload(options) {
file_url: data.url || "",
file_size: file.size || 0,
});
ElMessage.success("上传成功");
reload();
okCount.value += 1;
} catch (e) {
ElMessage.error(e.message || "上传失败");
failed.value.push(`${file.name}:${e?.message || "上传失败"}`);
} finally {
pending.value -= 1;
if (pending.value > 0) return;
uploading.value = false;
// 统一提示:单个成功保持原文案,多个成功汇总,失败逐个列出
if (failed.value.length) {
ElMessage.error(
okCount.value
? `成功 ${okCount.value} 个,失败 ${failed.value.length} 个:${failed.value.join(";")}`
: `上传失败:${failed.value.join(";")}`
);
} else if (okCount.value > 1) {
ElMessage.success(`已上传 ${okCount.value} 个附件`);
} else {
ElMessage.success("上传成功");
}
if (okCount.value) reload();
}
}
@@ -12,7 +12,9 @@
</template>
</el-table-column>
<el-table-column prop="position" label="职位" min-width="85" show-overflow-tooltip />
<el-table-column prop="mobile" label="手机号" min-width="110" />
<el-table-column label="手机号" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) || row.mobile || "-" }}</template>
</el-table-column>
<el-table-column prop="wechat" label="微信" min-width="95" show-overflow-tooltip />
<el-table-column prop="qq" label="QQ" min-width="85" />
<el-table-column prop="remark" label="备注" min-width="95" show-overflow-tooltip />
@@ -32,7 +34,17 @@
<el-input v-model="form.contact_name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="职位"><el-input v-model="form.position" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.mobile" /></el-form-item>
<el-form-item label="手机号">
<div class="mobile-list">
<div v-for="(m, idx) in form.mobiles" :key="idx" class="mobile-item">
<el-input v-model="form.mobiles[idx]" placeholder="请输入手机号" maxlength="20" clearable />
<el-button :icon="Delete" :disabled="form.mobiles.length <= 1" @click="removeMobile(idx)" />
</div>
<el-button v-if="form.mobiles.length < 5" type="primary" plain :icon="Plus" @click="addMobile">
添加手机号({{ form.mobiles.length }}/5)
</el-button>
</div>
</el-form-item>
<el-form-item label="微信"><el-input v-model="form.wechat" /></el-form-item>
<el-form-item label="QQ"><el-input v-model="form.qq" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
@@ -52,13 +64,14 @@
<script setup>
import { ref, reactive } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { Plus, Delete } from "@element-plus/icons-vue";
import {
getEntityContactList,
addEntityContact,
updateEntityContact,
deleteEntityContact,
} from "@/api/crmPipeline";
import { formatMobiles } from "../../dict";
const props = defineProps({
// 关联对象ID(商机ID)
@@ -82,6 +95,8 @@ const defaultForm = () => ({
contact_name: "",
position: "",
mobile: "",
// 手机号支持登记多个(有的人有多个号码),默认一行,点加号增加
mobiles: [""],
wechat: "",
qq: "",
email: "",
@@ -90,6 +105,16 @@ const defaultForm = () => ({
});
const form = reactive(defaultForm());
/** 增加一行手机号输入 */
function addMobile() {
if (form.mobiles.length < 5) form.mobiles.push("");
}
/** 删除一行手机号输入(至少保留一行) */
function removeMobile(idx) {
if (form.mobiles.length > 1) form.mobiles.splice(idx, 1);
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
if (!props.relatedId) {
@@ -116,7 +141,26 @@ async function reload() {
function openEdit(row) {
Object.assign(form, defaultForm());
if (row) Object.assign(form, row);
if (row) {
Object.assign(form, row);
// 回显手机号:兼容 JSON 数组 / 分隔符字符串 / 旧数据单个 mobile(最多 5 个)
let arr = [];
const raw = row.mobiles;
if (Array.isArray(raw)) {
arr = raw;
} else if (raw !== undefined && raw !== null && String(raw).trim()) {
const s = String(raw).trim();
try {
const parsed = JSON.parse(s);
arr = Array.isArray(parsed) ? parsed : [s];
} catch (e) {
arr = s.split(/[,,、;;\s]+/);
}
}
arr = arr.map((m) => String(m || "").trim()).filter(Boolean).slice(0, 5);
if (arr.length === 0 && row.mobile) arr = [String(row.mobile).trim()];
form.mobiles = arr.length ? arr : [""];
}
dialog.value = true;
}
@@ -127,10 +171,14 @@ async function handleSave() {
}
saving.value = true;
try {
// 手机号:动态列表去空后按数组提交(后端按 JSON 数组存储,最多 5 个)
const mobiles = form.mobiles.map((m) => (m || "").trim()).filter(Boolean);
const payload = {
...form,
related_type: props.relatedType,
related_id: props.relatedId,
mobiles,
mobile: mobiles[0] || "",
};
if (form.id) {
await updateEntityContact(payload);
@@ -173,4 +221,16 @@ defineExpose({ reload });
color: var(--el-text-color-secondary);
}
}
/* 手机号:动态列表,每行输入框 + 删除按钮 */
.mobile-list {
width: 100%;
.mobile-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
}
</style>
@@ -7,6 +7,27 @@
@update:model-value="handleClose"
@opened="loadAll"
>
<!-- 标题 + 右上角操作:编辑 / 删除(自带的 X 关闭按钮仍在其右侧) -->
<template #header>
<div class="drawer-header">
<span class="drawer-title">{{ clue?.clue_name || "线索详情" }}</span>
<div v-if="clue" class="drawer-actions">
<el-button size="small" :icon="EditPen" :disabled="Number(clue.locked) === 1" @click="emit('edit', clue)">
编辑
</el-button>
<el-button
size="small"
type="danger"
:icon="Delete"
:disabled="Number(clue.locked) === 1 || Number(clue.status) === 2"
@click="emit('delete', clue)"
>
删除
</el-button>
</div>
</div>
</template>
<div v-if="clue" class="clue-detail">
<el-alert
v-if="Number(clue.locked) === 1 || Number(clue.status) === 2"
@@ -66,6 +87,7 @@
<script setup>
import { ref, reactive, watch } from "vue";
import { Delete, EditPen } from "@element-plus/icons-vue";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
@@ -76,7 +98,7 @@ const props = defineProps({
visible: { type: Boolean, default: false },
clue: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "refresh"]);
const emit = defineEmits(["update:visible", "refresh", "edit", "delete"]);
// 关联类型:1=线索
const RELATED_TYPE = 1;
@@ -122,6 +144,30 @@ defineExpose({ loadAll });
overflow-x: hidden;
}
/* 抽屉头部:左侧标题(超长省略)+ 右上角编辑 / 删除 */
.drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
.drawer-title {
flex: 1;
min-width: 0;
overflow: hidden;
font-size: 16px;
font-weight: 600;
color: var(--el-text-color-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-actions {
flex-shrink: 0;
}
}
.clue-detail {
.detail-tabs {
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
@@ -1,8 +1,11 @@
<template>
<div class="detail-attach">
<div class="tab-toolbar">
<el-upload :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
<!-- multiple:支持一次选择多个文件批量上传,全部结束后统一提示并刷新列表 -->
<el-upload multiple :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-tooltip content="支持多选:可一次选择多个文件批量上传" placement="top">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
</el-tooltip>
</el-upload>
<span v-if="tip" class="tip">{{ tip }}</span>
</div>
@@ -61,6 +64,10 @@ const emit = defineEmits(["count"]);
const loading = ref(false);
const uploading = ref(false);
const list = ref([]);
// 批量上传:并发计数 + 结果汇总(全部结束后统一提示、统一刷新)
const pending = ref(0);
const okCount = ref(0);
const failed = ref([]);
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
@@ -86,9 +93,20 @@ async function reload() {
emit("count", list.value.length);
}
/**
* 单文件上传:多选时 el-upload 会为每个文件调用一次(并发),
* 这里用 pending 计数保证全部结束后只提示一次、只刷新一次列表。
*/
async function handleUpload(options) {
const file = options.file;
uploading.value = true;
if (!file) return;
// 新一轮批量上传:重置汇总,并保持按钮 loading 直到最后一个文件结束
if (pending.value === 0) {
okCount.value = 0;
failed.value = [];
uploading.value = true;
}
pending.value += 1;
try {
const fd = new FormData();
fd.append("file", file);
@@ -102,12 +120,26 @@ async function handleUpload(options) {
file_url: data.url || "",
file_size: file.size || 0,
});
ElMessage.success("上传成功");
reload();
okCount.value += 1;
} catch (e) {
ElMessage.error(e.message || "上传失败");
failed.value.push(`${file.name}:${e?.message || "上传失败"}`);
} finally {
pending.value -= 1;
if (pending.value > 0) return;
uploading.value = false;
// 统一提示:单个成功保持原文案,多个成功汇总,失败逐个列出
if (failed.value.length) {
ElMessage.error(
okCount.value
? `成功 ${okCount.value} 个,失败 ${failed.value.length} 个:${failed.value.join(";")}`
: `上传失败:${failed.value.join(";")}`
);
} else if (okCount.value > 1) {
ElMessage.success(`已上传 ${okCount.value} 个附件`);
} else {
ElMessage.success("上传成功");
}
if (okCount.value) reload();
}
}
@@ -12,7 +12,9 @@
</template>
</el-table-column>
<el-table-column prop="position" label="职位" min-width="85" show-overflow-tooltip />
<el-table-column prop="mobile" label="手机号" min-width="110" />
<el-table-column label="手机号" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) || row.mobile || "-" }}</template>
</el-table-column>
<el-table-column prop="wechat" label="微信" min-width="95" show-overflow-tooltip />
<el-table-column prop="qq" label="QQ" min-width="85" />
<el-table-column prop="remark" label="备注" min-width="95" show-overflow-tooltip />
@@ -32,7 +34,17 @@
<el-input v-model="form.contact_name" placeholder="请输入姓名" />
</el-form-item>
<el-form-item label="职位"><el-input v-model="form.position" /></el-form-item>
<el-form-item label="手机号"><el-input v-model="form.mobile" /></el-form-item>
<el-form-item label="手机号">
<div class="mobile-list">
<div v-for="(m, idx) in form.mobiles" :key="idx" class="mobile-item">
<el-input v-model="form.mobiles[idx]" placeholder="请输入手机号" maxlength="20" clearable />
<el-button :icon="Delete" :disabled="form.mobiles.length <= 1" @click="removeMobile(idx)" />
</div>
<el-button v-if="form.mobiles.length < 5" type="primary" plain :icon="Plus" @click="addMobile">
添加手机号({{ form.mobiles.length }}/5)
</el-button>
</div>
</el-form-item>
<el-form-item label="微信"><el-input v-model="form.wechat" /></el-form-item>
<el-form-item label="QQ"><el-input v-model="form.qq" /></el-form-item>
<el-form-item label="邮箱"><el-input v-model="form.email" /></el-form-item>
@@ -52,13 +64,14 @@
<script setup>
import { ref, reactive } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { Plus, Delete } from "@element-plus/icons-vue";
import {
getEntityContactList,
addEntityContact,
updateEntityContact,
deleteEntityContact,
} from "@/api/crmPipeline";
import { formatMobiles } from "../../dict";
const props = defineProps({
// 关联对象ID(线索ID)
@@ -80,6 +93,8 @@ const defaultForm = () => ({
contact_name: "",
position: "",
mobile: "",
// 手机号支持登记多个(有的人有多个号码),默认一行,点加号增加
mobiles: [""],
wechat: "",
qq: "",
email: "",
@@ -88,6 +103,16 @@ const defaultForm = () => ({
});
const form = reactive(defaultForm());
/** 增加一行手机号输入 */
function addMobile() {
if (form.mobiles.length < 5) form.mobiles.push("");
}
/** 删除一行手机号输入(至少保留一行) */
function removeMobile(idx) {
if (form.mobiles.length > 1) form.mobiles.splice(idx, 1);
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
if (!props.relatedId) {
@@ -112,7 +137,26 @@ async function reload() {
function openEdit(row) {
Object.assign(form, defaultForm());
if (row) Object.assign(form, row);
if (row) {
Object.assign(form, row);
// 回显手机号:兼容 JSON 数组 / 分隔符字符串 / 旧数据单个 mobile(最多 5 个)
let arr = [];
const raw = row.mobiles;
if (Array.isArray(raw)) {
arr = raw;
} else if (raw !== undefined && raw !== null && String(raw).trim()) {
const s = String(raw).trim();
try {
const parsed = JSON.parse(s);
arr = Array.isArray(parsed) ? parsed : [s];
} catch (e) {
arr = s.split(/[,,、;;\s]+/);
}
}
arr = arr.map((m) => String(m || "").trim()).filter(Boolean).slice(0, 5);
if (arr.length === 0 && row.mobile) arr = [String(row.mobile).trim()];
form.mobiles = arr.length ? arr : [""];
}
dialog.value = true;
}
@@ -123,10 +167,14 @@ async function handleSave() {
}
saving.value = true;
try {
// 手机号:动态列表去空后按数组提交(后端按 JSON 数组存储,最多 5 个)
const mobiles = form.mobiles.map((m) => (m || "").trim()).filter(Boolean);
const payload = {
...form,
related_type: props.relatedType,
related_id: props.relatedId,
mobiles,
mobile: mobiles[0] || "",
};
if (form.id) {
await updateEntityContact(payload);
@@ -169,4 +217,16 @@ defineExpose({ reload });
color: var(--el-text-color-secondary);
}
}
/* 手机号:动态列表,每行输入框 + 删除按钮 */
.mobile-list {
width: 100%;
.mobile-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
}
</style>
@@ -63,6 +63,14 @@
</el-input>
</el-form-item>
</el-col>
<!-- 线索状态:编辑时可直接管理(跟进中 / 已转化 / 已废弃) -->
<el-col v-if="isEdit" :span="12">
<el-form-item label="线索状态" prop="status">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
<el-option v-for="i in CLUE_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="公司地址" prop="address">
<el-input v-model="form.address" placeholder="请输入公司地址" maxlength="255" />
@@ -126,6 +134,7 @@ import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
import {
CLUE_SOURCE_OPTIONS,
CLUE_STATUS_OPTIONS,
PIPELINE_LEVEL_OPTIONS,
} from "../../dict";
@@ -152,6 +161,8 @@ const defaultForm = () => ({
amount: "",
next_contact_time: "",
clue_level: "2",
// 线索状态:1跟进中 / 2已转化 / 3已废弃(编辑时可调整,新增固定为跟进中)
status: "1",
contact_person: "",
contact_position: "",
contact_phone: "",
@@ -181,6 +192,8 @@ watch(
isEdit.value = true;
internalId.value = props.editData.id;
Object.assign(form, defaultForm(), props.editData);
// 后端返回的状态是数字,下拉选项用字符串值,统一归一避免回显为空
form.status = String(props.editData.status ?? 1);
} else {
isEdit.value = false;
internalId.value = null;
+20 -18
View File
@@ -81,11 +81,9 @@
<el-table-column label="下次联系时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.next_contact_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<el-table-column label="操作" width="100" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" :disabled="row.locked === 1" @click="openEdit(row)">
编辑
</el-button>
<!-- 编辑 / 删除已收口到「线索详情」内,列表只保留转化 -->
<el-button
link
type="warning"
@@ -95,15 +93,6 @@
>
转化
</el-button>
<el-button
link
type="danger"
size="small"
:disabled="row.locked === 1 || row.status === 2"
@click="handleDelete(row)"
>
删除
</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无线索" :image-size="80" /></template>
@@ -128,6 +117,8 @@
v-model:visible="detailVisible"
:clue="currentEditData"
@refresh="fetchList"
@edit="handleDetailEdit"
@delete="handleDetailDelete"
/>
</div>
</template>
@@ -209,11 +200,6 @@ function openCreate() {
editVisible.value = true;
}
function openEdit(row) {
currentEditData.value = { ...row };
editVisible.value = true;
}
function openConvert(row) {
currentEditData.value = { ...row };
convertVisible.value = true;
@@ -229,6 +215,20 @@ function handleConvertSuccess() {
ElMessage.success("线索已转化,可在商机管理中继续跟进");
}
/** 详情内编辑:收起详情后打开编辑弹窗(详情数据即当前行) */
function handleDetailEdit() {
detailVisible.value = false;
editVisible.value = true;
}
/** 详情内删除:复用删除逻辑,删除成功后收起详情 */
async function handleDetailDelete() {
const row = currentEditData.value;
if (!row) return;
if (await handleDelete(row)) detailVisible.value = false;
}
/** 删除线索:返回是否删除成功,供详情内调用判断是否需要收起抽屉 */
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定删除线索「${row.clue_name}」吗?删除后不可恢复。`, "删除确认", {
@@ -237,8 +237,10 @@ async function handleDelete(row) {
await deleteClue(row.id);
ElMessage.success("删除成功");
fetchList();
return true;
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
return false;
}
}
</script>
@@ -73,9 +73,33 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="手机号" prop="mobile">
<el-input v-model="form.mobile" placeholder="请输入手机号" />
<el-col :span="24">
<!-- 手机号:支持登记多个(有的人有多个号码),默认一行,点加号增加,最多 5 个 -->
<el-form-item label="手机号" prop="mobiles">
<div class="mobile-list">
<div v-for="(item, idx) in form.mobiles" :key="idx" class="mobile-item">
<el-input
v-model="form.mobiles[idx]"
:placeholder="`手机号${idx + 1}`"
maxlength="20"
clearable
/>
<el-button
:icon="Delete"
:disabled="form.mobiles.length <= 1"
@click="removeMobile(idx)"
/>
</div>
<el-button
v-if="form.mobiles.length < 5"
type="primary"
plain
:icon="Plus"
@click="addMobile"
>
添加手机号({{ form.mobiles.length }}/5)
</el-button>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
@@ -163,6 +187,7 @@
<script setup>
import { ref, reactive, computed, watch, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { Plus, Delete } from "@element-plus/icons-vue";
import { createContact, updateContact } from "@/api/contact";
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
import { RELATED_TYPE_OPTIONS, GENDER_OPTIONS } from "../../dict";
@@ -190,7 +215,8 @@ const defaultForm = () => ({
related_id: undefined,
contact_name: "",
gender: 0,
mobile: "",
// 手机号:动态列表,默认一行,点击加号增加(最多 5 个,与后端存储上限一致)
mobiles: [""],
phone: "",
email: "",
wechat: "",
@@ -210,13 +236,15 @@ const rules = {
related_type: [{ required: true, message: "请选择关联类型", trigger: "change" }],
related_id: [{ required: true, message: "请选择关联对象", trigger: "change" }],
contact_name: [{ required: true, message: "请输入联系人姓名", trigger: "blur" }],
mobile: [
mobiles: [
{
// 手机号非必填,填写时才校验格式
// 手机号非必填,填写时才校验格式(多个号码逐个校验)
validator: (rule, value, callback) => {
const val = (value || "").trim();
if (!val) return callback();
if (!/^1[3-9]\d{9}$/.test(val)) return callback(new Error("手机号格式不正确"));
const list = Array.isArray(value) ? value : [value];
const bad = list
.map((m) => String(m || "").trim())
.find((m) => m && !/^1[3-9]\d{9}$/.test(m));
if (bad) return callback(new Error(`手机号格式不正确:${bad}`));
callback();
},
trigger: "blur",
@@ -225,6 +253,42 @@ const rules = {
email: [{ type: "email", message: "邮箱格式不正确", trigger: "blur" }],
};
/** 解析手机号:兼容 JSON 数组 / 分隔符字符串 / 旧数据只存单个 mobile */
function parseMobiles(row) {
let arr = [];
const raw = row?.mobiles;
if (Array.isArray(raw)) {
arr = raw;
} else if (raw !== undefined && raw !== null && String(raw).trim()) {
const s = String(raw).trim();
try {
const parsed = JSON.parse(s);
arr = Array.isArray(parsed) ? parsed : [s];
} catch (e) {
arr = s.split(/[,,、;;\s]+/);
}
}
arr = arr.map((m) => String(m || "").trim()).filter(Boolean);
if (arr.length === 0 && row?.mobile) arr = [String(row.mobile).trim()];
return arr;
}
/** 手机号填充表单:全部号码进动态列表(最多 5 个,与后端存储上限一致) */
function fillMobiles(row) {
const arr = parseMobiles(row).slice(0, 5);
form.mobiles = arr.length ? arr : [""];
}
/** 增加一行手机号输入 */
function addMobile() {
if (form.mobiles.length < 5) form.mobiles.push("");
}
/** 删除一行手机号输入(至少保留一行) */
function removeMobile(idx) {
if (form.mobiles.length > 1) form.mobiles.splice(idx, 1);
}
const companyPlaceholder = computed(() =>
form.related_type === 2 ? "搜索供应商名称" : "搜索客户名称"
);
@@ -271,6 +335,7 @@ watch(
form.related_type = Number(props.editData.related_type) || 1;
form.is_primary = Number(props.editData.is_primary) || 0;
form.gender = Number(props.editData.gender) || 0;
fillMobiles(props.editData);
await loadCompanyOptions();
// 编辑时确保关联对象在下拉中存在
const name =
@@ -314,7 +379,27 @@ async function handleSave() {
}
submitting.value = true;
try {
const payload = { ...form };
// 手机号:动态列表去空后按数组提交(后端按 JSON 数组存储,最多 5 个)
const mobiles = form.mobiles.map((m) => String(m || "").trim()).filter(Boolean);
const payload = {
id: form.id,
related_type: form.related_type,
related_id: form.related_id,
contact_name: form.contact_name,
gender: form.gender,
mobiles,
phone: form.phone,
email: form.email,
wechat: form.wechat,
qq: form.qq,
dingtalk: form.dingtalk,
department: form.department,
position: form.position,
home_address: form.home_address,
is_primary: form.is_primary,
status: form.status,
remark: form.remark,
};
if (isEdit.value) {
await updateContact(payload);
ElMessage.success("更新成功");
@@ -331,3 +416,22 @@ async function handleSave() {
}
}
</script>
<style lang="less" scoped>
/* 手机号:动态列表,每行输入框 + 删除按钮 */
.mobile-list {
width: 100%;
.mobile-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
:deep(.el-input) {
flex: 1;
min-width: 0;
}
}
}
</style>
+8 -2
View File
@@ -73,7 +73,10 @@
<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="手机号" min-width="170" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) || row.mobile || "-" }}</template>
</el-table-column>
<el-table-column label="关联类型" width="100" align="center">
<template #default="{ row }">
<el-tag size="small" :type="Number(row.related_type) === 1 ? 'primary' : 'warning'">
@@ -137,7 +140,9 @@
<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="手机号">
{{ formatMobiles(detailData.mobiles) || 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>
@@ -177,6 +182,7 @@ import {
genderText,
relatedTypeText,
formatDateTime,
formatMobiles,
} from "../dict";
const loading = ref(false);
@@ -88,6 +88,11 @@
</el-select>
<el-input v-model="party.signer_phone" placeholder="联系电话" maxlength="20" />
</div>
<!-- 该企业还没有联系人:签约人姓名与电话会在保存合同时写入企业通讯录 -->
<div v-if="showContactTip" class="contact-tip">
该企业暂无联系人,填写姓名与电话即可,保存合同后会自动写入通讯录,可稍后补全其他资料
</div>
</div>
</template>
@@ -143,6 +148,15 @@ const contactPlaceholder = computed(() => {
return party.value?.ref_id ? "选择签约人(也可输入)" : "请先选择客户 / 供应商";
});
/** 已选企业但通讯录里还没有联系人:提示签约人信息会在保存合同后自动写入通讯录 */
const showContactTip = computed(
() =>
!isOwnCompany.value &&
!!party.value?.ref_id &&
!contactLoading.value &&
contacts.value.length === 0
);
/** 拉取本公司人员(在职员工),映射为与联系人一致的字段结构,便于复用下拉渲染 */
async function fetchOwnEmployees() {
ownLoading.value = true;
@@ -338,5 +352,13 @@ const handleRefTypeChange = () => {
.select-footer {
text-align: center;
}
/* 暂无联系人提示:签约人信息保存后自动写入通讯录 */
.contact-tip {
margin-top: 8px;
font-size: 12px;
line-height: 1.5;
color: var(--el-text-color-secondary);
}
}
</style>
@@ -10,6 +10,15 @@
</el-select>
</template>
</el-table-column>
<el-table-column label="研发方式" width="120">
<template #default="{ row }">
<!-- 软件开发分两种:自行研发 / 外协研发(别人帮我们研发,成本按外包总额计) -->
<el-select v-if="row.line_type === 'dev'" v-model="row.dev_mode" style="width: 100%">
<el-option v-for="i in DEV_MODE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="产品类别" width="150">
<template #default="{ row }">
<el-select
@@ -74,7 +83,24 @@
</el-table-column>
<el-table-column label="单位" width="90">
<template #default="{ row }">
<el-input v-model="row.unit" :placeholder="row.line_type === 'dev' ? '项' : '套'" maxlength="10" />
<!-- 单位下拉取自「基本设置 - 单位设置」(启用中的单位),支持搜索与直接输入 -->
<el-select
v-model="row.unit"
filterable
allow-create
clearable
:placeholder="row.line_type === 'dev' ? '项' : '套'"
style="width: 100%"
>
<el-option v-for="u in unitOptions" :key="u" :label="u" :value="u" />
<!-- 历史数据中的单位不在单位设置里时兜底展示,避免已填单位看不到 -->
<el-option
v-if="row.unit && !unitOptions.includes(row.unit)"
:key="row.unit"
:label="row.unit"
:value="row.unit"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="数量" width="110">
@@ -198,13 +224,14 @@
<span class="summary-label">产品总成本</span>
<span class="summary-value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<!-- 我方为甲方(付款方)时显示「合同支出」(负数),否则显示「合同总利润」 -->
<div class="summary-item profit">
<span class="summary-label">合同总利润</span>
<span class="summary-label">{{ contractProfitLabel(ourRole) }}</span>
<span
class="summary-value"
:class="{ loss: summary.total_profit < 0 }"
:class="{ loss: !isPayerContract(ourRole) && summary.total_profit < 0 }"
>
¥{{ formatMoney(summary.total_profit) }}
{{ contractProfitText(ourRole, summary) }}
</span>
</div>
</div>
@@ -226,20 +253,31 @@
<script setup>
import { ref, computed, onMounted } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import {
formatMoney,
PRODUCT_UNIT_OPTIONS,
DEV_MODE_OPTIONS,
contractProfitLabel,
contractProfitText,
isPayerContract,
} from "../../dict";
import { buildSummary, lineAmount, devCost, treeSellTotal } from "./utils";
import { getProductList } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import { getUnitList } from "@/api/crmUnit";
import DevModuleTree from "./DevModuleTree.vue";
const props = defineProps({
/** 产品行数组(父组件持有) */
products: { type: Array, required: true },
/** 我方角色:1=甲方(付款方,金额汇总显示「合同支出」,不显示利润) */
ourRole: { type: [Number, String], default: 2 },
});
const summary = computed(() => buildSummary(props.products));
const categoryOptions = ref([]);
const unitOptions = ref([]);
const productOptions = ref([]);
const activeRow = ref(null); // 当前正在操作的产品行(用于按分类过滤搜索)
@@ -259,6 +297,7 @@ const addRow = () => {
tax_rate: 0,
dev_tree: [],
dev_unit_price: 0,
dev_mode: "self", // 软件开发研发方式:self=自行研发(默认)/ outsource=外协研发
dev_total_price: 0, // 模块报价带出的开发总成本(= 本行成本单价)
dev_auto_total: 0, // 最近一次按人天自动汇总的值,用于判断是否需要跟随更新
remark: "",
@@ -269,11 +308,12 @@ const removeRow = (index) => {
props.products.splice(index, 1);
};
/** 切换行类型:软件开发行确保有模块树容器,并补上类别/单位默认值 */
/** 切换行类型:软件开发行确保有模块树容器与研发方式,并补上类别/单位默认值 */
function onLineTypeChange(row) {
if (row.line_type === "dev") {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
if (row.dev_unit_price == null) row.dev_unit_price = 0;
if (!row.dev_mode) row.dev_mode = "self";
// 开发行不进产品管理建档,类别与单位需在此兜底,否则详情展示为空
if (!row.category) row.category = "软件开发";
if (!row.unit) row.unit = "项";
@@ -333,6 +373,21 @@ async function fetchCategories() {
}
}
/** 加载计量单位下拉:基本设置「单位设置」中启用中的单位 + 内置默认单位(去重) */
async function fetchUnits() {
let names = [];
try {
const res = await getUnitList({ page: 1, pageSize: 200, status: 1 });
names = (res?.data?.list || []).map((i) => i.name).filter(Boolean);
} catch (e) {
names = [];
}
PRODUCT_UNIT_OPTIONS.forEach((i) => {
if (!names.includes(i.value)) names.push(i.value);
});
unitOptions.value = names;
}
/** 切换分类:清空已选产品及带出的字段,强制「分类优先」,并重载该分类下的产品选项 */
function onCategoryChange(row) {
row.name = "";
@@ -393,6 +448,8 @@ function onProductPick(row, val) {
if (hit.line_type === "dev") {
row.line_type = "dev";
row.dev_unit_price = Number(hit.dev_unit_price) || 0;
// 研发方式随产品档案带出(自行研发 / 外协研发),可在本行调整
row.dev_mode = hit.dev_mode === "outsource" ? "outsource" : "self";
row.dev_tree = Array.isArray(hit.dev_tree)
? JSON.parse(JSON.stringify(hit.dev_tree))
: safeParseTree(hit.dev_tree);
@@ -442,6 +499,7 @@ function safeParseTree(v) {
onMounted(() => {
fetchCategories();
fetchUnits();
// 为已加载的合同产品预置下拉选项,保证名称回显
props.products.forEach((r) => {
// 兼容历史数据:开发行补全「开发总成本 / 成本单价 / 销售单价」,避免显示为 0
@@ -454,6 +512,8 @@ onMounted(() => {
if (!Number(r.price)) r.price = total;
if (!r.category) r.category = "软件开发";
if (!r.unit) r.unit = "项";
// 历史数据补默认研发方式(自行研发)
if (!r.dev_mode) r.dev_mode = "self";
}
if (r && r.name && !productOptions.value.some((o) => o.product_name === r.name)) {
productOptions.value.push({
@@ -8,7 +8,7 @@
@closed="handleClosed"
>
<el-alert
:title="`创建成功后将自动关联到合同参与方`"
:title="`创建成功后将自动关联到${associateTip}`"
type="info"
:closable="false"
show-icon
@@ -32,7 +32,7 @@
<el-input v-model="form.contact_person" placeholder="请输入联系人(选填)" />
</el-form-item>
<el-form-item label="联系电话" prop="contact_phone">
<el-input v-model="form.contact_phone" placeholder="请输入联系电话(选填)" />
<el-input v-model="form.contact_phone" placeholder="请输入联系电话" />
</el-form-item>
</el-form>
<template #footer>
@@ -48,6 +48,7 @@
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { createCrmCustomer, createCrmSupplier } from "@/api/crm";
import { createContact } from "@/api/contact";
import { CUSTOMER_TYPE_OPTIONS, SUPPLIER_TYPE_OPTIONS } from "../../dict";
const props = defineProps({
@@ -56,6 +57,8 @@ const props = defineProps({
refType: { type: [Number, String], default: 1 },
/** 从搜索词带入名称 */
presetName: { type: String, default: "" },
/** 创建成功后自动关联的目标(提示文案,如:合同参与方 / 项目客户) */
associateTip: { type: String, default: "合同参与方" },
});
const emit = defineEmits(["update:visible", "created"]);
@@ -79,6 +82,8 @@ const form = reactive(defaultForm());
const rules = {
name: [{ required: true, message: "名称不能为空", trigger: "blur" }],
// 后端正式保存要求联系电话非空,这里提前提示,避免提交后才报错
contact_phone: [{ required: true, message: "联系人电话不能为空", trigger: "blur" }],
};
watch(
@@ -99,6 +104,33 @@ function handleClosed() {
Object.assign(form, defaultForm());
}
/** 是否手机号(11 位、1 开头);非手机号按座机提交,保证联系人资料分列正确 */
function isMobilePhone(phone) {
return /^1\d{10}$/.test(String(phone || "").replace(/[\s-]/g, ""));
}
/**
* 新建企业时填写的联系人一并写入企业通讯录(仅姓名 + 电话,其余资料可后续在联系人中补全);
* 写入失败不阻断企业创建结果。
*/
async function syncContactToBook(companyID) {
const name = String(form.contact_person || "").trim();
if (!companyID || !name) return;
const phone = String(form.contact_phone || "").trim();
try {
await createContact({
related_type: Number(props.refType) === 2 ? 2 : 1,
related_id: companyID,
contact_name: name,
mobile: phone && isMobilePhone(phone) ? phone : "",
phone: phone && !isMobilePhone(phone) ? phone : "",
is_primary: 1, // 新企业还没有联系人,首个联系人默认设为主联系人
});
} catch (e) {
// 通讯录写入失败不影响企业创建,用户可稍后在联系人中手动补录
}
}
async function handleSubmit() {
if (!formRef.value) return;
try {
@@ -127,11 +159,17 @@ async function handleSubmit() {
const res = await createCrmCustomer(payload);
row = res?.data || { id: null, customer_name: form.name };
}
const createdID = Number(row?.id ?? row?.customer_id ?? row?.supplier_id ?? 0);
// 新建企业时填写的联系人写入企业通讯录(先写入再回调,调用方刷新联系人时即可选到)
await syncContactToBook(createdID);
ElMessage.success(`${refTypeText.value}创建成功`);
emit("created", {
...row,
id: row?.id ?? row?.customer_id ?? row?.supplier_id ?? null,
id: createdID || null,
label: form.name,
// 一并回传联系人信息,供调用方自动带出对接人 / 电话
contact_person: form.contact_person,
contact_phone: form.contact_phone,
});
handleClose();
} catch (e) {
@@ -45,12 +45,30 @@
<div class="config-block">
<span class="config-label">我方角色</span>
<el-radio-group v-model="form.our_role">
<el-radio-button :value="0">无</el-radio-button>
<el-radio-button v-for="i in roleOptionsForCount" :key="i.value" :value="i.value">
{{ i.label }}
</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 我方不参与:如上中游项目中上游与中游主体之间签订的合同,甲乙双方均非本公司 -->
<el-alert
v-if="Number(form.our_role) === 0"
type="info"
:closable="false"
show-icon
title="我方不参与本合同签约:甲乙双方均从客户库 / 供应商库中选择,本公司不作为签约主体。"
style="margin: 0 16px 16px"
/>
<el-alert
v-else-if="selectedProjectType === 3"
type="warning"
:closable="false"
show-icon
title="该项目为上中游项目:若登记的是上游公司与中游主体之间签订的合同,我方不参与签约,请将「我方角色」切换为「无」;若是我方与中游主体之间的合同,保持当前角色即可。"
style="margin: 0 16px 16px"
/>
<div class="parties-grid">
<div v-for="role in activeRoles" :key="role.key">
<PartySelect
@@ -188,7 +206,7 @@
<!-- 步骤 2:产品清单 -->
<div v-show="activeStep === 1">
<ProductList :products="products" />
<ProductList :products="products" :our-role="form.our_role" />
</div>
</el-form>
@@ -321,8 +339,12 @@ const initParties = () => {
parties.value = PARTY_ROLES.slice(0, count).map((r) => emptyParty(r.key));
};
/** 当前租户在合同中的角色:由「我方角色」决定(1甲方/2乙方/3丙方/4丁方) */
/**
* 当前租户在合同中的角色:由「我方角色」决定(1甲方/2乙方/3丙方/4丁方)。
* 0=我方不参与(如上中游项目的链条合同):不锁定任何一方,全部参与方从客户/供应商库选择。
*/
const ownPartyRole = computed(() => {
if (Number(form.our_role) === 0) return "";
const idx = Math.min(Math.max(Number(form.our_role) || 2, 1), 4);
return PARTY_ROLES[idx - 1].key;
});
@@ -399,6 +421,16 @@ const handleProjectChange = (id) => {
form.project_name = hit?.project_name || "";
};
/**
* 当前选中项目的类型:1=单一 2=上下游 3=上中游。
* 上中游项目存在「上游与中游主体之间」的合同,我方不参与签约,需提示切换角色。
*/
const selectedProjectType = computed(() => {
if (!form.project_id) return 0;
const hit = projectOptions.value.find((i) => String(i.id) === String(form.project_id));
return Number(hit?.project_type) || 0;
});
/* ------------------------------ 联动 ------------------------------ */
/** 我方角色切换:原我方还原为待选方(保留签约人),新我方写入当前租户,其余各方不受影响 */
@@ -406,7 +438,9 @@ watch(
() => form.our_role,
(nv, ov) => {
if (!props.visible) return;
const roleKeyOf = (v) => PARTY_ROLES[Math.min(Math.max(Number(v) || 2, 1), 4) - 1].key;
// 0=我方不参与:没有对应的参与方
const roleKeyOf = (v) =>
Number(v) === 0 ? "" : PARTY_ROLES[Math.min(Math.max(Number(v) || 2, 1), 4) - 1].key;
const prevKey = roleKeyOf(ov);
const nextKey = roleKeyOf(nv);
if (prevKey === nextKey) return;
@@ -460,8 +494,9 @@ const setPartyCount = (v) => {
applyOwnParty();
};
/** 把当前租户(组织架构中的企业单位)写入本公司方 */
/** 把当前租户(组织架构中的企业单位)写入本公司方;我方不参与时不写入任何一方 */
const applyOwnParty = () => {
if (!ownPartyRole.value) return;
const own = parties.value.find((p) => p.role === ownPartyRole.value);
if (own) {
own.ref_type = 0;
@@ -482,8 +517,9 @@ watch(
isEdit.value = true;
internalId.value = props.editData.id;
Object.assign(form, emptyForm(), props.editData);
// 类型归一:后端返回数字,统一转为对应类型确保回显匹配
form.our_role = Number(props.editData.our_role) || 2;
// 类型归一:后端返回数字,统一转为对应类型确保回显匹配(0=我方不参与是合法值)
form.our_role =
props.editData.our_role == null ? 2 : Number(props.editData.our_role);
form.contract_category =
props.editData.contract_category == null ? "" : String(props.editData.contract_category);
form.party_count = Number(props.editData.party_count) || 2;
@@ -516,6 +552,7 @@ watch(
{
id: props.presetProject.id,
project_name: form.project_name || `项目 #${props.presetProject.id}`,
project_type: props.presetProject.project_type,
},
];
}
@@ -583,7 +620,7 @@ const buildPayload = (step, status) => {
sign_date: normalizeDate(form.sign_date),
effective_date: normalizeDate(form.effective_date),
expire_date: normalizeDate(form.expire_date),
our_role: Number(form.our_role) || 2,
our_role: Number(form.our_role) === 0 ? 0 : Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })),
products: products.value.map(({ __key, ...rest }) => ({
@@ -7,7 +7,7 @@
{{ contractStatusText(detail.status) }}
</el-tag> -->
<el-tag v-if="contractOverdue(detail).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(detail).days) }}
{{ contractOverdue(detail).text }}
</el-tag>
</div>
<!-- <div class="detail-sub">
@@ -25,7 +25,11 @@
<el-descriptions-item label="履约状态">
<el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="我方角色">{{ ourRoleText(detail.our_role) }}</el-descriptions-item>
<el-descriptions-item label="我方角色">
<el-tag :type="ourRoleTag(detail.our_role)" size="small" effect="plain">
{{ ourRoleText(detail.our_role) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="所属项目" :span="2">
<span v-if="detail.project_id">{{ detail.project_name }}</span>
<span v-else>-</span>
@@ -66,7 +70,10 @@
<el-table-column label="产品名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
<span>{{ row.name || (isDevRow(row) ? "软件开发" : "-") }}</span>
<!-- <el-tag v-if="isDevRow(row)" size="small" effect="plain" type="danger" class="dev-flag">开发</el-tag> -->
<!-- 软件开发行区分研发方式:自研 / 外协(别人帮我们研发) -->
<el-tag v-if="isDevRow(row)" size="small" effect="plain" :type="devModeTag(row.dev_mode)" class="dev-flag">
{{ devModeShortText(row.dev_mode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="类别" width="100" align="center">
@@ -103,9 +110,12 @@
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<!-- 我方为甲方(付款方)时显示「合同支出」(负数),否则显示「合同总利润」 -->
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
<span class="label">{{ contractProfitLabel(detail.our_role) }}</span>
<span class="value success">
{{ contractProfitText(detail.our_role, summary) }}
</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
@@ -130,13 +140,17 @@ import {
formatMoney,
formatDateOnly,
contractCategoryText,
devModeShortText,
devModeTag,
ourRoleText,
ourRoleTag,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
productCategoryText,
productCategoryTag,
contractProfitLabel,
contractProfitText,
} from "../../dict";
const props = defineProps({
@@ -11,37 +11,68 @@
</el-tooltip>
</div>
<el-table v-loading="loading" :data="list" border size="small">
<el-table-column label="状态" width="90" align="center">
<!-- 树形:计划为总条目(父行),其明细节点(进度 / 分期)以子条目挂在下方 -->
<el-table
v-loading="loading"
:data="treeRows"
border
size="small"
row-key="id"
:tree-props="{ children: '__itemRows' }"
default-expand-all
>
<el-table-column label="名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
<span v-if="!row.__isItem" class="plan-name">{{ paybackCycleText(row.plan_type) }}回款计划</span>
<span v-else class="item-name">{{ row.name || `节点${row.seq}` }}</span>
</template>
</el-table-column>
<el-table-column label="回款周期" width="90" align="center">
<el-table-column label="状态" width="85" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
<el-tag v-if="!row.__isItem" :type="paybackStatusTag(row.status)" size="small">
{{ paybackStatusText(row.status) }}
</el-tag>
<el-tag v-else :type="paybackItemStatusTag(row)" size="small">
{{ paybackItemStatusText(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="回款进度" min-width="140" align="center">
<el-table-column label="周期 / 进度" width="110" align="center">
<template #default="{ row }">
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
<el-tag v-if="!row.__isItem" size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
<span v-else class="item-percent">{{ Number(row.percent) > 0 ? `${Number(row.percent)}%` : "-" }}</span>
</template>
</el-table-column>
<el-table-column label="计划回款总额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
<el-table-column label="回款进度" min-width="125" align="center">
<template #default="{ row }">
<el-progress v-if="!row.__isItem" :percentage="paybackPercent(row)" :stroke-width="10" />
<el-progress
v-else
:percentage="paybackItemPercent(row)"
:stroke-width="8"
:status="itemStatusOf(row) === 2 ? 'success' : undefined"
/>
</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
<el-table-column label="计划回款总额" width="115" align="right">
<template #default="{ row }">{{ formatMoney(row.__isItem ? row.amount : row.total_amount) }}</template>
</el-table-column>
<el-table-column label="回款方式" width="100" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
<el-table-column label="已回款" width="105" align="right">
<template #default="{ row }">
<span v-if="!row.__isItem">{{ formatMoney(row.received_amount) }}</span>
<span v-else class="amount">{{ formatMoney(itemReceivedOf(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
<el-table-column label="计划回款日期" width="105" align="center">
<template #default="{ row }">
<span v-if="row.__isItem">{{ row.plan_date || "-" }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
<!-- 仅顶级(计划)有详情按钮;回款登记统一走「回款进度」区块的新建回款 -->
<el-button v-if="!row.__isItem" link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
<template #empty>
@@ -59,7 +90,7 @@
</div>
<el-tooltip :disabled="list.length > 0" content="回款进度以回款计划为先导条件,请先新建回款计划" placement="top">
<span class="btn-wrap">
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive">
<el-button type="primary" size="small" :icon="Plus" :disabled="!list.length" @click="openReceive()">
新建回款
</el-button>
</span>
@@ -71,7 +102,10 @@
<template #default="{ row }">{{ row.received_date || "-" }}</template>
</el-table-column>
<el-table-column label="对应回款计划" min-width="180" show-overflow-tooltip>
<template #default="{ row }">{{ planText(planOf(row.payback_id)) }}</template>
<template #default="{ row }">
{{ planText(planOf(row.payback_id)) }}
<span v-if="row.item_name" class="item-tag">· {{ row.item_name }}</span>
</template>
</el-table-column>
<el-table-column label="本次回款金额" width="130" align="right">
<template #default="{ row }">
@@ -149,6 +183,22 @@
<el-option v-for="p in list" :key="p.id" :label="planText(p)" :value="p.id" />
</el-select>
</el-form-item>
<el-form-item label="进度节点">
<!-- 按现有进度选择性付款:不指定时按整单登记 -->
<el-select
v-model="receiveForm.item_seq"
clearable
placeholder="不指定(按整单登记)"
style="width: 100%"
>
<el-option
v-for="it in planItems(receiveForm.payback_id)"
:key="it.seq"
:label="`${it.name || '节点' + it.seq}(¥${formatMoney(it.amount)})`"
:value="it.seq"
/>
</el-select>
</el-form-item>
<el-form-item label="本次回款" required>
<el-input-number
v-model="receiveForm.amount"
@@ -296,6 +346,68 @@ function planText(p) {
)}`;
}
/** 金额保留两位小数 */
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/* ---------------------- 树形结构:计划(总条目) → 明细节点(子条目) ---------------------- */
/**
* 树形行数据:计划为父行,其 items 明细(进度节点 / 分期)为子行;
* 子行 row-key 全局唯一(`{计划ID}-item-{序号}`);父行的回款记录(children)仅用于节点已回款汇总,
* 不参与树形展示(tree-props 显式指向 __itemRows)。
*/
const treeRows = computed(() =>
list.value.map((p) => ({
...p,
__isItem: false,
__itemRows: (Array.isArray(p.items) ? p.items : []).map((it) => ({
id: `${p.id}-item-${it.seq}`,
__isItem: true,
__plan: p,
...it,
})),
}))
);
/** 指定计划的明细节点列表(供新建回款选择进度节点) */
function planItems(paybackId) {
const p = list.value.find((i) => Number(i.id) === Number(paybackId));
return Array.isArray(p?.items) ? p.items : [];
}
/** 节点已回款:按「标记到该节点的回款记录」汇总,兼容历史上按明细直接登记(received_amount)的值 */
function itemReceivedOf(row) {
const plan = row.__plan;
const fromRecords = (Array.isArray(plan?.children) ? plan.children : [])
.filter((r) => Number(r.item_seq) === Number(row.seq))
.reduce((s, r) => s + (Number(r.amount) || 0), 0);
const legacy = Number(row.received_amount) || 0;
return Math.max(round2(fromRecords), legacy);
}
/** 节点回款进度百分比 */
function paybackItemPercent(row) {
const total = Number(row.amount) || 0;
if (!total) return 0;
return Math.min(100, Math.round((itemReceivedOf(row) / total) * 100));
}
/** 节点状态:0未回款 1部分回款 2已收齐 */
function itemStatusOf(row) {
const total = Number(row.amount) || 0;
const received = itemReceivedOf(row);
if (total > 0 && received >= total) return 2;
return received > 0 ? 1 : 0;
}
function paybackItemStatusText(row) {
return { 0: "未回款", 1: "部分回款", 2: "已收齐" }[itemStatusOf(row)] || "-";
}
function paybackItemStatusTag(row) {
return { 0: "info", 1: "warning", 2: "success" }[itemStatusOf(row)] || "info";
}
/** 回款计划是否已全部收齐(总进度达到 100%),收齐后禁止再新建回款计划 */
const paidOff = computed(() => {
const planned = Number(progressSummary.value.total_planned) || 0;
@@ -386,6 +498,7 @@ const receiveVisible = ref(false);
const receiveSaving = ref(false);
const receiveForm = reactive({
payback_id: null,
item_seq: 0, // 进度节点序号(1-based),0=不指定(按整单登记)
amount: 0,
received_date: "",
pay_method: 1,
@@ -394,7 +507,10 @@ const receiveForm = reactive({
remark: "",
});
/** 新建回款:以回款计划为先导条件(无计划时不允许登记) */
/**
* 新建回款:以回款计划为先导条件(无计划时不允许登记)。
* 计划与进度节点(按进度选择性付款)都在弹窗内选择。
*/
function openReceive() {
if (!list.value.length) {
ElMessage.warning("请先新建回款计划,回款进度以计划为先导条件");
@@ -403,6 +519,7 @@ function openReceive() {
const first = list.value[0];
Object.assign(receiveForm, {
payback_id: first.id,
item_seq: 0,
amount: 0,
received_date: todayStr(),
pay_method: first.pay_method || 1,
@@ -413,10 +530,11 @@ function openReceive() {
receiveVisible.value = true;
}
/** 切换回款计划:同步该计划的回款方式作为默认值 */
/** 切换回款计划:同步该计划的回款方式作为默认值,并清空进度节点选择 */
function onPlanChange(pid) {
const p = planOf(pid);
if (p) receiveForm.pay_method = p.pay_method || 1;
receiveForm.item_seq = 0;
}
const uploading = ref(false);
@@ -515,6 +633,7 @@ async function submitReceive() {
try {
await createPaybackRecord({
payback_id: receiveForm.payback_id,
item_seq: Number(receiveForm.item_seq) || 0,
amount: Number(receiveForm.amount) || 0,
received_date: receiveForm.received_date || "",
pay_method: receiveForm.pay_method,
@@ -602,6 +721,29 @@ defineExpose({ reload });
color: #409eff;
}
/* 树形父行:计划名称 */
.plan-name {
font-weight: 600;
color: var(--el-text-color-primary);
}
/* 树形子条目:进度节点名称 + 百分比 */
.item-name {
font-weight: 500;
color: var(--el-text-color-primary);
}
.item-percent {
margin-left: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.item-tag {
margin-left: 2px;
color: var(--el-color-primary);
}
.receipt-block {
width: 100%;
+92 -22
View File
@@ -14,6 +14,7 @@
<!-- 统计周期 -->
<div class="stat-period-bar">
<el-radio-group v-model="statsPeriod" @change="onPeriodChange">
<el-radio-button label="all">全部</el-radio-button>
<el-radio-button label="month">本月</el-radio-button>
<el-radio-button label="quarter">本季</el-radio-button>
<el-radio-button label="year">本年</el-radio-button>
@@ -30,10 +31,12 @@
:clearable="false"
@change="onRangeChange"
/>
<!-- 当前统计范围:切换周期后卡片按该区间重新统计(区间相同则数字自然相同) -->
<span class="stat-scope">统计范围:{{ statsScopeText }}</span>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-cards" v-loading="statsLoading">
<div class="stat-card">
<span class="stat-label">合同总数</span>
<span class="stat-value">{{ stats.total ?? 0 }}</span>
@@ -47,7 +50,12 @@
<span class="stat-value">{{ formatMoney(stats.total_cost) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">合同总利润</span>
<el-tooltip
content="仅统计非甲方(收款方)合同的利润;我方为甲方的合同属于支出,不计入利润"
placement="top"
>
<span class="stat-label">合同总利润</span>
</el-tooltip>
<span class="stat-value" :style="profitStyle(stats.total_profit)">{{ profitText(stats.total_profit) }}</span>
</div>
</div>
@@ -65,8 +73,8 @@
/>
</el-form-item>
<el-form-item label="我方角色">
<el-select v-model="filters.our_role" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in OUR_ROLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
<el-select v-model="filters.our_role" clearable placeholder="全部" style="width: 130px">
<el-option v-for="i in ourRoleFilterOptions" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="合同分类">
@@ -101,8 +109,13 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip />
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip fixed>
<el-table-column label="我方角色" width="90" align="center" fixed>
<template #default="{ row }">
<el-tag :type="ourRoleTag(row.our_role)" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
</template>
</el-table-column>
<!-- <el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip /> -->
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip >
<template #default="{ row }">
<span class="name-link" @click="openRow(row)">
{{ row.contract_name }}
@@ -110,11 +123,6 @@
</span>
</template>
</el-table-column>
<el-table-column label="我方角色" width="90" align="center">
<template #default="{ row }">
<el-tag type="primary" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="100" align="center">
<template #default="{ row }">{{ contractCategoryText(row.contract_category) }}</template>
</el-table-column>
@@ -128,20 +136,28 @@
{{ formatMoney(row.summary?.total_amount ?? row.total_amount) }}
</template>
</el-table-column>
<el-table-column label="合同总利润" width="130" align="right">
<el-table-column label="合同利润/支出" width="140" align="right">
<template #header>
<el-tooltip
content="我方为甲方(付款方):按负数(绿色)显示合同支出 = 合同总金额;其余角色:显示合同利润 = 总金额 − 产品总成本"
placement="top"
>
<span>合同利润/支出</span>
</el-tooltip>
</template>
<template #default="{ row }">
<span :style="profitStyle(row.summary?.total_profit ?? row.total_profit)">
{{ profitText(row.summary?.total_profit ?? row.total_profit) }}
<span :style="profitStyle(contractProfitOrExpense(row))">
{{ profitText(contractProfitOrExpense(row)) }}
</span>
</template>
</el-table-column>
<el-table-column label="签订日期" width="110" align="center">
<template #default="{ row }">{{ formatDateOnly(row.sign_date) }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<el-table-column label="逾期" width="130" align="center">
<template #default="{ row }">
<el-tag v-if="contractOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(row).days) }}
{{ contractOverdue(row).text }}
</el-tag>
<span v-else>-</span>
</template>
@@ -184,7 +200,7 @@
</template>
<script setup>
import { ref, reactive, onMounted, nextTick, watch } from "vue";
import { ref, reactive, computed, onMounted, nextTick, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
@@ -197,16 +213,17 @@ import ContractCreate from "./components/create.vue";
import ContractDetail from "./components/detail.vue";
import {
CONTRACT_CATEGORY_OPTIONS,
OUR_ROLE_OPTIONS,
ourRoleFilterOptions,
CONTRACT_STATUS_OPTIONS,
ourRoleText,
ourRoleTag,
contractCategoryText,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
formatDateOnly,
formatMoney,
isPayerContract,
} from "../dict";
const route = useRoute();
@@ -228,15 +245,46 @@ const filters = reactive({
});
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 统计周期:month/quarter/year/custom(自定义选区间),默认本年 */
const statsPeriod = ref("year");
/** 统计周期:all(全部)/ month / quarter / year / custom(自定义选区间),默认全部 */
const statsPeriod = ref("all");
const statsRange = ref([]);
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 });
/** 卡片加载态:切换周期时给出可见的刷新反馈 */
const statsLoading = ref(false);
/** 周期参数:统计与列表共用,保证两者口径一致 */
/** 当前统计范围文案:与后端口径一致(合同有效期与所选周期重叠),便于对照卡片数字 */
const statsScopeText = computed(() => {
const pad = (n) => String(n).padStart(2, "0");
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
const now = new Date();
const y = now.getFullYear();
const m = now.getMonth();
switch (statsPeriod.value) {
case "month":
return `${fmt(new Date(y, m, 1))} ~ ${fmt(new Date(y, m + 1, 0))}`;
case "quarter": {
const q = Math.floor(m / 3);
return `${fmt(new Date(y, q * 3, 1))} ~ ${fmt(new Date(y, q * 3 + 3, 0))}`;
}
case "year":
return `${y}-01-01 ~ ${y}-12-31`;
case "custom":
return statsRange.value?.length === 2
? `${statsRange.value[0]} ~ ${statsRange.value[1]}`
: "自定义区间";
default:
return "全部时间(不限有效期)";
}
});
/**
* 周期参数:统计与列表共用,保证两者口径一致。
* 「全部」不传周期参数——列表不按有效期过滤,避免有效期不在所选周期内的合同(如历史合同)被隐藏。
*/
function periodParams() {
if (statsPeriod.value === "all") return {};
const params = { period: statsPeriod.value };
if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) {
params.start_date = statsRange.value[0];
@@ -246,11 +294,14 @@ function periodParams() {
}
async function loadStats() {
statsLoading.value = true;
try {
const res = await getContractStats(periodParams());
stats.value = res?.data || stats.value;
} catch {
/* 统计失败不影响列表 */
} finally {
statsLoading.value = false;
}
}
@@ -373,6 +424,18 @@ function profitStyle(val) {
return {};
}
/**
* 列表展示值:我方为甲方(付款方)的合同是「支出」,取负值 → 显示为 -¥X(绿色);
* 其余角色的合同是「利润」(总金额 − 产品总成本)。表头已注明口径,单元格内不写"支出"文字。
*/
function contractProfitOrExpense(row) {
if (!isPayerContract(row.our_role)) {
return Number(row.summary?.total_profit ?? row.total_profit) || 0;
}
const paid = Number(row.summary?.total_amount ?? row.total_amount) || 0;
return -paid;
}
function openCreate() {
currentRow.value = null;
initStep.value = 1;
@@ -434,6 +497,13 @@ async function handleDelete(row) {
gap: 12px;
margin-bottom: 12px;
flex-wrap: wrap;
/* 当前统计范围:右对齐说明,切换周期后随卡片一起刷新 */
.stat-scope {
margin-left: auto;
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.stat-card {
+58 -4
View File
@@ -81,6 +81,10 @@
<el-table-column label="客户名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.customer_name }}</span>
<!-- 同一家单位可能既是客户又是供应商(已建供应商档案) -->
<el-tag v-if="row.also_supplier" size="small" type="warning" effect="plain" style="margin-left: 6px">
也是供应商
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="110" align="center">
@@ -111,10 +115,11 @@
<el-table-column label="更新时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.update_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="280" align="center" fixed="right">
<el-table-column label="操作" width="350" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openContact(row)">联系人</el-button>
<el-button link type="success" size="small" @click="handleConvertSupplier(row)">转为供应商</el-button>
<el-button link type="warning" size="small" @click="openMovePool([row])">移入公海</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
@@ -151,6 +156,10 @@
size="700px"
>
<div v-if="detailData" class="detail-content">
<div class="detail-actions">
<el-button type="success" size="small" @click="handleConvertSupplier(detailData)">转为供应商</el-button>
<span class="detail-actions-tip">同一家单位可能既是客户又是供应商,转化后原客户记录保留</span>
</div>
<el-descriptions title="基本信息" :column="2" border size="small">
<el-descriptions-item label="客户名称" :span="2">
{{ detailData.customer_name }}
@@ -254,12 +263,14 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="mobile" label="手机号" width="130" />
<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="170" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) || row.mobile || "-" }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="wechat" label="微信" width="120" show-overflow-tooltip />
<el-table-column prop="qq" label="QQ" width="110" show-overflow-tooltip />
<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="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openContactEdit(row)">编辑</el-button>
@@ -323,6 +334,7 @@ import {
getCrmCustomerList,
deleteCrmCustomer,
moveToPool,
convertCustomerToSupplier,
} from "@/api/crm";
import { listContacts, deleteContact } from "@/api/contact";
import CustomerEdit from "./components/edit.vue";
@@ -341,6 +353,7 @@ import {
statusTag,
formatDate,
formatDateTime,
formatMobiles,
} from "../dict";
const loading = ref(false);
@@ -465,6 +478,35 @@ async function handleDelete(row) {
}
}
/**
* 客户转供应商:把客户的企业资料与联系人复制一套到供应商库。
* 同一家单位可能既是客户又是供应商,原客户记录保留;供应商库已有同名企业时只补同步联系人,不重复建档。
*/
async function handleConvertSupplier(row) {
try {
await ElMessageBox.confirm(
`将客户「${row.customer_name}」的企业资料与联系人复制一套到供应商库?原客户记录会保留,可在供应商管理中继续完善。`,
"转为供应商",
{ type: "warning", confirmButtonText: "确认转化" }
);
} catch (e) {
return; // 取消
}
try {
const res = await convertCustomerToSupplier(row.id);
const data = res?.data || {};
const c = data.contacts || {};
ElMessage.success(
data.existed
? `供应商库已有同名企业,已同步联系人(新增 ${c.inserted || 0}、补充 ${c.merged || 0}),未重复建档`
: `已转为供应商,并同步联系人(新增 ${c.inserted || 0})`
);
fetchList();
} catch (e) {
ElMessage.error(e.message || "转化失败");
}
}
/* ------------------------------ 联系人 ------------------------------ */
async function openContact(row) {
currentRow.value = { ...row };
@@ -621,6 +663,18 @@ async function submitMovePool() {
.detail-content {
padding: 4px 8px;
.detail-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.detail-actions-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-descriptions__label) {
width: 130px;
min-width: 130px;
+181 -24
View File
@@ -163,7 +163,7 @@ export const PIPELINE_LEVEL_OPTIONS = [
export const CLUE_STATUS_OPTIONS = [
{ label: "跟进中", value: "1" },
{ label: "已转化", value: "2" },
{ label: "已关闭", value: "3" },
{ label: "已废弃", value: "3" },
];
/** 商机阶段 */
@@ -189,6 +189,19 @@ export const PROJECT_STATUS_OPTIONS = [
{ label: "进行中", value: "2" },
{ label: "已完成", value: "3" },
{ label: "已暂停", value: "4" },
{ label: "异常", value: "5" },
];
/**
* 项目类型:
* 1单一项目(仅上游客户)
* 2上下游项目(上游客户 + 下游供应商/客户)
* 3上中游项目(上游 A 公司 + 中游 B 公司,下游为本公司)
*/
export const PROJECT_TYPE_OPTIONS = [
{ label: "单一项目", value: "1" },
{ label: "上下游项目", value: "2" },
{ label: "上中游项目", value: "3" },
];
/** 回访方式 */
@@ -213,13 +226,16 @@ const CLUE_STATUS_MAP = CLUE_STATUS_OPTIONS.reduce((m, i) => ((m[i.value] = i.la
const BUSINESS_STAGE_MAP = BUSINESS_STAGE_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const BUSINESS_STATUS_MAP = BUSINESS_STATUS_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PROJECT_STATUS_MAP = PROJECT_STATUS_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PROJECT_TYPE_MAP = PROJECT_TYPE_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const FOLLOW_TYPE_MAP = FOLLOW_TYPE_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PIPELINE_LEVEL_TAG = { 1: "danger", 2: "warning", 3: "info" };
const CLUE_STATUS_TAG = { 1: "primary", 2: "success", 3: "info" };
const BUSINESS_STAGE_TAG = { 1: "info", 2: "primary", 3: "warning", 4: "warning", 5: "success", 6: "danger" };
const BUSINESS_STATUS_TAG = { 1: "primary", 2: "success", 3: "info" };
const PROJECT_STATUS_TAG = { 1: "info", 2: "primary", 3: "success", 4: "warning" };
const PROJECT_STATUS_TAG = { 1: "info", 2: "primary", 3: "success", 4: "warning", 5: "danger" };
const PROJECT_TYPE_SHORT = { 1: "单一", 2: "上下游", 3: "上中游" };
const PROJECT_TYPE_TAG = { 1: "info", 2: "warning", 3: "primary" };
export const clueSourceText = (val) => CLUE_SOURCE_MAP[normalize(val)] || "-";
export const pipelineLevelText = (val) => PIPELINE_LEVEL_MAP[normalize(val)] || "-";
@@ -232,6 +248,12 @@ export const businessStatusText = (val) => BUSINESS_STATUS_MAP[normalize(val)] |
export const businessStatusTag = (val) => BUSINESS_STATUS_TAG[normalize(val)] || "info";
export const projectStatusText = (val) => PROJECT_STATUS_MAP[normalize(val)] || "-";
export const projectStatusTag = (val) => PROJECT_STATUS_TAG[normalize(val)] || "info";
/** 项目类型:1单一项目 / 2上下游项目 / 3上中游项目(上中游=上游 A + 中游 B,下游为本公司) */
export const projectTypeText = (val) => PROJECT_TYPE_MAP[normalize(val)] || "单一项目";
/** 列表用的项目类型短标签:单一 / 上下游 / 上中游 */
export const projectTypeShortText = (val) => PROJECT_TYPE_SHORT[normalize(val)] || "单一";
export const projectTypeTag = (val) => PROJECT_TYPE_TAG[normalize(val)] || "info";
export const followTypeText = (val) => FOLLOW_TYPE_MAP[normalize(val)] || "-";
export const pipelineRelatedTypeText = (val) => {
@@ -245,6 +267,28 @@ const PIPELINE_STAGE_TAG = { 1: "info", 2: "primary", 3: "success" };
export const pipelineStageText = (val) => pipelineRelatedTypeText(val);
export const pipelineStageTag = (val) => PIPELINE_STAGE_TAG[normalize(val)] || "info";
/** 多个手机号格式化:JSON数组 / 逗号分隔 / 单个号码 → "138…、139…"(无内容返回空串) */
export function formatMobiles(val) {
if (!val) return "";
let arr = [];
if (Array.isArray(val)) {
arr = val;
} else {
const s = String(val).trim();
if (!s) return "";
try {
const parsed = JSON.parse(s);
arr = Array.isArray(parsed) ? parsed : [s];
} catch (e) {
arr = s.split(/[,,、;;\s]+/);
}
}
return arr
.map((m) => String(m || "").trim())
.filter(Boolean)
.join("、");
}
/** 金额格式化:12345.6 -> 12,345.60 */
export function formatMoney(val) {
if (val === undefined || val === null || val === "") return "-";
@@ -283,36 +327,82 @@ export function overdueDays(dateStr) {
return Math.round((t - d) / 86400000);
}
/** 逾期文案:days>0 -> "逾期 N 天",否则空串 */
/**
* 逾期时长换算(日历口径、含闰年):
* 从 startDate 到今天,先取满整年数 x(闰日 2-29 加年落到 2-28),
* 再取扣除整年后的剩余天数 y;不足 1 年按总天数展示。
* 返回 { days, years, rest, text }:text 如 "逾期 1 年 35 天" / "逾期 12 天"(未逾期为空串)。
*/
export function overdueDuration(startDate) {
const days = overdueDays(startDate);
if (days <= 0) return { days: 0, years: 0, rest: 0, text: "" };
const start = new Date(startDate);
if (isNaN(start.getTime())) return { days, years: 0, rest: 0, text: `逾期 ${days} 天` };
start.setHours(0, 0, 0, 0);
const today = new Date();
today.setHours(0, 0, 0, 0);
// 加 n 年:闰日 2-29 在平年落到 2-28,避免日期溢出到下月
const addYears = (d, n) => {
const y = d.getFullYear() + n;
const m = d.getMonth();
const day = Math.min(d.getDate(), new Date(y, m + 1, 0).getDate());
return new Date(y, m, day);
};
let years = 0;
while (addYears(start, years + 1) <= today) years++;
const anchor = addYears(start, years);
const rest = Math.round((today - anchor) / 86400000);
let text = `逾期 ${days} 天`;
if (years >= 1) text = rest > 0 ? `逾期 ${years} 年 ${rest} 天` : `逾期 ${years} 年`;
return { days, years, rest, text };
}
/** 逾期文案(简易版):days>0 -> "逾期 N 天",否则空串。
* 需要"x 年 y 天"的日历换算(含闰年)时,用 overdueDuration(startDate) 或各 overdue() 返回的 text。 */
export function overdueText(days) {
return days > 0 ? `逾期 ${days} 天` : "";
}
/** 项目逾期信息:{ overdue, days } */
/** 项目逾期信息:{ overdue, days, text }(text 为"逾期 x 年 y 天"日历换算) */
export function projectOverdue(row) {
// 项目起止时间以关联合同汇总为准(contract_end_date),兼容历史的手工日期 end_date
const days = overdueDays(row?.contract_end_date || row?.end_date);
const dateStr = row?.contract_end_date || row?.end_date;
const days = overdueDays(dateStr);
const overdue = days > 0 && Number(row?.status) !== 3;
return { overdue, days: overdue ? days : 0 };
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(dateStr).text : "" };
}
/** 合同逾期信息:{ overdue, days } */
/** 合同逾期信息:{ overdue, days, text }(text 为"逾期 x 年 y 天"日历换算) */
export function contractOverdue(row) {
const days = overdueDays(row?.expire_date);
const overdue = days > 0 && ![2, 3].includes(Number(row?.status));
return { overdue, days: overdue ? days : 0 };
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(row?.expire_date).text : "" };
}
/** 回款明细逾期信息:{ overdue, days } */
/** 回款明细逾期信息:{ overdue, days, text }
* 已收齐(status=3 已完成 或 已回款金额达计划金额)不算逾期;
* 按「回款记录」挂到节点的汇总口径见 paybackPlanOverdue。 */
export function paybackItemOverdue(item) {
const days = overdueDays(item?.plan_date);
const overdue = days > 0 && Number(item?.status) !== 3;
return { overdue, days: overdue ? days : 0 };
const amount = Number(item?.amount) || 0;
const received = Number(item?.received_amount) || 0;
const settled = Number(item?.status) === 3 || (amount > 0 && received >= amount);
const overdue = days > 0 && !settled;
return { overdue, days: overdue ? days : 0, text: overdue ? overdueDuration(item?.plan_date).text : "" };
}
/** 回款计划逾期:任一明细逾期即视为计划逾期(items 可能为数组或 JSON 字符串)。
* 返回 { overdue, days },days 取逾期明细中最大的逾期天数。 */
/**
* 回款计划逾期:任一「未收齐」明细超过计划回款日期即视为计划逾期,
* 天数取逾期明细中最大天数,文案按该明细的计划日期换算(含闰年)。
*
* 明细「已收齐」判定(与回款登记机制保持一致):
* - 计划整体已完成(status=2);
* - 明细旧登记值 received_amount >= amount(历史「进度登记」机制);
* - 计划回款记录(children,按 item_seq 挂到明细)金额合计 >= amount(当前「新建回款」机制)。
* 注意:登记回款记录不会回写明细 status,因此不能再以明细 status 判定是否逾期。
*/
export function paybackPlanOverdue(payback) {
if (Number(payback?.status) === 2) return { overdue: false, days: 0, text: "" };
let items = payback?.items;
if (typeof items === "string" && items.trim()) {
try {
@@ -322,16 +412,30 @@ export function paybackPlanOverdue(payback) {
}
}
if (!Array.isArray(items)) items = [];
// 回款记录按明细序号汇总(item_seq=0 的记录按整单登记,不计入任何明细)
const receivedBySeq = {};
for (const r of Array.isArray(payback?.children) ? payback.children : []) {
const seq = Number(r?.item_seq) || 0;
if (seq > 0) receivedBySeq[seq] = (receivedBySeq[seq] || 0) + (Number(r?.amount) || 0);
}
let maxDays = 0;
let overdue = false;
let maxDate = "";
for (const it of items) {
const r = paybackItemOverdue(it);
if (r.overdue) {
overdue = true;
if (r.days > maxDays) maxDays = r.days;
const amount = Number(it?.amount) || 0;
const received = Math.max(
Number(it?.received_amount) || 0,
receivedBySeq[Number(it?.seq)] || 0
);
const settled = Number(it?.status) === 3 || (amount > 0 && received >= amount);
if (settled) continue;
const days = overdueDays(it?.plan_date);
if (days > maxDays) {
maxDays = days;
maxDate = it?.plan_date;
}
}
return { overdue, days: overdue ? maxDays : 0 };
const overdue = maxDays > 0;
return { overdue, days: overdue ? maxDays : 0, text: overdue ? overdueDuration(maxDate).text : "" };
}
/* =====================================================================
@@ -371,12 +475,12 @@ export const CONTRACT_PARTY_COUNT_OPTIONS = [
{ label: "四方合同", value: 4 },
];
/** 合同状态:1=草稿 2=已完成 3=已作废 4=履约中 5=执行异常 */
/** 合同状态:1=草稿 2=已完成 3=已作废 4=履约中 5=异常 */
export const CONTRACT_STATUS_OPTIONS = [
{ label: "草稿", value: "1" },
{ label: "已完成", value: "2" },
{ label: "履约中", value: "4" },
{ label: "执行异常", value: "5" },
{ label: "异常", value: "5" },
{ label: "已作废", value: "3" },
];
@@ -403,7 +507,8 @@ const CONTRACT_CATEGORY_MAP = CONTRACT_CATEGORY_OPTIONS.reduce(
);
const OUR_ROLE_MAP = OUR_ROLE_OPTIONS.reduce(
(m, i) => ((m[i.value] = i.label), m),
{}
// 0=我方不参与:如上中游项目中上游与中游主体之间的合同,我方不是签约方
{ 0: "无" }
);
const CONTRACT_STATUS_MAP = CONTRACT_STATUS_OPTIONS.reduce(
(m, i) => ((m[i.value] = i.label), m),
@@ -420,10 +525,41 @@ const PRODUCT_CATEGORY_TAG = { 1: "warning", 2: "primary", 3: "success", 4: "dan
export const contractCategoryText = (val) =>
CONTRACT_CATEGORY_MAP[normalize(val)] || normalize(val) || "-";
/** 我方角色文案:1甲方/2乙方/3丙方/4丁方 */
/** 我方角色文案:1甲方/2乙方/3丙方/4丁方/0我方不参与 */
export const ourRoleText = (val) =>
OUR_ROLE_MAP[normalize(val)] || normalize(val) || "-";
export const ourRoleTag = () => "primary";
/** 我方角色标签颜色:甲方红 / 乙方蓝 / 丙方橙 / 丁方绿 / 我方不参与灰 */
const OUR_ROLE_TAG = { 0: "info", 1: "danger", 2: "primary", 3: "warning", 4: "success" };
export const ourRoleTag = (val) => OUR_ROLE_TAG[normalize(val)] || "primary";
/**
* 合同利润 / 支出口径(全模块统一):
* 我方为甲方(1)= 采购 / 付款方,合同金额即支出,不存在利润;
* 其余角色(乙方 / 丙方 / 丁方)= 收款方,按「合同总金额 − 产品总成本」计利润。
*/
export const isPayerContract = (ourRole) => Number(ourRole) === 1;
/** 金额汇总标题:甲方合同显示「合同支出」,其余显示「合同总利润」 */
export const contractProfitLabel = (ourRole) =>
isPayerContract(ourRole) ? "合同支出" : "合同总利润";
/** 金额汇总数值:甲方合同取合同总金额(支出),其余取 总金额 − 总成本(利润) */
export const contractProfitValue = (ourRole, summary) => {
const s = summary || {};
return isPayerContract(ourRole) ? Number(s.total_amount) || 0 : Number(s.total_profit) || 0;
};
/**
* 金额汇总显示文案:甲方合同为支出,按负数展示(如 -¥1,000.00,绿色);
* 其余角色为利润金额(如 ¥1,000.00)。
*/
export const contractProfitText = (ourRole, summary) => {
const value = contractProfitValue(ourRole, summary);
return isPayerContract(ourRole)
? `-¥${formatMoney(Math.abs(value))}`
: `¥${formatMoney(value)}`;
};
export const contractStatusText = (val) =>
CONTRACT_STATUS_MAP[normalize(val)] || normalize(val) || "-";
export const contractStatusTag = (val) => CONTRACT_STATUS_TAG[normalize(val)] || "info";
@@ -462,6 +598,25 @@ const PRODUCT_STATUS_TAG = { 1: "success", 0: "info" };
export const productStatusText = (val) => PRODUCT_STATUS_MAP[normalize(val)] || "-";
export const productStatusTag = (val) => PRODUCT_STATUS_TAG[normalize(val)] || "info";
/* ---------------------------------------------------------------------
* 软件开发研发方式(软件开发行的特例区分)
* self=自行研发(默认,自己团队开发)/ outsource=外协研发(别人帮我们研发,成本按外包合同总额计)
* ------------------------------------------------------------------- */
export const DEV_MODE_OPTIONS = [
{ label: "自行研发", value: "self" },
{ label: "外协研发", value: "outsource" },
];
const DEV_MODE_MAP = { self: "自行研发", outsource: "外协研发" };
const DEV_MODE_SHORT_MAP = { self: "自研", outsource: "外协" };
const DEV_MODE_TAG = { self: "success", outsource: "warning" };
/** 研发方式文案:空值按默认「自行研发」展示 */
export const devModeText = (val) => DEV_MODE_MAP[normalize(val)] || DEV_MODE_MAP.self;
/** 研发方式短文案(列表标签用):自研 / 外协 */
export const devModeShortText = (val) => DEV_MODE_SHORT_MAP[normalize(val)] || DEV_MODE_SHORT_MAP.self;
export const devModeTag = (val) => DEV_MODE_TAG[normalize(val)] || "success";
/** 税率格式化:13 -> 13% */
export function formatPercent(val) {
if (val === undefined || val === null || val === "") return "-";
@@ -474,6 +629,8 @@ export function formatPercent(val) {
export const contractCategoryOptions = CONTRACT_CATEGORY_OPTIONS;
export const contractStatusOptions = CONTRACT_STATUS_OPTIONS;
export const ourRoleOptions = OUR_ROLE_OPTIONS;
/** 我方角色筛选选项:在甲乙丙丁之外补充「我方不参与」(0) */
export const ourRoleFilterOptions = [{ label: "无", value: 0 }, ...OUR_ROLE_OPTIONS];
export const contractPartyCountOptions = CONTRACT_PARTY_COUNT_OPTIONS;
export const contractProductCategoryOptions = CONTRACT_PRODUCT_CATEGORY_OPTIONS;
@@ -59,14 +59,46 @@
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<!-- 计划明细:进度节点 / 分期(可按节点选择性登记回款) -->
<template v-if="planItems.length">
<div class="detail-section-title">
<span>计划明细</span>
<span class="section-tip">共 {{ planItems.length }} 个节点,点击「登记回款」可按进度付款</span>
</div>
<el-table :data="planItems" border size="small" class="items-table">
<el-table-column label="名称" min-width="110" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || `节点${row.seq}` }}</template>
</el-table-column>
<el-table-column label="百分比" width="70" align="center">
<template #default="{ row }">{{ Number(row.percent) > 0 ? `${Number(row.percent)}%` : "-" }}</template>
</el-table-column>
<el-table-column label="计划金额" width="110" align="right">
<template #default="{ row }">¥{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="已回款" width="110" align="right">
<template #default="{ row }">
<span class="success-text">¥{{ formatMoney(itemReceivedOf(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="计划回款日期" width="110" align="center">
<template #default="{ row }">{{ row.plan_date || "-" }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="itemStatusTagOf(row)" size="small">{{ itemStatusTextOf(row) }}</el-tag>
</template>
</el-table-column>
</el-table>
</template>
<!-- 回款明细:一笔笔实际回款记录 -->
<div class="detail-section-title">
<div class="detail-section-title" style="margin-top: 18px">
<span>回款明细</span>
<div class="section-right">
<span class="section-tip">
共 {{ records.length }} 笔,累计 ¥{{ formatMoney(receivedSum) }}
</span>
<el-button type="primary" size="small" :icon="Plus" @click="openRecord">新建回款记录</el-button>
<el-button type="primary" size="small" :icon="Plus" @click="openRecord()">新建回款记录</el-button>
</div>
</div>
@@ -74,6 +106,12 @@
<el-table-column label="回款日期" width="120" align="center">
<template #default="{ row }">{{ row.received_date || "-" }}</template>
</el-table-column>
<el-table-column label="进度节点" width="110" align="center">
<template #default="{ row }">
<span v-if="row.item_name">{{ row.item_name }}</span>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="本次回款金额" min-width="140" align="right">
<template #default="{ row }">
<span class="success-text">¥{{ formatMoney(row.amount) }}</span>
@@ -122,6 +160,21 @@
<!-- 新建回款记录(当前计划固定,无需再选) -->
<el-dialog v-model="recordVisible" title="新建回款记录" width="460px" append-to-body>
<el-form :model="recordForm" label-width="90px">
<el-form-item v-if="planItems.length" label="进度节点">
<el-select
v-model="recordForm.item_seq"
clearable
placeholder="不指定(按整单登记)"
style="width: 100%"
>
<el-option
v-for="it in planItems"
:key="it.seq"
:label="`${it.name || '节点' + it.seq}(¥${formatMoney(it.amount)})`"
:value="it.seq"
/>
</el-select>
</el-form-item>
<el-form-item label="本次回款" required>
<el-input-number
v-model="recordForm.amount"
@@ -217,6 +270,51 @@ const receivedSum = computed(() =>
records.value.reduce((sum, r) => sum + (Number(r.amount) || 0), 0)
);
/** 金额保留两位小数 */
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/* ---------------------- 计划明细(进度节点 / 分期) ---------------------- */
/** 计划明细:detail.items(后端解析为数组,兼容字符串存储) */
const planItems = computed(() => {
const raw = detail.value?.items;
if (Array.isArray(raw)) return raw;
if (typeof raw === "string" && raw.trim()) {
try {
const arr = JSON.parse(raw);
return Array.isArray(arr) ? arr : [];
} catch (e) {
return [];
}
}
return [];
});
/** 节点已回款:按「标记到该节点的回款记录」汇总,兼容历史上按明细直接登记(received_amount)的值 */
function itemReceivedOf(item) {
const fromRecords = records.value
.filter((r) => Number(r.item_seq) === Number(item.seq))
.reduce((s, r) => s + (Number(r.amount) || 0), 0);
const legacy = Number(item.received_amount) || 0;
return Math.max(round2(fromRecords), legacy);
}
/** 节点状态:0未回款 1部分回款 2已收齐 */
function itemStatusOf(item) {
const total = Number(item.amount) || 0;
const received = itemReceivedOf(item);
if (total > 0 && received >= total) return 2;
return received > 0 ? 1 : 0;
}
function itemStatusTextOf(item) {
return { 0: "未回款", 1: "部分回款", 2: "已收齐" }[itemStatusOf(item)] || "-";
}
function itemStatusTagOf(item) {
return { 0: "info", 1: "warning", 2: "success" }[itemStatusOf(item)] || "info";
}
async function loadDetail() {
// 先用列表行数据回显,再拉取详情补充完整信息
detail.value = props.payback || {};
@@ -272,6 +370,7 @@ const recordForm = reactive({
amount: 0,
received_date: "",
pay_method: 1,
item_seq: 0, // 进度节点序号(1-based),0=不指定(按整单登记)
receipt_url: "",
receipt_name: "",
remark: "",
@@ -285,12 +384,16 @@ function todayStr() {
return `${d.getFullYear()}-${m}-${day}`;
}
/** 打开新建回款记录(计划已固定为当前详情) */
/**
* 打开新建回款记录(计划已固定为当前详情)。
* 进度节点在弹窗内选择(按进度选择性付款),不指定时按整单登记。
*/
function openRecord() {
Object.assign(recordForm, {
amount: 0,
received_date: todayStr(),
pay_method: detail.value.pay_method || 1,
item_seq: 0,
receipt_url: "",
receipt_name: "",
remark: "",
@@ -332,6 +435,7 @@ async function submitRecord() {
try {
await createPaybackRecord({
payback_id: detail.value.id,
item_seq: Number(recordForm.item_seq) || 0,
amount: Number(recordForm.amount) || 0,
received_date: recordForm.received_date || "",
pay_method: recordForm.pay_method,
@@ -423,6 +527,10 @@ async function handleDelete() {
margin-bottom: 18px;
}
.items-table {
margin-bottom: 4px;
}
.detail-section-title {
display: flex;
justify-content: space-between;
+8 -3
View File
@@ -77,11 +77,11 @@
<el-tag v-else size="small" effect="plain" type="success">回款</el-tag>
</template>
</el-table-column>
<el-table-column label="逾期" width="120" align="center">
<el-table-column label="逾期" width="140" align="center">
<template #default="{ row }">
<template v-if="row.__isPlan">
<el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(paybackPlanOverdue(row).days) }}
{{ paybackPlanOverdue(row).text }}
</el-tag>
<span v-else>-</span>
</template>
@@ -91,6 +91,8 @@
<template #default="{ row }">
<span v-if="row.__isPlan" class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
<span v-else class="record-name">
<!-- 记录挂到计划明细节点时展示节点名(按进度选择性付款) -->
<span v-if="row.item_name" class="record-item">{{ row.item_name }} · </span>
{{ row.received_date || "-" }} 回款
<span v-if="row.remark" class="record-remark">({{ row.remark }})</span>
</span>
@@ -267,7 +269,6 @@ import {
paybackStatusText,
paybackStatusTag,
paybackPlanOverdue,
overdueText,
formatMoney,
} from "../dict";
@@ -510,6 +511,10 @@ onMounted(fetchList);
font-size: 13px;
color: var(--el-text-color-regular);
.record-item {
color: var(--el-color-primary);
}
.record-remark {
font-size: 12px;
color: var(--el-text-color-secondary);
+48 -7
View File
@@ -3,7 +3,7 @@
<div class="page-header">
<div>
<h2>产品分类</h2>
<p>维护 CRM 产品的自定义归类,产品表单的分类下拉取自本表</p>
<p>维护 CRM 产品的自定义归类</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
@@ -37,9 +37,23 @@
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id">
<el-table-column prop="name" label="分类名称" min-width="160" show-overflow-tooltip />
<el-table-column label="分类名称" min-width="180" show-overflow-tooltip>
<template #default="{ row }">
{{ row.name }}
<!-- 系统默认分类(未分类):只读 -->
<!-- <el-tag
v-if="Number(row.is_default) === 1"
type="primary"
size="small"
effect="plain"
style="margin-left: 6px"
>
默认
</el-tag> -->
</template>
</el-table-column>
<el-table-column prop="code" label="分类编码" width="140" show-overflow-tooltip />
<el-table-column prop="sort" label="排序" width="90" align="center" />
<!-- <el-table-column prop="sort" label="排序" width="90" align="center" /> -->
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="productStatusTag(row.status)" size="small">{{ productStatusText(row.status) }}</el-tag>
@@ -50,8 +64,16 @@
</el-table-column>
<el-table-column label="操作" width="160" 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 v-if="Number(row.is_default) === 1">
<el-tooltip content="系统默认分类不可编辑、不可删除" placement="top">
<span class="readonly-tip">系统内置</span>
</el-tooltip>
</template>
<template v-else>
<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>
</template>
</el-table-column>
<template #empty><el-empty description="暂无产品分类" :image-size="80" /></template>
@@ -85,9 +107,9 @@
<el-form-item label="分类编码" prop="code">
<el-input v-model="form.code" placeholder="选填,如:SERVER" maxlength="50" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<!-- <el-form-item label="排序" prop="sort">
<el-input-number v-model="form.sort" :min="0" :max="9999" :controls="false" style="width: 140px" />
</el-form-item>
</el-form-item> -->
<el-form-item label="状态" prop="status">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
<el-option v-for="i in PRODUCT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
@@ -185,6 +207,11 @@ function openCreate() {
}
function openEdit(row) {
// 双保险:系统默认分类不可编辑(按钮已隐藏,这里再拦一次)
if (Number(row.is_default) === 1) {
ElMessage.warning("系统默认分类不可编辑,如需调整请新增自定义分类");
return;
}
isEdit.value = true;
currentId.value = row.id;
Object.assign(form, defaultForm(), row);
@@ -229,6 +256,11 @@ async function handleSubmit() {
}
async function handleDelete(row) {
// 双保险:系统默认分类不可删除(按钮已隐藏,这里再拦一次)
if (Number(row.is_default) === 1) {
ElMessage.warning("系统默认分类不可删除");
return;
}
try {
await ElMessageBox.confirm(`确定删除产品分类「${row.name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
@@ -243,3 +275,12 @@ async function handleDelete(row) {
</script>
<style lang="less" scoped src="../styles/crm-page.less"></style>
<style lang="less" scoped>
/* 系统默认分类:只读提示 */
.readonly-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: help;
}
</style>
@@ -10,6 +10,11 @@
<el-descriptions :column="1" border>
<el-descriptions-item label="产品编号">{{ product.product_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="产品名称">{{ product.product_name }}</el-descriptions-item>
<el-descriptions-item label="行类型">
<el-tag :type="isDev ? 'warning' : 'primary'" size="small" effect="plain">
{{ isDev ? "软件开发" : "成品" }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="产品分类">
<el-tag :type="categoryType(product.category)" size="small" effect="plain">
{{ categoryDisplay(product.category) }}
@@ -17,12 +22,27 @@
</el-descriptions-item>
<el-descriptions-item label="规格型号">{{ product.spec || "-" }}</el-descriptions-item>
<el-descriptions-item label="单位">{{ product.unit || "-" }}</el-descriptions-item>
<el-descriptions-item label="销售单价">¥{{ formatMoney(product.price) }}</el-descriptions-item>
<el-descriptions-item label="成本单价">¥{{ formatMoney(product.cost_price) }}</el-descriptions-item>
<!-- 软件开发(特例):按人天与模块报价,无销售 / 成本单价口径 -->
<template v-if="isDev">
<el-descriptions-item label="研发方式">
<el-tag :type="devModeTag(product.dev_mode)" size="small" effect="plain">
{{ devModeText(product.dev_mode) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="统一人天单价">
¥{{ formatMoney(product.dev_unit_price) }}
</el-descriptions-item>
<el-descriptions-item label="模块总额">¥{{ formatMoney(devTotal) }}</el-descriptions-item>
<el-descriptions-item label="人天合计">{{ treeManDays(devTree) }} 人天</el-descriptions-item>
</template>
<template v-else>
<el-descriptions-item label="销售单价">¥{{ formatMoney(product.price) }}</el-descriptions-item>
<el-descriptions-item label="成本单价">¥{{ formatMoney(product.cost_price) }}</el-descriptions-item>
<el-descriptions-item label="毛利">
¥{{ formatMoney((Number(product.price) || 0) - (Number(product.cost_price) || 0)) }}
</el-descriptions-item>
</template>
<el-descriptions-item label="税率">{{ formatPercent(product.tax_rate) }}</el-descriptions-item>
<el-descriptions-item label="毛利">
¥{{ formatMoney((Number(product.price) || 0) - (Number(product.cost_price) || 0)) }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="productStatusTag(product.status)" size="small">{{ productStatusText(product.status) }}</el-tag>
</el-descriptions-item>
@@ -31,21 +51,49 @@
<el-descriptions-item label="创建时间">{{ formatDateTime(product.create_time) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(product.update_time) }}</el-descriptions-item>
</el-descriptions>
<!-- 软件开发模块明细(只读树形:模块 / 人天 / 金额,父级自动汇总) -->
<template v-if="isDev">
<el-divider content-position="left">软件开发模块明细</el-divider>
<el-table
v-if="moduleRows.length"
:data="moduleRows"
row-key="__key"
:tree-props="{ children: 'children' }"
size="small"
border
default-expand-all
>
<el-table-column label="模块名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || "-" }}</template>
</el-table-column>
<el-table-column label="人天" width="80" align="right">
<template #default="{ row }">{{ treeManDays([row]) }}</template>
</el-table-column>
<el-table-column label="金额" width="120" align="right">
<template #default="{ row }">¥{{ formatMoney(treeSellTotal([row], unitPrice)) }}</template>
</el-table-column>
</el-table>
<el-empty v-else description="暂无模块配置" :image-size="50" />
</template>
</div>
</el-drawer>
</template>
<script setup>
import { reactive, watch } from "vue";
import { computed, reactive, watch } from "vue";
import {
productCategoryText,
productCategoryTag,
productStatusText,
productStatusTag,
devModeText,
devModeTag,
formatMoney,
formatPercent,
formatDateTime,
} from "../../dict";
import { treeSellTotal, treeManDays } from "../../contract/components/utils";
import { getProductCategoryList } from "@/api/crmProductCategory";
const props = defineProps({
@@ -56,6 +104,46 @@ const emit = defineEmits(["update:visible"]);
const categoryMap = reactive({});
/* ---------------------- 软件开发(行类型特例)展示 ---------------------- */
/** 行类型:dev=软件开发(按人天 + 模块树报价) */
const isDev = computed(() => props.product?.line_type === "dev");
/** 安全解析产品返回的 dev_tree(可能为字符串或数组) */
function normalizeDevTree(v) {
if (Array.isArray(v)) return v;
if (typeof v === "string" && v) {
try {
const parsed = JSON.parse(v);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
return [];
}
const devTree = computed(() => normalizeDevTree(props.product?.dev_tree));
const unitPrice = computed(() => Number(props.product?.dev_unit_price) || 0);
/** 模块总额 = 各叶子人天 × 统一人天单价(父级自动汇总) */
const devTotal = computed(() => treeSellTotal(devTree.value, unitPrice.value));
/** 树形行包装:为 el-table 生成唯一 row-key(模块节点本身没有 id) */
function wrapTree(nodes, prefix = "") {
return (Array.isArray(nodes) ? nodes : []).map((n, i) => {
const key = `${prefix}${i}`;
const row = { ...n, __key: key };
if (Array.isArray(n.children) && n.children.length) {
row.children = wrapTree(n.children, `${key}-`);
} else {
delete row.children;
}
return row;
});
}
const moduleRows = computed(() => wrapTree(devTree.value));
/** 详情分类展示:优先管理分类名称;同步写入的产品带「金额归属」编码时回退 */
function categoryDisplay(val) {
if (!val) return "-";
@@ -34,6 +34,14 @@
</el-input-number>
</el-form-item>
</el-col>
<!-- 软件开发分两种:自行研发 / 外协研发(别人帮我们研发,成本按外包总额计) -->
<el-col :span="12" v-if="form.line_type === 'dev'">
<el-form-item label="研发方式" prop="dev_mode">
<el-select v-model="form.dev_mode" placeholder="请选择" style="width: 100%">
<el-option v-for="i in DEV_MODE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品分类" prop="category">
<el-select
@@ -89,7 +97,8 @@
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.line_type === 'product'">
<!-- 税率:成品与软件开发均需开票记录(软件开发为特例,但同样可能开票) -->
<el-col :span="8">
<el-form-item label="税率(%)" prop="tax_rate">
<el-input-number v-model="form.tax_rate" :min="0" :max="100" :precision="2" :controls="false" style="width: 100%" />
</el-form-item>
@@ -130,7 +139,7 @@ import { ElMessage } from "element-plus";
import { createProduct, updateProduct } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import { getUnitList } from "@/api/crmUnit";
import { PRODUCT_STATUS_OPTIONS, PRODUCT_UNIT_OPTIONS, formatMoney } from "../../dict";
import { PRODUCT_STATUS_OPTIONS, PRODUCT_UNIT_OPTIONS, DEV_MODE_OPTIONS, formatMoney } from "../../dict";
import { treeSellTotal } from "../../contract/components/utils";
import DevModuleTree from "../../contract/components/DevModuleTree.vue";
@@ -159,6 +168,8 @@ const defaultForm = () => ({
cost_price: 0,
tax_rate: 0,
dev_unit_price: 0,
// 软件开发研发方式:self=自行研发(默认)/ outsource=外协研发
dev_mode: "self",
dev_tree: [],
status: 1,
remark: "",
@@ -170,14 +181,16 @@ const rules = {
product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
};
/** 切换行类型:开发行确保有模块树容器;切回成品时清空开发字段,避免残留脏数据 */
/** 切换行类型:开发行确保有模块树容器与研发方式;切回成品时清空开发字段,避免残留脏数据 */
function onLineTypeChange() {
if (form.line_type === "dev") {
if (!Array.isArray(form.dev_tree)) form.dev_tree = [];
if (form.dev_unit_price == null) form.dev_unit_price = 0;
if (!form.dev_mode) form.dev_mode = "self";
} else {
form.dev_tree = [];
form.dev_unit_price = 0;
form.dev_mode = "self";
}
}
@@ -207,6 +220,7 @@ watch(
if (isNaN(form.status)) form.status = 1;
form.line_type = props.editData.line_type === "dev" ? "dev" : "product";
form.dev_unit_price = Number(props.editData.dev_unit_price) || 0;
form.dev_mode = props.editData.dev_mode === "outsource" ? "outsource" : "self";
form.dev_tree = normalizeDevTree(props.editData.dev_tree);
} else {
isEdit.value = false;
@@ -85,6 +85,15 @@
</template>
</el-table-column>
<el-table-column prop="product_no" label="产品编号" width="130" show-overflow-tooltip />
<el-table-column label="行类型" width="125" align="center">
<template #default="{ row }">
<!-- 软件开发为特例,并区分研发方式:自研 / 外协(别人帮我们研发) -->
<el-tag v-if="row.line_type === 'dev'" :type="devModeTag(row.dev_mode)" size="small" effect="plain">
软件开发 · {{ devModeShortText(row.dev_mode) }}
</el-tag>
<el-tag v-else type="primary" size="small" effect="plain">成品</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="130" align="center">
<template #default="{ row }">
<el-tag :type="categoryType(row.category)" size="small" effect="plain">
@@ -154,6 +163,8 @@ import {
productCategoryTag,
productStatusText,
productStatusTag,
devModeShortText,
devModeTag,
formatMoney,
formatPercent,
} from "../dict";
@@ -12,10 +12,27 @@
<div class="toolbar-status">
<el-tag :type="projectStatusTag(project.status)" size="small">{{ projectStatusText(project.status) }}</el-tag>
<el-tag v-if="projectOverdue(project).overdue" type="danger" size="small" effect="dark">
{{ overdueText(projectOverdue(project).days) }}
{{ projectOverdue(project).text }}
</el-tag>
</div>
<div class="toolbar-actions">
<!-- 状态流转:与合同详情一致,右上角下拉切换项目状态 -->
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(cmd)">
<el-button link type="warning" size="small">
状态流转<el-icon style="margin-left: 2px"><ArrowDown /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="i in statusOptionsExcept(project.status)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button type="primary" size="small" :icon="Edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
@@ -26,14 +43,9 @@
<DetailBasic :project="project" />
</el-tab-pane>
<!-- 联系人(正式客户通讯录) -->
<!-- 联系人(按项目类型分组:从对应企业通讯录中挑选,支持多选) -->
<el-tab-pane :label="`联系人 (${counts.contact})`" name="contact">
<DetailContact
ref="contactRef"
:company-id="project.customer_id"
company-type="customer"
@count="counts.contact = $event"
/>
<DetailContact ref="contactRef" :project="project" @count="counts.contact = $event" />
</el-tab-pane>
<!-- 跟进记录(链路累积:线索 + 商机 + 项目) -->
@@ -55,6 +67,7 @@
ref="contractRef"
:project-id="project.id"
:project-name="project.project_name || ''"
:project-type="project.project_type"
@count="counts.contract = $event"
/>
</el-tab-pane>
@@ -93,8 +106,8 @@
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete } from "@element-plus/icons-vue";
import { deleteProject } from "@/api/crmPipeline";
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
import { deleteProject, changeProjectStatus, getProjectDetail } from "@/api/crmPipeline";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
@@ -102,7 +115,12 @@ import DetailContract from "./detail_contract.vue";
import DetailAttach from "./detail_attach.vue";
import DetailDocs from "./detail_docs.vue";
import DetailLog from "./detail_log.vue";
import { projectStatusText, projectStatusTag, projectOverdue, overdueText } from "../../dict";
import {
PROJECT_STATUS_OPTIONS,
projectStatusText,
projectStatusTag,
projectOverdue,
} from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -145,6 +163,24 @@ function handleEdit() {
emit("edit", props.project);
}
/** 状态流转候选:排除当前状态 */
function statusOptionsExcept(cur) {
return PROJECT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(cur));
}
/** 项目状态流转:成功后更新本地展示并通知父组件刷新列表 */
async function handleChangeStatus(status) {
if (!props.project?.id) return;
try {
await changeProjectStatus(props.project.id, Number(status));
ElMessage.success(`状态已更新为「${projectStatusText(status)}」`);
props.project.status = Number(status);
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
}
async function handleDelete() {
try {
await ElMessageBox.confirm(`确定删除项目「${props.project.project_name}」吗?删除后不可恢复。`, "删除确认", {
@@ -160,8 +196,19 @@ async function handleDelete() {
}
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
async function loadAll() {
if (!props.project?.id) return;
// 列表行数据是打开页面时的快照,联系人选中集合等字段可能已过期(如刚在其他项目/本次会话保存过),
// 先拉取最新详情刷新快照,各 Tab(含联系人)再按最新数据加载
try {
const res = await getProjectDetail(props.project.id);
const data = res?.data;
if (data && typeof data === "object" && props.project) {
Object.assign(props.project, data);
}
} catch (e) {
// 详情接口失败时保持快照展示,不阻断其他 Tab
}
contactRef.value?.reload();
followRef.value?.reload();
contractRef.value?.reload();
@@ -2,8 +2,11 @@
<div class="detail-attach">
<div class="tab-toolbar">
<div class="toolbar-left">
<el-upload :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
<!-- multiple:支持一次选择多个文件批量上传,全部结束后统一提示并刷新列表 -->
<el-upload multiple :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-tooltip content="支持多选:可一次选择多个文件批量上传" placement="top">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
</el-tooltip>
</el-upload>
<span v-if="tip" class="tip">{{ tip }}</span>
</div>
@@ -219,6 +222,10 @@ const emit = defineEmits(["count"]);
const loading = ref(false);
const uploading = ref(false);
const list = ref([]);
// 批量上传:并发计数 + 结果汇总(全部结束后统一提示、统一刷新)
const pending = ref(0);
const okCount = ref(0);
const failed = ref([]);
// 视图模式:grid=图标模式(默认,便于快速查找) / list=列表模式
const viewMode = ref("grid");
// 分页:默认每页 20 条
@@ -341,9 +348,20 @@ function handleSizeChange(s) {
reload();
}
/**
* 单文件上传:多选时 el-upload 会为每个文件调用一次(并发),
* 这里用 pending 计数保证全部结束后只提示一次、只刷新一次列表。
*/
async function handleUpload(options) {
const file = options.file;
uploading.value = true;
if (!file) return;
// 新一轮批量上传:重置汇总,并保持按钮 loading 直到最后一个文件结束
if (pending.value === 0) {
okCount.value = 0;
failed.value = [];
uploading.value = true;
}
pending.value += 1;
try {
const fd = new FormData();
fd.append("file", file);
@@ -357,13 +375,29 @@ async function handleUpload(options) {
file_url: data.url || "",
file_size: file.size || 0,
});
ElMessage.success("上传成功");
page.value = 1;
reload();
okCount.value += 1;
} catch (e) {
ElMessage.error(e.message || "上传失败");
failed.value.push(`${file.name}:${e?.message || "上传失败"}`);
} finally {
pending.value -= 1;
if (pending.value > 0) return;
uploading.value = false;
// 统一提示:单个成功保持原文案,多个成功汇总,失败逐个列出
if (failed.value.length) {
ElMessage.error(
okCount.value
? `成功 ${okCount.value} 个,失败 ${failed.value.length} 个:${failed.value.join(";")}`
: `上传失败:${failed.value.join(";")}`
);
} else if (okCount.value > 1) {
ElMessage.success(`已上传 ${okCount.value} 个附件`);
} else {
ElMessage.success("上传成功");
}
if (okCount.value) {
page.value = 1; // 批量上传后回到第一页,确保新附件可见
reload();
}
}
}
@@ -5,8 +5,8 @@
<el-descriptions-item label="项目名称" :span="2">{{ project.project_name }}</el-descriptions-item>
<el-descriptions-item label="项目编号">{{ project.project_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="项目类型">
<el-tag :type="isBackToBack ? 'warning' : 'info'" size="small" effect="plain">
{{ isBackToBack ? "上下游项目" : "单一项目" }}
<el-tag :type="projectTypeTag(project.project_type)" size="small" effect="plain">
{{ projectTypeText(project.project_type) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="项目状态">
@@ -30,35 +30,47 @@
<el-descriptions-item label="备注" :span="2">{{ project.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游项目则区分上游(客户侧) -->
<el-divider content-position="left">{{ isBackToBack ? "上游信息" : "客户信息" }}</el-divider>
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游 / 上中游项目区分上游(客户侧) -->
<el-divider content-position="left">{{ hasCounterparty ? "上游信息" : "客户信息" }}</el-divider>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="客户名称">{{ project.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游金额' : '项目金额'">
<el-descriptions-item :label="hasCounterparty ? '上游客户' : '客户名称'">
{{ project.customer_name || "-" }}
</el-descriptions-item>
<el-descriptions-item :label="hasCounterparty ? '上游金额' : '项目金额'">
{{ formatMoney(upstreamAmount) }}
</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游对接人' : '对接人'">
<el-descriptions-item :label="hasCounterparty ? '上游对接人' : '对接人'">
{{ project.upstream_contact || project.contact_person || "-" }}
</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游联系电话' : '联系电话'">
<el-descriptions-item :label="hasCounterparty ? '上游联系电话' : '联系电话'">
{{ project.upstream_phone || project.contact_phone || "-" }}
</el-descriptions-item>
</el-descriptions>
<!-- 下游(供应商侧):仅上下游项目展示 -->
<template v-if="isBackToBack">
<el-divider content-position="left">下游信息</el-divider>
<!-- 对方主体(上下游的下游供应商 / 上中游的中游中间方):仅这两类项目展示 -->
<template v-if="hasCounterparty">
<el-divider content-position="left">{{ counterpartyLabel }}信息</el-divider>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="供应商/客户">
<el-descriptions-item :label="isUpMid ? '中游企业' : '供应商/客户'">
{{ project.supplier_name || "-" }}
<el-tag v-if="Number(project.supplier_ref_type) === 1" size="small" effect="plain" type="success">客户</el-tag>
<el-tag v-else-if="Number(project.supplier_ref_type) === 2" size="small" effect="plain" type="warning">
供应商
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="下游金额">{{ formatMoney(project.downstream_amount) }}</el-descriptions-item>
<el-descriptions-item label="下游对接人">{{ project.downstream_contact || "-" }}</el-descriptions-item>
<el-descriptions-item label="下游联系电话">{{ project.downstream_phone || "-" }}</el-descriptions-item>
<el-descriptions-item :label="`${counterpartyLabel}金额`">
{{ formatMoney(project.downstream_amount) }}
</el-descriptions-item>
<el-descriptions-item :label="`${counterpartyLabel}对接人`">
{{ project.downstream_contact || "-" }}
</el-descriptions-item>
<el-descriptions-item :label="`${counterpartyLabel}联系电话`">
{{ project.downstream_phone || "-" }}
</el-descriptions-item>
<!-- 上中游项目:下游固定为本公司 -->
<el-descriptions-item v-if="isUpMid" label="下游(本公司)" :span="2">
{{ ownCompanyName }}
</el-descriptions-item>
</el-descriptions>
</template>
</div>
@@ -66,9 +78,12 @@
<script setup>
import { computed } from "vue";
import { useAuthStore } from "@/stores/auth";
import {
projectStatusText,
projectStatusTag,
projectTypeText,
projectTypeTag,
formatDateTime,
formatMoney,
formatDateOnly,
@@ -79,9 +94,23 @@ const props = defineProps({
project: { type: Object, default: null },
});
const authStore = useAuthStore();
/** 是否背靠背项目:需要展示下游(供应商)信息 */
const isBackToBack = computed(() => Number(props.project?.project_type) === 2);
/** 是否上中游项目:上游 A 公司 + 中游 B 公司,下游固定为本公司 */
const isUpMid = computed(() => Number(props.project?.project_type) === 3);
/** 是否需要展示「对方主体」(上下游的下游 / 上中游的中游) */
const hasCounterparty = computed(() => isBackToBack.value || isUpMid.value);
/** 对方主体称谓:上下游项目叫「下游」,上中游项目叫「中游」 */
const counterpartyLabel = computed(() => (isUpMid.value ? "中游" : "下游"));
/** 本公司名称:上中游项目的下游固定为本公司 */
const ownCompanyName = computed(() => authStore.user?.tenant_name || "本公司");
/** 上游金额:新字段优先,兼容存量项目仅有旧「项目金额」的情况 */
const upstreamAmount = computed(
() => Number(props.project?.upstream_amount) || Number(props.project?.amount) || 0
@@ -1,224 +1,205 @@
<template>
<div class="detail-contact">
<div class="tab-toolbar">
<el-button
type="primary"
size="small"
:icon="Plus"
:disabled="!companyId"
@click="openEdit(null)"
>
新增联系人
</el-button>
</div>
<div class="detail-contact" v-loading="loading">
<el-alert
v-if="!companyId"
v-if="!project?.customer_id && !downCompany"
type="info"
:closable="false"
show-icon
title="该项目未关联正式客户,暂无正式联系人"
title="该项目未关联企业主体,暂无可选联系人"
style="margin-bottom: 12px"
/>
<el-table :data="list" v-loading="loading" stripe border size="small">
<el-table-column label="姓名" min-width="95">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="Number(row.is_primary) === 1" type="danger" size="small" effect="plain">主要</el-tag>
</template>
</el-table-column>
<el-table-column label="性别" min-width="50" align="center">
<template #default="{ row }">{{ genderText(row.gender) }}</template>
</el-table-column>
<el-table-column label="状态" min-width="60" align="center">
<template #default="{ row }">
<el-tag :type="Number(row.status) === 1 ? 'success' : 'info'" size="small">
{{ Number(row.status) === 1 ? "在职" : "离职" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" min-width="70" show-overflow-tooltip />
<el-table-column prop="position" label="职位" min-width="70" show-overflow-tooltip />
<el-table-column label="手机" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) }}</template>
</el-table-column>
<el-table-column prop="phone" label="座机" min-width="78" show-overflow-tooltip />
<el-table-column prop="email" label="邮箱" min-width="100" show-overflow-tooltip />
<el-table-column label="操作" width="96" align="center">
<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="60" /></template>
</el-table>
<!-- 联系人编辑(参数与 ERP 通讯录一致) -->
<template v-else>
<el-alert
type="info"
:closable="false"
show-icon
:title="`从各企业通讯录中挑选本项目对接联系人(多选),联系人信息与企业通讯录实时一致`"
style="margin-bottom: 12px"
/>
<!-- 上游(客户方) -->
<div class="contact-group">
<div class="group-header">
<div class="group-title-row">
<span class="group-title">上游联系人</span>
<span class="group-company">{{ project?.customer_name || "-" }}</span>
</div>
<el-button
type="primary"
size="small"
:icon="Plus"
:disabled="!project?.customer_id"
@click="openPicker('upstream')"
>
选择联系人
</el-button>
</div>
<el-table :data="upSelected" stripe border size="small">
<el-table-column label="姓名" min-width="95">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="Number(row.is_primary) === 1" type="danger" size="small" effect="plain">主要</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" min-width="75" show-overflow-tooltip />
<el-table-column prop="position" label="职位" min-width="75" show-overflow-tooltip />
<el-table-column label="手机" min-width="110" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="110" show-overflow-tooltip />
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removePicked('upstream', row)">移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="尚未选择联系人" :image-size="60" /></template>
</el-table>
</div>
<!-- 下游(上下游项目)/ 中游(上中游项目) -->
<div v-if="pt !== 1" class="contact-group">
<div class="group-header">
<div class="group-title-row">
<span class="group-title">{{ downLabel }}联系人</span>
<span class="group-company">{{ project?.supplier_name || "-" }}</span>
</div>
<el-button
type="primary"
size="small"
:icon="Plus"
:disabled="!downCompany"
@click="openPicker('downstream')"
>
选择联系人
</el-button>
</div>
<el-table v-if="downCompany" :data="downSelected" stripe border size="small">
<el-table-column label="姓名" min-width="95">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="Number(row.is_primary) === 1" type="danger" size="small" effect="plain">主要</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" min-width="75" show-overflow-tooltip />
<el-table-column prop="position" label="职位" min-width="75" show-overflow-tooltip />
<el-table-column label="手机" min-width="110" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="110" show-overflow-tooltip />
<el-table-column label="操作" width="70" align="center">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removePicked('downstream', row)">移除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="尚未选择联系人" :image-size="60" /></template>
</el-table>
<el-alert
v-else
type="warning"
:closable="false"
show-icon
:title="`${downLabel}主体未正式关联企业档案,无法选择联系人`"
/>
</div>
</template>
<!-- 选择联系人弹窗(多选,来源为对应企业通讯录) -->
<el-dialog
v-model="dialog"
:title="form.id ? '编辑联系人' : '新增联系人'"
width="640px"
v-model="picker.visible"
:title="pickerTitle"
width="680px"
append-to-body
:close-on-click-modal="false"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px" label-position="right">
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="请输入姓名" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="性别" prop="gender">
<el-select v-model="form.gender" placeholder="请选择" style="width: 100%">
<el-option label="未知" :value="0" />
<el-option label="男" :value="1" />
<el-option label="女" :value="2" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="部门" prop="department">
<el-input v-model="form.department" placeholder="请输入部门" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="职位" prop="position">
<el-input v-model="form.position" placeholder="请输入职位" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="座机" prop="phone">
<el-input v-model="form.phone" placeholder="请输入座机号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="邮箱" prop="email">
<el-input v-model="form.email" placeholder="请输入邮箱" />
</el-form-item>
</el-col>
</el-row>
<!-- 手机(动态,最多5个) -->
<el-form-item label="手机">
<div class="mobile-list">
<div v-for="(m, idx) in form.mobiles" :key="idx" class="mobile-item">
<el-input v-model="form.mobiles[idx]" placeholder="请输入手机号" style="flex: 1" />
<el-button :icon="Delete" :disabled="form.mobiles.length <= 1" @click="removeMobile(idx)" />
</div>
<el-button v-if="form.mobiles.length < 5" type="primary" plain :icon="Plus" @click="addMobile">
添加手机号({{ form.mobiles.length }}/5)
</el-button>
</div>
</el-form-item>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="微信" prop="wechat">
<el-input v-model="form.wechat" placeholder="请输入微信号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="QQ" prop="qq">
<el-input v-model="form.qq" placeholder="请输入QQ号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="钉钉" prop="dingtalk">
<el-input v-model="form.dingtalk" placeholder="请输入钉钉号" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="主要联系人" prop="is_primary">
<el-switch
v-model="form.is_primary"
:active-value="1"
:inactive-value="0"
active-text="是"
inactive-text="否"
/>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="人员状态" prop="status">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
<el-option label="在职" :value="1" />
<el-option label="离职" :value="0" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="家庭住址" prop="home_address">
<el-input v-model="form.home_address" placeholder="请输入家庭住址" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
</el-col>
</el-row>
</el-form>
<el-input
v-model="picker.keyword"
placeholder="搜索姓名 / 职位 / 手机号"
:prefix-icon="Search"
clearable
style="margin-bottom: 12px"
/>
<el-table
ref="pickerTableRef"
:data="pickerFilteredList"
v-loading="picker.loading"
stripe
border
size="small"
max-height="380"
row-key="id"
@selection-change="pickerSelection = $event"
>
<el-table-column type="selection" width="45" reserve-selection />
<el-table-column label="姓名" min-width="90">
<template #default="{ row }">
{{ row.name }}
<el-tag v-if="Number(row.is_primary) === 1" type="danger" size="small" effect="plain">主要</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" min-width="75" show-overflow-tooltip />
<el-table-column prop="position" label="职位" min-width="75" show-overflow-tooltip />
<el-table-column label="手机" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="120" show-overflow-tooltip />
</el-table>
<template #footer>
<el-button @click="dialog = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
<el-button @click="picker.visible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="confirmPicker">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Delete } from "@element-plus/icons-vue";
import {
getCompanyContactList,
createCompanyContact,
updateCompanyContact,
deleteCompanyContact,
} from "@/api/companyContact";
import { ref, reactive, computed, nextTick } from "vue";
import { ElMessage } from "element-plus";
import { Plus, Search } from "@element-plus/icons-vue";
import { getCompanyContactList } from "@/api/companyContact";
import { saveProjectContacts } from "@/api/crmPipeline";
const props = defineProps({
// 正式客户ID(项目关联的 customer_id)
companyId: { type: [Number, String], default: null },
// 正式客户类型
companyType: { type: String, default: "customer" },
// 完整项目对象(含 project_type / customer_id / supplier_id / *_contact_ids)
project: { type: Object, default: null },
});
const emit = defineEmits(["count"]);
const loading = ref(false);
const saving = ref(false);
const list = ref([]);
const dialog = ref(false);
const formRef = ref();
// 联系人表单:字段与 ERP 通讯录(contactBook.vue)完全一致
const defaultForm = () => ({
id: null,
name: "",
gender: 0,
phone: "",
mobiles: [""],
wechat: "",
qq: "",
dingtalk: "",
email: "",
department: "",
position: "",
home_address: "",
is_primary: 0,
status: 1,
remark: "",
// 两个企业通讯录的联系人(实时来自企业档案)
const upstreamContacts = ref([]);
const downstreamContacts = ref([]);
// 当前选中的联系人ID集合(项目联系人 = 从企业通讯录中挑选)
const pickedUp = ref([]);
const pickedDown = ref([]);
// 项目类型:1=单一项目(仅上游客户) 2=上下游项目 3=上中游项目
const pt = computed(() => {
const t = Number(props.project?.project_type) || 1;
return t === 2 || t === 3 ? t : 1;
});
// 对方主体:2=上下游项目的下游 / 3=上中游项目的中游
const downLabel = computed(() => (pt.value === 3 ? "中游" : "下游"));
// 对方主体的联系人来源库:supplier_ref_type 1=客户库 2=供应商库
const downCompany = computed(() => {
const p = props.project;
if (pt.value === 1 || !p?.supplier_id) return null;
return { type: Number(p.supplier_ref_type) === 1 ? "customer" : "supplier", id: p.supplier_id };
});
const form = reactive(defaultForm());
const rules = {
name: [{ required: true, message: "请输入姓名", trigger: "blur" }],
};
function genderText(g) {
if (Number(g) === 1) return "男";
if (Number(g) === 2) return "女";
return "未知";
/** 解析项目上的联系人ID集合(JSON 数组字符串 / 数组) */
function parseIds(raw) {
if (Array.isArray(raw)) return raw.map(Number).filter(Boolean);
const s = String(raw || "").trim();
if (!s) return [];
try {
const parsed = JSON.parse(s);
return Array.isArray(parsed) ? parsed.map(Number).filter(Boolean) : [];
} catch (e) {
return [];
}
}
function formatMobiles(mobiles) {
@@ -232,147 +213,205 @@ function formatMobiles(mobiles) {
}
}
function addMobile() {
if (form.mobiles.length < 5) form.mobiles.push("");
/** 按 ID 把已选集合映射为联系人详情(联系人已删除时自动剔除) */
function mapSelected(ids, contacts) {
const map = new Map(contacts.map((c) => [Number(c.id), c]));
return (ids || []).map((id) => map.get(id)).filter(Boolean);
}
function removeMobile(idx) {
if (form.mobiles.length > 1) form.mobiles.splice(idx, 1);
const upSelected = computed(() => mapSelected(pickedUp.value, upstreamContacts.value));
const downSelected = computed(() => mapSelected(pickedDown.value, downstreamContacts.value));
async function loadCompanyContacts(companyType, companyId) {
try {
const res = await getCompanyContactList({ company_type: companyType, company_id: companyId });
const data = res?.data || {};
const list = Array.isArray(data) ? data : data.list || [];
return list.filter((c) => Number(c.status) !== 0); // 离职联系人不参与选择
} catch (e) {
return [];
}
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
if (!props.companyId) {
list.value = [];
const p = props.project;
pickedUp.value = parseIds(p?.upstream_contact_ids);
pickedDown.value = parseIds(p?.downstream_contact_ids);
if (!p?.customer_id && !downCompany.value) {
upstreamContacts.value = [];
downstreamContacts.value = [];
emit("count", 0);
return;
}
loading.value = true;
try {
const res = await getCompanyContactList({
company_type: props.companyType,
company_id: props.companyId,
});
const data = res?.data || {};
list.value = Array.isArray(data) ? data : data.list || [];
} catch (e) {
list.value = [];
} finally {
loading.value = false;
const jobs = [];
if (p?.customer_id) {
jobs.push(
loadCompanyContacts("customer", p.customer_id).then((list) => {
upstreamContacts.value = list;
})
);
} else {
upstreamContacts.value = [];
}
emit("count", list.value.length);
if (downCompany.value) {
jobs.push(
loadCompanyContacts(downCompany.value.type, downCompany.value.id).then((list) => {
downstreamContacts.value = list;
})
);
} else {
downstreamContacts.value = [];
}
await Promise.all(jobs);
loading.value = false;
emit("count", upSelected.value.length + downSelected.value.length);
}
function openEdit(row) {
if (!props.companyId) {
ElMessage.warning("该项目未关联正式客户,暂无正式联系人");
/* ------------------------------ 选择联系人 ------------------------------ */
const picker = reactive({ visible: false, groupKey: "", keyword: "", loading: false, list: [] });
const pickerTableRef = ref();
const pickerSelection = ref([]);
const pickerTitle = computed(() => {
if (picker.groupKey === "upstream") {
return `选择上游联系人(${props.project?.customer_name || "客户方"})`;
}
return `选择${downLabel.value}联系人(${props.project?.supplier_name || "对方主体"})`;
});
const pickerFilteredList = computed(() => {
const kw = picker.keyword.trim().toLowerCase();
if (!kw) return picker.list;
return picker.list.filter((c) =>
[c.name, c.position, c.department, formatMobiles(c.mobiles)]
.map((v) => String(v || "").toLowerCase())
.some((v) => v.includes(kw))
);
});
async function openPicker(groupKey) {
picker.groupKey = groupKey;
picker.keyword = "";
pickerSelection.value = [];
picker.visible = true;
// 目标企业联系人:优先用已加载的缓存,无则现场拉取
const isUp = groupKey === "upstream";
const cached = isUp ? upstreamContacts.value : downstreamContacts.value;
if (cached.length) {
picker.list = cached;
await syncPickerSelection();
return;
}
Object.assign(form, defaultForm());
if (row) {
let mobiles = [""];
try {
mobiles = row.mobiles ? (typeof row.mobiles === "string" ? JSON.parse(row.mobiles) : row.mobiles) : [""];
if (!Array.isArray(mobiles) || mobiles.length === 0) mobiles = [""];
} catch (e) {
mobiles = [""];
}
Object.assign(form, {
id: row.id,
name: row.name || "",
gender: Number(row.gender) || 0,
phone: row.phone || "",
mobiles,
wechat: row.wechat || "",
qq: row.qq || "",
dingtalk: row.dingtalk || "",
email: row.email || "",
department: row.department || "",
position: row.position || "",
home_address: row.home_address || "",
is_primary: Number(row.is_primary) === 1 ? 1 : 0,
status: row.status === undefined || row.status === null ? 1 : Number(row.status),
remark: row.remark || "",
});
}
dialog.value = true;
const company = isUp
? { type: "customer", id: props.project?.customer_id }
: downCompany.value;
if (!company?.id) return;
picker.loading = true;
picker.list = await loadCompanyContacts(company.type, company.id);
if (isUp) upstreamContacts.value = picker.list;
else downstreamContacts.value = picker.list;
picker.loading = false;
await syncPickerSelection();
}
async function handleSave() {
if (formRef.value) {
try {
await formRef.value.validate();
} catch (e) {
return;
/** 打开弹窗后回显当前已选 */
async function syncPickerSelection() {
await nextTick();
const table = pickerTableRef.value;
if (!table) return;
// 先清空历史勾选:弹窗关闭后表格会保留上次的选中(reserve-selection),
// 不清会导致跨项目/跨次打开时残留上一个项目的选择,与本次实际勾选错位
table.clearSelection();
const picked = picker.groupKey === "upstream" ? pickedUp.value : pickedDown.value;
picker.list.forEach((row) => {
if (picked.includes(Number(row.id))) {
table.toggleRowSelection(row, true);
}
}
// 过滤空手机号后按 JSON 数组提交(与 ERP 通讯录一致)
const validMobiles = form.mobiles.filter((m) => m && String(m).trim());
});
}
async function confirmPicker() {
const ids = pickerSelection.value.map((row) => Number(row.id)).filter(Boolean);
if (picker.groupKey === "upstream") pickedUp.value = ids;
else pickedDown.value = ids;
await persist();
picker.visible = false;
}
/** 单个移除:更新集合后立即保存 */
async function removePicked(groupKey, row) {
const id = Number(row.id);
if (groupKey === "upstream") pickedUp.value = pickedUp.value.filter((i) => i !== id);
else pickedDown.value = pickedDown.value.filter((i) => i !== id);
await persist();
}
/** 保存两组选中集合(接口全量覆盖,一次性提交避免互相覆盖) */
async function persist() {
if (!props.project?.id) return;
saving.value = true;
try {
const payload = {
company_type: props.companyType,
company_id: Number(props.companyId),
name: form.name,
gender: form.gender,
phone: form.phone,
mobiles: JSON.stringify(validMobiles),
wechat: form.wechat,
qq: form.qq,
dingtalk: form.dingtalk,
email: form.email,
department: form.department,
position: form.position,
home_address: form.home_address,
is_primary: form.is_primary,
status: form.status,
remark: form.remark,
};
if (form.id) {
await updateCompanyContact(form.id, payload);
} else {
await createCompanyContact(payload);
const res = await saveProjectContacts(props.project.id, {
upstream_contact_ids: pickedUp.value,
downstream_contact_ids: pickedDown.value,
});
const data = res?.data || {};
// 以后端过滤结果为准(剔除不属于对应企业的ID / 单一项目的下游集合)
pickedUp.value = (data.upstream_contact_ids || []).map(Number);
pickedDown.value = (data.downstream_contact_ids || []).map(Number);
// 同步写回项目快照:详情数据源为列表行快照,不写回则关闭重开详情会读到旧集合(联系人不显示)
if (props.project) {
props.project.upstream_contact_ids = JSON.stringify(pickedUp.value);
props.project.downstream_contact_ids = JSON.stringify(pickedDown.value);
}
ElMessage.success("保存成功");
dialog.value = false;
reload();
emit("count", upSelected.value.length + downSelected.value.length);
ElMessage.success("项目联系人已保存");
} catch (e) {
ElMessage.error(e.message || "保存失败");
// 保存失败时重新拉取,避免本地状态与后端不一致
await reload();
} finally {
saving.value = false;
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定删除联系人「${row.name}」吗?`, "删除确认", { type: "warning" });
await deleteCompanyContact(row.id);
ElMessage.success("删除成功");
reload();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
defineExpose({ reload });
</script>
<style lang="less" scoped>
.tab-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.contact-group {
margin-bottom: 20px;
.mobile-list {
width: 100%;
.mobile-item {
.group-header {
display: flex;
align-items: center;
gap: 8px;
justify-content: space-between;
margin-bottom: 8px;
.group-title-row {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
}
.group-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.group-company {
font-size: 12px;
color: var(--el-text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>
@@ -16,8 +16,8 @@
<template #label>
<span class="tab-label">
<span class="tab-name">{{ c.contract_name }}</span>
<el-tag :type="contractSideOf(c) === 'down' ? 'warning' : 'success'" size="small" effect="plain">
{{ contractSideOf(c) === "down" ? "下游" : "上游" }}
<el-tag v-if="showSideTag" :type="sideTagType(c)" size="small" effect="plain">
{{ sideLabel(c) }}
</el-tag>
</span>
</template>
@@ -32,8 +32,8 @@
>
{{ c.contract_name }}
</span>
<el-tag :type="contractSideOf(c) === 'down' ? 'warning' : 'success'" size="small" effect="plain">
{{ contractSideOf(c) === "down" ? "下游" : "上游" }}
<el-tag v-if="showSideTag" :type="sideTagType(c)" size="small" effect="plain">
{{ sideLabel(c) }}
</el-tag>
</div>
<!-- <div class="pane-actions">
@@ -114,9 +114,12 @@
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summaryOf(c).total_cost) }}</span>
</div>
<!-- 我方为甲方(付款方)时显示「合同支出」(负数),否则显示「合同总利润」 -->
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summaryOf(c).total_profit) }}</span>
<span class="label">{{ contractProfitLabel(c.our_role) }}</span>
<span class="value success">
{{ contractProfitText(c.our_role, summaryOf(c)) }}
</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
@@ -166,6 +169,8 @@ import {
contractCategoryText,
productCategoryText,
productCategoryTag,
contractProfitLabel,
contractProfitText,
} from "../../dict";
const props = defineProps({
@@ -173,6 +178,8 @@ const props = defineProps({
projectId: { type: [Number, String], default: null },
// 关联项目名称(用于新建合同时预设)
projectName: { type: String, default: "" },
// 项目类型:1=单一项目(仅上游) 2=上下游项目
projectType: { type: [Number, String], default: 1 },
});
const emit = defineEmits(["count"]);
@@ -187,25 +194,53 @@ const createVisible = ref(false);
const editRow = ref(null);
const initStep = ref(1);
/** 新建合同时预设的关联项目 */
/** 新建合同时预设的关联项目(带项目类型,供新建向导给出上中游提示) */
const presetProject = computed(() =>
props.projectId ? { id: props.projectId, project_name: props.projectName } : null
props.projectId
? { id: props.projectId, project_name: props.projectName, project_type: props.projectType }
: null
);
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
/**
* 合同在项目中的上下游归属:
* 判定以「我方是收款方还是付款方」为准 —— 参与方中出现供应商(我方下游)即下游合同;
* 其余按我方角色判断:我方为甲方(采购 / 付款方)算下游,为乙方(供货 / 收款方)算上游。
* 注意:不能只看对方是否在客户库建档,客户也可能是我方的上游供应商。
* 是否展示合同归属分段标签:上下游项目区分「上游 / 下游」,
* 上中游项目区分「上中游 / 中下游」;单一项目只有一段,不展示。
*/
const showSideTag = computed(() => [2, 3].includes(Number(props.projectType)));
/**
* 合同在项目中的归属分段(side:up / down / mid):
* - 上下游项目(type=2):判定以「我方是收款方还是付款方」为准 —— 参与方中出现供应商(我方下游)
* 即下游合同;其余按我方角色判断:我方为甲方(采购 / 付款方)算下游,为乙方(供货 / 收款方)算上游。
* 注意:不能只看对方是否在客户库建档,客户也可能是我方的上游供应商。
* - 上中游项目(type=3):我方不参与签约(our_role=0 或参与方中无本公司)即「上中游」
* (上游与中游主体之间的合同,甲乙双方均非本公司);否则为我方与中游主体之间的「中下游」合同。
*/
function contractSideOf(c) {
const parties = Array.isArray(c?.parties) ? c.parties : [];
if (Number(props.projectType) === 3) {
const ownIn = Number(c?.our_role) !== 0 && parties.some((p) => Number(p?.ref_type) === 0);
return ownIn ? "down" : "mid";
}
if (parties.some((p) => Number(p?.ref_type) === 2)) return "down";
return Number(c?.our_role) === 1 ? "down" : "up";
}
/** 分段标签文案:上下游项目=上游 / 下游;上中游项目=上中游 / 中下游 */
function sideLabel(c) {
const side = contractSideOf(c);
if (Number(props.projectType) === 3) return side === "down" ? "中下游" : "上中游";
return side === "down" ? "下游" : "上游";
}
/** 分段标签颜色 */
function sideTagType(c) {
const side = contractSideOf(c);
if (Number(props.projectType) === 3) return side === "down" ? "warning" : "danger";
return side === "down" ? "warning" : "success";
}
/** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */
function summaryOf(c) {
const s = c?.summary;
@@ -69,10 +69,31 @@
</el-button>
</el-upload>
<el-button size="small" :icon="Refresh" :disabled="loading" @click="reload">刷新</el-button>
<!-- 视图切换:列表 / 图标(记住上次选择) -->
<el-radio-group v-model="viewMode" size="small">
<el-radio-button value="list">
<el-tooltip content="列表视图" placement="top">
<el-icon><List /></el-icon>
</el-tooltip>
</el-radio-button>
<el-radio-button value="grid">
<el-tooltip content="图标视图" placement="top">
<el-icon><Grid /></el-icon>
</el-tooltip>
</el-radio-button>
</el-radio-group>
</div>
</div>
<el-table :data="list" v-loading="loading" stripe border size="small">
<!-- 列表视图 -->
<el-table
v-if="viewMode === 'list'"
:data="list"
v-loading="loading"
stripe
border
size="small"
>
<el-table-column label="文件名" min-width="150" show-overflow-tooltip>
<template #default="{ row }">
<a :href="row.file_url" target="_blank" class="file-link">
@@ -102,13 +123,55 @@
</el-table-column>
<template #empty><el-empty description="暂无项目文档" :image-size="60" /></template>
</el-table>
<!-- 图标视图:文件卡片(点击文件图标/名称打开,删除在卡片底部) -->
<div v-else class="doc-grid" v-loading="loading">
<div
v-for="row in list"
:key="row.id"
class="doc-card"
:title="`${row.file_name || row.title || '-'}(${formatFileSize(row.size)})`"
>
<a class="doc-card-link" :href="row.file_url" target="_blank">
<span class="doc-card-icon" :style="{ color: docIcon(row).color }">
<el-icon><component :is="docIcon(row).icon" /></el-icon>
</span>
<span class="doc-card-name">{{ row.file_name || row.title || "-" }}</span>
</a>
<span class="doc-card-meta">{{ formatFileSize(row.size) }}</span>
<div class="doc-card-actions">
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</div>
<el-empty
v-if="!list.length && !loading"
description="暂无项目文档"
:image-size="60"
class="grid-empty"
/>
</div>
<!-- 分页:列表 / 图标视图共用,默认每页 20 -->
<div class="docs-pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
size="small"
background
@size-change="handlePageSizeChange"
@current-change="reload"
/>
</div>
</section>
</div>
</div>
</template>
<script setup>
import { computed, nextTick, ref } from "vue";
import { computed, nextTick, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
Upload,
@@ -117,6 +180,15 @@ import {
FolderDelete,
Folder,
FolderOpened,
List,
Grid,
Document,
DataBoard,
Picture,
Box,
VideoCamera,
Headset,
Files,
} from "@element-plus/icons-vue";
import {
getProjectDocs,
@@ -149,10 +221,34 @@ const DOC_TYPE_TEXT = {
8: "音频",
};
// 视图模式:list 列表 / grid 图标卡片(localStorage 记住上次选择)
const VIEW_KEY = "crm_project_doc_view";
const viewMode = ref(localStorage.getItem(VIEW_KEY) === "grid" ? "grid" : "list");
watch(viewMode, (v) => localStorage.setItem(VIEW_KEY, v));
// 文档类型图标与颜色(图标视图卡片使用)
const DOC_TYPE_ICON = {
0: { icon: Files, color: "#909399" },
1: { icon: Document, color: "#409eff" },
2: { icon: Grid, color: "#67c23a" },
3: { icon: DataBoard, color: "#e6a23c" },
4: { icon: Document, color: "#f56c6c" },
5: { icon: Picture, color: "#67c23a" },
6: { icon: Box, color: "#e6a23c" },
7: { icon: VideoCamera, color: "#9c27b0" },
8: { icon: Headset, color: "#e6a23c" },
};
function docIcon(row) {
return DOC_TYPE_ICON[Number(row?.doc_type)] || DOC_TYPE_ICON[0];
}
const loading = ref(false);
const uploading = ref(false);
const pending = ref(0);
const list = ref([]);
// 分页:默认每页 20,与后端 DocList 一致
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
// 项目文件夹树(根为项目文件夹,子节点为项目内新建的文件夹=文档库子分类)
const folderTree = ref([]);
// 项目根文件夹分类ID
@@ -186,10 +282,17 @@ function findFolder(nodes, id) {
return null;
}
/** 左侧树节点点击:切换筛选文件夹 */
/** 左侧树节点点击:切换筛选文件夹(回到第一页) */
function handleNodeClick(data) {
if (!data.id || data.id === selectedFolder.value) return;
selectedFolder.value = data.id;
pagination.page = 1;
reload();
}
/** 每页条数变化:回到第一页重新拉取 */
function handlePageSizeChange() {
pagination.page = 1;
reload();
}
@@ -204,29 +307,39 @@ function syncTreeHighlight() {
async function reload() {
if (!props.projectId) {
list.value = [];
pagination.total = 0;
emit("count", 0);
return;
}
loading.value = true;
try {
const params = { page: 1, pageSize: 200 };
const params = { page: pagination.page, pageSize: pagination.pageSize };
if (selectedFolder.value) params.category_id = selectedFolder.value;
const res = await getProjectDocs(props.projectId, params);
const data = res?.data || {};
rootId.value = data.category_id || 0;
list.value = data.list || [];
pagination.total = Number(data.total) || 0;
folderTree.value = data.folders || [];
// 兜底:默认选中项目根文件夹(项目名称);选中的文件夹已被删除时回到根
if (!selectedFolder.value || !findFolder(folderTree.value, selectedFolder.value)) {
selectedFolder.value = rootId.value;
}
// 当前页超出总页数(删除文档 / 切换文件夹后)自动回退到最后一页
const maxPage = Math.max(1, Math.ceil(pagination.total / pagination.pageSize));
if (pagination.page > maxPage) {
pagination.page = maxPage;
await reload();
return;
}
} catch (e) {
list.value = [];
} finally {
loading.value = false;
}
syncTreeHighlight();
emit("count", list.value.length);
// 计数(Tab 角标)使用全部文档数而非当前页条数
emit("count", pagination.total);
}
function extOf(name) {
@@ -339,7 +452,11 @@ async function handleUpload(options) {
} finally {
pending.value -= 1;
uploading.value = pending.value > 0;
if (pending.value === 0) reload();
if (pending.value === 0) {
// 上传完成后回到第一页,便于直接看到新文档
pagination.page = 1;
reload();
}
}
}
@@ -493,6 +610,14 @@ defineExpose({ reload });
align-items: center;
gap: 8px;
flex-shrink: 0;
/* 视图切换按钮:图标居中、紧凑内边距 */
:deep(.el-radio-button__inner) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 5px 9px;
}
}
}
@@ -504,4 +629,86 @@ defineExpose({ reload });
text-decoration: underline;
}
}
/* 分页:列表 / 图标视图共用 */
.docs-pagination {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
/* 图标视图:文件卡片网格 */
.doc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
align-content: start;
max-height: 420px;
overflow-y: auto;
padding: 2px;
.doc-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 14px 8px 8px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
background: var(--el-bg-color);
transition: border-color 0.2s, box-shadow 0.2s;
&:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.07);
}
.doc-card-link {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
width: 100%;
text-decoration: none;
}
.doc-card-icon {
font-size: 34px;
line-height: 1;
}
.doc-card-name {
width: 100%;
font-size: 12px;
color: var(--el-text-color-primary);
text-align: center;
line-height: 1.35;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
word-break: break-all;
&:hover {
color: var(--el-color-primary);
}
}
.doc-card-meta {
font-size: 11px;
color: var(--el-text-color-secondary);
text-align: center;
}
.doc-card-actions {
display: flex;
justify-content: center;
margin-top: -2px;
}
}
.grid-empty {
grid-column: 1 / -1;
}
}
</style>
@@ -8,12 +8,14 @@
@opened="handleOpened"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" label-position="right">
<!-- 项目类型:决定是否需要维护下游(供应商)信息 -->
<!-- 项目类型:决定是否需要维护「对方主体」(上下游项目的下游 / 上中游项目的中游) -->
<el-form-item label="项目类型" prop="project_type">
<el-radio-group v-model="form.project_type">
<el-radio-button :value="1">单一项目</el-radio-button>
<el-radio-button :value="2">上下游项目</el-radio-button>
<el-radio-button :value="3">上中游项目</el-radio-button>
</el-radio-group>
<!-- <span class="type-tip">{{ projectTypeTip }}</span> -->
</el-form-item>
<el-divider content-position="left">项目概况</el-divider>
@@ -59,34 +61,41 @@
</el-col>
</el-row>
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游项目才区分上游(客户侧) -->
<el-divider content-position="left">{{ isBackToBack ? "上游信息" : "客户信息" }}</el-divider>
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游 / 上中游项目区分上游(客户侧) -->
<el-divider content-position="left">{{ hasCounterparty ? "上游信息" : "客户信息" }}</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="客户" prop="customer_id">
<el-select
v-model="form.customer_id"
filterable
remote
clearable
:remote-method="searchCustomers"
:loading="customerLoading"
placeholder="输入客户名称搜索"
style="width: 100%"
>
<el-option v-for="c in customerOptions" :key="c.id" :label="c.customer_name" :value="c.id" />
</el-select>
<div class="picker-with-add">
<el-select
v-model="form.customer_id"
filterable
remote
clearable
:remote-method="searchCustomers"
:loading="customerLoading"
placeholder="输入客户名称搜索"
style="flex: 1"
@change="onCustomerPick"
>
<el-option v-for="c in customerOptions" :key="c.id" :label="c.customer_name" :value="c.id" />
</el-select>
<!-- 客户库没有时,可直接新建,创建成功后自动选中并回填 -->
<el-tooltip content="没有找到?点击新建客户" placement="top">
<el-button :icon="Plus" @click="openCustomerCreate" />
</el-tooltip>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="isBackToBack ? '上游金额' : '项目金额'" prop="upstream_amount">
<el-form-item :label="hasCounterparty ? '上游金额' : '项目金额'" prop="upstream_amount">
<el-input v-model="form.upstream_amount" placeholder="对客户的收入金额">
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="isBackToBack ? '上游对接人' : '对接人'" prop="upstream_contact">
<el-form-item :label="hasCounterparty ? '上游对接人' : '对接人'" prop="upstream_contact">
<!-- 对接人从该客户已有联系人中带出(自动带出电话),不支持手工输入 -->
<el-select
v-model="form.upstream_contact"
@@ -108,55 +117,70 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item :label="isBackToBack ? '上游联系电话' : '联系电话'" prop="upstream_phone">
<el-form-item :label="hasCounterparty ? '上游联系电话' : '联系电话'" prop="upstream_phone">
<el-input v-model="form.upstream_phone" placeholder="客户方对接人电话" maxlength="20" />
</el-form-item>
</el-col>
</el-row>
<!-- 下游信息:仅上下游项目需要维护 -->
<template v-if="isBackToBack">
<el-divider content-position="left">下游信息</el-divider>
<!-- 对方主体信息:上下游项目维护下游(供应商/客户),上中游项目维护中游(中间方 B 公司) -->
<template v-if="hasCounterparty">
<el-divider content-position="left">{{ counterpartyLabel }}信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="供应商/客户" prop="supplier_name">
<el-autocomplete
v-model="form.supplier_name"
clearable
value-key="supplier_name"
:fetch-suggestions="querySuppliers"
placeholder="输入名称搜索供应商库 / 客户库,没有则直接填写"
style="width: 100%"
@focus="searchSuppliers(form.supplier_name || '')"
@select="applySupplier"
@blur="() => applySupplier(null)"
>
<template #default="{ item }">
<span>{{ item.supplier_name }}</span>
<el-tag size="small" effect="plain" :type="Number(item.ref_type) === 1 ? 'success' : 'warning'">
{{ Number(item.ref_type) === 1 ? "客户" : "供应商" }}
</el-tag>
</template>
</el-autocomplete>
<el-form-item :label="isUpMid ? '中游企业' : '供应商/客户'" prop="supplier_name">
<div class="picker-with-add">
<el-autocomplete
v-model="form.supplier_name"
clearable
value-key="supplier_name"
:fetch-suggestions="querySuppliers"
:placeholder="
isUpMid
? '输入名称搜索供应商库 / 客户库(中游 B 公司),没有则直接填写'
: '输入名称搜索供应商库 / 客户库,没有则直接填写'
"
style="flex: 1"
@focus="searchSuppliers(form.supplier_name || '')"
@select="applySupplier"
@blur="() => applySupplier(null)"
>
<template #default="{ item }">
<span>{{ item.supplier_name }}</span>
<el-tag size="small" effect="plain" :type="Number(item.ref_type) === 1 ? 'success' : 'warning'">
{{ Number(item.ref_type) === 1 ? "客户" : "供应商" }}
</el-tag>
</template>
</el-autocomplete>
<!-- 中游企业可能不在客户 / 供应商库中:支持直接新建(建到客户库)并自动关联 -->
<el-tooltip v-if="isUpMid" content="没有找到?点击新建中游企业" placement="top">
<el-button :icon="Plus" @click="openCounterpartyCreate" />
</el-tooltip>
</div>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下游金额" prop="downstream_amount">
<el-input v-model="form.downstream_amount" placeholder="支付给供应商的成本">
<el-form-item :label="`${counterpartyLabel}金额`" prop="downstream_amount">
<el-input
v-model="form.downstream_amount"
:placeholder="isUpMid ? '中游方与我方的合同金额' : '支付给供应商的成本'"
>
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下游对接人" prop="downstream_contact">
<!-- 对接人从下游主体(供应商 / 客户)已有联系人中带出,不支持手工输入 -->
<el-form-item :label="`${counterpartyLabel}对接人`" prop="downstream_contact">
<!-- 对接人从对方主体(供应商 / 客户)已有联系人中带出,不支持手工输入 -->
<el-select
v-model="form.downstream_contact"
filterable
clearable
:loading="downstreamContactLoading"
:disabled="!form.supplier_id"
:placeholder="form.supplier_id ? '选择对方联系人' : '请先选择供应商/客户'"
:placeholder="
form.supplier_id ? '选择对方联系人' : isUpMid ? '请先选择中游企业' : '请先选择供应商/客户'
"
style="width: 100%"
@change="onDownstreamContactPick"
>
@@ -170,8 +194,21 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下游联系电话" prop="downstream_phone">
<el-input v-model="form.downstream_phone" placeholder="供应商方对接人电话" maxlength="20" />
<el-form-item :label="`${counterpartyLabel}联系电话`" prop="downstream_phone">
<el-input
v-model="form.downstream_phone"
:placeholder="isUpMid ? '中游方对接人电话' : '供应商方对接人电话'"
maxlength="20"
/>
</el-form-item>
</el-col>
</el-row>
<!-- 上中游项目:下游固定为本公司,无需维护,仅展示 -->
<el-row v-if="isUpMid" :gutter="20">
<el-col :span="12">
<el-form-item label="下游(本公司)">
<el-input :model-value="ownCompanyName" disabled />
</el-form-item>
</el-col>
</el-row>
@@ -184,6 +221,24 @@
</el-divider>
<ProductList :products="form.products" /> -->
<!-- 快速新建客户:创建成功后自动选中并回填项目客户信息 -->
<QuickCreateParty
v-model:visible="customerCreateVisible"
:ref-type="1"
:preset-name="customerKeyword"
associate-tip="项目客户"
@created="onCustomerCreated"
/>
<!-- 快速新建中游企业:中游企业可能不在客户 / 供应商库中,创建成功后自动选中并回填中游信息 -->
<QuickCreateParty
v-model:visible="counterpartyCreateVisible"
:ref-type="1"
:preset-name="form.supplier_name"
associate-tip="项目中游企业"
@created="onCounterpartyCreated"
/>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
@@ -194,6 +249,7 @@
<script setup>
import { computed, ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { createProject, updateProject } from "@/api/crmPipeline";
import { getProductList } from "@/api/crmProduct";
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
@@ -202,6 +258,7 @@ import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
import { PROJECT_STATUS_OPTIONS, formatMoney } from "../../dict";
import ProductList from "../../contract/components/ProductList.vue";
import QuickCreateParty from "../../contract/components/QuickCreateParty.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -217,11 +274,14 @@ const internalId = ref(null);
const userOptions = ref([]);
const customerOptions = ref([]);
const customerLoading = ref(false);
// 最近一次搜索词:打开新建客户弹窗时带入名称,省去重复输入
const customerKeyword = ref("");
const customerCreateVisible = ref(false);
const defaultForm = () => ({
project_name: "",
project_no: "",
// 项目类型:1=单一项目(仅上游) 2=上下游项目(上游客户 + 下游供应商)
// 项目类型:1=单一项目(仅上游) 2=上下游项目(上游客户 + 下游供应商) 3=上中游项目(上游 A + 中游 B,下游为本公司)
project_type: 1,
customer_id: "",
customer_name: "",
@@ -251,16 +311,40 @@ const form = reactive(defaultForm());
/** 是否上下游项目:需要维护下游(供应商)信息 */
const isBackToBack = computed(() => Number(form.project_type) === 2);
/** 是否上中游项目:上游 A 公司 + 中游 B 公司,下游固定为本公司 */
const isUpMid = computed(() => Number(form.project_type) === 3);
/** 是否需要维护「对方主体」:上下游项目的下游 / 上中游项目的中游(两者复用同一组字段存储) */
const hasCounterparty = computed(() => isBackToBack.value || isUpMid.value);
/** 对方主体称谓:上下游项目叫「下游」,上中游项目叫「中游」 */
const counterpartyLabel = computed(() => (isUpMid.value ? "中游" : "下游"));
/** 项目类型说明:随所选类型提示参与方结构 */
// const projectTypeTip = computed(() => {
// switch (Number(form.project_type)) {
// case 2:
// return "上游客户 + 下游供应商(背靠背)";
// case 3:
// return "上游 A 公司 + 中游 B 公司,下游为本公司";
// default:
// return "仅上游客户";
// }
// });
/** 本公司名称:上中游项目的下游固定为本公司 */
const ownCompanyName = computed(() => authStore.user?.tenant_name || "本公司");
/** 项目毛利:上游金额 − 下游金额(仅上下游项目有意义) */
const profitAmount = computed(
() => (Number(form.upstream_amount) || 0) - (Number(form.downstream_amount) || 0)
);
/** 切换为单一项目时清空下游信息,避免残留数据造成误解 */
/** 切换为单一项目时清空下游 / 中游信息,避免残留数据造成误解 */
watch(
() => form.project_type,
(val) => {
if (Number(val) === 2) return;
if (Number(val) === 2 || Number(val) === 3) return;
form.supplier_id = 0;
form.supplier_ref_type = 0;
form.supplier_name = "";
@@ -376,6 +460,7 @@ async function loadUsers() {
}
async function searchCustomers(keyword) {
customerKeyword.value = String(keyword || "");
customerLoading.value = true;
try {
const res = await getCrmCustomerList({ page: 1, pageSize: 20, keyword: keyword || "" });
@@ -388,6 +473,35 @@ async function searchCustomers(keyword) {
}
}
/** 选中客户后同步名称;清空时一并清掉,避免把旧名称提交到后端 */
function onCustomerPick(id) {
const hit = customerOptions.value.find((c) => Number(c.id) === Number(id));
form.customer_name = hit ? hit.customer_name : "";
}
/* --------------------------- 快速新建客户 --------------------------- */
function openCustomerCreate() {
customerCreateVisible.value = true;
}
/**
* 快速新建客户成功:刷新候选并自动选中新客户,
* 同时按新建时填写的信息回填对接人与联系电话(对接人候选随 customer_id 自动加载)。
*/
async function onCustomerCreated(row) {
if (!row?.id) return;
const name = row.customer_name || row.label || "";
await searchCustomers("");
if (!customerOptions.value.some((c) => Number(c.id) === Number(row.id))) {
customerOptions.value.unshift({ id: row.id, customer_name: name });
}
form.customer_id = Number(row.id);
form.customer_name = name;
form.upstream_contact = row.contact_person || "";
form.upstream_phone = row.contact_phone || "";
}
/* --------------------------- 下游主体(供应商 / 客户) --------------------------- */
const supplierOptions = ref([]);
@@ -451,6 +565,30 @@ function applySupplier(item) {
}
}
/* --------------------------- 快速新建中游企业 --------------------------- */
// 中游企业可能不在客户 / 供应商库中:支持直接新建到客户库后自动关联
const counterpartyCreateVisible = ref(false);
function openCounterpartyCreate() {
counterpartyCreateVisible.value = true;
}
/**
* 快速新建中游企业成功:按新建记录回填中游企业(库关联 + 标准名称),
* 并用新建时填写的联系人带出中游对接人 / 电话(联系人候选随 supplier_id 自动加载)。
*/
async function onCounterpartyCreated(row) {
if (!row?.id) return;
const name = row.customer_name || row.supplier_name || row.label || "";
await searchSuppliers(name);
form.supplier_id = Number(row.id);
form.supplier_ref_type = Number(row.ref_type) || 1; // 新建的企业落在客户库
form.supplier_name = name;
form.downstream_contact = row.contact_person || "";
form.downstream_phone = row.contact_phone || "";
}
/* --------------------------- 对接人(取自企业联系人) --------------------------- */
const upstreamContacts = ref([]);
@@ -554,15 +692,15 @@ async function handleSubmit() {
owner_user_name: form.owner_user_name,
industry: form.industry,
upstream_amount: upstream,
downstream_amount: isBackToBack.value ? Number(form.downstream_amount) || 0 : 0,
downstream_amount: hasCounterparty.value ? Number(form.downstream_amount) || 0 : 0,
amount: upstream, // 兼容旧字段
status: Number(form.status) || 1,
start_date: form.start_date,
end_date: form.end_date,
upstream_contact: form.upstream_contact,
upstream_phone: form.upstream_phone,
downstream_contact: isBackToBack.value ? form.downstream_contact : "",
downstream_phone: isBackToBack.value ? form.downstream_phone : "",
downstream_contact: hasCounterparty.value ? form.downstream_contact : "",
downstream_phone: hasCounterparty.value ? form.downstream_phone : "",
address: form.address,
remark: form.remark,
products: JSON.stringify(form.products || []),
@@ -602,6 +740,17 @@ async function handleSubmit() {
color: var(--el-color-danger);
}
/* 下拉选择 + 右侧新建按钮 */
.picker-with-add {
display: flex;
gap: 8px;
width: 100%;
.el-button {
flex-shrink: 0;
}
}
.opt-meta {
margin-left: 8px;
font-size: 12px;
+8 -7
View File
@@ -50,15 +50,15 @@
<el-table-column prop="project_no" label="项目编号" width="140" show-overflow-tooltip />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain" :type="Number(row.project_type) === 2 ? 'warning' : 'info'">
{{ Number(row.project_type) === 2 ? "上下游" : "单一" }}
<el-tag size="small" effect="plain" :type="projectTypeTag(row.project_type)">
{{ projectTypeShortText(row.project_type) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="客户" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<!-- 上下游项目:客户 / 供应商;单一项目:仅客户 -->
<span v-if="Number(row.project_type) === 2">
<!-- 上下游:客户 / 下游;上中游:上游客户 / 中游;单一项目:仅客户 -->
<span v-if="Number(row.project_type) === 2 || Number(row.project_type) === 3">
{{ row.customer_name || "-" }} / {{ row.supplier_name || "-" }}
</span>
<span v-else>{{ row.customer_name || "-" }}</span>
@@ -72,10 +72,10 @@
{{ Number(row.project_type) === 2 ? formatMoney(row.downstream_amount) : "-" }}
</template>
</el-table-column> -->
<el-table-column label="逾期" width="100" align="center">
<el-table-column label="逾期" width="130" align="center">
<template #default="{ row }">
<el-tag v-if="projectOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(projectOverdue(row).days) }}
{{ projectOverdue(row).text }}
</el-tag>
<span v-else>-</span>
</template>
@@ -140,8 +140,9 @@ import {
PROJECT_STATUS_OPTIONS,
projectStatusText,
projectStatusTag,
projectTypeShortText,
projectTypeTag,
projectOverdue,
overdueText,
formatMoney,
formatDateOnly,
} from "../dict";
@@ -3,11 +3,11 @@
<div class="page-header">
<div>
<h2>单位设置</h2>
<p>维护产品、报价等业务使用的计量单位,产品表单的「单位」下拉取自本表</p>
<p>维护产品、报价等业务使用的计量单位。系统默认单位不可编辑、不可删除;可新增本租户自定义单位</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
<el-button :icon="RefreshLeft" @click="handleInitDefault">恢复默认单位</el-button>
<!-- <el-button :icon="RefreshLeft" @click="handleInitDefault">恢复默认单位</el-button> -->
<el-button type="primary" :icon="Plus" @click="openCreate">新增单位</el-button>
</div>
</div>
@@ -38,7 +38,21 @@
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id">
<el-table-column prop="name" label="单位名称" min-width="140" show-overflow-tooltip />
<el-table-column label="单位名称" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.name }}
<!-- 系统默认单位:只读(不可编辑 / 删除) -->
<el-tag
v-if="Number(row.is_default) === 1"
type="primary"
size="small"
effect="plain"
style="margin-left: 6px"
>
默认
</el-tag>
</template>
</el-table-column>
<el-table-column prop="code" label="单位编码" width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.code || "-" }}</template>
</el-table-column>
@@ -59,8 +73,16 @@
</el-table-column>
<el-table-column label="操作" width="160" 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 v-if="Number(row.is_default) === 1">
<el-tooltip content="系统默认单位不可编辑、不可删除" placement="top">
<span class="readonly-tip">系统内置</span>
</el-tooltip>
</template>
<template v-else>
<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>
</template>
</el-table-column>
<template #empty><el-empty description="暂无单位" :image-size="80" /></template>
@@ -139,26 +161,36 @@ function openCreate() {
}
function openEdit(row) {
// 双保险:系统默认单位不可编辑(按钮已隐藏,这里再拦一次)
if (Number(row.is_default) === 1) {
ElMessage.warning("系统默认单位不可编辑,如需调整请新增自定义单位");
return;
}
currentRow.value = { ...row };
dialogVisible.value = true;
}
/** 补齐缺失的系统默认单位(已存在的不会被覆盖) */
async function handleInitDefault() {
try {
await ElMessageBox.confirm("将补充缺失的默认单位(套 / 台 / 个 …),已存在的单位不会被修改。", "恢复默认单位", {
type: "warning",
});
const res = await initDefaultUnits();
const added = res?.data?.added || 0;
ElMessage.success(added ? `已补充 ${added} 个单位` : "默认单位已齐全");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "操作失败");
}
}
// async function handleInitDefault() {
// try {
// await ElMessageBox.confirm("将补充缺失的默认单位(套 / 台 / 个 …),已存在的单位不会被修改。", "恢复默认单位", {
// type: "warning",
// });
// const res = await initDefaultUnits();
// const added = res?.data?.added || 0;
// ElMessage.success(added ? `已补充 ${added} 个单位` : "默认单位已齐全");
// fetchList();
// } catch (e) {
// if (e !== "cancel") ElMessage.error(e.message || "操作失败");
// }
// }
async function handleDelete(row) {
// 双保险:系统默认单位不可删除(按钮已隐藏,这里再拦一次)
if (Number(row.is_default) === 1) {
ElMessage.warning("系统默认单位不可删除");
return;
}
const used = Number(row.used_count) || 0;
const tip = used ? `该单位已被 ${used} 个产品引用,删除后产品中的历史单位不受影响。` : "删除后不可恢复。";
try {
@@ -173,3 +205,12 @@ async function handleDelete(row) {
</script>
<style lang="less" scoped src="../../styles/crm-page.less"></style>
<style lang="less" scoped>
/* 系统默认单位:只读提示 */
.readonly-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
cursor: help;
}
</style>
+58 -4
View File
@@ -66,6 +66,10 @@
<el-table-column label="供应商名称" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.supplier_name }}</span>
<!-- 同一家单位可能既是供应商又是客户(已建客户档案) -->
<el-tag v-if="row.also_customer" size="small" type="warning" effect="plain" style="margin-left: 6px">
也是客户
</el-tag>
</template>
</el-table-column>
<el-table-column label="类型" width="140" align="center">
@@ -98,10 +102,11 @@
<el-table-column label="更新时间" width="160" align="center">
<template #default="{ row }">{{ formatDateTime(row.update_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="260" align="center" fixed="right">
<el-table-column label="操作" width="330" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openContact(row)">联系人</el-button>
<el-button link type="success" size="small" @click="handleConvertCustomer(row)">转为客户</el-button>
<el-button link type="primary" size="small" @click="handleToggleStatus(row)">
{{ row.status === '1' ? '禁用' : '启用' }}
</el-button>
@@ -140,6 +145,10 @@
size="560px"
>
<div v-if="detailData" class="detail-content">
<div class="detail-actions">
<el-button type="success" size="small" @click="handleConvertCustomer(detailData)">转为客户</el-button>
<span class="detail-actions-tip">同一家单位可能既是供应商又是客户,转化后原供应商记录保留</span>
</div>
<el-descriptions title="基本信息" :column="2" border size="small">
<el-descriptions-item label="供应商名称" :span="2">
{{ detailData.supplier_name }}
@@ -240,12 +249,14 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="mobile" label="手机号" width="130" />
<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="170" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) || row.mobile || "-" }}</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" show-overflow-tooltip />
<el-table-column prop="wechat" label="微信" width="120" show-overflow-tooltip />
<el-table-column prop="qq" label="QQ" width="110" show-overflow-tooltip />
<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="120" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openContactEdit(row)">编辑</el-button>
@@ -284,6 +295,7 @@ import {
getCrmSupplierList,
deleteCrmSupplier,
toggleCrmSupplierStatus,
convertSupplierToCustomer,
} from "@/api/crm";
import { listContacts, deleteContact } from "@/api/contact";
import SupplierEdit from "./components/edit.vue";
@@ -301,6 +313,7 @@ import {
statusTag,
formatDate,
formatDateTime,
formatMobiles,
} from "../dict";
const loading = ref(false);
@@ -392,6 +405,35 @@ function openDetail(row) {
detailVisible.value = true;
}
/**
* 供应商转客户:把供应商的企业资料与联系人复制一套到客户库。
* 同一家单位可能既是供应商又是客户,原供应商记录保留;客户库已有同名企业时只补同步联系人,不重复建档。
*/
async function handleConvertCustomer(row) {
try {
await ElMessageBox.confirm(
`将供应商「${row.supplier_name}」的企业资料与联系人复制一套到客户库?原供应商记录会保留,可在客户管理中继续完善。`,
"转为客户",
{ type: "warning", confirmButtonText: "确认转化" }
);
} catch (e) {
return; // 取消
}
try {
const res = await convertSupplierToCustomer(row.id);
const data = res?.data || {};
const c = data.contacts || {};
ElMessage.success(
data.existed
? `客户库已有同名企业,已同步联系人(新增 ${c.inserted || 0}、补充 ${c.merged || 0}),未重复建档`
: `已转为客户,并同步联系人(新增 ${c.inserted || 0})`
);
fetchList();
} catch (e) {
ElMessage.error(e.message || "转化失败");
}
}
async function handleToggleStatus(row) {
const newStatus = row.status === "1" ? "0" : "1";
const action = newStatus === "1" ? "启用" : "禁用";
@@ -552,6 +594,18 @@ async function handleContactDelete(row) {
.detail-content {
padding: 4px 8px;
.detail-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.detail-actions-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
:deep(.el-descriptions__label) {
width: 130px;
min-width: 130px;
@@ -21,6 +21,8 @@
<el-tag v-if="row.is_primary === 1" type="danger" size="small" effect="plain" style="margin-left:4px">主要</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" width="90" show-overflow-tooltip />
<el-table-column prop="position" label="职位" width="90" show-overflow-tooltip />
<el-table-column prop="gender" label="性别" width="60" align="center">
<template #default="{ row }">{{ genderText(row.gender) }}</template>
</el-table-column>
@@ -31,8 +33,6 @@
</el-tag>
</template>
</el-table-column>
<el-table-column prop="department" label="部门" width="90" show-overflow-tooltip />
<el-table-column prop="position" label="职位" width="90" show-overflow-tooltip />
<!-- <el-table-column prop="phone" label="座机" width="110" show-overflow-tooltip /> -->
<el-table-column label="手机" min-width="130" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) }}</template>
+50 -2
View File
@@ -68,6 +68,8 @@
<div class="name-cell-wrapper">
<span class="name-cell name-link" @click="openDetail(row)">{{ row.customer_name }}</span>
<el-tag v-if="row.is_draft === 1" type="warning" size="small" effect="plain">草稿</el-tag>
<!-- 同一家单位可能既是客户又是供应商(已建供应商档案) -->
<el-tag v-if="row.also_supplier" type="warning" size="small" effect="plain">也是供应商</el-tag>
</div>
</template>
</el-table-column>
@@ -101,10 +103,11 @@
{{ formatDateTime(row.update_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<el-table-column label="操作" width="300" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openContactBook(row)">通讯录</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="success" size="small" @click="handleConvertSupplier(row)">转为供应商</el-button>
<el-button link type="primary" size="small" @click="handleToggleStatus(row)">
{{ row.status === '1' ? '禁用' : '启用' }}
</el-button>
@@ -140,6 +143,10 @@
size="560px"
>
<div v-if="detailData" class="detail-content">
<div class="detail-actions">
<el-button type="success" size="small" @click="handleConvertSupplier(detailData)">转为供应商</el-button>
<span class="detail-actions-tip">同一家单位可能既是客户又是供应商,转化后原客户记录保留</span>
</div>
<el-descriptions title="基本信息" :column="2" border size="small" label-width="120px">
<el-descriptions-item label="客户名称" :span="2">{{ detailData.customer_name }}</el-descriptions-item>
<el-descriptions-item label="客户类型">{{ customerTypeText(detailData.customer_type) }}</el-descriptions-item>
@@ -206,7 +213,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Cpu } from '@element-plus/icons-vue'
import { getCustomerList, deleteCustomer, toggleCustomerStatus } from '@/api/customer'
import { getCustomerList, deleteCustomer, toggleCustomerStatus, convertCustomerToSupplier } from '@/api/customer'
import CustomerEdit from './components/edit.vue'
import ContactBook from '../components/contactBook.vue'
import SmartAddCompany from '../components/smartAddCompany.vue'
@@ -377,6 +384,35 @@ async function handleDelete(row) {
}
}
/**
* 客户转供应商:把客户的企业资料与联系人复制一套到供应商库。
* 同一家单位可能既是客户又是供应商,原客户记录保留;供应商库已有同名企业时只补同步联系人,不重复建档。
*/
async function handleConvertSupplier(row) {
try {
await ElMessageBox.confirm(
`将客户「${row.customer_name}」的企业资料与联系人复制一套到供应商库?原客户记录会保留,可在供应商管理中继续完善。`,
'转为供应商',
{ type: 'warning', confirmButtonText: '确认转化' }
)
} catch (e) {
return // 取消
}
try {
const res = await convertCustomerToSupplier(row.id)
const data = res?.data || {}
const c = data.contacts || {}
ElMessage.success(
data.existed
? `供应商库已有同名企业,已同步联系人(新增 ${c.inserted || 0}、补充 ${c.merged || 0}),未重复建档`
: `已转为供应商,并同步联系人(新增 ${c.inserted || 0})`
)
fetchList()
} catch (e) {
ElMessage.error(e.message || '转化失败')
}
}
function customerTypeText(type) {
const map = { '1': '企业', '2': '政府机构', '3': '国企', '4': '教育机构', '5':'个人' }
return map[type] || type
@@ -479,4 +515,16 @@ function statusTag(status) {
display: flex;
justify-content: flex-end;
}
.detail-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.detail-actions-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+50 -2
View File
@@ -67,6 +67,8 @@
<div class="name-cell-wrapper">
<span class="name-cell name-link" @click="openDetail(row)">{{ row.supplier_name }}</span>
<el-tag v-if="row.is_draft === 1" type="warning" size="small" effect="plain">草稿</el-tag>
<!-- 同一家单位可能既是供应商又是客户(已建客户档案) -->
<el-tag v-if="row.also_customer" type="warning" size="small" effect="plain">也是客户</el-tag>
</div>
</template>
</el-table-column>
@@ -100,10 +102,11 @@
{{ formatDateTime(row.update_time) }}
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center" fixed="right">
<el-table-column label="操作" width="300" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openContactBook(row)">通讯录</el-button>
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="success" size="small" @click="handleConvertCustomer(row)">转为客户</el-button>
<el-button link type="primary" size="small" @click="handleToggleStatus(row)">
{{ row.status === '1' ? '禁用' : '启用' }}
</el-button>
@@ -139,6 +142,10 @@
size="560px"
>
<div v-if="detailData" class="detail-content">
<div class="detail-actions">
<el-button type="success" size="small" @click="handleConvertCustomer(detailData)">转为客户</el-button>
<span class="detail-actions-tip">同一家单位可能既是供应商又是客户,转化后原供应商记录保留</span>
</div>
<el-descriptions title="基本信息" :column="2" border size="small" label-width="120px">
<el-descriptions-item label="供应商名称" :span="2">{{ detailData.supplier_name }}</el-descriptions-item>
<el-descriptions-item label="供应商类型">{{ supplierTypeText(detailData.supplier_type) }}</el-descriptions-item>
@@ -205,7 +212,7 @@
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Search, Refresh, Cpu } from '@element-plus/icons-vue'
import { getSupplierList, deleteSupplier, toggleSupplierStatus } from '@/api/supplier'
import { getSupplierList, deleteSupplier, toggleSupplierStatus, convertSupplierToCustomer } from '@/api/supplier'
import SupplierEdit from './components/edit.vue'
import ContactBook from '../components/contactBook.vue'
import SmartAddCompany from '../components/smartAddCompany.vue'
@@ -376,6 +383,35 @@ async function handleDelete(row) {
}
}
/**
* 供应商转客户:把供应商的企业资料与联系人复制一套到客户库。
* 同一家单位可能既是供应商又是客户,原供应商记录保留;客户库已有同名企业时只补同步联系人,不重复建档。
*/
async function handleConvertCustomer(row) {
try {
await ElMessageBox.confirm(
`将供应商「${row.supplier_name}」的企业资料与联系人复制一套到客户库?原供应商记录会保留,可在客户管理中继续完善。`,
'转为客户',
{ type: 'warning', confirmButtonText: '确认转化' }
)
} catch (e) {
return // 取消
}
try {
const res = await convertSupplierToCustomer(row.id)
const data = res?.data || {}
const c = data.contacts || {}
ElMessage.success(
data.existed
? `客户库已有同名企业,已同步联系人(新增 ${c.inserted || 0}、补充 ${c.merged || 0}),未重复建档`
: `已转为客户,并同步联系人(新增 ${c.inserted || 0})`
)
fetchList()
} catch (e) {
ElMessage.error(e.message || '转化失败')
}
}
function supplierTypeText(type) {
const map = { '1': '原材料供应商', '2': '设备供应商', '3': '服务供应商', '4': '其他' }
return map[type] || type
@@ -478,4 +514,16 @@ function statusTag(status) {
display: flex;
justify-content: flex-end;
}
.detail-actions {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
}
.detail-actions-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -41,9 +41,11 @@
<h4>联系方式</h4>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="手机号">
<span v-if="contact.phone">
<a :href="'tel:' + contact.phone" class="contact-link">{{ contact.phone }}</a>
</span>
<template v-if="contactMobiles.length">
<div v-for="(m, i) in contactMobiles" :key="i">
<a :href="'tel:' + m" class="contact-link">{{ m }}</a>
</div>
</template>
<span v-else class="empty-text">-</span>
</el-descriptions-item>
<el-descriptions-item label="办公电话">
@@ -124,6 +126,28 @@ const genderLabel = (gender) => {
return map[gender] || '未知'
}
/** 解析多手机号:JSON 数组字符串优先,缺省回退单个 phone */
const contactMobiles = computed(() => {
const c = props.contact
if (!c) return []
let arr = []
const raw = c.mobiles
if (Array.isArray(raw)) {
arr = raw
} else if (raw !== undefined && raw !== null && String(raw).trim()) {
const s = String(raw).trim()
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : [s]
} catch (error) {
arr = s.split(/[,,、;;\s]+/)
}
}
arr = arr.map((m) => String(m || '').trim()).filter(Boolean)
if (arr.length === 0 && c.phone) arr = [String(c.phone).trim()]
return arr
})
const handleEdit = () => {
emit('edit', props.contact)
}
@@ -84,15 +84,23 @@
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="手机号" prop="phone">
<el-input
v-model="form.phone"
placeholder="请输入手机号"
maxlength="20"
/>
<el-col :span="24">
<!-- 手机号:支持登记多个(有的人有多个号码),默认一行,点加号增加,最多 5 个 -->
<el-form-item label="手机号" prop="mobiles">
<div class="mobile-list">
<div v-for="(m, idx) in form.mobiles" :key="idx" class="mobile-item">
<el-input v-model="form.mobiles[idx]" placeholder="请输入手机号" maxlength="20" clearable />
<el-button :icon="Delete" :disabled="form.mobiles.length <= 1" @click="removeMobile(idx)" />
</div>
<el-button v-if="form.mobiles.length < 5" type="primary" plain :icon="Plus" @click="addMobile">
添加手机号({{ form.mobiles.length }}/5)
</el-button>
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="办公电话" prop="work_phone">
<el-input
@@ -223,6 +231,7 @@
<script setup>
import { computed, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Plus, Delete } from '@element-plus/icons-vue'
import { getOrganizationList, getPositionList, getPositionDetail } from '@/api/contactOA'
const props = defineProps({
@@ -248,6 +257,8 @@ const emptyForm = () => ({
contact_type: 2,
gender: 0,
phone: '',
// 手机号:动态列表,默认一行,点击加号增加(最多 5 个,与后端存储上限一致)
mobiles: [''],
work_phone: '',
email: '',
wechat: '',
@@ -273,8 +284,17 @@ const rules = {
contact_type: [
{ required: true, message: '请选择联系人类型', trigger: 'change' }
],
phone: [
{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号码', trigger: 'blur' }
mobiles: [
{
// 手机号非必填,填写时逐个校验格式
validator: (rule, value, callback) => {
const list = Array.isArray(value) ? value : [value]
const bad = list.map((m) => String(m || '').trim()).find((m) => m && !/^1[3-9]\d{9}$/.test(m))
if (bad) return callback(new Error(`手机号格式不正确:${bad}`))
callback()
},
trigger: 'blur'
}
],
email: [
{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }
@@ -367,9 +387,41 @@ const handlePositionChange = (val) => {
form.value.position_title = pos ? pos.position_name : ''
}
/** 增加一行手机号输入 */
const addMobile = () => {
if (form.value.mobiles.length < 5) form.value.mobiles.push('')
}
/** 删除一行手机号输入(至少保留一行) */
const removeMobile = (idx) => {
if (form.value.mobiles.length > 1) form.value.mobiles.splice(idx, 1)
}
/** 解析手机号集合:兼容 JSON 数组字符串 / 分隔符字符串 / 旧数据单个号码(最多 5 个) */
const parseMobilesArr = (row) => {
let arr = []
const raw = row?.mobiles
if (Array.isArray(raw)) {
arr = raw
} else if (raw !== undefined && raw !== null && String(raw).trim()) {
const s = String(raw).trim()
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : [s]
} catch (error) {
arr = s.split(/[,,、;;\s]+/)
}
}
arr = arr.map((m) => String(m || '').trim()).filter(Boolean).slice(0, 5)
if (arr.length === 0 && row?.phone) arr = [String(row.phone).trim()]
return arr.length ? arr : ['']
}
const initForm = () => {
if (props.isEdit && props.formData) {
form.value = { ...props.formData }
// 回显手机号:mobiles JSON 数组优先,缺省回退单个 phone
form.value.mobiles = parseMobilesArr(props.formData)
} else {
form.value = emptyForm()
}
@@ -380,7 +432,14 @@ const handleSubmit = async () => {
try {
await formRef.value.validate()
submitting.value = true
const payload = { ...form.value }
// 手机号:动态列表去空后存 JSON 字符串(后端 TEXT 存 JSON 数组,最多 5 个),
// phone 同步为第一个号码,保持新旧字段一致
const valid = form.value.mobiles.map((m) => String(m || '').trim()).filter(Boolean)
const payload = {
...form.value,
mobiles: valid.length ? JSON.stringify(valid) : '',
phone: valid[0] || ''
}
emit('save', payload)
} catch {
// validation failed
@@ -424,4 +483,16 @@ watch(() => form.value.contact_type, (type) => {
justify-content: flex-end;
gap: 8px;
}
/* 手机号:动态列表,每行输入框 + 删除按钮 */
.mobile-list {
width: 100%;
.mobile-item {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
}
</style>
+27 -1
View File
@@ -116,7 +116,9 @@
<span>{{ row.org_name || row.dept_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="phone" label="手机号" width="140" />
<el-table-column label="手机号" width="150" show-overflow-tooltip>
<template #default="{ row }">{{ formatMobiles(row.mobiles) || row.phone || '-' }}</template>
</el-table-column>
<!-- <el-table-column prop="wechat" label="微信" width="180">
<template #default="{ row }">
<span>{{ row.wechat || '-' }}</span>
@@ -319,6 +321,8 @@ const editFormData = reactive({
contact_type: 2,
gender: 0,
phone: '',
// 多手机号原始 JSON 数组字符串,编辑弹窗内解析回显
mobiles: '',
work_phone: '',
email: '',
wechat: '',
@@ -342,6 +346,7 @@ const openCreateContact = () => {
contact_type: 2,
gender: 0,
phone: '',
mobiles: '',
work_phone: '',
email: '',
wechat: '',
@@ -366,6 +371,8 @@ const openEditContact = (row) => {
contact_type: row.contact_type,
gender: row.gender,
phone: row.phone,
// 多手机号原始 JSON 数组字符串,编辑弹窗内解析回显
mobiles: row.mobiles || '',
work_phone: row.work_phone,
email: row.email,
wechat: row.wechat,
@@ -399,6 +406,25 @@ const handleSaveContact = async (payload) => {
}
}
/** 多手机号格式化:JSON数组 / 分隔符字符串 / 单个号码 → "138…、139…"(无内容返回空串) */
function formatMobiles(val) {
if (!val) return ''
let arr = []
if (Array.isArray(val)) {
arr = val
} else {
const s = String(val).trim()
if (!s) return ''
try {
const parsed = JSON.parse(s)
arr = Array.isArray(parsed) ? parsed : [s]
} catch (e) {
arr = s.split(/[,,、;;\s]+/)
}
}
return arr.map((m) => String(m || '').trim()).filter(Boolean).join('、')
}
// ---- 详情 ----
const detailDialogVisible = ref(false)
const detailContact = ref(null)