Skip to content
项目
群组
代码片段
帮助
当前项目
正在载入...
登录 / 注册
切换导航面板
R
risk-monitor
项目
项目
详情
活动
周期分析
仓库
仓库
文件
提交
分支
标签
贡献者
图表
比较
统计图
议题
0
议题
0
列表
看板
标记
里程碑
合并请求
0
合并请求
0
CI / CD
CI / CD
流水线
作业
日程
统计图
Wiki
Wiki
代码片段
代码片段
成员
成员
折叠边栏
关闭边栏
活动
图像
聊天
创建新问题
作业
提交
问题看板
Open sidebar
蔡建
risk-monitor
Commits
02341b24
提交
02341b24
authored
3月 18, 2026
作者:
张伊明
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
feat 新增法案对比页面
feat 新增首页图标ai总结功能(未接入接口)
上级
b21e37b1
隐藏空白字符变更
内嵌
并排
正在显示
6 个修改的文件
包含
974 行增加
和
10 行删除
+974
-10
bill.js
src/api/bill.js
+14
-0
bill.js
src/router/modules/bill.js
+9
-0
index.vue
src/views/bill/billHome/index.vue
+91
-6
index.vue
src/views/bill/billLayout/index.vue
+11
-3
index.vue
src/views/bill/template/index.vue
+1
-1
index.vue
src/views/bill/versionCompare/index.vue
+848
-0
没有找到文件。
src/api/bill.js
浏览文件 @
02341b24
...
@@ -192,3 +192,17 @@ export function getBillFullText(params) {
...
@@ -192,3 +192,17 @@ export function getBillFullText(params) {
params
,
params
,
})
})
}
}
// 条款对比-根据两版版本与筛选条件获取配对条款列表
/**
* @param {billId,oldVersionId,newVersionId,diffType,cRelated,keyword}
* @header token
* @returns { list: Array<{ oldTerm: object|null, newTerm: object|null }> }
*/
export
function
getBillTermsCompare
(
params
)
{
return
request
({
method
:
"GET"
,
url
:
"/api/billInfoBean/content/compare"
,
params
,
});
}
src/router/modules/bill.js
浏览文件 @
02341b24
...
@@ -13,6 +13,7 @@ const BillInfluenceLayout = () => import('@/views/bill/influence/index.vue')
...
@@ -13,6 +13,7 @@ const BillInfluenceLayout = () => import('@/views/bill/influence/index.vue')
const
BillInfluenceIndustry
=
()
=>
import
(
'@/views/bill/influence/industry/index.vue'
)
const
BillInfluenceIndustry
=
()
=>
import
(
'@/views/bill/influence/industry/index.vue'
)
const
BillInfluenceScientificResearch
=
()
=>
import
(
'@/views/bill/influence/scientificResearch/index.vue'
)
const
BillInfluenceScientificResearch
=
()
=>
import
(
'@/views/bill/influence/scientificResearch/index.vue'
)
const
BillRelevantCircumstance
=
()
=>
import
(
'@/views/bill/relevantCircumstance/index.vue'
)
const
BillRelevantCircumstance
=
()
=>
import
(
'@/views/bill/relevantCircumstance/index.vue'
)
const
BillVersionCompare
=
()
=>
import
(
'@/views/bill/versionCompare/index.vue'
)
const
billRoutes
=
[
const
billRoutes
=
[
...
@@ -129,6 +130,14 @@ const billRoutes = [
...
@@ -129,6 +130,14 @@ const billRoutes = [
// meta: {
// meta: {
// title: "相关情况"
// title: "相关情况"
// }
// }
},
{
path
:
"versionCompare"
,
name
:
"BillVersionCompare"
,
component
:
BillVersionCompare
,
meta
:
{
title
:
"版本对比"
}
}
}
]
]
},
},
...
...
src/views/bill/billHome/index.vue
浏览文件 @
02341b24
...
@@ -127,7 +127,13 @@
...
@@ -127,7 +127,13 @@
<el-empty
v-if=
"!box5HasData"
description=
"暂无数据"
:image-size=
"100"
/>
<el-empty
v-if=
"!box5HasData"
description=
"暂无数据"
:image-size=
"100"
/>
<div
v-else
id=
"box5Chart"
class=
"overview-chart"
></div>
<div
v-else
id=
"box5Chart"
class=
"overview-chart"
></div>
</div>
</div>
<TipTab
class=
"overview-tip"
/>
<div
class=
"overview-tip-row"
>
<TipTab
class=
"overview-tip"
/>
<AiButton
class=
"overview-tip-action"
@
mouseenter=
"handleShowAiPane('box5')"
/>
</div>
<div
v-if=
"aiPaneVisible.box5"
class=
"overview-ai-pane"
@
mouseleave=
"handleHideAiPane('box5')"
>
<AiPane
:aiContent=
"overviewAiContent.box5"
/>
</div>
</div>
</div>
</OverviewCard>
</OverviewCard>
<OverviewCard
class=
"overview-card--single box6"
title=
"涉华法案领域分布"
:icon=
"box6HeaderIcon"
>
<OverviewCard
class=
"overview-card--single box6"
title=
"涉华法案领域分布"
:icon=
"box6HeaderIcon"
>
...
@@ -141,7 +147,13 @@
...
@@ -141,7 +147,13 @@
<el-empty
v-if=
"!box9HasData"
description=
"暂无数据"
:image-size=
"100"
/>
<el-empty
v-if=
"!box9HasData"
description=
"暂无数据"
:image-size=
"100"
/>
<div
v-else
id=
"box9Chart"
class=
"overview-chart"
></div>
<div
v-else
id=
"box9Chart"
class=
"overview-chart"
></div>
</div>
</div>
<TipTab
class=
"overview-tip"
/>
<div
class=
"overview-tip-row"
>
<TipTab
class=
"overview-tip"
/>
<AiButton
class=
"overview-tip-action"
@
mouseenter=
"handleShowAiPane('box6')"
/>
</div>
<div
v-if=
"aiPaneVisible.box6"
class=
"overview-ai-pane"
@
mouseleave=
"handleHideAiPane('box6')"
>
<AiPane
:aiContent=
"overviewAiContent.box6"
/>
</div>
</div>
</div>
</OverviewCard>
</OverviewCard>
</div>
</div>
...
@@ -157,7 +169,13 @@
...
@@ -157,7 +169,13 @@
<el-empty
v-if=
"!box7HasData"
description=
"暂无数据"
:image-size=
"100"
/>
<el-empty
v-if=
"!box7HasData"
description=
"暂无数据"
:image-size=
"100"
/>
<div
v-else
id=
"box7Chart"
class=
"overview-chart"
></div>
<div
v-else
id=
"box7Chart"
class=
"overview-chart"
></div>
</div>
</div>
<TipTab
class=
"overview-tip"
/>
<div
class=
"overview-tip-row"
>
<TipTab
class=
"overview-tip"
/>
<AiButton
class=
"overview-tip-action"
@
mouseenter=
"handleShowAiPane('box7')"
/>
</div>
<div
v-if=
"aiPaneVisible.box7"
class=
"overview-ai-pane"
@
mouseleave=
"handleHideAiPane('box7')"
>
<AiPane
:aiContent=
"overviewAiContent.box7"
/>
</div>
</div>
</div>
</OverviewCard>
</OverviewCard>
<OverviewCard
class=
"overview-card--single box8"
title=
"涉华法案进展分布"
:icon=
"box7HeaderIcon"
>
<OverviewCard
class=
"overview-card--single box8"
title=
"涉华法案进展分布"
:icon=
"box7HeaderIcon"
>
...
@@ -174,7 +192,13 @@
...
@@ -174,7 +192,13 @@
<div
id=
"box8Chart"
class=
"overview-chart box8-chart"
></div>
<div
id=
"box8Chart"
class=
"overview-chart box8-chart"
></div>
</
template
>
</
template
>
</div>
</div>
<TipTab
class=
"overview-tip"
/>
<div
class=
"overview-tip-row"
>
<TipTab
class=
"overview-tip"
/>
<AiButton
class=
"overview-tip-action"
@
mouseenter=
"handleShowAiPane('box8')"
/>
</div>
<div
v-if=
"aiPaneVisible.box8"
class=
"overview-ai-pane"
@
mouseleave=
"handleHideAiPane('box8')"
>
<AiPane
:aiContent=
"overviewAiContent.box8"
/>
</div>
</div>
</div>
</OverviewCard>
</OverviewCard>
<OverviewCard
class=
"overview-card--single box9"
title=
"涉华法案关键条款"
:icon=
"box7HeaderIcon"
>
<OverviewCard
class=
"overview-card--single box9"
title=
"涉华法案关键条款"
:icon=
"box7HeaderIcon"
>
...
@@ -183,7 +207,13 @@
...
@@ -183,7 +207,13 @@
<el-empty
v-if=
"!wordCloudHasData"
description=
"暂无数据"
:image-size=
"100"
/>
<el-empty
v-if=
"!wordCloudHasData"
description=
"暂无数据"
:image-size=
"100"
/>
<WordCloundChart
v-else
class=
"overview-chart"
width=
"100%"
height=
"100%"
:data=
"wordCloudData"
/>
<WordCloundChart
v-else
class=
"overview-chart"
width=
"100%"
height=
"100%"
:data=
"wordCloudData"
/>
</div>
</div>
<TipTab
class=
"overview-tip"
/>
<div
class=
"overview-tip-row"
>
<TipTab
class=
"overview-tip"
/>
<AiButton
class=
"overview-tip-action"
@
mouseenter=
"handleShowAiPane('box9')"
/>
</div>
<div
v-if=
"aiPaneVisible.box9"
class=
"overview-ai-pane"
@
mouseleave=
"handleHideAiPane('box9')"
>
<AiPane
:aiContent=
"overviewAiContent.box9"
/>
</div>
</div>
</div>
</OverviewCard>
</OverviewCard>
</div>
</div>
...
@@ -221,6 +251,8 @@ import OverviewCard from "./OverviewCard.vue";
...
@@ -221,6 +251,8 @@ import OverviewCard from "./OverviewCard.vue";
import
ResourceLibrarySection
from
"./ResourceLibrarySection.vue"
;
import
ResourceLibrarySection
from
"./ResourceLibrarySection.vue"
;
import
{
useContainerScroll
}
from
"@/hooks/useScrollShow"
;
import
{
useContainerScroll
}
from
"@/hooks/useScrollShow"
;
import
TipTab
from
"@/components/base/TipTab/index.vue"
;
import
TipTab
from
"@/components/base/TipTab/index.vue"
;
import
AiButton
from
"@/components/base/Ai/AiButton/index.vue"
;
import
AiPane
from
"@/components/base/Ai/AiPane/index.vue"
;
import
WordCloundChart
from
"@/components/base/WordCloundChart/index.vue"
;
import
WordCloundChart
from
"@/components/base/WordCloundChart/index.vue"
;
import
getMultiLineChart
from
"./utils/multiLineChart"
;
import
getMultiLineChart
from
"./utils/multiLineChart"
;
...
@@ -370,6 +402,36 @@ const box7YearList = ref([
...
@@ -370,6 +402,36 @@ const box7YearList = ref([
}
}
]);
]);
const
aiPaneVisible
=
ref
({
box5
:
false
,
box6
:
false
,
box7
:
false
,
box8
:
false
,
box9
:
false
});
const
overviewAiContent
=
ref
({
box5
:
"智能总结生成中..."
,
box6
:
"智能总结生成中..."
,
box7
:
"智能总结生成中..."
,
box8
:
"智能总结生成中..."
,
box9
:
"智能总结生成中..."
});
const
handleShowAiPane
=
key
=>
{
aiPaneVisible
.
value
=
{
...
aiPaneVisible
.
value
,
[
key
]:
true
};
};
const
handleHideAiPane
=
key
=>
{
aiPaneVisible
.
value
=
{
...
aiPaneVisible
.
value
,
[
key
]:
false
};
};
const
box8selectetedTime
=
ref
(
"2025"
);
const
box8selectetedTime
=
ref
(
"2025"
);
const
box8YearList
=
ref
([
const
box8YearList
=
ref
([
{
{
...
@@ -2109,6 +2171,20 @@ onUnmounted(() => {
...
@@ -2109,6 +2171,20 @@ onUnmounted(() => {
.overview-card-body
{
.overview-card-body
{
display
:
flex
;
display
:
flex
;
flex-direction
:
column
;
flex-direction
:
column
;
position
:
relative
;
}
.overview-ai-pane
{
position
:
absolute
;
left
:
0
;
bottom
:
0
;
width
:
100%
;
z-index
:
3
;
pointer-events
:
none
;
.ai-pane-wrapper
{
pointer-events
:
auto
;
}
}
}
.overview-chart-wrap
{
.overview-chart-wrap
{
...
@@ -2131,8 +2207,17 @@ onUnmounted(() => {
...
@@ -2131,8 +2207,17 @@ onUnmounted(() => {
min-height
:
0
;
min-height
:
0
;
}
}
.overview-tip
{
.overview-tip
-row
{
margin-top
:
10px
;
margin-top
:
10px
;
position
:
relative
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
}
.overview-tip-action
{
position
:
absolute
;
right
:
-30px
;
}
}
}
}
}
}
...
...
src/views/bill/billLayout/index.vue
浏览文件 @
02341b24
...
@@ -7,6 +7,8 @@
...
@@ -7,6 +7,8 @@
:defaultLogo=
"USALogo"
:defaultLogo=
"USALogo"
:tabs=
"mainHeaderBtnList"
:tabs=
"mainHeaderBtnList"
:activeTitle=
"activeTitle"
:activeTitle=
"activeTitle"
:showTabs=
"showHeaderTabs"
:showActions=
"showHeaderActions"
@
tab-click=
"handleClickMainHeaderBtn"
@
tab-click=
"handleClickMainHeaderBtn"
@
open-analysis=
"handleAnalysisClick"
@
open-analysis=
"handleAnalysisClick"
/>
/>
...
@@ -79,6 +81,8 @@ const mainHeaderBtnList = ref([
...
@@ -79,6 +81,8 @@ const mainHeaderBtnList = ref([
]);
]);
const
activeTitle
=
ref
(
"法案概况"
);
const
activeTitle
=
ref
(
"法案概况"
);
const
showHeaderTabs
=
ref
(
true
);
const
showHeaderActions
=
ref
(
true
);
const
getActiveTitleByRoutePath
=
path
=>
{
const
getActiveTitleByRoutePath
=
path
=>
{
if
(
path
.
startsWith
(
"/billLayout/deepDig"
))
return
"深度挖掘"
;
if
(
path
.
startsWith
(
"/billLayout/deepDig"
))
return
"深度挖掘"
;
...
@@ -88,8 +92,12 @@ const getActiveTitleByRoutePath = path => {
...
@@ -88,8 +92,12 @@ const getActiveTitleByRoutePath = path => {
return
"法案概况"
;
return
"法案概况"
;
};
};
const
syncActiveTitleFromRoute
=
()
=>
{
const
syncHeaderStateFromRoute
=
()
=>
{
const
currentPath
=
route
.
path
||
""
;
activeTitle
.
value
=
getActiveTitleByRoutePath
(
route
.
path
);
activeTitle
.
value
=
getActiveTitleByRoutePath
(
route
.
path
);
const
isVersionCompare
=
currentPath
.
startsWith
(
"/billLayout/versionCompare"
);
showHeaderTabs
.
value
=
!
isVersionCompare
;
showHeaderActions
.
value
=
!
isVersionCompare
;
};
};
const
handleClickMainHeaderBtn
=
item
=>
{
const
handleClickMainHeaderBtn
=
item
=>
{
...
@@ -120,7 +128,7 @@ const handleAnalysisClick = () => {
...
@@ -120,7 +128,7 @@ const handleAnalysisClick = () => {
onMounted
(()
=>
{
onMounted
(()
=>
{
getBillInfoGlobalFn
();
getBillInfoGlobalFn
();
// 以当前路由为准,避免 sessionStorage 造成高亮错乱
// 以当前路由为准,避免 sessionStorage 造成高亮错乱
sync
ActiveTitl
eFromRoute
();
sync
HeaderStat
eFromRoute
();
// 兜底:如果未来出现未知路由且有缓存,再用缓存
// 兜底:如果未来出现未知路由且有缓存,再用缓存
const
cachedTitle
=
window
.
sessionStorage
.
getItem
(
"activeTitle"
);
const
cachedTitle
=
window
.
sessionStorage
.
getItem
(
"activeTitle"
);
if
(
!
activeTitle
.
value
&&
cachedTitle
)
activeTitle
.
value
=
cachedTitle
;
if
(
!
activeTitle
.
value
&&
cachedTitle
)
activeTitle
.
value
=
cachedTitle
;
...
@@ -129,7 +137,7 @@ onMounted(() => {
...
@@ -129,7 +137,7 @@ onMounted(() => {
watch
(
watch
(
()
=>
route
.
path
,
()
=>
route
.
path
,
()
=>
{
()
=>
{
sync
ActiveTitl
eFromRoute
();
sync
HeaderStat
eFromRoute
();
},
},
{
immediate
:
true
}
{
immediate
:
true
}
);
);
...
...
src/views/bill/template/index.vue
浏览文件 @
02341b24
...
@@ -387,7 +387,7 @@ const handleChangeBill = val => {
...
@@ -387,7 +387,7 @@ const handleChangeBill = val => {
};
};
const
handleOpenVersionCompare
=
()
=>
{
const
handleOpenVersionCompare
=
()
=>
{
const
targetUrl
=
`/billLayout/
deepDig/processOverview
?billId=
${
route
.
query
.
billId
}
`
;
const
targetUrl
=
`/billLayout/
versionCompare
?billId=
${
route
.
query
.
billId
}
`
;
window
.
open
(
targetUrl
,
"_blank"
);
window
.
open
(
targetUrl
,
"_blank"
);
};
};
...
...
src/views/bill/versionCompare/index.vue
0 → 100644
浏览文件 @
02341b24
<
template
>
<div
class=
"version-compare-wrap"
>
<div
class=
"compare-top"
>
<div
class=
"compare-top-col"
>
<div
class=
"compare-top-label"
>
原版本:
</div>
<el-select
v-model=
"oldVersionId"
placeholder=
"请选择版本"
class=
"compare-top-select"
clearable
>
<el-option
v-for=
"item in versionOptions"
:key=
"item.value"
:label=
"item.label"
:value=
"item.value"
/>
</el-select>
</div>
<div
class=
"compare-top-col"
>
<div
class=
"compare-top-label"
>
现版本:
</div>
<el-select
v-model=
"newVersionId"
placeholder=
"请选择版本"
class=
"compare-top-select"
clearable
>
<el-option
v-for=
"item in versionOptions"
:key=
"item.value"
:label=
"item.label"
:value=
"item.value"
/>
</el-select>
</div>
</div>
<div
class=
"compare-tools"
>
<el-tabs
v-model=
"diffType"
class=
"compare-tools-tabs"
>
<el-tab-pane
label=
"变更"
name=
"CHANGE"
/>
<el-tab-pane
label=
"新增"
name=
"ADD"
/>
<el-tab-pane
label=
"删除"
name=
"DELETE"
/>
</el-tabs>
<div
class=
"compare-tools-actions"
>
<el-checkbox
v-model=
"onlyChinaRelated"
label=
"只看涉华条款"
size=
"large"
/>
<div
class=
"compare-tools-switches"
>
<div
class=
"compare-tools-switch"
>
<span
class=
"label"
>
高亮实体
</span>
<el-switch
v-model=
"termsHighlight"
inline-prompt
active-text=
"开"
inactive-text=
"关"
/>
</div>
<div
class=
"compare-tools-switch"
>
<span
class=
"label"
>
显示原文
</span>
<el-switch
v-model=
"termsShowOriginal"
inline-prompt
active-text=
"开"
inactive-text=
"关"
/>
</div>
</div>
<div
class=
"find-word-wrap"
>
<div
class=
"find-word-box"
v-if=
"findWordBox"
>
<div
class=
"find-word-input"
>
<el-input
ref=
"findWordInputRef"
v-model=
"findWordTxt"
placeholder=
"查找条款内容"
clearable
@
input=
"handleUpdateWord"
/>
</div>
<div
class=
"find-word-limit"
>
{{
findWordNum
}}
/
{{
findWordMax
}}
</div>
<div
class=
"find-word-icon"
@
click=
"handleFindWord('last')"
>
<el-icon><ArrowUp
/></el-icon>
</div>
<div
class=
"find-word-icon"
@
click=
"handleFindWord('next')"
>
<el-icon><ArrowDown
/></el-icon>
</div>
<div
class=
"find-word-icon"
@
click=
"handleFindWord('close')"
>
<el-icon><Close
/></el-icon>
</div>
</div>
<el-button
type=
"primary"
plain
class=
"find-word-open-btn"
@
click=
"handleFindWord('open')"
>
查找
</el-button>
</div>
</div>
</div>
<div
class=
"compare-columns"
v-loading=
"isLoading"
>
<div
class=
"compare-col"
>
<div
class=
"compare-col-body"
>
<div
class=
"term-card"
v-for=
"(pair, index) in comparePairs"
:key=
"getPairKey(pair, index)"
>
<template
v-if=
"pair?.oldTerm"
>
<div
class=
"term-body"
>
<div
class=
"term-main"
>
<div
class=
"term-row term-row-cn"
>
<div
class=
"term-no-cn"
>
第
{{
pair
.
oldTerm
.
tkxh
}}
条.
</div>
<div
class=
"term-content-cn"
v-html=
"getTermContentHtml(pair.oldTerm, 'cn')"
></div>
</div>
<div
class=
"term-row term-row-en"
v-if=
"termsShowOriginal"
>
<div
class=
"term-no-en"
>
Sec.
{{
pair
.
oldTerm
.
tkxh
}}
</div>
<div
class=
"term-content-en"
v-html=
"getTermContentHtml(pair.oldTerm, 'en')"
></div>
</div>
</div>
</div>
</
template
>
<div
v-else
class=
"term-empty"
>
—
</div>
</div>
</div>
</div>
<div
class=
"compare-col"
>
<div
class=
"compare-col-body"
>
<div
class=
"term-card"
v-for=
"(pair, index) in comparePairs"
:key=
"getPairKey(pair, index)"
>
<
template
v-if=
"pair?.newTerm"
>
<div
class=
"term-body"
>
<div
class=
"term-main"
>
<div
class=
"term-row term-row-cn"
>
<div
class=
"term-no-cn"
>
第
{{
pair
.
newTerm
.
tkxh
}}
条.
</div>
<div
class=
"term-content-cn"
v-html=
"getTermContentHtml(pair.newTerm, 'cn')"
></div>
</div>
<div
class=
"term-row term-row-en"
v-if=
"termsShowOriginal"
>
<div
class=
"term-no-en"
>
Sec.
{{
pair
.
newTerm
.
tkxh
}}
</div>
<div
class=
"term-content-en"
v-html=
"getTermContentHtml(pair.newTerm, 'en')"
></div>
</div>
</div>
</div>
</
template
>
<div
v-else
class=
"term-empty"
>
—
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<
script
setup
>
import
{
computed
,
nextTick
,
onMounted
,
ref
,
watch
}
from
"vue"
;
import
{
useRoute
}
from
"vue-router"
;
import
{
getBillContentId
,
getBillTermsCompare
}
from
"@/api/bill"
;
import
{
extractTextEntity
}
from
"@/api/intelligent/index"
;
import
{
ArrowDown
,
ArrowUp
,
Close
}
from
"@element-plus/icons-vue"
;
const
route
=
useRoute
();
const
billId
=
computed
(()
=>
route
.
query
.
billId
);
const
versionOptions
=
ref
([]);
const
oldVersionId
=
ref
(
""
);
const
newVersionId
=
ref
(
""
);
const
diffType
=
ref
(
"CHANGE"
);
const
onlyChinaRelated
=
ref
(
false
);
const
keywordInput
=
ref
(
""
);
const
keyword
=
ref
(
""
);
const
termsHighlight
=
ref
(
true
);
const
termsShowOriginal
=
ref
(
true
);
const
findWordBox
=
ref
(
true
);
const
findWordInputRef
=
ref
();
const
findWordTxt
=
ref
(
""
);
const
findWordKeyword
=
ref
(
""
);
const
findWordNum
=
ref
(
0
);
const
findWordMax
=
ref
(
0
);
const
findWordTimer
=
ref
(
null
);
const
isLoading
=
ref
(
false
);
const
comparePairs
=
ref
([]);
const
compareRequestToken
=
ref
(
0
);
const
handleLoadVersionOptions
=
async
()
=>
{
if
(
!
billId
.
value
)
{
versionOptions
.
value
=
[];
return
;
}
const
res
=
await
getBillContentId
({
id
:
billId
.
value
});
const
rawList
=
Array
.
isArray
(
res
?.
data
)
?
res
.
data
:
[];
const
seen
=
new
Set
();
versionOptions
.
value
=
rawList
.
map
(
item
=>
{
return
{
label
:
item
?.
bbmc
,
value
:
item
?.
bbmc
};
})
.
filter
(
item
=>
item
.
value
)
.
filter
(
item
=>
{
if
(
seen
.
has
(
item
.
value
))
return
false
;
seen
.
add
(
item
.
value
);
return
true
;
});
if
(
!
versionOptions
.
value
.
length
)
{
oldVersionId
.
value
=
""
;
newVersionId
.
value
=
""
;
return
;
}
if
(
!
oldVersionId
.
value
)
{
oldVersionId
.
value
=
versionOptions
.
value
[
0
].
value
;
}
if
(
!
newVersionId
.
value
)
{
newVersionId
.
value
=
versionOptions
.
value
[
versionOptions
.
value
.
length
-
1
].
value
;
}
};
const
handleSearchSubmit
=
()
=>
{
keyword
.
value
=
(
keywordInput
.
value
||
""
).
trim
();
};
const
normalizeDiffType
=
value
=>
{
if
(
value
===
"ADD"
)
return
"ADD"
;
if
(
value
===
"DELETE"
)
return
"DELETE"
;
return
"CHANGE"
;
};
const
loadComparePairs
=
async
()
=>
{
if
(
!
billId
.
value
||
!
oldVersionId
.
value
||
!
newVersionId
.
value
)
{
comparePairs
.
value
=
[];
return
;
}
const
currentToken
=
++
compareRequestToken
.
value
;
isLoading
.
value
=
true
;
try
{
const
params
=
{
billId
:
billId
.
value
,
oldVersionId
:
oldVersionId
.
value
,
newVersionId
:
newVersionId
.
value
,
diffType
:
normalizeDiffType
(
diffType
.
value
),
cRelated
:
onlyChinaRelated
.
value
?
"Y"
:
"N"
,
keyword
:
keyword
.
value
};
const
res
=
await
getBillTermsCompare
(
params
);
if
(
currentToken
!==
compareRequestToken
.
value
)
return
;
const
list
=
Array
.
isArray
(
res
?.
data
?.
list
)
?
res
.
data
.
list
:
Array
.
isArray
(
res
?.
list
)
?
res
.
list
:
[];
comparePairs
.
value
=
list
;
await
ensureEntitiesForPairs
(
comparePairs
.
value
);
}
catch
(
error
)
{
if
(
currentToken
!==
compareRequestToken
.
value
)
return
;
comparePairs
.
value
=
[];
}
finally
{
if
(
currentToken
===
compareRequestToken
.
value
)
{
isLoading
.
value
=
false
;
}
}
};
const
getPairKey
=
(
pair
,
index
)
=>
{
const
oldKey
=
pair
?.
oldTerm
?.
ywid
??
pair
?.
oldTerm
?.
id
??
pair
?.
oldTerm
?.
tkxh
??
""
;
const
newKey
=
pair
?.
newTerm
?.
ywid
??
pair
?.
newTerm
?.
id
??
pair
?.
newTerm
?.
tkxh
??
""
;
return
`
${
oldKey
||
"old"
}
__
${
newKey
||
"new"
}
__
${
index
}
`
;
};
const
escapeHtml
=
value
=>
{
const
str
=
String
(
value
??
""
);
return
str
.
replace
(
/&/g
,
"&"
)
.
replace
(
/</g
,
"<"
)
.
replace
(
/>/g
,
">"
)
.
replace
(
/"/g
,
"""
)
.
replace
(
/'/g
,
"'"
);
};
const
escapeRegExp
=
text
=>
{
return
String
(
text
||
""
).
replace
(
/
[
.*+?^${}()|[
\]\\]
/g
,
"
\\
$&"
);
};
const
getSearchRanges
=
(
text
,
searchTerm
)
=>
{
const
rawText
=
String
(
text
??
""
);
const
term
=
String
(
searchTerm
??
""
).
trim
();
if
(
!
rawText
||
!
term
)
return
[];
const
ranges
=
[];
const
reg
=
new
RegExp
(
escapeRegExp
(
term
),
"g"
);
let
match
;
while
((
match
=
reg
.
exec
(
rawText
))
!==
null
)
{
ranges
.
push
({
start
:
match
.
index
,
end
:
match
.
index
+
match
[
0
].
length
});
if
(
match
[
0
].
length
===
0
)
reg
.
lastIndex
+=
1
;
}
return
ranges
;
};
const
normalizeEntities
=
entities
=>
{
const
list
=
Array
.
isArray
(
entities
)
?
entities
:
[];
return
list
.
map
(
item
=>
{
return
{
text_span
:
String
(
item
?.
text_span
??
""
).
trim
(),
type
:
String
(
item
?.
type
??
""
).
trim
()
};
})
.
filter
(
item
=>
item
.
text_span
);
};
const
getEntityRanges
=
(
text
,
entities
)
=>
{
const
ranges
=
[];
const
rawText
=
String
(
text
??
""
);
if
(
!
rawText
)
return
ranges
;
const
list
=
normalizeEntities
(
entities
).
sort
((
a
,
b
)
=>
b
.
text_span
.
length
-
a
.
text_span
.
length
);
for
(
const
ent
of
list
)
{
let
startIndex
=
0
;
while
(
startIndex
<
rawText
.
length
)
{
const
idx
=
rawText
.
indexOf
(
ent
.
text_span
,
startIndex
);
if
(
idx
===
-
1
)
break
;
ranges
.
push
({
start
:
idx
,
end
:
idx
+
ent
.
text_span
.
length
,
ent
});
startIndex
=
idx
+
ent
.
text_span
.
length
;
}
}
ranges
.
sort
((
a
,
b
)
=>
a
.
start
-
b
.
start
||
b
.
end
-
a
.
end
);
const
merged
=
[];
let
lastEnd
=
0
;
for
(
const
r
of
ranges
)
{
if
(
r
.
start
<
lastEnd
)
continue
;
merged
.
push
(
r
);
lastEnd
=
r
.
end
;
}
return
merged
;
};
const
buildHighlightedHtml
=
(
text
,
entities
,
enableHighlight
,
searchTerm
)
=>
{
const
rawText
=
String
(
text
??
""
);
if
(
!
rawText
)
return
""
;
const
safeText
=
escapeHtml
(
rawText
).
replace
(
/
\n
/g
,
"<br />"
);
const
term
=
String
(
searchTerm
??
""
).
trim
();
const
enableSearch
=
Boolean
(
term
);
if
(
!
enableHighlight
&&
!
enableSearch
)
return
safeText
;
const
ranges
=
getEntityRanges
(
rawText
,
entities
);
const
searchRanges
=
enableSearch
?
getSearchRanges
(
rawText
,
term
)
:
[];
if
(
!
ranges
.
length
&&
!
searchRanges
.
length
)
return
safeText
;
const
merged
=
[];
for
(
const
r
of
ranges
)
{
merged
.
push
({
start
:
r
.
start
,
end
:
r
.
end
,
type
:
"entity"
,
ent
:
r
.
ent
});
}
for
(
const
r
of
searchRanges
)
{
const
overlapsEntity
=
ranges
.
some
(
er
=>
r
.
start
<
er
.
end
&&
r
.
end
>
er
.
start
);
if
(
overlapsEntity
)
continue
;
merged
.
push
({
start
:
r
.
start
,
end
:
r
.
end
,
type
:
"search"
});
}
merged
.
sort
((
a
,
b
)
=>
a
.
start
-
b
.
start
||
b
.
end
-
a
.
end
);
let
html
=
""
;
let
cursor
=
0
;
for
(
const
r
of
merged
)
{
if
(
cursor
<
r
.
start
)
{
html
+=
escapeHtml
(
rawText
.
slice
(
cursor
,
r
.
start
));
}
const
spanText
=
rawText
.
slice
(
r
.
start
,
r
.
end
);
if
(
r
.
type
===
"entity"
)
{
const
type
=
escapeHtml
(
r
.
ent
?.
type
??
""
);
html
+=
`<span class="term-entity" data-entity-type="
${
type
}
">
${
escapeHtml
(
spanText
)}
</span>`
;
}
else
{
html
+=
`<span class="term-find-highlight">
${
escapeHtml
(
spanText
)}
</span>`
;
}
cursor
=
r
.
end
;
}
if
(
cursor
<
rawText
.
length
)
{
html
+=
escapeHtml
(
rawText
.
slice
(
cursor
));
}
return
html
.
replace
(
/
\n
/g
,
"<br />"
);
};
const
termEntityCache
=
ref
(
new
Map
());
const
entityRequestToken
=
ref
(
0
);
const
getTermEntityKey
=
(
term
,
lang
)
=>
{
const
baseKey
=
term
?.
ywid
??
term
?.
id
??
term
?.
tkxh
??
""
;
return
`
${
baseKey
}
__
${
lang
}
`
;
};
const
ensureEntitiesForPairs
=
async
pairs
=>
{
if
(
!
termsHighlight
.
value
)
return
;
const
list
=
Array
.
isArray
(
pairs
)
?
pairs
:
[];
if
(
!
list
.
length
)
return
;
const
currentToken
=
++
entityRequestToken
.
value
;
const
tasks
=
[];
const
maxTasks
=
40
;
for
(
const
pair
of
list
)
{
const
terms
=
[
pair
?.
oldTerm
,
pair
?.
newTerm
].
filter
(
Boolean
);
for
(
const
term
of
terms
)
{
const
cnKey
=
getTermEntityKey
(
term
,
"cn"
);
const
enKey
=
getTermEntityKey
(
term
,
"en"
);
if
(
!
termEntityCache
.
value
.
has
(
cnKey
)
&&
String
(
term
?.
fynr
??
""
).
trim
())
{
tasks
.
push
({
key
:
cnKey
,
text
:
term
.
fynr
});
}
if
(
!
termEntityCache
.
value
.
has
(
enKey
)
&&
String
(
term
?.
ywnr
??
""
).
trim
())
{
tasks
.
push
({
key
:
enKey
,
text
:
term
.
ywnr
});
}
if
(
tasks
.
length
>=
maxTasks
)
break
;
}
if
(
tasks
.
length
>=
maxTasks
)
break
;
}
if
(
!
tasks
.
length
)
return
;
try
{
const
results
=
await
Promise
.
all
(
tasks
.
map
(
async
item
=>
{
const
res
=
await
extractTextEntity
(
item
.
text
);
const
entities
=
normalizeEntities
(
res
?.
result
??
res
?.
data
?.
result
??
res
?.
data
??
res
);
return
{
key
:
item
.
key
,
entities
};
})
);
if
(
currentToken
!==
entityRequestToken
.
value
)
return
;
for
(
const
r
of
results
)
{
termEntityCache
.
value
.
set
(
r
.
key
,
r
.
entities
);
}
}
catch
(
error
)
{
if
(
currentToken
!==
entityRequestToken
.
value
)
return
;
}
};
const
getTermContentHtml
=
(
term
,
lang
)
=>
{
const
raw
=
lang
===
"en"
?
term
?.
ywnr
:
term
?.
fynr
;
const
key
=
getTermEntityKey
(
term
,
lang
);
const
entities
=
termEntityCache
.
value
.
get
(
key
)
||
[];
return
buildHighlightedHtml
(
raw
,
entities
,
termsHighlight
.
value
,
findWordKeyword
.
value
);
};
const
countOccurrences
=
(
text
,
searchTerm
)
=>
{
const
rawText
=
String
(
text
??
""
);
const
term
=
String
(
searchTerm
??
""
).
trim
();
if
(
!
rawText
||
!
term
)
return
0
;
const
reg
=
new
RegExp
(
escapeRegExp
(
term
),
"g"
);
const
matches
=
rawText
.
match
(
reg
);
return
matches
?
matches
.
length
:
0
;
};
const
updateActiveFindHighlight
=
()
=>
{
const
spans
=
document
.
querySelectorAll
(
"span.term-find-highlight"
);
spans
.
forEach
((
span
,
index
)
=>
{
if
(
index
+
1
===
findWordNum
.
value
)
{
span
.
classList
.
add
(
"is-active"
);
span
.
scrollIntoView
({
block
:
"center"
});
}
else
{
span
.
classList
.
remove
(
"is-active"
);
}
});
};
const
doUpdateFindWord
=
async
()
=>
{
findWordNum
.
value
=
0
;
findWordMax
.
value
=
0
;
const
term
=
String
(
findWordTxt
.
value
||
""
).
trim
();
findWordKeyword
.
value
=
term
;
if
(
!
term
)
{
await
nextTick
();
return
;
}
const
list
=
Array
.
isArray
(
comparePairs
.
value
)
?
comparePairs
.
value
:
[];
for
(
const
pair
of
list
)
{
const
terms
=
[
pair
?.
oldTerm
,
pair
?.
newTerm
].
filter
(
Boolean
);
for
(
const
t
of
terms
)
{
findWordMax
.
value
+=
countOccurrences
(
t
?.
fynr
,
term
);
if
(
termsShowOriginal
.
value
)
{
findWordMax
.
value
+=
countOccurrences
(
t
?.
ywnr
,
term
);
}
}
}
if
(
findWordMax
.
value
>
0
)
{
await
nextTick
();
findWordNum
.
value
=
1
;
updateActiveFindHighlight
();
}
};
const
handleUpdateWord
=
()
=>
{
if
(
findWordTimer
.
value
)
{
clearTimeout
(
findWordTimer
.
value
);
findWordTimer
.
value
=
null
;
}
findWordTimer
.
value
=
setTimeout
(()
=>
{
doUpdateFindWord
();
},
300
);
};
const
handleFindWord
=
event
=>
{
switch
(
event
)
{
case
"open"
:
findWordBox
.
value
=
true
;
nextTick
(()
=>
{
findWordInputRef
.
value
?.
focus
?.();
});
break
;
case
"last"
:
if
(
findWordMax
.
value
>
1
)
{
findWordNum
.
value
=
findWordNum
.
value
===
1
?
findWordMax
.
value
:
findWordNum
.
value
-
1
;
updateActiveFindHighlight
();
}
break
;
case
"next"
:
if
(
findWordMax
.
value
>
1
)
{
findWordNum
.
value
=
findWordNum
.
value
===
findWordMax
.
value
?
1
:
findWordNum
.
value
+
1
;
updateActiveFindHighlight
();
}
break
;
case
"close"
:
findWordBox
.
value
=
false
;
findWordTxt
.
value
=
""
;
findWordKeyword
.
value
=
""
;
findWordNum
.
value
=
0
;
findWordMax
.
value
=
0
;
break
;
}
};
watch
(
[
billId
,
oldVersionId
,
newVersionId
,
diffType
,
onlyChinaRelated
,
keyword
],
()
=>
{
loadComparePairs
();
},
{
immediate
:
true
}
);
watch
(
termsHighlight
,
()
=>
{
ensureEntitiesForPairs
(
comparePairs
.
value
);
});
watch
(
[
comparePairs
,
termsShowOriginal
],
()
=>
{
if
(
!
findWordBox
.
value
)
return
;
doUpdateFindWord
();
},
{
deep
:
true
}
);
onMounted
(
async
()
=>
{
await
handleLoadVersionOptions
();
});
</
script
>
<
style
lang=
"scss"
scoped
>
.version-compare-wrap
{
display
:
flex
;
flex-direction
:
column
;
row-gap
:
16px
;
width
:
100%
;
background
:
#ffffff
;
box-shadow
:
0
2px
8px
rgba
(
0
,
0
,
0
,
0
.04
);
padding
:
16px
75px
;
box-sizing
:
border-box
;
}
.compare-top
{
display
:
grid
;
grid-template-columns
:
1fr
1fr
;
gap
:
37px
;
margin-top
:
0
;
}
.compare-top-col
{
display
:
flex
;
align-items
:
center
;
gap
:
22px
;
background
:
transparent
;
border-radius
:
0
;
padding
:
0
;
box-shadow
:
none
;
}
.compare-top-label
{
font-size
:
18px
;
font-weight
:
700
;
color
:
var
(
--
text-primary-80-color
);
white-space
:
nowrap
;
}
.compare-top-select
{
flex
:
1
;
:deep
(
.el-select__wrapper
)
{
background-color
:
rgb
(
246
,
250
,
255
);
box-shadow
:
0
0
0
1px
var
(
--
color-primary-35
)
inset
;
}
:deep
(
.el-select__wrapper.is-hovering
)
{
box-shadow
:
0
0
0
1px
var
(
--
color-primary-35
)
inset
;
}
:deep
(
.el-select__wrapper.is-focused
)
{
box-shadow
:
0
0
0
1px
var
(
--
color-primary-35
)
inset
;
}
:deep
(
.el-select__selected-item
),
:deep
(
.el-select__placeholder
),
:deep
(
.el-select__input
),
:deep
(
.el-select__caret
),
:deep
(
.el-select__suffix
),
:deep
(
.el-select__icon
)
{
color
:
var
(
--
color-primary-100
);
}
}
.compare-tools
{
height
:
66px
;
border-bottom
:
1px
solid
var
(
--
border-black-5
);
border-top
:
1px
solid
var
(
--
border-black-5
);
background
:
transparent
;
display
:
flex
;
align-items
:
center
;
gap
:
16px
;
flex-wrap
:
wrap
;
}
.compare-tools-tabs
{
flex
:
0
0
auto
;
:deep
(
.el-tabs__item
)
{
font-size
:
18px
;
font-weight
:
700
;
}
}
.compare-tools-actions
{
display
:
flex
;
align-items
:
center
;
gap
:
16px
;
flex-wrap
:
wrap
;
margin-left
:
auto
;
justify-content
:
flex-end
;
}
.compare-tools-switches
{
display
:
flex
;
align-items
:
center
;
gap
:
16px
;
}
.compare-tools-switch
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
8px
;
.label
{
font-size
:
14px
;
color
:
rgba
(
95
,
101
,
108
,
1
);
}
}
.compare-tools-search
{
display
:
inline-flex
;
align-items
:
center
;
gap
:
8px
;
}
.find-word-box
{
width
:
430px
;
height
:
60px
;
border
:
1px
solid
rgba
(
230
,
231
,
232
,
1
);
background-color
:
#ffffff
;
border-radius
:
6px
;
display
:
flex
;
align-items
:
center
;
position
:
absolute
;
right
:
0
;
top
:
-68px
;
box-shadow
:
0
8px
20px
rgba
(
0
,
0
,
0
,
0
.06
);
.find-word-input
{
flex
:
1
;
min-width
:
0
;
padding-left
:
8px
;
}
.find-word-limit
{
border-right
:
solid
1px
rgba
(
230
,
231
,
232
,
1
);
color
:
#5f656c
;
padding
:
0
16px
0
8px
;
white-space
:
nowrap
;
}
.find-word-icon
{
padding
:
6px
10px
;
margin
:
0
2px
;
cursor
:
pointer
;
display
:
inline-flex
;
align-items
:
center
;
}
}
.find-word-wrap
{
position
:
relative
;
display
:
inline-flex
;
align-items
:
center
;
}
.find-word-open-btn
{
height
:
32px
;
}
:deep
(
span
.term-find-highlight
)
{
background-color
:
#ffff00
;
}
:deep
(
span
.term-find-highlight.is-active
)
{
background-color
:
#ff9632
;
}
.compare-columns
{
margin-top
:
16px
;
display
:
grid
;
grid-template-columns
:
1fr
1fr
;
gap
:
16px
;
}
.compare-col
{
background
:
transparent
;
border-radius
:
0
;
box-shadow
:
none
;
display
:
flex
;
flex-direction
:
column
;
min-height
:
520px
;
}
.compare-col-title
{
padding
:
0
0
12px
;
border-bottom
:
1px
solid
rgba
(
240
,
242
,
244
,
1
);
font-size
:
16px
;
font-weight
:
700
;
color
:
rgba
(
59
,
65
,
75
,
1
);
}
.compare-col-body
{
flex
:
1
;
min-height
:
0
;
overflow
:
auto
;
padding
:
16px
0
0
;
display
:
flex
;
flex-direction
:
column
;
gap
:
12px
;
}
.term-card
{
width
:
100%
;
box-sizing
:
border-box
;
border-radius
:
2px
;
background
:
rgb
(
247
,
248
,
249
);
display
:
flex
;
align-items
:
flex-start
;
position
:
relative
;
padding
:
16px
;
}
.term-card
:nth-child
(
2n-1
)
{
background
:
rgba
(
249
,
250
,
252
,
1
);
}
.term-empty
{
width
:
100%
;
min-height
:
48px
;
display
:
flex
;
align-items
:
center
;
justify-content
:
center
;
color
:
rgba
(
132
,
136
,
142
,
1
);
}
.term-body
{
display
:
flex
;
column-gap
:
18px
;
flex
:
1
;
min-width
:
0
;
width
:
100%
;
}
.term-main
{
flex
:
1
;
min-width
:
0
;
display
:
flex
;
flex-direction
:
column
;
row-gap
:
6px
;
}
.term-row
{
display
:
flex
;
align-items
:
flex-start
;
column-gap
:
18px
;
}
.term-no-cn
{
font-size
:
16px
;
font-weight
:
700
;
line-height
:
24px
;
color
:
var
(
--
color-primary-100
);
white-space
:
nowrap
;
width
:
90px
;
text-align
:
center
;
flex
:
0
0
90px
;
}
.term-no-en
{
font-size
:
14px
;
font-weight
:
400
;
line-height
:
24px
;
color
:
var
(
--
color-primary-100
);
white-space
:
nowrap
;
width
:
90px
;
text-align
:
center
;
flex
:
0
0
90px
;
}
.term-content-cn
{
flex
:
1
;
font-size
:
16px
;
font-weight
:
700
;
line-height
:
24px
;
color
:
var
(
--
text-primary-80-color
);
:deep
(
.term-entity
)
{
display
:
inline
;
padding
:
0
2px
;
border-radius
:
4px
;
background
:
rgba
(
255
,
213
,
79
,
0
.35
);
box-shadow
:
inset
0
0
0
1px
rgba
(
255
,
193
,
7
,
0
.25
);
}
}
.term-content-en
{
flex
:
1
;
font-size
:
14px
;
font-weight
:
400
;
line-height
:
24px
;
color
:
var
(
--
text-primary-65-color
);
:deep
(
.term-entity
)
{
display
:
inline
;
padding
:
0
2px
;
border-radius
:
4px
;
background
:
rgba
(
255
,
213
,
79
,
0
.28
);
box-shadow
:
inset
0
0
0
1px
rgba
(
255
,
193
,
7
,
0
.2
);
}
}
@media
(
max-width
:
1680px
)
{
.compare-columns
,
.compare-top
{
grid-template-columns
:
1fr
;
}
.compare-tools-actions
{
width
:
100%
;
}
.compare-tools-search
{
width
:
100%
;
}
.find-word-box
{
width
:
100%
;
}
}
</
style
>
编写
预览
Markdown
格式
0%
重试
或
添加新文件
添加附件
取消
您添加了
0
人
到此讨论。请谨慎行事。
请先完成此评论的编辑!
取消
请
注册
或者
登录
后发表评论