Files
2026-10-12 14:45:00 +08:00

18 KiB
Raw Permalink Blame History

AgentFront · 校园智能助手(前端)

一句话说需求,助手直接替你操作 —— 请假、报名、发公告、查作业、找资源… 信息说不全也没关系:助手会给你一张填写表单,填完直接提交执行。

与平台其它前端同族:Vue3 + Vite + alova + view-ui-plus,同源 /api 代理、运行时配置下发、SSO 登录态。

首屏:Hero 横幅 + 能力指标 + 8 张快捷卡

补全表单 · 请假(深色导航侧栏 + 浅色对话画布)

下拉浮层:选项由业务字典实时下发

提交后的回执与执行轨迹(写操作给「去系统里查看」)

补全表单 · 发布公告(标题已从对话识别并回显)

查询类直接给结果(我的作业列表)

提交闭环:报名活动成功回执

侧栏点模块 = 直接发一句完整请求

全部 54 项能力清单(写操作高亮)

1. 已接入模块

对话能力不是只做了请假——7 个业务模块共 54 项能力全部注册,其中 15 项写操作可直接提交:

模块 标识 能力数 写操作(可直接提交)
学生请假 homeleave 11 提交申请 / 撤销 / 销假 / 审批 / 批量通过
校园公告 campusnotice 9 发布 / 通过审批 / 驳回审批
宿舍通知 dormnotice 9 提交回执 / 取消回执
家校活动 familyactivity 7 报名 / 取消报名
课程作业 coursehomework 4 提交作业
资源库 resourcelibrary 5 收藏资源
教师专业发展 profession 9 发布记录
  • 写操作:缺必填参数 → 下发补全表单;信息齐全 → 直接执行。
  • 查询:直接返回结果(作业列表 / 请假记录 / 公告 / 宿舍通知 / 活动广场 / 资源检索 / 积分)。

以上数量是全量注册能力,由后端 ToolRegistry 实时注册。前端首屏的能力指标卡、侧栏模块导航与「全部能力」弹层都来自 GET /api/agent/tools,不是写死的数字。

注意:这 54 项不是每个登录用户都能看到。 /api/agent/tools 会按「当前登录身份 + 已授权模块」过滤后再下发:学生看不到「教师专业发展」的积分 / 发布类能力,教师也看不到学生专属的作业提交、活动报名。详见下一节。

2. 登录与权限

2.1 登录(SSO)—— 前端不做登录页

智能助手是平台的一个模块,登录态由平台 CAS 统一管理。前端不写登录页,也不自己存 token:

  1. 浏览器打开前端,发起 /api/base/GetUserInfo;
  2. 未登录时后端 CAS 过滤器返回 { code: 302, urlToRedirectTo: "…" };
  3. src/utils/alova/alova-core.ts 的 errorHandle 收到 302 后直接整页跳转到平台登录页,并返回一个永不 resolve 的 Promise(避免后续业务流程继续往下跑);
  4. 登录成功后 CAS 带票据跳回原页面,同一请求正常返回当前用户(BoaUser),页面直接可用。

所以这条链路只有两件事要维护:后端负责下发跳转地址,前端负责见到 302 就跳。

本地开发若后端以 --yh.sso.url-pattern=/__local_noauth__/* 放行,/api/base/GetUserInfo 会返回 data: null, 页面显示「未登录」,能力目录按访客身份下发(21 项)—— 这是正常的本地联调形态,不是故障。

2.2 退出登录(换账号)

侧栏底部用户区右侧有一个退出按钮。点击后前端按顺序做两件事,缺一不可:

① POST /api/base/Logout          ← 清本应用的服务端会话(后端 session.invalidate())
② {ssoHost}/logout?service=<当前页地址>   ← 再跳 CAS 全局登出,销毁 SSO 的 TGT

ssoHost 取自 src/assets/config/host.config.ts(本地 dev 读 .env.dev 的 VITE_SSO_HOST, 线上读运行时 /api/env-config);未配置时不乱跳,只提示。

为什么两步都要(2026-10-10 修):本应用的「登录态」就是服务端 session 里的 _const_cas_assertion_(平台 CasSsoApiRedirectFilter 正是拿它判断登录与否),

  • 只做 ②:SSO 的票据没了,本应用的 JSESSIONID 还活着 → 回到页面依旧是登录态, 用户看到的现象就是「点了退出没用」;
  • 只做 ①:本地会话清了,但 SSO 侧会话还在 → 一刷新就被重新认证成同一个账号, 现象是「点了退出又自动登回来 / 换不了人」。

顺序不能颠倒:跳走之后页面就卸载了,再没有机会回头调 ①。也不能指望 CAS 的单点登出 (SLO)回推来清会话 —— 平台只注册了 SingleSignOutFilter、没有配套的会话监听,且要求 本服务地址在 CAS 侧登记过,本地联调(http://localhost:8093)根本不成立。 平台自带模块(HomeSchLeave 的 LoginUserController#logout)同样是自己在服务端销毁会话。

登出后 CAS 会按 service 参数把浏览器送回本页,随后再走一次 「未登录 → 302 → 登录页」的流程,即可用另一个账号登录。

这是唯一需要前端主动跳 SSO 的地方;其余场景前端只被动响应后端的 302。 清会话期间由 src/utils/logout-guard.ts 压住「未登录自动跳登录页」—— 此刻 TGT 还在,被 302 拽去 CAS 会被静默登回原账号。

真机回归:node .scratch/verify-logout.mjs(断言"先清会话、再跳 CAS"的顺序与目标)。

2.3 身份与权限 —— 三道处理,越往后越严格

层 位置 作用
① 角色解析 后端 RoleResolver 读平台 BoaUser(type / roleIds)→ 归一为学生 / 教师 / 管理员 / 访客
② 模型侧过滤 后端 ToolRegistry 只把本身份可见的工具写进模型 schema —— 越权工具模型"根本看不见"
③ 服务端强校验 后端 ToolExecutor 每次调用前再校验一次,越权直接拒绝并打 [越权拦截] 日志留痕

前端在这里的定位是忠实呈现、绝不自行判权:

  • 侧栏模块列表、首屏快捷卡、能力数全部由 /api/agent/tools 的返回驱动(orderedModuleKeys()), 不在前端写死模块名单 —— 否则会出现「后端已过滤、前端又把它显示出来」的越权入口;
  • 侧栏底部展示 /api/agent/tools 返回的 roles(如「学生」),让用户理解自己能力范围的由来;
  • 前端不复刻角色判定逻辑:角色号与判定规则属后端,前端抄一份迟早会不一致。

具体哪个 roleId 算学生 / 教师,由 AgentService 的 application.yml → agent.roles.* 配置, 按本校 UserBase 角色体系校准;调整它不需要动前端。

3. 本地开发

npm install
npm run dev        # http://localhost:8080

/api 由 vite 代理到本地后端 AgentService(8093)(见 vite.config.ts)。 所以本地要同时起两个服务:

# 1) 后端(BackendProject_java/AgentService)
mvn spring-boot:run          # 或 java -jar target/agent.jar

# 2) 前端
npm run dev

VITE_SERVICE_HOST 必须留空(.env.dev 已留空):dev 模式走同源代理, 否则浏览器直连远端会被后端 CAS 过滤器拦截(不回 CORS 头)。

没有后端也能看界面(本地演示 mock)

.scratch/mock-agent-server.mjs 是一个与真实后端协议一致、覆盖全部 7 个模块的本地假后端, 用于纯前端验证(它不是产品代码,不参与构建):

node .scratch/mock-agent-server.mjs     # 监听 8093,与 vite 代理目标一致
npm run dev                             # 打开 8080 即可对话

它可以演示:7 个模块的写操作表单、查询结果、表单提交回执,以及「已识别参数中文回显」。 换成真实后端(AgentService)时无需改前端,只要让 8093 由真服务占用即可。

mock 覆盖的是全量 7 个模块(它不模拟身份过滤)。若要在本地验证"学生 / 教师看到的能力不同", 请连真实后端 8093,并用 --agent.roles.* 或真实账号切身份。

4. 页面结构与视觉规范

版式基调:深色导航侧栏 + 浅色对话画布——左边管"有什么能力、我是谁",右边管"把事办完"。

src/
├── App.vue                      外壳:左栏 + 右区(顶栏状态/能力概览);统一拉取用户与能力目录
├── views/Chat.vue               ★ 对话页(消息流 / 悬浮输入卡 / 会话管理 / 表单提交链路;轮播例句按身份过滤)
├── components/
│   ├── AppSidebar.vue           左栏:品牌 + 新建对话 + 模块导航(按身份过滤,点一下=发一句完整请求)+ 身份标签 + 使用提示
│   ├── QuickActions.vue         首屏:Hero 横幅 + 能力指标卡 + 快捷卡网格(按身份过滤)+ 全部能力弹层
│   ├── MessageBubble.vue        消息气泡(含等待动画、错误态、挂载表单卡)
│   ├── FormCard.vue             ★ 补全表单卡(下拉 / 日期时间 / 多行文本 + 必填校验)
│   └── ToolTraceCard.vue        ★ 工具执行轨迹卡(可展开看接口与参数、写操作给「去查看」)
├── api/module/agent.ts          后端接口封装(chat / submitForm / tools / clearSession)
├── assets/styles/agent.css      设计系统:色彩/圆角/阴影令牌、模块色调、外壳骨架、组件库微调
├── assets/config/
│   ├── host.config.ts           平台运行时配置(与其他模块一致)
│   └── modules.ts               模块 → 上下文路径映射 + MODULE_META(图标/色调/示例句)+ orderedModuleKeys(按能力目录排序)
└── types/agent.ts               类型契约(与后端 model 包对齐)

视觉约定(改样式前先看这里,避免各改一套):

  • 颜色:只用 --agent-* 令牌(见 agent.css :root),不要在组件里写新色值。
  • 模块色:MODULE_META[key].tone → .tone-xx 类,侧栏/快捷卡/轨迹卡三处同一模块必然同色。
  • 色值变体:.tone-xx 提供 --tone(浅底上用)、--tone-soft(浅底)、--tone-light(深色侧栏上用)。
  • 阴影:--agent-shadow-xs/sm/md/lg 四档,统一"贴地短影 + 大扩散长影",不要自造 box-shadow。
  • 内容宽度:正文与输入区统一 --agent-readw(880px)居中。
  • view-ui-plus:模板里组件必须 PascalCase(Select / DatePicker),写 i-xxx 会被静默忽略。

5. 与后端的数据契约

POST /api/agent/chat

// 请求:普通对话
{ "sessionId": "可选,首次不传", "message": "我要请假" }

// 响应
{
  "sessionId": "92f0…",              // 后续轮次带上它保持上下文
  "reply": "要完成「提交请假申请」,还差这几项:请假类型、开始时间、结束时间……",
  "toolCalls": [                      // 本轮实际执行过的接口调用(可审计)
    { "name": "leave.types", "title": "请假类型", "module": "homeleave",
      "success": true, "write": false, "costMs": 42 }
  ],
  "wroteSomething": false,
  "landingPath": "/my",               // 写操作后「去查看」的落地路由
  "form": {                           // ★ 需要补全信息时才有
    "tool": "leave.submit",
    "title": "提交请假申请",
    "write": true,
    "missing": ["请假类型", "开始时间"],
    "preset":     { "remarks": "发烧就医" },
    "recognized": [{ "name": "remarks", "label": "请假事由", "text": "发烧就医" }],
    "fields": [ /* widget: select | datetime | date | textarea | text | number */ ]
  }
}

POST /api/agent/chat(表单提交)

{ "sessionId": "92f0…",
  "formSubmit": { "tool": "leave.submit", "args": { "leaveType": 1, "startDate": "…", "endDate": "…" } } }

6. 表单化补参(核心交互)

用户说「我要请假」时,前端不做口头追问,而是把后端的 form 渲染成一张卡:

  • 控件按字段类型自动选:select → 下拉(选项可来自业务字典接口)、 datetime/date → 日期时间选择器(不用手输格式)、textarea → 多行输入。
  • 已识别的信息以中文标签回显(「请假类型:病假」而不是 leaveType=1),用户核对后再补缺的。
  • 必填校验在前端拦截,缺失时字段下方标红提示,不会带着残缺参数打到后端。
  • 提交即执行:走 formSubmit 直接调用目标工具,服务端不再让模型二次猜测 —— 填什么就提交什么。
  • 提交成功后卡片转为只读摘要;失败则恢复可编辑,用户改完可重试。

输入侧还有两处引导:

  • 输入框 placeholder 轮播成品例句(每 4.2s 换一条),例如「我要请 10月12日 上午的病假,发烧就医」。 例句按当前身份过滤:每条标注它依赖的模块与工具,学生不会轮播到「发通知」「专业发展积分」这类教师专属句子, 避免给出一个"照着说必被拒"的示范。
  • 边打字边提示:命中「请假 / 公告 / 作业 / 宿舍 / 报名 / 审批 / 资源 / 积分」等关键词时, 下方显示该场景「怎么说更准」(如请假要说清 类型 + 起止时间 + 事由)。

后端识别到信息已齐全时不会弹表单,而是直接执行。 例:「我要请 10月12日 上午的病假,发烧就医」→ 类型、起止时间、事由全部解析成功 → 直接提交并回执。

7. 怎么改「去处理」跳转

src/assets/config/modules.ts 里维护 模块标识 → 前端上下文路径:

export const MODULE_CONTEXT = {
  homeleave: "home-leave",       // → http://base.yunhaixingtong.com/home-leave/front/#/my
  coursehomework: "coursehomework",
  // …
};

key 与后端 ToolDef.module 一致;新模块上线时这里补一行即可。

8. 构建与发布

npm run build      # 产物直出 dist/ 根(CI 打 zip 取 dist 根)

推送 main 分支后由内网 Gitea Actions 自动构建(.gitea/workflows/build.yml): npm ci + npm run build → 打包 dist 为 zip → 上传 Nexus raw → 通知 projman 建档, 最后由人工在 projman「前端发布」Tab 点部署。

  • 产物名:agent-front-<commit前8位>.zip
  • webhook app:agent,front:front

⚠️ 首次上线的前置条件(必须由 projman / 平台管理员完成,否则流水线必红)

  1. 在 Gitea 建组织 agent,并预生成两个仓库:agent/agent-front、agent/agent-service (与其它 app 同构:<app>/<app>-front + <app>/<app>-service)。 仓库需带好 projman 脚手架(README.md + .gitea/workflows/build.yml)。
  2. 给 agent/agent-front 配仓库级 Secrets:DEPLOY_TOKEN、NEXUS_USER、NEXUS_PASSWORD。
  3. 在 projman 侧登记 app agent(app=agent、front=front),否则 webhook 建档那一步会失败。
  4. 后端 agent-service 的部署方式与其它 Java 服务一致(打 jar 运行,端口 8093), 并确保其 application.yml 的 agent.roles.*(身份判定)与 agent.modules.* 指向正确的模块地址。

以上就绪后:本地 git remote add origin <仓库地址> → push main 即可触发构建。

9. 浏览器代执行(方案 A)—— 打通模块 302

模块的 /<模块>/api/* 只认浏览器 CAS 会话,服务端代调必然 code=302(根因见后端 README 第 11 节)。 方案 A 换掉调用方:服务端不直调,而是把接口描述挂起下发给前端,由浏览器用用户自己的登录态执行, 再把原始响应回传服务端续跑。模块侧零改动。

两段协议(POST /api/agent/chat):

// ① 服务端挂起 → 前端
{ "sessionId": "92f0…",
  "browserTool": { "notice": "…正在通过你的浏览器执行…",
    "calls": [{ "name": "notice.received", "title": "我收到的公告",
                "method": "GET", "pathAndQuery": "/campusnotice/api/notice/List?pageNo=1",
                "arguments": { "pageNo": 1 }, "write": false }] } }

// ② 前端执行后回传 → 服务端续跑
{ "sessionId": "92f0…",
  "toolResult": [{ "name": "notice.received", "arguments": { "pageNo": 1 },
                   "status": 200, "body": "{\"code\":0,\"data\":{…}}" }] }

实现位置:

  • src/views/Chat.vue 的 execBrowserCall():拼 location.origin + pathAndQuery, credentials: "same-origin"(同源 ⇒ 自动带会话)、带 yschapirequest: 1、15s 超时; runBrowserTool() 负责批量执行 + 回传 + 链式挂起的递归续跑。
  • ★ 回传前必须先把挂起响应里的 sessionId 落地,否则服务端找不到会话历史(会当成新会话)。
  • 服务端只接受「工具名 + 参数 + 状态码 + 响应体」,路径/方法/模块一律自行重新解析,不信任前端。

生效前提:必须与模块同站部署(如 base.yunhaixingtong.com/agent/)。 浏览器只给同源请求带 Cookie,本地 localhost 拿不到 base 域的会话 —— 这是浏览器安全模型,绕不过; 本地只能验证"两段协议跑通",真实生效要等部署。

降级:浏览器这条也走不通时(跨域 / 会话过期 / 404),结果回传后由服务端判为失败, 自动落回第 7 节的深链兜底(「去系统里自己查看」),UI 不会卡死。

真机断言:

MOCK_BROWSER_EXEC=1 node .scratch/mock-agent-server.mjs     # mock 以代执行模式跑在 8093
node .scratch/verify-browser-exec.mjs http://127.0.0.1:8081 shot-browser-exec.png        # 成功分支 13 项
STUB_FAIL=1 node .scratch/verify-browser-exec.mjs http://127.0.0.1:8081 shot-fail.png    # 降级分支 13 项