批量更新功能
This commit is contained in:
1 parent
6f106e43af
commit
a26c98b92b
79 files changed
+4529
-724
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user