feat(leave): 裸意图直达表单 + 按身份分离请假表单 + 前端代取下拉
build / build (push) Successful in 1m52s

- 说「我要请假」这类只有意图的短句时,直接弹出表单不再过模型:
  后端 IntentShortcut 在编排器入口做「去空白标点 + 剥前后缀后正好等于标准说法
  且原句 ≤12 字」的窄匹配,命中即跳过 LLM 直接下发表单。
- 按身份分离:学生 → leave.submit(提交申请),教师/管理员 → leave.register
  (帮学生登记,必填 classId+stuId);RolePolicy 把两者分别归入 STUDENT_ONLY
  与 STAFF_ONLY,模型视野里天然只有一个,不可能选错。
- 表单字段由各自必填参数推导;下拉选项改为后端下发 optionCall(含 {{占位符}}),
  由前端带用户登录态代取后按 valueKey/labelKey 映射(模块 /api/* 服务端必然 302)。
  stuId 依赖 classId,选完班级才解锁学生名单。
- 抽出 utils/module-session.ts(会话预热 / 带登录态调模块 / 字典项解析),
  Chat.vue 与 FormCard.vue 复用。
- 例句与提示改为「我要请假」「帮学生登记请假」。
This commit is contained in:
lijing
2026-10-12 17:49:34 +08:00
parent 854550c019
commit abd97adf9a
4 changed files with 343 additions and 112 deletions
+7 -103
View File
@@ -15,6 +15,7 @@ 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 { callModuleApi } from "@/utils/module-session";
import type { AgentToolCatalog, BrowserToolCall, ChatItem, ToolResultItem } from "@/types/agent";
const props = defineProps<{ user?: any; catalog?: AgentToolCatalog | null }>();
@@ -40,7 +41,9 @@ const STAGES = ["正在理解你的需求…", "正在判断需要调用哪些
* 判据取自能力目录本身(模块是否下发、工具名是否存在),前端无需知道角色规则。
*/
const EXAMPLES: { text: string; module: string; tool?: string }[] = [
{ text: "我要请 10月12日 上午的病假,发烧就医", module: "homeleave", tool: "leave.submit" },
// 学生:说"我要请假"直接弹出请假申请表单;教师:弹出"帮学生登记请假"表单(含选班级/学生)
{ text: "我要请假", module: "homeleave", tool: "leave.submit" },
{ text: "帮学生登记请假", module: "homeleave", tool: "leave.register" },
{ text: "我的请假记录有几次", module: "homeleave", tool: "leave.myLeaves" },
{ text: "发一条关于社团招新的通知", module: "campusnotice", tool: "notice.publish" },
{ text: "这周有什么新的校园公告?", module: "campusnotice", tool: "notice.received" },
@@ -71,7 +74,7 @@ const placeholder = computed(() => {
/** 边说边提示:命中关键词时告诉用户"这样说更准" */
const TIPS: { key: RegExp; text: string }[] = [
{ key: /请假|病假|事假/, text: "说清「类型 + 起止时间 + 事由」一次就能提交,例:10月12日上午病假,发烧就医" },
{ key: /请假|病假|事假/, text: "直接说「我要请假」就会弹出请假表单;也可以一次说全:10月12日上午病假,发烧就医" },
{ key: /公告|通知(发布|发布公告)/, text: "发布公告需要「标题 + 正文 + 发文部门」,例:发一条关于运动会的通知,正文…" },
{ key: /作业|作答|提交作/, text: "可以说「我有哪些作业没做」,或「提交数学作业」,我会列出作业让你选" },
{ key: /宿舍|回执/, text: "例:宿舍有什么通知需要我回执吗?我会列出通知并给出回执表单" },
@@ -163,71 +166,6 @@ function fill(placeholder: ChatItem, resp: any) {
}
}
/**
* 判断模块响应是否为「未登录」信号。
*
* <p>平台 CAS 过滤器对 AJAX 不真的 302,而是返回 <b>HTTP 200 + `{"code":302,urlToRedirectTo}`</b>
* (带 `yschapirequest: 1` 时的约定)。body 以 `<` 开头是「落到网页」的老问题,另有守卫处理。
*/
function isLoginRedirect(status: number, body?: string): boolean {
if (status < 200 || status >= 300 || !body || !body.includes("302")) return false;
try {
const j = JSON.parse(body);
return j != null && String(j.code) === "302" && !!j.urlToRedirectTo;
} catch {
return false;
}
}
/** 每个模块最近一次「会话预热」的时间戳(避免失败后每条查询都白等一轮) */
const warmTriedAt = new Map<string, number>();
const WARM_RETRY_MS = 60_000;
/**
* 模块会话预热:用一次<b>真实导航</b>把业务模块的浏览器 CAS 会话建起来。
*
* <p>为什么必须这么做:模块接口只认<b>模块自己容器里的</b> CAS 会话(`_const_cas_assertion_`)。
* 同源部署只保证 Cookie 送得到,送不到「还没建出来的会话」。而模块 SPA 建会话靠的是
* `window.location.href = urlToRedirectTo` 整页跳 SSO 签票回跳 —— fetch 永远拿不到这一步
* (平台对 AJAX 只回 JSON `code=302`,不真跳转),于是每次查询都必然 302。
*
* <p>做法:同源隐藏 iframe 导航到刚失败的那个接口地址(<b>不带</b> `yschapirequest`,
* 平台即回真 302 → SSO 见已有 TGT 直接签票回跳 → 模块校验票据后落会话),随后重试原请求。
* 整个过程用户无感;只在首次查询某模块时发生一次。
*
* @param pathAndQuery 刚失败的接口相对路径(`/campusnotice/api/...`),首段即模块上下文
* @param onStart 确认要预热时回调(用于更新界面文案)
* @returns 是否完成了这轮预热尝试(false = 60s 内已试过,或拿不到模块段)
*/
function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs = 6000): Promise<boolean> {
const ctx = pathAndQuery.split("/").filter(Boolean)[0] || "";
if (!ctx) return Promise.resolve(false);
const now = Date.now();
if (now - (warmTriedAt.get(ctx) || 0) < WARM_RETRY_MS) return Promise.resolve(false);
warmTriedAt.set(ctx, now);
onStart?.();
const url = `${location.origin}${pathAndQuery}`;
return new Promise<boolean>(resolve => {
const frame = document.createElement("iframe");
frame.setAttribute("aria-hidden", "true");
frame.style.cssText = "position:fixed;left:-9999px;top:0;width:1px;height:1px;border:0;";
let settled = false;
const done = (ok: boolean) => {
if (settled) return;
settled = true;
window.clearTimeout(cap);
window.setTimeout(() => frame.remove(), 0);
resolve(ok);
};
const cap = window.setTimeout(() => done(false), timeoutMs);
// 导航链(模块 302 → SSO 签票 → 回跳模块)走完才会触发 load,再留一点沉降时间
frame.onload = () => window.setTimeout(() => done(true), 700);
frame.src = url;
document.body.appendChild(frame);
});
}
/**
* 浏览器代执行(方案 A):模块接口只认浏览器 CAS 会话,服务端代调必然 302。
* 后端挂起后把接口描述下发到这里,用「用户自己的登录态」调完模块接口再把结果回传续跑。
@@ -238,43 +176,9 @@ function warmModuleSession(pathAndQuery: string, onStart?: () => void, timeoutMs
*
* 首次遇到 `code=302`(该浏览器还没建过这个模块的会话)会先预热一次再重试;
* 会话建好后同一浏览器后续查询直接命中,无额外开销。
* 实现细节见 `@/utils/module-session`(表单下拉的「代取选项」共用同一套逻辑)。
*/
async function execBrowserCall(call: BrowserToolCall, onWarm?: () => void): Promise<ToolResultItem> {
const base = { name: call.name, arguments: call.arguments };
const send = async () => {
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 { status: res.status, body: await res.text() };
} finally {
clearTimeout(timer);
}
};
try {
let r = await send();
if (isLoginRedirect(r.status, r.body)) {
const warmed = await warmModuleSession(call.pathAndQuery, onWarm);
if (warmed) r = await send();
}
return { ...base, status: r.status, body: r.body };
} catch (e: any) {
const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败";
return { ...base, error: reason };
}
}
const execBrowserCall = callModuleApi;
/** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */
async function runBrowserTool(placeholder: ChatItem, pending: any) {