优化合同个管理和产品管理相关功能

This commit is contained in:
2026-09-12 23:29:45 +08:00
parent 2b1874943f
commit 4455b95e37
92 changed files with 5367 additions and 3144 deletions
@@ -0,0 +1,165 @@
<template>
<el-dialog
:model-value="visible"
:title="title || '软件开发模块报价'"
width="860px"
top="5vh"
append-to-body
@update:model-value="(v) => emit('update:visible', v)"
@open="syncLocal"
>
<div class="dev-tree">
<div class="tree-toolbar">
<el-button type="primary" size="small" :icon="Plus" @click="addRoot">添加顶级模块</el-button>
<span class="tree-tip">叶子模块只填「人天」,人天单价统一填写;父级自动汇总;可任意层级向下拆分</span>
</div>
<div class="unit-price-bar">
<span class="up-label">统一人天单价(元 / 人天)</span>
<el-input-number v-model="unitPrice" :min="0" :precision="2" :controls="false" style="width: 180px" />
<span class="up-total">开发总报价:<b>¥{{ formatMoney(treeSellTotal(tree, unitPrice)) }}</b></span>
</div>
<div v-if="tree.length" class="tree-head">
<span class="th-name">模块名称</span>
<span class="th-md">人天</span>
<span class="th-amount">小计(元)</span>
<span class="th-ops">操作</span>
</div>
<ModuleNode
v-for="n in tree"
:key="n.__id"
:node="n"
:depth="0"
:unit-price="unitPrice"
@remove="removeNode"
/>
<el-empty v-if="!tree.length" description="暂无模块,点击「添加顶级模块」开始" />
</div>
<template #footer>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { treeSellTotal } from "./utils";
import ModuleNode from "./ModuleNode.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
modelValue: { type: Array, default: () => [] },
unitPrice: { type: Number, default: 0 },
title: { type: String, default: "" },
});
const emit = defineEmits(["update:visible", "update:modelValue", "update:unitPrice"]);
const tree = ref([]);
const unitPrice = ref(0);
let uid = 0;
const newNode = () => ({
__id: `m_${Date.now()}_${uid++}`,
name: "",
children: [],
man_days: 0,
});
const syncLocal = () => {
tree.value = Array.isArray(props.modelValue) ? JSON.parse(JSON.stringify(props.modelValue)) : [];
unitPrice.value = Number(props.unitPrice) || 0;
};
watch(tree, (val) => emit("update:modelValue", val), { deep: true });
watch(unitPrice, (val) => emit("update:unitPrice", val));
const addRoot = () => tree.value.push(newNode());
/** 递归从树中移除指定 __id 节点 */
const removeNode = (target) => {
const cut = (list) => {
const idx = list.findIndex((n) => n.__id === target.__id);
if (idx >= 0) {
list.splice(idx, 1);
return true;
}
return list.some((n) => Array.isArray(n.children) && cut(n.children));
};
cut(tree.value);
};
const handleConfirm = () => emit("update:visible", false);
</script>
<style lang="less" scoped>
.dev-tree {
.tree-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
.tree-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.unit-price-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
margin-bottom: 10px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.up-label {
font-size: 13px;
font-weight: 600;
}
.up-total {
margin-left: auto;
font-size: 13px;
color: var(--el-text-color-regular);
b {
color: var(--el-color-primary);
}
}
}
.tree-head {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 4px 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 600;
.th-name {
flex: 1;
min-width: 160px;
}
.th-md {
width: 120px;
}
.th-amount {
width: 120px;
text-align: right;
}
.th-ops {
width: 96px;
text-align: center;
}
}
}
</style>
@@ -0,0 +1,119 @@
<template>
<div class="module-node">
<div class="node-row" :style="{ paddingLeft: depth * 22 + 'px' }">
<span class="node-bar" :style="{ background: depthColor }" />
<el-input v-model="node.name" class="node-name" placeholder="模块名称(如:Web端 / 用户中心)" maxlength="60" />
<template v-if="hasChildren">
<span class="node-agg">汇总</span>
<span class="node-amount">{{ formatMoney(treeSellTotal([node], unitPrice)) }}</span>
</template>
<template v-else>
<el-input-number
v-model="node.man_days"
:min="0"
:precision="1"
:controls="false"
class="node-md"
placeholder="人天"
/>
<span class="node-amount">{{ formatMoney((Number(node.man_days) || 0) * (Number(unitPrice) || 0)) }}</span>
</template>
<div class="node-ops">
<el-button link type="primary" size="small" @click="addChild">+ 子级</el-button>
<el-button link type="danger" size="small" @click="emit('remove', node)">删除</el-button>
</div>
</div>
<ModuleNode
v-for="c in node.children"
:key="c.__id"
:node="c"
:depth="depth + 1"
:unit-price="unitPrice"
@remove="(n) => emit('remove', n)"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import { formatMoney } from "../../dict";
import { treeSellTotal } from "./utils";
defineOptions({ name: "ModuleNode" });
const props = defineProps({
node: { type: Object, required: true },
depth: { type: Number, default: 0 },
unitPrice: { type: Number, default: 0 },
});
const emit = defineEmits(["remove"]);
let uid = 0;
const newNode = () => ({
__id: `m_${Date.now()}_${uid++}`,
name: "",
children: [],
man_days: 0,
});
const hasChildren = computed(() => Array.isArray(props.node.children) && props.node.children.length > 0);
const depthColor = ["#409EFF", "#67C23A", "#E6A23C", "#909399", "#9254DE"][props.depth % 5];
const addChild = () => {
if (!Array.isArray(props.node.children)) props.node.children = [];
props.node.children.push(newNode());
};
</script>
<style lang="less" scoped>
.module-node {
.node-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
margin: 6px 0;
.node-bar {
width: 4px;
align-self: stretch;
border-radius: 2px;
flex-shrink: 0;
}
.node-name {
flex: 1;
min-width: 160px;
}
.node-agg {
font-size: 12px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.node-md {
width: 120px;
flex-shrink: 0;
}
.node-amount {
width: 120px;
text-align: right;
font-weight: 600;
color: var(--el-color-primary);
flex-shrink: 0;
}
.node-ops {
display: flex;
gap: 4px;
flex-shrink: 0;
}
}
}
</style>
@@ -2,9 +2,18 @@
<div class="product-list">
<el-table :data="products" border row-key="__key" :empty-text="'暂无产品,点击下方按钮添加'">
<el-table-column label="序号" type="index" width="50" align="center" />
<el-table-column label="行类型" width="110">
<template #default="{ row }">
<el-select v-model="row.line_type" style="width: 100%" @change="() => onLineTypeChange(row)">
<el-option label="成品" value="product" />
<el-option label="软件开发" value="dev" />
</el-select>
</template>
</el-table-column>
<el-table-column label="产品类别" width="150">
<template #default="{ row }">
<el-select
v-if="row.line_type === 'product'"
v-model="row.category"
filterable
clearable
@@ -19,51 +28,62 @@
:value="c.name"
/>
</el-select>
<span v-else class="muted">软件开发</span>
</template>
</el-table-column>
<el-table-column label="产品名称" min-width="210">
<template #default="{ row }">
<el-select
v-model="row.name"
filterable
allow-create
default-first-option
clearable
:disabled="!row.category"
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
style="width: 100%"
@focus="onProductFocus(row)"
@visible-change="(v) => { if (v) onProductFocus(row) }"
@change="(val) => onProductPick(row, val)"
>
<el-option
v-for="p in productOptions"
:key="p.id"
:label="p.product_name"
:value="p.product_name"
<template v-if="row.line_type === 'dev'">
<el-button link type="primary" @click="openDev(row)">
📁 模块报价({{ (row.dev_tree || []).length }} 项 · ¥{{ formatMoney(lineAmount(row)) }})
</el-button>
</template>
<template v-else>
<el-select
v-model="row.name"
filterable
allow-create
default-first-option
clearable
:disabled="!row.category"
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
style="width: 100%"
@focus="onProductFocus(row)"
@visible-change="(v) => { if (v) onProductFocus(row) }"
@change="(val) => onProductPick(row, val)"
>
<span class="opt-name">{{ p.product_name }}</span>
<span class="opt-meta">{{ p.spec || "—" }} · ¥{{ p.price }}</span>
</el-option>
</el-select>
<!-- <div v-if="row.product_id" class="linked-tip">
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
</div> -->
<el-option
v-for="p in productOptions"
:key="p.id"
:label="p.product_name"
:value="p.product_name"
>
<span class="opt-name">{{ p.product_name }}</span>
<span class="opt-meta">{{ p.spec || "—" }} · ¥{{ p.price }}</span>
</el-option>
</el-select>
<!-- <div v-if="row.product_id" class="linked-tip">
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
</div> -->
</template>
</template>
</el-table-column>
<el-table-column label="规格型号" width="130">
<template #default="{ row }">
<el-input v-model="row.spec" :placeholder="specPlaceholder(row)" />
<el-input v-if="row.line_type === 'product'" v-model="row.spec" :placeholder="specPlaceholder(row)" />
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="单位" width="90">
<template #default="{ row }">
<el-input v-model="row.unit" placeholder="套" maxlength="10" />
<el-input v-if="row.line_type === 'product'" v-model="row.unit" placeholder="套" maxlength="10" />
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="数量" width="110">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.quantity"
:min="0"
:precision="2"
@@ -71,11 +91,13 @@
placeholder="0"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" width="130">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.price"
:min="0"
:precision="2"
@@ -83,26 +105,31 @@
placeholder="0.00"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="成本单价(元)" width="150">
<template #default="{ row }">
<el-tooltip content="成本单价取自产品管理,关联产品后不可修改" :disabled="!row.product_id">
<el-input-number
v-model="row.cost_price"
:min="0"
:precision="2"
:controls="false"
:disabled="!!row.product_id"
placeholder="0.00"
style="width: 100%"
/>
</el-tooltip>
<template v-if="row.line_type === 'product'">
<el-tooltip content="成本单价取自产品管理,关联产品后不可修改" :disabled="!row.product_id">
<el-input-number
v-model="row.cost_price"
:min="0"
:precision="2"
:controls="false"
:disabled="!!row.product_id"
placeholder="0.00"
style="width: 100%"
/>
</el-tooltip>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="税率(%)" width="110">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.tax_rate"
:min="0"
:max="100"
@@ -110,11 +137,12 @@
:controls="false"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="小计(元)" width="120" align="right">
<template #default="{ row }">
<span class="line-amount">{{ formatMoney(rowAmount(row)) }}</span>
<span class="line-amount">{{ formatMoney(lineAmount(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
@@ -132,7 +160,7 @@
<div class="add-row">
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
<span class="add-tip">
先选产品类别,再在该分类下搜索/选择产品(自动带出规格、单位、成本、税率);分类内无对应名称可输入新增,提交时自动建档到产品管理
每行可选「成品」或「软件开发」:成品按数量×单价计价;软件开发点「模块报价」编辑分层模块树(叶子填人天/人天单价,父级自动汇总),统一计入合同总价
</span>
</div>
@@ -170,6 +198,15 @@
</div>
</div>
</div>
<DevModuleTree
v-if="editingRow"
v-model="editingRow.dev_tree"
:unit-price="editingRow.dev_unit_price"
v-model:visible="devVisible"
@update:unit-price="(v) => (editingRow.dev_unit_price = v)"
:title="editingRow.name ? editingRow.name + ' - 模块报价' : '软件开发模块报价'"
/>
</div>
</template>
@@ -177,9 +214,10 @@
import { ref, computed, onMounted } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { buildSummary } from "./utils";
import { buildSummary, lineAmount } from "./utils";
import { getProductList } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import DevModuleTree from "./DevModuleTree.vue";
const props = defineProps({
/** 产品行数组(父组件持有) */
@@ -196,6 +234,7 @@ let rowSeed = 0;
const addRow = () => {
props.products.push({
__key: `row_${Date.now()}_${rowSeed++}`,
line_type: "product",
name: "",
product_id: null,
category: "",
@@ -205,6 +244,8 @@ const addRow = () => {
price: 0,
cost_price: 0,
tax_rate: 0,
dev_tree: [],
dev_unit_price: 0,
remark: "",
});
};
@@ -213,8 +254,23 @@ const removeRow = (index) => {
props.products.splice(index, 1);
};
const rowAmount = (row) =>
Math.round(((Number(row.quantity) || 0) * (Number(row.price) || 0) || 0) * 100) / 100;
/** 切换行类型:软件开发行确保有模块树容器 */
function onLineTypeChange(row) {
if (row.line_type === "dev") {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
if (row.dev_unit_price == null) row.dev_unit_price = 0;
}
}
/* ------------------------------ 软件开发模块树编辑 ------------------------------ */
const devVisible = ref(false);
const editingRow = ref(null);
function openDev(row) {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
editingRow.value = row;
devVisible.value = true;
}
const specPlaceholder = (row) => {
const cat = String(row.category || "");
@@ -273,6 +329,15 @@ function onProductPick(row, val) {
const hit = productOptions.value.find((p) => p.product_name === val);
if (hit) {
row.product_id = hit.id;
// 软件开发产品:带入模块树与人天单价,行类型自动切为 dev
if (hit.line_type === "dev" || hit.dev_tree) {
row.line_type = "dev";
row.dev_unit_price = Number(hit.dev_unit_price) || 0;
row.dev_tree = Array.isArray(hit.dev_tree)
? JSON.parse(JSON.stringify(hit.dev_tree))
: safeParseTree(hit.dev_tree);
return;
}
row.spec = hit.spec || "";
row.unit = hit.unit || "";
row.price = hit.price || 0;
@@ -284,6 +349,17 @@ function onProductPick(row, val) {
}
}
/** 安全解析产品管理返回的 dev_tree(可能为字符串或数组) */
function safeParseTree(v) {
if (Array.isArray(v)) return v;
try {
const parsed = typeof v === "string" ? JSON.parse(v) : v;
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
onMounted(() => {
fetchCategories();
// 为已加载的合同产品预置下拉选项,保证名称回显
@@ -311,6 +387,10 @@ onMounted(() => {
color: var(--el-text-color-primary);
}
.muted {
color: var(--el-text-color-placeholder);
}
.opt-name {
float: left;
}
@@ -8,11 +8,16 @@
@update:model-value="handleClose"
@closed="handleClosed"
>
<!-- 进度条 -->
<!-- 进度条(支持点击步骤直接跳转) -->
<div class="steps-wrapper">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="合同信息" description="关键点信息与各方签约主体" />
<el-step title="产品清单" description="产品明细与金额汇总" />
<el-steps :active="activeStep" finish-status="success" align-center class="clickable-steps">
<el-step
title="合同信息"
description="关键点信息与各方签约主体"
class="step-clickable"
@click="goStep(0)"
/>
<el-step title="产品清单" description="产品明细与金额汇总" class="step-clickable" @click="goStep(1)" />
</el-steps>
</div>
@@ -103,8 +108,8 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目负责人" prop="owner_name">
<el-input v-model="form.owner_name" placeholder="默认为创建人" />
<el-form-item label="项目负责人" prop="owner_user_name">
<el-input v-model="form.owner_user_name" placeholder="默认为创建人" />
</el-form-item>
</el-col>
</el-row>
@@ -145,6 +150,7 @@
value-format="YYYY-MM-DD"
placeholder="签订日期"
style="width: 100%"
@change="onSignDateChange"
/>
</el-form-item>
</el-col>
@@ -173,6 +179,18 @@
</el-row>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="技术日期" prop="tech_date">
<el-date-picker
v-model="form.tech_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="开发/交付完成日期"
style="width: 240px"
/>
<span class="form-tip">技术日期到期且回款未回完,合同状态将自动转为「执行异常」</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark" style="margin-top: 16px">
@@ -272,10 +290,11 @@ const emptyForm = () => ({
party_count: 2, // 合同形式:2双方 3三方 4四方,默认双方
project_id: null,
project_name: "",
owner_name: authStore.user?.name || "",
owner_user_name: authStore.user?.name || "",
sign_date: "",
effective_date: "",
expire_date: "",
tech_date: "",
remark: "",
status: 1,
step: 1,
@@ -395,6 +414,19 @@ watch(
}
);
/** 选择签订日期:生效日期为空时默认带入(生效日期仍可手动修改,改过则不再被覆盖) */
watch(
() => form.sign_date,
(nv) => {
if (nv && !form.effective_date) form.effective_date = nv;
}
);
/** 签订日期变更(用户操作):自动带入生效日期 */
function onSignDateChange(val) {
if (val && !form.effective_date) form.effective_date = val;
}
/** 合同形式文案:2双方 3三方 4四方 */
const partyCountName = (v) => ({ 2: "双方", 3: "三方", 4: "四方" }[Number(v)] || "双方");
@@ -455,6 +487,7 @@ watch(
form.sign_date = normalizeDate(props.editData.sign_date);
form.effective_date = normalizeDate(props.editData.effective_date);
form.expire_date = normalizeDate(props.editData.expire_date);
form.tech_date = normalizeDate(props.editData.tech_date);
parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p }));
products.value = (props.editData.products || []).map((row, i) => ({
...row,
@@ -524,6 +557,13 @@ const prevStep = () => {
if (activeStep.value > 0) activeStep.value--;
};
/** 点击上方步骤条直接跳转(无需按上一步 / 下一步) */
const goStep = (index) => {
const target = Math.max(0, Math.min(MAX_STEP, Number(index) || 0));
if (target === activeStep.value) return;
activeStep.value = target;
};
/** 组装保存 payload;数值字段统一转数字(后端 int8 解析),summary 由产品清单实时计算 */
const buildPayload = (step, status) => ({
...form,
@@ -531,6 +571,7 @@ const buildPayload = (step, status) => ({
sign_date: normalizeDate(form.sign_date),
effective_date: normalizeDate(form.effective_date),
expire_date: normalizeDate(form.expire_date),
tech_date: normalizeDate(form.tech_date),
our_role: Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })),
@@ -633,6 +674,18 @@ const handleClosed = () => {
padding: 0 24px;
}
/* 步骤条可点击跳转 */
.clickable-steps {
:deep(.step-clickable) {
cursor: pointer;
}
:deep(.el-step__head),
:deep(.el-step__main) {
cursor: pointer;
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
@@ -1,143 +1,88 @@
<template>
<el-drawer
:model-value="visible"
title="合同详情"
size="680px"
:title="detail?.contract_name || '合同详情'"
direction="rtl"
size="900px"
:close-on-click-modal="false"
@update:model-value="emit('update:visible', $event)"
@update:model-value="handleClose"
@opened="loadAll"
>
<div v-loading="loading" class="contract-detail">
<template v-if="detail.id">
<div class="detail-head">
<div class="detail-title">
<span class="name">{{ detail.contract_name }}</span>
<el-tag :type="contractStatusTag(detail.status)" size="small">
{{ contractStatusText(detail.status) }}
</el-tag>
<el-tag type="primary" size="small" effect="plain">
我方·{{ ourRoleText(detail.our_role) }}
</el-tag>
</div>
<div class="detail-sub">
{{ detail.contract_no }} · {{ contractCategoryText(detail.contract_category) }}
</div>
<div v-if="detail.id" class="detail-toolbar">
<div class="toolbar-status">
<!-- <el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag> -->
</div>
<el-descriptions :column="2" border style="margin-top: 16px">
<el-descriptions-item label="所属项目" :span="2">
<template v-if="detail.project_id">
<el-tag size="small" type="primary" effect="light">项目合同</el-tag>
<span style="margin-left: 8px">{{ detail.project_name }}</span>
<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(detail.status)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
<template v-else>
<el-tag size="small" type="warning" effect="light">无头合同</el-tag>
</template>
</el-descriptions-item>
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="项目负责人">{{ detail.owner_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">各方签约主体</el-divider>
<el-table :data="detail.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="160" 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 prop="signer_name" label="签约人" width="100">
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
</el-table-column>
<el-table-column prop="signer_phone" 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="detail.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 prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
</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(summary.total_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
<span class="value warning">¥{{ formatMoney(summary.hardware_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">软件部分金额</span>
<span class="value primary">¥{{ formatMoney(summary.software_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">其他部分金额</span>
<span class="value">¥{{ formatMoney(summary.other_amount) }}</span>
</div>
</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>
</template>
</div>
<el-tabs v-if="detail.id" v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息(拆分自原详情内容) -->
<el-tab-pane label="基本信息" name="basic">
<DetailBasic :detail="detail" />
</el-tab-pane>
<!-- 回款计划(针对本合同创建 / 查看回款计划) -->
<el-tab-pane :label="`回款计划 (${counts.payback})`" name="payback">
<DetailPayback ref="paybackRef" :contract="detail" @count="counts.payback = $event" @saved="emit('refresh')" />
</el-tab-pane>
<!-- 电子合同(上传 / 查看合同扫描件、电子合同正本) -->
<el-tab-pane :label="`电子合同 (${counts.econtract})`" name="econtract">
<DetailAttach
ref="econtractRef"
:related-id="detail.id"
:related-type="E_CONTRACT_TYPE"
tip="上传合同扫描件 / 电子合同正本,支持图片与 PDF"
/>
</el-tab-pane>
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="log">
<DetailLog ref="logRef" :related-id="detail.id" :related-type="RELATED_TYPE" />
</el-tab-pane>
</el-tabs>
<el-empty v-else-if="!loading" description="暂无数据" />
</div>
</el-drawer>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { getContractDetail } from "@/api/crmContract";
import { PARTY_ROLES, buildSummary } from "./utils";
import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
import {
formatMoney,
formatDateOnly,
contractCategoryText,
ourRoleText,
getContractDetail,
changeContractStatus,
deleteContract,
} from "@/api/crmContract";
import {
CONTRACT_STATUS_OPTIONS,
contractStatusText,
contractStatusTag,
productCategoryText,
productCategoryTag,
} from "../../dict";
import DetailBasic from "./detail_basic.vue";
import DetailPayback from "./detail_payback.vue";
import DetailLog from "../../project/components/detail_log.vue";
import DetailAttach from "../../project/components/detail_attach.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -145,23 +90,29 @@ const props = defineProps({
contract: { type: Object, default: null },
});
const emit = defineEmits(["update:visible"]);
const emit = defineEmits(["update:visible", "refresh", "edit"]);
// 合同操作日志与项目共用关联类型 3
const RELATED_TYPE = 3;
// 电子合同附件专属关联类型(与线索 1 / 商机 2 / 项目 3 区分)
const E_CONTRACT_TYPE = 4;
const loading = ref(false);
const detail = ref({});
const activeTab = ref("basic");
const counts = reactive({ payback: 0, econtract: 0 });
const summary = computed(() => {
const s = detail.value.summary;
if (s && Object.keys(s).length) return s;
return buildSummary(detail.value.products || []);
});
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
const paybackRef = ref();
const logRef = ref();
const econtractRef = ref();
watch(
() => props.visible,
async (val) => {
if (!val) return;
activeTab.value = "basic";
counts.payback = 0;
counts.econtract = 0;
detail.value = {};
if (!props.contract?.id) return;
loading.value = true;
@@ -175,6 +126,56 @@ watch(
}
}
);
function handleClose() {
emit("update:visible", false);
}
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
if (!detail.value?.id) return;
paybackRef.value?.reload();
logRef.value?.reload();
econtractRef.value?.reload();
}
/** 状态流转候选:排除当前状态 */
function statusOptionsExcept(cur) {
return CONTRACT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(cur));
}
async function handleChangeStatus(status) {
try {
await changeContractStatus(detail.value.id, Number(status));
ElMessage.success(`状态已更新为「${contractStatusText(status)}」`);
detail.value.status = Number(status);
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
}
function handleEdit() {
emit("edit", detail.value);
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
`确定删除合同「${detail.value.contract_name}」吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deleteContract(detail.value.id);
ElMessage.success("删除成功");
emit("update:visible", false);
emit("refresh");
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
defineExpose({ loadAll });
</script>
<style lang="less" scoped>
@@ -182,58 +183,72 @@ watch(
padding: 0 4px;
}
.detail-head {
.detail-title {
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 12px;
margin-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
.toolbar-status {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
.name {
font-size: 17px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.detail-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
:deep(.el-drawer__body) {
overflow-x: hidden;
}
.summary-card {
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
display: flex;
flex-direction: column;
gap: 6px;
.detail-tabs {
display: flex;
width: 100%;
min-width: 0;
.label {
font-size: 12px;
color: var(--el-text-color-secondary);
:deep(.el-tabs__content) {
flex: 1;
min-width: 0;
overflow: hidden;
padding-left: 4px;
}
:deep(.el-tabs__header) {
margin-right: 12px;
flex-shrink: 0;
}
:deep(.el-tabs__item) {
justify-content: flex-start;
text-align: left;
padding: 0 12px;
min-width: 96px;
font-size: 13px;
}
:deep(.el-tab-pane) {
width: 100%;
min-width: 0;
}
:deep(.el-table) {
width: 100%;
.el-button.is-link {
padding: 0;
height: auto;
min-height: auto;
}
.value {
font-size: 17px;
font-weight: 700;
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
&.warning {
color: var(--el-color-warning);
}
.el-button + .el-button {
margin-left: 6px;
}
}
}
@@ -0,0 +1,209 @@
<template>
<div class="contract-basic">
<div class="detail-head">
<div class="detail-title">
<span class="name">{{ detail.contract_name || "-" }}</span>
<!-- <el-tag :type="contractStatusTag(detail.status)" size="small">
{{ contractStatusText(detail.status) }}
</el-tag> -->
<el-tag v-if="contractOverdue(detail).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(detail).days) }}
</el-tag>
</div>
<!-- <div class="detail-sub">
{{ detail.contract_no || "-" }}
</div> -->
</div>
<el-descriptions :column="2" border style="margin-top: 16px">
<el-descriptions-item label="合同类型">
<el-tag v-if="detail.project_id" size="small" type="primary" effect="light">项目合同</el-tag>
<el-tag v-else size="small" type="warning" effect="light">无头合同</el-tag>
<el-tag size="small" type="warning" effect="light" style="margin-left: 8px">{{ contractCategoryText(detail.contract_category) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="合同编号">{{ detail.contract_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="履约状态">
<el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="我方角色">{{ ourRoleText(detail.our_role) }}</el-descriptions-item>
<el-descriptions-item label="所属项目" :span="2">
<span v-if="detail.project_id">{{ detail.project_name }}</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="项目负责人">{{ detail.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
<el-descriptions-item label="技术日期">{{ formatDateOnly(detail.tech_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">各方签约主体</el-divider>
<el-table :data="detail.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="160" 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 prop="signer_name" label="签约人" width="100">
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
</el-table-column>
<el-table-column prop="signer_phone" 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="detail.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 prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
</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(summary.total_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
<span class="value warning">¥{{ formatMoney(summary.hardware_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">软件部分金额</span>
<span class="value primary">¥{{ formatMoney(summary.software_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">其他部分金额</span>
<span class="value">¥{{ formatMoney(summary.other_amount) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { PARTY_ROLES, buildSummary } from "./utils";
import {
formatMoney,
formatDateOnly,
contractCategoryText,
ourRoleText,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
productCategoryText,
productCategoryTag,
} from "../../dict";
const props = defineProps({
/** 合同详情完整数据 */
detail: { type: Object, default: () => ({}) },
});
const summary = computed(() => {
const s = props.detail.summary;
if (s && Object.keys(s).length) return s;
return buildSummary(props.detail.products || []);
});
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
</script>
<style lang="less" scoped>
.detail-head {
.detail-title {
display: flex;
align-items: center;
gap: 8px;
.name {
font-size: 17px;
font-weight: 600;
margin-top: 10px;
color: var(--el-text-color-primary);
}
}
.detail-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.summary-card {
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
display: flex;
flex-direction: column;
gap: 6px;
.label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.value {
font-size: 17px;
font-weight: 700;
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
&.warning {
color: var(--el-color-warning);
}
}
}
}
</style>
@@ -0,0 +1,151 @@
<template>
<div class="contract-payback">
<div class="section-head">
<span class="section-title">回款计划</span>
<el-button type="primary" size="small" :icon="Plus" @click="openCreate">新建回款计划</el-button>
</div>
<el-table v-loading="loading" :data="list" border size="small">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="回款周期" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="回款进度" min-width="140" align="center">
<template #default="{ row }">
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
</template>
</el-table-column>
<el-table-column label="计划回款总额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
</el-table-column>
<el-table-column label="回款方式" width="100" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
</el-table-column>
<el-table-column label="负责人" width="90">
<template #default="{ row }">{{ row.owner_user_name || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="该合同暂无回款计划,点击右上角新建" :image-size="60" />
</template>
</el-table>
<!-- 新建 / 编辑回款计划(预设当前合同) -->
<PaybackCreate
v-model:visible="createVisible"
:preset-contract="contract"
:edit-data="editRow"
@success="handleSaved"
/>
<!-- 回款计划详情 -->
<PaybackDetail v-model:visible="detailVisible" :payback="currentRow" @edit="handleEdit" />
</div>
</template>
<script setup>
import { ref } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { getPaybackList } from "@/api/crmPayback";
import PaybackCreate from "../../payback/components/create.vue";
import PaybackDetail from "../../payback/components/detail.vue";
import {
paybackCycleText,
paybackMethodText,
paybackStatusText,
paybackStatusTag,
formatMoney,
} from "../../dict";
const props = defineProps({
/** 当前合同详情对象(含 id / contract_name / contract_no / parties / summary) */
contract: { type: Object, default: null },
});
const emit = defineEmits(["count", "saved"]);
const loading = ref(false);
const list = ref([]);
const createVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
const editRow = ref(null);
/** 回款进度百分比 */
function paybackPercent(row) {
const total = Number(row.total_amount) || 0;
if (!total) return 0;
return Math.min(100, Math.round((Number(row.received_amount) / total) * 100));
}
function openCreate() {
editRow.value = null;
createVisible.value = true;
}
function openDetail(row) {
currentRow.value = { ...row };
detailVisible.value = true;
}
/** 详情内点击编辑:关闭详情并打开编辑弹窗 */
function handleEdit(row) {
detailVisible.value = false;
editRow.value = { ...row };
createVisible.value = true;
}
function handleSaved() {
reload();
emit("saved");
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
const contractId = props.contract?.id;
if (!contractId) {
list.value = [];
emit("count", 0);
return;
}
loading.value = true;
try {
const res = await getPaybackList({ contract_id: contractId, page: 1, pageSize: 100 });
list.value = res?.data?.list || [];
} catch (e) {
list.value = [];
} finally {
loading.value = false;
emit("count", list.value.length);
}
}
defineExpose({ reload });
</script>
<style lang="less" scoped>
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.section-title {
font-size: 14px;
font-weight: 600;
}
}
</style>
@@ -13,6 +13,36 @@
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/**
* 软件开发模块树节点售价合计:
* - 含子节点 → 汇总子节点;
* - 叶子节点 → 人天 × 统一人天单价。
*/
export function treeSellTotal(nodes, unitPrice = 0) {
const list = Array.isArray(nodes) ? nodes : [];
const up = Number(unitPrice) || 0;
return round2(
list.reduce((sum, n) => {
if (Array.isArray(n.children) && n.children.length) {
return sum + treeSellTotal(n.children, up);
}
return sum + round2((Number(n.man_days) || 0) * up);
}, 0)
);
}
/** 单行金额:成品 = 数量×单价;软件开发 = 模块树售价合计(人天×统一人天单价) */
export function lineAmount(row) {
if (row?.line_type === "dev") return treeSellTotal(row.dev_tree, row.dev_unit_price);
return round2((Number(row?.quantity) || 0) * (Number(row?.price) || 0));
}
/** 单行成本:成品 = 数量×成本单价;软件开发不计成本 */
export function lineCost(row) {
if (row?.line_type === "dev") return 0;
return round2((Number(row?.quantity) || 0) * (Number(row?.cost_price) || 0));
}
export function buildSummary(products) {
const rows = Array.isArray(products) ? products : [];
let hardware = 0;
@@ -20,6 +50,11 @@ export function buildSummary(products) {
let other = 0;
let totalCost = 0;
rows.forEach((row) => {
if (row?.line_type === "dev") {
const amount = treeSellTotal(row.dev_tree, row.dev_unit_price);
software += amount; // 软件开发计入软件部分,不计成本
return;
}
const qty = Number(row?.quantity) || 0;
const price = Number(row?.price) || 0;
const costPrice = Number(row?.cost_price) || 0;