Files
yunhai-agent-front/src/views/Chat.vue
T
lijing 97cd25b0e0
build / build (push) Successful in 1m58s
feat(form): 表单卡支持审批流/部门名镜像/附件对象数组,并落地两步提交
配合后端把 5 个模块「新建/发布」页的字段完整搬进助手表单:

- FormCard 新增审批流控件(widget=flow):勾「走审批流程」后按步配置,
  每步可选或签/会签并多选审批人(来自教师组织树),提交时拼
  flow:[{name,vote,approverTeaIds}];发布方式选了「提交审批」却没配审批人则当场拦下
- 镜像字段(deptName)不渲染控件,提交时按源字段选中项反查显示名一起带上,
  与模块页「字典 id + 反查名称」的提交口径一致
- 附件支持对象数组形态(作业 attachments:[{fileId,fileUrl,fileType,fileName,sort}]);
  数组类字段在为空时也照常提交(模块页就是恒带 fileIds:"" / attachments:[])
- module-session:callModuleApi 支持 followUp —— 首步业务成功后用返回的资源 id 续跑第二步,
  并带上「第一步成功但没拿回编号」的如实报错
- types:BrowserToolCall 增加 followUp;widget 联合类型补 scopes / flow

验证:npm run build 通过;headless Chrome 真机跑「表单 → 浏览器代执行 → 模块接口」,
公告(镜像 + 审批流 + pushScopes)/ 作业(Create→Publish、Create→Schedule)/
宿舍(镜像 + 全部宿舍哨兵)/ 活动(分类存名称)共 50 项断言全绿
2026-10-13 11:29:12 +08:00

507 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 { callModuleApi } from "@/utils/module-session";
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: "我要请假", 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.publish" },
{ text: "这周有什么新的校园公告?", module: "campusnotice", tool: "notice.received" },
{ text: "提交数学作业", module: "coursehomework", tool: "homework.submit" },
{ text: "看看我还有哪些作业没做", module: "coursehomework", tool: "homework.myList" },
// 教师/宿管:说"我要发布宿舍通知"直接弹发布表单(标题/正文/通知类型/发文部门/回执方式)
{ text: "我要发布宿舍通知", module: "dormnotice", tool: "dorm.publish" },
{ text: "这条宿舍通知还有谁没回执?", module: "dormnotice", tool: "dorm.receipts" },
{ text: "宿舍有什么通知需要我回执吗?", module: "dormnotice", tool: "dorm.myNotice" },
// 教师:说"我要发布活动"直接弹发布表单(标题/说明/分类/部门/地点/起止时间)
{ text: "我要发布活动", module: "familyactivity", tool: "activity.publish" },
{ text: "秋季运动会的报名情况怎么样?", module: "familyactivity", tool: "activity.signups" },
{ text: "报名秋季亲子运动会,2 人参加", module: "familyactivity", tool: "activity.signup" },
{ 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: "教师说「我要发布宿舍通知」直接弹发布表单;也可问「这条通知还有谁没回执」「304 房住了谁」" },
{ key: /报名|活动/, text: "家长说「报名秋季亲子运动会,2 人参加」;教师说「我要发布活动」直接弹发布表单,或问「报名情况怎么样」" },
{ key: /审批|通过|驳回/, text: "例:把 101 号请假审批通过,意见「同意」" },
{ 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 不会卡死。
*
* 首次遇到 `code=302`(该浏览器还没建过这个模块的会话)会先预热一次再重试;
* 会话建好后同一浏览器后续查询直接命中,无额外开销。
* 实现细节见 `@/utils/module-session`(表单下拉的「代取选项」共用同一套逻辑)。
*/
const execBrowserCall = callModuleApi;
/** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */
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.followUp
? `正在用你的登录态提交并完成「${call.title}」…`
: `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
results.push(await execBrowserCall(call, () => {
const ctx = call.pathAndQuery.split("/").filter(Boolean)[0] || "该模块";
placeholder.stage = `「${ctx}」模块还没建立登录会话,正在为你自动登录…`;
}));
}
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>