Files
ClassHelper/packages/desktop-client/src/renderer/styles/index.css
T
LoveChengke 817af02357 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,历史通知按本地日期回填),升级前请先备份。
2026-10-11 11:49:32 +08:00

1013 lines
34 KiB
CSS
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.
/* ============================================================================
* ClassHelper 班级端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
*
* 参考 ClassIsland(Windows 课件表应用)的观感:
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
*
* 主题机制:
* - 全部颜色走 --ch-* / --el-* 变量,:root 是浅色值;
* - 深色主题 = html 根元素挂 .dark 类(由 stores/ui.ts 按配置切换),
* Element Plus 的 dark/css-vars.css(main.ts 引入)提供 --el-* 深色默认,
* 下面的 html.dark 块覆盖品牌变量(特异性高于 :root,必定生效);
* - 组件里**不要写死颜色**,一律用变量——深色回归靠这条纪律守着。
* ========================================================================== */
:root {
/* Fluent 强调色 */
--ch-accent: #0f6cbd;
--ch-accent-hover: #115ea3;
--ch-accent-soft: rgba(15, 108, 189, 0.08);
--ch-accent-soft-strong: rgba(15, 108, 189, 0.14);
/* 层次底色(Mica / Layer) */
--ch-bg: #f3f3f3;
--ch-layer: rgba(255, 255, 255, 0.7);
--ch-layer-solid: #ffffff;
--ch-layer-alt: rgba(249, 249, 249, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.5);
/* 描边(Fluent 用半透明黑,避免生硬的灰线) */
--ch-border: rgba(0, 0, 0, 0.06);
--ch-border-strong: rgba(0, 0, 0, 0.1);
--ch-divider: rgba(0, 0, 0, 0.05);
/* 文本 */
--ch-text: #1b1b1b;
--ch-text-secondary: #5c5c5c;
--ch-text-tertiary: #8a8a8a;
/* 交互态(悬停底色 / 侧栏与顶栏的亚克力底 / 滚动条) */
--ch-hover-soft: rgba(0, 0, 0, 0.04);
--ch-nav-bg: rgba(255, 255, 255, 0.65);
--ch-header-bg: rgba(255, 255, 255, 0.6);
--ch-scrollbar: rgba(0, 0, 0, 0.18);
--ch-scrollbar-hover: rgba(0, 0, 0, 0.3);
/* 圆角与阴影 */
--ch-radius-card: 8px;
--ch-radius-control: 4px;
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
/*
* 侧栏收放动画。组件(ClientLayout 的 .aside/.brand/.brand-logo/.aside-footer)
* 与全局的菜单导轨规则(下面的 .ch-nav.is-collapsed …)共用这一份参数,
* 改时长/缓动只改这里,两边不会漂。
*
* ⚠️ 缓动**刻意保留 Material 的 cubic-bezier(.4, 0, .2, 1)**,没有换成全站统一的
* beUI EASE_OUT。试过的后果:EASE_OUT 起步极快,宽度掉到 80px 以下时动画才走了约 35%,
* 而冒烟自检 layoutChromeSelfTest 一旦读到"宽度 ≤ 80"就立刻量图标中心 ——
* 此刻内边距还在动,量出来 6 个分组子项偏 2.3px(要求 ≤2px),直接判红。
* 原曲线的 0.882 进度点落在 74% 处,剩下的时间足够动画跑完,量到的是稳态。
* 换句话说:**这条断言是按"收完之后的几何"写的,而它采样的时机取决于缓动的形状**,
* 想换曲线就得连那条断言一起重新商量。
*
* ⚠️ 时长**不要**改到 0.05s 以下:同一条自检断言"折叠必须真的是动画"
* (读 getComputedStyle 的 transitionDuration,要求 > 0.05)。
*/
--nav-dur: 260ms;
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
/* 图标与文字的间距(折叠时收到 0) */
--nav-gap: 8px;
/* 兼容旧变量名 */
--ch-sidebar: #ffffff;
/* Element Plus 变量对齐 Fluent */
--el-color-primary: #0f6cbd;
--el-color-primary-light-3: #4d94d1;
--el-color-primary-light-5: #7fb1de;
--el-color-primary-light-7: #b3d0ec;
--el-color-primary-light-8: #d0e2f4;
--el-color-primary-light-9: #e7f0f9;
--el-color-primary-dark-2: #115ea3;
--el-border-radius-base: 4px;
--el-border-radius-small: 3px;
--el-text-color-primary: #1b1b1b;
--el-text-color-regular: #3a3a3a;
--el-text-color-secondary: #5c5c5c;
--el-border-color: rgba(0, 0, 0, 0.1);
--el-border-color-light: rgba(0, 0, 0, 0.06);
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
--el-fill-color-blank: transparent;
--el-font-size-base: 13px;
/* 关怀模式的字号倍率(需求 14)。
* 平时恒为 1:它只作为「当前有没有放大」的单一事实来源,方便 JS 侧读取与断言,
* 真正的放大规则集中在文件末尾的 `html.care-mode` 块里(那里才是覆盖点,
* 且**刻意只动字号**——圆角 / 阴影 / 侧栏宽度等几何令牌一律不碰)。 */
--ch-font-scale: 1;
}
/* ---------------------------------------------------------------- 深色主题 */
html.dark {
--ch-accent: #5aabec;
--ch-accent-hover: #7cc0f4;
--ch-accent-soft: rgba(90, 171, 236, 0.16);
--ch-accent-soft-strong: rgba(90, 171, 236, 0.26);
--ch-bg: #161719;
--ch-layer: rgba(32, 33, 36, 0.72);
--ch-layer-solid: #1e1f23;
--ch-layer-alt: rgba(28, 29, 32, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.04);
--ch-border: rgba(255, 255, 255, 0.08);
--ch-border-strong: rgba(255, 255, 255, 0.14);
--ch-divider: rgba(255, 255, 255, 0.06);
--ch-text: #e8e8ea;
--ch-text-secondary: #b0b1b5;
--ch-text-tertiary: #7e7f85;
--ch-hover-soft: rgba(255, 255, 255, 0.07);
--ch-nav-bg: rgba(28, 29, 32, 0.65);
--ch-header-bg: rgba(30, 31, 34, 0.6);
--ch-scrollbar: rgba(255, 255, 255, 0.22);
--ch-scrollbar-hover: rgba(255, 255, 255, 0.36);
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.45);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.3);
--ch-sidebar: #1e1f23;
/* Element Plus 深色下的品牌色对齐(dark/css-vars.css 的默认蓝偏亮,这里对齐品牌) */
--el-color-primary: #4a9eff;
--el-color-primary-light-3: #337fd6;
--el-color-primary-light-5: #2563ab;
--el-color-primary-light-7: #1b4a82;
--el-color-primary-light-8: #163c6b;
--el-color-primary-light-9: #122f55;
--el-color-primary-dark-2: #6cb4ff;
--el-text-color-primary: #e8e8ea;
--el-text-color-regular: #c9cacd;
--el-text-color-secondary: #9a9b9f;
--el-border-color: rgba(255, 255, 255, 0.12);
--el-border-color-light: rgba(255, 255, 255, 0.08);
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
}
* {
box-sizing: border-box;
}
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
}
body {
font-family:
'Segoe UI Variable Text', 'Segoe UI', 'Microsoft YaHei UI', 'Microsoft YaHei', 'PingFang SC',
'Helvetica Neue', Arial, sans-serif;
font-size: 13px;
background: var(--ch-bg);
color: var(--ch-text);
/* 桌面端禁用整页选中,更接近原生应用体验 */
user-select: none;
-webkit-font-smoothing: antialiased;
}
input,
textarea {
user-select: text;
}
/* ---------------------------------------------------------------- 页面骨架 */
.page {
padding: 16px 20px 24px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.page-title {
margin: 0;
font-size: 20px;
font-weight: 600;
letter-spacing: 0.2px;
}
.page-subtitle {
margin: 4px 0 0;
font-size: 12.5px;
color: var(--ch-text-secondary);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mt-12 {
margin-top: 12px;
}
.mt-16 {
margin-top: 16px;
}
.text-muted {
color: var(--ch-text-tertiary);
}
.offline-banner {
margin: 0 0 12px;
}
/* ---------------------------------------------------------------- 卡片 / 分组 */
/* 亚克力卡片:半透明底 + 极细描边 + 极轻阴影(ClassIsland 的内容卡片观感) */
.el-card {
border-radius: var(--ch-radius-card) !important;
border: 1px solid var(--ch-border) !important;
background: var(--ch-layer) !important;
backdrop-filter: blur(20px) saturate(1.1);
box-shadow: var(--ch-shadow-card) !important;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.el-card:hover {
border-color: var(--ch-border-strong) !important;
}
.el-card__header {
padding: 12px 16px !important;
border-bottom: 1px solid var(--ch-divider) !important;
font-weight: 600;
font-size: 13.5px;
}
.el-card__body {
padding: 14px 16px !important;
}
/* ---------------------------------------------------------------- 控件 */
.el-button {
border-radius: var(--ch-radius-control) !important;
font-weight: 500;
}
.el-button--default {
background: var(--ch-layer) !important;
border-color: var(--ch-border-strong) !important;
}
.el-button--default:hover {
background: var(--ch-hover-soft) !important;
}
.el-button--primary {
box-shadow: 0 1px 2px rgba(15, 108, 189, 0.2);
}
.el-input__wrapper,
.el-textarea__inner,
.el-select__wrapper {
border-radius: var(--ch-radius-control) !important;
background: var(--ch-layer) !important;
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused {
box-shadow: 0 0 0 1px var(--ch-accent) inset !important;
}
.el-tag {
border-radius: var(--ch-radius-control) !important;
}
.el-dialog,
.el-drawer {
border-radius: var(--ch-radius-card) !important;
background: var(--ch-layer-solid) !important;
}
.el-message-box {
border-radius: var(--ch-radius-card) !important;
}
.el-popper,
.el-dropdown__popper {
border-radius: var(--ch-radius-card) !important;
box-shadow: var(--ch-shadow-flyout) !important;
}
/* 关于页的折叠卡片:与 el-card 同一套观感(分割线代替卡片描边) */
.el-collapse {
--el-collapse-border-color: var(--ch-divider);
border: none !important;
}
.el-collapse-item__header {
background: var(--ch-layer) !important;
border: 1px solid var(--ch-border) !important;
border-radius: var(--ch-radius-card) !important;
padding: 0 16px;
margin-top: 8px;
font-weight: 600;
font-size: 13.5px;
}
.el-collapse-item__wrap {
background: transparent !important;
border-bottom: none !important;
}
.el-collapse-item__content {
padding: 12px 6px 4px !important;
color: var(--ch-text);
}
/* ---------------------------------------------------------------- 表格 */
.el-table {
--el-table-border-color: var(--ch-divider);
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: var(--ch-hover-soft);
background: transparent !important;
}
.el-table th.el-table__cell {
background: transparent !important;
font-weight: 600;
color: var(--ch-text-secondary);
}
.el-table td.el-table__cell,
.el-table th.el-table__cell {
border-bottom: 1px solid var(--ch-divider) !important;
}
.el-table__inner-wrapper::before {
display: none;
}
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
.ch-nav {
background: var(--ch-nav-bg);
backdrop-filter: blur(24px) saturate(1.2);
border-right: 1px solid var(--ch-border);
}
.ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
height: 40px;
line-height: 40px;
margin: 2px 8px;
border-radius: var(--ch-radius-control);
color: var(--ch-text);
font-weight: 500;
position: relative;
}
.ch-nav .el-menu-item:hover,
.ch-nav .el-sub-menu__title:hover {
background: var(--ch-hover-soft);
}
/* 分组标题:比普通菜单项轻一档(分组是"容器",不是目的地) */
.ch-nav .el-sub-menu__title {
font-weight: 600;
color: var(--ch-text-secondary);
}
.ch-nav .el-sub-menu .el-menu {
background: transparent;
}
/*
* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式)。
*
* 底色与强调条**都改由滑动指示器承担**(见 ClientLayout.vue 的 `.nav-indicator`)——
* 原先每个激活项各画一块,切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系;
* 现在由同一个底片滑过去。指示器是 z-index:0 的绝对定位元素,菜单项抬到 1 层,
* 文字才不会被它盖住(`.ch-nav .el-menu-item` 上面已有 `position: relative`)。
*/
.ch-nav .el-menu-item {
z-index: 1;
}
.ch-nav .el-menu-item.is-active {
color: var(--ch-text);
}
/* ---------------------------------------------------------------- 折叠态:图标导轨
*
* 折叠**不再用** el-menu 的 `:collapse`:那会把每个分组的子项卸载进浮层(一次结构切换),
* 并让整条菜单重排一遍 —— 既做不出连续动画,也是"点汉堡卡一下"的渲染侧来源。
* 现在改成纯粹的 CSS 收拢:栏宽、文字宽度、图标位置全是可过渡属性,全程没有结构变化,
* 折叠后就是一条图标导轨(分组标题与子项都以图标列出)。
*
* 几何(64px 栏、菜单项左右各 8px margin):
* 展开 一级项 padding-left 20px、二级项 40px(Element Plus 自带)→ 图标中心 40 / 60px
* 折叠 所有项 padding-left / padding-right 都是 12px → 图标中心 8 + 12 + 12 = 32px,
* 正好与顶部汉堡按钮的居中位置同列(冒烟断言中心差 ≤ 2px)。
* 图标槽(.menu-icon-slot)与分组标题里的裸图标都是 Element Plus 给的 24px 宽。
*/
/* 图标与文字的间距改由文字的 margin-left 负责:Element Plus 给图标挂了 margin-right:5px,
那会让图标在 24px 的槽里偏左 2.5px(折叠时肉眼可见地不居中)。 */
.ch-nav .el-menu-item [class^='el-icon'],
.ch-nav .el-sub-menu__title > [class^='el-icon'] {
margin-right: 0;
}
.ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
transition:
padding-left var(--nav-dur) var(--nav-ease),
padding-right var(--nav-dur) var(--nav-ease),
border-color var(--el-transition-duration),
background-color var(--el-transition-duration),
color var(--el-transition-duration);
}
/*
* 文字:**不给它单独设动画终点**,宽度靠 flex 被压缩 —— `min-width:0` 让这个 flex 子项
* 能被压到 0(默认的 `min-width:auto` 会撑住内容宽度),多出来的字由 `overflow:hidden` 裁掉。
* 于是文字宽度天然跟着栏宽连续变化,不需要写什么魔法 max-width;再叠一层 opacity 淡出,
* 避免中间帧留下"半截字"(`display:none` 没法过渡,用不了)。
*/
.ch-nav .el-menu-item > span:not(.menu-icon-slot),
.ch-nav .el-sub-menu__title > span:not(.menu-icon-slot) {
min-width: 0;
margin-left: var(--nav-gap);
overflow: hidden;
white-space: nowrap;
transition:
margin-left var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 分组标题右侧的折叠箭头:导轨里没有它的位置 */
.ch-nav .el-sub-menu__icon-arrow {
transition: opacity calc(var(--nav-dur) * 0.5) var(--nav-ease);
}
.ch-nav.is-collapsed .el-menu-item > span:not(.menu-icon-slot),
.ch-nav.is-collapsed .el-sub-menu__title > span:not(.menu-icon-slot) {
margin-left: 0;
opacity: 0;
pointer-events: none;
}
.ch-nav.is-collapsed .el-sub-menu__icon-arrow {
opacity: 0;
}
/*
* 折叠后的内边距。
*
* 这三条选择器形状都是**照抄 Element Plus 的**,不是冗余 —— 它按层级用三个不同的选择器算缩进
* (一级项 20px、分组标题 20px、分组内子项 40px),特异度分别是 0-4-0 / 0-4-0 / 0-5-0:
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu__title`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu .el-menu-item`
* 写成 `.ch-nav.is-collapsed .el-sub-menu__title` 这种(0-3-0)压不住任何一条 —— 实测踩过两次:
* 第一版漏了分组标题(偏 8.0px),把「学习」拆成一级项后又漏了一级项(同样偏 8.0px)。
* 下面两条(0-5-0 / 0-6-0)把它们全覆盖,且不用 !important。
*/
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-menu-item,
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu__title {
padding-left: 12px;
padding-right: 12px;
}
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu .el-menu-item {
padding-left: 12px;
padding-right: 12px;
}
/* ---------------------------------------------------------------- 课表时间轴 */
.timetable-cell {
min-height: 30px;
}
.timetable-course {
font-weight: 600;
}
.timetable-meta {
font-size: 12px;
color: var(--ch-text-tertiary);
}
.unread-dot {
display: inline-block;
width: 8px;
height: 8px;
border-radius: 50%;
background: #d13438;
margin-right: 6px;
}
/* ---------------------------------------------------------------- 滚动条 */
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar-thumb {
background: var(--ch-scrollbar);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--ch-scrollbar-hover);
background-clip: content-box;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* ============================================================================
* 动效层 —— beUI 的动效语言
*
* 令牌(缓动曲线、六组弹簧、时长)在 `packages/shared/src/motion.ts`,
* 由 `main.ts` 在 mount 之前写进 `:root`;这里只负责**用**它们。
* 与 Web 管理端(packages/web-admin/src/styles/index.css)是同一套词汇表。
*
* ⚠️ 灵动岛**不在**这套体系里:它是另一个渲染进程,用自研的 WinIsland 弹簧物理
* (src/island/spring.ts),刻意保持独立,不要往那边接这里的变量。
*
* 分三层,越往下越"重":
* ① 变量调音 —— 改写 Element Plus 自己的过渡变量,一次性改掉全站组件库过渡;
* ② 弹簧覆盖 —— 弹窗 / 抽屉 / 浮层换成真正的物理曲线;
* ③ 自有动效 —— 页面过渡、列表错峰入场、按钮按压、卡片抬升。
* ========================================================================== */
/* ------------------------------------------------- ① 变量调音(覆盖组件库)
*
* Element Plus 的每个过渡都由这几个变量拼出来(`--el-transition-all` / `-fade` /
* `-md-fade`),调这里等于一次性调完全站,比逐个覆盖类名安全。
*
* Fluent/ClassIsland 观感本来就偏"轻快",原来 300/200ms 的默认值在这套界面里更显拖沓,
* 收到 200/140ms;缓动换成 beUI 的 EASE_OUT。
*
* 只改时长与缓动、不改颜色,所以不需要在 html.dark 里再写一份。
* 侧栏的 `--nav-ease` 也在文件开头的 :root 里改成了同一条曲线(与折叠几何放在一起,
* 免得"改侧栏动画要翻两个地方")。
*/
:root {
--el-transition-duration: 200ms;
--el-transition-duration-fast: 140ms;
--el-transition-function-ease-in-out-bezier: cubic-bezier(0.16, 1, 0.3, 1);
--el-transition-function-fast-bezier: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ------------------------------------------------------------ ② 弹簧覆盖 */
/*
* 弹窗(el-dialog)。Element Plus 的入场做在 `.el-overlay-dialog`(居中容器)上,
* 遮罩淡入在 `.dialog-fade-enter-active` 自己身上,两条都要换 ——
* 否则遮罩先到位、弹窗还在动,看起来是"背景已经变暗了但框还没出来"。
*
* 弹簧 PANEL 总时长 463ms 看着偏长,但它 95% 的行程在 262ms 就走完了,
* 剩下的是极慢的收尾 —— 观感上就是"200ms 到位、再轻轻落定一下"。
*/
@keyframes ch-panel-in {
from {
opacity: 0;
transform: translateY(-12px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes ch-panel-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(-6px) scale(0.985);
}
}
/*
* 位移与缩放挂在 `.el-dialog` **自己**身上,不挂在 `.el-overlay-dialog`(居中容器)上。
*
* 挂外层容器有个隐蔽的坑:Element Plus 的「打开完成」回调(@opened)按**根元素**
* (`.el-overlay`,只有 200ms 的淡入)的动画结束触发,而外层容器上的弹簧要跑 463ms ——
* 于是任何在 @opened 里量容器尺寸做自适应的弹窗,都会量到"还带着 scale(0.97) 的缩小版"。
* **本客户端的作业看板正是这么算 scale 的**:测小了会被最小缩放兜住,反而超出屏幕 7px
* (冒烟断言「作业看板全屏自适应(铺满屏幕、内容不溢出)」实测逮到,两次跑出来都是 550 > 543)。
* 挂到 `.el-dialog` 上还有个好处:全屏弹窗能靠 `is-fullscreen` 精确豁免(见下)。
*/
.dialog-fade-enter-active .el-overlay-dialog,
.dialog-fade-leave-active .el-overlay-dialog {
/* 抵消 Element Plus 自带的那两条(它们同样带 translateY,会与下面这两条叠起来) */
animation: none;
}
.dialog-fade-enter-active .el-dialog {
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
/*
* 退出**刻意比入场快得多**:用户已经决定关掉它了,这时候再让他等一段"有分量"的动画
* 只会显得卡。而且冒烟里多处断言"窗口/弹窗已消失",退出越快越稳。
*/
.dialog-fade-leave-active .el-dialog {
animation: ch-panel-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
/*
* 全屏弹窗(`is-fullscreen`,就是作业看板)**不做位移缩放**。
* 它本来就是铺满整屏的表面,"从略小处落下来"既看不出效果,又会让内部的
* fitBoard / fitFullscreen 在 @opened 那一刻量到被 scale 过的容器 —— 测小了会被
* 最小缩放兜住,结果反而超出屏幕。只留淡入。
*/
.dialog-fade-enter-active .el-dialog.is-fullscreen {
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.dialog-fade-leave-active .el-dialog.is-fullscreen {
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
/*
* 确认框(ElMessageBox)。这条沿用了"动画挂在遮罩层上"的做法:
* 它内部没有任何按容器尺寸做自适应的内容,不存在上面那个 @opened 测量的坑。
*/
.el-overlay-message-box {
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
.dialog-fade-enter-active {
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.dialog-fade-leave-active {
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
@keyframes ch-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ch-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/*
* 抽屉。位移由 `.el-drawer` **自身**的 `transition: all` 承载
* (`.el-drawer-fade-*` 那组类只管遮罩与 overflow),所以弹簧挂在 `.el-drawer` 上,
* 且显式列出属性 —— 不想让背景色的变化也走弹簧曲线。
*/
.el-drawer {
transition:
transform var(--ch-spring-panel-dur) var(--ch-spring-panel),
opacity var(--ch-dur-base) var(--ch-ease-out);
}
/*
* 浮层(下拉菜单 / 选择器 / 气泡)。Element Plus 用 `el-zoom-in-*` 系列,
* transform-origin 已按 placement 设好。换成 SWAP 弹簧(263ms,比 PANEL 短):
* 这类浮层是高频出现的,每次点下拉都等半秒会烦。
*/
.el-zoom-in-top-enter-active,
.el-zoom-in-top-leave-active,
.el-zoom-in-bottom-enter-active,
.el-zoom-in-bottom-leave-active,
.el-zoom-in-center-enter-active,
.el-zoom-in-center-leave-active,
.el-zoom-in-left-enter-active,
.el-zoom-in-left-leave-active {
transition:
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
.el-zoom-in-top-enter-from,
.el-zoom-in-bottom-enter-from,
.el-zoom-in-center-enter-from,
.el-zoom-in-left-enter-from {
opacity: 0;
transform: scale(0.95);
}
.el-zoom-in-top-leave-active,
.el-zoom-in-bottom-leave-active,
.el-zoom-in-center-leave-active,
.el-zoom-in-left-leave-active {
opacity: 0;
transform: scale(0.97);
}
/* 消息条与通知:原生的位移是 0.4s,连弹三条时明显拖沓 */
.el-message {
transition:
opacity var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
.el-notification {
transition:
opacity var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
left var(--ch-spring-swap-dur) var(--ch-spring-swap),
right var(--ch-spring-swap-dur) var(--ch-spring-swap),
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
/* ------------------------------------------------------------ ③ 自有动效 */
/*
* 按钮按压。Fluent 的按钮只有颜色过渡,没有"按下去"的物理反馈。
*
* 上行/下行用**不同的缓动**,这是这里唯一需要技巧的地方:
* - 按下(`:active` 生效时):90ms 直落,要"跟手",慢了就是延迟;
* - 抬起(`:active` 撤销后回到基础规则):走 PRESS 弹簧,轻轻弹回原位。
* CSS 的规则是"进入某状态用该状态的 transition、离开用目标状态的 transition",
* 所以把弹簧写在基础规则、把快落写在 `:active` 里,正好得到想要的一对。
*/
.el-button {
transition:
transform var(--ch-spring-press-dur) var(--ch-spring-press),
color var(--el-transition-duration-fast) var(--ch-ease-out),
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
background-color var(--el-transition-duration-fast) var(--ch-ease-out),
box-shadow var(--el-transition-duration-fast) var(--ch-ease-out);
}
.el-button:not(.is-disabled):active {
transform: scale(var(--ch-press-scale));
transition-duration: var(--ch-dur-instant);
transition-timing-function: var(--ch-ease-out);
}
/*
* 卡片抬升。**必须关在 `@media (hover: hover) and (pointer: fine)` 里**:
* 教室机器的希沃触摸屏点一下会产生"幽灵 hover"并一直粘着,
* 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md §5 第 30 条)。
* 这也正是 `composables/motion.ts` 里 `useHoverCapable()` 的 CSS 版本 ——
* 纯 CSS 效果用媒体查询就够了,不必为它挂一个 composable。
*/
@media (hover: hover) and (pointer: fine) {
.el-card {
transition:
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
box-shadow var(--el-transition-duration) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
.el-card:hover {
transform: translateY(-1px);
}
}
/* 表格行:hover 底色快速跟上,别用 Element Plus 的 0.3s */
.el-table td.el-table__cell {
transition: background-color var(--el-transition-duration-fast) var(--ch-ease-out);
}
/*
* 列表错峰入场。配合 `composables/motion.ts` 的 `useStaggerIn()` 使用 ——
* 那个函数按索引下发 `--ch-reveal-delay`,延迟在 CSS 里生效。
* `both` 是必需的:否则带延迟的元素会在延迟期间先按终态渲染一下(闪一帧)。
*/
@keyframes ch-reveal-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.ch-reveal {
animation: ch-reveal-in var(--ch-dur-slow) var(--ch-ease-out) both;
animation-delay: var(--ch-reveal-delay, 0ms);
}
/*
* 页面切换**没有**容器级的过渡 —— 这里曾经有一对 `.ch-page-enter-active` /
* `-leave-active`(配 `<transition mode="out-in">`),已经去掉,原因写在
* ClientLayout.vue 的 router-view 注释里(延期换页 + 窗口被遮挡时卡死)。
* 页面切换的动效全部由下面的 `.page > *` 错峰入场承担。
*/
/*
* 页面内容的错峰入场。
*
* 挂在 `.page > *` 上而不是逐个视图去写类名:所有视图(含六个设置子页)的根容器
* 都叫 `.page`,一条规则覆盖全部,新增视图自动享有,也不会有人忘记加。
*
* 延迟按**块**递进(30ms 一档、封顶 180ms):一屏通常是"页头 + 两三块内容",
* 这个节奏读起来是依次浮现;给更多块继续累加就变成排队了,所以第 7 块起不再递增。
*/
@keyframes ch-page-block-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.page > * {
animation: ch-page-block-in var(--ch-dur-slow) var(--ch-ease-out) both;
}
.page > *:nth-child(2) {
animation-delay: 30ms;
}
.page > *:nth-child(3) {
animation-delay: 60ms;
}
.page > *:nth-child(4) {
animation-delay: 90ms;
}
.page > *:nth-child(5) {
animation-delay: 120ms;
}
.page > *:nth-child(6) {
animation-delay: 150ms;
}
.page > *:nth-child(n + 7) {
animation-delay: 180ms;
}
/*
* 三个例外,都是"动了就会破坏别人的测量":
* ① `v-loading` 的遮罩是插在 `.page` 里的直接子元素,让它跟着淡入会闪一下;
* ② **弹窗遮罩**(`.el-overlay`)。Element Plus 的 `el-dialog` 默认 `append-to-body=false`,
* 所以声明在视图里的弹窗,它的遮罩是 `.page` 的**直接子元素**。而 `.el-overlay` 是
* `position: fixed; inset: 0` 的全屏层,整体下移 6px 就会把"刚好铺满"的内容顶出屏幕 ——
* 作业看板正是这样被顶出去的(冒烟断言「作业看板全屏自适应」读到可视底 550 > 视口高 543)。
* 全屏层本来也不该参与"内容块依次浮现",它一出现就该盖住整屏。
* ③ **看板容器**(`.board-host`):它的尺寸是按"刚好铺满屏幕"用 `transform: scale()`
* 精确算出来的(见 HomeworkView 的 computeScale),算完就是贴边的,同样经不起位移。
* 一句话:**凡是 `position: fixed` 的整屏层、或自己量几何再据此设尺寸的元素,
* 都不要叠位移类动画。**
*/
.page > .el-loading-mask,
.page > .el-overlay,
.page > .board-host {
animation: none;
}
/* ------------------------------------------------------- 降低动态效果偏好
*
* 系统开了「减少动态效果」时,把全站的动画压到 1ms —— 不是删掉,
* 而是让它在同一帧内完成(`transition: none` 会让依赖 transitionend 的逻辑失灵)。
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
/*
* 例外:加载指示旋的是"状态"不是"装饰"。把它一起停掉会让界面看起来像卡死了
* (用户会去重复点击),所以这里改为**放慢**而不是停。
*/
.el-icon.is-loading,
.el-loading-spinner .circular,
.el-loading-spinner svg,
.is-loading {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
/* ------------------------------------------------- 整页换肤(View Transition)
*
* `handleThemeToggle`(ClientLayout.vue)用 View Transition API 做"从点击位置扩散的圆"。
* 浏览器默认会给新旧两帧做一次交叉淡入 —— 这里把它关掉,改由 JS 用 WAAPI 驱动
* `::view-transition-new(root)` 的 clip-path。不关掉的话两套动画会叠在一起,
* 观感是"圆在扩散的同时整页又在闪"。
*
* 层级:老帧在下(z-index 1)、新帧在上(z-index 2),新帧的圆从无到有地盖住老帧。
* 不支持这个 API 的浏览器不会渲染这些伪元素,规则自然失效(JS 侧也有守卫)。
*/
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}
/* ------------------------------------------------------------ 关怀模式(需求 14)
*
* 给视力不佳的用户把**正文与控件**整体放大一档(约 +15%)。由 `stores/ui.ts` 的
* `applyCareMode()` 在首屏渲染前按 `config.careMode` 给 `<html>` 挂/摘 `.care-mode` 类。
*
* 三条刻意的边界,改这个块之前先读:
*
* 1. **只放大字号,不动几何。** 圆角(`--ch-radius-*`)、阴影(`--ch-shadow-*`)、
* 卡片内边距、侧栏宽度一律不碰 —— 放大字号已经会让文字换行,再动几何就是"另一个主题",
* 而且 `verify:desktop` 的多条断言量的是像素几何。
* 2. **侧栏导轨(`.ch-nav`)的条目字号不在这里放大。** §5 第 49 条那套"折叠成图标导轨"
* 的硬要求(导轨宽度、条目高度、折叠态文字必须既无宽度又透明)是按固定几何量出来的,
* 动导航条目字号会让折叠态文字重新占宽而断言变红。导航的可见标签保持原字号。
* 3. **用 `html.care-mode` 而不是 `:root`**:特异性 (0,1,1) 高于普通类选择器,
* 能稳定压过上面那些 `.page-title` / `.el-card__header` 等单类规则,不必写 `!important`。
* 深色主题无关 —— 这里一条颜色都没写。
*/
html.care-mode {
/* 放大倍率放在这里,下面的覆盖全部由它算出来 —— 想再调一档只改这一个数 */
--ch-font-scale: 1.15;
/* 覆盖 Element Plus 的基准字号:按钮、输入框、下拉、分页、消息框等控件都跟着它走
(基准值是 :root 里的 13px,见上面的 Element Plus 变量对齐) */
--el-font-size-base: calc(13px * var(--ch-font-scale));
}
/* 页面标题与副标题(`views/*` 的通用页头) */
html.care-mode .page-title {
font-size: calc(20px * var(--ch-font-scale));
}
html.care-mode .page-subtitle {
font-size: calc(12.5px * var(--ch-font-scale));
}
/* 卡片标题 / 折叠面板标题(这两处在上面被显式写成 13.5px,必须逐条覆盖) */
html.care-mode .el-card__header,
html.care-mode .el-collapse-item__header {
font-size: calc(13.5px * var(--ch-font-scale));
}
/* 表格:表头与单元格一起放大,行高由内容自然撑开(不设固定 height) */
html.care-mode .el-table {
font-size: calc(13px * var(--ch-font-scale));
}
html.care-mode .el-table th.el-table__cell {
font-size: calc(12.5px * var(--ch-font-scale));
}
/* 表单里 Element Plus 自己的标签/说明文字(默认跟着 --el-font-size-base,
* 但 `.el-form-item__label` 在部分页面被行内样式压过,这里统一一档更稳) */
html.care-mode .el-form-item__label,
html.care-mode .el-descriptions__label,
html.care-mode .el-descriptions__content,
html.care-mode .el-message-box__message {
font-size: calc(13px * var(--ch-font-scale));
}