wezard 2.3.0 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +3 -3
- package/README.md +5 -5
- package/config.example.jsonc +12 -0
- package/dist/daemon/approval.js +103 -15
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-name.js +15 -0
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +281 -70
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/danger.js +5 -7
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +10 -2
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +4 -3
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/idle-nudge.js +55 -0
- package/dist/daemon/idle-nudge.js.map +1 -0
- package/dist/daemon/inbound.js +17 -14
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +957 -225
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +122 -13
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +97 -84
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/model-select.js +3 -0
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +172 -33
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +105 -78
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +171 -0
- package/dist/daemon/pending-items.js.map +1 -0
- package/dist/daemon/receipts.js +58 -24
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +76 -15
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +2 -2
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/steward-guard.js +32 -0
- package/dist/daemon/steward-guard.js.map +1 -0
- package/dist/daemon/top-only-nudge.js +29 -0
- package/dist/daemon/top-only-nudge.js.map +1 -0
- package/dist/daemon/wizard.js +114 -61
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +75 -25
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +23 -13
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/config-meta.js +91 -0
- package/dist/shared/config-meta.js.map +1 -0
- package/dist/shared/config-writer.js +5 -6
- package/dist/shared/config-writer.js.map +1 -1
- package/dist/shared/config.js +279 -294
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +121 -32
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/keepalive.js +5 -5
- package/dist/shared/keepalive.js.map +1 -1
- package/dist/shared/role-render.js +52 -12
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +84 -26
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/world.js +82 -13
- package/dist/shared/world.js.map +1 -1
- package/hooks/pre-tool-use.sh +14 -12
- package/package.json +1 -1
- package/web/chat.css +63 -37
- package/web/chat.js +513 -283
package/web/chat.js
CHANGED
|
@@ -19,10 +19,12 @@
|
|
|
19
19
|
var WITH = qs.get('with') || '';
|
|
20
20
|
// '' = 未指定 (服务端按全部时间, 回包里认领成 'all'); 'all' = 全部时间; 其他 = 那一段的 sid。
|
|
21
21
|
var SESSION = qs.get('session') || '';
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
// 页面上两个独立的视角: ROLE 是页面视角 (名片、侧栏、标题、关系图中心), 只有点头像 / 名字及其装饰 (⇄ 等) 换它;
|
|
23
|
+
// VIEWPOINT 是 chat detail 的视角 (谁算「自己」靠右、描述行以谁为准), 点卡片 / 会话项的其余地方与详情区里的行设它 ——
|
|
24
|
+
// 关系图卡片 = 被点的那个 role, .far 子项 = 那个 role, 移交行 = 移交人 (派活那一方),
|
|
25
|
+
// 新生 / 被收的提示行 = 那个 wizard; 会话与成对的子项 = 页面视角自己。'' = 与页面视角相同。老链接的 `eye=` 照读。
|
|
26
|
+
var VIEWPOINT = qs.get('viewpoint') || qs.get('eye') || '';
|
|
27
|
+
var viewpointOf = function () { return VIEWPOINT || ROLE; };
|
|
26
28
|
var TICK_MS = 3000;
|
|
27
29
|
|
|
28
30
|
// at / recvAt: 服务端快照时刻与本地收到时刻。所有"现在几点"的判断都换算到
|
|
@@ -30,13 +32,14 @@
|
|
|
30
32
|
var R = { at: 0, recvAt: 0, role: null, sessions: [], convs: [], relations: false, schedules: 0, plan: null, charter: null, stats: null, winStats: null };
|
|
31
33
|
// frags: 当前窗口的原始片段 (id → 片段)。片段不带方向, 换视角时拿它就地重包左右。
|
|
32
34
|
var S = { es: null, pinned: true, gen: 0, frags: {} };
|
|
33
|
-
// 关系/日程两栏共用的世界快照。
|
|
35
|
+
// 关系/日程两栏共用的世界快照。treeFor = 已把谁滚进过视野。
|
|
34
36
|
var W = {
|
|
35
37
|
at: 0, nodes: [], edges: [], chats: [], jobs: [], schedules: [],
|
|
36
|
-
degraded: false, loaded: false,
|
|
38
|
+
degraded: false, loaded: false, treeFor: '', glance: {}, gKeys: '',
|
|
37
39
|
};
|
|
38
40
|
var VIEW = 'msgs';
|
|
39
41
|
// 侧栏是会话列表还是关系图 —— 与右边的 VIEW 无关: 关系图下右边照样是选中的那段对话。
|
|
42
|
+
// URL 带了 side 以它为准; 没带 (新链接) 用本地记下的, 见 WORLD_KEY。
|
|
40
43
|
var WORLD = qs.get('side') === 'world';
|
|
41
44
|
var $ = function (s) { return document.querySelector(s); };
|
|
42
45
|
var app = $('#app'), thread = $('#thread'), inner = $('#thread-in'), convsEl = $('#convs');
|
|
@@ -97,11 +100,17 @@
|
|
|
97
100
|
if (CONV) p.conv = CONV;
|
|
98
101
|
if (WITH) p['with'] = WITH;
|
|
99
102
|
if (SESSION) p.session = SESSION;
|
|
100
|
-
if (
|
|
103
|
+
if (VIEWPOINT) p.viewpoint = VIEWPOINT;
|
|
101
104
|
if (WORLD) p.side = 'world';
|
|
102
105
|
Object.keys(extra || {}).forEach(function (k) { p[k] = extra[k]; });
|
|
103
106
|
return p;
|
|
104
107
|
};
|
|
108
|
+
// 摘要另带未读基线 (定了才带): 服务端据此只给基线之后的他人未读账。不进 URL。
|
|
109
|
+
var roleParams = function (extra) {
|
|
110
|
+
var p = viewParams(extra);
|
|
111
|
+
if (BASE) p.since = BASE;
|
|
112
|
+
return p;
|
|
113
|
+
};
|
|
105
114
|
// 视角状态回写 URL (replaceState: 不在历史里留一串中间态)。
|
|
106
115
|
var syncUrl = function () {
|
|
107
116
|
var p = new URLSearchParams({ id: TOKEN });
|
|
@@ -184,7 +193,10 @@
|
|
|
184
193
|
scope.querySelectorAll('.bubble, .tg-job').forEach(function (b) {
|
|
185
194
|
var src = b.querySelector('script.md-src'), body = b.querySelector('.md-body');
|
|
186
195
|
if (src && body && body.dataset.rendered !== '1') {
|
|
196
|
+
// 「发给谁」先挂在空 .md-body 里; 填进 markdown 后把它塞进第一段, 才能和正文第一行同行。
|
|
197
|
+
var to = body.querySelector(':scope > .to-in');
|
|
187
198
|
body.innerHTML = md.render(src.textContent || '');
|
|
199
|
+
if (to) { var p = body.firstElementChild; (p && p.tagName === 'P' ? p : body).prepend(to, ' '); }
|
|
188
200
|
body.dataset.rendered = '1';
|
|
189
201
|
}
|
|
190
202
|
});
|
|
@@ -317,7 +329,7 @@
|
|
|
317
329
|
r.classList.toggle('raw');
|
|
318
330
|
});
|
|
319
331
|
inner.addEventListener('click', function (e) {
|
|
320
|
-
var c = e.target.closest && e.target.closest('.mchip.job[data-job]');
|
|
332
|
+
var c = e.target.closest && e.target.closest('.mchip.job[data-job], .tg-job .jc[data-job], .jcrumb [data-job]');
|
|
321
333
|
if (c) openJob(c.getAttribute('data-job'));
|
|
322
334
|
});
|
|
323
335
|
// 回执 chip / 移交行的活号: 跳到派活那句 (同一个活号的入消息)。先在眼前这个窗口里找;
|
|
@@ -334,22 +346,36 @@
|
|
|
334
346
|
var conv = ch ? 'c:' + ch : ROLE === from ? 'p:' + to : ROLE === to ? 'p:' + from : 'a:' + from + '|' + to;
|
|
335
347
|
jumpMsg({ conv: conv, id: c.getAttribute('data-gid'), ts: Number(c.getAttribute('data-gts')) });
|
|
336
348
|
});
|
|
337
|
-
// 移交行整行: 侧栏选中移交双方之间的那一项 (群里的一对 / 私聊;
|
|
338
|
-
// 「这两方之间」的往来),
|
|
349
|
+
// 移交行整行: 侧栏选中移交双方之间的那一项 (群里的一对 / 私聊; 页面视角不是两端之一就开
|
|
350
|
+
// 「这两方之间」的往来), 有派活那句就落在这一行自己的那句上。页面视角不动, 详情区的 viewpoint
|
|
351
|
+
// 换成移交人 (派活那一方)。节点随 reconcile 换, 所以在根上委托。
|
|
339
352
|
// 行尾的 chevron 只管展开原文, 走 <summary> 自己的开合。
|
|
340
353
|
inner.addEventListener('click', function (e) {
|
|
341
|
-
var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hto]');
|
|
354
|
+
var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hto], .to-in[data-hto]');
|
|
342
355
|
if (!b || e.target.closest('.ho-chev')) return;
|
|
343
356
|
e.preventDefault(); // 行是 <summary>: 不顺带展开原文
|
|
357
|
+
e.stopPropagation();
|
|
344
358
|
var from = b.getAttribute('data-hfrom'), to = b.getAttribute('data-hto'), ch = b.getAttribute('data-hch');
|
|
345
359
|
var peer = ROLE === from ? to : ROLE === to ? from : '';
|
|
346
360
|
var conv = !peer ? 'a:' + from + '|' + to : ch ? 'c:' + ch : 'p:' + peer;
|
|
347
361
|
var w = peer && ch ? peer : '';
|
|
348
|
-
var id = b.getAttribute('data-gid');
|
|
349
362
|
markBack(function () { return VIEW === 'msgs' && CONV === conv && WITH === w; });
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
363
|
+
jumpMsg({ conv: conv, with: w, id: b.getAttribute('data-gid'), ts: Number(b.getAttribute('data-gts')), viewpoint: from });
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
// 其余 wezard 调用的提示行: 新生 / 被收的 wizard 开它的全部往来、viewpoint 站到它身上 (页面视角不动),
|
|
367
|
+
// 工单行进工单页, 定时行开日程 —— 日程只有页面视角自己的, 别人的定时行不跳 (换页面视角只走头像 / 名字)。
|
|
368
|
+
// 同移交行: 行是 <summary> 就不顺带展开原文, 行尾 chevron 照旧开合。
|
|
369
|
+
inner.addEventListener('click', function (e) {
|
|
370
|
+
var b = e.target.closest && e.target.closest('.handoff .ho-line[data-hrole], .handoff .ho-line[data-job], .handoff .ho-line[data-hplan]');
|
|
371
|
+
if (!b || e.target.closest('.ho-chev')) return;
|
|
372
|
+
e.preventDefault();
|
|
373
|
+
var role = b.getAttribute('data-hrole'), job = b.getAttribute('data-job'), plan = b.getAttribute('data-hplan');
|
|
374
|
+
if (job) return openJob(job);
|
|
375
|
+
if (plan) return plan === ROLE && setView('plan');
|
|
376
|
+
var conv = 'a:' + role;
|
|
377
|
+
markBack(function () { return VIEW === 'msgs' && CONV === conv; });
|
|
378
|
+
selectConv(conv, '', undefined, role);
|
|
353
379
|
});
|
|
354
380
|
|
|
355
381
|
// ── 左栏: 当前 role 的名片 + 会话列表 ──
|
|
@@ -359,6 +385,36 @@
|
|
|
359
385
|
var jobTag = function (j) {
|
|
360
386
|
return j ? '<span class="jtag' + (j.status === 'open' ? ' open' : '') + '">' + (j.status === 'open' ? j.done + '/' + j.total : '收工') + '</span>' : '';
|
|
361
387
|
};
|
|
388
|
+
// 阶段 (daemon 现算后推来): 缺省 (老快照) = 不标。
|
|
389
|
+
var STAGE = { plan: '规划', build: '实施', review: '评审', clarify: '澄清', stalled: '卡住', shelved: '搁置' };
|
|
390
|
+
var stageText = function (j) {
|
|
391
|
+
if (!j) return '';
|
|
392
|
+
if (j.status === 'closed') return j.kind === 'req' ? (j.end === 'cancel' ? '取消' : '归档') : '';
|
|
393
|
+
return j.stage === 'deliver' ? (j.kind === 'req' ? '等验收' : '待收工') : STAGE[j.stage] || '';
|
|
394
|
+
};
|
|
395
|
+
var stageTag = function (j) {
|
|
396
|
+
var t = stageText(j);
|
|
397
|
+
return t ? '<span class="jstage' + (j.stage === 'deliver' || j.stage === 'clarify' || j.stage === 'stalled' ? ' wait' : '') + '">' + t + '</span>' : '';
|
|
398
|
+
};
|
|
399
|
+
// 祖先链 (根在前): 按服务端的账走 parent; 环 / 缺失就停。
|
|
400
|
+
var jobAncestors = function (id) {
|
|
401
|
+
var idx = R.jobIndex || {}, out = [], seen = {}, cur = idx[id] && idx[id].parent;
|
|
402
|
+
while (cur && idx[cur] && !seen[cur]) { seen[cur] = 1; out.unshift(cur); cur = idx[cur].parent; }
|
|
403
|
+
return out;
|
|
404
|
+
};
|
|
405
|
+
var jobCrumbs = function (id) {
|
|
406
|
+
var up = jobAncestors(id);
|
|
407
|
+
return up.length ? '<span class="jcrumb">' + up.map(function (a) {
|
|
408
|
+
return '<button data-job="' + esc(a) + '" title="' + esc(a) + '">' + esc((R.jobIndex[a] || {}).title || a) + '</button>';
|
|
409
|
+
}).join('<i>›</i>') + '<i>›</i></span>' : '';
|
|
410
|
+
};
|
|
411
|
+
// 工单列表的树序: 父在前、子缩进跟在后; 父不在这一段里的当根。
|
|
412
|
+
var jobTreeOrder = function (list) {
|
|
413
|
+
var ids = {}; list.forEach(function (c) { ids[c.job.id] = 1; });
|
|
414
|
+
var kids = function (id) { return list.filter(function (c) { return c.job.parent === id; }); };
|
|
415
|
+
var walk = function (c, d) { return [{ c: c, d: d }].concat(kids(c.job.id).reduce(function (a, k) { return a.concat(walk(k, d + 1)); }, [])); };
|
|
416
|
+
return list.filter(function (c) { return !c.job.parent || !ids[c.job.parent]; }).reduce(function (a, c) { return a.concat(walk(c, 0)); }, []);
|
|
417
|
+
};
|
|
362
418
|
// 工单里有人卡住 (停在审批卡 / 反问待答 / 报错停了) 时标出几份卡着 —— 要人或开单者动手的。
|
|
363
419
|
var stuckTag = function (j) {
|
|
364
420
|
var n = j && j.status === 'open' ? (R.inflight || []).filter(function (x) {
|
|
@@ -376,7 +432,7 @@
|
|
|
376
432
|
return {
|
|
377
433
|
key: key, kind: 'job', name: j ? j.title : id, label: '📋', base: j ? j.base : '', subs: [], heard: [],
|
|
378
434
|
lastTs: j ? j.closedAt || j.openedAt : 0, preview: '',
|
|
379
|
-
job: j && { id: id, owner: j.owner, status: j.status, done: j.done, total: j.total },
|
|
435
|
+
job: j && { id: id, owner: j.owner, status: j.status, done: j.done, total: j.total, parent: j.parent, kind: j.kind, stage: j.stage },
|
|
380
436
|
};
|
|
381
437
|
};
|
|
382
438
|
/** 一个工单号 → 它的一行: 视角有份的取会话项 (带预览), 其余按账现造。 */
|
|
@@ -449,11 +505,6 @@
|
|
|
449
505
|
ms.map(function (m) { return '<i>' + esc(m.label || roleLabel(m.role)) + '</i>'; }).join('') + '</span>';
|
|
450
506
|
};
|
|
451
507
|
|
|
452
|
-
// ends = [[id, 已知头像]]; 没带头像的按 role 查。
|
|
453
|
-
var pairOf = function (ends) {
|
|
454
|
-
return '<span class="pair">' + ends.map(function (e) { return goSpan('av', e[0], esc(e[1] || roleLabel(e[0]))); }).join('') + '</span>';
|
|
455
|
-
};
|
|
456
|
-
|
|
457
508
|
var line = function (title, ts, pv, lamp, unread) {
|
|
458
509
|
return '<span class="b"><span class="l1"><span class="t">' + title + '</span>' + (lamp || '') +
|
|
459
510
|
'<span class="ts">' + esc(fmtAgo(ts)) + '</span></span>' +
|
|
@@ -461,93 +512,114 @@
|
|
|
461
512
|
(unread ? '<b class="ub">' + (unread > 99 ? '99+' : unread) + '</b>' : '') + '</span></span>';
|
|
462
513
|
};
|
|
463
514
|
|
|
464
|
-
// ── 未读:
|
|
465
|
-
//
|
|
466
|
-
//
|
|
467
|
-
//
|
|
468
|
-
//
|
|
469
|
-
//
|
|
470
|
-
|
|
471
|
-
var
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
var
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
// 限定了频道的窗口 (某 role 在一个群里的全部记录) 只覆盖那个群里的往来。
|
|
496
|
-
if (t.chat && (!c || c.base !== t.chat)) return false;
|
|
497
|
-
var hit = function (a, b) { return t.who === a && (!t.peers.length || t.peers.indexOf(b) >= 0); };
|
|
498
|
-
return hit(ROLE, p) || hit(p, ROLE);
|
|
499
|
-
};
|
|
500
|
-
// 一处会话里这次读到了哪几对 (存水位用的 withRole): 侧栏选中的就是那一项; 关系图卡片打开的
|
|
501
|
-
// 对话横跨会话, 每个会话里落在那段对话里的那几对都算 —— 私聊的那一对记在会话本身 ('')。
|
|
502
|
-
var readPairs = function (c) {
|
|
503
|
-
if (c.key === CONV) return [WITH];
|
|
504
|
-
if (c.kind !== 'group') return c.peer && talkCovers(c.peer, c) ? [''] : [];
|
|
505
|
-
return (c.subs || []).filter(function (s) { return talkCovers(s.role, c); }).map(function (s) { return s.role; });
|
|
506
|
-
};
|
|
507
|
-
// 读整个群 = 群的水位推到此刻 (子项随之全清); 只读一对 = 只推那一对的。
|
|
508
|
-
var markRead = function (c) {
|
|
515
|
+
// ── 未读: 别人说完、还没在详情里读到的话 ──
|
|
516
|
+
// 服务端给每个窗口 (侧栏会话项 / 子项、关系图卡片) 一份 heard = [说完的时刻, 发话方, 收信方, 消息 id]:
|
|
517
|
+
// 看的那个 role 在点开这一项会看到的消息里听得到、且晚于它在那一处最后开口的 (回过话 = 读到了那里)。
|
|
518
|
+
// 已读只有一份 (SEEN): 这句话说完的样子在详情里上过屏 (页面可见、正在看消息) 就记下它的 id ——
|
|
519
|
+
// 与页面视角、详情视角、侧栏 / 关系图无关, 哪一处读到, 所有视图里同一句话一起消失, 不必等下一份摘要。
|
|
520
|
+
// 基线是「打开页面这一刻」(BASE, 服务端时钟, 首次拿到快照时定): 此前已有的话不算未读, 刷新即重置 ——
|
|
521
|
+
// 所以 SEEN 只活在内存里, 不落本地。
|
|
522
|
+
var BASE = 0;
|
|
523
|
+
var SEEN = {};
|
|
524
|
+
var unreadIn = function (hs, skip) {
|
|
525
|
+
return (hs || []).filter(function (h) { return h[0] > BASE && !((SEEN[h[3]] || 0) >= h[0]) && !(skip && skip[h[3]]); });
|
|
526
|
+
};
|
|
527
|
+
// 群里的成对子项 = 说给视角的、出自它的那几句; 不给 withRole = 整个会话。
|
|
528
|
+
var unreadHeard = function (c, withRole) {
|
|
529
|
+
return unreadIn(withRole ? (c.heard || []).filter(function (h) { return h[1] === withRole && h[2] === ROLE; }) : c.heard);
|
|
530
|
+
};
|
|
531
|
+
var unreadOf = function (c, withRole) { return unreadHeard(c, withRole).length; };
|
|
532
|
+
// 名字旁的他人未读: 点名字切到 id 的视角会看到的未读 (它全部会话项的, 服务端 peerUnread 按同一口径给),
|
|
533
|
+
// 减去这一行右边红点已在数的那几句。已读同样取自 SEEN —— 哪一处读到, 这里一起消。
|
|
534
|
+
var idsOf = function (hs) { return hs.reduce(function (m, h) { m[h[3]] = 1; return m; }, {}); };
|
|
535
|
+
var theirsOf = function (id, counted) {
|
|
536
|
+
return id && id !== ROLE ? unreadIn((R.peerUnread || {})[id], idsOf(counted || [])).length : 0;
|
|
537
|
+
};
|
|
538
|
+
var peerBadge = function (id, n) {
|
|
539
|
+
return n && id !== ROLE ? '<b class="pub" title="' + esc(nameOf(id) + ' 的视角下还有 ' + n + ' 条未读') + '">' + (n > 99 ? '99+' : n) + '</b>' : '';
|
|
540
|
+
};
|
|
541
|
+
// 详情区真在屏上: 窄屏退回列表时它整个 display:none (offsetParent 为空)。
|
|
542
|
+
var reading = function () { return VIEW === 'msgs' && !document.hidden && thread.offsetParent !== null; };
|
|
543
|
+
// 上屏的片段记成已读 (正在看消息时): 记的是它说完的时刻, 还在吐字的那一版不算读到终句。
|
|
544
|
+
// 有新记的就重画侧栏 / 关系图 —— 两边从同一个 SEEN 派生, 同一拍变。
|
|
545
|
+
var see = function (frags) {
|
|
509
546
|
if (!reading()) return;
|
|
510
|
-
var
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
547
|
+
var hit = frags.filter(function (m) { return m && m.fin && !((SEEN[m.id] || 0) >= m.fin); });
|
|
548
|
+
hit.forEach(function (m) { SEEN[m.id] = m.fin; });
|
|
549
|
+
if (hit.length && R.convs) renderConvs();
|
|
550
|
+
};
|
|
551
|
+
var seeAll = function () { see(Object.keys(S.frags || {}).map(function (k) { return S.frags[k]; })); };
|
|
552
|
+
|
|
553
|
+
// 跨会话通用的个人偏好 (看全部、列表 / 关系、侧栏群节点的展开、session 选择): 记在本地, 刷新后还在。
|
|
554
|
+
// 取不到 / 写不进 (隐私窗口、禁存) 就按默认值, 页面照常。
|
|
555
|
+
var pref = function (key, def) {
|
|
556
|
+
try { var v = localStorage.getItem(key); return v === null ? def : JSON.parse(v); } catch (e) { return def; }
|
|
557
|
+
};
|
|
558
|
+
var setPref = function (key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch (e) { } };
|
|
559
|
+
var prefObj = function (key) { var v = pref(key, {}); return v && typeof v === 'object' ? v : {}; };
|
|
560
|
+
var WORLD_KEY = 'wezard.role.world';
|
|
561
|
+
if (!qs.has('side')) WORLD = pref(WORLD_KEY, false) === true;
|
|
562
|
+
// 早先也记过 ping 与日程卡片的展开 (只活在内存里了)、「往来 / 相关 / 全部」开关 (已去掉, 固定看全部 / 相关) —— 旧 key 顺手清掉。
|
|
563
|
+
try { ['wezard.role.pingOpen', 'wezard.role.planOpen', 'wezard.role.showAll', 'wezard.role.subScope'].forEach(function (k) { localStorage.removeItem(k); }); } catch (e) { }
|
|
564
|
+
|
|
565
|
+
// 展开态按群各记各的 (键 = 群节点的会话键): 点开一个不收起别的, 轮询重画也不动它。
|
|
566
|
+
// OPEN_AT = 已经替它自动展开过的那个 CONV —— 选中新会话时展开一次, 之后折不折由人说了算; null = 刚载入。
|
|
567
|
+
// 默认只展开选中的那个群; 本地只记偏离默认的: 没选中却展开着的 = true, 选中却被收起的 = false
|
|
568
|
+
// (载入时自动展开要让着它)。回到默认的那一项写的时候就删, 不越存越多。
|
|
569
|
+
var OPEN_KEY = 'wezard.role.groupOpen';
|
|
570
|
+
var OPEN = prefObj(OPEN_KEY), OPEN_AT = null;
|
|
571
|
+
// 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按群记, 只留展开着的。
|
|
572
|
+
var MORE_KEY = 'wezard.role.groupMore';
|
|
573
|
+
var SUB_FOLD = 5, MORE = prefObj(MORE_KEY);
|
|
574
|
+
// 某 role 在一个群里的全部记录挂在那个群下: CONV 落在哪个群节点上。
|
|
575
|
+
var groupOf = function (key) { var t = key && convOf(key); return t && t.chat ? 'c:' + t.chat : key; };
|
|
576
|
+
var saveOpen = function () {
|
|
577
|
+
var sel = groupOf(CONV);
|
|
578
|
+
setPref(OPEN_KEY, Object.keys(OPEN).reduce(function (m, k) {
|
|
579
|
+
if (!!OPEN[k] !== (k === sel)) m[k] = !!OPEN[k];
|
|
580
|
+
return m;
|
|
581
|
+
}, {}));
|
|
514
582
|
};
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
var OPEN = {}, OPEN_AT = '';
|
|
519
|
-
// 子项默认只露前 SUB_FOLD 个, 其余收在一条展开/折叠条后面; 展开态同样按会话各记各的。
|
|
520
|
-
var SUB_FOLD = 5, MORE = {};
|
|
521
|
-
var reveal = function () {
|
|
522
|
-
if (CONV && CONV !== OPEN_AT) {
|
|
523
|
-
// 某 role 在一个群里的全部记录挂在那个群下: 展开的是那个群。
|
|
524
|
-
var t = convOf(CONV);
|
|
525
|
-
OPEN[t && t.chat ? 'c:' + t.chat : CONV] = true; OPEN_AT = CONV;
|
|
526
|
-
}
|
|
583
|
+
var setMore = function (key, v) {
|
|
584
|
+
if (v) MORE[key] = true; else delete MORE[key];
|
|
585
|
+
setPref(MORE_KEY, MORE);
|
|
527
586
|
};
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
if (
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
587
|
+
var reveal = function () {
|
|
588
|
+
if (!CONV || CONV === OPEN_AT) return;
|
|
589
|
+
var g = groupOf(CONV);
|
|
590
|
+
if (!(OPEN_AT === null && OPEN[g] === false)) OPEN[g] = true;
|
|
591
|
+
OPEN_AT = CONV;
|
|
592
|
+
saveOpen();
|
|
593
|
+
};
|
|
594
|
+
// 点侧栏项 / 关系图卡片 (两边同一份): 没选中它 → 选中 (会话顺带展开); 已选中再点 → 取消选中, 右边回到
|
|
595
|
+
// 页面视角的全部对话 (`a:<role>`, 即「看全部」那一份), 页面视角不动; 取消的是会话本身就顺带收起它。
|
|
596
|
+
// viewpoint: 站在这一项的主人身上 —— 会话与成对的子项是页面视角自己的, .far 子项与卡片是那个 role 的。
|
|
597
|
+
var clickItem = function (key, withRole, viewpoint) {
|
|
598
|
+
var vp = viewpoint && viewpoint !== ROLE ? viewpoint : '';
|
|
599
|
+
if (key !== CONV || (withRole || '') !== WITH || VIEW !== 'msgs' || VIEWPOINT !== vp) return selectConv(key, withRole, undefined, viewpoint);
|
|
600
|
+
// 窄屏退回列表后再点它是要回去读, 不是要取消。
|
|
601
|
+
if (!app.classList.contains('reading')) { app.classList.add('reading'); return seeAll(); }
|
|
602
|
+
var fold = !withRole && OPEN[key];
|
|
603
|
+
if (fold) OPEN[key] = false;
|
|
604
|
+
selectConv('a:' + ROLE, '', undefined, '');
|
|
605
|
+
if (fold) saveOpen(); // 选中项换过之后再记: 没选中的收着是默认态, 不占一条
|
|
537
606
|
};
|
|
538
607
|
|
|
539
608
|
// ── 会话项 / 子项 / 关系图卡片共用的三样: 一行的数据 (glance)、一行的画法 (roleRow)、排序 (recentFirst) ──
|
|
540
609
|
// glance = 时刻 · 最近一句 · 未读, 取自会话本身 (子项 = 我与它在这个群里的那一对)。
|
|
610
|
+
// theirs = 这一行主人的未读 (名字旁): 子项是它, 私聊项是对端, 群项没有。
|
|
541
611
|
var glance = function (c, s) {
|
|
542
|
-
var x = s || c;
|
|
543
|
-
return { lastTs: x.lastTs, preview: x.preview, unread:
|
|
612
|
+
var x = s || c, mine = unreadHeard(c, s ? s.role : undefined);
|
|
613
|
+
return { lastTs: x.lastTs, preview: x.preview, unread: mine.length, theirs: theirsOf(s ? s.role : c.peer, mine) };
|
|
544
614
|
};
|
|
545
615
|
// 同层按最近活动排, 有新话就上浮。
|
|
546
616
|
var recentFirst = function (a, b) { return b.lastTs - a.lastTs; };
|
|
547
617
|
// 一个 role 的一行: 头像 · 名字 (+tail) + 忙闲灯 · 时刻 / 最近一句 + 未读。
|
|
618
|
+
// 名字悬停时紧跟一枚 ⇄ —— 同名片上名字悬停露出 ⧉: 点名字做的事, 先亮给人看。
|
|
619
|
+
var SWAP = '<i class="sw" aria-hidden="true">⇄</i>';
|
|
548
620
|
var roleRow = function (id, name, label, g, status, tail, badge) {
|
|
549
621
|
return goSpan('av', id, esc(label)) +
|
|
550
|
-
line(nm(id, name, true, badge) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
|
|
622
|
+
line(nm(id, name, true, peerBadge(id, g.theirs) + SWAP + (badge || '')) + (tail || ''), g.lastTs, g.preview, stTag(status, true), g.unread);
|
|
551
623
|
};
|
|
552
624
|
// 名字旁的「+N」: 视图外还有 N 个 —— 同一个徽标, 数什么由所在的视图注入 (会话列表数会话, 关系图数关系)。
|
|
553
625
|
var moreTag = function (n, tip) {
|
|
@@ -564,7 +636,7 @@
|
|
|
564
636
|
var dmKey = function (peer) { return 'p:' + pairKey(peer); };
|
|
565
637
|
var convRow = function (c) {
|
|
566
638
|
if (c.kind === 'wizard') return roleRow(c.peer, c.name, c.label, glance(c), c.status, jobMark(c.jobs), otherChats(c.peer, dmKey(c.peer)));
|
|
567
|
-
if (c.kind === 'job') return '<span class="av">' + esc(c.label) + '</span>' + line('<span class="nm chat">' + esc(c.name) + '</span>' + jobTag(c.job) + stuckTag(c.job), c.lastTs, c.preview, '', unreadOf(c));
|
|
639
|
+
if (c.kind === 'job') return '<span class="av">' + esc(c.label) + '</span>' + line('<span class="nm chat">' + esc(c.name) + '</span>' + stageTag(c.job) + jobTag(c.job) + stuckTag(c.job), c.lastTs, c.preview, '', unreadOf(c));
|
|
568
640
|
return avatarOf(c) + line('<span class="nm chat">' + esc(c.name) + '</span>' + jobMark(c.jobs), c.lastTs, c.preview, stTag(c.status, true), unreadOf(c));
|
|
569
641
|
};
|
|
570
642
|
var subRow = function (c, s) { return roleRow(s.role, s.name, s.label, glance(c, s), s.status, jobMark(s.jobs), otherChats(s.role, c.key + '|' + pairKey(s.role))); };
|
|
@@ -575,18 +647,16 @@
|
|
|
575
647
|
var farKey = function (c, s) { return 'a:' + s.role + '||' + c.base; };
|
|
576
648
|
var farRow = function (s) {
|
|
577
649
|
var w = s.whole;
|
|
578
|
-
return roleRow(s.role, s.name, s.label, { lastTs: w.lastTs, preview: w.preview, unread: 0 }, s.status, '', otherChats(s.role, ''));
|
|
650
|
+
return roleRow(s.role, s.name, s.label, { lastTs: w.lastTs, preview: w.preview, unread: 0, theirs: theirsOf(s.role) }, s.status, '', otherChats(s.role, ''));
|
|
579
651
|
};
|
|
580
652
|
|
|
581
653
|
var convItem = function (c) {
|
|
582
654
|
var on = c.key === CONV;
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
var pairs = c.subs.filter(function (s) { return s.count; }).sort(recentFirst);
|
|
586
|
-
var fars =
|
|
587
|
-
|
|
588
|
-
.sort(function (a, b) { return recentFirst(a.whole, b.whole); })
|
|
589
|
-
: [];
|
|
655
|
+
// 与我有往来的成对子项在前; chat 内其余有记录的接在后面 (.far), 各自按最近排。
|
|
656
|
+
// 时间范围按子项自己的时刻裁 (成对 = 我与它的往来, .far = 它在群里的全部) —— 群的时刻是群里任何人的动静, 放不得行。
|
|
657
|
+
var pairs = c.subs.filter(function (s) { return s.count && inWin(s.lastTs); }).sort(recentFirst);
|
|
658
|
+
var fars = c.subs.filter(function (s) { return !s.count && s.whole && s.whole.count && inWin(s.whole.lastTs); })
|
|
659
|
+
.sort(function (a, b) { return recentFirst(a.whole, b.whole); });
|
|
590
660
|
var talked = OPEN[c.key] ? pairs.concat(fars) : [];
|
|
591
661
|
var hidden = talked.length - SUB_FOLD;
|
|
592
662
|
var bar = hidden > 0
|
|
@@ -596,7 +666,7 @@
|
|
|
596
666
|
var sub = function (s) {
|
|
597
667
|
if (!s.count) {
|
|
598
668
|
var k = farKey(c, s);
|
|
599
|
-
return '<button class="si far' + (CONV === k ? ' on' : '') + '" data-conv="' + esc(k) + '" data-with="" ' +
|
|
669
|
+
return '<button class="si far' + (CONV === k ? ' on' : '') + '" data-conv="' + esc(k) + '" data-with="" data-vp="' + esc(s.role) + '" ' +
|
|
600
670
|
'title="' + esc(nameOf(s.role) + ' 在这里的 ' + s.whole.count + ' 条记录 (与我无往来)') + '">' + farRow(s) + '</button>';
|
|
601
671
|
}
|
|
602
672
|
var sel = on && s.role === WITH;
|
|
@@ -609,6 +679,16 @@
|
|
|
609
679
|
? '<div class="subs">' + talked.slice(0, SUB_FOLD).map(sub).join('') + bar + rest + '</div>'
|
|
610
680
|
: '';
|
|
611
681
|
var sel = on && !WITH;
|
|
682
|
+
|
|
683
|
+
// 群聊项特殊处理: 把 chevron 挪到行内部, 在时间左边
|
|
684
|
+
if (c.kind === 'group' && c.subs && c.subs.length) {
|
|
685
|
+
// 不能是 <button>: 嵌在外层 .ci 按钮里会被解析器提前闭合外层, chevron 就掉到卡片外
|
|
686
|
+
var chevron = '<span role="button" class="ci-chevron' + (OPEN[c.key] ? ' on' : '') + '" data-toggle="' + esc(c.key) + '" title="' + (OPEN[c.key] ? '折叠群' : '展开群') + '"></span>';
|
|
687
|
+
var content = avatarOf(c) + '<span class="b"><span class="l1"><span class="t"><span class="nm chat">' + esc(c.name) + '</span>' + jobMark(c.jobs) + '</span>' + chevron + '<span class="ts">' + esc(fmtAgo(c.lastTs)) + '</span></span>' +
|
|
688
|
+
'<span class="l2"><span class="pv">' + esc(c.preview) + '</span>' + (unreadOf(c) ? '<b class="ub">' + (unreadOf(c) > 99 ? '99+' : unreadOf(c)) + '</b>' : '') + '</span></span>';
|
|
689
|
+
return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' + content + '</button>' + subs;
|
|
690
|
+
}
|
|
691
|
+
|
|
612
692
|
return '<button class="ci' + (sel ? ' on' : '') + '" data-conv="' + esc(c.key) + '">' +
|
|
613
693
|
convRow(c) + '</button>' + subs;
|
|
614
694
|
};
|
|
@@ -625,9 +705,8 @@
|
|
|
625
705
|
// 老外壳 (daemon 未重启) 里还留着名片下那条入口栏 —— 日程已并进名片, 它整条不要了。
|
|
626
706
|
if ($('#rb-acts')) $('#rb-acts').remove();
|
|
627
707
|
var renderConvs = function () {
|
|
628
|
-
var groups = R.convs.filter(function (c) { return c.kind === 'group'; }).sort(recentFirst);
|
|
629
|
-
var dms = R.convs.filter(function (c) { return c.kind === 'wizard'; }).sort(recentFirst);
|
|
630
|
-
R.convs.forEach(markRead);
|
|
708
|
+
var groups = R.convs.filter(function (c) { return c.kind === 'group' && inWin(c.lastTs); }).sort(recentFirst);
|
|
709
|
+
var dms = R.convs.filter(function (c) { return c.kind === 'wizard' && inWin(c.lastTs); }).sort(recentFirst);
|
|
631
710
|
if (WORLD) return renderWorld();
|
|
632
711
|
// 空着的那一栏不画 —— 对谁都一样: 人没有私聊只是这条规则的一个特例。
|
|
633
712
|
var sec = function (title, list) {
|
|
@@ -635,22 +714,23 @@
|
|
|
635
714
|
};
|
|
636
715
|
// 没变就不碰 DOM: 心跳每 3s 来一次, 重建会把列表的滚动与焦点蹭掉。
|
|
637
716
|
// 开关挂在第一个标题行的右端; 一个会话都没有也留一行标题给它。
|
|
638
|
-
var html = (sec('群聊', groups) + sec('私聊', dms) || '<h2>会话<span>0</span></h2>').replace('</h2>',
|
|
717
|
+
var html = (sec('群聊', groups) + sec('私聊', dms) || '<h2>会话<span>0</span></h2>').replace('</h2>', winSelect() + worldToggle() + '</h2>');
|
|
639
718
|
if (convsEl._html === html) return;
|
|
640
719
|
convsEl._html = html; convsEl.innerHTML = html;
|
|
641
720
|
convsEl.querySelectorAll('[data-conv]').forEach(function (b) {
|
|
642
|
-
var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '';
|
|
643
|
-
b.onclick = function () { clickItem(key, w); };
|
|
721
|
+
var key = b.getAttribute('data-conv'), w = b.getAttribute('data-with') || '', vp = b.getAttribute('data-vp') || '';
|
|
722
|
+
b.onclick = function () { clickItem(key, w, vp); };
|
|
644
723
|
});
|
|
645
724
|
convsEl.querySelectorAll('[data-more]').forEach(function (b) {
|
|
646
725
|
var key = b.getAttribute('data-more');
|
|
647
|
-
b.onclick = function () {
|
|
726
|
+
b.onclick = function () { setMore(key, !MORE[key]); renderConvs(); };
|
|
648
727
|
});
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
b.onclick = function () { setScope(b.getAttribute('data-scope')); };
|
|
728
|
+
convsEl.querySelectorAll('[data-toggle]').forEach(function (b) {
|
|
729
|
+
var key = b.getAttribute('data-toggle');
|
|
730
|
+
b.onclick = function (e) { e.stopPropagation(); OPEN[key] = !OPEN[key]; saveOpen(); renderConvs(); };
|
|
653
731
|
});
|
|
732
|
+
bindGo(convsEl);
|
|
733
|
+
bindWorldToggle(convsEl); bindWin(convsEl);
|
|
654
734
|
};
|
|
655
735
|
|
|
656
736
|
var shortCwd = function (p) {
|
|
@@ -659,73 +739,113 @@
|
|
|
659
739
|
};
|
|
660
740
|
// session 只靠两样认: 何时开始、跑了几轮。最新那段直接叫「最新」。
|
|
661
741
|
var latestSess = function () { return R.sessions[R.sessions.length - 1]; };
|
|
742
|
+
// 选中的 session 按「role + 会话 (+ 群里的对端)」记; 选回默认 (全部时间, 不切) 就删那一项。
|
|
743
|
+
// 只在 URL 没指定 session 时恢复 —— 落地窗口要等第一份摘要才定, SESS_RESTORE 守到那时。
|
|
744
|
+
var SESS_KEY = 'wezard.role.session';
|
|
745
|
+
var SESS_RESTORE = !qs.get('session');
|
|
746
|
+
var sessKey = function () { return ROLE + '\n' + CONV + (WITH ? '\n' + WITH : ''); };
|
|
747
|
+
var savedSess = function () { return prefObj(SESS_KEY)[sessKey()] || ''; };
|
|
748
|
+
var saveSess = function (sid) {
|
|
749
|
+
var m = prefObj(SESS_KEY);
|
|
750
|
+
if (sid && sid !== 'all') m[sessKey()] = sid; else delete m[sessKey()];
|
|
751
|
+
setPref(SESS_KEY, m);
|
|
752
|
+
};
|
|
753
|
+
// 第一份摘要到手: 记下的那段还在列表里就换过去 (换了要重取一次), 不在了就回默认并删掉。
|
|
754
|
+
var restoreSess = function () {
|
|
755
|
+
if (!SESS_RESTORE) return false;
|
|
756
|
+
SESS_RESTORE = false;
|
|
757
|
+
var want = savedSess();
|
|
758
|
+
if (!want || want === SESSION) return false;
|
|
759
|
+
if (R.sessions.some(function (x) { return x.sessionId === want; })) { SESSION = want; return true; }
|
|
760
|
+
saveSess('');
|
|
761
|
+
return false;
|
|
762
|
+
};
|
|
662
763
|
var sessWhen = function (s) { return s.start ? fmtClock(s.start) : '(无时刻)'; };
|
|
663
764
|
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
|
|
667
|
-
var
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
'<span class="
|
|
672
|
-
'<
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
'
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
'
|
|
685
|
-
|
|
686
|
-
var setSessOpen = function (open) {
|
|
687
|
-
SESS_OPEN = open;
|
|
688
|
-
var sp = $('#rb-sp .sp');
|
|
689
|
-
if (!sp) return;
|
|
690
|
-
sp.classList.toggle('open', open);
|
|
691
|
-
sp.querySelector('.sp-btn').setAttribute('aria-expanded', open);
|
|
765
|
+
// 自定义下拉: 小号文字触发器 + 浮层列表 (.sp)。名片里的 session 与侧栏标题行的时间范围共用这一份。
|
|
766
|
+
// 宿主每次刷新都重画, 展开的是哪一个记在 DD_OPEN 里 (按 id) 才不会被轮询收起。
|
|
767
|
+
// items: { v, html, on, cls?, tip? }; `ic` = 触发器左侧的图标; `end` = 浮层以触发器右沿为锚向左下展开 (贴着侧栏右沿的那种)。
|
|
768
|
+
var DD_OPEN = '';
|
|
769
|
+
var dropdown = function (id, o) {
|
|
770
|
+
var open = DD_OPEN === id;
|
|
771
|
+
return '<span class="sp' + (o.end ? ' end' : '') + (open ? ' open' : '') + '" data-dd="' + id + '">' +
|
|
772
|
+
(o.ic ? '<span class="sp-ic" aria-hidden="true">' + o.ic + '</span>' : '') +
|
|
773
|
+
'<button class="sp-btn" aria-haspopup="listbox" aria-expanded="' + open + '" title="' + esc(o.title) + '">' +
|
|
774
|
+
esc(o.label) + '<span class="car" aria-hidden="true"></span></button>' +
|
|
775
|
+
'<span class="sp-list" role="listbox">' + o.items.map(function (it) {
|
|
776
|
+
return '<button class="sp-it' + (it.cls ? ' ' + it.cls : '') + (it.on ? ' on' : '') + '" role="option" aria-selected="' + !!it.on + '" data-v="' + esc(it.v) + '"' +
|
|
777
|
+
(it.tip ? ' title="' + esc(it.tip) + '"' : '') + '>' + it.html + '</button>';
|
|
778
|
+
}).join('') + '</span></span>';
|
|
779
|
+
};
|
|
780
|
+
var setDdOpen = function (id) {
|
|
781
|
+
DD_OPEN = id;
|
|
782
|
+
document.querySelectorAll('.sp[data-dd]').forEach(function (sp) {
|
|
783
|
+
var on = sp.getAttribute('data-dd') === id;
|
|
784
|
+
sp.classList.toggle('open', on);
|
|
785
|
+
sp.querySelector('.sp-btn').setAttribute('aria-expanded', on);
|
|
786
|
+
});
|
|
692
787
|
};
|
|
693
|
-
var
|
|
694
|
-
var sp =
|
|
788
|
+
var bindDropdown = function (root, id, pick) {
|
|
789
|
+
var sp = root && root.querySelector('.sp[data-dd="' + id + '"]');
|
|
695
790
|
if (!sp) return;
|
|
696
|
-
sp.querySelector('.sp-btn').onclick = function () {
|
|
791
|
+
sp.querySelector('.sp-btn').onclick = function () { setDdOpen(DD_OPEN === id ? '' : id); };
|
|
697
792
|
sp.querySelectorAll('.sp-it').forEach(function (it) {
|
|
698
|
-
it.onclick = function () {
|
|
793
|
+
it.onclick = function () { setDdOpen(''); pick(it.getAttribute('data-v')); };
|
|
699
794
|
});
|
|
700
795
|
};
|
|
701
796
|
document.addEventListener('click', function (e) {
|
|
702
|
-
if (
|
|
797
|
+
if (DD_OPEN && !e.target.closest('.sp[data-dd="' + DD_OPEN + '"]')) setDdOpen('');
|
|
703
798
|
});
|
|
704
799
|
document.addEventListener('keydown', function (e) {
|
|
705
|
-
if (
|
|
800
|
+
if (DD_OPEN && e.key === 'Escape') setDdOpen('');
|
|
706
801
|
});
|
|
707
802
|
|
|
803
|
+
// session 切换: name 右边那个下拉。
|
|
804
|
+
var sessItem = function (s, on) {
|
|
805
|
+
var last = s === latestSess();
|
|
806
|
+
return { v: s.sessionId || '', on: on, tip: s.sessionId || '',
|
|
807
|
+
html: '<span class="id">' + (last ? '最新 · ' : '') + esc(sessWhen(s)) + '</span><span class="n">' + s.turns + ' 轮</span>' };
|
|
808
|
+
};
|
|
809
|
+
var sessPicker = function () {
|
|
810
|
+
var cur = R.sessions.filter(function (s) { return s.sessionId === SESSION; })[0];
|
|
811
|
+
return dropdown('sess', {
|
|
812
|
+
title: '切换 session',
|
|
813
|
+
label: !cur ? '全部' : cur === latestSess() ? '最新' : sessWhen(cur),
|
|
814
|
+
items: [{ v: 'all', on: !cur, cls: 'all', html: '<span class="id">全部</span><span class="n">' + R.sessions.length + ' 段</span>' }]
|
|
815
|
+
.concat(R.sessions.slice().reverse().map(function (s) { return sessItem(s, s.sessionId === SESSION); })),
|
|
816
|
+
});
|
|
817
|
+
};
|
|
818
|
+
var bindSessPicker = function () {
|
|
819
|
+
bindDropdown($('#rb-sp'), 'sess', function (v) { SESSION = v; saveSess(SESSION); refresh(); });
|
|
820
|
+
};
|
|
821
|
+
|
|
708
822
|
// 会话列表 ↔ 关系图: 侧栏第一行右上角同一个小开关, 标的是「点了去哪」。
|
|
709
823
|
var LIST_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M3 4h10M3 8h10M3 12h10" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
710
824
|
var TREE_SVG = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M4 3v10M4 6h5M4 11h5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="11" cy="6" r="1.6" fill="currentColor"/><circle cx="11" cy="11" r="1.6" fill="currentColor"/></svg>';
|
|
711
|
-
//
|
|
712
|
-
|
|
713
|
-
var
|
|
714
|
-
var
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
825
|
+
// 时间范围: 只看最近 N 分钟内有动静的 (0 = 全部)。会话列表与关系图共用这一个值。
|
|
826
|
+
var WIN_KEY = 'wezard.role.winMin';
|
|
827
|
+
var WINS = [[0, '全部'], [30, '半小时'], [60, '1小时'], [120, '2小时'], [1440, '1天']];
|
|
828
|
+
var WIN = pref(WIN_KEY, 0);
|
|
829
|
+
if (!WINS.some(function (w) { return w[0] === WIN; })) WIN = 0;
|
|
830
|
+
var winCut = function () { return WIN ? srvNow() - WIN * 60000 : 0; };
|
|
831
|
+
/** 一个时刻落没落在时间范围里 —— 侧栏的会话 / 子项与关系图的边 (rangeOf) 都按 winCut 这一刀。 */
|
|
832
|
+
var inWin = function (ts) { return !WIN || ts >= winCut(); };
|
|
833
|
+
var winSelect = function () {
|
|
834
|
+
return dropdown('win', {
|
|
835
|
+
ic: '⏱', end: true, title: '时间范围: 只看这段时间内有动静的',
|
|
836
|
+
label: (WINS.filter(function (w) { return w[0] === WIN; })[0] || WINS[0])[1],
|
|
837
|
+
items: WINS.map(function (w) { return { v: String(w[0]), on: w[0] === WIN, html: '<span class="id">' + w[1] + '</span>' }; }),
|
|
838
|
+
});
|
|
839
|
+
};
|
|
840
|
+
var bindWin = function (scope) {
|
|
841
|
+
bindDropdown(scope, 'win', function (v) {
|
|
842
|
+
WIN = +v; W.treeFor = ''; setPref(WIN_KEY, WIN);
|
|
843
|
+
convsEl._html = ''; convsEl._tree = '';
|
|
844
|
+
renderConvs();
|
|
845
|
+
});
|
|
726
846
|
};
|
|
727
847
|
var worldToggle = function () {
|
|
728
|
-
return '<button class="vt" data-vt title="' + (WORLD ? '
|
|
848
|
+
return '<button class="vt" data-vt title="' + (WORLD ? '换回聊天' : '换成关系') + '">' + (WORLD ? LIST_SVG + '聊天' : TREE_SVG + '关系') + '</button>';
|
|
729
849
|
};
|
|
730
850
|
var bindWorldToggle = function (scope) {
|
|
731
851
|
var b = scope.querySelector('[data-vt]');
|
|
@@ -952,14 +1072,14 @@
|
|
|
952
1072
|
// ── 右栏头: 这个群聊 / 私聊是什么 (关系 / 日程视图时是视图名) ──
|
|
953
1073
|
// 跳走之前在看的那段会话 —— 顶栏的「‹ 回到 …」回这里。on: 在哪些窗口上露出这个按钮;
|
|
954
1074
|
// at: 跳走时点的那条消息, 回来落在它上面。进工单页 (只在从非工单的窗口进来时记) 与
|
|
955
|
-
//
|
|
1075
|
+
// 移交行 / 提示行点过去都记它, 同一个按钮。点过去会换详情区的 viewpoint, 所以连它一起记 (页面视角不动)。
|
|
956
1076
|
var BACK = null;
|
|
957
1077
|
var inJobs = function () { return VIEW === 'jobs' || CONV.indexOf('j:') === 0; };
|
|
958
|
-
// 回来 = 原样恢复那一页:
|
|
1078
|
+
// 回来 = 原样恢复那一页: 会话、viewpoint、滚动位置 (按视窗顶上那一行的偏移记, 不闪不吸)。吸在底部就不记锚点。
|
|
959
1079
|
var markBack = function (on) {
|
|
960
1080
|
var r = !atBottom() && firstShown();
|
|
961
1081
|
var at = r && { id: r.getAttribute('data-id'), off: r.getBoundingClientRect().top - thread.getBoundingClientRect().top };
|
|
962
|
-
BACK = { conv: CONV, with: WITH, session: SESSION,
|
|
1082
|
+
BACK = { conv: CONV, with: WITH, session: SESSION, viewpoint: VIEWPOINT, at: at || null, on: on };
|
|
963
1083
|
};
|
|
964
1084
|
var restoreAt = function (at) {
|
|
965
1085
|
if (!at) return undefined;
|
|
@@ -992,6 +1112,16 @@
|
|
|
992
1112
|
e.stopPropagation(); e.preventDefault();
|
|
993
1113
|
openJobs(m.getAttribute('data-jobs').split(' ').filter(Boolean));
|
|
994
1114
|
}, true);
|
|
1115
|
+
// 关系图的 clone 徽标: 开 origin 的全部往来、落到它分叉出这个分身的那一处; viewpoint 站到 origin, 页面视角不动。
|
|
1116
|
+
// 同 📋: 长在卡片按钮里, 捕获阶段截下来。
|
|
1117
|
+
document.addEventListener('click', function (e) {
|
|
1118
|
+
var m = e.target.closest && e.target.closest('.ek.clone[data-cpoint]');
|
|
1119
|
+
if (!m) return;
|
|
1120
|
+
e.stopPropagation(); e.preventDefault();
|
|
1121
|
+
var pt = JSON.parse(m.getAttribute('data-cpoint')), conv = 'a:' + pt.origin;
|
|
1122
|
+
markBack(function () { return VIEW === 'msgs' && CONV === conv; });
|
|
1123
|
+
jumpMsg({ conv: conv, id: pt.id, use: pt.use, ts: pt.ts, viewpoint: pt.origin });
|
|
1124
|
+
}, true);
|
|
995
1125
|
var backBtn = function () {
|
|
996
1126
|
var c = BACK && BACK.on() && convOf(BACK.conv);
|
|
997
1127
|
return c ? '<button class="back jb" id="ch-jback" title="' + esc('回到 ' + (BACK.with ? nameOf(BACK.with) : c.name)) + '" aria-label="返回">‹</button>' : '';
|
|
@@ -1001,8 +1131,10 @@
|
|
|
1001
1131
|
var h = backBtn();
|
|
1002
1132
|
if (h) $('#ch-who').insertAdjacentHTML('afterbegin', h);
|
|
1003
1133
|
var b = $('#ch-jback');
|
|
1004
|
-
if (b) b.onclick = function () { var x = BACK; BACK = null; SESSION = x.session; selectConv(x.conv, x.with, restoreAt(x.at), x.
|
|
1134
|
+
if (b) b.onclick = function () { var x = BACK; BACK = null; SESSION = x.session; selectConv(x.conv, x.with, restoreAt(x.at), x.viewpoint); };
|
|
1005
1135
|
};
|
|
1136
|
+
// 一对一的标题: 与关系图卡片窗口 (convOf 的 a:) 同一种写法。
|
|
1137
|
+
var pairTitle = function (peer) { return nameOf(ROLE) + ' 与 ' + nameOf(peer) + ' 的对话'; };
|
|
1006
1138
|
var renderHead = function () {
|
|
1007
1139
|
var who = $('#ch-who'), acts = $('#ch-acts');
|
|
1008
1140
|
if (VIEW === 'jobs') {
|
|
@@ -1022,29 +1154,29 @@
|
|
|
1022
1154
|
}
|
|
1023
1155
|
var c = convOf(CONV);
|
|
1024
1156
|
if (!c) { who.innerHTML = ''; acts.innerHTML = ''; return; }
|
|
1025
|
-
// 某 role 在一个群里的全部记录 (「chat 内全部」下与我无往来的子项):
|
|
1026
|
-
//
|
|
1157
|
+
// 某 role 在一个群里的全部记录 (「chat 内全部」下与我无往来的子项): 顶栏只写它, 副标题「在 <群> 的全部对话」——
|
|
1158
|
+
// 与成对子项 (「我 ⇄ 它」) 一眼分得开。群名点回整个群。
|
|
1027
1159
|
if (c.kind === 'all' && c.chat) {
|
|
1028
1160
|
who.innerHTML = '<span class="t">' + nm(c.who, '', true) + '</span>' +
|
|
1029
|
-
'<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + chatTitle(c.chat) + ' 的全部记录') + '">' + esc(chatTitle(c.chat)) + '</button
|
|
1161
|
+
'<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + chatTitle(c.chat) + ' 的全部记录') + '">' + esc(chatTitle(c.chat)) + '</button> 的全部对话</span>';
|
|
1030
1162
|
acts.innerHTML = '';
|
|
1031
1163
|
bindGo(who);
|
|
1032
1164
|
$('#ch-up').onclick = function () { selectConv('c:' + c.chat, ''); };
|
|
1033
1165
|
return;
|
|
1034
1166
|
}
|
|
1035
1167
|
if (c.kind === 'all') {
|
|
1036
|
-
who.innerHTML =
|
|
1168
|
+
who.innerHTML = '<span class="t" title="' + esc(c.name) + '"></span>';
|
|
1037
1169
|
acts.innerHTML = '';
|
|
1038
1170
|
bindBack();
|
|
1039
1171
|
fitTalk(who.querySelector('.t'), c);
|
|
1040
1172
|
bindGo(who);
|
|
1041
1173
|
return;
|
|
1042
1174
|
}
|
|
1043
|
-
// 群里选中一个子项: 窗口是「我与它」在这个群里的往来,
|
|
1175
|
+
// 群里选中一个子项: 窗口是「我与它」在这个群里的往来, 顶栏同关系图卡片的窗口写成「我 与 它 的对话」, 不画头像;
|
|
1044
1176
|
// 群名退到副标题, 点它 = 选回这个群本身 (整个群的视图)。
|
|
1045
1177
|
if (WITH && c.kind === 'group') {
|
|
1046
|
-
var
|
|
1047
|
-
who.innerHTML = '<span class="t"
|
|
1178
|
+
var withName = pairTitle(WITH);
|
|
1179
|
+
who.innerHTML = '<span class="t" title="' + esc(withName) + '">' + esc(withName) + '</span>' +
|
|
1048
1180
|
'<span class="sub">在 <button type="button" class="up" id="ch-up" title="' + esc('看 ' + c.name + ' 的全部记录') + '">' + esc(c.name) + '</button></span>';
|
|
1049
1181
|
acts.innerHTML = '';
|
|
1050
1182
|
bindBack();
|
|
@@ -1053,19 +1185,19 @@
|
|
|
1053
1185
|
return;
|
|
1054
1186
|
}
|
|
1055
1187
|
if (c.kind === 'job') {
|
|
1056
|
-
who.innerHTML = '<span class="av">📋</span><span class="t">' + esc(c.name) + '</span>' +
|
|
1057
|
-
'<span class="sub">' + esc(c.key.slice(2)) + (c.job ? ' · 开单 ' + nm(c.job.owner, '', true) : '') + jobTag(c.job) + stuckTag(c.job) + '</span>';
|
|
1188
|
+
who.innerHTML = '<span class="av">📋</span><span class="t">' + jobCrumbs(c.key.slice(2)) + esc(c.name) + '</span>' +
|
|
1189
|
+
'<span class="sub">' + esc(c.key.slice(2)) + (c.job ? ' · 开单 ' + nm(c.job.owner, '', true) : '') + stageTag(c.job) + jobTag(c.job) + stuckTag(c.job) + '</span>';
|
|
1190
|
+
who.querySelectorAll('.jcrumb [data-job]').forEach(function (b) { b.onclick = function () { openJob(b.getAttribute('data-job')); }; });
|
|
1058
1191
|
acts.innerHTML = '';
|
|
1059
1192
|
bindBack();
|
|
1060
1193
|
bindGo(who);
|
|
1061
1194
|
return;
|
|
1062
1195
|
}
|
|
1063
|
-
// 一对一 (私聊, 或群里「只看我与 X」)
|
|
1064
|
-
var dm = dmOf(c);
|
|
1196
|
+
// 一对一 (私聊, 或群里「只看我与 X」) 与关系图卡片的窗口同一种写法: 「我 与 它 的对话」, 不画头像。
|
|
1065
1197
|
var peer = pairPeer(c);
|
|
1066
|
-
var
|
|
1067
|
-
who.innerHTML =
|
|
1068
|
-
'<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>') + '</span>';
|
|
1198
|
+
var pairName = peer && pairTitle(peer);
|
|
1199
|
+
who.innerHTML = peer ? '<span class="t" title="' + esc(pairName) + '">' + esc(pairName) + '</span>'
|
|
1200
|
+
: '<span class="t">' + (c.kind === 'wizard' ? nm(c.peer, c.name, true) : '<span class="nm chat">' + esc(c.name) + '</span>') + '</span>';
|
|
1069
1201
|
acts.innerHTML = '';
|
|
1070
1202
|
bindBack();
|
|
1071
1203
|
bindGo(who);
|
|
@@ -1124,45 +1256,53 @@
|
|
|
1124
1256
|
if (m.dir === 'mark') {
|
|
1125
1257
|
return '<div class="mrow mark" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '" data-sig="' + esc(m.sig) + '">' + signCut(m.html, m.from) + '</div>';
|
|
1126
1258
|
}
|
|
1127
|
-
var me =
|
|
1259
|
+
var me = viewpointOf();
|
|
1128
1260
|
var mine = m.from === me;
|
|
1129
1261
|
var other = mine ? m.to : m.from;
|
|
1130
1262
|
// 看 key 而不是会话列表: 换视角就地重包时, 列表还是上一个 role 的。
|
|
1131
1263
|
var group = CONV.indexOf('p:') !== 0;
|
|
1132
|
-
// 顶栏那个对端是相对 ROLE 的;
|
|
1264
|
+
// 顶栏那个对端是相对 ROLE 的; 详情区的 viewpoint 换成了它时, 它的对端就是 ROLE。
|
|
1133
1265
|
var peer = group ? pairPeer(convOf(CONV)) : CONV.slice(2);
|
|
1134
|
-
if (
|
|
1266
|
+
if (VIEWPOINT && peer === VIEWPOINT) peer = ROLE;
|
|
1135
1267
|
// 说给谁: 群里我不是收信方的那条 (X → Y); 我发的那条, 收信方不是顶栏那个对端时
|
|
1136
1268
|
// (一对一里收信方就是顶栏那个对端, 不再重复写)。
|
|
1137
|
-
|
|
1269
|
+
// 两个 role 之间的对话 (标题「X 与 Y 的对话」) 里对象就是另一方, 不标; 多方视图才标。
|
|
1270
|
+
var c = convOf(CONV);
|
|
1271
|
+
var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
|
|
1272
|
+
var dst = two ? '' : mine
|
|
1138
1273
|
? (m.to && m.to !== 'human:' && m.to !== peer ? m.to : '')
|
|
1139
1274
|
: (m.to !== me && group ? m.to : '');
|
|
1140
|
-
//
|
|
1141
|
-
var
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
? '<span class="dest" aria-hidden="true">' + (mine ? '' : '<i class="arr">→</i>') +
|
|
1146
|
-
'<span class="dav">' + esc(roleLabel(dst)) + '</span><span class="dn">' + esc(nameOf(dst)) + '</span>' +
|
|
1147
|
-
(mine ? '<i class="arr">←</i>' : '') + '</span>'
|
|
1275
|
+
// 说给谁画进文本泡泡开头 (头像 + 名字); 点它走移交行同一套跳转 (data-hfrom/hto/hch + gid/gts), 不换页面视角。
|
|
1276
|
+
var toIn = dst
|
|
1277
|
+
? '<span class="to-in" role="button" tabindex="0" data-hfrom="' + esc(m.from) + '" data-hto="' + esc(m.to) + '" data-hch="' + esc(m.channel || '') +
|
|
1278
|
+
'" data-gid="' + esc(m.id) + '" data-gts="' + m.ts + '" title="' + esc('发给 ' + nameOf(m.to) + ' —— 看这段往来') + '"><span class="av">' +
|
|
1279
|
+
esc(roleLabel(m.to)) + '</span><span class="nm ' + kindOf(m.to) + '">' + esc(nameOf(m.to)) + '</span></span>'
|
|
1148
1280
|
: '';
|
|
1281
|
+
var withTo = function (html) {
|
|
1282
|
+
if (!toIn) return html;
|
|
1283
|
+
// 只挂进这条消息自己的正文: 移交体也是 .md-body, 但它发给被移交的那个 wizard, 服务端已挂好它自己的 .to-in。
|
|
1284
|
+
var t = document.createElement('template');
|
|
1285
|
+
t.innerHTML = html;
|
|
1286
|
+
var body = [].find.call(t.content.querySelectorAll('.q-body, .md-body'), function (b) { return !b.closest('.handoff'); });
|
|
1287
|
+
if (!body) return '<div class="to-row">' + toIn + '</div>' + html;
|
|
1288
|
+
body.insertAdjacentHTML('afterbegin', toIn);
|
|
1289
|
+
return t.innerHTML;
|
|
1290
|
+
};
|
|
1149
1291
|
var priv = !m.channel && group ? '<span class="ch priv">私聊</span>' : '';
|
|
1150
1292
|
// 本轮的账 (呼吸点 + 模型 / 上下文 / 耗时) 跟在时刻后面 —— 片段是服务端渲染好的。
|
|
1151
1293
|
// 模型 / ctx 只在多方会话里有用 (分得清谁跑的什么模型); 两个 role 之间的对话里整行挂 .two, 头像行 (.mstat)、
|
|
1152
1294
|
// 终句气泡的时间行 (.say-cap) 与移交行的账 (.ho-acct) 里藏掉这两样, 耗时、呼吸点与回执留着。
|
|
1153
|
-
var c = convOf(CONV);
|
|
1154
|
-
var two = !group || !!pairPeer(c) || (!!c && c.kind === 'all' && c.peers.length === 1);
|
|
1155
1295
|
var stat = m.meta ? '<span class="mstat">' + jobChips(m.meta) + '</span>' : '';
|
|
1156
1296
|
var who = mine
|
|
1157
|
-
?
|
|
1158
|
-
: avBtn(m.from) + nm(m.from, '', true) +
|
|
1297
|
+
? stamp(m.ts) + stat + avBtn(m.from)
|
|
1298
|
+
: avBtn(m.from) + nm(m.from, '', true) + priv + stamp(m.ts) + stat;
|
|
1159
1299
|
var sw = canSwitch(other);
|
|
1160
1300
|
var flip = '<button class="flip" data-r="' + esc(other) + '"' + (sw ? '' : ' disabled tabindex="-1"') +
|
|
1161
1301
|
' aria-label="' + esc(sw ? '切到 ' + nameOf(other) + ' 的视角' : '') + '">' +
|
|
1162
|
-
|
|
1302
|
+
(sw ? '<span class="fi"><span class="fn">' + esc(nameOf(other)) + '</span>' + CHEVRON + '</span>' : '') + '</button>';
|
|
1163
1303
|
return '<div class="mrow ' + (mine ? 'mine' : 'them') + (two ? ' two' : '') + '" data-id="' + esc(m.id) + '" data-turn="' + esc(m.turnId || m.id) + '" data-ts="' + m.ts + '"' +
|
|
1164
1304
|
(m.ping ? ' data-ping="1" data-ping-who="' + esc(roleName(m.dir === 'in' ? m.to : m.from)) + '"' : '') + ' data-sig="' + esc(m.sig) + '" data-stale-at="' + (m.staleAt || 0) + '">' +
|
|
1165
|
-
'<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + jobChips(m.dir === 'out' ? signCut(m.html, m.from) : m.html) + '</div></div>' +
|
|
1305
|
+
'<div class="mcol"><div class="mwho">' + who + '</div><div class="mb">' + jobChips(withTo(m.dir === 'out' ? signCut(m.html, m.from) : m.html)) + '</div></div>' +
|
|
1166
1306
|
flip +
|
|
1167
1307
|
'</div>';
|
|
1168
1308
|
};
|
|
@@ -1218,6 +1358,13 @@
|
|
|
1218
1358
|
|
|
1219
1359
|
var bindRow = function (el) {
|
|
1220
1360
|
bindGo(el, '.flip[data-r]:not([disabled]), .mwho .go[data-r], .tg-cut .go[data-r], .tg-mark .go[data-r], .tg-job .go[data-r]');
|
|
1361
|
+
// 定时行开的是页面视角自己的日程: 片段不随视角 (服务端只标发话方), 能不能点在这里按 ROLE 定;
|
|
1362
|
+
// 别人的那行不可点, 只剩 chevron 展开 prompt。换视角就地重包时也走这里。
|
|
1363
|
+
el.querySelectorAll('.handoff .ho-line[data-hplan]').forEach(function (b) {
|
|
1364
|
+
var mine = b.getAttribute('data-hplan') === ROLE;
|
|
1365
|
+
b.classList.toggle('plan-go', mine);
|
|
1366
|
+
if (mine) b.title = '打开 ' + nameOf(ROLE) + ' 的日程'; else b.removeAttribute('title');
|
|
1367
|
+
});
|
|
1221
1368
|
};
|
|
1222
1369
|
var rowNode = function (id) {
|
|
1223
1370
|
var list = inner.querySelectorAll('.mrow');
|
|
@@ -1274,14 +1421,16 @@
|
|
|
1274
1421
|
foldPings(inner);
|
|
1275
1422
|
expireRows();
|
|
1276
1423
|
if (keep) keep();
|
|
1424
|
+
see(fresh);
|
|
1277
1425
|
}).catch(retry);
|
|
1278
1426
|
};
|
|
1279
1427
|
|
|
1280
1428
|
// land: 换视角时由它来定位 (锚住一条旧消息), 返回 false 才照常吸底。
|
|
1281
|
-
|
|
1429
|
+
// early: 换视角时与摘要并行发出的同一个请求 (见 refresh) —— 窗口参数没变才拿来用。
|
|
1430
|
+
var loadMsgs = function (limit, land, early) {
|
|
1282
1431
|
var gen = S.gen;
|
|
1283
1432
|
if (!CONV) { inner.innerHTML = '<div class="empty">选一个会话</div>'; return Promise.resolve(); }
|
|
1284
|
-
return api('api/msgs', viewParams(limit ? { limit: limit } : {})).then(function (d) {
|
|
1433
|
+
return (early || api('api/msgs', viewParams(limit ? { limit: limit } : {}))).then(function (d) {
|
|
1285
1434
|
if (!d.ok || gen !== S.gen) return;
|
|
1286
1435
|
S.frags = {};
|
|
1287
1436
|
d.msgs.forEach(function (m) { S.frags[m.id] = m; });
|
|
@@ -1294,6 +1443,7 @@
|
|
|
1294
1443
|
expireRows();
|
|
1295
1444
|
settleAbove();
|
|
1296
1445
|
applyFold(inner);
|
|
1446
|
+
see(d.msgs);
|
|
1297
1447
|
if (land && land()) return;
|
|
1298
1448
|
S.pinned = true; toBottom(true);
|
|
1299
1449
|
// CDN 字体/代码高亮加载完会改变高度, 再吸一次底。
|
|
@@ -1306,6 +1456,7 @@
|
|
|
1306
1456
|
var stick = S.pinned;
|
|
1307
1457
|
var cur = rowNode(m.id);
|
|
1308
1458
|
S.frags[m.id] = m;
|
|
1459
|
+
see([m]);
|
|
1309
1460
|
if (cur && cur.getAttribute('data-sig') === m.sig) return;
|
|
1310
1461
|
// 折行会把 .mrow 收进 details, 而下面的按时间插位假定它们都是 inner 的直接子节点。
|
|
1311
1462
|
unfoldPings(inner);
|
|
@@ -1357,7 +1508,8 @@
|
|
|
1357
1508
|
// 收下摘要 (视角、会话列表、落地窗口) 与按它画页头侧栏分开: 换视角整窗重拉时, 画要等到新行到手的同一帧。
|
|
1358
1509
|
var takeRole = function (d) {
|
|
1359
1510
|
R.at = d.at || Date.now(); R.recvAt = Date.now();
|
|
1360
|
-
|
|
1511
|
+
if (!BASE) BASE = R.at;
|
|
1512
|
+
R.role = d.role; R.sessions = d.sessions || []; R.convs = d.convs || []; R.chatKeys = d.chatKeys; R.peerUnread = d.peerUnread;
|
|
1361
1513
|
SESSION = d.session || '';
|
|
1362
1514
|
R.relations = !!d.relations; R.schedules = d.schedules || 0; R.plan = d.plan || null; R.inflight = d.inflight || [];
|
|
1363
1515
|
// 工单的账变了 (或比消息晚到): 已上屏的工单 badge 按新账重填一遍 —— 片段里只写了工单号。
|
|
@@ -1381,13 +1533,13 @@
|
|
|
1381
1533
|
};
|
|
1382
1534
|
|
|
1383
1535
|
// 切回标签页时正在读的那项就算读过了。
|
|
1384
|
-
document.addEventListener('visibilitychange', function () { if (!document.hidden)
|
|
1536
|
+
document.addEventListener('visibilitychange', function () { if (!document.hidden) seeAll(); });
|
|
1385
1537
|
|
|
1386
1538
|
// ── SSE: role 摘要 + 当前窗口的消息增量。断线指数退避重连。 ──
|
|
1387
1539
|
var backoff = 1000;
|
|
1388
1540
|
var connect = function () {
|
|
1389
1541
|
if (S.es) { S.es.close(); S.es = null; }
|
|
1390
|
-
var p = new URLSearchParams(
|
|
1542
|
+
var p = new URLSearchParams(roleParams({ id: TOKEN }));
|
|
1391
1543
|
var es = new EventSource('api/role-events?' + p.toString());
|
|
1392
1544
|
S.es = es;
|
|
1393
1545
|
es.addEventListener('role', function (e) { try { applyRole(JSON.parse(e.data)); } catch (err) { } });
|
|
@@ -1406,17 +1558,32 @@
|
|
|
1406
1558
|
syncUrl();
|
|
1407
1559
|
// 要做 FLIP 就把旧行留到新行到来 —— 中间闪一下「加载中」就量不到旧位置了。
|
|
1408
1560
|
if (!land) inner.innerHTML = '<div class="empty">加载中…</div>';
|
|
1409
|
-
|
|
1561
|
+
// 关系图上换视角: 自己那张卡片的窗口要等新树画出来才知道 (W.pickSelf), 正文等它定了只取一次;
|
|
1562
|
+
// 否则窗口已定, 正文与摘要并行取 —— 摘要改了窗口 (落点 / session) 就作废重取。
|
|
1563
|
+
var hold = WORLD && W.pickSelf === ROLE;
|
|
1564
|
+
// 窗口的指纹: 不带 session 与 session=all 取的是同一份 (摘要会把空的回填成 all)。
|
|
1565
|
+
var winSig = function () { var p = viewParams(); if (p.session === 'all') delete p.session; return new URLSearchParams(p).toString(); };
|
|
1566
|
+
var want = hold || !CONV ? '' : winSig();
|
|
1567
|
+
var early = want ? api('api/msgs', viewParams()) : null;
|
|
1568
|
+
W.pickHold = hold;
|
|
1569
|
+
return api('api/role', roleParams()).then(function (d) {
|
|
1410
1570
|
if (!d.ok) {
|
|
1411
1571
|
document.body.innerHTML = '<div class="empty" style="padding:80px">' + esc(d.error || 'not found') + '</div>';
|
|
1412
1572
|
return;
|
|
1413
1573
|
}
|
|
1414
|
-
// 带 land 的 (换视角)
|
|
1415
|
-
|
|
1574
|
+
// 带 land 的 (换视角) 只先画侧栏 (名片 + 会话列表, 不碰详情区的几何): 页头 / 页脚换了高度,
|
|
1575
|
+
// 旧行会在新行到来前先被顶一下, FLIP 的起点就错了 —— 它们等新行到手的同一帧画。
|
|
1576
|
+
if (land) { takeRole(d); reveal(); renderRole(); if (!WORLD) renderConvs(); } else applyRole(d);
|
|
1577
|
+
if (restoreSess()) return refresh(land);
|
|
1416
1578
|
syncUrl();
|
|
1417
|
-
|
|
1579
|
+
var world = WORLD || VIEW === 'plan' ? loadWorld() : null;
|
|
1418
1580
|
if (VIEW === 'charter') loadCharter();
|
|
1419
|
-
return
|
|
1581
|
+
return Promise.resolve(hold && world).then(function () {
|
|
1582
|
+
W.pickHold = false;
|
|
1583
|
+
syncUrl();
|
|
1584
|
+
var fresh = early && want === winSig() ? early : null;
|
|
1585
|
+
return loadMsgs(undefined, land && function () { paintRole(); return land(); }, fresh);
|
|
1586
|
+
}).then(function () {
|
|
1420
1587
|
if (land && !inner.querySelector('.mrow')) paintRole(); // 空窗口不走 land, 页头照样要画
|
|
1421
1588
|
connect();
|
|
1422
1589
|
});
|
|
@@ -1424,8 +1591,8 @@
|
|
|
1424
1591
|
};
|
|
1425
1592
|
|
|
1426
1593
|
// land: 见 loadMsgs —— 搜索跳到一条具体消息时由它来定位, 不吸底。
|
|
1427
|
-
var selectConv = function (key, withRole, land,
|
|
1428
|
-
CONV = key; WITH = withRole || '';
|
|
1594
|
+
var selectConv = function (key, withRole, land, viewpoint) {
|
|
1595
|
+
CONV = key; WITH = withRole || ''; VIEWPOINT = viewpoint && viewpoint !== ROLE ? viewpoint : '';
|
|
1429
1596
|
reveal();
|
|
1430
1597
|
if (VIEW !== 'msgs') setView('msgs');
|
|
1431
1598
|
app.classList.add('reading');
|
|
@@ -1450,7 +1617,7 @@
|
|
|
1450
1617
|
var withBack = keep && WITH === id ? from : '';
|
|
1451
1618
|
// 整个频道 / 两人私聊 / 群里的一对, 消息集合都与视角无关 —— 集合没变就就地翻面。
|
|
1452
1619
|
var same = VIEW === 'msgs' && (!WITH || withBack) && !SESSION && (keep || CONV.indexOf('p:') === 0);
|
|
1453
|
-
ROLE = id; WITH = withBack; SESSION = '';
|
|
1620
|
+
ROLE = id; WITH = withBack; SESSION = ''; VIEWPOINT = '';
|
|
1454
1621
|
CONV = keep || (CONV.indexOf('p:') === 0 ? 'p:' + from : '');
|
|
1455
1622
|
// 停在关系图时换视角 = 选中新视角自己那张卡片: 先开它的全部对话 (不留旧选中 / 空白),
|
|
1456
1623
|
// 新树画出来知道它连着谁后, renderWorld 再收窄成那张卡片的窗口 (W.pickSelf)。
|
|
@@ -1590,7 +1757,7 @@
|
|
|
1590
1757
|
S.gen++;
|
|
1591
1758
|
syncUrl();
|
|
1592
1759
|
var gen = S.gen;
|
|
1593
|
-
api('api/role',
|
|
1760
|
+
api('api/role', roleParams()).then(function (d) {
|
|
1594
1761
|
if (!d.ok || gen !== S.gen) return;
|
|
1595
1762
|
applyRole(d);
|
|
1596
1763
|
// 断点 (/clear /new) 是旧视角 role 自己的, 新视角的由下面补拉。
|
|
@@ -1625,13 +1792,16 @@
|
|
|
1625
1792
|
// · 只算选中 session 时间范围内发生的 —— 边的每次发生都带着时刻 (WorldEdge.ts)
|
|
1626
1793
|
// 每种关系一种线色 (CSS 里 li.<kind> / .ek.<kind> 同色), 画在树线、卡片入口的 label 与图例上。
|
|
1627
1794
|
var KIND = {
|
|
1628
|
-
|
|
1629
|
-
clone
|
|
1630
|
-
|
|
1795
|
+
// 生它、归它管的那一位下面挂的是 clone (带上下文) 或 spawn (白板) —— 与调用框下的提示行同一套字。
|
|
1796
|
+
// clone 徽标点了落到 origin 里的分叉处。
|
|
1797
|
+
clone: { mark: 'clone', tip: 'clone_wizard 生的分身: fork 了上下文, 开局带着那一刻读过的一切; 归生它的那位管 —— 点了看它在哪一处分叉' },
|
|
1798
|
+
spawn: { mark: 'spawn', tip: 'spawn_wizard / dispatch 白板生的: 不继承上下文, 只是归生它的那位管 (不带 detached)' },
|
|
1799
|
+
fork: { mark: '⑂ 上下文', tip: '分身的上下文 fork 自它 —— 生它、归它管的是另一位' },
|
|
1800
|
+
peer: { mark: '💬', tip: 'send_peer 发起的对话' },
|
|
1631
1801
|
job: { mark: '工单', tip: '它开的工单里有这位成员' },
|
|
1632
1802
|
graph: { mark: '流水线', tip: '流水线里上一步喂给下一步' },
|
|
1633
1803
|
};
|
|
1634
|
-
var LINEAGE = { spawn: 1, clone: 1 };
|
|
1804
|
+
var LINEAGE = { spawn: 1, clone: 1, fork: 1 };
|
|
1635
1805
|
|
|
1636
1806
|
// 老 webview 未必有 CSS.escape, 而 target 里带着 `:` 和 `#` —— 不转义选择器直接抛异常。
|
|
1637
1807
|
var cssEsc = function (v) {
|
|
@@ -1647,7 +1817,8 @@
|
|
|
1647
1817
|
// ── 时间窗: profile 里选中的 session; 全部 = 不设限 ──
|
|
1648
1818
|
var rangeOf = function () {
|
|
1649
1819
|
var s = R.sessions.filter(function (x) { return x.sessionId === SESSION; })[0];
|
|
1650
|
-
|
|
1820
|
+
var cut = winCut();
|
|
1821
|
+
return s ? { from: Math.max(s.start, cut), to: s.end || Infinity, s: s } : cut ? { from: cut, to: Infinity, s: null } : null;
|
|
1651
1822
|
};
|
|
1652
1823
|
var inRange = function (rg, t) { return !rg || (t >= rg.from && t <= rg.to); };
|
|
1653
1824
|
|
|
@@ -1663,12 +1834,19 @@
|
|
|
1663
1834
|
|
|
1664
1835
|
/** 窗内的关系: 一对有向 (a→b) 一条, kinds = 各种类在窗内发生几次。 */
|
|
1665
1836
|
var relations = function (rg) {
|
|
1666
|
-
var
|
|
1667
|
-
return { kind: e.kind, from: e.from, to: e.to, cross: e.cross, jobs: e.jobs || [], ts: e.ts && e.ts.length ? e.ts : [e.lastTs] };
|
|
1668
|
-
}).concat(jobEdges())
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1837
|
+
var all = W.edges.map(function (e) {
|
|
1838
|
+
return { kind: e.kind, from: e.from, to: e.to, cross: e.cross, jobs: e.jobs || [], ts: e.ts && e.ts.length ? e.ts : [e.lastTs], point: e.point };
|
|
1839
|
+
}).concat(jobEdges());
|
|
1840
|
+
// 时间范围里有过往来 (任一种非家谱边, 任一端) 的 role。
|
|
1841
|
+
var active = all.reduce(function (m, e) {
|
|
1842
|
+
if (!LINEAGE[e.kind] && e.ts.some(function (t) { return inRange(rg, t); })) { m[e.from] = 1; m[e.to] = 1; }
|
|
1843
|
+
return m;
|
|
1844
|
+
}, {});
|
|
1845
|
+
var pairs = all.reduce(function (m, e) {
|
|
1846
|
+
// 家谱是身份, 不是发生在某段 session 里的事 —— 不受 session 裁剪, 否则切到子 wizard 时
|
|
1847
|
+
// 它的出生早于自己的 session, 父亲那条边就被裁掉了。时间范围 (WIN) 照裁: 子 wizard 在范围里没有
|
|
1848
|
+
// 往来, 它那条家谱边也不画 —— 与侧栏同一刀 (inWin), 闲了一小时的分身不该因为出身留在「半小时」里。
|
|
1849
|
+
var ts = LINEAGE[e.kind] ? (!WIN || active[e.to] ? e.ts : []) : e.ts.filter(function (t) { return inRange(rg, t); });
|
|
1672
1850
|
if (!ts.length) return m;
|
|
1673
1851
|
var k = e.from + '\u0000' + e.to;
|
|
1674
1852
|
var p = m[k] || (m[k] = { from: e.from, to: e.to, kinds: {}, n: 0, first: Infinity, last: 0, cross: e.cross, jobs: [] });
|
|
@@ -1677,6 +1855,7 @@
|
|
|
1677
1855
|
p.first = Math.min(p.first, Math.min.apply(null, ts));
|
|
1678
1856
|
p.last = Math.max(p.last, Math.max.apply(null, ts));
|
|
1679
1857
|
e.jobs.forEach(function (id) { if (p.jobs.indexOf(id) < 0) p.jobs.push(id); });
|
|
1858
|
+
if (e.point) p.point = e.point;
|
|
1680
1859
|
return m;
|
|
1681
1860
|
}, {});
|
|
1682
1861
|
// 派活本身带着工单号时, 「工单」标记就是重复的。
|
|
@@ -1688,7 +1867,8 @@
|
|
|
1688
1867
|
var forestOf = function (pairs) {
|
|
1689
1868
|
var list = Object.keys(pairs).map(function (k) { return pairs[k]; });
|
|
1690
1869
|
var inc = list.reduce(function (m, p) { (m[p.to] = m[p.to] || []).push(p); return m; }, {});
|
|
1691
|
-
|
|
1870
|
+
// 主父亲: 生它的那位 (spawn / clone) 先于上下文来源 (fork), 再先于派活。
|
|
1871
|
+
var weight = function (p) { return (p.kinds.spawn || p.kinds.clone ? 3e6 : p.kinds.fork ? 2e6 : 0) + p.n; };
|
|
1692
1872
|
var pp = Object.keys(inc).reduce(function (m, t) {
|
|
1693
1873
|
m[t] = inc[t].slice().sort(function (a, b) { return weight(b) - weight(a) || a.first - b.first; })[0];
|
|
1694
1874
|
return m;
|
|
@@ -1724,7 +1904,11 @@
|
|
|
1724
1904
|
close();
|
|
1725
1905
|
// 兄弟的顺序在画的时候定 (按卡片的时刻, 同会话列表), 这里不排。
|
|
1726
1906
|
var kids = kidsIn();
|
|
1727
|
-
|
|
1907
|
+
// 人直接对 wizard 说话的那条边不是它的主父亲 (主父亲多是生它的那位) 时, 在人下面再挂一张它的引用卡:
|
|
1908
|
+
// 同一个节点、同一个窗口, 只是承载的是人 → 它这条边; 引用卡不再往下展开 (后代在它的本位上画)。
|
|
1909
|
+
var refs = list.filter(function (p) { return kindOf(p.from) === 'human' && pp[p.to] !== p; })
|
|
1910
|
+
.reduce(function (m, p) { (m[p.from] = m[p.from] || []).push(p); return m; }, {});
|
|
1911
|
+
return { pairs: pairs, inc: inc, pp: pp, kids: kids, refs: refs, roots: roots, ends: ends };
|
|
1728
1912
|
};
|
|
1729
1913
|
|
|
1730
1914
|
// F.vis: 只看相关时画哪些 (见 visibleOf); 没有 = 全画。
|
|
@@ -1743,19 +1927,19 @@
|
|
|
1743
1927
|
return '<span class="tlab"' + (p.jobs.length ? ' title="经手的工单: ' + esc(p.jobs.join(' ')) + '"' : '') + '>' +
|
|
1744
1928
|
// 工单这一种关系由 📋 那一枚说 (可点, 列的就是这几张), 不再另挂「工单 ×N」重复一遍。
|
|
1745
1929
|
Object.keys(KIND).filter(function (k) { return p.kinds[k] && !(k === 'job' && p.jobs.length); }).map(function (k) {
|
|
1746
|
-
return '<span class="ek ' + k + '" title="' + KIND[k].tip + '">' + KIND[k].mark +
|
|
1747
|
-
(!LINEAGE[k] ? '
|
|
1930
|
+
return '<span class="ek ' + k + '"' + (k === 'clone' && p.point ? ' data-cpoint="' + esc(JSON.stringify(p.point)) + '"' : '') + ' title="' + KIND[k].tip + '">' + KIND[k].mark +
|
|
1931
|
+
(!LINEAGE[k] ? ' ' + p.kinds[k] : '') + '</span>';
|
|
1748
1932
|
}).join('') +
|
|
1749
1933
|
(p.cross ? '<span class="ek cross" title="跨群的关系">⇄ 跨群</span>' : '') +
|
|
1750
|
-
(p.jobs.length ? '<span class="ek jmk" data-jobs="' + esc(p.jobs.join(' ')) + '" title="' + esc('经手的工单: ' + p.jobs.join(' ') + ' —— 点开') + '">📋' + (p.jobs.length > 1 ? '
|
|
1934
|
+
(p.jobs.length ? '<span class="ek jmk" data-jobs="' + esc(p.jobs.join(' ')) + '" title="' + esc('经手的工单: ' + p.jobs.join(' ') + ' —— 点开') + '">📋' + (p.jobs.length > 1 ? ' ' + p.jobs.length : '') + '</span>' : '') + '</span>';
|
|
1751
1935
|
};
|
|
1752
1936
|
// 一条边的主色: 家谱优先, 决定树上那道线的颜色。
|
|
1753
1937
|
var domKind = function (p) {
|
|
1754
|
-
return !p ? 'root' : ['spawn', 'clone', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
|
|
1938
|
+
return !p ? 'root' : ['spawn', 'clone', 'fork', 'peer', 'job', 'graph'].filter(function (k) { return p.kinds[k]; })[0] || 'peer';
|
|
1755
1939
|
};
|
|
1756
1940
|
|
|
1757
1941
|
// ── 卡片承载指进来的那条边 (主父亲 → 它): 预览取视角与对端在侧栏里现成的那一项 ──
|
|
1758
|
-
var edgeEnds = function (F, t) {
|
|
1942
|
+
var edgeEnds = function (F, t, p) { p = p || F.pp[t]; return p ? [p.from, t] : [t]; };
|
|
1759
1943
|
// 视角 role 与 other 之间的那一项: 私聊优先, 否则挑最近说过话的那个群里的「只看我与它」。
|
|
1760
1944
|
var pairConv = function (other) {
|
|
1761
1945
|
if (convOf('p:' + other)) return ['p:' + other, ''];
|
|
@@ -1765,23 +1949,30 @@
|
|
|
1765
1949
|
return hit ? [hit[0].key, dmOf(hit[0]) ? '' : other] : null;
|
|
1766
1950
|
};
|
|
1767
1951
|
// 视角这一端与边另一端之间、侧栏里现成的那一项 (视角不在边上 / 这段里没说过话 = 没有)。
|
|
1768
|
-
var edgeConv = function (F, t) {
|
|
1769
|
-
var ends = edgeEnds(F, t);
|
|
1952
|
+
var edgeConv = function (F, t, e) {
|
|
1953
|
+
var ends = edgeEnds(F, t, e);
|
|
1770
1954
|
var other = ends.length === 2 && ends.indexOf(ROLE) >= 0 && ends.filter(function (x) { return x !== ROLE; })[0];
|
|
1771
1955
|
return other ? pairConv(other) : null;
|
|
1772
1956
|
};
|
|
1773
|
-
// 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role
|
|
1957
|
+
// 点任何一张卡片 (视角自己也一样) 看的窗口: 它与图上和它相连的那几个 role 之间的对话。整页视角不换,
|
|
1958
|
+
// 只有详情区的 viewpoint 站到被点的那个 role 上: 这段往来是它的, 它说的靠右。侧栏子项是页面视角自己与对端的那一对, 不带 viewpoint。
|
|
1774
1959
|
// links = 这一次画出来的树里, 它的父亲与孩子 (见 linksOf); 一个都没连着 = 它的全部对话。
|
|
1775
1960
|
var talkKey = function (links, t) {
|
|
1776
1961
|
var ls = links[t] || [];
|
|
1777
1962
|
return 'a:' + t + (ls.length ? '|' + ls.join(',') : '');
|
|
1778
1963
|
};
|
|
1779
|
-
// 图上相连 =
|
|
1964
|
+
// 图上相连 = 画出来的边: 它的父亲 + 它的孩子 + 引用卡两端 (人 ↔ 它), 两端都画出来了才算。
|
|
1780
1965
|
var linksOf = function (F, shown) {
|
|
1781
1966
|
var on = shown.reduce(function (m, n) { m[n.target] = 1; return m; }, {});
|
|
1967
|
+
var refIn = Object.keys(F.refs).reduce(function (m, h) {
|
|
1968
|
+
F.refs[h].forEach(function (p) { (m[p.to] = m[p.to] || []).push(h); });
|
|
1969
|
+
return m;
|
|
1970
|
+
}, {});
|
|
1971
|
+
var refOut = function (t) { return (F.refs[t] || []).map(function (p) { return p.to; }); };
|
|
1782
1972
|
return shown.reduce(function (m, n) {
|
|
1783
1973
|
var t = n.target, p = F.pp[t];
|
|
1784
|
-
m[t] = (p && on[p.from] ? [p.from] : []).concat(
|
|
1974
|
+
m[t] = (p && on[p.from] ? [p.from] : []).concat(F.kids[t] || [], refIn[t] || [], refOut(t))
|
|
1975
|
+
.filter(function (k, i, a) { return on[k] && a.indexOf(k) === i; });
|
|
1785
1976
|
return m;
|
|
1786
1977
|
}, {});
|
|
1787
1978
|
};
|
|
@@ -1791,15 +1982,18 @@
|
|
|
1791
1982
|
// 而不是节点自己最后的动静 —— 卡片承载的是边。
|
|
1792
1983
|
// 身份 (头像 / 名字 / 状态灯) 永远是卡片自己的节点: 会话项的身份是「视角的对端」, 视角自己那张卡片
|
|
1793
1984
|
// (边的下端是视角) 的对端是它的父亲 —— 拿会话项的身份去画, 这张卡片就成了父亲的分身。
|
|
1794
|
-
//
|
|
1795
|
-
//
|
|
1796
|
-
var cardGlance = function (F, t) {
|
|
1797
|
-
var hit = edgeConv(F, t), c = hit && convOf(hit[0]);
|
|
1985
|
+
// 时刻、最近一句与未读取自卡片自己的窗口 (点它打开的那一份, 服务端 /api/glance 用同一个 talkOf 算, 说话人前缀
|
|
1986
|
+
// 与未读账也在那里定); 窗口还没取到 (或第一遍量树时) 退回视角与对端的会话项 / 节点。
|
|
1987
|
+
var cardGlance = function (F, t, e) {
|
|
1988
|
+
var hit = edgeConv(F, t, e), c = hit && convOf(hit[0]);
|
|
1798
1989
|
var s = c && hit[1] && (c.subs || []).filter(function (x) { return x.role === hit[1]; })[0];
|
|
1799
|
-
var n = nodeOf(t) || {}, p = F.pp[t];
|
|
1800
|
-
var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0 };
|
|
1990
|
+
var n = nodeOf(t) || {}, p = e || F.pp[t];
|
|
1991
|
+
var base = c ? glance(c, s || undefined) : { lastTs: p ? p.last : n.lastTs || 0, preview: n.preview || '', unread: 0, theirs: theirsOf(t) };
|
|
1801
1992
|
var w = F.links && W.glance[talkKey(F.links, t)];
|
|
1802
|
-
|
|
1993
|
+
if (!w) return base;
|
|
1994
|
+
// 未读也取自卡片自己的窗口 (与侧栏同一个 unreadIn): 点开它看得到的才数; 名字旁的是卡片主人整个视角的 (theirsOf)。
|
|
1995
|
+
var mine = unreadIn(w.mine);
|
|
1996
|
+
return { lastTs: w.lastTs, preview: w.preview, unread: mine.length, theirs: theirsOf(t, mine) };
|
|
1803
1997
|
};
|
|
1804
1998
|
// 卡片窗口的 glance: 树里画了谁变了就取一次, 世界快照每次轮询也顺带刷新。
|
|
1805
1999
|
var loadGlance = function () {
|
|
@@ -1827,14 +2021,15 @@
|
|
|
1827
2021
|
var n = Object.keys(all).filter(function (o) { return o !== t && !on[o]; }).length;
|
|
1828
2022
|
return moreTag(n, nameOf(t) + ' 还和 ' + n + ' 个 role 有关系, 不在这张图上, 切到它的视角可见');
|
|
1829
2023
|
};
|
|
1830
|
-
|
|
2024
|
+
// e = 引用卡承载的那条边 (人 → 它); 没有 = 本位卡片, 承载主父亲边。
|
|
2025
|
+
var tnodeHTML = function (F, n, e) {
|
|
1831
2026
|
var me = n.target === ROLE;
|
|
1832
|
-
var p = F.pp[n.target];
|
|
1833
|
-
var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target), n, '', otherRels(F, n.target));
|
|
2027
|
+
var p = e || F.pp[n.target];
|
|
2028
|
+
var row = roleRow(n.target, n.name, n.label, cardGlance(F, n.target, e), n, '', otherRels(F, n.target));
|
|
1834
2029
|
var via = F.vis && F.vis[n.target] === 'via';
|
|
1835
2030
|
var jin = F.job && (F.job.in[n.target] ? ' jin' : ' jout');
|
|
1836
|
-
return '<button class="ci tci' + (me ? ' me' : '') + (via ? ' via' : '') + (jin || '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
|
|
1837
|
-
(via ? ' title="' + esc(nameOf(n.target) + ' 和 ' + nameOf(ROLE) + ' 没有直接关系, 留着是为了连到它下面有关系的') + '"' : '') + '>' +
|
|
2031
|
+
return '<button class="ci tci' + (me ? ' me' : '') + (e ? ' ref' : '') + (via ? ' via' : '') + (n.ghost ? ' ghost' : '') + (jin || '') + (F.links && CONV === talkKey(F.links, n.target) ? ' on' : '') + '" data-t="' + esc(n.target) + '"' +
|
|
2032
|
+
(n.ghost ? ' title="' + esc(nameOf(n.target) + ' 已停且没有记录, 这里只是占位, 代表它挂着下面的后代') + '"' : via ? ' title="' + esc(nameOf(n.target) + ' 和 ' + nameOf(ROLE) + ' 没有直接关系, 留着是为了连到它下面有关系的') + '"' : '') + '>' +
|
|
1838
2033
|
labelHTML(p) + row + '</button>';
|
|
1839
2034
|
};
|
|
1840
2035
|
|
|
@@ -1844,13 +2039,19 @@
|
|
|
1844
2039
|
if (!n || seen[t] || depth > 32) return '';
|
|
1845
2040
|
seen[t] = 1;
|
|
1846
2041
|
shown.push(n);
|
|
1847
|
-
|
|
2042
|
+
// 孩子 = 本位的主孩子 + 引用卡 (它是人时, 它直接说过话、但主父亲另有其人的 wizard), 一起按卡片时刻排。
|
|
2043
|
+
var kids = (F.kids[t] || []).filter(function (k) { return shows(F, k); }).map(function (k) { return { t: k }; })
|
|
2044
|
+
.concat((F.refs[t] || []).filter(function (p) { return shows(F, p.to) && nodeOf(p.to); }).map(function (p) { return { t: p.to, e: p }; }))
|
|
2045
|
+
.sort(function (a, b) { return recentFirst(cardGlance(F, a.t, a.e), cardGlance(F, b.t, b.e)); });
|
|
1848
2046
|
// 选中工单时: 两端都是它的当事人 (含开单者) 的那道线才亮。
|
|
1849
|
-
var jl = F.job ? (F.job.in[
|
|
1850
|
-
|
|
2047
|
+
var jl = function (u, p) { return F.job ? (F.job.in[u] && p && F.job.in[p.from] ? ' jline' : ' jdim') : ''; };
|
|
2048
|
+
var zs = function (z) { return z ? ' style="z-index:' + z + '"' : ''; };
|
|
2049
|
+
return '<li class="' + domKind(F.pp[t]) + jl(t, F.pp[t]) + '"' + zs(z) + '>' + tnodeHTML(F, n) +
|
|
1851
2050
|
// 兄弟的线共用一段竖干, 越往下的越长: 短的叠在上面 (z 随序号递减), 每条线的末段都看得见自己的颜色。
|
|
1852
2051
|
(kids.length ? '<ul>' + kids.map(function (k, i) {
|
|
1853
|
-
return
|
|
2052
|
+
return k.e
|
|
2053
|
+
? '<li class="' + domKind(k.e) + jl(k.t, k.e) + '"' + zs(kids.length - i) + '>' + tnodeHTML(F, nodeOf(k.t), k.e) + '</li>'
|
|
2054
|
+
: treeHTML(F, k.t, depth + 1, shown, seen, kids.length - i);
|
|
1854
2055
|
}).join('') + '</ul>' : '') +
|
|
1855
2056
|
'</li>';
|
|
1856
2057
|
};
|
|
@@ -1858,7 +2059,23 @@
|
|
|
1858
2059
|
// 只看相关时画谁: 视角自己 + 和视角有直接关系的 (任一种, 任一方向; 'rel'); 它们通向根的主父亲链上
|
|
1859
2060
|
// 与视角无关的祖先留作连接 ('via', 画淡) —— 抹掉它, 有关系的后代就从家谱上脱开, 成了一棵来历不明的孤树。
|
|
1860
2061
|
var visibleOf = function (F) {
|
|
1861
|
-
|
|
2062
|
+
// 相关 = 沿关系边 (任一种, 任一方向) 一路走得到的整个连通块, 不止一跳: 间接、多跳的也展开。
|
|
2063
|
+
var nbr = Object.keys(F.pairs).reduce(function (m, k) {
|
|
2064
|
+
var p = F.pairs[k];
|
|
2065
|
+
(m[p.from] = m[p.from] || []).push(p.to);
|
|
2066
|
+
(m[p.to] = m[p.to] || []).push(p.from);
|
|
2067
|
+
return m;
|
|
2068
|
+
}, {});
|
|
2069
|
+
var reach = function (seen, frontier) {
|
|
2070
|
+
if (!frontier.length) return seen;
|
|
2071
|
+
var next = frontier.reduce(function (acc, t) {
|
|
2072
|
+
(nbr[t] || []).forEach(function (o) { if (!seen[o]) { seen[o] = 1; acc.push(o); } });
|
|
2073
|
+
return acc;
|
|
2074
|
+
}, []);
|
|
2075
|
+
return reach(seen, next);
|
|
2076
|
+
};
|
|
2077
|
+
var linked = reach((function (o) { o[ROLE] = 1; return o; })({}), [ROLE]);
|
|
2078
|
+
var keep = Object.keys(F.ends).filter(function (t) { return t === ROLE || linked[t]; });
|
|
1862
2079
|
var vis = keep.reduce(function (m, t) { m[t] = 'rel'; return m; }, {});
|
|
1863
2080
|
if (!vis[ROLE]) vis[ROLE] = 'rel';
|
|
1864
2081
|
keep.forEach(function (t) { chainUp(F, t).forEach(function (u) { if (!vis[u]) vis[u] = 'via'; }); });
|
|
@@ -1879,10 +2096,10 @@
|
|
|
1879
2096
|
var F = forestOf(relations(rg));
|
|
1880
2097
|
F.job = jobOfConv();
|
|
1881
2098
|
var me = nodeOf(ROLE);
|
|
1882
|
-
|
|
2099
|
+
// 只画相关的 (visibleOf); 视角不是图上的节点 (没有关系可言) 才全画。
|
|
2100
|
+
var all = !me;
|
|
1883
2101
|
var shown = [];
|
|
1884
2102
|
var path = me ? chainUp(F, ROLE) : [];
|
|
1885
|
-
// 和视角没有直接关系的只在「看全部」里画。
|
|
1886
2103
|
F.vis = all ? null : visibleOf(F);
|
|
1887
2104
|
var draw = function (into) {
|
|
1888
2105
|
return F.roots.filter(function (r) { return shows(F, r); }).sort(function (a, b) {
|
|
@@ -1900,16 +2117,17 @@
|
|
|
1900
2117
|
if (W.pickSelf === ROLE && W.role === ROLE && R.role && R.role.id === ROLE) {
|
|
1901
2118
|
W.pickSelf = '';
|
|
1902
2119
|
var self = talkKey(F.links, ROLE);
|
|
1903
|
-
|
|
2120
|
+
// refresh 正等着它定窗口 (W.pickHold): 只改窗口, 正文由 refresh 取一次 —— 不再先取全部对话、再收窄重取。
|
|
2121
|
+
if (CONV !== self && W.pickHold) { CONV = self; WITH = ''; }
|
|
2122
|
+
else if (CONV !== self) return selectConv(self, '');
|
|
1904
2123
|
}
|
|
1905
2124
|
var trees = draw([]);
|
|
1906
|
-
var span = rg ? (rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '
|
|
2125
|
+
var span = rg ? (!rg.s ? '最近' + (WINS.filter(function (w) { return w[0] === WIN; })[0] || [0, ''])[1] : rg.s === R.sessions[R.sessions.length - 1] ? '最新 session' : 'session ' + fmtClock(rg.from)) : '';
|
|
1907
2126
|
var alone = !all && shown.length < 2;
|
|
1908
2127
|
var html = '<div class="tview">' +
|
|
1909
|
-
'<h2>关系<span title="在名片里的 session 下拉切换范围">' + esc(span) + ' · ' + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
|
|
2128
|
+
'<h2>关系<span title="在名片里的 session 下拉切换范围">' + (span ? esc(span) + ' · ' : '') + (all ? Object.keys(F.ends).length : shown.length) + ' 个</span>' +
|
|
1910
2129
|
(W.degraded ? '<span class="warn" title="没拿到 wizard 注册表 (svr 还没收到 daemon 的快照), 只画观测到的往来">名册缺席</span>' : '') +
|
|
1911
|
-
(
|
|
1912
|
-
(all ? '画出范围内所有有关系的 wizard (点击改为只画相关的)' : '只画它自己、和它有直接关系的, 以及连到它们的上游链 (点击改为画全部)') + '">' + (all ? '全部' : '相关') + '</button>' : '') + worldToggle() + '</h2>' +
|
|
2130
|
+
winSelect() + worldToggle() + '</h2>' +
|
|
1913
2131
|
(alone ? '<div class="tsolo">' + esc(nameOf(ROLE)) + (rg ? ' 在这段 session 里' : '') + ' 没和谁有关系</div>' : '') +
|
|
1914
2132
|
(shown.length ? '<ul class="tree' + (all ? ' all' : '') + (F.job ? ' jsel' : '') + '">' + trees + '</ul>' : '<div class="pempty">这段时间里没有任何关系</div>') +
|
|
1915
2133
|
'</div>';
|
|
@@ -1918,18 +2136,11 @@
|
|
|
1918
2136
|
convsEl._tree = html; convsEl._html = '';
|
|
1919
2137
|
convsEl.innerHTML = html;
|
|
1920
2138
|
convsEl.querySelectorAll('.tci').forEach(function (el) {
|
|
1921
|
-
el.onclick = function () {
|
|
2139
|
+
el.onclick = function () { var t = el.getAttribute('data-t'); clickItem(talkKey(F.links, t), '', t); };
|
|
1922
2140
|
});
|
|
1923
2141
|
bindGo(convsEl);
|
|
1924
|
-
convsEl
|
|
1925
|
-
|
|
1926
|
-
var on = b.getAttribute('data-tall') === 'all';
|
|
1927
|
-
if (on === !!W.treeAll) return;
|
|
1928
|
-
W.treeAll = on; W.treeFor = ''; renderWorld();
|
|
1929
|
-
};
|
|
1930
|
-
});
|
|
1931
|
-
bindWorldToggle(convsEl);
|
|
1932
|
-
var cur = convsEl.querySelector('.tci.me');
|
|
2142
|
+
bindWorldToggle(convsEl); bindWin(convsEl);
|
|
2143
|
+
var cur = convsEl.querySelector('.tci.me:not(.ref)');
|
|
1933
2144
|
if (cur && W.treeFor !== ROLE) { W.treeFor = ROLE; cur.scrollIntoView({ block: 'nearest' }); }
|
|
1934
2145
|
};
|
|
1935
2146
|
|
|
@@ -2115,7 +2326,7 @@
|
|
|
2115
2326
|
// 手机上关系/日程占满主区 —— 进入即阅读态。
|
|
2116
2327
|
if (v !== 'msgs') app.classList.add('reading');
|
|
2117
2328
|
renderRole(); renderHead(); renderUsage();
|
|
2118
|
-
if (v === 'msgs') { toBottom(true); renderConvs(); }
|
|
2329
|
+
if (v === 'msgs') { toBottom(true); renderConvs(); seeAll(); }
|
|
2119
2330
|
else if (v === 'charter') loadCharter();
|
|
2120
2331
|
else if (v === 'jobs') renderJobList();
|
|
2121
2332
|
else {
|
|
@@ -2149,10 +2360,14 @@
|
|
|
2149
2360
|
var renderJobList = function () {
|
|
2150
2361
|
var el = jobsPane().firstChild;
|
|
2151
2362
|
var js = jobListRows();
|
|
2363
|
+
// 视角里没有的祖先也照账补上, 树才连得起来 (JOB_ONLY 是点名的几张, 不补)。
|
|
2364
|
+
if (!JOB_ONLY) js = js.concat(js.reduce(function (a, c) {
|
|
2365
|
+
return a.concat(jobAncestors(c.job.id).filter(function (id) { return !js.some(function (x) { return x.job.id === id; }); }));
|
|
2366
|
+
}, []).filter(function (id, i, a) { return a.indexOf(id) === i; }).map(jobRowOf).filter(function (c) { return c.job; }));
|
|
2152
2367
|
var open = js.filter(function (c) { return c.job.status === 'open'; }).sort(recentFirst);
|
|
2153
2368
|
var closed = js.filter(function (c) { return c.job.status !== 'open'; }).sort(recentFirst);
|
|
2154
2369
|
var sec = function (title, list, cls) {
|
|
2155
|
-
return '<h2>' + title + '<span>' + list.length + '</span></h2>' + '<div class="' + cls + '">' + list.map(convItem).join('') + '</div>';
|
|
2370
|
+
return '<h2>' + title + '<span>' + list.length + '</span></h2>' + '<div class="' + cls + '">' + jobTreeOrder(list).map(function (x) { return x.d ? '<div class="jind" style="margin-left:' + (x.d * 16) + 'px">' + convItem(x.c) + '</div>' : convItem(x.c); }).join('') + '</div>';
|
|
2156
2371
|
};
|
|
2157
2372
|
var html = js.length
|
|
2158
2373
|
? sec('进行中', open, 'jopen') + (closed.length ? sec('已收工', closed, 'jclosed') : '')
|
|
@@ -2212,9 +2427,10 @@
|
|
|
2212
2427
|
all().forEach(function (x) { x.ontoggle = sync; });
|
|
2213
2428
|
};
|
|
2214
2429
|
|
|
2215
|
-
// 侧栏换成关系图 /
|
|
2430
|
+
// 侧栏换成关系图 / 换回聊天。换回时选中的仍是在关系图里点开的那一项, 并把它滚进视野。
|
|
2216
2431
|
var setWorld = function (on) {
|
|
2217
2432
|
WORLD = on; W.treeFor = ''; W.pickSelf = '';
|
|
2433
|
+
setPref(WORLD_KEY, on);
|
|
2218
2434
|
convsEl._html = convsEl._tree = '';
|
|
2219
2435
|
syncUrl();
|
|
2220
2436
|
// 手机上侧栏与主区二选一 —— 开关在侧栏里, 结果也在侧栏里。
|
|
@@ -2225,9 +2441,9 @@
|
|
|
2225
2441
|
// 选中的子项得露出来: 群展开, 排在折叠条后面的连折叠条一起展开。
|
|
2226
2442
|
var c = convOf(CONV);
|
|
2227
2443
|
if (c) {
|
|
2228
|
-
OPEN[CONV] = true; OPEN_AT = CONV;
|
|
2444
|
+
OPEN[CONV] = true; OPEN_AT = CONV; saveOpen();
|
|
2229
2445
|
var rank = c.subs.filter(function (x) { return x.count; }).map(function (x) { return x.role; }).indexOf(WITH);
|
|
2230
|
-
if (rank >= SUB_FOLD)
|
|
2446
|
+
if (rank >= SUB_FOLD) setMore(CONV, true);
|
|
2231
2447
|
}
|
|
2232
2448
|
renderConvs();
|
|
2233
2449
|
var sel = convsEl.querySelector('.si.on') || convsEl.querySelector('.ci.on');
|
|
@@ -2335,20 +2551,32 @@
|
|
|
2335
2551
|
row.classList.remove('hit'); void row.offsetWidth; row.classList.add('hit');
|
|
2336
2552
|
return true;
|
|
2337
2553
|
};
|
|
2338
|
-
|
|
2554
|
+
// 落到一行里的某一次工具调用 (`use`): 它所在的过程框摊开, 调用框滚到中间闪一下。
|
|
2555
|
+
var focusUse = function (id, use) {
|
|
2556
|
+
if (!focusRow(id)) return false;
|
|
2557
|
+
var b = use && rowNode(id).querySelector('[data-use="' + cssEsc(use) + '"]');
|
|
2558
|
+
if (!b) return true;
|
|
2559
|
+
var g = b.closest('.steps');
|
|
2560
|
+
if (g) { g.classList.remove('folded'); FOLD[g.getAttribute('data-key')] = false; }
|
|
2561
|
+
b.scrollIntoView({ block: 'center' });
|
|
2562
|
+
S.pinned = atBottom();
|
|
2563
|
+
b.classList.remove('hit'); void b.offsetWidth; b.classList.add('hit');
|
|
2564
|
+
return true;
|
|
2565
|
+
};
|
|
2566
|
+
var landOn = function (id, use) {
|
|
2339
2567
|
return function () {
|
|
2340
|
-
if (
|
|
2341
|
-
loadMsgs('0', function () { return
|
|
2568
|
+
if (focusUse(id, use)) return true;
|
|
2569
|
+
loadMsgs('0', function () { return focusUse(id, use); });
|
|
2342
2570
|
return true;
|
|
2343
2571
|
};
|
|
2344
2572
|
};
|
|
2345
2573
|
var jumpMsg = function (h) {
|
|
2346
|
-
if (inSession(h.ts) && convOf(h.conv)) return selectConv(h.conv, h.with || '', landOn(h.id), h.
|
|
2347
|
-
SESSION = 'all'; CONV = h.conv; WITH = h.with || '';
|
|
2574
|
+
if ((!h.id || inSession(h.ts)) && convOf(h.conv)) return selectConv(h.conv, h.with || '', h.id ? landOn(h.id, h.use) : undefined, h.viewpoint);
|
|
2575
|
+
SESSION = 'all'; CONV = h.conv; WITH = h.with || ''; VIEWPOINT = h.viewpoint && h.viewpoint !== ROLE ? h.viewpoint : '';
|
|
2348
2576
|
if (VIEW !== 'msgs') setView('msgs');
|
|
2349
2577
|
app.classList.add('reading');
|
|
2350
2578
|
reveal();
|
|
2351
|
-
refresh(landOn(h.id));
|
|
2579
|
+
refresh(h.id ? landOn(h.id, h.use) : undefined);
|
|
2352
2580
|
};
|
|
2353
2581
|
|
|
2354
2582
|
var paintSearch = function (sections) {
|
|
@@ -2448,5 +2676,7 @@
|
|
|
2448
2676
|
// ── boot ──
|
|
2449
2677
|
// 带着 role / conv 来的链接直接进阅读态 (手机上不先落在会话列表)。
|
|
2450
2678
|
if ((ROLE || CONV) && !WORLD) app.classList.add('reading');
|
|
2679
|
+
// 链接已带着 role 与会话: 记下的 session 先放进第一次请求, 省一次重取 (不在列表里由 restoreSess 收拾)。
|
|
2680
|
+
if (SESS_RESTORE && ROLE && CONV && savedSess()) SESSION = savedSess();
|
|
2451
2681
|
refresh();
|
|
2452
2682
|
})();
|