优化合同个管理和产品管理相关功能
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user