Files
yunhai-agent-front/src/components/AppSidebar.vue
T
lijing 64de9a0548
build / build (push) Successful in 3m22s
feat(theme): 接入云海子主题通道(A 类免插件,规范 v1.12 §2/§3)
- 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(对齐通道皮肤生成版本)
2026-10-21 17:35:24 +08:00

581 lines
18 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>