build / build (push) Successful in 1m58s
配合后端把 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 项断言全绿
507 lines
19 KiB
Vue
507 lines
19 KiB
Vue
<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>
|