用 Vue + Element UI 把元宝搜索的返回值做成“能点开检查的证据卡片”

摘要 :元宝搜索征文里的技术文章,前端部分基本停留在"输入框 → 结果列表"。我试着把"用户信不信 AI 的结论"这件事本身当成一个 Vue 组件问题来做:元宝返回的 Pages 是嵌套 JSON 字符串,我把它解析成证据卡片,每张卡片可以标"可信 / 存疑 / 过期",标注结果即时改变结论区的呈现方式。后端负责拿到证据,前端负责让用户亲手检查证据。

一、我为什么想在前端做这件事

平时写的最多的是"后端给了什么数据,前端怎么让它好看好用"。做过 el-select 下拉表格多选回显,做过拼音搜索,做过 vxe-table 动态列------都是同一类问题:数据是后端给的,但用户能不能真正操作它,取决于前端做了多少层翻译。

元宝搜索这个征文里,技术文章已经很多了。"政策过期检测器"靠三路取证判断政策是否现行,"端侧 RAG 可信外脑"把 SearchPro 接进本地检索链路,"企业合规舆情 Agent"用 Industry=gov 和 Freshness 做定时巡检。这些作品的重心全在检索逻辑和规则引擎,前端基本是一个输入框加一个结果列表。

没有人把"用户怎么验证搜索结果"当成一个前端问题来认真做。 后端把 10 条检索结果压缩成一段自然语言结论,用户看不到支撑这段结论的原始页面是什么、什么时候发布的、来源是谁。AI 没有说谎,但前端没有给用户"检查这句话是否成立"的工具。

我想试试把 "信任 AI 结论" 这件事本身拆成一个 Vue 组件问题。

二、整体架构:前端拿不到密钥,代理层只做一件事

元宝搜索 SearchPro 的接口本身不复杂:POST https://api.wsa.cloud.tencent.com/SearchPro,请求头带 Authorization: Bearer sk-xxx,请求体传 Query 以及可选的 Freshness、Industry、Mode 等参数。响应里 Response.Pages 是一个字符串数组 ,每个元素内部是 JSON 字符串,包含 title、url、passage、date、score、site 等字段。

但有一个硬边界:API KEY 不能进浏览器。 官方文档写得很清楚,服务密钥是安全鉴权凭证,"请勿通过任何方式上传或者公开分享您的密钥信息"。直接把 sk- 开头的 key 写进前端 fetch,等于把密钥发给每一个打开页面的用户。

所以代理层是必须的。我用 Express 写了一个只做鉴权转发的中间件,不缓存、不重试、不承载业务逻辑:

javascript 复制代码
// proxy/index.js
const express = require('express');
const app = express();
app.use(express.json());

app.post('/search', async (req, res) => {
  const { Query, Freshness, Industry, Mode } = req.body;
  const body = { Query };
  if (Freshness) body.Freshness = Freshness;
  if (Industry) body.Industry = Industry;
  if (Mode !== undefined) body.Mode = Mode;

  const resp = await fetch('https://api.wsa.cloud.tencent.com/SearchPro', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.YUANBAO_API_KEY}`
    },
    body: JSON.stringify(body)
  });
  const data = await resp.json();
  res.json(data);
});

app.listen(3000);

前端只调 /search,拿到的已经是脱敏后的 Response.Pages。如果不想自己维护 Express,腾讯云 API 网关也可以直接托管这个转发,配好 CORS 之后前端一行代理代码都不用写。

代理层是前端可控的信任边界------它不解决检索准确率,只保证密钥不进浏览器。

三、第一步就被 Pages 绊了一下

Response.Pages 的类型是 Array of String,每个元素不是对象,是序列化之后的 JSON 字符串 。直接 pages.map(p => p.title) 拿到的是 undefined。

这和之前处理 vxe-table 动态数据、el-cascader 回显是同一类问题:永远不要假设后端返回的结构是干净的。 写一个逐条解析、逐条兜底的工具函数:

javascript 复制代码
// utils/parsePages.js
export function parsePages(pages) {
  return (pages || []).map((raw, index) => {
    try {
      const obj = JSON.parse(raw);
      return {
        id: `ev-${index}`,
        title: obj.title || '(无标题)',
        url: obj.url || '',
        passage: obj.passage || '',
        site: obj.site || '',
        publishedAt: obj.date || null,
        freshnessBand: resolveBand(obj.date),
        userVerdict: 'unreviewed',
        conflictsWith: []
      };
    } catch (e) {
      // 解析失败不能白屏,保留原始字符串供排查
      return {
        id: `ev-${index}`,
        title: '解析失败',
        url: '',
        passage: raw,
        site: '',
        publishedAt: null,
        freshnessBand: 'unknown',
        userVerdict: 'unreviewed',
        conflictsWith: []
      };
    }
  });
}

function resolveBand(dateStr) {
  if (!dateStr) return 'unknown';
  const days = (Date.now() - new Date(dateStr).getTime()) / 86400000;
  if (days <= 7) return 'd7';
  if (days <= 30) return 'm1';
  if (days <= 180) return 'm6';
  if (days <= 730) return 'y2';
  return 'old';
}

try/catch 必须逐条加,不能让一条格式异常的结果把整个列表打成白屏。date 字段不是每条都有,缺失时显示"时间未知",而不是留一个空字符串。

四、Freshness 参数和前端时间分档不是一回事

元宝搜索的 Freshness 参数控制的是检索范围 :d7 最近七天,m3 最近三月,y2 最近两年,也可以传具体日期区间。但这个参数回答的是"搜什么",前端还需要回答另一个问题:"这条证据在用户眼里有多新。"

我在前端用 publishedAt 自行推导 freshnessBand,而不是依赖 API 返回的元数据。展示上用一个圆点区分:7 天内绿色,1 个月内黄色,6 个月内橙色,超过 1 年灰色。CSS 大概是这样:

css 复制代码
.freshness-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; margin-right: 8px; }
.freshness-dot.d7 { background: #67c23a; }
.freshness-dot.m1 { background: #e6a23c; }
.freshness-dot.m6 { background: #f56c6c; }
.freshness-dot.y2, .freshness-dot.old { background: #909399; }
.freshness-dot.unknown { background: #c0c4cc; }

搜索参数是筛子 ,前端分档是尺子 。筛子决定什么进来,尺子决定用户怎么看待进来的东西。把 Freshness 传给后端就结束了,等于只做了筛子,没做尺子。

五、证据卡片:el-collapse-transition 加三态标注

每一条证据渲染成一张 el-card,头部默认只显示时效圆点、标题和发布时间,点开后才露出 passage 摘要和原文链接。展开动画用 Element UI 自带的 el-collapse-transition,不需要引额外组件。

vue 复制代码
<!-- components/EvidenceCard.vue -->
<template>
  <el-card :class="['evidence-card', `verdict-${item.userVerdict}`]" shadow="never">
    <div slot="header" class="card-header" @click="expanded = !expanded">
      <span :class="['freshness-dot', item.freshnessBand]"></span>
      <span class="ev-title">{{ item.title }}</span>
      <span class="ev-date">{{ item.publishedAt || '时间未知' }}</span>
      <i :class="expanded ? 'el-icon-arrow-up' : 'el-icon-arrow-down'"></i>
    </div>

    <el-collapse-transition>
      <div v-show="expanded" class="card-body">
        <p class="ev-passage">{{ item.passage }}</p>
        <div class="ev-meta">
          <span v-if="item.site" class="ev-site">{{ item.site }}</span>
          <a :href="item.url" target="_blank" class="ev-link">查看原文 →</a>
        </div>
        <div class="ev-actions">
          <el-button size="mini" :type="item.userVerdict === 'trusted' ? 'success' : ''"
                     @click.stop="$emit('verdict', item.id, 'trusted')">可信</el-button>
          <el-button size="mini" :type="item.userVerdict === 'questioned' ? 'warning' : ''"
                     @click.stop="$emit('verdict', item.id, 'questioned')">存疑</el-button>
          <el-button size="mini" :type="item.userVerdict === 'expired' ? 'info' : ''"
                     @click.stop="$emit('verdict', item.id, 'expired')">标记过期</el-button>
          <el-button size="mini" plain @click.stop="$emit('conflict', item.id)">标记冲突</el-button>
        </div>
      </div>
    </el-collapse-transition>
  </el-card>
</template>

<script>
export default {
  props: { item: { type: Object, required: true } },
  data() { return { expanded: false }; }
};
</script>

<style scoped>
.evidence-card { margin-bottom: 12px; border-left: 3px solid #dcdfe6; }
.evidence-card.verdict-trusted { border-left-color: #67c23a; }
.evidence-card.verdict-questioned { border-left-color: #e6a23c; background: #fdf6ec; }
.evidence-card.verdict-expired { border-left-color: #909399; opacity: 0.65; }
.card-header { cursor: pointer; display: flex; align-items: center; }
.ev-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-right: 12px; }
.ev-date { color: #909399; font-size: 12px; margin-right: 12px; }
.ev-actions { margin-top: 12px; }
</style>

三态标注(trusted / questioned / expired)通过 border-left-color 和背景色改变卡片视觉状态。用户的操作是纯前端本地状态,不触发二次 API 调用。 用 Vuex 或 provide/inject 管理 evidenceList,刷新即重置。不做持久化,是因为这个作品验证的是"单次核查会话"中的信任建构过程,引入账号体系和后端存储反而偏离了前端体验验证的核心。

六、冲突标记:让前端成为"对抗性测试"的触发器

这是整个作品里最有记忆点的交互。用户可以在任意两张卡片上点击"标记冲突",前端把两条证据的 id 写入彼此的 conflictsWith,并在结论区弹出一条 el-alert:

⚠️ 你标记了 2 条证据存在矛盾,当前结论未检测到这一冲突。

查看冲突证据\] \[基于冲突重新判断

"基于冲突重新判断"是一个可选的后端调用:把两条冲突证据的 passage 发给一个轻量 LLM,要求它"基于这两条互相矛盾的证据,给出一个承认不确定性的回答"。如果不接 LLM,前端也可以纯本地渲染一条模板提示:"现有证据存在矛盾,建议交叉验证后再采信。"

重点是:冲突的触发权在前端。 用户不需要等后端规则引擎跑完才被告知"哦这里有矛盾"。他可以自己制造一次对抗性测试,然后观察 AI 结论会怎么变化。

七、演示界面长什么样

三栏太重,上下结构就够。上面是查询区和结论区,下面是证据卡片列表:

text 复制代码
┌──────────────────────────────────────────────────────────────┐
│ [查询输入]  时效: [近6月 ▼]  垂类: [通用 ▼]          [核查]   │
├──────────────────────────────────────────────────────────────┤
│ AI 结论(折叠态)                                              │
│ "该型号在 2025 年有召回记录......"          [展开证据链]           │
│ ⚠️ 你标记了 1 组冲突   [查看] [基于冲突重新判断]                │
├──────────────────────────────────────────────────────────────┤
│ 证据卡片列表  (3/8 已核查)                                   │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 🟢 市场监管总局公告...  2025-11-02                        │ │
│ │    [可信] [存疑] [标记过期] [标记冲突]                    │ │
│ └──────────────────────────────────────────────────────────┘ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ 🟠 某资讯站报道...  2024-06-15                            │ │
│ │    [可信] [存疑⚠️] [标记过期] [标记冲突]                  │ │
│ └──────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘

技术栈就是 Vue 2 + Element UI + Vuex(或 provide/inject)。不需要图表库,不需要额外的 UI 框架。el-collapse-transition、el-button、el-alert、el-select,全是 Element UI 自带的东西。

八、踩坑清单

坑一:Pages 是字符串套字符串。 不是对象数组,是 Array<String>,每条都要 JSON.parse。逐条 try/catch,解析失败显示"解析失败"而不是让整个列表崩掉。

坑二:date 字段会缺。 部分结果没有发布时间,前端必须有兜底显示"时间未知",不能留空,也不能因为 null 让 resolveBand 抛异常。

坑三:极速版会静默忽略 Freshness 和 Industry。 极速版只接受 Query(和 Site),其他参数不会被报错,只会被服务端直接忽略。如果演示用的是极速版,不要把时效选择器和垂类选择器当成"生效功能"来展示,否则评审复现时会发现参数根本没起作用。需要这两个能力,得升级到标准版或旗舰版。

坑四:score 的量纲会变。 默认模式下 score 落在 0~1,但加上 Industry 参数之后,实测分数量纲会超出文档描述的上界(新闻垂类最高到过 3 以上)。前端不要写 if (score > 0.8) 采信 这类绝对阈值逻辑,score 只适合做相对排序,不适合当置信度用。

坑五:authority_level 只有旗舰版有。 如果前端想根据权威度做二次排序,得先确认当前版本。标准版不返回这个字段,代码里不能假设它一定存在。

九、这个作品证明了什么

元宝搜索让 AI 的答案"有源可查"。后端通过 Freshness、Industry 和 Pages 把证据拿回来了,但证据的可信不等于用户感知到的可信。从"10 条原始结果"到"用户愿意相信的一段结论",中间那层翻译工作,是前端的事。

证据折叠不解决检索准确率,也不替代模型判断。它只做一件事:把"信不信"的控制权从 AI 手里拿回来,放进用户的按钮里。每一条 AI 判断旁边都有一组可以逐条展开、标注、甚至制造冲突的证据卡片。用户可以亲手检查 AI 到底凭什么这么说------而不是由 AI 宣称自己可信。

这个作品的全部代码,最复杂的部分是一个 JSON.parse 加一个 el-collapse-transition。但它把"搜索结果的证据结构"变成了一个用户可以操作的前端对象。如果你也在写元宝搜索的前端,不妨从 Pages 解析开始,先让证据能展开,再让用户能标注,最后让结论跟着标注变。三步下来,前端在这个链路里的位置就清楚了。

相关推荐
Csvn10 小时前
WASM
前端
修己xj11 小时前
我用 AI 给自己写了一个 Markdown 编辑器,然后把它开源了
前端
FRing0311 小时前
vue2干货总结2(v-bind、v-for、v-model、指令修饰符、v-bind操作class和style)
开发语言·前端·vue.js·vue2
幸运小猿11 小时前
模拟慢上传-处理超时重试image 丢失线上问题
前端·javascript·html
moshuying13 小时前
网购能买Token了!
前端
h_654321013 小时前
echarts实现柱状图加一条横线
前端·echarts
风骏时光牛马13 小时前
生成一个后端标题,只需要一个标题,不需要其他的任何文本。
前端
子非鱼@Itfuture13 小时前
关于前后端联调中登录状态/登录信息失效?
java·前端
郭小李13 小时前
openFeign入门
java·开发语言·前端