批量更新功能

This commit is contained in:
hero920103 committed 2026-09-14 16:15:44 +08:00
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;
+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";