feat(bid): 完成物料管理模块全功能开发

1. 新增物料详情页路由、菜单与接口,支持查看物料报价与信息
2. 重构物料列表页面,新增品牌筛选、表格样式优化与详情跳转
3. 扩展物料实体与数据库字段,新增材质、用途、性能参数等字段
4. 新增供应商/甲方报价查询、批量对比、同名称物料匹配接口
5. 新增物料详情组件,包含基础信息、供应商报价、甲方报价标签页
6. 修复比价路由跳转路径错误,调整数据库密码配置
7. 新增物料相关SQL脚本与初始化数据
This commit is contained in:
2026-05-29 08:58:58 +08:00
parent c718ec4076
commit e521b0dfeb
26 changed files with 4871 additions and 41 deletions

View File

@@ -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
})

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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 } });

View 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>

View File

@@ -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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>