Files
erp-next/ruoyi-ui/src/views/bid/material/index.vue
王文昊 7b71822a32 feat: 完成履约管理模块全量功能迭代
本次迭代包含以下核心功能:
1. 新增履约时效总览可视化页面,支持多维度数据统计
2. 实现物料/客户/供应商的Excel批量导入导出功能
3. 新增订单批量结单功能,优化结单流程校验
4. 完善日志配置,新增文件日志落地
5. 修复分类查询逻辑,优化多租户数据隔离
6. 新增甲方履约结单管理页面与权限控制
7. 重构部分Mapper与Service接口,增强代码健壮性
2026-06-18 11:10:36 +08:00

678 lines
27 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="app-container">
<!-- 标签页 -->
<el-tabs v-model="activeTab">
<!-- Tab 1: 物料列表左右布局 -->
<el-tab-pane label="物料列表" name="list">
<div class="material-layout">
<!-- 左侧分类树 -->
<div class="category-tree-panel">
<div class="tree-header">
<span class="tree-title">物料分类</span>
<el-input
v-model="treeFilter"
prefix-icon="el-icon-search"
size="mini"
placeholder="搜索分类"
clearable
style="width:140px" />
</div>
<el-tree
ref="categoryTree"
:data="categoryTreeData"
:props="{ label: 'categoryName', children: 'children' }"
node-key="categoryId"
:filter-node-method="filterNode"
:expand-on-click-node="false"
:default-expand-all="true"
highlight-current
@node-click="handleCategoryClick"
class="category-tree" />
<div class="tree-footer">
<el-button size="mini" type="text" icon="el-icon-setting" @click="activeTab='category'">管理分类</el-button>
</div>
</div>
<!-- 右侧物料表格 -->
<div class="material-table-panel">
<el-form :model="queryParams" ref="queryForm" size="small" :inline="true">
<el-form-item label="物料名称" prop="materialName">
<el-input v-model="queryParams.materialName" placeholder="请输入物料名称" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="物料编码" prop="materialCode">
<el-input v-model="queryParams.materialCode" placeholder="请输入物料编码" clearable @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="厂家/品牌" prop="brand">
<el-select v-model="queryParams.brand" placeholder="全部品牌" clearable style="width:160px" filterable>
<el-option v-for="b in brandList" :key="b" :label="b" :value="b" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAdd">新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="danger" plain icon="el-icon-delete" size="mini" :disabled="multiple" @click="handleDelete">删除</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="info" plain icon="el-icon-upload2" size="mini" @click="handleImport" v-hasPermi="['bid:material:import']">导入</el-button>
</el-col>
<el-col :span="1.5">
<el-button type="warning" plain icon="el-icon-download" size="mini" @click="handleExport" v-hasPermi="['bid:material:export']">导出</el-button>
</el-col>
<el-col :span="1.5" v-if="currentCategoryName">
<el-tag size="medium" closable @close="clearCategoryFilter" type="warning">
当前分类: {{ currentCategoryName }}
</el-tag>
</el-col>
</el-row>
<el-table
v-loading="loading"
:data="materialList"
@selection-change="handleSelectionChange"
border stripe style="width:100%"
:header-cell-style="{ background: '#ffffff', color: '#666', fontWeight: 500, fontSize: '12px' }"
:cell-style="{ fontSize: '12px', color: '#333' }"
size="small">
<el-table-column type="selection" width="44" align="center" />
<el-table-column label="物料编码" prop="materialCode" width="110" header-align="center" align="center" />
<el-table-column label="物料名称" prop="materialName" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="分类" prop="categoryName" width="90" :show-overflow-tooltip="true" />
<el-table-column label="品牌" prop="brand" width="100" :show-overflow-tooltip="true" />
<el-table-column label="规格型号" prop="spec" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="材质" prop="material" width="70" :show-overflow-tooltip="true" />
<el-table-column label="用途" prop="purpose" min-width="80" :show-overflow-tooltip="true" />
<el-table-column label="状态" align="center" width="70">
<template slot-scope="scope">
<el-switch v-model="scope.row.status" active-value="0" inactive-value="1" @change="handleStatusChange(scope.row)" />
</template>
</el-table-column>
<el-table-column label="操作" align="center" width="160" fixed="right">
<template slot-scope="scope">
<el-button size="mini" type="text" @click="handleDetail(scope.row)">详情</el-button>
<el-button size="mini" type="text" @click="handleUpdate(scope.row)">修改</el-button>
<el-button size="mini" type="text" style="color:#f56c6c" @click="handleDelete(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && total === 0" description="暂无物料数据" />
<pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
</div>
</div>
</el-tab-pane>
<!-- Tab 2: 发货记录 -->
<el-tab-pane label="发货记录" name="records">
<el-select v-model="recordMaterialId" filterable placeholder="选择物料" style="width:400px" @change="loadRecords" clearable>
<el-option v-for="m in materialOptions" :key="m.materialId"
:label="m.materialCode + ' | ' + (m.materialName || '')" :value="m.materialId" />
</el-select>
<el-table v-loading="recordLoading" :data="recordList" border stripe size="small" style="width:100%;margin-top:12px">
<el-table-column label="发货单号" width="150">
<template slot-scope="s">{{ s.row.do_no || '-' }}</template>
</el-table-column>
<el-table-column label="类型" width="60" align="center">
<template slot-scope="s"><el-tag :type="s.row.type==='client'?'primary':'warning'" size="mini" effect="plain">{{ s.row.type==='client'?'甲方':'供应商' }}</el-tag></template>
</el-table-column>
<el-table-column label="供应商" width="140" show-overflow-tooltip>
<template slot-scope="s">{{ s.row.type==='client' ? '——' : (s.row.supplier_name || '-') }}</template>
</el-table-column>
<el-table-column label="甲方客户" width="140" show-overflow-tooltip>
<template slot-scope="s">{{ s.row.client_name || '-' }}</template>
</el-table-column>
<el-table-column label="数量" prop="quantity" width="75" align="right" />
<el-table-column label="单价" width="95" align="right"><template slot-scope="s">¥{{ s.row.unit_price || 0 }}</template></el-table-column>
<el-table-column label="小计" width="95" align="right"><template slot-scope="s">¥{{ s.row.total_price || 0 }}</template></el-table-column>
<el-table-column label="交货期" prop="delivery_date" width="90" align="center" />
<el-table-column label="结单" prop="actual_close_date" width="90" align="center" />
<el-table-column label="状态" width="80" align="center">
<template slot-scope="s">
<el-tag :type="recordStatusType(s.row.delivery_status)" size="small" effect="dark">{{ recordStatusLabel(s.row.delivery_status) }}</el-tag>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!recordMaterialId && !recordLoading" description="请先选择物料" />
<el-empty v-if="recordMaterialId && !recordList.length && !recordLoading" description="该物料暂无发货记录" />
</el-tab-pane>
<!-- Tab 3: 分类管理 -->
<el-tab-pane label="分类管理" name="category">
<div class="category-mgmt">
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button type="primary" plain icon="el-icon-plus" size="mini" @click="handleAddCategory(0)">新增根分类</el-button>
</el-col>
<el-col :span="1.5">
<el-button icon="el-icon-refresh" size="mini" @click="loadCategories">刷新</el-button>
</el-col>
</el-row>
<el-table
:data="categoryTreeData"
row-key="categoryId"
border
size="small"
:tree-props="{ children: 'children' }"
default-expand-all
style="width:100%">
<el-table-column label="分类名称" prop="categoryName" min-width="200" />
<el-table-column label="排序" prop="sort" width="80" align="center" />
<el-table-column label="状态" width="80" align="center">
<template slot-scope="scope">
<el-tag :type="scope.row.status === '0' ? 'success' : 'danger'" size="mini">
{{ scope.row.status === '0' ? '正常' : '停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="240" align="center">
<template slot-scope="scope">
<el-button size="mini" type="text" icon="el-icon-plus" @click="handleAddCategory(scope.row.categoryId)">添加子级</el-button>
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleEditCategory(scope.row)">编辑</el-button>
<el-button size="mini" type="text" style="color:#f56c6c" icon="el-icon-delete" @click="handleDeleteCategory(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-tab-pane>
</el-tabs>
<!-- 新增/修改物料对话框 -->
<el-dialog :title="title" :visible.sync="open" width="760px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="90px">
<el-row>
<el-col :span="12">
<el-form-item label="物料编码" prop="materialCode">
<el-input v-model="form.materialCode" placeholder="请输入物料编码" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="所属分类">
<el-cascader
v-model="form.categoryId"
:options="categoryTreeData"
:props="{ label: 'categoryName', value: 'categoryId', children: 'children', checkStrictly: true, emitPath: false }"
placeholder="请选择分类"
clearable
filterable
style="width:100%" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="物料名称" prop="materialName">
<el-input v-model="form.materialName" placeholder="请输入物料名称" />
</el-form-item>
<el-row>
<el-col :span="8">
<el-form-item label="厂家/品牌" prop="brand">
<el-input v-model="form.brand" placeholder="如:汇川" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="规格型号" prop="spec">
<el-input v-model="form.spec" placeholder="如MD500T37G" />
</el-form-item>
</el-col>
<el-col :span="4">
<el-form-item label="单位" prop="unit">
<el-input v-model="form.unit" placeholder="台/件" />
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="材质" prop="material">
<el-input v-model="form.material" placeholder="如:铜/铝合金/PVC" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="用途" prop="purpose">
<el-input v-model="form.purpose" placeholder="如:通用负载" />
</el-form-item>
</el-col>
</el-row>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" placeholder="备注信息" />
</el-form-item>
<el-form-item label="性能参数">
<div class="perf-params-form">
<el-button type="success" size="mini" icon="el-icon-plus" @click="addPerfRow">添加参数</el-button>
<el-table :data="perfParams" border size="small" style="margin-top:8px" max-height="240">
<el-table-column label="参数名" width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.name" placeholder="如: 功率" size="small" />
</template>
</el-table-column>
<el-table-column label="参数值" width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.value" placeholder="如: 37" size="small" />
</template>
</el-table-column>
<el-table-column label="单位" width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.unit" placeholder="如: kW" size="small" />
</template>
</el-table-column>
<el-table-column label="操作" width="80" align="center">
<template slot-scope="scope">
<el-button type="text" size="small" style="color:#f56c6c" icon="el-icon-delete" @click="removePerfRow(scope.$index)" />
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="form.description" type="textarea" rows="2" />
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="cancel">取消</el-button>
<el-button type="primary" @click="submitForm">确定</el-button>
</div>
</el-dialog>
<!-- 新增/修改分类对话框 -->
<el-dialog :title="categoryTitle" :visible.sync="categoryOpen" width="500px" append-to-body>
<el-form ref="categoryForm" :model="categoryForm" :rules="categoryRules" label-width="90px">
<el-form-item label="上级分类">
<el-cascader
v-model="categoryForm.parentId"
:options="categoryTreeData"
:props="{ label: 'categoryName', value: 'categoryId', children: 'children', checkStrictly: true, emitPath: false }"
placeholder="不选则为根分类"
clearable
filterable
style="width:100%" />
</el-form-item>
<el-form-item label="分类名称" prop="categoryName">
<el-input v-model="categoryForm.categoryName" placeholder="请输入分类名称" />
</el-form-item>
<el-form-item label="排序" prop="sort">
<el-input-number v-model="categoryForm.sort" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="categoryForm.status">
<el-radio label="0">正常</el-radio>
<el-radio label="1">停用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="categoryOpen = false">取消</el-button>
<el-button type="primary" @click="submitCategoryForm">确定</el-button>
</div>
</el-dialog>
<!-- Excel 导入对话框 -->
<excel-import-dialog
ref="importRef"
title="物料导入"
action="/bid/material/importData"
template-action="/bid/material/importTemplate"
template-file-name="material_template"
update-support-label="是否更新已经存在的物料数据"
@success="getList" />
</div>
</template>
<script>
import { listMaterial, getMaterial, addMaterial, updateMaterial, delMaterial, listManufacturer } from "@/api/bid/material";
import { getCategoryList, addCategory, updateCategory, delCategory } from "@/api/bid/category";
import request from '@/utils/request'
import ExcelImportDialog from "@/components/ExcelImportDialog"
export default {
name: "Material",
components: { ExcelImportDialog },
data() {
return {
loading: false, multiple: true, total: 0, materialList: [],
open: false, title: "",
queryParams: { pageNum: 1, pageSize: 10, materialName: null, materialCode: null, categoryId: null, brand: null, spec: null },
form: {},
flatCategories: [],
categoryTreeData: [],
brandList: [],
perfParams: [],
// 分类树筛选
treeFilter: "",
currentCategoryName: "",
// 发货记录
activeTab: "list",
recordMaterialId: null,
recordLoading: false,
recordList: [],
materialOptions: [],
// 分类管理
categoryOpen: false,
categoryTitle: "",
categoryForm: {},
categoryRules: {
categoryName: [{ required: true, message: "分类名称不能为空", trigger: "blur" }]
},
rules: {
materialCode: [{ required: true, message: "物料编码不能为空", trigger: "blur" }],
materialName: [{ required: true, message: "物料名称不能为空", trigger: "blur" }],
}
};
},
watch: {
treeFilter(val) {
this.$refs.categoryTree.filter(val);
}
},
created() {
this.getList();
this.loadCategories();
this.loadBrands();
this.loadMaterialOptions();
},
methods: {
loadCategories() {
getCategoryList().then(res => {
this.categoryTreeData = res.data || [];
this.flatCategories = this.flattenTree(this.categoryTreeData, 0, "");
});
},
loadBrands() {
listManufacturer().then(res => { this.brandList = res.data || []; });
},
flattenTree(nodes, depth, prefix) {
let result = [];
for (const n of nodes) {
result.push({ ...n, indentName: prefix + n.categoryName });
if (n.children && n.children.length) {
result = result.concat(this.flattenTree(n.children, depth + 1, prefix + " "));
}
}
return result;
},
filterNode(value, data) {
if (!value) return true;
return data.categoryName.indexOf(value) !== -1;
},
handleCategoryClick(data) {
this.queryParams.categoryId = data.categoryId;
this.currentCategoryName = data.categoryName;
this.queryParams.pageNum = 1;
this.getList();
},
clearCategoryFilter() {
this.queryParams.categoryId = null;
this.currentCategoryName = "";
this.handleQuery();
},
getList() {
this.loading = true;
listMaterial(this.queryParams).then(res => {
this.materialList = res.rows || [];
this.total = res.total || 0;
this.loading = false;
}).catch(() => {
this.materialList = [];
this.total = 0;
this.loading = false;
});
},
handleQuery() { this.queryParams.pageNum = 1; this.getList(); },
resetQuery() { this.resetForm("queryForm"); this.clearCategoryFilter(); },
handleSelectionChange(sel) { this.multiple = !sel.length; this.ids = sel.map(s => s.materialId); },
handleAdd() {
this.reset();
this.perfParams = [];
// 自动带入当前选中的分类
if (this.queryParams.categoryId) {
this.form.categoryId = this.queryParams.categoryId;
}
this.open = true;
this.title = "新增物料";
},
handleUpdate(row) {
this.reset();
getMaterial(row.materialId).then(res => {
this.form = res.data;
this.perfParams = this.parsePerfParamsToArray(this.form.performanceParams);
this.open = true;
this.title = "修改物料";
});
},
handleDetail(row) {
this.$router.push({ path: '/bid/material/detail', query: { id: row.materialId } });
},
handleDelete(row) {
const ids = row.materialId || (this.ids || []).join(",");
this.$modal.confirm("确认删除?").then(() => delMaterial(ids)).then(() => { this.getList(); this.$modal.msgSuccess("删除成功"); });
},
// ═══ Excel 导入导出 ═══
handleImport() {
this.$refs.importRef.open();
},
handleExport() {
this.download('/bid/material/export', { ...this.queryParams }, `material_${new Date().getTime()}.xlsx`);
},
handleStatusChange(row) { updateMaterial(row); },
// 性能参数
addPerfRow() {
this.perfParams.push({ name: '', value: '', unit: '' });
},
removePerfRow(index) {
this.perfParams.splice(index, 1);
},
parsePerfParamsToArray(jsonStr) {
if (!jsonStr) return [];
try {
const arr = JSON.parse(jsonStr);
if (Array.isArray(arr)) return arr;
return Object.keys(arr).map(k => ({ name: k, value: arr[k], unit: '' }));
} catch { return []; }
},
reset() {
this.form = { status: "0" };
this.perfParams = [];
this.resetForm && this.resetForm("form");
},
cancel() { this.open = false; this.reset(); },
submitForm() {
this.$refs["form"].validate(valid => {
if (!valid) return;
if (this.perfParams.length) {
this.form.performanceParams = JSON.stringify(this.perfParams);
} else {
this.form.performanceParams = null;
}
const action = this.form.materialId ? updateMaterial : addMaterial;
action(this.form).then(() => { this.$modal.msgSuccess("操作成功"); this.open = false; this.getList(); });
});
},
// ═══════════ 分类管理 ═══════════
handleAddCategory(parentId) {
this.categoryForm = { parentId: parentId || 0, sort: 0, status: "0" };
this.categoryTitle = "新增分类";
this.categoryOpen = true;
},
handleEditCategory(row) {
this.categoryForm = { ...row };
this.categoryTitle = "编辑分类";
this.categoryOpen = true;
},
handleDeleteCategory(row) {
this.$modal.confirm("确认删除分类「" + row.categoryName + "」?").then(() => {
return delCategory(row.categoryId);
}).then(() => {
this.$modal.msgSuccess("删除成功");
this.loadCategories();
}).catch(err => {
if (err && err.message) this.$modal.msgError(err.message);
});
},
submitCategoryForm() {
this.$refs["categoryForm"].validate(valid => {
if (!valid) return;
const action = this.categoryForm.categoryId ? updateCategory : addCategory;
action(this.categoryForm).then(() => {
this.$modal.msgSuccess("操作成功");
this.categoryOpen = false;
this.loadCategories();
});
});
},
// ═══════════ 发货记录 ═══════════
loadMaterialOptions() {
listMaterial({ pageSize: 999 }).then(r => { this.materialOptions = r.rows || [] }).catch(() => {})
},
loadRecords(materialId) {
if (!materialId) { this.recordList = []; return }
this.recordLoading = true
request({ url: '/bid/delivery/materialRecords/' + materialId, method: 'get' })
.then(r => { this.recordList = (r.data || []).map(d => ({
...d, deliveryDate: d.deliveryDate ? d.deliveryDate.substring(0, 10) : '',
actualCloseDate: d.actualCloseDate ? d.actualCloseDate.substring(0, 10) : ''
})); this.recordLoading = false })
.catch(() => { this.recordLoading = false })
},
recordStatusType(s) { return { pending: "warning", transit: "primary", history: "success" }[s] || "" },
recordStatusLabel(s) { return { pending: "待发", transit: "在途", history: "已收货" }[s] || s || "-" },
}
};
</script>
<style scoped>
.app-container {
background: var(--bg-page);
padding: 16px 20px;
border-radius: var(--radius-base);
}
/* ═══ 左右布局 ═══ */
.material-layout {
display: flex;
gap: 12px;
align-items: flex-start;
}
.category-tree-panel {
width: 200px;
flex-shrink: 0;
background: #fff;
border: 1px solid #e5e5e5;
border-radius: 4px;
display: flex;
flex-direction: column;
max-height: calc(100vh - 220px);
position: sticky;
top: 0;
}
.tree-header {
padding: 10px 12px;
border-bottom: 1px solid #f0f0f0;
display: flex;
align-items: center;
justify-content: space-between;
}
.tree-title {
font-size: 13px;
font-weight: 500;
color: #333;
}
.category-tree {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.category-tree >>> .el-tree-node__content {
height: 32px;
font-size: 13px;
}
.category-tree >>> .el-tree-node.is-current > .el-tree-node__content {
background: #fef0f0;
color: #e4393c;
}
.tree-footer {
padding: 8px 12px;
border-top: 1px solid #f0f0f0;
text-align: center;
}
.material-table-panel {
flex: 1;
min-width: 0;
overflow-x: hidden;
}
/* 紧凑表格行 */
.el-table td { padding: 4px 4px !important; }
.el-table th { padding: 6px 4px !important; }
/* 圆角按钮 */
.el-button--mini { border-radius: var(--radius-base) !important; }
/* 搜索表单样式 */
.el-form--inline .el-form-item {
margin-bottom: 16px;
}
.el-form--inline .el-form-item:last-child {
margin-bottom: 0;
}
/* 操作按钮区域间距 */
.mb8 {
margin-bottom: 12px;
}
/* 表格行高 */
>>> .el-table .el-table__row {
height: 42px;
}
/* 表头字体加粗 */
>>> .el-table thead th {
font-weight: 700;
}
/* 开关居中 */
>>> .el-table .el-switch {
margin: 0 auto;
}
/* 对话框内表格边距 */
.perf-params-form {
margin-top: 4px;
}
/* 搜索框宽度统 */
.el-input--small {
width: 160px;
}
.el-form-item:last-child .el-input--small {
width: auto;
}
/* 按钮组间距 */
.el-button + .el-button {
margin-left: 8px;
}
/* 分类管理 */
.category-mgmt {
background: #fff;
padding: 16px;
border: 1px solid #e5e5e5;
border-radius: 4px;
}
</style>