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

324 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AgentFront · 校园智能助手(前端)
> 一句话说需求,助手直接替你操作 —— 请假、报名、发公告、查作业、找资源…
> 信息说不全也没关系:助手会给你一张**填写表单**,填完直接提交执行。
与平台其它前端同族:**Vue3 + Vite + alova + view-ui-plus**,同源 `/api` 代理、运行时配置下发、SSO 登录态。
![首屏:Hero 横幅 + 能力指标 + 8 张快捷卡](docs/new-1-welcome.png)
![补全表单 · 请假(深色导航侧栏 + 浅色对话画布)](docs/new-2-form.png)
![下拉浮层:选项由业务字典实时下发](docs/new-2b-select.png)
![提交后的回执与执行轨迹(写操作给「去系统里查看」)](docs/new-3-done.png)
![补全表单 · 发布公告(标题已从对话识别并回显)](docs/new-6-notice.png)
![查询类直接给结果(我的作业列表)](docs/new-7-query.png)
![提交闭环:报名活动成功回执](docs/new-8-activity-done.png)
![侧栏点模块 = 直接发一句完整请求](docs/new-4-module.png)
![全部 54 项能力清单(写操作高亮)](docs/new-5-caps.png)
## 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. 本地开发
```bash
npm install
npm run dev # http://localhost:8080
```
`/api` 由 vite 代理到本地后端 **AgentService(8093)**(见 `vite.config.ts`)。
所以本地要同时起两个服务:
```bash
# 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 个模块的本地假后端,
用于纯前端验证(**它不是产品代码,不参与构建**):
```bash
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`
```jsonc
// 请求:普通对话
{ "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`(表单提交)
```jsonc
{ "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` 里维护 `模块标识 → 前端上下文路径`:
```ts
export const MODULE_CONTEXT = {
homeleave: "home-leave", // → http://base.yunhaixingtong.com/home-leave/front/#/my
coursehomework: "coursehomework",
// …
};
```
key 与后端 `ToolDef.module` 一致;新模块上线时这里补一行即可。
## 8. 构建与发布
```bash
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`):
```jsonc
// ① 服务端挂起 → 前端
{ "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 不会卡死。
**真机断言**:
```bash
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 项
```