Skip to content

Commit 8e82a20

Browse files
authored
Merge pull request #8 from gangbaRuby/fix/pa-quest-answer-cleanup
fix(pa-quest-answers): 修复聊天记录清除后答案框残留错位
2 parents e1b1307 + 8c41103 commit 8e82a20

2 files changed

Lines changed: 159 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
### 已变更
88

99
- 聊天输入框自动扩大支持自定义高度,桌面端与移动端可分别设置。
10+
- 修复 PA 任务答案框在聊天记录清除后残留、新消息到达时出现在错误位置的问题。
1011

1112

1213
## [1.32.39] - 2026-07-28

src/features/paQuestAnswers.js

Lines changed: 158 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
let dataLoadAttempted = false;
99
let initAttempted = false;
1010
let observer = null;
11+
let cleanupTimer = null;
1112

1213
// 检查功能开关
1314
function isEnabled() {
@@ -101,7 +102,7 @@
101102
const score = calcMatchRate(text, v.text);
102103
if (score > bestScore) {
103104
bestScore = score;
104-
best = { quest: q, lang: v.lang };
105+
best = { quest: q, lang: v.lang, qText: v.text };
105106
}
106107
}
107108
}
@@ -128,44 +129,88 @@
128129
return parts.join('').trim();
129130
}
130131

132+
// 提取 stopEl 之前的所有纯文本(用于定位 pa-reply 链接前的问题文字)
133+
function getTextBefore(element, stopEl) {
134+
const parts = [];
135+
function walk(node) {
136+
if (node === stopEl) return true;
137+
if (node.nodeType === Node.TEXT_NODE) {
138+
const t = (node.textContent || '').trim();
139+
if (t) parts.push(t);
140+
} else if (node.nodeType === Node.ELEMENT_NODE) {
141+
const tag = node.tagName;
142+
if (tag === 'A' || tag === 'SCRIPT' || tag === 'STYLE') return false;
143+
for (let child = node.firstChild; child; child = child.nextSibling) {
144+
if (walk(child)) return true;
145+
}
146+
}
147+
return false;
148+
}
149+
walk(element);
150+
return parts.join('').trim();
151+
}
152+
131153
// 从消息元素获取用于匹配的文本段列表
132-
// 返回数组,每个元素独立匹配,避免拼接答案选项导致双向匹配失败
154+
// 返回 { text, el } 列表:text 独立匹配,el 为该文本的来源元素
133155
function getMessageTexts(element) {
134156
// PA消息(带 pa-reply 链接):提取第一个 pa-reply 之前的文本(即问题文本)
135157
var paReply = element.querySelector('a.pa-reply');
136158
if (paReply) {
137-
var parts = [];
138-
var children = element.children;
139-
for (var i = 0; i < children.length; i++) {
140-
// 遇到包含 pa-reply 的子元素就停止
141-
if (children[i].querySelector('a.pa-reply')) break;
142-
var t = extractText(children[i]);
143-
if (t) parts.push(t);
144-
}
145-
if (parts.length > 0) return [parts.join(' ').trim()];
159+
var text = getTextBefore(element, paReply);
160+
if (text) return [{ text: text, el: element }];
146161
return [];
147162
}
148163

149164
// 聊天消息/其他:优先按子元素分割独立匹配
150-
var texts = [];
165+
var results = [];
151166
var children = element.children;
152167
if (children.length > 1) {
153168
for (var i = 0; i < children.length; i++) {
154169
var childText = extractText(children[i]);
155170
if (childText && childText.length > 3) {
156-
texts.push(childText);
171+
results.push({ text: childText, el: children[i] });
157172
}
158173
}
159174
// 同时加入全部合并文本(处理问题被拆分到多个span的情况)
160175
var fullText = extractText(element);
161-
if (fullText) texts.push(fullText);
176+
if (fullText) results.push({ text: fullText, el: element });
162177
}
163178
// 如果子元素分割没有结果,退回到整体提取
164-
if (texts.length === 0) {
179+
if (results.length === 0) {
165180
var fullText = extractText(element);
166-
if (fullText) texts.push(fullText);
181+
if (fullText) results.push({ text: fullText, el: element });
167182
}
168-
return texts;
183+
return results;
184+
}
185+
186+
// 定位 PA 问题消息元素:从链接向上找最近的、包含该链接且链接前有足够文本的 DIV,
187+
// 避免把答案框追加到聊天窗口/页面级包装元素上
188+
function findPaMessageElement(link) {
189+
var el = link.parentElement;
190+
while (el && el !== document.body) {
191+
if (el.tagName === 'DIV') {
192+
var text = getTextBefore(el, link);
193+
if (text && text.length >= 3 && !isChatContainer(el)
194+
&& !el.querySelector('.input-group')) {
195+
return el;
196+
}
197+
}
198+
el = el.parentElement;
199+
}
200+
return null;
201+
}
202+
203+
// 防御:答案框只允许插入消息级元素,拒绝聊天容器/聊天窗口/页面级包装元素
204+
function isSafeAnswerParent(el) {
205+
if (!el || el === document.body) return false;
206+
if (isChatContainer(el)) return false;
207+
if (el.querySelector && el.querySelector('.input-group')) return false;
208+
// 内部仍嵌套消息容器,说明是包装层而非单条消息
209+
if (el.querySelector && el.querySelector('div.css-xo2rg1.e1llepen2, div[style*="column-reverse"][style*="overflow"]')) return false;
210+
// PA 消息:包含 pa-reply 链接
211+
if (el.querySelector && el.querySelector('a.pa-reply')) return true;
212+
// 聊天消息:必须是已知消息容器的直接子元素
213+
return !!(el.parentElement && isChatContainer(el.parentElement));
169214
}
170215

171216
// 创建答案UI
@@ -249,14 +294,26 @@
249294
if (element.scPaProcessed) return;
250295
element.scPaProcessed = true;
251296

252-
var texts = getMessageTexts(element);
253-
for (var ti = 0; ti < texts.length; ti++) {
254-
if (!texts[ti] || texts[ti].length < 3) continue;
255-
var match = findBestMatch(texts[ti]);
297+
if (!isSafeAnswerParent(element)) {
298+
return;
299+
}
300+
301+
var entries = getMessageTexts(element);
302+
for (var ti = 0; ti < entries.length; ti++) {
303+
if (!entries[ti].text || entries[ti].text.length < 3) continue;
304+
var match = findBestMatch(entries[ti].text);
256305
if (match) {
257306
if (element.querySelector('.sc-pa-answer-box')) return;
258307
var answerUI = createAnswerUI(match);
259-
element.appendChild(answerUI);
308+
answerUI.dataset.scPaQuestion = match.qText;
309+
// 若匹配文本来自子元素且该子元素本身是安全的消息内容块,优先插到该子元素
310+
var target = element;
311+
if (entries[ti].el !== element && entries[ti].el.tagName === 'DIV'
312+
&& isSafeAnswerParent(entries[ti].el)) {
313+
target = entries[ti].el;
314+
}
315+
if (!isSafeAnswerParent(target)) return;
316+
target.appendChild(answerUI);
260317
return;
261318
}
262319
}
@@ -268,14 +325,9 @@
268325

269326
// 1. 处理 PA 系统消息(通过 pa-reply 链接定位)
270327
document.querySelectorAll('a.pa-reply').forEach(function (link) {
271-
let el = link.parentElement;
272-
for (let i = 0; i < 10 && el && el !== document.body; i++) {
273-
var texts = getMessageTexts(el);
274-
if (texts.length > 0 && !el.scPaProcessed) {
275-
processMessage(el);
276-
break;
277-
}
278-
el = el.parentElement;
328+
var msgEl = findPaMessageElement(link);
329+
if (msgEl && !msgEl.scPaProcessed) {
330+
processMessage(msgEl);
279331
}
280332
});
281333

@@ -323,6 +375,62 @@
323375
return document.querySelectorAll('div[style*="column-reverse"][style*="overflow"]');
324376
}
325377

378+
// 判断是否为聊天消息容器(与 findChatContainers 同一套选择器)
379+
function isChatContainer(el) {
380+
if (!el || el.nodeType !== 1 || !el.matches) return false;
381+
return el.matches('div.css-xo2rg1.e1llepen2') ||
382+
el.matches('div[style*="column-reverse"][style*="overflow"]');
383+
}
384+
385+
// 判断是否为消息级元素(聊天容器的直接子级,或包含 pa-reply 链接的 PA 消息)
386+
function isMessageLevelElement(el) {
387+
if (!el || el === document.body || isChatContainer(el)) return false;
388+
if (el.querySelector && el.querySelector('a.pa-reply')) return true;
389+
return !!(el.parentElement && isChatContainer(el.parentElement));
390+
}
391+
392+
// 清理过期答案框:宿主被 React 清空/复用、宿主为聊天容器本身、
393+
// 或宿主文本不再匹配原问题时,移除答案框并允许重新评估宿主
394+
function cleanupStaleAnswers() {
395+
var boxes = document.querySelectorAll('.sc-pa-answer-box');
396+
for (var i = 0; i < boxes.length; i++) {
397+
var box = boxes[i];
398+
var parent = box.parentElement;
399+
if (!parent || !parent.isConnected) {
400+
box.remove();
401+
continue;
402+
}
403+
var qText = box.dataset.scPaQuestion;
404+
var valid = false;
405+
if (qText && !isChatContainer(parent)) {
406+
var entries = getMessageTexts(parent);
407+
for (var ti = 0; ti < entries.length; ti++) {
408+
if (calcMatchRate(entries[ti].text, qText) >= MATCH_THRESHOLD) {
409+
valid = true;
410+
break;
411+
}
412+
}
413+
}
414+
if (valid) continue;
415+
416+
box.remove();
417+
// 宿主可能已被 React 复用装入新消息:重置标记并重新评估
418+
parent.scPaProcessed = false;
419+
if (isMessageLevelElement(parent)) {
420+
processMessage(parent);
421+
}
422+
}
423+
}
424+
425+
// 防抖调度清理,避免每次 DOM 变更都全量校验
426+
function scheduleCleanup() {
427+
if (cleanupTimer) return;
428+
cleanupTimer = setTimeout(function () {
429+
cleanupTimer = null;
430+
cleanupStaleAnswers();
431+
}, 300);
432+
}
433+
326434
// 初始化
327435
async function init() {
328436
// 仅在 /messages/ 页面运行
@@ -348,6 +456,7 @@
348456

349457
// 扫描现有消息
350458
scanPage();
459+
cleanupStaleAnswers();
351460

352461
// 监听变化
353462
if (observer) observer.disconnect();
@@ -364,6 +473,7 @@
364473
}
365474
}
366475
}
476+
scheduleCleanup();
367477
});
368478

369479
// 观察聊天容器
@@ -381,14 +491,9 @@
381491

382492
// 如果是 pa-reply 链接,处理其消息容器
383493
if (element.tagName === 'A' && element.classList.contains('pa-reply')) {
384-
let el = element.parentElement;
385-
for (let i = 0; i < 10 && el && el !== document.body; i++) {
386-
var texts = getMessageTexts(el);
387-
if (texts.length > 0 && !el.scPaProcessed) {
388-
processMessage(el);
389-
break;
390-
}
391-
el = el.parentElement;
494+
var msgEl = findPaMessageElement(element);
495+
if (msgEl && !msgEl.scPaProcessed) {
496+
processMessage(msgEl);
392497
}
393498
return;
394499
}
@@ -406,19 +511,25 @@
406511
var links = element.querySelectorAll('a.pa-reply');
407512
if (links.length > 0) {
408513
links.forEach(function (link) {
409-
let el = link.parentElement;
410-
for (let i = 0; i < 10 && el && el !== document.body; i++) {
411-
var linkTexts = getMessageTexts(el);
412-
if (linkTexts.length > 0 && !el.scPaProcessed) {
413-
processMessage(el);
414-
break;
415-
}
416-
el = el.parentElement;
514+
var msgEl = findPaMessageElement(link);
515+
if (msgEl && !msgEl.scPaProcessed) {
516+
processMessage(msgEl);
417517
}
418518
});
419519
}
420520
}
421521

522+
// React 整体重建消息容器时,容器内部的消息不会作为独立新增节点出现,
523+
// 这里递归处理新增节点内部嵌套的已知聊天容器及其消息子元素
524+
if (element.querySelectorAll && !isChatContainer(element)) {
525+
var nestedContainers = element.querySelectorAll('div.css-xo2rg1.e1llepen2, div[style*="column-reverse"][style*="overflow"]');
526+
nestedContainers.forEach(function (c) {
527+
c.querySelectorAll(':scope > div').forEach(function (msgEl) {
528+
if (!msgEl.scPaProcessed) processMessage(msgEl);
529+
});
530+
});
531+
}
532+
422533
// 通用后备:处理新增的有文本内容的元素(新聊天消息、新PA消息等)
423534
if (!element.scPaProcessed && element.nodeType === 1
424535
&& element.textContent.trim().length > 5) {
@@ -444,4 +555,4 @@
444555
setTimeout(init, 500);
445556

446557
return { init };
447-
})();
558+
})();

0 commit comments

Comments
 (0)