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

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
+14
View File
@@ -0,0 +1,14 @@
import request from "@/utils/request";
/**
* CRM 数据仪表盘接口
*
* 说明:
* - range 维度:day(天) / week(周) / month(月) / quarter(季度) / year(年);
* - 后端按租户 + 时间维度聚合真实业务数据(客户/联系人/项目/合同/线索/商机/回访/回款)。
*/
/** 仪表盘汇总数据 */
export function getCrmDashboard(params) {
return request({ url: "/backend/crm/dashboard", method: "get", params });
}
+49
View File
@@ -0,0 +1,49 @@
import request from "@/utils/request";
/**
* CRM 回款管理接口(针对于合同)
*
* 说明:
* - 回款计划必须挂靠合同(contract_id);
* - plan_type 回款周期:1月度/2季度/3年度/4进度/5自定义,决定 items 明细结构;
* 月度/季度/年度:{seq,name,amount};
* 进度:{seq,name,percent,amount};
* 自定义:{seq,name,plan_date,amount};
* - pay_method 回款方式:1对公转账/2网银转账/3现金/4支票/5支付宝/6微信/7其他;
* - items 每行带 received_amount/received_date/status,用于回款进度登记。
*/
/** 回款计划列表 */
export function getPaybackList(params) {
return request({ url: "/backend/crm/payback/list", method: "get", params });
}
/** 回款计划详情 */
export function getPaybackDetail(id) {
return request({ url: `/backend/crm/payback/${id}`, method: "get" });
}
/** 创建回款计划 */
export function createPayback(data) {
return request({ url: "/backend/crm/payback", method: "post", data });
}
/** 更新回款计划 */
export function updatePayback(id, data) {
return request({ url: `/backend/crm/payback/${id}`, method: "put", data });
}
/** 删除回款计划 */
export function deletePayback(id) {
return request({ url: `/backend/crm/payback/${id}`, method: "delete" });
}
/** 回款进度列表(跨计划摊平的分期明细 + 汇总) */
export function getPaybackProgress(params) {
return request({ url: "/backend/crm/payback/progress/list", method: "get", params });
}
/** 回款进度登记:按明细序号写入实收金额 / 回款日期 */
export function registerPaybackReceive(id, data) {
return request({ url: `/backend/crm/payback/${id}/receive`, method: "post", data });
}
@@ -0,0 +1,165 @@
<template>
<el-dialog
:model-value="visible"
:title="title || '软件开发模块报价'"
width="860px"
top="5vh"
append-to-body
@update:model-value="(v) => emit('update:visible', v)"
@open="syncLocal"
>
<div class="dev-tree">
<div class="tree-toolbar">
<el-button type="primary" size="small" :icon="Plus" @click="addRoot">添加顶级模块</el-button>
<span class="tree-tip">叶子模块只填「人天」,人天单价统一填写;父级自动汇总;可任意层级向下拆分</span>
</div>
<div class="unit-price-bar">
<span class="up-label">统一人天单价(元 / 人天)</span>
<el-input-number v-model="unitPrice" :min="0" :precision="2" :controls="false" style="width: 180px" />
<span class="up-total">开发总报价:<b>¥{{ formatMoney(treeSellTotal(tree, unitPrice)) }}</b></span>
</div>
<div v-if="tree.length" class="tree-head">
<span class="th-name">模块名称</span>
<span class="th-md">人天</span>
<span class="th-amount">小计(元)</span>
<span class="th-ops">操作</span>
</div>
<ModuleNode
v-for="n in tree"
:key="n.__id"
:node="n"
:depth="0"
:unit-price="unitPrice"
@remove="removeNode"
/>
<el-empty v-if="!tree.length" description="暂无模块,点击「添加顶级模块」开始" />
</div>
<template #footer>
<el-button type="primary" @click="handleConfirm">确定</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, watch } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { treeSellTotal } from "./utils";
import ModuleNode from "./ModuleNode.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
modelValue: { type: Array, default: () => [] },
unitPrice: { type: Number, default: 0 },
title: { type: String, default: "" },
});
const emit = defineEmits(["update:visible", "update:modelValue", "update:unitPrice"]);
const tree = ref([]);
const unitPrice = ref(0);
let uid = 0;
const newNode = () => ({
__id: `m_${Date.now()}_${uid++}`,
name: "",
children: [],
man_days: 0,
});
const syncLocal = () => {
tree.value = Array.isArray(props.modelValue) ? JSON.parse(JSON.stringify(props.modelValue)) : [];
unitPrice.value = Number(props.unitPrice) || 0;
};
watch(tree, (val) => emit("update:modelValue", val), { deep: true });
watch(unitPrice, (val) => emit("update:unitPrice", val));
const addRoot = () => tree.value.push(newNode());
/** 递归从树中移除指定 __id 节点 */
const removeNode = (target) => {
const cut = (list) => {
const idx = list.findIndex((n) => n.__id === target.__id);
if (idx >= 0) {
list.splice(idx, 1);
return true;
}
return list.some((n) => Array.isArray(n.children) && cut(n.children));
};
cut(tree.value);
};
const handleConfirm = () => emit("update:visible", false);
</script>
<style lang="less" scoped>
.dev-tree {
.tree-toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
.tree-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
.unit-price-bar {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
margin-bottom: 10px;
background: var(--el-fill-color-extra-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
.up-label {
font-size: 13px;
font-weight: 600;
}
.up-total {
margin-left: auto;
font-size: 13px;
color: var(--el-text-color-regular);
b {
color: var(--el-color-primary);
}
}
}
.tree-head {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 8px 4px 16px;
font-size: 12px;
color: var(--el-text-color-secondary);
font-weight: 600;
.th-name {
flex: 1;
min-width: 160px;
}
.th-md {
width: 120px;
}
.th-amount {
width: 120px;
text-align: right;
}
.th-ops {
width: 96px;
text-align: center;
}
}
}
</style>
@@ -0,0 +1,119 @@
<template>
<div class="module-node">
<div class="node-row" :style="{ paddingLeft: depth * 22 + 'px' }">
<span class="node-bar" :style="{ background: depthColor }" />
<el-input v-model="node.name" class="node-name" placeholder="模块名称(如:Web端 / 用户中心)" maxlength="60" />
<template v-if="hasChildren">
<span class="node-agg">汇总</span>
<span class="node-amount">{{ formatMoney(treeSellTotal([node], unitPrice)) }}</span>
</template>
<template v-else>
<el-input-number
v-model="node.man_days"
:min="0"
:precision="1"
:controls="false"
class="node-md"
placeholder="人天"
/>
<span class="node-amount">{{ formatMoney((Number(node.man_days) || 0) * (Number(unitPrice) || 0)) }}</span>
</template>
<div class="node-ops">
<el-button link type="primary" size="small" @click="addChild">+ 子级</el-button>
<el-button link type="danger" size="small" @click="emit('remove', node)">删除</el-button>
</div>
</div>
<ModuleNode
v-for="c in node.children"
:key="c.__id"
:node="c"
:depth="depth + 1"
:unit-price="unitPrice"
@remove="(n) => emit('remove', n)"
/>
</div>
</template>
<script setup>
import { computed } from "vue";
import { formatMoney } from "../../dict";
import { treeSellTotal } from "./utils";
defineOptions({ name: "ModuleNode" });
const props = defineProps({
node: { type: Object, required: true },
depth: { type: Number, default: 0 },
unitPrice: { type: Number, default: 0 },
});
const emit = defineEmits(["remove"]);
let uid = 0;
const newNode = () => ({
__id: `m_${Date.now()}_${uid++}`,
name: "",
children: [],
man_days: 0,
});
const hasChildren = computed(() => Array.isArray(props.node.children) && props.node.children.length > 0);
const depthColor = ["#409EFF", "#67C23A", "#E6A23C", "#909399", "#9254DE"][props.depth % 5];
const addChild = () => {
if (!Array.isArray(props.node.children)) props.node.children = [];
props.node.children.push(newNode());
};
</script>
<style lang="less" scoped>
.module-node {
.node-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--el-border-color-lighter);
background: var(--el-bg-color);
margin: 6px 0;
.node-bar {
width: 4px;
align-self: stretch;
border-radius: 2px;
flex-shrink: 0;
}
.node-name {
flex: 1;
min-width: 160px;
}
.node-agg {
font-size: 12px;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.node-md {
width: 120px;
flex-shrink: 0;
}
.node-amount {
width: 120px;
text-align: right;
font-weight: 600;
color: var(--el-color-primary);
flex-shrink: 0;
}
.node-ops {
display: flex;
gap: 4px;
flex-shrink: 0;
}
}
}
</style>
@@ -2,9 +2,18 @@
<div class="product-list">
<el-table :data="products" border row-key="__key" :empty-text="'暂无产品,点击下方按钮添加'">
<el-table-column label="序号" type="index" width="50" align="center" />
<el-table-column label="行类型" width="110">
<template #default="{ row }">
<el-select v-model="row.line_type" style="width: 100%" @change="() => onLineTypeChange(row)">
<el-option label="成品" value="product" />
<el-option label="软件开发" value="dev" />
</el-select>
</template>
</el-table-column>
<el-table-column label="产品类别" width="150">
<template #default="{ row }">
<el-select
v-if="row.line_type === 'product'"
v-model="row.category"
filterable
clearable
@@ -19,51 +28,62 @@
:value="c.name"
/>
</el-select>
<span v-else class="muted">软件开发</span>
</template>
</el-table-column>
<el-table-column label="产品名称" min-width="210">
<template #default="{ row }">
<el-select
v-model="row.name"
filterable
allow-create
default-first-option
clearable
:disabled="!row.category"
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
style="width: 100%"
@focus="onProductFocus(row)"
@visible-change="(v) => { if (v) onProductFocus(row) }"
@change="(val) => onProductPick(row, val)"
>
<el-option
v-for="p in productOptions"
:key="p.id"
:label="p.product_name"
:value="p.product_name"
<template v-if="row.line_type === 'dev'">
<el-button link type="primary" @click="openDev(row)">
📁 模块报价({{ (row.dev_tree || []).length }} 项 · ¥{{ formatMoney(lineAmount(row)) }})
</el-button>
</template>
<template v-else>
<el-select
v-model="row.name"
filterable
allow-create
default-first-option
clearable
:disabled="!row.category"
:placeholder="row.category ? '搜索或输入产品名称' : '请先选择产品类别'"
style="width: 100%"
@focus="onProductFocus(row)"
@visible-change="(v) => { if (v) onProductFocus(row) }"
@change="(val) => onProductPick(row, val)"
>
<span class="opt-name">{{ p.product_name }}</span>
<span class="opt-meta">{{ p.spec || "—" }} · ¥{{ p.price }}</span>
</el-option>
</el-select>
<!-- <div v-if="row.product_id" class="linked-tip">
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
</div> -->
<el-option
v-for="p in productOptions"
:key="p.id"
:label="p.product_name"
:value="p.product_name"
>
<span class="opt-name">{{ p.product_name }}</span>
<span class="opt-meta">{{ p.spec || "—" }} · ¥{{ p.price }}</span>
</el-option>
</el-select>
<!-- <div v-if="row.product_id" class="linked-tip">
已关联产品管理 #{{ row.product_id }}(成本取自产品管理)
</div> -->
</template>
</template>
</el-table-column>
<el-table-column label="规格型号" width="130">
<template #default="{ row }">
<el-input v-model="row.spec" :placeholder="specPlaceholder(row)" />
<el-input v-if="row.line_type === 'product'" v-model="row.spec" :placeholder="specPlaceholder(row)" />
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="单位" width="90">
<template #default="{ row }">
<el-input v-model="row.unit" placeholder="套" maxlength="10" />
<el-input v-if="row.line_type === 'product'" v-model="row.unit" placeholder="套" maxlength="10" />
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="数量" width="110">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.quantity"
:min="0"
:precision="2"
@@ -71,11 +91,13 @@
placeholder="0"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="单价(元)" width="130">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.price"
:min="0"
:precision="2"
@@ -83,26 +105,31 @@
placeholder="0.00"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="成本单价(元)" width="150">
<template #default="{ row }">
<el-tooltip content="成本单价取自产品管理,关联产品后不可修改" :disabled="!row.product_id">
<el-input-number
v-model="row.cost_price"
:min="0"
:precision="2"
:controls="false"
:disabled="!!row.product_id"
placeholder="0.00"
style="width: 100%"
/>
</el-tooltip>
<template v-if="row.line_type === 'product'">
<el-tooltip content="成本单价取自产品管理,关联产品后不可修改" :disabled="!row.product_id">
<el-input-number
v-model="row.cost_price"
:min="0"
:precision="2"
:controls="false"
:disabled="!!row.product_id"
placeholder="0.00"
style="width: 100%"
/>
</el-tooltip>
</template>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="税率(%)" width="110">
<template #default="{ row }">
<el-input-number
v-if="row.line_type === 'product'"
v-model="row.tax_rate"
:min="0"
:max="100"
@@ -110,11 +137,12 @@
:controls="false"
style="width: 100%"
/>
<span v-else class="muted">—</span>
</template>
</el-table-column>
<el-table-column label="小计(元)" width="120" align="right">
<template #default="{ row }">
<span class="line-amount">{{ formatMoney(rowAmount(row)) }}</span>
<span class="line-amount">{{ formatMoney(lineAmount(row)) }}</span>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
@@ -132,7 +160,7 @@
<div class="add-row">
<el-button :icon="Plus" @click="addRow">添加产品</el-button>
<span class="add-tip">
先选产品类别,再在该分类下搜索/选择产品(自动带出规格、单位、成本、税率);分类内无对应名称可输入新增,提交时自动建档到产品管理
每行可选「成品」或「软件开发」:成品按数量×单价计价;软件开发点「模块报价」编辑分层模块树(叶子填人天/人天单价,父级自动汇总),统一计入合同总价
</span>
</div>
@@ -170,6 +198,15 @@
</div>
</div>
</div>
<DevModuleTree
v-if="editingRow"
v-model="editingRow.dev_tree"
:unit-price="editingRow.dev_unit_price"
v-model:visible="devVisible"
@update:unit-price="(v) => (editingRow.dev_unit_price = v)"
:title="editingRow.name ? editingRow.name + ' - 模块报价' : '软件开发模块报价'"
/>
</div>
</template>
@@ -177,9 +214,10 @@
import { ref, computed, onMounted } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { formatMoney } from "../../dict";
import { buildSummary } from "./utils";
import { buildSummary, lineAmount } from "./utils";
import { getProductList } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import DevModuleTree from "./DevModuleTree.vue";
const props = defineProps({
/** 产品行数组(父组件持有) */
@@ -196,6 +234,7 @@ let rowSeed = 0;
const addRow = () => {
props.products.push({
__key: `row_${Date.now()}_${rowSeed++}`,
line_type: "product",
name: "",
product_id: null,
category: "",
@@ -205,6 +244,8 @@ const addRow = () => {
price: 0,
cost_price: 0,
tax_rate: 0,
dev_tree: [],
dev_unit_price: 0,
remark: "",
});
};
@@ -213,8 +254,23 @@ const removeRow = (index) => {
props.products.splice(index, 1);
};
const rowAmount = (row) =>
Math.round(((Number(row.quantity) || 0) * (Number(row.price) || 0) || 0) * 100) / 100;
/** 切换行类型:软件开发行确保有模块树容器 */
function onLineTypeChange(row) {
if (row.line_type === "dev") {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
if (row.dev_unit_price == null) row.dev_unit_price = 0;
}
}
/* ------------------------------ 软件开发模块树编辑 ------------------------------ */
const devVisible = ref(false);
const editingRow = ref(null);
function openDev(row) {
if (!Array.isArray(row.dev_tree)) row.dev_tree = [];
editingRow.value = row;
devVisible.value = true;
}
const specPlaceholder = (row) => {
const cat = String(row.category || "");
@@ -273,6 +329,15 @@ function onProductPick(row, val) {
const hit = productOptions.value.find((p) => p.product_name === val);
if (hit) {
row.product_id = hit.id;
// 软件开发产品:带入模块树与人天单价,行类型自动切为 dev
if (hit.line_type === "dev" || hit.dev_tree) {
row.line_type = "dev";
row.dev_unit_price = Number(hit.dev_unit_price) || 0;
row.dev_tree = Array.isArray(hit.dev_tree)
? JSON.parse(JSON.stringify(hit.dev_tree))
: safeParseTree(hit.dev_tree);
return;
}
row.spec = hit.spec || "";
row.unit = hit.unit || "";
row.price = hit.price || 0;
@@ -284,6 +349,17 @@ function onProductPick(row, val) {
}
}
/** 安全解析产品管理返回的 dev_tree(可能为字符串或数组) */
function safeParseTree(v) {
if (Array.isArray(v)) return v;
try {
const parsed = typeof v === "string" ? JSON.parse(v) : v;
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
onMounted(() => {
fetchCategories();
// 为已加载的合同产品预置下拉选项,保证名称回显
@@ -311,6 +387,10 @@ onMounted(() => {
color: var(--el-text-color-primary);
}
.muted {
color: var(--el-text-color-placeholder);
}
.opt-name {
float: left;
}
@@ -8,11 +8,16 @@
@update:model-value="handleClose"
@closed="handleClosed"
>
<!-- 进度条 -->
<!-- 进度条(支持点击步骤直接跳转) -->
<div class="steps-wrapper">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="合同信息" description="关键点信息与各方签约主体" />
<el-step title="产品清单" description="产品明细与金额汇总" />
<el-steps :active="activeStep" finish-status="success" align-center class="clickable-steps">
<el-step
title="合同信息"
description="关键点信息与各方签约主体"
class="step-clickable"
@click="goStep(0)"
/>
<el-step title="产品清单" description="产品明细与金额汇总" class="step-clickable" @click="goStep(1)" />
</el-steps>
</div>
@@ -103,8 +108,8 @@
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="项目负责人" prop="owner_name">
<el-input v-model="form.owner_name" placeholder="默认为创建人" />
<el-form-item label="项目负责人" prop="owner_user_name">
<el-input v-model="form.owner_user_name" placeholder="默认为创建人" />
</el-form-item>
</el-col>
</el-row>
@@ -145,6 +150,7 @@
value-format="YYYY-MM-DD"
placeholder="签订日期"
style="width: 100%"
@change="onSignDateChange"
/>
</el-form-item>
</el-col>
@@ -173,6 +179,18 @@
</el-row>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="技术日期" prop="tech_date">
<el-date-picker
v-model="form.tech_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="开发/交付完成日期"
style="width: 240px"
/>
<span class="form-tip">技术日期到期且回款未回完,合同状态将自动转为「执行异常」</span>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark" style="margin-top: 16px">
@@ -272,10 +290,11 @@ const emptyForm = () => ({
party_count: 2, // 合同形式:2双方 3三方 4四方,默认双方
project_id: null,
project_name: "",
owner_name: authStore.user?.name || "",
owner_user_name: authStore.user?.name || "",
sign_date: "",
effective_date: "",
expire_date: "",
tech_date: "",
remark: "",
status: 1,
step: 1,
@@ -395,6 +414,19 @@ watch(
}
);
/** 选择签订日期:生效日期为空时默认带入(生效日期仍可手动修改,改过则不再被覆盖) */
watch(
() => form.sign_date,
(nv) => {
if (nv && !form.effective_date) form.effective_date = nv;
}
);
/** 签订日期变更(用户操作):自动带入生效日期 */
function onSignDateChange(val) {
if (val && !form.effective_date) form.effective_date = val;
}
/** 合同形式文案:2双方 3三方 4四方 */
const partyCountName = (v) => ({ 2: "双方", 3: "三方", 4: "四方" }[Number(v)] || "双方");
@@ -455,6 +487,7 @@ watch(
form.sign_date = normalizeDate(props.editData.sign_date);
form.effective_date = normalizeDate(props.editData.effective_date);
form.expire_date = normalizeDate(props.editData.expire_date);
form.tech_date = normalizeDate(props.editData.tech_date);
parties.value = (props.editData.parties || []).map((p) => ({ ...emptyParty(p.role), ...p }));
products.value = (props.editData.products || []).map((row, i) => ({
...row,
@@ -524,6 +557,13 @@ const prevStep = () => {
if (activeStep.value > 0) activeStep.value--;
};
/** 点击上方步骤条直接跳转(无需按上一步 / 下一步) */
const goStep = (index) => {
const target = Math.max(0, Math.min(MAX_STEP, Number(index) || 0));
if (target === activeStep.value) return;
activeStep.value = target;
};
/** 组装保存 payload;数值字段统一转数字(后端 int8 解析),summary 由产品清单实时计算 */
const buildPayload = (step, status) => ({
...form,
@@ -531,6 +571,7 @@ const buildPayload = (step, status) => ({
sign_date: normalizeDate(form.sign_date),
effective_date: normalizeDate(form.effective_date),
expire_date: normalizeDate(form.expire_date),
tech_date: normalizeDate(form.tech_date),
our_role: Number(form.our_role) || 2,
party_count: Number(form.party_count) || 2,
parties: parties.value.map((p) => ({ ...p })),
@@ -633,6 +674,18 @@ const handleClosed = () => {
padding: 0 24px;
}
/* 步骤条可点击跳转 */
.clickable-steps {
:deep(.step-clickable) {
cursor: pointer;
}
:deep(.el-step__head),
:deep(.el-step__main) {
cursor: pointer;
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
@@ -1,143 +1,88 @@
<template>
<el-drawer
:model-value="visible"
title="合同详情"
size="680px"
:title="detail?.contract_name || '合同详情'"
direction="rtl"
size="900px"
:close-on-click-modal="false"
@update:model-value="emit('update:visible', $event)"
@update:model-value="handleClose"
@opened="loadAll"
>
<div v-loading="loading" class="contract-detail">
<template v-if="detail.id">
<div class="detail-head">
<div class="detail-title">
<span class="name">{{ detail.contract_name }}</span>
<el-tag :type="contractStatusTag(detail.status)" size="small">
{{ contractStatusText(detail.status) }}
</el-tag>
<el-tag type="primary" size="small" effect="plain">
我方·{{ ourRoleText(detail.our_role) }}
</el-tag>
</div>
<div class="detail-sub">
{{ detail.contract_no }} · {{ contractCategoryText(detail.contract_category) }}
</div>
<div v-if="detail.id" class="detail-toolbar">
<div class="toolbar-status">
<!-- <el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag> -->
</div>
<el-descriptions :column="2" border style="margin-top: 16px">
<el-descriptions-item label="所属项目" :span="2">
<template v-if="detail.project_id">
<el-tag size="small" type="primary" effect="light">项目合同</el-tag>
<span style="margin-left: 8px">{{ detail.project_name }}</span>
<div class="toolbar-actions">
<el-dropdown trigger="click" @command="(cmd) => handleChangeStatus(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(detail.status)"
:key="i.value"
:command="i.value"
>
{{ i.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
<template v-else>
<el-tag size="small" type="warning" effect="light">无头合同</el-tag>
</template>
</el-descriptions-item>
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="项目负责人">{{ detail.owner_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">各方签约主体</el-divider>
<el-table :data="detail.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="160" 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 prop="signer_name" label="签约人" width="100">
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
</el-table-column>
<el-table-column prop="signer_phone" 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="detail.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 prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
</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(summary.total_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
<span class="value warning">¥{{ formatMoney(summary.hardware_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">软件部分金额</span>
<span class="value primary">¥{{ formatMoney(summary.software_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">其他部分金额</span>
<span class="value">¥{{ formatMoney(summary.other_amount) }}</span>
</div>
</el-dropdown>
<el-button type="primary" size="small" :icon="Edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
</template>
</div>
<el-tabs v-if="detail.id" v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息(拆分自原详情内容) -->
<el-tab-pane label="基本信息" name="basic">
<DetailBasic :detail="detail" />
</el-tab-pane>
<!-- 回款计划(针对本合同创建 / 查看回款计划) -->
<el-tab-pane :label="`回款计划 (${counts.payback})`" name="payback">
<DetailPayback ref="paybackRef" :contract="detail" @count="counts.payback = $event" @saved="emit('refresh')" />
</el-tab-pane>
<!-- 电子合同(上传 / 查看合同扫描件、电子合同正本) -->
<el-tab-pane :label="`电子合同 (${counts.econtract})`" name="econtract">
<DetailAttach
ref="econtractRef"
:related-id="detail.id"
:related-type="E_CONTRACT_TYPE"
tip="上传合同扫描件 / 电子合同正本,支持图片与 PDF"
/>
</el-tab-pane>
<!-- 操作日志 -->
<el-tab-pane label="操作日志" name="log">
<DetailLog ref="logRef" :related-id="detail.id" :related-type="RELATED_TYPE" />
</el-tab-pane>
</el-tabs>
<el-empty v-else-if="!loading" description="暂无数据" />
</div>
</el-drawer>
</template>
<script setup>
import { ref, computed, watch } from "vue";
import { getContractDetail } from "@/api/crmContract";
import { PARTY_ROLES, buildSummary } from "./utils";
import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete, ArrowDown } from "@element-plus/icons-vue";
import {
formatMoney,
formatDateOnly,
contractCategoryText,
ourRoleText,
getContractDetail,
changeContractStatus,
deleteContract,
} from "@/api/crmContract";
import {
CONTRACT_STATUS_OPTIONS,
contractStatusText,
contractStatusTag,
productCategoryText,
productCategoryTag,
} from "../../dict";
import DetailBasic from "./detail_basic.vue";
import DetailPayback from "./detail_payback.vue";
import DetailLog from "../../project/components/detail_log.vue";
import DetailAttach from "../../project/components/detail_attach.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -145,23 +90,29 @@ const props = defineProps({
contract: { type: Object, default: null },
});
const emit = defineEmits(["update:visible"]);
const emit = defineEmits(["update:visible", "refresh", "edit"]);
// 合同操作日志与项目共用关联类型 3
const RELATED_TYPE = 3;
// 电子合同附件专属关联类型(与线索 1 / 商机 2 / 项目 3 区分)
const E_CONTRACT_TYPE = 4;
const loading = ref(false);
const detail = ref({});
const activeTab = ref("basic");
const counts = reactive({ payback: 0, econtract: 0 });
const summary = computed(() => {
const s = detail.value.summary;
if (s && Object.keys(s).length) return s;
return buildSummary(detail.value.products || []);
});
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
const paybackRef = ref();
const logRef = ref();
const econtractRef = ref();
watch(
() => props.visible,
async (val) => {
if (!val) return;
activeTab.value = "basic";
counts.payback = 0;
counts.econtract = 0;
detail.value = {};
if (!props.contract?.id) return;
loading.value = true;
@@ -175,6 +126,56 @@ watch(
}
}
);
function handleClose() {
emit("update:visible", false);
}
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
if (!detail.value?.id) return;
paybackRef.value?.reload();
logRef.value?.reload();
econtractRef.value?.reload();
}
/** 状态流转候选:排除当前状态 */
function statusOptionsExcept(cur) {
return CONTRACT_STATUS_OPTIONS.filter((i) => String(i.value) !== String(cur));
}
async function handleChangeStatus(status) {
try {
await changeContractStatus(detail.value.id, Number(status));
ElMessage.success(`状态已更新为「${contractStatusText(status)}」`);
detail.value.status = Number(status);
emit("refresh");
} catch (e) {
ElMessage.error(e.message || "状态更新失败");
}
}
function handleEdit() {
emit("edit", detail.value);
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
`确定删除合同「${detail.value.contract_name}」吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deleteContract(detail.value.id);
ElMessage.success("删除成功");
emit("update:visible", false);
emit("refresh");
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
defineExpose({ loadAll });
</script>
<style lang="less" scoped>
@@ -182,58 +183,72 @@ watch(
padding: 0 4px;
}
.detail-head {
.detail-title {
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 12px;
margin-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
.toolbar-status {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
.name {
font-size: 17px;
font-weight: 600;
color: var(--el-text-color-primary);
}
}
.detail-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
:deep(.el-drawer__body) {
overflow-x: hidden;
}
.summary-card {
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
display: flex;
flex-direction: column;
gap: 6px;
.detail-tabs {
display: flex;
width: 100%;
min-width: 0;
.label {
font-size: 12px;
color: var(--el-text-color-secondary);
:deep(.el-tabs__content) {
flex: 1;
min-width: 0;
overflow: hidden;
padding-left: 4px;
}
:deep(.el-tabs__header) {
margin-right: 12px;
flex-shrink: 0;
}
:deep(.el-tabs__item) {
justify-content: flex-start;
text-align: left;
padding: 0 12px;
min-width: 96px;
font-size: 13px;
}
:deep(.el-tab-pane) {
width: 100%;
min-width: 0;
}
:deep(.el-table) {
width: 100%;
.el-button.is-link {
padding: 0;
height: auto;
min-height: auto;
}
.value {
font-size: 17px;
font-weight: 700;
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
&.warning {
color: var(--el-color-warning);
}
.el-button + .el-button {
margin-left: 6px;
}
}
}
@@ -0,0 +1,209 @@
<template>
<div class="contract-basic">
<div class="detail-head">
<div class="detail-title">
<span class="name">{{ detail.contract_name || "-" }}</span>
<!-- <el-tag :type="contractStatusTag(detail.status)" size="small">
{{ contractStatusText(detail.status) }}
</el-tag> -->
<el-tag v-if="contractOverdue(detail).overdue" type="danger" size="small" effect="dark">
{{ overdueText(contractOverdue(detail).days) }}
</el-tag>
</div>
<!-- <div class="detail-sub">
{{ detail.contract_no || "-" }}
</div> -->
</div>
<el-descriptions :column="2" border style="margin-top: 16px">
<el-descriptions-item label="合同类型">
<el-tag v-if="detail.project_id" size="small" type="primary" effect="light">项目合同</el-tag>
<el-tag v-else size="small" type="warning" effect="light">无头合同</el-tag>
<el-tag size="small" type="warning" effect="light" style="margin-left: 8px">{{ contractCategoryText(detail.contract_category) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="合同编号">{{ detail.contract_no || "-" }}</el-descriptions-item>
<el-descriptions-item label="履约状态">
<el-tag :type="contractStatusTag(detail.status)" size="small">{{ contractStatusText(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="我方角色">{{ ourRoleText(detail.our_role) }}</el-descriptions-item>
<el-descriptions-item label="所属项目" :span="2">
<span v-if="detail.project_id">{{ detail.project_name }}</span>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="项目负责人">{{ detail.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="签订日期">{{ formatDateOnly(detail.sign_date) }}</el-descriptions-item>
<el-descriptions-item label="生效日期">{{ formatDateOnly(detail.effective_date) }}</el-descriptions-item>
<el-descriptions-item label="结束日期">{{ formatDateOnly(detail.expire_date) }}</el-descriptions-item>
<el-descriptions-item label="技术日期">{{ formatDateOnly(detail.tech_date) }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">各方签约主体</el-divider>
<el-table :data="detail.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="160" 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 prop="signer_name" label="签约人" width="100">
<template #default="{ row }">{{ row.signer_name || "-" }}</template>
</el-table-column>
<el-table-column prop="signer_phone" 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="detail.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 prop="remark" label="备注" min-width="100" show-overflow-tooltip>
<template #default="{ row }">{{ row.remark || "-" }}</template>
</el-table-column>
</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(summary.total_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">产品总成本</span>
<span class="value">¥{{ formatMoney(summary.total_cost) }}</span>
</div>
<div class="summary-card">
<span class="label">合同总利润</span>
<span class="value success">¥{{ formatMoney(summary.total_profit) }}</span>
</div>
<div class="summary-card">
<span class="label">硬件部分金额</span>
<span class="value warning">¥{{ formatMoney(summary.hardware_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">软件部分金额</span>
<span class="value primary">¥{{ formatMoney(summary.software_amount) }}</span>
</div>
<div class="summary-card">
<span class="label">其他部分金额</span>
<span class="value">¥{{ formatMoney(summary.other_amount) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { PARTY_ROLES, buildSummary } from "./utils";
import {
formatMoney,
formatDateOnly,
contractCategoryText,
ourRoleText,
contractStatusText,
contractStatusTag,
contractOverdue,
overdueText,
productCategoryText,
productCategoryTag,
} from "../../dict";
const props = defineProps({
/** 合同详情完整数据 */
detail: { type: Object, default: () => ({}) },
});
const summary = computed(() => {
const s = props.detail.summary;
if (s && Object.keys(s).length) return s;
return buildSummary(props.detail.products || []);
});
const partyLabel = (role) => PARTY_ROLES.find((r) => r.key === role)?.label || role || "-";
</script>
<style lang="less" scoped>
.detail-head {
.detail-title {
display: flex;
align-items: center;
gap: 8px;
.name {
font-size: 17px;
font-weight: 600;
margin-top: 10px;
color: var(--el-text-color-primary);
}
}
.detail-sub {
margin-top: 6px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
}
.summary-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
.summary-card {
padding: 12px 16px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-fill-color-extra-light);
display: flex;
flex-direction: column;
gap: 6px;
.label {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.value {
font-size: 17px;
font-weight: 700;
&.primary {
color: var(--el-color-primary);
}
&.success {
color: var(--el-color-success);
}
&.warning {
color: var(--el-color-warning);
}
}
}
}
</style>
@@ -0,0 +1,151 @@
<template>
<div class="contract-payback">
<div class="section-head">
<span class="section-title">回款计划</span>
<el-button type="primary" size="small" :icon="Plus" @click="openCreate">新建回款计划</el-button>
</div>
<el-table v-loading="loading" :data="list" border size="small">
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="回款周期" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="回款进度" min-width="140" align="center">
<template #default="{ row }">
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
</template>
</el-table-column>
<el-table-column label="计划回款总额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
</el-table-column>
<el-table-column label="回款方式" width="100" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</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="80" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="该合同暂无回款计划,点击右上角新建" :image-size="60" />
</template>
</el-table>
<!-- 新建 / 编辑回款计划(预设当前合同) -->
<PaybackCreate
v-model:visible="createVisible"
:preset-contract="contract"
:edit-data="editRow"
@success="handleSaved"
/>
<!-- 回款计划详情 -->
<PaybackDetail v-model:visible="detailVisible" :payback="currentRow" @edit="handleEdit" />
</div>
</template>
<script setup>
import { ref } from "vue";
import { Plus } from "@element-plus/icons-vue";
import { getPaybackList } from "@/api/crmPayback";
import PaybackCreate from "../../payback/components/create.vue";
import PaybackDetail from "../../payback/components/detail.vue";
import {
paybackCycleText,
paybackMethodText,
paybackStatusText,
paybackStatusTag,
formatMoney,
} from "../../dict";
const props = defineProps({
/** 当前合同详情对象(含 id / contract_name / contract_no / parties / summary) */
contract: { type: Object, default: null },
});
const emit = defineEmits(["count", "saved"]);
const loading = ref(false);
const list = ref([]);
const createVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
const editRow = ref(null);
/** 回款进度百分比 */
function paybackPercent(row) {
const total = Number(row.total_amount) || 0;
if (!total) return 0;
return Math.min(100, Math.round((Number(row.received_amount) / total) * 100));
}
function openCreate() {
editRow.value = null;
createVisible.value = true;
}
function openDetail(row) {
currentRow.value = { ...row };
detailVisible.value = true;
}
/** 详情内点击编辑:关闭详情并打开编辑弹窗 */
function handleEdit(row) {
detailVisible.value = false;
editRow.value = { ...row };
createVisible.value = true;
}
function handleSaved() {
reload();
emit("saved");
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
const contractId = props.contract?.id;
if (!contractId) {
list.value = [];
emit("count", 0);
return;
}
loading.value = true;
try {
const res = await getPaybackList({ contract_id: contractId, page: 1, pageSize: 100 });
list.value = res?.data?.list || [];
} catch (e) {
list.value = [];
} finally {
loading.value = false;
emit("count", list.value.length);
}
}
defineExpose({ reload });
</script>
<style lang="less" scoped>
.section-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
.section-title {
font-size: 14px;
font-weight: 600;
}
}
</style>
@@ -13,6 +13,36 @@
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/**
* 软件开发模块树节点售价合计:
* - 含子节点 → 汇总子节点;
* - 叶子节点 → 人天 × 统一人天单价。
*/
export function treeSellTotal(nodes, unitPrice = 0) {
const list = Array.isArray(nodes) ? nodes : [];
const up = Number(unitPrice) || 0;
return round2(
list.reduce((sum, n) => {
if (Array.isArray(n.children) && n.children.length) {
return sum + treeSellTotal(n.children, up);
}
return sum + round2((Number(n.man_days) || 0) * up);
}, 0)
);
}
/** 单行金额:成品 = 数量×单价;软件开发 = 模块树售价合计(人天×统一人天单价) */
export function lineAmount(row) {
if (row?.line_type === "dev") return treeSellTotal(row.dev_tree, row.dev_unit_price);
return round2((Number(row?.quantity) || 0) * (Number(row?.price) || 0));
}
/** 单行成本:成品 = 数量×成本单价;软件开发不计成本 */
export function lineCost(row) {
if (row?.line_type === "dev") return 0;
return round2((Number(row?.quantity) || 0) * (Number(row?.cost_price) || 0));
}
export function buildSummary(products) {
const rows = Array.isArray(products) ? products : [];
let hardware = 0;
@@ -20,6 +50,11 @@ export function buildSummary(products) {
let other = 0;
let totalCost = 0;
rows.forEach((row) => {
if (row?.line_type === "dev") {
const amount = treeSellTotal(row.dev_tree, row.dev_unit_price);
software += amount; // 软件开发计入软件部分,不计成本
return;
}
const qty = Number(row?.quantity) || 0;
const price = Number(row?.price) || 0;
const costPrice = Number(row?.cost_price) || 0;
+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;
@@ -7,13 +7,13 @@
@update:model-value="handleClose"
@closed="handleClosed"
>
<!-- 步骤条 -->
<!-- 步骤条(支持点击步骤直接跳转) -->
<div class="steps-wrapper">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="基本信息" />
<el-step title="联系信息" />
<el-step title="开票信息" />
<el-step title="备注信息" />
<el-steps :active="activeStep" finish-status="success" align-center class="clickable-steps">
<el-step title="基本信息" class="step-clickable" @click="goStep(0)" />
<el-step title="联系信息" class="step-clickable" @click="goStep(1)" />
<el-step title="开票信息" class="step-clickable" @click="goStep(2)" />
<el-step title="备注信息" class="step-clickable" @click="goStep(3)" />
</el-steps>
</div>
@@ -382,6 +382,13 @@ function prevStep() {
}
}
/** 点击上方步骤条直接跳转(无需按上一步 / 下一步) */
function goStep(index) {
const target = Math.max(0, Math.min(3, Number(index) || 0));
if (target === activeStep.value) return;
activeStep.value = target;
}
async function handleSave(isDraft) {
if (!formRef.value) return;
if (isDraft) {
@@ -441,6 +448,15 @@ function handleSaveDraft() {
padding: 0 20px;
}
/* 步骤条可点击跳转 */
.clickable-steps {
:deep(.step-clickable),
:deep(.el-step__head),
:deep(.el-step__main) {
cursor: pointer;
}
}
.dialog-footer {
display: flex;
justify-content: space-between;
+84 -214
View File
@@ -3,7 +3,7 @@
<!-- 顶部工具栏:时间维度切换 -->
<div class="toolbar">
<div class="toolbar-left">
<div class="title">客户关系管理 · 数据仪表盘</div>
<div class="title">数据仪表盘</div>
<div class="subtitle">
<el-icon><Calendar /></el-icon>
<span>{{ rangeText }}</span>
@@ -169,6 +169,7 @@ import {
Wallet,
Phone,
} from "@element-plus/icons-vue";
import { getCrmDashboard } from "@/api/crmDashboard";
/* ------------------------------ 类型定义 ------------------------------ */
type RangeKey = "day" | "week" | "month" | "quarter" | "year";
@@ -179,7 +180,6 @@ interface StatMeta {
label: string;
type: StatType;
icon: unknown;
base: Record<RangeKey, number>;
}
interface StatItem extends StatMeta {
@@ -241,171 +241,55 @@ const rangeText = computed(() => {
/* ------------------------------ 统计卡片 ------------------------------ */
const statMeta: StatMeta[] = [
{
key: "newCustomer",
label: "新增客户",
type: "count",
icon: markRaw(UserFilled),
base: { day: 8, week: 42, month: 168, quarter: 486, year: 1935 },
},
{
key: "newContact",
label: "新增联系人",
type: "count",
icon: markRaw(Postcard),
base: { day: 15, week: 86, month: 342, quarter: 998, year: 3960 },
},
{
key: "newProject",
label: "新增项目",
type: "count",
icon: markRaw(FolderOpened),
base: { day: 3, week: 16, month: 62, quarter: 186, year: 742 },
},
{
key: "newContract",
label: "新增合同",
type: "count",
icon: markRaw(Tickets),
base: { day: 5, week: 24, month: 96, quarter: 288, year: 1150 },
},
{
key: "newClue",
label: "新增线索",
type: "count",
icon: markRaw(Aim),
base: { day: 26, week: 152, month: 610, quarter: 1820, year: 7250 },
},
{
key: "newChance",
label: "新增商机",
type: "count",
icon: markRaw(TrendCharts),
base: { day: 12, week: 68, month: 268, quarter: 800, year: 3180 },
},
{
key: "projectAmount",
label: "项目金额",
type: "amount",
icon: markRaw(Files),
base: {
day: 380000,
week: 1980000,
month: 7860000,
quarter: 23500000,
year: 93800000,
},
},
{
key: "contractAmount",
label: "合同金额",
type: "amount",
icon: markRaw(Document),
base: {
day: 260000,
week: 1360000,
month: 5420000,
quarter: 16200000,
year: 64800000,
},
},
{
key: "chanceAmount",
label: "商机金额",
type: "amount",
icon: markRaw(Money),
base: {
day: 520000,
week: 2760000,
month: 10900000,
quarter: 32600000,
year: 130000000,
},
},
{
key: "paymentAmount",
label: "回款金额",
type: "amount",
icon: markRaw(Wallet),
base: {
day: 210000,
week: 1080000,
month: 4280000,
quarter: 12800000,
year: 51200000,
},
},
{
key: "newVisit",
label: "新增回访",
type: "count",
icon: markRaw(Phone),
base: { day: 18, week: 96, month: 386, quarter: 1150, year: 4600 },
},
{ key: "newCustomer", label: "新增客户", type: "count", icon: markRaw(UserFilled) },
{ key: "newContact", label: "新增联系人", type: "count", icon: markRaw(Postcard) },
{ key: "newProject", label: "新增项目", type: "count", icon: markRaw(FolderOpened) },
{ key: "newContract", label: "新增合同", type: "count", icon: markRaw(Tickets) },
{ key: "newClue", label: "新增线索", type: "count", icon: markRaw(Aim) },
{ key: "newChance", label: "新增商机", type: "count", icon: markRaw(TrendCharts) },
{ key: "projectAmount", label: "项目金额", type: "amount", icon: markRaw(Files) },
{ key: "contractAmount", label: "合同金额", type: "amount", icon: markRaw(Document) },
{ key: "chanceAmount", label: "商机金额", type: "amount", icon: markRaw(Money) },
{ key: "paymentAmount", label: "回款金额", type: "amount", icon: markRaw(Wallet) },
{ key: "newVisit", label: "新增回访", type: "count", icon: markRaw(Phone) },
];
const statCards = ref<StatItem[]>([]);
/* ------------------------------ 列表数据 ------------------------------ */
const rankList = ref([
{ name: "张伟", dept: "销售一部", contract: 1280000, payment: 960000 },
{ name: "李娜", dept: "销售二部", contract: 1060000, payment: 820000 },
{ name: "王强", dept: "销售一部", contract: 950000, payment: 700000 },
{ name: "刘洋", dept: "大客户部", contract: 880000, payment: 640000 },
{ name: "陈静", dept: "销售二部", contract: 760000, payment: 530000 },
{ name: "赵磊", dept: "大客户部", contract: 640000, payment: 510000 },
{ name: "孙悦", dept: "销售三部", contract: 520000, payment: 380000 },
{ name: "周涛", dept: "销售三部", contract: 430000, payment: 300000 },
]);
/* ------------------------------ 列表数据(真实接口返回) ------------------------------ */
interface RankItem {
name: string;
dept: string;
contract: number;
payment: number;
}
interface VisitItem {
customer: string;
way: string;
content: string;
user: string;
time: string;
}
const rankList = ref<RankItem[]>([]);
const maxContract = computed(() =>
rankList.value.reduce((max, item) => Math.max(max, item.contract), 1)
);
const visitList = ref<VisitItem[]>([]);
const visitList = ref([
{
customer: "深圳市星辰科技有限公司",
way: "电话",
content: "确认合同条款细节与交付时间节点",
user: "张伟",
time: "09-10 09:30",
},
{
customer: "广州恒远制造集团",
way: "上门",
content: "现场沟通二期项目需求,客户预算已确认",
user: "李娜",
time: "09-10 08:50",
},
{
customer: "杭州云图网络技术公司",
way: "微信",
content: "发送报价单,等待客户内部审批流程",
user: "王强",
time: "09-09 17:20",
},
{
customer: "成都锦程物流有限公司",
way: "电话",
content: "回访首月使用情况,客户反馈良好",
user: "刘洋",
time: "09-09 15:05",
},
{
customer: "北京中启信息技术股份公司",
way: "邮件",
content: "跟进续约意向,客户提出增加 20 个坐席",
user: "陈静",
time: "09-09 11:40",
},
{
customer: "上海瑞丰医疗器械公司",
way: "上门",
content: "演示新版本功能,约下周做正式方案汇报",
user: "赵磊",
time: "09-08 16:30",
},
]);
// 图表数据(趋势 / 金额 / 漏斗 / 来源),由接口返回
interface DashData {
trend: { axis: string[]; customer: number[]; chance: number[] };
funnel: { name: string; value: number }[];
amount: { axis: string[]; contract: number[]; payment: number[] };
source: { name: string; value: number }[];
}
const dash = ref<DashData>({
trend: { axis: [], customer: [], chance: [] },
funnel: [],
amount: { axis: [], contract: [], payment: [] },
source: [],
});
/* ------------------------------ 工具方法 ------------------------------ */
const formatValue = (item: StatItem) => {
@@ -423,36 +307,6 @@ const formatWan = (value: number) => {
const trendClass = (trend: number) =>
trend > 0 ? "up" : trend < 0 ? "down" : "flat";
// 基于基数生成带随机波动的模拟数据(后续替换为接口数据)
const randomBy = (base: number, spread = 0.3) =>
Math.round(base * (1 - spread / 2 + Math.random() * spread));
const getBase = (key: string) =>
statMeta.find((i) => i.key === key)?.base[range.value] ?? 0;
const buildAxis = (): string[] => {
const now = new Date();
switch (range.value) {
case "day":
return Array.from({ length: 12 }, (_, i) => `${pad(i * 2)}:00`);
case "week":
return ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
case "month": {
const days = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
return Array.from({ length: days }, (_, i) => `${i + 1}日`);
}
case "quarter": {
const q = Math.floor(now.getMonth() / 3);
return [0, 1, 2].map((i) => `${q * 3 + i + 1}月`);
}
default:
return Array.from({ length: 12 }, (_, i) => `${i + 1}月`);
}
};
const buildSeries = (total: number, len: number) =>
Array.from({ length: len }, () => randomBy(total / len, 0.6));
/* ------------------------------ 图表 ------------------------------ */
const trendRef = ref<HTMLElement | null>(null);
const funnelRef = ref<HTMLElement | null>(null);
@@ -476,7 +330,7 @@ const getPalette = () => {
const renderTrendChart = () => {
if (!trendChart.value) return;
const p = getPalette();
const axis = buildAxis();
const axis = dash.value.trend.axis;
trendChart.value.setOption(
{
tooltip: { trigger: "axis", axisPointer: { type: "cross" } },
@@ -519,7 +373,7 @@ const renderTrendChart = () => {
{ offset: 1, color: "rgba(57,115,255,0.02)" },
]),
},
data: buildSeries(getBase("newCustomer"), axis.length),
data: dash.value.trend.customer,
},
{
name: "新增商机",
@@ -535,7 +389,7 @@ const renderTrendChart = () => {
{ offset: 1, color: "rgba(16,185,129,0.02)" },
]),
},
data: buildSeries(getBase("newChance"), axis.length),
data: dash.value.trend.chance,
},
],
},
@@ -546,14 +400,12 @@ const renderTrendChart = () => {
const renderFunnelChart = () => {
if (!funnelChart.value) return;
const p = getPalette();
const clue = getBase("newClue");
const data = [
{ name: "线索", value: clue, ratio: 1 },
{ name: "商机", value: Math.round(clue * 0.42), ratio: 0.42 },
{ name: "报价", value: Math.round(clue * 0.22), ratio: 0.22 },
{ name: "合同", value: Math.round(clue * 0.1), ratio: 0.1 },
{ name: "回款", value: Math.round(clue * 0.078), ratio: 0.078 },
];
const maxValue = Math.max(...dash.value.funnel.map((i) => i.value), 1);
const data = dash.value.funnel.map((i) => ({
name: i.name,
value: i.value,
ratio: i.value / maxValue,
}));
funnelChart.value.setOption(
{
tooltip: {
@@ -601,7 +453,7 @@ const renderFunnelChart = () => {
const renderAmountChart = () => {
if (!amountChart.value) return;
const p = getPalette();
const axis = buildAxis();
const axis = dash.value.amount.axis;
amountChart.value.setOption(
{
tooltip: { trigger: "axis", axisPointer: { type: "shadow" } },
@@ -638,14 +490,14 @@ const renderAmountChart = () => {
type: "bar",
barMaxWidth: 18,
itemStyle: { color: "#f59e0b", borderRadius: [4, 4, 0, 0] },
data: buildSeries(getBase("contractAmount"), axis.length),
data: dash.value.amount.contract,
},
{
name: "回款金额",
type: "bar",
barMaxWidth: 18,
itemStyle: { color: "#eab308", borderRadius: [4, 4, 0, 0] },
data: buildSeries(getBase("paymentAmount"), axis.length),
data: dash.value.amount.payment,
},
],
},
@@ -656,13 +508,7 @@ const renderAmountChart = () => {
const renderSourceChart = () => {
if (!sourceChart.value) return;
const p = getPalette();
const total = getBase("newClue");
const ratio = [0.28, 0.2, 0.16, 0.14, 0.12, 0.1];
const names = ["官网注册", "电话咨询", "展会获客", "客户转介绍", "社交媒体", "广告投放"];
const data = names.map((name, i) => ({
name,
value: Math.round(total * ratio[i] * (0.9 + Math.random() * 0.2)),
}));
const data = dash.value.source;
sourceChart.value.setOption(
{
tooltip: { trigger: "item", formatter: "{b}:{c}({d}%)" },
@@ -720,20 +566,44 @@ const handleResize = () => {
const loadData = async () => {
loading.value = true;
try {
// TODO: 接入真实接口,按 range 维度查询统计数据
// const res = await getCrmDashboard({ range: range.value });
await new Promise((resolve) => setTimeout(resolve, 260));
const res = await getCrmDashboard({ range: range.value });
const data: any = res?.data || {};
const statsMap: Record<string, { value: number; trend: number }> =
data.stats || {};
statCards.value = statMeta.map((meta) => ({
...meta,
value: randomBy(meta.base[range.value]),
trend: Number((Math.random() * 46 - 16).toFixed(1)),
value: statsMap[meta.key]?.value ?? 0,
trend: statsMap[meta.key]?.trend ?? 0,
}));
dash.value = {
trend: data.trend || { axis: [], customer: [], chance: [] },
funnel: data.funnel || [],
amount: data.amount || { axis: [], contract: [], payment: [] },
source: data.source || [],
};
rankList.value = data.rank || [];
visitList.value = data.visits || [];
const now = new Date();
updateTime.value = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(
now.getDate()
)} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`;
await nextTick();
renderCharts();
} catch (e) {
statCards.value = statMeta.map((meta) => ({ ...meta, value: 0, trend: 0 }));
dash.value = {
trend: { axis: [], customer: [], chance: [] },
funnel: [],
amount: { axis: [], contract: [], payment: [] },
source: [],
};
rankList.value = [];
visitList.value = [];
await nextTick();
renderCharts();
} finally {
loading.value = false;
}
+133
View File
@@ -263,6 +263,76 @@ export function formatDateOnly(val) {
return `${d.getFullYear()}-${m}-${day}`;
}
/* =====================================================================
* 逾期(overdue)计算
* 规则:到期/结束日期 < 今天 且 状态非「完成/作废」即视为逾期。
* - 项目:结束日期(end_date) 已过 且 状态 != 已完成(3)
* - 合同:到期日期(expire_date) 已过 且 状态 != 已完成(2) / 已作废(3)
* - 回款明细:计划回款日期(plan_date) 已过 且 状态 != 已回款(3)
* 全部以「当前日期」判定,无需服务端额外参数。
* ===================================================================== */
/** 距今天数:今天 - dateStr(按日期比较),>0 表示 dateStr 已在今天之前 */
export function overdueDays(dateStr) {
if (!dateStr) return 0;
const d = new Date(dateStr);
if (isNaN(d.getTime())) return 0;
const t = new Date();
t.setHours(0, 0, 0, 0);
d.setHours(0, 0, 0, 0);
return Math.round((t - d) / 86400000);
}
/** 逾期文案:days>0 -> "逾期 N 天",否则空串 */
export function overdueText(days) {
return days > 0 ? `逾期 ${days} 天` : "";
}
/** 项目逾期信息:{ overdue, days } */
export function projectOverdue(row) {
const days = overdueDays(row?.end_date);
const overdue = days > 0 && Number(row?.status) !== 3;
return { overdue, days: overdue ? days : 0 };
}
/** 合同逾期信息:{ overdue, days } */
export function contractOverdue(row) {
const days = overdueDays(row?.expire_date);
const overdue = days > 0 && ![2, 3].includes(Number(row?.status));
return { overdue, days: overdue ? days : 0 };
}
/** 回款明细逾期信息:{ overdue, days } */
export function paybackItemOverdue(item) {
const days = overdueDays(item?.plan_date);
const overdue = days > 0 && Number(item?.status) !== 3;
return { overdue, days: overdue ? days : 0 };
}
/** 回款计划逾期:任一明细逾期即视为计划逾期(items 可能为数组或 JSON 字符串)。
* 返回 { overdue, days },days 取逾期明细中最大的逾期天数。 */
export function paybackPlanOverdue(payback) {
let items = payback?.items;
if (typeof items === "string" && items.trim()) {
try {
items = JSON.parse(items);
} catch {
items = [];
}
}
if (!Array.isArray(items)) items = [];
let maxDays = 0;
let overdue = false;
for (const it of items) {
const r = paybackItemOverdue(it);
if (r.overdue) {
overdue = true;
if (r.days > maxDays) maxDays = r.days;
}
}
return { overdue, days: overdue ? maxDays : 0 };
}
/* =====================================================================
* 合同管理
* 我方角色 our_role:1=甲方 2=乙方 3=丙方 4=丁方(当前租户扮演的一方,默认乙方)
@@ -417,3 +487,66 @@ export function stripHtml(val) {
if (text) return text;
return /<img/i.test(raw) ? "[图片]" : "-";
}
/* =====================================================================
* 回款管理(针对于合同:回款计划 + 回款进度)
* 回款周期 plan_type:1=月度 2=季度 3=年度 4=进度 5=自定义
* 回款方式 pay_method:1=对公转账 2=网银转账 3=现金 4=支票 5=支付宝 6=微信 7=其他
* 计划状态 status:1=进行中 2=已完成
* 明细状态 item.status:1=未回款 2=部分回款 3=已回款
* ===================================================================== */
/** 回款周期 */
export const PAYBACK_CYCLE_OPTIONS = [
{ label: "月度", value: 1 },
{ label: "季度", value: 2 },
{ label: "年度", value: 3 },
{ label: "进度", value: 4 },
{ label: "自定义", value: 5 },
];
/** 按周期的分期单位(用于生成「第 N 月/季度/年」文案) */
export const PAYBACK_CYCLE_UNIT = { 1: "月", 2: "季度", 3: "年" };
/** 回款方式 */
export const PAYBACK_METHOD_OPTIONS = [
{ label: "对公转账", value: 1 },
{ label: "网银转账", value: 2 },
{ label: "现金", value: 3 },
{ label: "支票", value: 4 },
{ label: "支付宝", value: 5 },
{ label: "微信", value: 6 },
{ label: "其他", value: 7 },
];
/** 回款计划状态 */
export const PAYBACK_STATUS_OPTIONS = [
{ label: "进行中", value: 1 },
{ label: "已完成", value: 2 },
];
/** 回款明细(分期)状态 */
export const PAYBACK_ITEM_STATUS_OPTIONS = [
{ label: "未回款", value: 1 },
{ label: "部分回款", value: 2 },
{ label: "已回款", value: 3 },
];
const PAYBACK_CYCLE_MAP = PAYBACK_CYCLE_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PAYBACK_METHOD_MAP = PAYBACK_METHOD_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PAYBACK_STATUS_MAP = PAYBACK_STATUS_OPTIONS.reduce((m, i) => ((m[i.value] = i.label), m), {});
const PAYBACK_ITEM_STATUS_MAP = PAYBACK_ITEM_STATUS_OPTIONS.reduce(
(m, i) => ((m[i.value] = i.label), m),
{}
);
const PAYBACK_STATUS_TAG = { 1: "primary", 2: "success" };
const PAYBACK_ITEM_STATUS_TAG = { 1: "info", 2: "warning", 3: "success" };
export const paybackCycleText = (val) => PAYBACK_CYCLE_MAP[normalize(val)] || normalize(val) || "-";
export const paybackMethodText = (val) => PAYBACK_METHOD_MAP[normalize(val)] || normalize(val) || "-";
export const paybackStatusText = (val) => PAYBACK_STATUS_MAP[normalize(val)] || normalize(val) || "-";
export const paybackStatusTag = (val) => PAYBACK_STATUS_TAG[normalize(val)] || "info";
export const paybackItemStatusText = (val) =>
PAYBACK_ITEM_STATUS_MAP[normalize(val)] || normalize(val) || "-";
export const paybackItemStatusTag = (val) => PAYBACK_ITEM_STATUS_TAG[normalize(val)] || "info";
@@ -0,0 +1,763 @@
<template>
<el-dialog
:model-value="visible"
:title="isEdit ? '编辑回款计划' : '新建回款计划'"
width="880px"
top="5vh"
:close-on-click-modal="false"
@update:model-value="handleClose"
@closed="handleClosed"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="104px">
<!-- 关联合同 / 客户 / 金额 -->
<el-divider content-position="left">合同信息</el-divider>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="关联合同" prop="contract_id">
<el-select
v-model="form.contract_id"
filterable
remote
clearable
reserve-keyword
:loading="contractLoading"
:remote-method="searchContracts"
placeholder="搜索合同名称"
style="width: 100%"
@change="handleContractChange"
@visible-change="handleContractDropdown"
>
<el-option
v-for="c in contractOptions"
:key="c.id"
:label="c.contract_name"
:value="c.id"
>
<span class="option-name">{{ c.contract_name }}</span>
<span class="option-sub">{{ c.contract_no }}</span>
</el-option>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="客户名称" prop="customer_name">
<el-input v-model="form.customer_name" placeholder="默认取合同客户,可修改" maxlength="128" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同名称" prop="contract_name">
<el-input v-model="form.contract_name" placeholder="选择合同后自动带入" maxlength="100" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="合同总金额" prop="contract_amount">
<el-input-number
v-model="form.contract_amount"
:min="0"
:precision="2"
:controls="false"
placeholder="元"
style="width: 100%"
/>
</el-form-item>
</el-col>
</el-row>
<!-- 计划配置 -->
<el-divider content-position="left">计划配置</el-divider>
<el-row :gutter="20">
<el-col :span="24">
<el-form-item label="回款周期" prop="plan_type">
<el-radio-group v-model="form.plan_type" @change="handlePlanTypeChange">
<el-radio-button v-for="i in PAYBACK_CYCLE_OPTIONS" :key="i.value" :value="i.value">
{{ i.label }}
</el-radio-button>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="回款方式" prop="pay_method">
<el-select v-model="form.pay_method" placeholder="请选择" style="width: 100%">
<el-option v-for="i in PAYBACK_METHOD_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="负责人" prop="owner_user_id">
<el-select v-model="form.owner_user_id" filterable placeholder="请选择负责人" style="width: 100%">
<el-option v-for="u in userOptions" :key="u.id" :label="u.name" :value="String(u.id)" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="提前提醒" prop="remind_days">
<el-input-number v-model="form.remind_days" :min="0" :max="365" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="2" placeholder="回款补充说明(选填)" />
</el-form-item>
</el-col>
</el-row>
<!-- 流水回执:银行流水 / 转账凭证,非必填 -->
<el-divider content-position="left">
流水回执
<span class="tip">银行流水 / 转账凭证等,选填</span>
</el-divider>
<div class="receipt-block">
<el-upload
:show-file-list="false"
:http-request="handleReceiptUpload"
:disabled="uploading"
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.xls,.xlsx"
>
<el-button :icon="Upload" :loading="uploading" size="small">上传流水回执</el-button>
</el-upload>
<div v-if="form.receipt_url" class="receipt-file">
<el-link type="primary" :href="form.receipt_url" target="_blank" :underline="false">
<el-icon><Document /></el-icon>
<span class="receipt-name">{{ form.receipt_name || "流水回执" }}</span>
</el-link>
<el-button link type="danger" size="small" :icon="Delete" @click="clearReceipt">移除</el-button>
</div>
</div>
<!-- 计划明细:月度 / 季度 / 年度 = 期数 + 每期金额 -->
<template v-if="isFixedCycle">
<el-divider content-position="left">
回款明细
<span class="tip">按{{ cycleUnitText }}生成分期,共 {{ form.periodCount || 0 }} 期</span>
</el-divider>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item :label="`期数(${cycleUnitText})`">
<el-input-number v-model="form.periodCount" :min="1" :max="120" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="每期金额">
<el-input-number
v-model="form.periodAmount"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
/>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="计划总额">
<span class="total-amount">¥{{ formatMoney(plannedTotal) }}</span>
</el-form-item>
</el-col>
</el-row>
<el-table :data="fixedItems" border size="small" class="detail-table">
<el-table-column type="index" label="期数" width="70" align="center" />
<el-table-column prop="name" label="名称" min-width="120" />
<el-table-column label="支付金额" width="140" align="right">
<template #default="{ row }">¥{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="计划回款日期" width="180">
<template #default="{ $index }">
<el-date-picker v-model="form.periodDates[$index]" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</template>
</el-table-column>
<template #empty><el-empty description="请填写期数与每期金额" :image-size="50" /></template>
</el-table>
</template>
<!-- 计划明细:进度 = 进度名称 + 进度百分比 + 进度金额 -->
<template v-else-if="form.plan_type === 4">
<el-divider content-position="left">
回款进度
<span class="tip">按进度节点逐笔回款</span>
</el-divider>
<el-table :data="form.progressItems" border size="small" class="detail-table" row-key="__key">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="进度名称" min-width="160">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="如:预付款、验收款" maxlength="50" />
</template>
</el-table-column>
<el-table-column label="进度百分比" width="150">
<template #default="{ row }">
<el-input-number
v-model="row.percent"
:min="0"
:max="100"
:precision="2"
:controls="false"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column width="210">
<template #header>
<span>进度金额</span>
<el-tooltip
content="默认按「合同总金额 × 进度百分比」自动计算;手动填写后该行不再自动计算,可点「重算」恢复"
placement="top"
>
<el-icon class="col-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<template #default="{ row }">
<div class="amount-cell">
<el-input-number
v-model="row.amount"
:min="0"
:precision="2"
:controls="false"
class="amount-input"
@change="markAmountManual(row)"
/>
<el-button v-if="row.auto === false" link type="primary" size="small" @click="resetRowAuto(row)">
重算
</el-button>
</div>
</template>
</el-table-column>
<el-table-column label="计划回款日期" width="180">
<template #default="{ row }">
<el-date-picker v-model="row.plan_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRow('progressItems', $index)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无进度节点" :image-size="50" /></template>
</el-table>
<el-button class="add-row" :icon="Plus" size="small" @click="addRow('progressItems')">增加进度</el-button>
<span class="sum-tip">计划总额:¥{{ formatMoney(plannedTotal) }}</span>
</template>
<!-- 计划明细:自定义 = 名称 + 计划日期 + 金额 -->
<template v-else>
<el-divider content-position="left">
回款明细
<span class="tip">自定义回款节点</span>
</el-divider>
<el-table :data="form.customItems" border size="small" class="detail-table" row-key="__key">
<el-table-column type="index" label="#" width="50" align="center" />
<el-table-column label="名称" min-width="160">
<template #default="{ row }">
<el-input v-model="row.name" placeholder="如:首付款" maxlength="50" />
</template>
</el-table-column>
<el-table-column label="计划日期" width="180">
<template #default="{ row }">
<el-date-picker v-model="row.plan_date" type="date" value-format="YYYY-MM-DD" placeholder="选择日期" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="金额" width="170">
<template #default="{ row }">
<el-input-number v-model="row.amount" :min="0" :precision="2" :controls="false" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="操作" width="70" align="center">
<template #default="{ $index }">
<el-button link type="danger" size="small" @click="removeRow('customItems', $index)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无回款节点" :image-size="50" /></template>
</el-table>
<el-button class="add-row" :icon="Plus" size="small" @click="addRow('customItems')">增加明细</el-button>
<span class="sum-tip">计划总额:¥{{ formatMoney(plannedTotal) }}</span>
</template>
</el-form>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { ref, reactive, computed, watch } from "vue";
import { ElMessage } from "element-plus";
import { Plus, QuestionFilled, Upload, Delete, Document } from "@element-plus/icons-vue";
import { createPayback, updatePayback } from "@/api/crmPayback";
import { uploadFile } from "@/api/file";
import { getContractList } from "@/api/crmContract";
import { getAllUsers } from "@/api/user";
import { useAuthStore } from "@/stores/auth";
import {
PAYBACK_CYCLE_OPTIONS,
PAYBACK_METHOD_OPTIONS,
PAYBACK_CYCLE_UNIT,
formatMoney,
} from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
/** 编辑数据(含 id 与 items) */
editData: { type: Object, default: null },
/** 预设合同(从合同详情「回款计划」进入时使用) */
presetContract: { type: Object, default: null },
/** 预设合同ID(列表选择合同后快捷新建) */
presetContractId: { type: [Number, String], default: null },
});
const emit = defineEmits(["update:visible", "success"]);
const authStore = useAuthStore();
const formRef = ref();
const saving = ref(false);
const isEdit = ref(false);
const internalId = ref(null);
const userOptions = ref([]);
let rowSeed = 0;
const genKey = () => `pb_${Date.now()}_${rowSeed++}`;
const defaultForm = () => ({
contract_id: null,
contract_no: "",
contract_name: "",
customer_id: null,
customer_name: "",
contract_amount: 0,
plan_type: 4,
pay_method: 1,
remind_days: 0,
owner_user_id: authStore.user?.id ? String(authStore.user.id) : "",
owner_user_name: authStore.user?.name || "",
remark: "",
receipt_url: "",
receipt_name: "",
periodCount: 1,
periodAmount: 0,
periodDates: [],
progressItems: [{ __key: genKey(), name: "", percent: 0, amount: 0, plan_date: "", auto: true }],
customItems: [],
});
const form = reactive(defaultForm());
const rules = {
contract_id: [{ required: true, message: "请选择关联合同", trigger: "change" }],
plan_type: [{ required: true, message: "请选择回款周期", trigger: "change" }],
pay_method: [{ required: true, message: "请选择回款方式", trigger: "change" }],
};
/** 固定周期(月度/季度/年度):期数 + 每期金额 */
const isFixedCycle = computed(() => [1, 2, 3].includes(Number(form.plan_type)));
const cycleUnitText = computed(() => PAYBACK_CYCLE_UNIT[Number(form.plan_type)] || "");
const fixedItems = computed(() => {
const n = Number(form.periodCount) || 0;
const amount = Number(form.periodAmount) || 0;
return Array.from({ length: n }, (_, i) => ({
seq: i + 1,
name: `第${i + 1}${cycleUnitText.value}`,
amount,
plan_date: form.periodDates[i] || "",
}));
});
/** 计划总额:按当前周期口径汇总 */
const plannedTotal = computed(() => {
if (isFixedCycle.value) return Number(form.periodCount || 0) * (Number(form.periodAmount) || 0);
const rows = Number(form.plan_type) === 4 ? form.progressItems : form.customItems;
return rows.reduce((sum, r) => sum + (Number(r.amount) || 0), 0);
});
/* ------------------------------ 合同搜索 ------------------------------ */
const contractLoading = ref(false);
const contractOptions = ref([]);
const searchContracts = async (keyword) => {
contractLoading.value = true;
try {
const res = await getContractList({ keyword: keyword || "", page: 1, pageSize: 50 });
contractOptions.value = res?.data?.list || [];
} catch {
contractOptions.value = [];
} finally {
contractLoading.value = false;
}
};
const handleContractDropdown = (visible) => {
if (visible && contractOptions.value.length === 0) searchContracts("");
};
const handleContractChange = (id) => {
const hit = contractOptions.value.find((c) => String(c.id) === String(id));
if (!hit) return;
applyContract(hit);
};
/** 用合同数据回填客户 / 合同名称 / 合同金额(客户取合同参与方中的客户) */
function applyContract(contract) {
form.contract_name = contract.contract_name || "";
form.contract_no = contract.contract_no || "";
const amount = contract.summary?.total_amount ?? contract.total_amount;
if (amount !== undefined && amount !== null) form.contract_amount = Number(amount) || 0;
const parties = Array.isArray(contract.parties) ? contract.parties : [];
const customer = parties.find((p) => String(p.ref_type) === "1");
if (customer) {
form.customer_name = customer.ref_name || form.customer_name;
form.customer_id = customer.ref_id || form.customer_id;
}
}
/* ------------------------------ 明细操作 ------------------------------ */
const addRow = (key) => {
if (key === "progressItems") {
const row = { __key: genKey(), name: "", percent: 0, amount: 0, plan_date: "", auto: true };
form.progressItems.push(row);
syncRowAmount(row);
} else {
form.customItems.push({ __key: genKey(), name: "", plan_date: "", amount: 0 });
}
};
const removeRow = (key, index) => {
form[key].splice(index, 1);
};
/* ---------------------- 流水回执上传(非必填) ---------------------- */
const uploading = ref(false);
/** 上传流水回执:复用通用文件上传接口,返回地址存入表单(选填) */
async function handleReceiptUpload(options) {
const file = options.file;
uploading.value = true;
try {
const fd = new FormData();
fd.append("file", file);
const res = await uploadFile(fd);
const data = res?.data || {};
form.receipt_url = data.url || "";
form.receipt_name = data.name || file.name;
ElMessage.success("上传成功");
} catch (e) {
ElMessage.error(e.message || "上传失败");
} finally {
uploading.value = false;
}
}
/** 移除已上传的流水回执 */
function clearReceipt() {
form.receipt_url = "";
form.receipt_name = "";
}
/* ---------------------- 进度金额自动计算 ---------------------- */
/** 金额保留两位小数 */
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
/** 单行进度金额 = 合同总金额 × 进度百分比(手动修改过的行不参与) */
function syncRowAmount(row) {
if (!row || row.auto === false) return;
const base = Number(form.contract_amount) || 0;
row.amount = round2((base * (Number(row.percent) || 0)) / 100);
}
/** 全部自动行重算(合同总金额变化 / 百分比变化时调用) */
function syncAllAmounts() {
form.progressItems.forEach((row) => syncRowAmount(row));
}
/** 手动填写进度金额:该行转为手动,不再随百分比自动变化 */
function markAmountManual(row) {
row.auto = false;
}
/** 恢复该行自动计算 */
function resetRowAuto(row) {
row.auto = true;
syncRowAmount(row);
}
/** 合同总金额变化:所有自动行重算 */
watch(() => form.contract_amount, syncAllAmounts);
/** 进度百分比变化:自动行实时重算 */
watch(
() => form.progressItems.map((r) => `${r.__key}#${Number(r.percent) || 0}`).join(","),
syncAllAmounts
);
/** 周期切换:首次进入对应明细时补一行默认数据 */
const handlePlanTypeChange = (val) => {
if (Number(val) === 4 && form.progressItems.length === 0) addRow("progressItems");
if (Number(val) === 5 && form.customItems.length === 0) addRow("customItems");
};
/* ------------------------------ 回显 ------------------------------ */
/** 解析 items(兼容 JSON 字符串 / 数组) */
function parseItems(val) {
if (Array.isArray(val)) return val;
if (typeof val === "string" && val.trim()) {
try {
const arr = JSON.parse(val);
return Array.isArray(arr) ? arr : [];
} catch {
return [];
}
}
return [];
}
async function loadUsers() {
if (userOptions.value.length) return;
try {
const res = await getAllUsers();
const data = res?.data || {};
const list = Array.isArray(data) ? data : data.list || [];
userOptions.value = list.map((u) => ({
id: u.uid || u.id,
name: u.name || u.account || `用户${u.uid || u.id}`,
}));
} catch {
userOptions.value = [];
}
}
watch(
() => props.visible,
(val) => {
if (!val) return;
loadUsers();
const preset = props.presetContract;
if (props.editData?.id) {
isEdit.value = true;
internalId.value = props.editData.id;
Object.assign(form, defaultForm(), props.editData);
form.plan_type = Number(props.editData.plan_type) || 1;
form.pay_method = Number(props.editData.pay_method) || 1;
form.remind_days = Number(props.editData.remind_days) || 0;
form.contract_amount = Number(props.editData.contract_amount) || 0;
const items = parseItems(props.editData.items);
if ([1, 2, 3].includes(form.plan_type)) {
form.periodCount = items.length || 1;
form.periodAmount = items.length ? Number(items[0].amount) || 0 : 0;
form.periodDates = items.map((it) => it.plan_date || "");
} else if (form.plan_type === 4) {
// 回显历史数据:保留已保存金额,默认按手动处理(可点「重算」恢复自动)
form.progressItems = items.map((it) => ({ ...it, __key: genKey(), auto: false }));
} else {
form.customItems = items.map((it) => ({ ...it, __key: genKey() }));
}
// 合同下拉回显(不重新拉取,直接补一条)
if (form.contract_id) {
contractOptions.value = [
{
id: form.contract_id,
contract_no: form.contract_no,
contract_name: form.contract_name,
},
];
}
} else {
isEdit.value = false;
internalId.value = null;
Object.assign(form, defaultForm());
contractOptions.value = [];
const source = preset || (props.presetContractId ? { id: props.presetContractId } : null);
if (source) {
let hit = contractOptions.value.find((c) => String(c.id) === String(source.id));
if (source.contract_name) {
hit = { ...source };
contractOptions.value = [hit];
}
form.contract_id = source.id;
if (hit) applyContract(hit);
else form.contract_name = source.contract_name || "";
}
}
}
);
/* ------------------------------ 保存 ------------------------------ */
/** 组装明细:按周期口径输出后端 items 结构 */
function buildItems() {
if (isFixedCycle.value) {
return fixedItems.value.map((it) => ({
name: it.name,
amount: Number(it.amount) || 0,
plan_date: form.periodDates[it.seq - 1] || "",
}));
}
if (Number(form.plan_type) === 4) {
return form.progressItems.map((it) => ({
name: (it.name || "").trim(),
percent: Number(it.percent) || 0,
amount: Number(it.amount) || 0,
plan_date: it.plan_date || "",
}));
}
return form.customItems.map((it) => ({
name: (it.name || "").trim(),
plan_date: it.plan_date || "",
amount: Number(it.amount) || 0,
}));
}
async function handleSubmit() {
if (!formRef.value) return;
try {
await formRef.value.validate();
} catch {
return;
}
const items = buildItems();
if (!items.length) {
ElMessage.warning("请填写回款明细");
return;
}
if (items.some((it) => !(Number(it.amount) > 0))) {
ElMessage.warning("请填写正确的回款金额");
return;
}
if (Number(form.plan_type) === 4 && items.some((it) => !it.name)) {
ElMessage.warning("请填写进度名称");
return;
}
if (Number(form.plan_type) === 5 && items.some((it) => !it.name)) {
ElMessage.warning("请填写回款名称");
return;
}
const owner = userOptions.value.find((u) => String(u.id) === String(form.owner_user_id));
const payload = {
contract_id: Number(form.contract_id) || 0,
contract_no: form.contract_no,
contract_name: form.contract_name,
customer_id: Number(form.customer_id) || 0,
customer_name: form.customer_name,
contract_amount: Number(form.contract_amount) || 0,
plan_type: Number(form.plan_type) || 1,
pay_method: Number(form.pay_method) || 1,
remind_days: Number(form.remind_days) || 0,
owner_user_id: form.owner_user_id,
owner_user_name: owner?.name || form.owner_user_name || "",
remark: form.remark,
receipt_url: form.receipt_url,
receipt_name: form.receipt_name,
items,
};
saving.value = true;
try {
if (internalId.value) {
await updatePayback(internalId.value, payload);
ElMessage.success("更新成功");
} else {
await createPayback(payload);
ElMessage.success("创建成功");
}
emit("success");
handleClose();
} catch (e) {
ElMessage.error(e.message || "操作失败");
} finally {
saving.value = false;
}
}
function handleClose() {
emit("update:visible", false);
}
function handleClosed() {
formRef.value?.resetFields();
contractOptions.value = [];
Object.assign(form, defaultForm());
}
</script>
<style lang="less" scoped>
.tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-left: 8px;
}
/* 流水回执上传 */
.receipt-block {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.receipt-file {
display: flex;
align-items: center;
gap: 8px;
.receipt-name {
margin-left: 4px;
max-width: 240px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.total-amount {
font-size: 16px;
font-weight: 700;
color: var(--el-color-primary);
}
.detail-table {
margin: 0 16px;
width: calc(100% - 32px);
}
/* 进度金额:输入框 + 重算按钮 */
.amount-cell {
display: flex;
align-items: center;
gap: 6px;
.amount-input {
flex: 1;
min-width: 0;
}
}
.col-tip {
margin-left: 4px;
vertical-align: -2px;
color: var(--el-text-color-secondary);
cursor: help;
}
.add-row {
margin: 10px 16px 0;
}
.sum-tip {
margin-left: 12px;
font-size: 13px;
color: var(--el-text-color-secondary);
}
.option-name {
float: left;
}
.option-sub {
float: right;
margin-left: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>
@@ -0,0 +1,260 @@
<template>
<el-drawer
:model-value="visible"
title="回款计划详情"
size="900px"
:destroy-on-close="true"
@update:model-value="(v) => emit('update:visible', v)"
@open="loadDetail"
>
<div v-loading="loading" class="payback-detail">
<div class="detail-head">
<div class="head-main">
<h3>{{ detail.contract_name || "-" }}</h3>
<!-- <span class="head-sub"></span> -->
</div>
<el-button type="primary" link :icon="Edit" @click="emit('edit', detail)">编辑</el-button>
<el-button type="danger" link :icon="Delete" @click="handleDelete" style="margin-left: 0px">删除</el-button>
</div>
<el-descriptions :column="2" border class="desc-block">
<el-descriptions-item label="客户名称">{{ detail.customer_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="合同编号">{{ detail.contract_no || "未关联合同编号" }}</el-descriptions-item>
<el-descriptions-item label="回款状态">
<el-tag :type="paybackStatusTag(detail.status)" size="small">
{{ paybackStatusText(detail.status) }}
</el-tag></el-descriptions-item>
<el-descriptions-item label="回款周期">{{ paybackCycleText(detail.plan_type) }}</el-descriptions-item>
<el-descriptions-item label="回款方式">{{ paybackMethodText(detail.pay_method) }}</el-descriptions-item>
<el-descriptions-item label="合同总金额">¥{{ formatMoney(detail.contract_amount) }}</el-descriptions-item>
<el-descriptions-item label="计划回款总额">
<span class="primary-text">¥{{ formatMoney(detail.total_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="已回款金额">
<span class="success-text">¥{{ formatMoney(detail.received_amount) }}</span>
</el-descriptions-item>
<el-descriptions-item label="流水回执">
<a v-if="detail.receipt_url" :href="detail.receipt_url" target="_blank" class="primary-text">{{ detail.receipt_name || "查看回执" }}</a>
<span v-else>-</span>
</el-descriptions-item>
<el-descriptions-item label="提前提醒">
{{ detail.remind_days ? `${detail.remind_days} 天` : "-" }}
</el-descriptions-item>
<el-descriptions-item label="负责人">{{ detail.owner_user_name || "-" }}</el-descriptions-item>
<el-descriptions-item label="备注" :span="2">{{ detail.remark || "-" }}</el-descriptions-item>
</el-descriptions>
<div class="detail-section-title">
<span>回款明细</span>
<span class="section-tip">
共 {{ items.length }} 期,已完成 {{ doneCount }} 期
</span>
</div>
<el-table :data="items" border size="small">
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="paybackItemStatusTag(row.status)" size="small">
{{ paybackItemStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="name" label="明细名称" min-width="130" show-overflow-tooltip />
<el-table-column label="计划日期" width="110" align="center">
<template #default="{ row }">{{ row.plan_date || "-" }}</template>
</el-table-column>
<el-table-column label="回款进度" min-width="190">
<template #default="{ row }">
<div class="progress-cell">
<div class="progress-top">
<span class="success-text">¥{{ formatMoney(row.received_amount) }}</span>
<span class="progress-sep">/</span>
<span>¥{{ formatMoney(row.amount) }}</span>
</div>
<el-progress
:percentage="paybackItemPercent(row)"
:stroke-width="10"
:color="paybackItemPercent(row) >= 100 ? 'var(--el-color-success)' : 'var(--el-color-primary)'"
:show-text="true"
/>
</div>
</template>
</el-table-column>
<el-table-column label="回款日期" width="110" align="center">
<template #default="{ row }">{{ row.received_date || "-" }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="paybackItemOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(paybackItemOverdue(row).days) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<template #empty><el-empty description="暂无回款明细" :image-size="60" /></template>
</el-table>
</div>
</el-drawer>
</template>
<script setup>
import { ref, computed } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete } from "@element-plus/icons-vue";
import { getPaybackDetail, deletePayback } from "@/api/crmPayback";
import {
paybackCycleText,
paybackMethodText,
paybackStatusText,
paybackStatusTag,
paybackItemStatusText,
paybackItemStatusTag,
paybackPlanOverdue,
paybackItemOverdue,
overdueText,
formatMoney,
} from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
payback: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "edit", "delete"]);
const loading = ref(false);
const detail = ref({});
const items = computed(() => (Array.isArray(detail.value.items) ? detail.value.items : []));
const doneCount = computed(() => items.value.filter((i) => Number(i.status) === 3).length);
/** 回款明细进度百分比(已回款/计划金额,封顶 100 用于进度条显示) */
function paybackItemPercent(row) {
const amount = Number(row.amount) || 0;
const received = Number(row.received_amount) || 0;
if (amount <= 0) return 0;
const pct = Math.round((received / amount) * 100);
return pct > 100 ? 100 : pct;
}
/** 兼容 items 为 JSON 字符串 / 数组两种形态 */
function normalize(row) {
let list = row.items;
if (typeof list === "string" && list.trim()) {
try {
list = JSON.parse(list);
} catch {
list = [];
}
}
return { ...row, items: Array.isArray(list) ? list : [] };
}
async function loadDetail() {
// 先用列表行数据回显,再拉取详情补充完整明细
detail.value = normalize(props.payback || {});
const id = props.payback?.id;
if (!id) return;
loading.value = true;
try {
const res = await getPaybackDetail(id);
detail.value = normalize(res?.data || {});
} catch (e) {
// 详情拉取失败时保留列表行数据
} finally {
loading.value = false;
}
}
async function handleDelete() {
try {
await ElMessageBox.confirm(
`确定删除合同「${detail.value.contract_name}」的回款计划吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deletePayback(detail.value.id);
ElMessage.success("删除成功");
emit("update:visible", false);
emit("delete", detail.value);
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
</script>
<style lang="less" scoped>
.payback-detail {
.detail-head {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 16px;
.head-main {
flex: 1;
display: flex;
flex-direction: column;
h3 {
margin: 0;
font-size: 16px;
}
.head-sub {
font-size: 12px;
color: var(--el-text-color-secondary);
}
}
}
.desc-block {
margin-bottom: 18px;
}
.detail-section-title {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 14px;
font-weight: 600;
margin-bottom: 10px;
.section-tip {
font-size: 12px;
font-weight: 400;
color: var(--el-text-color-secondary);
}
}
.primary-text {
color: var(--el-color-primary);
font-weight: 600;
}
.success-text {
color: var(--el-color-success);
font-weight: 600;
}
.progress-cell {
display: flex;
flex-direction: column;
gap: 4px;
.progress-top {
display: flex;
align-items: baseline;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
.progress-sep {
color: var(--el-text-color-placeholder);
}
}
}
}
</style>
+659 -2
View File
@@ -1,5 +1,662 @@
<template>
<div />
<div class="crm-page">
<div class="page-header">
<div>
<h2>回款管理</h2>
<p>针对于合同的回款计划创建与回款进度跟踪</p>
</div>
<div class="header-actions">
<el-button :icon="Refresh" @click="refreshAll">刷新</el-button>
<el-button type="primary" :icon="Plus" @click="openCreate">新建回款计划</el-button>
</div>
</div>
<el-tabs v-model="activeTab" class="payback-tabs" @tab-change="handleTabChange">
<el-tab-pane label="回款计划" name="plan" />
<el-tab-pane label="回款进度" name="progress" />
</el-tabs>
<!-- ============================ 回款计划 ============================ -->
<template v-if="activeTab === 'plan'">
<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(summary.total_amount) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">已回款金额</span>
<span class="stat-value success">¥{{ formatMoney(summary.received_amount) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">待回款金额</span>
<span class="stat-value warning">¥{{ formatMoney(summary.pending_amount) }}</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: 240px"
@keyup.enter="handleSearch"
/>
</el-form-item>
<el-form-item label="回款周期">
<el-select v-model="filters.plan_type" clearable placeholder="全部" style="width: 110px">
<el-option v-for="i in PAYBACK_CYCLE_OPTIONS" :key="i.value" :label="i.label" :value="i.value" />
</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 PAYBACK_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="paybackStatusTag(row.status)" size="small">{{ paybackStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip fixed>
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.contract_name || "-" }}</span>
</template>
</el-table-column>
<el-table-column label="客户名称" min-width="150" show-overflow-tooltip>
<template #default="{ row }">{{ row.customer_name || "-" }}</template>
</el-table-column>
<el-table-column label="回款周期" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ paybackCycleText(row.plan_type) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="回款方式" width="110" align="center">
<template #default="{ row }">{{ paybackMethodText(row.pay_method) }}</template>
</el-table-column>
<el-table-column label="合同总金额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.contract_amount) }}</template>
</el-table-column>
<el-table-column label="计划回款总额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.total_amount) }}</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
</el-table-column>
<el-table-column label="回款进度" width="150" align="center">
<template #default="{ row }">
<el-progress :percentage="paybackPercent(row)" :stroke-width="10" />
</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="90" align="center">
<template #default="{ row }">{{ row.remind_days ? `${row.remind_days}天` : "-" }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="paybackPlanOverdue(row).overdue" type="danger" size="small" effect="dark">{{ overdueText(paybackPlanOverdue(row).days) }}</el-tag>
<span v-else>-</span>
</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>
</template>
<!-- ============================ 回款进度 ============================ -->
<template v-else>
<div class="stat-cards">
<div class="stat-card">
<span class="stat-label">分期总数</span>
<span class="stat-value">{{ progressList.length }}</span>
</div>
<div class="stat-card">
<span class="stat-label">计划回款总额</span>
<span class="stat-value primary">¥{{ formatMoney(progressSummary.total_planned) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">已回款金额</span>
<span class="stat-value success">¥{{ formatMoney(progressSummary.total_received) }}</span>
</div>
<div class="stat-card">
<span class="stat-label">待回款金额</span>
<span class="stat-value warning">
¥{{ formatMoney((progressSummary.total_planned || 0) - (progressSummary.total_received || 0)) }}
</span>
</div>
</div>
<div class="filter-bar">
<el-form :inline="true" :model="progressFilters" @submit.prevent>
<el-form-item>
<el-input
v-model="progressFilters.keyword"
clearable
placeholder="搜索合同 / 客户 / 负责人"
:prefix-icon="Search"
style="width: 240px"
@keyup.enter="fetchProgress"
/>
</el-form-item>
<el-form-item label="回款状态">
<el-select v-model="progressFilters.status" clearable placeholder="全部" style="width: 120px">
<el-option v-for="i in PAYBACK_ITEM_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="fetchProgress">查询</el-button>
<el-button :icon="Refresh" @click="resetProgressFilters">重置</el-button>
</el-form-item>
</el-form>
</div>
<div class="table-container" v-loading="progressLoading">
<el-table :data="pagedProgress" stripe border row-key="rowKey">
<el-table-column label="回款周期" width="90" align="center" fixed>
<template #default="{ row }">{{ paybackCycleText(row.plan_type) }}</template>
</el-table-column>
<el-table-column label="明细" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.name || `第${row.seq}期` }}</template>
</el-table-column>
<el-table-column label="进度百分比" width="100" align="center">
<template #default="{ row }">{{ row.percent ? `${row.percent}%` : "-" }}</template>
</el-table-column>
<el-table-column label="计划金额" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="计划日期" width="110" align="center">
<template #default="{ row }">{{ row.plan_date || "-" }}</template>
</el-table-column>
<el-table-column label="已回款" width="120" align="right">
<template #default="{ row }">{{ formatMoney(row.received_amount) }}</template>
</el-table-column>
<el-table-column label="回款日期" width="110" align="center">
<template #default="{ row }">{{ (row.received_date && row.received_date !== "<nil>") ? row.received_date : "-" }}</template>
</el-table-column>
<el-table-column label="合同名称" min-width="180" show-overflow-tooltip >
<template #default="{ row }">{{ row.contract_name || "-" }}</template>
</el-table-column>
<el-table-column label="客户名称" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.customer_name || "-" }}</template>
</el-table-column>
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag v-if="paybackItemOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(paybackItemOverdue(row).days) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="paybackItemStatusTag(row.status)" size="small">
{{ paybackItemStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="流水回执" width="110" align="center">
<template #default="{ row }">
<el-link v-if="row.receipt_url" type="primary" :href="row.receipt_url" target="_blank" :underline="false">查看回执</el-link>
<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>
<el-table-column label="操作" width="100" align="center" class-name="op-col" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openReceive(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="progressPagination.page"
v-model:page-size="progressPagination.pageSize"
:total="progressList.length"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
/>
</div>
</div>
</template>
<!-- 新建 / 编辑回款计划 -->
<PaybackCreate
v-model:visible="createVisible"
:edit-data="currentRow"
@success="refreshAll"
/>
<!-- 回款计划详情 -->
<PaybackDetail
v-model:visible="detailVisible"
:payback="currentRow"
@edit="handleDetailEdit"
@delete="handleDetailDelete"
/>
<!-- 回款登记 -->
<el-dialog v-model="receiveVisible" title="回款登记" width="440px">
<el-form :model="receiveForm" label-width="90px">
<el-form-item label="合同名称">
<span>{{ receiveForm.contract_name || "-" }}</span>
</el-form-item>
<el-form-item label="明细">
<span>{{ receiveForm.name || `第${receiveForm.seq}期` }}</span>
</el-form-item>
<el-form-item label="计划金额">
<span class="amount">¥{{ formatMoney(receiveForm.amount) }}</span>
</el-form-item>
<el-form-item label="实收金额">
<el-input-number
v-model="receiveForm.received_amount"
:min="0"
:precision="2"
:controls="false"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="回款日期">
<el-date-picker
v-model="receiveForm.received_date"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择日期"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="流水回执">
<div class="receipt-block">
<el-upload
:show-file-list="false"
:http-request="handleReceiptUpload"
:disabled="uploading"
accept=".jpg,.jpeg,.png,.pdf,.doc,.docx,.xls,.xlsx"
>
<el-button :icon="Upload" :loading="uploading" size="small">上传流水回执</el-button>
</el-upload>
<div v-if="receiveForm.receipt_url" class="receipt-file">
<el-link type="primary" :href="receiveForm.receipt_url" target="_blank" :underline="false">
<el-icon><Document /></el-icon>
<span class="receipt-name">{{ receiveForm.receipt_name || "流水回执" }}</span>
</el-link>
<el-button link type="danger" size="small" :icon="Delete" @click="clearReceipt">移除</el-button>
</div>
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="receiveVisible = false">取消</el-button>
<el-button type="primary" :loading="receiveSaving" @click="submitReceive">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup></script>
<script setup>
import { ref, reactive, computed, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus, Search, Refresh, Upload, Document, Delete } from "@element-plus/icons-vue";
import {
getPaybackList,
deletePayback,
getPaybackProgress,
registerPaybackReceive,
} from "@/api/crmPayback";
import { uploadFile } from "@/api/file";
import PaybackCreate from "./components/create.vue";
import PaybackDetail from "./components/detail.vue";
import {
PAYBACK_CYCLE_OPTIONS,
PAYBACK_STATUS_OPTIONS,
PAYBACK_ITEM_STATUS_OPTIONS,
paybackCycleText,
paybackMethodText,
paybackStatusText,
paybackStatusTag,
paybackItemStatusText,
paybackItemStatusTag,
paybackPlanOverdue,
paybackItemOverdue,
overdueText,
formatMoney,
} from "../dict";
const activeTab = ref("plan");
/* ---------------------------- 回款计划 ---------------------------- */
const loading = ref(false);
const tableData = ref([]);
const createVisible = ref(false);
const detailVisible = ref(false);
const currentRow = ref(null);
const summary = ref({ total_amount: 0, received_amount: 0, pending_amount: 0 });
const filters = reactive({ keyword: "", plan_type: "", status: "" });
const pagination = reactive({ page: 1, pageSize: 20, total: 0 });
async function fetchList() {
loading.value = true;
try {
const res = await getPaybackList({
page: pagination.page,
pageSize: pagination.pageSize,
...filters,
});
tableData.value = res?.data?.list || [];
pagination.total = res?.data?.total || 0;
summary.value = res?.data?.summary || { total_amount: 0, received_amount: 0, pending_amount: 0 };
} 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.plan_type = "";
filters.status = "";
handleSearch();
}
/** 回款进度百分比(已回款 / 计划总额) */
function paybackPercent(row) {
const total = Number(row.total_amount) || 0;
if (!total) return 0;
const p = (Number(row.received_amount) / total) * 100;
return Math.min(100, Math.round(p));
}
/* ---------------------------- 回款进度 ---------------------------- */
const progressLoading = ref(false);
const progressList = ref([]);
const progressSummary = ref({ total_planned: 0, total_received: 0 });
const progressFilters = reactive({ keyword: "", status: "" });
const progressPagination = reactive({ page: 1, pageSize: 20 });
const pagedProgress = computed(() => {
const start = (progressPagination.page - 1) * progressPagination.pageSize;
return progressList.value.slice(start, start + progressPagination.pageSize).map((r) => ({
...r,
rowKey: `${r.payback_id}_${r.seq}`,
}));
});
async function fetchProgress() {
progressLoading.value = true;
try {
const res = await getPaybackProgress({ ...progressFilters });
progressList.value = (res?.data?.list || []).map((r) => ({ ...r, rowKey: `${r.payback_id}_${r.seq}` }));
progressSummary.value = {
total_planned: res?.data?.total_planned || 0,
total_received: res?.data?.total_received || 0,
};
progressPagination.page = 1;
} catch (e) {
progressList.value = [];
progressSummary.value = { total_planned: 0, total_received: 0 };
ElMessage.error(e.message || "查询失败");
} finally {
progressLoading.value = false;
}
}
function resetProgressFilters() {
progressFilters.keyword = "";
progressFilters.status = "";
fetchProgress();
}
/* ---------------------------- 回款登记 ---------------------------- */
const receiveVisible = ref(false);
const receiveSaving = ref(false);
const receiveForm = reactive({
payback_id: null,
seq: 0,
name: "",
contract_name: "",
amount: 0,
received_amount: 0,
received_date: "",
receipt_url: "",
receipt_name: "",
});
function openReceive(row) {
Object.assign(receiveForm, {
payback_id: row.payback_id,
seq: row.seq,
name: row.name,
contract_name: row.contract_name,
amount: row.amount,
received_amount: Number(row.received_amount) || 0,
received_date: (row.received_date && row.received_date !== "<nil>") ? row.received_date : "",
receipt_url: row.receipt_url || "",
receipt_name: row.receipt_name || "",
});
receiveVisible.value = true;
}
/** 上传流水回执(非必填):复用通用文件上传接口 */
const uploading = ref(false);
async function handleReceiptUpload(options) {
const file = options.file;
uploading.value = true;
try {
const fd = new FormData();
fd.append("file", file);
const res = await uploadFile(fd);
const data = res?.data || {};
receiveForm.receipt_url = data.url || "";
receiveForm.receipt_name = data.name || file.name;
ElMessage.success("上传成功");
} catch (e) {
ElMessage.error(e.message || "上传失败");
} finally {
uploading.value = false;
}
}
function clearReceipt() {
receiveForm.receipt_url = "";
receiveForm.receipt_name = "";
}
async function submitReceive() {
receiveSaving.value = true;
try {
await registerPaybackReceive(receiveForm.payback_id, {
seq: receiveForm.seq,
received_amount: Number(receiveForm.received_amount) || 0,
received_date: receiveForm.received_date || "",
receipt_url: receiveForm.receipt_url,
receipt_name: receiveForm.receipt_name,
});
ElMessage.success("回款登记成功");
receiveVisible.value = false;
fetchProgress();
} catch (e) {
ElMessage.error(e.message || "登记失败");
} finally {
receiveSaving.value = false;
}
}
/* ---------------------------- 通用操作 ---------------------------- */
function openCreate() {
currentRow.value = null;
createVisible.value = true;
}
function openEdit(row) {
currentRow.value = { ...row };
createVisible.value = true;
}
function openDetail(row) {
currentRow.value = { ...row };
detailVisible.value = true;
}
/** 详情内点击编辑:关闭详情并打开编辑弹窗 */
function handleDetailEdit(row) {
detailVisible.value = false;
openEdit(row);
}
/** 详情内点击删除:删除回款计划并刷新列表 */
async function handleDetailDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除合同「${row.contract_name}」的回款计划吗?删除后不可恢复。`,
"删除确认",
{ type: "warning" }
);
await deletePayback(row.id);
ElMessage.success("删除成功");
detailVisible.value = false;
refreshAll();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
function handleTabChange(name) {
if (name === "progress") fetchProgress();
else fetchList();
}
function refreshAll() {
if (activeTab.value === "progress") fetchProgress();
else fetchList();
}
onMounted(fetchList);
</script>
<style lang="less" scoped src="../styles/crm-page.less"></style>
<style lang="less" scoped>
.payback-tabs {
margin-bottom: 4px;
:deep(.el-tabs__header) {
margin-bottom: 12px;
}
}
.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);
}
&.warning {
color: var(--el-color-warning);
}
}
}
}
.amount {
font-weight: 700;
color: var(--el-color-primary);
}
/* 流水回执上传 */
.receipt-block {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
.receipt-file {
display: flex;
align-items: center;
gap: 8px;
.receipt-name {
margin-left: 4px;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
::deep(.el-table .op-col .cell) {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
gap: 4px;
}
</style>
@@ -19,6 +19,21 @@
<el-input v-model="form.product_no" placeholder="如:P-2026-001" maxlength="50" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="行类型" prop="line_type">
<el-select v-model="form.line_type" placeholder="请选择" style="width: 100%" @change="onLineTypeChange">
<el-option label="成品" value="product" />
<el-option label="软件开发" value="dev" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12" v-if="form.line_type === 'dev'">
<el-form-item label="统一人天单价" prop="dev_unit_price">
<el-input-number v-model="form.dev_unit_price" :min="0" :precision="2" :controls="false" style="width: 100%">
<template #prefix>¥</template>
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="产品分类" prop="category">
<el-select
@@ -45,21 +60,29 @@
<el-input v-model="form.spec" placeholder="如:型号 / 配置 / 功能模块" maxlength="255" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="24" v-if="form.line_type === 'dev'">
<el-form-item label="模块报价" prop="dev_tree">
<el-button type="primary" link @click="devVisible = true">
📁 编辑模块树({{ (form.dev_tree || []).length }} 项 · ¥{{ formatMoney(treeSellTotal(form.dev_tree, form.dev_unit_price)) }})
</el-button>
<div class="dev-tip">叶子模块只填「人天」,人天单价统一;父级自动汇总金额</div>
</el-form-item>
</el-col>
<el-col :span="8" v-if="form.line_type === 'product'">
<el-form-item label="销售单价" prop="price">
<el-input-number v-model="form.price" :min="0" :precision="2" :controls="false" style="width: 100%">
<template #prefix>¥</template>
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8" v-if="form.line_type === 'product'">
<el-form-item label="成本单价" prop="cost_price">
<el-input-number v-model="form.cost_price" :min="0" :precision="2" :controls="false" style="width: 100%">
<template #prefix>¥</template>
</el-input-number>
</el-form-item>
</el-col>
<el-col :span="8">
<el-col :span="8" v-if="form.line_type === 'product'">
<el-form-item label="税率(%)" prop="tax_rate">
<el-input-number v-model="form.tax_rate" :min="0" :max="100" :precision="2" :controls="false" style="width: 100%" />
</el-form-item>
@@ -78,6 +101,15 @@
</el-col>
</el-row>
</el-form>
<DevModuleTree
v-model="form.dev_tree"
:unit-price="form.dev_unit_price"
v-model:visible="devVisible"
@update:unit-price="(v) => (form.dev_unit_price = v)"
:title="form.product_name ? form.product_name + ' - 模块报价' : '软件开发模块报价'"
/>
<template #footer>
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">保存</el-button>
@@ -90,7 +122,9 @@ import { ref, reactive, watch } from "vue";
import { ElMessage } from "element-plus";
import { createProduct, updateProduct } from "@/api/crmProduct";
import { getProductCategoryList } from "@/api/crmProductCategory";
import { PRODUCT_STATUS_OPTIONS, PRODUCT_UNIT_OPTIONS } from "../../dict";
import { PRODUCT_STATUS_OPTIONS, PRODUCT_UNIT_OPTIONS, formatMoney } from "../../dict";
import { treeSellTotal } from "../../contract/components/utils";
import DevModuleTree from "../../contract/components/DevModuleTree.vue";
const props = defineProps({
visible: { type: Boolean, default: false },
@@ -103,6 +137,7 @@ const submitting = ref(false);
const isEdit = ref(false);
const internalId = ref(null);
const categoryOptions = ref([]);
const devVisible = ref(false);
const defaultForm = () => ({
product_name: "",
@@ -110,9 +145,12 @@ const defaultForm = () => ({
category: "",
unit: "",
spec: "",
line_type: "product",
price: 0,
cost_price: 0,
tax_rate: 0,
dev_unit_price: 0,
dev_tree: [],
status: 1,
remark: "",
});
@@ -123,6 +161,28 @@ const rules = {
product_name: [{ required: true, message: "请输入产品名称", trigger: "blur" }],
};
/** 切换行类型:软件开发确保有模块树容器 */
function onLineTypeChange() {
if (form.line_type === "dev") {
if (!Array.isArray(form.dev_tree)) form.dev_tree = [];
if (form.dev_unit_price == null) form.dev_unit_price = 0;
}
}
/** 安全解析产品管理返回的 dev_tree(可能为字符串或数组) */
function normalizeDevTree(v) {
if (Array.isArray(v)) return v;
if (typeof v === "string" && v) {
try {
const parsed = JSON.parse(v);
return Array.isArray(parsed) ? parsed : [];
} catch (e) {
return [];
}
}
return [];
}
watch(
() => props.visible,
(val) => {
@@ -133,6 +193,9 @@ watch(
Object.assign(form, defaultForm(), props.editData);
form.status = Number(props.editData.status);
if (isNaN(form.status)) form.status = 1;
form.line_type = props.editData.line_type === "dev" ? "dev" : "product";
form.dev_unit_price = Number(props.editData.dev_unit_price) || 0;
form.dev_tree = normalizeDevTree(props.editData.dev_tree);
} else {
isEdit.value = false;
internalId.value = null;
@@ -8,6 +8,18 @@
@opened="loadAll"
>
<div v-if="project" class="project-detail">
<div class="detail-toolbar">
<div class="toolbar-status">
<el-tag :type="projectStatusTag(project.status)" size="small">{{ projectStatusText(project.status) }}</el-tag>
<el-tag v-if="projectOverdue(project).overdue" type="danger" size="small" effect="dark">
{{ overdueText(projectOverdue(project).days) }}
</el-tag>
</div>
<div class="toolbar-actions">
<el-button type="primary" size="small" :icon="Edit" @click="handleEdit">编辑</el-button>
<el-button type="danger" size="small" :icon="Delete" @click="handleDelete">删除</el-button>
</div>
</div>
<el-tabs v-model="activeTab" tab-position="left" class="detail-tabs">
<!-- 基本信息 -->
<el-tab-pane label="基本信息" name="basic">
@@ -80,6 +92,9 @@
<script setup>
import { ref, reactive, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Edit, Delete } from "@element-plus/icons-vue";
import { deleteProject } from "@/api/crmPipeline";
import DetailBasic from "./detail_basic.vue";
import DetailContact from "./detail_contact.vue";
import DetailFollow from "./detail_follow.vue";
@@ -87,12 +102,13 @@ import DetailContract from "./detail_contract.vue";
import DetailAttach from "./detail_attach.vue";
import DetailDocs from "./detail_docs.vue";
import DetailLog from "./detail_log.vue";
import { projectStatusText, projectStatusTag, projectOverdue, overdueText } from "../../dict";
const props = defineProps({
visible: { type: Boolean, default: false },
project: { type: Object, default: null },
});
const emit = defineEmits(["update:visible", "refresh"]);
const emit = defineEmits(["update:visible", "refresh", "edit", "delete"]);
// 关联类型:3=项目
const RELATED_TYPE = 3;
@@ -125,6 +141,24 @@ function handleClose() {
emit("update:visible", false);
}
function handleEdit() {
emit("edit", props.project);
}
async function handleDelete() {
try {
await ElMessageBox.confirm(`确定删除项目「${props.project.project_name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
});
await deleteProject(props.project.id);
ElMessage.success("删除成功");
emit("update:visible", false);
emit("delete");
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
}
}
/** 抽屉打开后刷新各 Tab 数据 */
function loadAll() {
if (!props.project?.id) return;
@@ -145,6 +179,27 @@ defineExpose({ loadAll });
}
.project-detail {
.detail-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 4px 12px;
margin-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
.toolbar-status {
display: flex;
align-items: center;
gap: 10px;
}
.toolbar-actions {
display: flex;
align-items: center;
gap: 8px;
}
}
.detail-tabs {
// 横向 flex:导航固定宽 + 内容区占满剩余宽度
display: flex;
@@ -1,15 +1,73 @@
<template>
<div class="detail-attach">
<div class="tab-toolbar">
<el-upload :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
</el-upload>
<span v-if="tip" class="tip">{{ tip }}</span>
<div class="toolbar-left">
<el-upload :show-file-list="false" :http-request="handleUpload" :disabled="uploading">
<el-button type="primary" size="small" :icon="Upload" :loading="uploading">上传附件</el-button>
</el-upload>
<span v-if="tip" class="tip">{{ tip }}</span>
</div>
<el-radio-group v-model="viewMode" size="small" class="view-switch">
<el-radio-button value="grid">
<el-icon><Grid /></el-icon>
</el-radio-button>
<el-radio-button value="list">
<el-icon><Menu /></el-icon>
</el-radio-button>
</el-radio-group>
</div>
<el-table :data="list" v-loading="loading" stripe border size="small">
<!-- 图标模式 -->
<div v-if="viewMode === 'grid'" v-loading="loading" class="attach-grid">
<div
v-for="row in list"
:key="row.id"
class="attach-card"
@click="openPreview(row)"
>
<div class="attach-icon" :class="fileKind(row)">
<el-icon :size="32"><component :is="fileIcon(row)" /></el-icon>
</div>
<div class="attach-name" :title="row.file_name || '-'">
{{ row.file_name || "-" }}
</div>
<div class="attach-meta">
<span class="attach-size">{{ formatFileSize(row.file_size) }}</span>
<el-tag
v-if="showStage"
size="small"
effect="plain"
:type="pipelineStageTag(row.related_type)"
>
{{ pipelineStageText(row.related_type) }}
</el-tag>
</div>
<div class="attach-sub">
<span class="attach-user" :title="row.uploader_name">{{ row.uploader_name || "-" }}</span>
<span class="attach-time">{{ formatDateTime(row.create_time) }}</span>
</div>
<el-button
class="attach-del"
link
type="danger"
size="small"
:icon="Delete"
@click.stop="handleDelete(row)"
/>
</div>
<el-empty
v-if="!list.length && !loading"
description="暂无附件"
:image-size="60"
class="attach-empty"
/>
</div>
<!-- 列表模式 -->
<el-table v-else :data="list" v-loading="loading" stripe border size="small">
<el-table-column label="文件名" min-width="170" show-overflow-tooltip>
<template #default="{ row }">
<a :href="row.file_url" target="_blank" class="file-link">{{ row.file_name || "-" }}</a>
<span class="file-link" @click="openPreview(row)">{{ row.file_name || "-" }}</span>
</template>
</el-table-column>
<el-table-column v-if="showStage" label="阶段" min-width="62" align="center">
@@ -26,20 +84,120 @@
<el-table-column label="上传时间" min-width="140">
<template #default="{ row }">{{ formatDateTime(row.create_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="76" align="center">
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openPreview(row)">预览</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
<template #empty><el-empty description="暂无附件" :image-size="60" /></template>
</el-table>
<!-- 分页 -->
<div v-if="total > 0" class="attach-pager">
<el-pagination
v-model:current-page="page"
v-model:page-size="pageSize"
:total="total"
:page-sizes="[20, 40, 60, 100]"
layout="total, sizes, prev, pager, next"
background
size="small"
@current-change="handlePageChange"
@size-change="handleSizeChange"
/>
</div>
<!-- 在线预览 -->
<el-dialog
v-model="previewVisible"
:title="previewRow?.file_name || '附件预览'"
width="900px"
top="6vh"
append-to-body
destroy-on-close
class="attach-preview-dialog"
>
<div class="preview-body">
<div
v-if="previewType === 'image'"
class="preview-zoom"
:class="{ 'is-dragging': dragging }"
@wheel.prevent="onWheel"
@mousedown="onDragStart"
@mousemove="onDragMove"
@mouseup="onDragEnd"
@mouseleave="onDragEnd"
>
<img
:src="previewRow.file_url"
class="preview-zoom-img"
:style="imgStyle"
alt=""
draggable="false"
/>
<div class="preview-zoom-bar" @mousedown.stop @wheel.stop>
<el-button link size="small" :icon="ZoomOut" @click="stepZoom(-0.2)" />
<span class="zoom-text">{{ Math.round(zoom * 100) }}%</span>
<el-button link size="small" :icon="ZoomIn" @click="stepZoom(0.2)" />
<el-button link size="small" :icon="RefreshRight" @click="resetZoom" />
</div>
</div>
<video
v-else-if="previewType === 'video'"
:src="previewRow.file_url"
controls
class="preview-video"
></video>
<audio
v-else-if="previewType === 'audio'"
:src="previewRow.file_url"
controls
class="preview-audio"
></audio>
<iframe
v-else-if="previewType === 'pdf'"
:src="previewRow.file_url"
class="preview-frame"
></iframe>
<iframe v-else-if="previewType === 'office'" :src="officeUrl" class="preview-frame"></iframe>
<iframe
v-else-if="previewType === 'frame'"
:src="previewRow.file_url"
class="preview-frame"
></iframe>
<el-empty
v-else
description="该文件类型暂不支持在线预览,请下载后查看"
:image-size="80"
/>
</div>
<template #footer>
<el-button :icon="Download" @click="downloadFile(previewRow)">下载</el-button>
<el-button type="primary" @click="previewVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref } from "vue";
import { computed, ref } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Upload } from "@element-plus/icons-vue";
import {
Upload,
Grid,
Menu,
Delete,
Download,
ZoomIn,
ZoomOut,
RefreshRight,
Document,
Picture,
VideoCamera,
Headset,
FolderOpened,
} from "@element-plus/icons-vue";
import { getAttachList, addAttach, deleteAttach } from "@/api/crmPipeline";
import { uploadFile } from "@/api/file";
import { formatDateTime, pipelineStageText, pipelineStageTag } from "../../dict";
@@ -61,11 +219,82 @@ const emit = defineEmits(["count"]);
const loading = ref(false);
const uploading = ref(false);
const list = ref([]);
// 视图模式:grid=图标模式(默认,便于快速查找) / list=列表模式
const viewMode = ref("grid");
// 分页:默认每页 20 条
const page = ref(1);
const pageSize = ref(20);
const total = ref(0);
// 预览
const previewVisible = ref(false);
const previewRow = ref({});
// 图片缩放/拖拽
const zoom = ref(1);
const panX = ref(0);
const panY = ref(0);
const dragging = ref(false);
const dragStart = { x: 0, y: 0, px: 0, py: 0 };
const ZOOM_MIN = 0.2;
const ZOOM_MAX = 8;
const imgStyle = computed(() => ({
transform: `translate(${panX.value}px, ${panY.value}px) scale(${zoom.value})`,
}));
function clampPan() {
// 未放大时不允许拖动偏移
if (zoom.value <= 1) {
panX.value = 0;
panY.value = 0;
}
}
function setZoom(v) {
zoom.value = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, Number(v.toFixed(2))));
clampPan();
}
function stepZoom(delta) {
setZoom(zoom.value + delta);
}
function resetZoom() {
zoom.value = 1;
panX.value = 0;
panY.value = 0;
}
/** 滚轮缩放(向上放大、向下缩小) */
function onWheel(e) {
const factor = e.deltaY < 0 ? 1.12 : 0.89;
setZoom(zoom.value * factor);
}
function onDragStart(e) {
if (zoom.value <= 1) return;
dragging.value = true;
dragStart.x = e.clientX;
dragStart.y = e.clientY;
dragStart.px = panX.value;
dragStart.py = panY.value;
}
function onDragMove(e) {
if (!dragging.value) return;
panX.value = dragStart.px + (e.clientX - dragStart.x);
panY.value = dragStart.py + (e.clientY - dragStart.y);
}
function onDragEnd() {
dragging.value = false;
}
/** 供父组件(detail.vue)在抽屉打开时调用 */
async function reload() {
if (!props.relatedId) {
list.value = [];
total.value = 0;
emit("count", 0);
return;
}
@@ -74,16 +303,41 @@ async function reload() {
const params = {
related_type: props.relatedType,
related_id: props.relatedId,
page: page.value,
pageSize: pageSize.value,
};
if (props.includeSource) params.include_source = 1;
const res = await getAttachList(params);
list.value = res?.data?.list || [];
const data = res?.data || {};
const rawList = data.list || [];
total.value = Number(data.total) || rawList.length;
// 兜底:后端若未按分页返回(条数超过每页),前端本地切片,保证一页数量正确
if (rawList.length > pageSize.value) {
const start = (page.value - 1) * pageSize.value;
list.value = rawList.slice(start, start + pageSize.value);
} else {
list.value = rawList;
}
} catch (e) {
list.value = [];
total.value = 0;
} finally {
loading.value = false;
}
emit("count", list.value.length);
emit("count", total.value);
}
/** 切换页码 */
function handlePageChange(p) {
page.value = p;
reload();
}
/** 切换每页条数(回到第一页) */
function handleSizeChange(s) {
pageSize.value = s;
page.value = 1;
reload();
}
async function handleUpload(options) {
@@ -103,6 +357,7 @@ async function handleUpload(options) {
file_size: file.size || 0,
});
ElMessage.success("上传成功");
page.value = 1;
reload();
} catch (e) {
ElMessage.error(e.message || "上传失败");
@@ -116,6 +371,8 @@ async function handleDelete(row) {
await ElMessageBox.confirm(`确定删除附件「${row.file_name}」吗?`, "删除确认", { type: "warning" });
await deleteAttach({ id: row.id });
ElMessage.success("删除成功");
// 删除当前页最后一条时回退一页,避免出现空白页
if (list.value.length === 1 && page.value > 1) page.value -= 1;
reload();
} catch (e) {
if (e !== "cancel") ElMessage.error(e.message || "删除失败");
@@ -129,6 +386,101 @@ function formatFileSize(size) {
return `${(n / 1024 / 1024).toFixed(2)} MB`;
}
/** 取文件扩展名(小写,不含点) */
function extOf(name) {
const n = String(name || "");
const i = n.lastIndexOf(".");
return i > -1 ? n.slice(i + 1).toLowerCase() : "";
}
const IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico"];
const VIDEO_EXTS = ["mp4", "avi", "mov", "wmv", "flv", "mkv", "webm", "m4v"];
const AUDIO_EXTS = ["mp3", "wav", "flac", "aac", "ogg", "m4a", "wma"];
const ARCHIVE_EXTS = ["zip", "rar", "7z", "tar", "gz", "bz2"];
const OFFICE_EXTS = ["doc", "docx", "xls", "xlsx", "ppt", "pptx"];
const FRAME_EXTS = [
"txt",
"md",
"json",
"log",
"xml",
"html",
"htm",
"css",
"js",
"ts",
"csv",
"yml",
"yaml",
"ini",
"conf",
];
/** 按扩展名归类,用于图标配色 */
function fileKind(row) {
const ext = extOf(row.file_name || row.file_url);
if (IMAGE_EXTS.includes(ext)) return "is-image";
if (VIDEO_EXTS.includes(ext)) return "is-video";
if (AUDIO_EXTS.includes(ext)) return "is-audio";
if (ARCHIVE_EXTS.includes(ext)) return "is-archive";
if (ext === "pdf") return "is-pdf";
if (["doc", "docx"].includes(ext)) return "is-word";
if (["xls", "xlsx", "csv"].includes(ext)) return "is-excel";
if (["ppt", "pptx"].includes(ext)) return "is-ppt";
return "is-file";
}
/** 按扩展名返回文件图标组件 */
function fileIcon(row) {
switch (fileKind(row)) {
case "is-image":
return Picture;
case "is-video":
return VideoCamera;
case "is-audio":
return Headset;
case "is-archive":
return FolderOpened;
default:
return Document;
}
}
/** 预览类型:image / video / audio / pdf / office / frame(可 iframe 直出)/ none */
function previewTypeOf(row) {
const ext = extOf(row.file_name || row.file_url);
if (IMAGE_EXTS.includes(ext)) return "image";
if (VIDEO_EXTS.includes(ext)) return "video";
if (AUDIO_EXTS.includes(ext)) return "audio";
if (ext === "pdf") return "pdf";
if (OFFICE_EXTS.includes(ext)) return "office";
if (FRAME_EXTS.includes(ext)) return "frame";
return "none";
}
const previewType = computed(() =>
previewRow.value ? previewTypeOf(previewRow.value) : "none"
);
// Office 文档通过微软在线预览服务渲染(需要文件 URL 可被公网访问)
const officeUrl = computed(() => {
const url = previewRow.value?.file_url || "";
if (!url) return "";
return `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(url)}`;
});
/** 打开在线预览 */
function openPreview(row) {
previewRow.value = row || {};
resetZoom();
previewVisible.value = true;
}
/** 下载文件(新窗口打开原始地址) */
function downloadFile(row) {
if (row?.file_url) window.open(row.file_url, "_blank");
}
defineExpose({ reload });
</script>
@@ -136,21 +488,241 @@ defineExpose({ reload });
.tab-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 12px;
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.tip {
font-size: 12px;
color: var(--el-text-color-secondary);
}
.view-switch {
:deep(.el-radio-button__inner) {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
}
}
}
.file-link {
color: var(--el-color-primary);
text-decoration: none;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
/* 图标模式网格 */
.attach-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
gap: 12px;
min-height: 120px;
.attach-empty {
grid-column: 1 / -1;
}
}
.attach-card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 12px 12px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 8px;
background: var(--el-bg-color);
cursor: pointer;
transition: all 0.2s;
&:hover {
border-color: var(--el-color-primary-light-5);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.06);
transform: translateY(-1px);
.attach-del {
opacity: 1;
}
}
.attach-icon {
display: flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 10px;
color: #fff;
background: var(--el-text-color-placeholder);
&.is-image {
background: #10b981;
}
&.is-video {
background: #8b5cf6;
}
&.is-audio {
background: #14b8a6;
}
&.is-archive {
background: #f59e0b;
}
&.is-pdf {
background: #ef4444;
}
&.is-word {
background: #3b82f6;
}
&.is-excel {
background: #16a34a;
}
&.is-ppt {
background: #f97316;
}
}
.attach-name {
width: 100%;
font-size: 13px;
color: var(--el-text-color-primary);
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attach-meta {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.attach-sub {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
width: 100%;
font-size: 11px;
color: var(--el-text-color-placeholder);
.attach-user,
.attach-time {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.attach-del {
position: absolute;
top: 4px;
right: 4px;
opacity: 0;
transition: opacity 0.2s;
}
}
/* 分页 */
.attach-pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
/* 在线预览 */
.preview-body {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
max-height: 74vh;
overflow: auto;
.preview-zoom {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 72vh;
overflow: hidden;
background: var(--el-fill-color-lighter);
cursor: grab;
&.is-dragging {
cursor: grabbing;
}
.preview-zoom-img {
max-width: 100%;
max-height: 100%;
user-select: none;
transform-origin: center center;
transition: transform 0.06s linear;
will-change: transform;
}
.preview-zoom-bar {
position: absolute;
top: 8px;
right: 8px;
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
.zoom-text {
min-width: 42px;
font-size: 12px;
text-align: center;
}
:deep(.el-button) {
color: #fff;
margin-left: 0;
}
}
}
.preview-video {
max-width: 100%;
max-height: 72vh;
}
.preview-audio {
width: 100%;
}
.preview-frame {
width: 100%;
height: 72vh;
border: 0;
}
}
</style>
@@ -26,17 +26,17 @@
<div class="pane-head">
<div class="pane-title">
<span class="name">{{ c.contract_name }}</span>
<el-tag :type="contractStatusTag(c.status)" size="small">
<!-- <el-tag :type="contractStatusTag(c.status)" size="small">
{{ contractStatusText(c.status) }}
</el-tag>
</el-tag> -->
<el-tag type="primary" size="small" effect="plain">
{{ ourRoleText(c.our_role) }}
</el-tag>
</div>
<div class="pane-actions">
<!-- <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>
<div class="pane-sub">
{{ c.contract_no || "-" }} · {{ contractCategoryText(c.contract_category) }}
+28 -34
View File
@@ -37,7 +37,12 @@
<div class="table-container" v-loading="loading">
<el-table :data="tableData" stripe border row-key="id">
<el-table-column label="项目名称" min-width="180" show-overflow-tooltip fixed>
<el-table-column label="状态" width="100" align="center" fixed>
<template #default="{ row }">
<el-tag :type="projectStatusTag(row.status)" size="small">{{ projectStatusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="项目名称" min-width="180" show-overflow-tooltip >
<template #default="{ row }">
<span class="name-link" @click="openDetail(row)">{{ row.project_name }}</span>
</template>
@@ -47,9 +52,12 @@
<el-table-column label="项目金额" width="130" align="right">
<template #default="{ row }">{{ formatMoney(row.amount) }}</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<el-table-column label="逾期" width="100" align="center">
<template #default="{ row }">
<el-tag :type="projectStatusTag(row.status)" size="small">{{ projectStatusText(row.status) }}</el-tag>
<el-tag v-if="projectOverdue(row).overdue" type="danger" size="small" effect="dark">
{{ overdueText(projectOverdue(row).days) }}
</el-tag>
<span v-else>-</span>
</template>
</el-table-column>
<el-table-column label="开始日期" width="120" align="center">
@@ -64,21 +72,6 @@
<el-table-column label="对接人" width="100">
<template #default="{ row }">{{ row.contact_person || "-" }}</template>
</el-table-column>
<el-table-column label="操作" width="210" align="center" fixed="right">
<template #default="{ row }">
<!-- <el-button
link
type="primary"
size="small"
:disabled="!row.customer_id"
@click="openContactBook(row)"
>
通讯录
</el-button> -->
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<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>
@@ -105,15 +98,21 @@
:company-name="contactBookCompanyName"
/>
<ProjectDetail v-model:visible="detailVisible" :project="currentRow" @refresh="fetchList" />
<ProjectDetail
v-model:visible="detailVisible"
:project="currentRow"
@refresh="fetchList"
@edit="onDetailEdit"
@delete="fetchList"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { ElMessage } from "element-plus";
import { Plus, Search, Refresh } from "@element-plus/icons-vue";
import { getProjectList, deleteProject } from "@/api/crmPipeline";
import { getProjectList } from "@/api/crmPipeline";
import ProjectEdit from "./components/edit.vue";
import ProjectDetail from "./components/detail.vue";
import ContactBook from "../../erp/components/contactBook.vue";
@@ -121,6 +120,8 @@ import {
PROJECT_STATUS_OPTIONS,
projectStatusText,
projectStatusTag,
projectOverdue,
overdueText,
formatMoney,
formatDateOnly,
} from "../dict";
@@ -188,6 +189,12 @@ function openDetail(row) {
detailVisible.value = true;
}
/** 详情内触发编辑:关闭详情抽屉,打开编辑弹窗 */
function onDetailEdit(row) {
detailVisible.value = false;
openEdit(row || currentRow.value);
}
function openContactBook(row) {
if (!row.customer_id) {
ElMessage.warning("该项目未关联正式客户,暂无正式联系人");
@@ -197,19 +204,6 @@ function openContactBook(row) {
contactBookCompanyName.value = row.customer_name || "";
contactBookVisible.value = true;
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(`确定删除项目「${row.project_name}」吗?删除后不可恢复。`, "删除确认", {
type: "warning",
});
await deleteProject(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>
@@ -7,13 +7,13 @@
@update:model-value="handleClose"
@closed="handleClosed"
>
<!-- 步骤条 -->
<!-- 步骤条(支持点击步骤直接跳转) -->
<div class="steps-wrapper">
<el-steps :active="activeStep" finish-status="success" align-center>
<el-step title="基本信息" />
<el-step title="联系信息" />
<el-step title="开票信息" />
<el-step title="备注信息" />
<el-steps :active="activeStep" finish-status="success" align-center class="clickable-steps">
<el-step title="基本信息" class="step-clickable" @click="goStep(0)" />
<el-step title="联系信息" class="step-clickable" @click="goStep(1)" />
<el-step title="开票信息" class="step-clickable" @click="goStep(2)" />
<el-step title="备注信息" class="step-clickable" @click="goStep(3)" />
</el-steps>
</div>
@@ -382,6 +382,13 @@ function prevStep() {
}
}
/** 点击上方步骤条直接跳转(无需按上一步 / 下一步) */
function goStep(index) {
const target = Math.max(0, Math.min(3, Number(index) || 0));
if (target === activeStep.value) return;
activeStep.value = target;
}
async function handleSave(isDraft) {
if (!formRef.value) return;
if (isDraft) {
@@ -441,6 +448,15 @@ function handleSaveDraft() {
padding: 0 20px;
}
/* 步骤条可点击跳转 */
.clickable-steps {
:deep(.step-clickable),
:deep(.el-step__head),
:deep(.el-step__main) {
cursor: pointer;
}
}
.dialog-footer {
display: flex;
justify-content: space-between;