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

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
+102 -54
View File
@@ -11,23 +11,44 @@
</div>
</div>
<!-- 统计周期 -->
<div class="stat-period-bar">
<el-radio-group v-model="statsPeriod" @change="onPeriodChange">
<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="loadStats"
/>
</div>
<!-- 统计卡片 -->
<div class="stat-cards">
<div class="stat-card">
<span class="stat-label">合同总数</span>
<span class="stat-value">{{ pagination.total }}</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>
<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>
<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>
<span class="stat-value" :style="profitStyle(stats.total_profit)">{{ profitText(stats.total_profit) }}</span>
</div>
</div>
@@ -109,40 +130,25 @@
</el-table-column>
<el-table-column label="合同总利润" width="130" align="right">
<template #default="{ row }">
{{ formatMoney(row.summary?.total_profit ?? row.total_profit) }}
<span :style="profitStyle(row.summary?.total_profit ?? row.total_profit)">
{{ profitText(row.summary?.total_profit ?? row.total_profit) }}
</span>
</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="190" align="center" class-name="op-col" fixed="right">
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<!-- 状态流转:草稿 / 已完成 / 履约中 / 执行异常 / 已作废 互切 -->
<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="primary" size="small" @click="openEdit(row)">
编辑
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<el-tag v-if="contractOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(row).days) }}
</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>
@@ -168,19 +174,22 @@
/>
<!-- 合同详情 -->
<ContractDetail v-model:visible="detailVisible" :contract="currentRow" />
<ContractDetail
v-model:visible="detailVisible"
:contract="currentRow"
@refresh="fetchList"
@edit="onDetailEdit"
/>
</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 { Plus, Search, Refresh } 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";
@@ -192,6 +201,8 @@ import {
contractCategoryText,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
formatDateOnly,
formatMoney,
} from "../dict";
@@ -212,18 +223,39 @@ const filters = reactive({
});
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
/** 列表页统计:后端全租户汇总(排除已作废) */
const stats = ref({ total_amount: 0, total_cost: 0, total_profit: 0 });
/** 统计周期:month/quarter/year/custom(自定义选区间) */
const statsPeriod = ref("month");
const statsRange = ref([]);
/** 列表页统计:后端按周期汇总(排除已作废),周期参数透传给 Stats */
const stats = ref({ total: 0, total_amount: 0, total_cost: 0, total_profit: 0 });
async function loadStats() {
try {
const res = await getContractStats();
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];
}
const res = await getContractStats(params);
stats.value = res?.data || stats.value;
} catch {
/* 统计失败不影响列表 */
}
}
/** 切到「自定义」首次未选区间时,默认当前月,避免卡片空值 */
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}`];
}
loadStats();
}
onMounted(() => {
fetchList();
loadStats();
@@ -269,6 +301,23 @@ function partyName(row, 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 {};
}
function openCreate() {
currentRow.value = null;
initStep.value = 1;
@@ -287,25 +336,15 @@ function openDetail(row) {
detailVisible.value = true;
}
/** 点击名称:草稿继续填写,已完成看详情 */
/** 点击名称:统一查看详情(编辑 / 删除请走合同详情) */
function openRow(row) {
if (Number(row.status) === 1) openEdit(row);
else openDetail(row);
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 || "状态更新失败");
}
/** 详情页触发编辑:关闭详情抽屉,打开创建/编辑向导 */
function onDetailEdit(row) {
detailVisible.value = false;
openEdit(row || currentRow.value);
}
async function handleDelete(row) {
@@ -332,6 +371,15 @@ async function handleDelete(row) {
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-card {
background: var(--el-bg-color);
@@ -360,10 +408,10 @@ async function handleDelete(row) {
color: var(--el-color-success);
}
}
}
}
/* 操作列:强制水平居中(多按钮换行也整体居中) */
:deep(.el-table .op-col .cell) {
display: flex;
flex-wrap: wrap;