548 lines
18 KiB
Vue
548 lines
18 KiB
Vue
<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-period-bar">
|
||
<el-radio-group v-model="statsPeriod" @change="onPeriodChange">
|
||
<el-radio-button label="all">全部</el-radio-button>
|
||
<el-radio-button label="month">本月</el-radio-button>
|
||
<el-radio-button label="quarter">本季</el-radio-button>
|
||
<el-radio-button label="year">本年</el-radio-button>
|
||
<el-radio-button label="custom">自定义</el-radio-button>
|
||
</el-radio-group>
|
||
<el-date-picker
|
||
v-if="statsPeriod === 'custom'"
|
||
v-model="statsRange"
|
||
type="daterange"
|
||
value-format="YYYY-MM-DD"
|
||
range-separator="至"
|
||
start-placeholder="开始日期"
|
||
end-placeholder="结束日期"
|
||
:clearable="false"
|
||
@change="onRangeChange"
|
||
/>
|
||
<!-- 当前统计范围:切换周期后卡片按该区间重新统计(区间相同则数字自然相同) -->
|
||
<span class="stat-scope">统计范围:{{ statsScopeText }}</span>
|
||
</div>
|
||
|
||
<!-- 统计卡片 -->
|
||
<div class="stat-cards" v-loading="statsLoading">
|
||
<div class="stat-card">
|
||
<span class="stat-label">合同总数</span>
|
||
<span class="stat-value">{{ stats.total ?? 0 }}</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">
|
||
<el-tooltip
|
||
content="仅统计非甲方(收款方)合同的利润;我方为甲方的合同属于支出,不计入利润"
|
||
placement="top"
|
||
>
|
||
<span class="stat-label">合同总利润</span>
|
||
</el-tooltip>
|
||
<span class="stat-value" :style="profitStyle(stats.total_profit)">{{ profitText(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: 130px">
|
||
<el-option v-for="i in ourRoleFilterOptions" :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 label="状态" width="90" align="center" fixed>
|
||
<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="90" align="center" fixed>
|
||
<template #default="{ row }">
|
||
<el-tag :type="ourRoleTag(row.our_role)" size="small" effect="plain">{{ ourRoleText(row.our_role) }}</el-tag>
|
||
</template>
|
||
</el-table-column>
|
||
<!-- <el-table-column prop="contract_no" label="合同编号" width="150" show-overflow-tooltip /> -->
|
||
<el-table-column label="合同名称" min-width="190" show-overflow-tooltip >
|
||
<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="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="合同总金额" width="130" align="right">
|
||
<template #default="{ row }">
|
||
{{ formatMoney(row.summary?.total_amount ?? row.total_amount) }}
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="合同利润/支出" width="140" align="right">
|
||
<template #header>
|
||
<el-tooltip
|
||
content="我方为甲方(付款方):按负数(绿色)显示合同支出 = 合同总金额;其余角色:显示合同利润 = 总金额 − 产品总成本"
|
||
placement="top"
|
||
>
|
||
<span>合同利润/支出</span>
|
||
</el-tooltip>
|
||
</template>
|
||
<template #default="{ row }">
|
||
<span :style="profitStyle(contractProfitOrExpense(row))">
|
||
{{ profitText(contractProfitOrExpense(row)) }}
|
||
</span>
|
||
</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="130" align="center">
|
||
<template #default="{ row }">
|
||
<el-tag v-if="contractOverdue(row).overdue" type="danger" size="small" effect="dark">
|
||
{{ contractOverdue(row).text }}
|
||
</el-tag>
|
||
<span v-else>-</span>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column label="负责人" width="90">
|
||
<template #default="{ row }">{{ row.owner_user_name || "-" }}</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"
|
||
@refresh="fetchList"
|
||
@edit="onDetailEdit"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, reactive, computed, onMounted, nextTick, watch } from "vue";
|
||
import { useRoute, useRouter } from "vue-router";
|
||
import { ElMessage, ElMessageBox } from "element-plus";
|
||
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
|
||
import {
|
||
getContractList,
|
||
getContractStats,
|
||
getContractDetail,
|
||
} from "@/api/crmContract";
|
||
import ContractCreate from "./components/create.vue";
|
||
import ContractDetail from "./components/detail.vue";
|
||
import {
|
||
CONTRACT_CATEGORY_OPTIONS,
|
||
ourRoleFilterOptions,
|
||
CONTRACT_STATUS_OPTIONS,
|
||
ourRoleText,
|
||
ourRoleTag,
|
||
contractCategoryText,
|
||
contractStatusText,
|
||
contractStatusTag,
|
||
contractOverdue,
|
||
formatDateOnly,
|
||
formatMoney,
|
||
isPayerContract,
|
||
} from "../dict";
|
||
|
||
const route = useRoute();
|
||
const router = useRouter();
|
||
|
||
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 });
|
||
|
||
/** 统计周期:all(全部)/ month / quarter / year / custom(自定义选区间),默认全部 */
|
||
const statsPeriod = ref("all");
|
||
const statsRange = ref([]);
|
||
|
||
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
|
||
const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 });
|
||
/** 卡片加载态:切换周期时给出可见的刷新反馈 */
|
||
const statsLoading = ref(false);
|
||
|
||
/** 当前统计范围文案:与后端口径一致(合同有效期与所选周期重叠),便于对照卡片数字 */
|
||
const statsScopeText = computed(() => {
|
||
const pad = (n) => String(n).padStart(2, "0");
|
||
const fmt = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
const now = new Date();
|
||
const y = now.getFullYear();
|
||
const m = now.getMonth();
|
||
switch (statsPeriod.value) {
|
||
case "month":
|
||
return `${fmt(new Date(y, m, 1))} ~ ${fmt(new Date(y, m + 1, 0))}`;
|
||
case "quarter": {
|
||
const q = Math.floor(m / 3);
|
||
return `${fmt(new Date(y, q * 3, 1))} ~ ${fmt(new Date(y, q * 3 + 3, 0))}`;
|
||
}
|
||
case "year":
|
||
return `${y}-01-01 ~ ${y}-12-31`;
|
||
case "custom":
|
||
return statsRange.value?.length === 2
|
||
? `${statsRange.value[0]} ~ ${statsRange.value[1]}`
|
||
: "自定义区间";
|
||
default:
|
||
return "全部时间(不限有效期)";
|
||
}
|
||
});
|
||
|
||
/**
|
||
* 周期参数:统计与列表共用,保证两者口径一致。
|
||
* 「全部」不传周期参数——列表不按有效期过滤,避免有效期不在所选周期内的合同(如历史合同)被隐藏。
|
||
*/
|
||
function periodParams() {
|
||
if (statsPeriod.value === "all") return {};
|
||
const params = { period: statsPeriod.value };
|
||
if (statsPeriod.value === "custom" && statsRange.value && statsRange.value.length === 2) {
|
||
params.start_date = statsRange.value[0];
|
||
params.end_date = statsRange.value[1];
|
||
}
|
||
return params;
|
||
}
|
||
|
||
async function loadStats() {
|
||
statsLoading.value = true;
|
||
try {
|
||
const res = await getContractStats(periodParams());
|
||
stats.value = res?.data || stats.value;
|
||
} catch {
|
||
/* 统计失败不影响列表 */
|
||
} finally {
|
||
statsLoading.value = false;
|
||
}
|
||
}
|
||
|
||
/** 周期变化:列表与统计一并刷新(回到第一页) */
|
||
function onPeriodChange(val) {
|
||
if (val === "custom" && (!statsRange.value || statsRange.value.length !== 2)) {
|
||
const now = new Date();
|
||
const y = now.getFullYear();
|
||
const m = String(now.getMonth() + 1).padStart(2, "0");
|
||
const last = new Date(y, now.getMonth() + 1, 0).getDate();
|
||
statsRange.value = [`${y}-${m}-01`, `${y}-${m}-${last}`];
|
||
}
|
||
pagination.page = 1;
|
||
fetchList();
|
||
loadStats();
|
||
}
|
||
|
||
/** 自定义区间变化:同样刷新列表与统计 */
|
||
function onRangeChange() {
|
||
pagination.page = 1;
|
||
fetchList();
|
||
loadStats();
|
||
}
|
||
|
||
onMounted(() => {
|
||
fetchList();
|
||
loadStats();
|
||
});
|
||
|
||
// 页面被 keep-alive 缓存后 onMounted 只执行一次,改为监听 ?id 变化,
|
||
// 保证每次从项目详情「合同管理」等入口带 id 跳转进来都能打开目标合同。
|
||
watch(
|
||
() => route.query.id,
|
||
(id) => {
|
||
if (id) openByQueryId();
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
async function fetchList() {
|
||
loading.value = true;
|
||
try {
|
||
const res = await getContractList({
|
||
page: pagination.page,
|
||
pageSize: pagination.pageSize,
|
||
...filters,
|
||
// 与顶部统计同口径:按所选周期筛选列表
|
||
...periodParams(),
|
||
});
|
||
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;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* URL 带 ?id= 时自动打开对应合同详情(供项目详情「合同管理」点击合同名称跳转)。
|
||
* 打开后清除 query,避免刷新或返回本页时反复弹出。
|
||
*/
|
||
async function openByQueryId() {
|
||
const id = route.query.id;
|
||
if (!id) return;
|
||
// 抽屉可能因 keep-alive 缓存仍处于打开状态,先收起再打开,确保按新合同重新加载
|
||
detailVisible.value = false;
|
||
await nextTick();
|
||
try {
|
||
const res = await getContractDetail(id);
|
||
const row = res?.data;
|
||
if (!row?.id) throw new Error("合同不存在或已被删除");
|
||
openDetail(row);
|
||
} catch (e) {
|
||
ElMessage.error(e.message || "打开合同详情失败");
|
||
} finally {
|
||
const query = { ...route.query };
|
||
delete query.id;
|
||
router.replace({ path: route.path, query });
|
||
}
|
||
}
|
||
|
||
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 profitText(val) {
|
||
const n = Number(val) || 0;
|
||
const s = formatMoney(Math.abs(n));
|
||
if (n > 0) return "+" + s;
|
||
if (n < 0) return "-" + s;
|
||
return s;
|
||
}
|
||
|
||
/** 利润正负配色:正利润红色(danger),负利润绿色(success) */
|
||
function profitStyle(val) {
|
||
const n = Number(val) || 0;
|
||
if (n > 0) return { color: "var(--el-color-danger)" };
|
||
if (n < 0) return { color: "var(--el-color-success)" };
|
||
return {};
|
||
}
|
||
|
||
/**
|
||
* 列表展示值:我方为甲方(付款方)的合同是「支出」,取负值 → 显示为 -¥X(绿色);
|
||
* 其余角色的合同是「利润」(总金额 − 产品总成本)。表头已注明口径,单元格内不写"支出"文字。
|
||
*/
|
||
function contractProfitOrExpense(row) {
|
||
if (!isPayerContract(row.our_role)) {
|
||
return Number(row.summary?.total_profit ?? row.total_profit) || 0;
|
||
}
|
||
const paid = Number(row.summary?.total_amount ?? row.total_amount) || 0;
|
||
return -paid;
|
||
}
|
||
|
||
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) {
|
||
openDetail(row);
|
||
}
|
||
|
||
/** 详情页触发编辑:关闭详情抽屉,打开创建/编辑向导 */
|
||
function onDetailEdit(row) {
|
||
detailVisible.value = false;
|
||
openEdit(row || currentRow.value);
|
||
}
|
||
|
||
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-period-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
flex-wrap: wrap;
|
||
|
||
/* 当前统计范围:右对齐说明,切换周期后随卡片一起刷新 */
|
||
.stat-scope {
|
||
margin-left: auto;
|
||
font-size: 12px;
|
||
color: var(--el-text-color-secondary);
|
||
}
|
||
}
|
||
|
||
.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);
|
||
}
|
||
}
|
||
}
|
||
|
||
/* 操作列:强制水平居中(多按钮换行也整体居中) */
|
||
|
||
:deep(.el-table .op-col .cell) {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: 4px;
|
||
}
|
||
</style>
|