@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +1,323 @@
|
||||
# yunhai-agent-front
|
||||
# AgentFront · 校园智能助手(前端)
|
||||
|
||||
managed by projman
|
||||
> 一句话说需求,助手直接替你操作 —— 请假、报名、发公告、查作业、找资源…
|
||||
> 信息说不全也没关系:助手会给你一张**填写表单**,填完直接提交执行。
|
||||
|
||||
与平台其它前端同族:**Vue3 + Vite + alova + view-ui-plus**,同源 `/api` 代理、运行时配置下发、SSO 登录态。
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

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