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

This commit is contained in:
lijing
2026-10-12 14:45:00 +08:00
parent 96bb37c89f
commit 37d292c5d2
51 changed files with 13430 additions and 2 deletions
+513
View File
@@ -0,0 +1,513 @@
<script setup lang="ts">
/**
* 智能助手对话页。
*
* 交互要点:
* - 用户发一句话 → 后端编排(大模型决策 + 调用业务接口)→ 返回回复 + 工具执行轨迹
* - 信息不全时后端下发「补全表单」,这里渲染成可填卡片,用户填完直接提交执行(不再反复追问)
* - 等待期间显示阶段提示(理解需求 → 调用接口)
* - 助手消息下挂「工具轨迹卡」,写操作后提供「去查看」直达对应模块页面
* - 输入框轮播示例,并按关键词提示「怎么说更准确」
* - 能力目录由外壳统一拉取后传入,侧栏与首屏共用一份数据
*/
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { agentApi } from "@/api/module/agent";
import QuickActions from "@/components/QuickActions.vue";
import MessageBubble from "@/components/MessageBubble.vue";
import type { AgentToolCatalog, BrowserToolCall, ChatItem, ToolResultItem } from "@/types/agent";
const props = defineProps<{ user?: any; catalog?: AgentToolCatalog | null }>();
const items = ref<ChatItem[]>([]);
const input = ref("");
const sending = ref(false);
const stage = ref("");
const sessionId = ref<string | undefined>(undefined);
const listEl = ref<HTMLElement | null>(null);
const inputEl = ref<HTMLTextAreaElement | null>(null);
/** 等待期间的阶段轮播(后端返回前给用户进度感) */
let stageTimer: ReturnType<typeof setInterval> | null = null;
const STAGES = ["正在理解你的需求…", "正在判断需要调用哪些功能…", "正在调用系统接口…", "正在整理结果…"];
/**
* 输入示例轮播:直接给出"可以怎么说"的成品句,比抽象描述更好用。
*
* 每条标注它依赖的「模块 + 工具」,用于按当前身份裁剪:
* 学生身份下不会轮播「发通知」「专业发展积分」这类教师专属句子 ——
* 否则等于给用户一个"照着说必被拒"的示范。
* 判据取自能力目录本身(模块是否下发、工具名是否存在),前端无需知道角色规则。
*/
const EXAMPLES: { text: string; module: string; tool?: string }[] = [
{ text: "我要请 10月12日 上午的病假,发烧就医", module: "homeleave", tool: "leave.submit" },
{ text: "我的请假记录有几次", module: "homeleave", tool: "leave.myLeaves" },
{ text: "发一条关于社团招新的通知", module: "campusnotice", tool: "notice.publish" },
{ text: "这周有什么新的校园公告?", module: "campusnotice", tool: "notice.received" },
{ text: "提交数学作业", module: "coursehomework", tool: "homework.submit" },
{ text: "看看我还有哪些作业没做", module: "coursehomework", tool: "homework.myList" },
{ text: "宿舍有什么通知需要我回执吗?", module: "dormnotice", tool: "dorm.myNotice" },
{ text: "报名秋季亲子运动会,2 人参加", module: "familyactivity", tool: "activity.signup" },
{ text: "帮我找一份语文课件", module: "resourcelibrary", tool: "resource.search" },
{ text: "我的专业发展积分是多少?", module: "profession", tool: "profession.myPoints" },
];
/** 当前身份下适合轮播的示例句;能力目录未就绪时先给全量,避免输入框提示空着 */
const examples = computed(() => {
const mods = props.catalog?.modules;
if (!mods) return EXAMPLES.map(e => e.text);
return EXAMPLES.filter(e => {
const list = mods[e.module] || [];
return list.length > 0 && (!e.tool || list.some(t => t.name === e.tool));
}).map(e => e.text);
});
const exampleIndex = ref(0);
let exampleTimer: ReturnType<typeof setInterval> | null = null;
const placeholder = computed(() => {
const list = examples.value;
if (!list.length) return "说说你想办什么…";
return `试试说:${list[exampleIndex.value % list.length]}`;
});
/** 边说边提示:命中关键词时告诉用户"这样说更准" */
const TIPS: { key: RegExp; text: string }[] = [
{ key: /请假|病假|事假/, text: "说清「类型 + 起止时间 + 事由」一次就能提交,例:10月12日上午病假,发烧就医" },
{ key: /公告|通知(发布|发布公告)/, text: "发布公告需要「标题 + 正文 + 发文部门」,例:发一条关于运动会的通知,正文…" },
{ key: /作业|作答|提交作/, text: "可以说「我有哪些作业没做」,或「提交数学作业」,我会列出作业让你选" },
{ key: /宿舍|回执/, text: "例:宿舍有什么通知需要我回执吗?我会列出通知并给出回执表单" },
{ key: /报名|活动/, text: "例:报名秋季亲子运动会,2 人参加,联系人张伟 13800000000" },
{ key: /审批|通过|驳回/, text: "例:把 101 号请假审批通过,意见「同意」" },
{ key: /资源|课件|教案|资料|试题/, text: "例:帮我找一份语文课件;想留存就说「收藏这份课件」" },
{ key: /积分|专业发展|记录|成果/, text: "查积分说「我的专业发展积分是多少」;要留痕说「发布一条听课记录」" },
];
const activeTip = computed(() =>
input.value.trim() ? TIPS.find(t => t.key.test(input.value.trim()))?.text || "" : ""
);
const isEmpty = computed(() => items.value.length === 0);
onMounted(() => {
exampleTimer = setInterval(() => {
/* 用当前身份可见的例句数取模:目录到达后例句会变少,索引必须跟着收敛 */
exampleIndex.value = (exampleIndex.value + 1) % Math.max(1, examples.value.length);
}, 4200);
});
onUnmounted(() => {
stopStage();
if (exampleTimer) clearInterval(exampleTimer);
});
function scrollToBottom() {
nextTick(() => {
const el = listEl.value;
if (el) el.scrollTop = el.scrollHeight;
});
}
/** 输入框随内容长高(最多 148px,再长就内部滚动) */
function autoGrow() {
const el = inputEl.value;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 148)}px`;
}
watch(input, () => nextTick(autoGrow));
function startStage() {
let i = 0;
stage.value = STAGES[0];
stageTimer = setInterval(() => {
i = Math.min(i + 1, STAGES.length - 1);
stage.value = STAGES[i];
const last = items.value[items.value.length - 1];
if (last && last.role === "assistant") last.stage = stage.value;
}, 1600);
}
function stopStage() {
if (stageTimer) {
clearInterval(stageTimer);
stageTimer = null;
}
}
/** 新建一条等待中的助手消息 */
function pushPending(): ChatItem {
const placeholder: ChatItem = {
id: `a-${Date.now()}`,
role: "assistant",
text: "",
stage: STAGES[0],
traces: [],
at: Date.now(),
};
items.value.push(placeholder);
sending.value = true;
startStage();
scrollToBottom();
return placeholder;
}
/** 把后端响应灌进占位消息 */
function fill(placeholder: ChatItem, resp: any) {
sessionId.value = resp?.sessionId || sessionId.value;
placeholder.text = resp?.reply || "";
placeholder.traces = resp?.toolCalls || [];
placeholder.wroteSomething = !!resp?.wroteSomething;
placeholder.landingPath = resp?.landingPath;
if (resp?.form) {
placeholder.form = resp.form;
}
if (resp?.warning && !resp?.reply) {
placeholder.error = resp.warning;
}
}
/**
* 浏览器代执行(方案 A):模块接口只认浏览器 CAS 会话,服务端代调必然 302。
* 后端挂起后把接口描述下发到这里,用「用户自己的登录态」调完模块接口再把结果回传续跑。
*
* 前提:本应用与模块同站部署(同源 Cookie 自动携带)。本地 dev(localhost:8081)
* 跨域取不到 base 域会话,fetch 会失败或 404 → 结果回传后由服务端判为失败,
* 自动落回「去系统里查看」的深链兜底,UI 不会卡死。
*/
async function execBrowserCall(call: BrowserToolCall): Promise<ToolResultItem> {
const base = { name: call.name, arguments: call.arguments };
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 15000);
try {
const headers: Record<string, string> = { yschapirequest: "1" };
let body: string | undefined;
if (call.method !== "GET") {
headers["Content-Type"] = call.contentType || "application/json;charset=UTF-8";
body = call.body;
}
const res = await fetch(`${location.origin}${call.pathAndQuery}`, {
method: call.method === "GET" ? "GET" : "POST",
headers,
body,
credentials: "same-origin",
signal: ctrl.signal,
});
return { ...base, status: res.status, body: await res.text() };
} catch (e: any) {
const reason = e?.name === "AbortError" ? "请求超时(15 秒)" : e?.message || "网络请求失败";
return { ...base, error: reason };
} finally {
clearTimeout(timer);
}
}
/** 执行一批代调用并回传续跑;续跑响应若再挂起(链式查询)则递归处理 */
async function runBrowserTool(placeholder: ChatItem, pending: any) {
// ★ 先把挂起响应里的 sessionId 落地:续跑请求要用它找回同一会话,
// 否则服务端会当成没有历史的新会话(模型拿不到上文的 tool_calls)。
sessionId.value = pending?.sessionId || sessionId.value;
const calls: BrowserToolCall[] = pending?.browserTool?.calls || [];
placeholder.text = pending?.reply || pending?.browserTool?.notice || "正在用你的登录态查询业务模块…";
const results: ToolResultItem[] = [];
for (const call of calls) {
placeholder.stage = `正在用你的登录态${call.write ? "提交" : "查询"}「${call.title}」…`;
results.push(await execBrowserCall(call));
}
placeholder.stage = "正在整理结果…";
const resp: any = await agentApi.submitToolResults(sessionId.value, results).send();
fill(placeholder, resp);
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
}
}
async function send(text?: string) {
const content = (text ?? input.value).trim();
if (!content || sending.value) return;
input.value = "";
items.value.push({ id: `u-${Date.now()}`, role: "user", text: content, at: Date.now() });
const placeholder = pushPending();
try {
const resp: any = await agentApi.chat({ sessionId: sessionId.value, message: content }).send();
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
} else {
fill(placeholder, resp);
}
} catch (e: any) {
if (e?.message === "redirect") return;
placeholder.error = e?.message || "请求失败,请稍后重试";
} finally {
stopStage();
placeholder.stage = "";
sending.value = false;
scrollToBottom();
}
}
/** 表单提交:直接执行对应工具,不再让模型二次判断 */
async function onFormSubmit(payload: { item: ChatItem; form: { tool: string; args: Record<string, any> } }) {
if (sending.value) return;
const { item, form } = payload;
item.formSubmitted = true; // 卡片立即转为只读回显
const placeholder = pushPending();
try {
const resp: any = await agentApi.submitForm(sessionId.value, form).send();
if (resp?.browserTool) {
await runBrowserTool(placeholder, resp);
} else {
fill(placeholder, resp);
}
} catch (e: any) {
if (e?.message === "redirect") return;
placeholder.error = e?.message || "提交失败,请稍后重试";
item.formSubmitted = false; // 失败则允许用户改完再提交
} finally {
stopStage();
placeholder.stage = "";
sending.value = false;
scrollToBottom();
}
}
function onKeydown(e: KeyboardEvent) {
// Enter 发送;Shift+Enter 换行
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
send();
}
}
async function newSession() {
try {
await agentApi.clearSession(sessionId.value).send();
} catch {
/* 忽略:清理失败不影响本地重置 */
}
sessionId.value = undefined;
items.value = [];
input.value = "";
Message.success("已开启新对话");
}
/** 供侧栏调用:把模块示例句直接发出去 */
function pickSample(text: string) {
send(text);
}
defineExpose({ newSession, pickSample });
</script>
<template>
<div class="chat">
<div ref="listEl" class="chat__list agent-scroll">
<QuickActions v-if="isEmpty" :catalog="catalog ?? null" @pick="t => send(t)" />
<div v-else class="chat__stream">
<MessageBubble
v-for="(it, i) in items"
:key="it.id"
:item="it"
:pending="sending && i === items.length - 1 && !it.text"
@form-submit="onFormSubmit"
/>
</div>
</div>
<div class="chat__composer">
<div class="chat__box" :class="{ 'chat__box--busy': sending }">
<textarea
ref="inputEl"
v-model="input"
class="chat__input"
rows="1"
:disabled="sending"
:placeholder="placeholder"
@keydown="onKeydown"
/>
<div class="chat__actions">
<button class="chat__ghost" type="button" :disabled="sending" title="清空当前会话" @click="newSession">
新会话
</button>
<button
class="chat__send"
type="button"
:disabled="!input.trim() || sending"
@click="send()"
>
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12l16-8-6 8 6 8-16-8z" />
</svg>
{{ sending ? "处理中" : "发送" }}
</button>
</div>
</div>
<p class="chat__hint" :class="{ 'chat__hint--tip': !!activeTip }">
<svg v-if="activeTip" class="chat__hint-icon" viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
<path d="M12 3a6 6 0 00-3.5 10.9V17h7v-3.1A6 6 0 0012 3z" />
<path d="M9.5 20.5h5" />
</svg>
<template v-if="activeTip">{{ activeTip }}</template>
<template v-else>Enter 发送 · Shift + Enter 换行 · 写操作请确认信息无误后再提交</template>
</p>
</div>
</div>
</template>
<style scoped>
.chat {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.chat__list {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
padding-bottom: 8px;
}
.chat__stream {
max-width: var(--agent-readw);
margin: 0 auto;
padding: 26px 28px 8px;
}
/* ------------------------------ 输入区 ------------------------------ */
.chat__composer {
flex: none;
padding: 12px 28px 16px;
background: linear-gradient(to top, var(--agent-bg) 58%, rgba(244, 246, 251, 0));
}
.chat__box {
max-width: var(--agent-readw);
margin: 0 auto;
display: flex;
align-items: flex-end;
gap: 12px;
padding: 10px 12px 10px 16px;
border: 1px solid var(--agent-border-strong);
border-radius: 18px;
background: var(--agent-surface);
box-shadow: 0 2px 4px rgba(15, 23, 42, 0.04), 0 18px 34px -20px rgba(15, 23, 42, 0.32);
transition: border-color 0.18s ease, box-shadow 0.18s ease;
}
.chat__box:focus-within {
border-color: var(--agent-primary);
box-shadow: 0 0 0 4px rgba(37, 99, 235, 0.1), 0 18px 34px -20px rgba(37, 99, 235, 0.4);
}
.chat__box--busy {
opacity: 0.85;
}
.chat__input {
flex: 1 1 auto;
min-width: 0;
border: none;
outline: none;
resize: none;
background: transparent;
font-family: inherit;
font-size: 14px;
line-height: 1.65;
color: var(--agent-text);
min-height: 26px;
max-height: 148px;
padding: 6px 0;
}
.chat__input::placeholder {
color: var(--agent-text-mute);
}
.chat__actions {
flex: none;
display: flex;
align-items: center;
gap: 8px;
padding-bottom: 1px;
}
.chat__ghost {
height: 36px;
padding: 0 12px;
border: none;
border-radius: 11px;
background: transparent;
color: var(--agent-text-sub);
font-family: inherit;
font-size: 13px;
cursor: pointer;
transition: background 0.16s ease, color 0.16s ease;
}
.chat__ghost:hover:not(:disabled) {
background: var(--agent-surface-2);
color: var(--agent-text);
}
.chat__ghost:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat__send {
display: inline-flex;
align-items: center;
gap: 6px;
height: 36px;
padding: 0 18px;
border: none;
border-radius: 11px;
background: linear-gradient(135deg, #2563eb, #4f46e5);
color: #fff;
font-family: inherit;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.3px;
cursor: pointer;
box-shadow: 0 10px 20px -10px rgba(37, 99, 235, 0.9);
transition: transform 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
}
.chat__send:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 14px 24px -10px rgba(37, 99, 235, 0.95);
}
.chat__send:disabled {
background: #cbd5e1;
box-shadow: none;
cursor: not-allowed;
}
.chat__hint {
max-width: var(--agent-readw);
margin: 9px auto 0;
display: flex;
align-items: center;
justify-content: center;
gap: 5px;
font-size: 12px;
line-height: 1.6;
color: var(--agent-text-mute);
transition: color 0.18s ease;
}
.chat__hint--tip {
color: var(--agent-primary-dark);
font-weight: 500;
}
.chat__hint-icon {
flex: none;
}
</style>