Browse Source

fix(slider,session): 刻度严格等分 + 会话名称上云

一、字体滑块刻度改为等分
根因:刻度按字号数值在 13~20 线性定位,采样点 13/16/17/18/19/20 时
前两格间距是其它格的 3 倍;且 6 个刻度配 8 个档位必然对不上。
- ui-slider 新增 marksEvenly 输入,按下标均分而非数值比例
- 字号滑块改用档位下标(0..7)作为值,刻度数与档位数一致(8 个)
- 手柄、轨道填充、刻度共用同一套等分坐标,拖动时永远落在刻度上

二、历史会话名称显示成「新对话」
根因:noteMessage() 只写本地、从不推云端。标题/计数/时间全在浏览器里,
云端记录一直停在「新对话 / 0 条消息」,换设备或重新拉列表就退回默认名。
- noteMessage 在标题变化或距上次推送 >10s 时 upsert 到云端(节流)
- 新增 lastPushAt 记录推送时间
- 新增 2 条回归测试:标题变更必须上云、短时间内不重复打接口

数据:清理云端 2 条历史遗留空会话,该账号现只剩 1 条正常会话

验证:tsc 0 错误;单测 1076/1076;构建通过;verify:ui 12/12;
已用 Chrome 渲染截图核对 8 刻度等分效果

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 ngày trước cách đây
mục cha
commit
7923eae3e4

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

@@ -3546,3 +3546,37 @@ messageCount: (s.messageCount ?? 0) + 1,   // 先累加
 ### 验证
 - tsc 0 错误;单测 **1074/1074**(新增 3 条工作区回归);构建通过;verify:ui 12/12;verify:docs 79 文件
 - 云函数 v0.1.4 已热更(备份:`cloud/backup/oR5O67i1Cm-...-v0.1.3.js`)
+
+## 2026-11-01 第十二轮:滑块刻度等分 + 会话名称上云
+
+### 一、字体滑块刻度改为严格等分
+**问题**:刻度疏密不均,不是「等分」的效果。
+
+**根因**:刻度按**字号数值**在 13~20 区间里线性定位。
+采样点取 13/16/17/18/19/20 时,前两格间距是其它格的 3 倍。
+只放 6 个刻度配 8 个档位也必然对不上。
+
+**修复**:
+- `ui-slider` 新增 `marksEvenly` 输入:按下标均分,而非按数值比例
+- 字号滑块改用**档位下标**(0..7)作为滑块值,刻度数与档位数一致(8 个)
+- 手柄、轨道填充、刻度三者共用同一套等分坐标,拖动时永远落在刻度上
+- 「默认」(16px)位于第 4 档 → 42.9%,与参考图中偏左的位置一致
+
+### 二、🔴 历史会话名称显示成「新对话」
+**根因**:`ChatSessionService.noteMessage()` **只写本地,从不推云端**。
+标题、消息数、最后时间全留在浏览器里,云端那条记录一直停在
+「新对话 / 0 条消息」——换设备、清缓存、甚至重新拉一次列表就退回默认名。
+
+**修复**:
+- `noteMessage` 在**标题变化**或**距上次推送 >10s** 时 upsert 到云端(节流,避免每条消息打接口)
+- 新增 `lastPushAt` 内存映射记录推送时间
+- 清理云端 2 条历史遗留的空会话(count=0 且无标题、无预览),该账号现只剩 1 条正常会话
+
+**新增回归测试**:
+- 首条消息确定标题后必须推送到云端
+- 同一会话短时间内重复消息不重复打接口
+
+### 验证
+- tsc 0 错误;单测 **1076/1076**;构建通过;verify:ui 12/12
+- 已用 Chrome 实际渲染截图核对 8 刻度等分效果(明暗两套主题)
+- 云端核查:活跃会话 1 条,标题正常,count=38

+ 51 - 0
src/app/core/chat-session.service.spec.ts

@@ -242,6 +242,57 @@ describe('ChatSessionService', () => {
     });
   });
 
+  describe('消息推进必须同步云端(2026-11-01)', () => {
+    const ROOM = '!room1:fmode.cn';
+    const AGENT = 'agent-a';
+
+    beforeEach(() => localStorage.clear());
+
+    /*
+     * 🔴 回归:历史会话名称加载不对,显示成「新对话」。
+     *
+     * 根因:`noteMessage` 只写本地,标题/计数/时间从不推云端。
+     * 云端那条记录永远停在「新对话 / 0 条」,换设备或重新拉列表就退回默认名。
+     */
+    it('🔴 首条消息确定标题后必须推送到云端', async () => {
+      const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: upsert,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      const s = svc2.create(ROOM, AGENT);
+      upsert.calls.reset();
+
+      svc2.noteMessage(s.id, { text: '帮我做个登录页', ts: Date.now(), fromMe: true });
+
+      expect(upsert).withContext('标题变了必须推云端,否则换设备退回「新对话」').toHaveBeenCalled();
+      const pushed = upsert.calls.mostRecent().args[0][0];
+      expect(pushed.title).toBe('做个登录页');
+      expect(pushed.threadRoot).toBe(s.id);
+    });
+
+    it('同一会话短时间内重复消息不重复打接口(节流)', async () => {
+      const upsert = jasmine.createSpy('upsert').and.returnValue(Promise.resolve());
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) => (o?.includeDeleted ? [] : []),
+        upsertChatThreads: upsert,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      const s = svc2.create(ROOM, AGENT);
+      upsert.calls.reset();
+
+      // 首条定标题(触发一次)
+      svc2.noteMessage(s.id, { text: '第一条', ts: 1000, fromMe: true });
+      const afterFirst = upsert.calls.count();
+      // 紧随其后的几条只改计数,不应再推
+      svc2.noteMessage(s.id, { text: '第二条', ts: 1001, fromMe: true });
+      svc2.noteMessage(s.id, { text: '第三条', ts: 1002, fromMe: true });
+
+      expect(upsert.calls.count()).withContext('计数变化不该每条都打接口').toBe(afterFirst);
+    });
+  });
+
   describe('工作区归属(2026-11-01 上云)', () => {
     const ROOM = '!room1:fmode.cn';
     const AGENT = 'agent-a';

+ 28 - 1
src/app/core/chat-session.service.ts

@@ -115,6 +115,14 @@ export class ChatSessionService {
    */
   private cloudRemoved = new Set<string>();
 
+  /**
+   * 每个会话上一次把元信息推到云端的时间(节流用,见 `noteMessage`)。
+   *
+   * 放在内存里而不是持久化:它只用于「别太频繁地打接口」,
+   * 刷新后重新计时即可,不影响任何数据的正确性。
+   */
+  private readonly lastPushAt = new Map<string, number>();
+
   /**
    * 从 localStorage 载入(Shell 启动时调用一次;重复调用幂等)。
    *
@@ -536,7 +544,26 @@ export class ChatSessionService {
   ): void {
     const cur = this.byId(id);
     if (!cur) return;
-    this.update(id, touchSession(cur, msg));
+    const next = touchSession(cur, msg);
+    this.update(id, next);
+    /*
+     * 🔴 必须把变化推回云端(2026-11-01 修复)。
+     *
+     * 此前 `noteMessage` **只写本地** —— 标题、消息数、最后时间全留在浏览器里,
+     * 云端那条记录一直停在「新对话 / 0 条消息」。
+     * 于是换设备、清缓存、甚至只是重新拉一次列表,会话名就退回「新对话」
+     * (用户实测:历史会话名称加载不对)。
+     *
+     * 节流:只在两种关键时刻推送,避免每条消息都打一次接口 ——
+     *   ① 标题变了(首条消息命名成功)
+     *   ② 距上次推送超过 10 秒(保证消息数与时间不会长期落后)
+     * 其余情况交给下次推送顺带更新。
+     */
+    const titleChanged = next.title !== cur.title;
+    const due = Date.now() - (this.lastPushAt.get(id) ?? 0) > 10_000;
+    if (!this._ready() || (!titleChanged && !due)) return;
+    this.lastPushAt.set(id, Date.now());
+    void this.trySync(() => this.api!.upsertChatThreads([toUpsert(this.byId(id) ?? next)]));
   }
 
   /** 取某房间下所有已知会话的线程根(消息过滤用) */

+ 44 - 20
src/app/desktop/pages/mine/mine.ts

@@ -91,14 +91,22 @@ import { SessionService } from '../../../core/session.service';
                 <p>调整全局字号(菜单、列表与对话正文同步),当前 {{ chatFont.size() }}px。</p>
               </div>
               <div class="font-scale">
+                <!--
+                  滑块值用**档位下标**(0..7)而不是字号 px(13..20)。
+                  这样滑块位置、轨道填充、刻度三者共用同一套等分坐标,
+                  拖动时手柄永远精确落在某个刻度上,不会出现
+                  「停在两个刻度中间」或「刻度和手柄对不上」。
+                  对外仍然以 px 形式落到 ChatFontService。
+                -->
                 <ui-slider
                   label="字体大小"
-                  [min]="fontMin"
-                  [max]="fontMax"
+                  [min]="0"
+                  [max]="fontMaxIndex"
                   [step]="1"
+                  [marksEvenly]="true"
                   [marks]="fontMarks"
-                  [value]="chatFont.size()"
-                  (valueChange)="setChatFont($event)"
+                  [value]="chatFont.index()"
+                  (valueChange)="setChatFontIndex($event)"
                 />
               </div>
             </div>
@@ -527,26 +535,42 @@ export class DkMinePage {
   readonly chatFont = inject(ChatFontService);
   readonly fontMin = CHAT_FONT_SIZES[0];
   readonly fontMax = CHAT_FONT_SIZES[CHAT_FONT_SIZES.length - 1];
+  /** 档位下标上限(滑块用下标而非 px,见模板注释) */
+  readonly fontMaxIndex = CHAT_FONT_SIZES.length - 1;
   /**
-   * 滑块刻度(2026-11-01 按参考稿定稿)。
+   * 滑块刻度(2026-11-01 定稿:**严格等分**)。
    *
-   * 6 个刻度、**每个都画短线**,只有第 1 / 2 / 6 个带文字(小 / 默认 / 大)。
-   * 档位从 CHAT_FONT_SIZES 里按等距取样,保证刻度间距一致 ——
-   * 直接写死数值会在改动字号档位时与滑块对不上。
+   * 🔴 刻度数必须等于**档位数**(8 档 → 8 个刻度),否则对不上:
+   *    滑块是 0..7 共 8 个位置,刻度若只放 6 个(0/3/4/5/6/7),
+   *    第 2 个刻度落在 3/7≈42.9% 而第 1、3 个在 0% 和 25%,
+   *    间距就会忽宽忽窄 —— 看起来完全不是「均分」。
+   *    现在每档一个刻度,8 个刻度严格等距,手柄永远落在刻度上。
    *
-   *  13 ─ 16 ─ 17 ─ 18 ─ 19 ─ 20
-   *  小    默认             大
+   *   下标:  0    1    2    3    4    5    6    7
+   *   字号: 13   14   15   16   17   18   19   20
+   *   文字: 小                       默认(i=6)  大
+   *
+   * 标签位置:小 = 第 1 格(下标 0)、默认 = 默认字号所在格、大 = 最后一格。
    */
-  readonly fontMarks: { value: number; label: string }[] = (() => {
-    const picks = [0, 3, 4, 5, 6, 7];
-    return picks.map((i, idx) => ({
-      value: CHAT_FONT_SIZES[Math.min(i, CHAT_FONT_SIZES.length - 1)],
-      label: idx === 0 ? '小' : idx === 1 ? '默认' : idx === picks.length - 1 ? '大' : '',
-    }));
-  })();
-
-  setChatFont(px: unknown): void {
-    if (typeof px === 'number') this.chatFont.set(px);
+  readonly fontMarks: { value: number; label: string }[] = CHAT_FONT_SIZES.map(
+    (_px, i) => ({
+      value: i,
+      label:
+        i === 0
+          ? '小'
+          : CHAT_FONT_SIZES[i] === DEFAULT_CHAT_FONT
+            ? '默认'
+            : i === CHAT_FONT_SIZES.length - 1
+              ? '大'
+              : '',
+    }),
+  );
+
+  /** 滑块给的是档位下标 → 换算成字号 px 落到服务 */
+  setChatFontIndex(index: unknown): void {
+    if (typeof index !== 'number') return;
+    const px = CHAT_FONT_SIZES[index];
+    if (px !== undefined) this.chatFont.set(px);
   }
 
   readonly palette = inject(PaletteService);

+ 4 - 4
src/app/ui/components/slider/slider.html

@@ -15,16 +15,16 @@
   @if (marks().length) {
     <!-- 刻度短线:每个刻度一根,等距分布(2026-11-01) -->
     <div class="ui-slider-ticks" aria-hidden="true">
-      @for (mark of marks(); track mark.value) {
-        <span class="tick" [style.left.%]="markPercent(mark)"></span>
+      @for (mark of marks(); track mark.value; let i = $index) {
+        <span class="tick" [style.left.%]="markPercent(mark, i)"></span>
       }
     </div>
 
     <!-- 刻度文字:只有标记了 label 的刻度才显示 -->
     <div class="ui-slider-labels" aria-hidden="true">
-      @for (mark of marks(); track mark.value) {
+      @for (mark of marks(); track mark.value; let i = $index) {
         @if (mark.label) {
-          <span class="label" [style.left.%]="markPercent(mark)">{{ mark.label }}</span>
+          <span class="label" [style.left.%]="markPercent(mark, i)">{{ mark.label }}</span>
         }
       }
     </div>

+ 17 - 1
src/app/ui/components/slider/slider.ts

@@ -35,6 +35,17 @@ export class UiSlider {
   /** 刻度标记 [{ value, label? }] */
   readonly marks = input<SliderMark[]>([]);
 
+  /**
+   * 刻度是否**等距排布**(2026-11-01)。
+   *
+   * 默认按「数值在区间中的比例」定位 —— 适合连续量(音量、亮度)。
+   * 但用于**离散档位**时会出现刻度疏密不均:
+   * 例如字号 13~20 的线性区间里,若采样点取 13/16/17/18/19/20,
+   * 前两个刻度的间距是其它刻度的 3 倍,看起来就不是「均分」。
+   * 置为 true 后,刻度按**下标均分**,视觉上等距。
+   */
+  readonly marksEvenly = input(false, { transform: booleanAttribute });
+
   protected readonly percent = computed(() => this.toPercent(this.value()));
 
   /** 通过 CSS 变量把已填充比例传给 SCSS 里的轨道渐变 */
@@ -47,7 +58,12 @@ export class UiSlider {
     this.value.set(Number((event.target as HTMLInputElement).value));
   }
 
-  protected markPercent(mark: SliderMark): number {
+  /** 刻度位置(%):等距模式按下标均分,否则按数值比例 */
+  protected markPercent(mark: SliderMark, index: number): number {
+    if (this.marksEvenly()) {
+      const n = this.marks().length;
+      return n <= 1 ? 0 : (index / (n - 1)) * 100;
+    }
     return this.toPercent(mark.value);
   }