提交 21e931b8 authored 作者: yanpeng's avatar yanpeng

merge decree chartChain

VITE_BASE_API= '/api'
\ No newline at end of file
VITE_BASE_API= '/api'
# 图表解读等 /aiAnalysis 代理服务密钥(勿提交到公开仓库时可改为本地 .env.local)
VITE_AI_ANALYSIS_API_KEY=aircasKEY19491001
\ No newline at end of file
# 线上地址
VITE_BASE_API= '/api'
\ No newline at end of file
VITE_BASE_API= '/api'
# 图表解读等 AI 服务(与部署环境一致时填写)
VITE_AI_ANALYSIS_API_KEY=aircasKEY19491001
\ No newline at end of file
差异被折叠。
......@@ -43,7 +43,10 @@
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"sass": "^1.93.3",
"tailwindcss": "^3.4.17",
"unplugin-auto-import": "^0.17.0",
"unplugin-vue-components": "^0.26.0",
"vite": "^5.0.0"
......
const baseUrl = `http://8.140.26.4:9085/`
\ No newline at end of file
const baseUrl = `http://8.140.26.4:9085`
\ No newline at end of file
......@@ -251,7 +251,7 @@ body {
/* 可点击文本 鼠标悬浮样式 */
#app .text-click-hover:hover {
text-decoration: underline;
color: rgb(5, 95, 194);
color: var(--color-primary-100);
cursor: pointer;
}
/* #endregion 公共样式类名 */
......
import request from "@/api/request.js";
import { getToken } from "@/api/request.js";
const CHART_INTERPRETATION_URL = "/aiAnalysis/chart_interpretation";
const API_KEY = "aircasKEY19491001";
/**
* 从模型返回的 markdown code fence 中提取数组 JSON
* 例如:```json\n[ ... ]\n```
* @param {string} buffer
* @returns {unknown[]}
*/
function parseChartInterpretationArray(buffer) {
// 可能带有前后空白、换行、以及 ```/```json 包裹
let cleaned = String(buffer || "").trim();
// 移除开头 fence
cleaned = cleaned.replace(/^```(?:json)?\s*/i, "");
// 移除结尾 fence(允许末尾有换行)
cleaned = cleaned.replace(/```[\s\r\n]*$/i, "").trim();
// 优先直接解析
try {
const parsed = JSON.parse(cleaned);
if (Array.isArray(parsed)) return parsed;
} catch (_) { }
// 兜底:从 first '[' 到 last ']' 截取
const start = cleaned.indexOf("[");
const end = cleaned.lastIndexOf("]");
if (start !== -1 && end !== -1 && end > start) {
const arrStr = cleaned.slice(start, end + 1);
const parsed = JSON.parse(arrStr);
if (Array.isArray(parsed)) return parsed;
}
throw new Error("无法解析图表解读 JSON 数组");
}
// 图表解读(流式)
/**
* @param {text}
* 图表解读(SSE 流式)
* @param {object} data - 请求体
* @param {object} [options] - 可选配置
* @param {function(string): void} [options.onChunk] - 每收到一条 SSE 消息时回调,参数为当前 chunk 的 text
* @returns {Promise<{data: unknown[]}>}
*/
export function getChartAnalysis(data) {
return request({
method: 'POST',
url: `/aiAnalysis/chart_interpretation`,
data,
})
}
\ No newline at end of file
export function getChartAnalysis(data, options = {}) {
const { onChunk } = options;
return new Promise((resolve, reject) => {
let buffer = "";
let settled = false;
const abortController = new AbortController();
const safeResolve = value => {
if (settled) return;
settled = true;
resolve(value);
};
const safeReject = err => {
if (settled) return;
settled = true;
reject(err);
};
(async () => {
try {
const { fetchEventSource } = await import("@microsoft/fetch-event-source");
await fetchEventSource(CHART_INTERPRETATION_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
"Cache-Control": "no-cache",
"X-API-Key": API_KEY,
// 后端同项目其它接口使用 token 字段名(axios 拦截器里就是这样注入的)
token: getToken()
},
body: JSON.stringify(data),
signal: abortController.signal,
openWhenHidden: true,
retryDelay: 1000,
maxRetries: 2,
onopen: response => {
const contentType = response.headers.get("content-type") || "";
if (!contentType.includes("text/event-stream")) {
throw new Error("SSE连接格式异常:content-type 不是 text/event-stream");
}
},
onmessage: event => {
const raw = (event?.data || "").trim();
if (!raw) return;
if (raw === "[DONE]") return;
let chunk = "";
// 后端返回格式示例:{"text":"```"} / {"text":"json\n[\n"}
try {
const msg = JSON.parse(raw);
if (msg && typeof msg === "object" && "text" in msg) {
chunk = String(msg.text ?? "");
buffer += chunk;
} else {
chunk = raw;
buffer += raw;
}
} catch (e) {
chunk = raw;
buffer += raw;
}
// 每收到一条消息即回调,用于流式渲染
if (chunk && typeof onChunk === "function") {
onChunk(chunk);
}
// 如果 buffer 已经拼完 markdown code fence,则提前解析并中断连接
const trimmed = buffer.trim();
if (trimmed.endsWith("```")) {
try {
const arr = parseChartInterpretationArray(trimmed);
safeResolve({ data: arr });
abortController.abort();
} catch (_) { }
}
},
onclose: () => {
try {
const arr = parseChartInterpretationArray(buffer);
safeResolve({ data: arr });
} catch (e) {
safeReject(e);
}
},
onerror: error => {
if (error?.name === "AbortError") return true;
safeReject(error);
return true;
}
});
} catch (error) {
safeReject(error);
}
})();
});
}
......@@ -212,10 +212,25 @@ export function getBillTermsCompare(params) {
* @param {billId,content,currentPage,currentVersion,isCn,originalVersion,pageSize,status}
* @header token
*/
export function getBillVersionCompare(params) {
export function getBillVersionCompare(params, config = {}) {
return request({
method: "GET",
url: "/api/billInfoBean/versionCompare",
params,
signal: config.signal,
});
}
// 版本对比-根据筛选条件获取变更统计
/**
* @param {billId,content,currentVersion,isCn,originalVersion}
* @header token
*/
export function getBillVersionCompareStatistics(params, config = {}) {
return request({
method: "GET",
url: "/api/billInfoBean/versionCompareStatistics",
params,
signal: config.signal,
});
}
差异被折叠。
......@@ -129,6 +129,18 @@ export function getCharacterRelation(params) {
params,
})
}
// 获取人物教育履历
/**
* @param {personId}
* @header token
*/
export function getCharacterReducationResume(params) {
return request({
method: 'GET',
url: `/api/personHomepage/educationResume/${params.personId}`,
params,
})
}
// 获取相关实体
/**
......@@ -142,18 +154,14 @@ export function getCharacterRelatedEntity(params) {
params,
})
}
// 获取人物教育履历
/**
* @param {personId}
* @header token
*/
export function getCharacterReducationResume(params) {
export function getareaType(params) {
return request({
method: 'GET',
url: `/api/personHomepage/educationResume/${params.personId}`,
params,
url: `/api/commonDict/areaType`,
params
})
}
export async function getFindingsReport(personId, params = {}) {
const queryParts = []
......@@ -192,11 +200,4 @@ export async function getSubjectList(params) {
method: 'GET',
params
})
}
export function getareaType(params) {
return request({
method: 'GET',
url: `/api/commonDict/areaType`,
params
})
}
\ No newline at end of file
}
......@@ -5,6 +5,7 @@ export function getDepartmentList(params) {
return request({
method: 'GET',
url: `/api/administrativeDict/department`,
params
})
}
......@@ -27,34 +28,36 @@ export function getDecreeRiskSignal(params) {
// 行政令发布频度
export function getDecreeYearOrder(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderOverview/yearOrder/${params.year}`,
params
method: 'POST',
url: `/api/administrativeOrderOverview/yearOrder`,
data: params
})
}
// 政令涉及领域
export function getDecreeArea(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderOverview/industry/${params.year}`,
params
method: 'POST',
url: `/api/administrativeOrderOverview/industry`,
data: params
})
}
// 关键行政令
export function getKeyDecree(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderOverview/action?pageSize=${params.pageSize}&pageNum=${params.pageNum}`,
method: 'POST',
url: `/api/administrativeOrderOverview/action`,
data: params
})
}
// 政令重点条款
export function getDecreeKeyInstruction() {
export function getDecreeKeyInstruction(params) {
return request({
method: 'GET',
method: 'POST',
url: `/api/administrativeOrderOverview/instruction`,
data: params
})
}
......@@ -85,4 +88,22 @@ export function getDecreeTypeList() {
method: 'GET',
url: `/api/administrativeDict/type`,
})
}
// 关键机构
export function getKeyOrganization() {
return request({
method: 'GET',
url: `/api/commonFeature/keyOrganization`,
})
}
// AI智能总结
export function getChartInterpretation(params) {
return request({
method: 'POST',
url: `/aiAnalysis/chart_interpretation`,
headers: {"X-API-Key": "aircasKEY19491001"},
data: params
})
}
\ No newline at end of file
......@@ -20,6 +20,39 @@ export function getDecreehylyList() {
})
}
// 获取受影响实体列表
export function getDecreeEntities(params) {
return request({
method: 'POST',
url: `/api/administrativeOrderInfo/relatedEntities`,
data: params
})
}
// 获取实体产业链列表
export function getDecreeRelatedChain(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderInfo/relatedChain/${params.id}`,
})
}
// 获取产业链节点列表
export function getDecreeChainNodes(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderInfo/relatedChainNodes/${params.id}`,
})
}
// 获取实体关系节点列表
export function getDecreeRelatedEntitie(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderInfo/listRelatedEntitie/${params.id}`,
})
}
// 根据政行业领域ID获取公司列表
/**
* @param {cRelated, id}
......
......@@ -77,4 +77,20 @@ export function getDecreeReport(params) {
method: 'GET',
url: `/api/administrativeOrderInfo/contentUrl/${params.id}`,
})
}
// 政令关键词云
export function getKeyWordUp() {
return request({
method: 'GET',
url: `/api/element/getKeyWordUp/2025-01-01`,
})
}
// 报告内容摘要
export function getOverview(params) {
return request({
method: 'GET',
url: `/api/administrativeOrderInfo/overview/${params.id}`,
})
}
\ No newline at end of file
......@@ -10,12 +10,16 @@ export class TextEntity {
type: string;
}
// 智能化:提取文本实体
export function extractTextEntity(text: string): Promise<IntelligentResultWrapper<TextEntity[]>> {
export function extractTextEntity(
text: string,
config: { signal?: AbortSignal } = {}
): Promise<IntelligentResultWrapper<TextEntity[]>> {
return request({
url: `${INTELLECTUAL_API}/extract-entity`,
method: "POST",
data: {
text
}
},
signal: config.signal
});
}
......@@ -51,6 +51,14 @@ service.interceptors.request.use(config => {
config.headers['token'] = token
// config.headers['Authorization'] = `Bearer ${token}` // 如果后端需要Bearer格式可以使用这个
}
// 图表解读等 AI 分析服务(Vite 代理 /aiAnalysis)需要 X-API-Key
const reqUrl = String(config.url || '')
if (reqUrl.includes('aiAnalysis')) {
const aiApiKey = import.meta.env.VITE_AI_ANALYSIS_API_KEY
if (aiApiKey) {
config.headers['X-API-Key'] = aiApiKey
}
}
return config
}, error => {
console.log(error)
......@@ -82,8 +90,14 @@ service.interceptors.response.use(
// 重复请求触发的取消不提示错误
if (isCanceledError) return Promise.reject(error)
// 处理token过期或无效的情况
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
// 处理token过期或无效的情况(排除 AI 分析服务:其 401 多为 API Key 问题)
const errUrl = String(error.config?.url || '')
const isAiAnalysisRequest = errUrl.includes('aiAnalysis')
if (
error.response &&
(error.response.status === 401 || error.response.status === 403) &&
!isAiAnalysisRequest
) {
ElMessage({
message: 'Token已过期,请重新登录',
type: 'error',
......
......@@ -125,8 +125,7 @@ export function getPersonSummaryInfo(params) {
url: `/api/personHomepage/summaryInfo/${params.personId}`,
})
}
// 获取人物全局信息 通过personId 获取personType
export function getareaType(params) {
return request({
method: 'GET',
......
......@@ -10,6 +10,13 @@ export function getThinkTankList() {
})
}
export function getAllThinkTankList(params) {
return request({
method: 'GET',
url: '/api/thinkTankOverview/thinkTanks/page',
params: params
})
}
//智库概览:获取智库发布
export function getNewReport() {
return request({
......@@ -28,13 +35,19 @@ export function getThinkTankRiskSignal() {
})
}
// 政策建议趋势分布
/**
* 政策建议趋势分布(数量变化趋势)
* @param {{ startDate: string, endDate: string }} params - 如 2024-01-01 ~ 2024-12-31
*/
export function getThinkTankPolicyIndustryChange(params) {
return request({
method: 'GET',
url: `/api/thinkTankOverview/policyIndustryChange/${params}`,
})
method: "GET",
url: `/api/thinkTankOverview/policyIndustryChange`,
params: {
startDate: params.startDate,
endDate: params.endDate
}
});
}
// 政策建议领域分布
......@@ -113,10 +126,44 @@ export function getThinkDynamicsReportType() {
//智库动态:获取智库报告
export function getThinkDynamicsReport(params) {
const safe = params || {}
// 兼容两种调用方式:
// 1) { id, startDate, authorName, currentPage, pageSize, researchTypeIds, searchText, sortFun, years }
// 2) { id, startDate, parmas: { authorName, currentPage, pageSize, researchTypeIds, searchText, sortFun, years } }
const inner = safe.parmas && typeof safe.parmas === 'object' ? safe.parmas : {}
const id = safe.id
const startDate = safe.startDate
const authorName = inner.authorName ?? safe.authorName ?? ''
const currentPage = inner.currentPage ?? safe.currentPage ?? 1
const pageSize = inner.pageSize ?? safe.pageSize ?? 10
const researchTypeIds = inner.researchTypeIds ?? safe.researchTypeIds ?? ''
const searchText = inner.searchText ?? safe.searchText ?? ''
const sortFun = inner.sortFun ?? safe.sortFun ?? false
const years = inner.years ?? safe.years ?? null
const query = { currentPage, pageSize, sortFun }
// 仅在有值时才传,避免后端按空值筛选
if (authorName) query.authorName = authorName
if (researchTypeIds) query.researchTypeIds = researchTypeIds
if (searchText) query.searchText = searchText
if (years !== null && years !== undefined && years !== '') query.years = years
return request({
method: 'GET',
url: `/api/thinkTankInfo/report/${params.id}/${params.startDate}`,
params: params.parmas
url: `/api/thinkTankInfo/report/${id}/${startDate}`,
params: query
})
}
// 智库领域观点分析(流式)
// [POST] 8.140.26.4:10029/report-domain-view-analysis
export function postReportDomainViewAnalysis(data) {
return request({
method: 'POST',
// 开发环境走 Vite 同源代理,避免浏览器跨域(见 vite.config.js:/intelligent-api -> 8.140.26.4:10029)
url: '/intelligent-api/report-domain-view-analysis',
data
})
}
......@@ -152,15 +199,31 @@ export function getThinkPolicyIndustryChange(params) {
})
}
//获取智库政策
/**
* 获取智库政策(政策追踪列表)
* GET /api/thinkTankInfo/policy
* Query: thinkTankId, startDate, endDate, orgIds, domainIds(科技领域/智库领域,逗号分隔 id), pageNum, pageSize, sortField, sortOrder, sortFun, reportId 等
*/
export function getThinkPolicy(params) {
return request({
method: 'GET',
url: `/api/thinkTankInfo/policy/${params.id}/${params.startDate}`,
url: '/api/thinkTankInfo/policy',
params
})
}
/**
* 政府机构字典(政策追踪-涉及部门筛选项)
* GET /api/commonDict/gov/agency
* @returns {Promise<{ code: number, data: Array<{ id: string, name: string }> }>}
*/
export function getGovAgencyDict() {
return request({
method: 'GET',
url: '/api/commonDict/gov/agency'
})
}
//智库百科基本信息
export function getThinkTankInfoBasic(params) {
return request({
......@@ -299,9 +362,26 @@ export function getThinkTankReportPolicy(params) {
//获取相关政策动态
export function getThinkTankReportPolicyAction(params) {
return request({
method: 'GET',
url: `/api/thinkTankReport/policyAction/${params}`,
const {
reportId,
currentPage,
pageSize,
keyword = "",
orgIds = "",
// 新增:按科技领域 / 标签过滤
industryName = ""
} = params;
return request({
method: 'GET',
url: `/api/thinkTankReport/policyDetail/${reportId}`,
params: {
currentPage,
pageSize,
keyword,
// 后端按标签过滤使用的字段
industryName,
orgIds,
}
})
}
......
......@@ -326,9 +326,8 @@ watch(isTranslate, () => {
.report-main {
flex: auto;
min-height: 0;
box-sizing: border-box;
padding-top: 10px;
height: 20px;
padding: 10px 0;
:deep(.el-scrollbar) {
height: 100%;
......
......@@ -5,10 +5,11 @@
</template>
<script setup>
import { onMounted, nextTick } from 'vue';
import { onMounted, onBeforeUnmount } from 'vue';
import setChart from '@/utils/setChart';
import getGraphChart from './graphChart';
const emits = defineEmits(["handleClickNode"])
const props = defineProps({
nodes: {
type: Array,
......@@ -27,16 +28,22 @@ const props = defineProps({
default: 'force'
},
height: {
type: String,
type: String,
default: 'force'
}
})
let chart = null
onMounted(() => {
const graph = getGraphChart(props.nodes, props.links, props.layoutType)
setChart(graph, 'graph')
chart = setChart(graph, 'graph')
chart.on("click", (event) => { emits("handleClickNode", event) })
})
onBeforeUnmount(() => {
chart.off("click")
chart.dispose()
})
</script>
......
......@@ -24,17 +24,19 @@ const props = defineProps({
</script>
<style lang="scss" scoped>
.tip-wrapper{
.tip-wrapper {
width: 100%;
display: flex;
gap: 8px;
justify-content: center;
align-items: center;
height: 22px;
.icon{
.icon {
width: 16px;
height: 16px;
img{
img {
width: 100%;
height: 100%;
}
......
......@@ -96,16 +96,16 @@ const homeTitleList = ref([
path: "/ZMOverView",
disabled: false
},
{
name: "主要国家科技动向感知",
path: "",
disabled: true
},
{
name: "主要国家竞争科技安全",
path: "",
disabled: true
}
// {
// name: "主要国家科技动向感知",
// path: "",
// disabled: true
// },
// {
// name: "主要国家竞争科技安全",
// path: "",
// disabled: true
// }
]);
const homeActiveTitleIndex = ref(0);
......
......@@ -11,6 +11,7 @@ const BillDeepDigProcessAnalysis = () => import('@/views/bill/deepDig/processAna
const BillDeepDigPoliContribution = () => import('@/views/bill/deepDig/poliContribution/index.vue')
const BillInfluenceLayout = () => import('@/views/bill/influence/index.vue')
const BillInfluenceIndustry = () => import('@/views/bill/influence/industry/index.vue')
const BillProgressForecast = () => import('@/views/bill/influence/ProgressForecast/index.vue')
const BillInfluenceScientificResearch = () => import('@/views/bill/influence/scientificResearch/index.vue')
const BillRelevantCircumstance = () => import('@/views/bill/relevantCircumstance/index.vue')
const BillVersionCompare = () => import('@/views/bill/versionCompare/index.vue')
......@@ -52,7 +53,7 @@ const billRoutes = [
component: BillIntroduction,
// meta: {
// title: "法案简介"
// }
},
{
......@@ -120,9 +121,21 @@ const billRoutes = [
name: "BillInfluenceScientificResearch",
component: BillInfluenceScientificResearch,
// meta: { title: "对华科研影响" }
}
},
// {
// path: "ProgressForecast",
// name: "BillProgressForecast",
// component: BillProgressForecast,
// // meta: { title: "对华科研影响" }
// }
]
}, {
path: "ProgressForecast/:id",
name: "BillProgressForecast",
component: BillProgressForecast,
// meta: { title: "对华科研影响" }
},
{
path: "relevantCircumstance",
name: "BillRelevantCircumstance",
......
......@@ -3,6 +3,8 @@ const thinkTank = () => import('@/views/thinkTank/index.vue')
const ThinkTankDetail = () => import('@/views/thinkTank/ThinkTankDetail/index.vue')
const ReportDetail = () => import('@/views/thinkTank/ReportDetail/index.vue')
const ReportOriginal = () => import('@/views/thinkTank/reportOriginal/index.vue')
const allThinkTank= () => import('@/views/thinkTank/allThinkTank/index.vue')
const MultiThinkTankViewAnalysis= () => import('@/views/thinkTank/MultiThinkTankViewAnalysis/index.vue')
const thinktankRoutes = [
// 智库系统的主要路由
......@@ -36,9 +38,19 @@ const thinktankRoutes = [
path: "/thinkTank/reportOriginal/:id",
name: "ReportOriginal",
component: ReportOriginal,
// meta: {
// title: "报告原文"
// }
},
{
path: "/thinkTank/allThinkTank",
name: "allThinkTank",
component: allThinkTank,
},
{
path: "/thinkTank/MultiThinkTankViewAnalysis/:id",
name: "MultiThinkTankViewAnalysis",
component: MultiThinkTankViewAnalysis,
},
]
......
......@@ -73,4 +73,4 @@
line-height: 30px;
letter-spacing: 0px;
text-align: left;
}
\ No newline at end of file
}
......@@ -9,6 +9,10 @@ const setChart = (option, chartId) => {
chartDom.removeAttribute("_echarts_instance_");
let chart = echarts.init(chartDom);
chart.setOption(option);
// 容器可能受布局/异步渲染影响,强制一次 resize 保证 canvas 与容器一致
setTimeout(() => {
chart.resize();
}, 0);
return chart;
};
......
......@@ -65,9 +65,22 @@ const getMultiLineChart = (dataX, dataY1, dataY2, dataY3) => {
{
type: 'value',
position: 'left',
// 纵轴单位只在纵轴上方显示一次(通过 axis.name),避免每个刻度重复显示
name: '项',
nameLocation: 'end',
nameGap: 12,
nameTextStyle: {
color: '#666',
fontSize: 14,
fontWeight: 400,
// 给单位一点点下移空间,使其更贴近顶部刻度数字的视觉基线
padding: [0, 0, 6, -20]
},
axisLabel: {
formatter: '{value}项',
color: '#666'
formatter: '{value}',
color: '#666',
fontSize: 14,
fontWeight: 400
},
splitLine: {
show: true,
......@@ -83,9 +96,21 @@ const getMultiLineChart = (dataX, dataY1, dataY2, dataY3) => {
min: 0,
max: 100,
interval: 20,
// 通过率单位仅展示一次
name: '%',
nameLocation: 'end',
nameGap: 12,
nameTextStyle: {
color: '#666',
fontSize: 14,
fontWeight: 400,
padding: [0, 0, 6, 20]
},
axisLabel: {
formatter: '{value}%',
color: '#666'
formatter: '{value}',
color: '#666',
fontSize: 14,
fontWeight: 400
},
splitLine: {
show: true,
......
......@@ -6,9 +6,18 @@ const truncateLabel = (value, maxLen = 6) => {
return `${chars.slice(0, maxLen).join('')}...`
}
const getPieChart = (data, colorList) => {
const getPieChart = (data, colorList, options = {}) => {
const showCount = options.showCount !== false
let option = {
// color: colorList,
tooltip: showCount
? undefined
: {
formatter: params => {
const percent = typeof params.percent === 'number' ? params.percent : 0
return `${params.name}: ${percent}%`
}
},
series: [
{
type: 'pie',
......@@ -26,7 +35,8 @@ const getPieChart = (data, colorList) => {
const name = truncateLabel(params?.name, 6)
const value = params?.value ?? 0
const percent = typeof params?.percent === 'number' ? params.percent : 0
return `{name|${name}}\n{time|${value}${percent}%}`
const labelText = showCount ? `${value}${percent}%` : `${percent}%`
return `{name|${name}}\n{time|${labelText}}`
},
minMargin: 5,
edgeDistance: 10,
......
<svg viewBox="0 0 18 18" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="18.000000" height="18.000000" fill="none" customFrame="#000000">
<path id="星形 2" d="M10.9534 6.01133C10.9936 6.13492 11.1088 6.2186 11.2387 6.2186L16.6363 6.21885C16.9269 6.21885 17.0478 6.59069 16.8127 6.76151L12.446 9.93429C12.3409 10.0107 12.2969 10.1461 12.3371 10.2697L14.0048 15.4031C14.0946 15.6795 13.7783 15.9094 13.5432 15.7385L9.17633 12.5661C9.07118 12.4897 8.92882 12.4897 8.82367 12.5661L4.45683 15.7385C4.22171 15.9094 3.90539 15.6795 3.99518 15.4031L5.66293 10.2697C5.70308 10.1461 5.65909 10.0107 5.55395 9.93429L1.18734 6.76151C0.952245 6.59069 1.07307 6.21885 1.36368 6.21885L6.76125 6.2186C6.89121 6.2186 7.00639 6.13492 7.04655 6.01132L8.71469 0.877992C8.8045 0.601613 9.1955 0.601613 9.28531 0.877992L10.9534 6.01133Z" fill="rgb(132,136,142)" fill-rule="evenodd" />
</svg>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="容器 1925" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="合并" d="M2.45616 3.00599L2.47971 13.9809C2.48128 14.542 2.78116 14.998 3.34324 14.998L2.54408 14.998C1.982 14.998 1.52512 14.542 1.52355 13.9809L1.5 2.01867C1.5 1.45605 1.95688 1 2.52053 1L10.9705 1C11.5342 1 11.9911 1.45605 11.9911 2.01867L11.9911 2.62987C11.9079 2.14874 11.4886 1.98732 10.9847 1.98732L3.31969 1.98732C2.75604 1.98732 2.45616 2.44337 2.45616 3.00599ZM4.24592 2.72586L11.0034 2.72586C11.5576 2.72586 12.0051 3.1819 12.0051 3.74452L12.0051 8.14359C11.8481 8.12479 11.6895 8.11538 11.5278 8.11538C9.35171 8.11538 7.58541 9.85182 7.58541 11.9926C7.58541 13.2056 8.1522 14.2885 9.04084 15L4.26947 15C3.71838 15 3.26935 14.544 3.26778 13.9829L3.24423 3.74452C3.24423 3.1819 3.69169 2.72586 4.24592 2.72586ZM10.2419 5.08759C10.5151 5.08759 10.7365 4.86192 10.7365 4.58453C10.7365 4.30714 10.5151 4.08147 10.2419 4.08147L5.00739 4.08147C4.7342 4.08147 4.51282 4.30714 4.51282 4.58453C4.51282 4.86192 4.7342 5.08759 5.00739 5.08759L10.2419 5.08759ZM8.65461 7.92732C8.9278 7.92732 9.14918 7.70165 9.14918 7.42426C9.14918 7.14687 8.92623 6.92119 8.65304 6.92119L5.00739 6.92119C4.7342 6.92119 4.51282 7.14687 4.51282 7.42426C4.51282 7.70165 4.7342 7.92732 5.00739 7.92732L8.65461 7.92732ZM8.47101 11.9891C8.47101 10.3278 9.82126 8.98007 11.4855 8.98007C13.1498 8.98007 14.5 10.3278 14.5 11.9891C14.5 13.6503 13.1498 14.998 11.4855 14.998C9.82126 14.998 8.47101 13.6503 8.47101 11.9891ZM6.14096 10.7968C6.41415 10.7968 6.63553 10.5712 6.63553 10.2938C6.63553 10.0164 6.41258 9.7907 6.14096 9.7907L5.00896 9.7907C4.73577 9.7907 4.51439 10.0164 4.51439 10.2938C4.51439 10.5712 4.73577 10.7968 5.00896 10.7968L6.14096 10.7968ZM12.0036 13.5186L12.0036 12.0141L13.0132 12.0141L11.5012 10.3592L9.98925 12.0141L10.9941 12.0141L10.9941 13.5186L12.0036 13.5186ZM6.14096 13.6444C6.41415 13.6444 6.63553 13.4187 6.63553 13.1413C6.63553 12.8639 6.41258 12.6383 6.14096 12.6383L5.00896 12.6383C4.73577 12.6383 4.51439 12.8639 4.51439 13.1413C4.51439 13.4187 4.73577 13.6444 5.00896 13.6444L6.14096 13.6444Z" fill="rgb(59,65,75)" fill-rule="evenodd" />
</svg>
<svg viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="28.000000" height="28.000000" fill="none" clip-path="url(#clipPath_2)" customFrame="url(#clipPath_2)">
<defs>
<clipPath id="clipPath_2">
<rect width="28.000000" height="28.000000" x="0.000000" y="0.000000" rx="14.000000" fill="rgb(255,255,255)" />
</clipPath>
<clipPath id="clipPath_3">
<rect width="18.000000" height="15.000000" x="5.000000" y="5.000000" fill="rgb(255,255,255)" />
</clipPath>
</defs>
<rect id="导出数据" width="28.000000" height="28.000000" x="0.000000" y="0.000000" rx="14.000000" />
<g id="容器 742" customFrame="url(#clipPath_3)">
<rect id="容器 742" width="18.000000" height="15.000000" x="5.000000" y="5.000000" />
<rect id="矩形 347" width="2.000000" height="6.000000" x="13.000000" y="5.000000" fill="rgb(132,136,142)" />
<path id="矢量 600" d="M18 11L10 11L14 16L18 11Z" fill="rgb(132,136,142)" fill-rule="evenodd" />
<path id="矢量 601" d="M22 19.9996L22.9999 15.0012L19.9999 12.0011L18.9999 12L21.0003 15.001L17.9999 15.0015L16.9998 17.9987L14 17.9996L11.0001 17.9997L9.99998 15.002L7.00017 15.0028L8.99996 12.0008L8 12.0004L5 15.0023L6.00016 20L22 19.9996Z" fill="rgb(132,136,142)" fill-rule="evenodd" />
</g>
</svg>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="容器 1926" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="合并" d="M1.35139 1.5L14.6461 1.5C14.946 1.5 15.1091 1.78803 14.9173 1.97461L10.1219 6.60295L10.1219 14.2076C10.1219 14.4249 9.84414 14.5662 9.60823 14.4684L6.0762 13.0174C5.95494 12.9685 5.87998 12.8671 5.87998 12.7566L5.87998 6.60295L1.08461 1.97461C0.890592 1.78803 1.05154 1.5 1.35139 1.5ZM11.1956 6.63044L14.0243 6.63044C14.2183 6.63044 14.3771 6.76087 14.3749 6.92028L14.3749 7.6177C14.3749 7.77711 14.2161 7.90754 14.0221 7.90754L11.1956 7.90754C11.0016 7.90754 10.8428 7.77711 10.8428 7.6177L10.8428 6.92028C10.8428 6.76087 11.0016 6.63044 11.1956 6.63044ZM11.1956 9.18464L14.0243 9.18464C14.2183 9.18464 14.3771 9.31506 14.3749 9.47447L14.3749 10.1719C14.3749 10.3313 14.2161 10.4617 14.0221 10.4617L11.1956 10.4617C11.0016 10.4617 10.8428 10.3313 10.8428 10.1719L10.8428 9.47447C10.8428 9.31506 11.0016 9.18464 11.1956 9.18464ZM11.1956 11.7406L14.0243 11.7406C14.2183 11.7406 14.3771 11.8693 14.3749 12.0305L14.3749 12.7279C14.3749 12.8873 14.2161 13.0177 14.0221 13.0177L11.1956 13.0177C11.0016 13.0177 10.8428 12.8873 10.8428 12.7279L10.8428 12.0305C10.8428 11.8711 11.0016 11.7406 11.1956 11.7406Z" fill="rgb(59,65,75)" fill-rule="evenodd" />
</svg>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="容器 1438" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="矢量 1656" d="M9.67676 9.0769C9.55421 9.41452 9.32715 9.70684 9.02686 9.9136C8.72656 10.1204 8.3678 10.2314 8 10.2314C7.6322 10.2314 7.27344 10.1204 6.97314 9.9136C6.67285 9.70684 6.44579 9.41452 6.32324 9.0769L3.55562 9.0769C1.59838 9.0769 0 7.59858 0 5.75958C0 4.20884 1.13686 2.91448 2.66667 2.54703L2.66667 1.5769C2.66667 1.47563 2.69405 1.37615 2.74606 1.28845C2.79806 1.20075 2.87287 1.12792 2.96295 1.07729C3.05303 1.02666 3.15522 1 3.25924 1L11.5555 1C11.6596 0.999984 11.7618 1.02663 11.8518 1.07726C11.9419 1.12789 12.0168 1.20072 12.0688 1.28842C12.1208 1.37613 12.1482 1.47562 12.1482 1.5769L12.1482 4.4615C12.1482 4.56279 12.1208 4.6623 12.0688 4.75002C12.0168 4.83774 11.942 4.91058 11.8519 4.96122C11.7618 5.01186 11.6596 5.03851 11.5555 5.0385L3.25933 5.0385C3.15529 5.03851 3.05308 5.01186 2.96298 4.96122C2.87288 4.91058 2.79806 4.83774 2.74604 4.75002C2.69403 4.6623 2.66665 4.56279 2.66667 4.4615L2.66667 3.7534C1.79552 4.07533 1.18514 4.85584 1.18514 5.75958C1.18514 6.94778 2.24 7.9231 3.55562 7.9231L6.32324 7.9231C6.44579 7.58548 6.67285 7.29316 6.97314 7.0864C7.27344 6.87964 7.6322 6.76862 8 6.76862C8.3678 6.76862 8.72656 6.87964 9.02686 7.0864C9.32715 7.29316 9.55421 7.58548 9.67676 7.9231L12.4444 7.9231C14.4015 7.9231 16 9.40142 16 11.2403C16 13.0793 14.4015 14.5577 12.4445 14.5577L12.1482 14.5577L12.1482 15.4231C12.1482 15.5244 12.1208 15.6239 12.0688 15.7116C12.0168 15.7993 11.9419 15.8721 11.8518 15.9227C11.7618 15.9734 11.6596 16 11.5555 16L3.25933 16C3.1553 16 3.0531 15.9734 2.96301 15.9227C2.87291 15.8721 2.79809 15.7993 2.74607 15.7116C2.69405 15.6239 2.66667 15.5244 2.66667 15.4231L2.66667 12.5384C2.66667 12.4371 2.69405 12.3377 2.74606 12.25C2.79806 12.1623 2.87287 12.0894 2.96295 12.0388C3.05303 11.9882 3.15522 11.9615 3.25924 11.9615L11.5555 11.9615C11.6596 11.9615 11.7618 11.9881 11.8518 12.0388C11.9419 12.0894 12.0168 12.1622 12.0688 12.2499C12.1208 12.3376 12.1482 12.4371 12.1482 12.5384L12.1482 13.4038L12.4444 13.4038C13.76 13.4038 14.8149 12.4285 14.8149 11.2403C14.8149 10.0522 13.76 9.0769 12.4444 9.0769L9.67676 9.0769Z" fill="rgb(59,65,75)" fill-rule="nonzero" />
</svg>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none">
<rect id="统计 2" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="合并" d="M8.09474 1.99497L8.09474 6.91078C8.09474 7.08635 8.141 7.25882 8.22886 7.41086C8.31671 7.56291 8.44308 7.68917 8.59526 7.77695C8.74744 7.86473 8.92006 7.91095 9.09578 7.91095L14.0159 7.91095C14.2102 7.9107 14.4003 7.85392 14.5629 7.74754C14.7255 7.64116 14.8536 7.48979 14.9316 7.3119C15.0095 7.13401 15.034 6.93729 15.0019 6.74575C14.7594 5.31252 14.0763 3.99025 13.0476 2.96241C12.0189 1.93458 10.6954 1.25214 9.26095 1.0098C9.06925 0.977761 8.87236 1.00217 8.69431 1.08005C8.51626 1.15793 8.36476 1.28592 8.25829 1.44838C8.15182 1.61085 8.09499 1.80077 8.09474 1.99497ZM12.4164 8.74814L7.75904 8.74814C7.67118 8.74814 7.58487 8.72504 7.50878 8.68115C7.43269 8.63725 7.36951 8.57413 7.32558 8.4981C7.28165 8.42208 7.25852 8.33584 7.25852 8.24806L7.25852 3.60729C7.26139 3.41073 7.20622 3.21768 7.0999 3.05227C6.99358 2.88686 6.84083 2.75642 6.66074 2.67725C6.48065 2.59808 6.2812 2.57369 6.08731 2.60713C4.80952 2.85335 3.64037 3.49154 2.74265 4.43285C1.84493 5.37415 1.26334 6.57171 1.07883 7.85879C0.894329 9.14586 1.11611 10.4584 1.71328 11.6136C2.31046 12.7688 3.25331 13.7093 4.41056 14.3039C5.56781 14.8986 6.88186 15.118 8.16974 14.9314C9.45762 14.7449 10.6552 14.1617 11.5958 13.2632C12.5364 12.3647 13.1732 11.1954 13.4174 9.91834C13.4509 9.72462 13.4265 9.52534 13.3472 9.34541C13.268 9.16547 13.1374 9.01285 12.9719 8.90663C12.8063 8.8004 12.6131 8.74528 12.4164 8.74814Z" fill="rgb(59,65,75)" fill-rule="evenodd" />
</svg>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="容器 704" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<circle id="椭圆 96" cx="8" cy="8" r="7" fill="rgb(230,231,232)" />
<circle id="椭圆 97" cx="8" cy="4" r="1" fill="rgb(132,136,142)" />
<path id="矩形 241" d="M6.49996 6L8.00028 6.0004C8.55256 6.0004 9.00028 6.44811 9.00028 7.00039L9.00028 10.4992C9.00028 10.7754 9.22408 10.9989 9.50033 10.9992L9.50033 10.9997C9.77657 10.9998 10.0005 11.2236 10.0005 11.4998L10.0003 11.5001C10.0002 11.7765 9.77622 12.0006 9.49978 12.0006L8.00028 12.0004L6.50033 12.0004C6.22423 12.0004 6.00064 11.7767 6.00049 11.5006L6.00021 11.5005C6.00021 11.2243 6.22418 11.0003 6.50037 11.0003L6.50037 11.0006C6.77649 11.0007 7.00042 10.7766 7.00042 10.5005L7.00017 7.50005C7.00017 7.22376 6.77644 7.00047 6.50015 7.00002L6.49946 6.99922C6.22357 6.999 6 6.77565 6 6.49976C6.00011 6.22373 6.22393 6 6.49996 6Z" fill="rgb(132,136,142)" fill-rule="evenodd" />
</svg>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="16.000000" height="16.000000" fill="none" customFrame="#000000">
<rect id="容器 1928" width="16.000000" height="16.000000" x="0.000000" y="0.000000" />
<path id="矢量 1657" d="M4.99213 1.2695C5.91426 1.091 6.91689 1 8 1C9.08311 1 10.0857 1.091 11.0079 1.2695C11.93 1.448 12.6597 1.693 13.1951 2.001C13.7305 2.309 14 2.6415 14 3.002L14 4.003C14 4.3635 13.7323 4.696 13.1951 5.004C12.6579 5.312 11.93 5.55525 11.0079 5.7355C10.0857 5.91225 9.08311 6.0015 8 6.0015C6.91689 6.0015 5.91426 5.9105 4.99213 5.732C4.06999 5.5535 3.34033 5.3085 2.8049 5.0005C2.26772 4.6925 2 4.36 2 3.9995L2 2.9985C2 2.638 2.26772 2.3055 2.8049 1.9975C3.34208 1.693 4.06999 1.44975 4.99213 1.2695L4.99213 1.2695ZM4.54068 6.663C5.6133 6.887 6.7664 6.999 8 6.999C9.23359 6.999 10.3867 6.887 11.4593 6.663C12.5319 6.439 13.3788 6.10825 13.9983 5.67075L13.9983 6.999C13.9983 7.3595 13.7305 7.692 13.1934 8C12.6562 8.308 11.9283 8.55125 11.0061 8.7315C10.0857 8.91 9.08311 8.99925 8 8.99925C6.91689 8.99925 5.91426 8.90825 4.99213 8.72975C4.06999 8.55125 3.34033 8.30625 2.8049 7.99825C2.26772 7.69025 2 7.35775 2 6.99725L2 5.669C2.62117 6.11 3.46807 6.44075 4.54068 6.663L4.54068 6.663ZM4.54068 9.66425C5.6133 9.88825 6.7664 10.0002 8 10.0002C9.23359 10.0002 10.3867 9.88825 11.4593 9.66425C12.5319 9.44025 13.3788 9.1095 13.9983 8.672L13.9983 10.0002C13.9983 10.3607 13.7305 10.6932 13.1934 11.0013C12.6562 11.3093 11.9283 11.5525 11.0061 11.7327C10.084 11.913 9.08311 12.0023 8 12.0023C6.91689 12.0023 5.91426 11.9113 4.99213 11.7327C4.06999 11.5542 3.34208 11.3092 2.8049 11.0013C2.26772 10.6933 2 10.3608 2 10.0002L2 8.672C2.62117 9.1095 3.46807 9.44025 4.54068 9.66425L4.54068 9.66425ZM4.54068 12.6637C5.6133 12.8877 6.7664 12.9998 8 12.9998C9.23359 12.9998 10.3867 12.8877 11.4593 12.6637C12.5319 12.4397 13.3788 12.109 13.9983 11.6715L13.9983 12.9998C13.9983 13.3602 13.7305 13.6927 13.1934 14.0008C12.6562 14.3088 11.9283 14.552 11.0061 14.7323C10.084 14.9125 9.08311 15 8 15C6.91689 15 5.91426 14.909 4.99213 14.7305C4.06999 14.552 3.34033 14.307 2.8049 13.999C2.26772 13.691 2 13.3585 2 12.998L2 11.6697C2.62117 12.109 3.46807 12.4397 4.54068 12.6637L4.54068 12.6637Z" fill="rgb(59,65,75)" fill-rule="nonzero" />
</svg>
<svg viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="28.000000" height="28.000000" fill="none" clip-path="url(#clipPath_0)" customFrame="url(#clipPath_0)">
<defs>
<clipPath id="clipPath_0">
<rect width="28.000000" height="28.000000" x="0.000000" y="0.000000" rx="14.000000" fill="rgb(255,255,255)" />
</clipPath>
<clipPath id="clipPath_1">
<rect width="20.000000" height="20.000000" x="4.000000" y="4.000000" fill="rgb(255,255,255)" />
</clipPath>
</defs>
<rect id="数据源" width="28.000000" height="28.000000" x="0.000000" y="0.000000" rx="14.000000" />
<g id="数据库 1" clip-path="url(#clipPath_1)" customFrame="url(#clipPath_1)">
<rect id="数据库 1" width="20.000000" height="20.000000" x="4.000000" y="4.000000" />
<path id="合并" d="M10.6426 6.48828C11.6719 6.28906 12.791 6.1875 14 6.1875C15.209 6.1875 16.3281 6.28906 17.3574 6.48828C18.3867 6.6875 19.2012 6.96094 19.7988 7.30469C20.3965 7.64844 20.6973 8.01953 20.6973 8.42188L20.6973 9.53906C20.6973 9.94141 20.3984 10.3125 19.7988 10.6563C19.1992 11 18.3867 11.2715 17.3574 11.4727C16.3281 11.6699 15.209 11.7695 14 11.7695C12.791 11.7695 11.6719 11.668 10.6426 11.4688C9.61328 11.2695 8.79883 10.9961 8.20117 10.6523C7.60156 10.3086 7.30273 9.9375 7.30273 9.53516L7.30273 8.41797C7.30273 8.01563 7.60156 7.64453 8.20117 7.30078C8.80078 6.96094 9.61328 6.68945 10.6426 6.48828ZM10.1387 12.5078C11.3359 12.7578 12.623 12.8828 14 12.8828C15.377 12.8828 16.6641 12.7578 17.8613 12.5078C19.0586 12.2578 20.0039 11.8887 20.6953 11.4004L20.6953 12.8828C20.6953 13.2852 20.3965 13.6563 19.7969 14C19.1973 14.3438 18.3848 14.6152 17.3555 14.8164C16.3281 15.0156 15.209 15.1152 14 15.1152C12.791 15.1152 11.6719 15.0137 10.6426 14.8145C9.61328 14.6152 8.79883 14.3418 8.20117 13.998C7.60156 13.6543 7.30273 13.2832 7.30273 12.8809L7.30273 11.3984C7.99609 11.8906 8.94141 12.2598 10.1387 12.5078ZM10.1387 15.8574C11.3359 16.1074 12.623 16.2324 14 16.2324C14.6624 16.2324 15.3041 16.2035 15.9249 16.1456C14.2088 16.4715 12.8443 17.3161 12.2805 18.3935C11.7114 18.3432 11.1654 18.2672 10.6426 18.166C9.61328 17.9668 8.80078 17.6934 8.20117 17.3496C7.60156 17.0059 7.30273 16.6348 7.30273 16.2324L7.30273 14.75C7.9961 15.2383 8.94141 15.6074 10.1387 15.8574ZM17.5 16C17.3281 16 17.1581 16.005 16.9902 16.0148C17.2857 15.9695 17.5761 15.917 17.8613 15.8574C19.0586 15.6074 20.0039 15.2383 20.6953 14.75L20.6953 16.2324C20.6953 16.3614 20.6646 16.4872 20.6031 16.6099C19.7199 16.2251 18.6512 16 17.5 16ZM13 19.5C13 18.1193 15.0147 17 17.5 17C19.9853 17 22 18.1193 22 19.5C22 20.8807 19.9853 22 17.5 22C15.0147 22 13 20.8807 13 19.5ZM17.5 18C18.3284 18 19 18.6716 19 19.5C19 20.3284 18.3284 21 17.5 21C16.6716 21 16 20.3284 16 19.5C16 18.6716 16.6716 18 17.5 18ZM12 19.5L12 19.4861C11.3572 19.4236 10.7367 19.33 10.1387 19.2051C8.94141 18.9551 7.99609 18.5859 7.30273 18.0957L7.30273 19.5781C7.30273 19.9805 7.60156 20.3516 8.20117 20.6953C8.79883 21.0391 9.61328 21.3125 10.6426 21.5117C11.4872 21.6752 12.3923 21.7729 13.3579 21.8027C12.5123 21.1873 12 20.3817 12 19.5Z" fill="rgb(132,136,142)" fill-rule="evenodd" />
</g>
</svg>
<template>
<div class="action-buttons">
<button class="btn-secondary" @click="emit('reset')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
<span>重置筛选条件</span>
</button>
<div class="action-right">
<button
v-if="showPrevious"
class="btn-secondary"
@click="emit('previous')"
>
<span>{{ previousText || '返回设置' }}</span>
</button>
<button
class="btn-primary"
:disabled="nextDisabled"
@click="emit('next')"
>
<span>{{ nextText || '下一步' }}</span>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
defineProps<{
showPrevious?: boolean
previousText?: string
nextText?: string
nextDisabled?: boolean
}>()
const emit = defineEmits<{
reset: []
previous: []
next: []
}>()
</script>
<style scoped>
.action-buttons {
display: flex;
align-items: center;
justify-content: space-between;
padding-top: 24px;
}
.action-right {
display: flex;
align-items: center;
gap: 12px;
}
.btn-secondary {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
border: 1px solid var(--bg-black-10);
border-radius: var(--radius-4);
background-color: var(--bg-white-100);
font-size: 14px;
color: var(--text-primary-80-color);
cursor: pointer;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: var(--bg-black-2);
}
.btn-primary {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 260px;
padding: 10px 32px;
border: none;
border-radius: var(--radius-4);
background-color: var(--color-primary-100);
font-size: 14px;
color: var(--bg-white-100);
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.icon {
width: 16px;
height: 16px;
}
.btn-secondary .icon {
color: var(--text-primary-65-color);
}
</style>
<template>
<div class="bill-card">
<div class="card-content">
<input
type="checkbox"
:checked="bill.selected"
class="checkbox"
@change="emit('toggleSelect', bill.id)"
/>
<div class="card-main">
<h4 class="text-title-3-bold text-primary-80-clor">{{ bill.title }}</h4>
<div class="fields-grid">
<div
v-for="field in billFields"
:key="field.key"
class="field-item"
>
<span class="field-label text-tip-2 text-primary-50-clor">{{ field.label }}</span>
<template v-if="field.key === 'areas'">
<div class="area-tags">
<AreaTag v-for="area in bill.areas"
:key="area" :tagName="area" />
</div>
</template>
<template v-else-if="field.key === 'passDays'">
<span class="text-tip-2 main-color">{{ bill.passDays }}</span>
</template>
<template v-else>
<span class="text-tip-2">{{ bill[field.key as keyof typeof bill] }}</span>
</template>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { BillInfo } from '../api'
import AreaTag from '@/components/base/AreaTag/index.vue'
defineProps<{
bill: BillInfo
}>()
const emit = defineEmits<{
toggleSelect: [id: string]
}>()
const billFields = [
{ key: 'proposalDate', label: '提案时间:' },
{ key: 'areas', label: '涉及领域:' },
{ key: 'electionPhase', label: '选举周期阶段:' },
{ key: 'proposer', label: '提案人:' },
{ key: 'coProposers', label: '共同提案人:' },
{ key: 'proposerPosition', label: '提案人职务:' },
{ key: 'governmentType', label: '政府结构类型:' },
{ key: 'budgetScale', label: '法案预算规模:' },
{ key: 'passDays', label: '通过耗时:' }
]
</script>
<style scoped>
.bill-card {
border: 1px solid var(--bg-black-10);
border-radius: var(--radius-10);
padding: 16px;
}
.card-content {
display: flex;
align-items: flex-start;
gap: 12px;
}
.checkbox {
width: 16px;
height: 16px;
margin-top: 2px;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--color-primary-100);
}
.card-main {
flex: 1;
}
.card-main h4 {
margin: 0 0 16px 0;
line-height: 20px;
}
.fields-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px 24px;
}
.field-item {
display: flex;
align-items: center;
}
.field-label {
width: 100px;
flex-shrink: 0;
}
.area-tags {
display: flex;
gap: 8px;
}
.area-tag {
padding: 2px 8px;
font-size: 12px;
color: var(--color-primary-100);
background-color: var(--color-primary-10);
border: 1px solid var(--color-primary-10);
border-radius: var(--radius-4);
}
</style>
<template>
<section class="filter-section">
<div class="section-header">
<div class="header-left">
<img src="../assets/fitller.svg" />
<h2 class="section-title text-title-3-bold">核心相似度维度筛选</h2>
</div>
<button class="btn-outline" @click="emit('setAsCurrent')">设置为当前提案</button>
</div>
<div class="divider" />
<div class="fields-grid">
<div
v-for="field in fields"
:key="field.id"
class="field-item"
>
<div class="field-label-wrapper">
<span class="field-label text-tip-1 text-primary-65-clor">{{ field.label }}</span>
</div>
<div class="field-content">
<FilterSelect
:options="field.options"
:model-value="field.selectedValues"
@update:model-value="(val) => handleFieldUpdate(field.id, val)"
/>
<div v-if="field.hint" class="field-hint">
<img src="../assets/importent.svg" />
<span class="text-tip-2 text-primary-50-clor">{{ field.hint }}</span>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { FilterField } from '../api'
import FilterSelect from './FilterSelect.vue'
const props = defineProps<{
fields: FilterField[]
}>()
const emit = defineEmits<{
'update:fields': [fields: FilterField[]]
setAsCurrent: []
}>()
// 更新字段选中值
function handleFieldUpdate(fieldId: string, newValues: string[]) {
const updatedFields = props.fields.map(f =>
f.id === fieldId
? { ...f, selectedValues: newValues }
: f
)
emit('update:fields', updatedFields)
}
</script>
<style scoped>
.filter-section {
margin-top: 24px;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.section-icon {
width: 16px;
height: 16px;
color: var(--text-primary-80-color);
}
.btn-outline {
padding: 6px 16px;
border: 1px solid var(--bg-black-10);
border-radius: var(--radius-4);
background-color: var(--bg-white-100);
font-size: 14px;
color: var(--text-primary-80-color);
cursor: pointer;
transition: background-color 0.2s;
}
.btn-outline:hover {
background-color: var(--bg-black-2);
}
.divider {
height: 1px;
background-color: var(--border-black-5);
margin-bottom: 24px;
}
.fields-grid {
display: flex;
flex-wrap: wrap;
gap: 24px 24px;
padding-left: 30px;
}
.field-item {
width: 580px;
display: flex;
align-items: flex-start;
}
.field-label-wrapper {
padding-top: 4px;
}
.field-label {
display: block;
width: 150px;
}
.field-content {
display: flex;
flex-direction: column;
gap: 6px;
}
.field-hint {
display: flex;
align-items: center;
gap: 8px;
}
.hint-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
color: var(--text-primary-50-color);
}
</style>
<template>
<div class="filter-select">
<div class="select-trigger flex-display-start" @click.stop="isOpen = !isOpen">
<div class="selected-tags">
<template v-if="selectedLabels.length > 0">
<div
v-for="(label, index) in selectedLabels"
:key="modelValue[index]"
class="tag-item flex-display-center"
>
<span>{{ label }}</span>
<button class="tag-remove flex-display-center" @click.stop="removeValue(modelValue[index])">
<svg class="icon-xs" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</template>
<span v-else class="placeholder text-tip-2 text-primary-50-clor">{{ placeholder || '请选择' }}</span>
</div>
<svg class="icon-sm dropdown-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="6 9 12 15 18 9" />
</svg>
</div>
<div v-if="isOpen" class="dropdown-menu">
<div
v-for="option in options"
:key="option.value"
class="dropdown-item flex-display-start"
:class="{ 'dropdown-item--selected': modelValue.includes(option.value) }"
@click.stop="toggleOption(option.value)"
>
<span>{{ option.label }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import type { FilterOption } from '../api'
const props = defineProps<{
options: FilterOption[]
modelValue: string[]
placeholder?: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string[]]
}>()
// 下拉框展开状态
const isOpen = ref(false)
// 已选中的标签文字
const selectedLabels = computed(() => {
return props.modelValue
.map(v => props.options.find(opt => opt.value === v)?.label)
.filter(Boolean) as string[]
})
// 切换选项选中状态
function toggleOption(value: string) {
const index = props.modelValue.indexOf(value)
if (index === -1) {
emit('update:modelValue', [...props.modelValue, value])
} else {
emit('update:modelValue', props.modelValue.filter(v => v !== value))
}
}
// 移除已选中的值
function removeValue(value: string) {
emit('update:modelValue', props.modelValue.filter(v => v !== value))
}
// 点击外部关闭下拉框
function handleClickOutside(e: MouseEvent) {
const target = e.target as HTMLElement
if (!target.closest('.filter-select')) {
isOpen.value = false
}
}
// 组件挂载时添加事件监听
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
// 组件卸载时移除事件监听
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
</script>
<style scoped>
.filter-select {
position: relative;
width: 420px;
}
.select-trigger {
height: 32px;
padding: 5px 8px;
border: 1px solid var(--bg-black-10);
border-radius: var(--radius-4);
background-color: var(--bg-white-100);
justify-content: space-between;
cursor: pointer;
}
.selected-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
flex: 1;
min-width: 0;
}
.tag-item {
gap: 4px;
padding: 0 4px 0 8px;
background-color: var(--bg-black-2);
border-radius: var(--radius-4);
font-size: 14px;
line-height: 22px;
color: var(--text-primary-80-color);
}
.tag-remove {
width: 13px;
height: 13px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--text-primary-50-color);
transition: color 0.2s;
}
.tag-remove:hover {
color: var(--text-primary-80-color);
}
.dropdown-arrow {
flex-shrink: 0;
width: 11px;
height: 6px;
margin-left: 8px;
color: var(--text-primary-50-color);
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 4px;
background-color: var(--bg-white-100);
border: 1px solid var(--bg-black-10);
border-radius: var(--radius-4);
box-shadow: var(--shadow-card);
z-index: 10;
}
.dropdown-item {
padding: 8px 12px;
font-size: 14px;
color: var(--text-primary-80-color);
cursor: pointer;
gap: 8px;
transition: background-color 0.2s;
}
.dropdown-item:hover {
background-color: var(--bg-black-2);
}
.dropdown-item--selected {
background-color: var(--color-primary-10);
color: var(--color-primary-100);
}
.icon-xs {
width: 13px;
height: 13px;
}
.icon-sm {
width: 16px;
height: 16px;
}
</style>
<template>
<div class="filter-tag flex-display-center">
<span>{{ label }}</span>
<button class="tag-remove flex-display-center" @click="emit('remove')">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
defineProps<{
label: string
}>()
const emit = defineEmits<{
remove: []
}>()
</script>
<style scoped>
.filter-tag {
gap: 4px;
padding: 2px 8px;
background-color: var(--bg-black-2);
border-radius: var(--radius-4);
font-size: 14px;
color: var(--text-primary-80-color);
}
.tag-remove {
width: 12px;
height: 12px;
background: none;
border: none;
padding: 0;
color: var(--text-primary-50-color);
transition: color 0.2s;
}
.tag-remove:hover {
color: var(--text-primary-80-color);
}
.icon {
width: 12px;
height: 12px;
}
</style>
<template>
<header class="page-header">
<div class="header-content">
<div class="header-left">
<div class="text-title-2-bold">科技法案立法进展预测</div>
<div class="text-tip-2 text-primary-65-clor">H.R.1-大而美法案(2025年5月20日)</div>
</div>
<div class="header-actions">
<button class="action-btn">
<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span>选择法案</span>
</button>
<button class="action-btn">
<svg class="action-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<span>导出分析结果</span>
</button>
</div>
</div>
</header>
</template>
<script setup lang="ts">
</script>
<style scoped>
.page-header {
width: 2548px;
height: 80px;
/* margin-left: 160px; */
box-sizing: border-box;
display: flex;
align-items: center;
margin-left: -478px;
padding-left: 478px;
background: #ffffff;
box-shadow: 0px 0px 20px 0px rgba(25, 69, 130, 0.1);
margin-bottom: 16px;
}
.header-content {
width: 1600px;
display: flex;
align-items: center;
justify-content: space-between;
}
.header-left {
display: flex;
flex-direction: column;
gap: 4px;
}
.header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.action-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
border: 1px solid var(--bg-black-10);
border-radius: 5px;
background-color: var(--bg-white-100);
font-size: 16px;
line-height: 24px;
color: var(--text-primary-65-color);
cursor: pointer;
transition: background-color 0.2s;
}
.action-btn:hover {
background-color: var(--bg-black-2);
}
.action-icon {
width: 20px;
height: 20px;
color: var(--text-primary-65-color);
}
</style>
<template>
<div v-if="phase" class="phase-card" :class="borderColorClass">
<div class="phase-header flex-display-start">
<div>
<h3 class="phase-title main-color text-title-2-bold">{{ phase.title }}</h3>
<p class="text-tip-2 text-primary-50-clor">{{ phase.description }}</p>
</div>
<div class="phase-status">
<span class="risk-badge" :class="riskColorClass">{{ riskLabel }}</span>
<div class="progress-bar-container">
<div
v-for="i in 3"
:key="i"
class="progress-segment"
:class="getSegmentClass(i)"
></div>
</div>
<p v-if="phase.riskLevel !== 'passed'" class="text-tip-2 text-primary-50-clor">{{ phase.estimatedDays }}</p>
</div>
</div>
<div class="model-inputs-box">
<div class="box-header flex-display-start">
<div class="box-title-row flex-display-center">
<img src="../assets/input.svg" />
<span class="text-compact-bold">预测模型数据输入</span>
</div>
<div class="box-hint flex-display-center text-tip-2 text-primary-50-clor">
<img src="../assets/importent.svg"/>
<span>此阶段预测基于以下多维特征</span>
</div>
</div>
<div class="model-inputs">
<p
v-for="(input, index) in phase.modelInputs"
:key="index"
class="text-tip-2 text-primary-65-clor"
>
{{ input }}
</p>
</div>
</div>
<div class="facts-section">
<div class="box-header flex-display-start">
<div class="box-title-row flex-display-center">
<img src="../assets/icon1.svg"/>
<span class="text-compact-bold">{{ phase.supportingFacts.title }}</span>
</div>
<div class="box-hint flex-display-center text-tip-2 text-primary-50-clor">
<img src="../assets/importent.svg"/>
<span>{{ phase.supportingFacts.basedOn }}</span>
</div>
</div>
<div class="stats-grid">
<div
v-for="(stat, index) in phase.supportingFacts.stats"
:key="index"
class="stat-card"
>
<div class="stat-value main-color">{{ stat.value }}</div>
<div class="text-tip-3 text-primary-65-clor">{{ stat.label }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { PredictionPhase } from '../api'
const props = defineProps<{
phase: PredictionPhase
}>()
// 根据风险等级返回边框颜色类
const borderColorClass = computed(() => {
if (!props.phase) return ''
switch (props.phase.riskLevel) {
case 'passed':
return 'border-primary'
case 'high':
return 'border-high'
case 'medium':
return 'border-medium'
case 'low':
return 'border-low'
default:
return ''
}
})
// 根据索引和progressLevel返回进度条格子的类名
function getSegmentClass(index: number) {
if (!props.phase) return 'segment-inactive'
const level = props.phase.progressLevel
const isActive = index <= level
if (!isActive) {
return 'segment-inactive'
}
switch (props.phase.riskLevel) {
case 'passed':
return 'segment-primary'
case 'high':
return 'segment-high'
case 'medium':
return 'segment-medium'
case 'low':
return 'segment-low'
default:
return 'segment-inactive'
}
}
// 根据风险等级返回文字颜色类
const riskColorClass = computed(() => {
if (!props.phase) return ''
switch (props.phase.riskLevel) {
case 'passed':
return 'main-color'
case 'high':
return 'color-red-100'
case 'medium':
return 'color-orange-100'
case 'low':
return 'color-green-100'
default:
return ''
}
})
// 根据风险等级返回标签文字
const riskLabel = computed(() => {
if (!props.phase) return ''
switch (props.phase.riskLevel) {
case 'passed':
return '已通过'
case 'high':
return '高'
case 'medium':
return '中'
case 'low':
return '低'
default:
return ''
}
})
</script>
<style scoped>
.phase-card {
padding-left: 24px;
padding-bottom: 32px;
}
.border-primary {
border-left-color: var(--color-primary-100);
}
.border-high {
border-left-color: var(--color-red-100);
}
.border-medium {
border-left-color: var(--color-orange-100);
}
.border-low {
border-left-color: var(--color-green-100);
}
.phase-header {
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.phase-header > div:first-child {
display: flex;
flex-direction: column;
gap: 4px;
}
.phase-title {
font-size: 18px;
font-weight: bold;
}
.phase-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 4px;
}
.progress-bar-container {
display: flex;
gap: 4px;
}
.progress-segment {
width: 50px;
height: 4px;
border-radius: 2px;
}
.segment-inactive {
background-color: var(--bg-black-5);
}
.segment-primary {
background-color: var(--color-primary-100);
}
.segment-high {
background-color: var(--color-red-100);
}
.segment-medium {
background-color: var(--color-orange-100);
}
.segment-low {
background-color: var(--color-green-100);
}
.risk-badge {
font-size: 20px;
font-weight: bold;
}
.model-inputs-box {
background-color: var(--bg-black-2);
border-radius: var(--radius-10);
padding: 16px;
margin-bottom: 16px;
}
.box-header {
justify-content: space-between;
margin-bottom: 12px;
}
.box-title-row {
gap: 8px;
}
.box-hint {
gap: 4px;
}
.model-inputs {
display: flex;
flex-direction: column;
gap: 12px;
}
.model-inputs p {
line-height: 1.6;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.stat-card {
background-color: var(--color-primary-2);
border: 1px;
padding: 12px;
text-align: center;
border : 1px solid var(--color-primary-10);
}
.stat-value {
font-size: 18px;
font-weight: bold;
margin-bottom: 4px;
}
.icon {
width: 16px;
height: 16px;
color: var(--text-primary-65-color);
}
</style>
<template>
<section class="proposal-section">
<div class="section-header">
<div class="header-left">
<svg class="section-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<h2 class="section-title text-title-3-bold">当前提案信息</h2>
</div>
<div class="header-actions">
<button class="action-btn" title="对比">
<img src="../assets/source.svg"/>
</button>
<button class="action-btn" title="下载">
<img src="../assets/down.svg"/>
</button>
<button class="action-btn" title="收藏">
<img src="../assets/collection.svg"/>
</button>
</div>
</div>
<div class="divider" />
<div class="info-grid">
<div
v-for="field in infoFields"
:key="field.key"
class="info-item"
>
<span class="info-label text-body-1">{{ field.label }}</span>
<template v-if="field.key === 'areas'">
<div class="area-tags">
<!-- <span
v-for="area in info.areas"
:key="area"
class="area-tag"
>
{{ area }}
</span> -->
<AreaTag v-for="area in info.areas" :key="area" :tagName="area" />
</div>
</template>
<template v-else>
<span class="info-value text-body-1">{{ info[field.key as keyof typeof info] }}</span>
</template>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import type { ProposalInfo } from '../api'
defineProps<{
info: ProposalInfo
}>()
// 信息字段配置
const infoFields = [
{ key: 'title', label: '提案标题:' },
{ key: 'date', label: '提案时间:' },
{ key: 'areas', label: '涉及领域:' },
{ key: 'electionPhase', label: '选举周期阶段:' },
{ key: 'proposer', label: '提案人:' },
{ key: 'coProposers', label: '共同提案人:' },
{ key: 'proposerPosition', label: '提案人职务:' },
{ key: 'governmentType', label: '政府结构类型:' },
{ key: 'budgetScale', label: '法案预算规模:' }
]
</script>
<style scoped>
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.section-icon {
width: 16px;
height: 16px;
color: var(--text-primary-80-color);
}
.header-actions {
display: flex;
gap: 8px;
}
.action-btn {
width: 28px;
height: 28px;
padding: 4px;
border: none;
border-radius: 50px;
background-color: transparent;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.action-btn svg {
width: 20px;
height: 20px;
color: var(--text-primary-50-color);
}
.action-btn:hover svg {
color: var(--color-primary-100);
}
.divider {
height: 1px;
background-color: var(--border-black-5);
margin-bottom: 24px;
}
.info-grid {
display: flex;
flex-wrap: wrap;
gap: 24px 24px;
padding-left: 30px;
}
.info-item {
width: 580px;
display: flex;
align-items: center;
}
.info-label {
width: 150px;
flex-shrink: 0;
color: var(--text-primary-65-color);
}
.info-value {
color: var(--text-primary-80-color);
}
.area-tags {
display: flex;
gap: 8px;
}
.area-tag {
padding: 1px 8px;
font-size: 14px;
line-height: 20px;
color: var(--color-primary-100);
background-color: var(--color-primary-10);
border: 1px solid var(--color-primary-10);
border-radius: var(--radius-4);
}
</style>
<template>
<div class="step-container">
<div v-if="loading" class="loading-wrapper flex-display-center">
<span class="text-tip-2 text-primary-50-clor">加载中...</span>
</div>
<template v-else>
<div class="content-wrapper">
<ProposalInfoSection v-if="currentProposalInfo" :info="currentProposalInfo" />
<FilterSection
:fields="filterFields"
@update:fields="filterFields = $event"
@set-as-current="handleSetAsCurrent"
/>
</div>
<ActionButtons
@reset="handleReset"
@next="handleNext"
/>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import type { ProposalInfo, FilterField } from '../api'
import { fetchProposalInfo, fetchFilterFields } from '../api'
import ProposalInfoSection from './ProposalInfoSection.vue'
import FilterSection from './FilterSection.vue'
import ActionButtons from './ActionButtons.vue'
const props = defineProps<{
proposalInfo?: ProposalInfo | null
defaultFilters?: Record<string, string[]>
}>()
const emit = defineEmits<{
next: []
}>()
// 提案信息(优先使用 props,否则从 API 获取)
const currentProposalInfo = ref<ProposalInfo | null>(null)
// 筛选字段列表
const filterFields = ref<FilterField[]>([])
// 加载状态
const loading = ref(true)
// 初始筛选字段(用于重置)
const initialFilterFields = ref<FilterField[]>([])
// 监听 props.proposalInfo 变化
watch(() => props.proposalInfo, (newInfo) => {
if (newInfo) {
currentProposalInfo.value = newInfo
}
}, { immediate: true })
// 页面初始化时加载数据
onMounted(async () => {
try {
// 如果没有从 props 获取到提案信息,则从 API 获取
if (!props.proposalInfo) {
currentProposalInfo.value = await fetchProposalInfo()
}
// 获取筛选字段配置
const fields = await fetchFilterFields()
// 如果有默认筛选条件,应用到筛选字段
if (props.defaultFilters && Object.keys(props.defaultFilters).length > 0) {
filterFields.value = fields.map(field => ({
...field,
selectedValues: props.defaultFilters?.[field.id] || field.selectedValues
}))
} else {
filterFields.value = fields
}
// 保存初始状态用于重置
initialFilterFields.value = JSON.parse(JSON.stringify(filterFields.value))
} finally {
loading.value = false
}
})
// 重置所有筛选条件
function handleReset() {
filterFields.value = JSON.parse(JSON.stringify(initialFilterFields.value))
}
// 设置为当前提案
function handleSetAsCurrent() {
// 根据当前提案信息重新设置筛选条件
if (currentProposalInfo.value) {
filterFields.value = filterFields.value.map(field => {
// 根据提案信息设置默认值
const defaultValues = getDefaultValuesForField(field.id, currentProposalInfo.value!)
return {
...field,
selectedValues: defaultValues.length > 0 ? defaultValues : field.selectedValues
}
})
}
}
// 根据字段 ID 和提案信息获取默认值
function getDefaultValuesForField(fieldId: string, info: ProposalInfo): string[] {
const areaMap: Record<string, string> = {
'能源': 'energy',
'集成电路': 'ic',
'人工智能': 'ai',
'生物技术': 'biotech'
}
switch (fieldId) {
case 'policyArea':
return info.areas.map(area => areaMap[area] || area.toLowerCase())
case 'proposerPosition':
return info.proposerPosition === '委员会主席' ? ['chairman'] : []
case 'governmentType':
return info.governmentType.includes('一致') ? ['unified'] : ['divided']
case 'electionPhase':
return info.electionPhase.includes('蜜月') ? ['honeymoon'] : []
default:
return []
}
}
// 下一步
function handleNext() {
emit('next')
}
</script>
<style scoped>
.step-container {
display: flex;
flex-direction: column;
height: 100%;
}
.loading-wrapper {
flex: 1;
}
.content-wrapper {
flex: 1;
overflow: auto;
}
</style>
<template>
<div class="step-container">
<div v-if="loading" class="loading-wrapper flex-display-center">
<span class="text-tip-2 text-tip-1">加载中...</span>
</div>
<template v-else-if="stats">
<div class="content-wrapper">
<div class="stats-section">
<div class="stats-header">
<div class="header-left">
<div class="flex-display-center section-title-row">
<img src="../assets/document.svg">
<span class="text-title-3-bold text-primary-80-clor">筛选结果统计</span>
</div>
<p class="text-tip-2 stats-subtitle text-tip-1" style="margin-left: 21px;">基于筛选条件匹配的历史相似法案</p>
</div>
<div class="header-right">
<div class="total-number-row">
<span class="total-number main-color">{{ stats.totalBills }}</span>
<span class="total-unit main-color"></span>
</div>
<p class="total-label main-color">历史相似法案</p>
</div>
</div>
<div class="stats-cards">
<div
v-for="stat in statsItems"
:key="stat.label"
class="stat-card"
>
<span class="text-title-2-bold text-primary-80-clor">{{ stat.label }}</span>
<span class="main-color stat-value">{{ stat.value }}</span>
</div>
</div>
<div class="select-row">
<label class="select-all-label flex-display-center">
<input
type="checkbox"
:checked="isAllSelected"
class="checkbox"
@change="toggleSelectAll"
/>
<span class="text-compact">全选</span>
</label>
<span class="text-tip-1">已选择{{ selectedCount }}项法案</span>
</div>
<div class="bills-list">
<BillCard
v-for="bill in bills"
:key="bill.id"
:bill="bill"
@toggle-select="toggleBillSelect"
/>
</div>
</div>
</div>
<div class="action-footer">
<button class="btn-secondary flex-display-center" @click="handleBack">
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
</svg>
<span>返回设置</span>
</button>
<button class="btn-primary flex-display-center" @click="handleStartAnalysis">
<span>开始预测分析</span>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="5" y1="12" x2="19" y2="12" />
<polyline points="12 5 19 12 12 19" />
</svg>
</button>
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, inject, watch } from 'vue'
import type { FilterStats, BillInfo } from '../api'
import { fetchFilterStats, fetchBillList } from '../api'
import { getSimiBills, transformSimiBillsData } from '@/api/bill/billHome'
import BillCard from './BillCard.vue'
const emit = defineEmits<{
previous: []
next: []
}>()
// 从父组件注入筛选参数
const filterParams = inject<any>('filterParams', null)
// 筛选统计数据
const stats = ref<FilterStats | null>(null)
// 法案列表
const bills = ref<BillInfo[]>([])
// 加载状态
const loading = ref(true)
// 统计项配置,使用循环避免重复代码
const statsItems = computed(() => {
if (!stats.value) return []
return [
{ label: '成为法律', value: `${stats.value.becameLaw}个` },
{ label: '未通过/搁置', value: `${stats.value.notPassedOrShelved}个` },
{ label: '中位数耗时', value: `${stats.value.medianDays}天` },
{ label: '法案通过率', value: `${stats.value.passRate}%` }
]
})
// 计算属性:是否全选
const isAllSelected = computed(() => {
return bills.value.length > 0 && bills.value.every(bill => bill.selected)
})
// 计算属性:已选中数量
const selectedCount = computed(() => {
return bills.value.filter(bill => bill.selected).length
})
// 加载数据
async function loadData() {
loading.value = true
try {
// 优先使用真实 API
const params = {
patternType: filterParams?.governmentType || '统一政府',
proposalType: '两党共同提案'
}
const response = await getSimiBills(params)
if (response && response.success && response.data) {
const { stats: apiStats, bills: apiBills } = transformSimiBillsData(response)
stats.value = apiStats
bills.value = apiBills
} else {
// 如果 API 失败,使用模拟数据
const [statsData, billsData] = await Promise.all([
fetchFilterStats(),
fetchBillList()
])
stats.value = statsData
bills.value = billsData
}
} catch (error) {
console.error('获取相似法案失败:', error)
// 使用模拟数据作为 fallback
const [statsData, billsData] = await Promise.all([
fetchFilterStats(),
fetchBillList()
])
stats.value = statsData
bills.value = billsData
} finally {
loading.value = false
}
}
// 加载数据
onMounted(() => {
loadData()
})
// 监听筛选参数变化,重新加载数据
watch(() => filterParams, () => {
loadData()
}, { deep: true })
// 切换全选
function toggleSelectAll() {
const newValue = !isAllSelected.value
bills.value.forEach(bill => {
bill.selected = newValue
})
}
// 切换单个法案选中状态
function toggleBillSelect(id: string) {
const bill = bills.value.find(b => b.id === id)
if (bill) {
bill.selected = !bill.selected
}
}
// 返回设置
function handleBack() {
emit('previous')
}
// 开始预测分析
function handleStartAnalysis() {
emit('next')
}
</script>
<style scoped>
.text-tip-1 {
color: #5f656c;
}
.step-container {
display: flex;
flex-direction: column;
height: 100%;
}
.loading-wrapper {
flex: 1;
}
.content-wrapper {
flex: 1;
overflow: auto;
}
.stats-section {
display: flex;
flex-direction: column;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 16px;
}
.header-left {
display: flex;
flex-direction: column;
gap: 8px;
}
.section-title-row {
gap: 8px;
justify-content: left;
}
.stats-subtitle {
margin: 0;
}
.header-right {
text-align: right;
}
.total-number-row {
display: flex;
align-items: baseline;
justify-content: flex-end;
}
.total-number {
font-size: 36px;
font-weight: bold;
line-height: 1;
}
.total-unit {
font-size: 14px;
margin-left: 4px;
}
.total-label {
font-size: 14px;
margin: 4px 0 0 0;
}
.stats-cards {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
position: relative;
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 24px;
background: white;
border-radius: 10px;
border: 1px solid rgba(234, 236, 238, 1);
overflow: hidden;
}
.stat-card::before {
content: '';
position: absolute;
left: 0;
top: 15px;
bottom: 15px;
width: 4px;
border-radius: 0 2px 2px 0;
background-color: var(--color-red-100);
}
.stat-label {
font-size: 16px;
font-weight: 400;
color: rgb(59, 65, 75);
}
.stat-value {
font-size: 30px;
font-weight: 700;
color: var(--color-red-100);
}
.select-row {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10px;
border-bottom: 1px solid var(--border-black-5);
margin-bottom: 16px;
}
.select-all-label {
gap: 8px;
cursor: pointer;
}
.checkbox {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: var(--color-primary-100);
}
.bills-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.action-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 24px;
border-top: 1px solid var(--border-black-5);
margin-top: 24px;
}
.btn-secondary {
gap: 8px;
padding: 8px 16px;
border: 1px solid var(--bg-black-10);
border-radius: var(--radius-10);
background-color: var(--bg-white-100);
font-size: 14px;
color: var(--text-primary-65-color);
cursor: pointer;
transition: background-color 0.2s;
}
.btn-secondary:hover {
background-color: var(--bg-black-2);
}
.btn-primary {
gap: 8px;
padding: 8px 24px;
border: none;
border-radius: var(--radius-10);
background-color: var(--color-primary-100);
font-size: 14px;
color: var(--bg-white-100);
min-width: 200px;
justify-content: center;
cursor: pointer;
transition: opacity 0.2s;
}
.btn-primary:hover {
opacity: 0.9;
}
.icon {
width: 16px;
height: 16px;
}
.icon-sm {
width: 16px;
height: 16px;
color: var(--color-primary-100);
}
.btn-secondary .icon {
color: var(--text-primary-65-color);
}
</style>
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
差异被折叠。
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论