feat: 提交校园智能助手前端工程源码(Vue3 + Vite + view-ui-plus)
build / build (push) Successful in 3m14s

This commit is contained in:
lijing
2026-10-12 14:45:00 +08:00
parent 96bb37c89f
commit 37d292c5d2
51 changed files with 13430 additions and 2 deletions
+525
View File
@@ -0,0 +1,525 @@
<script setup lang="ts">
/**
* 左侧导航栏。
*
* 承担三件事,让主区可以专心做对话:
* 1. 品牌与身份 —— 一眼知道这是什么、当前是谁在用
* 2. 能力导航 —— 7 个业务模块全部列出,点一下就等于发出一句完整请求
* 3. 使用提示 —— 常驻说明"信息不全也没关系",降低首次使用门槛
*/
import { computed, ref } from "vue";
import { Message } from "view-ui-plus";
import { baseApi } from "@/api/module/agent";
import conf from "@/assets/config/host.config";
import { MODULE_LABEL, moduleMeta, orderedModuleKeys } from "@/assets/config/modules";
import { beginLogoutGuard } from "@/utils/logout-guard";
import type { AgentToolCatalog, UserInfoVo } from "@/types/agent";
const props = defineProps<{ catalog: AgentToolCatalog | null; user?: UserInfoVo | null }>();
const emit = defineEmits<{
(e: "pick", text: string): void;
(e: "new-session"): void;
}>();
/**
* 退出登录(同时也是"换个账号登录"的唯一入口)。
*
* ★ 必须两步都做,缺一不可(2026-10-10 修:此前只做第 ② 步,用户反馈"退出登录没用"):
*
* ① **先清本应用的服务端会话**(`POST /api/base/Logout`,后端 `session.invalidate()`)。
* 本应用的登录态就是服务端 session 里的 `_const_cas_assertion_`
* (平台 CasSsoApiRedirectFilter 拿它判断登录与否)。不清它,回到页面依旧是登录态。
* ② **再跳 CAS 全局登出**销毁 SSO 的 TGT。只做 ① 也不行 —— SSO 侧会话还在时,
* 刷新页面会被重新认证成同一个账号,表现为"退不出去、换不了人"。
*
* 顺序不能颠倒:跳走之后页面就卸载了,再没有机会回头调 ①。
* 也不能只靠 CAS 的单点登出(SLO)回推来清会话 —— 平台虽注册了 SingleSignOutFilter,
* 但没有配套的会话监听,且要求本服务地址在 CAS 侧登记过,本地联调根本不成立。
*
* `service` 是登出后的回跳地址(CAS 会带回来),由它把浏览器送回本页,
* 回来后再走一次未登录 → 302 → 登录页的流程,即可用另一个账号登录。
*
* 地址取自 host.config 的 ssoHost(本地 dev 读 .env.dev 的 VITE_SSO_HOST,
* 线上读运行时 /api/env-config);未配置时不跳错地址,只提示。
*/
async function logout() {
const sso = (conf.ssoHost || "").replace(/\/+$/, "");
// 清会话期间先压住「未登录自动跳登录页」:此刻 TGT 还在,
// 若被 302 拽去 CAS 会被静默登回原账号,用户看到"点了退出又回来了"
beginLogoutGuard();
// ① 清本应用会话。给个上限:接口不通也要往下走,不能让用户卡在"点了没反应"
try {
await Promise.race([
baseApi.logout().send(),
new Promise(resolve => setTimeout(resolve, 2500)),
]);
} catch {
// 会话可能本就过期;继续跳 CAS,至少把 SSO 侧退掉
}
if (!sso) {
Message.warning("已退出本应用;未配置单点登录地址,统一登录(SSO)未退出");
// 稍等一下让提示可见,再重载(重载后闸门随页面一起消失)
setTimeout(() => window.location.reload(), 900);
return;
}
// ② 跳 CAS 全局登出销毁 TGT,并让它把浏览器送回本页
window.location.href = `${sso}/logout?service=${encodeURIComponent(window.location.href)}`;
}
/** 刚点过的模块(本地高亮,给用户"我刚才点了哪个"的反馈) */
const active = ref<string>("");
/**
* 侧栏只列出「当前身份真正可用」的模块。
*
* 能力目录由后端按「角色 + 已授权模块」过滤后下发,学生 / 教师 / 管理员拿到的集合本就不同,
* 因此这里直接以目录为准,而不是任何前端写死的名单 —— 既不会出现"点了就被拒"的假入口,
* 也不会漏掉后端新上线、前端还没配元数据的模块。
*/
const modules = computed(() => {
const mods = props.catalog?.modules || {};
return orderedModuleKeys(mods).map(key => {
const meta = moduleMeta(key);
return {
key,
icon: meta.icon,
tone: meta.tone,
sample: meta.sample,
label: MODULE_LABEL[key] || key,
count: mods[key].length,
};
});
});
/**
* 当前登录身份,由后端按平台 {@code BoaUser} 解析(如「学生」「教师」「管理员」)。
*
* 前端**不自行判断角色**:角色号与判定规则属后端,前端复刻一份迟早会不一致。
* 展示它的意义是让用户理解"为什么我能用的功能和别人不一样"。
*/
const roleText = computed(() => props.catalog?.roles || "");
function pick(m: { key: string; sample: string }) {
if (!m.sample) return;
active.value = m.key;
emit("pick", m.sample);
}
</script>
<template>
<aside class="sb">
<!-- 品牌 -->
<div class="sb__brand">
<span class="sb__logo">
<svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
<path
d="M12 2.6l2.3 6.1 6.1 2.3-6.1 2.3L12 19.4l-2.3-6.1L3.6 11l6.1-2.3L12 2.6z"
fill="#fff"
/>
</svg>
</span>
<span class="sb__brand-text">
<strong>校园智能助手</strong>
<em>Campus Copilot</em>
</span>
</div>
<!-- 新建对话 -->
<button class="sb__new" @click="emit('new-session')">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
新建对话
</button>
<!-- 能力模块 -->
<div class="sb__group">
<div class="sb__group-title">
<span>能力模块</span>
<em v-if="modules.length" class="agent-num">{{ modules.length }}</em>
</div>
<nav class="sb__nav agent-scroll agent-scroll--dark">
<button
v-for="m in modules"
:key="m.key"
class="sb__item"
:class="[`tone-${m.tone}`, { 'sb__item--on': active === m.key }]"
:title="`对「${m.label}」说:${m.sample}`"
@click="pick(m)"
>
<span class="sb__item-icon">{{ m.icon }}</span>
<span class="sb__item-label">{{ m.label }}</span>
<span class="sb__item-count agent-num">{{ m.count }}</span>
</button>
<p v-if="!modules.length" class="sb__nav-empty">正在读取能力目录…</p>
</nav>
</div>
<!-- 底部提示 -->
<div class="sb__foot">
<div class="sb__tip">
<span class="sb__tip-title">使用提示</span>
<p>信息说不全也没关系,我会直接给你一张表来填。</p>
<p>写操作提交前可核对,确认无误再提交。</p>
</div>
<div class="sb__user">
<Avatar :src="user?.profilePicture" size="small" class="sb__avatar">
{{ (user?.name || "访").slice(0, 1) }}
</Avatar>
<span class="sb__user-text">
<strong>{{ user?.name || (user ? "用户" : "未登录") }}</strong>
<em>{{ user?.deptName || user?.account || "校园账号" }}</em>
</span>
<em
v-if="roleText"
class="sb__role"
:title="`当前身份:${roleText}(能力模块已按该身份过滤)`"
>
{{ roleText }}
</em>
<button
class="sb__logout"
type="button"
title="退出登录(可换个账号重新登录)"
aria-label="退出登录"
@click="logout"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
</button>
</div>
</div>
</aside>
</template>
<style scoped>
.sb {
flex: none;
width: var(--agent-sidebar-w);
height: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 20px 14px 16px;
background: var(--agent-sidebar-bg);
color: var(--agent-sidebar-text);
position: relative;
overflow: hidden;
}
.sb::before {
content: "";
position: absolute;
top: -130px;
left: -70px;
width: 280px;
height: 280px;
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.5), transparent 68%);
pointer-events: none;
}
/* ---------- 品牌 ---------- */
.sb__brand {
display: flex;
align-items: center;
gap: 11px;
padding: 4px 6px 0;
position: relative;
}
.sb__logo {
flex: none;
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(140deg, #3b82f6, #7c3aed);
box-shadow: 0 8px 18px -8px rgba(59, 130, 246, 0.9), inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.sb__brand-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sb__brand-text strong {
font-size: 14.5px;
font-weight: 650;
letter-spacing: 0.3px;
line-height: 1.2;
}
.sb__brand-text em {
font-style: normal;
font-size: 10.5px;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--agent-sidebar-sub);
}
/* ---------- 新建对话 ---------- */
.sb__new {
margin-top: 18px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 11px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.16s ease;
position: relative;
}
.sb__new:hover {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
.sb__new:active {
transform: translateY(0);
}
/* ---------- 模块导航 ---------- */
.sb__group {
margin-top: 22px;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
.sb__group-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 10px;
font-size: 11px;
letter-spacing: 1.4px;
color: var(--agent-sidebar-sub);
}
.sb__group-title em {
font-style: normal;
font-size: 10.5px;
padding: 1px 7px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
letter-spacing: 0;
}
.sb__nav {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 3px;
padding-right: 2px;
}
.sb__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--agent-sidebar-text);
font-size: 13px;
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.sb__item:hover {
background: var(--agent-sidebar-hover);
}
.sb__item--on {
background: var(--agent-sidebar-active);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.sb__item-icon {
flex: none;
width: 25px;
height: 25px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--tone-light, #93c5fd);
background: rgba(255, 255, 255, 0.1);
}
.sb__item-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__item-count {
flex: none;
font-size: 11px;
color: var(--agent-sidebar-sub);
}
.sb__item--on .sb__item-count {
color: rgba(255, 255, 255, 0.8);
}
.sb__nav-empty {
margin: 0;
padding: 8px 10px;
font-size: 12px;
color: var(--agent-sidebar-sub);
}
/* ---------- 底部 ---------- */
.sb__foot {
flex: none;
margin-top: 14px;
position: relative;
}
.sb__tip {
padding: 11px 12px;
border-radius: 11px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.sb__tip-title {
display: block;
font-size: 10.5px;
letter-spacing: 1.2px;
color: var(--agent-sidebar-sub);
margin-bottom: 5px;
}
.sb__tip p {
margin: 0 0 3px;
font-size: 11.5px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.72);
}
.sb__tip p:last-child {
margin-bottom: 0;
}
.sb__user {
margin-top: 12px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 9px;
border-radius: 11px;
border-top: 1px solid var(--agent-sidebar-line);
}
.sb__avatar {
flex: none;
background: linear-gradient(140deg, #3b82f6, #7c3aed);
}
.sb__user-text {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
}
/* 当前身份标签(学生 / 教师 …):解释"为什么我的能力范围和别人不同" */
.sb__role {
flex: none;
font-style: normal;
font-size: 10.5px;
line-height: 1.5;
padding: 2px 7px;
border-radius: 999px;
color: #bfdbfe;
background: rgba(59, 130, 246, 0.2);
box-shadow: inset 0 0 0 1px rgba(147, 197, 253, 0.3);
white-space: nowrap;
}
/* 退出登录:CAS 全局登出,也是"换账号重新登录"的唯一入口 */
.sb__logout {
flex: none;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: var(--agent-sidebar-sub);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.sb__logout:hover {
color: #fecaca;
background: rgba(239, 68, 68, 0.22);
}
.sb__user-text strong {
font-size: 12.5px;
font-weight: 500;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__user-text em {
font-style: normal;
font-size: 10.5px;
color: var(--agent-sidebar-sub);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+534
View File
@@ -0,0 +1,534 @@
<script setup lang="ts">
/**
* 补全表单卡片。
*
* 用户说「我要请假」但信息不全时,后端会把缺失字段(中文标签 + 格式示例 + 下拉选项)
* 下发到这里渲染成表单 —— 比让模型口头追问精确得多:
* - 下拉框:选项由后端从业务字典接口现取(如请假类型、发文部门)
* - 日期时间:用选择器,避免用户手输格式出错
* - 已识别的信息以只读标签回显,用户可核对
* 提交后直接执行对应工具,不再让模型二次猜测。
*
* 注意:view-ui-plus 组件在模板里必须用 PascalCase(Select / DatePicker / Input / Option),
* 写成 i-select / i-date-picker 不会被解析。
*/
import { reactive, computed, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import type { FormField, FormRequest } from "@/types/agent";
const props = defineProps<{
form: FormRequest;
submitted?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: "submit", payload: { tool: string; args: Record<string, any> }): void;
}>();
/** 常见参数名的中文兜底(后端已给 label 的以 label 为准) */
const NAME_LABEL: Record<string, string> = {
leaveType: "请假类型",
startDate: "开始时间",
endDate: "结束时间",
remarks: "请假事由",
classId: "班级",
stuId: "学生",
serid: "记录",
deptId: "发文部门",
moduleId: "所属栏目",
opinion: "审批意见",
remark: "备注",
joinCount: "参加人数",
guardianName: "监护人姓名",
guardianPhone: "监护人电话",
relation: "与学生关系",
};
const values = reactive<Record<string, any>>({});
const errors = reactive<Record<string, string>>({});
/** 提交后的摘要(用于只读回显) */
const summary = ref<{ label: string; text: string }[]>([]);
function toDate(v: any): Date | undefined {
if (!v) return undefined;
if (v instanceof Date) return v;
const d = new Date(String(v).replace(/-/g, "/"));
return isNaN(d.getTime()) ? undefined : d;
}
function fmt(d: Date, withTime: boolean): string {
const p = (n: number) => String(n).padStart(2, "0");
const day = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
return withTime ? `${day} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` : day;
}
function initValues() {
Object.keys(values).forEach(k => delete values[k]);
Object.keys(errors).forEach(k => delete errors[k]);
for (const f of props.form?.fields || []) {
if (f.widget === "datetime" || f.widget === "date") {
values[f.name] = toDate(f.defaultValue) ?? "";
} else if (f.widget === "select" || f.widget === "number") {
values[f.name] = f.defaultValue ?? undefined;
} else {
values[f.name] = f.defaultValue ?? "";
}
}
}
initValues();
watch(() => props.form, initValues);
/** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */
const presetList = computed(() => {
const f = props.form;
if (f?.recognized?.length) {
return f.recognized.map(r => ({ name: r.name, label: r.label, text: r.text }));
}
const preset = f?.preset || {};
return Object.entries(preset).map(([k, v]) => ({
name: k,
label: labelOf(k),
text: String(v),
}));
});
/** 还差几项必填没填(页脚给出明确预期) */
const missingCount = computed(() => (props.form?.fields || []).filter(f => f.required).length);
function labelOf(name: string): string {
const f = (props.form?.fields || []).find(x => x.name === name);
return f?.label || NAME_LABEL[name] || name;
}
function normalize(f: FormField, v: any) {
const t = (f.type || "string").toLowerCase();
if (t === "integer" || t === "number") {
const n = Number(v);
return isNaN(n) ? v : n;
}
if (v instanceof Date) return f.widget === "date" ? fmt(v, false) : fmt(v, true);
return v;
}
/**
* 选项来自后端内置兜底(学校自定义字典没取到)时的轻提示。
* 下拉框是可用的,只是可能不包含学校自定义的项 —— 用淡色说明,不阻塞填写。
*/
function dictNote(f: FormField): string {
return f.optionsFallback
? `「${f.label}」为系统内置标准类型;学校自定义项暂时没取到。`
: "";
}
/**
* 选项完全没取到(来源接口 302 / 超时,控件退化成输入框)时给一句人话提示,
* 免得用户对着一个空输入框发呆。
*/
function dictWarn(f: FormField): string {
if (f.optionsFallback) return "";
if (!f.optionsFrom) return "";
if (f.options && f.options.length) return "";
return `「${f.label}」的可选项暂时没取到(来源接口未通),可以先直接填对应编号,稍后再试。`;
}
function onSubmit() {
if (props.submitted || props.disabled) return;
let ok = true;
for (const f of props.form?.fields || []) {
const v = values[f.name];
const empty = v === undefined || v === null || String(v).trim() === "";
if (f.required && empty) {
errors[f.name] = `请填写「${f.label}」`;
ok = false;
} else {
delete errors[f.name];
}
}
if (!ok) {
Message.warning("还有必填项没填完,请检查标红的字段");
return;
}
const args: Record<string, any> = {};
// 1) 已识别的参数原样带上
Object.entries(props.form?.preset || {}).forEach(([k, v]) => {
args[k] = v;
});
// 2) 用户填写的字段
const rows: { label: string; text: string }[] = [];
for (const f of props.form?.fields || []) {
const v = values[f.name];
if (v === undefined || v === null || String(v).trim() === "") continue;
const normalized = normalize(f, v);
args[f.name] = normalized;
const opt = f.options?.find(o => String(o.value) === String(normalized));
rows.push({ label: f.label, text: opt ? opt.label : String(normalized) });
}
summary.value = rows;
emit("submit", { tool: props.form.tool, args });
}
function reset() {
initValues();
Message.info("已重置为初始值");
}
</script>
<template>
<div class="form-card" :class="{ 'form-card--done': submitted }">
<!-- 头部 -->
<div class="form-card__head">
<span class="form-card__badge" :class="form.write ? 'form-card__badge--write' : 'form-card__badge--read'">
{{ form.write ? "需要你确认" : "需要补充信息" }}
</span>
<span class="form-card__title">{{ form.title }}</span>
<span v-if="submitted" class="form-card__state">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12.5l5 5 11-11" />
</svg>
已提交
</span>
</div>
<div class="form-card__body">
<p v-if="form.description && !submitted" class="form-card__desc">{{ form.description }}</p>
<!-- 已识别信息:只读回显,便于核对 -->
<div v-if="presetList.length && !submitted" class="form-card__preset">
<span class="form-card__preset-label">已识别</span>
<span v-for="p in presetList" :key="p.name" class="chip">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12.5l5 5 11-11" />
</svg>
{{ p.label }}:{{ p.text }}
</span>
</div>
<!-- 填写区 -->
<div v-if="!submitted" class="form-card__fields">
<div v-for="f in form.fields" :key="f.name" class="field">
<label class="field__label">
<span v-if="f.required" class="field__star">*</span>{{ f.label }}
</label>
<div class="field__control">
<Select
v-if="f.widget === 'select'"
v-model="values[f.name]"
:placeholder="f.example || '请选择'"
:disabled="disabled"
clearable
>
<Option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<DatePicker
v-else-if="f.widget === 'datetime'"
v-model="values[f.name]"
type="datetime"
format="yyyy-MM-dd HH:mm:ss"
:placeholder="f.example || '选择日期与时刻'"
:disabled="disabled"
style="width: 100%"
/>
<DatePicker
v-else-if="f.widget === 'date'"
v-model="values[f.name]"
type="date"
format="yyyy-MM-dd"
:placeholder="f.example || '选择日期'"
:disabled="disabled"
style="width: 100%"
/>
<InputNumber
v-else-if="f.widget === 'number'"
v-model="values[f.name]"
:precision="f.type === 'integer' ? 0 : undefined"
:placeholder="f.example || f.description || '请输入数字'"
:disabled="disabled"
style="width: 100%"
/>
<Input
v-else-if="f.widget === 'textarea'"
v-model="values[f.name]"
type="textarea"
:rows="3"
:placeholder="f.example || f.description || '请输入'"
:disabled="disabled"
/>
<Input
v-else
v-model="values[f.name]"
:placeholder="f.example || f.description || '请输入'"
:disabled="disabled"
/>
</div>
<p v-if="dictNote(f)" class="field__tip">{{ dictNote(f) }}</p>
<p v-if="dictWarn(f)" class="field__warn">{{ dictWarn(f) }}</p>
<p v-if="f.description && f.example" class="field__tip">{{ f.description }}</p>
<p v-if="errors[f.name]" class="field__error">{{ errors[f.name] }}</p>
</div>
</div>
<!-- 提交后:只读摘要 -->
<div v-else class="form-card__summary">
<div v-for="row in summary" :key="row.label" class="summary-row">
<span class="summary-row__label">{{ row.label }}</span>
<span class="summary-row__value">{{ row.text }}</span>
</div>
</div>
</div>
<!-- 页脚 -->
<div v-if="!submitted" class="form-card__footer">
<span class="form-card__hint">
{{ form.hint || (missingCount ? `带 * 为必填,共 ${missingCount} 项` : "确认无误后提交") }}
</span>
<div class="form-card__btns">
<button class="fc-btn fc-btn--ghost" type="button" :disabled="disabled" @click="reset">重置</button>
<button class="fc-btn fc-btn--primary" type="button" :disabled="disabled" @click="onSubmit">
{{ form.write ? "确认提交" : "查询" }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.form-card {
margin-top: 11px;
border: 1px solid var(--agent-border);
border-radius: var(--agent-radius-lg);
background: var(--agent-surface);
overflow: hidden;
box-shadow: var(--agent-shadow-sm);
}
.form-card--done {
box-shadow: var(--agent-shadow-xs);
}
/* ------------------------------ 头部 ------------------------------ */
.form-card__head {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 16px;
background: linear-gradient(96deg, rgba(37, 99, 235, 0.07), rgba(124, 58, 237, 0.03) 60%, transparent);
border-bottom: 1px solid var(--agent-border);
}
.form-card__badge {
flex: none;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
color: #fff;
}
.form-card__badge--write {
background: linear-gradient(135deg, #2563eb, #4f46e5);
}
.form-card__badge--read {
background: linear-gradient(135deg, #0d9488, #0891b2);
}
.form-card__title {
font-size: 13.5px;
font-weight: 600;
color: var(--agent-text);
}
.form-card__state {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
color: var(--agent-success);
}
/* ------------------------------ 主体 ------------------------------ */
.form-card__body {
padding: 13px 16px 3px;
}
.form-card__desc {
margin: 0 0 10px;
font-size: 12.5px;
line-height: 1.7;
color: var(--agent-text-sub);
}
.form-card__preset {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-bottom: 13px;
padding: 9px 11px;
border-radius: 10px;
background: var(--agent-surface-2);
border: 1px dashed var(--agent-border-strong);
}
.form-card__preset-label {
font-size: 11px;
color: var(--agent-text-mute);
letter-spacing: 0.5px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
color: var(--agent-primary-dark);
background: var(--agent-primary-soft);
border: 1px solid var(--agent-primary-line);
}
.form-card__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 14px 18px;
padding-bottom: 4px;
}
.field__label {
display: block;
margin-bottom: 6px;
font-size: 12.5px;
color: var(--agent-text);
}
.field__star {
color: var(--agent-danger);
margin-right: 3px;
}
.field__tip {
margin: 5px 0 0;
font-size: 11px;
line-height: 1.55;
color: var(--agent-text-mute);
}
.field__warn {
margin: 5px 0 0;
font-size: 11px;
line-height: 1.55;
color: var(--agent-warn);
}
.field__error {
margin: 5px 0 0;
font-size: 11px;
color: var(--agent-danger);
}
/* ------------------------------ 提交后摘要 ------------------------------ */
.form-card__summary {
padding: 2px 0 6px;
}
.summary-row {
display: flex;
gap: 12px;
padding: 5px 0;
font-size: 12.5px;
border-bottom: 1px dashed var(--agent-border);
}
.summary-row:last-child {
border-bottom: none;
}
.summary-row__label {
flex: none;
width: 80px;
color: var(--agent-text-mute);
}
.summary-row__value {
color: var(--agent-text);
word-break: break-all;
}
/* ------------------------------ 页脚 ------------------------------ */
.form-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
padding: 11px 16px;
border-top: 1px solid var(--agent-border);
background: var(--agent-surface-2);
}
.form-card__hint {
font-size: 11.5px;
line-height: 1.6;
color: var(--agent-text-mute);
}
.form-card__btns {
flex: none;
display: flex;
gap: 8px;
}
.fc-btn {
height: 32px;
padding: 0 16px;
border-radius: 9px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.16s ease;
}
.fc-btn--ghost {
background: var(--agent-surface);
border-color: var(--agent-border-strong);
color: var(--agent-text-sub);
}
.fc-btn--ghost:hover:not(:disabled) {
border-color: var(--agent-text-mute);
color: var(--agent-text);
}
.fc-btn--primary {
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: #fff;
box-shadow: 0 10px 20px -12px rgba(37, 99, 235, 0.9);
}
.fc-btn--primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 24px -12px rgba(37, 99, 235, 0.95);
}
.fc-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
</style>
+242
View File
@@ -0,0 +1,242 @@
<script setup lang="ts">
/**
* 单条消息气泡:用户右侧、助手左侧;助手消息下挂工具执行轨迹。
* 助手侧带一个小名牌,让长对话里"谁在说话"一眼可辨。
* 文本做最小化处理:换行保留、代码块/多余 markdown 符号不渲染(保持纯文本可读)。
*/
import ToolTraceCard from "./ToolTraceCard.vue";
import FormCard from "./FormCard.vue";
import type { ChatItem } from "@/types/agent";
const props = defineProps<{ item: ChatItem; pending?: boolean }>();
const emit = defineEmits<{
(e: "form-submit", payload: { item: ChatItem; form: { tool: string; args: Record<string, any> } }): void;
}>();
/** 表单提交:冒泡给对话页统一处理(统一走同一条提交链路) */
function onFormSubmit(p: { tool: string; args: Record<string, any> }) {
emit("form-submit", { item: props.item, form: p });
}
/** 把回复拆成段落,保留换行 */
function paragraphs(text: string): string[] {
return (text || "")
.split(/\n{2,}/)
.map(s => s.trim())
.filter(Boolean);
}
</script>
<template>
<div class="msg" :class="item.role === 'user' ? 'msg--user' : 'msg--assistant'">
<div class="msg__avatar">
<svg v-if="item.role === 'assistant'" viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
<path d="M12 2.6l2.3 6.1 6.1 2.3-6.1 2.3L12 19.4l-2.3-6.1L3.6 11l6.1-2.3L12 2.6z" fill="#fff" />
</svg>
<template v-else>我</template>
</div>
<div class="msg__content">
<span v-if="item.role === 'assistant'" class="msg__name">校园智能助手</span>
<div class="msg__stack">
<!-- 助手等待中:阶段提示 -->
<div v-if="pending" class="msg__stage">
<span class="dot" />
<span class="dot" />
<span class="dot" />
<span class="msg__stage-text">{{ item.stage || "思考中…" }}</span>
</div>
<!-- 正文 -->
<div v-if="item.text" class="msg__bubble">
<p v-for="(p, i) in paragraphs(item.text)" :key="i">{{ p }}</p>
</div>
<!-- 工具轨迹 -->
<ToolTraceCard v-if="item.traces && item.traces.length" :traces="item.traces" />
<!-- 补全表单:信息不全时让用户直接填,比反复追问清晰得多 -->
<FormCard
v-if="item.form"
:form="item.form"
:submitted="item.formSubmitted"
:disabled="pending"
@submit="onFormSubmit"
/>
<!-- 错误 -->
<div v-if="item.error" class="msg__error">{{ item.error }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.msg {
display: flex;
gap: 12px;
margin-bottom: 22px;
align-items: flex-start;
}
.msg--user {
flex-direction: row-reverse;
}
.msg--assistant {
animation: rise 0.28s ease both;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ------------------------------ 头像 ------------------------------ */
.msg__avatar {
flex: none;
width: 34px;
height: 34px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12.5px;
font-weight: 600;
color: #fff;
background: linear-gradient(140deg, #3b82f6, #7c3aed);
box-shadow: 0 8px 16px -10px rgba(59, 130, 246, 0.95);
}
.msg--user .msg__avatar {
background: linear-gradient(140deg, #64748b, #475569);
box-shadow: 0 8px 16px -10px rgba(71, 85, 105, 0.9);
}
/* ------------------------------ 内容列 ------------------------------ */
.msg__content {
max-width: 84%;
min-width: 0;
display: flex;
flex-direction: column;
}
.msg--assistant .msg__content {
align-items: flex-start;
}
.msg--user .msg__content {
align-items: flex-end;
}
.msg__name {
font-size: 11.5px;
color: var(--agent-text-mute);
margin: 0 0 6px 3px;
letter-spacing: 0.3px;
}
.msg__stack {
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 0;
max-width: 100%;
}
/* ------------------------------ 气泡 ------------------------------ */
.msg__bubble {
padding: 11px 15px;
border-radius: 14px;
font-size: 14px;
line-height: 1.75;
color: var(--agent-text);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
word-break: break-word;
}
.msg__bubble p {
margin: 0 0 7px;
white-space: pre-wrap;
}
.msg__bubble p:last-child {
margin-bottom: 0;
}
.msg--user .msg__bubble {
background: linear-gradient(135deg, #2563eb, #4f46e5);
border-color: transparent;
color: #fff;
box-shadow: 0 12px 24px -14px rgba(37, 99, 235, 0.85);
}
/* ------------------------------ 阶段提示 ------------------------------ */
.msg__stage {
display: inline-flex;
align-items: center;
gap: 5px;
align-self: flex-start;
padding: 10px 15px;
border-radius: 14px;
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
font-size: 13px;
color: var(--agent-text-sub);
}
.msg__stage-text {
margin-left: 5px;
}
.dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--agent-primary);
animation: bounce 1.2s infinite ease-in-out;
}
.dot:nth-child(2) {
animation-delay: 0.15s;
}
.dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
/* ------------------------------ 错误 ------------------------------ */
.msg__error {
margin-top: 8px;
padding: 9px 13px;
border-radius: 10px;
background: var(--agent-danger-soft);
border: 1px solid #f8c9c5;
color: var(--agent-danger);
font-size: 13px;
}
</style>
+548
View File
@@ -0,0 +1,548 @@
<script setup lang="ts">
/**
* 首屏引导区。
*
* 排版分三层,视线由上到下自然下落:
* 1. Hero 横幅 —— 说明"我能帮你做什么",建立信任
* 2. 指标卡 —— 用数字证明覆盖面(能力数 / 可提交数 / 模块数)
* 3. 快速开始 —— 每个**当前身份可用的**业务模块给一句成品请求,点一下就等于说出来
* 末尾一格是"查看全部能力",既补齐网格,也让全部能力可查。
*
* 关键:模块集合与数量都来自能力目录(后端已按角色过滤),不是写死的 7 个 / 54 项。
* 学生登录后不会出现「教师专业发展」这类入口 —— 否则点下去必被服务端拒绝。
*/
import { computed, ref } from "vue";
import { MODULE_LABEL, MODULE_ORDER, moduleMeta, orderedModuleKeys } from "@/assets/config/modules";
import type { AgentToolCatalog } from "@/types/agent";
const props = defineProps<{ catalog: AgentToolCatalog | null }>();
const emit = defineEmits<{ (e: "pick", text: string): void }>();
/**
* 已知模块的开场文案(一句话就能跑通的高频请求)。
*
* 这里只负责「怎么说」,不负责「能不能看到」:
* 可见性完全由能力目录决定,因此学生身份下 profession 等条目不会出现。
* 目录里出现了此处未配置的模块(后端新上线的能力)也有兜底文案,不会漏格。
*/
const PRESET_TEXT: Record<string, { title: string; desc: string; text: string }> = {
homeleave: { title: "我要请假", desc: "说清类型与时间,直接提交", text: "我要请假" },
campusnotice: { title: "有什么新公告", desc: "查看未读校园公告", text: "我有哪些未读的校园公告?" },
dormnotice: { title: "宿舍通知回执", desc: "查看待回执的宿舍通知", text: "宿舍有什么通知需要我回执吗?" },
familyactivity: { title: "报名校园活动", desc: "活动广场报名,一次填清", text: "报名秋季亲子运动会,2 人参加" },
coursehomework: { title: "今天有什么作业", desc: "查看待做作业与截止时间", text: "看看我还有哪些作业没做" },
resourcelibrary: { title: "找教学资源", desc: "按关键词检索资源库", text: "帮我找一份语文课件" },
profession: { title: "我的积分", desc: "查看专业发展积分", text: "我的专业发展积分是多少?" },
};
/**
* 快捷卡条目:跟随能力目录生成。
*
* 目录未就绪时(首次加载中 / 拉取失败)先按全量模块占位,避免首屏"快速开始"整块空着;
* 目录到达后立即收缩为当前身份真正可用的模块。
*/
const tiles = computed(() => {
const keys = props.catalog?.modules ? orderedModuleKeys(props.catalog.modules) : MODULE_ORDER;
return keys
.map(key => {
const meta = moduleMeta(key);
const preset = PRESET_TEXT[key];
return {
module: key,
icon: meta.icon,
tone: meta.tone,
title: preset?.title || MODULE_LABEL[key] || key,
desc: preset?.desc || "点一下直接发起",
text: preset?.text || meta.sample,
};
})
.filter(t => t.text);
});
const writeCount = computed(() => {
const mods = props.catalog?.modules || {};
let n = 0;
Object.keys(mods).forEach(k => mods[k].forEach(t => t.write && n++));
return n;
});
const moduleGroups = computed(() =>
MODULE_ORDER.filter(k => props.catalog?.modules?.[k]?.length).map(key => ({
key,
label: MODULE_LABEL[key] || key,
meta: moduleMeta(key),
tools: props.catalog!.modules[key],
}))
);
/** 指标卡:数字来自后端能力目录,未就绪时先显示占位 */
const stats = computed(() => [
{
key: "tools",
value: props.catalog?.total ?? 0,
unit: "项",
label: "可操作能力",
hint: "覆盖全部业务模块",
tone: "blue",
icon: "layers",
},
{
key: "write",
value: writeCount.value,
unit: "项",
label: "可直接提交",
hint: "请假、报名、发公告…",
tone: "violet",
icon: "bolt",
},
{
key: "mods",
value: moduleGroups.value.length,
unit: "个",
label: "已接入模块",
hint: "全部来自现有系统",
tone: "teal",
icon: "cube",
},
]);
const allOpen = ref(false);
function pick(text: string) {
emit("pick", text);
}
</script>
<template>
<div class="welcome">
<!-- ① Hero -->
<section class="hero">
<span class="hero__glow hero__glow--a" />
<span class="hero__glow hero__glow--b" />
<div class="hero__inner">
<span class="hero__tag">AI Agent · 直连业务系统</span>
<h2>你好,我是校园智能助手 👋</h2>
<p>
我不只会聊天 —— 说出需求,我会直接调用系统接口帮你办完。<br />
信息说不全也没关系,我会给你一张表,填完就提交。
</p>
</div>
</section>
<!-- ② 指标卡(上浮压住横幅下沿) -->
<section class="stats">
<div v-for="s in stats" :key="s.key" class="stat" :class="`tone-${s.tone}`">
<span class="stat__icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<template v-if="s.icon === 'layers'">
<path d="M12 3l9 4.5-9 4.5-9-4.5L12 3z" />
<path d="M3 12.5l9 4.5 9-4.5" />
<path d="M3 17l9 4.5 9-4.5" />
</template>
<template v-else-if="s.icon === 'bolt'">
<path d="M13 2.5L5.5 13H11l-1 8.5L17.5 11H12l1-8.5z" />
</template>
<template v-else>
<path d="M12 2.5l8.5 4.7v9.6L12 21.5l-8.5-4.7V7.2L12 2.5z" />
<path d="M12 12l8.5-4.8M12 12v9.5M12 12L3.5 7.2" />
</template>
</svg>
</span>
<span class="stat__body">
<span class="stat__value-row">
<strong class="stat__value agent-num">{{ s.value }}</strong>
<em class="stat__unit">{{ s.unit }}</em>
<span class="stat__label">{{ s.label }}</span>
</span>
<span class="stat__hint">{{ s.hint }}</span>
</span>
</div>
</section>
<!-- ③ 快速开始 -->
<section class="block">
<div class="agent-section">
<span class="agent-section__title">快速开始</span>
<span class="agent-section__hint">点一下就是一句完整请求,也可以直接在下方输入</span>
</div>
<div class="grid">
<button v-for="t in tiles" :key="t.module" class="tile" :class="`tone-${t.tone}`" @click="pick(t.text)">
<span class="tile__icon">{{ t.icon }}</span>
<span class="tile__title">{{ t.title }}</span>
<span class="tile__desc">{{ t.desc }}</span>
<span class="tile__go">
去办理
<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>
</span>
</button>
<!-- 第 8 格:补齐网格 + 让 54 项能力可查 -->
<button class="tile tile--more" :disabled="!catalog" @click="allOpen = true">
<span class="tile__icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="5" cy="12" r="1.4" />
<circle cx="12" cy="12" r="1.4" />
<circle cx="19" cy="12" r="1.4" />
</svg>
</span>
<span class="tile__title">查看全部能力</span>
<span class="tile__desc">
共 <strong class="agent-num">{{ catalog?.total ?? 0 }}</strong> 项,按模块分组列出
</span>
<span class="tile__go">
展开清单
<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>
</span>
</button>
</div>
</section>
<!-- 能力全清单 -->
<Modal v-model="allOpen" title="已接入的全部能力" :footer-hide="true" width="780" class="caps-modal">
<div class="caps-body agent-scroll">
<p class="caps-modal__lead">
下面每一项都是对现有业务系统接口的封装,我调用它们时与你本人在系统里手动操作完全等价。
</p>
<div v-for="g in moduleGroups" :key="g.key" class="caps-group">
<div class="caps-group__head">
<span class="caps-group__icon" :class="`tone-${g.meta.tone}`">{{ g.meta.icon }}</span>
<strong>{{ g.label }}</strong>
<em class="agent-num">{{ g.tools.length }} 项</em>
</div>
<div class="caps-group__list">
<span v-for="t in g.tools" :key="t.name" class="caps-tool" :class="{ 'caps-tool--write': t.write }">
<i class="caps-tool__dot" />
{{ t.title }}
</span>
</div>
</div>
</div>
</Modal>
</div>
</template>
<style scoped>
.welcome {
max-width: var(--agent-readw);
margin: 0 auto;
padding: 28px 28px 8px;
}
/* ------------------------------ Hero ------------------------------ */
.hero {
position: relative;
overflow: hidden;
border-radius: var(--agent-radius-xl);
padding: 28px 30px 46px;
background: linear-gradient(126deg, #1b3a8f 0%, #2f5fd8 42%, #6d3fd4 100%);
box-shadow: 0 20px 44px -22px rgba(29, 78, 216, 0.7);
}
.hero__glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.hero__glow--a {
top: -150px;
right: -80px;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 62%);
}
.hero__glow--b {
bottom: -190px;
left: 16%;
width: 440px;
height: 360px;
background: radial-gradient(circle, rgba(124, 58, 237, 0.42), rgba(124, 58, 237, 0) 66%);
}
.hero__inner {
position: relative;
}
.hero__tag {
display: inline-block;
padding: 4px 11px;
border-radius: 999px;
font-size: 11.5px;
letter-spacing: 0.6px;
color: rgba(255, 255, 255, 0.94);
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.hero h2 {
margin: 14px 0 9px;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.4px;
line-height: 1.25;
color: #fff;
}
.hero p {
margin: 0;
font-size: 13.5px;
line-height: 1.75;
color: rgba(255, 255, 255, 0.82);
}
/* ------------------------------ 指标卡 ------------------------------ */
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: -30px 18px 0;
position: relative;
}
.stat {
display: flex;
align-items: center;
gap: 13px;
padding: 14px 16px;
border-radius: var(--agent-radius-lg);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-sm);
}
.stat__icon {
flex: none;
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--tone);
background: var(--tone-soft);
}
.stat__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat__value-row {
display: flex;
align-items: baseline;
gap: 6px;
}
.stat__value {
font-size: 21px;
font-weight: 700;
line-height: 1.1;
color: var(--agent-text);
}
.stat__unit {
font-style: normal;
font-size: 11.5px;
color: var(--agent-text-mute);
}
.stat__label {
font-size: 12.5px;
color: var(--agent-text-sub);
}
.stat__hint {
font-size: 11.5px;
color: var(--agent-text-mute);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ------------------------------ 快速开始 ------------------------------ */
.block {
margin-top: 30px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 13px;
}
.tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 9px;
min-height: 148px;
padding: 16px 15px 14px;
border-radius: var(--agent-radius-lg);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
text-align: left;
font-family: inherit;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.tile:hover {
transform: translateY(-3px);
box-shadow: var(--agent-shadow-md);
border-color: var(--tone);
}
.tile__icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
color: var(--tone);
background: var(--tone-soft);
}
.tile__title {
font-size: 14px;
font-weight: 600;
line-height: 1.35;
color: var(--agent-text);
}
.tile__desc {
flex: 1 1 auto;
font-size: 12px;
line-height: 1.6;
color: var(--agent-text-sub);
}
.tile__go {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 500;
color: var(--agent-text-mute);
transition: color 0.18s ease, transform 0.18s ease;
}
.tile:hover .tile__go {
color: var(--tone);
transform: translateX(2px);
}
/* 第 8 格:更多能力(虚线,弱化) */
.tile--more {
border-style: dashed;
border-color: var(--agent-border-strong);
background: rgba(255, 255, 255, 0.5);
--tone: var(--agent-text-sub);
--tone-soft: #eef1f6;
}
.tile--more:hover {
border-color: var(--agent-text-mute);
}
.tile--more .tile__desc strong {
color: var(--agent-text);
}
/* ------------------------------ 能力清单弹层 ------------------------------ */
.caps-body {
max-height: 62vh;
overflow-y: auto;
padding-right: 6px;
}
.caps-modal__lead {
margin: 0 0 16px;
font-size: 12.5px;
line-height: 1.7;
color: var(--agent-text-sub);
}
.caps-group + .caps-group {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed var(--agent-border);
}
.caps-group__head {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 9px;
}
.caps-group__head strong {
font-size: 13.5px;
color: var(--agent-text);
}
.caps-group__head em {
font-style: normal;
font-size: 11.5px;
color: var(--agent-text-mute);
}
.caps-group__icon {
width: 24px;
height: 24px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11.5px;
font-weight: 600;
color: var(--tone);
background: var(--tone-soft);
}
.caps-group__list {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.caps-tool {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 8px;
border-radius: 999px;
font-size: 12px;
color: var(--agent-text-sub);
background: var(--agent-surface-2);
border: 1px solid var(--agent-border);
}
.caps-tool__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--agent-text-mute);
}
.caps-tool--write {
color: var(--agent-text);
background: var(--agent-primary-soft);
border-color: var(--agent-primary-line);
}
.caps-tool--write .caps-tool__dot {
background: var(--agent-primary);
}
</style>
+341
View File
@@ -0,0 +1,341 @@
<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>