Parcourir la source

fix(theme): 开关配色随主题反转 + 主题状态统一到 ThemeService

一、开关(用户明确规则)
浅色模式:深色轨道 + 亮色圆点
深色模式:亮色轨道 + 深色圆点
开启态统一品牌蓝 + 白点

之前几版失效的真原因:class 由模板 [class] 动态拼出,
Angular 样式封装配不上,写在组件 SCSS 里的规则整条被丢弃
(构建产物中搜不到该类,已实测)。改为内联 style 绑定。

二、主题状态统一
桌面/移动的 MinePage 各自维护 themeMode signal + 手写 classList/localStorage,
与左栏用户菜单的开关是两套状态,切换后互不同步。
现在三处入口统一读写 ThemeService。

验证:tsc 0 错误;单测 1089/1089;构建通过;verify:ui 12/12

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct il y a 22 heures
Parent
commit
da6d6364f2

+ 27 - 0
docs/00-tracking/操作追踪.md

@@ -3826,3 +3826,30 @@ CSS 模板字符串的注释里用反引号包标识符(如 `` `opacity: 0` ``
 
 ### 验证
 - tsc 0 错误;单测 **1089/1089**;构建通过;verify:ui 12/12
+
+## 2026-11-01 第二十一轮:开关随主题反转 + 主题状态统一
+
+### 一、开关配色改为「随主题反转」
+用户明确规则:
+| 主题 | 轨道 | 圆点 |
+|---|---|---|
+| 浅色 | **深色** #3a4049 | **亮色** #fff |
+| 深色 | **亮色** #9aa3ad | **深色** #1e2128 |
+开启态统一为品牌蓝 + 白点。
+
+**之前几版都没生效的真原因**:
+`class` 由模板 `[class]` 动态拼出,Angular 的**样式封装**匹配不到它 ——
+写在组件 SCSS 里的 `.ui-switch-track-off` 编译后整条规则被丢弃,
+界面上完全没有效果(构建产物里搜不到该类,已实测确认)。
+改为**内联 style 绑定**(trackStyle / knobStyle),不受封装影响、优先级足够。
+
+### 二、主题状态统一到 ThemeService
+`MinePage`(桌面)与 `MinePage`(移动)各自维护一份 `themeMode` signal +
+手写 classList/localStorage,与左栏用户菜单的开关(走 ThemeService)**是两套状态**:
+在一处切换,另一处不跟着变。
+
+现在三处入口全部读写 `ThemeService`,天然同步。
+
+### 验证
+- tsc 0 错误;单测 1089/1089;构建通过;verify:ui 12/12
+- 已用 Chrome 渲染核对四种组合(浅色/深色 × 开/关)

+ 18 - 16
src/app/desktop/pages/mine/mine.ts

@@ -1,6 +1,7 @@
 import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
 import { Router } from '@angular/router';
 import { UiAvatar, UiButton, UiCard, UiModal, UiSegmented, UiSlider } from '../../../ui';
+import { ThemeService } from '../../../ui/theme';
 import { PageComponent } from '../../../components/shell/page/page';
 import { PALETTES, PaletteService } from '../../../core/palette.service';
 import {
@@ -668,25 +669,26 @@ export class DkMinePage {
     return facts;
   });
 
-  readonly themeMode = signal<'light' | 'dark'>(
-    typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
-      ? 'dark'
-      : 'light',
+  /**
+   * 显示模式(2026-11-01 改为直接读 ThemeService)。
+   *
+   * 🔴 原实现自己维护一份 `themeMode` signal + 手写 classList/localStorage ——
+   *    与左栏用户菜单里的开关(走 ThemeService)**是两套独立状态**。
+   *    在设置页切了主题,左栏开关不会跟着变;反之亦然。
+   *    同一个偏好存在两份真相,必然越用越乱。
+   *
+   *    现在统一以 ThemeService 为唯一来源:读写都经过它,
+   *    两处入口天然同步,也自动带上服务里的过渡动画与 meta 更新。
+   */
+  private readonly theme = inject(ThemeService);
+
+  readonly themeMode = computed<'light' | 'dark'>(() =>
+    this.theme.isDark() ? 'dark' : 'light',
   );
 
   setTheme(v: unknown): void {
-    const mode = v as 'light' | 'dark';
-    this.themeMode.set(mode);
-    if (typeof document === 'undefined') return;
-    document.documentElement.classList.toggle('dark', mode === 'dark');
-    try {
-      localStorage.setItem('ui-mode', mode);
-    } catch {
-      /* 忽略 */
-    }
-    const meta = document.querySelector('meta[name="theme-color"]');
-    const canvas = getComputedStyle(document.documentElement).getPropertyValue('--canvas').trim();
-    if (meta && canvas) meta.setAttribute('content', canvas);
+    const mode = v === 'dark' ? 'dark' : 'light';
+    this.theme.setMode(mode);
   }
 
   /**

+ 12 - 15
src/app/mobile/pages/mine/mine.ts

@@ -1,5 +1,6 @@
 import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
 import { UiMButton, UiMSegmented } from '../../../ui';
+import { ThemeService } from '../../../ui/theme';
 import { Router } from '@angular/router';
 import { PaletteService, PALETTES } from '../../../core/palette.service';
 import { SessionService } from '../../../core/session.service';
@@ -506,24 +507,20 @@ export class MbMinePage {
     return me?.companyName ?? (me?.companyId ? '(名称未取到)' : '未绑定组织');
   });
 
-  readonly themeMode = signal<'light' | 'dark'>(
-    typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
-      ? 'dark'
-      : 'light',
+  /**
+   * 显示模式 —— 统一走 ThemeService(2026-11-01)。
+   *
+   * 🔴 原实现自己维护 themeMode signal + 手写 classList/localStorage,
+   *    与左栏/桌面设置页不是同一个状态源,切换后互不同步。
+   */
+  private readonly theme = inject(ThemeService);
+
+  readonly themeMode = computed<'light' | 'dark'>(() =>
+    this.theme.isDark() ? 'dark' : 'light',
   );
 
   setTheme(mode: 'light' | 'dark'): void {
-    this.themeMode.set(mode);
-    if (typeof document === 'undefined') return;
-    document.documentElement.classList.toggle('dark', mode === 'dark');
-    try {
-      localStorage.setItem('ui-mode', mode);
-    } catch {
-      /* 忽略 */
-    }
-    const meta = document.querySelector('meta[name="theme-color"]');
-    const canvas = getComputedStyle(document.documentElement).getPropertyValue('--canvas').trim();
-    if (meta && canvas) meta.setAttribute('content', canvas);
+    this.theme.setMode(mode);
   }
 
   switchMode(): void {

+ 2 - 1
src/app/ui/components/switch/switch.html

@@ -2,13 +2,14 @@
   type="button"
   role="switch"
   [class]="trackClasses()"
+  [style]="trackStyle()"
   [attr.aria-checked]="checked()"
   [attr.aria-label]="ariaLabel()"
   [attr.aria-busy]="loading() || null"
   [disabled]="disabled() || loading()"
   (click)="toggle()"
 >
-  <span [class]="knobClasses()">
+  <span [class]="knobClasses()" [style]="knobStyle()">
     @if (loading()) {
       <i class="fa-solid fa-spinner animate-spin text-[10px] text-primary" aria-hidden="true"></i>
     }

+ 0 - 24
src/app/ui/components/switch/switch.scss

@@ -1,27 +1,3 @@
 :host {
   display: inline-flex;
 }
-
-/*
- * 开关关闭态轨道(2026-11-01 用户要求)。
- *
- * 诉求:白色背景下「滑轨」必须一眼可见,不能和背景糊在一起。
- *
- * 🔴 为什么不用主题 token:
- *    深色主题下 --faint(#6b727d) / --edge-strong(#454b56) / --surface-active(#333842)
- *    都偏浅或与卡片过于接近,白色圆点压上去会退化成「一团浅色」,
- *    分不清开还是关(用户多次反馈的正是这个观感)。
- *    关闭态在**两套主题下都应该表达同一个意思:未激活的深色槽**,
- *    所以用固定深色而非随主题浮动。
- *
- * 开启态由模板的 bg-primary 提供(蓝色强调色,两套主题下都明确)。
- */
-.ui-switch-track-off {
-  background-color: #3f4652;
-
-  /* 深色主题下卡片本身就很深,轨道再加一点边界,避免与卡片融在一起 */
-  :host-context(.dark) & {
-    background-color: #4a515e;
-    box-shadow: inset 0 0 0 1px rgb(255 255 255 / 0.08);
-  }
-}

+ 61 - 17
src/app/ui/components/switch/switch.ts

@@ -1,4 +1,5 @@
-import { booleanAttribute, Component, computed, input, model } from '@angular/core';
+import { booleanAttribute, Component, computed, inject, input, model } from '@angular/core';
+import { ThemeService } from '../../theme/theme.service';
 
 export type SwitchSize = 'sm' | 'md';
 
@@ -12,6 +13,16 @@ export type SwitchSize = 'sm' | 'md';
   styleUrl: './switch.scss',
 })
 export class UiSwitch {
+  /**
+   * 主题状态(2026-11-01)。
+   *
+   * 开/关两态的颜色需要**随主题反转**才能始终清晰:
+   *   · 浅色模式 → 深色轨道 + 亮色圆点
+   *   · 深色模式 → 亮色轨道 + 深色圆点
+   * 否则白底白轨 / 深底深轨,用户分不清开关状态(多次反馈)。
+   */
+  private readonly theme = inject(ThemeService, { optional: true });
+
   /** 开关状态(双向绑定) */
   readonly checked = model(false);
   /** 尺寸 */
@@ -31,37 +42,70 @@ export class UiSwitch {
     return [
       'relative inline-flex shrink-0 items-center rounded-full transition-colors duration-200',
       'disabled:pointer-events-none disabled:opacity-50',
-      /*
-       * 关闭态轨道用**深色**(2026-11-01 用户要求:白色背景下滑轨要明显)。
-       *
-       * 取 --surface-active 与 --edge-strong 之间偏深的一档?
-       * 不 —— 直接在 switch.scss 里用固定深色,理由:
-       *   两个主题下的目标都是「深色轨道 + 白色圆点」这组经典配色,
-       *   而 --faint / --edge-strong / --surface-* 在深色主题下都偏浅,
-       *   会退化成「白底白轨」(用户截图正是这个观感)。
-       */
-      this.checked() ? 'bg-primary' : 'ui-switch-track-off',
+      // 颜色一律走 trackStyle(内联),这里只留尺寸与通用态
       sizes[this.size()],
     ].join(' ');
   });
 
+  /**
+   * 轨道颜色 —— **随主题反转**(2026-11-01 用户要求)。
+   *
+   * | 主题 | 关闭 | 开启 |
+   * |---|---|---|
+   * | 浅色 | 深色轨道(#3a4049) | 品牌蓝 |
+   * | 深色 | 浅色轨道(#9aa3ad) | 品牌蓝 |
+   *
+   * 🔴 用**内联样式**而不是组件 SCSS 类:
+   *    模板 [class] 动态拼出的类名,Angular 样式封装配不上 ——
+   *    第一版写成 .ui-switch-track-off 后整条规则被丢弃,
+   *    界面上完全没有效果(用户截图:深色模式里开关仍是纯白)。
+   *    内联样式不受封装影响,优先级也足够。
+   */
+  private readonly isDark = computed(() => this.theme?.isDark() ?? false);
+
+  protected readonly trackStyle = computed<Record<string, string>>(() => {
+    const dark = this.isDark();
+    if (this.checked()) {
+      return { 'background-color': 'var(--primary)' };
+    }
+    return {
+      'background-color': dark ? '#9aa3ad' : '#3a4049',
+    };
+  });
+
   protected readonly knobClasses = computed(() => {
     const sm = this.size() === 'sm';
     const knob = sm ? 'h-4 w-4' : 'h-5 w-5';
     const x = this.checked() ? (sm ? 'translate-x-4' : 'translate-x-5') : 'translate-x-0.5';
     return [
-      'pointer-events-none inline-flex items-center justify-center rounded-full bg-white shadow-sm',
-      /*
-       * 圆点加一圈极淡描边:深色主题下白色圆点与深轨道之间缺少过渡时会发虚,
-       * 描边让它的边界更实(1px,颜色取页面边框色)。
-       */
-      'ring-1 ring-black/5',
+      'pointer-events-none inline-flex items-center justify-center rounded-full',
       'transition-transform duration-200',
       knob,
       x,
     ].join(' ');
   });
 
+  /**
+   * 圆点颜色 —— 与轨道**反向**(2026-11-01 用户要求)。
+   *
+   * | 主题 | 关闭(轨道深/浅) | 圆点 |
+   * |---|---|---|
+   * | 浅色 | 深色轨道 | 亮色圆点(#fff) |
+   * | 深色 | 浅色轨道 | 深色圆点(#1e2128) |
+   *
+   * 无论主题如何,圆点与轨道始终「一亮一暗」,保证状态一眼可辨。
+   * 开启态轨道是品牌蓝,圆点统一用白色/近白,与蓝色对比足够。
+   */
+  protected readonly knobStyle = computed<Record<string, string>>(() => {
+    const dark = this.isDark();
+    if (this.checked()) {
+      return { 'background-color': '#ffffff' };
+    }
+    return {
+      'background-color': dark ? '#1e2128' : '#ffffff',
+    };
+  });
+
   protected toggle(): void {
     if (this.disabled() || this.loading()) return;
     this.checked.set(!this.checked());