build / build (push) Successful in 3m22s
- index.html 插 A 类 loader:唯一接入点,FOUC 门/manifest 预取/主题 link 追加与排序 全由通道 yh-theme-init.js 承担,主题升级走 /static 通道,本应用无需重新构建 - vite.config 加 /static dev 代理(YH_THEME_PROXY_TARGET 可覆盖,默认线上通道) - host.config 回写 __APP_CONFIG__.serviceHost(仅同源):init 的 appearance 请求有硬性 同源限制,跨源会被直接跳过;本地 dev 保持空串走同源 /api 代理 - 顶栏头像接平台键 yh-avatar,监听 yh-appearance-updated,userInfo 兜底 - view-ui-plus 声明锁 1.3.24(对齐通道皮肤生成版本)
581 lines
18 KiB
Vue
581 lines
18 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 左侧导航栏。
|
||
*
|
||
* 承担三件事,让主区可以专心做对话:
|
||
* 1. 品牌与身份 —— 一眼知道这是什么、当前是谁在用
|
||
* 2. 能力导航 —— 当前身份可用的模块全部列出(**常用的排在前面并带一道色条**),
|
||
* 点一下就等于发出一句完整请求
|
||
* 3. 使用提示 —— 常驻说明"信息不全也没关系",降低首次使用门槛
|
||
*/
|
||
import { computed, onBeforeUnmount, onMounted, 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 { rankModuleKeys, usageScope, usageSnapshot } from "@/utils/usage";
|
||
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;
|
||
}>();
|
||
|
||
/* 头像:平台键 yh-avatar(yh-theme-init 外观补齐段写入)优先,本仓用户信息兜底;
|
||
监听平台事件重读,不轮询、不手写清理(清键归 init,规范 §3) */
|
||
const yhAvatar = ref("");
|
||
const readYhAvatar = () => {
|
||
let v: string | null = null;
|
||
try {
|
||
v = localStorage.getItem("yh-avatar");
|
||
} catch {
|
||
v = null;
|
||
}
|
||
yhAvatar.value = v || "";
|
||
};
|
||
onMounted(() => {
|
||
readYhAvatar();
|
||
document.addEventListener("yh-appearance-updated", readYhAvatar);
|
||
});
|
||
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readYhAvatar));
|
||
const avatarUrl = computed(() => yhAvatar.value || props.user?.profilePicture || "");
|
||
|
||
/**
|
||
* 退出登录(同时也是"换个账号登录"的唯一入口)。
|
||
*
|
||
* ★ 必须两步都做,缺一不可(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>("");
|
||
|
||
/**
|
||
* 侧栏只列出「当前身份真正可用」的模块,并**按使用频次把常用项置顶**。
|
||
*
|
||
* 能力目录由后端按「角色 + 已授权模块」过滤后下发,学生 / 教师 / 管理员拿到的集合本就不同,
|
||
* 因此这里直接以目录为准,而不是任何前端写死的名单 —— 既不会出现"点了就被拒"的假入口,
|
||
* 也不会漏掉后端新上线、前端还没配元数据的模块。
|
||
*
|
||
* ★ 排序只在**目录到达时算一次**(`catalog` 变化才重算),不订阅画像变更:
|
||
* 侧栏是导航而非展示位,如果用户点完一个模块它就当场跳到第一位,
|
||
* "我刚点的那一行跑哪去了"会打断操作。下次进页面时它自然已经在前面。
|
||
*/
|
||
const modules = computed(() => {
|
||
const mods = props.catalog?.modules || {};
|
||
const keyList = orderedModuleKeys(mods);
|
||
const scope = usageScope(props.catalog);
|
||
const keys = rankModuleKeys(scope, keyList);
|
||
const heat = usageSnapshot(scope).modules;
|
||
|
||
return keys.map(key => {
|
||
const meta = moduleMeta(key);
|
||
const used = heat[key]?.count ?? 0;
|
||
return {
|
||
key,
|
||
icon: meta.icon,
|
||
tone: meta.tone,
|
||
sample: meta.sample,
|
||
label: MODULE_LABEL[key] || key,
|
||
count: mods[key].length,
|
||
/** 用过 2 次以上算常用:左侧给一道色条,方便扫视时定位 */
|
||
hot: used >= 2,
|
||
used,
|
||
};
|
||
});
|
||
});
|
||
|
||
/**
|
||
* 当前登录身份名称:**直接采用平台下发的 {@code currentRole.roleName}**。
|
||
*
|
||
* 与 {@code GetUserInfo} / 门户顶栏同源 —— 平台把当前身份叫什么(「老师」「校领导」
|
||
* 「工作号」…)就原样显示什么,前端不做任何重命名、也不自行判权。
|
||
*
|
||
* 取值优先级:① {@code GetUserInfo} 回来的 {@code currentRole.roleName}(权威)
|
||
* → ② 后端 {@code /api/agent/tools} 的 {@code identity.roleName}(同一字段的转述)
|
||
* → ③ 归一化标签 {@code catalog.roles}(教师/学生/…,仅在平台没下发角色名时兜底)。
|
||
*/
|
||
const roleText = computed(() => {
|
||
const fromUser = props.user?.currentRole?.roleName;
|
||
const fromCatalog = props.catalog?.identity?.roleName;
|
||
return (fromUser || fromCatalog || props.catalog?.roles || "").trim();
|
||
});
|
||
|
||
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, 'sb__item--hot': m.hot }]"
|
||
:title="
|
||
m.hot
|
||
? `你常办这个(用过 ${m.used} 次)|对「${m.label}」说:${m.sample}`
|
||
: `对「${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="avatarUrl" 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--hot {
|
||
box-shadow: inset 2px 0 0 var(--tone-light, #93c5fd);
|
||
}
|
||
|
||
.sb__item--hot.sb__item--on {
|
||
box-shadow: inset 2px 0 0 var(--tone-light, #93c5fd), 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>
|