批量优化产品和产品分类

This commit is contained in:
2026-09-12 00:44:13 +08:00
parent 184c604bee
commit 2b1874943f
33 changed files with 2595 additions and 59 deletions
@@ -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>