Files
yunzerwebsiteallinone/backend/src/views/apps/crm/contract/index.vue
T
2026-09-14 16:15:44 +08:00

548 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>