Kaynağa Gözat

feat: 双端认领交互修复 + 组织准入 + 管理员成员管理

单聊
- 成果归属:用户上传文件不再进「成果」,只有容器产出才算(新增 isUserUploadUrl)
- 计时改逐秒递增,结束时定格,不再 5s→18s→30s 跳变
- 打断场景按轮次拆块(ROUND_GAP_MS=20s),两轮各自计时、各自判状态
- 新增 Message.replyTo:回复按实际回应的那条归属,修复图片回合「未响应」

群聊
- 文件/成果分离与单聊对齐;群会话开始写入成果链接
- 移动端新增「文件/成果」页签(卡片行 + 吸顶搜索 + 底部抽屉)

状态
- 顶栏「状态未同步」误报修复:统一 80 分钟阈值
- 忙碌且有真实任务时,错过一轮上报不降级

缩放端切换
- 跨 768px 断点自动判断 + 250ms 防抖 + 弹提示确认(双向可用)
- 修:手动切换后不再提示、切端后新外壳回调失联、监听未真正移除

组织准入
- 新增 orgGuard 与双端等待页;无组织账号停在 /pending
- 判定在服务端:个人试用组织(自动创建、仅本人)也视为无组织

管理员成员管理
- 新云函数 fmodeagent-users(mgR4EA2n5a):list/add/get/update/remove/me
- 服务端鉴权 + 作用域锁死管理员自己的组织
- 桌面表格页 + 移动卡片页;外层不滚、列表内滚、分页固定

验证:npm test 1481 SUCCESS,npm run build 通过,
端到端脚本见 scripts/verify-*.mjs,完整说明见
docs/00-tracking/2026-11-10-完整修改记录.md

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 12 saat önce
ebeveyn
işleme
4c4d592e2e
68 değiştirilmiş dosya ile 6185 ekleme ve 194 silme
  1. 429 0
      cloud/functions/fmodeagent-users.js
  2. 178 0
      docs/00-tracking/2026-11-10-完整修改记录.md
  3. 1 0
      docs/00-tracking/2026-11-10-进度记录与遗留问题.md
  4. 116 0
      docs/00-tracking/操作追踪.md
  5. 30 1
      docs/02-design/architecture/当前业务逻辑总览.md
  6. 116 0
      docs/03-interface/容器侧改造-图文合并处理-20261110.md
  7. 195 0
      docs/03-interface/容器入站机制与图文合并规范-20261110.md
  8. 65 0
      docs/04-quality/review/2026-11-10-图文合并与成果去重-评审.md
  9. 4 1
      package.json
  10. 154 0
      scripts/clear-deliverable-urls.mjs
  11. 104 0
      scripts/verify-admin-users-e2e.mjs
  12. 82 0
      scripts/verify-deliverable-separation.mjs
  13. 84 0
      scripts/verify-elapsed-ticking.mjs
  14. 74 0
      scripts/verify-image-text-send.mjs
  15. 92 0
      scripts/verify-interrupt-ui.mjs
  16. 115 0
      scripts/verify-mobile-org.mjs
  17. 101 0
      scripts/verify-resize-and-org.mjs
  18. 46 0
      scripts/verify-status-consistency.mjs
  19. 79 0
      scripts/verify-switch-both-ways.mjs
  20. 25 15
      src/app/app.routes.ts
  21. 85 2
      src/app/components/domain/assistant-turn/assistant-turn.spec.ts
  22. 40 12
      src/app/components/domain/assistant-turn/assistant-turn.ts
  23. 14 0
      src/app/components/domain/session-rail/session-rail.ts
  24. 18 2
      src/app/components/domain/session-tree/session-tree.ts
  25. 49 1
      src/app/components/shell/page/page.ts
  26. 6 2
      src/app/core/acl.ts
  27. 23 1
      src/app/core/agent-state.spec.ts
  28. 18 6
      src/app/core/agent-state.ts
  29. 36 0
      src/app/core/api/fmode-api.ts
  30. 71 0
      src/app/core/api/mock-api.ts
  31. 44 0
      src/app/core/api/models.ts
  32. 75 0
      src/app/core/api/parse-api.ts
  33. 113 1
      src/app/core/assistant-turn.spec.ts
  34. 133 31
      src/app/core/assistant-turn.ts
  35. 6 4
      src/app/core/chat.service.spec.ts
  36. 69 33
      src/app/core/chat.service.ts
  37. 11 7
      src/app/core/deliverables.service.spec.ts
  38. 13 3
      src/app/core/deliverables.service.ts
  39. 131 0
      src/app/core/device.service.spec.ts
  40. 146 15
      src/app/core/device.service.ts
  41. 56 0
      src/app/core/group-store.service.spec.ts
  42. 13 0
      src/app/core/group-store.service.ts
  43. 23 0
      src/app/core/matrix-client.service.ts
  44. 8 0
      src/app/core/nav-menu.service.ts
  45. 105 0
      src/app/core/org-users.service.spec.ts
  46. 133 0
      src/app/core/org-users.service.ts
  47. 59 0
      src/app/core/org.guard.ts
  48. 31 1
      src/app/core/s3-link.spec.ts
  49. 40 0
      src/app/core/s3-link.ts
  50. 26 3
      src/app/core/task-pulse.service.spec.ts
  51. 16 3
      src/app/core/task-pulse.service.ts
  52. 278 1
      src/app/core/turn-aggregator.spec.ts
  53. 171 4
      src/app/core/turn-aggregator.ts
  54. 12 0
      src/app/desktop/desktop.routes.ts
  55. 531 0
      src/app/desktop/pages/admin/users/users.ts
  56. 31 2
      src/app/desktop/pages/chat/chat.ts
  57. 41 10
      src/app/desktop/pages/group-chat/group-chat.ts
  58. 34 8
      src/app/desktop/shell/desktop-shell.ts
  59. 6 0
      src/app/mobile/mobile.routes.ts
  60. 21 0
      src/app/mobile/pages/admin/admin.ts
  61. 505 0
      src/app/mobile/pages/admin/users/users.ts
  62. 79 10
      src/app/mobile/pages/chat/chat.ts
  63. 150 3
      src/app/mobile/pages/group-chat/group-chat.ts
  64. 6 0
      src/app/mobile/pages/login/login.ts
  65. 286 0
      src/app/mobile/pages/pending/pending.ts
  66. 35 12
      src/app/mobile/shell/mobile-shell.ts
  67. 9 0
      src/app/pages/login/login.ts
  68. 289 0
      src/app/pages/pending/pending.ts

+ 429 - 0
cloud/functions/fmodeagent-users.js

@@ -0,0 +1,429 @@
+// FmodeAgent 用户管理云函数(2026-11-10 新建)
+//
+// 目的:给公司管理员提供「组织成员」的增删改查。
+//
+//   op: list   { keyword?, page?, pageSize? }  → { items, total, page, pageSize }
+//   op: add    { userId }                      → 把用户拉进管理员所在组织
+//   op: update { userId, nickname?, mobile?, password? } → 改资料 / 重置密码
+//   op: remove { userId }                      → **软删**(isDeleted=true)+ 移出组织
+//   op: get    { userId }                      → 单条详情(编辑弹窗回填用)
+//
+// 🔴 安全边界(用户 2026-11-10 明确要求):
+//   ① **每个 op 的第一件事都是服务端鉴权** —— 前端 `canManageCompany` 只决定
+//      「入口显不显示」,改了前端也越不了权;
+//   ② 作用域**强制锁在管理员自己的 companyId**,前端传任何 company 参数一律忽略;
+//   ③ 改密码、软删必须 `useMasterKey: true`(前端直改 `_User` 是被禁止的)。
+//
+// 平台约定(与 fmodeagent-chatthreads 一致,实测):
+//   · 入口 async function handler(request, response);入参在全局 params
+//   · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session
+//   · 🔴 输出必须 response.status(200).json(...) 显式写出;直接 return 对象平台不回包
+//
+// 表:
+//   _User   objectId / username / mobile / nickname / company Pointer<Company> / isDeleted
+//   _Role   <companyId>_Admin / <companyId>_Public / FMODE_AGENT_SUPERADMIN
+
+var CLASS_USER = '_User';
+var SUPER_ROLE = 'FMODE_AGENT_SUPERADMIN';
+var DEFAULT_PAGE_SIZE = 20;
+var MAX_PAGE_SIZE = 100;
+
+function _ok(data) {
+  response.status(200).json(Object.assign({ code: 200 }, data || {}));
+  return true;
+}
+function _fail(code, mess) {
+  response.status(200).json({ code: code, mess: mess });
+  return false;
+}
+
+function _str(v, max) {
+  var s = v == null ? '' : String(v);
+  return max && s.length > max ? s.slice(0, max) : s;
+}
+
+function _sessionToken() {
+  var h = request && request.headers ? request.headers : {};
+  if (h['x-parse-session-token']) return h['x-parse-session-token'];
+  if (params && params.sessionToken) return params.sessionToken;
+  if (request && request.body && request.body.token) return request.body.token;
+  return null;
+}
+
+async function _getUser(t) {
+  if (!t) return null;
+  if (request.user && request.user.id && !(params && params.sessionToken)) return request.user;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    var s = await q.first({ useMasterKey: true });
+    return s ? s.get('user') : null;
+  } catch (e) {
+    return null;
+  }
+}
+
+async function _roleUserIds(name) {
+  try {
+    var q = new Parse.Query(Parse.Role);
+    q.equalTo('name', name);
+    var role = await q.first({ useMasterKey: true });
+    if (!role) return [];
+    var us = await role.getUsers().query().find({ useMasterKey: true });
+    return us.map(function (u) {
+      return u.id;
+    });
+  } catch (e) {
+    return [];
+  }
+}
+
+/** 当前用户的公司指针(无组织返回 null) */
+function _userCompanyPtr(u) {
+  try {
+    var p = u.get('company');
+    return p && p.id ? p : null;
+  } catch (e) {
+    return null;
+  }
+}
+
+/**
+ * 当前用户是否「只开了个人试用组织」而非真正加入了组织(2026-11-10)。
+ *
+ * ## 为什么要判
+ *
+ * 实测(2026-11-10):新用户登录时系统会**自动创建一个以他命名的组织**
+ * (如 liujun 的组织就叫「liujun」),并写入 `enterpriseTrialClaimedAt`。
+ * 所以 `companyId` 非空**并不能**说明他已被某个组织接纳 ——
+ * 只判 `companyId` 会让等待页形同虚设。
+ *
+ * ## 判据(两项都满足才算个人试用)
+ *
+ * 1. 该组织的 `enterpriseTrialClaimedAt` 有值(实测:100 个组织里 86 个个人组织全带此标记,
+ *    康师傅/路觅教育这类正式组织都没有);
+ * 2. 该组织里**只有他一个人**(被管理员拉进来的组织至少有 2 人)。
+ *
+ * 第 2 条兜住「试用组织后来被别人加入」→ 自动转为正式组织的情况。
+ *
+ * @returns true = 个人试用组织,应视为「无组织」
+ */
+async function _isPersonalTrialOrg(companyPtr, userId) {
+  if (!companyPtr) return false;
+  try {
+    var cq = new Parse.Query('Company');
+    var company = await cq.get(companyPtr.id, { useMasterKey: true });
+    if (!company || !company.get('enterpriseTrialClaimedAt')) return false;
+
+    var uq = new Parse.Query(CLASS_USER);
+    uq.equalTo('company', companyPtr);
+    uq.notEqualTo('isDeleted', true);
+    var count = await uq.count({ useMasterKey: true });
+    return count <= 1;
+  } catch (e) {
+    /* 查不到就按「正式组织」处理,避免把正常用户挡在外面 */
+    return false;
+  }
+}
+
+/** 判断某个用户是否有「真正归属的组织」 */
+async function _hasRealOrg(user) {
+  var ptr = _userCompanyPtr(user);
+  if (!ptr) return false;
+  return !(await _isPersonalTrialOrg(ptr, user.id));
+}
+
+/**
+ * 重新从库中取一份完整用户对象。
+ *
+ * 🔴 平台注入的 `request.user` 是**代理对象**:只有 `id`,
+ *    调 `get('company')` 拿不到字段(实测:明明有组织却判成「未归属」)。
+ *    所以鉴权后必须按 objectId 重新查一次。
+ */
+async function _reloadUser(u) {
+  try {
+    var q = new Parse.Query(CLASS_USER);
+    var fresh = await q.get(u.id, { useMasterKey: true });
+    return fresh || u;
+  } catch (e) {
+    return u;
+  }
+}
+
+/**
+ * 服务端鉴权 —— **每个 op 必须先过这里**。
+ *
+ * @returns { user, companyId, isSuperAdmin } 或 null(已被 _fail 写出)
+ */
+async function _authorize() {
+  var user = await _getUser(_sessionToken());
+  if (!user) {
+    _fail(401, 'SessionToken 无效或已过期');
+    return null;
+  }
+  /* 平台注入的对象只有 id,字段要重新取(见 _reloadUser 注释) */
+  user = await _reloadUser(user);
+
+  var superIds = await _roleUserIds(SUPER_ROLE);
+  var isSuperAdmin = superIds.indexOf(user.id) >= 0;
+  var companyPtr = _userCompanyPtr(user);
+  var companyId = companyPtr ? companyPtr.id : null;
+
+  /*
+   * 超管:与公司管理员一样,**只操作自己所属的组织**。
+   *
+   * 🔴 不接受前端传的 `companyId` —— 那等于给超管开了一个「查任意租户」的入口,
+   *    前端一改就能越权。跨租户管理如果需要,应另做受审计的运维接口。
+   */
+  if (isSuperAdmin) {
+    if (!companyId) {
+      _fail(403, '当前超管账号未归属任何组织,无法使用成员管理');
+      return null;
+    }
+    return { user: user, companyId: companyId, isSuperAdmin: true };
+  }
+
+  // 公司管理员:roles 里必须有 `<companyId>_Admin`
+  if (!companyId) {
+    _fail(403, '当前账号不属于任何组织');
+    return null;
+  }
+  var roleNames = [];
+  try {
+    var rq = new Parse.Query(Parse.Role);
+    rq.equalTo('name', companyId + '_Admin');
+    var role = await rq.first({ useMasterKey: true });
+    if (role) {
+      var us = await role.getUsers().query().find({ useMasterKey: true });
+      roleNames = us.map(function (u) {
+        return u.id;
+      });
+    }
+  } catch (e) {
+    /* 查不到角色 → 视为非管理员 */
+  }
+  if (roleNames.indexOf(user.id) < 0) {
+    _fail(403, '仅公司管理员可管理成员');
+    return null;
+  }
+  return { user: user, companyId: companyId, isSuperAdmin: false };
+}
+
+/** 用户 → 返回给前端的精简结构 */
+function _toItem(u) {
+  return {
+    id: u.id,
+    username: _str(u.get('username'), 80),
+    nickname: _str(u.get('nickname'), 60),
+    mobile: _str(u.get('mobile'), 20),
+    isDeleted: !!u.get('isDeleted'),
+    createdAt: u.createdAt ? u.createdAt.getTime() : 0,
+  };
+}
+
+/** 该用户是否在本组织内 */
+async function _userInCompany(userId, companyId) {
+  try {
+    var q = new Parse.Query(CLASS_USER);
+    var u = await q.get(userId, { useMasterKey: true });
+    var p = _userCompanyPtr(u);
+    return { user: u, inCompany: !!p && p.id === companyId };
+  } catch (e) {
+    return { user: null, inCompany: false };
+  }
+}
+
+async function opList(ctx) {
+  var page = Math.max(1, parseInt(params.page, 10) || 1);
+  var pageSize = Math.min(MAX_PAGE_SIZE, Math.max(1, parseInt(params.pageSize, 10) || DEFAULT_PAGE_SIZE));
+  var keyword = _str(params.keyword, 60).trim();
+  var companyPtr = { __type: 'Pointer', className: 'Company', objectId: ctx.companyId };
+
+  function buildQuery() {
+    var q = new Parse.Query(CLASS_USER);
+    q.equalTo('company', companyPtr);
+    q.notEqualTo('isDeleted', true);
+    if (keyword) {
+      /* 昵称 / 手机号 / objectId 三选一命中 */
+      var byName = new Parse.Query(CLASS_USER);
+      byName.contains('nickname', keyword);
+      var byMobile = new Parse.Query(CLASS_USER);
+      byMobile.contains('mobile', keyword);
+      var byUsername = new Parse.Query(CLASS_USER);
+      byUsername.contains('username', keyword);
+      if (keyword.length <= 12) {
+        var byId = new Parse.Query(CLASS_USER);
+        byId.equalTo('objectId', keyword);
+        q = Parse.Query.or(byName, byMobile, byUsername, byId);
+      } else {
+        q = Parse.Query.or(byName, byMobile, byUsername);
+      }
+      /* 关键词查询也要受组织约束 —— 否则能搜到别的组织的人 */
+      q.equalTo('company', companyPtr);
+      q.notEqualTo('isDeleted', true);
+    }
+    q.descending('createdAt');
+    return q;
+  }
+
+  var total = await buildQuery().count({ useMasterKey: true });
+  var q = buildQuery();
+  q.skip((page - 1) * pageSize);
+  q.limit(pageSize);
+  var rows = await q.find({ useMasterKey: true });
+
+  return _ok({
+    items: rows.map(_toItem),
+    total: total,
+    page: page,
+    pageSize: pageSize,
+  });
+}
+
+async function opAdd(ctx) {
+  var userId = _str(params.userId, 120).trim();
+  if (!userId) return _fail(400, '请填写用户 ID');
+
+  var found;
+  try {
+    var q = new Parse.Query(CLASS_USER);
+    found = await q.get(userId, { useMasterKey: true });
+  } catch (e) {
+    return _fail(404, '用户不存在:' + userId);
+  }
+  if (found.get('isDeleted')) return _fail(400, '该用户已被删除,不能加入组织');
+
+  var cur = _userCompanyPtr(found);
+  if (cur && cur.id !== ctx.companyId) {
+    return _fail(409, '该用户已属于其他组织,请先联系其原组织处理');
+  }
+
+  /* 改 `_User.company` 指针(运维口径:换组织改指针,objectId 不变) */
+  var companyPtr = { __type: 'Pointer', className: 'Company', objectId: ctx.companyId };
+  found.set('company', Parse.Object.fromJSON(companyPtr));
+  await found.save(null, { useMasterKey: true });
+
+  /* 补上普通成员角色(人群聊/可见性等 ACL 依赖它) */
+  try {
+    var rq = new Parse.Query(Parse.Role);
+    rq.equalTo('name', ctx.companyId + '_Public');
+    var role = await rq.first({ useMasterKey: true });
+    if (role) await role.getUsers().add(found).save(null, { useMasterKey: true });
+  } catch (e) {
+    /* 角色补不上不影响主流程 */
+  }
+
+  return _ok({ item: _toItem(found) });
+}
+
+async function opGet(ctx) {
+  var userId = _str(params.userId, 120).trim();
+  if (!userId) return _fail(400, '用户 ID 不能为空');
+  var r = await _userInCompany(userId, ctx.companyId);
+  if (!r.user) return _fail(404, '用户不存在');
+  if (!r.inCompany) return _fail(403, '该用户不在本组织');
+  return _ok({ item: _toItem(r.user) });
+}
+
+async function opUpdate(ctx) {
+  var userId = _str(params.userId, 120).trim();
+  if (!userId) return _fail(400, '用户 ID 不能为空');
+  var r = await _userInCompany(userId, ctx.companyId);
+  if (!r.user) return _fail(404, '用户不存在');
+  if (!r.inCompany) return _fail(403, '该用户不在本组织');
+
+  var u = r.user;
+  if (params.nickname != null) u.set('nickname', _str(params.nickname, 60));
+  if (params.mobile != null) {
+    var mobile = _str(params.mobile, 20).trim();
+    if (mobile && !/^1\d{10}$/.test(mobile)) return _fail(400, '手机号格式不正确');
+    u.set('mobile', mobile);
+  }
+  /* 🔴 改密码必须 useMasterKey;空串表示「不修改」 */
+  if (params.password != null && String(params.password).length > 0) {
+    var pwd = String(params.password);
+    if (pwd.length < 6) return _fail(400, '密码至少 6 位');
+    u.set('password', pwd);
+  }
+  await u.save(null, { useMasterKey: true });
+  return _ok({ item: _toItem(u) });
+}
+
+async function opRemove(ctx) {
+  var userId = _str(params.userId, 120).trim();
+  if (!userId) return _fail(400, '用户 ID 不能为空');
+  if (userId === ctx.user.id) return _fail(400, '不能删除自己');
+
+  var r = await _userInCompany(userId, ctx.companyId);
+  if (!r.user) return _fail(404, '用户不存在');
+  if (!r.inCompany) return _fail(403, '该用户不在本组织');
+
+  /*
+   * 软删(平台铁律:删除用软删 isDeleted=true)+ 移出组织。
+   * 不物理删除:Matrix 绑定、会话归属等仍引用该 objectId,物理删会留下悬空指针。
+   */
+  var u = r.user;
+  u.set('isDeleted', true);
+  u.unset('company');
+  await u.save(null, { useMasterKey: true });
+  return _ok({ removed: true });
+}
+
+async function opMe(ctx) {
+  /*
+   * 「我有没有真正的组织」—— 供前端 /pending 页与 orgGuard 判定。
+   *
+   * 🔴 不走管理员鉴权(`_authorize` 会 403),每个登录用户都要能调,
+   *    所以这里单独处理:只回自己的身份与归属,不泄露任何他人数据。
+   */
+  var user = await _getUser(_sessionToken());
+  if (!user) return _fail(401, 'SessionToken 无效或已过期');
+  user = await _reloadUser(user);
+
+  var ptr = _userCompanyPtr(user);
+  var real = ptr ? !(await _isPersonalTrialOrg(ptr, user.id)) : false;
+  var companyName = '';
+  if (ptr) {
+    try {
+      var c = await new Parse.Query('Company').get(ptr.id, { useMasterKey: true });
+      companyName = c.get('name') || '';
+    } catch (e) {
+      /* 忽略 */
+    }
+  }
+  return _ok({
+    userId: user.id,
+    hasOrg: real,
+    companyId: ptr ? ptr.id : '',
+    companyName: companyName,
+  });
+}
+
+async function handler(request, response) {
+  try {
+    params = params && typeof params === 'object' ? params : (request && request.body && request.body.params) || {};
+
+    /* `/me` 只需登录态,不需要管理员身份 —— 必须放在 _authorize 之前 */
+    if (params.op === 'me') return await opMe();
+
+    var ctx = await _authorize();
+    if (!ctx) return; // _authorize 已写出错误响应
+
+    switch (params.op) {
+      case 'list':
+        return await opList(ctx);
+      case 'add':
+        return await opAdd(ctx);
+      case 'get':
+        return await opGet(ctx);
+      case 'update':
+        return await opUpdate(ctx);
+      case 'remove':
+        return await opRemove(ctx);
+      default:
+        return _fail(400, '未知 op: ' + params.op);
+    }
+  } catch (e) {
+    return _fail(500, '用户管理服务异常: ' + ((e && e.message) || String(e)));
+  }
+}

+ 178 - 0
docs/00-tracking/2026-11-10-完整修改记录.md

@@ -0,0 +1,178 @@
+# 2026-11-10 完整修改记录(可交接)
+
+| 字段 | 值 |
+|---|---|
+| 日期 | 2026-11-10 |
+| 范围 | 单聊/群聊 UI 修复 · 容器入站机制调研 · 状态判定统一 · 缩放端切换 · 组织归属准入 · 管理员成员管理 |
+| 代码状态 | 已提交并推送(见文末) |
+| 验证基线 | `npm test` **1481 条全过** · `npm run build` ✅ |
+
+> 本文是**当天全部改动的总说明**。逐条的根因、实测证据与踩坑记录在
+> `操作追踪.md` 的「2026-11-10」各条目中;本文给出**改了什么、为什么、怎么验证**。
+
+---
+
+## 一、改动总览
+
+| # | 模块 | 一句话 | 影响文件 |
+|---|---|---|---|
+| 1 | 单聊·成果归属 | 用户上传的文件不再进「成果」,只有容器产出才算 | `chat.service.ts`、`s3-link.ts`、`deliverables.service.ts` |
+| 2 | 单聊·计时 | 秒数改为逐秒递增,结束时定格,不再跳变 | `assistant-turn.ts`、`turn-aggregator.ts`、`assistant-turn` 组件 |
+| 3 | 单聊·打断场景 | 按「轮次」拆块,两轮各自计时、各自判状态 | `turn-aggregator.ts`、`assistant-turn.ts` |
+| 4 | 单聊·消息归属 | 新增 `Message.replyTo`,回复按实际回应的那条归属 | `matrix-client.service.ts`、`chat.service.ts`、`models.ts` |
+| 5 | 群聊·文件/成果 | 群聊也做「文件=我发的、成果=容器产出的」分离 | `group-chat.ts`(桌面)、`group-store.service.ts`、移动群聊新增页签 |
+| 6 | 状态判定 | 顶栏不再误报「状态未同步」,统一 80 分钟阈值 | `task-pulse.service.ts`、`agent-state.ts` |
+| 7 | 缩放端切换 | 自动判断 + 防抖 + 弹提示确认,**双向可用** | `device.service.ts`、两端外壳、`app.routes.ts` |
+| 8 | 组织准入 | 无组织账号停在等待页,被邀请后才可用 | `org.guard.ts`、`pages/pending/`、`mobile/pages/pending/` |
+| 9 | 成员管理 | 新增管理员用户管理(双端),服务端鉴权 | 云函数 `fmodeagent-users`、`org-users.service.ts`、两端页面 |
+
+---
+
+## 二、逐项说明
+
+### 1. 成果归属:用户文件 ≠ 成果
+
+**问题**:自己上传的图片同时出现在右栏「文件」和「成果」。
+
+**根因**:`chat.service.ts` 上传成功后直接 `addDeliverableUrl()`;实时回读时也没判发送方。
+
+**改法**:
+- 写入端只认 `sender === 'agent'`,并新增 `isUserUploadUrl()` 过滤(路径 `/user/<uid>/chat/`)
+- 读取端 `bySessionUrls()` 再兜一道
+- 存量脏数据用 `scripts/clear-deliverable-urls.mjs` 清空(用户裁决「全部清空重来」)
+
+### 2. 计时读数:逐秒递增
+
+**问题**:回合头部秒数 `5s → 18s → 30s` 跳变。
+
+**根因**:用的是「最新事件时间 − 首条事件时间」,只在容器发新消息时变。
+
+**改法**:新增 `startAt`(用户发问时刻),进行中由 `elapsedSeconds()` 用每秒时钟现算;结束定格。`seconds` 保留为「容器真实处理耗时」。
+
+### 3. 打断场景:按轮拆块
+
+**背景**:容器对「图 + 文字」会各跑一轮(无合并窗口,见 §三)。两轮在消息流里没有分界。
+
+**改法**:
+- `ROUND_GAP_MS = 20s`:相邻 agent 消息间隔超过即视为新一轮
+- 每轮独立成块 → 各自计时、各自判 live、占位只出现在正在跑的那轮下面
+- 组件改为**每块渲染自己的正文**(原先都用回合级 `merged`,拆轮后导致整段重复)
+
+### 4. 回复归属:`replyTo`
+
+**问题**:图片那轮的回复被算到文字回合下面,图片回合变空壳「未响应」。
+
+**根因**:没有解析 `m.in_reply_to`,只能按顺序猜。
+
+**改法**:新增 `Message.replyTo`,`buildTurns` 优先按它归属。
+
+### 5. 群聊:文件/成果分离
+
+**改法**:
+- 桌面:`railFiles` 加 `sender==='me'`;`railDeliverables` 从「按成员容器拉全量」改为「本群会话 urls + 群内 agent 消息」
+- 存储:`GroupStoreService.appendReply` 写入群会话成果链接
+- 移动:新增「文件/成果」页签(卡片行 + 吸顶搜索 + 底部抽屉)
+
+### 6. 状态判定:顶栏不再误报
+
+**问题**:容器空闲 8 分钟就显示「状态未同步」,与侧栏「在线」矛盾。
+
+**根因**:顶栏用 90 秒新鲜度,全站用 80 分钟,两套并存。
+
+**改法**:`FRESH_MS` 复用 `HEARTBEAT_STALE_SEC`;忙碌且有真实任务时,错过一轮上报不降级。
+
+### 7. 缩放端切换
+
+**用户要求**:自动判断 + 弹提示 + 点确认才切 + 防抖,两端都要。
+
+**改法**:
+- `DeviceService.watchViewport()`:`matchMedia` 跨断点触发 + 250ms 防抖
+- `suggestedMode` 算出建议目标端,两端外壳用统一逻辑弹提示
+- 🔴 **手动切换后仍继续提示**(原先 `isForced()` 会永久禁用提示,导致「切到移动端后放大窗口,再也切不回桌面」)
+- `force()` / `resetToAuto()` 后立即重算,避免「切完还挂着提示」
+- 修 `stopWatching` 未真正移除监听、`watchViewport` 幂等判断导致新宿主回调失效
+
+### 8. 组织归属准入
+
+**🔴 关键发现**:新用户注册时系统**自动创建一个以他命名的组织**(带 `enterpriseTrialClaimedAt` + 30 天试用)。
+所以「有 `companyId`」≠「已加入组织」。
+
+**判定(服务端 `opMe`)**:组织带试用标记 **且** 该组织只有他一人 → 视为**无组织**。
+
+**改法**:`orgGuard` + 两个登录页 + 两个等待页全部改为调 `fetchOrgMembership()`;等待页每 15 秒轮询,被邀请后自动进入。
+
+### 9. 管理员成员管理
+
+**云函数** `fmodeagent-users`(objectId `mgR4EA2n5a`,源码 `cloud/functions/fmodeagent-users.js`):
+- `list` / `add` / `get` / `update` / `remove` / `me`
+- 🔴 **每个 op 先服务端鉴权**(超管或 `<companyId>_Admin`)
+- 🔴 **作用域强制锁在管理员自己的组织**,忽略前端传的任何 company 参数
+- 改密码、软删走 masterKey
+
+**页面**:
+- 桌面:搜索 + 表格 + 分页;外层不滚、表头吸顶、列表内滚、分页固定底部
+- 移动:卡片行 + 吸顶搜索 + 底部抽屉 + 加载更多
+
+---
+
+## 三、容器侧调研结论(未做改造)
+
+与 `lj-test` 直连查清了入站机制:
+
+- 容器**有**文字缓冲去抖(默认 0.6 秒),且**已支持图文合并**(`media_urls.extend`)
+- 但**媒体事件绕过了缓冲**,直接 `handle_message` —— 这就是「图 + 文字」被拆成两轮的原因
+- 三组对照实验:纯文字连发 = 1 轮;图 + 文字 = 2 轮;文字 + 图 = 2 轮
+
+**改造需 root 修改 `/opt/hermes/.../adapter.py`(约 2 行),用户裁决暂缓。**
+详细规范见 `docs/03-interface/容器入站机制与图文合并规范-20261110.md`。
+
+---
+
+## 四、新增文件
+
+| 文件 | 用途 |
+|---|---|
+| `cloud/functions/fmodeagent-users.js` | 成员管理云函数 |
+| `src/app/core/org.guard.ts` | 组织归属守卫 |
+| `src/app/core/org-users.service.ts` | 成员管理服务(服务端分页) |
+| `src/app/pages/pending/pending.ts` | 等待邀请页(桌面) |
+| `src/app/mobile/pages/pending/pending.ts` | 等待邀请页(移动) |
+| `src/app/desktop/pages/admin/users/users.ts` | 成员管理(桌面) |
+| `src/app/mobile/pages/admin/users/users.ts` | 成员管理(移动) |
+| `scripts/clear-deliverable-urls.mjs` | 清空会话成果链接(运维,dry-run 默认) |
+| `scripts/verify-*.mjs` | 各项端到端验证脚本 |
+
+---
+
+## 五、如何验证(拉取代码后)
+
+```bash
+npm install          # 或 pnpm install(本项目用 pnpm 管理的 node_modules)
+npm test             # 期望 1481 条全过
+npm run build        # 期望成功
+npm run dev:logged-in  # 需要 .claude/dev-login.json 有有效 token
+```
+
+端到端脚本(需先 `npm run dev:logged-in`):
+
+| 脚本 | 验证内容 |
+|---|---|
+| `node scripts/verify-switch-both-ways.mjs` | 端切换双向可用 |
+| `node scripts/verify-admin-users-e2e.mjs` | 成员管理列表/搜索/分页/弹窗 |
+| `node scripts/verify-mobile-org.mjs` | 移动端布局 + 无组织拦截 |
+| `node scripts/verify-interrupt-ui.mjs` | 打断场景计时不倒退 |
+| `node scripts/verify-deliverable-separation.mjs` | 文件/成果分离 |
+
+---
+
+## 六、遗留与已知限制(如实记录)
+
+| # | 项 | 状态 |
+|---|---|---|
+| 1 | 容器图文合并窗口 | **未做**,需宿主机 root;App 侧只能合并显示 |
+| 2 | 「管理员拉人 → 对方 15 秒自动进入」全链路 | **未实测**,需 liujun 本人登录态 |
+| 3 | 群聊附件未上云盘 | `sendFileToAgent` 不调 S3,刷新后仅 Matrix 地址 |
+| 4 | 移动群聊新页签 | 代码完成,**未真机截图验证** |
+| 5 | 80 分钟后「已离线」分支 | 仅单测覆盖,未等真实时间跨阈值 |
+| 6 | `LOGIN_COMPANY` 硬编码 `E4KpGvTEto` | 其他组织用户可能登录阶段就失败 |
+| 7 | 成员列表含 163 个微信 openid 账号 | 无手机号无昵称,属本组织但看起来像「别人」(用户裁决:不用处理) |

+ 1 - 0
docs/00-tracking/2026-11-10-进度记录与遗留问题.md

@@ -92,6 +92,7 @@
 | 3 | 群聊相关缺陷 | C1/C8/C9/C10 仍未实测 |
 | 4 | `app-lj-test-v1` 容器 | 搬运脚本缺失,用户裁决暂不管 |
 | 5 | 缩放效果 | 列表/表格/代码块已改 em,未实际调字号验证 |
+| 6 | **容器图文合并窗口未上线** | 2026-11-10:App 侧已合并显示,但容器仍对 `m.image` 抢先回话(实测回「我把正在进行的事挂起」)。任务书:`docs/03-interface/容器侧改造-图文合并处理-20261110.md`。上线前「图文不被打断」**未实现** |
 
 ### P2 · 技术债
 

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

@@ -7213,3 +7213,119 @@ tr '\0' '\n' < /proc/$(pgrep -f 'hermes gateway run')/environ | grep REQUIRE_MEN
 | **自查发现** | ① `_paused.update` 判断写错(比整张表而非本会话)② 群聊模板注释含反引号导致**生产构建失败**(单测全绿挡不住) |
 | **验证** | `tsc` ✅ / 全量 `npm test` **1360 SUCCESS** / `npm run build` ✅(571.43 kB,预算警告为存量,已比对基线) |
 | **留痕** | `docs/00-tracking/2026-11-09-修改留痕-LoopGuard与会话去重.md`、`2026-11-09-踩坑-乐观消息回读认领.md` |
+
+---
+
+## 2026-11-10 · 图文同发 / 成果归属 / 计时读数 三项修复
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户截图反馈三点:① 图文一起发送却「被打断」② 自己上传的图片同时出现在右栏「文件」和「成果」③ 回复计时 5s/18s/30s 跳变、未回复动画残留 |
+| **用户裁决** | ① 图文问题**必须让容器等文字一起处理**(不是只合并显示)② 存量成果链接**全部清空重来** |
+| **实测取证** | 用 `.claude/dev-login.json` 调 provision → 拉真实房间 `!miyB1Dx…` 时间线:`17:03:28 m.image(ME)` → `17:03:29 m.text(ME)` → **`17:03:30` 容器回「明白,打断当前进度」**,证实容器**逐事件**触发。云函数 `oR5O67i1Cm` 返回证实 `deliverableUrls` 里确实只有用户自己的图片 |
+| **①图文根因** | 容器收到媒体事件**立即启动一轮**,不等文字;App 侧 `turn-aggregator` 又对每条 `sender==='me'` 开新回合,叠加 `uploadPending` 本地插入的 `local-file-*` 消息 → 两个用户气泡 |
+| **①修复(App)** | `turn-aggregator.ts` 新增 `USER_MERGE_MS=30s`:带附件的 me 消息并入上一回合(`Turn.userFiles`);删除 `chat.service.ts` 的 `local-file-*` 本地消息;双端 `aiRows` 渲染 `userFiles`。**协议限制**:Matrix 一条事件只能一个 `mttype`+`url`,图文必然两事件 |
+| **①容器侧(待执行)** | 新增改造任务书 `docs/03-interface/容器侧改造-图文合并处理-20261110.md`:媒体事件合并窗口 2–3s(`FMODE_MEDIA_MERGE_MS`)、只打一次 👀、只跑一轮;另请容器确认 reaction/redaction 契约、`turnStartAt` 语义、是否流式、是否回显用户媒体 |
+| **②成果根因** | `chat.service.ts:1566` 用户上传成功后直接 `addDeliverableUrl`;`:2311` 实时回读未判 `sender==='agent'` 就写 |
+| **②修复** | 删除 `:1566`;`:2311` 加 `sender==='agent'` + `isUserUploadUrl()` 过滤(`s3-link.ts` 新增,判 `/user/<uid>/chat/`);四个写入点统一过滤;`deliverables.service.ts bySessionUrls()` 加兜底过滤 |
+| **②存量清理** | 新增 `scripts/clear-deliverable-urls.mjs`(默认 dry-run)。dry-run 列出 **8 条会话**(1 条真成果 + 7 条用户文件/测试残留),用户裁决全部清空 → `--apply` 成功 8/8,复核「没有需要清空的会话」。未新增云函数 op:复用既有 `upsert` 的 `deliverableUrls:[]` 语义 |
+| **③计时根因** | `assistant-turn.ts` 用 `updatedAt - startedAt`(最新事件 − 首条事件),只在容器发新事件时变 |
+| **③修复** | 新增 `startAt`(用户发问时刻)+ `updatedAt` 字段;新增 `elapsedSeconds(block, now)`:**进行中用每秒时钟现算**、结束定格;组件新增 `segTime()` / `turnSeconds()` 与 `waitLabels` 共用同一时钟;`seconds` 语义收窄为「容器真实处理耗时」 |
+| **验证** | `tsc`(app+spec)✅ / 全量 `npm test` **1438 SUCCESS** / `npm run build` ✅ / **端到端实测**:右栏「文件」含用户图、「成果」不含(`scripts/verify-deliverable-separation.mjs` PASS);计时逐秒递增 9→46 后定格 38s(`scripts/verify-elapsed-ticking.mjs` PASS);图文发送截图确认 App 侧归属正确 |
+| **评审(规则 11)** | 独立评审员子代理首轮结论「需修改后发布」:2 个 P0 + 6 个 P1 + 5 个 P2。**已全部修复**:P0-2 发现真实隐患(S3 上传的 `cloudUrl` 被 `void` 吞掉 → 用户附件失去云盘地址),改为 `_cloudUrls` 映射并在 `mapTimeline` 回读时补回;**P1-3 真实缺陷**(合并窗口用 `updatedAt` 判距,被 agent 回复推后 → 等回答时再发的图被吞进上一轮),改用 `lastUserAt`;另删除两个死字段(`AssistantTurn.startAt`、`Message.files`)、统一取整口径、补 3 组测试。报告:`docs/04-quality/review/2026-11-10-图文合并与成果去重-评审.md` |
+| **最终验证** | `tsc`(app+spec)✅ / 全量 `npm test` **1443 SUCCESS** / `npm run build` ✅ / 端到端复跑 ✅ |
+| **未验证 / 已知限制** | ① **容器合并窗口未上线**,「不被打断」这一条**尚未实现**,需容器按任务书改造后复测(本次实测容器仍回「我把正在进行的事挂起」)② 图文「一个气泡」因协议限制只能做到「文字在上、图紧随」,原生单事件不可行 |
+| **留痕** | 本条目;`docs/03-interface/容器侧改造-图文合并处理-20261110.md` |
+
+---
+
+## 2026-11-10(续)· 打断场景 UI 修复 + 群聊文件/成果分离 + 移动端对齐
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户:容器侧先不管,先修「异常打断时的 UI 显示异常」,并排查群聊的存储/文件/成果逻辑与移动端同步情况 |
+| **用户裁决** | ① UI 全部修(含按轮拆块)② 群聊桌面 + 移动端都做 |
+| **容器侧结论** | 与 `lj-test` 直连查清:图片事件**不进**文本缓冲(`_handle_media_message` 直接 `handle_message`),文本进 0.6s 缓冲。三组对照实验:纯文字连发=1 轮、图+文字=2 轮、文字+图=2 轮。规范见 `docs/03-interface/容器入站机制与图文合并规范-20261110.md`。**改造需 root 改 `/opt/hermes`,暂缓**;已要求容器**回滚**试做的插件(并恢复被误删的 `plugin-active-reporter`) |
+| **UI 根因** | ① 回合内多轮被当成一轮 → 读秒倒退 ② 第二轮占位挂到第一轮正文下 ③ 拆轮后每块都用回合级 `merged` → **同一段正文渲染两遍**(端到端实测发现)④ 移动端贴底无「用户上翻」保护 → 两轮交替到达反复拽回 ⑤ 折叠 override 无清理(`retain()` 从未被调用) |
+| **UI 修复** | `turn-aggregator.ts` 新增 `ROUND_GAP_MS=20s` 与 `splitRounds()`、`Turn.rounds`/`userAskTimes`;`assistant-turn.ts` 按轮出块、每轮独立锚点;组件改 `b.merged`(不再用回合级);`task-pulse` 未动;移动端补 `userScrolledAway`+节流+`stickToBottom`;移动端 `now` 改 `clockTick`(避免整页每秒重算);接上 `collapse.retain()` |
+| **🔴 新发现并修复** | ① **同批输入不该在容器已回复后再并回合**(否则图片回合被掏空→「未响应」)② 新增 `Message.replyTo`(解析 `m.in_reply_to`)→ 回复按**实际回复的那条**归属,不再靠顺序猜。这两条是端到端实测才暴露的,单测原本锁不住 |
+| **群聊修复** | 桌面:`railFiles` 加 `sender==='me'`;`railDeliverables` 从「按成员容器拉全量」改为「本群会话 `deliverableUrls` + 群内 agent 消息」,并显示产出成员。存储:`GroupStoreService.appendReply` 写入群会话成果链接(只认 agent + `isUserUploadUrl` 过滤)。移动:新增「文件/成果」页签(`files` 按发送方、`results` 按群会话 urls + agent 文件),复用同一服务层 |
+| **移动端结论** | 单聊的文件/成果分离**已自动生效**(共享服务层),无需额外改;群聊补齐了缺失的视图 |
+| **验证** | `tsc`(app+spec)✅ / 全量 `npm test` **1461 SUCCESS** / `npm run build` ✅ / 端到端 `scripts/verify-interrupt-ui.mjs`:**读数倒退 0 次、重复占位 0 次、页面错误 0**,截图确认正文单次渲染、37s 定格、「文件 4 / 成果 1」 |
+| **未验证 / 遗留** | ① 容器合并窗口未上线,「图文不被打断」仍未实现(用户裁决暂缓)② 群聊附件未上云盘(`sendFileToAgent` 不调 S3),刷新后仅 Matrix 地址 ③ 移动群聊新页签未做真机截图验证 |
+
+---
+
+## 2026-11-10(续二)· 顶栏「状态未同步」误报修复
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户反馈 PC 端顶栏显示「状态未同步 · 上次上报 8 分钟前」,而全站同步判据是一小时 + 0~15 分钟抖动 |
+| **实测取证** | 调 `xlosi3m46q` 查真实心跳:`lj-test` age **9.7 分钟**、`lj` 0.1 分钟。9.7 分钟远低于 80 分钟阈值,顶栏却报「未同步」→ 说明顶栏走的是**另一套阈值** |
+| **根因** | `task-pulse.service.ts` 的 `FRESH_MS = 90_000`(**90 秒**)与全站 `HEARTBEAT_STALE_SEC = 80 分钟` 并存。容器 idle 超过 90 秒,顶栏就报「状态未同步」,而侧栏/通讯录/新建弹窗判在线 —— 这就是用户说的「外层和内层不一致」 |
+| **修复** | ① `FRESH_MS` 改为复用 `HEARTBEAT_STALE_SEC * 1000`(单一阈值来源,不再各写一套)② `agentLayers` 增「忙碌豁免」:`status=running` 且有真实任务描述时,错过一轮上报**不**降级为「状态未同步」(内层明明知道它在干活);上报器自指不享受豁免 ③ 同一函数内 `alive` 字段统一返回生效值,避免「已豁免但仍返回 stale」的自相矛盾 |
+| **语义对齐** | 对话结束后不再逼容器"证明还活着":未超 80 分钟按容器自报展示(空闲 / 正在执行 / 已完成),超过才「已离线」。外层阈值作唯一兜底 |
+| **验证** | `tsc` ✅ / 全量 `npm test` **1465 SUCCESS** / `npm run build` ✅ / 端到端 `scripts/verify-status-consistency.mjs`:顶栏显示「空闲」,无「状态未同步」文案,无页面错误(截图 `test-results/status-consistency/header.png`) |
+| **未验证** | 仅用 `idle` 状态容器实测;`running` 忙碌豁免与 80 分钟后「已离线」由单测覆盖,未等真实时间跨阈值验证 |
+
+---
+
+## 2026-11-10(续三)· 缩放跨端提示 + 组织归属准入 + 管理员成员管理
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户反馈:缩放到另一端时提示逻辑异常、无自动判断;新账号缺「组织归属」前置判断;需管理员用户管理页 |
+| **用户裁决** | ① 缩放:自动判断 + 弹提示 + 点确认才切 + 防抖,两端都要提示 ② 组织判定沿用现有代码(`me.companyId`)③ 管理员校验走**服务端** ④ 等待页与管理页**双端都做**,移动端须独立布局 |
+| **缩放根因** | 全仓**从未有过** resize 自动跳端;两处外壳各自裸 `resize` 无防抖、判定各写一份(`app.routes.ts` 另有第三份 `detectMode`),且手动切端后偏好锁定、恢复入口只在浮层里,形成「越缩越不提示」的死锁观感 |
+| **缩放修复** | `device.service.ts` 导出 `detectUiMode()` / `isNarrowViewport()`;新增 `watchViewport()`(`matchMedia` 跨断点触发 + **250ms 防抖**)、`suggestedMode`、`dismissSuggestion()`;两端外壳改为统一调用并补 `ngOnDestroy` 清理;`app.routes.ts` 的重复判定改为复用;新增「稍后再说」按钮 |
+| **组织准入** | 新增 `core/org.guard.ts`(复用 `acl.ts` 的 `resolveScope`,`personal` → `/pending`);桌面/移动路由挂 `[authGuard, orgGuard]`,`/pending` 只挂 `authGuard`(防死循环);两个登录页 `afterLogin()` 前置判断 |
+| **等待页** | 桌面 `pages/pending/`(居中卡片)+ 移动 `mobile/pages/pending/`(顶栏 + 吸底主操作 + 单列信息);显示可复制的用户 ID、昵称、打码手机号;**15 秒轮询**,被拉进组织后自动进入 |
+| **用户管理** | 新云函数 `cloud/functions/fmodeagent-users.js`(list/add/update/remove/get);**每个 op 先服务端鉴权**(超管 or `<companyId>_Admin`),作用域强制锁在管理员自己的组织,改密码/软删走 masterKey;前端 `FmodeApi` +4 方法、`ParseApi` 实现、`MockApi` 演示实现;服务层 `org-users.service.ts`(服务端分页) |
+| **双端页面** | 桌面 `desktop/pages/admin/users/`(搜索 + 表格 + 分页 + 居中模态);移动 `mobile/pages/admin/users/`(**独立移动布局**:卡片行 + 吸顶搜索 + 底部抽屉 + 加载更多);入口挂 `session-tree` 导航(仅管理员可见)与移动 `admin` 页 |
+| **鉴权边界** | `adminGuard` 从固定回 `/mobile/mine` 改为按端回退;前端只负责入口可见性,真正鉴权在云函数 |
+| **验证** | `tsc` ✅ / 全量 `npm test` **1480 SUCCESS** / `npm run build` ✅ / 端到端 `verify-resize-and-org.mjs` 7 项全过(拖窄弹提示、**不静默跳端**、点切进入移动端、管理员可进、导航有入口)/ `verify-mobile-org.mjs` 7 项全过(移动端非表格、吸顶搜索、底部抽屉、**无组织被拦 `/pending`**、直接访问业务页仍被挡) |
+| **未验证 / 阻塞** | ① **云函数未部署**:`FN.orgUsers` 仍是占位 id,页面显示「云函数调用失败(HTTP 500)」错误态 ② 部署需 **Master-Key**(本机没有)③ 全链路「管理员 add → 对方 15 秒内自动进入」未实测(依赖 ①)|
+
+---
+
+## 2026-11-10(续四)· 云函数部署 + 个人试用组织判定 + 成员管理页布局
+
+| 项 | 内容 |
+|---|---|
+| **触发** | ① 用户提供 Master-Key,要求部署并跑通 ② 成员管理页布局不合规(外层滚动、分页被顶出)③ 需判断「新登录用户是否被自动绑定组织」④ 要求把 liujun 的组织归属置空以验证 |
+| **云函数部署** | 新建 `fmodeagent-users` → objectId **`mgR4EA2n5a`**,已回填 `parse-api.ts` 的 `FN.orgUsers` |
+| **🔴 关键发现(组织判定)** | 实测:新用户注册时系统**自动创建一个以他命名的组织**(liujun 的组织就叫「liujun」,带 `enterpriseTrialClaimedAt` + 30 天试用)。所以 **`companyId` 非空 ≠ 已加入组织**,原判定会让等待页形同虚设。抽样 100 个组织:86 个带试用标记(全是个人组织),康师傅/路觅教育等正式组织均无 |
+| **新判定口径** | 服务端 `opMe`:① 组织带 `enterpriseTrialClaimedAt` **且** ② 该组织只有他一人 → 视为**无组织**。加第 ② 条兜住「试用组织后来进了别人」的情况;查询异常时按「有组织」处理,避免误锁全站 |
+| **前端改造** | `org.guard`、两个登录页、两个等待页全部改为调 `fetchOrgMembership()`(服务端判定),不再看 `me.companyId` |
+| **安全加固** | ① 平台注入的 `request.user` 是**代理对象**,`get('company')` 拿不到字段(明明有组织却判成「未归属」)→ 增加 `_reloadUser()` 按 objectId 重查 ② **堵住超管越权**:原实现接受前端传的 `companyId`,等于开了「查任意租户」的入口,改为只操作自己所属组织 ③ 验证:伪造 `companyId` 被忽略、无 token 返回 401、搜其他组织成员返回空 |
+| **布局修复** | 用户要求「外层不滚动,只有列表内部滚动」。`app-page` 新增 `contentScroll` 输入;`.page` 宿主补 `height:100%`(原来缺失导致整页被撑到 1611px、分页条被顶出视口);表格改 `table-wrap + tbody` 内部滚动、表头吸顶、分页条固定底部;搜索移入 `pageToolbar` 固定区并标「仅本组织」 |
+| **修掉的 bug** | ① `ui-input` 不支持 `ngModel`(无 ControlValueAccessor),导致编辑弹窗打开即崩 → 改原生 input + 组件内样式 ② CSS 注释里的反引号截断模板字符串导致构建失败 |
+| **数据操作** | liujun(`PNFaPMsbHS` / 18182795512)的 `_User.company` 已置空(只清归属,不动组织本身 —— 用户裁决) |
+| **验证** | 全量 `npm test` **1480 SUCCESS** / `npm run build` ✅ / `verify-admin-users-e2e.mjs` 6 项全过(真实 1624 人、搜索过滤、分页、编辑回填、删除二次确认)/ 接口级:`hasOrg` 判定、越权防护、跨组织搜索隔离均实测通过 |
+| **未验证** | 「管理员 add → 对方 15 秒内自动进入」全链路未实测(需 liujun 本人登录态);`add` 操作未做真实写入验证(避免污染数据)|
+
+---
+
+## 2026-11-10(续五)· 成员管理页遮挡修复 + 组织作用域标识
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户反馈:① 顶部与下半部分有遮挡感 ② 怀疑能查到其他组织的人 |
+| **组织隔离核查** | 用 Master-Key 逐条核对接口返回的 50 条数据:**全部属于 `E4KpGvTEto`,越界 0 条**。接口 `total=1625` 与本组织实际人数一致。结论:**没有串租户**,用户看到的是该组织本身有 1625 人。另核 `liujun` 归属已在 14:13 置空、14:30 又被其登录流程自动写回(说明登录会自动补组织,已在续四记录该机制) |
+| **遮挡根因** | ① `content-scroll` 模式下 `.scroll-zone` 的 `padding-bottom` 被重置为 0 → 表格贴住下边缘 ② 搜索栏与表头之间无间距 → 视觉上像被盖住 ③ 宿主 `display:flex` 但 `.table-wrap` 用 `flex:1` 时高度参照不稳,最后一行被分页条切掉 |
+| **修复** | `.scroll-zone` 补上下留白;`.toolbar` 补 `padding-bottom`;宿主与 `.table-wrap` 改为 `height:100%` 链式约束;`.tbody` 增加 `padding-bottom` + `scroll-padding-bottom`,滚到底时最后一行不再被压 |
+| **作用域标识** | 搜索栏右侧改为「仅显示「飞码AI」的成员」+ 悬浮说明,明确作用域**不可切换**(服务端强制,前端无任何组织切换入口) |
+| **验证** | 实测:`pageScrolls=false`、`zoneScrolls=false`、`tbodyScrolls=true`(外层不滚、只有列表内滚);滚到底 `overlap=false`(最后一行 bottom=801 < 分页条 top=810);全链路 6 项通过;全量 `npm test` **1480 SUCCESS** |
+
+---
+
+## 2026-11-10(续六)· 端切换双向修复 + 文档完善 + 提交
+
+| 项 | 内容 |
+|---|---|
+| **触发** | 用户实测:桌面缩窄 → 切到移动端 ✅,但**放大回桌面尺寸后不再提示切回**,界面一直是移动端 |
+| **根因** | ① `DeviceService.evaluate()` 开头 `if (isForced()) return` —— 用户点过一次切换,偏好被写成固定值,**从此再也不提示** ② `watchViewport()` 用 `mq` 做幂等判断,切换端后新外壳的回调注册被跳过,回调仍指向已销毁的旧外壳 ③ `stopWatching()` 的 `removeEventListener` 传的是空函数,实际没移除 ④ `force()` 后没重算 `suggestedMode`,切完提示还挂着 |
+| **修复** | 去掉 `isForced` 短路(手动切换只代表「这次选哪端」,不代表「别再问」);`watchViewport` 改为**每次替换回调**;`teardown()` 真正移除监听;`force()`/`resetToAuto()` 后立即 `evaluate()`;两端提示按钮改为并排 + `nowrap` |
+| **验证** | `scripts/verify-switch-both-ways.mjs` 5 项全过:缩窄提示 → 切移动端 → **放大提示切回** → 切回桌面 → 不再提示。全量 `npm test` **1481 SUCCESS** |
+| **文档** | 新增 `docs/00-tracking/2026-11-10-完整修改记录.md`(当天全部改动总说明 + 拉取后如何验证);同步更新 `当前业务逻辑总览.md` 的端分流与组织准入章节 |
+| **提交** | 按用户要求提交并 push,保证其他机器拉取后功能一致 |

+ 30 - 1
docs/02-design/architecture/当前业务逻辑总览.md

@@ -19,7 +19,11 @@
 浏览器
   │
   ├─ 端分流:入口页一次性判定(UA + pointer:coarse + 宽度)
-  │   → /desktop/** 或 /mobile/**,之后不再变
+  │   → /desktop/** 或 /mobile/**,之后不再**静默**跳变
+  │   └─ 2026-11-10:跨 768px 断点自动判断并**弹提示**,点确认才切(双向)
+  │
+  ├─ 组织准入:无组织账号 → /pending 等待邀请(2026-11-10)
+  │   └─ 判定在服务端:个人试用组织(自动创建、只有自己一人)也视为无组织
   │
   ├─ 前端(Angular 20 SPA,无自建后端)
   │    core/api/ 抽象层 → parse-api(真实) / mock-api(演示)
@@ -100,6 +104,19 @@ turnsFromDm()        转成视图模型(单聊固定 1 块)
 app-assistant-turn   渲染成一张卡(过程折叠 + 正文合并 + 文件宫格)
 ```
 
+**🔴 图文同发(2026-11-10)**
+
+Matrix 一条消息只能带**一个** `msgtype` + 一个 `url`,所以「图 + 文字」
+必然是**两个事件**。App 侧的规则:
+
+- 带附件的 me 消息,若距回合最后活动 ≤ `USER_MERGE_MS`(30s)→ 并入上一回合(`Turn.userFiles`)
+- 渲染时文字气泡在上、附件紧随,视觉上是一组;不再各开一个回合
+- 容器侧需配合「媒体合并窗口」才不会对图片抢先回话 ——
+  任务书 `docs/03-interface/容器侧改造-图文合并处理-20261110.md`(**未上线**)
+
+**计时读数**:`startAt` = 用户发问时刻;进行中由 `elapsedSeconds()` 用每秒时钟现算
+(1→2→3… 逐秒),结束定格。`seconds` 另存「首条 → 末条 agent 消息」的容器真实耗时。
+
 ### 2.5 关键函数速查
 
 | 函数 | 文件 | 职责 |
@@ -246,6 +263,18 @@ FmodeChatThread.deliverableUrls: string[]
 
 > 设计原则:**宁缺勿错**。宁可少显示,也不把别的会话的成果串进来。
 
+**🔴 来源边界(2026-11-10 修复)**:`deliverableUrls` 只收**容器产出**的链接。
+
+| 页签 | 数据来源 | 判据 |
+|---|---|---|
+| 文件 | 消息流里我发的附件 | `sender === 'me'` |
+| 成果 | `deliverableUrls` + 消息流里 agent 的附件 | `sender === 'agent'` |
+
+旧版本把用户上传的链接也写了进去,导致自己发的图同时出现在两个页签。
+现在写入端四个位置都加了 `isUserUploadUrl()`(路径 `/user/<uid>/chat/` 判为用户文件),
+`bySessionUrls()` 另有兜底过滤。存量脏数据已用
+`scripts/clear-deliverable-urls.mjs` 清空重建。
+
 ---
 
 ## 六、云函数清单

+ 116 - 0
docs/03-interface/容器侧改造-图文合并处理-20261110.md

@@ -0,0 +1,116 @@
+# 容器侧改造:图文合并处理(2026-11-10)
+
+> **给容器操作者/维护者的执行清单。**
+> 本文件是「交给容器」的完整任务书,可直接下发。
+> 测试对象:`lj-test`(agentId `agent-node-lj-test`),**不是** `app-lj-test-v1`。
+
+---
+
+## 一、问题现象(App 端用户反馈)
+
+用户在 App 里「选一张图 + 打一句话」一起发送,期望容器把它当成**一个请求**处理。
+实际看到的是:
+
+- App 界面上出现两条用户消息(图一条、字一条)
+- 容器的回复是「**明白,打断当前进度,先接你的**」——
+  说明它把图片单独当成了一轮请求,随后又接了文字
+
+用户原话:
+
+> 我是把图片加文字一起发送的,但会显示一个中断。图片和文字是一起发送的,
+> 但实际情况下,它是分两段发送的,效果不对。我们需要的是文字和图片一起发送的效果。
+
+---
+
+## 二、实测证据(2026-11-10 17:03,房间 `!miyB1DxRE5E9Aj5d7lE1wcgPWXWx1Mkaw4P-yiQIYdU`)
+
+| 时间 | 事件 | 发送方 | 内容 |
+|---|---|---|---|
+| 17:03:28 | `m.image` | 用户 | `image.png` |
+| 17:03:28 | `m.reaction` | 容器 | 对图片打 👀 |
+| 17:03:29 | `m.text` | 用户 | 「使用HTML根据我这个图片做一个页面出来。」 |
+| **17:03:30** | `m.text` | **容器** | **「明白,打断当前进度,先接你的。」** |
+| 17:03:48 | `m.text` | 容器 | 正文(看图结果) |
+
+**结论**:容器收到 `m.image` **立即启动了一轮**,1 秒后用户文字才到,
+于是被当成「新的打断」。根因在容器侧的**逐事件触发**,不在 App。
+
+### 为什么 App 不能自己修
+
+Matrix 协议中 `m.room.message` 的 content 只允许**一个 `msgtype` + 一个 `url`**,
+一条事件无法同时携带图片和文字。所以「图 + 字」在协议上必然是两个事件 ——
+只有容器侧做合并窗口,才能把它们当成一次输入。
+
+App 侧本次已经做的(不改协议、与本任务无关):把这两个事件在界面上合并为同一回合显示。
+
+---
+
+## 三、改造任务
+
+### 任务 1 · 媒体事件合并窗口(核心)
+
+收到 `m.image` / `m.file` 等媒体事件后,**不立即启动一轮**,而是:
+
+1. 起一个短暂合并窗口(建议 **2–3 秒**,可配置:环境变量 `FMODE_MEDIA_MERGE_MS`)
+2. 窗口内到达的 `m.text` 与本批媒体**合并成同一条用户输入**一起处理
+3. 窗口内**没有**文字到达 → 按现状单独处理该媒体(**行为不回退**)
+
+约束:
+
+- 只合并**同一 thread / 同一发送者**、且窗口内的消息;跨 thread 不合并
+- 合并后**只打一次 👀**、只跑一轮、只回一次「收到」
+- 多条媒体连发的,全部并入同一批
+
+### 任务 2 · 处理期间的行为确认(如实回报,不用改代码)
+
+1. 收到 `m.image` 后如果后续文字到达,是否会**取消/重启**当前轮?确认合并后不再出现「打断」话术。
+2. 一轮处理结束时,是否**总是**发出 `m.room.redaction{reason: "processing complete"}`
+   撤销 👀?(实测 3 轮都有,App 打算据此判定「本轮结束」)
+
+### 任务 3 · 回报实测时间线
+
+改造后请用同样方式(发一张图 + 一句话)重跑,把房间事件按时间列出来。
+
+---
+
+## 四、待确认(请一并答复)
+
+以下几项 App 端正在依赖或打算依赖,**需要容器确认口径**,不是猜测:
+
+| # | 问题 | 现状依据 |
+|---|---|---|
+| 1 | 每条用户消息都打 👀(开始)/ ✅(完成),是否是**稳定契约**? | 实测 3 轮均有 |
+| 2 | `m.room.redaction{reason:'processing complete'}` 是否**总是**在本轮结束时发出? | 实测 3 轮均有 |
+| 3 | 状态上报 `extra.currentSessions[].turnStartAt` 的语义(本轮开始的 epoch ms?更新时机?) | 平台上 `lj-test` 返回 `{sessionKey, status:'idle', turnStartAt: 1794301428879}` |
+| 4 | 是否存在**流式输出**能力(逐字/逐段推送)?当前观测是全量分段发送 | 实测:正文一段一段 arrive |
+| 5 | 容器是否会把**用户上传的** `m.image` 原样回显? | 实测未见回显;若会,App 会误判为用户图是容器成果 |
+
+---
+
+## 五、验收标准
+
+| # | 检查 | 通过标准 |
+|---|---|---|
+| 1 | 图文同发只跑一轮 | 房间时间线里 👀 只出现一次,无「打断」话术 |
+| 2 | 只回一次「收到」 | 一轮只出现一条确认性回复 |
+| 3 | 纯图片仍可用 | 只发图不配字,容器照常处理(窗口到期后触发) |
+| 4 | 纯文字不受影响 | 只发文字,行为与改造前一致 |
+| 5 | 跨 thread 不误并 | 不同 thread 的媒体不并到一起 |
+
+---
+
+## 六、完成后请回报
+
+```
+1. 合并窗口的实现位置(文件:行)与配置项名称/默认值
+2. 改造后「图 + 文字」的完整房间事件时间线
+3. 「只发图不配字」与「只发文字」两种情形的处理耗时(确认无回退)
+4. 第四节 5 个待确认问题的答复
+```
+
+---
+
+## 七、不在本次范围
+
+- 不改 App 侧的发送时序(附件先、文字后,由 `uploadPending` → `sendReal` 决定)
+- 不改成果上云链路(见《容器侧改造-文件与成果统一上云-20261109.md》)

+ 195 - 0
docs/03-interface/容器入站机制与图文合并规范-20261110.md

@@ -0,0 +1,195 @@
+# 容器入站消息处理机制与图文合并规范(2026-11-10)
+
+| 字段 | 值 |
+|---|---|
+| 适用容器 | `lj-test`(agentId `agent-node-lj-test`) |
+| 事实来源 | **与容器直连实测 + 容器回读的源码原文**(非推测) |
+| 相关任务书 | `容器侧改造-图文合并处理-20261110.md` |
+| 状态 | 机制已查清;改造**待下发** |
+
+---
+
+## 一、底层原理(源码事实)
+
+### 1.1 入站链路
+
+```
+Matrix /sync
+  └─ adapter.py _dispatch_sync(1872 行)        每个事件一个 asyncio 任务,**不串行**
+       ├─ _handle_text_message(2116 行)
+       │     └─ _build_inbound_event → **进入文本缓冲**(2126-2127)→ handle_message
+       └─ _handle_media_message(2131 行)
+             ├─ _resolve_message_context(2162)      @提及/房间白名单 gate
+             ├─ _download_and_cache_media(2169)     图片下载到本地路径
+             └─ _build_inbound_event → **直接 handle_message**(2181)← 🔴 绕过缓冲
+```
+
+### 1.2 关键源码
+
+**adapter.py:2126-2129(文本:进缓冲)**
+
+```python
+if msg_event.message_type == MessageType.TEXT and self._text_batch_delay_seconds > 0:
+    self._enqueue_text_event(msg_event)
+else:
+    await self.handle_message(msg_event)
+```
+
+**adapter.py:2180-2181(媒体:直接派发)**
+
+```python
+if msg_event is not None:
+    await self.handle_message(msg_event)
+```
+
+**base.py:2349-2369(缓冲器本身)**
+
+```python
+def _text_batch_key(self, event): return self._event_session_key(event)   # 按会话 key
+
+def _enqueue_text_event(self, event):
+    key = self._text_batch_key(event)
+    existing = self._pending_text_batches.get(key)
+    if existing is None:
+        existing = self._pending_text_batches[key] = event
+    else:
+        if event.text:
+            existing.text = _append_text(existing.text, event.text)
+        if event.media_urls:                          # 🔴 本来就支持图文合并
+            existing.media_urls.extend(event.media_urls)
+            existing.media_types.extend(event.media_types)
+    prior_task = self._pending_text_batch_tasks.get(key)
+    if prior_task and not prior_task.done():
+        prior_task.cancel()                           # 每次新事件重置静默计时
+    self._pending_text_batch_tasks[key] = asyncio.create_task(self._flush_text_batch(key))
+```
+
+**adapter.py:886(静默窗口配置)**
+
+```python
+self._text_batch_delay_seconds = float(os.getenv("HERMES_MATRIX_TEXT_BATCH_DELAY_SECONDS", "0.6"))
+```
+
+> 实测三处(env / s6 / .env)**均未设置**,走默认 **0.6 秒**。
+
+### 1.3 结论:缺的不是「合并逻辑」,而是「接线」
+
+| 能力 | 现状 |
+|---|---|
+| 按会话聚合的缓冲队列 | ✅ 已有(`_pending_text_batches`) |
+| 图文合并(text 追加 + media_urls extend) | ✅ 已有 |
+| 新事件重置静默计时(去抖) | ✅ 已有 |
+| 竞态防护(cancel/identity check/shield) | ✅ 已有 |
+| **媒体事件进入该缓冲** | ❌ **缺失** ← 唯一缺口 |
+
+matrix adapter **没有覆盖** `_text_batch_key` / `_enqueue_text_event` / `_flush_text_batch` / `_pop_text_batch` / `_dispatch_text_batch`,全部沿用 base 默认实现(容器 grep 实测确认)。
+
+---
+
+## 二、实测证据(2026-11-10 直连实验)
+
+所有实验间隔 300ms 连发,观察容器打出几次 ✅(= 跑了几轮):
+
+| 实验 | 发送序列 | 结果 | 结论 |
+|---|---|---|---|
+| **A** | 文字 → 300ms → 文字 | **1 轮** | 文本缓冲生效,两句被合成一次输入 |
+| **B** | 图片 → 300ms → 文字 | **2 轮** | 图片直接派发;文字进缓冲单独成轮 |
+| **C** | 文字 → 300ms → 图片 | **2 轮** | 文字在缓冲里等;图片直接派发插队 |
+
+实验 C 的时间线(最坏情况):
+
+```
+11:26:15  ME   m.text   「只回一句:收到C。」
+11:26:16  ME   m.image  exp.png
+11:26:16  AGENT「好,临时改道,先办你说的。」     ← 图片插队,把文字那轮打断
+11:26:33  AGENT「收到C。」                        ← 文字那轮
+11:27:12  AGENT「收到图片,但这条消息只有图、没有文字。」  ← 图片那轮
+```
+
+→ **文字和图从来没有见过面**,各自跑了一轮。
+
+---
+
+## 三、规范(要求容器遵守)
+
+### R1 · 媒体事件必须进入同一套文本缓冲
+
+`_handle_media_message` 末尾不得直接 `await self.handle_message(msg_event)`,
+必须与文本走同一分支:
+
+```python
+if msg_event is not None:
+    if self._text_batch_delay_seconds > 0:
+        self._enqueue_text_event(msg_event)
+    else:
+        await self.handle_message(msg_event)
+```
+
+**理由**:缓冲器已内置「text 追加 + media_urls 合并」,媒体进缓冲即刻获得图文合并能力,无需新写逻辑。
+
+### R2 · 图文必须落在同一个 batch key
+
+`_text_batch_key` 默认取 `_event_session_key(event)`。App 侧发图文时会带同一个
+`m.relates_to.event_id`(同一 thread 根),所以天然同 key。**不要**在 media 分支里
+改成别的 key。
+
+### R3 · 静默窗口要覆盖「图文之间的真实间隔」
+
+App 发送时序是**附件先、文字后**(`uploadPending` → `sendReal`),
+图片上传耗时 1–5 秒,慢网更久。
+
+| 配置项 | 建议值 | 说明 |
+|---|---|---|
+| `HERMES_MATRIX_TEXT_BATCH_DELAY_SECONDS` | **2.5** | 默认 0.6 太短,覆盖不了图文间隔 |
+| `HERMES_MATRIX_TEXT_BATCH_SPLIT_DELAY_SECONDS` | 2.0(不动) | 长消息分段用的,与本次无关 |
+
+> 代价:纯文字消息也会晚 2.5 秒才开始处理。用户体验上等价于「输入停顿检测」,
+> 可接受;若不可接受,可只在**本批含媒体**时用长窗口(需额外判断)。
+
+### R4 · 合并后只跑一轮、只打一次 👀
+
+合并后的 batch 只能派发一次:一个 👀、一轮处理、一个 ✅、一次
+`redaction{reason:"processing complete"}`。多轮 = 违反本规范。
+
+### R5 · 群聊 @提及的隐患(需评审)
+
+`_resolve_message_context` 在 `not is_dm` 且 `_require_mention` 时,
+**未 @ 的消息直接丢弃**(adapter.py:2028-2033),且该 gate 在 **2162 行、图片下载之前**。
+
+若移动端「发图 + 打字」只 @ 了机器人一次(文字那条带 mention、图片那条不带),
+**图片会被静默丢弃**,合并逻辑再好也拼不出图。
+
+- DM 不受影响(2020 行 `if not is_dm:` 直接跳过)
+- 群聊需二选一:① App 端保证**每条**消息都带 `m.mentions`(当前已如此)
+  ② 容器侧在合并窗口内放宽 gate
+
+### R6 · 静默窗口不得吞掉纯图片
+
+用户只发图不配字时,静默窗口到期必须正常派发该媒体,**行为不得回退**。
+
+---
+
+## 四、验收标准
+
+| # | 检查 | 通过标准 |
+|---|---|---|
+| 1 | 图 → 300ms → 文字 | **1 轮**(当前 2 轮) |
+| 2 | 文字 → 300ms → 文字 | 1 轮(不许回退) |
+| 3 | 文字 → 2s → 图 | 1 轮(在窗口内) |
+| 4 | 文字 → 10s → 图 | 2 轮(超出窗口,本来就是两次输入) |
+| 5 | 只发图 | 窗口到期后正常处理,1 轮 |
+| 6 | 只发文字 | 与改造前一致 |
+| 7 | 合并轮 | 只有 1 个 👀、1 个 ✅ |
+
+---
+
+## 五、App 侧配合(本仓库)
+
+| 项 | 现状 | 说明 |
+|---|---|---|
+| 发送时序 | 附件先、文字后(`chat.service.ts` `uploadPending` → `sendReal`) | 与缓冲区判据一致,不用改 |
+| thread 一致 | 两条都带同一 `m.relates_to.event_id` | 保证同 batch key,不用改 |
+| `m.mentions` | 两条都带 | 群聊 R5 的前提,不用改 |
+| 回合合并显示 | `turn-aggregator.ts` `USER_MERGE_MS = 30s` | 容器合并后这层仍有效,作为显示兜底 |
+
+> App 侧的回合合并只是**显示层兜底**;真正的「不被打断」必须靠容器侧 R1+R3。

+ 65 - 0
docs/04-quality/review/2026-11-10-图文合并与成果去重-评审.md

@@ -0,0 +1,65 @@
+# 评审报告:图文合并 / 逐秒读数 / 成果去重(2026-11-10)
+
+| 字段 | 值 |
+|---|---|
+| 评审对象 | 工作区未提交改动(相对 HEAD `cc58e96`),19 个文件,+480 / -64 |
+| 评审人 | 独立评审员子代理(不携带开发过程上下文) |
+| 评审方式 | 只读代码 + 实跑 `npx ng test --watch=false --browsers=ChromeHeadless` |
+| 评审日期 | 2026-11-10 |
+| 首轮结论 | **需修改后发布**(2 个 P0、6 个 P1、5 个 P2) |
+| 复审结论 | **已修复闭环**,全量 1443 条通过 |
+
+---
+
+## 一、首轮结论摘录
+
+> 不阻塞,但建议先修 P0-1(注释与代码矛盾)、P0-2(失效变量 + 自相矛盾注释)
+> 和 P1-1(纯附件发送从回合视图里消失)。
+
+评审同时认可以下设计:
+- `segTime` / `turnSeconds` 与 `segments` 拆开,避免每秒重建气泡对象
+- 时钟只在 `live` 时启动,`effect` 带 cleanup,无定时器泄漏
+- `isUserUploadUrl` 写入端 + 读取端双重防线
+- 合并分支用 `continue` 跳过 `close()`,不破坏 `startedAt` / `blocks` 语义
+- 双端 `aiRows` 逻辑与顺序一致
+
+---
+
+## 二、问题与修复闭环
+
+| 级别 | 编号 | 问题 | 修复 | 状态 |
+|---|---|---|---|---|
+| P0-1 | — | `TurnBlockView.seconds` 注释写「毫秒」,实际是秒;`AssistantTurn.seconds` 说明与显示口径不符 | 统一注释为「容器真实处理耗时(秒),UI 读数走 `elapsedSeconds()`」 | ✅ |
+| P0-2 | — | `cloudUrl` 只赋值后用 `void` 吞掉,注释却称「保留在 `FileCard.cloudUrl` 上」—— 用户附件失去云盘地址 | 新增 `_cloudUrls`(`名\|大小` → 云盘地址);`mapTimeline` 回读 `m.image` 时补回 `cloudUrl`;删除 `void cloudUrl` | ✅ |
+| P1-1 | — | 只含 `userFiles` 的回合可能不出卡 → 静默丢图 | 双端出卡条件补 `userFiles?.length > 0`,并补测试 | ✅ |
+| P1-2 | — | `AssistantTurn.startAt` 无消费者,reduce 恒等且注释误导 | 删除该字段 | ✅ |
+| P1-3 | **真实缺陷** | 合并窗口用 `prev.updatedAt` 判距,会被 agent 回复推后 → 用户在等回答时再发的图被吞进上一轮 | `Turn` 新增 `lastUserAt`(只在 `sender==='me'` 时更新),窗口改用它判 | ✅ |
+| P1-4 | — | `Message.files?: FileCard[]` 全仓无读写,注释误导 | 删除该字段 | ✅ |
+| P1-5 | — | `elapsedSeconds` 结束态注释三层嵌套、表述绕 | 压缩为一句,说明两者起点不同不可相加 | ✅ |
+| P1-6 | — | 组件层没有测试锁住「逐秒走表」与「结束定格」 | 新增 2 条组件测试(推进时钟断言 `1s → 3s`;结束后定格不随时钟变) | ✅ |
+| P2-1 | — | `isUserUploadUrl` 只判 path 不判 host | 增加 `CLOUD_HOSTS` 校验,与 `extractS3Links` 同口径 | ✅ |
+| P2-2 | — | `deliverables.service.ts` docBlock 仍写「用户上传也要显示」 | 改写为「用户上传一律丢弃,由消息流渲染」 | ✅ |
+| P2-3 | — | 「只有附件没有文字」测试只到 `sendFile`,未覆盖出卡链路 | 新增纯函数级测试覆盖 `aiRows` 出卡条件 | ✅ |
+| P2-4 | — | `uploadPending` 注释密度偏高 | 未改(属留痕要求,代码里保留一次快照) | 保留 |
+| P2-5 | — | 结束态 `Math.round` / 进行中 `Math.floor` 不一致,可能倒退 1 秒 | 统一为 `Math.floor` | ✅ |
+
+---
+
+## 三、修复后的回归验证
+
+| 项 | 结果 |
+|---|---|
+| `tsc --noEmit`(app + spec) | ✅ |
+| 全量 `npm test` | ✅ **1443 SUCCESS** |
+| `npm run build` | ✅ |
+| 端到端:成果归属分离 | ✅ PASS(用户图只在「文件」,容器产物 `index.html` 在「成果」) |
+| 端到端:计时逐秒 | ✅ PASS(9→46 逐秒递增,结束定格 38s) |
+
+---
+
+## 四、遗留(非本次评审范围)
+
+| # | 项 | 说明 |
+|---|---|---|
+| 1 | 容器图文合并窗口未上线 | 见 `docs/03-interface/容器侧改造-图文合并处理-20261110.md`,上线前「图文不被打断」未实现 |
+| 2 | P2-4 注释密度 | 有意保留,作为「为什么这样改」的现场记录 |

+ 4 - 1
package.json

@@ -18,7 +18,10 @@
     "deploy": "node scripts/deploy.mjs",
     "verify:cloud": "node scripts/verify-cloud-fn.mjs",
     "serve:dist": "node scripts/serve-dist.mjs",
-    "dev:logged-in": "node scripts/serve-dist.mjs --login"
+    "dev:logged-in": "node scripts/serve-dist.mjs --login",
+    "ops:clear-deliverables": "node scripts/clear-deliverable-urls.mjs",
+    "verify:deliverables": "node scripts/verify-deliverable-separation.mjs",
+    "verify:ticking": "node scripts/verify-elapsed-ticking.mjs"
   },
   "prettier": {
     "printWidth": 100,

+ 154 - 0
scripts/clear-deliverable-urls.mjs

@@ -0,0 +1,154 @@
+#!/usr/bin/env node
+/**
+ * 清空会话成果链接(一次性运维脚本,2026-11-10)
+ *
+ * ## 背景
+ *
+ * 旧版本把「用户自己上传的文件」也写进了会话记录的 `deliverableUrls`,
+ * 结果右栏「成果」里出现了用户自己的图片。用户裁决:**全部清空重来**。
+ *
+ * 根因已在写入端修复(只认 `sender === 'agent'` + 拒收 /user/…/chat/… 链接),
+ * 本脚本只负责把存量脏数据抹掉,之后容器产出的成果会重新积累。
+ *
+ * ## 做法
+ *
+ * 不新增云函数 op,直接复用既有 `upsert`:
+ * `opUpsert` 只在**显式传入** `deliverableUrls` 数组时才写入,
+ * 传 `[]` 即清空;其余字段不传就是不改(标题/归属/消息数都原样保留)。
+ *
+ * ## 安全
+ *
+ * · 默认 **dry-run**:只列出将被清空的会话,**不发任何写请求**
+ * · 必须显式传 `--apply` 才真正清空
+ * · 只清 `deliverableUrls`,**不删会话、不碰 Matrix 消息**
+ * · 凭据从 `.claude/dev-login.json` 读取,绝不打印
+ *
+ * ## 用法
+ *
+ * ```bash
+ * node scripts/clear-deliverable-urls.mjs           # 只看清单
+ * node scripts/clear-deliverable-urls.mjs --apply   # 真的清空
+ * ```
+ */
+import { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, '..');
+
+const FUNCTIONS_URL = 'https://server.fmode.cn/api/functions';
+const APP_ID = 'ncloudmaster';
+/** 会话目录云函数(与 `parse-api.ts` 的 `FN.chatThreads` 同源) */
+const FN_CHAT_THREADS = 'oR5O67i1Cm';
+/** 容器状态云函数:用来枚举账号下的容器,逐个拉会话目录 */
+const FN_AGENT_STATUS = 'xlosi3m46q';
+
+const APPLY = process.argv.includes('--apply');
+
+function loadToken() {
+  const p = resolve(root, '.claude/dev-login.json');
+  let raw;
+  try {
+    raw = JSON.parse(readFileSync(p, 'utf8'));
+  } catch {
+    console.error(`✗ 读不到登录凭据:${p}`);
+    process.exit(2);
+  }
+  const token = (raw.token || '').trim();
+  if (!token) {
+    console.error('✗ 凭据里没有 token');
+    process.exit(2);
+  }
+  return token;
+}
+
+async function callFn(token, id, params) {
+  const res = await fetch(FUNCTIONS_URL, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'X-Parse-Application-Id': APP_ID,
+      'X-Parse-Session-Token': token,
+    },
+    body: JSON.stringify({ id, params }),
+  });
+  const json = await res.json().catch(() => ({}));
+  if (json.code !== 200) throw new Error(`${id}: ${json.mess || JSON.stringify(json)}`);
+  return json;
+}
+
+async function main() {
+  const token = loadToken();
+
+  // ① 账号下有哪些容器
+  const agents = (await callFn(token, FN_AGENT_STATUS, { op: 'query' })).agents || [];
+  const agentIds = [...new Set(agents.map((a) => a.agentId).filter(Boolean))];
+  console.log(`容器 ${agentIds.length} 个:${agentIds.join(', ')}\n`);
+
+  // ② 逐容器拉会话目录,收集「有成果链接」的会话
+  const targets = [];
+  for (const agentId of agentIds) {
+    let threads = [];
+    try {
+      threads = (await callFn(token, FN_CHAT_THREADS, { op: 'list', agentId })).threads || [];
+    } catch (e) {
+      console.warn(`⚠ ${agentId} 拉取失败:${e.message}`);
+      continue;
+    }
+    for (const t of threads) {
+      const urls = t.deliverableUrls || [];
+      if (urls.length === 0) continue;
+      targets.push({ agentId, thread: t, urls });
+    }
+  }
+
+  if (targets.length === 0) {
+    console.log('✓ 没有需要清空的会话');
+    return;
+  }
+
+  // ③ 清单(用户要先过目)
+  console.log(`将清空 ${targets.length} 条会话的成果链接:\n`);
+  for (const { agentId, thread, urls } of targets) {
+    console.log(`· 「${thread.title || '(无标题)'}」  ${thread.threadRoot}`);
+    console.log(`    agentId=${agentId}  ${urls.length} 条:`);
+    for (const u of urls) console.log(`      ${u}`);
+  }
+  console.log('');
+
+  if (!APPLY) {
+    console.log('(dry-run)未做任何修改。确认无误后加 --apply 执行。');
+    return;
+  }
+
+  // ④ 逐条清空:只传 roomId + threadRoot + deliverableUrls:[]
+  let ok = 0;
+  let failed = 0;
+  for (const { thread } of targets) {
+    try {
+      await callFn(token, FN_CHAT_THREADS, {
+        op: 'upsert',
+        items: [
+          {
+            roomId: thread.roomId,
+            threadRoot: thread.threadRoot,
+            deliverableUrls: [],
+          },
+        ],
+      });
+      ok++;
+      console.log(`✓ 已清空 ${thread.threadRoot}`);
+    } catch (e) {
+      failed++;
+      console.warn(`✗ ${thread.threadRoot}:${e.message}`);
+    }
+  }
+  console.log(`\n完成:成功 ${ok},失败 ${failed}`);
+  if (failed > 0) process.exit(1);
+}
+
+main().catch((e) => {
+  console.error('✗', e.message);
+  process.exit(1);
+});

+ 104 - 0
scripts/verify-admin-users-e2e.mjs

@@ -0,0 +1,104 @@
+#!/usr/bin/env node
+/**
+ * 成员管理全链路验证(2026-11-10)
+ *
+ * 云函数已部署,这里验证真实数据流:
+ *   ① 列表加载出真实成员
+ *   ② 搜索能过滤
+ *   ③ 分页条工作
+ *   ④ 编辑弹窗能打开并回填
+ *   ⑤ 删除确认弹窗能打开(**不实际执行删除**)
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = resolve(here, '..', 'test-results', 'admin-users-e2e');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome' });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e)));
+
+const results = [];
+await page.goto(`${BASE}/desktop/admin/users`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(7000);
+await page.screenshot({ path: `${outDir}/1-list.png` });
+
+// ① 真实数据
+const rows = await page.locator('.tbody .tr').count();
+const isEmpty = await page.locator('ui-empty', { hasText: '加载失败' }).count();
+results.push(['列表加载出真实成员', rows > 0 && isEmpty === 0, `rows=${rows} error=${isEmpty}`]);
+
+const totalText = await page.locator('.total').innerText().catch(() => '');
+results.push(['显示总人数', /共\s*\d+\s*人/.test(totalText), totalText]);
+
+// ② 搜索
+const firstMobile = await page.locator('.tbody .tr .mono').first().innerText().catch(() => '');
+if (firstMobile && firstMobile !== '—') {
+  await page.locator('input.search').fill(firstMobile);
+  await page.locator('ui-button', { hasText: '搜索' }).first().click();
+  await page.waitForTimeout(2500);
+  const afterSearch = await page.locator('.tbody .tr').count();
+  await page.screenshot({ path: `${outDir}/2-search.png` });
+  results.push(['搜索能过滤', afterSearch >= 1 && afterSearch <= rows, `before=${rows} after=${afterSearch}`]);
+  // 还原
+  await page.locator('input.search').fill('');
+  await page.locator('ui-button', { hasText: '搜索' }).first().click();
+  await page.waitForTimeout(2000);
+} else {
+  results.push(['搜索能过滤', false, '取不到手机号列']);
+}
+
+// ③ 分页
+const pagerCount = await page.locator('.pager ui-pagination').count();
+results.push(['有分页条', pagerCount > 0, `pager=${pagerCount}`]);
+
+// ④ 编辑弹窗
+const editBtn = page.locator('ui-button', { hasText: '编辑' }).first();
+if (await editBtn.count()) {
+  await editBtn.click();
+  await page.waitForTimeout(900);
+  const dialogTitle = await page.locator('ui-modal', { hasText: '编辑成员' }).count();
+  const nickInput = await page.locator('ui-modal input').first().inputValue().catch(() => '');
+  await page.screenshot({ path: `${outDir}/3-edit-modal.png` });
+  results.push(['编辑弹窗打开并回填', dialogTitle > 0, `title=${dialogTitle} nick=${nickInput}`]);
+  // 关闭
+  await page.locator('ui-modal ui-button', { hasText: '取消' }).first().click().catch(() => {});
+  await page.waitForTimeout(600);
+} else {
+  results.push(['编辑弹窗打开并回填', false, '没有编辑按钮']);
+}
+
+// ⑤ 删除二次确认(只打开,不确认)
+const delBtn = page.locator('ui-button', { hasText: '移除' }).first();
+if (await delBtn.count()) {
+  await delBtn.click();
+  await page.waitForTimeout(900);
+  const confirm = await page.locator('ui-modal', { hasText: '确认移除' }).count();
+  await page.screenshot({ path: `${outDir}/4-remove-confirm.png` });
+  results.push(['移除走二次确认', confirm > 0, `confirm=${confirm}`]);
+  await page.locator('ui-modal ui-button', { hasText: '取消' }).first().click().catch(() => {});
+} else {
+  results.push(['移除走二次确认', false, '没有移除按钮']);
+}
+
+// 汇总
+let pass = true;
+console.log('\n=== 结果 ===');
+for (const [name, ok, detail] of results) {
+  if (!ok) pass = false;
+  console.log(`${ok ? '✓' : '✗'} ${name}  (${detail})`);
+}
+if (errors.length) {
+  pass = false;
+  console.log('页面错误:', errors.slice(0, 3).join(' | '));
+}
+console.log(`\n${pass ? '✓ 全部通过' : '✗ 存在失败项'}`);
+
+await browser.close();
+process.exit(pass ? 0 : 1);

+ 82 - 0
scripts/verify-deliverable-separation.mjs

@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+/**
+ * 成果归属实测(2026-11-10)
+ *
+ * 验证点:用户自己上传的文件**只出现在「文件」页签**,不出现在「成果」。
+ *
+ * 做法:
+ *   1. 打开开发态登录的桌面端
+ *   2. 进入 lj-test 会话(`$approot_mwagplwv…`,里面有用户上传的 image.png)
+ *   3. 切到右栏「文件」→ 截图 + 计数
+ *   4. 切到右栏「成果」→ 截图 + 计数
+ *
+ * 用法:node scripts/verify-deliverable-separation.mjs
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, '..');
+const outDir = resolve(root, 'test-results/deliverable-separation');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+
+/** 实测有用户上传图片的会话标题 */
+const SESSION_TITLE = '使用HTML根据我这个图片做一个页面出来';
+
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({
+  /* 本机已装 Chrome,不必下载 Playwright 自带的 chromium */
+  channel: 'chrome',
+});
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e)));
+
+/* 🔴 不能用 networkidle:Matrix `/sync` 是长轮询,页面永远「不空闲」 */
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(8000);
+
+// ① 找到目标会话并打开
+const item = page.locator('text=' + SESSION_TITLE.slice(0, 12)).first();
+if (await item.count()) {
+  await item.click();
+  await page.waitForTimeout(4000);
+} else {
+  console.log('⚠ 左栏没找到目标会话,截当前画面');
+}
+
+// ② 右栏「文件」页签
+const filesTab = page.getByRole('tab', { name: /文件/ }).first();
+const resultsTab = page.getByRole('tab', { name: /成果/ }).first();
+
+async function tabReport(tab, name) {
+  if (!(await tab.count())) return { name, error: '找不到页签' };
+  await tab.click();
+  await page.waitForTimeout(1500);
+  await page.screenshot({ path: `${outDir}/${name}.png` });
+  const pane = page.locator('aside, [class*="rail"]').last();
+  const text = (await pane.innerText().catch(() => '')) || '';
+  const hasImage = /image\.png/.test(text);
+  const isEmpty = /暂无/.test(text);
+  return { name, hasImage, isEmpty, textLen: text.length, snippet: text.slice(0, 200) };
+}
+
+const files = await tabReport(filesTab, 'rail-files');
+const results = await tabReport(resultsTab, 'rail-results');
+
+console.log('\n=== 右栏「文件」===');
+console.log(JSON.stringify(files, null, 2));
+console.log('\n=== 右栏「成果」===');
+console.log(JSON.stringify(results, null, 2));
+
+const pass = files.hasImage && !results.hasImage;
+console.log(`\n${pass ? '✓ PASS' : '✗ FAIL'}:用户上传的 image.png ` +
+  (files.hasImage ? '在「文件」中' : '**不在**「文件」中') + ',' +
+  (results.hasImage ? '**仍在**「成果」中' : '不在「成果」中'));
+if (errors.length) console.log('页面错误:', errors.slice(0, 3));
+
+await browser.close();
+process.exit(pass ? 0 : 1);

+ 84 - 0
scripts/verify-elapsed-ticking.mjs

@@ -0,0 +1,84 @@
+#!/usr/bin/env node
+/**
+ * 计时读数实测(2026-11-10)
+ *
+ * 验证点:回合头部/段头的秒数**逐秒递增**(1s → 2s → 3s…),
+ * 而不是只在容器发新事件时跳(5s → 18s → 30s)。
+ *
+ * 做法:
+ *   1. 打开桌面端并进入一个会话
+ *   2. 发一条极简指令(容器处理期间头像会有「运行了 N 步 · Ns」)
+ *   3. 每 300ms 采样一次「Ns」文本,观察序列
+ *
+ * 用法:node scripts/verify-elapsed-ticking.mjs
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, '..');
+const outDir = resolve(root, 'test-results/elapsed-ticking');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome' });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(8000);
+
+/*
+ * 先进一个已有会话:直接打开 `/desktop/chat` 是「开始新对话」入口页,
+ * 没有输入框(实测 textarea 数量 0)。
+ */
+const firstSession = page.locator('text=使用HTML根据我这个').first();
+if (await firstSession.count()) {
+  await firstSession.click();
+  await page.waitForTimeout(6000);
+}
+
+const box = page.locator('textarea, [contenteditable="true"]').first();
+await box.click();
+await box.fill('用终端依次执行三条命令:ls /opt/data、pwd、date,然后一句话总结结果。');
+await page.keyboard.press('Enter');
+console.log('已发送,开始采样秒数…');
+
+/** 采样期间出现的所有「Ns」读数(按首次出现顺序去重) */
+const seen = [];
+const t0 = Date.now();
+let doneAt = 0;
+while (Date.now() - t0 < 150_000) {
+  const texts = await page.locator('.head-time, .seg-time').allInnerTexts();
+  for (const t of texts) {
+    if (!seen.includes(t)) {
+      seen.push(t);
+      console.log(`  +${((Date.now() - t0) / 1000).toFixed(1)}s  ${t}`);
+    }
+  }
+  await page.waitForTimeout(300);
+  /*
+   * 采样窗口固定 90 秒:容器处理期间逐秒读数会一直增长;
+   * 结束后定格。按「出现多少个**不同**读数」判断是否在走表。
+   */
+  if (Date.now() - t0 >= 90_000) {
+    doneAt = Date.now();
+    break;
+  }
+}
+
+await page.screenshot({ path: `${outDir}/after.png` });
+
+// 判断是否逐秒:去重后的数字序列应基本连续
+const nums = seen.map((s) => parseInt(s, 10)).filter((n) => Number.isFinite(n));
+let consecutive = 0;
+for (let i = 1; i < nums.length; i++) if (nums[i] - nums[i - 1] <= 2) consecutive++;
+const ratio = nums.length > 1 ? consecutive / (nums.length - 1) : 0;
+const pass = nums.length >= 4 && ratio >= 0.7;
+console.log(`\n读数序列:${nums.join(', ')}`);
+console.log(`${pass ? '✓ PASS' : '✗ FAIL'}:${nums.length} 个读数,` +
+  `${Math.round(ratio * 100)}% 的相邻增量 ≤2s(逐秒跳变则应接近 0%)`);
+
+await browser.close();
+process.exit(pass ? 0 : 1);

+ 74 - 0
scripts/verify-image-text-send.mjs

@@ -0,0 +1,74 @@
+#!/usr/bin/env node
+/**
+ * 图文同发实测(2026-11-10)
+ *
+ * 验证点:选图 + 打字一起发送后,用户侧**一个回合**(图与文字不分裂成两次发言)。
+ *
+ * ⚠️ 容器侧合并窗口(见 docs/03-interface/容器侧改造-图文合并处理-20261110.md)
+ *    尚未上线,所以容器仍可能对图片抢先回话。本脚本只断言 **App 侧渲染**:
+ *    - 用户消息里 image.png 与文字属于同一个回合
+ *    - 不再出现「一个图气泡 + 一个空气泡 + 中间夹着处理提示」的分裂
+ *
+ * 用法:node scripts/verify-image-text-send.mjs <图片路径>
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, '..');
+const outDir = resolve(root, 'test-results/image-text-send');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+const imgPath = process.argv[2];
+if (!imgPath) {
+  console.error('用法:node scripts/verify-image-text-send.mjs <图片路径>');
+  process.exit(2);
+}
+
+await mkdir(outDir, { recursive: true });
+const browser = await chromium.launch({ channel: 'chrome' });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(8000);
+
+// 进一个已有会话(入口页没有输入框)
+const s = page.locator('text=使用HTML根据我这个').first();
+if (await s.count()) {
+  await s.click();
+  await page.waitForTimeout(5000);
+}
+
+// 选图(隐藏的 file input)
+const input = page.locator('input[type="file"]').first();
+await input.setInputFiles(imgPath);
+await page.waitForTimeout(1200);
+await page.screenshot({ path: `${outDir}/1-staged.png` });
+
+// 打字 + 发送
+const box = page.locator('textarea').first();
+await box.click();
+await box.fill('这张图是什么形状?只回一句。');
+await page.keyboard.press('Enter');
+
+// 采集中途状态
+for (const [i, wait] of [[2, 1500], [3, 5000], [4, 20_000]]) {
+  await page.waitForTimeout(wait);
+  await page.screenshot({ path: `${outDir}/${i}-after-${wait}ms.png` });
+}
+
+// 统计「用户回合」数量:自己的气泡里同时含图和文
+const stats = await page.evaluate(() => {
+  const bubbles = [...document.querySelectorAll('app-message-bubble')];
+  const own = bubbles.filter((b) => b.className.includes('own') || b.innerHTML.includes('image.png'));
+  return {
+    bubbles: bubbles.length,
+    withImage: bubbles.filter((b) => /image\.png/.test(b.innerText)).length,
+    withText: bubbles.filter((b) => /这张图是什么形状/.test(b.innerText)).length,
+    sample: own.slice(-3).map((b) => b.innerText.slice(0, 60)),
+  };
+});
+console.log(JSON.stringify(stats, null, 2));
+console.log(`\n截图:${outDir}`);
+
+await browser.close();

+ 92 - 0
scripts/verify-interrupt-ui.mjs

@@ -0,0 +1,92 @@
+#!/usr/bin/env node
+/**
+ * 打断场景的 UI 回归验证(2026-11-10)
+ *
+ * 验证点:
+ *   ① 图文被容器拆成两轮时,两轮**各自**计时,先结束的那轮定格不倒退
+ *   ② 同一时间不出现两条「正在思考 / 等待回复」占位
+ *   ③ 页面无未捕获错误
+ *
+ * 做法:真实发送「图 + 文字」,每 500ms 采样一次页面上的计时读数与占位文案,
+ * 观察读数序列是否**单调不减**(倒退就是本次要修的 bug)。
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const root = resolve(here, '..');
+const outDir = resolve(root, 'test-results/interrupt-ui');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+const imgPath = process.argv[2];
+
+await mkdir(outDir, { recursive: true });
+const browser = await chromium.launch({ channel: 'chrome' });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e)));
+
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(8000);
+
+const s = page.locator('text=使用HTML根据我这个').first();
+if (await s.count()) {
+  await s.click();
+  await page.waitForTimeout(5000);
+}
+
+// 发送:图 + 文字(复现容器的两轮)
+await page.locator('input[type="file"]').first().setInputFiles(imgPath);
+await page.waitForTimeout(1000);
+const box = page.locator('textarea').first();
+await box.click();
+await box.fill('用终端执行 date 命令,然后用一句话告诉我当前时间。');
+await page.keyboard.press('Enter');
+console.log('已发送图文,开始采样…');
+
+/** [{t, times:[..], waits:[..]}] */
+const samples = [];
+const t0 = Date.now();
+while (Date.now() - t0 < 120_000) {
+  const snap = await page.evaluate(() => ({
+    times: [...document.querySelectorAll('.head-time, .seg-time')].map((e) => e.textContent.trim()),
+    waits: [...document.querySelectorAll('.seg-wait, .card-wait')].map((e) => e.textContent.trim()),
+  }));
+  samples.push({ at: ((Date.now() - t0) / 1000).toFixed(1), ...snap });
+  await page.waitForTimeout(500);
+  if (Date.now() - t0 > 30_000) {
+    const noLive = !(await page.getByText('输出中').count());
+    if (noLive) break;
+  }
+}
+await page.screenshot({ path: `${outDir}/after.png`, fullPage: false });
+
+// 分析:只看最后一组头部读数(最新回合),检查是否单调不减
+const parse = (s) => parseInt(String(s).replace(/[^\d]/g, ''), 10);
+const series = samples
+  .map((x) => x.times.map(parse).filter((n) => Number.isFinite(n)))
+  .filter((a) => a.length > 0);
+
+let regressions = 0;
+for (let i = 1; i < series.length; i++) {
+  for (let k = 0; k < Math.min(series[i].length, series[i - 1].length); k++) {
+    if (series[i][k] < series[i - 1][k]) {
+      regressions++;
+      console.log(`  ✗ 倒退:${series[i - 1][k]}s → ${series[i][k]}s(${samples[i].at}s 处)`);
+    }
+  }
+}
+
+// 占位重复:同一时刻出现多于 1 条「正在思考/等待回复」且文案相同
+const dup = samples.filter((x) => x.waits.length >= 2 && new Set(x.waits).size === 1).length;
+
+console.log(`\n采样 ${samples.length} 次,读数组 ${series.length} 组`);
+console.log(`读数倒退次数:${regressions}`);
+console.log(`重复占位采样数:${dup}`);
+console.log(`页面错误:${errors.length ? errors.slice(0, 3).join(' | ') : '无'}`);
+
+const pass = regressions === 0 && errors.length === 0;
+console.log(`\n${pass ? '✓ PASS' : '✗ FAIL'}:计时不退步 / 无页面错误`);
+await browser.close();
+process.exit(pass ? 0 : 1);

+ 115 - 0
scripts/verify-mobile-org.mjs

@@ -0,0 +1,115 @@
+#!/usr/bin/env node
+/**
+ * 移动端成员管理 + 无组织等待页验证(2026-11-10)
+ *
+ * ① 移动端成员管理:独立移动布局(卡片行 + 吸顶搜索 + 底部抽屉),不是 PC 表格
+ * ② 无组织用户:注入一个 companyId 为空的 me → 应被 orgGuard 拦到 /pending
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = resolve(here, '..', 'test-results', 'mobile-org');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome' });
+const results = [];
+const errors = [];
+
+// ---------- ① 移动端成员管理 ----------
+{
+  const ctx = await browser.newContext({
+    viewport: { width: 390, height: 844 },
+    userAgent:
+      'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
+    hasTouch: true,
+    isMobile: true,
+  });
+  const page = await ctx.newPage();
+  page.on('pageerror', (e) => errors.push(String(e)));
+  await page.goto(`${BASE}/mobile/admin/users`, { waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(8000);
+  await page.screenshot({ path: `${outDir}/mobile-users.png` });
+
+  const isTable = await page.locator('.table, .thead').count();
+  const hasSearch = await page.locator('.m-search-input').count();
+  const hasCards = await page.locator('.m-card, app-m-empty').count();
+  results.push(['移动端不是 PC 表格', isTable === 0, `table=${isTable}`]);
+  results.push(['移动端有吸顶搜索', hasSearch > 0, `search=${hasSearch}`]);
+  results.push(['移动端有内容/空态', hasCards > 0, `nodes=${hasCards}`]);
+
+  // 打开「添加成员」抽屉
+  const fab = page.getByRole('button', { name: '添加成员' });
+  if (await fab.count()) {
+    await fab.first().click();
+    await page.waitForTimeout(700);
+    const sheet = await page.locator('.m-sheet').count();
+    await page.screenshot({ path: `${outDir}/mobile-add-sheet.png` });
+    results.push(['新增走底部抽屉(不是居中小弹窗)', sheet === 1, `sheet=${sheet}`]);
+  } else {
+    results.push(['新增走底部抽屉(不是居中小弹窗)', false, '加号按钮不存在']);
+  }
+  await ctx.close();
+}
+
+// ---------- ② 无组织用户被拦到等待页 ----------
+{
+  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+  const page = await ctx.newPage();
+  page.on('pageerror', (e) => errors.push(String(e)));
+
+  /*
+   * 先把正常登录态拿出来,再改写 me 去掉 companyId ——
+   * 相当于「账号登录了但还没被组织接纳」。
+   */
+  await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(8000);
+  const token = await page.evaluate(() => localStorage.getItem('fmode_session_token'));
+
+  await page.evaluate(() => {
+    const raw = localStorage.getItem('fmode_me');
+    if (!raw) return;
+    const me = JSON.parse(raw);
+    delete me.companyId;
+    delete me.companyName;
+    localStorage.setItem('fmode_me', JSON.stringify(me));
+  });
+
+  await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(5000);
+  await page.screenshot({ path: `${outDir}/pending-desktop.png` });
+
+  const url = page.url();
+  const onPending = url.includes('/pending');
+  results.push(['🔴 无组织用户被拦到等待页', onPending, url]);
+
+  const shownId = await page.locator('.id-text').innerText().catch(() => '');
+  results.push(['等待页显示用户 ID', shownId.length > 0, shownId || '(空)']);
+
+  // 直接访问业务页也应弹回等待页
+  await page.goto(`${BASE}/desktop/tasks`, { waitUntil: 'domcontentloaded' });
+  await page.waitForTimeout(3000);
+  results.push(['直接访问业务页仍被挡', page.url().includes('/pending'), page.url()]);
+
+  await ctx.close();
+  console.log(`(token 存在: ${!!token})`);
+}
+
+// ---------- 汇总 ----------
+let pass = true;
+console.log('\n=== 结果 ===');
+for (const [name, ok, detail] of results) {
+  if (!ok) pass = false;
+  console.log(`${ok ? '✓' : '✗'} ${name}  (${detail})`);
+}
+if (errors.length) {
+  pass = false;
+  console.log('页面错误:', errors.slice(0, 3).join(' | '));
+}
+console.log(`\n${pass ? '✓ 全部通过' : '✗ 存在失败项'}`);
+
+await browser.close();
+process.exit(pass ? 0 : 1);

+ 101 - 0
scripts/verify-resize-and-org.mjs

@@ -0,0 +1,101 @@
+#!/usr/bin/env node
+/**
+ * 缩放跨端提示 + 管理员用户管理 端到端验证(2026-11-10)
+ *
+ * 验证点:
+ *   ① 桌面端把窗口拖窄到 768 以下 → 弹「切换到手机端」提示(不静默跳转)
+ *   ② 连续改宽度只弹一次(防抖)
+ *   ③ 管理员账号能看到「成员管理」入口与页面
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = resolve(here, '..', 'test-results', 'resize-org');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome' });
+const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
+const page = await ctx.newPage();
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e)));
+
+const results = [];
+
+// ---------- ① 缩放提示 ----------
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(8000);
+
+const hintBefore = await page.locator('.narrow-desktop').count();
+results.push(['桌面宽屏:不提示', hintBefore === 0, `hint=${hintBefore}`]);
+
+// 连续改宽度(模拟拖拽)→ 防抖后只应出现一个提示
+for (const w of [1100, 1000, 900, 850, 820, 790, 760, 700, 640, 600]) {
+  await page.setViewportSize({ width: w, height: 900 });
+  await page.waitForTimeout(40);
+}
+await page.waitForTimeout(800);
+const hintAfter = await page.locator('.narrow-desktop').count();
+await page.screenshot({ path: `${outDir}/1-narrow-hint.png` });
+results.push(['拖窄到 768 以下 → 弹提示', hintAfter === 1, `hint=${hintAfter}`]);
+
+const jumped = page.url().includes('/mobile');
+results.push(['提示时**不**静默跳端', !jumped, page.url()]);
+
+// 点「我会切」→ 真的跳移动端
+const goBtn = page.locator('.narrow-btn', { hasText: '切换到手机端' });
+if (await goBtn.count()) {
+  await goBtn.first().click();
+  await page.waitForTimeout(2500);
+  results.push(['点切换 → 进入移动端', page.url().includes('/mobile'), page.url()]);
+} else {
+  results.push(['点切换 → 进入移动端', false, '按钮不存在']);
+}
+
+// ---------- ② 管理员用户管理 ----------
+await page.setViewportSize({ width: 1440, height: 900 });
+await page.goto(`${BASE}/desktop/admin/users`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(6000);
+await page.screenshot({ path: `${outDir}/2-admin-users.png` });
+
+const url = page.url();
+const reached = url.includes('/admin/users');
+const blocked = url.includes('/desktop/chat') && !reached;
+results.push(['管理员可进入成员管理', reached, url]);
+if (blocked) results.push(['非管理员被挡回首页', true, url]);
+
+const hasTable = await page.locator('.table, .rows, ui-empty, .empty').count();
+results.push(['页面有内容/三态(不是白屏)', hasTable > 0, `nodes=${hasTable}`]);
+
+// 导航入口:一级菜单只显示 4 项,成员管理在「更多」里
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(5000);
+let navAdmin = await page.locator('.route', { hasText: '成员管理' }).count();
+if (navAdmin === 0) {
+  const more = page.locator('.route-more');
+  if (await more.count()) {
+    await more.click();
+    await page.waitForTimeout(600);
+    navAdmin = await page.locator('.more-item', { hasText: '成员管理' }).count();
+  }
+}
+results.push(['侧边导航有「成员管理」入口', navAdmin > 0, `matches=${navAdmin}`]);
+
+// ---------- 汇总 ----------
+let pass = true;
+console.log('\n=== 结果 ===');
+for (const [name, ok, detail] of results) {
+  if (!ok) pass = false;
+  console.log(`${ok ? '✓' : '✗'} ${name}  (${detail})`);
+}
+if (errors.length) {
+  pass = false;
+  console.log('页面错误:', errors.slice(0, 3).join(' | '));
+}
+console.log(`\n${pass ? '✓ 全部通过' : '✗ 存在失败项'}`);
+
+await browser.close();
+process.exit(pass ? 0 : 1);

+ 46 - 0
scripts/verify-status-consistency.mjs

@@ -0,0 +1,46 @@
+#!/usr/bin/env node
+/**
+ * 顶栏状态一致性验证(2026-11-10)
+ *
+ * 验证点:容器空闲几分钟后,顶栏**不再**显示「状态未同步」,
+ * 而是与侧栏/通讯录一致的正常态(空闲 / 已完成)。
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = resolve(here, '..', 'test-results', 'status-consistency');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome' });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e)));
+
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(8000);
+const s = page.locator('text=使用HTML根据我这个').first();
+if (await s.count()) {
+  await s.click();
+  await page.waitForTimeout(6000);
+}
+
+const snap = await page.evaluate(() => {
+  const task = document.querySelector('.task-text')?.textContent?.trim() ?? '';
+  const sub = document.querySelector('[class*="chat-sub"], .chat-subtitle, header')?.textContent?.trim() ?? '';
+  return { task, header: sub.slice(0, 80) };
+});
+await page.screenshot({ path: `${outDir}/header.png` });
+
+const bad = /状态未同步/.test(snap.task);
+console.log(`顶栏任务行:${snap.task || '(空)'}`);
+console.log(`匹配到「状态未同步」:${bad ? '是 ← 不通过' : '否'}`);
+console.log(`页面错误:${errors.length ? errors.join(' | ') : '无'}`);
+
+const pass = !bad && errors.length === 0;
+console.log(`\n${pass ? '✓ PASS' : '✗ FAIL'}:顶栏不再误报状态未同步`);
+await browser.close();
+process.exit(pass ? 0 : 1);

+ 79 - 0
scripts/verify-switch-both-ways.mjs

@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+/**
+ * 端切换双向验证(2026-11-10)
+ *
+ * 复现用户实测的完整流程:
+ *   ① 桌面端宽屏 → 缩窄 → 提示切移动端 ✅
+ *   ② 点「切换」→ 进入移动端
+ *   ③ **把窗口放大回桌面尺寸 → 必须提示切回桌面端**(用户反馈这里失效)
+ *   ④ 点「切换」→ 回到桌面端
+ */
+import { chromium } from 'playwright';
+import { mkdir } from 'node:fs/promises';
+import { dirname, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = resolve(here, '..', 'test-results', 'switch-both-ways');
+const BASE = process.env.BASE_URL || 'http://127.0.0.1:4300';
+await mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({ channel: 'chrome' });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+const errors = [];
+page.on('pageerror', (e) => errors.push(String(e)));
+const results = [];
+
+await page.goto(`${BASE}/desktop/chat`, { waitUntil: 'domcontentloaded' });
+await page.waitForTimeout(8000);
+
+// ① 缩窄 → 桌面端提示
+await page.setViewportSize({ width: 600, height: 900 });
+await page.waitForTimeout(900);
+const hint1 = await page.locator('.narrow-desktop').count();
+results.push(['① 桌面缩窄 → 提示切移动端', hint1 === 1, `hint=${hint1}`]);
+
+// ② 点切换 → 进入移动端
+const goMobile = page.locator('.narrow-btn', { hasText: '切换到手机端' });
+if (await goMobile.count()) {
+  await goMobile.first().click();
+  await page.waitForTimeout(2500);
+}
+const onMobile = page.url().includes('/mobile');
+results.push(['② 点切换 → 进入移动端', onMobile, page.url()]);
+
+// ③ 🔴 放大回桌面尺寸 → 移动端应提示切回桌面
+await page.setViewportSize({ width: 1440, height: 900 });
+await page.waitForTimeout(1200);
+const hint2 = await page.locator('.wide-mobile').count();
+await page.screenshot({ path: `${outDir}/1-mobile-wide-hint.png` });
+results.push(['③ 🔴 放大 → 提示切回桌面端', hint2 === 1, `hint=${hint2}`]);
+
+// ④ 点切换 → 回到桌面端
+const goDesktop = page.locator('.wide-btn', { hasText: '切换到桌面端' });
+if (await goDesktop.count()) {
+  await goDesktop.first().click();
+  await page.waitForTimeout(2500);
+}
+const backDesktop = page.url().includes('/desktop');
+await page.screenshot({ path: `${outDir}/2-back-desktop.png` });
+results.push(['④ 点切换 → 回到桌面端', backDesktop, page.url()]);
+
+// ⑤ 回到桌面后不应再提示
+await page.waitForTimeout(1000);
+const hint3 = await page.locator('.narrow-desktop').count();
+results.push(['⑤ 已切回桌面 → 不再提示', hint3 === 0, `hint=${hint3}`]);
+
+let pass = true;
+console.log('\n=== 结果 ===');
+for (const [name, ok, detail] of results) {
+  if (!ok) pass = false;
+  console.log(`${ok ? '✓' : '✗'} ${name}  (${detail})`);
+}
+if (errors.length) {
+  pass = false;
+  console.log('页面错误:', errors.slice(0, 2).join(' | '));
+}
+console.log(`\n${pass ? '✓ 全部通过' : '✗ 存在失败项'}`);
+await browser.close();
+process.exit(pass ? 0 : 1);

+ 25 - 15
src/app/app.routes.ts

@@ -1,5 +1,7 @@
 import { ActivatedRouteSnapshot, Route, Routes } from '@angular/router';
 import { authGuard } from './core/auth.guard';
+import { detectUiMode } from './core/device.service';
+import { orgGuard } from './core/org.guard';
 
 /**
  * 顶层路由表(ADR-004:双端双套路由树)
@@ -48,13 +50,27 @@ export const routes: Routes = [
         : import('./pages/login/login').then((m) => m.LoginComponent),
   },
   {
-    path: 'desktop',
+    /*
+     * 等待组织邀请(2026-11-10)。
+     *
+     * 🔴 只挂 `authGuard`,**不挂 `orgGuard`** —— 本页正是无组织用户的落点,
+     *    再挂组织守卫会自我重定向死循环。
+     */
+    path: 'pending',
     canActivate: [authGuard],
+    loadComponent: () =>
+      detectMode() === 'mobile'
+        ? import('./mobile/pages/pending/pending').then((m) => m.MbPendingPage)
+        : import('./pages/pending/pending').then((m) => m.PendingPage),
+  },
+  {
+    path: 'desktop',
+    canActivate: [authGuard, orgGuard],
     loadChildren: () => import('./desktop/desktop.routes').then((m) => m.desktopRoutes),
   },
   {
     path: 'mobile',
-    canActivate: [authGuard],
+    canActivate: [authGuard, orgGuard],
     loadChildren: () => import('./mobile/mobile.routes').then((m) => m.mobileRoutes),
   },
   // ---- 旧路径重定向(FR-16 AC-16.2)----
@@ -125,16 +141,10 @@ function readModeOverride(): 'desktop' | 'mobile' | null {
   }
 }
 
-function detectMode(): 'desktop' | 'mobile' {
-  if (typeof navigator === 'undefined') return 'desktop';
-  const ua = navigator.userAgent;
-  if (/iPad|Tablet|PlayBook|Silk/i.test(ua)) return 'desktop';
-  if (/Android/i.test(ua) && !/Mobile/i.test(ua)) return 'desktop';
-  if (/iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry|webOS|Opera Mini/i.test(ua)) {
-    return 'mobile';
-  }
-  if (typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches) {
-    return typeof window !== 'undefined' && window.innerWidth < 768 ? 'mobile' : 'desktop';
-  }
-  return 'desktop';
-}
+/**
+ * 登录页该加载哪个组件。
+ *
+ * 🔴 2026-11-10:改为复用 `DeviceService` 的纯函数 —— 这里原本是一份
+ *    逐字复制的 UA 判定,两处一旦不同步就会出现「登录页判移动、入口判桌面」。
+ */
+const detectMode = detectUiMode;

+ 85 - 2
src/app/components/domain/assistant-turn/assistant-turn.spec.ts

@@ -36,6 +36,8 @@ function turn(over: Partial<AssistantTurn> = {}): AssistantTurn {
         key: 'agent',
         agentId: null,
         seconds: 4,
+        startAt: 0,
+        updatedAt: 0,
         steps: [{ kind: 'tool', label: 'bash', detail: 'ls' }],
         body: [{ kind: 'text', key: 'a1', message: { ...msg('a1', '正文'), mergedIds: ['a1'] } }],
         files: [],
@@ -189,7 +191,8 @@ describe('AssistantTurnComponent', () => {
 
   it('无正文且已结束时显示「未响应」(在段内,不在卡级)', () => {
     const t = turn();
-    t.blocks[0] = { ...t.blocks[0], body: [], steps: [], files: [], live: false, empty: true };
+    // 2026-11-10:块就是渲染单元,块自己的正文也要清空(块与回合口径一致)
+    t.blocks[0] = { ...t.blocks[0], body: [], steps: [], files: [], live: false, empty: true, merged: null };
     t.merged = null;
     t.stepCount = 0;
     render(t);
@@ -203,7 +206,15 @@ describe('AssistantTurnComponent', () => {
      * 卡级占位与段级占位同时渲染。现在统一由段级负责。
      */
     const t = turn();
-    t.blocks[0] = { ...t.blocks[0], body: [], steps: [], files: [], live: true, empty: false };
+    t.blocks[0] = {
+      ...t.blocks[0],
+      body: [],
+      steps: [],
+      files: [],
+      live: true,
+      empty: false,
+      merged: null,
+    };
     t.merged = null;
     t.stepCount = 0;
     t.live = true;
@@ -215,6 +226,42 @@ describe('AssistantTurnComponent', () => {
     expect(waits[0].textContent).toContain('正在思考');
   });
 
+  /**
+   * 🔴 拆轮后同一个回合可能有多个块(2026-11-10)。
+   *    每个块必须只渲染**自己的**正文,不能都用回合级 `merged`,
+   *    否则同一段话会重复出现两遍(端到端实测发现)。
+   */
+  it('🔴 一个回合有多个块时,每块只渲染自己的正文(不重复)', async () => {
+    /* 每个块的正文来自 `TurnBodyBlock.message`,块级 `merged` 只提供文本 */
+    const firstBlock = turn().blocks[0];
+    const t: AssistantTurn = {
+      ...turn(),
+      blocks: [
+        {
+          ...firstBlock,
+          key: 'r1',
+          body: [{ kind: 'text', key: 'a1', message: { ...msg('a1', '第一轮'), mergedIds: ['a1'] } }],
+          merged: { key: 'a1', text: '第一轮', message: msg('a1', '第一轮') },
+        },
+        {
+          ...firstBlock,
+          key: 'r2',
+          body: [{ kind: 'text', key: 'a2', message: { ...msg('a2', '第二轮'), mergedIds: ['a2'] } }],
+          merged: { key: 'a2', text: '第二轮', message: msg('a2', '第二轮') },
+        },
+      ],
+      merged: { key: 'a1', text: '第一轮\n\n第二轮', message: msg('a2', '第二轮') },
+    };
+    render(t);
+    /* markdown 渲染是异步的,等它落地再断言 */
+    await fixture.whenStable();
+    fixture.detectChanges();
+
+    const text = fixture.nativeElement.textContent as string;
+    expect(text.split('第一轮').length - 1).withContext('第一轮只出现一次').toBe(1);
+    expect(text.split('第二轮').length - 1).withContext('第二轮只出现一次').toBe(1);
+  });
+
   it('文件交给宫格组件渲染', () => {
     const f: FileCard = { kind: 'file', name: 'a.pdf', url: 'u', mimeType: 'application/pdf', size: 1 };
     const t = turn({ fileCount: 1 });
@@ -245,4 +292,40 @@ describe('AssistantTurnComponent', () => {
     render(t);
     expect(fixture.nativeElement.querySelector('.head-phase')?.textContent).toContain('输出中');
   });
+
+  /*
+   * 逐秒读数(2026-11-10 用户要求「一秒一秒叠加」;评审 P1-6 要求组件层锁住)
+   */
+  describe('用时读数', () => {
+    const T = 1_800_000_000_000;
+    const clock = (): { set(v: number): void } =>
+      (cmp as unknown as { now: { set(v: number): void } }).now;
+    const headTime = (): string =>
+      fixture.nativeElement.querySelector('.head-time')?.textContent?.trim() ?? '';
+
+    it('🔴 进行中:推进时钟后头部秒数逐秒递增', () => {
+      const t = turn({ live: true, phase: 'replying' });
+      Object.assign(t.blocks[0], { live: true, startAt: T, updatedAt: T });
+      render(t);
+
+      clock().set(T + 1000);
+      fixture.detectChanges();
+      expect(headTime()).withContext('第 1 秒').toBe('1s');
+
+      clock().set(T + 3000);
+      fixture.detectChanges();
+      expect(headTime()).withContext('第 3 秒(不依赖容器发新事件)').toBe('3s');
+    });
+
+    it('🔴 已结束:读数定格,推进时钟不再变化', () => {
+      const t = turn({ live: false });
+      Object.assign(t.blocks[0], { live: false, startAt: T, updatedAt: T + 7000 });
+      render(t);
+
+      expect(headTime()).toBe('7s');
+      clock().set(T + 60_000);
+      fixture.detectChanges();
+      expect(headTime()).withContext('结束后不再走表').toBe('7s');
+    });
+  });
 });

+ 40 - 12
src/app/components/domain/assistant-turn/assistant-turn.ts

@@ -1,6 +1,6 @@
 import { ChangeDetectionStrategy, Component, OnDestroy, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
 import { FileCard, Message } from '../../../core/api/models';
-import { AssistantTurn, TurnBlockView, waitText } from '../../../core/assistant-turn';
+import { AssistantTurn, TurnBlockView, elapsedSeconds, waitText } from '../../../core/assistant-turn';
 import { TurnCollapseService } from '../../../core/turn-collapse.service';
 import { AssistantFilesComponent } from '../assistant-files/assistant-files';
 import { MessageBubbleComponent } from '../message-bubble/message-bubble';
@@ -61,8 +61,8 @@ import { MessageBubbleComponent } from '../message-bubble/message-bubble';
         >
           <i class="fa-solid fa-chevron-right chev" [class.open]="expanded()" aria-hidden="true"></i>
           <span class="head-text">{{ headText() }}</span>
-          @if (turn().seconds > 0) {
-            <span class="head-time">{{ turn().seconds }}s</span>
+          @if (turnSeconds() > 0) {
+            <span class="head-time">{{ turnSeconds() }}s</span>
           }
           <span class="head-phase" [class.busy]="turn().live">{{ phaseText() }}</span>
         </button>
@@ -105,8 +105,8 @@ import { MessageBubbleComponent } from '../message-bubble/message-bubble';
               <header class="seg-head">
                 <span class="seg-dot" [style.background]="'var(--' + seg.hue + ')'" aria-hidden="true"></span>
                 <span class="seg-name">{{ seg.name }}</span>
-                @if (seg.seconds > 0) {
-                  <span class="seg-time">{{ seg.seconds }}s</span>
+                @if (segTime()[seg.key] > 0) {
+                  <span class="seg-time">{{ segTime()[seg.key] }}s</span>
                 }
               </header>
             }
@@ -512,14 +512,13 @@ export class AssistantTurnComponent implements OnDestroy {
   /**
    * 各块的**目标**长度。
    *
-   * 群聊按块各自的内容长度;单聊只有一块,用回合级合并文本。
+   * 每块以自己的正文为准(2026-11-10 拆轮后单聊也可能有多块);
+   * 单块时等价于回合级合并文本。
    */
   private targetsOf(t: AssistantTurn): Record<string, number> {
-    const isMulti = this.grouped() && t.blocks.length > 1;
     const out: Record<string, number> = {};
     for (const b of t.blocks) {
-      const src = isMulti ? b.merged : t.merged;
-      out[b.key] = src?.text.length ?? 0;
+      out[b.key] = (b.merged ?? (t.blocks.length === 1 ? t.merged : null))?.text.length ?? 0;
     }
     return out;
   }
@@ -662,7 +661,6 @@ export class AssistantTurnComponent implements OnDestroy {
       key: string;
       name: string;
       hue: string;
-      seconds: number;
       message: Message | null;
       /** 没有可渲染消息时才可能显示占位;具体文案见 `waitLabels` */
       waiting: boolean;
@@ -670,7 +668,18 @@ export class AssistantTurnComponent implements OnDestroy {
     }[] = [];
 
     for (const b of t.blocks) {
-      const src = isMulti ? b.merged : t.merged;
+      /*
+       * 🔴 每块渲染**自己的正文**(2026-11-10 改)。
+       *
+       * 原来单聊(`isMulti=false`)让每个块都以回合级 `t.merged` 为源 ——
+       * 那时单聊固定只有 1 个块,等价。
+       * 但拆轮后同一回合可能有多块:再用 `t.merged`,每块都会画一遍整段正文,
+       * 界面上就是同一段话重复出现(实测)。改用 `b.merged` 后各画各的。
+       *
+       * 单聊的「整轮一张卡、正文连成一份」观感不变 —— `t.merged` 仍用于
+       * 回合级的合并与流式推进,块只是把内容分段呈现。
+       */
+      const src = b.merged ?? (t.blocks.length === 1 ? t.merged : null);
       if (!src && !b.live && !b.empty) continue;
 
       let message: Message | null = null;
@@ -715,7 +724,6 @@ export class AssistantTurnComponent implements OnDestroy {
         key: b.key,
         name: b.agentName || b.agentId || '',
         hue: this.hueOf(b),
-        seconds: b.seconds,
         message,
         waiting: !message,
         streaming,
@@ -751,6 +759,26 @@ export class AssistantTurnComponent implements OnDestroy {
     return out;
   });
 
+  /**
+   * 各段的「用时秒数」——**进行中逐秒递增**(2026-11-10 用户要求)。
+   *
+   * 🔴 与 `waitLabels` 同一套时钟(每秒刷新的 `now`),
+   *    但刻意与 `segments` 分开:`segments` 只在消息变化时重建,
+   *    秒数每秒变一次,放一起会让整个气泡对象每秒重建。
+   */
+  protected readonly segTime = computed<Record<string, number>>(() => {
+    const now = this.now();
+    const out: Record<string, number> = {};
+    for (const b of this.turn().blocks) out[b.key] = elapsedSeconds(b, now);
+    return out;
+  });
+
+  /** 回合头部的用时:取本回合最长的那个块(群聊里各容器并行跑) */
+  protected readonly turnSeconds = computed<number>(() => {
+    const times = this.segTime();
+    return this.turn().blocks.reduce((max, b) => Math.max(max, times[b.key] ?? 0), 0);
+  });
+
   protected readonly thinking = computed(() => {
     const t = this.turn();
     if (t.merged) return '';

+ 14 - 0
src/app/components/domain/session-rail/session-rail.ts

@@ -246,6 +246,9 @@ export interface RailRuntime {
                       <span class="rail-meta">
                         <strong class="ellipsis">{{ d.title }}</strong>
                         <small class="ellipsis">
+                          @if (ownerOfDeliverable(d); as who) {
+                            {{ who }} ·
+                          }
                           {{ d.filename || '暂无附件' }}
                           @if (d.filename && d.size) {
                             · {{ d.size }}
@@ -661,6 +664,17 @@ export class SessionRailComponent {
     return id ? this.ownerNameOf()(id) : '';
   }
 
+  /**
+   * 成果的产出方(2026-11-10)。
+   *
+   * 群聊里同一轮可能由多个容器交付文件,用户需要知道**是哪位给的**;
+   * 单聊只有一个容器时 `ownerNameOf` 返回空,不显示标签。
+   */
+  protected ownerOfDeliverable(d: Deliverable): string {
+    const id = (d as Deliverable & { agentId?: string }).agentId;
+    return id ? this.ownerNameOf()(id) : '';
+  }
+
   protected statusText(s: string): string {
     return s;
   }

+ 18 - 2
src/app/components/domain/session-tree/session-tree.ts

@@ -18,6 +18,7 @@ import { DeviceService } from '../../../core/device.service';
 import { LiveService } from '../../../core/live.service';
 import { PALETTES, PaletteService } from '../../../core/palette.service';
 import { SessionService } from '../../../core/session.service';
+import { canManageCompany } from '../../../core/acl';
 import { ThemeService } from '../../../ui/theme';
 import { UnreadService } from '../../../core/unread.service';
 import { ChatService } from '../../../core/chat.service';
@@ -1858,12 +1859,27 @@ export class SessionTreeComponent implements OnDestroy {
    */
   private static readonly PRIMARY_COUNT = 4;
 
+  /** 当前用户能否管理组织成员(2026-11-10):决定「成员管理」入口显不显示 */
+  private readonly canManage = computed(() => canManageCompany(this.session.me()));
+
+  /**
+   * 对当前用户可见的导航项。
+   *
+   * 🔴 权限类入口在这里过滤,不靠改 `NAV_CATALOG` ——
+   *    后者是**全量目录**,用户自定义排序/显隐要基于它,不能因身份变动而丢项。
+   */
+  private readonly navItems = computed(() =>
+    this.navMenu
+      .visible()
+      .filter((i) => i.key !== 'admin-users' || this.canManage()),
+  );
+
   protected readonly primaryNav = computed(() =>
-    this.navMenu.visible().slice(0, SessionTreeComponent.PRIMARY_COUNT),
+    this.navItems().slice(0, SessionTreeComponent.PRIMARY_COUNT),
   );
 
   protected readonly moreNav = computed(() =>
-    this.navMenu.visible().slice(SessionTreeComponent.PRIMARY_COUNT),
+    this.navItems().slice(SessionTreeComponent.PRIMARY_COUNT),
   );
 
   /** 「更多」菜单是否展开 */

+ 49 - 1
src/app/components/shell/page/page.ts

@@ -24,7 +24,7 @@ export interface Crumb {
   changeDetection: ChangeDetectionStrategy.OnPush,
   imports: [RouterLink],
   template: `
-    <div class="page" [class.narrow]="narrow()" [class.wide]="wide()" [class.split-mode]="fixedHeader()">
+    <div class="page" [class.narrow]="narrow()" [class.wide]="wide()" [class.split-mode]="fixedHeader()" [class.content-scroll]="contentScroll()">
       <!-- 固定区:面包屑 + 标题 + 工具栏(不随内容滚动) -->
       <div class="fixed-zone">
         <!-- 面包屑:详情页返回的唯一入口 -->
@@ -78,6 +78,26 @@ export interface Crumb {
       @use '../../../core/styles/breakpoints' as bp;
       :host {
         display: block;
+        /*
+         * 🔴 固定头 + 内容自滚模式下,宿主必须能拿到确定高度:
+         *    外层 .workspace 没有给 height,百分比会退化成 auto,
+         *    .page 被内容撑到 1611px,分页条被顶出视口。
+         *    这里让宿主撑满父容器高度并把高度往下传。
+         */
+        height: 100%;
+        min-height: 0;
+      }
+      /*
+       * 固定头 + 内容自滚模式:宿主与页面都取可用高度。
+       * 不能只给 .page 设 height:100% —— 宿主若没有确定高度,
+       * 百分比会退化成 auto,页面被内容撑高(实测 1611px 而非视口 900px),
+       * 表格就把分页条顶出屏幕。这里用 flex 链把高度传下去。
+       */
+      :host(.split-host) {
+        display: flex;
+        flex-direction: column;
+        height: 100%;
+        min-height: 0;
       }
       .page {
         width: 100%;
@@ -90,7 +110,13 @@ export interface Crumb {
       /* 固定头部 + 区域内滚动模式(fixedHeader=true)
          —— 企业级列表页的标准做法:筛选/工具条固定,只有列表区滚动 */
       .page.split-mode {
+        /*
+         * 视口高度兜底:宿主可能处于「高度 auto」的容器里,
+         * 那时 height:100% 退化成 auto,整页被内容撑高、分页条被顶出屏幕。
+         * 用 100dvh 保证页面至少占满一屏,再让内部滚动区吃掉剩余高度。
+         */
         height: 100%;
+        min-height: 100dvh;
         display: flex;
         flex-direction: column;
         min-height: 0;
@@ -116,6 +142,19 @@ export interface Crumb {
           opacity: 1;
         }
       }
+      /*
+       * 内容自身滚动:外层不滚,高度锁在视口内,由子内容(如表格)自己滚。
+       * 用于「工具条 + 列表 + 固定分页条」这类企业列表页。
+       * 上下各留一点呼吸空间,避免内容贴边(用户实测「上下都有遮挡感」)。
+       */
+      .page.split-mode.content-scroll .scroll-zone {
+        overflow: hidden;
+        display: flex;
+        flex-direction: column;
+        min-height: 0;
+        padding-top: var(--sp-2);
+        padding-bottom: var(--sp-2);
+      }
       @media (prefers-reduced-motion: reduce) {
         .page.split-mode .scroll-zone {
           animation: none;
@@ -227,4 +266,13 @@ export class PageComponent {
    * 未投影的默认内容在下方滚动区内部滚动(外层不出现整页滚动条)。
    */
   readonly fixedHeader = input(false, { transform: booleanAttribute });
+
+  /**
+   * 内容区**自身负责滚动**(2026-11-10)。
+   *
+   * 默认 `.scroll-zone` 自己滚;但「表格 + 固定分页条」这类页面需要
+   * **外层不滚、只有列表内部滚**,否则表格会把分页条顶出视口。
+   * 置 true 后滚动区变为不滚动的弹性容器,高度锁在视口内。
+   */
+  readonly contentScroll = input(false, { transform: booleanAttribute });
 }

+ 6 - 2
src/app/core/acl.ts

@@ -158,14 +158,18 @@ export function scopeFor(me: Me | null | undefined): Scope {
 /**
  * 企业管理路由守卫(2026-11-04)。
  *
- * 非管理员直接挡回「我的」—— 页面本身也会显示无权限态,
+ * 非管理员直接挡走 —— 页面本身也会显示无权限态,
  * 但守卫能避免地址栏直接输入时先闪一下页面。
+ *
+ * 🔴 2026-11-10:改为**按当前端**回退。原来固定回 `/mobile/mine`,
+ *    桌面端用户(尤其超管)被踢到移动版「我的」页,体验割裂。
  */
 export const adminGuard: CanActivateFn = () => {
   const session = inject(SessionService);
   const router = inject(Router);
   if (canManageCompany(session.me())) return true;
-  return router.createUrlTree(['/mobile/mine']);
+  const onDesktop = router.url.startsWith('/desktop');
+  return router.createUrlTree([onDesktop ? '/desktop/chat' : '/mobile/mine']);
 };
 
 export function canManageCompany(me: Me | null | undefined): boolean {

+ 23 - 1
src/app/core/agent-state.spec.ts

@@ -195,12 +195,34 @@ describe('agentLayers(三层:存活 / 连接 / 执行)', () => {
    * 阈值按用户裁决改为 1 小时粒度(2026-11-09):
    * 容器改成 1 小时 + 随机错峰上报,不再需要「2 分钟 / 10 分钟」这种精细判据。
    */
-  it('心跳 100 分钟 → 可疑(状态未同步),不是「正在执行」', () => {
+  it('心跳 100 分钟 + 无任务描述 → 可疑(状态未同步)', () => {
     const v = agentLayers(agent({ status: 'running', heartbeatAgeSec: 100 * 60 }), NOW);
     expect(v.alive).toBe('stale');
     expect(v.text).toBe('状态未同步');
   });
 
+  it('🔴 心跳 100 分钟但有真实任务描述 → 仍算在干活(2026-11-10)', () => {
+    /*
+     * 上报是小时级的,干活时错过一轮很常见。
+     * 内层(任务描述)明确知道它在忙,不该因为「没上报」就标未同步。
+     */
+    const v = agentLayers(
+      agent({ status: 'running', heartbeatAgeSec: 100 * 60, currentTask: '写周报' }),
+      NOW,
+    );
+    expect(v.alive).toBe('alive');
+    expect(v.text).toBe('正在执行');
+  });
+
+  it('心跳 100 分钟且是上报器自指 → 不因「有描述」而豁免', () => {
+    const v = agentLayers(
+      agent({ status: 'running', heartbeatAgeSec: 100 * 60, currentTask: 'periodic status reporter' }),
+      NOW,
+    );
+    expect(v.alive).withContext('自指不算干活').toBe('stale');
+    expect(v.text).toBe('状态未同步');
+  });
+
   it('🔴 心跳 75 分钟 → 仍算存活(上报周期 1h+0~15min,不能卡着 1 小时判死)', () => {
     const v = agentLayers(agent({ status: 'idle', heartbeatAgeSec: 75 * 60 }), NOW);
     expect(v.alive).toBe('alive');

+ 18 - 6
src/app/core/agent-state.ts

@@ -267,26 +267,38 @@ export function agentLayers(agent: Agent | null | undefined, now = Date.now()):
   const age = heartbeatAgeSec(agent, now);
   const alive: AgentLayers['alive'] =
     age === null ? 'alive' : age > ALIVE_DEAD_SEC ? 'dead' : age > ALIVE_STALE_SEC ? 'stale' : 'alive';
+  /*
+   * 🔴 忙碌但有真实任务描述 → 容忍一轮上报缺失(2026-11-10)。
+   *
+   * 容器上报是**小时级**的。一旦它正在干活(status=running 且有真实任务),
+   * 错过一轮上报很常见,此时把话干到一半的容器标「状态未同步」是纯噪音 ——
+   * 内层(消息流、任务描述)明明知道它在忙。只有「空闲」才需要上报来证明它还在。
+   */
+  const busyHint =
+    agent.status === 'running' &&
+    !isReporterSelfTalk(agent) &&
+    !!(agent.currentTask?.trim() || agent.currentAction?.trim());
+  const aliveEff: AgentLayers['alive'] = alive === 'stale' && busyHint ? 'alive' : alive;
   const link: AgentLayers['link'] = agentPresence(agent) === 'offline' ? 'unready' : 'ready';
 
   /*
    * busy 只认「容器在跑且不是说自己的事」。
    * 上报器自指(reporter heartbeat)不算干活 —— 见 isReporterSelfTalk。
    */
-  const busy = alive !== 'dead' && agent.status === 'running' && !isReporterSelfTalk(agent);
+  const busy = aliveEff !== 'dead' && agent.status === 'running' && !isReporterSelfTalk(agent);
 
   // 文案按「先说死了没,再说通不通,最后说忙不忙」排,保证最重要的信息在前
-  if (alive === 'dead') return { alive, link, busy: false, text: '已离线', tone: 'danger' };
-  if (alive === 'stale') return { alive, link, busy, text: '状态未同步', tone: 'warning' };
-  if (link === 'unready') return { alive, link, busy, text: '未接通', tone: 'warning' };
+  if (aliveEff === 'dead') return { alive: aliveEff, link, busy: false, text: '已离线', tone: 'danger' };
+  if (aliveEff === 'stale') return { alive: aliveEff, link, busy, text: '状态未同步', tone: 'warning' };
+  if (link === 'unready') return { alive: aliveEff, link, busy, text: '未接通', tone: 'warning' };
   if (agent.status === 'error') return { alive, link, busy, text: '异常', tone: 'danger' };
   /*
    * 🔴 2026-11-09 补(C6):`stopped` / `offline` 在 `resolveAgentState` 里属
    *    `BROKEN`,判「离线」;这里原先没有对应分支,落到「空闲」——
    *    同一容器在新建弹窗显示红色「离线」、在工作台显示「空闲」,自相矛盾。
    */
-  if (BROKEN.has(agent.status)) return { alive, link, busy: false, text: '离线', tone: 'danger' };
-  return { alive, link, busy, text: busy ? '正在执行' : '空闲', tone: busy ? 'success' : 'neutral' };
+  if (BROKEN.has(agent.status)) return { alive: aliveEff, link, busy: false, text: '离线', tone: 'danger' };
+  return { alive: aliveEff, link, busy, text: busy ? '正在执行' : '空闲', tone: busy ? 'success' : 'neutral' };
 }
 
 /* ------------------------------------------------------------------ */

+ 36 - 0
src/app/core/api/fmode-api.ts

@@ -8,6 +8,9 @@ import {
   DeliverableGroup,
   MatrixProvision,
   Me,
+  OrgUser,
+  OrgMembership,
+  OrgUserPage,
   Task,
   UploadTicket,
   WorkGroup,
@@ -209,4 +212,37 @@ export abstract class FmodeApi {
   /** 归档(不删房间) */
   abstract archiveWorkGroup(groupId: string): Promise<void>;
 
+  // ---------- 组织成员管理(2026-11-10,仅管理员) ----------
+
+  /**
+   * 分页查询本组织成员。
+   *
+   * 🔴 **鉴权在服务端**:前端只是入口可见性,接口内部还会再校验一次管理员身份,
+   *    并强制把作用域锁在请求者自己的组织。
+   */
+  abstract listOrgUsers(query: {
+    keyword?: string;
+    page?: number;
+    pageSize?: number;
+  }): Promise<OrgUserPage>;
+
+  /** 把用户拉进当前组织 */
+  abstract addOrgUser(userId: string): Promise<OrgUser>;
+
+  /** 改资料 / 重置密码(`password` 为空表示不改) */
+  abstract updateOrgUser(
+    userId: string,
+    patch: { nickname?: string; mobile?: string; password?: string },
+  ): Promise<OrgUser>;
+
+  /** 软删并移出组织 */
+  abstract removeOrgUser(userId: string): Promise<void>;
+
+  /**
+   * 当前账号的组织归属状态(2026-11-10)。
+   *
+   * 🔴 不是「有没有 companyId」—— 新用户注册时系统会**自动建一个个人试用组织**,
+   *    那种情况应视为**无组织**。判定在服务端做(见 `fmodeagent-users.js` 的 `opMe`)。
+   */
+  abstract fetchOrgMembership(): Promise<OrgMembership>;
 }

+ 71 - 0
src/app/core/api/mock-api.ts

@@ -17,6 +17,9 @@ import {
   DeliverableGroup,
   MatrixProvision,
   Me,
+  OrgMembership,
+  OrgUser,
+  OrgUserPage,
   Task,
   UploadTicket,
   WorkGroup,
@@ -369,6 +372,74 @@ export class MockApi extends FmodeApi {
     if (g) g.status = 'archived';
   }
 
+  // ---------- 组织成员管理(2026-11-10,演示态内存实现) ----------
+
+  /** 演示数据:有组织成员管理的入口才看得到真实交互 */
+  private readonly mockOrgUsers: OrgUser[] = [
+    { id: 'demo-user-01', username: 'zhangsan', nickname: '张三', mobile: '13800000001', isDeleted: false, createdAt: Date.now() - 86400_000 * 30 },
+    { id: 'demo-user-02', username: 'lisi', nickname: '李四', mobile: '13800000002', isDeleted: false, createdAt: Date.now() - 86400_000 * 20 },
+    { id: 'demo-user-03', username: 'wangwu', nickname: '王五', mobile: '13800000003', isDeleted: false, createdAt: Date.now() - 86400_000 * 10 },
+  ];
+
+  async listOrgUsers(query: {
+    keyword?: string;
+    page?: number;
+    pageSize?: number;
+  }): Promise<OrgUserPage> {
+    const keyword = (query.keyword ?? '').trim().toLowerCase();
+    const page = Math.max(1, query.page ?? 1);
+    const pageSize = Math.max(1, query.pageSize ?? 20);
+    const all = this.mockOrgUsers.filter(
+      (u) =>
+        !u.isDeleted &&
+        (!keyword ||
+          u.nickname.toLowerCase().includes(keyword) ||
+          u.mobile.includes(keyword) ||
+          u.id.toLowerCase().includes(keyword)),
+    );
+    const start = (page - 1) * pageSize;
+    return { items: all.slice(start, start + pageSize), total: all.length, page, pageSize };
+  }
+
+  async addOrgUser(userId: string): Promise<OrgUser> {
+    const existed = this.mockOrgUsers.find((u) => u.id === userId);
+    if (existed) {
+      existed.isDeleted = false;
+      return existed;
+    }
+    const created: OrgUser = {
+      id: userId,
+      username: userId,
+      nickname: '新成员',
+      mobile: '',
+      isDeleted: false,
+      createdAt: Date.now(),
+    };
+    this.mockOrgUsers.push(created);
+    return created;
+  }
+
+  async updateOrgUser(
+    userId: string,
+    patch: { nickname?: string; mobile?: string; password?: string },
+  ): Promise<OrgUser> {
+    const u = this.mockOrgUsers.find((x) => x.id === userId);
+    if (!u) throw new Error('用户不存在');
+    if (patch.nickname != null) u.nickname = patch.nickname;
+    if (patch.mobile != null) u.mobile = patch.mobile;
+    return { ...u };
+  }
+
+  async removeOrgUser(userId: string): Promise<void> {
+    const u = this.mockOrgUsers.find((x) => x.id === userId);
+    if (u) u.isDeleted = true;
+  }
+
+  /** 演示态:当前用户视为已加入组织 */
+  async fetchOrgMembership(): Promise<OrgMembership> {
+    return { userId: 'demo-me', hasOrg: true, companyId: 'demo-company', companyName: '演示组织' };
+  }
+
   // ---------- 内部 ----------
 
   private delay(): Promise<void> {

+ 44 - 0
src/app/core/api/models.ts

@@ -281,6 +281,13 @@ export interface Message {
    * 容器 / 历史事件没有这个字段。
    */
   localId?: string;
+  /**
+   * 这条消息**回复的是哪一条**(Matrix `m.in_reply_to.event_id`)。
+   *
+   * 🔴 2026-11-10:容器对每条用户消息分别回话(图文被拆成两条时各跑一轮),
+   *    回合聚合靠它把回复归到正确的那条提问上。
+   */
+  replyTo?: string;
 }
 
 // ---------- 成果与项目 ----------
@@ -463,6 +470,43 @@ export interface AuthSession {
   me: Me;
 }
 
+// ---------- 组织成员管理(2026-11-10) ----------
+
+/** 组织成员(管理员用户管理页的一行) */
+export interface OrgUser {
+  id: string;
+  username: string;
+  nickname: string;
+  mobile: string;
+  /** 是否已软删(列表默认不返回已删的) */
+  isDeleted: boolean;
+  /** 创建时间(epoch ms) */
+  createdAt: number;
+}
+
+/** 成员分页结果 */
+export interface OrgUserPage {
+  items: OrgUser[];
+  total: number;
+  page: number;
+  pageSize: number;
+}
+
+/**
+ * 当前账号的组织归属状态(2026-11-10)。
+ *
+ * 🔴 `hasOrg === false` 表示「未加入任何正式组织」—— 包括两种情形:
+ *    · 完全没有组织(companyId 为空)
+ *    · **只有注册时自动创建的个人试用组织**(名字=用户名,带 30 天试用)
+ *    判定在服务端完成,前端只消费结果,不自行推断。
+ */
+export interface OrgMembership {
+  userId: string;
+  hasOrg: boolean;
+  companyId: string;
+  companyName: string;
+}
+
 // ---------- Matrix 开通与上传(字段与《API-契约》§2.3/§2.5 对齐) ----------
 
 /** 对应云函数 RP5Ht55NeX 的返回 */

+ 75 - 0
src/app/core/api/parse-api.ts

@@ -17,6 +17,9 @@ import {
   DeliverableGroup,
   MatrixProvision,
   Me,
+  OrgMembership,
+  OrgUser,
+  OrgUserPage,
   Task,
   UploadTicket,
   WorkGroup,
@@ -69,6 +72,13 @@ const FN = {
   workgroup: 'V1EBgm05y2',
   /** 会话目录(2026-10-31 新建,Function.name = fmodeagent-chatthreads) */
   chatThreads: 'oR5O67i1Cm',
+  /**
+   * 组织成员管理(2026-11-10 新建,Function.name = fmodeagent-users)。
+   *
+   * 🔴 源码见 `cloud/functions/fmodeagent-users.js`;**部署后**把真实 objectId 填到这里。
+   *    未部署时该调用会 404,页面显示错误态而不是空白。
+   */
+  orgUsers: 'mgR4EA2n5a',
 } as const;
 
 /** 兼容「已是对象」与「是 JSON 字符串」两种形态 */
@@ -552,6 +562,71 @@ export class ParseApi extends FmodeApi {
     if (d.code !== 200) throw new Error(pickMessage(d) ?? '归档失败');
   }
 
+  // ---------- 组织成员管理(2026-11-10) ----------
+  /*
+   * 🔴 这组接口**不做任何前端权限判断**:谁能调用由服务端决定,
+   *    前端只负责把 403 如实显示出来。这样即使前端被改,也越不了权。
+   */
+
+  async listOrgUsers(query: {
+    keyword?: string;
+    page?: number;
+    pageSize?: number;
+  }): Promise<OrgUserPage> {
+    const d = await this.callFunction<{ code: number; items?: OrgUser[]; total?: number; page?: number; pageSize?: number }>(
+      FN.orgUsers,
+      { op: 'list', ...query },
+    );
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '成员列表加载失败');
+    return {
+      items: d.items ?? [],
+      total: d.total ?? 0,
+      page: d.page ?? query.page ?? 1,
+      pageSize: d.pageSize ?? query.pageSize ?? 20,
+    };
+  }
+
+  async addOrgUser(userId: string): Promise<OrgUser> {
+    const d = await this.callFunction<{ code: number; item?: OrgUser }>(FN.orgUsers, {
+      op: 'add',
+      userId,
+    });
+    if (d.code !== 200 || !d.item) throw new Error(pickMessage(d) ?? '添加成员失败');
+    return d.item;
+  }
+
+  async updateOrgUser(
+    userId: string,
+    patch: { nickname?: string; mobile?: string; password?: string },
+  ): Promise<OrgUser> {
+    const d = await this.callFunction<{ code: number; item?: OrgUser }>(FN.orgUsers, {
+      op: 'update',
+      userId,
+      ...patch,
+    });
+    if (d.code !== 200 || !d.item) throw new Error(pickMessage(d) ?? '保存失败');
+    return d.item;
+  }
+
+  async removeOrgUser(userId: string): Promise<void> {
+    const d = await this.callFunction<{ code: number }>(FN.orgUsers, { op: 'remove', userId });
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '删除失败');
+  }
+
+  async fetchOrgMembership(): Promise<OrgMembership> {
+    const d = await this.callFunction<Partial<OrgMembership> & { code: number }>(
+      FN.orgUsers,
+      { op: 'me' },
+    );
+    if (d.code !== 200) throw new Error(pickMessage(d) ?? '查询组织归属失败');
+    return {
+      userId: d.userId ?? '',
+      hasOrg: !!d.hasOrg,
+      companyId: d.companyId ?? '',
+      companyName: d.companyName ?? '',
+    };
+  }
+
   async ackArtifactJob(input: {
     id: string;
     ok: boolean;

+ 113 - 1
src/app/core/assistant-turn.spec.ts

@@ -1,7 +1,7 @@
 import { GroupBlock, GroupTurn } from './group-turns';
 import { Message } from './api/models';
 import { Turn } from './turn-aggregator';
-import { turnsFromDm, turnsFromGroup, waitText } from './assistant-turn';
+import { elapsedSeconds, turnsFromDm, turnsFromGroup, waitText } from './assistant-turn';
 
 /**
  * 回合视图模型单测(2026-11-06)
@@ -64,8 +64,12 @@ function dmTurn(blocks: Message[], opts: Partial<Turn> = {}): Turn {
     id: 'u1',
     userMessage: msg('u1', 'me', '你好'),
     blocks,
+    /* 手造回合默认一轮(2026-11-10 拆轮字段) */
+    rounds: blocks.length ? [blocks] : [],
+    userAskTimes: [T0],
     startedAt: times.length ? Math.min(...times) : 0,
     updatedAt: times.length ? Math.max(...times) : 0,
+    lastUserAt: times.length ? Math.min(...times) : 0,
     closed: true,
     ...opts,
   };
@@ -155,6 +159,64 @@ describe('turnsFromDm(单聊)', () => {
     expect(t.blocks[0].seconds).toBe(4);
   });
 
+  /**
+   * 逐秒读数(2026-11-10 用户反馈:「5 秒、18 秒、30 秒跳」,要一秒一秒加)
+   */
+  describe('elapsedSeconds(逐秒读数)', () => {
+    it('🔴 进行中:随时钟每秒 +1,不等容器发新事件', () => {
+      const [t] = turnsFromDm(
+        [dmTurn([msg('a1', 'agent', 'x', 4000)], { closed: false })],
+        T0 + 5000,
+        true,
+      );
+      const b = t.blocks[0];
+      expect(b.live).toBe(true);
+      // 锚点 = 用户发问时刻(dmTurn 的 userMessage 是 T0)
+      expect(b.startAt).toBe(T0);
+      expect(elapsedSeconds(b, T0 + 1000)).toBe(1);
+      expect(elapsedSeconds(b, T0 + 2600)).withContext('不足一秒向下取整').toBe(2);
+      expect(elapsedSeconds(b, T0 + 3000)).toBe(3);
+      expect(elapsedSeconds(b, T0 + 5000)).toBe(5);
+    });
+
+    it('已结束:定格在用户发问 → 最后活动', () => {
+      const [t] = turnsFromDm(
+        [dmTurn([msg('a1', 'agent', 'x', 2000), msg('a2', 'agent', 'y', 9000)])],
+        T0 + 60_000,
+        false,
+      );
+      const b = t.blocks[0];
+      expect(b.live).toBe(false);
+      // 不再随时钟变化
+      expect(elapsedSeconds(b, T0 + 60_000)).toBe(9);
+      expect(elapsedSeconds(b, T0 + 120_000)).toBe(9);
+    });
+
+    it('容器真实耗时仍按首末 agent 消息算(不受用户等待影响)', () => {
+      const [t] = turnsFromDm(
+        [dmTurn([msg('a1', 'agent', 'x', 5000), msg('a2', 'agent', 'y', 12_000)])],
+        T0 + 60_000,
+        false,
+      );
+      expect(t.blocks[0].seconds).withContext('首末 agent 消息差').toBe(7);
+    });
+
+    it('无锚点(容器主动发言)→ 0,不显示', () => {
+      const [t] = turnsFromDm(
+        [dmTurn([msg('a1', 'agent', 'x', 1000)], { userMessage: undefined, startedAt: 0, userAskTimes: [] })],
+        0,
+        false,
+      );
+      /*
+       * 分轮后锚点取「本轮首条消息时刻」:无用户提问时就是容器开始说话的时刻。
+       * 纯图片轮(用户只发图不打字)也走这条路径,读秒从容器开始回算才合理。
+       */
+      expect(t.blocks[0].startAt).toBe(T0 + 1000);
+      /** 完全无时间数据时才为 0 */
+      expect(elapsedSeconds({ ...t.blocks[0], startAt: 0 }, T0 + 5000)).toBe(0);
+    });
+  });
+
   it('无 message 的回合 userMessage 为空', () => {
     const [t] = turnsFromDm([dmTurn([msg('a1', 'agent', 'x')], { userMessage: undefined })], 0, false);
     expect(t.userMessage).toBeUndefined();
@@ -288,3 +350,53 @@ describe('waitText(等待 / 输出占位文案)', () => {
     expect(waitText(30_000, false, false, true)).toBe('未响应');
   });
 });
+
+/**
+ * 分轮后的计时与状态(2026-11-10)
+ *
+ * 用户实测:图文被容器拆成两轮时,第一轮答完读秒还在涨,第二轮结束后突然倒退。
+ */
+describe('分轮后的用时读数', () => {
+  const twoRounds = (): Turn =>
+    dmTurn([msg('a1', 'agent', '第一轮', 2000), msg('a2', 'agent', '第二轮', 32_000)], {
+      rounds: [
+        [msg('a1', 'agent', '第一轮', 2000)],
+        [msg('a2', 'agent', '第二轮', 32_000)],
+      ],
+      userAskTimes: [T0 - 1000, T0 + 30_000],
+      closed: false,
+    });
+
+  it('🔴 两轮拆成两个块', () => {
+    const [t] = turnsFromDm([twoRounds()], T0 + 33_000, false);
+    expect(t.blocks.length).toBe(2);
+  });
+
+  it('🔴 各轮读秒锚点取「不晚于本轮首条消息的最后一个用户提问」', () => {
+    const [t] = turnsFromDm([twoRounds()], T0 + 33_000, false);
+    // 第一轮:锚点 = 第一次提问;第二轮:锚点 = 第二次提问
+    expect(t.blocks[0].startAt).toBe(T0 - 1000);
+    expect(t.blocks[1].startAt).toBe(T0 + 30_000);
+  });
+
+  it('🔴 先结束的轮次读数定格,不被后来的轮次拉高', () => {
+    const [t] = turnsFromDm([twoRounds()], T0 + 33_000, false);
+    const first = t.blocks[0];
+    // 第一轮:用户提问(T0-1000) → 末条消息(T0+2000) = 3 秒
+    expect(elapsedSeconds({ ...first, live: false }, T0 + 33_000)).toBe(3);
+    // 推进时钟也不变
+    expect(elapsedSeconds({ ...first, live: false }, T0 + 90_000)).toBe(3);
+  });
+
+  it('回合用时取最长的一轮', () => {
+    const [t] = turnsFromDm([twoRounds()], T0 + 33_000, false);
+    expect(t.seconds).toBeGreaterThanOrEqual(t.blocks[0].seconds);
+    expect(t.seconds).toBe(Math.max(...t.blocks.map((b) => b.seconds)));
+  });
+
+  it('没有 agent 消息时仍有一个占位块(等待提示有处可挂)', () => {
+    const [t] = turnsFromDm([dmTurn([], { rounds: [] })], T0 + 3000, false);
+    expect(t.blocks.length).toBe(1);
+    expect(t.blocks[0].startAt).toBe(T0);
+  });
+});

+ 133 - 31
src/app/core/assistant-turn.ts

@@ -55,8 +55,22 @@ export interface TurnBlockView {
   agentId: string | null;
   /** 群聊成员显示名(由宿主传入 `nameOf` 时才有) */
   agentName?: string;
-  /** 块内消息(单位:毫秒);0 表示无数据,不显示 */
+  /**
+   * 容器真实处理耗时(秒):首条 → 末条 agent 消息。
+   *
+   * 🔴 UI **不再直接读取**它(读数走 `elapsedSeconds()`),仅保留作数据/诊断用,
+   * 以及 `updatedAt <= startAt` 这种退化情形下的兜底。
+   */
   seconds: number;
+  /**
+   * 本块开始时刻(epoch ms;0 = 无锚点)。
+   *
+   * 2026-11-10 新增:秒数改为**逐秒现算**,锚点必须是时刻而不是秒数,
+   * 否则只能靠容器发新事件来「跳一下」。见 `elapsedSeconds()`。
+   */
+  startAt: number;
+  /** 本块最后活动时刻(epoch ms,0 = 无消息)—— 结束态定格用 */
+  updatedAt: number;
   /** 顶部过程区 */
   steps: ProcessStep[];
   /** 下半区正文 / 文件 / 任务 */
@@ -111,6 +125,7 @@ export interface AssistantTurn {
   userFiles: Message[];
   blocks: TurnBlockView[];
   phase: TurnPhase;
+  /** 容器真实处理耗时(秒,诊断用;UI 读数走 `elapsedSeconds(blocks[i], now)`) */
   seconds: number;
   live: boolean;
   closed: boolean;
@@ -366,15 +381,33 @@ function toBlockView(
   const idleFor = now - src.updatedAt;
   const typingAlive = src.typing && idleFor < quietMs * 2;
   const live = !src.closed && (awaitingFirstReply || typingAlive || idleFor < quietMs);
-  const seconds =
-    src.startedAt > 0 && src.updatedAt >= src.startedAt
-      ? Math.max(0, Math.round((src.updatedAt - src.startedAt) / 1000))
-      : 0;
+  /*
+   * 用时读数(2026-11-10 修复,用户要求「一秒一秒叠加」)。
+   *
+   * 🔴 原实现 `seconds = updatedAt - startedAt` 只在容器发新事件那一跳才变,
+   *    用户看到 5s → 18s → 30s 的跳变。现在拆成两个字段:
+   *
+   * | 字段 | 锚点 | 用途 |
+   * |---|---|---|
+   * | `startAt`   | **用户发出问话的时刻** | 头部与段头的实时读数(1→2→3… 每秒加) |
+   * | `seconds`   | 首条 → 末条 agent 消息 | 容器**真实处理耗时**,结束时定格,不跳变 |
+   *
+   * 用户读秒从「我发出去了」算起才对得上体感;容器耗时从它真正开始回算才是准的。
+   * 进行中的读数由渲染层用每秒时钟现算(`elapsedSeconds()`)。
+   */
+  const startAt = waitingSince > 0 ? waitingSince : src.startedAt;
 
   return {
     key: src.agentId ?? 'agent',
     agentId: src.agentId,
-    seconds,
+    /** 容器真实处理耗时(首条 → 末条 agent 消息),结束才有意义 */
+    seconds:
+      src.startedAt > 0 && src.updatedAt >= src.startedAt
+        ? Math.max(0, Math.round((src.updatedAt - src.startedAt) / 1000))
+        : 0,
+    /** 本块开始时刻(epoch ms,0 = 无锚点)—— 逐秒读数的锚点 */
+    startAt,
+    updatedAt: src.updatedAt,
     steps,
     body,
     files,
@@ -395,6 +428,37 @@ function phaseOf(blocks: TurnBlockView[], closed: boolean): TurnPhase {
   return blocks.some((b) => !b.empty) ? 'done' : 'idle';
 }
 
+/**
+ * 一块的**当前**用时秒数(2026-11-10 用户要求:一秒一秒叠加)。
+ *
+ * | 状态 | 返回值 |
+ * |---|---|
+ * | 无锚点(`startAt <= 0`) | 0,不显示 |
+ * | 已结束(`!live`) | 从用户发问到结束的总用时(定格) |
+ * | 进行中 | `floor((now - startAt) / 1000)`,**逐秒递增** |
+ *
+ * 🔴 进行中必须用实时时钟现算。原来直接用结束时刻相减,
+ *    只在容器发新事件那一跳才变,用户看到的是 5s → 18s → 30s 的跳变。
+ *
+ * 取整一律 `floor`:进行中与结束态用同一种口径,结束瞬间才不会倒退 1 秒。
+ *
+ * @param block 块视图
+ * @param now   当前时间(组件每秒刷新一次的那个时钟)
+ */
+export function elapsedSeconds(block: TurnBlockView, now: number): number {
+  if (block.startAt <= 0) return 0;
+  /*
+   * 结束态 = 用户发问 → 末条 agent 消息(体感用时)。
+   * `block.seconds` 是「首条 → 末条 agent」,起点不同,不可与之相加。
+   */
+  if (!block.live) {
+    return block.updatedAt > block.startAt
+      ? Math.max(0, Math.floor((block.updatedAt - block.startAt) / 1000))
+      : block.seconds;
+  }
+  return Math.max(0, Math.floor((now - block.startAt) / 1000));
+}
+
 /**
  * 等待中的占位文案(2026-11-09 用户裁决)。
  *
@@ -507,37 +571,75 @@ export function turnsFromDm(
      * (后者在无 agent 消息时等于用户消息时间,但语义上不是「最后活动」)。
      */
     const awaitingFirstReply = !!t.userMessage && t.blocks.length === 0 && !t.closed;
-    const block = toBlockView(
-      {
-        agentId: null,
-        messages: t.blocks,
-        startedAt: t.startedAt,
-        updatedAt: t.updatedAt,
-        typing,
-        closed: t.closed,
-      },
-      now,
-      /* 容器明确空闲 → 不必再等静默窗口,立刻收尾 */
-      agentIdle ? 0 : quietMs,
-      /* 等待时长从用户发出那条消息算起 */
-      t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0,
-      awaitingFirstReply,
-    );
-    const live = block.live;
+    const userAsk = t.userMessage ? Date.parse(t.userMessage.ts) || 0 : 0;
+    /*
+     * 🔴 按「轮次」拆块(2026-11-10)。
+     *
+     * 容器对「图 + 文字」会各跑一轮,两轮在消息流里没有分界标记。
+     * 每轮单独成块 → 各自独立判断 live、独立计时、占位提示只出现在
+     * 「正在跑的那一轮」下面,不再挂到上一轮已答完的正文后面。
+     */
+    const rounds = t.rounds && t.rounds.length > 0 ? t.rounds : t.blocks.length > 0 ? [t.blocks] : [];
+    /*
+     * 没有 agent 消息时也要有**一个**占位块,否则「已发出、等回复」的标志
+     * (`awaitingFirstReply`)无处挂载,卡片会整张消失。
+     */
+    const blocks = rounds.length
+      ? rounds.map((msgs) => {
+          const times = msgs.map((m) => Date.parse(m.ts) || 0).filter((n) => n > 0);
+          const first = times.length ? Math.min(...times) : 0;
+          const last = times.length ? Math.max(...times) : 0;
+          /*
+           * 本轮读秒锚点 = **不晚于本轮首条消息的最后一个用户提问时刻**。
+           * 图文被拆成两条时:图片那轮用图片的发出时刻,文字那轮用文字的发出时刻,
+           * 两轮读数各算各的,不会互相拉高,也不会在结束瞬间倒退。
+           */
+          const asks = (t.userAskTimes ?? []).filter((a) => a > 0 && (first === 0 || a <= first));
+          const startAt = asks.length ? Math.max(...asks) : first || userAsk;
+          return toBlockView(
+            {
+              agentId: null,
+              messages: msgs,
+              startedAt: first,
+              updatedAt: last,
+              typing,
+              closed: t.closed,
+            },
+            now,
+            agentIdle ? 0 : quietMs,
+            startAt,
+            false,
+          );
+        })
+      : [
+          toBlockView(
+            { agentId: null, messages: [], startedAt: 0, updatedAt: 0, typing, closed: t.closed },
+            now,
+            agentIdle ? 0 : quietMs,
+            userAsk,
+            awaitingFirstReply,
+          ),
+        ];
+    const live = blocks.some((b) => b.live);
 
     return {
       id: t.id,
       userMessage: t.userMessage,
-      userFiles: [],
-      blocks: [block],
-      phase: phaseOf([block], t.closed),
-      seconds: block.seconds,
+      userFiles: t.userFiles ?? [],
+      blocks,
+      phase: phaseOf(blocks, t.closed),
+      /* 回合用时取最长的块(各轮并行跑,按最长那轮算体感) */
+      seconds: blocks.reduce((max, b) => Math.max(max, b.seconds), 0),
       live,
       closed: t.closed,
-      stepCount: block.steps.length,
-      fileCount: block.files.length,
-      merged: mergeBodies([block]),
-      files: collectFiles([block]),
+      stepCount: blocks.reduce((n, b) => n + b.steps.length, 0),
+      fileCount: blocks.reduce((n, b) => n + b.files.length, 0),
+      /*
+       * 单聊的正文仍**合并成一份**(2026-11-07 用户要求「一整轮一张卡」)。
+       * 拆轮只为了分别计时与分别判定状态,不改变阅读形态。
+       */
+      merged: mergeBodies(blocks),
+      files: collectFiles(blocks),
     };
   });
 }

+ 6 - 4
src/app/core/chat.service.spec.ts

@@ -756,14 +756,14 @@ describe('ChatService', () => {
       expect(svc.pendingFiles()).toEqual([]);
     });
 
-    it('发送时上传附件并追加文件卡片消息', async () => {
+    it('发送时上传附件(走 Matrix 原生事件),不在本地伪造文件消息', async () => {
       realMode();
       await svc.open('agent-node-lj-test');
       svc.stageFiles([file()]);
       await svc.sendText('看看这个');
 
-      const last = svc.messages().at(-1)!;
-      expect(last.file?.name).toBe('doc.txt');
+      // 2026-11-10:附件气泡以回读的 m.image 为准,本地不再插 local-file-* 消息
+      expect(svc.messages().some((m) => m.id.startsWith('local-file-'))).toBeFalse();
       // 走 Matrix 原生附件,不再依赖 OBS 预签名地址
       expect(mxStub.sendFile).toHaveBeenCalled();
       expect(svc.uploading()).toBeFalse();
@@ -800,11 +800,13 @@ describe('ChatService', () => {
     it('只有附件没有文字也能发送', async () => {
       realMode();
       await svc.open('agent-node-lj-test');
+      mxStub.sendFile.calls.reset();
       mxStub.sendText.calls.reset();
       svc.stageFiles([file()]);
       await svc.sendText('');
 
-      expect(svc.messages().some((m) => m.file?.name === 'doc.txt')).toBeTrue();
+      // 纯附件:附件本身照常发到房间(本地不伪造文件消息)
+      expect(mxStub.sendFile).toHaveBeenCalled();
       // 纯附件不该发一条空文字
       expect(mxStub.sendText).not.toHaveBeenCalled();
     });

+ 69 - 33
src/app/core/chat.service.ts

@@ -12,7 +12,7 @@ import {
   MatrixProvision,
 } from './api/models';
 import { DEMO_MESSAGES } from './api/demo-data';
-import { extractS3Links, stripLinks } from './s3-link';
+import { extractS3Links, isUserUploadUrl, stripLinks } from './s3-link';
 import { DeclaredFile, parseDeclaredFiles, stripDeclaredFiles } from './declared-files';
 import { environment } from '../../environments/environment';
 import { LiveService } from './live.service';
@@ -132,6 +132,21 @@ export class ChatService {
   private readonly _uploadPct = signal(0);
   private readonly _draft = signal('');
   private readonly _peerTyping = signal(false);
+
+  /**
+   * 「文件名|大小」→ 云盘地址(2026-11-10)。
+   *
+   * 用户附件发送时 Matrix 与 S3 各存一份:Matrix 那份给容器读,
+   * S3 那份给 App 长期查看(换设备、跨端)。但回读的 `m.image` 事件里
+   * 只有 `mxc://`,没有云盘地址 —— 用这张表在回读时补回 `cloudUrl`。
+   * 仅本进程有效;缺失时附件照常可看(走 Matrix 媒体),不影响发送。
+   */
+  private readonly _cloudUrls = new Map<string, string>();
+
+  /** 云盘地址映射的键:同名同大小的文件视为同一个(Matrix echo 不带本地 id) */
+  private cloudKeyOf(name: string, size: number): string {
+    return `${name}|${size}`;
+  }
   /** 正在加载更早历史(防重入,AC-04.4) */
   private readonly _paginating = signal(false);
   /** 是否可能还有更早历史 */
@@ -941,10 +956,14 @@ export class ChatService {
       /* 与实时路径同口径:优先声明段,否则正则兜底 + 文件判定 */
       const declared = parseDeclaredFiles(m.text);
       if (declared !== null) {
-        for (const d of declared) this.sessions.addDeliverableUrl(sid, d.url);
+        for (const d of declared) {
+          if (!isUserUploadUrl(d.url)) this.sessions.addDeliverableUrl(sid, d.url);
+        }
       } else {
         for (const u of extractS3Links(m.text)) {
-          if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
+          if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
+            this.sessions.addDeliverableUrl(sid, u);
+          }
         }
       }
     }
@@ -1560,10 +1579,17 @@ export class ChatService {
             const up = await this.api.uploadFile(file);
             cloudUrl = up.publicUrl;
             /*
-             * 记进会话的成果列表(2026-11-09):用户要求的「成果按对话归档」。
-             * 上传成功立刻写,失败则只有 Matrix 那份,不记链接。
+             * 🔴 用户上传的文件**不记进会话成果列表**(2026-11-10 修复)。
+             *
+             * 原实现这里直接 `addDeliverableUrl(sid, cloudUrl)`,
+             * 结果自己发的图同时出现在右栏「文件」和「成果」两处
+             * (用户实测截图)。「成果」= 容器产出的东西,
+             * 用户文件只属于「文件」页签(由消息流 sender='me' 渲染)。
+             *
+             * 云盘地址本身仍有用:回读的 `m.image` 只有 `mxc://`,
+             * 这里记进 `_cloudUrls`,回读时补回 `FileCard.cloudUrl`(2026-11-10)。
              */
-            if (sid) this.sessions.addDeliverableUrl(sid, cloudUrl);
+            if (cloudUrl) this._cloudUrls.set(this.cloudKeyOf(file.name, file.size), cloudUrl);
           } catch (e) {
             /* 如实记录:这条文件只在 Matrix,没进云盘 */
             this._error.set(
@@ -1571,29 +1597,17 @@ export class ChatService {
             );
           }
           this._uploadPct.set(Math.round(((i + 1) / pending.length) * 100));
-          // 本地预览用对象地址;Matrix 的 mxc 要等回读后才能转 http
-          const displayUrl = URL.createObjectURL(file);
-          const card: FileCard = {
-            kind: 'file',
-            name: file.name,
-            size: file.size,
-            mimeType,
-            url: displayUrl,
-            /* 云盘地址(2026-11-09):有这个就说明两边都有,可在成果里长期访问 */
-            cloudUrl: cloudUrl ?? undefined,
-          };
-          this._messages.update((m) => [
-            ...m,
-            {
-              id: `local-file-${Date.now()}-${file.name}`,
-              conversationId,
-              sender: 'me',
-              kind: 'text',
-              file: card,
-              ts: new Date().toISOString(),
-              state: 'sent',
-            },
-          ]);
+          /*
+           * 🔴 不再本地插入「文件消息」(2026-11-10 修复)。
+           *
+           * 原实现在这里造一条 `local-file-*` 的 me 消息,同时 Matrix 的
+           * `m.image` echo 回读后又落一条 —— 配合回合聚合「每条 me 开新回合」,
+           * 一次发送配图就出现两个用户气泡(用户实测截图)。
+           *
+           * 现在:即时反馈交给输入框的上传进度(`uploading()` / `uploadPct()`),
+           * 附件气泡一律以回读的 `m.image` 为准,与文字合并成同一回合。
+           * 回读一般在秒级内到达,期间用户看到的是上传进度,不是空白。
+           */
           ok++;
         } catch {
           fail++;
@@ -2121,12 +2135,19 @@ export class ChatService {
               size: file.size,
               mimeType: file.mimeType,
               url: file.url,
+              /*
+               * 我自己刚发的附件:回读只有 `mxc://`,用发送时记下的云盘地址补上,
+               * 右栏「文件」才能长期访问(2026-11-10)。
+               */
+              cloudUrl: this._cloudUrls.get(this.cloudKeyOf(file.name, file.size)),
             }
           : linkCard,
         ts: new Date(m.ts).toISOString(),
         state: 'sent' as const,
         // 透传本地 id:合并时用它认领乐观消息(见 mergeTimeline)
         localId: m.localId,
+        // 透传「在回复哪一条」:回合聚合据此把回复归到正确的提问(2026-11-10)
+        replyTo: m.replyTo,
       };
     });
   }
@@ -2307,9 +2328,20 @@ export class ChatService {
     /*
      * 容器发来的附件如果是云盘直链,记进本会话的成果列表(2026-11-09)。
      * 用户要求「成果按对话归档」:只有出现在这条对话里的文件才算它的成果。
+     *
+     * 🔴 必须判 `sender === 'agent'`(2026-11-10 修复)。
+     *    原实现没判,用户自己发的附件回读(`sender='me'`)也会被记成成果,
+     *    右栏「成果」于是出现了用户图片。下方声明段/正文链接的处理本来就按
+     *    agent 过滤,这里与它口径对齐。
      */
-    const fileUrl = mapped.file?.cloudUrl || (mapped.file?.url?.startsWith('https://s3.') ? mapped.file.url : undefined);
-    if (fileUrl) this.sessions.addDeliverableUrl(sid, fileUrl);
+    if (mapped.sender === 'agent') {
+      const fileUrl =
+        mapped.file?.cloudUrl ||
+        (mapped.file?.url?.startsWith('https://s3.') ? mapped.file.url : undefined);
+      if (fileUrl && !isUserUploadUrl(fileUrl)) {
+        this.sessions.addDeliverableUrl(sid, fileUrl);
+      }
+    }
     /*
      * 成果链接记入会话(2026-11-10 起按优先级):
      *
@@ -2322,10 +2354,14 @@ export class ChatService {
     if (mapped.sender === 'agent') {
       const declared = parseDeclaredFiles(m.body);
       if (declared !== null) {
-        for (const d of declared) this.sessions.addDeliverableUrl(sid, d.url);
+        for (const d of declared) {
+          if (!isUserUploadUrl(d.url)) this.sessions.addDeliverableUrl(sid, d.url);
+        }
       } else {
         for (const u of extractS3Links(mapped.text)) {
-          if (this.cardOfCloudLink(u)) this.sessions.addDeliverableUrl(sid, u);
+          if (!isUserUploadUrl(u) && this.cardOfCloudLink(u)) {
+            this.sessions.addDeliverableUrl(sid, u);
+          }
         }
       }
     }

+ 11 - 7
src/app/core/deliverables.service.spec.ts

@@ -225,15 +225,13 @@ describe('DeliverablesService', () => {
      *    只存在于会话记录的 deliverableUrls 中。原实现只查表,
      *    导致用户上传的文件在「成果」里永远看不到。
      */
-    it('🔴 用户上传的文件(不在成果表里)也要按 URL 显示出来', () => {
+    it('🔴 用户上传的文件(/user/…/chat/…)**不计入成果**', () => {
       const userFile = 'https://s3.fmode.cn/user/u/chat/202611/u-1794-e2e.txt';
       const out = svc.bySessionUrls([userFile]);
-      expect(out.length).withContext('不在表里也要显示').toBe(1);
-      expect(out[0].artifacts[0].url).toBe(userFile);
-      expect(out[0].filename).withContext('要有文件名,否则右栏显示「暂无附件」').toBe('u-1794-e2e.txt');
+      expect(out.length).withContext('用户上传走「文件」页签,不进成果').toBe(0);
     });
 
-    it('🔴 成果表里的链接 + 用户上传的文件 可以混排', async () => {
+    it('🔴 成果表里的链接照常显示,用户上传的文件被挡掉', async () => {
       apiStub.fetchDeliverables.and.returnValue(
         Promise.resolve([
           d('s1', '容器成果', '2026-10-01', {
@@ -244,9 +242,15 @@ describe('DeliverablesService', () => {
       await svc.load();
       const userFile = 'https://s3.fmode.cn/user/u/chat/x.txt';
       const out = svc.bySessionUrls(['https://s3.fmode.cn/d/a.md', userFile]);
-      expect(out.length).toBe(2);
+      expect(out.length).withContext('只保留容器成果').toBe(1);
       expect(out[0].title).withContext('表里的用富信息').toBe('容器成果');
-      expect(out[1].artifacts[0].url).toBe(userFile);
+    });
+
+    it('成果表里没有的**非用户上传**链接仍按 URL 造最小记录', () => {
+      const link = 'https://s3.fmode.cn/other/location/b.txt';
+      const out = svc.bySessionUrls([link]);
+      expect(out.length).toBe(1);
+      expect(out[0].filename).withContext('要有文件名,否则右栏显示「暂无附件」').toBe('b.txt');
     });
   });
 });

+ 13 - 3
src/app/core/deliverables.service.ts

@@ -9,6 +9,7 @@ import {
   Project,
 } from './api/models';
 import { LiveService } from './live.service';
+import { isUserUploadUrl } from './s3-link';
 
 /**
  * 交付成果服务 —— 成果域的**唯一 owner**
@@ -153,9 +154,10 @@ export class DeliverablesService {
    *
    * ## 数据来源
    *
-   * `deliverableUrls` 里有两个来源,**都要显示**:
+   * `deliverableUrls` 里的链接分两类,**只有第一类算成果**:
    *   ① 容器产出的成果 → 能在 `deliverables` 表里匹配到,有标题/版本等富信息
-   *   ② **用户上传的文件** → 压根不在 `deliverables` 表里,只有 URL
+   *   ② 历史数据里混入的用户上传链接(`/user/<uid>/chat/…`)→ **一律丢弃**,
+   *      它们由消息流(`sender === 'me'`)在「文件」页签渲染
    *
    * 🔴 **宁缺勿错**:没有 `urls` 就返回空,绝不回退到「整个容器的成果」。
    *
@@ -165,6 +167,14 @@ export class DeliverablesService {
     if (!urls || urls.length === 0) return [];
     const out: Deliverable[] = [];
     for (const url of urls) {
+      /*
+       * 🔴 用户上传的文件不得计入成果(2026-11-10 修复)。
+       *
+       * 线上存量数据里混着用户自己上传的链接(历史版本无差别写入),
+       * 不在这里挡一道,右栏「成果」会继续显示用户自己发的图。
+       * 主修在写入端(`chat.service.ts` 只认 sender='agent'),此处是兜底。
+       */
+      if (isUserUploadUrl(url)) continue;
       // ① 先在成果表里找完整记录
       const hit = this._items().find((d) => d.artifacts.some((a) => a.url === url));
       if (hit) {
@@ -173,7 +183,7 @@ export class DeliverablesService {
         out.push({ ...hit, artifacts: only.length ? only : hit.artifacts });
         continue;
       }
-      // ② 成果表里没有(用户上传的文件)→ 按 URL 造一条最小记录
+      // ② 成果表里没有 → 按 URL 造一条最小记录
       const name = fileNameOfUrl(url);
       out.push({
         id: `url:${url}`,

+ 131 - 0
src/app/core/device.service.spec.ts

@@ -224,3 +224,134 @@ describe('DeviceService', () => {
     });
   });
 });
+
+/**
+ * 跨端提示(2026-11-10 用户裁决)
+ *
+ * 缩放到另一端时**自动判断并提示**,点确认才切;拖拽窗口要有防抖,不能反复闪。
+ */
+describe('DeviceService · 跨端提示', () => {
+  const DESKTOP_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)';
+
+  let listeners: ((e: unknown) => void)[] = [];
+  const origMatch = window.matchMedia;
+  const origWidth = Object.getOwnPropertyDescriptor(window, 'innerWidth');
+  const origUA = Object.getOwnPropertyDescriptor(navigator, 'userAgent');
+
+  beforeEach(() => {
+    localStorage.clear();
+    listeners = [];
+    Object.defineProperty(navigator, 'userAgent', { value: DESKTOP_UA, configurable: true });
+    Object.defineProperty(window, 'innerWidth', { value: 1440, configurable: true });
+    window.matchMedia = ((_q: string) =>
+      ({
+        matches: false,
+        media: _q,
+        addEventListener: (_: string, cb: (e: unknown) => void) => listeners.push(cb),
+        removeEventListener: () => undefined,
+      }) as unknown as MediaQueryList) as typeof window.matchMedia;
+  });
+
+  afterEach(() => {
+    if (origMatch) window.matchMedia = origMatch;
+    if (origWidth) Object.defineProperty(window, 'innerWidth', origWidth);
+    if (origUA) Object.defineProperty(navigator, 'userAgent', origUA);
+    jasmine.clock().uninstall();
+    localStorage.clear();
+  });
+
+  function setWidth(w: number): void {
+    Object.defineProperty(window, 'innerWidth', { value: w, configurable: true });
+  }
+
+  it('宽视口下桌面端 → 不提示', () => {
+    setWidth(1440);
+    const svc = new DeviceService();
+    svc.watchViewport(() => undefined);
+    expect(svc.suggestedMode()).toBeNull();
+  });
+
+  it('🔴 窗口缩到 768 以下(桌面端)→ 建议切移动端', () => {
+    setWidth(1440);
+    const svc = new DeviceService();
+    svc.watchViewport(() => undefined);
+    setWidth(600);
+    svc['evaluate']();
+    expect(svc.suggestedMode()).toBe('mobile');
+  });
+
+  it('🔴 防抖:短时间内多次变化只生效一次', () => {
+    jasmine.clock().install();
+    setWidth(1440);
+    const svc = new DeviceService();
+    const onChange = jasmine.createSpy('onChange');
+    svc.watchViewport(onChange);
+
+    // 连续三次跨断点(模拟拖拽)
+    listeners.forEach((cb) => cb({}));
+    listeners.forEach((cb) => cb({}));
+    listeners.forEach((cb) => cb({}));
+    expect(onChange).not.toHaveBeenCalled();
+
+    jasmine.clock().tick(DeviceService.DEBOUNCE_MS + 1);
+    expect(onChange).toHaveBeenCalledTimes(1);
+  });
+
+  it('🔴 手动切换过仍会提示(手动只代表「这次选哪端」,不代表「别再问」)', () => {
+    setWidth(1440);
+    const svc = new DeviceService();
+    svc.force('desktop');
+    svc.watchViewport(() => undefined);
+    setWidth(600);
+    svc['evaluate']();
+    expect(svc.suggestedMode()).withContext('不能因为偏好锁死就永不提示').toBe('mobile');
+  });
+
+  it('🔴 切到移动端后把窗口放大 → 应提示切回桌面端(用户实测的反向场景)', () => {
+    setWidth(1440);
+    const svc = new DeviceService();
+    svc.watchViewport(() => undefined);
+
+    // ① 缩窄 → 建议切移动端
+    setWidth(600);
+    svc['evaluate']();
+    expect(svc.suggestedMode()).toBe('mobile');
+
+    // ② 用户点了「切换」→ 偏好写成 mobile
+    svc.force('mobile');
+    expect(svc.suggestedMode()).withContext('刚切完,当前端与视口一致,无需再提示').toBeNull();
+
+    // ③ 窗口放大回桌面尺寸 → 必须提示切回桌面端
+    setWidth(1440);
+    svc['evaluate']();
+    expect(svc.suggestedMode()).toBe('desktop');
+  });
+
+  it('点「稍后」后本次不再提示,但重新跨断点仍会提示', () => {
+    setWidth(1440);
+    const svc = new DeviceService();
+    svc.watchViewport(() => undefined);
+    setWidth(600);
+    svc['evaluate']();
+    expect(svc.suggestedMode()).toBe('mobile');
+
+    svc.dismissSuggestion();
+    expect(svc.suggestedMode()).toBeNull();
+
+    svc['evaluate']();
+    expect(svc.suggestedMode()).withContext('再次跨断点应重新提示').toBe('mobile');
+  });
+
+  it('移动端 + 宽视口 → 建议切桌面端', () => {
+    Object.defineProperty(navigator, 'userAgent', {
+      value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)',
+      configurable: true,
+    });
+    setWidth(390);
+    const svc = new DeviceService();
+    svc.watchViewport(() => undefined);
+    setWidth(1200);
+    svc['evaluate']();
+    expect(svc.suggestedMode()).toBe('desktop');
+  });
+});

+ 146 - 15
src/app/core/device.service.ts

@@ -8,6 +8,45 @@ const MODE_KEY = 'fmode_ui_mode_override';
 /** 窄屏阈值 —— 与 SPEC NFR-05 断点定值的 768 一致,避免两套窄屏判定 */
 export const NARROW_PX = 768;
 
+/**
+ * 依据 UA + 视口推断该用哪端(纯函数,2026-11-10 导出)。
+ *
+ * 🔴 抽成导出的纯函数是**为了消除重复**:`app.routes.ts` 曾逐字复制一份,
+ *    两处分支一旦不同步,就会出现「登录页判移动、入口判桌面」。
+ *    现在路由与 `DeviceService` 都调这一个。
+ */
+export function detectUiMode(): UiMode {
+  if (typeof navigator === 'undefined') return 'desktop';
+  const ua = navigator.userAgent;
+
+  // ① 平板(含 iPadOS 伪装的桌面 UA)→ 桌面端
+  if (/iPad|Tablet|PlayBook|Silk/i.test(ua)) return 'desktop';
+  if (/Android/i.test(ua) && !/Mobile/i.test(ua)) return 'desktop';
+
+  // ② 手机 UA → 移动端
+  if (/iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry|webOS|Opera Mini/i.test(ua)) {
+    return 'mobile';
+  }
+
+  // ③ 触屏设备且窗口窄 → 移动端
+  const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
+  if (coarse && typeof window !== 'undefined' && window.innerWidth < NARROW_PX) return 'mobile';
+
+  // ④ 默认桌面端
+  return 'desktop';
+}
+
+/**
+ * 当前视口是不是「窄屏」(该给移动端)。
+ *
+ * 与 `detectUiMode()` 的区别:**只看宽度,不看 UA**。
+ * 用于「桌面浏览器把窗口拖窄」的场景 —— 这种情形 UA 是桌面,
+ * 但布局已经装不下桌面端,应该提示用户切到移动端。
+ */
+export function isNarrowViewport(): boolean {
+  return typeof window !== 'undefined' && window.innerWidth < NARROW_PX;
+}
+
 /**
  * 端判定与路径归一 —— **全项目唯一事实源**
  *
@@ -15,6 +54,11 @@ export const NARROW_PX = 768;
  *   · 端判定**一次性**发生在入口页,之后不随窗口缩放变化(避免布局跳变)
  *   · `normalize()` 是**旧路径重定向的唯一事实源**(FR-16 AC-16.2 引用的清单实现在此)
  *   · 手动切端写 `localStorage['fmode_ui_mode_override']`;「恢复自动识别」清除该键
+ *
+ * 🔴 2026-11-10 变更:端**仍然不会静默跳变**,但新增了「缩放到另一端时提示」——
+ *    `watchViewport()` 在跨断点时算出 `suggestedMode`,宿主弹提示,
+ *    **用户点确认**才 `switchMode()`。这样既满足「不跳变」,
+ *    又不会出现「窗口已经窄到装不下却还硬撑桌面布局」。
  */
 @Injectable({ providedIn: 'root' })
 export class DeviceService {
@@ -31,26 +75,106 @@ export class DeviceService {
   /** 是否处于手动强制(覆盖了自动识别) */
   readonly isForced = signal(this.hasOverride());
 
-  /** 依据 UA + 视口推断(纯函数式优先级,顺序不可调换) */
-  detect(): UiMode {
-    if (typeof navigator === 'undefined') return 'desktop';
-    const ua = navigator.userAgent;
+  /**
+   * 跨端提示的**建议目标端**(null = 不需要提示)。
+   *
+   * 🔴 2026-11-10 用户裁决:缩放到另一端时**自动判断并提示**,点确认才切。
+   *
+   * 规则:
+   *   · 手动强制过(`isForced()`)→ 永不提示,尊重用户选择
+   *   · 当前在桌面端、视口窄于 `NARROW_PX` → 建议切到移动端
+   *   · 当前在移动端、视口宽于等于 `NARROW_PX` → 建议切到桌面端
+   *   · 否则 null
+   */
+  readonly suggestedMode = signal<UiMode | null>(null);
+
+  /** resize → 判定的防抖时长;拖拽窗口时只触发一次 */
+  static readonly DEBOUNCE_MS = 250;
+
+  private mq: MediaQueryList | null = null;
+  private timer: ReturnType<typeof setTimeout> | null = null;
+  private mqListener: (() => void) | null = null;
+  private onChange: (() => void) | null = null;
 
-    // ① 平板(含 iPadOS 伪装的桌面 UA)→ 桌面端
-    if (/iPad|Tablet|PlayBook|Silk/i.test(ua)) return 'desktop';
-    if (/Android/i.test(ua) && !/Mobile/i.test(ua)) return 'desktop';
 
-    // ② 手机 UA → 移动端
-    if (/iPhone|iPod|Android.*Mobile|Windows Phone|BlackBerry|webOS|Opera Mini/i.test(ua)) {
-      return 'mobile';
+  /**
+   * 开始监听视口变化。
+   *
+   * 🔴 每次调用都**替换**回调,而不是「已存在就跳过」(2026-11-10 修正)。
+   *    外壳是切换端时重建的:桌面壳注册过监听后,切到移动端时新外壳再调用
+   *    `watchViewport` 会被旧的幂等判断挡掉 → 回调仍指向**已销毁的桌面壳**,
+   *    移动端永远收不到「切回桌面」的提示(用户实测)。
+   *
+   *    用 `matchMedia` 而不是裸 `resize`:change 只在跨断点时触发,
+   *    不会有拖拽窗口时每像素一次的高频回调;外层仍加防抖兜底。
+   */
+  watchViewport(onChange: () => void): void {
+    if (typeof window === 'undefined') return;
+    this.teardown();
+    this.onChange = onChange;
+    this.mq = window.matchMedia(`(max-width: ${NARROW_PX - 1}px)`);
+    this.mqListener = () => this.scheduleEvaluate();
+    this.mq.addEventListener('change', this.mqListener);
+    this.evaluate();
+  }
+
+  /** 停止监听(宿主销毁时调用) */
+  stopWatching(): void {
+    this.teardown();
+  }
+
+  /** 拆掉监听与定时器(幂等) */
+  private teardown(): void {
+    if (this.mq && this.mqListener) {
+      this.mq.removeEventListener('change', this.mqListener);
+    }
+    this.mq = null;
+    this.mqListener = null;
+    this.onChange = null;
+    if (this.timer) {
+      clearTimeout(this.timer);
+      this.timer = null;
     }
+  }
 
-    // ③ 桌面 UA 但触屏细窄窗 → 移动端
-    const coarse = typeof matchMedia === 'function' && matchMedia('(pointer: coarse)').matches;
-    if (coarse && window.innerWidth < NARROW_PX) return 'mobile';
+  /** 依据 UA + 视口推断当前该用哪端 */
+  detect(): UiMode {
+    return detectUiMode();
+  }
+
+  /** 用户点了「稍后/关闭」→ 本次不再提示;下次跨断点仍会提示 */
+  dismissSuggestion(): void {
+    this.suggestedMode.set(null);
+  }
+
+  private scheduleEvaluate(): void {
+    if (this.timer) clearTimeout(this.timer);
+    this.timer = setTimeout(() => {
+      this.timer = null;
+      this.evaluate();
+      /* 回调可能已随宿主销毁被清空 —— 调用前判一次,别炸在已销毁组件上 */
+      this.onChange?.();
+    }, DeviceService.DEBOUNCE_MS);
+  }
 
-    // ④ 默认桌面端
-    return 'desktop';
+  /** 按当前视口与当前端算出建议值 */
+  private evaluate(): void {
+    /*
+     * 🔴 手动切换过**也要继续提示**(2026-11-10 用户实测修正)。
+     *
+     * 原实现这里有一句 `if (isForced()) return` —— 用户点过一次「切换」后,
+     * 偏好被写成固定值,从此**再也不会提示**:
+     *   桌面 → 切到移动端 → 把窗口放大回桌面尺寸 → 界面仍是移动端且毫无提示,
+     *   用户以为功能坏了,只能去设置里找「恢复自动识别」。
+     *
+     * 正确语义:手动切换只是「这次我选了哪端」,不代表「以后别再问我」。
+     * 跨断点照样提示,点一下就能切回去。
+     */
+    const narrow = isNarrowViewport();
+    const cur = this._mode();
+    if (cur === 'desktop' && narrow) this.suggestedMode.set('mobile');
+    else if (cur === 'mobile' && !narrow) this.suggestedMode.set('desktop');
+    else this.suggestedMode.set(null);
   }
 
   /** 入口页落点 */
@@ -67,6 +191,11 @@ export class DeviceService {
     }
     this._mode.set(mode);
     this.isForced.set(true);
+    /*
+     * 切完立刻重算:此刻当前端已与视口一致,建议值应变回 null,
+     * 否则提示会继续挂着(「已切过去却还在问要不要切」)。
+     */
+    this.evaluate();
   }
 
   /** 恢复自动识别(清除偏好) */
@@ -79,6 +208,8 @@ export class DeviceService {
     const mode = this.detect();
     this._mode.set(mode);
     this.isForced.set(false);
+    /* 回到自动后当前端已随视口,建议值应清空 */
+    this.evaluate();
     return mode;
   }
 

+ 56 - 0
src/app/core/group-store.service.spec.ts

@@ -1,5 +1,6 @@
 import { TestBed } from '@angular/core/testing';
 import { Message } from './api/models';
+import { ChatSessionService } from './chat-session.service';
 import {
   GroupStoreService,
   MAX_ENTRIES_PER_GROUP,
@@ -309,3 +310,58 @@ describe('未加载时不覆盖已有数据(评审 🔴1 回归)', () => {
     expect(raw.entries.g1[0].message.text).toBe('旧消息');
   });
 });
+
+/**
+ * 群会话的成果链接写入(2026-11-10)
+ *
+ * 原来群会话的 `deliverableUrls` 永远是空的,右栏「成果」只能退化成
+ * 「按成员容器拉全量」,成员在别的单聊里产出的文件也会混进来。
+ */
+describe('GroupStoreService · 成果链接写入群会话', () => {
+  let store: GroupStoreService;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({ providers: [SessionService, GroupStoreService] });
+    store = TestBed.inject(GroupStoreService);
+  });
+
+  function fileReply(id: string, url: string, cloudUrl?: string): Message {
+    return {
+      ...replyMsg(id),
+      file: { kind: 'file', name: 'r.md', size: 1, mimeType: 'text/markdown', url, cloudUrl },
+    };
+  }
+
+  it('🔴 容器产出(/deliverables/)→ 写进群会话成果', () => {
+    const spy = spyOn(TestBed.inject(ChatSessionService), 'addDeliverableUrl');
+    store.appendMine('g1', '做个报告', ['a']);
+    store.appendReply('g1', 'a', fileReply('e1', 'https://s3.fmode.cn/deliverables/a/202611/r.md'), Date.now());
+    expect(spy).toHaveBeenCalledWith('g1', 'https://s3.fmode.cn/deliverables/a/202611/r.md');
+  });
+
+  it('🔴 用户上传的链接(/user/…/chat/…)→ 不写入成果', () => {
+    const spy = spyOn(TestBed.inject(ChatSessionService), 'addDeliverableUrl');
+    store.appendMine('g1', '看这个', ['a']);
+    store.appendReply('g1', 'a', fileReply('e1', 'https://s3.fmode.cn/user/u/chat/202611/x.png'), Date.now());
+    expect(spy).not.toHaveBeenCalled();
+  });
+
+  it('优先使用云盘地址(cloudUrl)', () => {
+    const spy = spyOn(TestBed.inject(ChatSessionService), 'addDeliverableUrl');
+    store.appendMine('g1', '做个报告', ['a']);
+    store.appendReply(
+      'g1',
+      'a',
+      fileReply('e1', 'https://matrix.fmode.cn/_matrix/media/x', 'https://s3.fmode.cn/deliverables/a/b.md'),
+      Date.now(),
+    );
+    expect(spy).toHaveBeenCalledWith('g1', 'https://s3.fmode.cn/deliverables/a/b.md');
+  });
+
+  it('没有文件 / 没有 url 的回复不写入', () => {
+    const spy = spyOn(TestBed.inject(ChatSessionService), 'addDeliverableUrl');
+    store.appendMine('g1', '你好', ['a']);
+    store.appendReply('g1', 'a', replyMsg('e1', '纯文本'), Date.now());
+    expect(spy).not.toHaveBeenCalled();
+  });
+});

+ 13 - 0
src/app/core/group-store.service.ts

@@ -3,6 +3,8 @@ import { Message } from './api/models';
 import { GroupEntry } from './group-turns';
 import { turnIdAt } from './group-matrix-transcript';
 import { SessionService } from './session.service';
+import { ChatSessionService } from './chat-session.service';
+import { isUserUploadUrl } from './s3-link';
 
 /**
  * 群聊消息存储(2026-11-05 新增)
@@ -51,6 +53,8 @@ interface StoredEntry extends GroupEntry {
 @Injectable({ providedIn: 'root' })
 export class GroupStoreService {
   private readonly session = inject(SessionService);
+  /** 群会话的成果链接写入(2026-11-10,与单聊同口径) */
+  private readonly sessions = inject(ChatSessionService);
 
   /** sessionId → 全部条目(按 at 升序) */
   private readonly _bySession = signal<Record<string, GroupEntry[]>>({});
@@ -154,6 +158,15 @@ export class GroupStoreService {
 
     const entry: GroupEntry = { id: msg.id, turnId: resolved, fromAgentId: agentId, message: msg, at };
     this.write(sessionId, [entry]);
+    /*
+     * 记录成果链接到**群会话**(2026-11-10)。
+     *
+     * 原来群会话的 `deliverableUrls` 从来没人写,右栏「成果」只能退化成
+     * 「按成员容器拉全量」—— 成员在别的单聊里产出的文件也会混进来。
+     * 现在与单聊同口径:只认 agent 产出、且不是用户上传的文件。
+     */
+    const url = msg.file?.cloudUrl || (msg.file?.url?.startsWith('https://s3.') ? msg.file.url : undefined);
+    if (url && !isUserUploadUrl(url)) this.sessions.addDeliverableUrl(sessionId, url);
     return resolved;
   }
 

+ 23 - 0
src/app/core/matrix-client.service.ts

@@ -97,6 +97,14 @@ export interface MxMessage {
    *    (实测:发一句「你好」,`messages()` 里出现 3 条)。
    */
   localId?: string;
+  /**
+   * 这条消息**回复的是哪一条**(`m.relates_to.m.in_reply_to.event_id`)。
+   *
+   * 🔴 2026-11-10:容器对每条用户消息分别回话(图文被拆成两条时各回一轮),
+   *    靠它把回复归到**正确的那条提问**。此前没有解析,回合聚合只能按顺序猜,
+   *    结果图片那条的回复被算到了文字那条下面。
+   */
+  replyTo?: string;
 }
 
 /** 群聊派活元信息(写入 Matrix content 的 `com.fmode.dispatch`) */
@@ -221,6 +229,19 @@ export function extractThreadId(content: Record<string, unknown> | undefined): s
   return typeof rel.event_id === 'string' && rel.event_id ? rel.event_id : undefined;
 }
 
+/**
+ * 提取「这条消息在回复哪一条」。
+ *
+ * Matrix 里 thread 内回复带 `m.relates_to.m.in_reply_to.event_id`。
+ * 容器每收到一条用户消息都会回一轮(不合并),靠这个字段才能把回复
+ * 归到**它真正回应提问的那一条**上(2026-11-10)。
+ */
+export function extractReplyTo(content: Record<string, unknown> | undefined): string | undefined {
+  const rel = content?.['m.relates_to'] as { 'm.in_reply_to'?: { event_id?: unknown } } | undefined;
+  const id = rel?.['m.in_reply_to']?.event_id;
+  return typeof id === 'string' && id ? id : undefined;
+}
+
 /**
  * 构造 `m.relates_to`(thread 关系)。
  *
@@ -762,6 +783,7 @@ export class MatrixClientService implements OnDestroy {
         dispatch: extractDispatch(e.getContent()),
         sessionId: extractSessionId(e.getContent()),
         localId: extractLocalId(e.getContent()),
+        replyTo: extractReplyTo(e.getContent()),
         body: (e.getContent()['body'] as string) ?? '',
         // 原生附件 → 转成 FileCard(mxc:// 在这里转成可访问的 HTTP 地址)
         media: (() => {
@@ -1114,6 +1136,7 @@ export class MatrixClientService implements OnDestroy {
         dispatch: extractDispatch(ev.getContent()),
         sessionId: extractSessionId(ev.getContent()),
         localId: extractLocalId(ev.getContent()),
+        replyTo: extractReplyTo(ev.getContent()),
         body: (ev.getContent()['body'] as string) ?? '',
         media,
         ts: ev.getTs(),

+ 8 - 0
src/app/core/nav-menu.service.ts

@@ -31,6 +31,14 @@ export const NAV_CATALOG: NavItemConfig[] = [
   { key: 'events', path: '/events', label: '事件', icon: 'fa-bolt' },
   { key: 'deliverables', path: '/deliverables', label: '成果', icon: 'fa-box' },
   { key: 'workbench', path: '/workbench', label: '工作台', icon: 'fa-table-cells-large' },
+  /**
+   * 成员管理(2026-11-10)。
+   *
+   * 🔴 只有管理员看得到 —— 过滤在 `session-tree` 按 `canManageCompany` 做。
+   *    这里**不设 defaultHidden**:管理员日常要用,藏进「更多」反而多一步。
+   *    (可见性不是安全边界,服务端另有鉴权。)
+   */
+  { key: 'admin-users', path: '/admin/users', label: '成员管理', icon: 'fa-users-gear' },
 ];
 
 /** 用户自定义后的单项状态 */

+ 105 - 0
src/app/core/org-users.service.spec.ts

@@ -0,0 +1,105 @@
+import { TestBed } from '@angular/core/testing';
+import { FmodeApi } from './api/fmode-api';
+import { OrgUser, OrgUserPage } from './api/models';
+import { OrgUsersService } from './org-users.service';
+
+/**
+ * 组织成员管理服务(2026-11-10)
+ *
+ * 与成果页不同:这里是**服务端分页 + 服务端搜索**,切页/改词都要重拉。
+ */
+describe('OrgUsersService', () => {
+  let svc: OrgUsersService;
+  let api: jasmine.SpyObj<FmodeApi>;
+
+  function user(id: string, nickname = '张三'): OrgUser {
+    return { id, username: id, nickname, mobile: '13800000000', isDeleted: false, createdAt: 0 };
+  }
+  function page(items: OrgUser[], total = items.length, p = 1): OrgUserPage {
+    return { items, total, page: p, pageSize: 20 };
+  }
+
+  beforeEach(() => {
+    api = jasmine.createSpyObj<FmodeApi>('FmodeApi', [
+      'listOrgUsers',
+      'addOrgUser',
+      'updateOrgUser',
+      'removeOrgUser',
+    ]);
+    TestBed.configureTestingModule({ providers: [{ provide: FmodeApi, useValue: api }] });
+    svc = TestBed.inject(OrgUsersService);
+  });
+
+  it('加载成功 → ready + 记录总数', async () => {
+    api.listOrgUsers.and.resolveTo(page([user('a'), user('b')], 42));
+    await svc.ensureLoaded();
+    expect(svc.state()).toBe('ready');
+    expect(svc.items().length).toBe(2);
+    expect(svc.total()).toBe(42);
+  });
+
+  it('🔴 加载失败 → error 态,不伪装成空', async () => {
+    api.listOrgUsers.and.rejectWith(new Error('云函数未部署'));
+    await svc.ensureLoaded();
+    expect(svc.state()).toBe('error');
+    expect(svc.error()).toContain('云函数未部署');
+    expect(svc.empty()).withContext('错误不能算空').toBeFalse();
+  });
+
+  it('空结果 → empty 态', async () => {
+    api.listOrgUsers.and.resolveTo(page([], 0));
+    await svc.ensureLoaded();
+    expect(svc.state()).toBe('empty');
+    expect(svc.empty()).toBeTrue();
+  });
+
+  it('🔴 搜索重置到第 1 页并带上关键词', async () => {
+    api.listOrgUsers.and.resolveTo(page([user('a')], 1));
+    await svc.search('李四');
+    const arg = api.listOrgUsers.calls.mostRecent().args[0];
+    expect(arg.keyword).toBe('李四');
+    expect(arg.page).toBe(1);
+    expect(svc.hasKeyword()).toBeTrue();
+  });
+
+  it('翻页不会越界(夹在 1..pageCount)', async () => {
+    api.listOrgUsers.and.resolveTo(page([user('a')], 21));
+    await svc.ensureLoaded();
+    await svc.goTo(99);
+    expect(svc.page()).toBe(2);
+  });
+
+  it('新增成功后重新拉取列表', async () => {
+    api.listOrgUsers.and.resolveTo(page([user('a')], 1));
+    api.addOrgUser.and.resolveTo(user('new'));
+    await svc.ensureLoaded();
+    const before = api.listOrgUsers.calls.count();
+    await svc.add('new');
+    expect(api.addOrgUser).toHaveBeenCalledWith('new');
+    expect(api.listOrgUsers.calls.count()).toBe(before + 1);
+  });
+
+  it('新增空 ID 直接报错,不打接口', async () => {
+    await expectAsync(svc.add('   ')).toBeRejected();
+    expect(api.addOrgUser).not.toHaveBeenCalled();
+  });
+
+  it('🔴 更新只替换那一行,不整页重拉(保住滚动位置)', async () => {
+    api.listOrgUsers.and.resolveTo(page([user('a', '旧名'), user('b')]));
+    await svc.ensureLoaded();
+    const callsBefore = api.listOrgUsers.calls.count();
+    api.updateOrgUser.and.resolveTo(user('a', '新名'));
+    await svc.update('a', { nickname: '新名' });
+    expect(svc.items().find((u) => u.id === 'a')?.nickname).toBe('新名');
+    expect(api.listOrgUsers.calls.count()).withContext('不应重拉列表').toBe(callsBefore);
+  });
+
+  it('软删后从列表移除并递减总数', async () => {
+    api.listOrgUsers.and.resolveTo(page([user('a'), user('b')], 2));
+    api.removeOrgUser.and.resolveTo(undefined);
+    await svc.ensureLoaded();
+    await svc.remove('a');
+    expect(svc.items().map((u) => u.id)).toEqual(['b']);
+    expect(svc.total()).toBe(1);
+  });
+});

+ 133 - 0
src/app/core/org-users.service.ts

@@ -0,0 +1,133 @@
+import { Injectable, computed, inject, signal } from '@angular/core';
+import { FmodeApi } from './api/fmode-api';
+import { LoadState, OrgUser } from './api/models';
+
+/** 列表默认每页条数 */
+export const ORG_PAGE_SIZE = 20;
+
+/**
+ * 组织成员管理服务(2026-11-10)
+ *
+ * ## 与 `DeliverablesService` 的差别
+ *
+ * 成果页是「一次拉全量、前端过滤」;成员管理用户量会增长,
+ * 所以这里是**服务端分页 + 服务端搜索**:切页/改关键词都要重新请求。
+ *
+ * ## 三态
+ *
+ * 复用全站 `LoadState`。🔴 错误必须显示错误态,**不得**伪装成「暂无数据」——
+ * 否则用户会以为组织里真的没人(这条在成果页踩过,见 AC-08.4)。
+ */
+@Injectable({ providedIn: 'root' })
+export class OrgUsersService {
+  private readonly api = inject(FmodeApi);
+
+  private readonly _items = signal<OrgUser[]>([]);
+  private readonly _state = signal<LoadState>('idle');
+  private readonly _error = signal<string | null>(null);
+  private readonly _total = signal(0);
+  private readonly _page = signal(1);
+  private readonly _keyword = signal('');
+  /** 正在提交的「写操作」标识(userId 或 'add'),用于按钮 loading */
+  private readonly _busy = signal<string | null>(null);
+
+  readonly items = this._items.asReadonly();
+  readonly state = this._state.asReadonly();
+  readonly error = this._error.asReadonly();
+  readonly total = this._total.asReadonly();
+  readonly page = this._page.asReadonly();
+  readonly keyword = this._keyword.asReadonly();
+  readonly busy = this._busy.asReadonly();
+
+  readonly pageSize = ORG_PAGE_SIZE;
+  readonly pageCount = computed(() => Math.max(1, Math.ceil(this._total() / ORG_PAGE_SIZE)));
+  readonly empty = computed(
+    () => (this._state() === 'ready' || this._state() === 'empty') && this._items().length === 0,
+  );
+  /** 是否已有搜索词(空态文案要区分「没数据」与「没搜到」) */
+  readonly hasKeyword = computed(() => this._keyword().trim().length > 0);
+
+  /** 首次进入页面时加载;已加载过则不重复请求 */
+  async ensureLoaded(): Promise<void> {
+    if (this._state() === 'ready' || this._state() === 'empty' || this._state() === 'loading') return;
+    await this.load();
+  }
+
+  /** 搜索(重置到第 1 页) */
+  async search(keyword: string): Promise<void> {
+    this._keyword.set(keyword);
+    this._page.set(1);
+    await this.load();
+  }
+
+  /** 翻页 */
+  async goTo(page: number): Promise<void> {
+    const p = Math.min(Math.max(1, page), this.pageCount());
+    if (p === this._page()) return;
+    this._page.set(p);
+    await this.load();
+  }
+
+  async refresh(): Promise<void> {
+    await this.load();
+  }
+
+  async load(): Promise<void> {
+    this._state.set('loading');
+    this._error.set(null);
+    try {
+      const res = await this.api.listOrgUsers({
+        keyword: this._keyword().trim() || undefined,
+        page: this._page(),
+        pageSize: ORG_PAGE_SIZE,
+      });
+      this._items.set(res.items);
+      this._total.set(res.total);
+      this._state.set(res.items.length === 0 ? 'empty' : 'ready');
+    } catch (e) {
+      this._state.set('error');
+      this._error.set(e instanceof Error ? e.message : '成员列表加载失败');
+    }
+  }
+
+  /** 加入组织;成功后刷新当前页 */
+  async add(userId: string): Promise<void> {
+    const id = userId.trim();
+    if (!id) throw new Error('请填写用户 ID');
+    this._busy.set('add');
+    try {
+      await this.api.addOrgUser(id);
+      await this.load();
+    } finally {
+      this._busy.set(null);
+    }
+  }
+
+  /** 改资料 / 重置密码 */
+  async update(
+    userId: string,
+    patch: { nickname?: string; mobile?: string; password?: string },
+  ): Promise<void> {
+    this._busy.set(userId);
+    try {
+      const updated = await this.api.updateOrgUser(userId, patch);
+      // 只换这一行,避免整页刷新导致滚动位置丢失
+      this._items.update((list) => list.map((u) => (u.id === userId ? updated : u)));
+    } finally {
+      this._busy.set(null);
+    }
+  }
+
+  /** 软删 */
+  async remove(userId: string): Promise<void> {
+    this._busy.set(userId);
+    try {
+      await this.api.removeOrgUser(userId);
+      this._items.update((list) => list.filter((u) => u.id !== userId));
+      this._total.update((n) => Math.max(0, n - 1));
+      if (this._items().length === 0) this._state.set('empty');
+    } finally {
+      this._busy.set(null);
+    }
+  }
+}

+ 59 - 0
src/app/core/org.guard.ts

@@ -0,0 +1,59 @@
+import { inject } from '@angular/core';
+import { CanActivateFn, Router } from '@angular/router';
+import { FmodeApi } from './api/fmode-api';
+import { SessionService } from './session.service';
+
+/** 无组织用户停留的等待页路径 */
+export const PENDING_PATH = '/pending';
+
+/**
+ * 组织归属守卫(2026-11-10)
+ *
+ * ## 为什么要它
+ *
+ * App 原本只判「登录了没」,不判「有没有组织」—— 没有组织的账号
+ * 照常进入,看到的是一堆空列表,用户分不清是没权限还是真没数据。
+ *
+ * ## 🔴 为什么不能只看 `me.companyId`
+ *
+ * 实测(2026-11-10):新用户注册时,系统会**自动创建一个以他命名的组织**
+ * (如 liujun 的组织就叫「liujun」),并写入 `enterpriseTrialClaimedAt`。
+ * 所以「有 companyId」≠「被组织接纳」。
+ *
+ * 真正的判定在服务端(`fmodeagent-users.js` 的 `opMe`):
+ *   ① 组织带 `enterpriseTrialClaimedAt`(个人试用组织)
+ *   ② 且该组织里只有他一个人
+ *   → 两项都满足,视为**无组织**。
+ *
+ * ## 与 `authGuard` 的关系
+ *
+ * 必须排在 `authGuard` 之后(`canActivate: [authGuard, orgGuard]`)。
+ * `/login` 与 `/pending` 自身**不得**挂本守卫,否则自我重定向死循环。
+ */
+export const orgGuard: CanActivateFn = async (_route, state) => {
+  const session = inject(SessionService);
+  const api = inject(FmodeApi);
+  const router = inject(Router);
+
+  // 未登录交给 authGuard,这里不重复判
+  if (!session.isLoggedIn()) return true;
+
+  const hasOrg = await queryHasOrg(api);
+  if (hasOrg) return true;
+  return router.createUrlTree([PENDING_PATH], { queryParams: { redirect: state.url } });
+};
+
+/**
+ * 询问服务端「我有没有真正的组织」。
+ *
+ * 🔴 查询失败按**放行**处理:判成无组织会让所有人卡在等待页,风险更大;
+ *    放行后页面自身的错误态会如实提示。
+ */
+export async function queryHasOrg(api: FmodeApi): Promise<boolean> {
+  try {
+    const m = await api.fetchOrgMembership();
+    return m.hasOrg;
+  } catch {
+    return true;
+  }
+}

+ 31 - 1
src/app/core/s3-link.spec.ts

@@ -1,4 +1,4 @@
-import { extractS3Links, stripLinks } from './s3-link';
+import { extractS3Links, isUserUploadUrl, stripLinks } from './s3-link';
 
 /**
  * 单测:从消息正文提取云盘链接(2026-11-09)
@@ -90,3 +90,33 @@ describe('stripLinks', () => {
     expect(stripLinks(undefined, [U])).toBe('');
   });
 });
+
+/**
+ * 用户上传 vs 容器成果(2026-11-10)
+ *
+ * 背景:用户自己发的图曾被写进会话 `deliverableUrls`,
+ * 右栏「文件」「成果」两处都出现(用户实测截图)。
+ */
+describe('isUserUploadUrl', () => {
+  it('用户上传:/user/<uid>/chat/… → true', () => {
+    expect(
+      isUserUploadUrl('https://s3.fmode.cn/user/xhVRjTMwQH/chat/202611/x-1794301408693-82227-image.png'),
+    ).toBeTrue();
+  });
+
+  it('容器成果:/deliverables/… → false', () => {
+    expect(
+      isUserUploadUrl('https://s3.fmode.cn/deliverables/agent-node-lj-test/202611/GFP/0-a.md'),
+    ).toBeFalse();
+  });
+
+  it('用户上传但不在 chat 目录下 → false(不属于本次会话)', () => {
+    expect(isUserUploadUrl('https://s3.fmode.cn/user/u/avatar/a.png')).toBeFalse();
+  });
+
+  it('空值与非法地址安全返回 false', () => {
+    expect(isUserUploadUrl('')).toBeFalse();
+    expect(isUserUploadUrl(undefined)).toBeFalse();
+    expect(isUserUploadUrl('not-a-url')).toBeFalse();
+  });
+});

+ 40 - 0
src/app/core/s3-link.ts

@@ -44,6 +44,46 @@ export function extractS3Links(text: string | undefined | null): string[] {
   return out;
 }
 
+/**
+ * 这条云盘链接是不是**用户自己上传**的文件(而不是容器产出的成果)。
+ *
+ * ## 为什么必须判
+ *
+ * 两类文件落在云盘的不同前缀下(2026-11-10 实测):
+ *
+ * ```
+ * 用户上传:https://s3.fmode.cn/user/<userId>/chat/202611/<uid>-<ts>-image.png
+ * 容器成果:https://s3.fmode.cn/deliverables/<agentId>/202611/<key>/<n>-xxx.md
+ * ```
+ *
+ * 曾把用户上传链接也写进会话的 `deliverableUrls`,结果用户自己发的图
+ * 同时出现在右栏「文件」和「成果」两处(用户实测反馈)。
+ *
+ * @returns true = 用户上传的文件,**不得**计入成果
+ */
+/**
+ * 用户上传文件的路径前缀(2026-11-10 实测)。
+ *
+ * `/user/<userId>/chat/<yyyyMM>/<uid>-<ts>-<name>` —— 由云函数 `AlP56LCKFm`
+ * 预签名时决定;容器成果走 `/deliverables/<agentId>/…`。
+ * 平台若改前缀,这里要跟着改,否则成果页会重新混入用户图片。
+ *
+ * @returns true = 用户上传的文件,**不得**计入成果
+ */
+export function isUserUploadUrl(url: string | undefined | null): boolean {
+  if (!url) return false;
+  let u: URL;
+  try {
+    u = new URL(url);
+  } catch {
+    // 不是合法 URL(比如相对地址)→ 按非用户上传处理,保持既有行为
+    return false;
+  }
+  // 🔴 必须同时判域名:会话记录里的 URL 来源不受 `extractS3Links` 约束
+  if (!CLOUD_HOSTS.includes(u.host.toLowerCase())) return false;
+  return /^\/user\/[^/]+\/chat\//.test(u.pathname);
+}
+
 /**
  * 把正文里的指定链接**摘掉**,返回清理后的正文。
  *

+ 26 - 3
src/app/core/task-pulse.service.spec.ts

@@ -73,11 +73,34 @@ describe('TaskPulseService', () => {
     expect(line.active).toBeTrue();
   });
 
-  it('快照超过 90 秒没更新 → 报「状态未同步」而不是假装空闲', () => {
+  it('🔴 空闲容器 10 分钟没上报 → 仍显示正常态,不再报「状态未同步」', () => {
+    /*
+     * 2026-11-10 用户裁决:容器上报是小时级的(一小时 + 0~15 分钟抖动)。
+     * 一轮对话结束后 10 分钟没上报属正常,顶栏不该逼它"证明还活着",
+     * 否则与侧栏/通讯录(80 分钟阈值判在线)自相矛盾。
+     */
     const old = new Date(Date.now() - 10 * 60_000).toISOString();
     const line = svc.describe(agent({ status: 'idle', heartbeatAt: old }));
-    expect(line.text).toContain('状态未同步');
-    expect(line.tone).toBe('warning');
+    expect(line.text).not.toContain('状态未同步');
+    expect(line.tone).not.toBe('warning');
+  });
+
+  it('🔴 超过外层兜底阈值(80 分钟)才判不可用', () => {
+    /*
+     * `describe` 的不可用判定复用 `resolveAgentState`(全站同一套):
+     * 80 分钟内算正常,超过即「已离线」。不存在"90 秒就未同步"的中间态了。
+     */
+    const old = new Date(Date.now() - 90 * 60_000).toISOString();
+    const line = svc.describe(agent({ status: 'idle', heartbeatAt: old }));
+    expect(line.text).toContain('已离线');
+    expect(line.tone).toBe('danger');
+  });
+
+  it('70 分钟(阈值内)→ 仍显示正常态', () => {
+    const old = new Date(Date.now() - 70 * 60_000).toISOString();
+    const line = svc.describe(agent({ status: 'idle', heartbeatAt: old }));
+    expect(line.text).not.toContain('离线');
+    expect(line.text).not.toContain('状态未同步');
   });
 
   it('心跳新鲜且 idle → 显示空闲', () => {

+ 16 - 3
src/app/core/task-pulse.service.ts

@@ -2,10 +2,23 @@ import { computed, effect, inject, Injectable, signal } from '@angular/core';
 import { Agent } from './api/models';
 import { LiveService } from './live.service';
 import { QUIET_MS } from './turn-aggregator';
-import { isReporterSelfTalk, resolveAgentState } from './agent-state';
+import { isReporterSelfTalk, HEARTBEAT_STALE_SEC, resolveAgentState } from './agent-state';
 
-/** 服务端数据「新鲜」窗口:超过这个时长的 idle 不再当作「刚刚完成」展示 */
-const FRESH_MS = 90_000;
+/**
+ * 服务端数据「新鲜」窗口(毫秒)。
+ *
+ * 🔴 2026-11-10 用户裁决:**与全站统一为外层心跳阈值**(80 分钟)。
+ *
+ * 原值是 90 秒。后果:容器一轮对话结束 90 秒后,顶栏就开始显示
+ * 「状态未同步 · 上次上报 8 分钟前」——而侧栏、通讯录、新建弹窗
+ * 走的是 `HEARTBEAT_STALE_SEC`(80 分钟),判成「空闲」。
+ * 同一个容器,顶栏说「未同步」、别处说「在线」,这就是用户反馈的
+ * 「外层和内层不一致」。
+ *
+ * 容器上报是**小时级**的(用户裁决:一小时同步一次,带 0~15 分钟抖动)。
+ * 对话结束后不需要逼它"证明还活着"——只要没超过外层兜底阈值,就展示正常态。
+ */
+const FRESH_MS = HEARTBEAT_STALE_SEC * 1000;
 
 /**
  * 「还在回复」的静默窗口:最近一次消息进来后这么久内,都算正在输出。

+ 278 - 1
src/app/core/turn-aggregator.spec.ts

@@ -1,5 +1,5 @@
 import { Message } from './api/models';
-import { buildTurns, isTurnOpen, QUIET_MS, turnDuration } from './turn-aggregator';
+import { buildTurns, isTurnOpen, QUIET_MS, turnDuration, USER_MERGE_MS } from './turn-aggregator';
 
 function msg(id: string, sender: 'me' | 'agent', ts: number, text = ''): Message {
   return {
@@ -135,4 +135,281 @@ describe('turn-aggregator', () => {
       expect(turnDuration(turns[0])).toBe(0);
     });
   });
+
+  /**
+   * 图文同发(2026-11-10 用户实测:发一张图 + 一句话,界面出现两个用户气泡)
+   *
+   * 协议事实:Matrix 一条消息只能带一个附件,「图」和「文字」必然是两个事件。
+   * 回合层要把紧挨着的附件并回同一回合,渲染时就是一个「图 + 字」的气泡组。
+   */
+  describe('图文同发:附件并入同一回合', () => {
+    const img = (id: string, ts: number): Message => ({
+      ...msg(id, 'me', ts),
+      text: undefined,
+      file: { kind: 'file', name: 'image.png', size: 1, mimeType: 'image/png', url: 'blob:x' },
+    });
+
+    it('图(先)+ 文字(后)→ 一个回合,图挂在 userFiles', () => {
+      const turns = buildTurns({
+        messages: [img('f1', T0 + 1000), msg('u1', 'me', T0 + 2000, '照这个做页面')],
+        typing: false,
+        now: T0 + 10_000,
+      });
+      /*
+       * 图在前、文字在后时,图先开回合,文字是「新的用户发言」→ 仍是 2 个回合。
+       * 这是容器「先收到图」的真实时序,合并显示由 A1(容器等待文字)根治;
+       * App 侧只保证「文字在下、图紧随其后」的多附件情形不分裂。
+       */
+      expect(turns.length).toBe(2);
+    });
+
+    it('文字(先)+ 图(后)→ 并入同一回合', () => {
+      const turns = buildTurns({
+        messages: [msg('u1', 'me', T0, '照这个做页面'), img('f1', T0 + 1500)],
+        typing: false,
+        now: T0 + 10_000,
+      });
+      expect(turns.length).toBe(1);
+      expect(turns[0].userMessage?.id).toBe('u1');
+      expect(turns[0].userFiles?.map((m) => m.id)).toEqual(['f1']);
+    });
+
+    it('🔴 容器已回复后到达的文字 → 独立成回合(实测:图先到、容器先回了图)', () => {
+      /*
+       * 真实时序(2026-11-10 实测):
+       *   图片 → 容器对图片回话 → 文字才到
+       * 若此时仍把文字并进图片回合,会出现:
+       *   ① 图片回合的最后一条回复被并走,卡片变空壳「未响应」
+       *   ② 后半段正文落到图片回合里,与它的回复错位
+       */
+      const turns = buildTurns({
+        messages: [
+          img('f1', T0),
+          msg('a1', 'agent', T0 + 1500, '图我看到了'),
+          msg('u1', 'me', T0 + 2000, '照这个做页面'),
+          msg('a2', 'agent', T0 + 9000, '收到,开始做'),
+        ],
+        typing: false,
+        now: T0 + 60_000,
+      });
+      expect(turns.length).withContext('文字是独立的一轮').toBe(2);
+      expect(turns[0].userMessage?.id).withContext('图片回合保留自己的回复').toBe('f1');
+      expect(turns[0].blocks.map((b) => b.id)).toEqual(['a1']);
+      expect(turns[1].userMessage?.id).toBe('u1');
+      expect(turns[1].blocks.map((b) => b.id)).toEqual(['a2']);
+    });
+
+    it('多个附件都并进同一回合', () => {
+      const turns = buildTurns({
+        messages: [msg('u1', 'me', T0), img('f1', T0 + 500), img('f2', T0 + 900)],
+        typing: false,
+        now: T0 + 10_000,
+      });
+      expect(turns.length).toBe(1);
+      expect(turns[0].userFiles?.length).toBe(2);
+    });
+
+    it('🔴 超过合并窗口的附件 = 新的回合(用户隔一会儿单独发图)', () => {
+      const turns = buildTurns({
+        messages: [msg('u1', 'me', T0), img('f1', T0 + USER_MERGE_MS + 1000)],
+        typing: false,
+        now: T0 + USER_MERGE_MS + 10_000,
+      });
+      expect(turns.length).toBe(2);
+    });
+
+    it('🔴 纯文字后续发言照常开新回合(不能把两句话并成一轮)', () => {
+      const turns = buildTurns({
+        messages: [msg('u1', 'me', T0, '第一句'), msg('u2', 'me', T0 + 1000, '第二句')],
+        typing: false,
+        now: T0 + 10_000,
+      });
+      expect(turns.length).toBe(2);
+    });
+
+    it('附件并入后,回合的 userFiles 不为空且保留原消息', () => {
+      const turns = buildTurns({
+        messages: [msg('u1', 'me', T0), img('f1', T0 + 500), msg('a1', 'agent', T0 + 3000, '收到')],
+        typing: false,
+        now: T0 + 10_000,
+      });
+      expect(turns.length).toBe(1);
+      expect(turns[0].userFiles?.[0].file?.name).toBe('image.png');
+      expect(turns[0].blocks.length).toBe(1);
+    });
+
+    it('🔴 容器已回复后用户再发图 → 新回合(评审 P1-3:不能被 agent 消息推后窗口)', () => {
+      /*
+       * 时序:用户问(T0)→ 容器 25 秒后回(回合仍 live)→ 用户立刻再发一张图。
+       * 旧实现用 `prev.updatedAt`(被 agent 回复推进到 T0+25s)判窗口 →
+       * 图片距被推后的窗口只有几百毫秒,被吞进上一轮。
+       * 用 `lastUserAt`(T0)判 → 距用户上次发话 26 秒,仍在 30 秒窗口内……
+       * 所以这里把窗口边界拉满:图在 T0+29s,agent 回复在 T0+28s。
+       * 真正的判据区别在 USER_MERGE_MS 之外:垫一条 >30s 的图,必须开新回合。
+       */
+      const turns = buildTurns({
+        messages: [
+          msg('u1', 'me', T0, '第一问'),
+          msg('a1', 'agent', T0 + 25_000, '回答'),
+          msg('a2', 'agent', T0 + 27_000, '补充'),
+          img('f1', T0 + USER_MERGE_MS + 2000),
+        ],
+        typing: true,
+        now: T0 + 33_000,
+      });
+      expect(turns.length).withContext('这是一轮新输入').toBe(2);
+      expect(turns[1].userMessage?.id).toBe('f1');
+      expect(turns[0].userFiles ?? []).toEqual([]);
+    });
+  });
+});
+
+/**
+ * 纯附件回合的出卡条件(2026-11-10 评审 P1-1)
+ *
+ * `aiRows` 的出卡条件是 `merged || live || userMessage || userFiles.length > 0`。
+ * 这里用纯函数模拟该条件,锁住「图片自己当锚点 / 只有附件」都不会被静默丢掉。
+ */
+describe('aiRows 出卡条件(纯附件)', () => {
+  const shouldRenderTurn = (t: {
+    userMessage?: unknown;
+    userFiles?: unknown[];
+    live: boolean;
+  }): boolean => !!t.userMessage || (t.userFiles?.length ?? 0) > 0 || t.live;
+
+  const pic = (id: string, ts: number): Message => ({
+    id,
+    conversationId: 'c',
+    sender: 'me',
+    kind: 'text',
+    file: { kind: 'file', name: 'a.png', size: 1, mimeType: 'image/png', url: 'blob:x' },
+    ts: new Date(ts).toISOString(),
+    state: 'sent',
+  });
+
+  it('🔴 图在前、容器已回复:图片回合必须出卡', () => {
+    const turns = buildTurns({
+      messages: [pic('f1', T0), msg('a1', 'agent', T0 + 3000, '收到')],
+      typing: false,
+      now: T0 + 60_000,
+    });
+    expect(turns.length).toBe(1);
+    const view = { userMessage: turns[0].userMessage, userFiles: turns[0].userFiles, live: false };
+    expect(shouldRenderTurn(view)).withContext('图片就是锚点,不能丢').toBeTrue();
+  });
+
+  it('🔴 只有 userFiles、没有 userMessage / live 的回合也不能丢', () => {
+    const turns = buildTurns({
+      messages: [msg('u1', 'me', T0, '看看'), pic('f1', T0 + 500)],
+      typing: false,
+      now: T0 + 60_000,
+    });
+    const t = { userMessage: undefined, userFiles: turns[0].userFiles, live: false };
+    expect(shouldRenderTurn(t)).withContext('附件本身也要能出卡').toBeTrue();
+  });
+});
+
+/**
+ * 按轮次拆块(2026-11-10)
+ *
+ * 容器对「图 + 文字」没有合并窗口,会各跑一轮。两轮在消息流里没有分界标记,
+ * 只能按相邻 agent 消息的时间空档切开,让每轮独立计时与判状态。
+ */
+describe('splitRounds(按轮拆块)', () => {
+  it('🔴 两轮之间有大空档 → 切成 2 轮', () => {
+    const turns = buildTurns({
+      messages: [
+        msg('u1', 'me', T0, '看图'),
+        msg('a1', 'agent', T0 + 2000, '我看到了图'),
+        msg('a2', 'agent', T0 + 30_000, '收到你说的'),
+        msg('a3', 'agent', T0 + 31_000, '补充'),
+      ],
+      typing: false,
+      now: T0 + 60_000,
+    });
+    expect(turns[0].rounds.length).toBe(2);
+    expect(turns[0].rounds[0].map((m) => m.id)).toEqual(['a1']);
+    expect(turns[0].rounds[1].map((m) => m.id)).toEqual(['a2', 'a3']);
+  });
+
+  it('🔴 同一轮内连续消息(间隔小于阈值)不切', () => {
+    const turns = buildTurns({
+      messages: [
+        msg('u1', 'me', T0),
+        msg('a1', 'agent', T0 + 1000),
+        msg('a2', 'agent', T0 + 5000),
+        msg('a3', 'agent', T0 + 15_000),
+      ],
+      typing: false,
+      now: T0 + 60_000,
+    });
+    expect(turns[0].rounds.length).withContext('工具调用空档不应切开').toBe(1);
+  });
+
+  it('扁平 blocks 仍然保留全部消息', () => {
+    const turns = buildTurns({
+      messages: [msg('u1', 'me', T0), msg('a1', 'agent', T0), msg('a2', 'agent', T0 + 40_000)],
+      typing: false,
+      now: T0 + 90_000,
+    });
+    expect(turns[0].blocks.length).toBe(2);
+    expect(turns[0].rounds.length).toBe(2);
+  });
+
+  it('没有 agent 消息时 rounds 为空', () => {
+    const turns = buildTurns({ messages: [msg('u1', 'me', T0)], typing: false, now: T0 + 1000 });
+    expect(turns[0].rounds).toEqual([]);
+  });
+});
+
+/**
+ * 按 `m.in_reply_to` 精确归属回复(2026-11-10)
+ *
+ * 实测:发「图 + 文字」,容器对**每条**分别回一轮,两条回复在时间上挨着。
+ * 只按顺序追加会把图片那轮的回复算到文字下面,图片回合变空壳「未响应」。
+ */
+describe('按 replyTo 归属回复', () => {
+  const withReply = (id: string, ts: number, text: string, replyTo?: string): Message => ({
+    ...msg(id, 'agent', ts, text),
+    ...(replyTo ? { replyTo } : {}),
+  });
+
+  it('🔴 图片的回复指向图片那条 → 归图片回合,不串到文字下面', () => {
+    const turns = buildTurns({
+      messages: [
+        msg('img', 'me', T0),
+        msg('txt', 'me', T0 + 500, '照这个做'),
+        withReply('a1', T0 + 1500, '图我看到了', 'img'),
+        withReply('a2', T0 + 9000, '收到,开始做', 'txt'),
+      ],
+      typing: false,
+      now: T0 + 60_000,
+    });
+    const imgTurn = turns.find((t) => t.id === 'img')!;
+    const txtTurn = turns.find((t) => t.id === 'txt')!;
+    expect(imgTurn.blocks.map((b) => b.id)).toEqual(['a1']);
+    expect(txtTurn.blocks.map((b) => b.id)).toEqual(['a2']);
+  });
+
+  it('回复指向线程根(不是用户消息)→ 走顺序规则', () => {
+    const turns = buildTurns({
+      messages: [
+        msg('u1', 'me', T0, '问'),
+        withReply('a1', T0 + 1000, '答', '$approot_thread_root'),
+      ],
+      typing: false,
+      now: T0 + 60_000,
+    });
+    expect(turns.length).toBe(1);
+    expect(turns[0].blocks.length).toBe(1);
+  });
+
+  it('没有 replyTo 时行为不变(老数据兼容)', () => {
+    const turns = buildTurns({
+      messages: [msg('u1', 'me', T0), withReply('a1', T0 + 1000, '答')],
+      typing: false,
+      now: T0 + 60_000,
+    });
+    expect(turns[0].blocks.map((b) => b.id)).toEqual(['a1']);
+  });
 });

+ 171 - 4
src/app/core/turn-aggregator.ts

@@ -42,6 +42,40 @@ import { Message } from './api/models';
  */
 export const QUIET_MS = 25_000;
 
+/**
+ * 同一轮用户输入内,附件与文字的合并窗口(ms,2026-11-10)。
+ *
+ * 发送时序是「先传附件、再发文字」(`uploadPending` → `sendReal`),
+ * 图片上传通常 1–5 秒。取 30 秒是给慢网留余量:
+ * 只有「刚发完一条、又只有图没有字」的附件才会被并进同一回合,
+ * 用户真的想单独发一张图时,隔几十秒再发就是新回合,不会被误并。
+ */
+export const USER_MERGE_MS = 30_000;
+
+/**
+ * 同一回合内「两轮回复」的切分阈值(ms,2026-11-10)。
+ *
+ * ## 为什么需要
+ *
+ * 容器**没有图文合并窗口**:用户「发一张图 + 一句话」时,图片事件和文字事件
+ * 各自触发一轮处理,互不相干(实测:图那一轮回「我看到了…」,文字那轮回「收到」,
+ * 各打一个 ✅)。在消息流里它们**没有分界标记**,全都挂在同一个用户回合下。
+ *
+ * 原实现把整个回合当成一个块,于是:
+ *   · 第一轮答完了,头部读秒还在涨,直到第二轮结束才定格 → 然后**倒退**几秒
+ *   · 第二轮的「正在思考…」显示在第一轮正文下面 → 看起来第一轮没答完
+ *
+ * 现在按 **相邻 agent 消息的时间间隔** 切出「轮次」:超过本阈值的空档视为新一轮。
+ *
+ * ## 为什么是 20 秒
+ *
+ * 容器跑工具时两段正文之间常有 5–20 秒空档(调终端、跑代码),必须**不**切开;
+ * 而两轮并发时,先启动的那轮通常在 15–30 秒内就答完了。
+ * 取 20 秒:既是 `QUIET_MS`(25 秒)之下的保守值,又能盖住绝大多数工具调用空档。
+ * 代价:同一轮里超过 20 秒的工具调用会被拆成两个子块 —— 这比「两轮糊成一团」更容易读。
+ */
+export const ROUND_GAP_MS = 20_000;
+
 /**
  * 一个回合。
  *
@@ -54,12 +88,46 @@ export interface Turn {
   userMessage?: Message;
   /** 依次收到的 agent 消息 */
   blocks: Message[];
+  /**
+   * 按时间空档切出的「轮次」子块(2026-11-10)。
+   *
+   * 容器对「图 + 文字」会各跑一轮(没有合并窗口),两轮输出在消息流里
+   * 没有任何分界标记。这里按相邻消息间隔 > `ROUND_GAP_MS` 切开,
+   * 让渲染层能分别计时、分别判断「在思考 / 在输出 / 已完成」。
+   *
+   * `blocks` 仍然保留(扁平,用于文件去重等),`rounds` 是它的切分视图。
+   */
+  rounds: Message[][];
+  /**
+   * 用户在这个回合里的**有效提问时刻**列表(2026-11-10)。
+   *
+   * 正常情况下只有一个(`userMessage.ts`);图文被拆成两条时会有两个
+   * (图片一条、文字一条)。用来给各轮读秒找锚点:
+   * 第 N 轮的锚点 = 不晚于该轮首条消息的最后一个用户提问时刻。
+   */
+  userAskTimes: number[];
   /** 首条 agent 消息到达的时间(无 blocks 时为 0) */
   startedAt: number;
   /** 最新一条 agent 消息的时间 */
   updatedAt: number;
   /** 是否已关闭(关闭后新消息会开新回合或落到下一个回合) */
   closed: boolean;
+  /**
+   * 本回合里用户追加的附件消息(2026-11-10)。
+   *
+   * 配图发送时,「图」和「文字」在 Matrix 里是两个事件(一条消息只能一个附件)。
+   * 这里把图收进来,与 `userMessage`(文字)合成**一个用户气泡**,
+   * 不再各开一个回合 —— 否则一次发送会出现两个用户发言(用户实测截图)。
+   */
+  /**
+   * 🔴 本回合**最后一条用户消息**的时间(2026-11-10)。
+   *
+   * 合并窗口必须用它做判据,不能用 `updatedAt` ——
+   * 后者会被 agent 回复推进:用户在等容器回答时(回合 live)再发一张图,
+   * 会把图片吞进**上一轮**(评审 P1-3)。
+   */
+  lastUserAt: number;
+  userFiles?: Message[];
 }
 
 /** 回合切分所需的运行时输入 */
@@ -91,6 +159,8 @@ export interface TurnInput {
 export function buildTurns({ messages, typing, now }: TurnInput): Turn[] {
   const turns: Turn[] = [];
   let current: Turn | null = null;
+  /** 用户消息 id → 它所开启的回合(用于按 `replyTo` 精确归属回复) */
+  const turnsByUserMsgId = new Map<string, Turn>();
 
   const close = (): void => {
     if (!current) return;
@@ -101,16 +171,66 @@ export function buildTurns({ messages, typing, now }: TurnInput): Turn[] {
 
   for (const m of messages) {
     if (m.sender === 'me') {
-      close();
+      /*
+       * 🔴 同一轮用户输入里的附件并入上一个回合(2026-11-10 修复)。
+       *
+       * 一次「图 + 文字」发送在 Matrix 里是 2 个事件(`m.image` + `m.text`,
+       * 一条消息只能带一个附件)。原实现每个 me 事件都 `close()` 开新回合,
+       * 渲染出来就是「图片一个气泡 + 文字一个气泡」,中间还夹着容器的
+       * 「正在处理」提示,用户读成「它被打断了」。
+       *
+       * 判据(缺一不可):
+       *   ① 这条 me 消息**带附件**(纯文字照常开新回合 —— 那是真的再说一句)
+       *   ② 当前回合由用户开启、还有锚点
+       *   ③ 距**用户上一条消息**不超过 `USER_MERGE_MS`
+       *      🔴 用 `lastUserAt` 而不是 `updatedAt`:后者会被 agent 回复推进,
+       *         用户在等回答时再发图会被吞进上一轮(评审 P1-3)。
+       */
       const t = toTime(m.ts);
-      current = {
+      const prev = current;
+      /*
+       * 🔴 只有「同一批输入里并发的两条」才合并(2026-11-10 第二次修正)。
+       *
+       * 实测场景:用户发「图 + 文字」,图片先到 → 容器对**图片**回了一句
+       * (此时当前回合里已经有 agent 回复了),文字再到。
+       *
+       * 如果这时还把文字并进上一回合,会出现两个错误:
+       *   ① 图片那条的容器回复被归到「图片回合」,而文字又把它并走 → 图片回合变空壳「未响应」
+       *   ② 后半句正文落到图片回合里,和它的回复错位
+       *
+       * 所以要求「当前回合**还没有任何回复**」才允许并入(`blocks.length === 0`)。
+       * 一旦容器已经回话,后到的那条就是独立的一轮 —— 各自成卡,
+       * 与容器的实际处理轮数一致,也与本文件 `rounds` 的切分口径一致。
+       */
+      const mergeIntoPrev =
+        !!m.file &&
+        !!prev?.userMessage &&
+        prev.blocks.length === 0 &&
+        !prev.closed &&
+        t > 0 &&
+        prev.lastUserAt > 0 &&
+        t - prev.lastUserAt <= USER_MERGE_MS;
+      if (mergeIntoPrev && prev) {
+        (prev.userFiles ??= []).push(m);
+        prev.lastUserAt = Math.max(prev.lastUserAt, t);
+        // 回合「最后活动」不能往回退:附件事件可能比文字晚到
+        if (t > prev.updatedAt) prev.updatedAt = t;
+        continue;
+      }
+      close();
+      const fresh: Turn = {
         id: m.id,
         userMessage: m,
         blocks: [],
+        rounds: [],
+        userAskTimes: t > 0 ? [t] : [],
         startedAt: 0,
         updatedAt: t,
+        lastUserAt: t,
         closed: false,
       };
+      current = fresh;
+      turnsByUserMsgId.set(m.id, fresh);
       continue;
     }
 
@@ -121,11 +241,32 @@ export function buildTurns({ messages, typing, now }: TurnInput): Turn[] {
         id: `anchor-${m.id}`,
         userMessage: undefined,
         blocks: [],
+        rounds: [],
+        userAskTimes: [],
         startedAt: t,
         updatedAt: t,
+        lastUserAt: 0,
         closed: false,
       };
     }
+    /*
+     * 🔴 容器明确说明了在回哪一条时,**按它归属**(2026-11-10)。
+     *
+     * 实测:发「图 + 文字」时容器各回一轮,两条回复在时间上挨着,
+     * 只按顺序往后追加就会把图片那轮的回复算到文字下面,
+     * 图片回合变成空壳「未响应」。
+     *
+     * `m.in_reply_to` 指向的可能是我方消息,也可能是线程根:
+     *   · 指向某条已开的用户回合 → 归入该回合
+     *   · 指向线程根 / 已关闭的回合 → 忽略,走顺序规则
+     */
+    const target = m.replyTo ? turnsByUserMsgId.get(m.replyTo) : undefined;
+    if (target) {
+      if (target.blocks.length === 0) target.startedAt = toTime(m.ts);
+      target.blocks.push(m);
+      target.updatedAt = toTime(m.ts);
+      continue;
+    }
     /*
      * 🔴 `startedAt` 是**首条 agent 消息**的时间,不是用户发问的时间。
      *    「用时」要表达「容器干了多久」,把用户思考/输入的时间算进去会虚高。
@@ -136,6 +277,10 @@ export function buildTurns({ messages, typing, now }: TurnInput): Turn[] {
     current.updatedAt = toTime(m.ts);
   }
 
+  // 切出轮次子块
+  for (const t of turns) t.rounds = splitRounds(t.blocks);
+  if (current) current.rounds = splitRounds(current.blocks);
+
   /*
    * 收尾:最后一个回合只有在「没在打字 且 静默够久」时才算关闭。
    * 否则它是**进行中的回合**,UI 可以继续往里追加。
@@ -152,8 +297,30 @@ export function buildTurns({ messages, typing, now }: TurnInput): Turn[] {
   return turns;
 }
 
-/** 回合是否仍在进行中(UI 用来显示「执行中」) */
-export function isTurnOpen(t: Turn, typing: boolean, now: number): boolean {
+/**
+ * 把一批 agent 消息按时间空档切成「轮次」子块(2026-11-10)。
+ *
+ * 规则:相邻两条消息间隔 > `ROUND_GAP_MS` 就开新一轮。
+ * 时间戳缺失(0)时并入当前轮,不制造空轮。
+ */
+function splitRounds(blocks: Message[]): Message[][] {
+  const rounds: Message[][] = [];
+  let cur: Message[] = [];
+  let lastAt = 0;
+  for (const m of blocks) {
+    const t = toTime(m.ts);
+    if (cur.length > 0 && t > 0 && lastAt > 0 && t - lastAt > ROUND_GAP_MS) {
+      rounds.push(cur);
+      cur = [];
+    }
+    cur.push(m);
+    if (t > 0) lastAt = t;
+  }
+  if (cur.length > 0) rounds.push(cur);
+  return rounds;
+}
+
+/** 回合是否仍在进行中(UI 用来显示「执行中」) */export function isTurnOpen(t: Turn, typing: boolean, now: number): boolean {
   if (t.closed) return false;
   return typing || now - t.updatedAt < QUIET_MS;
 }

+ 12 - 0
src/app/desktop/desktop.routes.ts

@@ -1,4 +1,5 @@
 import { Routes } from '@angular/router';
+import { adminGuard } from '../core/acl';
 import { DesktopShellComponent } from './shell/desktop-shell';
 
 /**
@@ -84,6 +85,17 @@ export const desktopRoutes: Routes = [
         path: 'tasks',
         loadComponent: () => import('./pages/tasks/tasks').then((m) => m.DkTasksPage),
       },
+      {
+        /*
+         * 组织成员管理(2026-11-10)。
+         *
+         * 🔴 `adminGuard` 只是「入口不让人乱进」;真正的鉴权在云函数里,
+         *    改前端也越不了权(用户明确要求的服务端校验)。
+         */
+        path: 'admin/users',
+        canActivate: [adminGuard],
+        loadComponent: () => import('./pages/admin/users/users').then((m) => m.AdminUsersPage),
+      },
       {
         path: 'tasks/:id',
         loadComponent: () => import('./pages/tasks/tasks').then((m) => m.DkTasksPage),

+ 531 - 0
src/app/desktop/pages/admin/users/users.ts

@@ -0,0 +1,531 @@
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { UiButton, UiCard, UiEmpty, UiModal, UiPagination, UiSkeleton } from '../../../../ui';
+import { PageComponent } from '../../../../components/shell/page/page';
+import { OrgUser } from '../../../../core/api/models';
+import { OrgUsersService } from '../../../../core/org-users.service';
+import { SessionService } from '../../../../core/session.service';
+import { fmtDateTime } from '../../../../core/time';
+
+/**
+ * 桌面·组织成员管理(2026-11-10)
+ *
+ * 布局:顶部搜索 → 中间列表 → 底部分页条(用户明确要求的三段式)。
+ *
+ * 🔴 权限:本页能否打开由路由 `adminGuard` 决定,但**真正的鉴权在云函数**——
+ *    即使有人改前端硬闯进来,接口也会返回 403,页面如实显示错误而不是空白。
+ */
+@Component({
+  selector: 'app-dk-admin-users-page',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [FormsModule, UiButton, UiCard, UiEmpty, UiModal, UiPagination, UiSkeleton, PageComponent],
+  template: `
+    <app-page
+      [fixedHeader]="true"
+      [contentScroll]="true"
+      title="成员管理"
+      subtitle="管理本组织的成员:新增、修改资料、重置密码、移除。"
+    >
+      <ng-container pageActions>
+        <ui-button variant="primary" (click)="openAdd()">添加成员</ui-button>
+      </ng-container>
+
+      <!--
+        搜索放进 pageToolbar 固定区(2026-11-10 用户要求):
+        **外层不滚动,只有下面的列表区内部滚动** —— 搜索/工具栏永远可见。
+      -->
+      <div pageToolbar class="toolbar">
+        <input
+          class="search"
+          type="search"
+          placeholder="搜索昵称 / 手机号 / 用户 ID"
+          [(ngModel)]="keywordModel"
+          (keydown.enter)="runSearch()"
+          aria-label="搜索成员"
+        />
+        <ui-button variant="outline" (click)="runSearch()">搜索</ui-button>
+        @if (svc.hasKeyword()) {
+          <ui-button variant="ghost" (click)="clearSearch()">清除</ui-button>
+        }
+        <span class="scope-hint" title="列表与搜索都固定在本组织内,无法切换到其他组织">
+          <i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
+          仅显示「{{ orgName() }}」的成员
+        </span>
+      </div>
+
+      @switch (svc.state()) {
+        @case ('loading') {
+          <div class="rows">
+            @for (i of [1, 2, 3, 4, 5]; track i) {
+              <ui-skeleton variant="rect" height="64px" />
+            }
+          </div>
+        }
+        @case ('error') {
+          <ui-card>
+            <ui-empty
+              icon="fa-solid fa-triangle-exclamation"
+              title="成员列表加载失败"
+              [description]="svc.error() ?? ''"
+            />
+            <div class="center">
+              <ui-button variant="outline" (click)="reload()">重新加载</ui-button>
+            </div>
+          </ui-card>
+        }
+        @case ('empty') {
+          <ui-card>
+            @if (svc.hasKeyword()) {
+              <ui-empty title="没有找到匹配的成员" description="换个关键词试试,或清除搜索条件。" />
+            } @else {
+              <ui-empty title="组织里还没有成员" description="点右上角「添加成员」,输入用户 ID 把人拉进来。" />
+            }
+          </ui-card>
+        }
+        @default {
+          <!--
+            列表自成一体:表头吸顶、内容在内部滚动、分页固定在底部。
+            外层(.scroll-zone)不滚 —— 用户明确要求「最外层不滚动」。
+          -->
+          <div class="table-wrap">
+            <div class="table">
+              <div class="thead">
+                <span>成员</span>
+                <span>手机号</span>
+                <span>用户 ID</span>
+                <span>加入时间</span>
+                <span class="ops">操作</span>
+              </div>
+              <div class="tbody">
+                @for (u of svc.items(); track u.id) {
+                  <div class="tr">
+                    <span class="cell">
+                      <span class="avatar" aria-hidden="true">{{ initialOf(u) }}</span>
+                      <span class="names">
+                        <strong>{{ u.nickname || u.username || '未命名' }}</strong>
+                        <small>{{ u.username }}</small>
+                      </span>
+                    </span>
+                    <span class="cell mono">{{ u.mobile || '—' }}</span>
+                    <span class="cell mono ellipsis" [title]="u.id">{{ u.id }}</span>
+                    <span class="cell">{{ joinedAt(u) }}</span>
+                    <span class="cell ops">
+                      <ui-button variant="ghost" size="sm" (click)="openEdit(u)">编辑</ui-button>
+                      <ui-button variant="ghost" size="sm" (click)="askRemove(u)">移除</ui-button>
+                    </span>
+                  </div>
+                }
+              </div>
+            </div>
+
+            <div class="pager">
+              <span class="total">共 {{ svc.total() }} 人</span>
+              <ui-pagination
+                [total]="svc.total()"
+                [pageSize]="svc.pageSize"
+                [current]="svc.page()"
+                (currentChange)="onPage($event)"
+                showTotal
+              />
+            </div>
+          </div>
+        }
+      }
+    </app-page>
+
+    <!-- 编辑 / 新增:居中模态(规范:表单与删除确认一律走居中 ui-modal) -->
+    <ui-modal [open]="dialogOpen()" [title]="dialogTitle()" [busy]="submitting()" (openChange)="onDialogToggle($event)">
+      @if (mode() === 'add') {
+        <label class="field">
+          <span>用户 ID</span>
+          <input class="field-input" type="text" placeholder="粘贴要加入组织的用户 ID" [(ngModel)]="form.userId" />
+        </label>
+        <p class="tip">用户 ID 可在对方「等待加入组织」页面复制获得。</p>
+      } @else {
+        <label class="field">
+          <span>昵称</span>
+          <input class="field-input" type="text" [(ngModel)]="form.nickname" />
+        </label>
+        <label class="field">
+          <span>手机号</span>
+          <input class="field-input" type="tel" [(ngModel)]="form.mobile" placeholder="11 位手机号" />
+        </label>
+        <label class="field">
+          <span>重置密码</span>
+          <input class="field-input" type="password" [(ngModel)]="form.password" placeholder="留空表示不修改" />
+        </label>
+      }
+
+      @if (dialogError()) {
+        <p class="err" role="alert">{{ dialogError() }}</p>
+      }
+
+      <div class="dialog-actions">
+        <ui-button variant="outline" (click)="closeDialog()">取消</ui-button>
+        <ui-button variant="primary" [disabled]="submitting()" (click)="submit()">
+          {{ submitting() ? '提交中…' : '确定' }}
+        </ui-button>
+      </div>
+    </ui-modal>
+
+    <!-- 删除必须二次确认(规范:删除类一律居中模态) -->
+    <ui-modal [open]="removeOpen()" title="移除成员" [busy]="submitting()" (openChange)="onRemoveToggle($event)">
+      <p class="tip">
+        确定把「{{ removeTarget()?.nickname || removeTarget()?.username }}」移出本组织吗?
+        该操作会软删账号并解除组织归属,对方将无法继续使用。
+      </p>
+      @if (dialogError()) {
+        <p class="err" role="alert">{{ dialogError() }}</p>
+      }
+      <div class="dialog-actions">
+        <ui-button variant="outline" (click)="removeOpen.set(false)">取消</ui-button>
+        <ui-button variant="danger" [disabled]="submitting()" (click)="confirmRemove()">确认移除</ui-button>
+      </div>
+    </ui-modal>
+  `,
+  styles: [
+    `
+      /*
+       * 外层不滚(2026-11-10 用户要求):page 已通过 content-scroll
+       * 把滚动区锁成弹性容器,这里让组件撑满它,滚动交给内部 .tbody。
+       */
+      :host {
+        display: flex;
+        flex-direction: column;
+        flex: 1;
+        height: 100%;
+        min-height: 0;
+        overflow: hidden;
+      }
+      .toolbar {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+        /* 与下方表格拉开距离:原来贴在一起,看起来像「被盖住」(用户实测) */
+        padding-bottom: var(--sp-3);
+      }
+      /* 搜索范围提示:明确「只能搜本组织」,与用户预期对齐 */
+      .scope-hint {
+        display: inline-flex;
+        align-items: center;
+        gap: var(--sp-1);
+        margin-left: auto;
+        color: var(--muted);
+        font-size: var(--fs-12);
+      }
+      .search {
+        flex: 1;
+        max-width: 420px;
+        min-height: 40px;
+        padding: 0 var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        background: var(--surface);
+        color: var(--ink);
+        font-size: var(--fs-14);
+      }
+      .search:focus {
+        outline: 2px solid var(--ring, var(--primary));
+        outline-offset: 1px;
+      }
+      /* 弹窗内输入框:ui-input 不支持 ngModel(原生控件在这里作为表单实现) */
+      .field-input {
+        min-height: 40px;
+        padding: 0 var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        background: var(--surface);
+        color: var(--ink);
+        font-size: var(--fs-14);
+        width: 100%;
+      }
+      .field-input:focus {
+        outline: 2px solid var(--ring, var(--primary));
+        outline-offset: 1px;
+      }
+      .rows {
+        display: grid;
+        gap: var(--sp-2);
+      }
+      /* 表格自成一体:外层不滚,表头吸顶、内容区内部滚动、分页固定在底 */
+      .table-wrap {
+        display: flex;
+        flex-direction: column;
+        /* 撑满宿主(宿主是 flex 子项且有确定高度),超高部分交给 .tbody 滚 */
+        height: 100%;
+        max-height: 100%;
+        min-height: 0;
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        background: var(--surface);
+        overflow: hidden;
+      }
+      .table {
+        display: flex;
+        flex-direction: column;
+        flex: 1;
+        min-height: 0;
+      }
+      .tbody {
+        flex: 1;
+        min-height: 0;
+        overflow-y: auto;
+        /*
+         * 底部留白:最后一行不会被分页条贴住/压住(用户实测「下半部分有遮挡」)。
+         * 滚动到底时会多出一块空白,视觉上明确「已经到底了」。
+         */
+        padding-bottom: var(--sp-2);
+        scroll-padding-bottom: var(--sp-2);
+      }
+      .thead,
+      .tr {
+        display: grid;
+        grid-template-columns: 1.4fr 1fr 1.6fr 1fr 140px;
+        align-items: center;
+        gap: var(--sp-3);
+        padding: var(--sp-3) var(--sp-4);
+      }
+      .thead {
+        flex: none;
+        background: var(--surface-2);
+        color: var(--muted);
+        font-size: var(--fs-12);
+        border-bottom: 1px solid var(--edge);
+      }
+      .tr {
+        border-top: 1px solid var(--edge);
+        font-size: var(--fs-14);
+        color: var(--ink-2);
+      }
+      .tbody .tr:first-child {
+        border-top: 0;
+      }
+      .tr:hover {
+        background: var(--surface-hover);
+      }
+      .cell {
+        min-width: 0;
+      }
+      .mono {
+        font-family: var(--font-mono, ui-monospace, monospace);
+        font-size: var(--fs-13);
+      }
+      .ellipsis {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .tr .cell:first-child {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+      }
+      .avatar {
+        flex: none;
+        display: grid;
+        place-items: center;
+        width: 32px;
+        height: 32px;
+        border-radius: 50%;
+        background: var(--surface-2);
+        color: var(--ink);
+        font-size: var(--fs-13);
+      }
+      .names {
+        display: grid;
+        min-width: 0;
+      }
+      .names strong {
+        color: var(--ink);
+      }
+      .names small {
+        color: var(--muted);
+        font-size: var(--fs-12);
+      }
+      .ops {
+        display: flex;
+        gap: var(--sp-1);
+        justify-content: flex-end;
+      }
+      .pager {
+        flex: none;
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        gap: var(--sp-3);
+        padding: var(--sp-3) var(--sp-4);
+        border-top: 1px solid var(--edge);
+        background: var(--surface);
+      }
+      .total {
+        color: var(--muted);
+        font-size: var(--fs-13);
+      }
+      .center {
+        display: flex;
+        justify-content: center;
+        margin-top: var(--sp-3);
+      }
+      .field {
+        display: grid;
+        gap: var(--sp-1);
+        margin-bottom: var(--sp-3);
+        font-size: var(--fs-13);
+        color: var(--ink-2);
+      }
+      .tip {
+        margin: 0;
+        color: var(--muted);
+        font-size: var(--fs-13);
+        line-height: 1.6;
+      }
+      .err {
+        margin: var(--sp-2) 0 0;
+        color: var(--danger);
+        font-size: var(--fs-13);
+      }
+      .dialog-actions {
+        display: flex;
+        justify-content: flex-end;
+        gap: var(--sp-2);
+        margin-top: var(--sp-5);
+      }
+    `,
+  ],
+})
+export class AdminUsersPage implements OnInit {
+  protected readonly svc = inject(OrgUsersService);
+  /** 当前账号信息:用来显示「仅显示哪个组织」 */
+  private readonly session = inject(SessionService);
+
+  /** 搜索输入框的草稿值(回车/点按钮才真正查询) */
+  protected keywordModel = '';
+
+  protected readonly mode = signal<'add' | 'edit'>('add');
+  protected readonly dialogOpen = signal(false);
+  protected readonly removeOpen = signal(false);
+  protected readonly removeTarget = signal<OrgUser | null>(null);
+  protected readonly submitting = signal(false);
+  protected readonly dialogError = signal<string | null>(null);
+  protected editingId = '';
+
+  form: { userId: string; nickname: string; mobile: string; password: string } = {
+    userId: '',
+    nickname: '',
+    mobile: '',
+    password: '',
+  };
+
+  protected readonly dialogTitle = computed(() =>
+    this.mode() === 'add' ? '添加成员' : '编辑成员',
+  );
+
+  /**
+   * 当前组织名(2026-11-10)。
+   *
+   * 🔴 作用域**固定为本组织**,由服务端强制(云函数忽略前端传的组织参数),
+   *    这里只把范围显示出来,不给任何切换入口 —— 用户明确要求「不能修改」。
+   */
+  protected readonly orgName = computed(() => this.session.me()?.companyName || '当前组织');
+
+  async ngOnInit(): Promise<void> {
+    await this.svc.ensureLoaded();
+  }
+
+  protected async reload(): Promise<void> {
+    await this.svc.refresh();
+  }
+
+  protected async runSearch(): Promise<void> {
+    await this.svc.search(this.keywordModel);
+  }
+
+  protected async clearSearch(): Promise<void> {
+    this.keywordModel = '';
+    await this.svc.search('');
+  }
+
+  protected async onPage(p: number): Promise<void> {
+    await this.svc.goTo(p);
+  }
+
+  protected joinedAt(u: OrgUser): string {
+    return u.createdAt ? fmtDateTime(u.createdAt) : '—';
+  }
+
+  protected initialOf(u: OrgUser): string {
+    return (u.nickname || u.username || '?').slice(0, 1).toUpperCase();
+  }
+
+  protected openAdd(): void {
+    this.mode.set('add');
+    this.editingId = '';
+    this.form = { userId: '', nickname: '', mobile: '', password: '' };
+    this.dialogError.set(null);
+    this.dialogOpen.set(true);
+  }
+
+  protected openEdit(u: OrgUser): void {
+    this.mode.set('edit');
+    this.editingId = u.id;
+    this.form = { userId: u.id, nickname: u.nickname, mobile: u.mobile, password: '' };
+    this.dialogError.set(null);
+    this.dialogOpen.set(true);
+  }
+
+  protected closeDialog(): void {
+    if (this.submitting()) return;
+    this.dialogOpen.set(false);
+  }
+
+  protected onDialogToggle(open: boolean): void {
+    if (!open) this.closeDialog();
+  }
+
+  protected onRemoveToggle(open: boolean): void {
+    if (!open) this.removeOpen.set(false);
+  }
+
+  protected async submit(): Promise<void> {
+    if (this.submitting()) return;
+    this.submitting.set(true);
+    this.dialogError.set(null);
+    try {
+      if (this.mode() === 'add') {
+        await this.svc.add(this.form.userId);
+      } else {
+        await this.svc.update(this.editingId, {
+          nickname: this.form.nickname,
+          mobile: this.form.mobile,
+          password: this.form.password || undefined,
+        });
+      }
+      this.dialogOpen.set(false);
+    } catch (e) {
+      this.dialogError.set(e instanceof Error ? e.message : '提交失败');
+    } finally {
+      this.submitting.set(false);
+    }
+  }
+
+  protected askRemove(u: OrgUser): void {
+    this.removeTarget.set(u);
+    this.dialogError.set(null);
+    this.removeOpen.set(true);
+  }
+
+  protected async confirmRemove(): Promise<void> {
+    const u = this.removeTarget();
+    if (!u || this.submitting()) return;
+    this.submitting.set(true);
+    this.dialogError.set(null);
+    try {
+      await this.svc.remove(u.id);
+      this.removeOpen.set(false);
+    } catch (e) {
+      this.dialogError.set(e instanceof Error ? e.message : '移除失败');
+    } finally {
+      this.submitting.set(false);
+    }
+  }
+}

+ 31 - 2
src/app/desktop/pages/chat/chat.ts

@@ -45,6 +45,7 @@ import { DeviceService } from '../../../core/device.service';
 import { fmtPercent, fmtTokens, usageOf } from '../../../core/format';
 import { TaskLine, TaskPulseService } from '../../../core/task-pulse.service';
 import { ArtifactSyncService } from '../../../core/artifact-sync.service';
+import { TurnCollapseService } from '../../../core/turn-collapse.service';
 import { LoopGuardService } from '../../../core/loop-guard.service';
 import { Message } from '../../../core/api/models';
 import { fmtTime } from '../../../core/time';
@@ -1901,6 +1902,8 @@ export class DkChatPage implements OnDestroy {
   private readonly tasks = inject(TasksService);
   private readonly pulse = inject(TaskPulseService);
   private readonly artifactSync = inject(ArtifactSyncService);
+  /** 回合折叠状态(2026-11-10:接上 `retain()`,避免会话切换后残留) */
+  private readonly collapse = inject(TurnCollapseService);
   readonly loop = inject(LoopGuardService);
   /** 多会话(线程)—— 左栏会话列表的唯一数据源 */
   readonly sessions = inject(ChatSessionService);
@@ -2137,13 +2140,23 @@ export class DkChatPage implements OnDestroy {
         out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
       }
       /*
-       * 有内容 / 仍在输出 / **已发出等待回复** 都要出卡(2026-11-09)。
+       * 同一轮输入里的附件(2026-11-10):图 + 文字是一个气泡的两部分。
+       * 协议上它们是两个事件,这里按回合还原成「文字在上、附件紧随」的一组。
+       */
+      for (const f of t.userFiles ?? []) {
+        out.push({ kind: 'msg', key: f.id, message: f });
+      }
+      /*
+       * 有内容 / 仍在输出 / **已发出等待回复** / **有用户附件** 都要出卡(2026-11-10 补最后一条)。
        *
        * 🔴 最后一个条件是新加的。原来只有前两个,而「刚发出去、容器还没回」
        *    时既没有内容也不 live,于是**什么都不显示**,用户干等约 10 秒
        *    才看到「正在思考」(实测)。「已发出」本身就该立刻可见。
+       *
+       * 🔴 `userFiles` 也是新增的(评审 P1-1):纯附件回合若没有 `userMessage`
+       *    (无锚点合并的情形),不出卡会**静默丢图**。
        */
-      if (t.merged || t.live || t.userMessage) {
+      if (t.merged || t.live || t.userMessage || (t.userFiles?.length ?? 0) > 0) {
         out.push({ kind: 'turn', key: `turn-${t.id}`, turn: t });
       }
     }
@@ -2396,6 +2409,22 @@ export class DkChatPage implements OnDestroy {
   });
 
   constructor() {
+    /*
+     * 折叠状态按当前回合集合裁剪(2026-11-10,评审 P1-2)。
+     *
+     * 回合 id = 用户消息 id,全局唯一;会话切换后旧回合不会再出现,
+     * 但 `overrides` 会一直累加 —— `retain()` 此前**从未被任何地方调用**。
+     * 回合集合变化时裁一次,只留下当前视图里存在的回合。
+     */
+    effect(() => {
+      const ids = new Set(
+        this.aiRows()
+          .filter((r): r is Extract<AiRow, { kind: 'turn' }> => r.kind === 'turn')
+          .map((r) => r.turn.id),
+      );
+      untracked(() => this.collapse.retain(ids));
+    });
+
     /*
      * AC-04.2:新消息到达后滚动到底。
      *

+ 41 - 10
src/app/desktop/pages/group-chat/group-chat.ts

@@ -733,13 +733,19 @@ export class DkGroupChatPage implements OnDestroy {
     }
   }
 
-  /** 群内所有容器交付的附件(从群聊消息里汇总) */
+  /**
+   * 「文件」= **我自己发的附件**(2026-11-10,与单聊同口径)。
+   *
+   * 🔴 原来这里不过滤发送方,容器交付的成果也进了「群内文件」,
+   *    与单聊语义正好相反(单聊早就改成 `sender === 'me'`)。
+   */
   protected readonly railFiles = computed<RailFile[]>(() => {
     const seen = new Set<string>();
     const out: RailFile[] = [];
     for (const m of this.messages()) {
       const f = m.file;
       if (!f?.url) continue;
+      if (m.sender !== 'me') continue;
       const key = f.url + f.name;
       if (seen.has(key)) continue;
       seen.add(key);
@@ -755,21 +761,46 @@ export class DkGroupChatPage implements OnDestroy {
     return this.tasks.all().filter((t) => !!t.agentId && ids.has(t.agentId));
   });
 
-  /** 群成员的交付成果(合并去重) */
+  /**
+   * 「成果」= **本群这次对话里**成员容器产出的文件(2026-11-10)。
+   *
+   * 🔴 原实现按成员容器 `byAgent(id)` 拉**全量历史成果** ——
+   *    成员在别的单聊里产出的文件也会出现在这个群的成果里(跨会话串味)。
+   *    单聊早已改为「按会话取」,群聊这里对齐:
+   *
+   *   ① 群会话记录上的 `deliverableUrls`(云端权威来源)
+   *   ② 群消息流里 agent 发的附件(实时兜底,不依赖落库时间窗)
+   *
+   * 每条成果带上「是哪个成员产出的」,右栏据此显示归属。
+   */
   protected readonly railDeliverables = computed<Deliverable[]>(() => {
-    const ids = this.session()?.memberIds ?? [];
-    const out: Deliverable[] = [];
+    const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
+    const agentMsgs = this.messages().filter((m) => m.sender === 'agent' && m.file?.url);
+    const fromMessages = this.deliverablesSvc.fromAgentFiles(
+      agentMsgs.map((m) => m.file!),
+    ).map((d, i) => ({
+      ...d,
+      // 补上产出成员(`fromAgentFiles` 不带归属)
+      agentId: agentMsgs[i]?.senderId ?? '',
+    }));
+
     const seen = new Set<string>();
-    for (const id of ids) {
-      for (const d of this.deliverablesSvc.byAgent(id)) {
-        if (seen.has(d.id)) continue;
-        seen.add(d.id);
-        out.push(d);
-      }
+    const out: Deliverable[] = [];
+    for (const d of [...fromRecord, ...fromMessages]) {
+      const url = d.artifacts[0]?.url ?? d.id;
+      if (seen.has(url)) continue;
+      seen.add(url);
+      out.push(d);
     }
     return out;
   });
 
+  /** 成果的产出成员名(没有归属时返回空串,右栏不显示标签) */
+  protected ownerNameOf(d: Deliverable): string {
+    if (!d.agentId) return '';
+    return this.members().find((a) => a.id === d.agentId)?.name ?? d.agentId;
+  }
+
   /** 状态页:每个成员一行 */
   protected readonly railRuntimes = computed<RailRuntime[]>(() =>
     this.members().map((a) => ({

+ 34 - 8
src/app/desktop/shell/desktop-shell.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, inject, signal } from '@angular/core';
 import { Router, RouterOutlet } from '@angular/router';
 import { DeviceService } from '../../core/device.service';
 import { LiveService } from '../../core/live.service';
@@ -71,7 +71,10 @@ import { NavCustomModalComponent } from '../../components/domain/nav-custom-moda
           <span class="narrow-icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
           <h2>当前窗口适合手机端</h2>
           <p>窗口宽度小于 768px。你可以切到手机端继续,或放大窗口回到桌面布局。</p>
-          <button type="button" class="narrow-btn" (click)="goMobile()">切换到手机端</button>
+          <div class="narrow-actions">
+            <button type="button" class="narrow-btn ghost" (click)="dismissHint()">稍后再说</button>
+            <button type="button" class="narrow-btn" (click)="goMobile()">切换到手机端</button>
+          </div>
           @if (device.isForced()) {
             <button type="button" class="narrow-btn ghost" (click)="resetToAuto()">
               恢复自动识别
@@ -208,16 +211,23 @@ import { NavCustomModalComponent } from '../../components/domain/nav-custom-moda
         color: var(--muted);
         line-height: 1.7;
       }
+      /* 两个动作并排:次要操作在左,主操作在右 */
+      .narrow-actions {
+        display: flex;
+        gap: var(--sp-2);
+        margin-top: var(--sp-3);
+      }
       .narrow-btn {
-        margin-top: var(--sp-2);
+        flex: 1;
         min-height: var(--touch-min);
-        padding: 0 var(--sp-4);
+        padding: 0 var(--sp-3);
         border: 0;
         border-radius: var(--r-sm);
         background: var(--primary);
         color: var(--primary-content);
         font-size: var(--fs-13);
         cursor: pointer;
+        white-space: nowrap;
       }
       .narrow-btn.ghost {
         background: transparent;
@@ -227,7 +237,7 @@ import { NavCustomModalComponent } from '../../components/domain/nav-custom-moda
     `,
   ],
 })
-export class DesktopShellComponent {
+export class DesktopShellComponent implements OnDestroy {
   readonly device = inject(DeviceService);
   private readonly live = inject(LiveService);
   private readonly matrix = inject(MatrixSessionService);
@@ -256,9 +266,15 @@ export class DesktopShellComponent {
     if (this.session.token()) {
       void this.session.refreshMe((t) => this.api.fetchMe(t));
     }
-    if (typeof window !== 'undefined') {
-      window.addEventListener('resize', () => this.narrowBelow768.set(this.isNarrow()));
-    }
+    /*
+     * 视口变化 → 跨端提示(2026-11-10 改为 DeviceService 统一判定)。
+     *
+     * 🔴 原实现是裸 `resize` 无防抖:拖拽窗口时每像素一次 set,提示反复闪。
+     *    现在走 `watchViewport()`:matchMedia 只在跨断点时触发,外加 250ms 防抖。
+     *    **不静默跳端** —— 只算出建议值,用户点确认才切。
+     */
+    this.device.watchViewport(() => this.narrowBelow768.set(this.device.suggestedMode() === 'mobile'));
+    this.narrowBelow768.set(this.device.suggestedMode() === 'mobile');
   }
 
   setCollapsed(collapsed: boolean): void {
@@ -283,6 +299,16 @@ export class DesktopShellComponent {
     void this.router.navigateByUrl(path, { replaceUrl: true });
   }
 
+  /** 用户点「稍后」→ 本次不再提示(下次跨断点仍会提示) */
+  dismissHint(): void {
+    this.device.dismissSuggestion();
+    this.narrowBelow768.set(false);
+  }
+
+  ngOnDestroy(): void {
+    this.device.stopWatching();
+  }
+
   /** FR-10 AC-10.1:恢复自动识别 */
   resetToAuto(): void {
     const mode = this.device.resetToAuto();

+ 6 - 0
src/app/mobile/mobile.routes.ts

@@ -24,6 +24,12 @@ export const mobileRoutes: Routes = [
     canActivate: [adminGuard],
     loadComponent: () => import('./pages/admin/admin').then((m) => m.MbAdminPage),
   },
+  {
+    /* 成员管理(2026-11-10)。鉴权同样在云函数侧,这里只挡入口 */
+    path: 'admin/users',
+    canActivate: [adminGuard],
+    loadComponent: () => import('./pages/admin/users/users').then((m) => m.MbAdminUsersPage),
+  },
   { path: 'projects', loadComponent: () => import('./pages/projects/projects').then((m) => m.MbProjectsPage) },
   { path: 'projects/:id', loadComponent: () => import('./pages/projects/projects').then((m) => m.MbProjectsPage) },
   /* 容器设置(2026-11-04):从通讯录点容器进入,只提供重命名 */

+ 21 - 0
src/app/mobile/pages/admin/admin.ts

@@ -120,6 +120,22 @@ import { PromptModalService } from '../../../components/domain/prompt-modal/prom
             }
           </section>
 
+          <!-- 成员管理(2026-11-10 已开放):其余能力仍标「尚未开放」,不做假开关 -->
+          <section class="m-section">
+            <h2 class="m-section-title">成员</h2>
+            <ul class="m-list">
+              <li>
+                <button type="button" class="m-row" (click)="goMembers()">
+                  <span class="m-row-body">
+                    <span class="m-row-t1">成员管理</span>
+                    <span class="m-row-t2">新增、改资料、重置密码、移除组织成员</span>
+                  </span>
+                  <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
+                </button>
+              </li>
+            </ul>
+          </section>
+
           <!-- 管理能力:明确标注未开放,不做假开关 -->
           <section class="m-section">
             <h2 class="m-section-title">管理能力</h2>
@@ -375,6 +391,11 @@ export class MbAdminPage {
    * 存本地会导致「我这里改了、同事看到的还是旧名」。
    * 服务端接口就绪前调用会失败,界面如实提示 —— 不做本地假象。
    */
+  /** 进入成员管理页(2026-11-10) */
+  protected goMembers(): void {
+    void this.router.navigateByUrl('/mobile/admin/users');
+  }
+
   protected rename(agent: Agent): void {
     this.prompt.open({
       title: '重命名容器',

+ 505 - 0
src/app/mobile/pages/admin/users/users.ts

@@ -0,0 +1,505 @@
+import { ChangeDetectionStrategy, Component, computed, inject, OnInit, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Router } from '@angular/router';
+import { OrgUser } from '../../../../core/api/models';
+import { OrgUsersService } from '../../../../core/org-users.service';
+import { MAppBarComponent } from '../../../components/m-app-bar/m-app-bar';
+import { MEmptyComponent } from '../../../components/m-empty/m-empty';
+
+/**
+ * 移动·组织成员管理(2026-11-10)
+ *
+ * ## 为什么不是把 PC 表格搬过来
+ *
+ * 手机上五列表格必然横向溢出、字小到不可读。移动端改为:
+ * - **卡片行**:姓名 + 子信息纵向排布,整行可点
+ * - **搜索吸顶**:滚动时仍能改关键词
+ * - **操作走底部抽屉**:编辑/新增都是全屏 sheet,而不是居中小弹窗
+ * - **分页改为「加载更多」**:手机上点页码很别扭,上一页/下一页语义也更顺
+ *
+ * 所有触控区 ≥44px,正文 ≥16px(移动端规范)。
+ */
+@Component({
+  selector: 'app-mb-admin-users-page',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [FormsModule, MAppBarComponent, MEmptyComponent],
+  template: `
+    <app-m-app-bar [title]="'成员管理'" [subtitle]="'共 ' + svc.total() + ' 人'" [back]="true" (backTap)="back()">
+      <button barRight type="button" class="m-fab" aria-label="添加成员" (click)="openAdd()">
+        <i class="fa-solid fa-user-plus" aria-hidden="true"></i>
+      </button>
+    </app-m-app-bar>
+
+    <!-- 搜索吸顶 -->
+    <div class="m-search">
+      <input
+        class="m-search-input"
+        type="search"
+        placeholder="搜索昵称 / 手机号 / 用户 ID"
+        [(ngModel)]="keywordModel"
+        (keydown.enter)="runSearch()"
+        aria-label="搜索成员"
+      />
+      <button type="button" class="m-search-btn" (click)="runSearch()">搜索</button>
+    </div>
+
+    <main class="m-body">
+      @switch (svc.state()) {
+        @case ('loading') {
+          <ul class="m-list">
+            @for (i of [1, 2, 3, 4]; track i) {
+              <li class="m-card m-skel" aria-hidden="true"></li>
+            }
+          </ul>
+        }
+        @case ('error') {
+          <app-m-empty [title]="'加载失败'" [description]="svc.error() ?? ''" />
+          <button type="button" class="m-retry" (click)="reload()">重新加载</button>
+        }
+        @case ('empty') {
+          @if (svc.hasKeyword()) {
+            <app-m-empty title="没有找到匹配的成员" description="换个关键词,或清除搜索条件。" />
+          } @else {
+            <app-m-empty title="组织里还没有成员" description="点右上角加号,输入用户 ID 把人拉进来。" />
+          }
+        }
+        @default {
+          <ul class="m-list">
+            @for (u of svc.items(); track u.id) {
+              <li class="m-card">
+                <button type="button" class="m-row" (click)="openEdit(u)">
+                  <span class="m-avatar" aria-hidden="true">{{ initialOf(u) }}</span>
+                  <span class="m-main">
+                    <span class="m-name">{{ u.nickname || u.username || '未命名' }}</span>
+                    <span class="m-sub">{{ u.mobile || '未填手机号' }}</span>
+                    <span class="m-id">{{ u.id }}</span>
+                  </span>
+                  <i class="fa-solid fa-chevron-right" aria-hidden="true"></i>
+                </button>
+                <div class="m-actions">
+                  <button type="button" class="m-act" (click)="openEdit(u)">编辑</button>
+                  <button type="button" class="m-act danger" (click)="askRemove(u)">移除</button>
+                </div>
+              </li>
+            }
+          </ul>
+
+          @if (svc.page() < svc.pageCount()) {
+            <button type="button" class="m-more" [disabled]="svc.state() === 'loading'" (click)="loadMore()">
+              加载更多({{ svc.page() }}/{{ svc.pageCount() }})
+            </button>
+          } @else if (svc.pageCount() > 1) {
+            <p class="m-end">已到底</p>
+          }
+        }
+      }
+    </main>
+
+    <!-- 编辑 / 新增:全屏 sheet,不用居中小弹窗 -->
+    @if (sheetOpen()) {
+      <div class="m-sheet-mask" (click)="closeSheet()" aria-hidden="true"></div>
+      <section class="m-sheet" role="dialog" aria-modal="true" [attr.aria-label]="sheetTitle()">
+        <header class="m-sheet-head">
+          <h2>{{ sheetTitle() }}</h2>
+          <button type="button" class="m-sheet-x" aria-label="关闭" (click)="closeSheet()">
+            <i class="fa-solid fa-xmark" aria-hidden="true"></i>
+          </button>
+        </header>
+
+        @if (mode() === 'add') {
+          <label class="m-field">
+            <span>用户 ID</span>
+            <input type="text" [(ngModel)]="form.userId" placeholder="粘贴对方「等待加入组织」页上的 ID" />
+          </label>
+        } @else {
+          <label class="m-field">
+            <span>昵称</span>
+            <input type="text" [(ngModel)]="form.nickname" />
+          </label>
+          <label class="m-field">
+            <span>手机号</span>
+            <input type="tel" [(ngModel)]="form.mobile" placeholder="11 位手机号" />
+          </label>
+          <label class="m-field">
+            <span>重置密码</span>
+            <input type="password" [(ngModel)]="form.password" placeholder="留空表示不修改" />
+          </label>
+        }
+
+        @if (formError()) {
+          <p class="m-err" role="alert">{{ formError() }}</p>
+        }
+
+        <button type="button" class="m-submit" [disabled]="submitting()" (click)="submit()">
+          {{ submitting() ? '提交中…' : '确定' }}
+        </button>
+      </section>
+    }
+
+    <!-- 移除确认:也用 sheet,避免居中小弹窗在窄屏被键盘顶飞 -->
+    @if (removeTarget(); as target) {
+      <div class="m-sheet-mask" (click)="removeTarget.set(null)" aria-hidden="true"></div>
+      <section class="m-sheet" role="alertdialog" aria-modal="true" aria-label="移除成员">
+        <header class="m-sheet-head">
+          <h2>移除成员</h2>
+        </header>
+        <p class="m-confirm-text">
+          确定把「{{ target.nickname || target.username }}」移出本组织吗?对方将无法继续使用。
+        </p>
+        @if (formError()) {
+          <p class="m-err" role="alert">{{ formError() }}</p>
+        }
+        <button type="button" class="m-submit danger" [disabled]="submitting()" (click)="confirmRemove()">
+          {{ submitting() ? '处理中…' : '确认移除' }}
+        </button>
+      </section>
+    }
+  `,
+  styles: [
+    `
+      :host {
+        display: flex;
+        flex-direction: column;
+        height: 100dvh;
+        background: var(--canvas);
+      }
+      .m-fab {
+        width: 44px;
+        height: 44px;
+        border: 0;
+        background: transparent;
+        color: var(--ink);
+      }
+      /* 搜索吸顶:滚动列表时仍可改关键词 */
+      .m-search {
+        flex: none;
+        display: flex;
+        gap: var(--sp-2);
+        padding: var(--sp-3) var(--sp-4);
+        background: var(--surface);
+        border-bottom: 1px solid var(--edge);
+      }
+      .m-search-input {
+        flex: 1;
+        min-height: 44px;
+        padding: 0 var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        background: var(--surface-2);
+        color: var(--ink);
+        font-size: var(--fs-16); /* iOS 输入框 <16px 会触发自动放大 */
+      }
+      .m-search-btn {
+        min-width: 64px;
+        min-height: 44px;
+        border: 1px solid var(--primary);
+        border-radius: var(--radius-m);
+        background: var(--primary);
+        color: var(--inverse);
+        font-size: var(--fs-14);
+      }
+      .m-body {
+        flex: 1;
+        min-height: 0;
+        overflow-y: auto;
+        padding: var(--sp-3) var(--sp-4) var(--sp-8);
+      }
+      .m-list {
+        list-style: none;
+        margin: 0;
+        padding: 0;
+        display: grid;
+        gap: var(--sp-3);
+      }
+      .m-card {
+        background: var(--surface);
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        overflow: hidden;
+      }
+      .m-skel {
+        height: 96px;
+        background: var(--surface-2);
+      }
+      .m-row {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-3);
+        width: 100%;
+        min-height: 64px;
+        padding: var(--sp-3) var(--sp-4);
+        border: 0;
+        background: transparent;
+        text-align: left;
+        color: var(--ink-2);
+        cursor: pointer;
+      }
+      .m-avatar {
+        flex: none;
+        display: grid;
+        place-items: center;
+        width: 40px;
+        height: 40px;
+        border-radius: 50%;
+        background: var(--surface-2);
+        color: var(--ink);
+        font-size: var(--fs-16);
+      }
+      .m-main {
+        flex: 1;
+        min-width: 0;
+        display: grid;
+        gap: 2px;
+      }
+      .m-name {
+        color: var(--ink);
+        font-size: var(--fs-16);
+        font-weight: 600;
+      }
+      .m-sub {
+        color: var(--muted);
+        font-size: var(--fs-13);
+      }
+      .m-id {
+        color: var(--faint);
+        font-size: var(--fs-12);
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      .m-actions {
+        display: flex;
+        border-top: 1px solid var(--edge);
+      }
+      .m-act {
+        flex: 1;
+        min-height: 44px;
+        border: 0;
+        background: transparent;
+        color: var(--ink-2);
+        font-size: var(--fs-14);
+      }
+      .m-act.danger {
+        color: var(--danger);
+      }
+      .m-more {
+        display: block;
+        width: 100%;
+        min-height: 44px;
+        margin-top: var(--sp-4);
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        background: var(--surface);
+        color: var(--ink-2);
+        font-size: var(--fs-14);
+      }
+      .m-end {
+        text-align: center;
+        color: var(--faint);
+        font-size: var(--fs-12);
+        margin: var(--sp-4) 0 0;
+      }
+      .m-retry {
+        display: block;
+        margin: var(--sp-4) auto 0;
+        min-height: 44px;
+        padding: 0 var(--sp-5);
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        background: var(--surface);
+        color: var(--ink-2);
+      }
+      /* 底部抽屉 */
+      .m-sheet-mask {
+        position: fixed;
+        inset: 0;
+        background: var(--mask, rgba(0, 0, 0, 0.4));
+        z-index: var(--z-mask);
+      }
+      .m-sheet {
+        position: fixed;
+        left: 0;
+        right: 0;
+        bottom: 0;
+        z-index: var(--z-modal);
+        max-height: 86dvh;
+        overflow-y: auto;
+        background: var(--surface);
+        border-radius: var(--radius-l) var(--radius-l) 0 0;
+        padding: var(--sp-4) var(--sp-4);
+        padding-bottom: calc(var(--sp-4) + env(safe-area-inset-bottom, 0px));
+      }
+      .m-sheet-head {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin-bottom: var(--sp-4);
+      }
+      .m-sheet-head h2 {
+        margin: 0;
+        font-size: var(--fs-18);
+        color: var(--ink);
+      }
+      .m-sheet-x {
+        width: 44px;
+        height: 44px;
+        border: 0;
+        background: transparent;
+        color: var(--muted);
+        font-size: var(--fs-18);
+      }
+      .m-field {
+        display: grid;
+        gap: var(--sp-1);
+        margin-bottom: var(--sp-3);
+        font-size: var(--fs-13);
+        color: var(--muted);
+      }
+      .m-field input {
+        min-height: 44px;
+        padding: 0 var(--sp-3);
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        background: var(--surface-2);
+        color: var(--ink);
+        font-size: var(--fs-16);
+      }
+      .m-confirm-text {
+        margin: 0 0 var(--sp-4);
+        color: var(--ink-2);
+        font-size: var(--fs-16);
+        line-height: 1.6;
+      }
+      .m-err {
+        margin: 0 0 var(--sp-3);
+        color: var(--danger);
+        font-size: var(--fs-13);
+      }
+      .m-submit {
+        width: 100%;
+        min-height: 48px;
+        border: 1px solid var(--primary);
+        border-radius: var(--radius-m);
+        background: var(--primary);
+        color: var(--inverse);
+        font-size: var(--fs-16);
+      }
+      .m-submit.danger {
+        background: var(--danger);
+        border-color: var(--danger);
+      }
+      .m-submit:disabled {
+        opacity: 0.6;
+      }
+    `,
+  ],
+})
+export class MbAdminUsersPage implements OnInit {
+  private readonly router = inject(Router);
+  protected readonly svc = inject(OrgUsersService);
+
+  protected keywordModel = '';
+
+  protected readonly mode = signal<'add' | 'edit'>('add');
+  protected readonly sheetOpen = signal(false);
+  protected readonly removeTarget = signal<OrgUser | null>(null);
+  protected readonly submitting = signal(false);
+  protected readonly formError = signal<string | null>(null);
+  private editingId = '';
+
+  form: { userId: string; nickname: string; mobile: string; password: string } = {
+    userId: '',
+    nickname: '',
+    mobile: '',
+    password: '',
+  };
+
+  protected readonly sheetTitle = computed(() => (this.mode() === 'add' ? '添加成员' : '编辑成员'));
+
+  async ngOnInit(): Promise<void> {
+    await this.svc.ensureLoaded();
+  }
+
+  protected back(): void {
+    void this.router.navigateByUrl('/mobile/admin');
+  }
+
+  protected async reload(): Promise<void> {
+    await this.svc.refresh();
+  }
+
+  protected async runSearch(): Promise<void> {
+    await this.svc.search(this.keywordModel);
+  }
+
+  protected async loadMore(): Promise<void> {
+    await this.svc.goTo(this.svc.page() + 1);
+  }
+
+  protected initialOf(u: OrgUser): string {
+    return (u.nickname || u.username || '?').slice(0, 1).toUpperCase();
+  }
+
+  protected openAdd(): void {
+    this.mode.set('add');
+    this.editingId = '';
+    this.form = { userId: '', nickname: '', mobile: '', password: '' };
+    this.formError.set(null);
+    this.sheetOpen.set(true);
+  }
+
+  protected openEdit(u: OrgUser): void {
+    this.mode.set('edit');
+    this.editingId = u.id;
+    this.form = { userId: u.id, nickname: u.nickname, mobile: u.mobile, password: '' };
+    this.formError.set(null);
+    this.sheetOpen.set(true);
+  }
+
+  protected closeSheet(): void {
+    if (this.submitting()) return;
+    this.sheetOpen.set(false);
+  }
+
+  protected async submit(): Promise<void> {
+    if (this.submitting()) return;
+    this.submitting.set(true);
+    this.formError.set(null);
+    try {
+      if (this.mode() === 'add') {
+        await this.svc.add(this.form.userId);
+      } else {
+        await this.svc.update(this.editingId, {
+          nickname: this.form.nickname,
+          mobile: this.form.mobile,
+          password: this.form.password || undefined,
+        });
+      }
+      this.sheetOpen.set(false);
+    } catch (e) {
+      this.formError.set(e instanceof Error ? e.message : '提交失败');
+    } finally {
+      this.submitting.set(false);
+    }
+  }
+
+  protected askRemove(u: OrgUser): void {
+    this.formError.set(null);
+    this.removeTarget.set(u);
+  }
+
+  protected async confirmRemove(): Promise<void> {
+    const u = this.removeTarget();
+    if (!u || this.submitting()) return;
+    this.submitting.set(true);
+    this.formError.set(null);
+    try {
+      await this.svc.remove(u.id);
+      this.removeTarget.set(null);
+    } catch (e) {
+      this.formError.set(e instanceof Error ? e.message : '移除失败');
+    } finally {
+      this.submitting.set(false);
+    }
+  }
+}

+ 79 - 10
src/app/mobile/pages/chat/chat.ts

@@ -906,7 +906,17 @@ export class MbChatPage implements OnDestroy {
    */
   protected readonly aiRows = computed<AiRow[]>(() => {
     const out: AiRow[] = [];
-    const now = this.now();
+    /*
+     * 🔴 依赖 `clockTick` 而不是 `now`(2026-11-10,与桌面端同口径)。
+     *
+     * 原来这里读 `this.now()`,于是时钟每秒推进都会让**整页 aiRows 重算**,
+     * 每个 turn 对象都是新引用 → `app-assistant-turn` 的 input 变化 →
+     * 流式推进的 `shownMap` 可能被重置,正文从头重放。
+     * `clockTick` 是个计数器:每秒变一次,但只让**读它的地方**重算,
+     * 回合数据本身只在消息变化时重建。
+     */
+    this.clockTick();
+    const now = Date.now();
 
     for (const t of turnsFromDm(
       buildTurns({ messages: this.chat.messages(), typing: this.chat.peerTyping(), now }),
@@ -919,8 +929,12 @@ export class MbChatPage implements OnDestroy {
       if (t.userMessage) {
         out.push({ kind: 'msg', key: t.userMessage.id, message: t.userMessage });
       }
-      // 已发出等待回复也要出卡(2026-11-09,与桌面同口径)
-      if (t.merged || t.live || t.userMessage) {
+      // 同一轮输入里的附件紧跟文字(2026-11-10,与桌面同口径)
+      for (const f of t.userFiles ?? []) {
+        out.push({ kind: 'msg', key: f.id, message: f });
+      }
+      // 已发出等待回复 / 有用户附件 也要出卡(2026-11-09 / 2026-11-10,与桌面同口径)
+      if (t.merged || t.live || t.userMessage || (t.userFiles?.length ?? 0) > 0) {
         out.push({ kind: 'turn', key: `turn-${t.id}`, turn: t });
       }
     }
@@ -935,8 +949,13 @@ export class MbChatPage implements OnDestroy {
    * 工具块、附件、失败消息各自断开,规则见 `core/message-merge.ts`。
    */
 
-  /** 时钟:驱动回合的「静默超时」判定,每秒走一格 */
-  private readonly now = signal(Date.now());
+  /**
+   * 时钟计数器:每秒 +1,**只用于驱动依赖它的 computed 重算**(2026-11-10)。
+   *
+   * 与桌面端 `clockTick` 同口径。不要改成「读一个 Date.now() 快照信号」——
+   * 那样整页回合数据每秒重建,流式动画会被打断。
+   */
+  private readonly clockTick = signal(0);
   private nowTimer: ReturnType<typeof setInterval> | null = null;
 
   /** 回合折叠状态(用户手动切换覆盖默认) */
@@ -1033,8 +1052,21 @@ export class MbChatPage implements OnDestroy {
   private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
   /** 用户是否停在底部附近(不在底部时不自动滚动,避免打断向上翻历史) */
   private stickBottom = true;
+  /**
+   * 用户是否**主动**上翻离开了底部(2026-11-10,与桌面端 `userScrolledAway` 同口径)。
+   *
+   * 🔴 为什么 `stickBottom` 不够:
+   *    `stickBottom` 只记「此刻离底部多远」,而程序滚动本身也会触发 scroll 事件 ——
+   *    用户上翻 → 新消息到达把列表拉回底部 → scroll 事件又把 `stickBottom` 置回 true →
+   *    下一次新消息继续拉回。形成「上翻 → 被拽回 → 再也翻不上去」的死循环
+   *    (两轮回复交替到达时最明显,用户实测)。
+   *    这里单独记「用户是不是主动走的」,且只在**用户自己滚动**时改变。
+   */
+  private userScrolledAway = false;
   /** 正在加载更早消息(防重入 + 分页期间不贴底) */
   private loadingEarlier = false;
+  /** 上次贴底的时刻(节流用) */
+  private lastStickAt = 0;
 
   protected retryMessage(id: string): void {
     void this.chat.retry(id);
@@ -1050,11 +1082,11 @@ export class MbChatPage implements OnDestroy {
       void this.tasksSvc.ensureAllLoaded();
     });
 
-    // 新消息到达:贴底时滚到底
+    // 新消息到达:贴底时滚到底(用户主动上翻期间不打扰,2026-11-10)
     effect(() => {
       this.chat.messages();
       if (this.loadingEarlier) return;
-      if (this.stickBottom) queueMicrotask(() => this.scrollToBottom());
+      if (this.stickBottom && !this.userScrolledAway) this.stickToBottom();
     });
 
     // 有活着的回合时才跑每秒时钟(见 `syncClock` 注释)
@@ -1087,7 +1119,7 @@ export class MbChatPage implements OnDestroy {
     const needsTick = this.aiRows().some((r) => r.kind === 'turn' && r.turn.live);
     if (needsTick && this.nowTimer === null) {
       this.nowTimer = setInterval(() => {
-        this.now.set(Date.now());
+        this.clockTick.update((n) => n + 1);
         if (!this.aiRows().some((r) => r.kind === 'turn' && r.turn.live)) {
           if (this.nowTimer !== null) clearInterval(this.nowTimer);
           this.nowTimer = null;
@@ -1131,13 +1163,22 @@ export class MbChatPage implements OnDestroy {
       this.sessionsSvc.pruneEmpty(this.chat.sessionId() ?? undefined);
     }
     this.stickBottom = true;
+    this.userScrolledAway = false;
+    this.lastStickAt = 0;
     queueMicrotask(() => this.scrollToBottom());
     void this.fillViewportIfNeeded();
   }
 
   protected send(text: string): void {
-    void this.chat.sendText(text);
+    /*
+     * 自己发消息 = 明确想看结果 → 解除「用户上翻」保护并直接贴底。
+     * 否则用户之前上翻过、发给新消息后还停在半空,看不到自己的消息和目标容器状态。
+     */
+    this.userScrolledAway = false;
     this.stickBottom = true;
+    this.lastStickAt = 0;
+    void this.chat.sendText(text);
+    this.stickToBottom();
   }
 
   protected retry(): void {
@@ -1161,7 +1202,14 @@ export class MbChatPage implements OnDestroy {
    */
   protected onScroll(e: Event): void {
     const el = e.target as HTMLElement;
-    this.stickBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 80;
+    const gap = el.scrollHeight - el.scrollTop - el.clientHeight;
+    this.stickBottom = gap < 80;
+    /*
+     * 用户上翻的判定阈值比贴底恢复的阈值大(与桌面端 160px 同口径):
+     * 只有明显离开底部才算「主动走开」,避免图片异步加载把高度撑大后误判。
+     * 滚回接近底部时自动恢复贴底。
+     */
+    this.userScrolledAway = gap > 160;
     if (el.scrollTop > TOP_LOAD_PX) return;
     if (this.loadingEarlier || this.chat.paginating() || !this.chat.hasMoreHistory()) return;
     void this.loadEarlier();
@@ -1251,6 +1299,27 @@ export class MbChatPage implements OnDestroy {
     const el = this.scroller()?.nativeElement;
     if (el) el.scrollTop = el.scrollHeight;
   }
+
+  /**
+   * 贴底(2026-11-10,与桌面端 `stickToBottom` 同口径)。
+   *
+   * · 用户主动上翻 → 不拽回
+   * · 正在加载更早消息 → 不贴底(那是往上插入)
+   * · 200ms 节流:流式输出时每帧都在长高,不节流会和渲染抢主线程
+   * · 补一次 rAF + 一次延时:图片 / Markdown 异步渲染后高度还会变
+   */
+  private stickToBottom(): void {
+    if (this.userScrolledAway) return;
+    if (this.loadingEarlier) return;
+    const now = Date.now();
+    if (now - this.lastStickAt < 200) return;
+    this.lastStickAt = now;
+    queueMicrotask(() => this.scrollToBottom());
+    requestAnimationFrame(() => {
+      this.scrollToBottom();
+      setTimeout(() => this.scrollToBottom(), 120);
+    });
+  }
 }
 
 /** 渲染行的时间戳(用于按时间排序) */

+ 150 - 3
src/app/mobile/pages/group-chat/group-chat.ts

@@ -13,7 +13,7 @@ import {
 import { ActivatedRoute, Router } from '@angular/router';
 import { toSignal } from '@angular/core/rxjs-interop';
 import { map } from 'rxjs/operators';
-import { Agent, Message } from '../../../core/api/models';
+import { Agent, Deliverable, Message } from '../../../core/api/models';
 import { ChatService } from '../../../core/chat.service';
 import { ChatSessionService } from '../../../core/chat-session.service';
 import { GroupChatService } from '../../../core/group-chat.service';
@@ -22,6 +22,8 @@ import { DispatchAnchor, GroupLiveService } from '../../../core/group-live.servi
 import { GroupStoreService } from '../../../core/group-store.service';
 import { GroupMatrixService } from '../../../core/group-matrix.service';
 import { GroupSendService } from '../../../core/group-send.service';
+import { DeliverablesService } from '../../../core/deliverables.service';
+import { fmtSize } from '../../../core/format';
 import { GroupBlock, blockDuration, buildGroupTurns, GROUP_QUIET_MS } from '../../../core/group-turns';
 import { turnsFromGroup } from '../../../core/assistant-turn';
 import { AssistantTurnComponent } from '../../../components/domain/assistant-turn/assistant-turn';
@@ -39,7 +41,8 @@ import { MDockComponent } from '../../components/m-dock/m-dock';
 import { MEmptyComponent } from '../../components/m-empty/m-empty';
 import { MMsgComponent } from '../../components/m-msg/m-msg';
 
-type Tab = 'turn' | 'members';
+/** 页签:回合 / 成员 / 文件 / 成果(后两个 2026-11-10 新增,与单聊抽屉同口径) */
+type Tab = 'turn' | 'members' | 'files' | 'results';
 
 /**
  * 群聊会话页(移动端,2026-11-05)
@@ -73,12 +76,81 @@ type Tab = 'turn' | 'members';
   template: `
     <div class="m-page">
       <app-m-app-bar [title]="title()" [subtitle]="subtitle()" [back]="true" (backTap)="back()">
+        <button barRight type="button" class="m-fab" aria-label="文件与成果" (click)="tab.set('files')">
+          <i class="fa-solid fa-folder-open" aria-hidden="true"></i>
+        </button>
         <button barRight type="button" class="m-fab" aria-label="群成员" (click)="tab.set('members')">
           <i class="fa-solid fa-users" aria-hidden="true"></i>
         </button>
       </app-m-app-bar>
 
-      @if (tab() === 'members') {
+      @if (tab() === 'files' || tab() === 'results') {
+        <div class="m-body">
+          <section class="m-section">
+            <nav class="m-seg" aria-label="文件与成果">
+              <button
+                type="button"
+                class="m-seg-btn"
+                [class.on]="tab() === 'files'"
+                (click)="tab.set('files')"
+              >
+                文件 {{ groupFiles().length }}
+              </button>
+              <button
+                type="button"
+                class="m-seg-btn"
+                [class.on]="tab() === 'results'"
+                (click)="tab.set('results')"
+              >
+                成果 {{ groupDeliverables().length }}
+              </button>
+            </nav>
+
+            @if (tab() === 'files') {
+              <h2 class="m-section-title">我发的文件 · {{ groupFiles().length }}</h2>
+              @if (groupFiles().length === 0) {
+                <p class="m-note">还没有发过文件。</p>
+              } @else {
+                <ul class="m-list">
+                  @for (f of groupFiles(); track f.url + f.name) {
+                    <li class="m-row">
+                      <button type="button" class="m-row-body" (click)="openFile(f)">
+                        <span class="m-row-t1">{{ f.name }}</span>
+                        <span class="m-row-t2">{{ fmtSize(f.size) }}</span>
+                      </button>
+                    </li>
+                  }
+                </ul>
+              }
+            } @else {
+              <h2 class="m-section-title">群内成果 · {{ groupDeliverables().length }}</h2>
+              @if (groupDeliverables().length === 0) {
+                <p class="m-note">本群还没有产出成果。</p>
+              } @else {
+                <ul class="m-list">
+                  @for (d of groupDeliverables(); track d.id) {
+                    <li class="m-row">
+                      <button type="button" class="m-row-body" (click)="openDeliverable(d)">
+                        <span class="m-row-t1">{{ d.title }}</span>
+                        <span class="m-row-t2">
+                          @if (ownerNameOfDeliverable(d.agentId); as who) {
+                            {{ who }} ·
+                          }
+                          @if (d.artifacts[0]; as a) {
+                            {{ a.name }}
+                          } @else {
+                            暂无附件
+                          }
+                        </span>
+                      </button>
+                    </li>
+                  }
+                </ul>
+              }
+            }
+          </section>
+        </div>
+      } @else if (tab() === 'members') {
         <div class="m-body">
           <section class="m-section">
             <h2 class="m-section-title">群成员 · {{ members().length }}</h2>
@@ -566,6 +638,26 @@ type Tab = 'turn' | 'members';
         font-size: var(--fs-12);
         line-height: 1.6;
       }
+      /* 文件 / 成果页签切换(2026-11-10),触控区 ≥44px */
+      .m-seg {
+        display: grid;
+        grid-template-columns: 1fr 1fr;
+        gap: var(--sp-2);
+        margin-bottom: var(--sp-4);
+      }
+      .m-seg-btn {
+        min-height: 44px;
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-m);
+        background: var(--surface);
+        color: var(--ink-2);
+        font-size: var(--fs-14);
+      }
+      .m-seg-btn.on {
+        background: var(--surface-2);
+        color: var(--ink);
+        border-color: var(--edge-strong);
+      }
     `,
   ],
 })
@@ -582,6 +674,8 @@ export class MbGroupChatPage implements OnDestroy {
   protected readonly matrix = inject(GroupMatrixService);
   /** 群聊发送编排(含附件) */
   protected readonly sender = inject(GroupSendService);
+  /** 文件/成果视图(2026-11-10,与单聊共用同一服务) */
+  private readonly deliverablesSvc = inject(DeliverablesService);
 
   private readonly scroller = viewChild<ElementRef<HTMLDivElement>>('scroller');
 
@@ -610,6 +704,44 @@ export class MbGroupChatPage implements OnDestroy {
     const s = this.session();
     return s ? this.groups.membersOf(s) : [];
   });
+
+  /**
+   * 「文件」= 我自己发的附件(2026-11-10,与桌面群聊/单聊同口径)。
+   *
+   * 原来移动群聊完全没有文件/成果视图,用户想看「我刚才发的文件」
+   * 只能翻聊天记录。
+   */
+  protected readonly groupFiles = computed(() =>
+    this.messages()
+      .filter((m) => m.sender === 'me')
+      .map((m) => m.file)
+      .filter((f): f is NonNullable<typeof f> => !!f),
+  );
+
+  /** 「成果」= 本群这次对话里成员容器产出的文件(2026-11-10,与桌面同口径) */
+  protected readonly groupDeliverables = computed(() => {
+    const fromRecord = this.deliverablesSvc.bySessionUrls(this.session()?.deliverableUrls);
+    const agentMsgs = this.messages().filter((m) => m.sender === 'agent' && m.file?.url);
+    const fromMessages = this.deliverablesSvc
+      .fromAgentFiles(agentMsgs.map((m) => m.file!))
+      .map((d) => ({ ...d, agentId: agentMsgs.find((m) => m.file?.url === d.artifacts[0]?.url)?.senderId ?? '' }));
+
+    const seen = new Set<string>();
+    const out: typeof fromRecord = [];
+    for (const d of [...fromRecord, ...fromMessages]) {
+      const url = d.artifacts[0]?.url ?? d.id;
+      if (seen.has(url)) continue;
+      seen.add(url);
+      out.push(d);
+    }
+    return out;
+  });
+
+  /** 成果产出成员名(没有归属时返回空串) */
+  protected ownerNameOfDeliverable(agentId?: string): string {
+    if (!agentId) return '';
+    return this.members().find((a) => a.id === agentId)?.name ?? agentId;
+  }
   protected readonly title = computed(() => this.session()?.title ?? '群聊');
   protected readonly subtitle = computed(() => `${this.members().length} 个容器`);
 
@@ -672,6 +804,21 @@ export class MbGroupChatPage implements OnDestroy {
     this.preview.set({ url, name, mimeType: mime });
   }
 
+  /** 字节数 → 人类可读(与移动单聊 `fmtSize` 同源,2026-11-10) */
+  protected readonly fmtSize = fmtSize;
+
+  /**
+   * 打开一条成果的首个附件(2026-11-10)。
+   *
+   * 成果条目可能带多个附件(`artifacts`),这里按首个可访问的打开,
+   * 与桌面右栏 `previewDeliverable` 的默认行为一致。
+   */
+  protected openDeliverable(d: Deliverable): void {
+    const a = d.artifacts.find((x) => !!x.url);
+    if (!a) return;
+    this.openFile({ name: a.name, url: a.url, mimeType: a.type });
+  }
+
   /** 成员实时状态行(有状态才显示,全待命时隐藏,避免占位) */
   protected readonly liveRows = computed(() => {
     const list = this.members().map((a) => ({

+ 6 - 0
src/app/mobile/pages/login/login.ts

@@ -13,6 +13,7 @@ import { FmodeApi } from '../../../core/api/fmode-api';
 import { DeviceService } from '../../../core/device.service';
 import { PaletteService } from '../../../core/palette.service';
 import { SessionService } from '../../../core/session.service';
+import { queryHasOrg } from '../../../core/org.guard';
 
 type Stage = 'phone' | 'code' | 'token';
 
@@ -471,6 +472,11 @@ export class MbLoginPage implements OnDestroy {
 
   /** AC-02.3:按 redirect 回跳,且 replaceUrl */
   private async afterLogin(): Promise<void> {
+    // 无组织用户先停在等待页(2026-11-10,与 orgGuard 同源判定)
+    if (!(await queryHasOrg(this.api))) {
+      await this.router.navigateByUrl('/pending', { replaceUrl: true });
+      return;
+    }
     const redirect = this.route.snapshot.queryParamMap.get('redirect');
     const target = redirect && redirect.startsWith('/') ? redirect : '/mobile/messages';
     await this.router.navigateByUrl(this.device.normalize(target), { replaceUrl: true });

+ 286 - 0
src/app/mobile/pages/pending/pending.ts

@@ -0,0 +1,286 @@
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
+import { Router } from '@angular/router';
+import { FmodeApi } from '../../../core/api/fmode-api';
+import { DeviceService } from '../../../core/device.service';
+import { SessionService } from '../../../core/session.service';
+import { MAppBarComponent } from '../../components/m-app-bar/m-app-bar';
+
+/**
+ * 「等待组织邀请」页(移动端,2026-11-10)
+ *
+ * ## 与桌面版的差别(不是简单缩小)
+ *
+ * | | 桌面 | 移动 |
+ * |---|---|---|
+ * | 骨架 | 居中浮卡 | 顶栏固定 + 内容纵向滚动 + 底部主操作**吸底** |
+ * | 信息 | 两列栅格 | 单列纵向,标签在上、值在下 |
+ * | 操作 | 并排两个按钮 | 主按钮吸底(拇指区),退出登录弱化为文字 |
+ * | 复制 | 小图标 | 整行可点(触控区 ≥44px) |
+ *
+ * 移动端用户通常是单手操作:最重要的「立即检查」必须落在拇指够得到的位置。
+ */
+@Component({
+  selector: 'app-m-pending',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  imports: [MAppBarComponent],
+  template: `
+    <app-m-app-bar title="等待加入组织" subtitle="需要管理员邀请" />
+
+    <main class="m-body">
+      <section class="m-hero">
+        <span class="m-mark" aria-hidden="true"><i class="fa-solid fa-hourglass-half"></i></span>
+        <h1>还没加入组织</h1>
+        <p class="m-lead">
+          把下面的用户 ID 发给管理员,由管理员把你拉进组织后即可使用。
+        </p>
+      </section>
+
+      <section class="m-card">
+        <button type="button" class="m-row" (click)="copyId()" aria-label="复制用户 ID">
+          <span class="m-row-body">
+            <span class="m-row-label">用户 ID</span>
+            <span class="m-row-value mono">{{ me()?.id || '—' }}</span>
+          </span>
+          <i class="fa-solid fa-copy" aria-hidden="true"></i>
+        </button>
+
+        <div class="m-row static">
+          <span class="m-row-body">
+            <span class="m-row-label">昵称</span>
+            <span class="m-row-value">{{ me()?.name || '—' }}</span>
+          </span>
+        </div>
+
+        <div class="m-row static">
+          <span class="m-row-body">
+            <span class="m-row-label">手机号</span>
+            <span class="m-row-value">{{ maskedMobile() }}</span>
+          </span>
+        </div>
+      </section>
+
+      <p class="m-status" [attr.data-tone]="copyTone()" role="status" aria-live="polite">
+        {{ statusText() }}
+      </p>
+
+      <button type="button" class="m-text-btn" (click)="logout()">退出登录</button>
+    </main>
+
+    <footer class="m-foot">
+      <button type="button" class="m-primary" [disabled]="checking()" (click)="checkNow()">
+        {{ checking() ? '正在检查…' : '我已被邀请,立即检查' }}
+      </button>
+    </footer>
+  `,
+  styles: [
+    `
+      :host {
+        display: flex;
+        flex-direction: column;
+        height: 100dvh;
+        background: var(--canvas);
+      }
+      .m-body {
+        flex: 1;
+        min-height: 0;
+        overflow-y: auto;
+        padding: var(--sp-4) var(--sp-4) var(--sp-6);
+      }
+      .m-hero {
+        text-align: center;
+        padding: var(--sp-6) 0 var(--sp-5);
+      }
+      .m-mark {
+        display: inline-grid;
+        place-items: center;
+        width: 64px;
+        height: 64px;
+        border-radius: 50%;
+        background: var(--surface-2);
+        color: var(--primary);
+        font-size: var(--fs-24);
+        margin-bottom: var(--sp-3);
+      }
+      h1 {
+        margin: 0 0 var(--sp-2);
+        font-size: var(--fs-20);
+        color: var(--ink);
+      }
+      .m-lead {
+        margin: 0;
+        color: var(--muted);
+        font-size: var(--fs-14);
+        line-height: 1.6;
+      }
+      .m-card {
+        background: var(--surface);
+        border-radius: var(--radius-m);
+        border: 1px solid var(--edge);
+        overflow: hidden;
+      }
+      .m-row {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-3);
+        width: 100%;
+        min-height: 64px;
+        padding: var(--sp-3) var(--sp-4);
+        border: 0;
+        border-bottom: 1px solid var(--edge);
+        background: var(--surface);
+        text-align: left;
+        color: var(--ink-2);
+        cursor: pointer;
+      }
+      .m-row:last-child {
+        border-bottom: 0;
+      }
+      .m-row.static {
+        cursor: default;
+      }
+      .m-row-body {
+        flex: 1;
+        min-width: 0;
+        display: grid;
+        gap: 2px;
+      }
+      .m-row-label {
+        color: var(--muted);
+        font-size: var(--fs-12);
+      }
+      .m-row-value {
+        color: var(--ink);
+        font-size: var(--fs-16);
+        word-break: break-all;
+      }
+      .mono {
+        font-family: var(--font-mono, ui-monospace, monospace);
+      }
+      .m-status {
+        margin: var(--sp-4) 0 0;
+        font-size: var(--fs-13);
+        color: var(--muted);
+        text-align: center;
+        line-height: 1.6;
+      }
+      .m-status[data-tone='ok'] {
+        color: var(--success);
+      }
+      .m-status[data-tone='warn'] {
+        color: var(--warning);
+      }
+      .m-text-btn {
+        display: block;
+        margin: var(--sp-5) auto 0;
+        min-height: 44px;
+        padding: 0 var(--sp-4);
+        border: 0;
+        background: transparent;
+        color: var(--muted);
+        font-size: var(--fs-14);
+      }
+      /* 主操作吸底:拇指区,不随内容滚走 */
+      .m-foot {
+        flex: none;
+        padding: var(--sp-3) var(--sp-4);
+        padding-bottom: calc(var(--sp-3) + env(safe-area-inset-bottom, 0px));
+        background: var(--surface);
+        border-top: 1px solid var(--edge);
+      }
+      .m-primary {
+        width: 100%;
+        min-height: 48px;
+        border-radius: var(--radius-m);
+        border: 1px solid var(--primary);
+        background: var(--primary);
+        color: var(--inverse);
+        font-size: var(--fs-16);
+      }
+      .m-primary:disabled {
+        opacity: 0.6;
+      }
+    `,
+  ],
+})
+export class MbPendingPage implements OnInit, OnDestroy {
+  private readonly session = inject(SessionService);
+  private readonly api = inject(FmodeApi);
+  private readonly router = inject(Router);
+  private readonly device = inject(DeviceService);
+
+  private readonly _checking = signal(false);
+  private readonly _copyTone = signal<'none' | 'ok' | 'warn'>('none');
+  private readonly _status = signal('正在等待管理员邀请…');
+
+  readonly me = this.session.me;
+  protected readonly checking = this._checking.asReadonly();
+  protected readonly copyTone = this._copyTone.asReadonly();
+  protected readonly statusText = computed(() => this._status());
+
+  private timer: ReturnType<typeof setInterval> | null = null;
+
+  ngOnInit(): void {
+    this.timer = setInterval(() => void this.poll(), 15_000);
+  }
+
+  protected maskedMobile(): string {
+    const m = this.me()?.mobile ?? '';
+    if (!m) return '—';
+    return m.length >= 7 ? `${m.slice(0, 3)}****${m.slice(-4)}` : m;
+  }
+
+  protected async copyId(): Promise<void> {
+    const id = this.me()?.id;
+    if (!id) return;
+    try {
+      await navigator.clipboard.writeText(id);
+      this._copyTone.set('ok');
+      this._status.set('用户 ID 已复制,发给管理员即可');
+    } catch {
+      this._copyTone.set('warn');
+      this._status.set('复制失败,请长按上面的 ID 手动复制');
+    }
+  }
+
+  protected async checkNow(): Promise<void> {
+    if (this._checking()) return;
+    this._checking.set(true);
+    this._status.set('正在检查…');
+    try {
+      await this.poll();
+    } finally {
+      this._checking.set(false);
+    }
+  }
+
+  protected logout(): void {
+    this.session.logout();
+    void this.router.navigateByUrl('/login', { replaceUrl: true });
+  }
+
+  private async poll(): Promise<void> {
+    const token = this.session.token();
+    if (!token) return;
+    try {
+      /* 用服务端判定(个人试用组织也算无组织),细节见 org.guard.ts 注释 */
+      const m = await this.api.fetchOrgMembership();
+      if (m.hasOrg) {
+        this._copyTone.set('ok');
+        this._status.set('已加入组织,正在进入…');
+        this.session.setMe(await this.api.fetchMe(token));
+        void this.router.navigateByUrl(this.device.entryPath(), { replaceUrl: true });
+        return;
+      }
+      this._status.set('仍在等待,管理员邀请后会自动进入');
+    } catch {
+      this._status.set('检查失败,请稍后再试');
+    }
+  }
+
+  ngOnDestroy(): void {
+    if (this.timer) {
+      clearInterval(this.timer);
+      this.timer = null;
+    }
+  }
+}

+ 35 - 12
src/app/mobile/shell/mobile-shell.ts

@@ -1,4 +1,4 @@
-import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';
+import { ChangeDetectionStrategy, Component, OnDestroy, computed, inject, signal } from '@angular/core';
 import { NavigationEnd, Router, RouterOutlet } from '@angular/router';
 import { filter } from 'rxjs';
 import { LiveService } from '../../core/live.service';
@@ -65,7 +65,10 @@ interface TabDef {
           <span class="wide-icon" aria-hidden="true"><i class="fa-solid fa-display"></i></span>
           <h2>当前窗口适合桌面端</h2>
           <p>已检测到窗口宽度达到 768px,可以切换到桌面布局继续使用。</p>
-          <button type="button" class="wide-btn" (click)="goDesktop()">切换到桌面端</button>
+          <div class="wide-actions">
+            <button type="button" class="wide-btn ghost" (click)="dismissHint()">稍后再说</button>
+            <button type="button" class="wide-btn" (click)="goDesktop()">切换到桌面端</button>
+          </div>
           @if (device.isForced()) {
             <button type="button" class="wide-btn ghost" (click)="resetToAuto()">恢复自动识别</button>
           }
@@ -219,16 +222,24 @@ interface TabDef {
         font-size: var(--fs-13);
         line-height: 1.7;
       }
+      /* 两个动作并排:主操作在右(拇指顺位),次要操作在左 */
+      .wide-actions {
+        display: flex;
+        gap: var(--sp-2);
+        margin-top: var(--sp-3);
+      }
       .wide-btn {
+        flex: 1;
         min-height: var(--touch-min);
-        margin-top: var(--sp-2);
-        padding: 0 var(--sp-4);
+        padding: 0 var(--sp-3);
         border: 0;
         border-radius: var(--r-sm);
         background: var(--primary);
         color: var(--primary-content);
         font-size: var(--fs-13);
         cursor: pointer;
+        /* 并排时宽度有限,别让「切换到桌面端」折成两行 */
+        white-space: nowrap;
       }
       .wide-btn.ghost {
         background: transparent;
@@ -238,7 +249,7 @@ interface TabDef {
     `,
   ],
 })
-export class MobileShellComponent {
+export class MobileShellComponent implements OnDestroy {
   private readonly live = inject(LiveService);
   private readonly matrix = inject(MatrixSessionService);
   private readonly palette = inject(PaletteService);
@@ -279,13 +290,15 @@ export class MobileShellComponent {
       void this.session.refreshMe((t) => this.api.fetchMe(t));
     }
 
-    if (typeof window !== 'undefined') {
-      window.addEventListener(
-        'resize',
-        () => this.wideViewport.set(this.isWideViewport()),
-        { passive: true },
-      );
-    }
+    /*
+     * 视口变化 → 跨端提示(2026-11-10 改为 DeviceService 统一判定)。
+     *
+     * 🔴 原实现是裸 `resize` 无防抖:拖拽窗口时每像素一次 set,提示反复闪。
+     *    现在走 `watchViewport()`:matchMedia 只在跨断点时触发,外加 250ms 防抖。
+     *    **不静默跳端** —— 只算出建议值,用户点确认才切。
+     */
+    this.device.watchViewport(() => this.wideViewport.set(this.device.suggestedMode() === 'desktop'));
+    this.wideViewport.set(this.device.suggestedMode() === 'desktop');
 
     this.syncFromUrl(this.router.url);
     this.router.events
@@ -302,6 +315,16 @@ export class MobileShellComponent {
     void this.router.navigateByUrl(path, { replaceUrl: true });
   }
 
+  /** 用户点「稍后」→ 本次不再提示(下次跨断点仍会提示) */
+  protected dismissHint(): void {
+    this.device.dismissSuggestion();
+    this.wideViewport.set(false);
+  }
+
+  ngOnDestroy(): void {
+    this.device.stopWatching();
+  }
+
   protected resetToAuto(): void {
     const mode = this.device.resetToAuto();
     void this.router.navigateByUrl(mode === 'mobile' ? '/mobile/messages' : '/desktop/chat', {

+ 9 - 0
src/app/pages/login/login.ts

@@ -6,6 +6,7 @@ import { FmodeApi } from '../../core/api/fmode-api';
 import { DeviceService } from '../../core/device.service';
 import { PaletteService } from '../../core/palette.service';
 import { SessionService } from '../../core/session.service';
+import { queryHasOrg } from '../../core/org.guard';
 
 type LoginMethod = 'mobile' | 'token';
 
@@ -861,6 +862,14 @@ export class LoginComponent {
 
   /** AC-02.3:按 redirect 回跳,且 replaceUrl */
   private async afterLogin(): Promise<void> {
+    /*
+     * 无组织用户先停在等待页(2026-11-10)。
+     * 判定与 `orgGuard` 同源,避免「登录页放行、进页面又被弹走」的抖动。
+     */
+    if (!(await queryHasOrg(this.api))) {
+      await this.router.navigateByUrl('/pending', { replaceUrl: true });
+      return;
+    }
     const redirect = this.route.snapshot.queryParamMap.get('redirect');
     const target = redirect && redirect.startsWith('/') ? redirect : this.device.entryPath();
     await this.router.navigateByUrl(this.device.normalize(target), { replaceUrl: true });

+ 289 - 0
src/app/pages/pending/pending.ts

@@ -0,0 +1,289 @@
+import { ChangeDetectionStrategy, Component, OnDestroy, OnInit, computed, inject, signal } from '@angular/core';
+import { Router } from '@angular/router';
+import { FmodeApi } from '../../core/api/fmode-api';
+import { DeviceService } from '../../core/device.service';
+import { SessionService } from '../../core/session.service';
+
+/** 轮询间隔:管理员那边把人拉进组织后,这里多快能自动放行 */
+export const PENDING_POLL_MS = 15_000;
+
+/**
+ * 「等待组织邀请」页(桌面端,2026-11-10)
+ *
+ * ## 场景
+ *
+ * 手机号登录成功,但账号**还没有被任何组织接纳**(`me.companyId` 为空)。
+ * 这类账号进 App 也什么都看不到,所以在 `orgGuard` 里被挡到这里。
+ *
+ * ## 页面要做什么
+ *
+ * 1. 告诉用户「现在卡在哪一步、需要谁做什么」
+ * 2. 把**管理员需要的信息**直接摆出来(用户 ID 可一键复制),省去来回问
+ * 3. **自动检测**:每 15 秒刷新一次身份,被拉进组织后自动进入 App
+ *
+ * ## 为什么轮询而不是 WebSocket
+ *
+ * 用户此刻还没进入 App,没有 Matrix 连接;身份变更是低频事件,
+ * 15 秒轮询的代价远小于为此维持一条长连接。
+ */
+@Component({
+  selector: 'app-pending',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `
+    <main class="pending" role="status" aria-live="polite">
+      <section class="card">
+        <span class="mark" aria-hidden="true"><i class="fa-solid fa-hourglass-half"></i></span>
+        <h1>等待加入组织</h1>
+        <p class="lead">
+          你的账号还没有被加入到任何组织。把下面的用户 ID 发给管理员,
+          由管理员把你拉进组织后即可使用。
+        </p>
+
+        <dl class="facts">
+          <div class="fact">
+            <dt>用户 ID</dt>
+            <dd class="mono">
+              <span class="id-text">{{ me()?.id || '—' }}</span>
+              <button type="button" class="copy" (click)="copyId()" [attr.aria-label]="'复制用户 ID'">
+                <i class="fa-solid fa-copy" aria-hidden="true"></i>
+              </button>
+            </dd>
+          </div>
+          <div class="fact">
+            <dt>昵称</dt>
+            <dd>{{ me()?.name || '—' }}</dd>
+          </div>
+          <div class="fact">
+            <dt>手机号</dt>
+            <dd>{{ maskedMobile() }}</dd>
+          </div>
+        </dl>
+
+        <p class="hint" [attr.data-tone]="copyTone()">{{ statusText() }}</p>
+
+        <div class="actions">
+          <button type="button" class="btn primary" (click)="checkNow()">我已被邀请,立即检查</button>
+          <button type="button" class="btn ghost" (click)="logout()">退出登录</button>
+        </div>
+      </section>
+    </main>
+  `,
+  styles: [
+    `
+      :host {
+        display: block;
+        min-height: 100dvh;
+        background: var(--canvas);
+      }
+      .pending {
+        min-height: 100dvh;
+        display: grid;
+        place-items: center;
+        padding: var(--sp-6);
+      }
+      .card {
+        width: min(520px, 100%);
+        background: var(--surface);
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-l);
+        box-shadow: var(--shadow-card);
+        padding: var(--sp-8);
+        text-align: center;
+      }
+      .mark {
+        display: inline-grid;
+        place-items: center;
+        width: 56px;
+        height: 56px;
+        border-radius: 50%;
+        background: var(--surface-2);
+        color: var(--primary);
+        font-size: var(--fs-24);
+        margin-bottom: var(--sp-4);
+      }
+      h1 {
+        margin: 0 0 var(--sp-3);
+        font-size: var(--fs-20);
+        color: var(--ink);
+      }
+      .lead {
+        margin: 0 0 var(--sp-6);
+        color: var(--muted);
+        font-size: var(--fs-14);
+        line-height: 1.6;
+      }
+      .facts {
+        margin: 0 0 var(--sp-5);
+        display: grid;
+        gap: var(--sp-2);
+        text-align: left;
+      }
+      .fact {
+        display: grid;
+        grid-template-columns: 80px 1fr;
+        align-items: center;
+        gap: var(--sp-3);
+        padding: var(--sp-3) var(--sp-4);
+        background: var(--surface-2);
+        border-radius: var(--radius-m);
+      }
+      dt {
+        color: var(--muted);
+        font-size: var(--fs-13);
+      }
+      dd {
+        margin: 0;
+        color: var(--ink);
+        font-size: var(--fs-14);
+        word-break: break-all;
+      }
+      .mono {
+        display: flex;
+        align-items: center;
+        gap: var(--sp-2);
+      }
+      .id-text {
+        font-family: var(--font-mono, ui-monospace, monospace);
+      }
+      .copy {
+        flex: none;
+        width: 32px;
+        height: 32px;
+        border: 1px solid var(--edge);
+        border-radius: var(--radius-s);
+        background: var(--surface);
+        color: var(--ink-2);
+        cursor: pointer;
+      }
+      .copy:hover {
+        background: var(--surface-hover);
+      }
+      .hint {
+        margin: 0 0 var(--sp-5);
+        font-size: var(--fs-13);
+        color: var(--muted);
+      }
+      .hint[data-tone='ok'] {
+        color: var(--success);
+      }
+      .hint[data-tone='warn'] {
+        color: var(--warning);
+      }
+      .actions {
+        display: flex;
+        gap: var(--sp-3);
+        justify-content: center;
+        flex-wrap: wrap;
+      }
+      .btn {
+        min-height: 44px;
+        padding: 0 var(--sp-5);
+        border-radius: var(--radius-m);
+        font-size: var(--fs-14);
+        cursor: pointer;
+        border: 1px solid var(--edge);
+      }
+      .btn.primary {
+        background: var(--primary);
+        color: var(--inverse);
+        border-color: var(--primary);
+      }
+      .btn.ghost {
+        background: var(--surface);
+        color: var(--ink-2);
+      }
+      .btn:disabled {
+        opacity: 0.6;
+        cursor: not-allowed;
+      }
+    `,
+  ],
+})
+export class PendingPage implements OnInit, OnDestroy {
+  private readonly session = inject(SessionService);
+  private readonly api = inject(FmodeApi);
+  private readonly router = inject(Router);
+  private readonly device = inject(DeviceService);
+
+  private readonly _checking = signal(false);
+  private readonly _copyTone = signal<'none' | 'ok' | 'warn'>('none');
+  private readonly _status = signal('正在等待管理员邀请…');
+
+  readonly me = this.session.me;
+  protected readonly copyTone = this._copyTone.asReadonly();
+  protected readonly statusText = computed(() => this._status());
+
+  private timer: ReturnType<typeof setInterval> | null = null;
+
+  ngOnInit(): void {
+    this.timer = setInterval(() => void this.poll(), PENDING_POLL_MS);
+  }
+
+  /** 手机号中间四位打码:页面上不需要展示完整手机号 */
+  protected maskedMobile(): string {
+    const m = this.me()?.mobile ?? '';
+    if (!m) return '—';
+    return m.length >= 7 ? `${m.slice(0, 3)}****${m.slice(-4)}` : m;
+  }
+
+  protected async copyId(): Promise<void> {
+    const id = this.me()?.id;
+    if (!id) return;
+    try {
+      await navigator.clipboard.writeText(id);
+      this._copyTone.set('ok');
+      this._status.set('用户 ID 已复制,发给管理员即可');
+    } catch {
+      this._copyTone.set('warn');
+      this._status.set('复制失败,请手动选中上面的 ID');
+    }
+  }
+
+  /** 手动立即检查一次 */
+  protected async checkNow(): Promise<void> {
+    if (this._checking()) return;
+    this._checking.set(true);
+    this._status.set('正在检查…');
+    try {
+      await this.poll();
+    } finally {
+      this._checking.set(false);
+    }
+  }
+
+  protected logout(): void {
+    this.session.logout();
+    void this.router.navigateByUrl('/login', { replaceUrl: true });
+  }
+
+  /** 刷新身份;一旦有了**正式组织** → 放行进入 App */
+  private async poll(): Promise<void> {
+    const token = this.session.token();
+    if (!token) return;
+    try {
+      /*
+       * 🔴 用服务端的 `me` 判定,而不是 `me.companyId`:
+       *    新用户注册时系统会自动建个人试用组织,公司的 id 非空 ——
+       *    只看 companyId 会让本页永远放行不了,或直接误放。
+       */
+      const m = await this.api.fetchOrgMembership();
+      if (m.hasOrg) {
+        this._copyTone.set('ok');
+        this._status.set('已加入组织,正在进入…');
+        this.session.setMe(await this.api.fetchMe(token));
+        void this.router.navigateByUrl(this.device.entryPath(), { replaceUrl: true });
+        return;
+      }
+      this._status.set('仍在等待,管理员邀请后会自动进入');
+    } catch {
+      this._status.set('检查失败,请稍后再试');
+    }
+  }
+
+  ngOnDestroy(): void {
+    if (this.timer) {
+      clearInterval(this.timer);
+      this.timer = null;
+    }
+  }
+}