feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)

功能级准入改为「权限键」,`requireRole` 退成默认模板。

权限组
- 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission
- 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺;
  CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板
- 新增「权限组」页:组增删改、成员分配、生效权限预览
- 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖

客户端设备(ClassHelper 桌面端)
- 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留)
- 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒
- 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、
  60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次
- 新增「客户端设备」页:版本一览、一键推送更新、下发远控

客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制)
- POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size
- 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装

大模型助手:只解析、不落库
- POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人),
  真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用
- 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信)
- API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出
- provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥)

系统与运维
- 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥)
- 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable,
  **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项
- 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌

客户端
- 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次),
  未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例)
- 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推

发布内容批量
- POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果
- 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课
  (自动建课会把创建者写成任课老师,等于越权改任教关系)
- 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators

BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。
未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。
服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 /
AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
LoveChengke committed 2026-10-11 11:49:32 +08:00
1 parent e51879667a
commit 817af02357
131 files changed
+12995 -538

No files matched your search

+411 -15
View File
@@ -2,12 +2,12 @@
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { STORAGE_KEYS } from '@classhelper/shared';
import { STORAGE_KEYS, type PermissionKey, type UserRole } from '@classhelper/shared';
import { authApi } from '@/api';
import UpdateCheckDialog from '@/components/UpdateCheckDialog.vue';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { useUiStore } from '@/stores/ui';
import { useUiStore, CARE_SCALE_DEFAULT, CARE_SCALE_MAX, CARE_SCALE_MIN } from '@/stores/ui';
import { useResponsive } from '@/composables/useResponsive';
import { useThemeReveal } from '@/composables/motion';
import { APP_TITLE } from '@/config';
@@ -32,6 +32,49 @@ function handleThemeToggle(event: MouseEvent): void {
revealThemeChange(event, () => ui.toggleTheme());
}
/* ---------------------------------------------------------------- 侧栏折叠 */
/**
* 顶栏左侧的汉堡按钮(仅桌面端;≤768px 侧栏已经收进抽屉,用的是另一个 `nav-toggle`)。
*
* 它**只改一个布尔值**:栏宽、文字的淡出、图标位置、行高、滚动条全都交给
* `styles/index.css` 里 `.ch-nav.is-collapsed …` 那一段(那里有完整推导)——
* 模板里没有任何 `v-if` 增删菜单项,折叠才是连续的收拢动画而不是"重排一下"。
*
* 缓动刻意与桌面客户端同一套参数(`--nav-dur` / `--nav-ease`,AGENTS.md §5 第 49 条):
* 这里不要换成 EASE_OUT 之类的"起步快"曲线 —— 两端观感要一致,且自动化会量到中间态。
*/
const collapseLabel = computed(() => (ui.sidebarCollapsed ? '展开菜单' : '收起菜单'));
/* ---------------------------------------------------------------- 关怀模式 */
/**
* 关怀模式的入口放在顶栏的 `el-popover` 里,**不放进用户下拉菜单**:
* 下拉菜单点一下(甚至点里面的滑块)就会收起,滑块根本拖不动。
* 气泡里的开关与滑块都不是"点一下就走"的控件,放在气泡里正好。
*/
/** 放大倍数按百分比显示(1.2 → 120%),比裸小数好读 */
const careScalePercent = computed(() => `${Math.round(ui.careScale * 100)}%`);
/** el-switch 的 payload 是 `boolean | string | number` 的联合(active-value 可自定义),这里收口 */
function handleCareModeChange(value: boolean | string | number): void {
ui.setCareMode(value === true);
}
/** el-slider 单值模式发 `number`、范围模式发 `number[]`,这里只接单值 */
function handleCareScaleChange(value: number | number[]): void {
ui.setCareScale(Array.isArray(value) ? (value[0] ?? CARE_SCALE_DEFAULT) : value);
}
/* -------------------------------------------------------------- AI 助手入口 */
/** 顶栏的 AI 助手快捷入口:与侧栏菜单项同一道**生效权限**门控(默认管理员与教师都有) */
const canUseAssistant = computed(() => auth.can('ai:use'));
function openAssistant(): void {
void router.push('/assistant');
}
/**
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
@@ -41,8 +84,20 @@ function handleThemeToggle(event: MouseEvent): void {
* - 作业 / 通知:所有教师
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
* - 权限组管理 / 客户端设备:`roles` 只是粗筛(管理员 + 教师),真正的门控是**生效权限**
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
* (见 `visibleMenuItems`)
*/
const menuItems = [
interface MenuItem {
path: string;
title: string;
icon: string;
roles: UserRole[];
/** 可选的**生效权限**门控:有值时还要 `auth.can()` 通过才显示(角色判不出来这一层) */
permission?: PermissionKey;
}
const menuItems: MenuItem[] = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
@@ -51,13 +106,57 @@ const menuItems = [
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
{
path: '/assistant',
title: 'AI 助手',
icon: 'MagicStick',
// `ai:use` 默认给 ADMIN + TEACHER(教师用一句话发布作业/通知/叫人),
// 但仍走**生效权限**细筛:管理员把 ai:use 从某个权限组收回后这里就该消失。
roles: ['ADMIN', 'TEACHER'],
permission: 'ai:use',
},
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
{
path: '/permissions',
title: '权限组',
icon: 'Lock',
roles: ['ADMIN', 'TEACHER'],
permission: 'permission:manage',
},
{
path: '/clients',
title: '客户端设备',
icon: 'Monitor',
// 与「权限组」同样按**生效权限**门控:教师默认没有 client:manage(见 shared/permissions.ts
// 的默认模板只给 ADMIN),所以这里不能只写 roles —— 被权限组授予后教师也该看见。
roles: ['ADMIN', 'TEACHER'],
permission: 'client:manage',
},
{
path: '/ai-settings',
title: '大模型设置',
icon: 'Setting',
// 与「权限组」「客户端设备」同一道门控:只有 ai:manage(默认仅管理员)才看得见,
// 页面里的配置读写也要求它(教师访问会 403)。
roles: ['ADMIN', 'TEACHER'],
permission: 'ai:manage',
},
{ path: '/database', title: '数据库管理', icon: 'Coin', roles: ['ADMIN'] },
];
/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */
/**
* 当前账号可见的菜单。
*
* 两道筛子:`roles` 是粗筛(角色级,与后端 `requireRole` 对齐),`permission` 是细筛
* (生效权限级)——「权限组」必须走后者:教师被权限组授予 `permission:manage` 后
* 角色还是 TEACHER,靠 `roles` 判断不出来。管理员在 `can()` 里短路为 true,
* 所以这里不会因为权限还没拉回来而少显示一项。
*/
const visibleMenuItems = computed(() =>
menuItems.filter((item) => (auth.role ? item.roles.includes(auth.role) : false)),
menuItems.filter((item) => {
if (!auth.role || !item.roles.includes(auth.role)) return false;
return !item.permission || auth.can(item.permission);
}),
);
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
@@ -96,25 +195,53 @@ function asideElement(): HTMLElement | null {
}
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
/** 指示器的裁剪框 = 菜单**视口**(滚动容器自身),滚动时激活项滚出去就跟着被裁掉 */
const indicatorClip = ref({ top: 0, left: 0, width: 0, height: 0 });
/** 菜单滚动容器:桌面侧栏里的 .el-menu(小屏是抽屉里的另一个 el-menu,没有指示器) */
function menuScroller(): HTMLElement | null {
return asideElement()?.querySelector<HTMLElement>('.layout-menu') ?? null;
}
function syncIndicator(): void {
const aside = asideElement();
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !active) {
const scroller = menuScroller();
const active = scroller?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !scroller || !active) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const asideRect = aside.getBoundingClientRect();
const box = scroller.getBoundingClientRect();
/*
* 以菜单视口为基准量,而不是以整个侧栏为基准。
*
* 菜单列是可滚动的(见 .layout-menu 的 overflow):量侧栏的话,激活项被滚出视口后
* 指示器会"贴"到品牌区/页脚上。裁剪框跟着菜单视口,指示器就只在菜单里可见。
*/
indicatorClip.value = {
top: box.top - asideRect.top,
left: box.left - asideRect.left,
width: box.width,
height: box.height,
};
const rect = active.getBoundingClientRect();
indicator.value = {
x: rect.left - asideRect.left,
y: rect.top - asideRect.top,
x: rect.left - box.left,
y: rect.top - box.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
};
}
const indicatorClipStyle = computed(() => ({
top: `${indicatorClip.value.top}px`,
left: `${indicatorClip.value.left}px`,
width: `${indicatorClip.value.width}px`,
height: `${indicatorClip.value.height}px`,
}));
const indicatorStyle = computed(() => ({
width: `${indicator.value.width}px`,
height: `${indicator.value.height}px`,
@@ -124,6 +251,11 @@ const indicatorStyle = computed(() => ({
let asideObserver: ResizeObserver | null = null;
let indicatorFrame = 0;
let scrollerEl: HTMLElement | null = null;
let scrollSettleTimer = 0;
/** 正在跟随滚动:这期间关掉过渡,否则每帧都重新起一段 397ms 的弹簧,看起来"慢吞吞地追" */
const indicatorFollowing = ref(false);
const SCROLL_SETTLE_MS = 140;
/**
* 把测量推迟到下一帧再跑。
@@ -141,13 +273,39 @@ function scheduleIndicatorSync(): void {
});
}
/**
* 滚动菜单时**同步**量一次(不走 rAF 版):滚动本身已经在合成器上跑,
* 再隔一帧读数会看到指示器明显落后于菜单。
*/
function handleMenuScroll(): void {
syncIndicator();
indicatorFollowing.value = true;
window.clearTimeout(scrollSettleTimer);
scrollSettleTimer = window.setTimeout(() => {
indicatorFollowing.value = false;
}, SCROLL_SETTLE_MS);
}
/** 绑定/解绑菜单滚动监听(小屏没有侧栏,切回来时要重新绑) */
function bindMenuScroller(): void {
const next = menuScroller();
if (next === scrollerEl) return;
scrollerEl?.removeEventListener('scroll', handleMenuScroll);
scrollerEl = next;
scrollerEl?.addEventListener('scroll', handleMenuScroll, { passive: true });
}
/**
* 首次测量放在 onMounted(而不是 nextTick):子组件先于父组件挂载完成,
* 此刻 `.el-menu-item.is-active` 已在文档里,量完再让浏览器首帧绘制 ——
* 于是指示器**第一次出现就是正确位置**,不会从左上角滑过去。
*/
onMounted(() => {
// 生效权限(`GET /permissions/me`):菜单里的「权限组」要等它回来才显示。
// 失败不阻塞布局(store 内已静默吞掉),管理员在 can() 里短路、不受这一拍影响。
void auth.ensurePermissions();
syncIndicator();
bindMenuScroller();
const aside = asideElement();
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
@@ -158,6 +316,9 @@ onMounted(() => {
onUnmounted(() => {
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
indicatorFrame = 0;
window.clearTimeout(scrollSettleTimer);
scrollerEl?.removeEventListener('scroll', handleMenuScroll);
scrollerEl = null;
asideObserver?.disconnect();
asideObserver = null;
});
@@ -182,6 +343,11 @@ watch(
watch(isMobile, (mobile) => {
if (!mobile) drawerVisible.value = false;
// 侧栏是按 v-if 挂载/卸载的:从抽屉切回常驻侧栏后,新的 .el-menu 需要重新绑滚动监听
void nextTick(() => {
syncIndicator();
bindMenuScroller();
});
});
/**
@@ -351,12 +517,13 @@ const tourSteps = computed(() => [
<template>
<el-container class="layout">
<!-- 桌面/平板:常驻深色侧边栏 -->
<!-- 桌面/平板:常驻深色侧边栏(折叠成 64px 图标导轨,见样式里的 .ch-nav.is-collapsed) -->
<el-aside
v-if="!isMobile"
ref="asideRef"
width="210px"
class="layout-aside ch-sidebar"
:width="ui.sidebarCollapsed ? 'var(--ch-aside-collapsed-width)' : 'var(--ch-aside-width)'"
class="layout-aside ch-sidebar ch-nav"
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
data-tour="side-nav"
>
<div class="brand">
@@ -364,10 +531,22 @@ const tourSteps = computed(() => [
<span class="brand-text">ClassHelper</span>
</div>
<!-- 激活项的滑动指示器:单个药丸在菜单项之间平移,位置由 syncIndicator() 量出来。
外层裁剪框贴着**菜单视口**(菜单列可滚动,激活项滚出去时指示器要一起被裁掉),
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
<div class="nav-indicator-clip" :style="indicatorClipStyle" aria-hidden="true">
<div
class="nav-indicator"
:class="{ 'is-following': indicatorFollowing }"
:style="indicatorStyle"
/>
</div>
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
<el-menu-item
v-for="item in visibleMenuItems"
:key="item.path"
:index="item.path"
:title="ui.sidebarCollapsed ? item.title : undefined"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
@@ -389,6 +568,26 @@ const tourSteps = computed(() => [
>
<el-icon :size="20"><Menu /></el-icon>
</button>
<!--
桌面:同一个位置的汉堡按钮负责把侧栏收成图标导轨。
图标方向跟着**当前状态**走(展开时给「收起」、折叠时给「展开」),
文案同步进 aria-label 与 title,鼠标悬停时能看出这一下会做什么。
-->
<button
v-else
type="button"
class="nav-toggle"
:aria-label="collapseLabel"
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
:title="collapseLabel"
data-test="nav-collapse-toggle"
@click="ui.toggleSidebar()"
>
<el-icon :size="20">
<Expand v-if="ui.sidebarCollapsed" />
<Fold v-else />
</el-icon>
</button>
<span class="header-title">{{ isMobile ? currentTitle : APP_TITLE }}</span>
<el-tag v-if="!isMobile" :type="connectionType" size="small" effect="light" data-tour="conn-tag">
<span class="header-conn">{{ connectionText }}</span>
@@ -409,6 +608,76 @@ const tourSteps = computed(() => [
</div>
<div class="header-right">
<!--
AI 助手快捷入口:与侧栏「AI 助手」菜单项同一道门控。
关怀模式打开时挂 .care-emphasis(字号写在 styles/index.css 的关怀模式块里,
这里不写死数值)—— 这就是需求里"关怀模式下助手入口更大更醒目"那条。
-->
<el-tooltip
v-if="canUseAssistant"
content="AI 助手:一句话发布作业 / 通知 / 叫人"
placement="bottom"
>
<el-button
class="assistant-entry"
:class="{ 'care-emphasis': ui.careMode }"
text
circle
aria-label="打开 AI 助手"
data-test="assistant-entry"
@click="openAssistant"
>
<el-icon><MagicStick /></el-icon>
</el-button>
</el-tooltip>
<!--
关怀模式。入口刻意放在 el-popover 而不是用户下拉菜单里:
下拉菜单点一下(甚至点里面的滑块)就会收起,滑块根本拖不动(见脚本里的注释)。
-->
<el-popover placement="bottom-end" :width="264" trigger="click" popper-class="care-popover">
<template #reference>
<button
type="button"
class="nav-toggle care-toggle"
:class="{ 'is-on': ui.careMode }"
:aria-pressed="ui.careMode ? 'true' : 'false'"
aria-label="关怀模式"
title="关怀模式"
data-test="care-toggle"
>
<el-icon :size="18"><ZoomIn /></el-icon>
</button>
</template>
<div class="care-panel">
<div class="care-panel-row">
<span class="care-panel-title">关怀模式</span>
<el-switch
:model-value="ui.careMode"
aria-label="关怀模式"
@update:model-value="handleCareModeChange"
/>
</div>
<p class="care-panel-desc">放大正文字号与控件,方便不习惯默认字号的老师。</p>
<div class="care-panel-row">
<span class="care-panel-label">放大倍数</span>
<span class="care-panel-value">{{ careScalePercent }}</span>
</div>
<el-slider
:model-value="ui.careScale"
:min="CARE_SCALE_MIN"
:max="CARE_SCALE_MAX"
:step="0.05"
:disabled="!ui.careMode"
aria-label="放大倍数"
@update:model-value="handleCareScaleChange"
/>
<p class="care-panel-desc">
100% 是原始大小,最大 150%。关掉关怀模式后字号立即恢复,倍数会记住。
</p>
</div>
</el-popover>
<!--
主题切换。与桌面客户端同一套(圆形揭示由 useThemeReveal 提供)。
注意 `.header-right` 必须是 flex 容器:Element Plus 给 `.el-button` 的是
@@ -559,6 +828,12 @@ const tourSteps = computed(() => [
flex-direction: column;
/* 导航指示器(.nav-indicator)的定位父级 */
position: relative;
/*
* 折叠只过渡**栏宽**:宽度是唯一在变的东西,其余(文字淡出、图标位置、行高)
* 都在 styles/index.css 的 .ch-nav.is-collapsed 段里各自声明过渡。
* 时长/缓动用 --nav-dur / --nav-ease —— 与桌面客户端同一套参数,改一处两端同步。
*/
transition: width var(--nav-dur) var(--nav-ease);
}
.brand {
@@ -590,11 +865,37 @@ const tourSteps = computed(() => [
border-right: none;
background: transparent;
flex: 1;
/*
* 菜单列**自己滚动**(这是"小屏状态下菜单无法上下滑动"的修法):
* - `min-height: 0` 是关键:flex 项默认 `min-height: auto`,永远不小于内容高度,
* 于是 overflow 永不生效、超出的菜单项被直接裁掉;
* - 小屏抽屉里 `.el-drawer__body` 是 `overflow: hidden`,滚动只能由这一列承担,
* 否则抽屉一关一开就只剩上半截菜单能点。
* 滚动条做细一点、半透明:深色侧栏上一条系统浅色滚动条太扎眼。
*/
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
--el-menu-text-color: #cbd5e1;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #ffffff;
}
.layout-menu::-webkit-scrollbar {
width: 6px;
}
.layout-menu::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.18);
border-radius: 999px;
}
.layout-menu::-webkit-scrollbar-track {
background: transparent;
}
/*
* 激活项的底色改由导航指示器承担(见 .nav-indicator)。
*
@@ -614,6 +915,17 @@ const tourSteps = computed(() => [
color: #ffffff;
}
/*
* 导航指示器的裁剪框:贴着菜单视口(.layout-menu 的 border box)。
* 菜单列可以滚动,激活项滚出视口后指示器必须跟着被裁掉,否则它会画到品牌区/页脚上。
*/
.nav-indicator-clip {
position: absolute;
overflow: hidden;
pointer-events: none;
z-index: 0;
}
/*
* 导航指示器:一个跟着激活项平移的药丸。宽高与位移由 syncIndicator() 量出来后内联下发,
* 这里只负责外观与过渡。
@@ -625,7 +937,6 @@ const tourSteps = computed(() => [
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: var(--ch-radius-sm);
background: #409eff;
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
@@ -638,6 +949,11 @@ const tourSteps = computed(() => [
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
/* 滚动跟随期间关掉过渡:每帧重起一段 397ms 弹簧会变成"慢吞吞地追" */
.nav-indicator.is-following {
transition: none;
}
.layout-header {
/* 顶栏跟随圆角设计:底部圆角 + 轻投影,与内容区一体化 */
border-radius: 0 0 var(--ch-radius-lg) var(--ch-radius-lg);
@@ -704,6 +1020,37 @@ const tourSteps = computed(() => [
color: var(--ch-text);
}
/*
* 关怀模式按钮:关掉时与主题按钮同款(安静的文字色),开启时用主色 + 淡底标出"正在生效"。
* 纯图标按钮必须靠这点颜色区分状态,否则用户看不出自己刚点开过什么。
*/
.care-toggle {
color: var(--ch-text-muted);
}
.care-toggle:hover {
color: var(--ch-text);
}
.care-toggle.is-on {
color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
/* 悬停时同样要保持"已开启"的底色(.nav-toggle:hover 的特异度更低,不会覆盖到这里) */
.care-toggle.is-on:hover {
background: var(--el-color-primary-light-8);
}
/* AI 助手快捷入口:与主题按钮同款文字色;关怀模式下由全局的 .care-emphasis 放大字号 */
.assistant-entry {
color: var(--ch-text-muted);
}
.assistant-entry:hover {
color: var(--ch-text);
}
.user-chip {
display: flex;
align-items: center;
@@ -759,4 +1106,53 @@ const tourSteps = computed(() => [
padding: 0;
overflow: hidden;
}
/*
* 关怀模式气泡的排版。
*
* 放在**全局块**里是必需的:el-popover 的内容被 teleport 到 body 上,
* 组件级 scoped 样式(带 data-v 属性选择器)一个都选不中它 —— 与上面 .ch-sidebar 同一个理由。
* 字号一律引用 --el-font-size-* 且圆角/投影走 --ch-* 令牌:关怀模式打开时这一块自己也会跟着放大,
* 不需要在这里再写一份 calc。
*/
.care-popover {
border-radius: var(--ch-radius-md);
box-shadow: var(--ch-shadow-md);
}
.care-panel {
display: flex;
flex-direction: column;
gap: 6px;
}
.care-panel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.care-panel-title {
font-size: var(--el-font-size-base);
font-weight: 600;
}
.care-panel-label {
color: var(--ch-text-secondary);
font-size: var(--el-font-size-small);
}
.care-panel-value {
color: var(--el-color-primary);
font-size: var(--el-font-size-small);
font-variant-numeric: tabular-nums;
}
.care-panel-desc {
margin: 0;
color: var(--ch-text-muted);
font-size: var(--el-font-size-extra-small);
line-height: 1.5;
}
</style>