批量优化产品和产品分类
This commit is contained in:
@@ -37,6 +37,16 @@
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 合同管理(一个项目可关联多份合同,横向 Tab 切换查看) -->
|
||||
<el-tab-pane :label="`合同管理 (${counts.contract})`" name="contract">
|
||||
<DetailContract
|
||||
ref="contractRef"
|
||||
:project-id="project.id"
|
||||
:project-name="project.project_name || ''"
|
||||
@count="counts.contract = $event"
|
||||
/>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- 附件(链路累积:线索 + 商机 + 项目) -->
|
||||
<el-tab-pane :label="`附件 (${counts.attach})`" name="attach">
|
||||
<DetailAttach
|
||||
@@ -73,6 +83,7 @@ import { ref, reactive, watch } from "vue";
|
||||
import DetailBasic from "./detail_basic.vue";
|
||||
import DetailContact from "./detail_contact.vue";
|
||||
import DetailFollow from "./detail_follow.vue";
|
||||
import DetailContract from "./detail_contract.vue";
|
||||
import DetailAttach from "./detail_attach.vue";
|
||||
import DetailDocs from "./detail_docs.vue";
|
||||
import DetailLog from "./detail_log.vue";
|
||||
@@ -87,10 +98,11 @@ const emit = defineEmits(["update:visible", "refresh"]);
|
||||
const RELATED_TYPE = 3;
|
||||
|
||||
const activeTab = ref("basic");
|
||||
const counts = reactive({ contact: 0, follow: 0, attach: 0, docs: 0 });
|
||||
const counts = reactive({ contact: 0, follow: 0, contract: 0, attach: 0, docs: 0 });
|
||||
|
||||
const contactRef = ref();
|
||||
const followRef = ref();
|
||||
const contractRef = ref();
|
||||
const attachRef = ref();
|
||||
const docsRef = ref();
|
||||
const logRef = ref();
|
||||
@@ -102,6 +114,7 @@ watch(
|
||||
activeTab.value = "basic";
|
||||
counts.contact = 0;
|
||||
counts.follow = 0;
|
||||
counts.contract = 0;
|
||||
counts.attach = 0;
|
||||
counts.docs = 0;
|
||||
}
|
||||
@@ -117,6 +130,7 @@ function loadAll() {
|
||||
if (!props.project?.id) return;
|
||||
contactRef.value?.reload();
|
||||
followRef.value?.reload();
|
||||
contractRef.value?.reload();
|
||||
attachRef.value?.reload();
|
||||
docsRef.value?.reload();
|
||||
logRef.value?.reload();
|
||||
|
||||
@@ -0,0 +1,364 @@
|
||||
<template>
|
||||
<div class="detail-contract">
|
||||
<div class="tab-toolbar">
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="handleCreate">新增合同</el-button>
|
||||
<!-- <span class="tip">一个项目可关联多份合同,点击上方横向 Tab 切换查看</span> -->
|
||||
</div>
|
||||
|
||||
<div v-loading="loading">
|
||||
<!-- 多个合同:横向 Tab 逐个展示合同内容 -->
|
||||
<el-tabs v-if="list.length" v-model="activeId" tab-position="top" class="contract-tabs">
|
||||
<el-tab-pane
|
||||
v-for="c in list"
|
||||
:key="c.id"
|
||||
:name="String(c.id)"
|
||||
>
|
||||
<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>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<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) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="pane-actions">
|
||||
<el-button link type="primary" size="small" @click="handleEdit(c)">编辑</el-button>
|
||||
<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-descriptions :column="2" border style="margin-top: 12px">
|
||||
<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>
|
||||
<el-descriptions-item label="结束日期">{{ formatDateOnly(c.expire_date) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注" :span="2">{{ c.remark || "-" }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-divider content-position="left">各方签约主体</el-divider>
|
||||
<el-table :data="c.parties || []" border size="small">
|
||||
<el-table-column label="角色" width="80" align="center">
|
||||
<template #default="{ row }">{{ partyLabel(row.role) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签约主体" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.ref_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="来源" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="Number(row.ref_type) === 1" size="small" type="success" effect="plain">客户</el-tag>
|
||||
<el-tag v-else-if="Number(row.ref_type) === 2" size="small" type="warning" effect="plain">供应商</el-tag>
|
||||
<span v-else>本公司</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="签约人" width="100">
|
||||
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="联系电话" width="120">
|
||||
<template #default="{ row }">{{ row.signer_phone || "-" }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider content-position="left">产品清单</el-divider>
|
||||
<el-table :data="c.products || []" border size="small">
|
||||
<el-table-column type="index" label="#" width="45" align="center" />
|
||||
<el-table-column prop="name" label="产品名称" min-width="140" show-overflow-tooltip />
|
||||
<el-table-column label="类别" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="productCategoryTag(row.category)" size="small" effect="plain">
|
||||
{{ productCategoryText(row.category) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="unit" label="单位" width="60" align="center" />
|
||||
<el-table-column label="数量" width="80" align="right">
|
||||
<template #default="{ row }">{{ row.quantity ?? "-" }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价" width="100" align="right">
|
||||
<template #default="{ row }">{{ formatMoney(row.price) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="小计" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
{{ formatMoney((Number(row.quantity) || 0) * (Number(row.price) || 0)) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="100" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.remark || "-" }}</template>
|
||||
</el-table-column>
|
||||
<template #empty><el-empty description="暂无产品" :image-size="60" /></template>
|
||||
</el-table>
|
||||
|
||||
<el-divider content-position="left">金额汇总</el-divider>
|
||||
<div class="summary-grid">
|
||||
<div class="summary-card">
|
||||
<span class="label">合同总金额</span>
|
||||
<span class="value primary">¥{{ formatMoney(summaryOf(c).total_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<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>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">硬件部分金额</span>
|
||||
<span class="value warning">¥{{ formatMoney(summaryOf(c).hardware_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">软件部分金额</span>
|
||||
<span class="value primary">¥{{ formatMoney(summaryOf(c).software_amount) }}</span>
|
||||
</div>
|
||||
<div class="summary-card">
|
||||
<span class="label">其他部分金额</span>
|
||||
<span class="value">¥{{ formatMoney(summaryOf(c).other_amount) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-empty v-else-if="!loading" description="暂无合同" :image-size="60">
|
||||
<el-button type="primary" size="small" :icon="Plus" @click="handleCreate">新增合同</el-button>
|
||||
</el-empty>
|
||||
</div>
|
||||
|
||||
<!-- 进度式创建 / 编辑向导(新建时预设当前项目) -->
|
||||
<ContractCreate
|
||||
v-model:visible="createVisible"
|
||||
:edit-data="editRow"
|
||||
:init-step="initStep"
|
||||
:preset-project="presetProject"
|
||||
@success="reload"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 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";
|
||||
|
||||
const props = defineProps({
|
||||
// 关联项目ID
|
||||
projectId: { type: [Number, String], default: null },
|
||||
// 关联项目名称(用于新建合同时预设)
|
||||
projectName: { type: String, default: "" },
|
||||
});
|
||||
const emit = defineEmits(["count"]);
|
||||
|
||||
const loading = ref(false);
|
||||
const list = ref([]);
|
||||
const activeId = ref("");
|
||||
|
||||
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
|
||||
);
|
||||
|
||||
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
|
||||
|
||||
/** 金额汇总:优先后端 summary,缺失时按产品清单前端重算 */
|
||||
function summaryOf(c) {
|
||||
const s = c?.summary;
|
||||
if (s && Object.keys(s).length) return s;
|
||||
return buildSummary(c?.products || []);
|
||||
}
|
||||
|
||||
/** 供父组件(detail.vue)在抽屉打开时调用 */
|
||||
async function reload() {
|
||||
if (!props.projectId) {
|
||||
list.value = [];
|
||||
emit("count", 0);
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await getContractList({ project_id: props.projectId, page: 1, pageSize: 100 });
|
||||
list.value = res?.data?.list || [];
|
||||
} catch (e) {
|
||||
list.value = [];
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
// 保持当前选中合同有效,失效时回落到第一份
|
||||
if (!list.value.some((c) => String(c.id) === String(activeId.value))) {
|
||||
activeId.value = list.value.length ? String(list.value[0].id) : "";
|
||||
}
|
||||
emit("count", list.value.length);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
editRow.value = null;
|
||||
initStep.value = 1;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function handleEdit(c) {
|
||||
editRow.value = { ...c };
|
||||
initStep.value = Number(c.step) === 2 ? 2 : 1;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(c) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除合同「${c.contract_name}」吗?删除后不可恢复。`, "删除确认", {
|
||||
type: "warning",
|
||||
});
|
||||
await deleteContract(c.id);
|
||||
ElMessage.success("删除成功");
|
||||
reload();
|
||||
} catch (e) {
|
||||
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ reload });
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.tab-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.contract-tabs {
|
||||
:deep(.el-tabs__header) {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__item) {
|
||||
min-width: auto;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 0 14px;
|
||||
height: 40px;
|
||||
line-height: 40px;
|
||||
}
|
||||
|
||||
:deep(.el-tabs__content) {
|
||||
padding-left: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
max-width: 220px;
|
||||
|
||||
.tab-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.contract-pane {
|
||||
.pane-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pane-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
.name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.pane-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.pane-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 12px;
|
||||
|
||||
.summary-card {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
|
||||
&.primary {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
&.success {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
&.warning {
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,7 @@
|
||||
<el-dialog
|
||||
:model-value="visible"
|
||||
:title="isEdit ? '编辑项目' : '新增项目'"
|
||||
width="760px"
|
||||
width="960px"
|
||||
:close-on-click-modal="false"
|
||||
@update:model-value="handleClose"
|
||||
@opened="handleOpened"
|
||||
@@ -98,6 +98,13 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
</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>
|
||||
@@ -109,10 +116,12 @@
|
||||
import { 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 { getAllUsers } from "@/api/user";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { PROJECT_STATUS_OPTIONS } from "../../dict";
|
||||
import ProductList from "../../contract/components/ProductList.vue";
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
@@ -146,10 +155,58 @@ const defaultForm = () => ({
|
||||
contact_phone: "",
|
||||
address: "",
|
||||
remark: "",
|
||||
products: [],
|
||||
});
|
||||
|
||||
const form = reactive(defaultForm());
|
||||
|
||||
/** 解析项目产品清单:兼容 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" }],
|
||||
};
|
||||
@@ -163,6 +220,15 @@ watch(
|
||||
internalId.value = props.editData.id;
|
||||
Object.assign(form, defaultForm(), props.editData);
|
||||
form.status = Number(props.editData.status) || 1;
|
||||
// 解析项目已关联的产品清单(后端返回 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;
|
||||
@@ -220,7 +286,7 @@ async function handleSubmit() {
|
||||
if (owner) form.owner_user_name = owner.name;
|
||||
submitting.value = true;
|
||||
try {
|
||||
const payload = { ...form, amount: Number(form.amount) || 0 };
|
||||
const payload = { ...form, amount: Number(form.amount) || 0, products: JSON.stringify(form.products || []) };
|
||||
if (internalId.value) {
|
||||
await updateProject(internalId.value, payload);
|
||||
ElMessage.success("更新成功");
|
||||
@@ -237,3 +303,11 @@ async function handleSubmit() {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.tip {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user