新增合同管理相关功能

This commit is contained in:
2026-09-11 18:22:22 +08:00
parent 840673bda5
commit 184c604bee
17 changed files with 2944 additions and 0 deletions
@@ -0,0 +1,371 @@
<template>
<div class="crm-page">
<div class="page-header">
<div>
<h2>合同管理</h2>
<p>进度式创建合同,绑定项目或无头合同,管理甲乙丙丁各方与产品清单</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="fetchList">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新建合同</el-button>
</div>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<span class="stat-label">合同总数</span>
<span class="stat-value">{{ pagination.total }}</span>
</div>
<div class="stat-card">
<span class="stat-label">合同总金额</span>
<span class="stat-value primary">¥{{ formatMoney(stats.total_amount) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">产品总成本</span>
<span class="stat-value">¥{{ formatMoney(stats.total_cost) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">合同总利润</span>
<span class="stat-value success">¥{{ formatMoney(stats.total_profit) }}</span>
</div>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="filters" @submit.prevent>
<el-form-item>
<el-input
v-model="filters.keyword"
clearable
placeholder="搜索合同名称 / 编号 / 项目 / 签约方"
:prefix-icon="Search"
style="width: 260px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="我方角色">
<el-select v-model="filters.our_role" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in OUR_ROLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="合同分类">
<el-select v-model="filters.contract_category" clearable placeholder="全部" style="width: 130px">
<el-option v-for="i in CONTRACT_CATEGORY_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item label="归属">
<el-select v-model="filters.project_type" clearable placeholder="全部" style="width: 130px">
<el-option label="项目合同" value="project" />
<el-option label="无头合同" value="headless" />
</el-select>
</el-form-item>
<el-form-item label="状态">
<el-select v-model="filters.status" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in CONTRACT_STATUS_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleSearch">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id">
<el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip />
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip fixed>
<template #default="{ row }">
<span class="name-link" @click="openRow(row)">
{{ row.contract_name }}
<el-tag v-if="Number(row.status) === 1" size="small" type="warning" effect="light">草稿</el-tag>
</span>
</template>
</el-table-column>
<el-table-column label="我方角色" width="90" align="center">
<template #default="{ row }">
<el-tag type="primary" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="分类" width="100" align="center">
<template #default="{ row }">{{ contractCategoryText(row.contract_category) }}</template>
</el-table-column>
<el-table-column label="归属项目" min-width="160" show-overflow-tooltip>
<template #default="{ row }">
{{ row.project_name || "无" }}
</template>
</el-table-column>
<el-table-column label="甲方" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ partyName(row, "party_a") }}</template>
</el-table-column>
<el-table-column label="乙方" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ partyName(row, "party_b") }}</template>
</el-table-column>
<el-table-column label="合同总金额" width="130" align="right">
<template #default="{ row }">
{{ formatMoney(row.summary?.total_amount ?? row.total_amount) }}
</template>
</el-table-column>
<el-table-column label="合同总利润" width="130" align="right">
<template #default="{ row }">
{{ formatMoney(row.summary?.total_profit ?? row.total_profit) }}
</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="110" align="center">
<template #default="{ row }">{{ formatDateOnly(row.sign_date) }}</template>
</el-table-column>
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="contractStatusTag(row.status)" size="small">
{{ contractStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">
{{ Number(row.status) === 1 ? "继续填写" : "编辑" }}
</el-button>
<!-- 状态流转:草稿 / 已完成 / 履约中 / 执行异常 / 已作废 互切 -->
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(row, 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(row)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无合同" :image-size="80" /></template>
</el-table>
<div class="pagination">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSearch"
@current-change="fetchList"
/>
</div>
</div>
<!-- 进度式创建 / 编辑向导 -->
<ContractCreate
v-model:visible="createVisible"
:edit-data="currentRow"
:init-step="initStep"
@success="fetchList"
/>
<!-- 合同详情 -->
<ContractDetail v-model:visible="detailVisible" :contract="currentRow" />
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh, ArrowDown } from "@element-plus/icons-vue";
import {
getContractList,
getContractStats,
changeContractStatus,
deleteContract,
} from "@/api/crmContract";
import ContractCreate from "./components/create.vue";
import ContractDetail from "./components/detail.vue";
import {
CONTRACT_CATEGORY_OPTIONS,
OUR_ROLE_OPTIONS,
CONTRACT_STATUS_OPTIONS,
ourRoleText,
contractCategoryText,
contractStatusText,
contractStatusTag,
formatDateOnly,
formatMoney,
} from "../dict";
const loading = ref(false);
const tableData = ref([]);
const createVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
const initStep = ref(1);
const filters = reactive({
keyword: "",
our_role: "",
contract_category: "",
project_type: "",
status: "",
});
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 列表页统计:后端全租户汇总(排除已作废) */
const stats = ref({ total_amount: 0, total_cost: 0, total_profit: 0 });
async function loadStats() {
try {
const res = await getContractStats();
stats.value = res?.data || stats.value;
} catch {
/* 统计失败不影响列表 */
}
}
onMounted(() => {
fetchList();
loadStats();
});
async function fetchList() {
loading.value = true;
try {
const res = await getContractList({
page: pagination.page,
pageSize: pagination.pageSize,
...filters,
});
tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0;
loadStats(); // 新增/删除后统计随之刷新
} catch (e) {
tableData.value = [];
pagination.total = 0;
ElMessage.error(e.message || "查询失败");
} finally {
loading.value = false;
}
}
function handleSearch() {
pagination.page = 1;
fetchList();
}
function resetFilters() {
filters.keyword = "";
filters.our_role = "";
filters.contract_category = "";
filters.project_type = "";
filters.status = "";
handleSearch();
}
/** 从行数据的 parties 里取某一方签约主体名称 */
function partyName(row, role) {
const hit = (row.parties || []).find((p) => p.role === role);
return hit?.ref_name || "-";
}
function openCreate() {
currentRow.value = null;
initStep.value = 1;
createVisible.value = true;
}
/** 草稿:按保存的进度定位步骤;已完成:从第一步编辑 */
function openEdit(row) {
currentRow.value = { ...row };
initStep.value = Number(row.step) === 2 ? 2 : 1;
createVisible.value = true;
}
function openDetail(row) {
currentRow.value = { ...row };
detailVisible.value = true;
}
/** 点击名称:草稿继续填写,已完成看详情 */
function openRow(row) {
if (Number(row.status) === 1) openEdit(row);
else openDetail(row);
}
/** 状态流转候选:排除当前状态 */
function statusOptionsExcept(row) {
return CONTRACT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(row.status));
}
async function handleChangeStatus(row, status) {
try {
await changeContractStatus(row.id, Number(status));
ElMessage.success(`状态已更新为「${contractStatusText(status)}」`);
fetchList();
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除合同「${row.contract_name}」吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deleteContract(row.id);
ElMessage.success("删除成功");
fetchList();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
</script>
<style lang="less" scoped src="../styles/crm-page.less"></style>
<style lang="less" scoped>
.stat-cards {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
margin-bottom: 12px;
.stat-card {
background: var(--el-bg-color);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
padding: 14px 18px;
display: flex;
flex-direction: column;
gap: 6px;
.stat-label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.stat-value {
font-size: 20px;
font-weight: 700;
color: var(--el-text-color-primary);
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
}
}
}
</style>