/* ============================================================================ * 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`(配 ``),已经去掉,原因写在 * 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` 给 `` 挂/摘 `.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)); }