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
+525
View File
@@ -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>