## 9. 主题系统(内置主题、主题包格式、壁纸取色、主题预览、自定义颜色) 实现者必须把「主题」拆成**两层独立系统**,不得混为一谈: 1. **应用主题(App Theme)**:控制整个应用(设置中心、窗口、组件库)的明暗与强调色。对应 ClassIsland 的 `IThemeService` / `ThemeService`,只做三件事:切换 ThemeVariant(浅/深/跟随系统)、设置强调色、扩散强调色派生画刷。 2. **主界面主题(Island Theme / XamlTheme)**:只控制「岛」的形状、背景、描边、投影、遮罩动画与排版系数。对应 ClassIsland 的 `IXamlThemeService` + `XamlThemes/*`,它是**可安装、可叠加、可市场分发**的主题包。 两层必须有各自的状态、各自的持久化字段、各自的设置页面分区。**不得**把「换岛皮肤」实现成「换应用 accent」。 ### 9.1 应用主题(明暗 + 强调色) 必须实现以下状态字段(与源码一致,便于数据迁移): | 字段 | 类型 | 默认 | 含义 | 证据 | |---|---|---|---|---| | `Theme` | int | `2` | `0=跟随系统`、`1=明亮`、`2=黑暗` | `ClassIsland/Models/Settings.cs:32,979-989` | | `PrimaryColor` | color | `#00BFFF` | 自定义强调色 | `Settings.cs:35,991-1001` | | `SecondaryColor` | color | `#7FFFD4` | 次级强调色 | `Settings.cs:36,1003-1012` | | `ColorSource` | int | `2` | `0=自定义`、`1=壁纸主题色`、`2=跟随系统`、`3=屏幕主题色` | `Settings.cs:83,1014-1023` | | `TargetLightValue` | double | `0.6` | 取色目标明度 | `Settings.cs:85,1107-1116` | | `WallpaperColorPlatte` | color[] | `[]` | 壁纸调色板(最多 5 色) | `Settings.cs:1025-1034` | | `SelectedPlatteIndex` | int | `0` | 当前调色板选择 | `Settings.cs:1041-1050` | | `IsWallpaperAutoUpdateEnabled` | bool | `false` | 壁纸变化时自动取色 | `Settings.cs:92,1052-1061` | | `WallpaperAutoUpdateIntervalSeconds` | int | `60` | 自动取色轮询间隔(秒) | `Settings.cs:93,1063-1072` | | `UseExperimentColorPickingMethod` | bool | `false` | 启用实验性取色算法 | `Settings.cs:1096-1105` | 切换逻辑必须严格实现(`ClassIsland/MainWindow.axaml.cs:743-764` 与 `ClassIsland/Services/ThemeService.cs:43-75`): 1. `themeMode=0` → 主题变体 `Default` 且 `preferSystemTheme=true`(系统变浅即浅、变深即深); 2. `themeMode=1/2` → 显式 `Light`/`Dark`,并 `preferSystemTheme=false`; 3. 强调色:`ColorSource=0` 用 `PrimaryColor`;`1`/`3` 用 `SelectedPlatte`;`2` 传 `null` 表示跟随系统强调色; 4. 强调色变化后**必须重算** `CustomizedAccentBarBackground1Brush` = 强调色 + alpha `0.10`(`ThemeService.cs:66-74`、`Settings.cs:1037-1039`); 5. 应用主题变化必须触发 `ThemeUpdated` 事件,通知所有依赖主题的组件重绘;事件名与语义照抄 `ClassIsland.Core/Abstractions/Services/IThemeService.cs:16`。 CSS 落地要求: ```css :root { --ci-accent: #00bfff; --ci-accent-soft: color-mix(in srgb, var(--ci-accent) 10%, transparent); } html.dark { /* 深色下的主题覆盖只允许改「派生值」,强调色本身仍由同一变量驱动 */ } ``` `--ci-accent` 必须由运行时 JS 写入 `document.documentElement.style`(与 ClassHelper 注入动效 token 的方式一致,见 `packages/desktop-client/src/renderer/composables/motion.ts:35-45`),**不得**通过重新生成样式表字符串实现。 ### 9.2 内置主界面主题 必须内置且仅内置两套主界面主题,id 与名称不得更改(主题包 id 是持久化键): | id | 名称 | 描述 | Banner(预览图) | 竖直安全区 | 证据 | |---|---|---|---|---|---| | `classisland.classic` | 经典 | ClassIsland 的经典外观。 | `avares://ClassIsland/Assets/XamlThemePreviews/classisland.classic.png` | `0` | `ClassIsland/App.Services.xaml.cs:297-305` | | `classisland.fluent` | Fluent | 焕然一新的 ClassIsland 外观。 | `avares://ClassIsland/Assets/XamlThemePreviews/classisland.fluent.png` | `20` | `App.Services.xaml.cs:306-315` | - 两套主题的作者均为 `ClassIsland`,URL 均为 `https://github.com/ClassIsland/ClassIsland`。 - 两张预览图均为 **1920×350 PNG**(实测文件头,`Assets/XamlThemePreviews/`),实现时必须按 1920×350 的比例裁切展示,不得拉伸变形。 - 默认启用列表必须只包含 `classisland.fluent`(`Services/XamlThemeService.cs:81-84`)。 - 首次启动写入 `EnabledThemes.json`(`XamlThemeService.cs:48`),内容为字符串数组,顺序即加载优先级。 #### 9.2.1 Fluent 与 Classic 的差异(逐项实现,不得混用) | 差异项 | Fluent 主题 | Classic 主题 | 证据 | |---|---|---|---| | 行背景画刷 | `SolidBackgroundFillColorSecondaryBrush` | `SolidBackgroundFillColorBaseBrush` | `XamlThemes/FluentTheme/Styles.axaml:45`;`ClassicTheme/Styles.axaml:214` | | 行背景描边 | `ControlElevationBorderBrush`,1px | 无显式描边(依赖背景色与投影) | `FluentTheme:46-47` | | 行投影 | `BoxShadow 0 4 8 2 #48000000` | 常态无投影(`DropShadowEffect` Opacity=0),提醒时升为 `BlurRadius=8, Opacity=1` | `FluentTheme:49`;`ClassicTheme:36-45,283-287` | | 圆角来源 | `MainWindowStylesAssist.CornerRadius / 1.0` | 无圆角(矩形裁剪) | `FluentTheme:48`;`ClassicTheme:46-59` | | 背景宽度动画 | `300ms`,`cubic-bezier(0.65, 0, 0.35, 1.0)` | `550ms`,`cubic-bezier(0.00, 1.00, 0.50, 1.10)`(**带过冲**) | `FluentTheme:269`;`ClassicTheme:207` | | 提醒遮罩实现 | 自绘斜切分块(`SlantedMaskControl`)+ 文字缩放 + 投影淡入 | 整块下压/上滑(`TranslateTransform Y=-50 → 0` / `0 → 50`) | `FluentTheme:175-197`;`ClassicTheme:78-91,142-154` | | 提醒遮罩参数 | `RegionDurationMs=360`、`StageStaggerMs=120`;文字 250ms 延迟 260ms;缩放 1.1→1.0 用时 750ms 延迟 260ms;投影 100ms 延迟 600ms | 遮罩下压 200ms(QuadraticEaseIn);文字 100ms 延迟 200ms;退出 200ms 延迟 100ms + 文字 100ms | 见 part 14 表 #10–#14 | | 分体主界面(行独立成岛) | 支持:`IsIslandSeperated=True` 时每行独立背景、独立 `border-radius`,组件左右外扩 `Margin="-2 0"`、最小宽 `40`、内容 `Margin="16 0"` | 不支持分体(无对应样式) | `FluentTheme:245-259`;`Settings.cs:1287-1296` | | 内容裁剪 | 用 `clip-host` + `WidthDoubleToRectConverter` 生成按圆角的 Rect 裁剪 | 用 `Grid.Clip` 矩形裁剪,无圆角 | `FluentTheme:21-38`;`ClassicTheme:46-59` | | `IslandContainerHeight` | `40` | `40` | `FluentTheme:65`;`ClassicTheme:16` | | 未启用提醒时的行模板 | 最小模板(无遮罩层、无进度条) | 最小模板(同样精简) | `FluentTheme:203-232,275-278`;`ClassicTheme:158-196,219-222` | **验收硬要求**:在两种主题下分别触发同一条提醒,动画形态必须明显不同(Fluent 为「斜切分块扫入 + 文字缩放」,Classic 为「整块下压 + 上滑退出」),且时长与上表逐毫秒一致。 ### 9.3 主题包格式(必须实现安装/卸载/打包/市场) #### 9.3.1 目录与文件 | 路径 | 内容 | 证据 | |---|---|---| | `<配置目录>/Themes/<主题id>/` | 已安装主题目录(每主题一个文件夹) | `Services/XamlThemeService.cs:47,440-447` | | `<配置目录>/Themes//manifest.yml` | 主题清单(必需,缺失时回退为「目录名即 id/name」) | `XamlThemeService.cs:249-279` | | `<配置目录>/Themes//Styles.axaml` | 主题样式入口(必需) | `XamlThemeService.cs:117-120` | | `<配置目录>/Themes//banner.png`(或 manifest 指定) | 预览图 | `XamlThemeService.cs:271` | | `<配置目录>/Themes//.uninstall` | 卸载标记(存在即下次启动删除整个目录) | `XamlThemeService.cs:413-423`;`ThemeInfo.cs:IsUninstalling` | | `<配置目录>/EnabledThemes.json` | 启用主题 id 数组,顺序=加载顺序 | `XamlThemeService.cs:48,63,80-86` | | `<缓存目录>/ThemePackages/.zip` | 下载完成的主题包缓存,启动时自动安装 | `XamlThemeService.cs:49,390-411,426-450` | | `<插件市场索引目录>/<源名>/themes.json` | 市场主题索引 | `XamlThemeService.cs:171-220` | #### 9.3.2 `manifest.yml` 字段(逐字段实现,camelCase) | 字段 | 类型 | 默认 | 必填 | 说明 | 证据 | |---|---|---|---|---|---| | `id` | string | `""` | 是 | 主题唯一 id(建议 `作者.主题`) | `ClassIsland.Core/Models/XamlTheme/ThemeManifest.cs` | | `name` | string | `""` | 是 | 显示名 | 同上 | | `description` | string | `""` | 否 | 一句话描述 | 同上 | | `version` | string | `"0.0.0.0"` | 否 | 语义化版本,用于市场更新判断 | 同上;`XamlThemeService.cs:201-206` | | `author` | string | `""` | 否 | 作者 | 同上 | | `url` | string | `""` | 否 | 项目地址 | 同上 | | `banner` | string | `"banner.png"` | 否 | 预览图相对路径 | 同上 | | `verticalSafeAreaPx` | double | `0` | 否 | 竖直安全区像素 | 同上 | 市场索引 `themes.json` 的结构必须为: ```json { "themes": [ { "manifest": { ...上表字段... }, "downloadUrl": "https://.../{root}/xxx.zip", "downloadMd5": "……" } ] } ``` 其中 `downloadUrl` 必须支持 `{root}` 占位符,由所选镜像替换(`XamlThemeService.cs:194,213`);下载后必须校验 MD5 再落盘(`XamlThemeService.cs:345-351`)。 #### 9.3.3 加载与覆盖规则 1. 启动时先注册内置主题(`IXamlThemeService.IntegratedThemes`,`ClassIsland.Core/Abstractions/Services/IXamlThemeService.cs:21`),再扫描 `Themes/` 目录加载外部主题; 2. 只加载 `EnabledThemes` 中列出的主题,且**按数组顺序依次叠加**:后加载主题覆盖前面主题的同名样式(`IXamlThemeService.cs:63-66`); 3. 任一主题样式加载失败必须**隔离**:标记 `IsError`、记录异常、继续加载后续主题,不得导致应用崩溃(`XamlThemeService.cs:112-133`); 4. 主题的 `VerticalSafeAreaPx` 取所有已启用主题的**最大值**(不是求和)(`XamlThemeService.cs:111-135`); 5. 主题安装/卸载/换序后必须提示「重启以应用更改」(`XamlThemeService.cs:232-236,369-374`); 6. 安全模式(`--safe`)下不得加载任何主题(`XamlThemeService.cs:87-90,101-104`)。 #### 9.3.4 打包 `PackageThemeAsync(id, outputPath)` 必须把主题目录整体 zip 成包,且不得在包内包含 `.uninstall`(`XamlThemeService.cs:452-466`)。实现时对应一个「导出主题包」命令。 ### 9.4 主题包在 ClassHelper 栈中的落地格式(CSS 版) 由于实现栈是 Web 技术,主题包不再执行 XAML,必须改为「**样式清单 + 受控 CSS**」。实现者必须定义如下主题包结构(这是本节的核心交付物): ``` <主题id>/ manifest.yml(与 9.3.2 完全一致,保持兼容) theme.json ← 新增,主题变量清单 theme.css ← 新增,受控 CSS(只允许声明 CSS 变量与白名单选择器) banner.png(1920×350) ``` `theme.json` 必须包含: | 字段 | 类型 | 必填 | 说明 | |---|---|---|---| | `schemaVersion` | int | 是 | 固定 `1` | | `id` / `name` / `version` / `author` / `description` | string | 是 | 与 manifest 一致(双写校验) | | `verticalSafeAreaPx` | number | 否 | 与 manifest 一致 | | `tokens` | object | 是 | `{ "island.bg": "#202020cc", "island.radius": "8px", ... }`,键名必须来自 9.4.1 白名单 | | `styles` | string[] | 否 | 允许的主题 CSS 选择器 id(见 9.4.2),空表示只用 token | | `fonts` | object | 否 | `{ "islandFamily": "...", "weight": 500 }` | | `assets` | string[] | 否 | 主题自带资源相对路径 | #### 9.4.1 Token 白名单(主题只能改这些,改别的无效) 必须允许覆盖:`--ci-accent`(仅当主题声明 `allowAccentOverride: true`,默认 false,防止主题抢走用户强调色)、`--ci-island-bg`、`--ci-island-bg-opacity`、`--ci-island-border-color/width`、`--ci-island-radius`、`--ci-island-shadow`、`--ci-island-font`、`--ci-island-fs-*`、`--ci-island-line-gap`、`--ci-island-pad-x`、`--ci-island-mask-*`、`--ci-island-progress-*`、`--ci-island-fade-opacity`。 必须禁止覆盖:`--ci-text-1..5`(由可访问性/自定义前景色决定)、`--ci-z-*`、`--ci-island-height`(固定 40,改它会破坏窗口几何)、`--ci-scale`、以及任何 `--ch-*` 应用外壳 token。 #### 9.4.2 受控选择器(styles 白名单) 允许:`.:island-root`、`.island-card`、`.island-background`、`.island-content`、`.island-overlay`、`.island-mask`、`.island-progress`、`.island-component`、`.island-separator`。禁止:`*`、`html`、`body`、`#app`、`[class*=...]`、任何 `@import`、任何 `url(http…)`、任何 `position: fixed`、任何 `z-index`。加载时必须用 CSSOM 解析并逐条校验,不合格条目丢弃并记录警告,不得整包拒绝(隔离原则同 9.3.3 第 3 条)。 ### 9.5 XAML 主题 → CSS 主题的映射规则 实现者必须按下表做等价映射;遇到不在表内的 XAML 特性,先实现等价 CSS 再补文档,**不得**用「运行一段脚本改 DOM」绕过。 | ClassIsland(Avalonia) | 实现(Web) | 说明 | |---|---|---| | `ResourceDictionary` + `x:Key` | CSS 变量 + `theme.json.tokens` | 资源键名转 kebab-case 并加 `ci-` 前缀 | | `DynamicResource Key` | `var(--ci-…)` | 运行时可变;`StaticResource` 则写成固定值 | | `Style Selector="controls|MainWindowLine"` | `.island-line` | 控件类名 → 固定类名 | | `Style Selector="^:mask-in"`(伪类) | `.island-line.is-mask-in` | 伪类 → 状态类 | | `Style.Animations` + `KeyFrame` | `@keyframes` + `animation` | 时长/曲线/延迟逐值迁移(见 part 14) | | `Transitions` + `DoubleTransition` | `transition` | 属性名映射见下 | | `DoubleTransition Property="BackgroundWidth"` | `transition: width` / `inline-size` | 源码动画的是背景层宽度 | | `DoubleTransition Property="Opacity"` | `transition: opacity` | — | | `BoxShadowsTransition Property="BoxShadow"` | `transition: box-shadow` | — | | `BrushTransition Property="Background"` | `transition: background-color` | — | | `Easing="0.65, 0, 0.35, 1.0"` | `cubic-bezier(.65,0,.35,1)` | 逐值照抄 | | `Easing="SineEaseIn"` | `cubic-bezier(.12,0,.39,0)`【推断:按 Avalonia SineEaseIn 定义换算】 | 或 `ease-in` 近似 | | `QuadraticEaseIn/Out` | `cubic-bezier(.11,0,.5,0)` / `cubic-bezier(.5,1,.89,1)`【推断】 | 按 Avalonia 定义换算 | | `ExponentialEase EaseOut` | `cubic-bezier(.16,1,.3,1)` 近似 | 用于闪屏/OOBE | | `FillMode="Both"` | `animation-fill-mode: both` | 必须保留,否则延迟期会闪终态 | | `IterationCount="Infinite"` | `animation-iteration-count: infinite` | — | | `PlaybackDirection="Alternate"` | `animation-direction: alternate` | — | | `Clip` + `WidthDoubleToRectConverter` | `border-radius` + `overflow:hidden` 的内层 | 圆角裁剪 | | `OpacityMask` 渐变 | `mask-image: linear-gradient(...)` | 主题卡底部渐隐 | | `DropShadowEffect` | `filter: drop-shadow(...)`(仅限非卡片元素) | 卡片禁止用 filter(会闪帧) | | `AcrylicBlur` | `backdrop-filter: blur(30px) saturate(1.2)`【推断】 | 只用于编辑模式整窗底 | | 加载顺序覆盖 | 同优先级下「后插入的 `