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

This commit is contained in:
lijing
2026-10-12 14:45:00 +08:00
parent 96bb37c89f
commit 37d292c5d2
51 changed files with 13430 additions and 2 deletions
+11
View File
@@ -0,0 +1,11 @@
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*
# (线上由 /api/env-config?target=front 运行时下发)。
# ⚠️ VITE_* 仅本地开发生效。
# API 走 /api 同源相对路径(vite proxy 转发到本地 AgentService 8093,见 vite.config.ts),
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境。
# ⚠️ VITE_SERVICE_HOST 必须留空:host.config 设计约定 dev 模式走同源代理,
# 配置远端地址会导致浏览器直连被 CAS 过滤器拦截(无 CORS 头),全部请求失败
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=
VITE_IS_MOCK=false
+44
View File
@@ -0,0 +1,44 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist_old
dist-ssr
*.local
yarn.lock
.npmrc
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# scratch
.temp
.scratch
# AI 工作目录(记忆/技能,非工程内容,勿入公开仓库)
.workbuddy/
# 本地备份与编辑器残留
*.bak
*.orig
*.old
# 本地验证截图与 vite 临时产物
shot-*.png
*.timestamp-*.mjs
+15
View File
@@ -0,0 +1,15 @@
module.exports = {
extends: ["airbnb", "prettier", "prettier/react"],
singleQuote: false, // 是否强制使用单引号
printWidth: 120, // 超过最大值换行
tabWidth: 4,
htmlWhitespaceSensitivity: "ignore",
semi: true, // 结尾是否使用分号
//disableLanguages: ["vue"], // 不格式化vue文件,vue文件的格式化单独设置
endOfLine: "auto",
arrowParens: "avoid", // 箭头函数参数括号 默认avoid 可选 avoid| always
};
//如需显示webpack目录,运行以下命令
// npm run eject
//如需配合使eslint生效,运行以下命令
//npm install --save-dev eslint-plugin-prettier
+322 -2
View File
@@ -1,3 +1,323 @@
# yunhai-agent-front
# AgentFront · 校园智能助手(前端)
managed by projman
> 一句话说需求,助手直接替你操作 —— 请假、报名、发公告、查作业、找资源…
> 信息说不全也没关系:助手会给你一张**填写表单**,填完直接提交执行。
与平台其它前端同族:**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 项
```
+42
View File
@@ -0,0 +1,42 @@
import type { GeneratorConfig } from "@alova/wormhole";
export default {
generator: [
{
// 智能体服务的 OpenAPI 文档地址(如需用 `npm run api:gen` 自动生成接口代码时改这里)
input: "http://localhost:8093/v2/api-docs",
// 文档平台类型
platform: "swagger",
// 生成代码的输出目录
output: "src/api",
// 响应数据的媒体类型
responseMediaType: "application/json",
// 请求体数据的媒体类型
bodyMediaType: "application/json",
// 生成的 API 版本
version: 1,
// 生成代码类型
type: "typescript",
// 全局 API 名称
global: "Apis",
splitImplementation: true,
implementation: "inline",
// API 处理函数
handleApi: apiDescriptor => {
// 过滤掉已废弃的 API
if (apiDescriptor.deprecated) {
return undefined;
}
const newOpId = apiDescriptor.summary?.trim() || apiDescriptor.operationId;
const url = apiDescriptor.url.replace(/^\/{platform}\//, "/api/");
return {
...apiDescriptor,
operationId: newOpId, // 关键:改掉它
url: apiDescriptor.url.replace(/^\/{platform}\//, "/api/"),
};
},
},
],
} as GeneratorConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

+37
View File
@@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<contextPath>/api/env-config
(后端免登录端点,projman 配置管理在线维护),替代生产构建环境变量。
target = URL 第 2 段目录名:本应用产物部署于 /{contextPath}/front/,故为 front。 -->
<script>
(function () {
try {
var seg = location.pathname.split("/").filter(Boolean);
if (seg.length < 2) return; // 本地开发(根路径):走 .env.dev
var xhr = new XMLHttpRequest();
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
xhr.send(null);
if (xhr.status === 200) {
var conf = JSON.parse(xhr.responseText);
if (conf && typeof conf === "object") window.__APP_CONFIG__ = conf;
}
} catch (e) {
/* 拉取失败保持未设置,走同域推导兜底 */
}
})();
</script>
<title><%- title %></title>
<%- injectScript %>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+8454
View File
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
{
"name": "agent-front",
"private": true,
"version": "1.0.0",
"nodeversion": "20.19.4",
"type": "module",
"scripts": {
"dev": "vite --mode dev",
"build": "vite build --mode base",
"build:base": "vite build --mode base",
"preview": "vite preview",
"api:gen": "alova gen -f"
},
"dependencies": {
"alova": "^3.4.0",
"lodash": "^4.17.21",
"mitt": "^3.0.1",
"pinia": "^2.1.4",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"vue": "^3.4.19",
"vue-router": "4"
},
"devDependencies": {
"@alova/wormhole": "^1.5.0",
"@types/lodash": "^4.14.196",
"@types/node": "^20.4.4",
"@unocss/preset-rem-to-px": "^66.6.0",
"@unocss/preset-uno": "^66.6.0",
"@vitejs/plugin-vue": "^5.0.4",
"typescript": "^5.2.2",
"unocss": "^66.6.0",
"unplugin-auto-import": "0.18.3",
"vite": "^5.1.4",
"vite-plugin-html": "^3.2.2",
"vite-plugin-svg-icons": "^2.0.1",
"vue-tsc": "^1.8.27"
}
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#2d8cf0"/><text x="16" y="22" font-size="15" font-family="Arial" font-weight="bold" fill="#fff" text-anchor="middle">AI</text></svg>

After

Width:  |  Height:  |  Size: 235 B

+103
View File
@@ -0,0 +1,103 @@
<script setup lang="ts">
/**
* 应用外壳:左侧导航栏 + 右侧内容区。
*
* 职责划分:
* - 这里统一拉取「当前用户」和「能力目录」(两者都是全局信息,侧栏和对话页都要用),
* 再往下传,避免各组件各拉一次。
* - 会话状态留在对话页内部,侧栏的「新建对话」通过组件 ref 调用它的方法触发。
*/
import { computed, onMounted, ref } from "vue";
import { Message } from "view-ui-plus";
import { agentApi, baseApi } from "@/api/module/agent";
import AppSidebar from "@/components/AppSidebar.vue";
import type { AgentToolCatalog, UserInfoVo } from "@/types/agent";
const user = ref<UserInfoVo | null>(null);
const catalog = ref<AgentToolCatalog | null>(null);
const loading = ref(true);
const online = ref(false);
/** 对话页组件实例(用于从侧栏触发"新建对话") */
const chatRef = ref<any>(null);
const statusText = computed(() => (loading.value ? "正在连接" : online.value ? "服务已连接" : "服务未连接"));
const toolCount = computed(() => catalog.value?.total ?? 0);
const writeCount = computed(() => {
const mods = catalog.value?.modules || {};
let n = 0;
Object.keys(mods).forEach(k => mods[k].forEach(t => t.write && n++));
return n;
});
/**
* 拉取当前登录用户(平台 CAS 会话)。
*
* 未登录时后端 CasSsoApiRedirectFilter 返回 code=302 + urlToRedirectTo,
* alova 的 errorHandle 收到 302 会直接整页跳转登录页,并返回一个**永不 resolve** 的 Promise,
* 所以跳转链路根本不会落到下面的 catch —— 这里只需处理「服务没起来 / 接口报错」。
*/
async function loadUser() {
try {
const info = await baseApi.getUserInfo().send();
user.value = (info || null) as UserInfoVo;
online.value = true;
} catch (e: any) {
console.warn("载入用户信息失败", e);
Message.error("载入用户信息失败,请检查智能体服务是否已启动");
} finally {
loading.value = false;
}
}
/** 能力目录:拉取失败不阻塞对话(只是侧栏模块列表为空) */
async function loadCatalog() {
try {
catalog.value = (await agentApi.tools().send()) as AgentToolCatalog;
online.value = true;
} catch (e) {
console.warn("能力目录拉取失败", e);
}
}
onMounted(() => {
loadUser();
loadCatalog();
});
function onNewSession() {
chatRef.value?.newSession?.();
}
</script>
<template>
<div class="agent-shell">
<AppSidebar :catalog="catalog" :user="user" @pick="t => chatRef?.pickSample?.(t)" @new-session="onNewSession" />
<section class="agent-main">
<header class="agent-topbar">
<div class="agent-topbar__titles">
<h1>智能对话</h1>
<p>说出需求,我直接调用系统接口帮你办</p>
</div>
<div class="agent-topbar__right">
<span class="agent-status" :class="{ 'agent-status--off': !loading && !online }">
<i class="agent-status__dot" />
{{ statusText }}
</span>
<span v-if="toolCount" class="agent-capsule">
可用能力 <strong class="agent-num">{{ toolCount }}</strong> 项
<i class="agent-capsule__sep" />
可直接提交 <strong class="agent-num">{{ writeCount }}</strong> 项
</span>
</div>
</header>
<main class="agent-body">
<router-view v-slot="{ Component }">
<component :is="Component" ref="chatRef" :user="user" :catalog="catalog" />
</router-view>
</main>
</section>
</div>
</template>
+101
View File
@@ -0,0 +1,101 @@
import { Method } from "alova";
import { alovaInstance } from "@/utils/alova/alova-iview";
import conf from "@/assets/config/host.config";
import type {
AgentChatRequest,
AgentChatResponse,
AgentToolCatalog,
FormSubmit,
ToolResultItem,
UserInfoVo,
ModuleNode,
} from "@/types/agent";
/**
* 智能助手接口封装。
*
* 前缀说明:conf.serviceHost 本地为空(走 vite 同源代理转发到本地 AgentService 8093),
* 线上为基础平台域名;platform 固定 "api",与后端 Config.API_PATH 匹配。
* 统一响应 {code,msg,data} 由平台 ApiResponseAdvice 包装,alova-iview 解包为 data(code==0)。
*/
const AGENT = "/api/agent";
const BASE = "/api/base";
/** GET 请求 */
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
return new Method<T>("GET", alovaInstance, url, { params });
}
/** POST JSON body(与后端 @RequestBody 对齐) */
function postJSON<T = any>(url: string, data?: Record<string, any>): Method<T> {
return new Method<T>(
"POST",
alovaInstance,
url,
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
data ? JSON.stringify(data) : undefined
);
}
/** 平台壳层接口(当前用户 / 已授权模块 / 退出登录) */
export class BaseApi {
getUserInfo() {
return get<UserInfoVo>(`${BASE}/GetUserInfo`);
}
getUserMod() {
return get<ModuleNode[]>(`${BASE}/GetUserMod`);
}
/**
* 退出登录:销毁本应用的服务端 CAS 会话。
*
* <p>本应用的「登录态」就是服务端 session 里的 {@code _const_cas_assertion_},
* 光跳 CAS 的 {@code /logout} 只销毁 SSO 票据,JSESSIONID 还活着 ——
* 回到页面仍是登录态,表现为「点了退出没用」。所以登出必须先调它。
*/
logout() {
return postJSON<null>(`${BASE}/Logout`);
}
}
/** 智能助手接口 */
export class AgentApi {
/** 一问一答:返回智能体回复 + 本轮执行过的工具轨迹(可能带补全表单) */
chat(data: AgentChatRequest) {
return postJSON<AgentChatResponse>(`${AGENT}/chat`, data);
}
/**
* 表单提交:带着用户填好的参数直接执行指定工具。
* 服务端不再让模型二次决策,保证「填了什么就提交什么」。
*/
submitForm(sessionId: string | undefined, data: FormSubmit) {
return postJSON<AgentChatResponse>(`${AGENT}/chat`, { sessionId, formSubmit: data });
}
/**
* 浏览器代执行结果回传(方案 A):前端用用户登录态调完模块接口后,
* 把每个调用的原始响应带回,服务端解析并喂回模型生成最终回答。
*/
submitToolResults(sessionId: string | undefined, toolResult: ToolResultItem[]) {
return postJSON<AgentChatResponse>(`${AGENT}/chat`, { sessionId, toolResult });
}
/** 能力目录:按模块分组的可用工具,用于首屏快捷指令 */
tools() {
return get<AgentToolCatalog>(`${AGENT}/tools`);
}
/** 清空会话上下文 */
clearSession(sessionId?: string) {
return postJSON<{ cleared: boolean }>(`${AGENT}/session/clear`, { sessionId });
}
/** SSE 地址(供 EventSource / fetch 流式消费;本版本前端默认用阻塞式 chat) */
streamUrl(): string {
return `${conf.serviceHost}${AGENT}/chat/stream`;
}
}
export const baseApi = new BaseApi();
export const agentApi = new AgentApi();
+81
View File
@@ -0,0 +1,81 @@
import { BaseConf } from "@/types/Basics";
/**
* 配置优先级(高 → 低):
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
* /<contextPath>/api/env-config?target=<部署目录名>(本应用部署于 /{contextPath}/front/,
* 故为 front,对应后端键 yh.env-config.source.front,projman 配置管理维护)写入
* ② import.meta.env.VITE_*:仅本地开发(vite dev --mode dev 读 .env.dev);
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod 已删),VITE_* 恒为 undefined,
* 本层自动跳过,产物不含任何环境值
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
* ssoHost 为空 → 登出跳转不可用——与后端未就绪状态一致,可接受。
*/
const env = import.meta.env;
// ③ 同域推导兜底:context 段取当前路径首段(/{contextPath}/front/ → contextPath,即后端上下文),
// 与服务端 context-path 对齐(本地无、base/prod /homeleave);本地 dev 在根路径运行(无段),
// serviceHost 留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
const seg = window.location.pathname.split("/").filter(Boolean);
const origin = window.location.origin;
let conf: BaseConf = {
baseHost: origin,
ssoHost: "",
serviceHost: seg.length >= 2 ? `${origin}/${seg[0]}` : "",
relayConf: {
ziyuankuHost: "",
zhuanxiangHost: "",
},
isDebug: env.DEV,
};
// 逐键覆盖:只覆盖来源里实际出现且非空的键,未出现的键沿用底层值
const applyConf = (target: any, source: any) => {
if (!source || typeof source !== "object") return;
Object.keys(source).forEach(key => {
if (source[key] !== undefined && source[key] !== null && source[key] !== "") {
(target as any)[key] = source[key];
}
});
};
// ② 本地开发(build 产物中恒 undefined → 自动跳过)
applyConf(conf, {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_SERVICE_HOST,
relayConf: {
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
},
});
// ① 线上运行时配置(最高优先,覆盖前面所有);relayConf 为嵌套对象,按子键合并,
// 运行时 JSON 只配部分资源库地址时未配的键沿用兜底值
const runtime = (window as any).__APP_CONFIG__;
applyConf(conf, runtime);
if (runtime && typeof runtime === "object" && runtime.relayConf) {
applyConf(conf.relayConf, runtime.relayConf);
}
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
var ishttps = "https:" == document.location.protocol ? true : false;
if (ishttps) {
Object.keys(conf).forEach(element => {
if (typeof conf[element] === "string" && conf[element].indexOf("http://") > -1) {
conf[element] = conf[element].replace("http://", "https://");
}
});
// relayConf 内部也需要处理
if (conf.relayConf) {
Object.keys(conf.relayConf).forEach(key => {
const val = (conf.relayConf as any)[key];
if (typeof val === "string" && val.indexOf("http://") > -1) {
(conf.relayConf as any)[key] = val.replace("http://", "https://");
}
});
}
}
export default conf;
+122
View File
@@ -0,0 +1,122 @@
import conf from "@/assets/config/host.config";
/**
* 业务模块 → 前端上下文路径映射。
*
* 平台部署约定:每个前端应用部署在 {@code /{contextPath}/front/},
* 例如学生请假为 {@code /home-leave/front/}。智能体执行完写操作后,
* 「去处理」按钮就是按这张表拼出具体页面地址({@code baseHost + /contextPath/front/#landingPath})。
*
* 新增模块时,这里补一行即可(key 必须与后端 ToolDef.module 一致)。
*/
export const MODULE_CONTEXT: Record<string, string> = {
homeleave: "home-leave",
campusnotice: "campusnotice",
dormnotice: "dormnotice",
familyactivity: "familyactivity",
coursehomework: "coursehomework",
resourcelibrary: "resourcelibrary",
profession: "profession",
};
/** 模块中文名(能力目录 / 轨迹展示用) */
export const MODULE_LABEL: Record<string, string> = {
homeleave: "学生请假",
campusnotice: "校园公告",
dormnotice: "宿舍通知",
familyactivity: "家校活动",
coursehomework: "课程作业",
resourcelibrary: "资源库",
profession: "教师专业发展",
};
/**
* 模块**默认落地页**(模块前端的 hash 路由)——「深链兜底」用。
*
* <p>为什么需要:模块的用户态接口只认浏览器 CAS 会话,服务端调不通(下游 `code=302`)。
* 这时智能体会给出「去系统里查看」入口,让用户带自己的登录态打开模块页面看到真实数据。
* 后端已在注册期用 `agent.module-landing` 回填了 `landingPath`,这里是**前端侧的兜底**:
* 万一后端没给(旧版本 / 新模块未配),就退到这张表,保证按钮永远有目标。
*
* <p>取值已与各模块前端 `src/router/routes.ts` 的真实路由核对(2026-10-10)。
*/
export const MODULE_LANDING: Record<string, string> = {
homeleave: "/my",
campusnotice: "/",
dormnotice: "/par/notices",
familyactivity: "/par/activities",
coursehomework: "/stu/today",
resourcelibrary: "/resource",
profession: "/home",
};
/**
* 模块展示元数据(侧栏导航 / 快捷卡 / 轨迹卡共用)。
*
* - icon:单字图标(中文首字,比图形图标更好认,也不用额外加载图标资源)
* - tone:色调类名后缀,对应 agent.css 里的 .tone-xx,保证同一模块处处同色
* - sample:点击该模块时直接发出的示例句(一句就能跑通的高频请求)
*/
export const MODULE_META: Record<string, { icon: string; tone: string; sample: string }> = {
homeleave: { icon: "假", tone: "blue", sample: "我要请假" },
campusnotice: { icon: "公", tone: "violet", sample: "我有哪些未读的校园公告?" },
dormnotice: { icon: "舍", tone: "teal", sample: "宿舍有什么通知需要我回执吗?" },
familyactivity: { icon: "活", tone: "amber", sample: "报名秋季亲子运动会,2 人参加" },
coursehomework: { icon: "作", tone: "rose", sample: "看看我还有哪些作业没做" },
resourcelibrary: { icon: "资", tone: "cyan", sample: "帮我找一份语文课件" },
profession: { icon: "分", tone: "indigo", sample: "我的专业发展积分是多少?" },
};
/**
* 侧栏模块导航的**首选展示顺序**(与后端 ToolRegistry 注册顺序保持一致)。
*
* 注意:它是「排序依据」,**不是可见性白名单**。真正决定"能看到哪些模块"的是
* `GET /api/agent/tools` —— 后端已按「当前角色 + 已授权模块」筛过一遍,
* 不同身份(学生 / 教师 / 管理员)拿到的模块集合本来就不同。
* 前端若再拿这份名单去过滤,会出现「后端给了、前端不显示」的漏配。
*/
export const MODULE_ORDER = [
"homeleave",
"campusnotice",
"dormnotice",
"familyactivity",
"coursehomework",
"resourcelibrary",
"profession",
];
/**
* 按能力目录返回的模块集合,排出稳定且可扩展的展示顺序。
*
* 策略:MODULE_ORDER 里出现过的按既定顺序在前,其余(后端新增、前端还没配元数据的模块)
* 按后端返回顺序追加到尾部 —— `moduleMeta()` / `MODULE_LABEL` 都有兜底,不会渲染成空白项。
*
* @param modules 后端 `catalog.modules`(形如 `{ homeleave: [...], profession: [...] }`)
*/
export function orderedModuleKeys(modules?: Record<string, any[]> | null): string[] {
const keys = Object.keys(modules || {}).filter(k => modules![k]?.length);
const known = MODULE_ORDER.filter(k => keys.includes(k));
const rest = keys.filter(k => !MODULE_ORDER.includes(k));
return [...known, ...rest];
}
/** 取模块元数据;未知模块给一份中性兜底 */
export function moduleMeta(key: string) {
return MODULE_META[key] || { icon: "·", tone: "slate", sample: "" };
}
/**
* 拼出模块内某个页面的完整地址。
* @param module 模块标识(homeleave / coursehomework …)
* @param landingPath 模块内路由(如 /my);省略时退到 {@link MODULE_LANDING} 的模块默认页
* @returns 可打开的 URL;模块未知时返回 null
*/
export function moduleUrl(module?: string, landingPath?: string): string | null {
if (!module) return null;
const ctx = MODULE_CONTEXT[module];
if (!ctx) return null;
const base = (conf.baseHost || window.location.origin).replace(/\/+$/, "");
const path = landingPath || MODULE_LANDING[module] || "";
const hash = path ? `#${path.startsWith("/") ? path : "/" + path}` : "";
return `${base}/${ctx}/front/${hash}`;
}
+1
View File
@@ -0,0 +1 @@
+384
View File
@@ -0,0 +1,384 @@
/* ==========================================================================
校园智能助手 · 设计系统
--------------------------------------------------------------------------
版式基调:深色导航侧栏 + 浅色内容画布,强调留白、层次与克制的色彩。
本文件只放"全局令牌 + 外壳骨架",组件内部样式各自写在 .vue 的 scoped 块里。
========================================================================== */
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
color: var(--agent-text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei",
"Helvetica Neue", Arial, sans-serif;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-rendering: optimizeLegibility;
}
#app {
height: 100%;
}
/* ------------------------------ 设计令牌 ------------------------------ */
:root {
/* 品牌色 */
--agent-primary: #2563eb;
--agent-primary-dark: #1d4ed8;
--agent-primary-soft: #eaf1ff;
--agent-primary-line: #cfe0ff;
--agent-accent: #7c3aed;
/* 中性色 */
--agent-bg: #f4f6fb;
--agent-surface: #ffffff;
--agent-surface-2: #fafbfe;
--agent-border: #e9edf5;
--agent-border-strong: #dbe2ee;
--agent-text: #0f172a;
--agent-text-sub: #5b6b83;
--agent-text-mute: #94a3b8;
/* 语义色 */
--agent-success: #16a34a;
--agent-success-soft: #e9f7ef;
--agent-warn: #d97706;
--agent-danger: #dc2626;
--agent-danger-soft: #fdeceb;
/* 形状 */
--agent-radius-sm: 8px;
--agent-radius: 12px;
--agent-radius-lg: 16px;
--agent-radius-xl: 22px;
/* 阴影(统一用"贴地短影 + 大扩散长影"两层,显得干净不脏) */
--agent-shadow-xs: 0 1px 2px rgba(15, 23, 42, 0.04);
--agent-shadow-sm: 0 1px 3px rgba(15, 23, 42, 0.05), 0 8px 20px -12px rgba(15, 23, 42, 0.16);
--agent-shadow-md: 0 2px 6px rgba(15, 23, 42, 0.06), 0 18px 36px -16px rgba(15, 23, 42, 0.22);
--agent-shadow-lg: 0 4px 10px rgba(15, 23, 42, 0.07), 0 28px 60px -24px rgba(15, 23, 42, 0.3);
/* 侧栏 */
--agent-sidebar-w: 252px;
--agent-sidebar-bg: linear-gradient(178deg, #0e1730 0%, #131f3d 46%, #17264a 100%);
--agent-sidebar-text: rgba(255, 255, 255, 0.92);
--agent-sidebar-sub: rgba(255, 255, 255, 0.5);
--agent-sidebar-line: rgba(255, 255, 255, 0.1);
--agent-sidebar-hover: rgba(255, 255, 255, 0.07);
--agent-sidebar-active: rgba(255, 255, 255, 0.13);
/* 内容区最大宽度 */
--agent-readw: 880px;
}
/* --------------------------- 模块色调(共享) ---------------------------
每个模块一个克制的低饱和色调:
--tone 主色(浅底上使用)
--tone-soft 浅色底(卡片/标签底)
--tone-light 亮色(深色侧栏上使用,保证对比度)
侧栏导航 / 快捷卡 / 轨迹卡统一用 .tone-xx 取色,保证同一模块到哪都是同一个颜色。 */
.tone-blue {
--tone: #2563eb;
--tone-soft: #eaf1ff;
--tone-light: #7cb0ff;
}
.tone-violet {
--tone: #7c3aed;
--tone-soft: #f2ecff;
--tone-light: #b79cff;
}
.tone-teal {
--tone: #0d9488;
--tone-soft: #e3f6f3;
--tone-light: #5fd3c6;
}
.tone-amber {
--tone: #d97706;
--tone-soft: #fdf2e2;
--tone-light: #f3c169;
}
.tone-rose {
--tone: #e11d48;
--tone-soft: #fdeaee;
--tone-light: #ff92a9;
}
.tone-cyan {
--tone: #0891b2;
--tone-soft: #e4f5fa;
--tone-light: #6ad7ee;
}
.tone-indigo {
--tone: #4f46e5;
--tone-soft: #ececff;
--tone-light: #a8a5ff;
}
.tone-slate {
--tone: #475569;
--tone-soft: #eef1f6;
--tone-light: #b6c1d1;
}
/* ------------------------------ 滚动条 ------------------------------ */
.agent-scroll {
scrollbar-width: thin;
scrollbar-color: rgba(15, 23, 42, 0.16) transparent;
}
.agent-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.agent-scroll::-webkit-scrollbar-track {
background: transparent;
}
.agent-scroll::-webkit-scrollbar-thumb {
background: rgba(15, 23, 42, 0.14);
border-radius: 999px;
}
.agent-scroll::-webkit-scrollbar-thumb:hover {
background: rgba(15, 23, 42, 0.26);
}
.agent-scroll--dark {
scrollbar-color: rgba(255, 255, 255, 0.2) transparent;
}
.agent-scroll--dark::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
}
.agent-scroll--dark::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.32);
}
::selection {
background: rgba(37, 99, 235, 0.18);
}
/* ------------------------------ 外壳骨架 ------------------------------ */
.agent-shell {
height: 100%;
display: flex;
overflow: hidden;
background: var(--agent-bg);
}
/* 左:导航侧栏 */
.agent-sidebar {
flex: none;
width: var(--agent-sidebar-w);
height: 100%;
display: flex;
flex-direction: column;
background: var(--agent-sidebar-bg);
color: var(--agent-sidebar-text);
position: relative;
overflow: hidden;
}
/* 侧栏顶部一抹品牌光晕 */
.agent-sidebar::before {
content: "";
position: absolute;
top: -120px;
left: -60px;
width: 260px;
height: 260px;
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.55), transparent 68%);
pointer-events: none;
}
/* 右:主内容区 */
.agent-main {
flex: 1 1 auto;
min-width: 0;
height: 100%;
display: flex;
flex-direction: column;
background:
radial-gradient(1100px 380px at 12% -12%, rgba(37, 99, 235, 0.07), transparent 62%),
radial-gradient(900px 340px at 92% -6%, rgba(124, 58, 237, 0.06), transparent 58%),
var(--agent-bg);
}
.agent-topbar {
flex: none;
height: 64px;
padding: 0 28px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
background: rgba(255, 255, 255, 0.78);
backdrop-filter: saturate(180%) blur(12px);
border-bottom: 1px solid var(--agent-border);
position: relative;
z-index: 5;
}
.agent-topbar__titles h1 {
margin: 0;
font-size: 16px;
font-weight: 650;
letter-spacing: 0.2px;
line-height: 1.3;
color: var(--agent-text);
}
.agent-topbar__titles p {
margin: 2px 0 0;
font-size: 12px;
line-height: 1.3;
color: var(--agent-text-mute);
}
.agent-topbar__right {
display: flex;
align-items: center;
gap: 14px;
}
/* 状态胶囊:服务在线 */
.agent-status {
display: inline-flex;
align-items: center;
gap: 7px;
height: 30px;
padding: 0 12px;
border-radius: 999px;
font-size: 12.5px;
color: var(--agent-text-sub);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
}
.agent-status__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--agent-success);
box-shadow: 0 0 0 3px rgba(22, 163, 74, 0.14);
}
.agent-status--off .agent-status__dot {
background: var(--agent-danger);
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.14);
}
/* 顶栏能力概览 */
.agent-capsule {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 13px;
border-radius: 999px;
font-size: 12.5px;
color: var(--agent-text-sub);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
}
.agent-capsule strong {
font-weight: 650;
color: var(--agent-text);
}
.agent-capsule__sep {
width: 1px;
height: 11px;
background: var(--agent-border-strong);
margin: 0 2px;
}
.agent-body {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
/* ---------------------------- 通用小工具 ---------------------------- */
/* 分区块标题:左侧一道短色条 + 标题 + 右侧说明 */
.agent-section {
display: flex;
align-items: baseline;
gap: 10px;
margin-bottom: 14px;
}
.agent-section__title {
position: relative;
padding-left: 12px;
font-size: 15px;
font-weight: 650;
color: var(--agent-text);
letter-spacing: 0.2px;
}
.agent-section__title::before {
content: "";
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 15px;
border-radius: 999px;
background: linear-gradient(180deg, var(--agent-primary), var(--agent-accent));
}
.agent-section__hint {
font-size: 12px;
color: var(--agent-text-mute);
}
/* 数字用等宽数字,避免跳动 */
.agent-num {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* ---------------------- view-ui-plus 局部微调 ----------------------
只做"全局手感"的轻量统一,避免大面积覆盖组件库样式。 */
.ivu-btn {
font-weight: 500;
}
.ivu-btn-primary {
background-color: var(--agent-primary);
border-color: var(--agent-primary);
}
.ivu-btn-primary:hover {
background-color: var(--agent-primary-dark);
border-color: var(--agent-primary-dark);
}
.ivu-input:hover,
.ivu-select:hover .ivu-select-selection,
.ivu-input-number:hover {
border-color: var(--agent-border-strong);
}
.ivu-input:focus,
.ivu-input.ivu-input-focus,
.ivu-select-visible .ivu-select-selection {
border-color: var(--agent-primary);
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.1);
}
.ivu-select-selection,
.ivu-input {
border-radius: 8px;
}
+92
View File
@@ -0,0 +1,92 @@
/* eslint-disable */
/* prettier-ignore */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// Generated by unplugin-auto-import
// biome-ignore lint: disable
export {}
declare global {
const AgentApi: typeof import('./api/module/agent')['AgentApi']
const Avatar: typeof import('view-ui-plus')['Avatar']
const BaseApi: typeof import('./api/module/agent')['BaseApi']
const Button: typeof import('view-ui-plus')['Button']
const EffectScope: typeof import('vue')['EffectScope']
const Input: typeof import('view-ui-plus')['Input']
const Message: typeof import('view-ui-plus')['Message']
const Tag: typeof import('view-ui-plus')['Tag']
const Tooltip: typeof import('view-ui-plus')['Tooltip']
const agentApi: typeof import('./api/module/agent')['agentApi']
const baseApi: typeof import('./api/module/agent')['baseApi']
const computed: typeof import('vue')['computed']
const createApp: typeof import('vue')['createApp']
const customRef: typeof import('vue')['customRef']
const defineAsyncComponent: typeof import('vue')['defineAsyncComponent']
const defineComponent: typeof import('vue')['defineComponent']
const effectScope: typeof import('vue')['effectScope']
const getCurrentInstance: typeof import('vue')['getCurrentInstance']
const getCurrentScope: typeof import('vue')['getCurrentScope']
const h: typeof import('vue')['h']
const inject: typeof import('vue')['inject']
const isProxy: typeof import('vue')['isProxy']
const isReactive: typeof import('vue')['isReactive']
const isReadonly: typeof import('vue')['isReadonly']
const isRef: typeof import('vue')['isRef']
const markRaw: typeof import('vue')['markRaw']
const nextTick: typeof import('vue')['nextTick']
const onActivated: typeof import('vue')['onActivated']
const onBeforeMount: typeof import('vue')['onBeforeMount']
const onBeforeRouteLeave: typeof import('vue-router')['onBeforeRouteLeave']
const onBeforeRouteUpdate: typeof import('vue-router')['onBeforeRouteUpdate']
const onBeforeUnmount: typeof import('vue')['onBeforeUnmount']
const onBeforeUpdate: typeof import('vue')['onBeforeUpdate']
const onDeactivated: typeof import('vue')['onDeactivated']
const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted']
const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose']
const onServerPrefetch: typeof import('vue')['onServerPrefetch']
const onUnmounted: typeof import('vue')['onUnmounted']
const onUpdated: typeof import('vue')['onUpdated']
const onWatcherCleanup: typeof import('vue')['onWatcherCleanup']
const provide: typeof import('vue')['provide']
const reactive: typeof import('vue')['reactive']
const readonly: typeof import('vue')['readonly']
const ref: typeof import('vue')['ref']
const resolveComponent: typeof import('vue')['resolveComponent']
const shallowReactive: typeof import('vue')['shallowReactive']
const shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef']
const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs']
const toValue: typeof import('vue')['toValue']
const triggerRef: typeof import('vue')['triggerRef']
const unref: typeof import('vue')['unref']
const useAttrs: typeof import('vue')['useAttrs']
const useCssModule: typeof import('vue')['useCssModule']
const useCssVars: typeof import('vue')['useCssVars']
const useForm: typeof import('alova/client')['useForm']
const useId: typeof import('vue')['useId']
const useLink: typeof import('vue-router')['useLink']
const useModel: typeof import('vue')['useModel']
const useRequest: typeof import('alova/client')['useRequest']
const useRoute: typeof import('vue-router')['useRoute']
const useRouter: typeof import('vue-router')['useRouter']
const useSlots: typeof import('vue')['useSlots']
const useTemplateRef: typeof import('vue')['useTemplateRef']
const useWatcher: typeof import('alova/client')['useWatcher']
const watch: typeof import('vue')['watch']
const watchEffect: typeof import('vue')['watchEffect']
const watchPostEffect: typeof import('vue')['watchPostEffect']
const watchSyncEffect: typeof import('vue')['watchSyncEffect']
}
// for type re-export
declare global {
// @ts-ignore
export type { Component, ComponentPublicInstance, ComputedRef, DirectiveBinding, ExtractDefaultPropTypes, ExtractPropTypes, ExtractPublicPropTypes, InjectionKey, PropType, Ref, MaybeRef, MaybeRefOrGetter, VNode, WritableComputedRef } from 'vue'
import('vue')
// @ts-ignore
export type { BaseApi, AgentApi } from './api/module/agent'
import('./api/module/agent')
}
+525
View File
@@ -0,0 +1,525 @@
<script setup lang="ts">
/**
* 左侧导航栏。
*
* 承担三件事,让主区可以专心做对话:
* 1. 品牌与身份 —— 一眼知道这是什么、当前是谁在用
* 2. 能力导航 —— 7 个业务模块全部列出,点一下就等于发出一句完整请求
* 3. 使用提示 —— 常驻说明"信息不全也没关系",降低首次使用门槛
*/
import { computed, ref } from "vue";
import { Message } from "view-ui-plus";
import { baseApi } from "@/api/module/agent";
import conf from "@/assets/config/host.config";
import { MODULE_LABEL, moduleMeta, orderedModuleKeys } from "@/assets/config/modules";
import { beginLogoutGuard } from "@/utils/logout-guard";
import type { AgentToolCatalog, UserInfoVo } from "@/types/agent";
const props = defineProps<{ catalog: AgentToolCatalog | null; user?: UserInfoVo | null }>();
const emit = defineEmits<{
(e: "pick", text: string): void;
(e: "new-session"): void;
}>();
/**
* 退出登录(同时也是"换个账号登录"的唯一入口)。
*
* ★ 必须两步都做,缺一不可(2026-10-10 修:此前只做第 ② 步,用户反馈"退出登录没用"):
*
* ① **先清本应用的服务端会话**(`POST /api/base/Logout`,后端 `session.invalidate()`)。
* 本应用的登录态就是服务端 session 里的 `_const_cas_assertion_`
* (平台 CasSsoApiRedirectFilter 拿它判断登录与否)。不清它,回到页面依旧是登录态。
* ② **再跳 CAS 全局登出**销毁 SSO 的 TGT。只做 ① 也不行 —— SSO 侧会话还在时,
* 刷新页面会被重新认证成同一个账号,表现为"退不出去、换不了人"。
*
* 顺序不能颠倒:跳走之后页面就卸载了,再没有机会回头调 ①。
* 也不能只靠 CAS 的单点登出(SLO)回推来清会话 —— 平台虽注册了 SingleSignOutFilter,
* 但没有配套的会话监听,且要求本服务地址在 CAS 侧登记过,本地联调根本不成立。
*
* `service` 是登出后的回跳地址(CAS 会带回来),由它把浏览器送回本页,
* 回来后再走一次未登录 → 302 → 登录页的流程,即可用另一个账号登录。
*
* 地址取自 host.config 的 ssoHost(本地 dev 读 .env.dev 的 VITE_SSO_HOST,
* 线上读运行时 /api/env-config);未配置时不跳错地址,只提示。
*/
async function logout() {
const sso = (conf.ssoHost || "").replace(/\/+$/, "");
// 清会话期间先压住「未登录自动跳登录页」:此刻 TGT 还在,
// 若被 302 拽去 CAS 会被静默登回原账号,用户看到"点了退出又回来了"
beginLogoutGuard();
// ① 清本应用会话。给个上限:接口不通也要往下走,不能让用户卡在"点了没反应"
try {
await Promise.race([
baseApi.logout().send(),
new Promise(resolve => setTimeout(resolve, 2500)),
]);
} catch {
// 会话可能本就过期;继续跳 CAS,至少把 SSO 侧退掉
}
if (!sso) {
Message.warning("已退出本应用;未配置单点登录地址,统一登录(SSO)未退出");
// 稍等一下让提示可见,再重载(重载后闸门随页面一起消失)
setTimeout(() => window.location.reload(), 900);
return;
}
// ② 跳 CAS 全局登出销毁 TGT,并让它把浏览器送回本页
window.location.href = `${sso}/logout?service=${encodeURIComponent(window.location.href)}`;
}
/** 刚点过的模块(本地高亮,给用户"我刚才点了哪个"的反馈) */
const active = ref<string>("");
/**
* 侧栏只列出「当前身份真正可用」的模块。
*
* 能力目录由后端按「角色 + 已授权模块」过滤后下发,学生 / 教师 / 管理员拿到的集合本就不同,
* 因此这里直接以目录为准,而不是任何前端写死的名单 —— 既不会出现"点了就被拒"的假入口,
* 也不会漏掉后端新上线、前端还没配元数据的模块。
*/
const modules = computed(() => {
const mods = props.catalog?.modules || {};
return orderedModuleKeys(mods).map(key => {
const meta = moduleMeta(key);
return {
key,
icon: meta.icon,
tone: meta.tone,
sample: meta.sample,
label: MODULE_LABEL[key] || key,
count: mods[key].length,
};
});
});
/**
* 当前登录身份,由后端按平台 {@code BoaUser} 解析(如「学生」「教师」「管理员」)。
*
* 前端**不自行判断角色**:角色号与判定规则属后端,前端复刻一份迟早会不一致。
* 展示它的意义是让用户理解"为什么我能用的功能和别人不一样"。
*/
const roleText = computed(() => props.catalog?.roles || "");
function pick(m: { key: string; sample: string }) {
if (!m.sample) return;
active.value = m.key;
emit("pick", m.sample);
}
</script>
<template>
<aside class="sb">
<!-- 品牌 -->
<div class="sb__brand">
<span class="sb__logo">
<svg viewBox="0 0 24 24" width="19" height="19" aria-hidden="true">
<path
d="M12 2.6l2.3 6.1 6.1 2.3-6.1 2.3L12 19.4l-2.3-6.1L3.6 11l6.1-2.3L12 2.6z"
fill="#fff"
/>
</svg>
</span>
<span class="sb__brand-text">
<strong>校园智能助手</strong>
<em>Campus Copilot</em>
</span>
</div>
<!-- 新建对话 -->
<button class="sb__new" @click="emit('new-session')">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
新建对话
</button>
<!-- 能力模块 -->
<div class="sb__group">
<div class="sb__group-title">
<span>能力模块</span>
<em v-if="modules.length" class="agent-num">{{ modules.length }}</em>
</div>
<nav class="sb__nav agent-scroll agent-scroll--dark">
<button
v-for="m in modules"
:key="m.key"
class="sb__item"
:class="[`tone-${m.tone}`, { 'sb__item--on': active === m.key }]"
:title="`对「${m.label}」说:${m.sample}`"
@click="pick(m)"
>
<span class="sb__item-icon">{{ m.icon }}</span>
<span class="sb__item-label">{{ m.label }}</span>
<span class="sb__item-count agent-num">{{ m.count }}</span>
</button>
<p v-if="!modules.length" class="sb__nav-empty">正在读取能力目录…</p>
</nav>
</div>
<!-- 底部提示 -->
<div class="sb__foot">
<div class="sb__tip">
<span class="sb__tip-title">使用提示</span>
<p>信息说不全也没关系,我会直接给你一张表来填。</p>
<p>写操作提交前可核对,确认无误再提交。</p>
</div>
<div class="sb__user">
<Avatar :src="user?.profilePicture" size="small" class="sb__avatar">
{{ (user?.name || "访").slice(0, 1) }}
</Avatar>
<span class="sb__user-text">
<strong>{{ user?.name || (user ? "用户" : "未登录") }}</strong>
<em>{{ user?.deptName || user?.account || "校园账号" }}</em>
</span>
<em
v-if="roleText"
class="sb__role"
:title="`当前身份:${roleText}(能力模块已按该身份过滤)`"
>
{{ roleText }}
</em>
<button
class="sb__logout"
type="button"
title="退出登录(可换个账号重新登录)"
aria-label="退出登录"
@click="logout"
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<path d="M16 17l5-5-5-5" />
<path d="M21 12H9" />
</svg>
</button>
</div>
</div>
</aside>
</template>
<style scoped>
.sb {
flex: none;
width: var(--agent-sidebar-w);
height: 100%;
display: flex;
flex-direction: column;
gap: 4px;
padding: 20px 14px 16px;
background: var(--agent-sidebar-bg);
color: var(--agent-sidebar-text);
position: relative;
overflow: hidden;
}
.sb::before {
content: "";
position: absolute;
top: -130px;
left: -70px;
width: 280px;
height: 280px;
border-radius: 50%;
background: radial-gradient(circle, rgba(37, 99, 235, 0.5), transparent 68%);
pointer-events: none;
}
/* ---------- 品牌 ---------- */
.sb__brand {
display: flex;
align-items: center;
gap: 11px;
padding: 4px 6px 0;
position: relative;
}
.sb__logo {
flex: none;
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(140deg, #3b82f6, #7c3aed);
box-shadow: 0 8px 18px -8px rgba(59, 130, 246, 0.9), inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.sb__brand-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sb__brand-text strong {
font-size: 14.5px;
font-weight: 650;
letter-spacing: 0.3px;
line-height: 1.2;
}
.sb__brand-text em {
font-style: normal;
font-size: 10.5px;
letter-spacing: 1.2px;
text-transform: uppercase;
color: var(--agent-sidebar-sub);
}
/* ---------- 新建对话 ---------- */
.sb__new {
margin-top: 18px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid rgba(255, 255, 255, 0.18);
border-radius: 11px;
background: rgba(255, 255, 255, 0.08);
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.16s ease;
position: relative;
}
.sb__new:hover {
background: rgba(255, 255, 255, 0.16);
border-color: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
.sb__new:active {
transform: translateY(0);
}
/* ---------- 模块导航 ---------- */
.sb__group {
margin-top: 22px;
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
.sb__group-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 8px 10px;
font-size: 11px;
letter-spacing: 1.4px;
color: var(--agent-sidebar-sub);
}
.sb__group-title em {
font-style: normal;
font-size: 10.5px;
padding: 1px 7px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
letter-spacing: 0;
}
.sb__nav {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 3px;
padding-right: 2px;
}
.sb__item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 10px;
background: transparent;
color: var(--agent-sidebar-text);
font-size: 13px;
font-family: inherit;
text-align: left;
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.sb__item:hover {
background: var(--agent-sidebar-hover);
}
.sb__item--on {
background: var(--agent-sidebar-active);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.12);
}
.sb__item-icon {
flex: none;
width: 25px;
height: 25px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: var(--tone-light, #93c5fd);
background: rgba(255, 255, 255, 0.1);
}
.sb__item-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__item-count {
flex: none;
font-size: 11px;
color: var(--agent-sidebar-sub);
}
.sb__item--on .sb__item-count {
color: rgba(255, 255, 255, 0.8);
}
.sb__nav-empty {
margin: 0;
padding: 8px 10px;
font-size: 12px;
color: var(--agent-sidebar-sub);
}
/* ---------- 底部 ---------- */
.sb__foot {
flex: none;
margin-top: 14px;
position: relative;
}
.sb__tip {
padding: 11px 12px;
border-radius: 11px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.sb__tip-title {
display: block;
font-size: 10.5px;
letter-spacing: 1.2px;
color: var(--agent-sidebar-sub);
margin-bottom: 5px;
}
.sb__tip p {
margin: 0 0 3px;
font-size: 11.5px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.72);
}
.sb__tip p:last-child {
margin-bottom: 0;
}
.sb__user {
margin-top: 12px;
padding: 8px 10px;
display: flex;
align-items: center;
gap: 9px;
border-radius: 11px;
border-top: 1px solid var(--agent-sidebar-line);
}
.sb__avatar {
flex: none;
background: linear-gradient(140deg, #3b82f6, #7c3aed);
}
.sb__user-text {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
}
/* 当前身份标签(学生 / 教师 …):解释"为什么我的能力范围和别人不同" */
.sb__role {
flex: none;
font-style: normal;
font-size: 10.5px;
line-height: 1.5;
padding: 2px 7px;
border-radius: 999px;
color: #bfdbfe;
background: rgba(59, 130, 246, 0.2);
box-shadow: inset 0 0 0 1px rgba(147, 197, 253, 0.3);
white-space: nowrap;
}
/* 退出登录:CAS 全局登出,也是"换账号重新登录"的唯一入口 */
.sb__logout {
flex: none;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
border-radius: 7px;
background: transparent;
color: var(--agent-sidebar-sub);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease;
}
.sb__logout:hover {
color: #fecaca;
background: rgba(239, 68, 68, 0.22);
}
.sb__user-text strong {
font-size: 12.5px;
font-weight: 500;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb__user-text em {
font-style: normal;
font-size: 10.5px;
color: var(--agent-sidebar-sub);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+534
View File
@@ -0,0 +1,534 @@
<script setup lang="ts">
/**
* 补全表单卡片。
*
* 用户说「我要请假」但信息不全时,后端会把缺失字段(中文标签 + 格式示例 + 下拉选项)
* 下发到这里渲染成表单 —— 比让模型口头追问精确得多:
* - 下拉框:选项由后端从业务字典接口现取(如请假类型、发文部门)
* - 日期时间:用选择器,避免用户手输格式出错
* - 已识别的信息以只读标签回显,用户可核对
* 提交后直接执行对应工具,不再让模型二次猜测。
*
* 注意:view-ui-plus 组件在模板里必须用 PascalCase(Select / DatePicker / Input / Option),
* 写成 i-select / i-date-picker 不会被解析。
*/
import { reactive, computed, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import type { FormField, FormRequest } from "@/types/agent";
const props = defineProps<{
form: FormRequest;
submitted?: boolean;
disabled?: boolean;
}>();
const emit = defineEmits<{
(e: "submit", payload: { tool: string; args: Record<string, any> }): void;
}>();
/** 常见参数名的中文兜底(后端已给 label 的以 label 为准) */
const NAME_LABEL: Record<string, string> = {
leaveType: "请假类型",
startDate: "开始时间",
endDate: "结束时间",
remarks: "请假事由",
classId: "班级",
stuId: "学生",
serid: "记录",
deptId: "发文部门",
moduleId: "所属栏目",
opinion: "审批意见",
remark: "备注",
joinCount: "参加人数",
guardianName: "监护人姓名",
guardianPhone: "监护人电话",
relation: "与学生关系",
};
const values = reactive<Record<string, any>>({});
const errors = reactive<Record<string, string>>({});
/** 提交后的摘要(用于只读回显) */
const summary = ref<{ label: string; text: string }[]>([]);
function toDate(v: any): Date | undefined {
if (!v) return undefined;
if (v instanceof Date) return v;
const d = new Date(String(v).replace(/-/g, "/"));
return isNaN(d.getTime()) ? undefined : d;
}
function fmt(d: Date, withTime: boolean): string {
const p = (n: number) => String(n).padStart(2, "0");
const day = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
return withTime ? `${day} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` : day;
}
function initValues() {
Object.keys(values).forEach(k => delete values[k]);
Object.keys(errors).forEach(k => delete errors[k]);
for (const f of props.form?.fields || []) {
if (f.widget === "datetime" || f.widget === "date") {
values[f.name] = toDate(f.defaultValue) ?? "";
} else if (f.widget === "select" || f.widget === "number") {
values[f.name] = f.defaultValue ?? undefined;
} else {
values[f.name] = f.defaultValue ?? "";
}
}
}
initValues();
watch(() => props.form, initValues);
/** 只读回显:已从对话里识别出的参数(优先用后端翻译好的可读文本) */
const presetList = computed(() => {
const f = props.form;
if (f?.recognized?.length) {
return f.recognized.map(r => ({ name: r.name, label: r.label, text: r.text }));
}
const preset = f?.preset || {};
return Object.entries(preset).map(([k, v]) => ({
name: k,
label: labelOf(k),
text: String(v),
}));
});
/** 还差几项必填没填(页脚给出明确预期) */
const missingCount = computed(() => (props.form?.fields || []).filter(f => f.required).length);
function labelOf(name: string): string {
const f = (props.form?.fields || []).find(x => x.name === name);
return f?.label || NAME_LABEL[name] || name;
}
function normalize(f: FormField, v: any) {
const t = (f.type || "string").toLowerCase();
if (t === "integer" || t === "number") {
const n = Number(v);
return isNaN(n) ? v : n;
}
if (v instanceof Date) return f.widget === "date" ? fmt(v, false) : fmt(v, true);
return v;
}
/**
* 选项来自后端内置兜底(学校自定义字典没取到)时的轻提示。
* 下拉框是可用的,只是可能不包含学校自定义的项 —— 用淡色说明,不阻塞填写。
*/
function dictNote(f: FormField): string {
return f.optionsFallback
? `「${f.label}」为系统内置标准类型;学校自定义项暂时没取到。`
: "";
}
/**
* 选项完全没取到(来源接口 302 / 超时,控件退化成输入框)时给一句人话提示,
* 免得用户对着一个空输入框发呆。
*/
function dictWarn(f: FormField): string {
if (f.optionsFallback) return "";
if (!f.optionsFrom) return "";
if (f.options && f.options.length) return "";
return `「${f.label}」的可选项暂时没取到(来源接口未通),可以先直接填对应编号,稍后再试。`;
}
function onSubmit() {
if (props.submitted || props.disabled) return;
let ok = true;
for (const f of props.form?.fields || []) {
const v = values[f.name];
const empty = v === undefined || v === null || String(v).trim() === "";
if (f.required && empty) {
errors[f.name] = `请填写「${f.label}」`;
ok = false;
} else {
delete errors[f.name];
}
}
if (!ok) {
Message.warning("还有必填项没填完,请检查标红的字段");
return;
}
const args: Record<string, any> = {};
// 1) 已识别的参数原样带上
Object.entries(props.form?.preset || {}).forEach(([k, v]) => {
args[k] = v;
});
// 2) 用户填写的字段
const rows: { label: string; text: string }[] = [];
for (const f of props.form?.fields || []) {
const v = values[f.name];
if (v === undefined || v === null || String(v).trim() === "") continue;
const normalized = normalize(f, v);
args[f.name] = normalized;
const opt = f.options?.find(o => String(o.value) === String(normalized));
rows.push({ label: f.label, text: opt ? opt.label : String(normalized) });
}
summary.value = rows;
emit("submit", { tool: props.form.tool, args });
}
function reset() {
initValues();
Message.info("已重置为初始值");
}
</script>
<template>
<div class="form-card" :class="{ 'form-card--done': submitted }">
<!-- 头部 -->
<div class="form-card__head">
<span class="form-card__badge" :class="form.write ? 'form-card__badge--write' : 'form-card__badge--read'">
{{ form.write ? "需要你确认" : "需要补充信息" }}
</span>
<span class="form-card__title">{{ form.title }}</span>
<span v-if="submitted" class="form-card__state">
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12.5l5 5 11-11" />
</svg>
已提交
</span>
</div>
<div class="form-card__body">
<p v-if="form.description && !submitted" class="form-card__desc">{{ form.description }}</p>
<!-- 已识别信息:只读回显,便于核对 -->
<div v-if="presetList.length && !submitted" class="form-card__preset">
<span class="form-card__preset-label">已识别</span>
<span v-for="p in presetList" :key="p.name" class="chip">
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12.5l5 5 11-11" />
</svg>
{{ p.label }}:{{ p.text }}
</span>
</div>
<!-- 填写区 -->
<div v-if="!submitted" class="form-card__fields">
<div v-for="f in form.fields" :key="f.name" class="field">
<label class="field__label">
<span v-if="f.required" class="field__star">*</span>{{ f.label }}
</label>
<div class="field__control">
<Select
v-if="f.widget === 'select'"
v-model="values[f.name]"
:placeholder="f.example || '请选择'"
:disabled="disabled"
clearable
>
<Option v-for="o in f.options" :key="o.value" :value="o.value">{{ o.label }}</Option>
</Select>
<DatePicker
v-else-if="f.widget === 'datetime'"
v-model="values[f.name]"
type="datetime"
format="yyyy-MM-dd HH:mm:ss"
:placeholder="f.example || '选择日期与时刻'"
:disabled="disabled"
style="width: 100%"
/>
<DatePicker
v-else-if="f.widget === 'date'"
v-model="values[f.name]"
type="date"
format="yyyy-MM-dd"
:placeholder="f.example || '选择日期'"
:disabled="disabled"
style="width: 100%"
/>
<InputNumber
v-else-if="f.widget === 'number'"
v-model="values[f.name]"
:precision="f.type === 'integer' ? 0 : undefined"
:placeholder="f.example || f.description || '请输入数字'"
:disabled="disabled"
style="width: 100%"
/>
<Input
v-else-if="f.widget === 'textarea'"
v-model="values[f.name]"
type="textarea"
:rows="3"
:placeholder="f.example || f.description || '请输入'"
:disabled="disabled"
/>
<Input
v-else
v-model="values[f.name]"
:placeholder="f.example || f.description || '请输入'"
:disabled="disabled"
/>
</div>
<p v-if="dictNote(f)" class="field__tip">{{ dictNote(f) }}</p>
<p v-if="dictWarn(f)" class="field__warn">{{ dictWarn(f) }}</p>
<p v-if="f.description && f.example" class="field__tip">{{ f.description }}</p>
<p v-if="errors[f.name]" class="field__error">{{ errors[f.name] }}</p>
</div>
</div>
<!-- 提交后:只读摘要 -->
<div v-else class="form-card__summary">
<div v-for="row in summary" :key="row.label" class="summary-row">
<span class="summary-row__label">{{ row.label }}</span>
<span class="summary-row__value">{{ row.text }}</span>
</div>
</div>
</div>
<!-- 页脚 -->
<div v-if="!submitted" class="form-card__footer">
<span class="form-card__hint">
{{ form.hint || (missingCount ? `带 * 为必填,共 ${missingCount} 项` : "确认无误后提交") }}
</span>
<div class="form-card__btns">
<button class="fc-btn fc-btn--ghost" type="button" :disabled="disabled" @click="reset">重置</button>
<button class="fc-btn fc-btn--primary" type="button" :disabled="disabled" @click="onSubmit">
{{ form.write ? "确认提交" : "查询" }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.form-card {
margin-top: 11px;
border: 1px solid var(--agent-border);
border-radius: var(--agent-radius-lg);
background: var(--agent-surface);
overflow: hidden;
box-shadow: var(--agent-shadow-sm);
}
.form-card--done {
box-shadow: var(--agent-shadow-xs);
}
/* ------------------------------ 头部 ------------------------------ */
.form-card__head {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 16px;
background: linear-gradient(96deg, rgba(37, 99, 235, 0.07), rgba(124, 58, 237, 0.03) 60%, transparent);
border-bottom: 1px solid var(--agent-border);
}
.form-card__badge {
flex: none;
padding: 3px 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
color: #fff;
}
.form-card__badge--write {
background: linear-gradient(135deg, #2563eb, #4f46e5);
}
.form-card__badge--read {
background: linear-gradient(135deg, #0d9488, #0891b2);
}
.form-card__title {
font-size: 13.5px;
font-weight: 600;
color: var(--agent-text);
}
.form-card__state {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
color: var(--agent-success);
}
/* ------------------------------ 主体 ------------------------------ */
.form-card__body {
padding: 13px 16px 3px;
}
.form-card__desc {
margin: 0 0 10px;
font-size: 12.5px;
line-height: 1.7;
color: var(--agent-text-sub);
}
.form-card__preset {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 7px;
margin-bottom: 13px;
padding: 9px 11px;
border-radius: 10px;
background: var(--agent-surface-2);
border: 1px dashed var(--agent-border-strong);
}
.form-card__preset-label {
font-size: 11px;
color: var(--agent-text-mute);
letter-spacing: 0.5px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border-radius: 999px;
font-size: 12px;
color: var(--agent-primary-dark);
background: var(--agent-primary-soft);
border: 1px solid var(--agent-primary-line);
}
.form-card__fields {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 14px 18px;
padding-bottom: 4px;
}
.field__label {
display: block;
margin-bottom: 6px;
font-size: 12.5px;
color: var(--agent-text);
}
.field__star {
color: var(--agent-danger);
margin-right: 3px;
}
.field__tip {
margin: 5px 0 0;
font-size: 11px;
line-height: 1.55;
color: var(--agent-text-mute);
}
.field__warn {
margin: 5px 0 0;
font-size: 11px;
line-height: 1.55;
color: var(--agent-warn);
}
.field__error {
margin: 5px 0 0;
font-size: 11px;
color: var(--agent-danger);
}
/* ------------------------------ 提交后摘要 ------------------------------ */
.form-card__summary {
padding: 2px 0 6px;
}
.summary-row {
display: flex;
gap: 12px;
padding: 5px 0;
font-size: 12.5px;
border-bottom: 1px dashed var(--agent-border);
}
.summary-row:last-child {
border-bottom: none;
}
.summary-row__label {
flex: none;
width: 80px;
color: var(--agent-text-mute);
}
.summary-row__value {
color: var(--agent-text);
word-break: break-all;
}
/* ------------------------------ 页脚 ------------------------------ */
.form-card__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 12px;
padding: 11px 16px;
border-top: 1px solid var(--agent-border);
background: var(--agent-surface-2);
}
.form-card__hint {
font-size: 11.5px;
line-height: 1.6;
color: var(--agent-text-mute);
}
.form-card__btns {
flex: none;
display: flex;
gap: 8px;
}
.fc-btn {
height: 32px;
padding: 0 16px;
border-radius: 9px;
border: 1px solid transparent;
font-family: inherit;
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 0.16s ease;
}
.fc-btn--ghost {
background: var(--agent-surface);
border-color: var(--agent-border-strong);
color: var(--agent-text-sub);
}
.fc-btn--ghost:hover:not(:disabled) {
border-color: var(--agent-text-mute);
color: var(--agent-text);
}
.fc-btn--primary {
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: #fff;
box-shadow: 0 10px 20px -12px rgba(37, 99, 235, 0.9);
}
.fc-btn--primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 24px -12px rgba(37, 99, 235, 0.95);
}
.fc-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
</style>
+242
View File
@@ -0,0 +1,242 @@
<script setup lang="ts">
/**
* 单条消息气泡:用户右侧、助手左侧;助手消息下挂工具执行轨迹。
* 助手侧带一个小名牌,让长对话里"谁在说话"一眼可辨。
* 文本做最小化处理:换行保留、代码块/多余 markdown 符号不渲染(保持纯文本可读)。
*/
import ToolTraceCard from "./ToolTraceCard.vue";
import FormCard from "./FormCard.vue";
import type { ChatItem } from "@/types/agent";
const props = defineProps<{ item: ChatItem; pending?: boolean }>();
const emit = defineEmits<{
(e: "form-submit", payload: { item: ChatItem; form: { tool: string; args: Record<string, any> } }): void;
}>();
/** 表单提交:冒泡给对话页统一处理(统一走同一条提交链路) */
function onFormSubmit(p: { tool: string; args: Record<string, any> }) {
emit("form-submit", { item: props.item, form: p });
}
/** 把回复拆成段落,保留换行 */
function paragraphs(text: string): string[] {
return (text || "")
.split(/\n{2,}/)
.map(s => s.trim())
.filter(Boolean);
}
</script>
<template>
<div class="msg" :class="item.role === 'user' ? 'msg--user' : 'msg--assistant'">
<div class="msg__avatar">
<svg v-if="item.role === 'assistant'" viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
<path d="M12 2.6l2.3 6.1 6.1 2.3-6.1 2.3L12 19.4l-2.3-6.1L3.6 11l6.1-2.3L12 2.6z" fill="#fff" />
</svg>
<template v-else>我</template>
</div>
<div class="msg__content">
<span v-if="item.role === 'assistant'" class="msg__name">校园智能助手</span>
<div class="msg__stack">
<!-- 助手等待中:阶段提示 -->
<div v-if="pending" class="msg__stage">
<span class="dot" />
<span class="dot" />
<span class="dot" />
<span class="msg__stage-text">{{ item.stage || "思考中…" }}</span>
</div>
<!-- 正文 -->
<div v-if="item.text" class="msg__bubble">
<p v-for="(p, i) in paragraphs(item.text)" :key="i">{{ p }}</p>
</div>
<!-- 工具轨迹 -->
<ToolTraceCard v-if="item.traces && item.traces.length" :traces="item.traces" />
<!-- 补全表单:信息不全时让用户直接填,比反复追问清晰得多 -->
<FormCard
v-if="item.form"
:form="item.form"
:submitted="item.formSubmitted"
:disabled="pending"
@submit="onFormSubmit"
/>
<!-- 错误 -->
<div v-if="item.error" class="msg__error">{{ item.error }}</div>
</div>
</div>
</div>
</template>
<style scoped>
.msg {
display: flex;
gap: 12px;
margin-bottom: 22px;
align-items: flex-start;
}
.msg--user {
flex-direction: row-reverse;
}
.msg--assistant {
animation: rise 0.28s ease both;
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ------------------------------ 头像 ------------------------------ */
.msg__avatar {
flex: none;
width: 34px;
height: 34px;
border-radius: 11px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12.5px;
font-weight: 600;
color: #fff;
background: linear-gradient(140deg, #3b82f6, #7c3aed);
box-shadow: 0 8px 16px -10px rgba(59, 130, 246, 0.95);
}
.msg--user .msg__avatar {
background: linear-gradient(140deg, #64748b, #475569);
box-shadow: 0 8px 16px -10px rgba(71, 85, 105, 0.9);
}
/* ------------------------------ 内容列 ------------------------------ */
.msg__content {
max-width: 84%;
min-width: 0;
display: flex;
flex-direction: column;
}
.msg--assistant .msg__content {
align-items: flex-start;
}
.msg--user .msg__content {
align-items: flex-end;
}
.msg__name {
font-size: 11.5px;
color: var(--agent-text-mute);
margin: 0 0 6px 3px;
letter-spacing: 0.3px;
}
.msg__stack {
display: flex;
flex-direction: column;
align-items: stretch;
min-width: 0;
max-width: 100%;
}
/* ------------------------------ 气泡 ------------------------------ */
.msg__bubble {
padding: 11px 15px;
border-radius: 14px;
font-size: 14px;
line-height: 1.75;
color: var(--agent-text);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
word-break: break-word;
}
.msg__bubble p {
margin: 0 0 7px;
white-space: pre-wrap;
}
.msg__bubble p:last-child {
margin-bottom: 0;
}
.msg--user .msg__bubble {
background: linear-gradient(135deg, #2563eb, #4f46e5);
border-color: transparent;
color: #fff;
box-shadow: 0 12px 24px -14px rgba(37, 99, 235, 0.85);
}
/* ------------------------------ 阶段提示 ------------------------------ */
.msg__stage {
display: inline-flex;
align-items: center;
gap: 5px;
align-self: flex-start;
padding: 10px 15px;
border-radius: 14px;
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
font-size: 13px;
color: var(--agent-text-sub);
}
.msg__stage-text {
margin-left: 5px;
}
.dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--agent-primary);
animation: bounce 1.2s infinite ease-in-out;
}
.dot:nth-child(2) {
animation-delay: 0.15s;
}
.dot:nth-child(3) {
animation-delay: 0.3s;
}
@keyframes bounce {
0%,
80%,
100% {
transform: translateY(0);
opacity: 0.35;
}
40% {
transform: translateY(-4px);
opacity: 1;
}
}
/* ------------------------------ 错误 ------------------------------ */
.msg__error {
margin-top: 8px;
padding: 9px 13px;
border-radius: 10px;
background: var(--agent-danger-soft);
border: 1px solid #f8c9c5;
color: var(--agent-danger);
font-size: 13px;
}
</style>
+548
View File
@@ -0,0 +1,548 @@
<script setup lang="ts">
/**
* 首屏引导区。
*
* 排版分三层,视线由上到下自然下落:
* 1. Hero 横幅 —— 说明"我能帮你做什么",建立信任
* 2. 指标卡 —— 用数字证明覆盖面(能力数 / 可提交数 / 模块数)
* 3. 快速开始 —— 每个**当前身份可用的**业务模块给一句成品请求,点一下就等于说出来
* 末尾一格是"查看全部能力",既补齐网格,也让全部能力可查。
*
* 关键:模块集合与数量都来自能力目录(后端已按角色过滤),不是写死的 7 个 / 54 项。
* 学生登录后不会出现「教师专业发展」这类入口 —— 否则点下去必被服务端拒绝。
*/
import { computed, ref } from "vue";
import { MODULE_LABEL, MODULE_ORDER, moduleMeta, orderedModuleKeys } from "@/assets/config/modules";
import type { AgentToolCatalog } from "@/types/agent";
const props = defineProps<{ catalog: AgentToolCatalog | null }>();
const emit = defineEmits<{ (e: "pick", text: string): void }>();
/**
* 已知模块的开场文案(一句话就能跑通的高频请求)。
*
* 这里只负责「怎么说」,不负责「能不能看到」:
* 可见性完全由能力目录决定,因此学生身份下 profession 等条目不会出现。
* 目录里出现了此处未配置的模块(后端新上线的能力)也有兜底文案,不会漏格。
*/
const PRESET_TEXT: Record<string, { title: string; desc: string; text: string }> = {
homeleave: { title: "我要请假", desc: "说清类型与时间,直接提交", text: "我要请假" },
campusnotice: { title: "有什么新公告", desc: "查看未读校园公告", text: "我有哪些未读的校园公告?" },
dormnotice: { title: "宿舍通知回执", desc: "查看待回执的宿舍通知", text: "宿舍有什么通知需要我回执吗?" },
familyactivity: { title: "报名校园活动", desc: "活动广场报名,一次填清", text: "报名秋季亲子运动会,2 人参加" },
coursehomework: { title: "今天有什么作业", desc: "查看待做作业与截止时间", text: "看看我还有哪些作业没做" },
resourcelibrary: { title: "找教学资源", desc: "按关键词检索资源库", text: "帮我找一份语文课件" },
profession: { title: "我的积分", desc: "查看专业发展积分", text: "我的专业发展积分是多少?" },
};
/**
* 快捷卡条目:跟随能力目录生成。
*
* 目录未就绪时(首次加载中 / 拉取失败)先按全量模块占位,避免首屏"快速开始"整块空着;
* 目录到达后立即收缩为当前身份真正可用的模块。
*/
const tiles = computed(() => {
const keys = props.catalog?.modules ? orderedModuleKeys(props.catalog.modules) : MODULE_ORDER;
return keys
.map(key => {
const meta = moduleMeta(key);
const preset = PRESET_TEXT[key];
return {
module: key,
icon: meta.icon,
tone: meta.tone,
title: preset?.title || MODULE_LABEL[key] || key,
desc: preset?.desc || "点一下直接发起",
text: preset?.text || meta.sample,
};
})
.filter(t => t.text);
});
const writeCount = computed(() => {
const mods = props.catalog?.modules || {};
let n = 0;
Object.keys(mods).forEach(k => mods[k].forEach(t => t.write && n++));
return n;
});
const moduleGroups = computed(() =>
MODULE_ORDER.filter(k => props.catalog?.modules?.[k]?.length).map(key => ({
key,
label: MODULE_LABEL[key] || key,
meta: moduleMeta(key),
tools: props.catalog!.modules[key],
}))
);
/** 指标卡:数字来自后端能力目录,未就绪时先显示占位 */
const stats = computed(() => [
{
key: "tools",
value: props.catalog?.total ?? 0,
unit: "项",
label: "可操作能力",
hint: "覆盖全部业务模块",
tone: "blue",
icon: "layers",
},
{
key: "write",
value: writeCount.value,
unit: "项",
label: "可直接提交",
hint: "请假、报名、发公告…",
tone: "violet",
icon: "bolt",
},
{
key: "mods",
value: moduleGroups.value.length,
unit: "个",
label: "已接入模块",
hint: "全部来自现有系统",
tone: "teal",
icon: "cube",
},
]);
const allOpen = ref(false);
function pick(text: string) {
emit("pick", text);
}
</script>
<template>
<div class="welcome">
<!-- ① Hero -->
<section class="hero">
<span class="hero__glow hero__glow--a" />
<span class="hero__glow hero__glow--b" />
<div class="hero__inner">
<span class="hero__tag">AI Agent · 直连业务系统</span>
<h2>你好,我是校园智能助手 👋</h2>
<p>
我不只会聊天 —— 说出需求,我会直接调用系统接口帮你办完。<br />
信息说不全也没关系,我会给你一张表,填完就提交。
</p>
</div>
</section>
<!-- ② 指标卡(上浮压住横幅下沿) -->
<section class="stats">
<div v-for="s in stats" :key="s.key" class="stat" :class="`tone-${s.tone}`">
<span class="stat__icon">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<template v-if="s.icon === 'layers'">
<path d="M12 3l9 4.5-9 4.5-9-4.5L12 3z" />
<path d="M3 12.5l9 4.5 9-4.5" />
<path d="M3 17l9 4.5 9-4.5" />
</template>
<template v-else-if="s.icon === 'bolt'">
<path d="M13 2.5L5.5 13H11l-1 8.5L17.5 11H12l1-8.5z" />
</template>
<template v-else>
<path d="M12 2.5l8.5 4.7v9.6L12 21.5l-8.5-4.7V7.2L12 2.5z" />
<path d="M12 12l8.5-4.8M12 12v9.5M12 12L3.5 7.2" />
</template>
</svg>
</span>
<span class="stat__body">
<span class="stat__value-row">
<strong class="stat__value agent-num">{{ s.value }}</strong>
<em class="stat__unit">{{ s.unit }}</em>
<span class="stat__label">{{ s.label }}</span>
</span>
<span class="stat__hint">{{ s.hint }}</span>
</span>
</div>
</section>
<!-- ③ 快速开始 -->
<section class="block">
<div class="agent-section">
<span class="agent-section__title">快速开始</span>
<span class="agent-section__hint">点一下就是一句完整请求,也可以直接在下方输入</span>
</div>
<div class="grid">
<button v-for="t in tiles" :key="t.module" class="tile" :class="`tone-${t.tone}`" @click="pick(t.text)">
<span class="tile__icon">{{ t.icon }}</span>
<span class="tile__title">{{ t.title }}</span>
<span class="tile__desc">{{ t.desc }}</span>
<span class="tile__go">
去办理
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h13M12 6l6 6-6 6" />
</svg>
</span>
</button>
<!-- 第 8 格:补齐网格 + 让 54 项能力可查 -->
<button class="tile tile--more" :disabled="!catalog" @click="allOpen = true">
<span class="tile__icon">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<circle cx="5" cy="12" r="1.4" />
<circle cx="12" cy="12" r="1.4" />
<circle cx="19" cy="12" r="1.4" />
</svg>
</span>
<span class="tile__title">查看全部能力</span>
<span class="tile__desc">
共 <strong class="agent-num">{{ catalog?.total ?? 0 }}</strong> 项,按模块分组列出
</span>
<span class="tile__go">
展开清单
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h13M12 6l6 6-6 6" />
</svg>
</span>
</button>
</div>
</section>
<!-- 能力全清单 -->
<Modal v-model="allOpen" title="已接入的全部能力" :footer-hide="true" width="780" class="caps-modal">
<div class="caps-body agent-scroll">
<p class="caps-modal__lead">
下面每一项都是对现有业务系统接口的封装,我调用它们时与你本人在系统里手动操作完全等价。
</p>
<div v-for="g in moduleGroups" :key="g.key" class="caps-group">
<div class="caps-group__head">
<span class="caps-group__icon" :class="`tone-${g.meta.tone}`">{{ g.meta.icon }}</span>
<strong>{{ g.label }}</strong>
<em class="agent-num">{{ g.tools.length }} 项</em>
</div>
<div class="caps-group__list">
<span v-for="t in g.tools" :key="t.name" class="caps-tool" :class="{ 'caps-tool--write': t.write }">
<i class="caps-tool__dot" />
{{ t.title }}
</span>
</div>
</div>
</div>
</Modal>
</div>
</template>
<style scoped>
.welcome {
max-width: var(--agent-readw);
margin: 0 auto;
padding: 28px 28px 8px;
}
/* ------------------------------ Hero ------------------------------ */
.hero {
position: relative;
overflow: hidden;
border-radius: var(--agent-radius-xl);
padding: 28px 30px 46px;
background: linear-gradient(126deg, #1b3a8f 0%, #2f5fd8 42%, #6d3fd4 100%);
box-shadow: 0 20px 44px -22px rgba(29, 78, 216, 0.7);
}
.hero__glow {
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.hero__glow--a {
top: -150px;
right: -80px;
width: 400px;
height: 400px;
background: radial-gradient(circle, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0) 62%);
}
.hero__glow--b {
bottom: -190px;
left: 16%;
width: 440px;
height: 360px;
background: radial-gradient(circle, rgba(124, 58, 237, 0.42), rgba(124, 58, 237, 0) 66%);
}
.hero__inner {
position: relative;
}
.hero__tag {
display: inline-block;
padding: 4px 11px;
border-radius: 999px;
font-size: 11.5px;
letter-spacing: 0.6px;
color: rgba(255, 255, 255, 0.94);
background: rgba(255, 255, 255, 0.16);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.hero h2 {
margin: 14px 0 9px;
font-size: 26px;
font-weight: 700;
letter-spacing: 0.4px;
line-height: 1.25;
color: #fff;
}
.hero p {
margin: 0;
font-size: 13.5px;
line-height: 1.75;
color: rgba(255, 255, 255, 0.82);
}
/* ------------------------------ 指标卡 ------------------------------ */
.stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin: -30px 18px 0;
position: relative;
}
.stat {
display: flex;
align-items: center;
gap: 13px;
padding: 14px 16px;
border-radius: var(--agent-radius-lg);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-sm);
}
.stat__icon {
flex: none;
width: 40px;
height: 40px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: var(--tone);
background: var(--tone-soft);
}
.stat__body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat__value-row {
display: flex;
align-items: baseline;
gap: 6px;
}
.stat__value {
font-size: 21px;
font-weight: 700;
line-height: 1.1;
color: var(--agent-text);
}
.stat__unit {
font-style: normal;
font-size: 11.5px;
color: var(--agent-text-mute);
}
.stat__label {
font-size: 12.5px;
color: var(--agent-text-sub);
}
.stat__hint {
font-size: 11.5px;
color: var(--agent-text-mute);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ------------------------------ 快速开始 ------------------------------ */
.block {
margin-top: 30px;
}
.grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 13px;
}
.tile {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 9px;
min-height: 148px;
padding: 16px 15px 14px;
border-radius: var(--agent-radius-lg);
background: var(--agent-surface);
border: 1px solid var(--agent-border);
box-shadow: var(--agent-shadow-xs);
text-align: left;
font-family: inherit;
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.tile:hover {
transform: translateY(-3px);
box-shadow: var(--agent-shadow-md);
border-color: var(--tone);
}
.tile__icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
font-weight: 600;
color: var(--tone);
background: var(--tone-soft);
}
.tile__title {
font-size: 14px;
font-weight: 600;
line-height: 1.35;
color: var(--agent-text);
}
.tile__desc {
flex: 1 1 auto;
font-size: 12px;
line-height: 1.6;
color: var(--agent-text-sub);
}
.tile__go {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11.5px;
font-weight: 500;
color: var(--agent-text-mute);
transition: color 0.18s ease, transform 0.18s ease;
}
.tile:hover .tile__go {
color: var(--tone);
transform: translateX(2px);
}
/* 第 8 格:更多能力(虚线,弱化) */
.tile--more {
border-style: dashed;
border-color: var(--agent-border-strong);
background: rgba(255, 255, 255, 0.5);
--tone: var(--agent-text-sub);
--tone-soft: #eef1f6;
}
.tile--more:hover {
border-color: var(--agent-text-mute);
}
.tile--more .tile__desc strong {
color: var(--agent-text);
}
/* ------------------------------ 能力清单弹层 ------------------------------ */
.caps-body {
max-height: 62vh;
overflow-y: auto;
padding-right: 6px;
}
.caps-modal__lead {
margin: 0 0 16px;
font-size: 12.5px;
line-height: 1.7;
color: var(--agent-text-sub);
}
.caps-group + .caps-group {
margin-top: 16px;
padding-top: 16px;
border-top: 1px dashed var(--agent-border);
}
.caps-group__head {
display: flex;
align-items: center;
gap: 9px;
margin-bottom: 9px;
}
.caps-group__head strong {
font-size: 13.5px;
color: var(--agent-text);
}
.caps-group__head em {
font-style: normal;
font-size: 11.5px;
color: var(--agent-text-mute);
}
.caps-group__icon {
width: 24px;
height: 24px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11.5px;
font-weight: 600;
color: var(--tone);
background: var(--tone-soft);
}
.caps-group__list {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.caps-tool {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px 4px 8px;
border-radius: 999px;
font-size: 12px;
color: var(--agent-text-sub);
background: var(--agent-surface-2);
border: 1px solid var(--agent-border);
}
.caps-tool__dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: var(--agent-text-mute);
}
.caps-tool--write {
color: var(--agent-text);
background: var(--agent-primary-soft);
border-color: var(--agent-primary-line);
}
.caps-tool--write .caps-tool__dot {
background: var(--agent-primary);
}
</style>
+341
View File
@@ -0,0 +1,341 @@
<script setup lang="ts">
/**
* 工具执行轨迹卡片:让用户看清"智能体到底动了系统的哪个接口、成功没有"。
* 这是把「黑盒 AI」变成「可信执行器」的关键 —— 也让审计有据可查。
* 模块标签沿用侧栏同一套色调,保证"同一个模块在哪里都是同一个颜色"。
*/
import { ref } from "vue";
import { MODULE_LABEL, moduleMeta, moduleUrl } from "@/assets/config/modules";
import type { ToolCallTrace } from "@/types/agent";
defineProps<{ traces: ToolCallTrace[] }>();
const expanded = ref<Record<number, boolean>>({});
function toggle(i: number) {
expanded.value[i] = !expanded.value[i];
}
/** 参数展示:过滤掉内部字段,值过长时截断 */
function prettyArgs(args?: Record<string, any>): string {
if (!args || !Object.keys(args).length) return "(无参数)";
const entries = Object.entries(args).map(([k, v]) => {
let val = typeof v === "object" ? JSON.stringify(v) : String(v);
if (val.length > 80) val = val.slice(0, 80) + "…";
return `${k}: ${val}`;
});
return entries.join(",");
}
function openLanding(t: ToolCallTrace) {
const url = moduleUrl(t.module, t.landingPath);
if (url) window.open(url, "_blank");
}
/**
* 是否显示「去系统里查看」入口。
*
* - 写操作成功:去系统里确认结果(原有行为);
* - **任何失败**:服务端调不通模块用户态接口(下游 code=302,只认浏览器 CAS 会话),
* 此时由用户带自己的登录态打开模块页面,是唯一能看到真实数据的路径 —— 这是「深链兜底」。
*/
function canGo(t: ToolCallTrace): boolean {
if (!t.module) return false;
// 写操作(无论成败)→ 去系统里确认/补办;只读失败(如下游 302)→ 去系统里自己看。
// 只读成功不显示:数据已经在回复里了。
return t.write === true || !t.success;
}
/** 按钮文案:成功是"去确认",失败是"自己去看" */
function goLabel(t: ToolCallTrace): string {
return t.success ? "去系统里查看" : "去系统里自己查看";
}
</script>
<template>
<div class="trace">
<div
v-for="(t, i) in traces"
:key="i"
class="trace__item"
:class="[t.success ? 'trace__item--ok' : 'trace__item--fail']"
>
<div class="trace__head" @click="toggle(i)">
<span class="trace__badge" :class="t.write ? 'trace__badge--write' : 'trace__badge--read'">
{{ t.write ? "操作" : "查询" }}
</span>
<span class="trace__title">{{ t.title }}</span>
<span v-if="t.module" class="trace__module" :class="`tone-${moduleMeta(t.module).tone}`">
{{ MODULE_LABEL[t.module] || t.module }}
</span>
<span class="trace__spacer" />
<span class="trace__status">
<svg v-if="t.success" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12.5l5 5 11-11" />
</svg>
<svg v-else viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
<!-- 失败原因是自然语言(可能很长)→ 这里只显示一行省略号,完整内容在「详情」里看 -->
<span class="trace__status-text" :title="t.success ? '' : t.error || ''">
<template v-if="t.success">完成{{ t.costMs ? ` · ${t.costMs}ms` : "" }}</template>
<template v-else>{{ t.error || "失败" }}</template>
</span>
</span>
<span class="trace__toggle">
{{ expanded[i] ? "收起" : "详情" }}
<svg viewBox="0 0 24 24" width="11" height="11" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round" :class="{ 'trace__chev--up': expanded[i] }">
<path d="M6 9l6 6 6-6" />
</svg>
</span>
</div>
<div v-if="expanded[i]" class="trace__body">
<div v-if="!t.success && t.error" class="trace__row">
<span class="trace__k">原因</span>
<span class="trace__v trace__v--err">{{ t.error }}</span>
</div>
<div class="trace__row">
<span class="trace__k">接口</span>
<code class="trace__code">{{ t.name }}</code>
</div>
<div class="trace__row">
<span class="trace__k">参数</span>
<span class="trace__v">{{ prettyArgs(t.arguments) }}</span>
</div>
</div>
<div v-if="canGo(t)" class="trace__actions">
<button
class="trace__go"
:class="{ 'trace__go--fallback': !t.success }"
type="button"
@click="openLanding(t)"
>
{{ goLabel(t) }}
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h13M12 6l6 6-6 6" />
</svg>
</button>
<span v-if="!t.success" class="trace__go-hint">
服务端拿不到你在业务系统的登录态,用你自己的登录态打开即可看到真实数据
</span>
</div>
</div>
</div>
</template>
<style scoped>
.trace {
display: flex;
flex-direction: column;
gap: 7px;
margin-top: 9px;
}
.trace__item {
border: 1px solid var(--agent-border);
border-left: 3px solid var(--agent-success);
border-radius: 10px;
background: var(--agent-surface);
padding: 9px 12px;
transition: box-shadow 0.16s ease;
}
.trace__item--fail {
border-left-color: var(--agent-danger);
background: #fffbfb;
}
/* ------------------------------ 头部 ------------------------------ */
.trace__head {
display: flex;
align-items: center;
gap: 9px;
cursor: pointer;
font-size: 12.5px;
min-width: 0;
}
.trace__badge {
flex: none;
padding: 2px 7px;
border-radius: 5px;
font-size: 11px;
font-weight: 500;
letter-spacing: 0.3px;
}
.trace__badge--read {
background: var(--agent-primary-soft);
color: var(--agent-primary-dark);
}
.trace__badge--write {
background: #fdf2e2;
color: var(--agent-warn);
}
.trace__title {
flex: none;
color: var(--agent-text);
font-weight: 550;
}
.trace__module {
flex: none;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
color: var(--tone);
background: var(--tone-soft);
}
.trace__spacer {
flex: 1 1 auto;
}
.trace__status {
/* ★ 可收缩:失败原因是自然语言(可能很长),必须让它让步给「详情」按钮,
否则整张卡会被撑破、右侧按钮跑出视野(见 2026-10-10 截图) */
flex: 0 1 auto;
min-width: 0;
max-width: 46%;
display: inline-flex;
align-items: center;
gap: 4px;
color: var(--agent-success);
font-size: 12px;
}
.trace__status > svg {
flex: none;
}
.trace__status-text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.trace__item--fail .trace__status {
color: var(--agent-danger);
}
.trace__toggle {
flex: none;
display: inline-flex;
align-items: center;
gap: 3px;
color: var(--agent-text-mute);
font-size: 12px;
transition: color 0.16s ease;
}
.trace__head:hover .trace__toggle {
color: var(--agent-primary);
}
.trace__chev--up {
transform: rotate(180deg);
}
/* ------------------------------ 展开体 ------------------------------ */
.trace__body {
margin-top: 9px;
padding-top: 9px;
border-top: 1px dashed var(--agent-border);
font-size: 12.5px;
color: var(--agent-text-sub);
}
.trace__row {
display: flex;
gap: 9px;
margin-bottom: 5px;
word-break: break-all;
}
.trace__row:last-child {
margin-bottom: 0;
}
.trace__k {
flex: none;
width: 32px;
color: var(--agent-text-mute);
}
.trace__code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 11.5px;
padding: 1px 6px;
border-radius: 5px;
color: var(--agent-primary-dark);
background: var(--agent-primary-soft);
}
.trace__v {
color: var(--agent-text);
}
.trace__v--err {
color: var(--agent-danger, #d4380d);
}
/* ------------------------------ 动作 ------------------------------ */
.trace__actions {
margin-top: 9px;
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
}
.trace__go {
display: inline-flex;
align-items: center;
gap: 5px;
height: 28px;
padding: 0 12px;
border-radius: 8px;
border: 1px solid var(--agent-primary-line);
background: var(--agent-primary-soft);
color: var(--agent-primary-dark);
font-family: inherit;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.16s ease, border-color 0.16s ease;
}
.trace__go:hover {
background: #dfeaff;
border-color: var(--agent-primary);
}
/* 深链兜底:查询失败时用稍弱的中性色,与"成功后的去确认"区分开 */
.trace__go--fallback {
border-color: var(--agent-border);
background: #fff;
color: var(--agent-text);
}
.trace__go--fallback:hover {
background: #f6f8fb;
border-color: var(--agent-primary);
color: var(--agent-primary-dark);
}
/* 解释"为什么要去系统里看" —— 一句话说清,避免用户以为智能体坏了 */
.trace__go-hint {
flex: 1 1 auto;
min-width: 0;
font-size: 11.5px;
color: var(--agent-text-mute);
line-height: 1.4;
}
</style>
+29
View File
@@ -0,0 +1,29 @@
import { createApp } from "vue";
import App from "./App.vue";
import router from "@/router";
import utils from "@/utils";
import { createPinia } from "pinia";
// @ts-ignore
import ViewUIPlus from "view-ui-plus";
import mittBus from "@/utils/mitt";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/agent.css";
import "virtual:uno.css";
const app = createApp(App);
app.use(createPinia());
app.use(router);
app.use(ViewUIPlus);
import config from "@/assets/config/host.config";
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
app.config.globalProperties.$bus = mittBus;
app.mount("#app");
+23
View File
@@ -0,0 +1,23 @@
import { createRouter, createWebHashHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
/**
* 智能助手路由:单页应用,唯一入口为对话页。
* 用 hash 模式:产物部署在 /{contextPath}/front/ 下,无需服务端 rewrite 规则。
*/
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "chat",
component: () => import("@/views/Chat.vue"),
meta: { title: "智能助手" },
},
{ path: "/:pathMatch(.*)*", redirect: "/" },
];
const router = createRouter({
history: createWebHashHistory(),
routes,
});
export default router;
+140
View File
@@ -0,0 +1,140 @@
/**
* 公共类型
*/
export declare interface IObj {
[key: string]: any;
}
export declare interface PageParams {
page: !number;
count: !number;
[keys: string]: any; //任意属性
}
export declare interface PageData {
list: T;
total: number;
[keys: string]: any; //任意属性
}
interface RelayConf {
ziyuankuHost: string; //资源库
zhuanxiangHost: string; //专项
}
/* 系统配置 */
export interface BaseConf {
baseHost: string;
ssoHost: string;
serviceHost: string;
relayConf: RelayConf;
isDebug?: Boolean;
[keys: string]: any; //任意属性
}
/* PC端角色 */
export interface PcDebugUser {
DebugUser: string;
AppType: string;
// yschapirequest: number;
}
/* webapp端角色 */
export declare interface WappDebugUser {
ticket: string;
schoolId: Number;
userId: Number;
roleId: Number;
}
export declare interface CurrentRole {
childId: number | null;
roleId: number;
schId: number;
userId: number;
}
export interface layoutData {
appName: String;
appList: string[];
modList: Array<string>;
userInfo: Object;
ssoHost: String;
baseHost: String;
isAlone: Boolean; //是否只有一个选项卡
}
export interface BaseDict {
k: any;
v: string | null;
[keys: string]: any; //任意属性
}
/* 计算表格高度的参数类型 */
export interface UseTableSetting {
/** 是否有查询面板 */
isSearch: boolean = true;
/** 是否有分页 */
isPage: boolean = true;
/** 是否是tabs布局 */
isTabs: boolean = false;
/** 是否有按钮操作 */
isOperat: boolean = true;
/** 额外的高度 */
otherH: number = 0;
}
/* 布局组件类型 */
export interface LayoutData {
appName: string | null;
appList: Array<any>;
modList: Array<any>;
userInfo: any;
ssoHost: string | null;
baseHost: string | null;
isAlone: boolean;
}
/* 自定义弹出对话框 */
export interface MyConfirm {
title?: string;
msg: string;
info?: string;
isLoading?: boolean;
confirmText?: string;
closeText?: string;
onOk?: Function;
onCancel?: Function;
}
export declare enum NODE_ENV {
/**
*开发环境
*/
development = "development",
/**
*生产环境
*/
production = "production",
}
export const _: any;
export const $: any;
declare global {
const $: any;
const _: any;
}
import Utils from "@/utils/index";
import mittBus from '@/utils/mitt'
import MyConfirm from "@/components/MyConfirm";
export interface VueGlobal {
$conf: BaseConf;
$bus: typeof mittBus;
$utils: typeof Utils;
$echarts: echarts;
[key:string]:any;
}
+209
View File
@@ -0,0 +1,209 @@
/** 智能助手相关类型契约(与后端 com.yunhai.agent.model 对齐) */
/** 平台用户信息(BoaUser 的子集) */
export interface UserInfoVo {
id?: number;
name?: string;
account?: string;
profilePicture?: string;
deptName?: string;
[key: string]: any;
}
/** 平台已授权模块节点 */
export interface ModuleNode {
id: number;
name: string;
modId: number;
page?: string;
children?: ModuleNode[];
[key: string]: any;
}
/** 补全表单里的一个字段(由后端工具定义推导) */
export interface FormField {
/** 提交时的参数名 */
name: string;
/** 中文标签,如「开始时间」 */
label: string;
/** 控件类型 */
widget: "text" | "textarea" | "number" | "date" | "datetime" | "select";
type?: string;
required: boolean;
/** 字段说明(含义与格式) */
description?: string;
/** 格式示例,用作 placeholder */
example?: string;
defaultValue?: string;
/** 下拉选项 */
options?: { value: string; label: string }[];
/**
* 下拉选项的动态来源(后端字典工具名,如 leave.types)。
* 有值但 options 为空 ⇒ 字典接口没取到,前端提示「可先手填编号」。
*/
optionsFrom?: string;
/**
* 当前 options 是否为内置兜底(而非学校实时字典)。
* 为 true 时说明「选项能用,但可能不含学校自定义的项」,前端给一句轻提示即可,不阻塞填写。
*/
optionsFallback?: boolean;
}
/**
* 信息不全时后端下发的「补全表单」。
* 用户填完直接提交,服务端会直接执行对应工具(不再让模型二次猜测)。
*/
export interface FormRequest {
tool: string;
title: string;
description?: string;
module?: string;
write: boolean;
landingPath?: string;
fields: FormField[];
/** 已从对话中确定的参数(只读回显) */
preset?: Record<string, any>;
/** 已识别参数的可读回显(text 已按字典翻译成中文,如「病假」而非 1) */
recognized?: { name: string; label: string; text: string }[];
/** 缺失字段的中文名 */
missing?: string[];
hint?: string;
}
/** 表单提交体 */
export interface FormSubmit {
tool: string;
args: Record<string, any>;
}
/**
* 「浏览器代执行」挂起载荷(方案 A):模块接口只认浏览器 CAS 会话,
* 服务端把接口描述下发前端,由前端用用户自己的登录态调用后回传结果续跑。
*/
export interface BrowserToolRequest {
calls: BrowserToolCall[];
/** 给用户看的一句话说明 */
notice?: string;
}
/** 一次代执行调用描述(pathAndQuery 相对当前站点根,同站部署 ⇒ 同源) */
export interface BrowserToolCall {
name: string;
title: string;
arguments?: Record<string, any>;
write: boolean;
method: "GET" | "POST_JSON" | "POST_FORM";
pathAndQuery: string;
body?: string;
contentType?: string;
}
/** 代执行结果回传项(与 BrowserToolCall 按 name 对应) */
export interface ToolResultItem {
name: string;
arguments?: Record<string, any>;
/** HTTP 状态码;网络层失败时为空 */
status?: number;
/** 响应体原文 */
body?: string;
/** 网络层错误(失败/超时/跨域) */
error?: string;
}
/** 对话请求 */
export interface AgentChatRequest {
sessionId?: string;
message?: string;
enableTools?: boolean;
/** 非空时:直接执行指定工具(表单提交) */
formSubmit?: FormSubmit;
/** 非空时:浏览器代执行结果回传(续跑) */
toolResult?: ToolResultItem[];
}
/** 一次工具调用轨迹 */
export interface ToolCallTrace {
name: string;
title: string;
/** 业务模块标识(前端据此拼模块页面地址做「去系统里查看」跳转) */
module?: string;
arguments?: Record<string, any>;
success: boolean;
error?: string;
write?: boolean;
/**
* 模块内的落地路由(如 `/my`)。
* 写操作 = 执行成功后去确认;**查询失败**(下游 code=302 需要浏览器登录态)= 深链兜底入口。
*/
landingPath?: string;
costMs?: number;
}
/** 对话响应 */
export interface AgentChatResponse {
sessionId: string;
reply: string;
toolCalls: ToolCallTrace[];
wroteSomething: boolean;
landingPath?: string;
rounds?: number;
promptTokens?: number;
completionTokens?: number;
warning?: string;
/** 需要用户补全信息时返回的表单 */
form?: FormRequest;
/** 非空:本轮工具需要浏览器代执行,前端调完接口把 toolResult 回传续跑 */
browserTool?: BrowserToolRequest;
}
/** 能力目录里的单项能力 */
export interface ToolCatalogItem {
name: string;
title: string;
description: string;
write: boolean;
landingPath?: string;
/** 可执行该能力的身份(中文标签,如「学生、教师」);不限身份时为空 */
roles?: string;
}
/** 能力目录:后端已按「当前角色 + 已授权模块」过滤后返回 */
export interface AgentToolCatalog {
/** 模块标识 → 能力列表;不同身份拿到的模块集合与能力数不同 */
modules: Record<string, ToolCatalogItem[]>;
/** 当前身份可见的能力总数 */
total: number;
/** 当前登录身份的中文描述(由后端按 BoaUser 解析,如「学生」「教师」「访客」) */
roles?: string;
/** 当前身份明细:一个账号可能有多重身份,后端只按「当前身份」生效 */
identity?: {
userId?: string;
userName?: string;
schoolId?: string;
/** 当前身份的角色号(平台 currentRole.roleId) */
roleId?: string;
/** 当前身份名称(平台 currentRole.roleName) */
roleName?: string;
roles?: string;
};
}
/** 聊天界面里的一条消息 */
export interface ChatItem {
id: string;
role: "user" | "assistant";
/** 正文(assistant 回复可能先为空,由 toolCalls 展示过程) */
text: string;
/** 阶段提示("正在调用:提交请假申请") */
stage?: string;
traces?: ToolCallTrace[];
wroteSomething?: boolean;
landingPath?: string;
error?: string;
/** 需要用户补全信息时的表单 */
form?: FormRequest;
/** 表单是否已提交(提交后置为只读回显) */
formSubmitted?: boolean;
/** 消息创建时间(毫秒) */
at: number;
}
+160
View File
@@ -0,0 +1,160 @@
import { createAlova, Method, StatesHook } from "alova";
import fetchAdapter from "alova/fetch";
import VueHook from "alova/vue";
import Utils from "..";
import { isLoggingOut } from "@/utils/logout-guard";
/* ============= 类型定义 ============= */
export interface RequestHooks {
/** 前置票据/通用头注入 */
injectHeaders?: (method: Method) => void;
/** 加载开始 */
onLoadingStart?: () => void;
/** 加载结束 */
onLoadingEnd?: () => void;
/** 业务错误提示(code !== 0)*/
onBizError?: (msg: string) => void;
/** http 错误提示(status !== 200)*/
onHttpError?: (msg: string) => void;
}
/** 和后端约定的通用响应格式 */
export interface BschResponse<T = any> {
code: number;
msg?: string;
data: T;
urlToRedirectTo?: string;
}
/* ============= 默认空实现 ============= */
const defHooks: Required<RequestHooks> = {
injectHeaders: () => {},
onLoadingStart: () => {},
onLoadingEnd: () => {},
onBizError: () => {},
onHttpError: () => {},
};
/* ============= 错误处理(原 axios 逻辑平移) ============= */
function errorHandle<T>(res: BschResponse<T>, status: number): T {
// http 层错误
if (status !== 200) {
let msg = "网络错误";
switch (status) {
case 401:
case 403:
break;
case 404:
msg = "请求的资源不存在!";
break;
default:
msg = res?.msg || "内部服务错误!";
}
throw new Error(msg);
}
// 业务层错误
switch (res.code) {
case 0:
return res.data ?? (null as T);
case 302:
console.log(res);
// 登出流程中:当前会话正是我们要清掉的那个,302 是预期结果而非「掉登录」,
// 此时自动跳登录页会被 CAS 用还没销毁的 TGT 静默登回原账号
// (用户看到「点了退出又回来了」),所以交给登出流程自己导航。
if (isLoggingOut()) {
return new Promise(() => {}) as T;
}
// 后端 CasSsoApiRedirectFilter 已在 service 中拼好 ;redirectToReferer=<hex>,
// 直接跳转即可;登录后由 CasSsoRefererRedirectFilter 解码 302 回前端页。
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,
// 全局 onBizError 与各页面的 catch 均不再触发(不弹 redirect 报错)
window.location.href = res.urlToRedirectTo!;
return new Promise(() => {}) as T;
case 4:
throw new Error(res.msg || "参数错误!");
default:
throw new Error(res.msg || "业务错误");
}
}
/* ============= 工厂函数 ============= */
export function createBschAlova(
baseURL: string,
hooks: RequestHooks = {},
statesHook: StatesHook<any> = VueHook,
) {
const { injectHeaders, onLoadingStart, onLoadingEnd, onBizError, onHttpError } = {
...defHooks,
...hooks,
};
let reqCount = 0;
let timer: ReturnType<typeof setTimeout>;
/* ---- 请求拦截 ---- */
function beforeRequest(method: Method) {
reqCount++;
if (reqCount === 1) timer = setTimeout(() => onLoadingStart(), 800);
// 1. 用户注入票据/角色等头
injectHeaders(method);
method.config.credentials = "include";
// 2. 通用头
method.config.headers = Object.assign(method.config.headers || {}, {
yschapirequest: "1",
timeout: 20000,
yschreferer: Utils.strToHexCharCode(window.location.href),
});
}
/* ---- 响应拦截 ---- */
async function onSuccess(response: Response) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
const res: BschResponse = await response.json();
try {
return errorHandle(res, response.status);
} catch (e: any) {
console.log(e);
onBizError(e.message);
return Promise.reject(e);
}
}
async function onError(err: any) {
// 只减不清零:清零会让其余在途请求把计数减成负数,全局 loading 永不结束
reqCount = Math.max(0, reqCount - 1);
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
const msg = err?.message || "网络请求出错";
onHttpError(msg);
return Promise.reject(err);
}
async function onComplete(method: Method) {
if (method.fromCache) {
reqCount--;
if (reqCount === 0) {
clearTimeout(timer);
onLoadingEnd();
}
}
}
return createAlova({
statesHook,
requestAdapter: fetchAdapter(),
baseURL,
cacheFor: null,
beforeRequest,
responded: { onSuccess, onError, onComplete },
});
}
+40
View File
@@ -0,0 +1,40 @@
// @/utils/alova-iview.ts
import { createBschAlova } from "./alova-core";
import { Message } from "view-ui-plus";
import conf from "@/assets/config/host.config";
/* 全局 loading 遮罩实例 */
let spinInstance: any = null;
/* 主动关闭 loading */
function closeLoading() {
if (spinInstance) {
Message.destroy();
spinInstance = null;
}
}
const baseUrl = conf.serviceHost;
/* 创建带 View Design UI 的 Alova 实例 */
export const alovaInstance = createBschAlova(baseUrl, {
injectHeaders(method) {
// currentRole: encodeURI(JSON.stringify({ userId, roleId, schId: schoolId })),
if (localStorage.getItem("currentRole") != null) {
method.config.headers["currentRole"] = localStorage.getItem("currentRole") as string;
}
},
/* 2. 加载动画 – View Design Spin */
onLoadingStart() {
spinInstance = Message.loading("加载中...");
},
onLoadingEnd: closeLoading,
onBizError(msg: string) {
if (!!msg) Message.error(msg);
},
onHttpError(msg: string) {
Message.error({ title: "网络错误", content: msg });
},
});
+66
View File
@@ -0,0 +1,66 @@
/**
* 通用工具方法(按本工程实际需要裁剪,保持与平台其它前端工程同形的静态方法签名)。
*/
export default class Utils {
/** 格式化日期 */
public static dateFormat(date: Date | string, fmt: string): string {
if (typeof date === "string") date = date.replace(/\-/g, "/");
const dt = new Date(date);
const o: Record<string, number> = {
"M+": dt.getMonth() + 1,
"d+": dt.getDate(),
"H+": dt.getHours(),
"m+": dt.getMinutes(),
"s+": dt.getSeconds(),
"q+": Math.floor((dt.getMonth() + 3) / 3),
S: dt.getMilliseconds(),
};
if (/(y+)/.test(fmt)) fmt = fmt.replace(RegExp.$1, (dt.getFullYear() + "").substr(4 - RegExp.$1.length));
for (const k in o) {
if (new RegExp("(" + k + ")").test(fmt)) {
fmt = fmt.replace(RegExp.$1, RegExp.$1.length === 1 ? String(o[k]) : ("00" + o[k]).substr(("" + o[k]).length));
}
}
return fmt;
}
/** 生成 uuid */
public static uuid(): string {
return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, function (c) {
const r = (Math.random() * 16) | 0;
const v = c === "x" ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* 字符串 → 16 进制表达。
* 平台后端 SSO 过滤器会读 yschreferer 头(当前页面地址的 hex),用于登录后回跳原页面。
*/
public static strToHexCharCode(_str: string): string {
if (_str === "") return "";
const hexCharCode: string[] = [];
for (let i = 0; i < _str.length; i++) {
const str = _str.charCodeAt(i).toString(16);
hexCharCode.push(str);
}
return hexCharCode.join("");
}
/** 读取当前角色(localStorage.currentRole 由平台外壳写入) */
public static getCurrentRoleInfo<T>(): T | null {
const currentRole = localStorage.getItem("currentRole");
if (!currentRole) return null;
try {
const { schId, roleId, childId, userId } = JSON.parse(decodeURI(currentRole ?? ""));
return { schId, roleId, childId, userId } as unknown as T;
} catch {
return null;
}
}
/** 简易 deep clone(够用即可) */
public static clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* 登出闸门:抑制登出过程中的「未登录自动跳登录页」。
*
* <p>为什么需要它 —— 登出必须按「先清服务端会话,再跳 CAS 全局登出」的顺序来:
* <ol>
* <li>清本地会话(POST /api/base/Logout)会让当前 JSESSIONID 失效;</li>
* <li>紧接着整页跳到 CAS 的 {@code /logout} 销毁 SSO 票据。</li>
* </ol>
* 这两步之间只要还有一个在途请求拿回 {@code code=302},
* `utils/alova/alova-core.ts` 的全局错误处理就会把浏览器拽去 CAS 登录页;
* 而此刻 CAS 的 TGT 还没销毁,会被**静默重新登录回原账号** ——
* 用户看到的就是「点了退出,页面一闪又回来了」。
*
* <p>置位后 302 不再自动跳转(登出流程自己负责导航)。登出只有两个终局:
* 整页跳到 CAS 的 {@code /logout},或(未配置 SSO 地址时)重载页面;
* 两者都会换掉文档,这个标记随页面一起消失,不需要在应用内复位。
*/
let loggingOut = false;
/** 进入登出流程(要在发起「清会话」请求之前调用) */
export function beginLogoutGuard(): void {
loggingOut = true;
}
/** 当前是否处于登出流程中 */
export function isLoggingOut(): boolean {
return loggingOut;
}
+15
View File
@@ -0,0 +1,15 @@
// import { BschIframeType, LightAppModal, IframePageParmas, RoleInfo, StateEnum } from "@/types";
import mitt, { Emitter } from "mitt";
type MittType<T = any> = {
reload: void; //刷新页面
setMenuPath: number; //选择左侧菜单
resetMenuPath: any[]; //重置设置左侧菜单
setNavPath: any[]; //设置面包屑
resetLayout: { identity: number; modId: number }; //重置页面菜单
};
// 类型
const emitter: Emitter<MittType> = mitt<MittType>();
// 导出
export default emitter;
+513
View File
@@ -0,0 +1,513 @@
<script setup lang="ts">
/**
* 智能助手对话页。
*
* 交互要点:
* - 用户发一句话 → 后端编排(大模型决策 + 调用业务接口)→ 返回回复 + 工具执行轨迹
* - 信息不全时后端下发「补全表单」,这里渲染成可填卡片,用户填完直接提交执行(不再反复追问)
* - 等待期间显示阶段提示(理解需求 → 调用接口)
* - 助手消息下挂「工具轨迹卡」,写操作后提供「去查看」直达对应模块页面
* - 输入框轮播示例,并按关键词提示「怎么说更准确」
* - 能力目录由外壳统一拉取后传入,侧栏与首屏共用一份数据
*/
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { agentApi } from "@/api/module/agent";
import QuickActions from "@/components/QuickActions.vue";
import MessageBubble from "@/components/MessageBubble.vue";
import type { AgentToolCatalog, BrowserToolCall, ChatItem, ToolResultItem } from "@/types/agent";
const props = defineProps<{ user?: any; catalog?: AgentToolCatalog | null }>();
const items = ref<ChatItem[]>([]);
const input = ref("");
const sending = ref(false);
const stage = ref("");
const sessionId = ref<string | undefined>(undefined);
const listEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
/** 等待期间的阶段轮播(后端返回前给用户进度感) */
let stageTimer: ReturnType<typeof setInterval> | null = null;
const STAGES = ["正在理解你的需求…", "正在判断需要调用哪些功能…", "正在调用系统接口…", "正在整理结果…"];
/**
* 输入示例轮播:直接给出"可以怎么说"的成品句,比抽象描述更好用。
*
* 每条标注它依赖的「模块 + 工具」,用于按当前身份裁剪:
* 学生身份下不会轮播「发通知」「专业发展积分」这类教师专属句子 ——
* 否则等于给用户一个"照着说必被拒"的示范。
* 判据取自能力目录本身(模块是否下发、工具名是否存在),前端无需知道角色规则。
*/
const EXAMPLES: { text: string; module: string; tool?: string }[] = [
{ text: "我要请 10月12日 上午的病假,发烧就医", module: "homeleave", tool: "leave.submit" },
{ text: "我的请假记录有几次", module: "homeleave", tool: "leave.myLeaves" },
{ text: "发一条关于社团招新的通知", module: "campusnotice", tool: "notice.publish" },
{ text: "这周有什么新的校园公告?", module: "campusnotice", tool: "notice.received" },
{ text: "提交数学作业", module: "coursehomework", tool: "homework.submit" },
{ text: "看看我还有哪些作业没做", module: "coursehomework", tool: "homework.myList" },
{ text: "宿舍有什么通知需要我回执吗?", module: "dormnotice", tool: "dorm.myNotice" },
{ text: "报名秋季亲子运动会,2 人参加", module: "familyactivity", tool: "activity.signup" },
{ text: "帮我找一份语文课件", module: "resourcelibrary", tool: "resource.search" },
{ text: "我的专业发展积分是多少?", module: "profession", tool: "profession.myPoints" },
];
/** 当前身份下适合轮播的示例句;能力目录未就绪时先给全量,避免输入框提示空着 */
const examples = computed(() => {
const mods = props.catalog?.modules;
if (!mods) return EXAMPLES.map(e => e.text);
return EXAMPLES.filter(e => {
const list = mods[e.module] || [];
return list.length > 0 && (!e.tool || list.some(t => t.name === e.tool));
}).map(e => e.text);
});
const exampleIndex = ref(0);
let exampleTimer: ReturnType<typeof setInterval> | null = null;
const placeholder = computed(() => {
const list = examples.value;
if (!list.length) return "说说你想办什么…";
return `试试说:${list[exampleIndex.value % list.length]}`;
});
/** 边说边提示:命中关键词时告诉用户"这样说更准" */
const TIPS: { key: RegExp; text: string }[] = [
{ key: /请假|病假|事假/, text: "说清「类型 + 起止时间 + 事由」一次就能提交,例:10月12日上午病假,发烧就医" },
{ key: /公告|通知(发布|发布公告)/, text: "发布公告需要「标题 + 正文 + 发文部门」,例:发一条关于运动会的通知,正文…" },
{ key: /作业|作答|提交作/, text: "可以说「我有哪些作业没做」,或「提交数学作业」,我会列出作业让你选" },
{ key: /宿舍|回执/, text: "例:宿舍有什么通知需要我回执吗?我会列出通知并给出回执表单" },
{ key: /报名|活动/, text: "例:报名秋季亲子运动会,2 人参加,联系人张伟 13800000000" },
{ key: /审批|通过|驳回/, text: "例:把 101 号请假审批通过,意见「同意」" },
{ key: /资源|课件|教案|资料|试题/, text: "例:帮我找一份语文课件;想留存就说「收藏这份课件」" },
{ key: /积分|专业发展|记录|成果/, text: "查积分说「我的专业发展积分是多少」;要留痕说「发布一条听课记录」" },
];
const activeTip = computed(() =>
input.value.trim() ? TIPS.find(t => t.key.test(input.value.trim()))?.text || "" : ""
);
const isEmpty = computed(() => items.value.length === 0);
onMounted(() => {
exampleTimer = setInterval(() => {
/* 用当前身份可见的例句数取模:目录到达后例句会变少,索引必须跟着收敛 */
exampleIndex.value = (exampleIndex.value + 1) % Math.max(1, examples.value.length);
}, 4200);
});
onUnmounted(() => {
stopStage();
if (exampleTimer) clearInterval(exampleTimer);
});
function scrollToBottom() {
nextTick(() => {
const el = listEl.value;
if (el) el.scrollTop = el.scrollHeight;
});
}
/** 输入框随内容长高(最多 148px,再长就内部滚动) */
function autoGrow() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
}
watch(input, () => nextTick(autoGrow));
function startStage() {
let i = 0;
stage.value = STAGES[0];
stageTimer = setInterval(() => {
i = Math.min(i + 1, STAGES.length - 1);
stage.value = STAGES[i];
const last = items.value[items.value.length - 1];
if (last && last.role === "assistant") last.stage = stage.value;
}, 1600);
}
function stopStage() {
if (stageTimer) {
clearInterval(stageTimer);
stageTimer = null;
}
}
/** 新建一条等待中的助手消息 */
function pushPending(): ChatItem {
const placeholder: ChatItem = {
id: `a-${Date.now()}`,
role: "assistant",
text: "",
stage: STAGES[0],
traces: [],
at: Date.now(),
};
items.value.push(placeholder);
sending.value = true;
startStage();
scrollToBottom();
return placeholder;
}
/** 把后端响应灌进占位消息 */
function fill(placeholder: ChatItem, resp: any) {
sessionId.value = resp?.sessionId || sessionId.value;
placeholder.text = resp?.reply || "";
placeholder.traces = resp?.toolCalls || [];
placeholder.wroteSomething = !!resp?.wroteSomething;
placeholder.landingPath = resp?.landingPath;
if (resp?.form) {
placeholder.form = resp.form;
}
if (resp?.warning && !resp?.reply) {
placeholder.error = resp.warning;
}
}
/**
* 浏览器代执行(方案 A):模块接口只认浏览器 CAS 会话,服务端代调必然 302。
* 后端挂起后把接口描述下发到这里,用「用户自己的登录态」调完模块接口再把结果回传续跑。
*
* 前提:本应用与模块同站部署(同源 Cookie 自动携带)。本地 dev(localhost:8081)
* 跨域取不到 base 域会话,fetch 会失败或 404 → 结果回传后由服务端判为失败,
* 自动落回「去系统里查看」的深链兜底,UI 不会卡死。
*/
async function execBrowserCall(call: BrowserToolCall): Promise<ToolResultItem> {
const base = { name: call.name, arguments: call.arguments };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
const headers: Record<string, string> = { yschapirequest: "1" };
let body: string | undefined;
if (call.method !== "GET") {
headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8";
body = call.body;
}
const res = await fetch(`${location.origin}${call.pathAndQuery}`, {
method: call.method === "GET" ? "GET" : "POST",
headers,
body,
credentials: "same-origin",
signal: ctrl.signal,
});
return { ...base, status: res.status, body: await res.text() };
} catch (e: any) {
const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败";
return { ...base, error: reason };
} finally {
clearTimeout(timer);
}
}
/** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */
async function runBrowserTool(placeholder: ChatItem, pending: any) {
// ★ 先把挂起响应里的 sessionId 落地:续跑请求要用它找回同一会话,
// 否则服务端会当成没有历史的新会话(模型拿不到上文的 tool_calls)。
sessionId.value = pending?.sessionId || sessionId.value;
const calls: BrowserToolCall[] = pending?.browserTool?.calls || [];
placeholder.text = pending?.reply || pending?.browserTool?.notice || "正在用你的登录态查询业务模块…";
const results: ToolResultItem[] = [];
for (const call of calls) {
placeholder.stage = `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
results.push(await execBrowserCall(call));
}
placeholder.stage = "正在整理结果…";
const resp: any = await agentApi.submitToolResults(sessionId.value, results).send();
fill(placeholder, resp);
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
}
}
async function send(text?: string) {
const content = (text ?? input.value).trim();
if (!content || sending.value) return;
input.value = "";
items.value.push({ id: `u-${Date.now()}`, role: "user", text: content, at: Date.now() });
const placeholder = pushPending();
try {
const resp: any = await agentApi.chat({ sessionId: sessionId.value, message: content }).send();
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
} else {
fill(placeholder, resp);
}
} catch (e: any) {
if (e?.message === "redirect") return;
placeholder.error = e?.message || "请求失败,请稍后重试";
} finally {
stopStage();
placeholder.stage = "";
sending.value = false;
scrollToBottom();
}
}
/** 表单提交:直接执行对应工具,不再让模型二次判断 */
async function onFormSubmit(payload: { item: ChatItem; form: { tool: string; args: Record<string, any> } }) {
if (sending.value) return;
const { item, form } = payload;
item.formSubmitted = true; // 卡片立即转为只读回显
const placeholder = pushPending();
try {
const resp: any = await agentApi.submitForm(sessionId.value, form).send();
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
} else {
fill(placeholder, resp);
}
} catch (e: any) {
if (e?.message === "redirect") return;
placeholder.error = e?.message || "提交失败,请稍后重试";
item.formSubmitted = false; // 失败则允许用户改完再提交
} finally {
stopStage();
placeholder.stage = "";
sending.value = false;
scrollToBottom();
}
}
function onKeydown(e: KeyboardEvent) {
// Enter 发送;Shift+Enter 换行
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}
async function newSession() {
try {
await agentApi.clearSession(sessionId.value).send();
} catch {
/* 忽略:清理失败不影响本地重置 */
}
sessionId.value = undefined;
items.value = [];
input.value = "";
Message.success("已开启新对话");
}
/** 供侧栏调用:把模块示例句直接发出去 */
function pickSample(text: string) {
send(text);
}
defineExpose({ newSession, pickSample });
</script>
<template>
<div class="chat">
<div ref="listEl" class="chat__list agent-scroll">
<QuickActions v-if="isEmpty" :catalog="catalog ?? null" @pick="t => send(t)" />
<div v-else class="chat__stream">
<MessageBubble
v-for="(it, i) in items"
:key="it.id"
:item="it"
:pending="sending && i === items.length - 1 && !it.text"
@form-submit="onFormSubmit"
/>
</div>
</div>
<div class="chat__composer">
<div class="chat__box" :class="{ 'chat__box--busy': sending }">
<textarea
ref="inputEl"
v-model="input"
class="chat__input"
rows="1"
:disabled="sending"
:placeholder="placeholder"
@keydown="onKeydown"
/>
<div class="chat__actions">
<button class="chat__ghost" type="button" :disabled="sending" title="清空当前会话" @click="newSession">
新会话
</button>
<button
class="chat__send"
type="button"
:disabled="!input.trim() || sending"
@click="send()"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12l16-8-6 8 6 8-16-8z" />
</svg>
{{ sending ? "处理中" : "发送" }}
</button>
</div>
</div>
<p class="chat__hint" :class="{ 'chat__hint--tip': !!activeTip }">
<svg v-if="activeTip" class="chat__hint-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 3a6 6 0 00-3.5 10.9V17h7v-3.1A6 6 0 0012 3z" />
<path d="M9.5 20.5h5" />
</svg>
<template v-if="activeTip">{{ activeTip }}</template>
<template v-else>Enter 发送 · Shift + Enter 换行 · 写操作请确认信息无误后再提交</template>
</p>
</div>
</div>
</template>
<style scoped>
.chat {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat__list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-bottom: 8px;
}
.chat__stream {
max-width: var(--agent-readw);
margin: 0 auto;
padding: 26px 28px 8px;
}
/* ------------------------------ 输入区 ------------------------------ */
.chat__composer {
flex: none;
padding: 12px 28px 16px;
background: linear-gradient(to top, var(--agent-bg) 58%, rgba(244, 246, 251, 0));
}
.chat__box {
max-width: var(--agent-readw);
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 12px;
padding: 10px 12px 10px 16px;
border: 1px solid var(--agent-border-strong);
border-radius: 18px;
background: var(--agent-surface);
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.04), 0 18px 34px -20px rgba(15, 23, 42, 0.32);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.chat__box:focus-within {
border-color: var(--agent-primary);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1), 0 18px 34px -20px rgba(37, 99, 235, 0.4);
}
.chat__box--busy {
opacity: 0.85;
}
.chat__input {
flex: 1 1 auto;
min-width: 0;
border: none;
outline: none;
resize: none;
background: transparent;
font-family: inherit;
font-size: 14px;
line-height: 1.65;
color: var(--agent-text);
min-height: 26px;
max-height: 148px;
padding: 6px 0;
}
.chat__input::placeholder {
color: var(--agent-text-mute);
}
.chat__actions {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 1px;
}
.chat__ghost {
height: 36px;
padding: 0 12px;
border: none;
border-radius: 11px;
background: transparent;
color: var(--agent-text-sub);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
}
.chat__ghost:hover:not(:disabled) {
background: var(--agent-surface-2);
color: var(--agent-text);
}
.chat__ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat__send {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 18px;
border: none;
border-radius: 11px;
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.3px;
cursor: pointer;
box-shadow: 0 10px 20px -10px rgba(37, 99, 235, 0.9);
transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
}
.chat__send:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 24px -10px rgba(37, 99, 235, 0.95);
}
.chat__send:disabled {
background: #cbd5e1;
box-shadow: none;
cursor: not-allowed;
}
.chat__hint {
max-width: var(--agent-readw);
margin: 9px auto 0;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 12px;
line-height: 1.6;
color: var(--agent-text-mute);
transition: color 0.18s ease;
}
.chat__hint--tip {
color: var(--agent-primary-dark);
font-weight: 500;
}
.chat__hint-icon {
flex: none;
}
</style>
+31
View File
@@ -0,0 +1,31 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Bundler mode */
"moduleResolution": "node",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"types": ["vite/client"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","types"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+11
View File
@@ -0,0 +1,11 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "node",
"allowSyntheticDefaultImports": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+126
View File
@@ -0,0 +1,126 @@
import { UserConfig, defineConfig } from "vite";
import { createHtmlPlugin } from "vite-plugin-html";
import vue from "@vitejs/plugin-vue";
import path, { resolve } from "path";
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";
import ViteAutoImport from "unplugin-auto-import/vite";
import UnoCSS from "@unocss/vite";
import presetUno from "@unocss/preset-uno";
import presetRemToPx from "@unocss/preset-rem-to-px";
export default defineConfig((): UserConfig => {
// 构建产物直出 dist/ 根(CI 打 zip 取 dist 根,projman 校验要求 zip 根直接是 index.html)
const outDir = "dist";
return {
plugins: [
vue(),
createSvgIconsPlugin({
iconDirs: [resolve(process.cwd(), "src/assets/icons")],
symbolId: "icon-[dir]-[name]",
inject: "body-last",
customDomId: "__svg__icons__dom__",
}),
ViteAutoImport({
imports: [
"vue",
"vue-router",
{
"view-ui-plus": ["Button", "Input", "Message", "Avatar", "Tag", "Tooltip"],
},
{
from: "alova/client",
imports: ["useRequest", "useWatcher", "useForm"],
},
],
dts: "./src/auto-imports.d.ts",
dirs: ["src/api/module"],
}),
createHtmlPlugin({
minify: true,
pages: [
{
entry: "src/main.ts",
template: "index.html",
filename: "index.html",
injectOptions: {
data: {
title: "智能助手",
injectScript: ``,
},
},
},
],
}),
UnoCSS({
presets: [
presetUno(),
presetRemToPx({ baseFontSize: 4 }),
],
shortcuts: {
"flex-center": "flex items-center justify-center",
card: "bg-white rounded-8px shadow-md p-16px",
"text-ellipsis": "truncate",
},
theme: {
fontSize: {
xs: "12px",
sm: "14px",
base: "16px",
lg: "18px",
xl: "20px",
"2xl": "24px",
},
},
}),
],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: "localhost",
port: 8080,
proxy: {
// 同源代理:serviceHost 留空时前端走 /api/...,由这里转发到本地 AgentService(8093),
// 规避后端 CAS 过滤器对 /api/* 不回 CORS 头导致的浏览器直连跨域拦截。
// 如需连测试环境:target 换成 http://base.yunhaixingtong.com/agent
"/api": {
target: "http://localhost:8093",
changeOrigin: true,
},
},
},
base: "./",
build: {
target: "modules",
outDir,
assetsDir: "assets",
sourcemap: false,
minify: "esbuild",
chunkSizeWarningLimit: 1000,
cssTarget: "chrome61",
rollupOptions: {
input: {
main: resolve("index.html"),
},
output: {
chunkFileNames: "static/js/[name]-[hash].js",
entryFileNames: "static/js/[name]-[hash].js",
assetFileNames: (assetInfo: any) => {
if (assetInfo.type === "asset" && /\.(jpe?g|png|gif|svg)$/i.test(assetInfo.name)) {
return "static/img/[name].[hash][ext]";
}
if (assetInfo.type === "asset" && /\.(ttf|woff|woff2|eot)$/i.test(assetInfo.name)) {
return "static/fonts/[name].[hash][ext]";
}
return "static/[ext]/[name]-[hash].[ext]";
},
},
},
},
};
});