feat(bid): 完成物料管理模块全功能开发
1. 新增物料详情页路由、菜单与接口,支持查看物料报价与信息 2. 重构物料列表页面,新增品牌筛选、表格样式优化与详情跳转 3. 扩展物料实体与数据库字段,新增材质、用途、性能参数等字段 4. 新增供应商/甲方报价查询、批量对比、同名称物料匹配接口 5. 新增物料详情组件,包含基础信息、供应商报价、甲方报价标签页 6. 修复比价路由跳转路径错误,调整数据库密码配置 7. 新增物料相关SQL脚本与初始化数据
This commit is contained in:
@@ -1,7 +1,25 @@
|
||||
import request from '@/utils/request'
|
||||
const baseUrl = '/bid/material'
|
||||
|
||||
export const listMaterial = (params) => request({ url: baseUrl + '/list', method: 'get', params })
|
||||
export const getMaterial = (id) => request({ url: baseUrl + '/' + id, method: 'get' })
|
||||
export const addMaterial = (data) => request({ url: baseUrl, method: 'post', data })
|
||||
export const updateMaterial = (data) => request({ url: baseUrl, method: 'put', data })
|
||||
export const delMaterial = (ids) => request({ url: baseUrl + '/' + ids, method: 'delete' })
|
||||
|
||||
// 物料详情页
|
||||
export const getSupplierQuotes = (id) => request({ url: baseUrl + '/' + id + '/supplier-quotes', method: 'get' })
|
||||
export const getClientQuotes = (id) => request({ url: baseUrl + '/' + id + '/client-quotes', method: 'get' })
|
||||
export const listManufacturer = () => request({ url: baseUrl + '/manufacturer/list', method: 'get' })
|
||||
export const compareMaterials = (data) => request({ url: baseUrl + '/compare', method: 'post', data })
|
||||
|
||||
// 同类型物料横向对比
|
||||
export const getSelectableMaterials = () => request({ url: baseUrl + '/selectable-for-comparison', method: 'get' })
|
||||
export const getQuoteComparison = (data) => request({ url: baseUrl + '/quote-comparison', method: 'post', data })
|
||||
|
||||
// 同名称不同规格/品牌对比
|
||||
export const getSameNameMaterials = (materialName, excludeId) => request({
|
||||
url: baseUrl + '/same-name/' + encodeURIComponent(materialName),
|
||||
method: 'get',
|
||||
params: excludeId ? { excludeId } : undefined
|
||||
})
|
||||
|
||||
@@ -176,3 +176,28 @@ aside {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
表格展示优化 - 表头略大于数据,字体优化
|
||||
========================================== */
|
||||
.el-table thead th {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.el-table__body td {
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.el-table__body .el-table__row {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.el-table--border th,
|
||||
.el-table--border td {
|
||||
padding: 6px 8px;
|
||||
}
|
||||
|
||||
@@ -98,6 +98,20 @@ export const constantRoutes = [
|
||||
|
||||
// 动态路由,基于用户权限动态去加载
|
||||
export const dynamicRoutes = [
|
||||
{
|
||||
path: '/bid/material/detail',
|
||||
component: Layout,
|
||||
hidden: true,
|
||||
permissions: ['bid:material:detail'],
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
component: () => import('@/views/bid/material/detail'),
|
||||
name: 'MaterialDetail',
|
||||
meta: { title: '物料详情', activeMenu: '/bid/material' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/system/user-auth',
|
||||
component: Layout,
|
||||
|
||||
@@ -74,7 +74,7 @@ export default {
|
||||
},
|
||||
resetQuery() { this.queryParams = { pageNum: 1, pageSize: 10, rfqNo: null, rfqTitle: null }; this.getList(); },
|
||||
goCompare(row) {
|
||||
this.$router.push({ path: "/procurement/comparison/detail", query: { rfqId: row.rfqId } });
|
||||
this.$router.push({ path: "/bid/comparison/detail", query: { rfqId: row.rfqId } });
|
||||
},
|
||||
goRfqDetail(row) {
|
||||
this.$router.push({ path: "/procurement/rfq/detail", query: { rfqId: row.rfqId } });
|
||||
|
||||
330
ruoyi-ui/src/views/bid/material/components/BasicInfoTab.vue
Normal file
330
ruoyi-ui/src/views/bid/material/components/BasicInfoTab.vue
Normal file
@@ -0,0 +1,330 @@
|
||||
<template>
|
||||
<div class="basic-info-tab">
|
||||
<!-- 操作栏 -->
|
||||
<el-row :gutter="10" class="mb8">
|
||||
<el-col :span="1.5">
|
||||
<el-button
|
||||
v-if="!isEditing"
|
||||
type="primary"
|
||||
icon="el-icon-edit"
|
||||
size="mini"
|
||||
@click="handleEdit">编辑</el-button>
|
||||
<template v-else>
|
||||
<el-button
|
||||
type="success"
|
||||
icon="el-icon-check"
|
||||
size="mini"
|
||||
@click="handleSave">保存</el-button>
|
||||
<el-button
|
||||
icon="el-icon-close"
|
||||
size="mini"
|
||||
@click="handleCancel">取消</el-button>
|
||||
</template>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 基础信息表格 -->
|
||||
<div class="section-title">基础信息</div>
|
||||
<el-table
|
||||
:data="basicInfoData"
|
||||
border
|
||||
size="small"
|
||||
:show-header="false"
|
||||
class="info-table">
|
||||
<el-table-column width="120" align="right" class-name="label-column">
|
||||
<template slot-scope="scope">
|
||||
<span class="info-label">{{ scope.row.label }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column>
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-if="isEditing"
|
||||
v-model="scope.row.value"
|
||||
size="small"
|
||||
:placeholder="'请输入' + scope.row.label" />
|
||||
<span v-else class="info-value">{{ scope.row.value || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column width="120" align="right" class-name="label-column">
|
||||
<template slot-scope="scope">
|
||||
<span class="info-label">{{ scope.row.label2 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column>
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-if="isEditing"
|
||||
v-model="scope.row.value2"
|
||||
size="small"
|
||||
:placeholder="'请输入' + scope.row.label2" />
|
||||
<span v-else class="info-value">{{ scope.row.value2 || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 性能参数表格 -->
|
||||
<div class="section-title">
|
||||
<span>性能参数</span>
|
||||
<el-button
|
||||
v-if="isEditing"
|
||||
type="primary"
|
||||
size="mini"
|
||||
icon="el-icon-plus"
|
||||
@click="addParam">添加参数</el-button>
|
||||
</div>
|
||||
<el-table
|
||||
:data="perfParams"
|
||||
border
|
||||
size="small"
|
||||
class="param-table">
|
||||
<el-table-column label="参数名" width="200">
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-if="isEditing"
|
||||
v-model="scope.row.name"
|
||||
size="small"
|
||||
placeholder="如:输出电压" />
|
||||
<span v-else>{{ scope.row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参数值" width="200">
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-if="isEditing"
|
||||
v-model="scope.row.value"
|
||||
size="small"
|
||||
placeholder="如:24VDC" />
|
||||
<span v-else>{{ scope.row.value }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="150">
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-if="isEditing"
|
||||
v-model="scope.row.unit"
|
||||
size="small"
|
||||
placeholder="如:V" />
|
||||
<span v-else>{{ scope.row.unit }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isEditing" label="操作" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
type="text"
|
||||
style="color: #f56c6c"
|
||||
@click="deleteParam(scope.$index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty
|
||||
v-if="!perfParams || perfParams.length === 0"
|
||||
description="暂无性能参数"
|
||||
:image-size="80" />
|
||||
|
||||
<!-- 描述表格 -->
|
||||
<div class="section-title">描述</div>
|
||||
<el-table
|
||||
:data="[{label: '描述', value: description}]"
|
||||
border
|
||||
size="small"
|
||||
:show-header="false"
|
||||
class="desc-table">
|
||||
<el-table-column width="120" align="right" class-name="label-column">
|
||||
<template>
|
||||
<span class="info-label">描述</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column>
|
||||
<template>
|
||||
<el-input
|
||||
v-if="isEditing"
|
||||
v-model="description"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="请输入物料描述" />
|
||||
<div v-else class="description-content">{{ description || '暂无描述' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { updateMaterial } from "@/api/bid/material";
|
||||
|
||||
export default {
|
||||
name: "BasicInfoTab",
|
||||
props: {
|
||||
material: { type: Object, default: () => ({}) }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isEditing: false,
|
||||
basicInfoData: [],
|
||||
perfParams: [],
|
||||
description: ''
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
material: {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
if (val) {
|
||||
this.initData();
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
// 初始化数据
|
||||
initData() {
|
||||
// 基础信息数据(两列布局)
|
||||
this.basicInfoData = [
|
||||
{
|
||||
label: '物料编码', value: this.material.materialCode || '',
|
||||
label2: '所属分类', value2: this.material.categoryName || ''
|
||||
},
|
||||
{
|
||||
label: '物料名称', value: this.material.materialName || '',
|
||||
label2: '厂家/品牌', value2: this.material.brand || ''
|
||||
},
|
||||
{
|
||||
label: '规格型号', value: this.material.spec || '',
|
||||
label2: '材质', value2: this.material.material || ''
|
||||
},
|
||||
{
|
||||
label: '型号', value: this.material.modelNo || '',
|
||||
label2: '用途', value2: this.material.purpose || ''
|
||||
},
|
||||
{
|
||||
label: '单位', value: this.material.unit || '',
|
||||
label2: '备注', value2: this.material.remark || ''
|
||||
}
|
||||
];
|
||||
|
||||
// 性能参数数据
|
||||
this.perfParams = this.material.perfArray && this.material.perfArray.length > 0
|
||||
? JSON.parse(JSON.stringify(this.material.perfArray))
|
||||
: [];
|
||||
|
||||
// 描述
|
||||
this.description = this.material.description || '';
|
||||
},
|
||||
|
||||
// 进入编辑模式
|
||||
handleEdit() {
|
||||
this.isEditing = true;
|
||||
},
|
||||
|
||||
// 保存(实时保存,无需刷新)
|
||||
async handleSave() {
|
||||
try {
|
||||
// 构建保存的数据
|
||||
const saveData = {
|
||||
materialId: this.material.materialId,
|
||||
materialCode: this.basicInfoData[0].value,
|
||||
materialName: this.basicInfoData[1].value,
|
||||
spec: this.basicInfoData[2].value,
|
||||
modelNo: this.basicInfoData[3].value,
|
||||
unit: this.basicInfoData[4].value,
|
||||
brand: this.basicInfoData[1].value2,
|
||||
material: this.basicInfoData[2].value2,
|
||||
purpose: this.basicInfoData[3].value2,
|
||||
remark: this.basicInfoData[4].value2,
|
||||
performanceParams: JSON.stringify(this.perfParams),
|
||||
description: this.description
|
||||
};
|
||||
|
||||
// 调用API保存
|
||||
await updateMaterial(saveData);
|
||||
|
||||
this.$message.success("保存成功");
|
||||
this.isEditing = false;
|
||||
|
||||
// 更新本地数据
|
||||
this.$emit('update:material', { ...this.material, ...saveData, perfArray: this.perfParams });
|
||||
} catch (error) {
|
||||
this.$message.error("保存失败:" + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// 取消编辑
|
||||
handleCancel() {
|
||||
this.isEditing = false;
|
||||
this.initData(); // 恢复原始数据
|
||||
},
|
||||
|
||||
// 添加性能参数
|
||||
addParam() {
|
||||
this.perfParams.push({ name: '', value: '', unit: '' });
|
||||
},
|
||||
|
||||
// 删除性能参数
|
||||
deleteParam(index) {
|
||||
this.perfParams.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.basic-info-tab {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.mb8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin: 20px 0 10px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-table >>> .label-column {
|
||||
background-color: #f5f7fa;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.param-table {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.desc-table {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
color: #606266;
|
||||
line-height: 1.8;
|
||||
min-height: 60px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
/* 表格样式优化 */
|
||||
>>> .el-table .cell {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
>>> .el-input__inner {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
>>> .el-textarea__inner {
|
||||
min-height: 80px !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<div>
|
||||
<div style="margin-bottom:10px; text-align:right">
|
||||
<el-button size="mini" icon="el-icon-download" @click="exportExcel">导出Excel</el-button>
|
||||
</div>
|
||||
<el-table :data="list" v-loading="loading" border size="small">
|
||||
<el-table-column label="报价日期" prop="create_time" width="160" />
|
||||
<el-table-column label="甲方名称" prop="client_name" width="160" />
|
||||
<el-table-column label="成本价(元)" prop="cost_price" width="120">
|
||||
<template slot-scope="scope">¥{{ scope.row.cost_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单价(元)" prop="unit_price" width="120">
|
||||
<template slot-scope="scope">¥{{ scope.row.unit_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="成交价(元)" prop="total_price" width="120">
|
||||
<template slot-scope="scope">¥{{ scope.row.total_price }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="报价单号" prop="quote_no" width="150" />
|
||||
<el-table-column label="状态" prop="quote_status" width="100">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.quote_status === 'accepted' ? 'success' : 'danger'" size="small">
|
||||
{{ scope.row.quote_status === 'accepted' ? '已接受' : '已拒绝' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="!loading && !list.length" description="暂无甲方报价" :image-size="80" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getClientQuotes } from "@/api/bid/material";
|
||||
|
||||
export default {
|
||||
name: "ClientQuoteTab",
|
||||
props: { materialId: [Number, String] },
|
||||
data() { return { loading: false, list: [] }; },
|
||||
created() { this.loadData(); },
|
||||
methods: {
|
||||
loadData() {
|
||||
if (!this.materialId) return;
|
||||
this.loading = true;
|
||||
getClientQuotes(this.materialId).then(res => {
|
||||
this.list = res.data || [];
|
||||
this.loading = false;
|
||||
}).catch(() => { this.loading = false; });
|
||||
},
|
||||
exportExcel() {
|
||||
this.$message.info('导出功能开发中');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
719
ruoyi-ui/src/views/bid/material/components/CompareSection.vue
Normal file
719
ruoyi-ui/src/views/bid/material/components/CompareSection.vue
Normal file
@@ -0,0 +1,719 @@
|
||||
<template>
|
||||
<div class="compare-section">
|
||||
<!-- 顶部控制栏 -->
|
||||
<div class="compare-bar">
|
||||
<div class="compare-bar-left">
|
||||
<span class="compare-section-title">同品类物料横向对比</span>
|
||||
<el-tag v-if="selectedMaterials.length" size="small" type="primary" style="margin-left:8px">
|
||||
{{ selectedMaterials.length }} 个物料
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="compare-bar-right">
|
||||
<span class="select-hint">选择需要对比的物料:</span>
|
||||
<el-select
|
||||
v-model="targetIds"
|
||||
multiple
|
||||
filterable
|
||||
clearable
|
||||
placeholder="同名称不同规格/品牌的物料..."
|
||||
size="small"
|
||||
style="width:400px"
|
||||
:loading="loadingMaterials"
|
||||
@change="handleSelectionChange">
|
||||
<el-option
|
||||
v-for="m in sameNameMaterials"
|
||||
:key="m.materialId"
|
||||
:label="buildOptionLabel(m)"
|
||||
:value="m.materialId" />
|
||||
</el-select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loadingMaterials || loadingQuote" class="compare-status">
|
||||
<i class="el-icon-loading"></i> 正在加载数据,请稍候…
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="loadError" class="compare-error">
|
||||
<i class="el-icon-warning-outline"></i>
|
||||
<span class="error-text">{{ loadError }}</span>
|
||||
<el-button size="small" type="primary" icon="el-icon-refresh" @click="retryLoad">重新加载</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 数据内容:卡片式布局 -->
|
||||
<div v-else-if="selectedMaterials.length" class="compare-content">
|
||||
<div class="quote-comparison-grid">
|
||||
<div
|
||||
v-for="mat in selectedMaterials"
|
||||
:key="mat.materialId"
|
||||
class="material-quote-card">
|
||||
|
||||
<!-- 卡片头部:物料名称 + 规格/品牌 -->
|
||||
<div class="mat-card-header">
|
||||
<div class="mat-title-group">
|
||||
<span class="mat-name">{{ mat.materialName }}</span>
|
||||
<span class="mat-subtitle">{{ mat.spec || '—' }} / {{ mat.brand || '—' }}</span>
|
||||
</div>
|
||||
<el-tag v-if="quoteMap[mat.materialId] && quoteMap[mat.materialId].length"
|
||||
size="mini" type="success" effect="plain">
|
||||
{{ quoteMap[mat.materialId].length }} 家报价
|
||||
</el-tag>
|
||||
<el-tag v-else size="mini" type="info" effect="plain">暂无报价</el-tag>
|
||||
</div>
|
||||
|
||||
<!-- 基础信息区 -->
|
||||
<div class="mat-basic-body">
|
||||
<div class="basic-info-grid">
|
||||
<div class="basic-info-item">
|
||||
<span class="basic-label">规格型号</span>
|
||||
<span class="basic-value">{{ mat.spec || '—' }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="basic-label">厂家/品牌</span>
|
||||
<span class="basic-value">{{ mat.brand || '—' }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="basic-label">材质</span>
|
||||
<span class="basic-value">{{ mat.material || '—' }}</span>
|
||||
</div>
|
||||
<div class="basic-info-item">
|
||||
<span class="basic-label">单位</span>
|
||||
<span class="basic-value">{{ mat.unit || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分隔线 -->
|
||||
<div class="section-divider">
|
||||
<span class="divider-line"></span>
|
||||
<span class="divider-text">
|
||||
<i class="el-icon-s-data"></i> 供应商报价明细
|
||||
</span>
|
||||
<span class="divider-line"></span>
|
||||
</div>
|
||||
|
||||
<!-- 供应商报价区 -->
|
||||
<div class="mat-quote-section">
|
||||
<div v-if="quoteMap[mat.materialId] && quoteMap[mat.materialId].length" class="quote-list">
|
||||
<div class="quote-header-row">
|
||||
<span class="qh-col supplier-col">供应商</span>
|
||||
<span class="qh-col price-col">单价(元)</span>
|
||||
<span class="qh-col delivery-col">交期(天)</span>
|
||||
<span class="qh-col quote-no-col">报价单号</span>
|
||||
<span class="qh-col date-col">报价日期</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(quote, qIdx) in quoteMap[mat.materialId]"
|
||||
:key="qIdx"
|
||||
class="quote-row"
|
||||
:class="{ 'is-lowest': isLowestPrice(mat.materialId, qIdx) }">
|
||||
<div class="qr-col supplier-col">
|
||||
<span class="supplier-name" :title="quote.supplier_name">{{ quote.supplier_name }}</span>
|
||||
<span v-if="quote.supplier_contact || quote.supplier_phone" class="supplier-contact">
|
||||
{{ quote.supplier_contact || '' }} {{ quote.supplier_phone || '' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="qr-col price-col">
|
||||
<span class="price-value">¥{{ formatPrice(quote.unit_price) }}</span>
|
||||
<span v-if="isLowestPrice(mat.materialId, qIdx)" class="lowest-tag">最低</span>
|
||||
</div>
|
||||
<div class="qr-col delivery-col">{{ quote.delivery_days || '—' }}</div>
|
||||
<div class="qr-col quote-no-col" :title="quote.quote_no">{{ quote.quote_no }}</div>
|
||||
<div class="qr-col date-col">{{ formatDate(quote.submit_time) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="quote-empty">
|
||||
<i class="el-icon-info"></i>
|
||||
<span>暂无供应商报价数据</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 汇总统计(仅在有报价时显示) -->
|
||||
<div v-if="quoteMap[mat.materialId] && quoteMap[mat.materialId].length" class="quote-summary-bar">
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">最低价</span>
|
||||
<span class="summary-value price-lowest">¥{{ formatPrice(getMinPrice(mat.materialId)) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">最高价</span>
|
||||
<span class="summary-value">¥{{ formatPrice(getMaxPrice(mat.materialId)) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">均价</span>
|
||||
<span class="summary-value">¥{{ formatPrice(getAvgPrice(mat.materialId)) }}</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">最高价差</span>
|
||||
<span class="summary-value price-diff">{{ getPriceDiff(mat.materialId) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div v-else class="cmp-hint">
|
||||
<i class="el-icon-info"></i>
|
||||
<span>暂无同名称不同规格/品牌的物料可对比</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getSameNameMaterials, getQuoteComparison } from "@/api/bid/material";
|
||||
|
||||
export default {
|
||||
name: "CompareSection",
|
||||
props: {
|
||||
materialId: { type: [Number, String], required: true },
|
||||
material: { type: Object, default: () => ({}) }
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loadingMaterials: false,
|
||||
loadingQuote: false,
|
||||
loadError: null,
|
||||
sameNameMaterials: [],
|
||||
targetIds: [],
|
||||
quoteMap: {},
|
||||
_loadingLock: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
currentMaterial() {
|
||||
return this.material || {};
|
||||
},
|
||||
selectedMaterials() {
|
||||
const others = this.sameNameMaterials.filter(m => this.targetIds.includes(m.materialId));
|
||||
return [this.currentMaterial, ...others];
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'material.materialName': {
|
||||
immediate: true,
|
||||
handler(val) {
|
||||
if (val) this.initLoad();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
buildOptionLabel(m) {
|
||||
const parts = [m.materialName];
|
||||
if (m.spec) parts.push(m.spec);
|
||||
if (m.brand) parts.push(m.brand);
|
||||
return parts.join(' / ');
|
||||
},
|
||||
|
||||
async initLoad() {
|
||||
this.loadError = null;
|
||||
await this.loadSameNameMaterials();
|
||||
if (this.sameNameMaterials.length) {
|
||||
const allIds = this.sameNameMaterials.map(m => m.materialId);
|
||||
this._loadingLock = true;
|
||||
await this.loadQuoteComparison(allIds);
|
||||
this.targetIds = allIds;
|
||||
this._loadingLock = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadSameNameMaterials() {
|
||||
this.loadingMaterials = true;
|
||||
try {
|
||||
const res = await getSameNameMaterials(this.currentMaterial.materialName, Number(this.materialId));
|
||||
this.sameNameMaterials = res.data || [];
|
||||
} catch (err) {
|
||||
this.sameNameMaterials = [];
|
||||
this.loadError = '加载物料列表失败:' + (err.message || '网络异常');
|
||||
} finally {
|
||||
this.loadingMaterials = false;
|
||||
}
|
||||
},
|
||||
|
||||
async handleSelectionChange(selectedIds) {
|
||||
if (this._loadingLock) return;
|
||||
this.loadError = null;
|
||||
if (selectedIds.length) {
|
||||
await this.loadQuoteComparison(selectedIds);
|
||||
} else {
|
||||
this.quoteMap = {};
|
||||
}
|
||||
},
|
||||
|
||||
async loadQuoteComparison(materialIds) {
|
||||
if (!materialIds || !materialIds.length) return;
|
||||
this.loadingQuote = true;
|
||||
this.loadError = null;
|
||||
try {
|
||||
const ids = [Number(this.materialId), ...materialIds];
|
||||
const res = await getQuoteComparison(ids);
|
||||
const data = res.data || [];
|
||||
const map = {};
|
||||
data.forEach(item => {
|
||||
const mid = item.material_id;
|
||||
if (!map[mid]) map[mid] = [];
|
||||
map[mid].push(item);
|
||||
});
|
||||
Object.keys(map).forEach(mid => {
|
||||
map[mid].sort((a, b) => {
|
||||
const pa = Number(a.unit_price) || 0;
|
||||
const pb = Number(b.unit_price) || 0;
|
||||
return pa - pb;
|
||||
});
|
||||
});
|
||||
this.quoteMap = map;
|
||||
} catch (err) {
|
||||
this.loadError = '加载报价明细失败:' + (err.message || '网络异常');
|
||||
this.quoteMap = {};
|
||||
} finally {
|
||||
this.loadingQuote = false;
|
||||
}
|
||||
},
|
||||
|
||||
retryLoad() {
|
||||
this.loadError = null;
|
||||
this.initLoad();
|
||||
},
|
||||
|
||||
formatPrice(val) {
|
||||
if (val === null || val === undefined || val === '') return '—';
|
||||
return Number(val).toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 4
|
||||
});
|
||||
},
|
||||
|
||||
formatDate(val) {
|
||||
if (!val) return '—';
|
||||
const d = new Date(val);
|
||||
if (isNaN(d.getTime())) return val;
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
},
|
||||
|
||||
getMinPrice(materialId) {
|
||||
const quotes = this.quoteMap[materialId];
|
||||
if (!quotes || !quotes.length) return null;
|
||||
return Math.min(...quotes.map(q => Number(q.unit_price) || 0));
|
||||
},
|
||||
|
||||
getMaxPrice(materialId) {
|
||||
const quotes = this.quoteMap[materialId];
|
||||
if (!quotes || !quotes.length) return null;
|
||||
return Math.max(...quotes.map(q => Number(q.unit_price) || 0));
|
||||
},
|
||||
|
||||
getAvgPrice(materialId) {
|
||||
const quotes = this.quoteMap[materialId];
|
||||
if (!quotes || !quotes.length) return null;
|
||||
const sum = quotes.reduce((acc, q) => acc + (Number(q.unit_price) || 0), 0);
|
||||
return sum / quotes.length;
|
||||
},
|
||||
|
||||
getPriceDiff(materialId) {
|
||||
const min = this.getMinPrice(materialId);
|
||||
const max = this.getMaxPrice(materialId);
|
||||
if (min === null || max === null) return '—';
|
||||
const diff = max - min;
|
||||
if (diff === 0) return '无差异';
|
||||
const pct = ((diff / min) * 100).toFixed(1);
|
||||
return `¥${diff.toFixed(2)} (+${pct}%)`;
|
||||
},
|
||||
|
||||
isLowestPrice(materialId, quoteIndex) {
|
||||
const quotes = this.quoteMap[materialId];
|
||||
if (!quotes || !quotes.length) return false;
|
||||
return quoteIndex === 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========== 整体容器 ========== */
|
||||
.compare-section {
|
||||
margin-top: 20px;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ========== 顶部控制栏 ========== */
|
||||
.compare-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 14px 20px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.compare-bar-left { display: flex; align-items: center; }
|
||||
.compare-bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.select-hint {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.compare-section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.compare-section-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 14px;
|
||||
background: linear-gradient(180deg, #409eff 0%, #2c3e50 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* ========== 状态提示 ========== */
|
||||
.compare-status {
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
.compare-status i {
|
||||
font-size: 22px;
|
||||
margin-right: 8px;
|
||||
animation: rotating 2s linear infinite;
|
||||
}
|
||||
@keyframes rotating {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.compare-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 50px 20px;
|
||||
color: #f56c6c;
|
||||
font-size: 14px;
|
||||
}
|
||||
.compare-error i {
|
||||
font-size: 36px;
|
||||
color: #f56c6c;
|
||||
}
|
||||
.compare-error .error-text {
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.cmp-hint {
|
||||
text-align: center;
|
||||
padding: 50px 20px;
|
||||
color: #c0c4cc;
|
||||
font-size: 13px;
|
||||
}
|
||||
.cmp-hint i { margin-right: 6px; }
|
||||
|
||||
/* ========== 内容区域 ========== */
|
||||
.compare-content {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* ========== 卡片网格 ========== */
|
||||
.quote-comparison-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(480px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
/* ========== 物料卡片 ========== */
|
||||
.material-quote-card {
|
||||
background: #fff;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.3s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.material-quote-card:hover {
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* 卡片头部 */
|
||||
.mat-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 14px 16px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
.mat-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
.mat-name {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: #1a2c4e;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.mat-subtitle {
|
||||
font-size: 12px;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
/* 基础信息区 */
|
||||
.mat-basic-body {
|
||||
background: linear-gradient(135deg, #f8fafc 0%, #f0f4f8 100%);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.basic-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px 20px;
|
||||
}
|
||||
.basic-info-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.basic-label {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
flex-shrink: 0;
|
||||
min-width: 56px;
|
||||
}
|
||||
.basic-value {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
font-weight: 500;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* 分隔线 */
|
||||
.section-divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 16px;
|
||||
background: #fff;
|
||||
}
|
||||
.divider-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent 0%, #dcdfe6 50%, transparent 100%);
|
||||
}
|
||||
.divider-text {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.divider-text i {
|
||||
color: #409eff;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 供应商报价区 */
|
||||
.mat-quote-section {
|
||||
padding: 0 8px 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.quote-list { font-size: 13px; }
|
||||
|
||||
.quote-header-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 0.7fr 1.3fr 0.9fr;
|
||||
gap: 4px;
|
||||
padding: 8px 10px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
font-weight: 600;
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.qh-col {
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qh-col:first-child { text-align: left; }
|
||||
|
||||
.quote-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1.6fr 1fr 0.7fr 1.3fr 0.9fr;
|
||||
gap: 4px;
|
||||
padding: 10px;
|
||||
margin: 0 4px;
|
||||
border-bottom: 1px solid #f0f2f5;
|
||||
align-items: center;
|
||||
transition: background-color 0.2s ease;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.quote-row:hover { background: #f8f9fb; }
|
||||
.quote-row.is-lowest {
|
||||
background: linear-gradient(90deg, #f0f9eb 0%, #e8f5e0 100%);
|
||||
border-left: 3px solid #67c23a;
|
||||
}
|
||||
|
||||
.qr-col {
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
.qr-col:first-child { text-align: left; }
|
||||
|
||||
.supplier-name {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.supplier-contact {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-weight: 700;
|
||||
color: #f56c6c;
|
||||
font-size: 14px;
|
||||
}
|
||||
.lowest-tag {
|
||||
display: inline-block;
|
||||
background: #67c23a;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.qr-col.delivery-col,
|
||||
.qr-col.quote-no-col,
|
||||
.qr-col.date-col {
|
||||
color: #606266;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 汇总统计 */
|
||||
.quote-summary-bar {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1px;
|
||||
background: #ebeef5;
|
||||
border-top: 1px solid #ebeef5;
|
||||
}
|
||||
.summary-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 12px 8px;
|
||||
background: #fff;
|
||||
gap: 4px;
|
||||
}
|
||||
.summary-label {
|
||||
font-size: 11px;
|
||||
color: #909399;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
}
|
||||
.price-lowest { color: #67c23a; }
|
||||
.price-diff { color: #e6a23c; font-size: 13px; }
|
||||
|
||||
/* 空状态 */
|
||||
.quote-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 50px 20px;
|
||||
color: #c0c4cc;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
.quote-empty i { font-size: 32px; }
|
||||
|
||||
/* ========== 响应式适配 ========== */
|
||||
@media screen and (max-width: 1200px) {
|
||||
.quote-comparison-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
.compare-bar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.compare-bar-right {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.compare-bar-right .el-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
.quote-comparison-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.quote-header-row,
|
||||
.quote-row {
|
||||
grid-template-columns: 1.4fr 1fr 0.7fr 1fr 0.8fr;
|
||||
gap: 2px;
|
||||
padding: 8px 6px;
|
||||
margin: 0;
|
||||
}
|
||||
.basic-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.quote-header-row,
|
||||
.quote-row {
|
||||
grid-template-columns: 1.2fr 1fr 0.6fr;
|
||||
font-size: 12px;
|
||||
}
|
||||
.quote-header-row .quote-no-col,
|
||||
.quote-header-row .date-col,
|
||||
.quote-row .quote-no-col,
|
||||
.quote-row .date-col {
|
||||
display: none;
|
||||
}
|
||||
.quote-summary-bar {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
ruoyi-ui/src/views/bid/material/components/DetailHeader.vue
Normal file
37
ruoyi-ui/src/views/bid/material/components/DetailHeader.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<div class="detail-header">
|
||||
<div class="header-left">
|
||||
<h2 style="margin:0 0 8px 0">
|
||||
{{ material.brand || '' }} {{ material.materialName || '' }} {{ material.spec || '' }}
|
||||
</h2>
|
||||
<el-tag size="small" v-if="material.categoryName">{{ material.categoryName }}</el-tag>
|
||||
<el-tag size="small" type="success" v-if="material.material">{{ material.material }}</el-tag>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
<el-button icon="el-icon-arrow-left" size="small" @click="$emit('back')">返回列表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-descriptions :column="4" border size="small" style="margin-top:12px">
|
||||
<el-descriptions-item label="物料编码">{{ material.materialCode || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="厂家/品牌">{{ material.brand || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="规格型号">{{ material.spec || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="单位">{{ material.unit || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="材质">{{ material.material || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用途">{{ material.purpose || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="备注">{{ material.remark || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "DetailHeader",
|
||||
props: { material: { type: Object, default: () => ({}) } }
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-header { display: flex; justify-content: space-between; align-items: flex-start; }
|
||||
.header-right { flex-shrink: 0; }
|
||||
</style>
|
||||
235
ruoyi-ui/src/views/bid/material/components/SupplierQuoteTab.vue
Normal file
235
ruoyi-ui/src/views/bid/material/components/SupplierQuoteTab.vue
Normal file
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<div class="supplier-quote-tab">
|
||||
<!-- 操作栏 -->
|
||||
<div class="tab-toolbar">
|
||||
<span class="tab-title">供应商报价记录</span>
|
||||
<el-button size="mini" icon="el-icon-download" @click="exportExcel">导出Excel</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 报价表格 -->
|
||||
<el-table
|
||||
:data="list"
|
||||
v-loading="loading"
|
||||
border
|
||||
size="small"
|
||||
class="quote-table"
|
||||
:header-cell-style="headerStyle">
|
||||
<el-table-column label="报价日期" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="date-cell">{{ formatDate(scope.row.submit_time) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<!-- 供应商信息完整一行展示 -->
|
||||
<el-table-column label="供应商信息" min-width="280">
|
||||
<template slot-scope="scope">
|
||||
<div class="supplier-info">
|
||||
<div class="supplier-name">{{ scope.row.supplier_name }}</div>
|
||||
<div class="supplier-contact" v-if="scope.row.contact || scope.row.phone">
|
||||
<span v-if="scope.row.contact">{{ scope.row.contact }}</span>
|
||||
<span v-if="scope.row.phone" class="phone">{{ scope.row.phone }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="单价(元)" width="120" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span class="price-cell">¥{{ formatPrice(scope.row.unit_price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="总价(元)" width="120" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span class="price-cell">¥{{ formatPrice(scope.row.total_price) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="交期" width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="days-cell">{{ scope.row.delivery_days }}天</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="报价单号" width="150" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span class="quote-no">{{ scope.row.quote_no }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
|
||||
<el-table-column label="状态" width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag
|
||||
:type="getStatusType(scope.row.quote_status)"
|
||||
size="small"
|
||||
effect="dark"
|
||||
class="status-tag">
|
||||
{{ getStatusText(scope.row.quote_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<el-empty v-if="!loading && !list.length" description="暂无供应商报价" :image-size="80">
|
||||
<template #description>
|
||||
<span class="empty-text">暂无供应商报价记录</span>
|
||||
</template>
|
||||
</el-empty>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getSupplierQuotes } from "@/api/bid/material";
|
||||
|
||||
export default {
|
||||
name: "SupplierQuoteTab",
|
||||
props: { materialId: [Number, String] },
|
||||
data() { return { loading: false, list: [] }; },
|
||||
created() { this.loadData(); },
|
||||
methods: {
|
||||
loadData() {
|
||||
if (!this.materialId) return;
|
||||
this.loading = true;
|
||||
getSupplierQuotes(this.materialId).then(res => {
|
||||
this.list = res.data || [];
|
||||
this.loading = false;
|
||||
}).catch(() => { this.loading = false; });
|
||||
},
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' });
|
||||
},
|
||||
formatPrice(price) {
|
||||
if (!price && price !== 0) return '-';
|
||||
return Number(price).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
},
|
||||
getStatusType(status) {
|
||||
const map = { 'awarded': 'success', 'submitted': 'primary', 'rejected': 'danger', 'draft': 'info' };
|
||||
return map[status] || 'info';
|
||||
},
|
||||
getStatusText(status) {
|
||||
const map = { 'awarded': '已中标', 'submitted': '已报价', 'rejected': '已拒绝', 'draft': '草稿' };
|
||||
return map[status] || status;
|
||||
},
|
||||
headerStyle() {
|
||||
return {
|
||||
background: '#f5f7fa',
|
||||
color: '#606266',
|
||||
fontWeight: 600,
|
||||
fontSize: '13px'
|
||||
};
|
||||
},
|
||||
exportExcel() {
|
||||
this.$message.info('导出功能开发中');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.supplier-quote-tab {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* 工具栏 */
|
||||
.tab-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.tab-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
/* 表格样式 */
|
||||
.quote-table {
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
>>> .el-table__header-wrapper {
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* 日期单元格 */
|
||||
.date-cell {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 供应商信息 */
|
||||
.supplier-info {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.supplier-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.supplier-contact {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.supplier-contact .phone {
|
||||
margin-left: 8px;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
/* 价格单元格 */
|
||||
.price-cell {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #f56c6c;
|
||||
}
|
||||
|
||||
/* 交期单元格 */
|
||||
.days-cell {
|
||||
font-size: 13px;
|
||||
color: #e6a23c;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 报价单号 */
|
||||
.quote-no {
|
||||
font-size: 12px;
|
||||
color: #909399;
|
||||
font-family: 'Courier New', monospace;
|
||||
background: #f5f7fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* 状态标签 */
|
||||
.status-tag {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-text {
|
||||
color: #909399;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 行悬停效果 */
|
||||
>>> .el-table__row:hover {
|
||||
background-color: #f5f7fa !important;
|
||||
}
|
||||
</style>
|
||||
456
ruoyi-ui/src/views/bid/material/detail.vue
Normal file
456
ruoyi-ui/src/views/bid/material/detail.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<template>
|
||||
<div class="app-container">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="detail-header">
|
||||
<div class="header-title">
|
||||
<span class="material-name">{{ material.materialName }}</span>
|
||||
<el-button icon="el-icon-back" size="small" @click="goBack">返回列表</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基础信息卡片 -->
|
||||
<div class="basic-info-card">
|
||||
<div class="card-header">
|
||||
<span class="card-title">基础信息</span>
|
||||
<div class="card-actions">
|
||||
<el-button v-if="!isEditing" size="small" icon="el-icon-edit" @click="handleEdit">编辑</el-button>
|
||||
<template v-else>
|
||||
<el-button size="small" type="success" icon="el-icon-check" @click="handleSave">保存</el-button>
|
||||
<el-button size="small" icon="el-icon-close" @click="handleCancel">取消</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述网格:3列 x 2行基础字段 -->
|
||||
<div class="desc-grid">
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">物料编码</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.materialCode" size="small" placeholder="请输入物料编码" />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.materialCode }">{{ material.materialCode || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">物料名称</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.materialName" size="small" placeholder="请输入物料名称" />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.materialName }">{{ material.materialName || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">厂家/品牌</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.brand" size="small" placeholder="如:汇川" />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.brand }">{{ material.brand || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">规格型号</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.spec" size="small" placeholder="如:MD500T37G" />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.spec }">{{ material.spec || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">材质</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.material" size="small" placeholder="如:铝合金" />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.material }">{{ material.material || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">用途</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.purpose" size="small" placeholder="如:通用负载" />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.purpose }">{{ material.purpose || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">单位</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.unit" size="small" placeholder="如:台" />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.unit }">{{ material.unit || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">所属分类</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.categoryName" size="small" disabled />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.categoryName }">{{ material.categoryName || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item">
|
||||
<span class="grid-label">备注</span>
|
||||
<div class="grid-value-wrap">
|
||||
<el-input v-if="isEditing" v-model="form.remark" size="small" placeholder="备注信息" />
|
||||
<span v-else class="grid-value" :class="{ empty: !material.remark }">{{ material.remark || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 性能参数区域 -->
|
||||
<div class="sub-section">
|
||||
<div class="sub-header">
|
||||
<span>性能参数</span>
|
||||
<el-button v-if="isEditing" size="mini" icon="el-icon-plus" @click="addParam">添加</el-button>
|
||||
</div>
|
||||
<el-table v-if="perfParams.length" :data="perfParams" border size="small" style="width:100%">
|
||||
<el-table-column label="参数名">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-if="isEditing" v-model="scope.row.name" size="small" placeholder="如:输出电压" />
|
||||
<span v-else>{{ scope.row.name }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="参数值">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-if="isEditing" v-model="scope.row.value" size="small" placeholder="如:24VDC" />
|
||||
<span v-else>{{ scope.row.value }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="单位" width="120">
|
||||
<template slot-scope="scope">
|
||||
<el-input v-if="isEditing" v-model="scope.row.unit" size="small" placeholder="如:V" />
|
||||
<span v-else>{{ scope.row.unit }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column v-if="isEditing" label="操作" width="70" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" style="color:#f56c6c" @click="deleteParam(scope.$index)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!perfParams.length" class="sub-empty">暂无性能参数</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述区域已移除 -->
|
||||
</div>
|
||||
|
||||
<!-- 下方Tab区域 - 仅报价历史 -->
|
||||
<el-tabs v-model="activeTab" type="border-card" style="margin-top:16px">
|
||||
<el-tab-pane label="供应商报价历史" name="supplier">
|
||||
<SupplierQuoteTab :material-id="materialId" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="甲方报价历史" name="client">
|
||||
<ClientQuoteTab :material-id="materialId" />
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<!-- 同名称不同规格/品牌物料横向对比 -->
|
||||
<CompareSection :material-id="materialId" :material="material" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getMaterial, updateMaterial } from "@/api/bid/material";
|
||||
import SupplierQuoteTab from "./components/SupplierQuoteTab";
|
||||
import ClientQuoteTab from "./components/ClientQuoteTab";
|
||||
import CompareSection from "./components/CompareSection";
|
||||
|
||||
export default {
|
||||
name: "MaterialDetail",
|
||||
components: { SupplierQuoteTab, ClientQuoteTab, CompareSection },
|
||||
data() {
|
||||
return {
|
||||
materialId: null,
|
||||
material: {},
|
||||
form: {},
|
||||
perfParams: [],
|
||||
activeTab: "supplier",
|
||||
isEditing: false
|
||||
};
|
||||
},
|
||||
created() {
|
||||
this.materialId = this.$route.query && this.$route.query.id;
|
||||
this.loadMaterial();
|
||||
},
|
||||
methods: {
|
||||
loadMaterial() {
|
||||
if (!this.materialId) return;
|
||||
getMaterial(this.materialId).then(res => {
|
||||
this.material = res.data || {};
|
||||
// 解析性能参数JSON
|
||||
if (this.material.performanceParams) {
|
||||
try {
|
||||
const parsed = JSON.parse(this.material.performanceParams);
|
||||
this.perfParams = Array.isArray(parsed) ? parsed : [];
|
||||
} catch { this.perfParams = []; }
|
||||
} else {
|
||||
this.perfParams = [];
|
||||
}
|
||||
// 初始化表单数据
|
||||
this.form = { ...this.material };
|
||||
});
|
||||
},
|
||||
|
||||
// 进入编辑模式
|
||||
handleEdit() {
|
||||
this.isEditing = true;
|
||||
this.form = { ...this.material };
|
||||
},
|
||||
|
||||
// 保存(实时保存,无需刷新)
|
||||
async handleSave() {
|
||||
try {
|
||||
const saveData = {
|
||||
materialId: this.materialId,
|
||||
...this.form,
|
||||
performanceParams: JSON.stringify(this.perfParams)
|
||||
};
|
||||
|
||||
await updateMaterial(saveData);
|
||||
this.$message.success("保存成功");
|
||||
this.isEditing = false;
|
||||
|
||||
// 更新本地数据
|
||||
this.material = { ...this.material, ...saveData };
|
||||
this.material.perfArray = this.perfParams;
|
||||
} catch (error) {
|
||||
this.$message.error("保存失败:" + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
// 取消编辑
|
||||
handleCancel() {
|
||||
this.isEditing = false;
|
||||
this.form = { ...this.material };
|
||||
// 恢复性能参数
|
||||
if (this.material.performanceParams) {
|
||||
try {
|
||||
const parsed = JSON.parse(this.material.performanceParams);
|
||||
this.perfParams = Array.isArray(parsed) ? parsed : [];
|
||||
} catch { this.perfParams = []; }
|
||||
} else {
|
||||
this.perfParams = [];
|
||||
}
|
||||
},
|
||||
|
||||
// 添加性能参数
|
||||
addParam() {
|
||||
this.perfParams.push({ name: '', value: '', unit: '' });
|
||||
},
|
||||
|
||||
// 删除性能参数
|
||||
deleteParam(index) {
|
||||
this.perfParams.splice(index, 1);
|
||||
},
|
||||
|
||||
goBack() {
|
||||
this.$router.push('/bid/material');
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ========= 顶部标题 ========= */
|
||||
.detail-header {
|
||||
background: linear-gradient(135deg, #1a2c4e 0%, #2c3e50 100%);
|
||||
padding: 18px 24px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
||||
}
|
||||
.header-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.material-name {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||
}
|
||||
.header-title .el-button {
|
||||
background: rgba(255,255,255,0.15);
|
||||
border: 1px solid rgba(255,255,255,0.3);
|
||||
color: #fff;
|
||||
}
|
||||
.header-title .el-button:hover {
|
||||
background: rgba(255,255,255,0.25);
|
||||
border-color: rgba(255,255,255,0.5);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ========= 基础信息卡片 ========= */
|
||||
.basic-info-card {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06);
|
||||
overflow: hidden;
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.basic-info-card:hover {
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 16px 24px;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
position: relative;
|
||||
padding-left: 12px;
|
||||
}
|
||||
.card-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
background: linear-gradient(180deg, #409eff 0%, #2c3e50 100%);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.card-actions .el-button {
|
||||
padding: 6px 14px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.card-actions .el-button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* ========= 描述网格(3列) ========= */
|
||||
.desc-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: 1px;
|
||||
background: #ebeef5;
|
||||
padding: 1px;
|
||||
margin: 16px 24px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: #fff;
|
||||
min-height: 44px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.grid-item:hover {
|
||||
background: #f8f9fb;
|
||||
}
|
||||
|
||||
.grid-label {
|
||||
width: 90px;
|
||||
flex-shrink: 0;
|
||||
background: linear-gradient(135deg, #f8f9fb 0%, #f0f2f5 100%);
|
||||
color: #606266;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 14px;
|
||||
border-right: 1px solid #ebeef5;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.grid-value-wrap {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.grid-value {
|
||||
font-size: 13px;
|
||||
color: #303133;
|
||||
word-break: break-word;
|
||||
line-height: 1.5;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.grid-value.empty {
|
||||
color: #c0c4cc;
|
||||
font-style: normal;
|
||||
}
|
||||
.grid-value.empty::before {
|
||||
content: '待填写';
|
||||
font-size: 12px;
|
||||
color: #c0c4cc;
|
||||
}
|
||||
|
||||
.grid-value-wrap >>> .el-input__inner {
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
padding: 0 10px;
|
||||
transition: border-color 0.3s ease, box-shadow 0.3s ease;
|
||||
}
|
||||
.grid-value-wrap >>> .el-input__inner:focus {
|
||||
border-color: #409eff;
|
||||
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.15);
|
||||
}
|
||||
|
||||
/* ========= 子区域(性能参数) ========= */
|
||||
.sub-section {
|
||||
padding: 16px 24px;
|
||||
margin: 0 24px 16px;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
|
||||
border: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.sub-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.sub-empty {
|
||||
text-align: center;
|
||||
color: #c0c4cc;
|
||||
padding: 24px;
|
||||
font-size: 13px;
|
||||
background: #f8f9fb;
|
||||
border-radius: 6px;
|
||||
border: 1px dashed #dcdfe6;
|
||||
}
|
||||
|
||||
/* Tab 区域美化 */
|
||||
>>> .el-tabs--border-card {
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.06);
|
||||
}
|
||||
>>> .el-tabs--border-card > .el-tabs__header {
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #e4e7ed 100%);
|
||||
}
|
||||
>>> .el-tabs--border-card > .el-tabs__header .el-tabs__item {
|
||||
font-weight: 500;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
>>> .el-tabs--border-card > .el-tabs__header .el-tabs__item.is-active {
|
||||
background: #fff;
|
||||
color: #409eff;
|
||||
font-weight: 600;
|
||||
}
|
||||
>>> .el-tabs--border-card > .el-tabs__content {
|
||||
padding: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,11 @@
|
||||
<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 label="所属分类">
|
||||
<el-select v-model="queryParams.categoryId" placeholder="全部分类" clearable style="width:160px">
|
||||
<el-option v-for="c in flatCategories" :key="c.categoryId"
|
||||
@@ -28,22 +33,37 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table v-loading="loading" :data="materialList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="物料编码" prop="materialCode" width="130" />
|
||||
<el-table-column label="物料名称" prop="materialName" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="所属分类" prop="categoryName" width="130" />
|
||||
<el-table-column label="规格" prop="spec" :show-overflow-tooltip="true" width="130" />
|
||||
<el-table-column label="型号" prop="modelNo" :show-overflow-tooltip="true" width="130" />
|
||||
<el-table-column label="单位" prop="unit" width="70" />
|
||||
<el-table-column label="品牌" prop="brand" width="100" />
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="materialList"
|
||||
@selection-change="handleSelectionChange"
|
||||
border
|
||||
stripe
|
||||
style="width:100%"
|
||||
:header-cell-style="{ background: '#f5f7fa', color: '#303133', fontWeight: 700, fontSize: '13px' }"
|
||||
:cell-style="{ fontSize: '13px', color: '#606266' }">
|
||||
<el-table-column type="selection" width="50" align="center" />
|
||||
<el-table-column label="物料编码" prop="materialCode" width="140" header-align="center" align="center" />
|
||||
<el-table-column label="物料名称" prop="materialName" min-width="150" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="所属分类" prop="categoryName" width="130" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="厂家/品牌" prop="brand" width="130" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="规格型号" prop="spec" min-width="160" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="材质" prop="material" width="90" header-align="center" align="center" />
|
||||
<el-table-column label="用途" prop="purpose" min-width="160" :show-overflow-tooltip="true" />
|
||||
<el-table-column label="性能参数" width="200" :show-overflow-tooltip="true">
|
||||
<template slot-scope="scope">
|
||||
<span v-if="scope.row.performanceParams">{{ parsePerfParams(scope.row.performanceParams) }}</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" align="center" width="80">
|
||||
<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="140">
|
||||
<el-table-column label="操作" align="center" width="210" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-document" @click="handleDetail(scope.row)">详情</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)">修改</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" style="color:#f56c6c" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
@@ -51,7 +71,8 @@
|
||||
</el-table>
|
||||
<pagination v-show="total>0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize" @pagination="getList" />
|
||||
|
||||
<el-dialog :title="title" :visible.sync="open" width="640px" append-to-body>
|
||||
<!-- 新增/修改对话框 -->
|
||||
<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">
|
||||
@@ -72,29 +93,64 @@
|
||||
<el-input v-model="form.materialName" placeholder="请输入物料名称" />
|
||||
</el-form-item>
|
||||
<el-row>
|
||||
<el-col :span="12">
|
||||
<el-form-item label="规格" prop="spec">
|
||||
<el-input v-model="form.spec" placeholder="如:500W/220V" />
|
||||
<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="12">
|
||||
<el-form-item label="型号" prop="modelNo">
|
||||
<el-input v-model="form.modelNo" placeholder="如:XD-2023A" />
|
||||
<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="unit">
|
||||
<el-input v-model="form.unit" placeholder="如:台/件/米" />
|
||||
<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="brand">
|
||||
<el-input v-model="form.brand" />
|
||||
<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>
|
||||
@@ -108,7 +164,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { listMaterial, getMaterial, addMaterial, updateMaterial, delMaterial } from "@/api/bid/material";
|
||||
import { listMaterial, getMaterial, addMaterial, updateMaterial, delMaterial, listManufacturer } from "@/api/bid/material";
|
||||
import { getCategoryList } from "@/api/bid/category";
|
||||
|
||||
export default {
|
||||
@@ -117,9 +173,11 @@ export default {
|
||||
return {
|
||||
loading: false, multiple: true, total: 0, materialList: [],
|
||||
open: false, title: "",
|
||||
queryParams: { pageNum: 1, pageSize: 10, materialName: null, materialCode: null, categoryId: null },
|
||||
queryParams: { pageNum: 1, pageSize: 10, materialName: null, materialCode: null, categoryId: null, brand: null, spec: null },
|
||||
form: {},
|
||||
flatCategories: [],
|
||||
brandList: [],
|
||||
perfParams: [],
|
||||
rules: {
|
||||
materialCode: [{ required: true, message: "物料编码不能为空", trigger: "blur" }],
|
||||
materialName: [{ required: true, message: "物料名称不能为空", trigger: "blur" }],
|
||||
@@ -129,6 +187,7 @@ export default {
|
||||
created() {
|
||||
this.getList();
|
||||
this.loadCategories();
|
||||
this.loadBrands();
|
||||
},
|
||||
methods: {
|
||||
loadCategories() {
|
||||
@@ -136,6 +195,9 @@ export default {
|
||||
this.flatCategories = this.flattenTree(res.data || [], 0, "");
|
||||
});
|
||||
},
|
||||
loadBrands() {
|
||||
listManufacturer().then(res => { this.brandList = res.data || []; });
|
||||
},
|
||||
flattenTree(nodes, depth, prefix) {
|
||||
let result = [];
|
||||
for (const n of nodes) {
|
||||
@@ -157,21 +219,70 @@ export default {
|
||||
handleQuery() { this.queryParams.pageNum = 1; this.getList(); },
|
||||
resetQuery() { this.resetForm("queryForm"); this.handleQuery(); },
|
||||
handleSelectionChange(sel) { this.multiple = !sel.length; this.ids = sel.map(s => s.materialId); },
|
||||
handleAdd() { this.reset(); this.open = true; this.title = "新增物料"; },
|
||||
handleAdd() {
|
||||
this.reset();
|
||||
this.perfParams = [];
|
||||
this.open = true;
|
||||
this.title = "新增物料";
|
||||
},
|
||||
handleUpdate(row) {
|
||||
this.reset();
|
||||
getMaterial(row.materialId).then(res => { this.form = res.data; this.open = true; this.title = "修改物料"; });
|
||||
getMaterial(row.materialId).then(res => {
|
||||
this.form = res.data;
|
||||
// 解析性能参数JSON → table
|
||||
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("删除成功"); });
|
||||
},
|
||||
handleStatusChange(row) { updateMaterial(row); },
|
||||
reset() { this.form = { status: "0" }; this.resetForm && this.resetForm("form"); },
|
||||
// 性能参数
|
||||
addPerfRow() {
|
||||
this.perfParams.push({ name: '', value: '', unit: '' });
|
||||
},
|
||||
removePerfRow(index) {
|
||||
this.perfParams.splice(index, 1);
|
||||
},
|
||||
parsePerfParams(jsonStr) {
|
||||
if (!jsonStr) return '-';
|
||||
try {
|
||||
const obj = JSON.parse(jsonStr);
|
||||
const arr = Object.keys(obj).map(k => ({ name: k, value: obj[k] }));
|
||||
return arr.map(p => p.name + ': ' + p.value).join('; ');
|
||||
} catch { return jsonStr; }
|
||||
},
|
||||
parsePerfParamsToArray(jsonStr) {
|
||||
if (!jsonStr) return [];
|
||||
try {
|
||||
// Try [{name,value,unit}] format first
|
||||
const arr = JSON.parse(jsonStr);
|
||||
if (Array.isArray(arr)) return arr;
|
||||
// Try {key: value} format
|
||||
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;
|
||||
// 性能参数数组 → JSON string
|
||||
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(); });
|
||||
});
|
||||
@@ -179,3 +290,59 @@ export default {
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.app-container {
|
||||
background: #fff;
|
||||
padding: 16px 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* 搜索表单样式 */
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user