Files
yunzerwebsiteallinone/backend/src/views/apps/crm/project/components/edit.vue
T
2026-09-13 22:31:42 +08:00

611 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<el-dialog
:model-value="visible"
:title="isEdit ? '编辑项目' : '新增项目'"
width="960px"
:close-on-click-modal="false"
@update:model-value="handleClose"
@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">
<el-input v-model="form.project_name" placeholder="请输入项目名称" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目编号" prop="project_no">
<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
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>
</el-form-item>
</el-col>
<el-col :span="12">
<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="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="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">
产品清单
<span class="tip">保存项目时,产品参数将自动写入「产品管理」</span>
</el-divider>
<ProductList :products="form.products" /> -->
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
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, getCrmSupplierList } from "@/api/crm";
import { listContacts } from "@/api/contact";
import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
import { PROJECT_STATUS_OPTIONS, formatMoney } from "../../dict";
import ProductList from "../../contract/components/ProductList.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
editData: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "success"]);
const authStore = useAuthStore();
const formRef = ref();
const submitting = ref(false);
const isEdit = ref(false);
const internalId = ref(null);
const userOptions = ref([]);
const customerOptions = ref([]);
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: "",
upstream_amount: "",
downstream_amount: "",
upstream_contact: "",
upstream_phone: "",
downstream_contact: "",
downstream_phone: "",
status: 1,
start_date: "",
end_date: "",
address: "",
remark: "",
products: [],
});
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 = [];
if (Array.isArray(val)) {
arr = val;
} else if (typeof val === "string" && val.trim()) {
try {
const parsed = JSON.parse(val);
if (Array.isArray(parsed)) arr = parsed;
} catch {
arr = [];
}
}
// 缺失 __key 时多行会被当作同一行,导致回显不出来
return arr.map((r, i) => ({
...r,
__key: r && r.__key ? r.__key : `row_${i}_${Date.now()}`,
}));
}
/**
* 兜底回显:存量项目 / 商机转化项目的 products 可能从未写入(null 或空串),
* 此时从「产品管理」回查归属本项目的产品,保证编辑时能看到产品清单。
*/
async function loadProjectProducts(projectId) {
try {
// 后端 pageSize 上限为 100
const res = await getProductList({ project_id: projectId, page: 1, pageSize: 100 });
const list = res?.data?.list || [];
form.products = list.map((p, i) => ({
__key: `row_p${p.id}_${i}`,
name: p.product_name || "",
product_id: p.id,
category: p.category || "",
spec: p.spec || "",
unit: p.unit || "",
quantity: 1,
price: Number(p.price) || 0,
cost_price: Number(p.cost_price) || 0,
tax_rate: Number(p.tax_rate) || 0,
remark: p.remark || "",
}));
} catch (e) {
form.products = [];
}
}
const rules = {
project_name: [{ required: true, message: "请输入项目名称", trigger: "blur" }],
};
watch(
() => props.visible,
(val) => {
if (val) {
if (props.editData) {
isEdit.value = true;
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);
// 从未写入过产品清单(null / 空串,而非 "[]")时,从产品管理回查兜底回显
const neverSet =
rawProducts === null || rawProducts === undefined || String(rawProducts).trim() === "";
if (neverSet && form.products.length === 0 && props.editData.id) {
loadProjectProducts(props.editData.id);
}
} else {
isEdit.value = false;
internalId.value = null;
Object.assign(form, defaultForm());
form.owner_user_id = authStore.user?.id ? String(authStore.user.id) : "";
form.owner_user_name = authStore.user?.name || "";
}
loadUsers();
searchCustomers(form.customer_name || "");
// 对接人候选随所选企业加载(编辑回显时也需带上,赋值未变化时不触发 watch)
loadUpstreamContacts();
loadDownstreamContacts();
}
}
);
async function loadUsers() {
if (userOptions.value.length) return;
try {
const res = await getAllUsers();
const data = res?.data || {};
const list = Array.isArray(data) ? data : data.list || [];
userOptions.value = list.map((u) => ({ id: u.uid || u.id, name: u.name || u.account || `用户${u.uid || u.id}` }));
} catch (e) {
userOptions.value = [];
}
}
async function searchCustomers(keyword) {
customerLoading.value = true;
try {
const res = await getCrmCustomerList({ page: 1, pageSize: 20, keyword: keyword || "" });
const data = res?.data || {};
customerOptions.value = (data.list || []).map((c) => ({ id: c.id, customer_name: c.customer_name }));
} catch (e) {
customerOptions.value = [];
} finally {
customerLoading.value = false;
}
}
/* --------------------------- 下游主体(供应商 / 客户) --------------------------- */
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);
}
function handleOpened() {
formRef.value?.clearValidate();
}
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch (e) {
return;
}
const owner = userOptions.value.find((u) => String(u.id) === String(form.owner_user_id));
if (owner) form.owner_user_name = owner.name;
submitting.value = true;
try {
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("更新成功");
} else {
await createProject(payload);
ElMessage.success("创建成功");
}
emit("success");
handleClose();
} catch (e) {
ElMessage.error(e.message || "操作失败");
} finally {
submitting.value = false;
}
}
</script>
<style lang="less" scoped>
.tip {
font-size: 12px;
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>