342 lines
10 KiB
Vue
342 lines
10 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 工具执行轨迹卡片:让用户看清"智能体到底动了系统的哪个接口、成功没有"。
|
||
* 这是把「黑盒 AI」变成「可信执行器」的关键 —— 也让审计有据可查。
|
||
* 模块标签沿用侧栏同一套色调,保证"同一个模块在哪里都是同一个颜色"。
|
||
*/
|
||
import { ref } from "vue";
|
||
import { MODULE_LABEL, moduleMeta, moduleUrl } from "@/assets/config/modules";
|
||
import type { ToolCallTrace } from "@/types/agent";
|
||
|
||
defineProps<{ traces: ToolCallTrace[] }>();
|
||
|
||
const expanded = ref<Record<number, boolean>>({});
|
||
function toggle(i: number) {
|
||
expanded.value[i] = !expanded.value[i];
|
||
}
|
||
|
||
/** 参数展示:过滤掉内部字段,值过长时截断 */
|
||
function prettyArgs(args?: Record<string, any>): string {
|
||
if (!args || !Object.keys(args).length) return "(无参数)";
|
||
const entries = Object.entries(args).map(([k, v]) => {
|
||
let val = typeof v === "object" ? JSON.stringify(v) : String(v);
|
||
if (val.length > 80) val = val.slice(0, 80) + "…";
|
||
return `${k}: ${val}`;
|
||
});
|
||
return entries.join(",");
|
||
}
|
||
|
||
function openLanding(t: ToolCallTrace) {
|
||
const url = moduleUrl(t.module, t.landingPath);
|
||
if (url) window.open(url, "_blank");
|
||
}
|
||
|
||
/**
|
||
* 是否显示「去系统里查看」入口。
|
||
*
|
||
* - 写操作成功:去系统里确认结果(原有行为);
|
||
* - **任何失败**:服务端调不通模块用户态接口(下游 code=302,只认浏览器 CAS 会话),
|
||
* 此时由用户带自己的登录态打开模块页面,是唯一能看到真实数据的路径 —— 这是「深链兜底」。
|
||
*/
|
||
function canGo(t: ToolCallTrace): boolean {
|
||
if (!t.module) return false;
|
||
// 写操作(无论成败)→ 去系统里确认/补办;只读失败(如下游 302)→ 去系统里自己看。
|
||
// 只读成功不显示:数据已经在回复里了。
|
||
return t.write === true || !t.success;
|
||
}
|
||
|
||
/** 按钮文案:成功是"去确认",失败是"自己去看" */
|
||
function goLabel(t: ToolCallTrace): string {
|
||
return t.success ? "去系统里查看" : "去系统里自己查看";
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="trace">
|
||
<div
|
||
v-for="(t, i) in traces"
|
||
:key="i"
|
||
class="trace__item"
|
||
:class="[t.success ? 'trace__item--ok' : 'trace__item--fail']"
|
||
>
|
||
<div class="trace__head" @click="toggle(i)">
|
||
<span class="trace__badge" :class="t.write ? 'trace__badge--write' : 'trace__badge--read'">
|
||
{{ t.write ? "操作" : "查询" }}
|
||
</span>
|
||
<span class="trace__title">{{ t.title }}</span>
|
||
<span v-if="t.module" class="trace__module" :class="`tone-${moduleMeta(t.module).tone}`">
|
||
{{ MODULE_LABEL[t.module] || t.module }}
|
||
</span>
|
||
|
||
<span class="trace__spacer" />
|
||
|
||
<span class="trace__status">
|
||
<svg v-if="t.success" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M4 12.5l5 5 11-11" />
|
||
</svg>
|
||
<svg v-else viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||
<path d="M6 6l12 12M18 6L6 18" />
|
||
</svg>
|
||
<!-- 失败原因是自然语言(可能很长)→ 这里只显示一行省略号,完整内容在「详情」里看 -->
|
||
<span class="trace__status-text" :title="t.success ? '' : t.error || ''">
|
||
<template v-if="t.success">完成{{ t.costMs ? ` · ${t.costMs}ms` : "" }}</template>
|
||
<template v-else>{{ t.error || "失败" }}</template>
|
||
</span>
|
||
</span>
|
||
|
||
<span class="trace__toggle">
|
||
{{ expanded[i] ? "收起" : "详情" }}
|
||
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" :class="{ 'trace__chev--up': expanded[i] }">
|
||
<path d="M6 9l6 6 6-6" />
|
||
</svg>
|
||
</span>
|
||
</div>
|
||
|
||
<div v-if="expanded[i]" class="trace__body">
|
||
<div v-if="!t.success && t.error" class="trace__row">
|
||
<span class="trace__k">原因</span>
|
||
<span class="trace__v trace__v--err">{{ t.error }}</span>
|
||
</div>
|
||
<div class="trace__row">
|
||
<span class="trace__k">接口</span>
|
||
<code class="trace__code">{{ t.name }}</code>
|
||
</div>
|
||
<div class="trace__row">
|
||
<span class="trace__k">参数</span>
|
||
<span class="trace__v">{{ prettyArgs(t.arguments) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="canGo(t)" class="trace__actions">
|
||
<button
|
||
class="trace__go"
|
||
:class="{ 'trace__go--fallback': !t.success }"
|
||
type="button"
|
||
@click="openLanding(t)"
|
||
>
|
||
{{ goLabel(t) }}
|
||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||
<path d="M5 12h13M12 6l6 6-6 6" />
|
||
</svg>
|
||
</button>
|
||
<span v-if="!t.success" class="trace__go-hint">
|
||
服务端拿不到你在业务系统的登录态,用你自己的登录态打开即可看到真实数据
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.trace {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 7px;
|
||
margin-top: 9px;
|
||
}
|
||
|
||
.trace__item {
|
||
border: 1px solid var(--agent-border);
|
||
border-left: 3px solid var(--agent-success);
|
||
border-radius: 10px;
|
||
background: var(--agent-surface);
|
||
padding: 9px 12px;
|
||
transition: box-shadow 0.16s ease;
|
||
}
|
||
|
||
.trace__item--fail {
|
||
border-left-color: var(--agent-danger);
|
||
background: #fffbfb;
|
||
}
|
||
|
||
/* ------------------------------ 头部 ------------------------------ */
|
||
.trace__head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
cursor: pointer;
|
||
font-size: 12.5px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.trace__badge {
|
||
flex: none;
|
||
padding: 2px 7px;
|
||
border-radius: 5px;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
|
||
.trace__badge--read {
|
||
background: var(--agent-primary-soft);
|
||
color: var(--agent-primary-dark);
|
||
}
|
||
|
||
.trace__badge--write {
|
||
background: #fdf2e2;
|
||
color: var(--agent-warn);
|
||
}
|
||
|
||
.trace__title {
|
||
flex: none;
|
||
color: var(--agent-text);
|
||
font-weight: 550;
|
||
}
|
||
|
||
.trace__module {
|
||
flex: none;
|
||
padding: 2px 8px;
|
||
border-radius: 999px;
|
||
font-size: 11px;
|
||
color: var(--tone);
|
||
background: var(--tone-soft);
|
||
}
|
||
|
||
.trace__spacer {
|
||
flex: 1 1 auto;
|
||
}
|
||
|
||
.trace__status {
|
||
/* ★ 可收缩:失败原因是自然语言(可能很长),必须让它让步给「详情」按钮,
|
||
否则整张卡会被撑破、右侧按钮跑出视野(见 2026-10-10 截图) */
|
||
flex: 0 1 auto;
|
||
min-width: 0;
|
||
max-width: 46%;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
color: var(--agent-success);
|
||
font-size: 12px;
|
||
}
|
||
|
||
.trace__status > svg {
|
||
flex: none;
|
||
}
|
||
|
||
.trace__status-text {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.trace__item--fail .trace__status {
|
||
color: var(--agent-danger);
|
||
}
|
||
|
||
.trace__toggle {
|
||
flex: none;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
color: var(--agent-text-mute);
|
||
font-size: 12px;
|
||
transition: color 0.16s ease;
|
||
}
|
||
|
||
.trace__head:hover .trace__toggle {
|
||
color: var(--agent-primary);
|
||
}
|
||
|
||
.trace__chev--up {
|
||
transform: rotate(180deg);
|
||
}
|
||
|
||
/* ------------------------------ 展开体 ------------------------------ */
|
||
.trace__body {
|
||
margin-top: 9px;
|
||
padding-top: 9px;
|
||
border-top: 1px dashed var(--agent-border);
|
||
font-size: 12.5px;
|
||
color: var(--agent-text-sub);
|
||
}
|
||
|
||
.trace__row {
|
||
display: flex;
|
||
gap: 9px;
|
||
margin-bottom: 5px;
|
||
word-break: break-all;
|
||
}
|
||
|
||
.trace__row:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.trace__k {
|
||
flex: none;
|
||
width: 32px;
|
||
color: var(--agent-text-mute);
|
||
}
|
||
|
||
.trace__code {
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||
font-size: 11.5px;
|
||
padding: 1px 6px;
|
||
border-radius: 5px;
|
||
color: var(--agent-primary-dark);
|
||
background: var(--agent-primary-soft);
|
||
}
|
||
|
||
.trace__v {
|
||
color: var(--agent-text);
|
||
}
|
||
|
||
.trace__v--err {
|
||
color: var(--agent-danger, #d4380d);
|
||
}
|
||
|
||
/* ------------------------------ 动作 ------------------------------ */
|
||
.trace__actions {
|
||
margin-top: 9px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 9px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.trace__go {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
height: 28px;
|
||
padding: 0 12px;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--agent-primary-line);
|
||
background: var(--agent-primary-soft);
|
||
color: var(--agent-primary-dark);
|
||
font-family: inherit;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: background 0.16s ease, border-color 0.16s ease;
|
||
}
|
||
|
||
.trace__go:hover {
|
||
background: #dfeaff;
|
||
border-color: var(--agent-primary);
|
||
}
|
||
|
||
/* 深链兜底:查询失败时用稍弱的中性色,与"成功后的去确认"区分开 */
|
||
.trace__go--fallback {
|
||
border-color: var(--agent-border);
|
||
background: #fff;
|
||
color: var(--agent-text);
|
||
}
|
||
|
||
.trace__go--fallback:hover {
|
||
background: #f6f8fb;
|
||
border-color: var(--agent-primary);
|
||
color: var(--agent-primary-dark);
|
||
}
|
||
|
||
/* 解释"为什么要去系统里看" —— 一句话说清,避免用户以为智能体坏了 */
|
||
.trace__go-hint {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
font-size: 11.5px;
|
||
color: var(--agent-text-mute);
|
||
line-height: 1.4;
|
||
}
|
||
</style>
|