## 3. 设计系统 Token 总表(颜色 / 字体 / 圆角 / 间距 / 阴影 / 层级 / DPI / 深浅色) 本节是整份提示词的「地基」。实现者必须先落地本节的 token(CSS 自定义属性 + TypeScript 常量),再开始写任何界面。任何界面代码**不得**出现裸色值、裸字号、裸圆角,一律引用本节 token。 实现技术栈为 ClassHelper 的 Electron + Vue 3 栈,宿主已有 `packages/ui/src/tokens.css` 的 `--ch-ui-*` 与桌面端 `renderer/styles/index.css` 的 `--ch-*`。**保留并复用**这些既有 token 作为「应用外壳层」,新增 `--ci-*` 命名空间承载 ClassIsland 主界面(岛)层 token。两层必须分开:`--ch-*` 管设置中心/管理页,`--ci-*` 管岛与课程信息。 ### 3.1 颜色 Token #### 3.1.1 强调色与派生色 | Token 名 | 默认值 | 用途 | 建议 CSS 变量 | 源码证据 | |---|---|---|---|---| | `PrimaryColor`(用户强调色) | `#00BFFF`(Colors.DeepSkyBlue) | 设置项「强调色」的默认值;`ColorSource=0` 时作为全站 accent | `--ci-accent` | `ClassIsland/Models/Settings.cs:35`、`Settings.cs:992` | | `SecondaryColor` | `#7FFFD4`(Colors.Aquamarine) | 备用强调色(组件/图表次级色) | `--ci-accent-2` | `Settings.cs:36`、`Settings.cs:1003` | | FluentAvalonia 初始 accent | `DodgerBlue`=`#1E90FF` | 主题服务尚未介入时的启动色 | `--ci-accent-fallback` | `ClassIsland/App.axaml:17` | | `SelectedPlatte` 兜底 | `DodgerBlue`=`#1E90FF` | 壁纸调色板取不到颜色时的兜底 | `--ci-accent-palette-fallback` | `Settings.cs:1036-1039`、`Settings.cs:80` | | `AccentFillColorDefaultBrush` | 由 accent 派生(Fluent 资源) | 提醒遮罩填充、教程高亮框、进度条强调 | `--ci-accent-fill` | `ClassIsland.Core/StylesBase.axaml:90-93`、`XamlThemes/FluentTheme/Styles.axaml:180` | | `AccentFillColorTertiaryBrush` | accent 低透明度变体 | 课程列表展开态底色 | `--ci-accent-fill-tertiary` | `ClassIsland.Core/Controls/LessonsControls/LessonControlExpanded.axaml:182` | | `AccentTextFillColorPrimaryBrush` | accent 文字色 | 拖拽高亮描边、TeachingTip 指示箭头 | `--ci-accent-text` | `EditableComponentsListBox.axaml:310`、`MyTeachingTipStyles.axaml:213` | | `TextOnAccentFillColorPrimaryBrush` | 白/黑(随 accent 对比度) | 遮罩上的文字 | `--ci-text-on-accent` | `FluentTheme/Styles.axaml:162,182`;`ClassicTheme/Styles.axaml:142` | | `CustomizedAccentBarBackground1Brush` | 亮 `#FFDDE6F0` / 暗 `#26374C`;运行时被覆盖为 accent 的 10% 透明 | 「强调按钮」淡底、`Button.foreground-accent` | `--ci-accent-soft` | `Themes/LightTheme.axaml:18`、`Themes/DarkTheme.axaml:18`、`Services/ThemeService.cs:66-74` | 运行时强调色决策必须严格按以下分支实现(源码见 `ClassIsland/MainWindow.axaml.cs:743-764`): 1. `ColorSource == 0`(自定义)→ 使用 `PrimaryColor`; 2. `ColorSource == 1`(壁纸主题色)或 `3`(屏幕主题色)→ 使用 `SelectedPlatte`(壁纸调色板第 `SelectedPlatteIndex` 项); 3. `ColorSource == 2`(跟随系统)→ 传 `null`,交给系统强调色; 4. 其余 → 兜底 `DodgerBlue`。 `--ci-accent-soft` 必须以 `color-mix(in srgb, var(--ci-accent) 10%, transparent)` 实时推导,不得写死。ClassHelper 已有等价写法可复用:`packages/ui/src/theme.css:4-9` 用 `color-mix()` 派生 Element Plus 的 `--el-color-primary-light-*`。 #### 3.1.2 前景文字阶梯(前景色 5 级) 主界面自定义前景色时,前景色不是 1 个值,而是**由同一个基色派生出 5 个 alpha 版本**,必须按下表实现(源码:`ClassIsland.Core/Helpers/UI/ControlColorHelper.cs:20-52`): | 层级 | 资源键 | alpha | 用途 | 建议 CSS 变量 | |---|---|---|---|---| | 1 | `TextFillColorPrimaryBrush` | 255(100%) | 主要文字、标题 | `--ci-text-1` | | 2 | `TextFillColorInformativeSecondaryBrush` | 218(85.5%) | 次要说明(本项目覆盖值) | `--ci-text-2` | | 3 | `TextFillColorSecondaryBrush` | 197(77.3%) | 辅助文字 | `--ci-text-3` | | 4 | `TextFillColorTertiaryBrush` | 135(52.9%) | 装饰/弱提示 | `--ci-text-4` | | 5 | `TextFillColorDisabledBrush` | 93(36.5%) | 禁用态 | `--ci-text-5` | 实现规则: - 未启用自定义前景色时,上述 5 个 token 使用 Fluent 默认值:亮色 `#E4000000` / `#9E000000` / `#72000000` / `#5C000000`,深色 `#FFFFFFFF` 及对应 alpha 版本【推断:FluentAvalonia 资源值不在本仓库,按 Fluent 2 规范给出】。 - 启用自定义前景色时,5 个 token 全部按上表 alpha 从 `CustomForegroundColor` 派生,且**必须同时**重设 `TemplatedControl.Foreground` 与 `TextElement.Foreground`,否则模板内文字会漏改(`ControlColorHelper.cs:34-40`)。 - 关闭自定义前景色时,必须 `Remove` 这 5 个局部资源并 `SetValue(..., AvaloniaProperty.UnsetValue)` 回落(`ControlColorHelper.cs:44-50`)——映射到 CSS 即删除元素上的内联 `--ci-text-*`,回落 `:root`。 - 两个默认覆盖值:亮色 `TextFillColorInformativeSecondaryBrush = #c0000000`(75% 黑),深色 `#daffffff`(85.5% 白)(`Themes/LightTheme.axaml:20`、`Themes/DarkTheme.axaml:20`)。 - 语义层级类必须映射为 CSS 工具类:`.l-primary|.l1 → --ci-text-1`、`.l-secondary|.l2 → --ci-text-2`、`.l-instructive|.l3 → --ci-text-3`、`.l-decoration|.l4 → --ci-text-4`、`.l-inactive|.l5 → --ci-text-5`(`ClassIsland.Core/Themes/Layers.axaml:10-43`)。 #### 3.1.3 背景与表面色 | Token 名 | 亮色 | 深色 | 用途 | 建议 CSS 变量 | 源码证据 | |---|---|---|---|---|---| | `SolidBackgroundFillColorBaseBrush` | `#F3F3F3`【推断】 | `#202020`【推断】 | 经典主题岛背景、编辑模式窗口底、抽屉内容底、主题卡渐变底 | `--ci-bg-base` | `ClassicTheme/Styles.axaml:214`;`DrawerHost.axaml:29`;`MainWindow.axaml:188` | | `SolidBackgroundFillColorSecondaryBrush` | `#EEEEEE`【推断】 | `#1C1C1C`【推断】 | Fluent 主题岛背景 | `--ci-bg-secondary` | `FluentTheme/Styles.axaml:45` | | `SolidBackgroundFillColorTertiaryBrush` | `#F9F9F9`【推断】 | `#282828`【推断】 | 弹出层底(`.popup-bg`) | `--ci-bg-tertiary` | `StylesBase.axaml:291` | | `ControlFillColorSecondaryBrush` | 半透明白/黑 | 同左 | 主题卡底色 | `--ci-fill-secondary` | `ThemesSettingsPage.axaml:149` | | `SmokeFillColorDefaultBrush` | 半透明遮罩 | 同左 | 编辑模式窗口背景、抽屉忽略层 | `--ci-smoke` | `MainWindow.axaml:178`;`DrawerHost.axaml:20` | | `Layer` 应用底色 | `#F3F3F3` | `#161719` | 设置中心/管理页页面底(沿用 ClassHelper) | `--ch-bg` | `ClassHelper/renderer/styles/index.css:27,114` | | `Layer` 卡片底 | `rgba(255,255,255,.7)` | `rgba(32,33,36,.72)` | 应用外壳卡片 | `--ch-layer` | 同上 `:28,115` | | `CustomBackgroundColor` | `#000000`(默认) | 同左 | 岛自定义背景色默认值 | `--ci-island-bg-custom` | `Settings.cs:34`、`Settings.cs:1129` | 主界面行的背景最终呈现必须叠加两个不透明度: - 自定义背景色本身的不透明度:`--ci-island-bg-opacity`,默认 `0.5`(`Settings.cs:64`、`:1141`;Fluent 用 `MainWindowStylesAssist.BackgroundOpacity` 绑定到 `Border.Opacity`,见 `FluentTheme/Styles.axaml:44`); - 整行不透明度:`MainWindowLineSettings.Opacity`,默认 `1.0`(`ClassIsland.Core/Models/Components/MainWindowLineSettings.cs:42`;`Controls/MainWindowLine.axaml:51`)。 - 鼠标移出淡化态:整行 `opacity = 0.05`(`Controls/MainWindowLine.axaml:73-75`)。 #### 3.1.4 描边与分隔线 | Token 名 | 值 | 用途 | 建议 CSS 变量 | 源码证据 | |---|---|---|---|---| | 岛描边 | 1px `ControlElevationBorderBrush` | 经典/Fluent 行背景描边 | `--ci-stroke-island` / 宽度 `--ci-stroke-island-w:1px` | `FluentTheme/Styles.axaml:46-47`;`ClassicTheme/Styles.axaml:61-65` | | `ControlStrokeColorDefaultBrush` | 亮色半透明黑 | 编辑模式选中框描边 | `--ci-stroke-control` | `MainWindow.axaml:357` | | `DividerStrokeColorDefaultBrush` | 亮色半透明黑 | 操作条分隔线(1px,长 25px) | `--ci-stroke-divider` | `MainWindow.axaml:610-612` | | `SurfaceStrokeColorFlyoutBrush` | 半透明 | 弹窗描边 | `--ci-stroke-flyout` | `StylesBase.axaml:292` | | `ControlStrongFillColorDefaultBrush` | 中灰 | OOBE 方块、Workflow 进度条底、Shimmer 底 | `--ci-fill-strong` | `OobeIntroAnimationControl.axaml:34`;`WorkflowProgressIndicatorItemControl.axaml:26`;`Shimmer.axaml:11` | | 主题卡描边 | 1px `ControlElevationBorderBrush` | 主题网格卡片 | `--ci-stroke-card` | `ThemesSettingsPage.axaml:150-151` | | 拖拽高亮描边 | 1px accent | 组件拖拽占位 | `--ci-stroke-drag` | `EditableComponentsListBox.axaml:310-311` | | 教程高亮描边 | 2.5px(外框)/ 3px(四角) | 教程 spotlight | `--ci-stroke-tutorial` | `TutorialHighlightControl.axaml:96-118` | #### 3.1.5 状态色 天气与空气质量状态色(浅色/深色两套,必须都实现): | 语义 | 亮色 | 深色 | 建议 CSS 变量 | 源码证据 | |---|---|---|---|---| | AQI 1 优 | `ForestGreen`=`#228B22` | `MediumSeaGreen`=`#3CB371` | `--ci-aqi-1` | `LightTheme.axaml:10`;`DarkTheme.axaml:10` | | AQI 2 良 | `#CCCC00` | `Yellow`=`#FFFF00` | `--ci-aqi-2` | `:11` | | AQI 3 轻度 | `DarkOrange`=`#FF8C00` | `Orange`=`#FFA500` | `--ci-aqi-3` | `:12` | | AQI 4 中度 | `IndianRed`=`#CD5C5C` | 同亮色 | `--ci-aqi-4` | `:13` | | AQI 5 重度 | `DarkRed`=`#8B0000` | 同亮色 | `--ci-aqi-5` | `:14` | | AQI 6 严重 | `Purple`=`#800080` | `MediumPurple`=`#9370DB` | `--ci-aqi-6` | `:15` | | 雨天图标色 | `Blue`=`#0000FF` | `DeepSkyBlue`=`#00BFFF` | `--ci-weather-rain` | `LightTheme.axaml:3`;`DarkTheme.axaml:3` | | 上课发光 | `DropShadowEffect` blur 6(亮)/2(暗),颜色 `#FFFF00` | 同左 | `--ci-glow-class` | `LightTheme.axaml:4-7`;`DarkTheme.axaml:4-7` | | 危险/删除按钮 | `IndianRed`=`#CD5C5C` | 同左 | `--ci-danger` | `Themes/Generic.xaml:225-226,275-276`;`MainWindow.axaml:435` | | 警告/错误图标 | `SystemFillColorCriticalBrush` / `SystemFillColorCautionBackgroundBrush` | 同左 | `--ci-warning-*` | `StylesBase.axaml:209,217`;`ThemesSettingsPage.axaml:343` | #### 3.1.6 阴影与遮罩色(必须逐值实现) | 用途 | 值 | 建议 CSS 变量 | 源码证据 | |---|---|---|---| | Fluent 岛投影 | `0 4 8 2 #48000000` | `--ci-shadow-island: 0 4px 8px 2px #48000000` | `FluentTheme/Styles.axaml:49` | | 提醒遮罩时岛投影 | `0 8 32 0 #66000000` | `--ci-shadow-mask` | `FluentTheme/Styles.axaml:324,327` | | 竖抽屉/遮罩卡投影 | `0 8 32 0 #66000000` | `--ci-shadow-drawer` | `VerticalDrawer.axaml:45` | | 横抽屉内浮层投影 | `0 0 8 #33000000` | `--ci-shadow-drawer-x` | `DrawerHost.axaml:33` | | 经典主题提醒时行投影 | `DropShadowEffect` BlurRadius=8, Opacity=1, Offset=0 | `--ci-shadow-classic-mask` | `ClassicTheme/Styles.axaml:283-287` | | 时间线装饰器阴影 | `DropShadowEffect` opacity 0.4, blur 8 | `--ci-shadow-adorn` | `Themes/Generic.xaml:215-219` | | 弹窗底渐变遮罩 | `#00ffffff`→(0.65)`#00ffffff`→(1)`#ffffffff` | `--ci-mask-fade` | `ThemesSettingsPage.axaml:162-169` | | 教程聚光遮罩 | `#99000000`(60% 黑) | `--ci-scrim-tutorial` | `TutorialSpotlightControl.axaml:45-61` | | 编辑模式调试叠加 | `#77222222` | `--ci-debug-bg` | `MainWindow.axaml:645` | | 启动闪屏底 | `#88111111` + 1px `ControlStrokeColorOnAccentDefaultBrush` | `--ci-splash-bg` | `SplashWindow.axaml:24-28` | | 主题卡渐变底 | 径向 `#ff0c0e1f`→`#ff0d1111`(关于页) | `--ci-brand-gradient-a/b` | `AboutSettingsPage.axaml:153-154` | | 关于页正文底 | `#0d1111` | `--ci-brand-dark` | `AboutSettingsPage.axaml:131` | | 通知涟漪默认填充 | `AccentFillColorDefaultBrush`(可被 `NotificationContent.Color` 覆盖) | `--ci-ripple` | `RippleEffect.axaml.cs:61`;`MainWindowLine.cs:750` | | 通知涟漪描边宽 | 200(Ellipse StrokeThickness) | `--ci-ripple-stroke:200` | `RippleEffect.axaml:15` | ### 3.2 字体 Token #### 3.2.1 字体族 | Token | 值 | 用途 | 建议 CSS 变量/写法 | |---|---|---|---| | `AppFont`(全局默认) | `HarmonyOS Sans SC, Microsoft YaHei UI, Noto Sans CJK SC, Noto Sans SC, PingFang SC, Source Han Sans SC, SimHei, WenQuanYi Micro Hei, sans-serif, FluentSystemIcons-Resizable` | 全应用默认字体 | `--ci-font: 'HarmonyOS Sans SC', 'Microsoft YaHei UI', 'Noto Sans CJK SC', 'Noto Sans SC', 'PingFang SC', 'Source Han Sans SC', SimHei, sans-serif` | | `MainWindowFont`(岛字体,用户可改) | 默认等于 `DefaultFontFamilyKey`:`avares://ClassIsland/Assets/Fonts/#HarmonyOS Sans SC, ...FluentSystemIcons-Resizable` | 只作用于岛的 `ZoomBorder` 文本 | `--ci-font-island`(元素级覆盖) | | `HarmonyOsSans`(旧键) | `HarmonyOS Sans SC, Microsoft YaHei UI, SimHei` | 旧样式/右键菜单 | 兼容别名 | | `BackendFontFamily` | `Microsoft YaHei UI` | 后端渲染/图表兜底 | `--ci-font-backend` | | 图标字体 1 | `FluentSystemIcons-Resizable`(`avares://ClassIsland.Core/Assets/Fonts/#FluentSystemIcons-Resizable`) | 全局 Fluent 图标 | `--ci-font-icon-fluent` | | 图标字体 2 | `lucide`(`#lucide`) | Lucide 天气图标等 | `--ci-font-icon-lucide` | | 图标字体 3 | `SF-Symbols`(`#SF-Symbols`) | SFSymbols 天气图标 | `--ci-font-icon-sf` | 证据:`ClassIsland/Assets/Fonts/Fonts.axaml:3`、`Assets/Fonts/Fonts.xaml:3`、`MainWindow.axaml.cs:185-189`、`App.axaml:48`、`ClassIsland.Core/AppBase.cs:142-152`、`ClassIsland.Core/ThemeBase.axaml:46-47`。 实现要求: - 字体栈**必须按上表顺序**声明,任何一环缺失即回落下一位;不得只写 `HarmonyOS Sans SC`。 - 字体文件必须以本地资源打包,**禁止**运行时从网络加载字体(离线教室环境)。 - 图标字体与正文字体必须拆成两个 token,图标组件禁止继承正文字体族。 #### 3.2.2 字重 字重设置以整数保存,映射表必须逐值实现(`ClassIsland.Core/Converters/IntToFontWeightConverter.cs:16-28`): | 数值 | 字重名 | CSS | |---|---|---| | 100 | Thin | 100 | | 200 | ExtraLight | 200 | | 300 | Light | 300 | | 400 | Normal | 400 | | 500 | Medium(**默认**) | 500 | | 600 | SemiBold | 600 | | 700 | Bold | 700 | | 800 | ExtraBold | 800 | | 900 | Black | 900 | | 其他 | Normal | 400 | 默认字重来自 `Settings.cs:101`(`(int)FontWeight.Medium` = 500)。岛内绝大多数文字必须用 500(Medium),标题可用 700(Bold),说明文字用 400(Regular)。 4 档核心字号在设置页的输入范围均为 **1–40 px**(`AppearanceSettingsPage.axaml:482-533`,`NumberBox` 的 `Minimum=1`、`Maximum=40`)。实现时必须允许 1–40 的整数/小数输入,并在预览区用四行示例文字实时展示 4 档效果(源码「字号预览」tab:`AppearanceSettingsPage.axaml:570-591`)。 #### 3.2.3 字号阶梯(核心 4 档 + 扩展档) 核心 4 档是**用户可调**的「主界面字号」,默认值与用途如下(`Settings.cs:197-200`;`ClassIsland.Core/ThemeBase.axaml:32-35`;`Settings.cs:1209-1251`): | Token | 默认 px | 用途 | 建议 CSS 变量 | 证据 | |---|---|---|---|---| | `MainWindowSecondaryFontSize` | 14 | 次要信息:天气副标题、日期星期、倒计时标签 | `--ci-fs-secondary` | `ThemeBase.axaml:32`;`WeatherComponent.axaml:261,294,296,305` | | `MainWindowBodyFontSize` | 16 | 正文:课程名、时间、温度 | `--ci-fs-body` | `ThemeBase.axaml:33`;`MainWindow.axaml:313`;`CountDownComponent.axaml:65` | | `MainWindowEmphasizedFontSize` | 18 | 强调:时钟、提醒主文案 | `--ci-fs-emphasized` | `ThemeBase.axaml:34`;`ClockComponent.axaml:14`;`TwoIconsMaskTemplate.axaml:14` | | `MainWindowLargeFontSize` | 20 | 大号标题 | `--ci-fs-large` | `ThemeBase.axaml:35` | 扩展固定档(非用户设置,必须写死为 token 并说明来源): | 字号 | 用途 | 建议 CSS 变量 | 证据 | |---|---|---|---| | 9 | 启动闪屏版本号(0.5 不透明度) | `--ci-fs-version` | `SplashWindow.axaml:86-87` | | 12 | 主题卡作者名 | `--ci-fs-caption` | `ThemesSettingsPage.axaml:191` | | 13 | 组件设置小字、ClassHelper 控件基准 | `--ci-fs-control-base` | `CountDownComponentSettingsControl.axaml:185-201`;`ClassHelper/renderer/styles/index.css:97` | | 14 | 通用控件/次级标签 | `--ci-fs-label` | `MainWindow.axaml:406,452,497` | | 16 | 主题卡名称、闪屏状态文字 | `--ci-fs-title` | `ThemesSettingsPage.axaml:188`;`SplashWindow.axaml:80` | | 17 | 滚动提醒文字 | `--ci-fs-rolling` | `RollingTextTemplate.axaml:26` | | 18 | OOBE 说明文字 | `--ci-fs-oobe-body` | `WelcomeWindowIntroControl.xaml:166` | | 20 | 启动 logo 文字、导航标签 | `--ci-fs-brand` | `SplashWindow.axaml:56-58`;`StylesBase.axaml:118-123` | | 22 | 天气温度/图标文字 | `--ci-fs-weather` | `WeatherComponent.axaml:86-175` | | 32 | OOBE 大标题 | `--ci-fs-oobe-title` | `OobeIntroAnimationControl.axaml:69,87`;`WelcomePage.axaml:38` | 实现硬约束: - 4 档核心字号必须由**用户设置驱动**,且必须能作用到「组件自身」与「单个主界面行」两个作用域:行级覆盖时把 4 个变量写到行根元素的 `style` 上;关闭行级覆盖时移除内联变量,回落 `:root`(`MainWindowCustomizableNodeHelper.cs:20-37`)。 - 缩放:所有字号乘以界面缩放 `--ci-scale`(默认 1.0),但**不得**用浏览器 zoom,必须用布局缩放以保持像素对齐(见 3.8)。 - 行高:源码未给出显式 `LineHeight`,建议正文 `line-height: 1.25`、说明文字 `1.6`【推断:依据 ClassHelper `theme.css:60-63` 的 `line-height:1.6` 与 ClassIsland 主界面 40px 行高/16px 字号的比值】。 ### 3.3 圆角 Token | Token | 默认值 | 用途 | 建议 CSS 变量 | 证据 | |---|---|---|---|---| | `RadiusX` / `RadiusY`(岛圆角) | `8`(UI 范围 `0–20`,步进 `0.5`,提示文案:调至最大时左右边缘为半圆) | 主界面行背景圆角;`SizeDoubleToCornerRadiusConverter` 以 `ConverterParameter=1.0` 直接取值(除以 1 即原值) | `--ci-radius-island: 8px` | `Settings.cs:183-184`、`:1187-1207`;`AppearanceSettingsPage.axaml:75-87`;`FluentTheme/Styles.axaml:48,91,124,160-168,252`;`SizeDoubleToCornerRadiusConverter.cs:12-18` | | 行级自定义圆角 | `0`(关闭) | 单行覆盖圆角 | `--ci-radius-island-local` | `MainWindowLineSettings.cs:40-41` | | 岛内容高度 | `40` | 所有行的容器高度(Fluent/Classic 同值) | `--ci-island-height: 40px` | `FluentTheme/Styles.axaml:65`;`ClassicTheme/Styles.axaml:16`;`MainWindowLine.axaml:16` | | 弹窗圆角 | `6` | `.popup-bg`、编辑模式操作条 | `--ci-radius-popup: 6px` | `StylesBase.axaml:295`;`MainWindow.axaml:488,599` | | 卡片圆角 | `4` | 设置项卡片、主题卡、Shimmer、时间线装饰器、OOBE 提示 | `--ci-radius-card: 4px` | `StylesBase.axaml:257-260`;`ThemesSettingsPage.axaml:152,161`;`Themes/Generic.xaml:214` | | 控件圆角 | `4` | 按钮/输入框等 | `--ci-radius-control: 4px` | ClassHelper `index.css:52`;`theme.css:10` | | 抽屉圆角 | `8 8 0 0` | 底部竖抽屉 | `--ci-radius-drawer: 8px 8px 0 0` | `VerticalDrawer.axaml:9` | | 胶囊圆角 | `999` | 徽标、胶囊按钮 | `--ci-radius-pill: 999px` | `classic-island.css:308,454,541,694` | | 圆形按钮 | `14`(28×28) | 主题卡安装按钮 | `--ci-radius-icon-btn: 14px` | `ThemesSettingsPage.axaml:246-249` | | OOBE 大圆钮 | `24`(48×48) | 欢迎页「下一步」 | `--ci-radius-oobe-btn: 24px` | `WelcomePage.axaml:61-65` | | 岛内小元素 | `0.5 × --ci-island-font` / `0.36 × --ci-island-font` | 岛内图标块 | 用 calc 派生 | `classic-island.css:388,403,476,502` | 实现硬约束: - 岛圆角必须**四角同值**(源码把 `RadiusX` 同时传给宽高两个方向:`FluentTheme/Styles.axaml:31-34` 传两次 `RadiusX`),不得实现「椭圆圆角」。 - 圆角必须作用于「背景层」与「内容裁剪层」两层,且两层同值,否则内容会溢出圆角外(`FluentTheme/Styles.axaml:48,91,124,160-168`)。 - 若引入 ClassHelper 的连续圆角(squircle),必须保持 `SUPERELLIPSE_EXPONENT = 4.2`、每角 `CORNER_SEGMENTS = 14`、展开卡半径 `min(48*scale, w/2, h/2)` 不变(`ClassHelper/packages/desktop-client/src/island/squircle.ts:21,24,79-81`)。**默认实现必须用 CSS `border-radius`**(与 Avalonia `CornerRadius` 的直角圆弧一致,保证与 ClassIsland 逐像素相同);连续圆角只能由主题包显式启用(P2),不得作为默认岛外形。 ### 3.4 间距 Token | Token | 值 | 用途 | 建议 CSS 变量 | 证据 | |---|---|---|---|---| | 行内容左右内边距 | `12 0` | 岛内容区 `GridContentRoot.Margin` | `--ci-island-pad-x: 12px` | `FluentTheme/Styles.axaml:110`;`ClassicTheme/Styles.axaml:100` | | 行内容最小宽度 | `20` | `MinWidth` | `--ci-island-min-w: 20px` | `FluentTheme/Styles.axaml:109` | | 组件间距 | `6 0` | 每个 `ComponentPresenter.Margin`;**token 值取相邻间距 12px**(每个组件左右各 6px),供 `calc(var(--ci-component-gap) / 2)` 反推单侧 margin | `--ci-component-gap: 12px` | `MainWindow.axaml:396,546`;与 part 04 §4.4 对齐 | | 行间距 | `5` | 多行堆叠 `StackPanel.Spacing`(用户设置) | `--ci-line-gap: 5px` | `Settings.cs:219`;`MainWindow.axaml:344` | | 设置页容器 | `margin 12 12 18 12`;`max-width 960`;`spacing 4` | 设置页正文 | `--ci-settings-pad` / `--ci-settings-max-w: 960px` | `StylesBase.axaml:67-71`;`ThemeBase.axaml:37` | | 组件设置容器 | `margin 4 0 4 12`;`max-width 960`;`spacing 4` | 子组件设置 | `--ci-component-settings-pad` | `StylesBase.axaml:73-77` | | 弹窗内边距 | `12` | `.popup-bg` | `--ci-popup-pad: 12px` | `StylesBase.axaml:294` | | 主题卡网格 | 单元高 `150`;目标宽 `328`;单元 `margin 2`;卡内 `padding 8` | 主题市场网格 | `--ci-theme-card-h: 150px`、`--ci-theme-card-w: 328px` | `ThemesSettingsPage.axaml:139-142,172` | | 编辑模式操作条 | 底距 `64`;`padding 4`;按钮间距 `6` | 编辑态底部工具条 | `--ci-editbar-bottom: 64px` | `MainWindow.axaml:595-604` | | 闪屏 | 尺寸 `400×100`;`padding 17`;logo 高 `24`;行距 `8`;底部进度条高 `4` | 启动闪屏 | `--ci-splash-w: 400px`、`--ci-splash-h: 100px` | `SplashWindow.axaml:16-17,24,29,42,92-96` | | 岛内水平内边距(ClassHelper 风格) | `0.78 × 字号` | 岛胶囊左右内边距 | calc 派生 | `classic-island.css:215` | | 进度条高度 | `3px` | 岛内倒计时/提醒进度条统一高度 | `--ci-progress-height: 3px` | 与 part 04 §4.4 对齐;源码进度条未显式设高(`FluentTheme/Styles.axaml:126-146`),取 FluentAvalonia ProgressBar 默认值【推断】 | | 岛内元素间距(ClassHelper) | `0.62 × 字号` | 岛内行间距 | calc 派生 | `classic-island.css:212` | ### 3.5 透明与模糊 Token | Token | 值 | 用途 | 建议实现 | 证据 | |---|---|---|---|---| | 主窗口透明级别 | `Transparent` | 非编辑态的岛窗口必须逐像素透明 | Electron `transparent: true` + `backgroundColor: '#00000000'` | `MainWindow.axaml:168-176` | | 编辑模式透明级别 | `AcrylicBlur, Transparent`(回退 Transparent) | 编辑态窗口使用亚克力 | 只能用 CSS `backdrop-filter` 在自绘层模拟,**不得**用 `setBackgroundMaterial`(会破坏逐像素透明) | `MainWindow.axaml:177-181`;ClassHelper `island.ts:1726-1734` 明确禁止 | | 普通窗口模式 | 若禁用透明则 `TransparencyLevelHint=None` | 窗口模式 | `transparent:false` | `MainWindow.axaml:187-190` | | 闪屏透明 | `AcrylicBlur, Transparent` | 启动闪屏 | `backdrop-filter: blur(...)` + 半透明底 | `SplashWindow.axaml:21` | | 岛背景不透明度 | `0.5`(默认范围 0–1,步进 0.05,滑块带数值气泡) | 背景层 opacity | `--ci-island-opacity`(主名;兼容别名 `--ci-island-bg-opacity`) | `Settings.cs:64,1141`;`AppearanceSettingsPage.axaml:61-72`;`FluentTheme/Styles.axaml:44` | | 行整体不透明度 | `1.0`(行级设置,可被单行覆盖) | 行级 opacity | `--ci-line-opacity` | `MainWindowLineSettings.cs:42` | | 鼠标移出淡化 | `0.05` | 悬停淡出模式 | `opacity:.05` | `MainWindowLine.axaml:73-75` | | 提醒特效渲染缩放 | `1.0`;屏高 ≥1400 → `0.75`;≥2000 → `0.5`(自动时) | 强提醒涟漪窗口缩放 | `--ci-effect-scale` | `Settings.cs:150`;`MainWindow.axaml.cs:377-392` | | 遮罩动画层级不透明度 | 0 → 1(0.1s) | 遮罩出现时附加投影 | `box-shadow` transition | `FluentTheme/Styles.axaml:355-367` | 硬规则: - **不得**给岛卡片加 `backdrop-filter`(会把整块矩形模糊掉);模糊只允许出现在「编辑模式窗口背景」与「闪屏底」这两个整窗层。 - 岛必须是「透明窗口 + 自绘圆角卡片」,卡片外区域完全透明、可点击穿透(与 ClassHelper 一致,见 `island.ts:250-257`)。 ### 3.6 层级(z-index)Token | 层 | 数值 | 用途 | 建议 CSS 变量 | 证据 | |---|---|---|---|---| | 窗口层级-底层 | `WindowLayer=0` | 岛沉到桌面层(`Bottommost`),仅提醒/编辑时置顶 | `--ci-z-window:0` | `Settings.cs:52`;`MainWindow.axaml.cs:843-874` | | 窗口层级-置顶 | `WindowLayer=1`(默认) | 岛始终置顶 | `--ci-z-window-top:1` | 同上;默认值 `Settings.cs:52` | | 岛内容层 | 0 | 正常内容 | — | — | | 遮罩/覆盖层 | 1 | 内容之上、背景之下 | `--ci-z-mask:1` | `FluentTheme/Styles.axaml:155-197` | | 拖拽占位 | `1`(且 opacity .5) | 编辑态拖动项 | `--ci-z-drag:1` | `MainWindow.axaml:478-481` | | 时间线装饰器 | `9999` | 分割线/行动装饰器必须压住时间块 | `--ci-z-timeline-adorn:9999` | `Themes/Generic.xaml:193,243` | | 「无穷大」哨兵 | `33550336` | 必须永远最前的元素(OOBE 方块、时间线提示、拖拽阈值) | `--ci-z-sentinel:33550336` | `OobeIntroAnimationControl.axaml:45`;`TimeLineListControl.axaml:309`;`MainWindow.axaml:334` | | 提醒特效窗口 | 独立 Topmost 窗口 | 全屏涟漪,高于一切 | 独立 BrowserWindow `alwaysOnTop` | `Views/TopmostEffectWindow.axaml.cs:120-128` | | 教程聚光灯 | 独立置顶窗口 | 遮罩挖洞 | 独立窗口 | `Controls/Tutorial/TutorialSpotlightControl.axaml:41` | | 抽屉遮罩 | 覆盖内容 | 横/竖抽屉 | `--ci-z-drawer:10`【推断】 | `DrawerHost.axaml`、`VerticalDrawer.axaml` | | 应用外壳引导层 | `10000` | 全屏引导 | `--ch-z-guide:10000` | `ClassHelper/packages/ui/src/theme.css:155` | | 主题切换快照层 | 旧 `1` / 新 `2` | View Transition 两帧 | `--ch-z-view-old/new` | `ClassHelper/renderer/styles/index.css:948-954` | 实现必须满足: - 岛内容、遮罩、覆盖层在同一「行」内按上表叠放;多行之间不重叠(`MainWindowLine` 的 `ClipToBounds=False`,行本身不裁剪)。 - `33550336` 是源码里反复使用的「无穷大」哨兵(同时被用作拖拽阈值 `HorizontalDragThreshold="33550336"` 表达「禁止横向拖动」,见 `MainWindow.axaml:334`)。实现时可直接用 `z-index: 2147483647` 代替,但**必须**在常量注释里保留原值语义。 - 编辑器/设置中心内任何弹窗、Tooltip 都必须高于主内容;不得用负 z-index 实现遮罩。 ### 3.7 DPI 与缩放规则 | 规则 | 数值/算法 | 证据 | |---|---|---| | 界面缩放用户设置 | `Scale`,默认 `1.0`,UI 范围 `0.75–2.5`,步进 `0.05`(滑块带数值气泡,格式 `F2`) | `Settings.cs:86,1153`;`Views/SettingPages/AppearanceSettingsPage.axaml:27-39` | | 缩放实现 | 用 `LayoutTransform`(布局缩放)+ `ScaleTransform`,同时作用于所有行;**不得**用 `transform: scale()` 直接缩放窗口内容(会模糊) | `MainWindow.axaml:250-265` | | 编码缩放补偿 | `GridRootLeft = Width/10*(Scale-1)`、`GridRootTop = Height/10*(Scale-1)` | `MainWindow.axaml.cs:881-882,938-939` | | 编辑模式缩放边界 | `MinZoom 0.5` / `MaxZoom 3` / `ZoomSpeed 1.2`,`EnableConstrains=True`,显示缩放指示 | `MainWindow.axaml:199-209` | | 窗口尺寸换算 | 逻辑宽 = 物理宽 / `dpiX`;逻辑高 = (物理高 − 1) / `dpiY`(−1 防被误判全屏) | `MainWindow.axaml.cs:969-971` | | 窗口定位 | 使用**物理像素** `PixelPoint`,不做 dpi 换算 | `MainWindow.axaml.cs:905-909` | | 安全区(主题声明) | 竖直安全区 = 当前启用主题 `VerticalSafeAreaPx` 的最大值;Fluent 主题为 `20` | `Services/XamlThemeService.cs:111-135`;`App.Services.xaml.cs:314` | | 教程运行时的安全区 | 主题安全区与 `150` 取大者 | `MainWindow.axaml.cs:898-902` | | 通知特效窗口缩放 | `1 / dpiX` | `MainWindow.axaml.cs:929` | | 鼠标命中判定 | 屏幕坐标与 `Bounds * dpi * Scale` 比较 | `Controls/MainWindowLine.cs:625-636` | | 字体缩放(关怀模式,应用外壳) | `--ch-font-scale: 1`;关怀模式 `1.15`,只放大字号不动几何 | `ClassHelper/renderer/styles/index.css:103,973-979` | | 岛内字号缩放(ClassHelper 模型) | 所有岛内尺寸由 `--island-font` 用 `calc()` 派生 | `classic-island.css:2-7,57` | 实现要求: - 所有视觉常量必须定义在 96 DPI 的 CSS px 基准上,再由 `--ci-scale` 统一放大;不得为不同 DPI 写两套值。 - 多显示器:每行/每窗口使用「所在显示器」的 DPI 与工作区;切换显示器必须重新计算窗口尺寸与位置(`GetSelectedScreenSafe`,`MainWindow.axaml.cs:806-812`)。 - `Scale` 改变时必须重算窗口宽高与安全区,不能只改 CSS transform。 ### 3.8 深浅色规则 | 规则 | 要求 | 证据 | |---|---|---| | 主题模式枚举 | `0=跟随系统`、`1=明亮`、`2=黑暗`;默认 `2`(黑暗) | `Settings.cs:32,979-989`;`Services/ThemeService.cs:53-59` | | 跟随系统实现 | `themeVariant=Default` + `preferSystemTheme=true`;系统切换要实时响应 | `ThemeService.cs:53-63` | | 资源覆盖机制 | Light/Dark 两套 ThemeDictionary,同名 key 覆盖:`RainyColorBrush`、`LuminousClass`、6 个 AQI、`CustomizedAccentBarBackground1Brush`、`TextFillColorInformativeSecondaryBrush` | `App.axaml:35-38`;`Themes/LightTheme.axaml`、`Themes/DarkTheme.axaml` | | 自定义前景色优先级 | 用户自定义前景色 > 主题前景色;关闭时回落主题 | `ControlColorHelper.cs:20-52` | | 自定义背景色优先级 | 用户自定义背景色 > 主题背景;关闭时回落 `SolidBackgroundFillColor*` | `FluentTheme/Styles.axaml:51-56`;`ClassicTheme/Styles.axaml:216-218` | | 深色下的阴影 | 阴影颜色不变(黑),只调不透明度;**不得**在深色下把投影改成白色 | Fluent `#48000000`/`#66000000`/`#33000000` 与 `VerticalDrawer.axaml:47` 注释「Even in WinUI, shadow is always black regardless of light/dark mode」 | | ClassHelper 深色底色 | 应用壳亮 `#F3F3F3`/暗 `#161719`;卡片 `rgba(255,255,255,.7)` / `rgba(32,33,36,.72)` | `renderer/styles/index.css:27-48,114-137` | | 主题切换过渡 | 窗口 `Background` 用 200ms 过渡;整页换肤用圆形扩散 View Transition(320×1.5=480ms) | `MainWindow.axaml:40-44`;`ClassHelper/renderer/composables/motion.ts:226-235` | 实现禁止项: - 禁止在组件里写死 `#fff`/`#000`;必须走 `--ci-text-*`。 - 禁止只做深色、不做亮色(两套必须同步验收)。 - 禁止在深色模式使用纯黑 `#000000` 作为岛背景(默认自定义背景色是黑,但必须靠 `--ci-island-bg-opacity` 降为 50% 并叠加主题描边,否则与桌面融为一体)。 ### 3.9 ClassHelper 侧落点(实现映射表) | 内容 | 落点文件 | 要求 | |---|---|---| | 应用外壳 token | `packages/ui/src/tokens.css` | 保留 `--ch-ui-*`,新增 `--ci-*` 只读引用 | | 应用外壳主题 | `packages/ui/src/theme.css` | 把 `--ci-accent` 注入 `--el-color-primary` 系列,`color-mix` 派生 | | 桌面端全局样式 | `packages/desktop-client/src/renderer/styles/index.css` | 在 `:root` 与 `html.dark` 中定义 `--ci-*` 的亮/暗值 | | 岛渲染进程样式 | `src/island/classic-island.css`、`custom-island.css` | 定义 `--island-*` 与 `--wn-*`(`classic-island.css:60-82`) | | 动效常量 | `packages/shared/src/motion.ts` + 桌面端/管理端 `composables/motion.ts` | 新增 token 只加在 shared;不得两端各抄一份(`motion.ts:14-16` 明确约束) | | 设计常量的 TS 形态 | `packages/shared/src/constants.ts` | 输出 `CLASSISLAND_TOKENS` 常量对象,供窗口几何计算复用 | ### 3.10 落地顺序与常见错误 实现顺序必须严格如下,不得跳步: 1. 先在 `packages/shared/src/constants.ts` 建立常量对象(颜色、字号、圆角、间距、层级、DPI 基准),字段名与本节 token 名一一对应,导出为只读(`as const`)。 2. 在 `packages/ui/src/tokens.css` 的 `:root` 写入亮色值,在 `html.dark` 写入深色值;两处必须保持键集合完全一致,缺一个键都算不合格。 3. 在 `packages/desktop-client/src/renderer/styles/index.css` 引入 `--ci-*` 的桌面端覆盖(通知特效窗口、闪屏窗口等独立渲染入口要单独引入同一份 token)。 4. 在 `src/island/classic-island.css` 与 `custom-island.css` 里把 `--island-*` 映射到 `--ci-*`,禁止在岛样式里重新定义颜色字面量。 5. 最后才写组件;组件只允许消费 token。 常见错误(必须在实现时主动规避): - 把「行级字号覆盖」写成给整个岛设置字号:这样改一行会连带改所有行,与源码作用域不符(源码把资源写在行控件自身,见 `MainWindowCustomizableNodeHelper.cs:20-26`)。 - 把 5 级前景色实现成「同一颜色的不同 `opacity` 属性」:`opacity` 会连带影响子元素与阴影,源码用的是颜色 alpha 通道(`ControlColorHelper.cs:24-33`),必须写进颜色值本身。 - 用 `filter: drop-shadow()` 代替 `box-shadow` 实现岛投影:`filter` 会新建渲染表面,开合时出现闪帧(ClassHelper `classic-island.css:114-120` 已记录该问题)。 - 在深色主题下把阴影改成 `rgba(0,0,0,.6)` 以外的颜色,或直接删除阴影:源码在两种主题下都用黑色阴影,只改透明度。 - 忘记「安全区」:Fluent 主题声明了 `20px` 竖直安全区,岛贴边时会空出这段距离(`App.Services.xaml.cs:314`、`XamlThemeService.cs:111-135`),漏实现会导致岛压住任务栏或屏幕边缘。 - 忘记 `Scale` 变化时重算窗口:只改 CSS 会导致点击热区与可见区域错位(源码在 `MainWindow.axaml.cs:876-931` 每次都重算 `Bounds` 与 `ActualClientBound`)。 - 把「编辑模式操作条」的层级设在岛内容之下:它必须浮在行内容之上,且底距 64px(`MainWindow.axaml:595`)。 ### 3.11 验收标准 实现者必须逐条自查,全部通过才算完成本节: 1. **颜色**:全仓库(渲染进程 + 主进程 + shared)搜索,除 token 定义文件外不得出现 `#` 开头的颜色字面量;`grep -r "#[0-9a-fA-F]\{6\}"` 的命中只允许出现在 token 文件与主题包预览样式中。 2. **强调色**:修改 `--ci-accent` 一处,岛背景描边、提醒遮罩、进度条、按钮强调态、天气图标强调色**全部**同步变化,无残留默认蓝。 3. **5 级前景**:开启自定义前景色后,5 个 alpha 分别为 100%/85.5%/77.3%/52.9%/36.5%;关闭后 5 个变量被移除并回落。 4. **字号**:把 4 档字号分别改成 10/20/30/40,岛内所有文字按对应档位变化且不溢出 40px 行高外的容器;行级覆盖可只影响单行。 5. **圆角**:把 `--ci-radius-island` 从 8 改到 20,背景层与裁剪层同时变化,内容不溢出四角;其他卡片圆角不受影响。 6. **间距**:改 `--ci-island-pad-x` 与 `--ci-component-gap`,内容宽度变化后背景宽度动画仍正常(见 part 14)。 7. **透明**:非编辑态岛卡片外区域 100% 透明(用取色器/截图验证 alpha=0);编辑态出现亚克力底;全程不允许整窗材质导致卡片外出现色块。 8. **层级**:编辑态拖动项浮在其他项之上;提醒遮罩覆盖内容但低于右上角操作按钮(若有);`33550336` 哨兵元素永远最前。 9. **DPI**:在 100%/125%/150%/200% 四档缩放下,岛高度逻辑值恒为 40、圆角逻辑值恒为 8、文字不模糊;跨显示器拖动后重新计算,无半个像素的缝。 10. **深浅色**:Light/Dark 各截图对照,两类主题的 AQI 色、雨天色、说明文字 alpha 与上表一致;深色下阴影仍为黑色。 11. **CSS 变量唯一来源**:`--ci-*` 只在两处定义(`:root` 与 `html.dark`),组件内只引用不重定义(行级作用域覆盖除外)。 12. **字符集**:所有 token 名使用 kebab-case,前缀统一为 `ci-`;禁止出现 `--ci-primaryColor` 这类混合命名。 --- ## 4. 主界面(岛):窗口、行、背景、进度、遮罩、编辑模式 本节是「主界面(MainWindow,俗称岛)」的完整实现规格。实现技术栈固定为 ClassHelper 的 Electron + Vue 3 + TypeScript:主进程用 `BrowserWindow` 复刻 Avalonia `Window` 的窗口级行为,渲染进程用 Vue 单文件组件 + CSS 复刻 `MainWindow.axaml` 的控件树与全部像素参数。所有 Avalonia 的"逻辑像素(DIP,96 DPI 基准)"一律直接对应 CSS `px`;`Settings.Scale` 用 `transform: scale()` 实现。 ### 4.1 术语与实现映射总则 必须先建立下面这张映射表,后文所有规则都按它落地: | ClassIsland 概念 | 源码实体 | 本实现替代物 | |---|---|---| | 主界面窗口 | `MainWindow : Window`(`repos/ClassIsland/ClassIsland/MainWindow.axaml`) | 独立 `island.html` 渲染入口 + 主进程 `BrowserWindow`(透明、无边框、置顶、跳过任务栏) | | 主界面行 | `MainWindowLine`(`repos/ClassIsland/ClassIsland/Controls/MainWindowLine.cs`) | `IslandLine.vue`(Vue 组件),一行一个 | | 组件宿主 | `ComponentPresenter`(`repos/ClassIsland/ClassIsland.Core/Controls/ComponentPresenter.axaml`) | `ComponentHost.vue`(统一承载宽度约束/透明度/隐藏规则/裁剪) | | 组件 | `DateComponent` / `ClockComponent` / … | `components/DateNode.vue` 等,见第 5 章 | | 主题样式 | `XamlThemes/FluentTheme/Styles.axaml`、`ClassicTheme/Styles.axaml` | `island.css` + CSS 变量主题包(`--island-*`) | | 编辑模式视图 | `Controls/EditMode/EditModeView.axaml` | `IslandEditMode.vue`(抽屉 + 拖动 + 选择) | | 提醒宿主 | 行模板内的 Overlay / Mask 层 | 行内 `.line-mask` / `.line-overlay` | 强制约定: 1. 不得把主界面做成"一个自适应大小的 div 窗口",也不得照搬 ClassIsland 的"窗口高度随内容自适应"。采用 4.3.1 的**固定包围盒**:窗口宽度 = 所选显示器整屏宽(DIP),高度 = 预设最大内容高 + 上下安全余量;**可见岛**在窗口内形变,其高度 = 行高之和 + 行间距之和。窗口大于岛的透明区域不会遮挡桌面(Windows 侧整窗被设为鼠标穿透,见 4.2)。 2. 不得使用 `win.setBackgroundMaterial()`(亚克力/mica)。ClassIsland 的透明是逐像素 alpha + `WS_EX_LAYERED`,用系统材质会破坏像素级透明与圆角(`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:523-524` 的既有结论)。 3. 不得给主界面行加 CSS `box-shadow` 之外的重滤镜;Fluent 主题只有 `BoxShadow`,Classic 主题只有提醒遮罩期才挂 `DropShadowEffect`。 4. 所有尺寸、时长、缓动必须按本文给的确切值写死为常量或 CSS 变量,不得凭观感微调。 ### 4.2 窗口(BrowserWindow)逐项规格 按下面顺序创建窗口(默认非编辑模式): ```ts const win = new BrowserWindow({ ...geometry, // 见 4.3,x / y / width / height show: false, frame: false, // Avalonia: SystemDecorations=None(MainWindow.axaml:171) transparent: true, // Background=Transparent + TransparencyLevelHint=Transparent(:169-170) resizable: false, // CanResize=False(:172) movable: false, minimizable: false, // CanMinimize=False(:174) maximizable: false, // CanMaximize=False(:173) fullscreenable: false, skipTaskbar: true, // 默认不进任务栏;例外见下 hasShadow: false, // 阴影由岛自己的 CSS box-shadow 画 focusable: false, // 普通态不抢焦点(配合 showInactive) alwaysOnTop: true, // WindowLayer 默认 1=置顶(Settings.cs:52) type: 'toolbar', // 对应 WS_EX_TOOLWINDOW(MainWindow.axaml.cs:838) backgroundColor: '#00000000', webPreferences: { preload: /* island preload 白名单 */, contextIsolation: true, nodeIntegration: false, sandbox: true, backgroundThrottling: false, }, }); ``` 逐条规则: 1. **透明与无边框**:`transparent: true` + `frame: false` + `hasShadow: false` + `backgroundColor: '#00000000'`。HTML 根节点(`html, body, #app`)必须 `margin:0;padding:0;width:100%;height:100%;background:transparent;overflow:hidden;-webkit-user-select:none;user-select:none`(对齐 `repos/ClassHelper-master/packages/desktop-client/src/island/classic-island.css:8-19`)。 2. **鼠标穿透(核心)**:ClassIsland 在 Windows 上给主窗口加 `WS_EX_LAYERED | WS_EX_TRANSPARENT | WS_EX_NOACTIVATE` 并调用 `SetLayeredWindowAttributes`(`repos/ClassIsland/platforms/ClassIsland.Platforms.Windows/Services/WindowPlatformService.cs:130-150`),因此**非编辑模式下整个窗口对鼠标完全透明**,点击直接落到桌面。由此得: - 非编辑模式:调用 `win.setIgnoreMouseEvents(true, { forward: true })`,且不得注册任何需要点击的交互(行、组件都不可点)。 - 编辑模式:调用 `win.setIgnoreMouseEvents(false)` 恢复命中,并 `win.focus()`。 - 不得实现"点岛展开/收起"这类交互;ClassIsland 的主界面是纯展示层。旧设置项 `IsMouseClickingEnabled` 已被废弃并被强制关闭(`repos/ClassIsland/ClassIsland/MainWindow.axaml.cs:733-737`,默认值 `false` 见 `Models/Settings.cs:53`)。 3. **不抢焦点**:显示时用 `win.showInactive()`;非编辑态 `focusable: false`。若为了接收触摸事件必须保持可激活,则只允许在编辑模式内切换 `focusable`,且退出编辑模式后立即恢复。 4. **置顶/置底**:`Settings.WindowLayer` 取值 `0=置底`、`1=置顶`,默认 `1`(`Models/Settings.cs:52`, `:1961-1978`)。置顶时 `win.setAlwaysOnTop(true, 'screen-saver')` 并 `win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true })`;置底时 `win.setAlwaysOnTop(false)` 并用 `setAlwaysOnTop(false)` + 必要时 `win.moveBottom()`(Windows 上等价于 `HWND_BOTTOM`,见 `WindowPlatformService.cs:151-159`)。置顶优先级还要被三个条件覆盖(`MainWindow.axaml.cs:791-803, 843-874`): - 处于自由窗口模式(编辑模式的 windowed)或编辑模式时为普通窗口; - `IsNotificationWindowExplicitShowed=true`(有提醒/编辑)时无论 `WindowLayer` 都置顶; - `WindowTopmostRecheckMode` 决定重查时机:`0` 由窗口 Z 序变化事件驱动、`1` 前台窗口变化时、`2` 主计时器每 tick、`3` 1ms 高频计时器(`MainWindow.axaml.cs:420-436, 461-466, 687-704`,默认 `WindowTopmostRecheckMode = 0` 见 `Models/Settings.cs:1860`)。本实现的复核策略见 4.2.1,**禁止照搬 `3` 的 1ms 轮询**。 5. **任务栏显示**:`ShowInTaskbar` 是 `IsWindowMode || IsScreenRecordingModeEnabled` 的逻辑或(`MainWindow.axaml:34-39`)。即只有"编辑模式 + 自由窗口"或"屏幕录制模式"才进任务栏。实现用 `win.setSkipTaskbar(!(isWindowedEditMode || settings.isScreenRecordingModeEnabled))`。 - **P2 / 能力说明**:`IsWindowMode`(= `EditModeIsWindowMode && IsEditMode`,`ViewModels/MainViewModel.cs:363`)本任务**不实现具体 UI 入口**,只保留设置字段与"编辑模式可切成自由窗口"的能力说明。依据:ClassIsland 2.1 中该设置字段存在,但编辑页(`Controls/EditMode/EditModeView.axaml`)没有可见的切换按钮【推断:入口可能在教程步骤或后续版本】,实现者不得臆造按钮。 6. **防截屏**:`IsWindowCaptureBlockingEnabled=true` 时调用 `win.setContentProtection(true)`(对应 `WDA_EXCLUDEFROMCAPTURE`,`WindowPlatformService.cs:169-172`, `MainWindow.axaml.cs:840`)。 7. **屏幕录制模式**:`IsScreenRecordingModeEnabled=true` 时:不进 ToolWindow(任务栏可见)、不被置底。默认 `false`(`Models/Settings.cs:1861`)。 8. **窗口关闭拦截**:主界面窗口不得被用户关闭。ClassIsland 在 `Closing` 事件里 `e.Cancel = true`,只有应用主动退出才放行(`MainWindow.axaml.cs:577-590`)。实现:`win.on('close', (e) => { if (!appQuitting) e.preventDefault(); })`。 9. **禁止的窗口能力**:不得 `setAlwaysOnTop` 到 `'floating'` 以下级别;不得开启 `thickFrame`;不得给窗口加系统菜单;不得设置 `roundedCorners`(圆角由 CSS 画)。 10. **单实例守卫**:见第 12 章(本部分不重复),但主界面窗口必须在单实例锁之后创建。 #### 4.2.1 置顶复核与性能底线 **必须事件驱动 + 兜底轮询,禁止任何 1ms/10ms 级高频轮询。** - 事件源(全部接上):`win.on('focus'/'blur')`、`screen` 的 `display-added/removed/metrics-changed`、编辑模式进出、`setVisibleOnAllWorkspaces` 变化、提醒开始/结束(对应 `TopmostLock` 的 `Acquire/Release`,`MainWindow.axaml.cs:1056-1092`)。 - 兜底轮询:仅在"当前是置顶态且窗口可见"时启用,间隔 **≥1000ms**,每次只做一次轻量 `setAlwaysOnTop(true, 'screen-saver')` 复核(对抗系统抢 Z 序)。窗口隐藏/置底时停止轮询。 - 禁止实现 `WindowTopmostRecheckMode === 3` 的 1ms 高频计时器(`MainWindow.axaml.cs:136-139`):那是原生 `SetWindowPos` 成本极低的平台红利,Electron 侧照搬会持续占用 CPU 并让渲染进程掉帧。 - 同类频率约束(本部分涉及的所有动画/刷新): - 时钟/日期/倒计时/课程表:**1s 节拍**(整秒对齐); - 提醒进度条:**100ms**(`ReduceProgressAccuracy=true` 时 250ms 即可); - 行淡出/透明度过渡:由 CSS `transition` 驱动,**不得**用 JS 每帧写样式; - 鼠标位置命中判定:只在 `pointermove`(编辑模式)或 200ms 兜底轮询(非编辑模式不需要,因为整窗穿透)。 ### 4.3 几何:包围盒、可见内容盒与编辑画布 所有坐标计算以「所选显示器」为基准,显示器由 `Settings.WindowDockingMonitorIndex` 选定;越界或负数时回退主显示器(`MainWindow.axaml.cs:806-812`,默认 `0` 见 `Models/Settings.cs:51`)。设: - `screen`:所选显示器;`screen.bounds` 为整屏物理像素,`screen.workArea` 为工作区物理像素; - `dpiX = screen.scaleFactor`、`dpiY = screen.scaleFactor`(Avalonia 取 `screen.Scaling`,`MainWindow.axaml.cs:1019-1037`); - `ox = Settings.WindowDockingOffsetX`、`oy = Settings.WindowDockingOffsetY`(单位:物理像素,默认 `0`、`0`,`Models/Settings.cs:49-50`); - `ignoreArea = Settings.IsIgnoreWorkAreaEnabled`(默认 `false`,`Models/Settings.cs:1835`); - `docking = Settings.WindowDockingLocation`(默认 `1`,`Models/Settings.cs:48`),取值为: ``` ############### # 0 1 2 # # # # 3 4 5 # ############### ``` 即 `0/1/2 = 上(左/中/右)`、`3/4/5 = 下(左/中/右)`(`Models/Settings.cs:1810-1823`)。 #### 4.3.1 实现策略裁决:固定包围盒 + 窗内形变 ClassIsland 的原生模型是"窗口高度随内容自适应"(`SizeToContent = Height`,`MainWindow.axaml.cs:781`;窗口高 = 内容高 + 安全区,`:916`)。**本实现不得照搬这一条**:Electron 的逐像素透明窗口在频繁 `setBounds` 时会触发 DWM 重排与透明表面重建,产生整块闪烁/掉帧,而行增删、提醒展开、时钟秒数出现都会触发高度变化。因此采用 ClassHelper 已验证的「固定包围盒 + 窗内形变」方案(`repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:480-512`,注释 `:78-90`): 1. **窗口创建时一次算好包围盒**: - `winW = 目标屏幕宽(DIP) = screen.bounds.width / dpiX`; - `winH = contentMaxHeight + 2 * safePadding`,其中 `contentMaxHeight` = 预设最大内容高(默认按"当前可见行数 × (40 + 行间距 5) + 上下安全区"计算,并留出一行余量),`safePadding ≥ 20`(阴影 + 光晕余量,对齐 `ISLAND_SHADOW_PAD = 10` 的双侧口径)。 2. **可见岛在窗口内形变**:窗口不动,岛用 CSS `height` 过渡(或第 14 章的 spring 形变)在包围盒内伸展/收缩;内容层按 4.5 的行高与间距排布。 3. **只有下列情形才 `setBounds`**:显示器/DPI 变化、行数超出包围盒上限、进入/退出编辑模式、窗口偏移/缩放/停靠位置设置变化。这类变更必须**节流 ≥100ms**(同一窗口内多次合并为一次),且不得与弹簧/高度动画同帧竞争。 4. **视觉结果必须与 ClassIsland 一致**:用户看到的**可见岛高度 = 行高之和 + 行间距之和**(行高恒 `40`,间距 `MainWindowLineVerticalMargin`),**可见岛宽度 = 内容自然宽 + 两侧各 12px 内边距**。窗口比岛大只是透明余量,用户不可见。 5. **禁止**:把窗口高度做成"刚好等于内容高"再逐帧改;用 `requestAnimationFrame` 每帧 `setBounds`;在行内容变化(秒数、课程名长度、提醒)时改窗口尺寸。以上都会导致 1px 级抖动。 **与 ClassIsland 的差异及理由(必须写进实现注释)**:差异 = 窗口高度固定为包围盒 vs 随内容自适应;理由 = Electron 透明窗口 resize 抖动/闪烁,且 ClassHelper 的既有实现已证明"固定包围盒 + 窗内画布形变"在开合过程中零抖动(见该文件 `:64-70` 的设计说明)。**视觉结果不做任何妥协**:可见岛的行高、间距、圆角、阴影与 4.5 完全一致。 #### 4.3.2 可见内容盒(视觉基准公式) 下面的 ClassIsland 原生公式**只用于计算"可见岛"的内容尺寸、安全区裁剪与停靠对齐**,不直接作为 `BrowserWindow.setBounds` 的 height 参数(`setBounds` 规则见 4.3.1): 1. `contentW`、`contentH`:由渲染进程上报的岛内容尺寸(CSS px)。内容盒 = 所有行高度之和 + 行间距之和(见 4.5)。 2. `safe = 主题竖直安全区`:Fluent 主题 `20`(`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:314`),Classic 主题 `0`(`ThemeManifest._verticalSafeAreaPx` 默认 0,`repos/ClassIsland/ClassIsland.Core/Models/XamlTheme/ThemeManifest.cs:20`)。若正在播放教程且教程吸附在本窗口,则 `safe = max(safe, 150)`(`MainWindow.axaml.cs:899-902`)。 3. `dockingTop = docking ∈ {0,1,2}`。 4. `safeT = Math.max(dockingTop ? Math.min(safe, oy) : safe, 0) * scale`。 5. `safeB = Math.max(dockingTop ? safe : Math.min(safe, -oy), 0) * scale`。 6. `岛锚点 X = screen.workArea.x + ox`;`岛锚点 Y = dockingTop ? top + oy - safeT : bottom - contentH + oy + safeB`,其中 `top = ignoreArea ? screen.bounds.y : screen.workArea.y`,`bottom = ignoreArea ? screen.bounds.bottom : screen.workArea.bottom`。 7. `layoutW = screen.bounds.width / dpiX`(布局容器宽 = 整屏宽,用于左/中/右对齐计算,`MainWindow.axaml.cs:915`)。 8. `layoutH = contentH + safeT + safeB`(岛的可布局高度;这只是内容画布高,不是窗口 `height`)。 9. 岛在画布内的偏移:`translateY = safeT`、`translateX = 0`;水平对齐:`docking ∈ {0,3}` → 左、`{1,4}` → 居中、`{2,5}` → 右(对应 `align-items: flex-start / center / flex-end`,与 `WidthDoubleToRectConverter` 的 X 偏移逻辑一致,`repos/ClassIsland/ClassIsland.Core/Converters/WidthDoubleToRectConverter.cs:66-77`)。 10. 窗口自身的包围盒位置必须让"岛锚点"落在公式 6 的位置:`winX = 岛锚点X`、`winY = 岛锚点Y - safePadding`(上停靠)/ `岛锚点Y - contentMaxHeight - safePadding`(下停靠,包围盒底部贴锚点)。 #### 4.3.3 编辑模式窗口 进入编辑模式时**窗口必须扩展为全屏编辑画布**(这一步允许 `setBounds`,因为是模式切换、不是内容变化): 1. `winX = screen.bounds.x`、`winY = screen.bounds.y`。 2. `winW = screen.bounds.width / dpiX`。 3. `winH = (screen.bounds.height - 1) / dpiY`——**必须减 1px**,防止 Windows 把窗口判定成全屏并触发全屏优化(`MainWindow.axaml.cs:970`)。 4. **岛内容在窗口内四边各留 60px 安全边距**:`padding = 60px`(来源 `WindowedEditModeScreenMargin = 60`,`MainWindow.axaml.cs:134`),即编辑画布的可用区为 `winW - 120` × `winH - 120`,岛在其中垂直居中、水平按停靠位置对齐。 5. 内容偏移:`translateX = ox / dpiX`、`translateY = oy / dpiY`(编辑模式偏移是逻辑像素,`MainWindow.axaml.cs:973-974`);`clientRect = ignoreArea ? screen.bounds : screen.workArea`。 6. 鼠标事件**必须开启**:`win.setIgnoreMouseEvents(false)`(见 4.2 第 2 条)。 7. **编辑模式 + 自由窗口(windowed)**:窗口改为取 `screen.workArea` 四边各内缩 60px(`winX = workArea.x + 60`、`winY = workArea.y + 60`、`winW = (workArea.width - 120) / dpiX`、`winH = (workArea.height - 120) / dpiY`),切回系统装饰(`SystemDecorations=Full`、可缩放/最大化/最小化,`MainWindow.axaml:182-191`),标题 `编辑主界面 | ClassIsland`(`MainWindow.axaml:180`)。 8. **退出编辑模式**:恢复 4.3.1 的包围盒(位置 + 尺寸),`win.setIgnoreMouseEvents(true, { forward: true })`,恢复 `skipTaskbar`。 #### 4.3.4 触发重算的时机与节流 必须接上这些时机,但全部走"标记脏 + ≥100ms 节流"的通道,不得每次直接 `setBounds`: - **包围盒**(4.3.1):显示器/DPI/显示器插拔变化、行数超出 `contentMaxHeight`、编辑模式进出、偏移/缩放/停靠设置变化; - **岛内容布局**(4.3.2,纯 CSS,不改窗口):主计时器每 tick(`LessonsServiceOnPreMainTimerTicked`,`MainWindow.axaml.cs:475`)、内容容器尺寸变化(`StackPanelRootContainer_OnSizeChanged`,`MainWindow.axaml.cs:786-789`)、任何 `Settings` 属性变化(`MainWindow.axaml.cs:488-501, 730-782`)。 **节流与稳定**:用 `requestAnimationFrame` 合并同一帧内的多次 `setBounds`;同一窗口尺寸在 100ms 内多次变化时只保留最后一次;高度目标值四舍五入到整数 CSS px。禁止在主计时器 tick 里直接改窗口尺寸。 **恢复与兜底**:`WindowDockingMonitorIndex` 超出显示器数量时回退主屏(`MainWindow.axaml.cs:806-812`);`contentH` 为 0(无行/全部隐藏)时岛整体 `opacity:0`(窗口保留包围盒、不改尺寸),避免窗口在 0 高附近抖动。 ### 4.4 DOM 层级与 CSS 变量 必须严格复刻下面这棵树(左侧为 `MainWindow.axaml` 的控件,右侧为实现用的标签/类名): ``` Window → html > body > #app └ Grid#WindowRoot → .window-root(透明,pointer-events:none) └ ZoomBorder → .zoom-layer(缩放/平移宿主,overflow:visible) └ Canvas#LayoutContainerGrid → .layout-container(position:relative; width:100%;height:100%) └ Panel#WorkingRoot → .working-root(position:absolute; left:0; top:safeT; width:窗口宽; height:contentH) └ StackPanel#StackPanelRootContainer → .lines-root(display:flex; flex-direction:column; align-items:center|flex-start|flex-end) └ LayoutTransformControl → .lines-transform(transform: translate(ox,oy) scale(scale)) └ Grid#GridRoot → .grid-root(每行背景/遮罩的公共祖先) └ Border#ResourceLoaderBorder → .resource-loader(font-size:16px,字体族/字重继承) └ Grid → .lines-host ├ StackPanel → .lines-stack(display:flex; flex-direction:column; row-gap:5px) │ └ ItemsControl#RootMainWindowLinesItemsControl → .lines-list │ └ (每行) Grid ColumnDefinitions="Auto,*,Auto" → .line-row(display:grid; grid-template-columns:auto 1fr auto) └ ItemsControl#ContainerComponentsEditHost → .container-edit-host(仅编辑模式,position:absolute) ``` 再加两个编辑模式专用兄弟节点:`.operate-area`(底部操作栏)、`.edit-mode-view`(抽屉宿主)。 CSS 根变量必须在 `#app` 或 `.grid-root` 上集中声明(对齐 ClassHelper 的 `--island-*` 命名习惯,`repos/ClassHelper-master/packages/desktop-client/src/island/IslandApp.vue:522-528`): ```css :root { /* 尺寸 token(ClassIsland 事实来源) */ --row-height: 40px; /* IslandContainerHeight,MainWindowLine.axaml:16 / FluentTheme/Styles.axaml:65 / ClassicTheme/Styles.axaml:16 */ --row-padding-x: 12px; /* GridContentRoot Margin="12 0",FluentTheme/Styles.axaml:110 */ --row-min-width: 20px; /* GridContentRoot MinWidth="20",同上 */ --row-gap: 5px; /* MainWindowLineVerticalMargin 默认 5,Models/Settings.cs:219 */ --component-margin-x: 6px; /* ComponentPresenter Margin="6 0",MainWindow.axaml:396 */ --island-radius: 8px; /* RadiusX/RadiusY 默认 8,Models/Settings.cs:183-184 */ --island-opacity: 0.5; /* Opacity 默认 0.5,Models/Settings.cs:64 */ --island-scale: 1; /* Scale 默认 1.0,Models/Settings.cs:86 */ --line-border-width: 1px; /* line-background BorderThickness=1,FluentTheme/Styles.axaml:47 */ --line-faded-opacity: 0.05; /* MainWindowLine.axaml:74 */ --safe-top: 20px; /* Fluent 主题安全区,App.Services.xaml.cs:314 */ --safe-bottom: 20px; /* 字号 token */ --font-secondary: 14px; /* Models/Settings.cs:197 */ --font-body: 16px; /* Models/Settings.cs:198 */ --font-emphasized: 18px; /* Models/Settings.cs:199 */ --font-large: 20px; /* Models/Settings.cs:200 */ /* 字体 */ --font-family: 'HarmonyOS Sans SC', 'Segoe UI Variable Display', 'Segoe UI', 'Microsoft YaHei', sans-serif; --font-weight: 500; /* MainWindowFontWeight2 默认 FontWeight.Medium,Models/Settings.cs:101 */ } ``` **与设计 token 的命名对齐(强制)**:本文为便于阅读使用语义化局部名(`--row-height`、`--font-body` 等),落地时**必须**改用 part 3 定义的 `--ci-*` 规范 token,局部名仅作为别名。对应关系: | 本文局部名 | 规范 token(part 3) | |---|---| | `--row-height` | `--ci-island-height`(=40px) | | `--row-padding-x` / `--row-min-width` | `--ci-island-pad-x`(=12px)/ `--ci-island-min-w`(=20px) | | `--row-gap` | `--ci-line-gap`(=5px) | | `--component-margin-x` | `calc(var(--ci-component-gap) / 2)`(=6px) | | `--island-radius` | `--ci-radius-island`(=8px) | | `--island-opacity`(背景层不透明度,0.5) | `--ci-island-opacity`(=0.5,作用于行背景) | | 行整体不透明度(`MainWindowLineSettings.Opacity`,默认 1.0) | `--ci-line-opacity`(=1.0,作用于整行 `opacity`;与上面的背景不透明度是两个独立字段,不得合并) | | `--island-scale` | `--ci-scale`(=1) | | `--font-secondary/body/emphasized/large` | `--ci-fs-secondary/body/emphasized/large`(14/16/18/20) | | `--accent` | `--ci-accent`;强调填充用 `--ci-accent-fill` | | 行背景(Fluent) | `--ci-bg-secondary`;描边 `--ci-stroke-island` + `--ci-stroke-island-w` | | 行阴影 | `--ci-shadow-island`;遮罩阴影 `--ci-shadow-mask`;Classic 遮罩阴影 `--ci-shadow-classic-mask` | | 编辑模式遮罩层 | `--ci-smoke` | | 编辑操作栏底距 | `--ci-editbar-bottom`(=64px) | | 抽屉宽度 / 设置区最大宽 | `--ci-settings-max-w`(=960px) | | 抽屉圆角 / 阴影 | `--ci-radius-drawer` / `--ci-shadow-drawer` | | 行内强调底 | `--ci-accent-fill-tertiary` | | 文本层级色 | `--ci-text-1/2/3`(对应 primary/secondary/tertiary) | | 进度条高度 | `--ci-progress-height`(**固定 3px**,所有进度条共用) | | 层级 | `--ci-z-mask` / `--ci-z-drawer` / `--ci-z-window` | **字号/字体注入链**:`--font-*` 由设置写入 `.resource-loader`(对应 `Border#ResourceLoaderBorder` 上 `TextElement.FontSize = MainWindowBodyFontSize`,`MainWindow.axaml:311-313` 与 `MainWindow.axaml.cs:766-778`),所有子组件用 `font-size: var(--font-body)` 继承;字体族与字重写在 `.zoom-layer`(对应 `ZoomBorder` 上 `TextElement.FontFamily/FontWeight` 绑定,`MainWindow.axaml:211-212`)。行级/组件级"资源覆盖"(`IsResourceOverridingEnabled`)时用内联 CSS 变量覆盖同一批 token。 ### 4.5 主界面行(MainWindowLine) #### 4.5.1 三种模板 每行按条件选模板,不得混用: | 模板 | 触发条件 | 差异 | |---|---|---| | Full(完整) | `IsNotificationEnabled = true` | 含背景、遮罩(Mask)、浮层(Overlay)、进度条 | | Min(精简) | `IsNotificationEnabled = false`(`FluentTheme/Styles.axaml:275-278`、`ClassicTheme/Styles.axaml:219-222`) | 只有背景 + 内容,不含遮罩/浮层/进度条 | | 分体(Separated) | `MainWindowLineSettings.IslandSeparationMode` 覆盖为"启用",或全局 `Settings.IsIslandSeperated=true`(`MainWindowLine.cs:478-492`) | 不再画整行背景,改为每个组件各自一个背景胶囊(见 4.5.6) | `IslandSeparationMode` 的取值语义固定为 `0=继承(用全局设置)`、`1=禁用`、`2=启用`(`repos/ClassIsland/ClassIsland.Core/Models/Components/MainWindowLineSettings.cs:47-50`,UI 文案"继承/禁用/启用"见 `EditModeView.axaml:319-329`)。 #### 4.5.2 尺寸与间距(精确值) - 行高固定 `40px`:`IslandContainerHeight = 40`(三处定义:`Controls/MainWindowLine.axaml:16`、`XamlThemes/FluentTheme/Styles.axaml:65`、`XamlThemes/ClassicTheme/Styles.axaml:16`),并同时用作 `Border#BackgroundBorder.Height`、`Grid#PART_GridWrapper.Height`、`Grid#GridContentRoot.Height`、遮罩高度、`ListBoxItem.MaxHeight`。 - 内容盒左右内边距 `12px`:`Grid#GridContentRoot Margin="12 0"`(`FluentTheme/Styles.axaml:110`、`ClassicTheme/Styles.axaml:100`)。 - 内容盒最小宽 `20px`:同上 `MinWidth="20"`。 - 行与行之间垂直间距 `5px`:`ItemsPanel StackPanel Spacing="{Binding Settings.MainWindowLineVerticalMargin}"`(`MainWindow.axaml:344`),默认 `5`(`Models/Settings.cs:219`,设置面板范围 `0–150`、步进 `5`,`EditModeView.axaml:114-120`)。 - 行内相邻组件水平间距 `12px`:每个组件宿主 `Margin="6 0"`(`MainWindow.axaml:396`),左右各 6 相加得 12;容器组件内部再用 `Margin="-6 0"` 抵消一次(`StackComponent.axaml:14`、`GroupComponent.axaml:15`、`RollingComponent.axaml:16`),因此嵌套容器不会累积边距。 - 行背景宽度 = 内容实际宽度,不是窗口宽度:`BackgroundWidth` 由 `PART_GridWrapper.SizeChanged` 回写(`MainWindowLine.cs:614-617`),背景 Border 与遮罩都绑 `Width="{Binding BackgroundWidth}"`(`FluentTheme/Styles.axaml:81, 94`)。 #### 4.5.3 背景(Fluent 主题,默认主题) 逐条写死(`XamlThemes/FluentTheme/Styles.axaml:40-56`): ```css .line-bg { position: absolute; height: var(--row-height); width: var(--line-width); /* = BackgroundWidth,由内容决定 */ background: var(--fill-secondary); /* FluentAvalonia: SolidBackgroundFillColorSecondaryBrush */ border: var(--line-border-width) solid var(--control-elevation-border); /* ControlElevationBorderBrush */ border-radius: 8px; /* = Settings.RadiusX,见下方说明 */ box-shadow: 0 4px 8px 2px #48000000; /* "0 4 8 2 #48000000" */ opacity: var(--island-opacity); /* = Settings.Opacity,默认 0.5 */ } ``` - 圆角 = `Settings.RadiusX` 直接作为**四角统一半径**(转换器 `SizeDoubleToCornerRadiusConverter` 以参数 `1.0` 相除,`repos/ClassIsland/ClassIsland.Core/Converters/SizeDoubleToCornerRadiusConverter.cs:8-17`,调用处 `FluentTheme/Styles.axaml:48`)。默认 `8`,设置范围 `0–20`、步进 `0.5`(`Models/Settings.cs:183`, `EditModeView.axaml:98-104`)。当半径 ≥ 行高一半(20)时左右边缘为半圆。 - 自定义背景色开启时(`IsCustomBackgroundColorEnabled`)背景色替换为 `Settings.BackgroundColor`(默认黑色,`Models/Settings.cs:34`),仍保留边框、阴影、透明度(`FluentTheme/Styles.axaml:51-56`)。 - **背景不透明度只作用于背景层**:`Opacity` 绑在 `Border.line-background` 上(`FluentTheme/Styles.axaml:44`),文字不跟着透明。这是硬性要求:文字必须始终 100% 不透明。 #### 4.5.4 行裁剪(圆角裁切矩形) 行内容用几何裁剪切出圆角矩形,X 偏移随停靠位置变化(`repos/ClassIsland/ClassIsland.Core/Converters/WidthDoubleToRectConverter.cs:55-92`,应用处 `FluentTheme/Styles.axaml:21-38`): ``` clipRectWidth = BackgroundWidth // 内容宽 clipRectHeight = 行高(40) 或实际 Bounds.Height clipX = docking ∈ {0,3} ? 0 : docking ∈ {1,4} ? (行容器宽 - BackgroundWidth)/2 : (行容器宽 - BackgroundWidth) // {2,5} clipRadius = (RadiusX, RadiusY) = (8, 8) ``` CSS 实现:行容器 `.line-row` 上 `overflow: visible`,内容层 `.line-clip` 用 `clip-path: inset(0 round 8px)` 并把 `.line-clip` 定位到 `left: clipX`、`width: BackgroundWidth`、`height: 40px`;或直接对内容层设置 `clip-path: inset(0 calc(...) 0 ...) round 8px`。分体模式关闭该裁剪(`clip-host` 只在 `IsIslandSeperated=False` 或 `clip-enforcing` 时生效,`FluentTheme/Styles.axaml:21`)。 #### 4.5.5 行可见性(三态组合,必须逐条实现) 行的 `IsVisible` 是下面表达式的逻辑或(`Controls/MainWindowLine.axaml:29-49`): ``` visible = ( ( !IsAllComponentsHid || IsOverlayOpen ) && ( Settings.HideOnRule ? Settings.IsVisible : true ) ) || MainWindowInEditMode ``` 其中: - `IsAllComponentsHid` = 该行所有组件都不可见(`MainWindowLine.cs:408-413`,判定 `Settings.Children.Any(x => x.IsVisible) == false`);注意"全部组件都被隐藏规则隐藏"时整行消失,而不是留一条空胶囊。 - `IsOverlayOpen` = 该行正在播放提醒(遮罩/浮层打开);提醒期间即使组件全隐藏也要显示。 - 行的规则隐藏(`HideOnRule=true`)由 `HidingRules` 规则集求值,规则满足时 `IsVisibleInternal=false` 并订阅 `RulesetService.StatusUpdated` 实时重算(`MainWindowLine.cs:347-381`)。 - 编辑模式下**永远可见**(否则无法编辑隐藏行)。 行的 `Opacity` = `Settings.Opacity`(行级不透明度,默认 `1.0`,范围 `0.1–1`、步进 `0.05`,`MainWindowLine.axaml:51`、`MainWindowLineSettings.cs:42`、`EditModeView.axaml:340-346`)。 **鼠标淡出**("指针不在岛上就变淡"): - 行级:`IsLineFaded = IsMouseInFadingEnabled && (mouseIn XOR IsMouseInFadingReversed)`,为 `true` 且非编辑模式时行 `opacity = 0.05`(`MainWindowLine.cs:503-509`、`MainWindowLine.axaml:73-75`)。默认 `IsMouseInFadingEnabled=true`、`IsMouseInFadingReversed=false`(`Models/Settings.cs:193-194`)。 - 判定鼠标是否在行内:把行内容矩形转换为屏幕物理坐标后与指针比较,宽度/高度都要乘 `dpi * scale`(`MainWindowLine.cs:619-637`)。 - 触摸屏:`TouchInFadingDurationMs > 0` 时,触摸命中后延迟该毫秒数再把"鼠标在内"置否(`MainWindowLine.cs:520-550`,默认 `0`,`Models/Settings.cs:195`)。 - 平台差异:靠 `pointerover` 判定而不是原始输入。 #### 4.5.6 分体主界面(Separated) 全局 `Settings.IsIslandSeperated = true` 或行级 `IslandSeparationMode = 2` 时: - 行不再画整行背景(`BackgroundBorderWrapper.IsVisible = !IsIslandSeperated`,`FluentTheme/Styles.axaml:78`)。 - 每个顶层组件各自渲染一个胶囊(`FluentTheme/Styles.axaml:245-259`): ```css .component-island { position: relative; margin: -2px 0; /* Panel Margin="-2 0" */ display: flex; align-items: center; } .component-island > .bg { position: absolute; inset: 0; background: 行背景色; opacity: var(--island-opacity); border: 1px solid 边框色; border-radius: 8px; box-shadow: 0 4px 8px 2px #48000000; } .component-island > .content { position: relative; min-width: 40px; /* Border MinWidth="40" */ padding: 0 16px; /* ContentPresenter Margin="16 0" */ border-radius: 8px; overflow: hidden; } ``` - 分体模式下窗口内容仍按整行排布(`MainWindow.axaml:213-217` 的 `WorkingRoot` 宽度 = 客户端宽),但每行的高度仍 `40`,行内组件按原顺序横排,胶囊之间保留 `12px` 间隔(每个组件 `Margin="6 0"`)。 - 分体模式下行的 `clip-host` 不再参与裁剪(`FluentTheme/Styles.axaml:21`),阴影由组件胶囊自己承担。 #### 4.5.7 行的过渡动画 - 行内容透明度:`opacity` 过渡 `100ms`,线性(`DoubleTransition Property="Opacity" Duration="0:0:0.100"`,`FluentTheme/Styles.axaml:268`、`ClassicTheme/Styles.axaml:206`)。 - 背景宽度:Fluent `300ms` `cubic-bezier(0.65, 0, 0.35, 1.0)`(`FluentTheme/Styles.axaml:269`);Classic `550ms` `cubic-bezier(0, 1, 0.5, 1.10)`(`ClassicTheme/Styles.axaml:207`,注意控制点 `y>1` 会产生一次轻微回弹,必须照写)。 - 行首次挂载淡入:`AnimationLevel ≥ 2` 时用组合层做 `opacity 0→1`、`250ms`、`cubic-bezier(0.25, 1, 0.5, 1)`(`MainWindowLine.cs:382-401`)。`AnimationLevel` 默认 `1`(`Models/Settings.cs:1851`),即默认不播。 - 行宽变化(组件增删、内容变化)由"自适应面板重排动画"驱动:`WrapPanelResizingAnimationAssist.Duration = 0.300s`、`Easing = (0.65, 0, 0.35, 1.0)`,挂在 `Grid#WindowRoot` 上(`MainWindow.axaml:197-198`),并把 `animated-resize` 类加给行的外层 `Panel`(`FluentTheme/Styles.axaml:103`)。编辑模式下该动画关闭(`MainWindow.axaml:345` 的 `Classes.animated-resize="{Binding !MainWindowInEditMode}"`)。 #### 4.5.8 行设置项(`MainWindowLineSettings`)与默认值 必须实现为一个 JSON 结构 + 抽屉表单。字段与默认值一律照抄(`repos/ClassIsland/ClassIsland.Core/Models/Components/MainWindowLineSettings.cs:17-50`): | 字段 | 类型 | 默认 | 说明 / UI | |---|---|---|---| | `Children` | 数组 | `[]` | 行内组件 | | `IsMainLine` | bool | `false` | 主要行;全局唯一 | | `IsNotificationEnabled` | bool | `true` | 是否允许显示提醒;关掉后该行用 Min 模板 | | `IsResourceOverridingEnabled` | bool | `false` | 覆盖字号资源 | | `MainWindowSecondaryFontSize` | number | `14` | 次级字号,范围 1–40 | | `MainWindowBodyFontSize` | number | `16` | 正文字号 | | `MainWindowEmphasizedFontSize` | number | `18` | 强调字号 | | `MainWindowLargeFontSize` | number | `20` | 大号字号 | | `IsCustomForegroundColorEnabled` | bool | `false` | 自定义字体颜色 | | `ForegroundColor` | color | `DodgerBlue` | 默认字体色 | | `BackgroundOpacity` | number | `0.5` | 自定义背景不透明度 | | `IsCustomBackgroundOpacityEnabled` | bool | `false` | | | `BackgroundColor` | color | `Black` | | | `IsCustomBackgroundColorEnabled` | bool | `false` | | | `CustomCornerRadius` | number | `0` | 自定义圆角,范围 0–20 | | `IsCustomCornerRadiusEnabled` | bool | `false` | | | `Opacity` | number | `1.0` | 整行不透明度,范围 0.1–1 | | `IsVisible` | bool | `true` | 运行时态(规则求值结果) | | `HideOnRule` | bool | `false` | 按规则隐藏 | | `HidingRules` | Ruleset | `{}` | 规则集 | | `IslandSeparationMode` | int | `0` | 0 继承 / 1 禁用 / 2 启用 | 行为约束: - 主要行唯一:勾选某行为主要行时,其余行必须自动取消(`MainWindow.axaml.cs:1569-1588`);取消唯一的主要行时,自动把第一行设为主要行并弹 toast"已将第一行设置为主要行。" - 主要行是提醒注册的优先消费者(注册序号 `-1`,非主要行用行号,`MainWindowLine.cs:441`)。 - **本部分只负责"提醒宿主"与 `IsNotificationEnabled` 标记**:行模板的遮罩/浮层/进度条容器(4.7)、行的可见性联动、`IsNotificationEnabled` 决定走 Full 还是 Min 模板。**多行之间谁显示提醒的分发规则归属第 7 章(提醒系统)**,本文不重复、不得在此臆造优先级。 - 删除行:只剩 1 行时禁止删除并弹警告"至少需要保留 1 个主界面行。"(`MainWindow.axaml.cs:1598-1612`)。 - 关闭所有行的提醒显示时弹警告"您已经禁用了所有主界面行的提醒显示功能……"(`MainWindow.axaml.cs:1590-1596`)。 #### 4.5.9 行内编辑控件(仅在编辑模式显示) 行模板右侧第三列(`MainWindow.axaml:401-444`)在编辑模式显示一组按钮,容器属性: ```css .line-actions { /* StackPanel Classes="items-valign-center" */ display: flex; align-items: center; gap: 2px; /* Spacing="2" */ font-size: 14px; /* TextElement.FontSize="14" */ } .line-actions .btn { background: transparent; border: none; } .line-actions .btn-delete { color: IndianRed; } ``` 四个按钮(自左至右,全部 `VerticalAlignment=Center`、`Background=Transparent`、`BorderThickness=0`): 1. 行设置:图标 `\uEF27`,ToolTip "设置…",打开行设置抽屉。 2. 主要行开关(ToggleButton):图标 `\uE994`,ToolTip "此行是主要行"。 3. 提醒开关(ToggleButton):图标 `\uE02B`,ToolTip "此行可以显示提醒"。 4. 删除(Button,`Foreground=IndianRed`,`Theme=TransparentButton`):图标 `\uE61D`,ToolTip "删除"。 行选择框(第一列的虚线矩形,`MainWindow.axaml:353-363`): ```css .line-select-box { position: absolute; inset: 0; border: 1.5px dashed rgba(控制描边色); border-radius: 0; /* 无圆角 */ opacity: 0.75; /* StrokeDashArray="1, 1.5" → dash 1px、gap 1.5px */ background: none; } ``` 拖动把手(第一列,`MainWindow.axaml:364-367`):`TouchDragThumb`,仅在编辑模式可见,垂直方向拖动改变行顺序;拖动阈值由 `AdvancedItemDragBehavior` 决定,其 `Orientation="Vertical"`、`HorizontalDragThreshold="33550336"`(即水平拖拽被彻底禁用)、`AllowDragFromDragThumbOnly="True"`(`MainWindow.axaml:332-335`)。 "新主界面行"按钮(`MainWindow.axaml:449-455`):水平居中、`font-size:14px`、内容为图标 `\uE00D` + 文本"新主界面行";仅在编辑模式可见;点击追加一行默认配置并在该行下方插入。 ### 4.6 主界面的缩放(裁决:只做缩放,不做平移) - `ZoomBorder`(`MainWindow.axaml:199-212`)必须实现为 `.zoom-layer`,参数:`Stretch=None`、`ZoomSpeed=1.2`(每格缩放倍率)、`EnableConstrains=True`、`MaxZoomX=MaxZoomY=3`、`MinZoomX=MinZoomY=0.5`、`ShowZoomIndicator=true`、`EnableGestureRotation=False`、`Focusable=true`、`Background=Transparent`。 - **缩放手势固定为 `Ctrl + 滚轮`**:以指针位置为锚点缩放,每格倍率按 `ZoomSpeed = 1.2` 指数累乘,范围钳制在 `[0.5, 3.0]`(= `MinZoomX/Y` 与 `MaxZoomX/Y`)。不带 `Ctrl` 的滚轮不得缩放(避免与页面/抽屉滚动冲突)。 - **双击画布空白处重置为 100%**(等价 `ResetMatrix`);底部操作栏的"重置视图"按钮同样恢复 100%。 - 缩放指示器:非 100% 时在窗口左下角显示当前倍率,`font-size:12px`,`background:#77222222`、`color:#fff`【推断:样式对齐 `MainWindow.axaml:643-649` 的调试文本样式】。 - **P2 / 不做平移**:首版只实现 `Ctrl+滚轮` 缩放与双击重置;`Space + 拖拽` 平移标 **P2 /【推断】**(ClassIsland 的 `ZoomBorder` 支持拖拽平移,但控件源码未定位,且非编辑态整窗鼠标穿透用不到),不实现。 - **设置里的"界面缩放"是另一件事**:`Settings.Scale` 默认 `1.0`,范围 `0.75–2.5`、步进 `0.05`(`Models/Settings.cs:86`、`EditModeView.axaml:50-57`),应用在 `.lines-transform` 的 `transform: scale()`,`transform-origin` 需按停靠位置设置:上停靠 `center top`、下停靠 `center bottom`(对齐 `LayoutTransformControl` 的布局缩放语义,`MainWindow.axaml:257-265`)。 - 两者叠加:总视觉倍率 = 缩放设置 × ZoomBorder 倍率;**窗口包围盒只由"未缩放的内容尺寸"决定**(见 4.3.1),缩放只改渲染层 `transform`,不改窗口尺寸。 - **不做平移**:见上条 P2 说明;`EnableConstrains=true` 仅用于把缩放后的内容约束在窗口内。 ### 4.7 提醒宿主、遮罩与进度条(行内) Full 模板的行内部结构(`XamlThemes/FluentTheme/Styles.axaml:70-202`)必须按下面层级实现: ``` .line (Grid#GridRoot) ├ .line-bg-wrap → Border#BackgroundBorderWrapper (整行背景层,分体模式隐藏) │ └ .line-bg → Border#BackgroundBorder (Classes: docking line-background) ├ .line-bg-mask → Border#BackgroundBorderOverlayMask (强调色遮罩,Opacity 0→1,仅提醒时) │ · opacity 过渡只对 BoxShadow 做 0.1s ├ .line-clip → Grid#ContentClipBorder (Classes: clip-host) │ └ .line-panel → Panel (Classes: animated-resize) │ └ .grid-wrapper → Grid#PART_GridWrapper (height 40, Classes: docking) │ ├ .content-root → Grid#GridContentRoot (height 40, min-width 20, margin 0 12) │ │ └ .components (组件列表) │ ├ .overlay → Grid#GridOverlay (opacity 0) │ │ ├ .overlay-bg → Border (line-background,分体模式才可见) │ │ └ .overlay-clip → Border (clip, corner-radius 8) │ │ └ Panel │ │ ├ Canvas#ProgressContainer (bottom, height = progressBar 高度) │ │ │ └ ProgressBar#OverlayTimeProgressBar (Maximum 1, Value = LeftProgress, MinWidth 0) │ │ └ ContentPresenter(OverlayContent,MaxHeight 40) │ └ .mask-shadow → Border#OverlayMaskShadowRoot (仅非编辑模式) │ └ .mask → Border#OverlayMask (背景 AccentFillColorDefaultBrush, 前景 TextOnAccentFillColorPrimaryBrush, clip, height 40) │ └ Panel │ ├ SlantedMaskControl (Classes: mask-bg, RegionDurationMs=360, StageStaggerMs=120, Fill=强调色) │ └ Border (Padding 0 12) │ └ .mask-content → ContentPresenter#OverlayMaskContent (opacity 0, scale) ``` #### 4.7.1 进度条(提醒剩余时间) - 容器 `Canvas` 贴底、水平拉伸,高度绑定进度条自身高度(`FluentTheme/Styles.axaml:127-147`)。 - `ProgressBar`:`Maximum=1`、`Value = 当前提醒的 LeftProgress`(0–1,`MainWindowLine.axaml:77`)、`MinWidth=0`、`Width = 容器宽`、贴底。 - 值变化过渡:`50ms`、线性(`DoubleTransition Property="Value" Duration="0:0:0.05"` + `LinearEasing`,`FluentTheme/Styles.axaml:137-145`)。 - 实现:进度条高度**固定 `3px`**(token `--ci-progress-height: 3px`,`border-radius: 1.5px`,贴底;三条进度条——行提醒进度条、课程进度条、倒计时进度条——共用同一 token),颜色取 `AccentFillColorDefaultBrush`,`transform: scaleX(value)` + `transform-origin:left` 比改 width 更省重排;但不能有过渡抖动,`transition: transform 50ms linear`。 - `ReduceProgressAccuracy=true`(默认,`Models/Settings.cs:1853`)时进度按步长量化:`progressTick = max(10, totalSeconds/500)`,`value = round(seconds/progressTick)*progressTick`(倒计时组件同款算法,`Controls/Components/CountDownComponent.axaml.cs:288-295`)。主界面行进度条同样应做降频更新(100ms 级),`false` 时按 1 秒精度。 #### 4.7.2 遮罩(Mask)动画 - `SlantedMaskControl`:5 个区段宽度权重 `[0.0, 0.1, 0.2, 0.4, 0.2, 0.1]` 的累计边界,斜面角度固定 `60°`(`Controls/SlantedMaskControl.cs:284, 298-308`),填充色 `AccentFillColorDefaultBrush`。 - 参数:`RegionDurationMs = 360`、`StageStaggerMs = 120`(行模板里显式赋值,`FluentTheme/Styles.axaml:178-179`);控件默认值 `300`/`120`(`SlantedMaskControl.cs:29-33`)——以行模板的 `360/120` 为准。 - 打开(`mask-in`)顺序:区段 0 与 4 立即开始(delay 0),区段 1 与 3 延迟 `stag*0.75 = 90ms`,区段 2 延迟 `stag*1 = 120ms`;缓动 `QuarticEaseOut`;每段时长为独立的关键帧(`SlantedMaskControl.cs:167-188`)。总时长 = `120 + 360 = 480ms`。 - 关闭(`mask-out`)顺序反向:区段 2 先(delay 0),1/3 延迟 90ms,0/4 延迟 120ms;缓动 `QuadraticEaseIn`(`SlantedMaskControl.cs:189-200`)。 - CSS 实现建议:遮罩层用 5 个 `clip-path` 平行四边形(`skewX(-30deg)` 等价于 60° 斜面),每个用 `animation: mask-region 360ms both`,`--scale-x: 0 → 1`,`transform-origin` 取各自区段中心。 - 遮罩背后的内容:`Grid#GridContentRoot.Opacity` 在 `mask-in` 时置 0,`:mask-out` 时也置 0,`:overlay-in/out` 控制恢复(`FluentTheme/Styles.axaml:293-305, 368-370, 411-413, 438-450`)。 - 遮罩投影:`mask-in` 期间背景层与遮罩层加 `BoxShadow = 0 8 32 0 #66000000`(`FluentTheme/Styles.axaml:323-328`),`mask-out` 时淡掉。 - 遮罩内容入场:`delay 260ms`、`duration 250ms`、`ease cubic-bezier(0.25, 1, 0.5, 1)`、`opacity 0→1`(`FluentTheme/Styles.axaml:329-340`);同时 `scale 1.1→1.0`、`duration 750ms`、`delay 260ms`、同缓动(`:341-352`)。 - 遮罩内容容器:`Padding="12 0"`、前景色 `TextOnAccentFillColorPrimaryBrush`(`FluentTheme/Styles.axaml:181-184`)。 - 强调色底:`Border#BackgroundBorderOverlayMask` 背景 = `AccentFillColorDefaultBrush`(`StylesBase.axaml:90-93`),`Opacity 0→1`、`delay 600ms`、`duration 100ms`、`FillMode=Forward`(`FluentTheme/Styles.axaml:355-367`)。 **Classic 主题的等价实现**(必须两套都做,用 CSS 变量或 `data-theme` 切换;`XamlThemes/ClassicTheme/Styles.axaml`): - 遮罩与强调层用 `TranslateTransform Y=-50` 起手;`mask-in`:`duration 200ms`、`QuadraticEaseIn`、`Y: -50 → 0`(`:265-281`)。 - `mask-out`:`delay 100ms`、`duration 200ms`、`QuadraticEaseOut`、`Y: 0 → 50`(`:305-318`)。 - 遮罩内容淡入:`delay 200ms`、`duration 100ms`、`opacity 0→1`(`:288-300`);淡出 `100ms`、`opacity 1→0`(`:319-331`)。 - 首次出现时给 `Grid#GridRoot` 挂 `DropShadowEffect(OffsetX=0, OffsetY=0, Opacity=1, BlurRadius=8)`,`Effect` 过渡 `100ms` 线性(`:34-45, 283-287`)。 #### 4.7.3 浮层(Overlay) - `Grid#GridOverlay` 初始 `Opacity=0`,带 `DoubleTransition Opacity 200ms SineEaseIn`(`FluentTheme/Styles.axaml:419-432`)。 - `:overlay-in` → 浮层 `opacity 1`、内容层 `opacity 0`;`:overlay-out` → 浮层 `opacity 0`、内容层 `opacity 1`(`:434-451`)。 - 浮层内容 `MaxHeight = 40`(`FluentTheme/Styles.axaml:149`);浮层里的进度条只显示"当前提醒剩余时间"。 ### 4.8 编辑模式 #### 4.8.1 进入与退出 - 入口:原生菜单"编辑主界面…"(`MainWindow.axaml:108-109`)、URI `classisland://app/edit`(`MainWindow.axaml.cs:295`)。 - 进入前必须通过权限校验(`ManagementService.AuthorizeByLevel(EditSettingsAuthorizeLevel)`,`MainWindow.axaml.cs:1357-1362`);被策略禁用时直接返回。 - 进入步骤(按序):`IsEditMode = true` → 窗口 `Activate()` → 挂载编辑视图 → `ZoomBorder.ResetMatrix()` 在退出时调用 → `TopmostEffectWindow.ViewModel.IsEditMode` 同步 → 更新窗口层级 → 重新加载主题(`MainWindow.axaml.cs:510-528`)。 - 退出步骤:关闭两级抽屉 → `IsEditMode=false` → 清空教程提示 → **保存组件配置**(`ComponentsService.SaveConfig()`,`MainWindow.axaml.cs:1391-1403`)。保存必须幂等且原子(写临时文件再替换)。 - 编辑模式的窗口态:ClassIsland 用 `Background = SmokeFillColorDefaultBrush` + `TransparencyLevelHint = "AcrylicBlur, Transparent"` + 标题 `编辑主界面 | ClassIsland`(`MainWindow.axaml:177-181`)。**本实现的裁决**: - **禁止**调用 Electron 的 `setBackgroundMaterial()` / `vibrancy` / `backgroundMaterial` 等任何系统材质 API(原因见 4.1 第 2 条与 `repos/ClassHelper-master/packages/desktop-client/src/main/island.ts:523-524`)。 - 替代方案:一层全屏半透明深色遮罩 div,`background: rgba(0, 0, 0, 0.30)`(token `--ci-smoke`;深色主题可下调至 `0.24`、浅色主题可上调至 `0.36`,但**默认必须是 `0.30`**),窗口保持 `transparent: true` 逐像素透明。 - **不得使用 `backdrop-filter: blur()`**:透明窗口下 Chromium 的背板采样不可靠,会出现整块灰底/边缘发白。 - 【推断 / 差异说明】该方案与 FluentAvalonia 的 Acrylic 是**近似而非等价**:Acrylic 带轻微模糊与噪声,本实现只有纯色压暗。这是**有意接受的视觉差异**,不得在实现说明里写成"与 Acrylic 完全一致";验收只要求"编辑态背景明显压暗、岛与抽屉可读、退出后恢复逐像素透明"。 - 编辑模式下窗口必须接收鼠标:`win.setIgnoreMouseEvents(false)`;同时 `Transparent` 窗口特性关闭(`MainWindow.axaml.cs:839`)。 - 平台兜底:Windows 10 1803 及更早版本不支持 windowed 透明,需给窗口加标记类 `no-windowed-transparent`,此时 windowed+edit 模式用不透明底色 `SolidBackgroundFillColorBaseBrush`、`TransparencyLevelHint=None`(`MainWindow.axaml:187-190`, `MainWindow.axaml.cs:257-260`)。 #### 4.8.2 编辑画布 - 画布 = 全屏(宽 = 屏宽,高 = 屏高 −1px),岛内容在画布内**四边各留 60px 安全边距**,再按 `(ox, oy)` 偏移(见 4.3.3 编辑模式窗口公式)。 - 行容器对齐:`dock-top` → `align-items:flex-start`,`dock-bottom` → `align-items:flex-end`,编辑模式 → `justify-content:center`(对应 `StackPanelRootContainer` 的 `VerticalAlignment` 覆写,`MainWindow.axaml:240-248`)。编辑模式下内容整体垂直居中,方便拖拽。 - 底部操作栏占用画布底部 64px 区域,安全边距必须保证岛内容不被操作栏遮挡(岛最高可到画布高 −120px)。 - 空白处左键点击:清除当前选中的组件并关闭组件设置抽屉(`MainWindow.axaml.cs:1511-1535`,只有点在 `ZoomBorder` 自身(非命中子控件)时触发)。 #### 4.8.3 底部操作栏(OperateArea) 精确规格(`MainWindow.axaml:595-634`): ```css .operate-area { position: fixed; left: 50%; transform: translateX(-50%); bottom: 64px; /* Margin="0 0 0 64" */ background: var(--commandbar-overflow-bg); /* CommandBarOverflowPresenterBackground */ border: 1px solid var(--commandbar-overflow-border); border-radius: 6px; padding: 4px; display: flex; /* 内部 StackPanel Spacing="6" */ gap: 6px; z-index: 10; pointer-events: auto; } .operate-area .divider { width: 1px; height: 25px; background: var(--divider-stroke); align-self: center; } ``` 内部按钮(自左至右,除"完成"外均为 `TransparentButton` 主题): 1. "重置视图"——图标 `\uE12F`,命令 = 缩放重置。 2. 竖分隔线:`StartPoint 0,0 → EndPoint 0,25`、`StrokeThickness=1`、颜色 `DividerStrokeColorDefaultBrush`。 3. "外观"——图标 `\uE51E`,打开外观抽屉;`DisableSettingsEditing` 策略下禁用。 4. "添加组件"——图标 `\uE071`,打开组件库抽屉;同策略禁用。 5. 组件配置按钮——图标 `\uE699`,文本 = 当前配置名(`Settings.CurrentComponentConfig`,默认 `"Default"`,`Models/Settings.cs:159`),`MaxWidth=150`;打开配置管理抽屉。 6. "完成"——图标 `\uE424`,`Classes="accent"`(强调按钮),退出编辑模式。 #### 4.8.4 抽屉(VerticalDrawer) - 主抽屉:`RequestedWidth = 960`(`SettingsContainerWidth`,`ThemeBase.axaml:37`;`EditModeView.axaml:600-605`),`AllowCollapse=True`,`SmokeBackground=False`。 - 次抽屉:`SmokeBackground=True`、`AllowCollapse=False`、`ContentHeightRatio=0.5`(`EditModeView.axaml:606-611`)。 - 外壳规格(`repos/ClassIsland/ClassIsland.Core/Controls/VerticalDrawer.axaml:8-155`): ```css .drawer-host { position:absolute; inset:0; pointer-events:none; } .drawer-smoke { position:absolute; inset:0; background: var(--smoke-fill); opacity:0; transition: opacity 250ms cubic-bezier(0,0,0,1); } .drawer-smoke.open { opacity:1; pointer-events:auto; } .drawer-panel { position:absolute; bottom:0; left:50%; transform: translateX(-50%); width: 960px; max-width: 100%; max-height: 80vh; /* 【推断:ContainerHeight 由内容/宿主决定,见下 */ background: var(--content-dialog-bg); border: 1px solid var(--content-dialog-border); border-top-left-radius: 8px; border-top-right-radius: 8px; /* "8 8 0 0" */ box-shadow: 0 8px 32px 0 #66000000; overflow: hidden; pointer-events: auto; } .drawer-inner { padding: 8px 8px 0; } /* Padding="8 8 8 0" */ .drawer-head { height: 32px; display:flex; align-items:center; justify-content:space-between; } .drawer-body { min-height: 0; overflow:auto; } ``` - 抽屉升降过渡:`Canvas.Bottom` 与 `Height` 各 `250ms`、`cubic-bezier(0,0,0,1)`(`VerticalDrawer.axaml:29-36`)。 - 标题栏右侧三个按钮:收起 `\uEC5C`(仅 `AllowCollapse` 且已展开时可见)、展开 `\uEC5E`(`AllowCollapse` 且非展开时)、关闭 `\uE671`(`VerticalDrawer.axaml:64-99`)。 - 抽屉内容宽度上限 = `960px`;设置项容器 `.settings-container` 的 `Margin = 12 12 18 12`、`Spacing = 4`(`StylesBase.axaml:67-71`)。 - 抽屉打开时挂载"逐项入场"动画类 `animated-intro`(`EditModeView.axaml:43, 317`),对应 `ClassIsland.Core/Themes/StackPanelIntroAnimation.axaml`(时长/曲线见第 14 章)。 - 拖拽组件时主抽屉自动临时收起(`Collapsed`),拖拽结束自动展开(`EditModeView.axaml.cs:143-163`)。 #### 4.8.5 组件库抽屉 - 标题"组件库"(`EditModeView.axaml.cs:130`)。 - 结构:按组件分组(`ComponentRegistryService.GetGroupedSortedComponents()`)→ 每组一个 `group-name`(`font-weight:SemiBold`、`margin-bottom:4px`)+ 一个 `WrapPanel`(目标宽 `225px`,`WrapPanelAutoResizeBehavior`,`EditModeView.axaml:167-218`)。 - 每个组件项:高 `50px`、`Margin 0 8`、四列 `Auto,Auto,*,Auto`: ```css .component-lib-item { display:grid; grid-template-columns:auto auto 1fr auto; height:50px; margin:8px 0; } .component-lib-item .drag-thumb { width:24px; align-self:center; } /* TouchDragThumb Width=24 */ .component-lib-item .icon { width:32px; height:32px; font-size:32px; margin:0 -4px 4px 0; } .component-lib-item .name { font-size:14px; overflow:hidden; text-overflow:ellipsis; } .component-lib-item .desc { grid-column:2 / span 2; font-size:12px; opacity:0.75; white-space:normal; } ``` - 底部提示文案(按是否已选目标列表切换):`"将组件拖动到主界面上即可添加组件。"` / `"选择要添加的组件,或将组件拖动到主界面上即可添加组件。"`,样式类 `l-instructive`,`margin:8px 0`(`EditModeView.axaml:226-235`)。 - 已选目标时右下角出现"添加"按钮:`padding:16px 6px`、`Classes="accent"`、`margin:4px 0 8px`(`EditModeView.axaml:236-240`)。 #### 4.8.6 组件设置抽屉 / 行设置抽屉 - 标题栏(组件):左侧图标 16×16 + 组件名 + `TabStrip.compact` 两个页签"组件设置""高级设置"(仅当组件有 `SettingsType` 时显示第一个页签);右侧"按规则隐藏"复选框 + 编辑规则按钮(图标 `\uF17E`,仅勾选时启用),`Margin 0 0 4 0`、`Spacing 4`(`EditModeView.axaml:243-283`)。 - 标题栏(行):文本"主界面行设置" + 同样的"按规则隐藏"组合(`EditModeView.axaml:298-313`)。 - 抽屉内容宽度上限 `960`,内部 `.settings-container`(`Margin 12 12 18 12`、`Spacing 4`)。 - 选择组件时其它所有列表的选择必须清空(`MainWindow.axaml.cs:1500-1504`)。 #### 4.8.7 容器组件浮动编辑框(ContainerComponents) 在编辑模式中,点"查看子组件"把某个容器组件的子组件以浮动小窗形式加到画布上(`MainWindow.axaml:457-582`): - 宿主:`ItemsControl` + `Canvas`(`CanvasDragBehavior`),每项 `Canvas.Left/Top` 绑定 `EditModeContainerComponentInfo.X/Y`。 - 初始位置:被点击组件的屏幕点转换到画布坐标,再 `Y + 48`(`MainWindow.axaml.cs:1559-1567`)。 - 外框:`Border`,`HorizontalAlignment=Center`、`VerticalAlignment=Bottom`、`Background=CommandBarOverflowPresenterBackground`、`CornerRadius=6`、`Padding="4 0"`、`BorderBrush=CommandBarOverflowPresenterBorderBrush`、`BorderThickness=1`。 - 内容:横排 `Spacing 4`; - 拖动把手 `TouchDragThumb`,`Width=20`,`IsExplicitVisible=true`; - 关闭按钮(图标 `\uE671`,ToolTip"关闭")、组件图标 16×16、组件名文本,这一组 `font-size:14`、`Spacing 4`、`Margin "4 0 0 0"`; - 子组件列表 `EditableComponentsListBox`,`Height=40`。 - 拖拽中的项:`opacity: 0.5`、`z-index: 1`(`MainWindow.axaml:478-481`)。 - 关闭时移除对应 info(`EditModeView.axaml.cs:240-249`)。 #### 4.8.8 编辑模式的组件外观 - 被隐藏的组件仍然占位并显示"斜条纹"提示:`OpacityMask` 为 `45°` 线性渐变、`Background` 起止 `#afffffff`(0–0.5)、`#50ffffff`(0.5–1)、`Repeat`(`repos/ClassIsland/ClassIsland.Core/Controls/ComponentPresenter.axaml:19-33`)。CSS: ```css .comps-hidden { /* 仅编辑模式 */ -webkit-mask-image: repeating-linear-gradient(45deg, #afffffff 0 4px, #50ffffff 4px 8px); mask-image: repeating-linear-gradient(45deg, #afffffff 0 4px, #50ffffff 4px 8px); } ``` - 容器组件未选中时显示虚线边框(`SurfaceStrokeColorDefaultBrush`、`1.5px`、`dash 1/1.5`、`opacity 0.75`、`margin:0 2px`);选中时改为强调色虚线边框(`AccentFillColorDefaultBrush`,`EditableComponentsListBox.axaml:217-252`)。 - 选中组件的操作条(Adorner):出现在组件正下方,`Background=CommandBarOverflowPresenterBackground`、`CornerRadius=6`、`Padding=4`、`BorderBrush=CommandBarOverflowPresenterBorderBrush`、`BorderThickness=1`、`Spacing 6`(`EditableComponentsListBox.axaml:100-184`)。按钮:组件设置 `\uEF27`、"查看子组件" `\uEC30`(仅容器组件)、删除 `\uE61D`(`IndianRed`)、`25px` 竖分隔线、"更多选项…" `\uEBAC`(菜单:包裹到新容器 / 创建副本)。 - 每个组件的"添加组件"按钮:`height:32px`、图标 `\uE00D`、`font-size:20px`,位于组件列表末尾,只在编辑模式可见(`EditableComponentsListBox.axaml:55-60`)。 - 拖拽插入指示:拖拽中的列表在 `AdornerLayer` 上画一个强调色 1px 边框 + BoxShadow,并做 `1s` 交替无限动画(`0%: opacity .1` → `100%: opacity .6`,`SineEaseInOut`,`PlaybackDirection=Alternate`,`EditableComponentsListBox.axaml:307-336`)。 - 拖拽开始阈值:水平/垂直各 `3px`,拖拽预览 `opacity 0.5`(`EditableComponentsListBox.axaml:286-288`)。 - 拖拽预览浮标:`Background=AcrylicBackgroundFillColorDefaultBrush`、`CornerRadius=6`、`Padding=4`、`BorderBrush=SurfaceStrokeColorDefaultBrush`、`BorderThickness=1`、图标 16 + 名称、`Spacing 4`(`EditableComponentsListBox.axaml:22-39`)。该资源名虽含 Acrylic,但**实现必须用不透明降级色 `--ci-bg-tertiary`**,不得调用系统材质 API。 ### 4.9 隐藏与显示规则(完整判定) `hideMode`(`Settings.HideMode`,默认 `0`,`Models/Settings.cs:185`): - `0 = 基本`:由三个独立开关控制,全部在 `GridRoot` 上以伪类组合判断(`MainWindow.axaml:150-160`): - `HideOnClass=true` 且当前时间状态为"上课" → 隐藏(默认 `false`,`Models/Settings.cs:45`); - `HideOnMaxWindow=true` 且前台窗口最大化 → 隐藏(默认 `false`,`Models/Settings.cs:63`); - `HideOnFullscreen=true` 且前台窗口全屏 → 隐藏(默认 `false`,`Models/Settings.cs:54`)。 - `1 = 高级`:用 `Settings.HideRules`(规则集,JSON 字段名 `HiedRules`,`Models/Settings.cs:462-472`)求值,满足则隐藏(`MainWindow.axaml:161-163`、`MainWindow.axaml.cs:444-449`)。 检测数据源与频率: - 全屏/最大化状态在每个主计时器 tick 检测:`PlatformServices.WindowPlatformService.IsForegroundWindowFullscreen(screen)` / `IsForegroundWindowMaximized(screen)`(`MainWindow.axaml.cs:451-456`)。 - 排除进程名单 `ExcludedFullscreenWindow`,默认 `["explorer"]`(`Models/Settings.cs:56-59`):这些进程处于前台时不算全屏/最大化,不触发隐藏。 - 判断基准屏幕必须是"本窗口所在屏幕"(`GetSelectedScreenSafe()`)。 强制显示(覆盖隐藏): - `ExplicitShowed` = 正在播放提醒或处于编辑模式(`MainWindow.axaml:271`、`MainWindow.axaml.cs:1056-1092` 的 `Acquire/ReleaseTopmostLock`)。此时即使命中隐藏条件也必须显示,且窗口临时置顶。 - `IsMainWindowVisible`(用户手动"隐藏主界面",默认 `true`,`Models/Settings.cs:67`):`GridRoot` 的可见性 = `IsMainWindowVisible || IsEditMode`(`MainWindow.axaml:135-141`)。手动隐藏时同时不接受新提醒(行级 `AcceptsNotificationRequests` 要求 `IsNotificationEnabled && !IsAllComponentsHid && !_isOverlayOpen && !_isUnloading`,`MainWindowLine.cs:814`)。 - 所有行都隐藏时(`IsAllComponentsHid` 对所有行成立):**可见岛内容 `opacity:0`,窗口包围盒保持不变**(不改尺寸、不隐藏窗口),但不得销毁窗口(提醒到来时要能立刻显示)。 ### 4.10 右键菜单(原生菜单)与托盘 菜单必须是系统原生菜单(Electron `Menu.buildFromTemplate` + `tray.setContextMenu`),不得用 HTML 菜单。逐项规格(`MainWindow.axaml:51-132`): | # | 文本 | 图标 glyph | 可见条件 | 操作 | |---|---|---|---|---| | 1 | `ClassIsland` | AppLogo | 总是 | 打开"关于"设置页 | | 2 | `教学…` | `\uE959` | `IsWelcomeWindowShowed` | 打开教程中心 | | 3 | `帮助文档…` | `\uE23C` | `IsWelcomeWindowShowed` | 打开 `https://docs.classisland.tech/app/` | | 4 | 分隔符 | | | | | 5 | `显示主界面` | `\uE813` | `!IsMainWindowVisible && IsWelcomeWindowShowed` | 切 `IsMainWindowVisible` | | 6 | `隐藏主界面` | `\uE817` | `IsMainWindowVisible && IsWelcomeWindowShowed` | 切 `IsMainWindowVisible` | | 7 | `清除全部提醒` | `\uE029` | `IsWelcomeWindowShowed && IsNotificationsPlaying` | 取消全部提醒 | | 8 | `更多选项…` | `\uEBAC` | 存在插件注入项 | 子菜单 | | 9 | 分隔符 | | | | | 10 | `编辑档案…` | `\uE699` | 总是 | 打开档案编辑窗口 | | 11 | `加载临时课表…` | `\uE6A1` | 总是 | 打开档案窗口并展开"临时课表"抽屉 | | 12 | `换课…` | `\uE15F` | 总是 | 打开换课窗口(需权限) | | 13 | 分隔符 | | | | | 14 | `编辑主界面…` | `\uE7C9` | 总是 | 进入编辑模式 | | 15 | `应用设置…` | `\uEF27` | 总是 | 打开设置窗口 | | 16 | 分隔符 | | | | | 17 | `重启` | `\uE0BD` | 总是 | 重启应用 | | 18 | `退出` | `\uE0DF` | 总是 | 退出(需权限) | | 19 | 分隔符 | | `IsDebugEnabled` | | | 20 | `dev__Debug` | `\uE2C8` | `IsDebugEnabled` | 子菜单:DevTools / DevPortal / 截图工具 / 教程编辑器 / 启用课表 / 显示崩溃窗口 / WelcomeWindow / WelcomeWindow(full) | 托盘左键点击行为 `TaskBarIconClickBehavior`:`0=打开主菜单`(仅 Windows 有效,其他平台自动改为 4)、`1=打开档案编辑窗口`、`2=显示/隐藏主界面`、`3=打开换课窗口`、`4=打开设置窗口`;默认 Windows 平台 `0`、其他平台 `4`(`Models/Settings.cs:102`, `:540-561`,实现 `MainWindow.axaml.cs:617-662`)。 ### 4.11 本部分动画总表(照此实现,不得自创) | 动画 | 触发 | 属性 | 时长 | 缓动 | |---|---|---|---|---| | 行背景色过渡 | 主题/背景色变化 | `Background` | 200ms | 线性(`MainWindow.axaml:40-44` BrushTransition) | | 行内容透明度 | 行显示/隐藏 | `opacity` | 100ms | 线性(`FluentTheme/Styles.axaml:268`) | | 行背景宽度 | 内容宽变化 | `width` | 300ms(Fluent)/ 550ms(Classic) | `cubic-bezier(0.65,0,0.35,1.0)` / `cubic-bezier(0,1,0.5,1.1)` | | 行挂载淡入 | 首次可见 | `opacity 0→1` | 250ms | `cubic-bezier(0.25,1,0.5,1)`;`AnimationLevel≥2` 才播 | | 内容层交错重排 | 组件变化 | 位置/宽 | 300ms | `cubic-bezier(0.65,0,0.35,1.0)`(`MainWindow.axaml:197-198`) | | 遮罩斜掠(开) | 提醒开始 | 5 区段 scaleX 0→1 | 每段 360ms,错峰 0/90/90/120/120ms | `cubic-bezier(0.165,0.84,0.44,1)`(QuarticEaseOut) | | 遮罩斜掠(关) | 提醒结束 | 5 区段 scaleX 1→0 | 每段 360ms,反向错峰 | `cubic-bezier(0.55,0.085,0.68,0.53)`(QuadraticEaseIn) | | 强调色底淡入 | 提醒开始 | `opacity 0→1` | 100ms,delay 600ms | 线性 | | 遮罩文字淡入 | 提醒开始 | `opacity 0→1` | 250ms,delay 260ms | `cubic-bezier(0.25,1,0.5,1)` | | 遮罩文字缩放 | 提醒开始 | `scale 1.1→1.0` | 750ms,delay 260ms | `cubic-bezier(0.25,1,0.5,1)` | | 遮罩文字淡出 | 提醒结束 | `opacity 1→0` | 200ms | 线性 | | 遮罩文字缩小 | 提醒结束 | `scale 1.0→1.1` | 200ms | `cubic-bezier(0.5,0,0.75,0)` | | 浮层淡入淡出 | Overlay 开始/结束 | `opacity` | 200ms | SineEaseIn | | 提醒进度条值 | 剩余时间更新 | `value` | 50ms | 线性 | | 行变淡 | 指针离开 | `opacity →0.05` | 100ms | 线性 | | 编辑抽屉升降 | 打开/关闭抽屉 | `bottom`/`height` | 250ms | `cubic-bezier(0,0,0,1)` | | 遮罩层(smoke) | 抽屉打开 | `opacity` | 250ms | `cubic-bezier(0,0,0,1)` | | 拖拽插入高亮 | 拖动中 | `opacity 0.1↔0.6` | 1000ms 交替无限 | SineEaseInOut | | 斜条纹隐藏提示 | 隐藏组件 | — | 静态 | — | ### 4.12 验收标准 实现者必须逐条自查,全部通过才算完成本节: 1. 非编辑模式下用 Spy++/AutoHotkey 之类工具点击岛的任何位置,点击都穿透到桌面应用;编辑模式下岛内的按钮、拖拽把手、抽屉都能被点到。 2. 主界面窗口在 4K(150% 缩放)与 1080p(100% 缩放)两种 DPI 下都不出现 1px 白边、黑边、圆角锯齿;圆角在 `RadiusX=0` 时是直角,在 `20` 时左右边缘为半圆。 3. 6 个停靠位置逐个验证:`0/1/2` 贴工作区上边、`3/4/5` 贴下边;左/中/右的横向对齐分别贴左、居中、贴右,且窗口本身始终横跨整屏。 4. 修改"窗口向右/向下偏移"后岛整体平移,正值向下时上停靠要把偏移吞进安全区(不得被屏幕边缘裁掉)。 5. 行数从 1 增到 5、再减到 1:**可见岛高度严格等于"行高之和 + 行间距之和"**(误差 ≤1px),岛在窗口包围盒内平滑形变,**不出现窗口级闪烁**;包围盒只在行数超出 `contentMaxHeight` 时变更一次,且变更被节流 ≥100ms。 5.1 用 DevTools 的 Performance 面板录 10 秒:`setBounds` 调用次数为 0(无设置/行数变化时);置顶复核轮询间隔 ≥1000ms,无 1ms/10ms 级高频定时器。 6. 单行内容宽度变化(如时钟秒数出现/消失、课程名变长)时,背景宽度在 300ms 内以 `cubic-bezier(0.65,0,0.35,1.0)` 平滑跟随,文字不重排抖动,且**不触发窗口 `setBounds`**。 7. 行间距设为 `0`、`5`、`150` 时,相邻行间距精确等于设置值(用像素标尺截图量)。 8. 关闭某行"可以显示提醒"后,该行不再出现遮罩与进度条(走 Min 模板),其余行不受影响;多行同时具备提醒能力时,**具体由哪一行显示由第 7 章的分发规则决定**,本节只验证宿主与 `IsNotificationEnabled` 标记生效。 8.1 三条进度条(行提醒、课程、倒计时)高度均为 `3px`,圆角 `1.5px`,贴底且不越出行圆角裁切。 9. 删除到只剩 1 行时再点删除,出现"至少需要保留 1 个主界面行。"提示且行数不变。 10. 勾选 A 行为主要行后 B 行自动取消;取消 A 行后第一行自动成为主要行并出现提示。 11. 触发一次提醒:遮罩从左、右两端同时向中间斜掠,总时长约 480ms,文字在 260ms 后淡入并伴随 1.1→1.0 缩放;遮罩期间行内容消失,结束后恢复。 12. 阈值条件全开(上课隐藏、最大化隐藏、全屏隐藏),在前台最大化窗口时岛消失、切回桌面 1 个计时器周期内恢复;`ExcludedFullscreenWindow` 含 `explorer` 时桌面(资源管理器)前台不隐藏。 13. 鼠标移出岛 0.5 秒内行的整体不透明度降到 0.05,移回立即恢复;勾选"反向"后行为相反。 14. 编辑模式:进入后窗口扩展为"屏宽 ×(屏高−1px)",岛内容四边留 60px 安全边距并垂直居中,任务栏出现图标,鼠标可交互;退出后恢复包围盒尺寸与穿透状态,并写盘保存组件配置;编辑态背景为 `rgba(0,0,0,0.30)` 遮罩(无 `backdrop-filter`、无系统材质),退出后恢复逐像素透明。 15. 编辑模式下拖拽组件到另一行的插入位置时,出现强调色发光插入线(1s 交替闪烁),松手后组件落到目标位置且行宽平滑重排。 16. 容器组件"查看子组件"浮动框出现在被点组件下方 48px 处,可拖动、可关闭,关闭后 `ContainerComponents` 中对应项被移除。 17. 主界面完全不可见(手动隐藏 / 全部隐藏规则命中)时收到提醒,岛必须临时显示、置顶,提醒结束后回到隐藏状态。 18. 托盘图标左键行为的 5 种取值(`0–4`)逐个验证正确,其中非 Windows 平台 `0` 自动降级为 `4`。 --- ## 5. 组件系统:全部内置组件逐个规格 本节规定主界面上所有组件的实现规格。组件是"岛"的内容单元:每个组件的实例由一份 JSON(`ComponentSettings`)描述,含「组件 ID + 组件自己的设置对象 + 一系列外观/布局/隐藏覆盖项」。实现必须做成可插拔的注册表(原生 11 个组件 + 后续插件组件走同一接口),不得把组件硬编码进主界面。 ### 5.1 组件系统总则 #### 5.1.1 组件注册表(唯一事实来源) 必须内置下面 11 个组件,ID、名称、图标 glyph、描述、是否容器、设置类型逐字照抄(`[ComponentInfo]` 特性): | 组件 | ID(GUID) | 名称 | 图标 glyph | 描述 | 容器 | 设置类型 | 证据 | |---|---|---|---|---|---|---|---| | 日期 | `DF3F8295-21F6-482E-BADA-FA0E5F14BB66` | 日期 | `\ue304` | 显示今天的日期和星期。 | 否 | 无 | `Controls/Components/DateComponent.axaml.cs:14` | | 时钟 | `9E1AF71D-8F77-4B21-A342-448787104DD9` | 时钟 | `\ue31c` | 显示现在的时间,支持精确到秒。 | 否 | `ClockComponentSettings` | `ClockComponent.axaml.cs:17` | | 倒计时 | `7C645D35-8151-48BA-B4AC-15017460D994` | 倒计时 | `\uf361` | 显示距离某一天的倒计时。 | 否 | `CountDownComponentSettings` | `CountDownComponent.axaml.cs:29` | | 课程表 | `1DB2017D-E374-4BC6-9D57-0B4ADF03A6B8` | 课程表 | `\ue751` | 显示当前的课程表信息。 | 否 | `LessonControlSettings` | `ScheduleComponent.axaml.cs:30` | | 天气简报 | `CA495086-E297-4BEB-9603-C5C1C1A8551E` | 天气简报 | `\uf465` | 显示当前的天气概况和气象预警。 | 否 | `WeatherComponentSettings` | `WeatherComponent.axaml.cs:16` | | 文本 | `EE8F66BD-C423-4E7C-AB46-AA9976B00E08` | 文本 | `\uf323` | 显示自定义文本。 | 否 | `TextComponentSettings` | `TextComponent.axaml.cs:16` | | 滚动容器 | `70FCD5EA-3FAE-4E06-ACA2-4F4DF47F9ACD` | 滚动容器 | `\uf279` | 滚动显示组件内的内容。 | 是 | `RollingComponentSettings` | `RollingComponent.axaml.cs:33` | | 轮播容器 | `7E19A113-D281-4F33-970A-834A0B78B5AD` | 轮播容器 | `\uefc9` | 轮播多个组件。 | 是 | `SlideComponentSettings` | `SlideComponent.axaml.cs:24` | | 分组容器 | `C911D762-107F-40C6-84CC-0146AB3C86B1` | 分组容器 | `\ue92f` | 将多个组件组合到一个组件中显示。 | 是 | `GroupComponentSettings` | `GroupComponent.axaml.cs:11` | | 堆叠容器 | `2D849ECE-9F21-4C78-9434-415CFC283294` | 堆叠容器 | `\uf04f` | 将多个容器堆叠显示。 | 是 | `StackComponentSettings` | `StackComponent.axaml.cs:11` | | 分割线 | `AB0F26D5-9DF6-4575-B844-73B04D0907C1` | 分割线 | `\uf023` | 显示一个分割线,视觉上对组件进行分组。 | 否 | 无 | `SeparatorComponent.axaml.cs:9` | 图标字体为 `FluentSystemIcons-Resizable`(`repos/ClassIsland/ClassIsland.Core/ThemeBase.axaml:46`),`FontFamily` 绑到 `ci|FluentIcon` 全局样式(`StylesBase.axaml:79-81`)。实现时用同名 SVG 图标表 + `mask-image` 或内联 SVG,不得贴无对应关系的图标。 `课程表` 组件有一个历史 ID 需在迁移时映射:`E7831603-61A0-4180-B51B-54AD75B1A4D3` → 现 ID(`[MigrateFrom]`,`ScheduleComponent.axaml.cs:29`)。 #### 5.1.2 默认组件布局(首次启动的样子) 首次启动必须生成 1 个主界面行,行内依次为「日期」「课程表」,其余字段取默认值(`repos/ClassIsland/ClassIsland/Services/ComponentsService.cs:33-52`)。实现即:`lines = [{ children: [dateId, scheduleId], ...默认行设置 }]`。 #### 5.1.3 组件宿主(ComponentPresenter):所有组件共用的外壳 每个组件都必须包在统一的宿主里,宿主负责下面这些语义(`repos/ClassIsland/ClassIsland.Core/Controls/ComponentPresenter.axaml`): 1. **可见性三态**:`visible = IsVisibleInternal || 主界面处于编辑模式`(`:13-18`)。隐藏的组件在编辑模式仍占位。 2. **透明度**:宿主 `opacity = 组件设置.Opacity`(默认 `1.0`,范围 0–1,`:34-36`)。 3. **字号继承**:宿主根元素的 `font-size = var(--font-body)`(16px),即所有组件默认正文字号;组件内部需要别的字号时必须显式引用 `--font-secondary/emphasized/large`(`:37-38`)。 4. **宽度三态**(互斥,优先级:固定宽 > 最大宽/最小宽): - `IsFixedWidthEnabled` → `width = FixedWidth`(默认 `200px`,`:45-47`); - `IsMaxWidthEnabled` → `max-width = MaxWidth`(默认 `300px`,`:39-41`); - `IsMinWidthEnabled` → `min-width = MinWidth`(默认 `100px`,`:42-44`)。 默认三者全 `false`(`repos/ClassIsland/ClassIsland.Core/Models/Components/ComponentSettings.cs:30-35`)。 5. **水平对齐**:宿主内容的 `justify-content` 取 `Settings.HorizontalAlignment`,默认 `stretch`(枚举 `Stretch/Left/Center/Right`,`ComponentSettings.cs:36`,选择器 `:49-51`)。 6. **内边距**:宿主内部 `Shimmer` 容器 `padding: 8px 0`(`:55-57`),但组件自身的横向间距由外层 `margin: 0 6px` 提供(行内模板 `Margin="6 0"`,`MainWindow.axaml:396`),两者都要保留,不得合并。 7. **加载占位**:首次布局或配置切换时显示 Shimmer 占位,占位宽度取上次测量宽度 `LastWidthCache`(默认 `100`,`ComponentSettings.cs:39`,`ComponentPresenter.axaml:69-70`)。占位态不得让行高变化。 8. **隐藏规则**:`HideOnRule + HidingRules` 满足时组件不可见但不卸载、不释放资源(`ComponentSettings.cs:100-123`)。 9. **裁剪**:宿主自身 `overflow: visible`(不对组件内部做裁剪),裁剪交给行的 `clip-path`(见 4.5.4);但轮播容器内部有 40px 高度钳制(见 5.9)。 #### 5.1.4 组件通用设置项("高级设置"页,所有组件都有) 以下字段存在于每一个 `ComponentSettings` 上,默认值固定(`repos/ClassIsland/ClassIsland.Core/Models/Components/ComponentSettings.cs:23-51`),UI 顺序照 `ComponentAdvancedSettingsControl.axaml`: | 字段 | 默认 | UI 控件 | 范围/步进 | |---|---|---|---| | `IsResourceOverridingEnabled` | `false` | 开关 + 4 个字号输入 | 字号 1–40 | | `MainWindowSecondaryFontSize` | `14` | NumberBox | 1–40 | | `MainWindowBodyFontSize` | `16` | NumberBox | 1–40 | | `MainWindowEmphasizedFontSize` | `18` | NumberBox | 1–40 | | `MainWindowLargeFontSize` | `20` | NumberBox | 1–40 | | `IsCustomForegroundColorEnabled` | `false` | 开关 + ColorPicker | — | | `ForegroundColor` | `DodgerBlue` | ColorPicker | — | | `Opacity` | `1.0` | Slider | 0–1,步进 0.05 | | `IsCustomBackgroundColorEnabled` | `false` | 开关 + ColorPicker | — | | `BackgroundColor` | `Black` | ColorPicker | — | | `IsCustomBackgroundOpacityEnabled` | `false` | 开关 + Slider | 0–1,步进 0.05 | | `BackgroundOpacity` | `0.5` | Slider | 0–1 | | `IsCustomCornerRadiusEnabled` | `false` | 开关 + Slider | 0–20,步进 0.5 | | `CustomCornerRadius` | `8.0` | Slider | 0–20 | | `HorizontalAlignment` | `Stretch` | 4 个图标按钮 | 左/居中/拉伸/右 | | `IsFixedWidthEnabled` / `FixedWidth` | `false` / `200` | 开关 + NumberBox | — | | `IsMinWidthEnabled` / `MinWidth` | `false` / `100` | 开关 + NumberBox | — | | `IsMaxWidthEnabled` / `MaxWidth` | `false` / `300` | 开关 + NumberBox | — | | `IsCustomMarginEnabled` | `false` | 开关 | — | | `MarginLeft` / `MarginTop` / `MarginRight` / `MarginBottom` | `0` / `0` / `0` / `0` | 4 个 NumberBox | — | | `HideOnRule` / `HidingRules` | `false` / `{}` | 开关 + 规则集编辑器 | — | | `RelativeLineNumber` | `0` | 内部字段 | — | 对齐按钮图标(`ComponentAdvancedSettingsControl.axaml:223-232`):左=`\uE03E`、居中=`\uE03A`、拉伸=`\uE034`、右=`\uE03C`。 自定义背景/圆角只有在"组件位于独立岛"(分体主界面)语义下才画得出来;普通行内背景由行承担,此时组件的背景设置不生效——实现必须在 UI 上如实表现(禁用或不渲染),不得偷偷画。 #### 5.1.5 容器组件通用约定 - 注册时带 `[ContainerComponent]`,宿主 `ComponentInfo.IsComponentContainer = true`。 - 子组件集合 = 组件设置对象实现 `IComponentContainerSettings.Children`(`ComponentSettings.cs:143-144`);因此 `GroupComponentSettings`、`StackComponentSettings` 只有 `Children` 一个字段(`Models/ComponentSettings/GroupComponentSettings.cs`、`StackComponentSettings.cs`)。 - 容器内部为子组件再套一层 `ComponentPresenter`,`IsOnMainWindow=true`、`Margin="6 0"`,并透传 `HidingRules/HideOnRule`(`GroupComponent.axaml:25-29`、`StackComponent.axaml:24-28`、`RollingComponent.axaml:36-41`、`SlideComponent.axaml:114-120`)。 - 容器的子列表外层容器统一 `Margin="-6 0"`(抵消一次边距),循环嵌套时视觉间距保持 12px 恒定(`GroupComponent.axaml:15`、`StackComponent.axaml:14`、`RollingComponent.axaml:16`、`SlideComponent.axaml:22`)。 - 容器内部的子组件条目使用 "components-panel" 面板类:`WrapPanel`/`StackPanel` 需要"重排动画"标记;`StackPanel` 固定 `Orientation=Horizontal`(`repos/ClassIsland/ClassIsland.Core/Themes/ComponentsPanelStyles.axaml:11-16`)。 #### 5.1.6 组件高度与"岛"的约束 - 组件可用高度恒为 `40px`(行高,见 4.5.2)。所有组件必须在这个高度内垂直居中显示。 - 宿主的 `MaxHeight` 受容器约束:轮播容器显式 `MaxHeight = IslandContainerHeight`(`SlideComponent.axaml:119`)、编辑态列表项 `MaxHeight = IslandContainerHeight`(`EditableComponentsListBox.axaml:70`)。 - 组件内部若出现高度超过 40px 的内容(如两行文字),必须自行 clamp 或按设计折行;不得撑高行。 #### 5.1.7 时间驱动 除文本/分割线外,所有组件在主计时器 tick 时刷新(ClassIsland 用 `ILessonsService.PostMainTimerTicked`,`DateComponent.axaml.cs:39-40`、`ClockComponent.axaml.cs:61`、`CountDownComponent.axaml.cs:142`、`ScheduleComponent.axaml.cs:119`)。实现: - 用单一 1 秒节拍(主进程 `setInterval(1000)` 对齐到整秒边界)+ 事件广播; - 需要亚秒/更快的(滚动、轮播、提醒进度)各自用 `requestAnimationFrame` 或独立定时器,不得把 1 秒节拍改成 16ms 全局广播; - 页面不可见或被系统节流时,靠"重新挂载时立即刷新一次"兜底(对应 `AttachedToVisualTree` 时 `UpdateContent()`)。 ### 5.2 日期(DateComponent) - 注册:见 5.1.1。 - 视觉:一个 `TextBlock`,垂直居中;文本格式 `ddd MM/dd`(`DateComponent.axaml:11`,`StringFormat=\{0:ddd MM/dd\}`)。 - 中文(`zh-CN`)渲染示例:`周三 11/12`;星期用短名(不补"星期"二字),月/日补零两位,斜杠分隔,无空格之外的分隔符(`ddd` 与 `MM/dd` 之间有一个空格)。 - 字号:继承 `--font-body` = 16px;颜色:继承前景色;字重:继承 `--font-weight`(默认 500)。 - 数据:`Today = ExactTimeService.GetCurrentLocalDateTime()`,每次 tick 刷新(`DateComponent.axaml.cs:43-46`)。必须使用"精确时间服务"(含 NTP 校正与时间偏移),不得直接用 `new Date()`。 - 设置:无。 - 边界: - 跨日 00:00 必须自动更新(不能只在启动时取一次)。 - 系统时间被手动修改后 1 个 tick 内反映。 - 语言/区域变化时按 `Intl.DateTimeFormat('zh-CN', { weekday:'short' })` 重新渲染,且宽度变化要触发行宽动画(见 4.5.7)。 - Vue 结构建议:`{{ dateText }}`,`white-space: nowrap; font-variant-numeric: tabular-nums;`。 ### 5.3 时钟(ClockComponent) - 视觉结构(`ClockComponent.axaml:13-41`):外层网格水平/垂直居中,`font-size = var(--font-emphasized)`(18px)。 ```html
09:41 09 41
``` - 三种显示态由两个类控制(`:19-32`): - `TimeSeparatorShowing`:`clock-full` 可见(`opacity:1`),`clock-hour/clock-minute` 隐藏; - 非 `TimeSeparatorShowing`:`clock-full` 隐藏(`opacity:0`),`clock-hour` 左对齐 + `clock-minute` 右对齐显示,二者之间留出分隔符的宽度(用等宽字符宽的占位,或给两个 span 之间固定 `gap: <一个字符宽>`;Avalonia 里两个 TextBlock 在同一 Grid 单元格内用 Left/Right 对齐实现,中间自然留空)。 - `ShowSeconds`:文本格式变为 `HH:mm:ss`(`:28-30`)。 - 分隔符闪动逻辑(`ClockComponent.axaml.cs:71-76`): ``` IsTimeSeparatorShowing = !FlashTimeSeparator || ShowSeconds || (当前秒 % 2 == 1) ``` 即:开启闪动且不显示秒时,冒号在奇数秒显示、偶数秒消失(每次消失 1 秒);显示秒时冒号常亮。 - 数字排版:必须 `font-variant-numeric: tabular-nums`(等宽数字),否则冒号闪动与秒数变化会让整块宽度跳动(ClassHelper 对时钟也做了同样处理:`repos/ClassHelper-master/packages/desktop-client/src/island/ComponentNode.vue:305-308`)。 - 时间源:`CurrentTime = Settings.ShowRealTime ? DateTime.Now : ExactTimeService.GetCurrentLocalDateTime()`(`ClockComponent.axaml.cs:73`)。 - 设置项与默认值(`Models/ComponentSettings/ClockComponentSettings.cs`): | 字段 | 默认 | UI 文案 | 说明 | |---|---|---|---| | `ShowRealTime` | `false` | 使用实际时间 | 显示不经过时间偏移的系统时间 | | `ShowSeconds` | `false` | 显示秒数 | 精确到秒 | | `FlashTimeSeparator` | `true` | 闪动时间分隔符 | 模拟电子时钟分隔符闪动 | - 边界: - 24 小时制,不显示 AM/PM(格式串 `HH:mm`)。 - 显示秒时每秒重绘一次;不显示秒时也需每秒 tick 以驱动闪动。 - 若系统时间不可用(精确时间服务失败)必须回退本地时间,不得显示空白。 - 验收要点:开启闪动后,肉眼观察冒号 1 秒亮 1 秒灭;开启秒数后冒号常亮且每秒变化;整块数字在切换时不发生水平位移。 ### 5.4 倒计时(CountDownComponent) 最复杂的非容器组件。结构(`CountDownComponent.axaml:64-114`): ```html
距离 倒计时 还有 30
``` 尺寸与样式(逐条,`CountDownComponent.axaml:66-112`): - 外层:横排、`gap: 2px`、垂直居中。 - 进度环容器:`margin-right: 4px`、垂直居中;底环 `Ellipse` 宽高 `21.4px`、描边 `2.6px`、颜色 `ControlStrongStrokeColorDisabledBrush`;前景弧 `Path` 宽高 `22px`、`stroke-linecap: round`、`stroke-width: 3.1`、颜色默认 `AccentFillColorDefaultBrush`,开启"进度条使用强调色"后改为 `Settings.FontColor`。 - 弧半径计算必须照抄:控件尺寸 22×22、描边 3、`radius = 22/2 - 3/2 = 9.5`,起点在正上方(`center.Y - radius`),顺时针扫过 `percent/100*360` 度,`percent >= 100` 时按 `99.9999` 处理以避免闭合渲染问题,`percent <= 0` 时不画(`CountDownComponent.axaml.cs:32-85`)。 - 文本:"距离" + 名称 + 连接词 + 数值;"距离"与连接词用 `connector l-instructive` 类(次要色);名称与数值用 `Settings.FontColor` + `Settings.FontSize`(默认 `#FFFF0000`、16px)。 - 紧凑模式(`IsCompactModeEnabled`):隐藏"距离"与连接词,只留名称 + 数值(`:85, :95`)。 - 底部进度条:`Maximum=100`、`Minimum=0`、`Value=Percent`、贴底、`min-width:0`、宽度撑满行、**高度固定 `3px`(token `--ci-progress-height: 3px`,`border-radius: 1.5px`)**;实现用 `width: Percent%` 或 `scaleX`(`:102-112`)。 - 设置项与默认值(`Models/ComponentSettings/CountDownComponentSettings.cs`,逐字段): | 字段 | 默认 | 说明 | |---|---|---| | `CountDownName` | `"倒计时"` | 事件名称 | | `CountdownSource` | `0` | `0 固定时间 / 1 周期 / 2 今天 / 3 本周` | | `StartTime` | 今天 0 点 | 固定时间模式的起点 | | `OverTime` | 今天 0 点 | 固定时间模式的终点(日为 0 的那天) | | `CycleStartTime` | 当前时刻 | 周期模式起点 | | `CycleBeforeDuration` | `TimeSpan.Zero` | 周期前阶段 | | `CycleDuration` | `1 天` | 周期长度 | | `CycleAfterDuration` | `TimeSpan.Zero` | 周期后阶段 | | `IsCycleCountLimited` | `false` | 限制循环次数 | | `CycleCountLimit` | `2` | 循环次数上限(UI 范围 1–33550336) | | `IsAdvancedCycleTimingEnabled` | `false` | 启用前后阶段 | | `NatureTimeUseMode` | `0` | `0 默认 / 1 强制自然时间 / 2 强制课程时间` | | `WeekCountdownStartDay` | `Monday` | 本周模式的第一天 | | `IsCustomWeekCountdownStartDayEnabled` | `false` | 自定义每周第一天(否则取学期开始日的星期) | | `FontColor` | `#FFFF0000` | 名称与数值颜色(UI 文案"强调色") | | `FontSize` | `16` | 范围 16–30,步进 1 | | `IsCompactModeEnabled` | `false` | 紧凑模式 | | `CountDownConnector` | `"还有"` | 连接词 | | `IsConnectorColorEmphasized` | `false` | 连接词用强调色 | | `ShowProgress` | `false` | 显示进度 | | `ProgressBarMode` | `0` | `0 进度环 / 1 进度条` | | `UseAccentOnProgressBar` | `true` | 进度使用强调色(否则用应用主题色) | | `IsProgressInverted` | `false` | 进度反相(用"剩余"而非"已过"计算) | | `CustomStringFormat` | `"%D天"` | 数值格式化模板 | - 格式化模板替换规则(必须逐 token 实现,`CountDownComponent.axaml.cs:268-287`): | Token | 含义 | 取整 | |---|---|---| | `%D` | 总天数 | 向上取整 | | `%H` | 总小时数 | 向上取整 | | `%M` | 总分钟数 | 向上取整 | | `%S` | 总秒数 | 向上取整 | | `%X` | 总毫秒数 | 向上取整 | | `%P` | 已过百分比(`P0`,如 `30%`) | 四舍五入到 2 位小数后按 P0 | | `%p` | 已过百分比(`P2`,如 `30.00%`) | 保留 2 位 | | `%L` | 剩余百分比(`P0`) | 四舍五入到 2 位 | | `%d` | 天(元素值) | 不取整 | | `%h` | 小时(元素值) | 不取整 | | `%m` | 分钟(元素值) | 补两位 | | `%s` | 秒(元素值) | 补两位 | | `%x` | 毫秒(元素值) | 补三位 | 中国区必须用不变文化(`InvariantCulture`)格式化数字,避免千分位。`delta < 0` 时钳制为 0(`:260-264`)。 - 四种时间源的时间区间计算(必须照抄,`CountDownComponent.axaml.cs:166-247`): 1. **固定时间**:`(StartTime, OverTime)`。 2. **周期**:`total = before + duration + after`;`cycles = floor((now - cycleStart)/total)`(启用次数限制时再取 `min(cycles, limit)`);`start = cycleStart + cycles*total + before`;`end = start + duration`。`total <= 0` 时退化为 `(cycleStart, cycleStart)`。 3. **今天**:`NatureTimeUseMode==1` → `(今天0点, 明天0点)`;否则取当天时间表中第一个"上课/课间"时间点的开始 → 最后一个"上课/课间"时间点的结束;课程不可用时按 `NatureTimeUseMode==2` 退化为 `(DateTime.MinValue, DateTime.MinValue)`(不显示倒计时),否则退化为自然日。 4. **本周**:按 `WeekCountdownStartDay`(或学期开始日的星期)求出本周起点;`NatureTimeUseMode==1` → 周起点到下周起点+1 天;否则遍历 7 天找第一个有课的日子与最后一个有课的日子,取其首/末时间点拼成区间;课程全无时按模式 2 退化或自然周。 - 进度百分比(`:288-295`):`value = (IsProgressInverted ? end - now : now - start).TotalSeconds`;`totalSeconds <= 0` 时 `Percent = 0`;开启进度精度降低(主界面全局设置 `ReduceProgressAccuracy` 默认 `true`)时 `progressTick = max(10, totalSeconds/500)`,`Percent = round(value/progressTick)*progressTick / totalSeconds * 100`;否则 `progressTick = 1`。 - 边界: - 终点在过去 → 数值全 0、`Percent=0`、进度环不画。 - 起点在未来(固定时间模式配错)→ `Percent` 为负,必须钳到 0。 - 名称/连接词为空字符串时不得留出空 span 的间距(用 `display:none` 而非空文本)。 - 字号变化(16–30)时进度环/进度条不跟随放大,只放大文本。 - Vue 结构建议:`CountDownNode.vue` props = `{ settings, now }`,内部 `computed` 出 `remaining`、`percent`、`text`;进度环用 SVG `circle` + `stroke-dashoffset = C * (1 - percent/100)`,`C = 2π*9.5 ≈ 59.69`。 ### 5.5 课程表(ScheduleComponent) 课程表是"一行多条课程"的核心组件,内部由 `LessonsListBox`(`repos/ClassIsland/ClassIsland.Core/Controls/LessonsControls/LessonsListBox.axaml`)渲染一组课程控件。 #### 5.5.1 外层结构 ```html
明天
  • …课程控件…
  • …
    …
今天没有课程。
``` - 外层 Grid 两列 `Auto, *`(`ScheduleComponent.axaml:78`)。 - "明天"标记:`margin-right: 2px`、垂直居中、背景 `AccentFillColorDefaultBrush`、内边距 `8px 2px`、`border-radius: 16px`(药丸)、文本"明天"、前景 `TextOnAccentFillColorPrimaryBrush`、文本 `margin: 0 1px -1px`(微调基线)(`:79-89`)。 - 课程列表容器:`height: 40px`、水平 `StackPanel`、`ScrollViewer.VerticalScrollBarVisibility=Disabled`(`LessonsListBox.axaml:41-50`)。 - 每个列表项容器(`LessonsListBox.axaml:100-136`):`background: ListViewItemBackground`、`border-radius: var(--control-corner-radius)`(FluentAvalonia 控制圆角,默认 4【推断:FluentAvalonia ControlCornerRadius 默认 4】)、`padding: 0`、`max-height: 40px`、`min-width: 0`、内容纵横都 `stretch`;`:disabled` 时 `opacity: 0.5`;`:pointerover/:pressed/:selected` 各自换背景色(默认鼠标穿透,实际见不到,但编辑/演示态仍要正确)。 - 已结束课程淡化:`opacity: 0.6`,`transition: opacity 150ms cubic-bezier(0.645, 0.045, 0.355, 1)`(`CubicEaseInOut`,`LessonsListBox.axaml:56-77`)。开关 `FadeCompletedClasses`(默认 `true`)。 #### 5.5.2 三种课程控件 **(a) 展开型 `LessonControlExpanded`**(时间点为"上课"时显示,`LessonControlExpanded.axaml`): ```html
语文 08:00-08:40
``` - 左右各一个 `16px` 间隔,宽度按 `ScheduleSpacing` 水平缩放(`ScaleTransform ScaleX`,`:51-56, 219-224`);`ScheduleSpacing` 默认 `1`,UI 范围 `0.2–1`(`ScheduleComponentSettingsControl.axaml:156`),因此左右各 3.2–16px。 - 科目名:`font-size = var(--font-emphasized)`(18px)、`font-weight: bold`,包在 `HighlightBox` 里(`:65-68`)。 - 附加信息区:`margin: 6px 0 0 0`、类 `l-secondary`(次要色、`font-size = var(--font-secondary)` = 14px)、贴底(`:72-84`)。 - `ExtraInfoType`(`ShowExtraInfoOnTimePoint=true` 时生效)6 种(`:85-171`): - `0`:`开始时间-结束时间`,格式 `hh:mm`(如 `08:00-08:40`); - `1`:已过时间,格式 `-mm:ss`(秒数向上/向下取整见转换器); - `2`:剩余时间,格式 `-mm:ss`; - `3`:进度百分比; - `4`:模糊倒计时,`-<1min` 形式(用 `CountdownSeconds` 向上取整); - `5`:精确倒计时,`-mm:ss`。 - 当前课程的"剩余时间药丸"(`IsCountdownEnabled` 且 `MasterTabIndex==1` 时):背景 `AccentFillColorTertiaryBrush` + `opacity: 0.3` 的底 + `1px` 边框 `AccentFillColorDefaultBrush` + `padding: 8px 2px` + 圆角 = 高度的一半(药丸);文本 `font-size = var(--font-body)`(16px),普通模式 `-mm:ss`,非精确倒计时模式 `< 0`(`:186-198`)。 - 每种预警有两种形态,由 `AlertsTitleShowMode` 决定(默认 `1`): - `0 = 总是图标`:只显示 22×22 图标; - `1 = 默认`:**有自定义图标**的预警显示图标,没有的显示全文药丸(`IsDefaultIcon` 判定); - `2 = 总是全文`。 - 图标形态:``(`:246-251`)。 - 全文形态(`:252-263`): ```css .alert-pill { display: grid; grid-template-columns: 1fr 1fr; margin-left: 2px; } .alert-pill .pill-bg { grid-column: 1 / span 2; background: var(--control-fill-default); border-radius: 11px; /* 高度 22 的整圆 */ } .alert-pill img { width: 22px; height: 22px; } .alert-pill .type { margin: 4px 8px 0 4px; /* Margin="4 0 8 0" 水平方向 */ font-size: var(--font-secondary); /* 14px */ align-self: center; } ``` - 预警图标由后端 `Images[icon]` 提供(可能是 GIF/PNG),必须支持动态图与失败兜底(失败时显示感叹号图标,不得整块消失)。 #### 5.6.3 降水提示(`ShowRainTime`,默认 `true`) - 仅当 `雨剩余分钟数 > 0` 时显示(`:268-275`)。 - 双层药丸(`:276-311`): ```css .rain { position: relative; display: flex; align-items: center; padding: 0 8px; height: 22px; } .rain .bg { position: absolute; inset: 0; background: var(--rainy-color); opacity: 0.15; border-radius: 11px; } .rain .ring { position: relative; border: 1px solid var(--rainy-color); border-radius: 11px; padding: 0 8px; display: flex; align-items: center; } .rain .icon { font-size: var(--font-secondary); margin-right: 4px; } /* LucideIcon \uE092 */ .rain .text { font-size: var(--font-secondary); font-weight: 400; } /* 14px */ ``` - 文本两态:`IsFuzzyLongRainCountdownEnabled=true`(默认)→ 用"约 X 分钟/约 X 小时"的模糊文案(`MinutesToApproxTimeConverter`);`false` → 精确 `{n}min`(`:296-308`)。 - 药丸圆角 = 自身高度的一半(`CornerRadius = SizeDoubleToCornerRadiusConverter(Bounds.Height)`)。 #### 5.6.4 设置项与默认值(`WeatherComponentSettings`) | 字段 | 默认 | UI 文案 | |---|---|---| | `ShowMainWeatherInfo` | `true` | 显示天气信息 | | `MainWeatherInfoKind` | `0` | 主信息类型(6 选 1) | | `ShowAlerts` | `true` | 显示预警信号 | | `AlertsTitleShowMode` | `1` | 显示预警类型(0 总是图标/1 默认/2 总是全文) | | `ShowRainTime` | `true` | 显示降水提示 | | `IsFuzzyLongRainCountdownEnabled` | `true` | 模糊长时间降雨倒计时时间显示精度 | #### 5.6.5 天气图标模板 全局设置 `Settings.WeatherIconId`,默认 `"classisland.weatherIcons.lucide"`(`Models/Settings.cs:1849`)。内置 4 套(`App.Services.xaml.cs:287-290`): | ID | 名称 | |---|---| | `classisland.weatherIcons.lucide` | Lucide(默认) | | `classisland.weatherIcons.fluentDesign` | Fluent Design | | `classisland.weatherIcons.sfSymbols` | SF Symbols | | `classisland.weatherIcons.simpleText` | 纯文本 | - 纯文本模板的渲染规则(`Controls/WeatherIcons/SimpleTextWeatherIconTemplate.axaml`):若该图标用于"到"(`Tag='To'`),前置一个 `~` 分隔符,再显示 `WeatherName`;否则只显示 `WeatherName`,水平居中。 - 实现:把这 4 套模板做成可注册的"图标渲染器"(返回 SVG 或文本节点),`weatherPackIcon` 组件按 `WeatherIconId` 分发;未知 ID 必须回退到 lucide。 #### 5.6.6 边界 - 天气数据为空/加载失败:不显示温度与图标(不得显示 `0°` 这种假数据);预警与降水区整体隐藏;组件不得撑高。 - 天气数据过期(`stale`)时仍显示旧数据,但可加 `opacity: 0.7`【推断:ClassIsland 未做此处理,属可选增强】。 - 预警可能同时 3 条以上,`WrapPanel` 在 40px 内只放得下一行,多余项被行裁切;必须按"最多显示 N 条 + 更多"策略实现【推断:ClassIsland 未裁剪,依赖用户自行配置】。 ### 5.7 文本(TextComponent) - 视觉:单个 `TextBlock`,垂直居中,`font-size = Settings.FontSize`(`:18-28`)。 - 设置项与默认值(`Models/ComponentSettings/TextComponentSettings.cs`): | 字段 | 默认 | UI 文案 | |---|---|---| | `TextContent` | `""` | 文本内容 | | `UseCustomFontColor` | `true` | 自定义字体颜色 | | `FontColor` | `#FFFFFF` | 颜色选择器 | | `FontSize` | `16` | 字体大小,范围 16–30、步进 1 | - 颜色仅当 `UseCustomFontColor=true` 时生效,否则继承前景色(宿主/行/全局)。 - 边界:内容为空时不占宽度(`display:none` 或空 span),避免行内留出 12px 空隙;内容超长时按 `max-width` 约束(如果启用了最大/固定宽度)并 `ellipsis`。 - 支持多行文本?ClassIsland 只有一个 `TextBlock` 且行高 40,因此**必须换行时用 `\n` 也只会被裁切**;实现上禁止插入换行,输入框遇到换行符替换为空格。 ### 5.8 滚动容器(RollingComponent) - 语义:内容宽于容器时,内容横向匀速滚动;可定时暂停、可按规则停止。 - 结构(`RollingComponent.axaml:14-48`):外层 `Border`(`overflow: visible`、`margin: 0 -6px`)→ `Canvas`(宽 = 内容 `ItemsControl` 宽、左对齐)→ `Grid`(`grid-template-columns: auto 16px auto`)→ `ItemsControl`(子组件横排)。 - 滚动动画(关键公式,`RollingComponent.axaml.cs:85-135`): ``` innerW = 内容自然宽度 outerW = 容器宽度 仅当 outerW < innerW 才滚动 pausePos = (!IsPauseEnabled || PauseOffsetX > innerW) ? 0 : PauseOffsetX durSeconds = innerW / clamp(SpeedPixelPerSecond, 1, +∞) pauseSeconds= IsPauseEnabled ? PauseSeconds : 0 backSeconds = outerW / clamp(SpeedPixelPerSecond, 1, +∞) totalSeconds= durSeconds + pauseSeconds + backSeconds 关键帧(全部 LinearEasing,IterationBehavior=Forever): t=0 X = 0 t=pause/total X = -pausePos t=(pause+dur)/total X = -(innerW + pausePos) t=(pause+dur)/total (同一时刻跳切) X = outerW t=1 X = 0 ``` 即:先停 `pauseSeconds`(若 `PauseOffsetX>0` 则先移到 `-PauseOffsetX`),再匀速把内容推到完全离开,瞬间跳回右侧,再匀速推回原位。`Duration = pauseSeconds + durSeconds`(`:108`)——注意 Aughor 原实现里 `anim.Duration` 只含前两段,靠 `t=1` 关键帧兜住回程;实现时必须按"总时长 = pause+dur+back"重新归一化时间轴,否则回程速度会与去程不一致(这是源码中的已知近似,实现时应修正以保证视觉匀速)。 - 规则停止:`StopOnRule && 规则满足` → 停止动画并立刻回到初始位置(`visual.Offset.X = -pausePos`),并写一个 1 秒的"空动画"把 Offset 固定住防止被其他动画拖走(`:137-157, 171-180`)。 - 设置项与默认值(`Models/ComponentSettings/RollingComponentSettings.cs`): | 字段 | 默认 | UI 文案 | 范围 | |---|---|---|---| | `SpeedPixelPerSecond` | `40.0` | 滚动速度 | ≥0.1 px/s | | `IsPauseEnabled` | `true` | 定时暂停 | | | `PauseSeconds` | `10.0` | 暂停时长 | 0–最大秒数 | | `PauseOffsetX` | `0.0` | 暂停位置 | ≥0 px | | `PauseOnRule` | `false` | — | 【预留】保留 schema、不给 UI(暂停由 5.9 轮播容器的规则承担) | | `PauseRule` | `{}` | — | 【预留】保留 schema、不给 UI | | `StopOnRule` | `false` | 满足规则时停止 | | | `StopRule` | `{}` | 规则集 | | - 提示 InfoBar(首次使用显示):"滚动组件仅在组件内容超出滚动组件宽度时才会开始滚动。您可能需要在【高级设置】中为本组件设置【最大宽度】或【固定宽度】……"(`RollingComponentSettingsControl.axaml:22-24`,`IsRollingComponentWarningVisible` 默认 `true`,`Models/Settings.cs:1850`)。 - 边界:内容未超出时完全不滚动(不得出现 1px 抖动);`SpeedPixelPerSecond` 极小时按 ≥1 px/s 计算以免除零;子组件全部隐藏时宽度为 0,应停止滚动;窗口失焦/系统节流导致帧不连续时不得跳帧。 ### 5.9 轮播容器(SlideComponent) - 语义:多个子组件共用一块区域,按间隔轮换显示;支持动画过渡。 - 结构(`SlideComponent.axaml:17-125`):`ListBox`(`margin: 0 -6px`、`overflow: visible`、水平滚动禁用、`ItemsPanel=Grid`(所有项叠在同一格))。 - 未启用动画(`IsAnimationEnabled=false`,默认):非选中项 `display:none`(`:51-53`),只显示当前项。 - 启用动画(`:54-108`): ```css .slide-item { position: relative; } /* 进入 */ .slide-item.is-selected .content { animation: slide-in 250ms cubic-bezier(0.65, 0, 0.35, 1) both; } @keyframes slide-in { 0% { opacity: 0; transform: translateY(-40px); visibility: visible; } 100% { opacity: 1; transform: translateY(0); } } /* 离开 */ .slide-item:not(.is-selected) .content { animation: slide-out 250ms cubic-bezier(0.65, 0, 0.35, 1) both; } @keyframes slide-out { 0% { opacity: 1; transform: translateY(0); visibility: visible; } 100% { opacity: 0; transform: translateY(40px); visibility: hidden; } } ``` 源码里两段动画都是 `Duration=250ms`、`KeySpline="0.65, 0, 0.35, 1"`(`:72-107`),进入位移 `-40 → 0`、离开位移 `0 → +40`,透明度 0↔1 同步;离开结束后 `IsVisible=false`。 - 轮播调度(`SlideComponent.axaml.cs:51-121`): - 间隔 = `Settings.SlideSeconds`(默认 `15`,UI 最小 `0.1`); - 子组件数 ≤ 1 时不轮播,恒显示第 0 个; - `SlideMode`:`0 循环`(+1 取模)、`1 随机`(预生成打乱队列,用尽再洗牌,保证一轮内不重复)、`2 往复`(到边界反向); - 跳过被隐藏规则的子组件(循环查找可见项,全部隐藏时停止); - 规则:`IsPauseOnRuleEnabled` 满足 → 暂停定时器;`IsStopOnRuleEnabled` 满足 → 停止定时器并把索引归 0;两者同时满足时"停止"优先。 - 每项 `MaxHeight = 40px`(`:119`),`ComponentPresenter` `Margin="6 0"`、`VerticalAlignment=Stretch`。 - 设置项与默认值(`Models/ComponentSettings/SlideComponentSettings.cs`): | 字段 | 默认 | UI | |---|---|---| | `SlideSeconds` | `15` | 轮换间隔(秒,≥0.1) | | `SlideMode` | `0` | 轮换模式(循环/随机/往复) | | `IsPauseOnRuleEnabled` | `false` | 满足规则时暂停 | | `PauseRule` | `{}` | 规则集 | | `IsStopOnRuleEnabled` | `false` | 满足规则时停止 | | `StopRule` | `{}` | 规则集 | | `UseOldPresentingBehavior` | `false` | 【预留】保留 schema、不给 UI(旧版呈现行为的兼容开关) | | `IsAnimationEnabled` | `false` | 启用动画 | - **【预留】字段处置**:`UseOldPresentingBehavior`、滚动容器的 `PauseOnRule`/`PauseRule`、课程表的 `ExtraInfo4ShowSecondsSeconds`/`ShowTomorrowSchedules` 均**保留 JSON schema 与默认值、迁移时原样写回,但不提供 UI 开关**。实现不得删除这些字段(会破坏旧配置反序列化),也不得为其臆造设置项。 - 边界:切换时不能让行高变化(所有项都叠在同一格,容器高度取最大者,且被 `MaxHeight` 钳制);只有一个子组件时不产生任何过渡动画。 ### 5.10 分组容器(GroupComponent) - 语义:把多个组件横向组合成一个整体(用于让它们共享一个"宽度固定/对齐"的约束)。 - 结构(`GroupComponent.axaml:13-33`):`ItemsControl` + `StackPanel`(`Orientation=Horizontal`,类 `components-panel`),`margin: 0 -6px`,子项 `ComponentPresenter` `Margin="6 0"`。 - 与"行直接放组件"的唯一区别:分组容器可以整体设置宽度/对齐/不透明度/背景/圆角,内部子组件单独再设各自的覆盖项。 - 设置:只有 `Children`(`GroupComponentSettings.cs`)。 - 边界:空分组不占宽度(自动塌缩为 0,不留 12px 空隙)。 ### 5.11 堆叠容器(StackComponent) - 语义:多个子组件**重叠**在同一位置显示(用于"层叠"效果,例如文字 + 背景图标)。 - 结构(`StackComponent.axaml:12-32`):`ItemsControl` + `Panel`(类 `components-panel`),`margin: 0 -6px`;`Panel` 的布局语义是"所有子元素占据同一矩形,各自按自身对齐方式摆放"。 - CSS 实现: ```css .stack-panel { position: relative; display: block; } .stack-panel > .component-host { position: absolute; inset: 0; /* 所有子项同一矩形 */ display: flex; /* 由子项自身 justify/align 决定落点 */ align-items: center; margin: 0 6px; } ``` - 尺寸:容器宽高取所有子项测量值的最大者(Avalonia `Panel` 的行为);行高仍是 40px。 - 设置:只有 `Children`。 - 边界:子项之间不得互相拦截命中(普通模式全窗穿透,无影响);子项数量多时不要改变容器宽度以外的布局。 - 与轮播容器的区别要写进文档与 UI 提示:**轮播是"不同时刻显示不同子项",堆叠是"同一时刻全部显示"**。 ### 5.12 分割线(SeparatorComponent) - 视觉(`SeparatorComponent.axaml:10-14`): ```css .node-separator { width: 2px; display: flex; align-items: center; justify-content: center; height: 100%; } .node-separator .line { width: 2px; height: 25px; background: var(--text-fill-secondary); opacity: 0.8; } ``` - 源码用 `Grid Width="2"` 包一条 `Line`:`StrokeThickness=2`、`StartPoint 0,0` → `EndPoint 0,25`、`Stroke=TextFillColorSecondaryBrush`、`Opacity=0.8`、垂直/水平居中。 - 设置:无(只有通用设置)。 - 边界:位于行首/行尾时仍然占 2px + 两侧各 6px = 14px,不做特殊收边(与 ClassIsland 一致)。 ### 5.13 组件设置 UI(组件设置页 + 高级设置页) 抽屉标题栏与页签见 4.8.6。两页内容: **页 1「组件设置」**:渲染组件自己的 `SettingsType` 控件(`ComponentPresenter` 以 `IsPresentingSettings=true` 渲染,`EditModeView.axaml:287-290`),即本文每个组件的"设置项"清单。 **页 2「高级设置」**:按下面顺序渲染(`repos/ClassIsland/ClassIsland.Core/Controls/ComponentAdvancedSettingsControl.axaml`): 1. 按规则隐藏(开关 + 说明"注意此处的隐藏不会释放组件资源"); 2. 不透明度(Slider 0–1,步进 0.05); 3. 自定义背景颜色(开关 + ColorPicker); 4. 自定义背景不透明度(开关 + Slider); 5. 自定义背景圆角半径(开关 + Slider 0–20,步进 0.5,文案"调至最大数值时左右边缘为半圆"); 6. 字体大小(总开关 `IsResourceOverridingEnabled` + 四个 NumberBox:次级/正文/强调/大号,范围 1–40,`ci:Field` 宽 175、后缀 `px`); 7. 字体颜色(开关 + ColorPicker); 8. 对齐方式(4 个图标按钮:左 `\uE03E`、居中 `\uE03A`、拉伸 `\uE034`、右 `\uE03C`); 9. 固定宽度(开关 + NumberBox); 10. 宽度限制(两个开关 + 两个 NumberBox:最大/最小); 11. 自定义边距(开关 + 4 个 NumberBox:左/上/右/下)。 `ci:Field` 后缀单位固定为 `px`;字号输入框的 `SpinButtonPlacementMode=Inline`、`Minimum=1`、`Maximum=40`(`EditModeView.axaml:418-427` 的等价写法)。 ### 5.14 空数据与异常兜底(逐组件) | 场景 | 必须表现 | |---|---| | 课表为空 / 无今日课表 | 课程表显示 `PlaceholderTextNoClass`(默认"今天没有课程。");不得显示空白胶囊 | | 今日课程全部结束 | 显示 `PlaceholderTextAllClassEnded`(默认"今日课程已全部结束。"),或按设置隐藏(`HideFinishedClass`) | | 天气数据缺失 | 天气组件隐藏温度/图标;只显示可用的预警/降水;不显示假值 | | 天气图标加载失败 | 该预警显示"图裂"占位(红色背景 + 警告图标),不得整块消失 | | 倒计时区间非法(end`(与 ClassHelper 一致,便于主题覆盖与自动化断言)。 必须暴露的 CSS 变量(在 `:root` 与组件节点上均可覆盖)。**规范 token 名以 part 3 的 `--ci-*` 为准**,下表左侧为本文别名,落地时按右侧映射: | 本文别名 | 规范 token | 值 | |---|---|---| | `--font-secondary` | `--ci-fs-secondary` | 14px | | `--font-body` | `--ci-fs-body` | 16px | | `--font-emphasized` | `--ci-fs-emphasized` | 18px | | `--font-large` | `--ci-fs-large` | 20px | | `--text-fill-primary` | `--ci-text-1` | 0.92 白 | | `--text-fill-secondary` | `--ci-text-2` | 0.72 白 | | `--accent` | `--ci-accent` / 填充 `--ci-accent-fill` | 默认 `#0a84ff`(用户强调色 DodgerBlue) | | `--control-fill-default` | `--ci-fill-secondary` | 0.10 白 | | `--control-strong-stroke-disabled` | `--ci-stroke-control` | 0.30 白 | | `--rainy-color` | `--ci-weather-rain` | 降雨色 | | `--aqi-1` … `--aqi-6` | `--ci-aqi-1` … `--ci-aqi-6` | AQI 六级色 | | `--node-gap` | `--ci-component-gap` | 12px | | 进度条高度 | `--ci-progress-height` | **3px**(倒计时进度条、课程进度条共用) | | 高亮条 | `--ci-accent-fill` + opacity 0.4 | — | ```css :root { --font-secondary: 14px; --font-body: 16px; --font-emphasized: 18px; --font-large: 20px; --text-fill-primary: rgba(255,255,255,0.92); --text-fill-secondary: rgba(255,255,255,0.72); --accent: #0a84ff; /* 用户强调色,默认 DodgerBlue/Apple 蓝 */ --control-fill-default: rgba(255,255,255,0.10); --control-strong-stroke-disabled: rgba(255,255,255,0.30); --rainy-color: #4a9eff; --aqi-1: #6fdd8b; --aqi-2: #ffd666; --aqi-3: #ff9f0a; --aqi-4: #ff6961; --aqi-5: #c77dff; --aqi-6: #b23a48; --node-gap: 12px; /* = 两个 6px 外边距之和 */ } ``` 组件节点通用样式: ```css .component-node { display: flex; align-items: center; /* 所有组件在 40px 行内垂直居中 */ min-width: 0; flex-shrink: 0; white-space: nowrap; } .component-node.text, .component-node.schedule { white-space: normal; } ``` ### 5.16 验收标准 1. 首次启动生成 1 行 2 组件(日期 + 课程表),日期显示形如 `周三 11/12`,课程表显示当天课程或占位符。 2. 11 个内置组件全部能从组件库拖入主界面,拖入后立刻可编辑、可删除,删除后无残留占位。 3. 时钟:显示 `HH:mm`;开启闪动后冒号 1 秒亮 1 秒灭;开启秒数后变 `HH:mm:ss` 且冒号常亮;数字等宽,切换时不水平抖动。 4. 倒计时:4 种时间源(固定时间/周期/今天/本周)各配一遍,数值、进度环、进度条、连接词显示正确;`%D/%H/%M/%S/%X/%P/%p/%L/%d/%h/%m/%s/%x` 全部 token 输出与源码规则一致(尤其 `%m/%s/%x` 的补零与向上取整语义)。 5. 倒计时进度环从 12 点方向顺时针填充,满 100% 时不出现缺口;紧凑模式只显示"名称 + 数值"。 6. 课程表:当前课程高亮、已结束课程淡化到 0.6、剩余时间药丸在阈值内出现;`HideFinishedClass` / `ShowCurrentLessonOnlyOnClass` / `FadeCompletedClasses` / `HighlightChangedClass` 各自开关验证;4 种"显示明天课表"模式行为正确。 7. 课程表空状态:无课表显示"今天没有课程。";全部结束显示"今日课程已全部结束。";`ShowPlaceholderOnEmptyClassPlan=false` 时两者都不显示。 8. 天气:6 种主信息逐一切换显示正确;有预警时按"总是图标/默认/总是全文"三种模式渲染;降水药丸在 `RainRemainingMinutes>0` 时出现、`=0` 时消失;模糊模式显示"约 X 分钟"、精确模式显示"Xmin"。 9. 文本:字号 16 与 30 时高度都不超过 40px;自定义颜色生效/关闭后回退;空文本不占宽。 10. 滚动容器:内容超出时按 `SpeedPixelPerSecond` 匀速滚动,`PauseSeconds` 停顿位置等于 `PauseOffsetX`;内容未超出时静止;命中"停止规则"后回到初始位置。 11. 轮播容器:`SlideSeconds` 到点切换;循环/随机/往复三种模式各验证一轮;开启动画时切换有 250ms 上下位移淡入淡出,关闭时无过渡;只有 1 个子项时不轮播。 12. 分组容器的子项横向排列,间距恒定 12px;堆叠容器的子项完全重叠在同一矩形且各自按对齐方式摆放;分割线为 2×25px、透明度 0.8。 13. 所有组件的"高级设置"项生效:固定宽度、最大/最小宽度、对齐、不透明度、自定义边距、字号覆盖、字体颜色覆盖;隐藏规则勾选后组件消失但在编辑模式显示斜条纹占位。 14. 组件 ID 不存在时显示"缺失组件"占位而不是白屏或崩溃。 15. 全部组件在 40px 行高内垂直居中,无任何组件把行撑高超过 40px。