This commit is contained in:
+103
@@ -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>
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+92
@@ -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')
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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");
|
||||
@@ -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;
|
||||
Vendored
+140
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user