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
+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
/**
* 应用外壳:左侧导航栏 + 右侧内容区。
*
* 职责划分:
* - 这里统一拉取「当前用户」和「能力目录」(两者都是全局信息,侧栏和对话页都要用),
* 再往下传,避免各组件各拉一次。
* - 会话状态留在对话页内部,侧栏的「新建对话」通过组件 ref 调用它的方法触发。
*/
import { computed, onMounted, ref } from "vue";
import { Message } from "view-ui-plus";
import { agentApi, baseApi } from "@/api/module/agent";
import AppSidebar from "@/components/AppSidebar.vue";
import type { AgentToolCatalog, UserInfoVo } from "@/types/agent";
const user = ref<UserInfoVo | null>(null);
const catalog = ref<AgentToolCatalog | null>(null);
const loading = ref(true);
const online = ref(false);
/** 对话页组件实例(用于从侧栏触发"新建对话") */
const chatRef = ref<any>(null);
const statusText = computed(() => (loading.value ? "正在连接" : online.value ? "服务已连接" : "服务未连接"));
const toolCount = computed(() => catalog.value?.total ?? 0);
const writeCount = computed(() => {
const mods = catalog.value?.modules || {};
let n = 0;
Object.keys(mods).forEach(k => mods[k].forEach(t => t.write && n++));
return n;
});
/**
* 拉取当前登录用户(平台 CAS 会话)。
*
* 未登录时后端 CasSsoApiRedirectFilter 返回 code=302 + urlToRedirectTo,
* alova 的 errorHandle 收到 302 会直接整页跳转登录页,并返回一个**永不 resolve** 的 Promise,
* 所以跳转链路根本不会落到下面的 catch —— 这里只需处理「服务没起来 / 接口报错」。
*/
async function loadUser() {
try {
const info = await baseApi.getUserInfo().send();
user.value = (info || null) as UserInfoVo;
online.value = true;
} catch (e: any) {
console.warn("载入用户信息失败", e);
Message.error("载入用户信息失败,请检查智能体服务是否已启动");
} finally {
loading.value = false;
}
}
/** 能力目录:拉取失败不阻塞对话(只是侧栏模块列表为空) */
async function loadCatalog() {
try {
catalog.value = (await agentApi.tools().send()) as AgentToolCatalog;
online.value = true;
} catch (e) {
console.warn("能力目录拉取失败", e);
}
}
onMounted(() => {
loadUser();
loadCatalog();
});
function onNewSession() {
chatRef.value?.newSession?.();
}
</script>
<template>
<div class="agent-shell">
<AppSidebar :catalog="catalog" :user="user" @pick="t => chatRef?.pickSample?.(t)" @new-session="onNewSession" />
<section class="agent-main">
<header class="agent-topbar">
<div class="agent-topbar__titles">
<h1>智能对话</h1>
<p>说出需求,我直接调用系统接口帮你办</p>
</div>
<div class="agent-topbar__right">
<span class="agent-status" :class="{ 'agent-status--off': !loading && !online }">
<i class="agent-status__dot" />
{{ statusText }}
</span>
<span v-if="toolCount" class="agent-capsule">
可用能力 <strong class="agent-num">{{ toolCount }}</strong> 项
<i class="agent-capsule__sep" />
可直接提交 <strong class="agent-num">{{ writeCount }}</strong> 项
</span>
</div>
</header>
<main class="agent-body">
<router-view v-slot="{ Component }">
<component :is="Component" ref="chatRef" :user="user" :catalog="catalog" />
</router-view>
</main>
</section>
</div>
</template>
+101
View File
@@ -0,0 +1,101 @@
import { Method } from "alova";
import { alovaInstance } from "@/utils/alova/alova-iview";
import conf from "@/assets/config/host.config";
import type {
AgentChatRequest,
AgentChatResponse,
AgentToolCatalog,
FormSubmit,
ToolResultItem,
UserInfoVo,
ModuleNode,
} from "@/types/agent";
/**
* 智能助手接口封装。
*
* 前缀说明:conf.serviceHost 本地为空(走 vite 同源代理转发到本地 AgentService 8093),
* 线上为基础平台域名;platform 固定 "api",与后端 Config.API_PATH 匹配。
* 统一响应 {code,msg,data} 由平台 ApiResponseAdvice 包装,alova-iview 解包为 data(code==0)。
*/
const AGENT = "/api/agent";
const BASE = "/api/base";
/** GET 请求 */
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
return new Method<T>("GET", alovaInstance, url, { params });
}
/** POST JSON body(与后端 @RequestBody 对齐) */
function postJSON<T = any>(url: string, data?: Record<string, any>): Method<T> {
return new Method<T>(
"POST",
alovaInstance,
url,
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
data ? JSON.stringify(data) : undefined
);
}
/** 平台壳层接口(当前用户 / 已授权模块 / 退出登录) */
export class BaseApi {
getUserInfo() {
return get<UserInfoVo>(`${BASE}/GetUserInfo`);
}
getUserMod() {
return get<ModuleNode[]>(`${BASE}/GetUserMod`);
}
/**
* 退出登录:销毁本应用的服务端 CAS 会话。
*
* <p>本应用的「登录态」就是服务端 session 里的 {@code _const_cas_assertion_},
* 光跳 CAS 的 {@code /logout} 只销毁 SSO 票据,JSESSIONID 还活着 ——
* 回到页面仍是登录态,表现为「点了退出没用」。所以登出必须先调它。
*/
logout() {
return postJSON<null>(`${BASE}/Logout`);
}
}
/** 智能助手接口 */
export class AgentApi {
/** 一问一答:返回智能体回复 + 本轮执行过的工具轨迹(可能带补全表单) */
chat(data: AgentChatRequest) {
return postJSON<AgentChatResponse>(`${AGENT}/chat`, data);
}
/**
* 表单提交:带着用户填好的参数直接执行指定工具。
* 服务端不再让模型二次决策,保证「填了什么就提交什么」。
*/
submitForm(sessionId: string | undefined, data: FormSubmit) {
return postJSON<AgentChatResponse>(`${AGENT}/chat`, { sessionId, formSubmit: data });
}
/**
* 浏览器代执行结果回传(方案 A):前端用用户登录态调完模块接口后,
* 把每个调用的原始响应带回,服务端解析并喂回模型生成最终回答。
*/
submitToolResults(sessionId: string | undefined, toolResult: ToolResultItem[]) {
return postJSON<AgentChatResponse>(`${AGENT}/chat`, { sessionId, toolResult });
}
/** 能力目录:按模块分组的可用工具,用于首屏快捷指令 */
tools() {
return get<AgentToolCatalog>(`${AGENT}/tools`);
}
/** 清空会话上下文 */
clearSession(sessionId?: string) {
return postJSON<{ cleared: boolean }>(`${AGENT}/session/clear`, { sessionId });
}
/** SSE 地址(供 EventSource / fetch 流式消费;本版本前端默认用阻塞式 chat) */
streamUrl(): string {
return `${conf.serviceHost}${AGENT}/chat/stream`;
}
}
export const baseApi = new BaseApi();
export const agentApi = new AgentApi();
+81
View File
@@ -0,0 +1,81 @@
import { BaseConf } from "@/types/Basics";
/**
* 配置优先级(高 → 低):
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
* /<contextPath>/api/env-config?target=<部署目录名>(本应用部署于 /{contextPath}/front/,
* 故为 front,对应后端键 yh.env-config.source.front,projman 配置管理维护)写入
* ② import.meta.env.VITE_*:仅本地开发(vite dev --mode dev 读 .env.dev);
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod 已删),VITE_* 恒为 undefined,
* 本层自动跳过,产物不含任何环境值
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
* ssoHost 为空 → 登出跳转不可用——与后端未就绪状态一致,可接受。
*/
const env = import.meta.env;
// ③ 同域推导兜底:context 段取当前路径首段(/{contextPath}/front/ → contextPath,即后端上下文),
// 与服务端 context-path 对齐(本地无、base/prod /homeleave);本地 dev 在根路径运行(无段),
// serviceHost 留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
const seg = window.location.pathname.split("/").filter(Boolean);
const origin = window.location.origin;
let conf: BaseConf = {
baseHost: origin,
ssoHost: "",
serviceHost: seg.length >= 2 ? `${origin}/${seg[0]}` : "",
relayConf: {
ziyuankuHost: "",
zhuanxiangHost: "",
},
isDebug: env.DEV,
};
// 逐键覆盖:只覆盖来源里实际出现且非空的键,未出现的键沿用底层值
const applyConf = (target: any, source: any) => {
if (!source || typeof source !== "object") return;
Object.keys(source).forEach(key => {
if (source[key] !== undefined && source[key] !== null && source[key] !== "") {
(target as any)[key] = source[key];
}
});
};
// ② 本地开发(build 产物中恒 undefined → 自动跳过)
applyConf(conf, {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_SERVICE_HOST,
relayConf: {
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
},
});
// ① 线上运行时配置(最高优先,覆盖前面所有);relayConf 为嵌套对象,按子键合并,
// 运行时 JSON 只配部分资源库地址时未配的键沿用兜底值
const runtime = (window as any).__APP_CONFIG__;
applyConf(conf, runtime);
if (runtime && typeof runtime === "object" && runtime.relayConf) {
applyConf(conf.relayConf, runtime.relayConf);
}
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
var ishttps = "https:" == document.location.protocol ? true : false;
if (ishttps) {
Object.keys(conf).forEach(element => {
if (typeof conf[element] === "string" && conf[element].indexOf("http://") > -1) {
conf[element] = conf[element].replace("http://", "https://");
}
});
// relayConf 内部也需要处理
if (conf.relayConf) {
Object.keys(conf.relayConf).forEach(key => {
const val = (conf.relayConf as any)[key];
if (typeof val === "string" && val.indexOf("http://") > -1) {
(conf.relayConf as any)[key] = val.replace("http://", "https://");
}
});
}
}
export default conf;
+122
View File
@@ -0,0 +1,122 @@
import conf from "@/assets/config/host.config";
/**
* 业务模块 → 前端上下文路径映射。
*
* 平台部署约定:每个前端应用部署在 {@code /{contextPath}/front/},
* 例如学生请假为 {@code /home-leave/front/}。智能体执行完写操作后,
* 「去处理」按钮就是按这张表拼出具体页面地址({@code baseHost + /contextPath/front/#landingPath})。
*
* 新增模块时,这里补一行即可(key 必须与后端 ToolDef.module 一致)。
*/
export const MODULE_CONTEXT: Record<string, string> = {
homeleave: "home-leave",
campusnotice: "campusnotice",
dormnotice: "dormnotice",
familyactivity: "familyactivity",
coursehomework: "coursehomework",
resourcelibrary: "resourcelibrary",
profession: "profession",
};
/** 模块中文名(能力目录 / 轨迹展示用) */
export const MODULE_LABEL: Record<string, string> = {
homeleave: "学生请假",
campusnotice: "校园公告",
dormnotice: "宿舍通知",
familyactivity: "家校活动",
coursehomework: "课程作业",
resourcelibrary: "资源库",
profession: "教师专业发展",
};
/**
* 模块**默认落地页**(模块前端的 hash 路由)——「深链兜底」用。
*
* <p>为什么需要:模块的用户态接口只认浏览器 CAS 会话,服务端调不通(下游 `code=302`)。
* 这时智能体会给出「去系统里查看」入口,让用户带自己的登录态打开模块页面看到真实数据。
* 后端已在注册期用 `agent.module-landing` 回填了 `landingPath`,这里是**前端侧的兜底**:
* 万一后端没给(旧版本 / 新模块未配),就退到这张表,保证按钮永远有目标。
*
* <p>取值已与各模块前端 `src/router/routes.ts` 的真实路由核对(2026-10-10)。
*/
export const MODULE_LANDING: Record<string, string> = {
homeleave: "/my",
campusnotice: "/",
dormnotice: "/par/notices",
familyactivity: "/par/activities",
coursehomework: "/stu/today",
resourcelibrary: "/resource",
profession: "/home",
};
/**
* 模块展示元数据(侧栏导航 / 快捷卡 / 轨迹卡共用)。
*
* - icon:单字图标(中文首字,比图形图标更好认,也不用额外加载图标资源)
* - tone:色调类名后缀,对应 agent.css 里的 .tone-xx,保证同一模块处处同色
* - sample:点击该模块时直接发出的示例句(一句就能跑通的高频请求)
*/
export const MODULE_META: Record<string, { icon: string; tone: string; sample: string }> = {
homeleave: { icon: "假", tone: "blue", sample: "我要请假" },
campusnotice: { icon: "公", tone: "violet", sample: "我有哪些未读的校园公告?" },
dormnotice: { icon: "舍", tone: "teal", sample: "宿舍有什么通知需要我回执吗?" },
familyactivity: { icon: "活", tone: "amber", sample: "报名秋季亲子运动会,2 人参加" },
coursehomework: { icon: "作", tone: "rose", sample: "看看我还有哪些作业没做" },
resourcelibrary: { icon: "资", tone: "cyan", sample: "帮我找一份语文课件" },
profession: { icon: "分", tone: "indigo", sample: "我的专业发展积分是多少?" },
};
/**
* 侧栏模块导航的**首选展示顺序**(与后端 ToolRegistry 注册顺序保持一致)。
*
* 注意:它是「排序依据」,**不是可见性白名单**。真正决定"能看到哪些模块"的是
* `GET /api/agent/tools` —— 后端已按「当前角色 + 已授权模块」筛过一遍,
* 不同身份(学生 / 教师 / 管理员)拿到的模块集合本来就不同。
* 前端若再拿这份名单去过滤,会出现「后端给了、前端不显示」的漏配。
*/
export const MODULE_ORDER = [
"homeleave",
"campusnotice",
"dormnotice",
"familyactivity",
"coursehomework",
"resourcelibrary",
"profession",
];
/**
* 按能力目录返回的模块集合,排出稳定且可扩展的展示顺序。
*
* 策略:MODULE_ORDER 里出现过的按既定顺序在前,其余(后端新增、前端还没配元数据的模块)
* 按后端返回顺序追加到尾部 —— `moduleMeta()` / `MODULE_LABEL` 都有兜底,不会渲染成空白项。
*
* @param modules 后端 `catalog.modules`(形如 `{ homeleave: [...], profession: [...] }`)
*/
export function orderedModuleKeys(modules?: Record<string, any[]> | null): string[] {
const keys = Object.keys(modules || {}).filter(k => modules![k]?.length);
const known = MODULE_ORDER.filter(k => keys.includes(k));
const rest = keys.filter(k => !MODULE_ORDER.includes(k));
return [...known, ...rest];
}
/** 取模块元数据;未知模块给一份中性兜底 */
export function moduleMeta(key: string) {
return MODULE_META[key] || { icon: "·", tone: "slate", sample: "" };
}
/**
* 拼出模块内某个页面的完整地址。
* @param module 模块标识(homeleave / coursehomework …)
* @param landingPath 模块内路由(如 /my);省略时退到 {@link MODULE_LANDING} 的模块默认页
* @returns 可打开的 URL;模块未知时返回 null
*/
export function moduleUrl(module?: string, landingPath?: string): string | null {
if (!module) return null;
const ctx = MODULE_CONTEXT[module];
if (!ctx) return null;
const base = (conf.baseHost || window.location.origin).replace(/\/+$/, "");
const path = landingPath || MODULE_LANDING[module] || "";
const hash = path ? `#${path.startsWith("/") ? path : "/" + path}` : "";
return `${base}/${ctx}/front/${hash}`;
}
+1
View File
@@ -0,0 +1 @@
+384
View File
@@ -0,0 +1,384 @@
/* ==========================================================================
校园智能助手 · 设计系统
--------------------------------------------------------------------------
版式基调:深色导航侧栏 + 浅色内容画布,强调留白、层次与克制的色彩。
本文件只放"全局令牌 + 外壳骨架",组件内部样式各自写在 .vue 的 scoped 块里。
========================================================================== */
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
color: var(--agent-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Helvetica Neue", Arial, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
#app {
height: 100%;
}
/* ------------------------------ 设计令牌 ------------------------------ */
:root {
/* 品牌色 */
--agent-primary: #2563eb;
--agent-primary-dark: #1d4ed8;
--agent-primary-soft: #eaf1ff;
--agent-primary-line: #cfe0ff;
--agent-accent: #7c3aed;
/* 中性色 */
--agent-bg: #f4f6fb;
--agent-surface: #ffffff;
--agent-surface-2: #fafbfe;
--agent-border: #e9edf5;
--agent-border-strong: #dbe2ee;
--agent-text: #0f172a;
--agent-text-sub: #5b6b83;
--agent-text-mute: #94a3b8;
/* 语义色 */
--agent-success: #16a34a;
--agent-success-soft: #e9f7ef;
--agent-warn: #d97706;
--agent-danger: #dc2626;
--agent-danger-soft: #fdeceb;
/* 形状 */
--agent-radius-sm: 8px;
--agent-radius: 12px;
--agent-radius-lg: 16px;
--agent-radius-xl: 22px;
/* 阴影(统一用"贴地短影 + 大扩散长影"两层,显得干净不脏) */
--agent-shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.04);
--agent-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.05), 0 8px 20px -12px rgba(15, 23, 42, 0.16);
--agent-shadow-md: 0 2px 6px rgba(15, 23, 42, 0.06), 0 18px 36px -16px rgba(15, 23, 42, 0.22);
--agent-shadow-lg: 0 4px 10px rgba(15, 23, 42, 0.07), 0 28px 60px -24px rgba(15, 23, 42, 0.3);
/* 侧栏 */
--agent-sidebar-w: 252px;
--agent-sidebar-bg: linear-gradient(178deg, #0e1730 0%, #131f3d 46%, #17264a 100%);
--agent-sidebar-text: rgba(255, 255, 255, 0.92);
--agent-sidebar-sub: rgba(255, 255, 255, 0.5);
--agent-sidebar-line: rgba(255, 255, 255, 0.1);
--agent-sidebar-hover: rgba(255, 255, 255, 0.07);
--agent-sidebar-active: rgba(255, 255, 255, 0.13);
/* 内容区最大宽度 */
--agent-readw: 880px;
}
/* --------------------------- 模块色调(共享) ---------------------------
每个模块一个克制的低饱和色调:
--tone 主色(浅底上使用)
--tone-soft 浅色底(卡片/标签底)
--tone-light 亮色(深色侧栏上使用,保证对比度)
侧栏导航 / 快捷卡 / 轨迹卡统一用 .tone-xx 取色,保证同一模块到哪都是同一个颜色。 */
.tone-blue {
--tone: #2563eb;
--tone-soft: #eaf1ff;
--tone-light: #7cb0ff;
}
.tone-violet {
--tone: #7c3aed;
--tone-soft: #f2ecff;
--tone-light: #b79cff;
}
.tone-teal {
--tone: #0d9488;
--tone-soft: #e3f6f3;
--tone-light: #5fd3c6;
}
.tone-amber {
--tone: #d97706;
--tone-soft: #fdf2e2;
--tone-light: #f3c169;
}
.tone-rose {
--tone: #e11d48;
--tone-soft: #fdeaee;
--tone-light: #ff92a9;
}
.tone-cyan {
--tone: #0891b2;
--tone-soft: #e4f5fa;
--tone-light: #6ad7ee;
}
.tone-indigo {
--tone: #4f46e5;
--tone-soft: #ececff;
--tone-light: #a8a5ff;
}
.tone-slate {
--tone: #475569;
--tone-soft: #eef1f6;
--tone-light: #b6c1d1;
}
/* ------------------------------ 滚动条 ------------------------------ */
.agent-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(15, 23, 42, 0.16) transparent;
}
.agent-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.agent-scroll::-webkit-scrollbar-track {
background: transparent;
}
.agent-scroll::-webkit-scrollbar-thumb {
background: rgba(15, 23, 42, 0.14);
border-radius: 999px;
}
.agent-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(15, 23, 42, 0.26);
}
.agent-scroll--dark {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.agent-scroll--dark::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
}
.agent-scroll--dark::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.32);
}
::selection {
background: rgba(37, 99, 235, 0.18);
}
/* ------------------------------ 外壳骨架 ------------------------------ */
.agent-shell {
height: 100%;
display: flex;
overflow: hidden;
background: var(--agent-bg);
}
/* 左:导航侧栏 */
.agent-sidebar {
flex: none;
width: var(--agent-sidebar-w);
height: 100%;
display: flex;
flex-direction: column;
background: var(--agent-sidebar-bg);
color: var(--agent-sidebar-text);
position: relative;
overflow: hidden;
}
/* 侧栏顶部一抹品牌光晕 */
.agent-sidebar::before {
content: "";
position: absolute;
top: -120px;
left: -60px;
width: 260px;
height: 260px;
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.55), transparent 68%);
pointer-events: none;
}
/* 右:主内容区 */
.agent-main {
flex: 1 1 auto;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
background:
radial-gradient(1100px 380px at 12% -12%, rgba(37, 99, 235, 0.07), transparent 62%),
radial-gradient(900px 340px at 92% -6%, rgba(124, 58, 237, 0.06), transparent 58%),
var(--agent-bg);
}
.agent-topbar {
flex: none;
height: 64px;
padding: 0 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: saturate(180%) blur(12px);
border-bottom: 1px solid var(--agent-border);
position: relative;
z-index: 5;
}
.agent-topbar__titles h1 {
margin: 0;
font-size: 16px;
font-weight: 650;
letter-spacing: 0.2px;
line-height: 1.3;
color: var(--agent-text);
}
.agent-topbar__titles p {
margin: 2px 0 0;
font-size: 12px;
line-height: 1.3;
color: var(--agent-text-mute);
}
.agent-topbar__right {
display: flex;
align-items: center;
gap: 14px;
}
/* 状态胶囊:服务在线 */
.agent-status {
display: inline-flex;
align-items: center;
gap: 7px;
height: 30px;
padding: 0 12px;
border-radius: 999px;
font-size: 12.5px;
color: var(--agent-text-sub);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
}
.agent-status__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--agent-success);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14);
}
.agent-status--off .agent-status__dot {
background: var(--agent-danger);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.14);
}
/* 顶栏能力概览 */
.agent-capsule {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 13px;
border-radius: 999px;
font-size: 12.5px;
color: var(--agent-text-sub);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
}
.agent-capsule strong {
font-weight: 650;
color: var(--agent-text);
}
.agent-capsule__sep {
width: 1px;
height: 11px;
background: var(--agent-border-strong);
margin: 0 2px;
}
.agent-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ---------------------------- 通用小工具 ---------------------------- */
/* 分区块标题:左侧一道短色条 + 标题 + 右侧说明 */
.agent-section {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
}
.agent-section__title {
position: relative;
padding-left: 12px;
font-size: 15px;
font-weight: 650;
color: var(--agent-text);
letter-spacing: 0.2px;
}
.agent-section__title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 15px;
border-radius: 999px;
background: linear-gradient(180deg, var(--agent-primary), var(--agent-accent));
}
.agent-section__hint {
font-size: 12px;
color: var(--agent-text-mute);
}
/* 数字用等宽数字,避免跳动 */
.agent-num {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* ---------------------- view-ui-plus 局部微调 ----------------------
只做"全局手感"的轻量统一,避免大面积覆盖组件库样式。 */
.ivu-btn {
font-weight: 500;
}
.ivu-btn-primary {
background-color: var(--agent-primary);
border-color: var(--agent-primary);
}
.ivu-btn-primary:hover {
background-color: var(--agent-primary-dark);
border-color: var(--agent-primary-dark);
}
.ivu-input:hover,
.ivu-select:hover .ivu-select-selection,
.ivu-input-number:hover {
border-color: var(--agent-border-strong);
}
.ivu-input:focus,
.ivu-input.ivu-input-focus,
.ivu-select-visible .ivu-select-selection {
border-color: var(--agent-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.ivu-select-selection,
.ivu-input {
border-radius: 8px;
}
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const AgentApi: typeof import('./api/module/agent')['AgentApi']
const Avatar: typeof import('view-ui-plus')['Avatar']
const BaseApi: typeof import('./api/module/agent')['BaseApi']
const Button: typeof import('view-ui-plus')['Button']
const EffectScope: typeof import('vue')['EffectScope']
const Input: typeof import('view-ui-plus')['Input']
const Message: typeof import('view-ui-plus')['Message']
const Tag: typeof import('view-ui-plus')['Tag']
const Tooltip: typeof import('view-ui-plus')['Tooltip']
const agentApi: typeof import('./api/module/agent')['agentApi']
const baseApi: typeof import('./api/module/agent')['baseApi']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useForm: typeof import('alova/client')['useForm']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRequest: typeof import('alova/client')['useRequest']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useWatcher: typeof import('alova/client')['useWatcher']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { BaseApi, AgentApi } from './api/module/agent'
import('./api/module/agent')
}
+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>
+29
View File
@@ -0,0 +1,29 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
import utils from "@/utils";
import { createPinia } from "pinia";
// @ts-ignore
import ViewUIPlus from "view-ui-plus";
import mittBus from "@/utils/mitt";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/agent.css";
import "virtual:uno.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ViewUIPlus);
import config from "@/assets/config/host.config";
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
app.config.globalProperties.$bus = mittBus;
app.mount("#app");
+23
View File
@@ -0,0 +1,23 @@
import { createRouter, createWebHashHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
/**
* 智能助手路由:单页应用,唯一入口为对话页。
* 用 hash 模式:产物部署在 /{contextPath}/front/ 下,无需服务端 rewrite 规则。
*/
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "chat",
component: () => import("@/views/Chat.vue"),
meta: { title: "智能助手" },
},
{ path: "/:pathMatch(.*)*", redirect: "/" },
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
+140
View File
@@ -0,0 +1,140 @@
/**
* 公共类型
*/
export declare interface IObj {
[key: string]: any;
}
export declare interface PageParams {
page: !number;
count: !number;
[keys: string]: any; //任意属性
}
export declare interface PageData {
list: T;
total: number;
[keys: string]: any; //任意属性
}
interface RelayConf {
ziyuankuHost: string; //资源库
zhuanxiangHost: string; //专项
}
/* 系统配置 */
export interface BaseConf {
baseHost: string;
ssoHost: string;
serviceHost: string;
relayConf: RelayConf;
isDebug?: Boolean;
[keys: string]: any; //任意属性
}
/* PC端角色 */
export interface PcDebugUser {
DebugUser: string;
AppType: string;
// yschapirequest: number;
}
/* webapp端角色 */
export declare interface WappDebugUser {
ticket: string;
schoolId: Number;
userId: Number;
roleId: Number;
}
export declare interface CurrentRole {
childId: number | null;
roleId: number;
schId: number;
userId: number;
}
export interface layoutData {
appName: String;
appList: string[];
modList: Array<string>;
userInfo: Object;
ssoHost: String;
baseHost: String;
isAlone: Boolean; //是否只有一个选项卡
}
export interface BaseDict {
k: any;
v: string | null;
[keys: string]: any; //任意属性
}
/* 计算表格高度的参数类型 */
export interface UseTableSetting {
/** 是否有查询面板 */
isSearch: boolean = true;
/** 是否有分页 */
isPage: boolean = true;
/** 是否是tabs布局 */
isTabs: boolean = false;
/** 是否有按钮操作 */
isOperat: boolean = true;
/** 额外的高度 */
otherH: number = 0;
}
/* 布局组件类型 */
export interface LayoutData {
appName: string | null;
appList: Array<any>;
modList: Array<any>;
userInfo: any;
ssoHost: string | null;
baseHost: string | null;
isAlone: boolean;
}
/* 自定义弹出对话框 */
export interface MyConfirm {
title?: string;
msg: string;
info?: string;
isLoading?: boolean;
confirmText?: string;
closeText?: string;
onOk?: Function;
onCancel?: Function;
}
export declare enum NODE_ENV {
/**
*开发环境
*/
development = "development",
/**
*生产环境
*/
production = "production",
}
export const _: any;
export const $: any;
declare global {
const $: any;
const _: any;
}
import Utils from "@/utils/index";
import mittBus from '@/utils/mitt'
import MyConfirm from "@/components/MyConfirm";
export interface VueGlobal {
$conf: BaseConf;
$bus: typeof mittBus;
$utils: typeof Utils;
$echarts: echarts;
[key:string]:any;
}
+209
View File
@@ -0,0 +1,209 @@
/** 智能助手相关类型契约(与后端 com.yunhai.agent.model 对齐) */
/** 平台用户信息(BoaUser 的子集) */
export interface UserInfoVo {
id?: number;
name?: string;
account?: string;
profilePicture?: string;
deptName?: string;
[key: string]: any;
}
/** 平台已授权模块节点 */
export interface ModuleNode {
id: number;
name: string;
modId: number;
page?: string;
children?: ModuleNode[];
[key: string]: any;
}
/** 补全表单里的一个字段(由后端工具定义推导) */
export interface FormField {
/** 提交时的参数名 */
name: string;
/** 中文标签,如「开始时间」 */
label: string;
/** 控件类型 */
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select";
type?: string;
required: boolean;
/** 字段说明(含义与格式) */
description?: string;
/** 格式示例,用作 placeholder */
example?: string;
defaultValue?: string;
/** 下拉选项 */
options?: { value: string; label: string }[];
/**
* 下拉选项的动态来源(后端字典工具名,如 leave.types)。
* 有值但 options 为空 ⇒ 字典接口没取到,前端提示「可先手填编号」。
*/
optionsFrom?: string;
/**
* 当前 options 是否为内置兜底(而非学校实时字典)。
* 为 true 时说明「选项能用,但可能不含学校自定义的项」,前端给一句轻提示即可,不阻塞填写。
*/
optionsFallback?: boolean;
}
/**
* 信息不全时后端下发的「补全表单」。
* 用户填完直接提交,服务端会直接执行对应工具(不再让模型二次猜测)。
*/
export interface FormRequest {
tool: string;
title: string;
description?: string;
module?: string;
write: boolean;
landingPath?: string;
fields: FormField[];
/** 已从对话中确定的参数(只读回显) */
preset?: Record<string, any>;
/** 已识别参数的可读回显(text 已按字典翻译成中文,如「病假」而非 1) */
recognized?: { name: string; label: string; text: string }[];
/** 缺失字段的中文名 */
missing?: string[];
hint?: string;
}
/** 表单提交体 */
export interface FormSubmit {
tool: string;
args: Record<string, any>;
}
/**
* 「浏览器代执行」挂起载荷(方案 A):模块接口只认浏览器 CAS 会话,
* 服务端把接口描述下发前端,由前端用用户自己的登录态调用后回传结果续跑。
*/
export interface BrowserToolRequest {
calls: BrowserToolCall[];
/** 给用户看的一句话说明 */
notice?: string;
}
/** 一次代执行调用描述(pathAndQuery 相对当前站点根,同站部署 ⇒ 同源) */
export interface BrowserToolCall {
name: string;
title: string;
arguments?: Record<string, any>;
write: boolean;
method: "GET" | "POST_JSON" | "POST_FORM";
pathAndQuery: string;
body?: string;
contentType?: string;
}
/** 代执行结果回传项(与 BrowserToolCall 按 name 对应) */
export interface ToolResultItem {
name: string;
arguments?: Record<string, any>;
/** HTTP 状态码;网络层失败时为空 */
status?: number;
/** 响应体原文 */
body?: string;
/** 网络层错误(失败/超时/跨域) */
error?: string;
}
/** 对话请求 */
export interface AgentChatRequest {
sessionId?: string;
message?: string;
enableTools?: boolean;
/** 非空时:直接执行指定工具(表单提交) */
formSubmit?: FormSubmit;
/** 非空时:浏览器代执行结果回传(续跑) */
toolResult?: ToolResultItem[];
}
/** 一次工具调用轨迹 */
export interface ToolCallTrace {
name: string;
title: string;
/** 业务模块标识(前端据此拼模块页面地址做「去系统里查看」跳转) */
module?: string;
arguments?: Record<string, any>;
success: boolean;
error?: string;
write?: boolean;
/**
* 模块内的落地路由(如 `/my`)。
* 写操作 = 执行成功后去确认;**查询失败**(下游 code=302 需要浏览器登录态)= 深链兜底入口。
*/
landingPath?: string;
costMs?: number;
}
/** 对话响应 */
export interface AgentChatResponse {
sessionId: string;
reply: string;
toolCalls: ToolCallTrace[];
wroteSomething: boolean;
landingPath?: string;
rounds?: number;
promptTokens?: number;
completionTokens?: number;
warning?: string;
/** 需要用户补全信息时返回的表单 */
form?: FormRequest;
/** 非空:本轮工具需要浏览器代执行,前端调完接口把 toolResult 回传续跑 */
browserTool?: BrowserToolRequest;
}
/** 能力目录里的单项能力 */
export interface ToolCatalogItem {
name: string;
title: string;
description: string;
write: boolean;
landingPath?: string;
/** 可执行该能力的身份(中文标签,如「学生、教师」);不限身份时为空 */
roles?: string;
}
/** 能力目录:后端已按「当前角色 + 已授权模块」过滤后返回 */
export interface AgentToolCatalog {
/** 模块标识 → 能力列表;不同身份拿到的模块集合与能力数不同 */
modules: Record<string, ToolCatalogItem[]>;
/** 当前身份可见的能力总数 */
total: number;
/** 当前登录身份的中文描述(由后端按 BoaUser 解析,如「学生」「教师」「访客」) */
roles?: string;
/** 当前身份明细:一个账号可能有多重身份,后端只按「当前身份」生效 */
identity?: {
userId?: string;
userName?: string;
schoolId?: string;
/** 当前身份的角色号(平台 currentRole.roleId) */
roleId?: string;
/** 当前身份名称(平台 currentRole.roleName) */
roleName?: string;
roles?: string;
};
}
/** 聊天界面里的一条消息 */
export interface ChatItem {
id: string;
role: "user" | "assistant";
/** 正文(assistant 回复可能先为空,由 toolCalls 展示过程) */
text: string;
/** 阶段提示("正在调用:提交请假申请") */
stage?: string;
traces?: ToolCallTrace[];
wroteSomething?: boolean;
landingPath?: string;
error?: string;
/** 需要用户补全信息时的表单 */
form?: FormRequest;
/** 表单是否已提交(提交后置为只读回显) */
formSubmitted?: boolean;
/** 消息创建时间(毫秒) */
at: number;
}
+160
View File
@@ -0,0 +1,160 @@
import { createAlova, Method, StatesHook } from "alova";
import fetchAdapter from "alova/fetch";
import VueHook from "alova/vue";
import Utils from "..";
import { isLoggingOut } from "@/utils/logout-guard";
/* ============= 类型定义 ============= */
export interface RequestHooks {
/** 前置票据/通用头注入 */
injectHeaders?: (method: Method) => void;
/** 加载开始 */
onLoadingStart?: () => void;
/** 加载结束 */
onLoadingEnd?: () => void;
/** 业务错误提示(code !== 0)*/
onBizError?: (msg: string) => void;
/** http 错误提示(status !== 200)*/
onHttpError?: (msg: string) => void;
}
/** 和后端约定的通用响应格式 */
export interface BschResponse<T = any> {
code: number;
msg?: string;
data: T;
urlToRedirectTo?: string;
}
/* ============= 默认空实现 ============= */
const defHooks: Required<RequestHooks> = {
injectHeaders: () => {},
onLoadingStart: () => {},
onLoadingEnd: () => {},
onBizError: () => {},
onHttpError: () => {},
};
/* ============= 错误处理(原 axios 逻辑平移) ============= */
function errorHandle<T>(res: BschResponse<T>, status: number): T {
// http 层错误
if (status !== 200) {
let msg = "网络错误";
switch (status) {
case 401:
case 403:
break;
case 404:
msg = "请求的资源不存在!";
break;
default:
msg = res?.msg || "内部服务错误!";
}
throw new Error(msg);
}
// 业务层错误
switch (res.code) {
case 0:
return res.data ?? (null as T);
case 302:
console.log(res);
// 登出流程中:当前会话正是我们要清掉的那个,302 是预期结果而非「掉登录」,
// 此时自动跳登录页会被 CAS 用还没销毁的 TGT 静默登回原账号
// (用户看到「点了退出又回来了」),所以交给登出流程自己导航。
if (isLoggingOut()) {
return new Promise(() => {}) as T;
}
// 后端 CasSsoApiRedirectFilter 已在 service 中拼好 ;redirectToReferer=<hex>,
// 直接跳转即可;登录后由 CasSsoRefererRedirectFilter 解码 302 回前端页。
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,
// 全局 onBizError 与各页面的 catch 均不再触发(不弹 redirect 报错)
window.location.href = res.urlToRedirectTo!;
return new Promise(() => {}) as T;
case 4:
throw new Error(res.msg || "参数错误!");
default:
throw new Error(res.msg || "业务错误");
}
}
/* ============= 工厂函数 ============= */
export function createBschAlova(
baseURL: string,
hooks: RequestHooks = {},
statesHook: StatesHook<any> = VueHook,
) {
const { injectHeaders, onLoadingStart, onLoadingEnd, onBizError, onHttpError } = {
...defHooks,
...hooks,
};
let reqCount = 0;
let timer: ReturnType<typeof setTimeout>;
/* ---- 请求拦截 ---- */
function beforeRequest(method: Method) {
reqCount++;
if (reqCount === 1) timer = setTimeout(() => onLoadingStart(), 800);
// 1. 用户注入票据/角色等头
injectHeaders(method);
method.config.credentials = "include";
// 2. 通用头
method.config.headers = Object.assign(method.config.headers || {}, {
yschapirequest: "1",
timeout: 20000,
yschreferer: Utils.strToHexCharCode(window.location.href),
});
}
/* ---- 响应拦截 ---- */
async function onSuccess(response: Response) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
const res: BschResponse = await response.json();
try {
return errorHandle(res, response.status);
} catch (e: any) {
console.log(e);
onBizError(e.message);
return Promise.reject(e);
}
}
async function onError(err: any) {
// 只减不清零:清零会让其余在途请求把计数减成负数,全局 loading 永不结束
reqCount = Math.max(0, reqCount - 1);
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
const msg = err?.message || "网络请求出错";
onHttpError(msg);
return Promise.reject(err);
}
async function onComplete(method: Method) {
if (method.fromCache) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
}
}
return createAlova({
statesHook,
requestAdapter: fetchAdapter(),
baseURL,
cacheFor: null,
beforeRequest,
responded: { onSuccess, onError, onComplete },
});
}
+40
View File
@@ -0,0 +1,40 @@
// @/utils/alova-iview.ts
import { createBschAlova } from "./alova-core";
import { Message } from "view-ui-plus";
import conf from "@/assets/config/host.config";
/* 全局 loading 遮罩实例 */
let spinInstance: any = null;
/* 主动关闭 loading */
function closeLoading() {
if (spinInstance) {
Message.destroy();
spinInstance = null;
}
}
const baseUrl = conf.serviceHost;
/* 创建带 View Design UI 的 Alova 实例 */
export const alovaInstance = createBschAlova(baseUrl, {
injectHeaders(method) {
// currentRole: encodeURI(JSON.stringify({ userId, roleId, schId: schoolId })),
if (localStorage.getItem("currentRole") != null) {
method.config.headers["currentRole"] = localStorage.getItem("currentRole") as string;
}
},
/* 2. 加载动画 – View Design Spin */
onLoadingStart() {
spinInstance = Message.loading("加载中...");
},
onLoadingEnd: closeLoading,
onBizError(msg: string) {
if (!!msg) Message.error(msg);
},
onHttpError(msg: string) {
Message.error({ title: "网络错误", content: msg });
},
});
+66
View File
@@ -0,0 +1,66 @@
/**
* 通用工具方法(按本工程实际需要裁剪,保持与平台其它前端工程同形的静态方法签名)。
*/
export default class Utils {
/** 格式化日期 */
public static dateFormat(date: Date | string, fmt: string): string {
if (typeof date === "string") date = date.replace(/\-/g, "/");
const dt = new Date(date);
const o: Record<string, number> = {
"M+": dt.getMonth() + 1,
"d+": dt.getDate(),
"H+": dt.getHours(),
"m+": dt.getMinutes(),
"s+": dt.getSeconds(),
"q+": Math.floor((dt.getMonth() + 3) / 3),
S: dt.getMilliseconds(),
};
if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (dt.getFullYear() + "").substr(4 - RegExp.$1.length));
for (const k in o) {
if (new RegExp("(" + k + ")").test(fmt)) {
fmt = fmt.replace(RegExp.$1, RegExp.$1.length === 1 ? String(o[k]) : ("00" + o[k]).substr(("" + o[k]).length));
}
}
return fmt;
}
/** 生成 uuid */
public static uuid(): string {
return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* 字符串 → 16 进制表达。
* 平台后端 SSO 过滤器会读 yschreferer 头(当前页面地址的 hex),用于登录后回跳原页面。
*/
public static strToHexCharCode(_str: string): string {
if (_str === "") return "";
const hexCharCode: string[] = [];
for (let i = 0; i < _str.length; i++) {
const str = _str.charCodeAt(i).toString(16);
hexCharCode.push(str);
}
return hexCharCode.join("");
}
/** 读取当前角色(localStorage.currentRole 由平台外壳写入) */
public static getCurrentRoleInfo<T>(): T | null {
const currentRole = localStorage.getItem("currentRole");
if (!currentRole) return null;
try {
const { schId, roleId, childId, userId } = JSON.parse(decodeURI(currentRole ?? ""));
return { schId, roleId, childId, userId } as unknown as T;
} catch {
return null;
}
}
/** 简易 deep clone(够用即可) */
public static clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 登出闸门:抑制登出过程中的「未登录自动跳登录页」。
*
* <p>为什么需要它 —— 登出必须按「先清服务端会话,再跳 CAS 全局登出」的顺序来:
* <ol>
* <li>清本地会话(POST /api/base/Logout)会让当前 JSESSIONID 失效;</li>
* <li>紧接着整页跳到 CAS 的 {@code /logout} 销毁 SSO 票据。</li>
* </ol>
* 这两步之间只要还有一个在途请求拿回 {@code code=302},
* `utils/alova/alova-core.ts` 的全局错误处理就会把浏览器拽去 CAS 登录页;
* 而此刻 CAS 的 TGT 还没销毁,会被**静默重新登录回原账号** ——
* 用户看到的就是「点了退出,页面一闪又回来了」。
*
* <p>置位后 302 不再自动跳转(登出流程自己负责导航)。登出只有两个终局:
* 整页跳到 CAS 的 {@code /logout},或(未配置 SSO 地址时)重载页面;
* 两者都会换掉文档,这个标记随页面一起消失,不需要在应用内复位。
*/
let loggingOut = false;
/** 进入登出流程(要在发起「清会话」请求之前调用) */
export function beginLogoutGuard(): void {
loggingOut = true;
}
/** 当前是否处于登出流程中 */
export function isLoggingOut(): boolean {
return loggingOut;
}
+15
View File
@@ -0,0 +1,15 @@
// import { BschIframeType, LightAppModal, IframePageParmas, RoleInfo, StateEnum } from "@/types";
import mitt, { Emitter } from "mitt";
type MittType<T = any> = {
reload: void; //刷新页面
setMenuPath: number; //选择左侧菜单
resetMenuPath: any[]; //重置设置左侧菜单
setNavPath: any[]; //设置面包屑
resetLayout: { identity: number; modId: number }; //重置页面菜单
};
// 类型
const emitter: Emitter<MittType> = mitt<MittType>();
// 导出
export default emitter;
+513
View File
@@ -0,0 +1,513 @@
<script setup lang="ts">
/**
* 智能助手对话页。
*
* 交互要点:
* - 用户发一句话 → 后端编排(大模型决策 + 调用业务接口)→ 返回回复 + 工具执行轨迹
* - 信息不全时后端下发「补全表单」,这里渲染成可填卡片,用户填完直接提交执行(不再反复追问)
* - 等待期间显示阶段提示(理解需求 → 调用接口)
* - 助手消息下挂「工具轨迹卡」,写操作后提供「去查看」直达对应模块页面
* - 输入框轮播示例,并按关键词提示「怎么说更准确」
* - 能力目录由外壳统一拉取后传入,侧栏与首屏共用一份数据
*/
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { agentApi } from "@/api/module/agent";
import QuickActions from "@/components/QuickActions.vue";
import MessageBubble from "@/components/MessageBubble.vue";
import type { AgentToolCatalog, BrowserToolCall, ChatItem, ToolResultItem } from "@/types/agent";
const props = defineProps<{ user?: any; catalog?: AgentToolCatalog | null }>();
const items = ref<ChatItem[]>([]);
const input = ref("");
const sending = ref(false);
const stage = ref("");
const sessionId = ref<string | undefined>(undefined);
const listEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
/** 等待期间的阶段轮播(后端返回前给用户进度感) */
let stageTimer: ReturnType<typeof setInterval> | null = null;
const STAGES = ["正在理解你的需求…", "正在判断需要调用哪些功能…", "正在调用系统接口…", "正在整理结果…"];
/**
* 输入示例轮播:直接给出"可以怎么说"的成品句,比抽象描述更好用。
*
* 每条标注它依赖的「模块 + 工具」,用于按当前身份裁剪:
* 学生身份下不会轮播「发通知」「专业发展积分」这类教师专属句子 ——
* 否则等于给用户一个"照着说必被拒"的示范。
* 判据取自能力目录本身(模块是否下发、工具名是否存在),前端无需知道角色规则。
*/
const EXAMPLES: { text: string; module: string; tool?: string }[] = [
{ text: "我要请 10月12日 上午的病假,发烧就医", module: "homeleave", tool: "leave.submit" },
{ text: "我的请假记录有几次", module: "homeleave", tool: "leave.myLeaves" },
{ text: "发一条关于社团招新的通知", module: "campusnotice", tool: "notice.publish" },
{ text: "这周有什么新的校园公告?", module: "campusnotice", tool: "notice.received" },
{ text: "提交数学作业", module: "coursehomework", tool: "homework.submit" },
{ text: "看看我还有哪些作业没做", module: "coursehomework", tool: "homework.myList" },
{ text: "宿舍有什么通知需要我回执吗?", module: "dormnotice", tool: "dorm.myNotice" },
{ text: "报名秋季亲子运动会,2 人参加", module: "familyactivity", tool: "activity.signup" },
{ text: "帮我找一份语文课件", module: "resourcelibrary", tool: "resource.search" },
{ text: "我的专业发展积分是多少?", module: "profession", tool: "profession.myPoints" },
];
/** 当前身份下适合轮播的示例句;能力目录未就绪时先给全量,避免输入框提示空着 */
const examples = computed(() => {
const mods = props.catalog?.modules;
if (!mods) return EXAMPLES.map(e => e.text);
return EXAMPLES.filter(e => {
const list = mods[e.module] || [];
return list.length > 0 && (!e.tool || list.some(t => t.name === e.tool));
}).map(e => e.text);
});
const exampleIndex = ref(0);
let exampleTimer: ReturnType<typeof setInterval> | null = null;
const placeholder = computed(() => {
const list = examples.value;
if (!list.length) return "说说你想办什么…";
return `试试说:${list[exampleIndex.value % list.length]}`;
});
/** 边说边提示:命中关键词时告诉用户"这样说更准" */
const TIPS: { key: RegExp; text: string }[] = [
{ key: /请假|病假|事假/, text: "说清「类型 + 起止时间 + 事由」一次就能提交,例:10月12日上午病假,发烧就医" },
{ key: /公告|通知(发布|发布公告)/, text: "发布公告需要「标题 + 正文 + 发文部门」,例:发一条关于运动会的通知,正文…" },
{ key: /作业|作答|提交作/, text: "可以说「我有哪些作业没做」,或「提交数学作业」,我会列出作业让你选" },
{ key: /宿舍|回执/, text: "例:宿舍有什么通知需要我回执吗?我会列出通知并给出回执表单" },
{ key: /报名|活动/, text: "例:报名秋季亲子运动会,2 人参加,联系人张伟 13800000000" },
{ key: /审批|通过|驳回/, text: "例:把 101 号请假审批通过,意见「同意」" },
{ key: /资源|课件|教案|资料|试题/, text: "例:帮我找一份语文课件;想留存就说「收藏这份课件」" },
{ key: /积分|专业发展|记录|成果/, text: "查积分说「我的专业发展积分是多少」;要留痕说「发布一条听课记录」" },
];
const activeTip = computed(() =>
input.value.trim() ? TIPS.find(t => t.key.test(input.value.trim()))?.text || "" : ""
);
const isEmpty = computed(() => items.value.length === 0);
onMounted(() => {
exampleTimer = setInterval(() => {
/* 用当前身份可见的例句数取模:目录到达后例句会变少,索引必须跟着收敛 */
exampleIndex.value = (exampleIndex.value + 1) % Math.max(1, examples.value.length);
}, 4200);
});
onUnmounted(() => {
stopStage();
if (exampleTimer) clearInterval(exampleTimer);
});
function scrollToBottom() {
nextTick(() => {
const el = listEl.value;
if (el) el.scrollTop = el.scrollHeight;
});
}
/** 输入框随内容长高(最多 148px,再长就内部滚动) */
function autoGrow() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
}
watch(input, () => nextTick(autoGrow));
function startStage() {
let i = 0;
stage.value = STAGES[0];
stageTimer = setInterval(() => {
i = Math.min(i + 1, STAGES.length - 1);
stage.value = STAGES[i];
const last = items.value[items.value.length - 1];
if (last && last.role === "assistant") last.stage = stage.value;
}, 1600);
}
function stopStage() {
if (stageTimer) {
clearInterval(stageTimer);
stageTimer = null;
}
}
/** 新建一条等待中的助手消息 */
function pushPending(): ChatItem {
const placeholder: ChatItem = {
id: `a-${Date.now()}`,
role: "assistant",
text: "",
stage: STAGES[0],
traces: [],
at: Date.now(),
};
items.value.push(placeholder);
sending.value = true;
startStage();
scrollToBottom();
return placeholder;
}
/** 把后端响应灌进占位消息 */
function fill(placeholder: ChatItem, resp: any) {
sessionId.value = resp?.sessionId || sessionId.value;
placeholder.text = resp?.reply || "";
placeholder.traces = resp?.toolCalls || [];
placeholder.wroteSomething = !!resp?.wroteSomething;
placeholder.landingPath = resp?.landingPath;
if (resp?.form) {
placeholder.form = resp.form;
}
if (resp?.warning && !resp?.reply) {
placeholder.error = resp.warning;
}
}
/**
* 浏览器代执行(方案 A):模块接口只认浏览器 CAS 会话,服务端代调必然 302。
* 后端挂起后把接口描述下发到这里,用「用户自己的登录态」调完模块接口再把结果回传续跑。
*
* 前提:本应用与模块同站部署(同源 Cookie 自动携带)。本地 dev(localhost:8081)
* 跨域取不到 base 域会话,fetch 会失败或 404 → 结果回传后由服务端判为失败,
* 自动落回「去系统里查看」的深链兜底,UI 不会卡死。
*/
async function execBrowserCall(call: BrowserToolCall): Promise<ToolResultItem> {
const base = { name: call.name, arguments: call.arguments };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
const headers: Record<string, string> = { yschapirequest: "1" };
let body: string | undefined;
if (call.method !== "GET") {
headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8";
body = call.body;
}
const res = await fetch(`${location.origin}${call.pathAndQuery}`, {
method: call.method === "GET" ? "GET" : "POST",
headers,
body,
credentials: "same-origin",
signal: ctrl.signal,
});
return { ...base, status: res.status, body: await res.text() };
} catch (e: any) {
const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败";
return { ...base, error: reason };
} finally {
clearTimeout(timer);
}
}
/** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */
async function runBrowserTool(placeholder: ChatItem, pending: any) {
// ★ 先把挂起响应里的 sessionId 落地:续跑请求要用它找回同一会话,
// 否则服务端会当成没有历史的新会话(模型拿不到上文的 tool_calls)。
sessionId.value = pending?.sessionId || sessionId.value;
const calls: BrowserToolCall[] = pending?.browserTool?.calls || [];
placeholder.text = pending?.reply || pending?.browserTool?.notice || "正在用你的登录态查询业务模块…";
const results: ToolResultItem[] = [];
for (const call of calls) {
placeholder.stage = `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
results.push(await execBrowserCall(call));
}
placeholder.stage = "正在整理结果…";
const resp: any = await agentApi.submitToolResults(sessionId.value, results).send();
fill(placeholder, resp);
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
}
}
async function send(text?: string) {
const content = (text ?? input.value).trim();
if (!content || sending.value) return;
input.value = "";
items.value.push({ id: `u-${Date.now()}`, role: "user", text: content, at: Date.now() });
const placeholder = pushPending();
try {
const resp: any = await agentApi.chat({ sessionId: sessionId.value, message: content }).send();
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
} else {
fill(placeholder, resp);
}
} catch (e: any) {
if (e?.message === "redirect") return;
placeholder.error = e?.message || "请求失败,请稍后重试";
} finally {
stopStage();
placeholder.stage = "";
sending.value = false;
scrollToBottom();
}
}
/** 表单提交:直接执行对应工具,不再让模型二次判断 */
async function onFormSubmit(payload: { item: ChatItem; form: { tool: string; args: Record<string, any> } }) {
if (sending.value) return;
const { item, form } = payload;
item.formSubmitted = true; // 卡片立即转为只读回显
const placeholder = pushPending();
try {
const resp: any = await agentApi.submitForm(sessionId.value, form).send();
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
} else {
fill(placeholder, resp);
}
} catch (e: any) {
if (e?.message === "redirect") return;
placeholder.error = e?.message || "提交失败,请稍后重试";
item.formSubmitted = false; // 失败则允许用户改完再提交
} finally {
stopStage();
placeholder.stage = "";
sending.value = false;
scrollToBottom();
}
}
function onKeydown(e: KeyboardEvent) {
// Enter 发送;Shift+Enter 换行
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}
async function newSession() {
try {
await agentApi.clearSession(sessionId.value).send();
} catch {
/* 忽略:清理失败不影响本地重置 */
}
sessionId.value = undefined;
items.value = [];
input.value = "";
Message.success("已开启新对话");
}
/** 供侧栏调用:把模块示例句直接发出去 */
function pickSample(text: string) {
send(text);
}
defineExpose({ newSession, pickSample });
</script>
<template>
<div class="chat">
<div ref="listEl" class="chat__list agent-scroll">
<QuickActions v-if="isEmpty" :catalog="catalog ?? null" @pick="t => send(t)" />
<div v-else class="chat__stream">
<MessageBubble
v-for="(it, i) in items"
:key="it.id"
:item="it"
:pending="sending && i === items.length - 1 && !it.text"
@form-submit="onFormSubmit"
/>
</div>
</div>
<div class="chat__composer">
<div class="chat__box" :class="{ 'chat__box--busy': sending }">
<textarea
ref="inputEl"
v-model="input"
class="chat__input"
rows="1"
:disabled="sending"
:placeholder="placeholder"
@keydown="onKeydown"
/>
<div class="chat__actions">
<button class="chat__ghost" type="button" :disabled="sending" title="清空当前会话" @click="newSession">
新会话
</button>
<button
class="chat__send"
type="button"
:disabled="!input.trim() || sending"
@click="send()"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12l16-8-6 8 6 8-16-8z" />
</svg>
{{ sending ? "处理中" : "发送" }}
</button>
</div>
</div>
<p class="chat__hint" :class="{ 'chat__hint--tip': !!activeTip }">
<svg v-if="activeTip" class="chat__hint-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 3a6 6 0 00-3.5 10.9V17h7v-3.1A6 6 0 0012 3z" />
<path d="M9.5 20.5h5" />
</svg>
<template v-if="activeTip">{{ activeTip }}</template>
<template v-else>Enter 发送 · Shift + Enter 换行 · 写操作请确认信息无误后再提交</template>
</p>
</div>
</div>
</template>
<style scoped>
.chat {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat__list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-bottom: 8px;
}
.chat__stream {
max-width: var(--agent-readw);
margin: 0 auto;
padding: 26px 28px 8px;
}
/* ------------------------------ 输入区 ------------------------------ */
.chat__composer {
flex: none;
padding: 12px 28px 16px;
background: linear-gradient(to top, var(--agent-bg) 58%, rgba(244, 246, 251, 0));
}
.chat__box {
max-width: var(--agent-readw);
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 12px;
padding: 10px 12px 10px 16px;
border: 1px solid var(--agent-border-strong);
border-radius: 18px;
background: var(--agent-surface);
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.04), 0 18px 34px -20px rgba(15, 23, 42, 0.32);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.chat__box:focus-within {
border-color: var(--agent-primary);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1), 0 18px 34px -20px rgba(37, 99, 235, 0.4);
}
.chat__box--busy {
opacity: 0.85;
}
.chat__input {
flex: 1 1 auto;
min-width: 0;
border: none;
outline: none;
resize: none;
background: transparent;
font-family: inherit;
font-size: 14px;
line-height: 1.65;
color: var(--agent-text);
min-height: 26px;
max-height: 148px;
padding: 6px 0;
}
.chat__input::placeholder {
color: var(--agent-text-mute);
}
.chat__actions {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 1px;
}
.chat__ghost {
height: 36px;
padding: 0 12px;
border: none;
border-radius: 11px;
background: transparent;
color: var(--agent-text-sub);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
}
.chat__ghost:hover:not(:disabled) {
background: var(--agent-surface-2);
color: var(--agent-text);
}
.chat__ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat__send {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 18px;
border: none;
border-radius: 11px;
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.3px;
cursor: pointer;
box-shadow: 0 10px 20px -10px rgba(37, 99, 235, 0.9);
transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
}
.chat__send:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 24px -10px rgba(37, 99, 235, 0.95);
}
.chat__send:disabled {
background: #cbd5e1;
box-shadow: none;
cursor: not-allowed;
}
.chat__hint {
max-width: var(--agent-readw);
margin: 9px auto 0;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 12px;
line-height: 1.6;
color: var(--agent-text-mute);
transition: color 0.18s ease;
}
.chat__hint--tip {
color: var(--agent-primary-dark);
font-weight: 500;
}
.chat__hint-icon {
flex: none;
}
</style>