优化项目管理

This commit is contained in:
2026-09-13 22:31:42 +08:00
parent 2f6cdbb3cd
commit a8c09c1db5
30 changed files with 1654 additions and 346 deletions
@@ -1,32 +1,71 @@
<template>
<el-descriptions v-if="project" :column="2" border size="small">
<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="客户名称">{{ project.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="项目金额">{{ formatMoney(project.amount) }}</el-descriptions-item>
<el-descriptions-item label="项目状态">
<el-tag :type="projectStatusTag(project.status)" size="small">
{{ projectStatusText(project.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="负责人">{{ project.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="客户行业">{{ project.industry || "-" }}</el-descriptions-item>
<el-descriptions-item label="开始日期">{{ formatDateOnly(project.start_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(project.end_date) }}</el-descriptions-item>
<el-descriptions-item label="来源商机">
{{ project.business_name || (project.business_id ? `#${project.business_id}` : "-") }}
</el-descriptions-item>
<el-descriptions-item label="对接人">{{ project.contact_person || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人职位">{{ project.contact_position || "-" }}</el-descriptions-item>
<el-descriptions-item label="对接人手机">{{ project.contact_phone || "-" }}</el-descriptions-item>
<el-descriptions-item label="项目地址" :span="2">{{ project.address || "-" }}</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(project.create_time) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(project.update_time) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ project.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<div v-if="project" class="project-basic">
<el-divider content-position="left">基本信息</el-divider>
<el-descriptions :column="2" border size="small">
<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>
</el-descriptions-item>
<el-descriptions-item label="项目状态">
<el-tag :type="projectStatusTag(project.status)" size="small">
{{ projectStatusText(project.status) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="负责人">{{ project.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item v-if="isBackToBack" label="项目毛利">
<span class="profit">¥{{ formatMoney(profitAmount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="项目起止时间" :span="2">
{{ formatDateOnly(contractStart) }} ~ {{ formatDateOnly(contractEnd) }}
</el-descriptions-item>
<el-descriptions-item label="来源商机">
{{ project.business_name || (project.business_id ? `#${project.business_id}` : "-") }}
</el-descriptions-item>
<el-descriptions-item label="创建时间">{{ formatDateTime(project.create_time) }}</el-descriptions-item>
<el-descriptions-item label="更新时间">{{ formatDateTime(project.update_time) }}</el-descriptions-item>
<el-descriptions-item label="项目地址" :span="2">{{ project.address || "-" }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ project.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<!-- 单一项目不存在上下游概念,只展示客户信息;上下游项目则区分上游(客户侧) -->
<el-divider content-position="left">{{ isBackToBack ? "上游信息" : "客户信息" }}</el-divider>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="客户名称">{{ project.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游金额' : '项目金额'">
{{ formatMoney(upstreamAmount) }}
</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游对接人' : '对接人'">
{{ project.upstream_contact || project.contact_person || "-" }}
</el-descriptions-item>
<el-descriptions-item :label="isBackToBack ? '上游联系电话' : '联系电话'">
{{ project.upstream_phone || project.contact_phone || "-" }}
</el-descriptions-item>
</el-descriptions>
<!-- 下游(供应商侧):仅上下游项目展示 -->
<template v-if="isBackToBack">
<el-divider content-position="left">下游信息</el-divider>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="供应商/客户">
{{ 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>
</template>
</div>
</template>
<script setup>
import { computed } from "vue";
import {
projectStatusText,
projectStatusTag,
@@ -35,8 +74,46 @@ import {
formatDateOnly,
} from "../../dict";
defineProps({
const props = defineProps({
// 项目详情对象
project: { type: Object, default: null },
});
/** 是否背靠背项目:需要展示下游(供应商)信息 */
const isBackToBack = computed(() => Number(props.project?.project_type) === 2);
/** 上游金额:新字段优先,兼容存量项目仅有旧「项目金额」的情况 */
const upstreamAmount = computed(
() => Number(props.project?.upstream_amount) || Number(props.project?.amount) || 0
);
/** 项目毛利:上游金额 − 下游金额 */
const profitAmount = computed(
() => upstreamAmount.value - (Number(props.project?.downstream_amount) || 0)
);
/** 项目起止时间:由关联合同汇总(最早开始 / 最晚结束),兼容历史手工日期 */
const contractStart = computed(() => props.project?.contract_start_date || props.project?.start_date || "");
const contractEnd = computed(() => props.project?.contract_end_date || props.project?.end_date || "");
</script>
<style lang="less" scoped>
.project-basic {
padding-top:20px;
/* 首段标题紧贴容器顶部,避免多余留白 */
:deep(.el-divider:first-child) {
margin-top: 0;
}
}
.profit {
font-weight: 600;
color: var(--el-color-danger);
}
.tip {
margin-left: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -16,8 +16,8 @@
<template #label>
<span class="tab-label">
<span class="tab-name">{{ c.contract_name }}</span>
<el-tag :type="contractStatusTag(c.status)" size="small" effect="plain">
{{ contractStatusText(c.status) }}
<el-tag :type="contractSideOf(c) === 'down' ? 'warning' : 'success'" size="small" effect="plain">
{{ contractSideOf(c) === "down" ? "下游" : "上游" }}
</el-tag>
</span>
</template>
@@ -25,12 +25,15 @@
<div class="contract-pane">
<div class="pane-head">
<div class="pane-title">
<span class="name">{{ c.contract_name }}</span>
<!-- <el-tag :type="contractStatusTag(c.status)" size="small">
{{ contractStatusText(c.status) }}
</el-tag> -->
<el-tag type="primary" size="small" effect="plain">
{{ ourRoleText(c.our_role) }}
<span
class="name link"
title="前往合同管理查看该合同完整详情(回款计划、电子合同、发票等)"
@click="goContract(c)"
>
{{ c.contract_name }}
</span>
<el-tag :type="contractSideOf(c) === 'down' ? 'warning' : 'success'" size="small" effect="plain">
{{ contractSideOf(c) === "down" ? "下游" : "上游" }}
</el-tag>
</div>
<!-- <div class="pane-actions">
@@ -38,11 +41,10 @@
<el-button link type="danger" size="small" @click="handleDelete(c)">删除</el-button>
</div> -->
</div>
<div class="pane-sub">
{{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
</div>
<el-divider content-position="left">基础信息</el-divider>
<el-descriptions :column="2" border style="margin-top: 12px">
<el-descriptions-item label="合同编号">{{ c.contract_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="合同类型">{{ contractCategoryText(c.contract_category) }}</el-descriptions-item>
<el-descriptions-item label="签订日期">{{ formatDateOnly(c.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="负责人">{{ c.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(c.effective_date) }}</el-descriptions-item>
@@ -154,15 +156,14 @@ import { ref, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
import { getContractList, deleteContract } from "@/api/crmContract";
import { useRouter } from "vue-router";
import { useMenuStore } from "@/stores/menu";
import ContractCreate from "../../contract/components/create.vue";
import { PARTY_ROLES, buildSummary } from "../../contract/components/utils";
import {
formatMoney,
formatDateOnly,
ourRoleText,
contractCategoryText,
contractStatusText,
contractStatusTag,
productCategoryText,
productCategoryTag,
} from "../../dict";
@@ -175,6 +176,9 @@ const props = defineProps({
});
const emit = defineEmits(["count"]);
const router = useRouter();
const menuStore = useMenuStore();
const loading = ref(false);
const list = ref([]);
const activeId = ref("");
@@ -190,6 +194,18 @@ const presetProject = computed(() =>
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
/**
* 合同在项目中的上下游归属:
* 判定以「我方是收款方还是付款方」为准 —— 参与方中出现供应商(我方下游)即下游合同;
* 其余按我方角色判断:我方为甲方(采购 / 付款方)算下游,为乙方(供货 / 收款方)算上游。
* 注意:不能只看对方是否在客户库建档,客户也可能是我方的上游供应商。
*/
function contractSideOf(c) {
const parties = Array.isArray(c?.parties) ? c.parties : [];
if (parties.some((p) => Number(p?.ref_type) === 2)) return "down";
return Number(c?.our_role) === 1 ? "down" : "up";
}
/** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */
function summaryOf(c) {
const s = c?.summary;
@@ -220,6 +236,34 @@ async function reload() {
emit("count", list.value.length);
}
/**
* 查找「合同管理」菜单路径:按菜单中配置的组件路径定位,
* 避免硬编码(菜单路径可由管理员调整,且需当前用户有该菜单权限)。
*/
function findContractManagePath() {
const walk = (nodes) => {
for (const m of nodes || []) {
if (String(m.component_path || "").includes("crm/contract/index")) return m.path;
if (m.children?.length) {
const hit = walk(m.children);
if (hit) return hit;
}
}
return "";
};
return walk(menuStore.menus) || "";
}
/** 点击合同名称:跳转「合同管理」并自动打开该合同详情 */
function goContract(c) {
const path = findContractManagePath();
if (!path) {
ElMessage.warning("未找到「合同管理」菜单入口,请联系管理员配置");
return;
}
router.push({ path, query: { id: c.id } });
}
function handleCreate() {
editRow.value = null;
initStep.value = 1;
@@ -312,18 +356,22 @@ defineExpose({ reload });
font-size: 15px;
font-weight: 600;
color: var(--el-text-color-primary);
/* 可点击:跳转合同管理查看完整详情 */
&.link {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
}
.pane-actions {
flex-shrink: 0;
}
.pane-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.summary-grid {
@@ -8,6 +8,15 @@
@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-group>
</el-form-item>
<el-divider content-position="left">项目概况</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="项目名称" prop="project_name">
@@ -19,6 +28,40 @@
<el-input v-model="form.project_no" placeholder="请输入项目编号" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人" prop="owner_user_id">
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
</el-select>
</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 v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
</el-select>
</el-form-item>
</el-col>
<el-col v-if="isBackToBack" :span="12">
<el-form-item label="项目毛利">
<span class="profit-value">¥{{ formatMoney(profitAmount) }}</span>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="项目地址" prop="address">
<el-input v-model="form.address" placeholder="请输入项目地址" maxlength="255" />
</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-divider content-position="left">{{ isBackToBack ? "上游信息" : "客户信息" }}</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="客户" prop="customer_id">
<el-select
@@ -36,67 +79,103 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户行业" prop="industry">
<el-input v-model="form.industry" placeholder="如:互联网、制造业" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目金额" prop="amount">
<el-input v-model="form.amount" placeholder="请输入项目金额">
<el-form-item :label="isBackToBack ? '上游金额' : '项目金额'" 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="项目状态" prop="status">
<el-select v-model="form.status" placeholder="请选择" style="width: 100%">
<el-option v-for="i in PROJECT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="Number(i.value)" />
<el-form-item :label="isBackToBack ? '上游对接人' : '对接人'" prop="upstream_contact">
<!-- 对接人从该客户已有联系人中带出(自动带出电话),不支持手工输入 -->
<el-select
v-model="form.upstream_contact"
filterable
clearable
:loading="upstreamContactLoading"
:disabled="!form.customer_id"
:placeholder="form.customer_id ? '选择客户联系人' : '请先选择客户'"
style="width: 100%"
@change="onUpstreamContactPick"
>
<el-option v-for="c in upstreamContacts" :key="c.id" :label="c.contact_name" :value="c.contact_name">
<span>{{ c.contact_name }}</span>
<span class="opt-meta">
{{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人" prop="owner_user_id">
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="开始日期" prop="start_date">
<el-date-picker v-model="form.start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="结束日期" prop="end_date">
<el-date-picker v-model="form.end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="对接人" prop="contact_person">
<el-input v-model="form.contact_person" placeholder="请输入对接人" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="对接人职位" prop="contact_position">
<el-input v-model="form.contact_position" placeholder="如:采购经理、技术总监" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="对接人手机" prop="contact_phone">
<el-input v-model="form.contact_phone" placeholder="请输入手机号" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="项目地址" prop="address">
<el-input v-model="form.address" placeholder="请输入项目地址" maxlength="255" />
</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 :label="isBackToBack ? '上游联系电话' : '联系电话'" 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>
<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>
</el-col>
<el-col :span="12">
<el-form-item label="下游金额" prop="downstream_amount">
<el-input v-model="form.downstream_amount" placeholder="支付给供应商的成本">
<template #append>元</template>
</el-input>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="下游对接人" prop="downstream_contact">
<!-- 对接人从下游主体(供应商 / 客户)已有联系人中带出,不支持手工输入 -->
<el-select
v-model="form.downstream_contact"
filterable
clearable
:loading="downstreamContactLoading"
:disabled="!form.supplier_id"
:placeholder="form.supplier_id ? '选择对方联系人' : '请先选择供应商/客户'"
style="width: 100%"
@change="onDownstreamContactPick"
>
<el-option v-for="c in downstreamContacts" :key="c.id" :label="c.contact_name" :value="c.contact_name">
<span>{{ c.contact_name }}</span>
<span class="opt-meta">
{{ [c.position || c.department, c.mobile || c.phone].filter(Boolean).join(" · ") }}
</span>
</el-option>
</el-select>
</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>
</el-col>
</el-row>
</template>
</el-form>
<!-- <el-divider content-position="left">
@@ -113,14 +192,15 @@
</template>
<script setup>
import { ref, reactive, watch } from "vue";
import { computed, ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { createProject, updateProject } from "@/api/crmPipeline";
import { getProductList } from "@/api/crmProduct";
import { getCrmCustomerList } from "@/api/crm";
import { getCrmCustomerList, getCrmSupplierList } from "@/api/crm";
import { listContacts } from "@/api/contact";
import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
import { PROJECT_STATUS_OPTIONS } from "../../dict";
import { PROJECT_STATUS_OPTIONS, formatMoney } from "../../dict";
import ProductList from "../../contract/components/ProductList.vue";
const props = defineProps({
@@ -141,18 +221,26 @@ const customerLoading = ref(false);
const defaultForm = () => ({
project_name: "",
project_no: "",
// 项目类型:1=单一项目(仅上游) 2=上下游项目(上游客户 + 下游供应商)
project_type: 1,
customer_id: "",
customer_name: "",
supplier_id: 0,
supplier_ref_type: 0,
supplier_name: "",
supplier_industry: "",
owner_user_id: "",
owner_user_name: "",
industry: "",
amount: "",
upstream_amount: "",
downstream_amount: "",
upstream_contact: "",
upstream_phone: "",
downstream_contact: "",
downstream_phone: "",
status: 1,
start_date: "",
end_date: "",
contact_person: "",
contact_position: "",
contact_phone: "",
address: "",
remark: "",
products: [],
@@ -160,6 +248,29 @@ const defaultForm = () => ({
const form = reactive(defaultForm());
/** 是否上下游项目:需要维护下游(供应商)信息 */
const isBackToBack = computed(() => Number(form.project_type) === 2);
/** 项目毛利:上游金额 − 下游金额(仅上下游项目有意义) */
const profitAmount = computed(
() => (Number(form.upstream_amount) || 0) - (Number(form.downstream_amount) || 0)
);
/** 切换为单一项目时清空下游信息,避免残留数据造成误解 */
watch(
() => form.project_type,
(val) => {
if (Number(val) === 2) return;
form.supplier_id = 0;
form.supplier_ref_type = 0;
form.supplier_name = "";
form.supplier_industry = "";
form.downstream_amount = "";
form.downstream_contact = "";
form.downstream_phone = "";
}
);
/** 解析项目产品清单:兼容 JSON 字符串 / 数组 / 空,并补全行内 __key(el-table 的 row-key) */
function parseProducts(val) {
let arr = [];
@@ -220,6 +331,13 @@ watch(
internalId.value = props.editData.id;
Object.assign(form, defaultForm(), props.editData);
form.status = Number(props.editData.status) || 1;
form.project_type = Number(props.editData.project_type) || 1;
// 存量项目兼容:只有旧「项目金额 / 对接人」时回填到上游侧
if (!Number(form.upstream_amount) && Number(props.editData.amount)) {
form.upstream_amount = props.editData.amount;
}
if (!form.upstream_contact) form.upstream_contact = props.editData.contact_person || "";
if (!form.upstream_phone) form.upstream_phone = props.editData.contact_phone || "";
// 解析项目已关联的产品清单(后端返回 JSON 字符串)
const rawProducts = props.editData.products;
form.products = parseProducts(rawProducts);
@@ -238,6 +356,9 @@ watch(
}
loadUsers();
searchCustomers(form.customer_name || "");
// 对接人候选随所选企业加载(编辑回显时也需带上,赋值未变化时不触发 watch)
loadUpstreamContacts();
loadDownstreamContacts();
}
}
);
@@ -267,6 +388,137 @@ async function searchCustomers(keyword) {
}
}
/* --------------------------- 下游主体(供应商 / 客户) --------------------------- */
const supplierOptions = ref([]);
/**
* 下游主体候选:同时检索供应商库与客户库。
* 同一家单位在系统里可能以客户身份建档,因此下游既支持选供应商,也支持选客户。
*/
async function searchSuppliers(keyword) {
const kw = String(keyword || "").trim();
try {
const [supRes, cusRes] = await Promise.all([
getCrmSupplierList({ page: 1, pageSize: 50, keyword: kw }),
getCrmCustomerList({ page: 1, pageSize: 50, keyword: kw }),
]);
const suppliers = (supRes?.data?.list || []).map((s) => ({
id: s.id,
ref_type: 2,
supplier_name: s.supplier_name || "",
}));
const customers = (cusRes?.data?.list || []).map((c) => ({
id: c.id,
ref_type: 1,
supplier_name: c.customer_name || "",
}));
supplierOptions.value = [...suppliers, ...customers];
} catch {
supplierOptions.value = [];
}
}
/** 供应商名称输入建议:本地模糊过滤已加载候选(自由输入同样生效) */
function querySuppliers(query, cb) {
const kw = String(query || "").trim().toLowerCase();
const list = (supplierOptions.value || []).filter(
(s) => !kw || String(s.supplier_name || "").toLowerCase().includes(kw)
);
// 附加 value 字段:兼容 autocomplete 默认以 value 作为选中值
cb(list.slice(0, 50).map((s) => ({ ...s, value: s.supplier_name })));
}
/**
* 选中的库中主体:记录来源类型(1客户 / 2供应商)与 ID,并写入标准名称;
* 手工输入且未命中库时清空关联,仅按名称保存。
*/
function applySupplier(item) {
if (item && item.id) {
form.supplier_id = item.id;
form.supplier_ref_type = Number(item.ref_type) || 0;
form.supplier_name = item.supplier_name;
return;
}
const name = String(form.supplier_name || "").trim();
const hit = supplierOptions.value.find((s) => s.supplier_name === name);
if (hit) {
form.supplier_id = hit.id;
form.supplier_ref_type = Number(hit.ref_type) || 0;
} else {
form.supplier_id = 0;
form.supplier_ref_type = 0;
}
}
/* --------------------------- 对接人(取自企业联系人) --------------------------- */
const upstreamContacts = ref([]);
const upstreamContactLoading = ref(false);
const downstreamContacts = ref([]);
const downstreamContactLoading = ref(false);
/** 查询某企业下的联系人:relatedType 1=客户 2=供应商 */
async function fetchContactsOf(relatedType, relatedID) {
try {
const res = await listContacts({
related_type: relatedType,
related_id: relatedID,
page: 1,
pageSize: 100,
});
return res?.data?.list || [];
} catch {
return [];
}
}
/** 上游对接人候选:所选客户(上游)的联系人 */
async function loadUpstreamContacts() {
const id = Number(form.customer_id) || 0;
if (!id) {
upstreamContacts.value = [];
return;
}
upstreamContactLoading.value = true;
try {
upstreamContacts.value = await fetchContactsOf(1, id);
} finally {
upstreamContactLoading.value = false;
}
}
/** 下游对接人候选:所选下游主体(供应商 / 客户)的联系人 */
async function loadDownstreamContacts() {
const id = Number(form.supplier_id) || 0;
if (!id) {
downstreamContacts.value = [];
return;
}
downstreamContactLoading.value = true;
try {
downstreamContacts.value = await fetchContactsOf(Number(form.supplier_ref_type) === 1 ? 1 : 2, id);
} finally {
downstreamContactLoading.value = false;
}
}
/** 选择上游对接人:自动带出联系电话 */
function onUpstreamContactPick(name) {
const hit = upstreamContacts.value.find((c) => c.contact_name === name);
if (hit) form.upstream_phone = hit.mobile || hit.phone || "";
}
/** 选择下游对接人:自动带出联系电话 */
function onDownstreamContactPick(name) {
const hit = downstreamContacts.value.find((c) => c.contact_name === name);
if (hit) form.downstream_phone = hit.mobile || hit.phone || "";
}
// 客户 / 下游主体变化时刷新对接人候选(不清空已选值,兼容历史手工数据)
watch(() => form.customer_id, loadUpstreamContacts);
watch(() => [form.supplier_id, form.supplier_ref_type], loadDownstreamContacts);
function handleClose() {
emit("update:visible", false);
}
@@ -286,7 +538,35 @@ async function handleSubmit() {
if (owner) form.owner_user_name = owner.name;
submitting.value = true;
try {
const payload = { ...form, amount: Number(form.amount) || 0, products: JSON.stringify(form.products || []) };
const upstream = Number(form.upstream_amount) || 0;
// 仅提交新字段(旧 amount / contact_* 不再使用,由后端按上游侧同步)
const payload = {
project_name: form.project_name,
project_no: form.project_no,
project_type: Number(form.project_type) || 1,
customer_id: Number(form.customer_id) || 0,
customer_name: form.customer_name,
supplier_id: Number(form.supplier_id) || 0,
supplier_ref_type: Number(form.supplier_ref_type) || 0,
supplier_name: form.supplier_name,
supplier_industry: form.supplier_industry,
owner_user_id: form.owner_user_id,
owner_user_name: form.owner_user_name,
industry: form.industry,
upstream_amount: upstream,
downstream_amount: isBackToBack.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 : "",
address: form.address,
remark: form.remark,
products: JSON.stringify(form.products || []),
};
if (internalId.value) {
await updateProject(internalId.value, payload);
ElMessage.success("更新成功");
@@ -310,4 +590,21 @@ async function handleSubmit() {
color: var(--el-text-color-secondary);
margin-left: 8px;
}
.type-tip {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.profit-value {
font-weight: 600;
color: var(--el-color-danger);
}
.opt-meta {
margin-left: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
+28 -8
View File
@@ -48,10 +48,30 @@
</template>
</el-table-column>
<el-table-column prop="project_no" label="项目编号" width="140" show-overflow-tooltip />
<el-table-column prop="customer_name" label="客户名称" min-width="160" show-overflow-tooltip />
<el-table-column label="项目金额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
<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>
</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">
{{ row.customer_name || "-" }} / {{ row.supplier_name || "-" }}
</span>
<span v-else>{{ row.customer_name || "-" }}</span>
</template>
</el-table-column>
<!-- <el-table-column label="上游金额" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.upstream_amount || row.amount) }}</template>
</el-table-column>
<el-table-column label="下游金额" width="120" align="right">
<template #default="{ row }">
{{ Number(row.project_type) === 2 ? formatMoney(row.downstream_amount) : "-" }}
</template>
</el-table-column> -->
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="projectOverdue(row).overdue" type="danger" size="small" effect="dark">
@@ -61,17 +81,17 @@
</template>
</el-table-column>
<el-table-column label="开始日期" width="120" align="center">
<template #default="{ row }">{{ formatDateOnly(row.start_date) }}</template>
<template #default="{ row }">{{ formatDateOnly(row.contract_start_date || row.start_date) }}</template>
</el-table-column>
<el-table-column label="结束日期" width="120" align="center">
<template #default="{ row }">{{ formatDateOnly(row.end_date) }}</template>
<template #default="{ row }">{{ formatDateOnly(row.contract_end_date || row.end_date) }}</template>
</el-table-column>
<el-table-column label="负责人" width="100">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="对接人" width="100">
<template #default="{ row }">{{ row.contact_person || "-" }}</template>
</el-table-column>
<!-- <el-table-column label="上游对接人" width="110">
<template #default="{ row }">{{ row.upstream_contact || row.contact_person || "-" }}</template>
</el-table-column> -->
<template #empty><el-empty description="暂无项目" :image-size="80" /></template>
</el-table>