招标解析

This commit is contained in:
cwchen 2025-11-27 15:57:09 +08:00
parent 8e1e74067d
commit 62b8abc4a8
3 changed files with 993 additions and 373 deletions

File diff suppressed because one or more lines are too long

View File

@ -25,161 +25,7 @@
<AnalysisResultPanel ref="resultPanel" :main-tabs="mainTabs" :default-main-tab="activeMainTab" <AnalysisResultPanel ref="resultPanel" :main-tabs="mainTabs" :default-main-tab="activeMainTab"
:default-sub-tab="defaultSubTab" @main-tab-change="handleMainTabChange" :default-sub-tab="defaultSubTab" @main-tab-change="handleMainTabChange"
@sub-tab-change="handleSubTabChange" :hide-main-tabs="true" :hide-header="true" @sub-tab-change="handleSubTabChange" :hide-main-tabs="true" :hide-header="true"
:key="activeMainTab"> :key="activeMainTab" />
<!-- 项目信息 - 招标人 -->
<template #project-tenderer>
<div class="content-section">
<div class="section-title">招标人信息</div>
<div class="section-content">{{ analysisData.tenderer || '--' }}</div>
</div>
</template>
<!-- 项目信息 - 基础信息 -->
<template #project-basic>
<div class="content-section">
<div class="section-title">基础信息</div>
<div class="section-content">{{ analysisData.basicInfo || '--' }}</div>
</div>
</template>
<!-- 项目信息 - 代理机构 -->
<template #project-agency>
<div class="content-section">
<div class="section-title">代理机构</div>
<div class="section-content">{{ analysisData.agency || '--' }}</div>
</div>
</template>
<!-- 项目信息 - 关键时间 -->
<template #project-keyTime>
<div class="content-section">
<div class="section-title">关键时间</div>
<div class="section-content">{{ analysisData.keyTime || '--' }}</div>
</div>
</template>
<!-- 项目信息 - 联合体要求 -->
<template #project-consortium>
<div class="content-section">
<div class="section-title">联合体要求</div>
<div class="section-content">{{ analysisData.consortium || '--' }}</div>
</div>
</template>
<!-- 项目信息 - 分包要求 -->
<template #project-subcontract>
<div class="content-section">
<div class="section-title">分包要求</div>
<div class="section-content">{{ analysisData.subcontract || '--' }}</div>
</div>
</template>
<!-- 项目信息 - 最高限价 -->
<template #project-maxPrice>
<div class="content-section">
<div class="section-title">最高限价</div>
<div class="section-content">{{ analysisData.maxPrice || '--' }}</div>
</div>
</template>
<!-- 项目信息 - 响应和偏差 -->
<template #project-response>
<div class="content-section">
<div class="section-title">响应和偏差要求</div>
<div class="section-content">{{ analysisData.response || '招标文件无此内容' }}</div>
</div>
<div class="content-section">
<div class="section-title">澄清要求</div>
<div class="section-content">{{ analysisData.clarification || '--' }}</div>
</div>
</template>
<!-- 标段信息 -->
<template #bid>
<div class="content-section">
<div class="section-title">标段信息</div>
<div class="section-content">{{ analysisData.bidInfo || '--' }}</div>
</div>
</template>
<!-- 保证金 - 投标保证金 -->
<template #deposit-bidDeposit>
<div class="content-section">
<div class="section-title">投标保证金金额</div>
<div class="section-content">{{ analysisData.bidDepositAmount || '--' }}</div>
</div>
<div class="content-section">
<div class="section-title">投标保证金形式</div>
<div class="section-content">{{ analysisData.bidDepositForm || '--' }}</div>
</div>
<div class="content-section">
<div class="section-title">投标保证金提交期限</div>
<div class="section-content">{{ analysisData.bidDepositSubmitDeadline || '--' }}</div>
</div>
<div class="content-section">
<div class="section-title">投标保证金退还期限</div>
<div class="section-content">{{ analysisData.bidDepositRefundDeadline || '--' }}</div>
</div>
</template>
<!-- 保证金 - 履约保证金 -->
<template #deposit-performanceDeposit>
<div class="content-section">
<div class="section-title">履约保证金</div>
<div class="section-content">{{ analysisData.performanceDeposit || '--' }}</div>
</div>
</template>
<!-- 其他标签页内容 -->
<template #qualification>
<div class="content-section">
<div class="section-title">资格要求</div>
<div class="section-content">{{ analysisData.qualification || '--' }}</div>
</div>
</template>
<template #performance>
<div class="content-section">
<div class="section-title">业绩要求</div>
<div class="section-content">{{ analysisData.performance || '--' }}</div>
</div>
</template>
<template #finance>
<div class="content-section">
<div class="section-title">财务要求</div>
<div class="section-content">{{ analysisData.finance || '--' }}</div>
</div>
</template>
<template #personnel>
<div class="content-section">
<div class="section-title">人员要求</div>
<div class="section-content">{{ analysisData.personnel || '--' }}</div>
</div>
</template>
<template #evaluation>
<div class="content-section">
<div class="section-title">开评定标要求</div>
<div class="section-content">{{ analysisData.evaluation || '--' }}</div>
</div>
</template>
<template #rejection>
<div class="content-section">
<div class="section-title">废标项</div>
<div class="section-content">{{ analysisData.rejection || '--' }}</div>
</div>
</template>
<template #document>
<div class="content-section">
<div class="section-title">投标文件要求</div>
<div class="section-content">{{ analysisData.document || '--' }}</div>
</div>
</template>
</AnalysisResultPanel>
</div> </div>
<!-- 右侧文档预览面板 --> <!-- 右侧文档预览面板 -->
@ -200,8 +46,106 @@ import { decryptWithSM4, encryptWithSM4 } from '@/utils/sm'
import AnalysisHeader from './child/AnalysisHeader.vue' import AnalysisHeader from './child/AnalysisHeader.vue'
import AnalysisResultPanel from './child/AnalysisResultPanel.vue' import AnalysisResultPanel from './child/AnalysisResultPanel.vue'
import DocumentPreviewPanel from './child/DocumentPreviewPanel.vue' import DocumentPreviewPanel from './child/DocumentPreviewPanel.vue'
import analysisResultMock from '../analysisResultMock.json'
// import { getBidDetailAPI } from '@/api/analysis/analysis' // import { getBidDetailAPI } from '@/api/analysis/analysis'
const HTML_TAG_REG = /<\/?[a-z][\s\S]*>/i
const MAX_PARSE_DEPTH = 5
const formatSectionValue = (raw, depth = 0) => {
const fallback = { content: '--', isHtml: false }
if (raw === undefined || raw === null) {
return fallback
}
if (depth > MAX_PARSE_DEPTH) {
const content = String(raw)
return { content, isHtml: HTML_TAG_REG.test(content) }
}
if (typeof raw === 'string') {
const trimmed = raw.trim()
if (!trimmed) {
return fallback
}
try {
const parsed = JSON.parse(trimmed)
return formatSectionValue(parsed, depth + 1)
} catch (error) {
return { content: trimmed, isHtml: HTML_TAG_REG.test(trimmed) }
}
}
if (Array.isArray(raw)) {
const lines = raw.map(item => {
if (typeof item === 'object' && item !== null) {
const label = item.name || item.label || item.title || item.key || ''
const valueField = item.content ?? item.value ?? item.text ?? ''
const formatted = formatSectionValue(valueField, depth + 1)
return label ? `${label}${formatted.content}` : formatted.content
}
return formatSectionValue(item, depth + 1).content
}).filter(Boolean)
const content = lines.length > 0 ? lines.join('\n') : '--'
return { content, isHtml: false }
}
if (typeof raw === 'object') {
if (Object.prototype.hasOwnProperty.call(raw, 'value')) {
return formatSectionValue(raw.value, depth + 1)
}
const lines = Object.entries(raw).map(([key, value]) => {
const formatted = formatSectionValue(value, depth + 1)
return `${key}${formatted.content}`
}).filter(Boolean)
const content = lines.length > 0 ? lines.join('\n') : '--'
return { content, isHtml: false }
}
const content = String(raw)
return { content, isHtml: HTML_TAG_REG.test(content) }
}
const collectSectionsFromNode = (node) => {
const sections = []
const traverse = (target) => {
if (!target) return
if (Object.prototype.hasOwnProperty.call(target, 'tagValue')) {
const formatted = formatSectionValue(target.tagValue)
sections.push({
id: target.id,
title: target.name,
content: formatted.content,
isHtml: formatted.isHtml
})
}
if (target.children && target.children.length) {
sortByWeight(target.children).forEach(child => traverse(child))
}
}
traverse(node)
return sections
}
const sortByWeight = (list = []) => {
return [...list].sort((a, b) => {
const weightA = a?.weight ?? 0
const weightB = b?.weight ?? 0
return weightA - weightB
})
}
const buildTabsFromTree = (tree = []) => {
return sortByWeight(tree).map(node => {
const subTabs = sortByWeight(node.children || []).map(child => ({
name: child.id,
label: child.name,
sections: collectSectionsFromNode(child)
}))
return {
name: node.id,
label: node.name,
subTabs,
sections: subTabs.length === 0 ? collectSectionsFromNode(node) : []
}
})
}
export default { export default {
name: 'AnalysisBidView', name: 'AnalysisBidView',
components: { components: {
@ -213,79 +157,15 @@ export default {
return { return {
proId: decryptWithSM4(this.$route.query.proId), proId: decryptWithSM4(this.$route.query.proId),
bidId: decryptWithSM4(this.$route.query.bidId), bidId: decryptWithSM4(this.$route.query.bidId),
activeMainTab: 'project', activeMainTab: '',
defaultSubTab: '', // AnalysisResultPanel defaultSubTab: '', // AnalysisResultPanel
analysisData: {},
tenderDocumentUrl: '', tenderDocumentUrl: '',
tenderDocumentTitle: '', tenderDocumentTitle: '',
tenderDocumentKey: '', tenderDocumentKey: '',
bidDocumentUrl: '', bidDocumentUrl: '',
bidDocumentTitle: '', bidDocumentTitle: '',
bidDocumentKey: '', bidDocumentKey: '',
mainTabs: [ mainTabs: []
{
name: 'project',
label: '项目信息',
subTabs: [
{ name: 'tenderer', label: '招标人' },
{ name: 'basic', label: '基础信息' },
{ name: 'agency', label: '代理机构' },
{ name: 'keyTime', label: '关键时间' },
{ name: 'consortium', label: '联合体要求' },
{ name: 'subcontract', label: '分包要求' },
{ name: 'maxPrice', label: '最高限价' },
{ name: 'response', label: '响应和偏差' },
]
},
{
name: 'bid',
label: '标段信息',
subTabs: []
},
{
name: 'deposit',
label: '保证金',
subTabs: [
{ name: 'bidDeposit', label: '投标保证金' },
{ name: 'performanceDeposit', label: '履约保证金' },
]
},
{
name: 'qualification',
label: '资格要求',
subTabs: []
},
{
name: 'performance',
label: '业绩要求',
subTabs: []
},
{
name: 'finance',
label: '财务要求',
subTabs: []
},
{
name: 'personnel',
label: '人员要求',
subTabs: []
},
{
name: 'evaluation',
label: '开评定标要求',
subTabs: []
},
{
name: 'rejection',
label: '废标项',
subTabs: []
},
{
name: 'document',
label: '投标文件要求',
subTabs: []
},
]
} }
}, },
created() { created() {
@ -302,34 +182,16 @@ export default {
// TODO: API // TODO: API
// const res = await getBidDetailAPI({ proId: this.proId, bidId: this.bidId }) // const res = await getBidDetailAPI({ proId: this.proId, bidId: this.bidId })
// if (res.code === 200) { // if (res.code === 200) {
// this.analysisData = res.data || {} // this.mainTabs = buildTabsFromTree(res.data || [])
// this.initDocumentData(res.data) // this.initDocumentData(res.data)
// } // }
// // 使 mock
this.analysisData = { const parsedTabs = buildTabsFromTree(analysisResultMock)
tenderer: '中国南方电网有限责任公司', this.mainTabs = parsedTabs
basic: '基础信息内容', if (parsedTabs.length > 0) {
agency: '代理机构信息', this.activeMainTab = parsedTabs[0].name
keyTime: '关键时间信息', this.defaultSubTab = parsedTabs[0].subTabs?.[0]?.name || ''
consortium: '联合体要求内容',
subcontract: '分包要求内容',
maxPrice: '最高限价信息',
response: '招标文件无此内容',
clarification: '澄清要求内容...',
bidInfo: '标段信息内容',
bidDepositAmount: '投标保证金金额',
bidDepositForm: '投标保证金形式',
bidDepositSubmitDeadline: '投标截止日前一天',
bidDepositRefundDeadline: '退还期限信息',
performanceDeposit: '履约保证金信息',
qualification: '资格要求内容',
performance: '业绩要求内容',
finance: '财务要求内容',
personnel: '人员要求内容',
evaluation: '开评定标要求内容',
rejection: '废标项内容',
document: '投标文件要求内容'
} }
} catch (error) { } catch (error) {
console.error('获取标段详情失败:', error) console.error('获取标段详情失败:', error)
@ -609,30 +471,4 @@ export default {
} }
} }
} }
.content-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.section-content {
font-size: 14px;
color: #606266;
line-height: 1.8;
white-space: pre-wrap;
word-break: break-word;
background: #F5F7FA;
padding: 16px;
border-radius: 4px;
}
}
</style> </style>

View File

@ -8,48 +8,80 @@
<!-- 主标签页可选可通过hideMainTabs隐藏 --> <!-- 主标签页可选可通过hideMainTabs隐藏 -->
<el-tabs v-if="!hideMainTabs" v-model="activeMainTab" @tab-click="handleMainTabClick" class="main-tabs"> <el-tabs v-if="!hideMainTabs" v-model="activeMainTab" @tab-click="handleMainTabClick" class="main-tabs">
<el-tab-pane v-for="tab in mainTabs" :key="tab.name" :label="tab.label" :name="tab.name"> <el-tab-pane v-for="tab in mainTabs" :key="tab.name" :label="tab.label" :name="tab.name">
<!-- 子标签页 --> <template v-if="hasSubTabs(tab)">
<el-tabs v-if="tab.subTabs && tab.subTabs.length > 0" v-model="activeSubTab" <el-tabs v-model="activeSubTab" @tab-click="handleSubTabClick" class="sub-tabs">
@tab-click="handleSubTabClick" class="sub-tabs">
<el-tab-pane v-for="subTab in tab.subTabs" :key="subTab.name" :label="subTab.label" <el-tab-pane v-for="subTab in tab.subTabs" :key="subTab.name" :label="subTab.label"
:name="subTab.name"> :name="subTab.name">
<div class="tab-content"> <div class="tab-content">
<slot :name="`${tab.name}-${subTab.name}`"> <template v-if="hasSections(subTab.sections)">
<div class="empty-content">{{ subTab.label }}内容</div> <div class="content-section" v-for="section in subTab.sections"
</slot> :key="section.id">
<div class="section-title">{{ section.title }}</div>
<div class="section-content" v-if="!section.isHtml"
v-html="formatPlainText(section.content)"></div>
<div class="section-content html-content" v-else v-html="section.content">
</div>
</div>
</template>
<div v-else class="empty-content">暂无数据</div>
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<!-- 没有子标签页的内容 --> </template>
<div v-else class="tab-content"> <template v-else>
<slot :name="tab.name"> <div class="tab-content">
<div class="empty-content">{{ tab.label }}内容</div> <template v-if="hasSections(tab.sections)">
</slot> <div class="content-section" v-for="section in tab.sections" :key="section.id">
<div class="section-title">{{ section.title }}</div>
<div class="section-content" v-if="!section.isHtml"
v-html="formatPlainText(section.content)"></div>
<div class="section-content html-content" v-else v-html="section.content"></div>
</div> </div>
</template>
<div v-else class="empty-content">暂无数据</div>
</div>
</template>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<!-- 隐藏主标签页时直接显示当前主标签的内容 --> <!-- 隐藏主标签页时直接显示当前主标签的内容 -->
<template v-else> <template v-else>
<template v-for="tab in mainTabs"> <template v-for="tab in mainTabs">
<template v-if="tab.name === activeMainTab"> <template v-if="tab.name === activeMainTab">
<!-- 子标签页 --> <template v-if="hasSubTabs(tab)">
<el-tabs v-if="tab.subTabs && tab.subTabs.length > 0" v-model="activeSubTab" <el-tabs v-model="activeSubTab" @tab-click="handleSubTabClick" class="sub-tabs"
@tab-click="handleSubTabClick" class="sub-tabs" :key="`sub-tabs-${tab.name}`"> :key="`sub-tabs-${tab.name}`">
<el-tab-pane v-for="subTab in tab.subTabs" :key="subTab.name" :label="subTab.label" <el-tab-pane v-for="subTab in tab.subTabs" :key="subTab.name" :label="subTab.label"
:name="subTab.name"> :name="subTab.name">
<div class="tab-content"> <div class="tab-content">
<slot :name="`${tab.name}-${subTab.name}`"> <template v-if="hasSections(subTab.sections)">
<div class="empty-content">{{ subTab.label }}内容</div> <div class="content-section" v-for="section in subTab.sections"
</slot> :key="section.id">
<div class="section-title">{{ section.title }}</div>
<div class="section-content" v-if="!section.isHtml"
v-html="formatPlainText(section.content)"></div>
<div class="section-content html-content" v-else
v-html="section.content"></div>
</div>
</template>
<div v-else class="empty-content">暂无数据</div>
</div> </div>
</el-tab-pane> </el-tab-pane>
</el-tabs> </el-tabs>
<!-- 没有子标签页的内容 --> </template>
<div v-else class="tab-content" :key="`content-${tab.name}`"> <template v-else>
<slot :name="tab.name"> <div class="tab-content" :key="`content-${tab.name}`">
<div class="empty-content">{{ tab.label }}内容</div> <template v-if="hasSections(tab.sections)">
</slot> <div class="content-section" v-for="section in tab.sections" :key="section.id">
<div class="section-title">{{ section.title }}</div>
<div class="section-content" v-if="!section.isHtml"
v-html="formatPlainText(section.content)"></div>
<div class="section-content html-content" v-else v-html="section.content">
</div> </div>
</div>
</template>
<div v-else class="empty-content">暂无数据</div>
</div>
</template>
</template> </template>
</template> </template>
</template> </template>
@ -64,70 +96,7 @@ export default {
// //
mainTabs: { mainTabs: {
type: Array, type: Array,
default: () => [ default: () => []
{
name: 'project',
label: '项目信息',
subTabs: [
{ name: 'tenderer', label: '招标人' },
{ name: 'basic', label: '基础信息' },
{ name: 'agency', label: '代理机构' },
{ name: 'keyTime', label: '关键时间' },
{ name: 'consortium', label: '联合体要求' },
{ name: 'subcontract', label: '分包要求' },
{ name: 'maxPrice', label: '最高限价' },
{ name: 'response', label: '响应和偏差' },
]
},
{
name: 'bid',
label: '标段信息',
subTabs: []
},
{
name: 'deposit',
label: '保证金',
subTabs: [
{ name: 'bidDeposit', label: '投标保证金' },
{ name: 'performanceDeposit', label: '履约保证金' },
]
},
{
name: 'qualification',
label: '资格要求',
subTabs: []
},
{
name: 'performance',
label: '业绩要求',
subTabs: []
},
{
name: 'finance',
label: '财务要求',
subTabs: []
},
{
name: 'personnel',
label: '人员要求',
subTabs: []
},
{
name: 'evaluation',
label: '开评定标要求',
subTabs: []
},
{
name: 'rejection',
label: '废标项',
subTabs: []
},
{
name: 'document',
label: '投标文件要求',
subTabs: []
},
]
}, },
// //
defaultMainTab: { defaultMainTab: {
@ -217,6 +186,18 @@ export default {
return mainTab.subTabs[0].name return mainTab.subTabs[0].name
} }
return '' return ''
},
hasSubTabs(tab) {
return Boolean(tab && tab.subTabs && tab.subTabs.length > 0)
},
hasSections(sections) {
return Array.isArray(sections) && sections.length > 0
},
formatPlainText(text) {
if (!text) {
return '--'
}
return text.replace(/\n/g, '<br/>')
} }
} }
} }
@ -435,7 +416,8 @@ export default {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
padding: 20px; // padding: 20px;
padding: 20px 0;
.empty-content { .empty-content {
text-align: center; text-align: center;
@ -445,4 +427,36 @@ export default {
} }
} }
} }
.content-section {
margin-bottom: 24px;
&:last-child {
margin-bottom: 0;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
}
.section-content {
font-size: 14px;
color: #606266;
line-height: 1.8;
white-space: normal;
word-break: break-word;
background: #F5F7FA;
padding: 16px;
border-radius: 4px;
&.html-content {
white-space: normal;
background: #FFFFFF;
border: 1px solid #EBEEF5;
}
}
}
</style> </style>