This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user