Files
nl-im-service/interaction-audit.html
2026-08-24 15:29:53 +08:00

681 lines
83 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NL-IM 全系统交互审查报告</title>
<style>
:root {
--bg: #0f1115;
--bg-elevated: #181818;
--bg-card: #1c1c1c;
--border: #2a2a2a;
--border-light: #333;
--text: #e4e4e4;
--text-secondary: #b0b0b0;
--text-tertiary: #808080;
--accent: #599ce7;
--danger: #f06262;
--warning: #e0a458;
--neutral: #888;
--info: #599ce7;
--code-bg: #0a0a0a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.container {
max-width: 1000px;
margin: 0 auto;
padding: 32px 24px 80px;
}
h1 {
font-size: 24px;
font-weight: 600;
margin: 0 0 8px;
}
h2 {
font-size: 18px;
font-weight: 600;
margin: 28px 0 12px;
}
.subtitle {
color: var(--text-tertiary);
font-size: 13px;
margin-bottom: 20px;
}
/* Stat grid */
.stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 24px;
}
.stat {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px;
}
.stat-value {
font-size: 22px;
font-weight: 600;
line-height: 1.2;
}
.stat-value.danger { color: var(--danger); }
.stat-value.warning { color: var(--warning); }
.stat-value.info { color: var(--info); }
.stat-label {
font-size: 12px;
color: var(--text-tertiary);
margin-top: 4px;
}
/* Callout */
.callout {
background: rgba(240, 98, 98, 0.08);
border: 1px solid rgba(240, 98, 98, 0.25);
border-left: 3px solid var(--danger);
border-radius: 8px;
padding: 14px 16px;
margin-bottom: 24px;
}
.callout-title {
font-weight: 600;
margin-bottom: 8px;
font-size: 14px;
}
.callout p {
margin: 4px 0;
font-size: 13px;
color: var(--text-secondary);
}
/* Distribution chart (SVG bar chart) */
.chart-wrap {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 18px 16px 12px;
margin-bottom: 8px;
}
.chart-svg {
width: 100%;
height: auto;
display: block;
font-family: inherit;
}
.chart-svg .axis-line { stroke: var(--border-light); stroke-width: 1; }
.chart-svg .grid-line { stroke: var(--border); stroke-width: 1; stroke-dasharray: 2 3; }
.chart-svg .tick-label { fill: var(--text-tertiary); font-size: 11px; }
.chart-svg .cat-label { fill: var(--text-secondary); font-size: 12px; }
.chart-svg .bar-seg {
transition: opacity 0.12s;
cursor: default;
}
/* 当鼠标悬停某个 bar 时,其它 bar 变暗,模拟画布的 hover 高亮 */
.chart-svg.has-hover .bar-seg { opacity: 0.32; }
.chart-svg.has-hover .bar-seg.hovered { opacity: 1; }
.chart-legend {
display: flex;
justify-content: center;
gap: 20px;
font-size: 12px;
color: var(--text-tertiary);
margin-top: 10px;
flex-wrap: wrap;
}
.chart-legend-item {
display: flex;
align-items: center;
gap: 6px;
}
.chart-legend-dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
.chart-caption {
text-align: center;
color: var(--text-tertiary);
font-size: 11px;
margin-top: 6px;
}
/* Tooltip */
.chart-tooltip {
position: fixed;
pointer-events: none;
background: #000;
border: 1px solid var(--border-light);
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
color: var(--text);
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
display: none;
line-height: 1.5;
min-width: 120px;
}
.chart-tooltip .tt-title { font-weight: 600; margin-bottom: 4px; }
.chart-tooltip .tt-row { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); }
.chart-tooltip .tt-dot { width: 8px; height: 8px; border-radius: 2px; flex-shrink: 0; }
.chart-tooltip .tt-val { margin-left: auto; font-variant-numeric: tabular-nums; color: var(--text); }
hr {
border: none;
border-top: 1px solid var(--border);
margin: 28px 0;
}
/* Filter */
.filter-group {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin: 6px 0;
}
.filter-label {
color: var(--text-tertiary);
font-size: 12px;
min-width: 44px;
}
.pill {
display: inline-block;
padding: 4px 10px;
font-size: 12px;
border: 1px solid var(--border-light);
border-radius: 14px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
user-select: none;
transition: all 0.15s;
}
.pill:hover { border-color: var(--accent); color: var(--text); }
.pill.active {
background: var(--accent);
border-color: var(--accent);
color: #0f1115;
font-weight: 500;
}
/* Severity section */
.sev-header {
display: flex;
align-items: center;
gap: 8px;
margin: 20px 0 8px;
font-weight: 600;
font-size: 15px;
}
.sev-dot {
width: 12px;
height: 12px;
border-radius: 3px;
}
.sev-dot.high { background: var(--danger); }
.sev-dot.mid { background: var(--warning); }
.sev-dot.low { background: var(--neutral); }
/* Finding */
.finding {
border-bottom: 1px solid var(--border);
padding: 0;
}
.finding-summary {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 4px;
cursor: pointer;
}
.finding-summary:hover { background: var(--bg-elevated); }
.chevron {
color: var(--text-tertiary);
margin-top: 3px;
font-size: 10px;
transition: transform 0.15s;
flex-shrink: 0;
}
.finding.open .chevron { transform: rotate(90deg); }
.finding-title {
flex: 1;
font-size: 14px;
line-height: 1.5;
}
.finding-meta {
color: var(--text-tertiary);
font-size: 12px;
flex-shrink: 0;
text-align: right;
white-space: nowrap;
}
.finding-body {
display: none;
padding: 0 4px 14px 24px;
}
.finding.open .finding-body { display: block; }
.file-list {
margin: 0 0 8px;
}
.file-list code {
display: block;
font-family: "SF Mono", Menlo, Monaco, Consolas, monospace;
font-size: 12px;
background: var(--code-bg);
padding: 2px 6px;
border-radius: 3px;
color: var(--text-secondary);
margin: 2px 0;
word-break: break-all;
}
.finding-desc {
font-size: 13px;
color: var(--text-secondary);
margin: 8px 0;
}
.finding-fix {
font-size: 13px;
color: var(--text-tertiary);
}
.finding-fix strong { color: var(--text-secondary); }
.tag {
display: inline-block;
padding: 1px 6px;
font-size: 11px;
border-radius: 3px;
margin-left: 6px;
vertical-align: middle;
}
.tag.opt { background: rgba(89, 156, 231, 0.15); color: var(--info); }
.tag.suspect { background: rgba(224, 164, 88, 0.15); color: var(--warning); }
.empty {
color: var(--text-tertiary);
padding: 20px;
text-align: center;
}
.footer-note {
color: var(--text-tertiary);
font-size: 12px;
margin-top: 24px;
line-height: 1.7;
}
</style>
</head>
<body>
<div class="container">
<h1>NL-IM 全系统交互审查报告</h1>
<p class="subtitle">
来源9 个并行审查子代理的静态代码审查 · 2026-08-13 · 范围nl-um-vue-tsPC 端 11 视图 + 40 组件 + 数据层)与 nl-im-uniapp移动端 25 页面 + 34 组件 + 数据层),只读审查未改动任何代码;行号基于当日快照,修复前建议先复现确认。
</p>
<div class="stats" id="stats"></div>
<div class="callout">
<div class="callout-title">最优先处理的三个结论</div>
<p>1. 移动端 H5 音视频通话目前实际不可用srcObject 绑在 uni-video 上不生效导致画面全黑,语音通话没有媒体元素承载远端流全程静音;叠加两端同款的"群通话全群响铃""主叫取消后来电层永久卡死",通话链路建议整体修复后回归。</p>
<p>2. 两端 WebSocket 连接生命周期都有洞重连次数耗尽后永久离线、PC 无心跳/移动端心跳无 ack 判定造成"假在线"、重连成功后都不补拉离线消息——组合起来就是"看着在线、实际丢消息"。</p>
<p>3. 一批"点了没反应"的失效交互源自组件事件契约错配wd-action-sheet 负载取错多个在用页面菜单失效、wot confirm 回调被覆盖退出登录无效、MentionPicker 即开即关(两端 @ 失效)、群通知页监听不存在的事件(分页失效)。</p>
</div>
<h2>发现分布(按功能域 × 严重程度)</h2>
<div class="chart-wrap">
<svg class="chart-svg" id="chart-svg" xmlns="http://www.w3.org/2000/svg"></svg>
<div class="chart-legend" id="chart-legend"></div>
<div class="chart-caption">横轴:发现条数 · 两端同款问题按 1 条计(已在明细中标注涉及的两端文件)</div>
</div>
<div class="chart-tooltip" id="chart-tooltip"></div>
<hr>
<h2 id="detail-title">问题明细</h2>
<div id="filters"></div>
<div id="findings"></div>
<p class="footer-note">
说明:标「疑似」的条目依赖具体平台/发布配置如是否发布非微信小程序端、Electron 打包方式),修复前先确认场景。后端 nl-im-service 不在本次"页面与组件"审查范围内子代理在验证前端问题时交叉查阅了后端信令广播、离线消息、TURN 凭证等实现并已在结论中引用。
</p>
</div>
<script>
const AREAS = [
"音视频通话", "消息与连接", "聊天界面", "朋友圈",
"通讯录与群组", "弹层与通用组件", "登录与设置"
];
const SEV_META = {
: { cls: "high", label: "高危" },
: { cls: "mid", label: "中危" },
: { cls: "low", label: "低危" }
};
const FINDINGS_DATA = [
{sev:"高",end:"两端",kind:"交互bug",area:"音视频通话",title:"群通话“选人邀请”实际全群响铃,选人功能形同虚设",files:["nl-um-vue-ts/src/composables/useGroupWebRTC.ts:165-178","nl-im-uniapp/src/composables/useGroupWebRTC.ts:169-188"],desc:"后端对群信令是全群广播,两端收到 invite 时都不检查自己是否在 participantIds 列表里直接弹出全屏来电。100 人群里选 2 人发起通话,其余 97 人全部响铃且接听后能真正入会。",fix:"invite 分支加 participantIds.includes(myId) 校验(两端同步修),或后端按名单定向分发。"},
{sev:"高",end:"两端",kind:"交互bug",area:"音视频通话",title:"主叫取消群通话后,被叫的全屏来电界面永不消失,且无来电超时",files:["nl-um-vue-ts/src/composables/useGroupWebRTC.ts:219-224","nl-im-uniapp/src/composables/useGroupWebRTC.ts:229-234"],desc:"被叫振铃中收到 leave 信令只移除参与者、不重置 incoming 状态z-index 10000 的全屏遮罩永久盖住应用;点“接听”会进入 roomId 已被清空的空通话。整条链路也没有振铃超时。",fix:"leave 分支中发起者离开且未接听时 resetState补 45-60 秒来电超时自动拒绝。"},
{sev:"高",end:"两端",kind:"交互bug",area:"朋友圈",title:"朋友圈详情页点赞/评论写到列表副本上,详情页 UI 无反应,诱发重复操作",files:["nl-um-vue-ts/src/stores/moment.ts:188-257","nl-im-uniapp/src/stores/moment.ts:173-264","nl-im-uniapp/src/pages/moment/detail.vue:146-183"],desc:"toggleLike/addComment/refreshComments 用 moments.find(...) || currentMoment 定位对象,从列表进详情(最常见路径)时命中的是列表旧副本,详情页渲染的 currentMoment 纹丝不动。用户以为没成功会再点:第二次点赞实际是取消赞,评论则重复发布。",fix:"三个方法同时更新列表项与 currentMomentid 相同时两份都改),两端同步修复。"},
{sev:"高",end:"两端",kind:"交互bug",area:"朋友圈",title:"UserMomentsModal 翻页页码双重自增,每次翻页跳过一页数据",files:["nl-um-vue-ts/src/components/moment/UserMomentsModal.vue:196-213","nl-im-uniapp/src/components/moment/UserMomentsModal.vue:189-206"],desc:"滚动触底的 loadMore 先 page++ 再调 loadMoments而 loadMoments 成功后又 page++。第一次翻页请求第 2 页后 page 已变 3第二次直接请求第 4 页——第 3 页被永久跳过PC 版还因此 hasMore 永不为 false翻到底后持续发无效请求。",fix:"删掉 loadMore 里的 page++,页码只在 loadMoments 成功回调内维护一处。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"消息与连接",title:"ChatView 挂载恢复会话时提前 return四个全局监听永不注册",files:["nl-um-vue-ts/src/views/chat/ChatView.vue:2697-2725"],desc:"上次退出前停留在私聊会话时老用户最常见启动路径onMounted 的恢复逻辑 selectChat 后直接 return跳过 resize、app-tab-change、visibilitychange、focus 四个监听注册:窗口缩放布局不响应、朋友圈通知“立即查看”切 tab 失效、失焦期间积攒的已读回执永不上报。onUnmounted 还在移除这些从未注册的监听,掩盖了问题。",fix:"删除提前 return与后续分支改为 if/else或把 addEventListener 挪到恢复逻辑之前。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"聊天界面",title:"三处入口直接 setCurrentTarget打开空白会话或毫无反应",files:["nl-um-vue-ts/src/views/contact/ContactDetailCard.vue:562-566","nl-um-vue-ts/src/views/contact/ContactCircle.vue:647-707,840-846","nl-um-vue-ts/src/components/chat/GroupInfoPanel.vue:740-765"],desc:"资料卡“发消息”、通讯录右键“发送消息”、搜索结果点击、群成员卡“发消息”都只设置 store 指针不走 selectChat不加载历史、不标已读ContactCircle 里 router.push('/chat') 还是重复导航 no-op界面停在通讯录 tab 毫无反应。顶部“加载历史”按钮又因空列表短路,点了也没用。",fix:"统一改为 emit('select-chat'/'open-chat') 交给 ChatView.selectChat 处理(项目内已有正确范式)。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"消息与连接",title:"未读预拉取与翻页分页错位,静默丢失第 31-50 条历史消息",files:["nl-um-vue-ts/src/views/chat/ChatView.vue:2604-2626,1130-1139"],desc:"登录后 prefetchUnreadMessages 用 page1×30 预拉但不记录页码;用户翻历史时 loadMoreMessages 从 page2×50 开始,服务端 offset 直接跳到第 51 条。本地只有最新 30 条,中间 20 条消息永远加载不出来,历史记录无提示地缺一段。",fix:"预拉取与翻页统一 pageSize 并记录游标;根治改为基于最早消息 id 的游标分页。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"消息与连接",title:"WebSocket 重连成功后不补拉离线消息,断线期间消息永久丢失",files:["nl-um-vue-ts/src/api/websocket/index.ts:129-136,502-507","nl-um-vue-ts/src/views/chat/ChatView.vue:1707-1719"],desc:"重连成功后没有任何补拉机制后端也不补推离线消息selectChat 只在本地缓存为空时才拉历史,断线期间漏掉的消息连切走再切回都救不回,只能刷新整页。会话列表/未读数同样不刷新。",fix:"wsManager 增加 onReconnected 回调,重连后 loadConversations 并对当前会话 syncMessages 增量合并。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"消息与连接",title:"WebSocket 无任何心跳机制,半开连接下“假在线”",files:["nl-um-vue-ts/src/api/websocket/index.ts:28-54"],desc:"电脑睡眠唤醒、切 Wi-Fi、NAT 超时后 TCP 链路已死但 readyState 仍是 OPEN连接指示灯显示“已连接”实际收不到任何推送onclose 不触发所以自动重连永不启动;也没有 online/visibilitychange 兜底。对常驻桌面 IM 是最隐蔽的失联故障。",fix:"客户端定时发应用层 ping超时未响应主动 close() 触发重连;监听 online/focus 补检查。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"消息与连接",title:"打开“已有本地缓存”的会话不上报已读回执,对方永远看不到已读",files:["nl-um-vue-ts/src/views/chat/ChatView.vue:1053-1097"],desc:"markMessagesRead 只在“缓存为空、走服务端拉取”分支调用。最常见路径——正在看 A 会话时 B 来消息(进入缓存),点开 B 只滚动不上报clearUnread 只清会话计数不发消息级回执。发送方气泡长期停留在“未读”。",fix:"selectChat 的缓存分支同样收集未读消息 id 调一次 markMessagesRead。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"音视频通话",title:"群通话窗口最小化后,所有远端参与者的声音全部消失",files:["nl-um-vue-ts/src/components/chat/GroupCallWindow.vue:40-122"],desc:"远端音频唯一播放出口是网格里的 video 元素,最小化用 v-if/v-else 切换把整个网格连同所有远端 video 卸载——最小化后立刻听不到任何人说话。单聊 CallWindow 用 v-show 没有此问题。",fix:"最小化改用 v-show 隐藏网格,或为每个远端流维持常驻的隐藏 audio 元素。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"聊天界面",title:"Enter 发送未处理中文输入法 composition选词时把半截消息发出去",files:["nl-um-vue-ts/src/components/chat/MessageInput.vue:323-329"],desc:"handleKeydown 判断 Enter 就发送,没检查 e.isComposing。中文输入法组词态按 Enter拼音上屏常见操作会把没打完的内容直接发送属于 IM 核心输入路径的高频损伤。",fix:"keydown 处理开头加 if (e.isComposing || e.keyCode === 229) return。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"朋友圈",title:"朋友圈动态的“删除”按钮永远不可见",files:["nl-um-vue-ts/src/components/moment/MomentPanel.vue:85-92"],desc:"删除按钮用 opacity-0 group-hover:opacity-100但从按钮到根节点没有任何祖先带裸 group 类(附近只有 group/comment 等命名 group按钮永远透明——用户在朋友圈主面板上找不到删除自己动态的入口且透明按钮仍占位可被误触。",fix:"给动态条目 div 加 group 类,或直接去掉 opacity-0。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"弹层与通用组件",title:"用户朋友圈弹窗里点击图片,预览层被弹窗自身盖住",files:["nl-um-vue-ts/src/components/moment/UserMomentsModal.vue:5","nl-um-vue-ts/src/components/common/ImagePreview.vue:6"],desc:"UserMomentsModal 遮罩 z-[10000]ImagePreview 只有 z-[100],两者是 body 下的 fixed 兄弟节点,预览层被压在弹窗下面。点图表面上“没反应”,实际预览在背后打开还锁了 body 滚动。",fix:"把 UserMomentsModal 的 z-index 降到预览层之下(如 z-[90]),或提升 ImagePreview 为全局最高层。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"聊天界面",title:"工具栏 @ 按钮完全失效:选人面板被同一次点击立即关闭",files:["nl-um-vue-ts/src/components/common/MentionPicker.vue:150-163","nl-um-vue-ts/src/components/chat/MessageInput.vue:65-71"],desc:"MentionPicker 在 onMounted 就往 document 挂 click 监听且不检查 props.show。点 @ 图标时按钮 handler 先置 show=true同一事件冒泡到 document 后被判定“点在面板外”立刻 emit close——面板即开即关按钮看起来点了没反应只有敲 @ 字符那条路径能用)。",fix:"handleClickOutside 开头加 if (!props.show) return并给 @ 按钮 click 加 .stop。"},
{sev:"高",end:"PC端",kind:"交互bug",area:"朋友圈",title:"“部分好友可见/不可见”没有选人步骤visible_user_ids 永远不会发给后端",files:["nl-um-vue-ts/src/components/moment/VisibilitySelector.vue:76-80","nl-um-vue-ts/src/components/moment/MomentPublisher.vue:293,385-388","nl-um-vue-ts/src/components/moment/MomentPublisherDark.vue:473-481,571-574"],desc:"两条发布链路都提供“部分可见/不可见”选项,但选择器的 handleSelect 是 TODO 不带 userIds浅色版 visibleUserIds 恒为空数组,深色版连该字段都没构建进请求。用户选了“部分好友可见”,实际可见范围与意图必然不符。",fix:"选中 2/3 时弹好友多选(可复用深色版已有的好友选择弹窗),填入 visible_user_ids 后再允许发布。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"音视频通话",title:"H5 端 srcObject 绑在 uni-video 组件上不生效,通话画面/声音渲染整体失效",files:["nl-im-uniapp/src/components/call/CallWindow.vue:34-60,210-228","nl-im-uniapp/src/components/call/GroupCallOngoing.vue:37-89"],desc:"uni-app H5 把模板里的 video 编译成内置 uni-video 组件,:srcObject 变成无效 attributeref 拿到的也是组件实例而非真实 video 元素。结果 H5 的 1v1 视频通话画面全黑、群视频宫格无画面、群语音隐藏播放层无声——信令全通但看不见听不到。",fix:"mounted 后用 $el.querySelector('video') 拿内部真实元素赋 srcObject或 H5 下动态创建原生 video/audio。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"音视频通话",title:"H5 语音通话没有任何媒体元素承载远端流,通话全程静音",files:["nl-im-uniapp/src/components/call/CallWindow.vue:63-74"],desc:"语音通话分支只渲染头像/文案/时长remoteStream 没挂到任何 audio/video 元素上(全工程也无 new Audio 兜底)。即使修复 srcObject 问题1v1 语音通话依然听不到对方。",fix:"语音分支增加隐藏的原生 audio 元素绑定 remoteStream。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"聊天界面",title:"录音权限异步竞态松手后才开始录音60 秒后把环境音自动当语音发送",files:["nl-im-uniapp/src/components/chat/MessageInput.vue:406-421,386-396"],desc:"startRecording 先 await 权限校验,快速点按时 touchend 的 stop 是空操作,权限 resolve 后才 start()——录音在手指抬起后开始,无手势可停,遮罩卡死直到 60 秒上限自动 onStop且时长达标会把这段无人说话的录音发出去既是功能错误也是隐私问题。",fix:"touchend/touchcancel 置结束标记await 返回后发现手指已抬起则放弃 start()。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"消息与连接",title:"聊天页翻页无去重:产生重复气泡和重复 v-for key",files:["nl-im-uniapp/src/pages/chat/index.vue:635,93-96"],desc:"loadMoreMessages 用页码分页并直接 [...new, ...old] 拼接,不按 id 去重。聊天中只要有新消息,服务端第 1 页内容整体后移,拉第 2 页会返回已有消息——重复气泡 + 重复 key 触发 Vue 渲染异常。项目里现成的 mergeMessageIntoList 没在这里用。",fix:"prepend 前用 Set 按已有 id 过滤;中长期改 before_id 游标分页。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"通讯录与群组",title:"群通知页监听了不存在的 loadmore 事件,超过 20 条的通知永远看不到",files:["nl-im-uniapp/src/pages/group/notify.vue:32,181-194"],desc:"wd-loadmore 组件本身不会随滚动触发事件(只在 error 态被点击时 emit reload页面也没注册 onReachBottom。loadMore 是死代码:通知超过 20 条永远只显示第一页,底部永远转“加载中”,失败重试也点不动。",fix:"用 onReachBottom 触发 loadMore@loadmore 改为 @reload 处理重试。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"登录与设置",title:"设置页“退出登录”确认后完全无反应",files:["nl-im-uniapp/src/pages/settings/index.vue:314-327"],desc:"handleLogout 用 success 回调风格调 message.confirm但 wot-design-uni 内部 deepMerge 会用自己的 resolve 覆盖调用方传入的 success——点“确定”只关弹窗authStore.logout() 永远不执行,用户无法退出登录。同项目 settings/ai.vue 的 await 写法才是正确的。",fix:"改为 await message.confirm(...) 后执行登出,用 isMessageBoxCancel 区分取消。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"朋友圈",title:"朋友圈详情页图片、视频未经 resolveImageUrlAPP/小程序端全部裂图",files:["nl-im-uniapp/src/pages/moment/detail.vue:33-49,159-161"],desc:"图片、视频、previewImage 都直接用后端返回的相对路径 /uploads/...,没有像列表页那样拼 API_ORIGIN第 122 行 import 了 resolveImageUrl 但从未使用。APP 和小程序端相对路径无宿主可解析,媒体 100% 裂图。",fix:"所有媒体 URL 及 previewImage 的 urls 统一套 resolveImageUrl。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"消息与连接",title:"WebSocket 重连 5 次耗尽后永久离线,且全程无网络状态监听",files:["nl-im-uniapp/src/api/websocket/index.ts:43-44,468-476","nl-im-uniapp/src/App.vue:64-73"],desc:"退避链路总计约 75 秒,耗尽后不再重连;全工程没有 uni.onNetworkStatusChange。网络中断超 75 秒电梯、地铁、Wi-Fi 切换)恢复后 WS 永远不连收不到消息和来电且无离线提示H5 前台标签页连 onShow 救济都没有。",fix:"监听网络恢复重置计数并立即重连;或改为封顶间隔的无限重连。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"消息与连接",title:"断线重连成功后不补拉数据,停留聊天页时断线窗口内的消息永久不显示",files:["nl-im-uniapp/src/api/websocket/index.ts:485-490","nl-im-uniapp/src/pages/chat/index.vue:309,333-345"],desc:"重连成功后 wsManager 不通知业务层,聊天页历史只在 onLoad 拉一次。锁屏/切后台导致的短暂断线恢复后,断线期间对方发的消息在当前聊天页永远不出现,用户毫无感知地漏掉回复,必须退出重进。",fix:"wsManager 增加 onReconnected 事件聊天页重拉当前房间、App 层刷新会话列表。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"音视频通话",title:"小程序端不过滤群通话信令:误弹 1v1 来电,还能挂断正在进行的通话",files:["nl-im-uniapp/src/composables/useMiniProgramCall.ts:109-125,230-236"],desc:"H5 端 1v1 的 handleSignaling 有群信令过滤,小程序版没有。任何群成员发起群通话,小程序用户会误弹 1v1 全屏来电(接听后卡在“正在连接”);我正在 1v1 通话时,某群里有人拒绝群通话邀请,因群信令 call_id 为空守卫失效,我的通话被莫名挂断并提示“对方已拒绝”。",fix:"handleSignaling 开头补 if (content.callRoomId || content.participantIds) return。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"弹层与通用组件",title:"5 个弹层组件把 v-model 直接绑在 prop 上,一经引用即编译失败",files:["nl-im-uniapp/src/components/common/UserInfoCard.vue:3","nl-im-uniapp/src/components/moment/MomentCommentInput.vue:3","nl-im-uniapp/src/components/moment/MomentNotificationPanel.vue:3","nl-im-uniapp/src/components/moment/UserMomentsModal.vue:3","nl-im-uniapp/src/components/moment/VisibilitySelector.vue:3"],desc:"均写成 <wd-popup v-model=\"modelValue\">Vue 3.4 编译器对“v-model 绑 prop”直接抛编译错误。目前没暴露只因这 5 个组件是死代码从未被 import——谁想启用它们构建立刻失败。",fix:"改为 :model-value + @update:model-value 转发 emit同目录 AppModal.vue 是正确范例)。"},
{sev:"高",end:"移动端",kind:"交互bug",area:"弹层与通用组件",title:"wd-action-sheet 的 select 负载取错,多个在用页面的菜单点击无效",files:["nl-im-uniapp/src/components/common/VideoPlayer.vue:89-94,210-216","nl-im-uniapp/src/pages/moment/index.vue:402-410","nl-im-uniapp/src/pages/chat/index.vue:913-917","nl-im-uniapp/src/pages/moment/publish.vue:203-206","nl-im-uniapp/src/pages/group/info.vue:642-646","nl-im-uniapp/src/pages/contact/index.vue:400-405"],desc:"组件库 select 事件负载是 { item, index },这些调用点把整个负载当 item 用(直接读 .value/.name拿到 undefined——朋友圈操作菜单、消息长按菜单、倍速选择等点了只关闭不执行。只有 pages/index/index.vue 用 event.item || event 解包正确,说明踩过坑但只修了一处。",fix:"统一从 event.item 取值;建议收口到已正确封装类型的 AppActionSheet 组件。"},
{sev:"中",end:"两端",kind:"交互bug",area:"音视频通话",title:"群通话 ICE 只配 Google 公共 STUN不使用后端 TURN受限网络必失败",files:["nl-um-vue-ts/src/composables/useGroupWebRTC.ts:78-82","nl-im-uniapp/src/composables/useGroupWebRTC.ts:78-82"],desc:"群通话 RTCPeerConnection 写死 stun:stun.l.google.com:19302国内访问不可靠且无 TURN 中继时对称 NAT/公司网络下打洞必失败,部分成员永远 connecting/黑屏。后端明明有 TURN 且 1v1 已在用。",fix:"群通话建连前复用 1v1 的 getIceServers() 拉取后端 TURN 配置。"},
{sev:"中",end:"两端",kind:"交互bug",area:"朋友圈",title:"UserInfoCard 朋友圈九宫格预览索引错位;移动端还有切换用户竞态",files:["nl-um-vue-ts/src/components/common/UserInfoCard.vue:283-298","nl-im-uniapp/src/components/common/UserInfoCard.vue:243-290"],desc:"九宫格每格显示每条动态的首图,但预览把格子序号直接当“全部图片扁平数组”下标——第一条动态多图时,点第 2/3 格打开的是第一条动态的第 2/3 张图。移动端版本还无竞态保护,快速切换 A、B 用户资料卡会显示串号的朋友圈。",fix:"按累计偏移换算扁平下标(或逐条取首图组数组);加载时记录 userId 校验后再写入。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"聊天界面",title:"发送失败时消息消失、输入框已清空,会话列表还残留“幽灵消息”摘要",files:["nl-um-vue-ts/src/views/chat/ChatView.vue:2138-2143,1306-1337"],desc:"点发送后 inputText 立即清空;失败时 catch 把乐观插入的消息从列表删除,只弹 toast——用户打的长文本既不在列表也不在输入框无法重发只能重打。且发送前更新过的会话摘要/排序不回滚,侧边栏一直显示从未发出去的内容。",fix:"失败时标记 failed 状态支持点击重发(或回填输入框),并回写会话摘要。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"音视频通话",title:"群通话发起方看到的参与者名字永远是“呼叫中...”,头像为空",files:["nl-um-vue-ts/src/composables/useGroupWebRTC.ts:388-436"],desc:"发起方用占位名 addParticipant(uid, '呼叫中...', ''),对方接听后 join/offer 信令带来了真实姓名头像,但 addParticipant 对已存在的参与者只更新 status 不更新 name/avatar此后无任何路径回填——整场通话所有名牌都是“呼叫中...”。",fix:"addParticipant 命中已有记录且传入 name 非占位值时同步更新 name/avatar。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"聊天界面",title:"移除 @ 标签用未转义昵称构造正则,特殊字符昵称直接抛异常",files:["nl-um-vue-ts/src/components/chat/MessageInput.vue:401-412"],desc:"new RegExp(`@${user.name}`) 把昵称原样拼进正则,昵称含 [、(、+、* 等字符时抛 SyntaxError× 移除完全失效。另外手动退格删掉正文里的 @某人 后 mentionedUsers 不同步,发送时仍携带该用户的提及通知。",fix:"昵称先 escapeRegExp或用字符串操作删除发送前按正文实际包含的 @名字 过滤 mentions。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"音视频通话",title:"群通话窗口最小化后位置错乱,拖拽无边界可拖出屏幕",files:["nl-um-vue-ts/src/components/chat/GroupCallWindow.vue:202-227"],desc:"inline left/top 在最小化时覆盖了应有的右上角定位,小窗停在原大窗位置;初始位置按 1000×700 常量估算与实际 90vw/80vh 不符拖拽无边界钳制标题栏拖出屏幕后窗口再也拖不回来。CallWindow 已有 clampPosition 却未复用。",fix:"最小化时清掉 inline 定位;复用 CallWindow 的 clampPosition 并按实际尺寸计算初始位置。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"通讯录与群组",title:"群成员搜索每敲一个字符发一次请求,无防抖且有响应乱序竞态",files:["nl-um-vue-ts/src/components/chat/GroupChatPanel.vue:105-116,462-471"],desc:"搜索框 @input 直接调接口,无防抖也无请求序号保护——慢网下先发后回的旧响应覆盖新结果,成员列表与搜索词不符;清空搜索还触发 loadData 一次连发 3 个接口。",fix:"加 300ms 防抖 + 递增序号丢弃过期响应;成员量不大时改前端本地过滤。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"消息与连接",title:"附件上传共用 30 秒全局超时,大文件/慢网发送必然失败",files:["nl-um-vue-ts/src/api/request/index.ts:20-26","nl-um-vue-ts/src/api/modules/attachment.ts:25-29"],desc:"axios 全局 timeout 30suploadAttachment 没有覆写。发视频、大文件或上行带宽低时 30 秒一到 axios 主动中断,弹“发送失败”,服务器端可能已收到半个文件。",fix:"上传请求单独设 timeout: 0或 5-10 分钟),顺带用 onUploadProgress 展示进度。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"消息与连接",title:"重连 10 次失败后永久放弃,且无网络恢复监听",files:["nl-um-vue-ts/src/api/websocket/index.ts:37-38,486-494"],desc:"断网/服务端维护超过约 3 分钟后重连计数耗尽直接放弃,没有 online/focus 事件兜底。用户若没注意到侧边栏小红点并手动点击,会无限期离线还以为一切正常。",fix:"达到上限后维持 30s 封顶的低频重试,或监听 online/focus 重置计数并重连。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"消息与连接",title:"storage.getUserInfo 裸 JSON.parselocalStorage 数据损坏启动即白屏且不自愈",files:["nl-um-vue-ts/src/utils/storage.ts:47-50","nl-um-vue-ts/src/stores/auth.ts:16-17"],desc:"getUserInfo 的 JSON.parse 没有 try/catch而它在 auth store 模块初始化时同步执行——user_info 被写入非法 JSON 时直接炸掉 Pinia 创建,整个应用白屏,用户不清 localStorage 无法恢复。",fix:"包 try/catch解析失败返回 null 并 removeItem 清掉脏数据。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"音视频通话",title:"ICE/TURN 配置永久缓存,客户端运行超 24 小时后 TURN 凭证过期、建连失败",files:["nl-um-vue-ts/src/composables/useWebRTC.ts:476-508"],desc:"后端 TURN 凭证有效期 24 小时,前端 cachedIceServers 是模块级缓存整个会话期永不刷新。Electron 常驻超 24 小时后在需要 TURN 中继的网络(对称 NAT、公司防火墙通话必然 failed 自动挂断。",fix:"缓存加时间戳、超时作废重取,或每次建连前重新拉取。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"登录与设置",title:"checkAuth 把服务端 5xx 也当成 token 失效,刷新撞上后端重启就被登出",files:["nl-um-vue-ts/src/stores/auth.ts:121-143"],desc:"catch 分支只把“无 response”视为网络异常保留会话502/503 带 response 会走 logout()——清空一切并踢到登录页,与注释声明的“只有 token 确认无效才登出”相悖。",fix:"仅 401或业务码认证失败登出5xx/超时一律按网络异常保留会话。"},
{sev:"中",end:"PC端",kind:"优化",area:"朋友圈",title:"MomentPublisher 与 Dark 版约 300 行逻辑完全重复,且已出现功能漂移",files:["nl-um-vue-ts/src/components/moment/MomentPublisher.vue:212-390","nl-um-vue-ts/src/components/moment/MomentPublisherDark.vue:304-575"],desc:"发布/选图/删图/定位/标签等逻辑两边逐行相同只差配色且已漂移Dark 版支持 @好友但丢了 visible_user_ids浅色版反之。修 bug 要改两处,极易漏。",fix:"抽 useMomentPublisher() composable或合并为一个组件加 theme prop两边只留模板。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"弹层与通用组件",title:"VideoPlayer 全局键盘监听多实例串扰isPlaying 与真实播放状态脱钩",files:["nl-um-vue-ts/src/components/common/VideoPlayer.vue:489-541,313-321"],desc:"每个实例都在 document 上挂 keydown朋友圈流里多个视频时按空格/方向键让所有播放器同时动作video 没绑 @play/@pausetogglePlay 乐观取反(自动播放策略拒绝时不回滚),画中画里的操作也不同步,之后点播放按钮会执行相反操作。",fix:"键盘监听只对焦点/悬停实例生效;用 @play/@pause 事件作为 isPlaying 唯一状态源。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"朋友圈",title:"/moment 页面点击动态图片没有任何反应viewMedia 是 TODO",files:["nl-um-vue-ts/src/components/moment/MomentCard.vue:229-232,26-36"],desc:"图片格子 @click 只执行 console.log图片预览功能缺失三点菜单也没有点击外部关闭逻辑打开后一直悬着。",fix:"viewMedia 接入公共 ImagePreview菜单加 click-outside 关闭。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"朋友圈",title:"朋友圈通知点了不会定位到对应动态",files:["nl-um-vue-ts/src/components/moment/MomentNotificationPanel.vue:128-135"],desc:"handleNotificationClick 只标已读然后 emit('close'),没有把 moment_id 传出去,父组件也只监听了 close。点“某某评论了你的动态”后弹窗直接消失找不到那条动态通知的核心价值缺失。",fix:"增加 emit('goto', moment_id),父组件滚动定位/高亮该动态。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"朋友圈",title:"朋友圈评论输入用 keyup.enter中文输入法回车上屏会误发评论",files:["nl-um-vue-ts/src/components/moment/MomentPanel.vue:323"],desc:"输入法组合态按 Enter 上屏时 compositionend 先于 keyup 触发,@keyup.enter 照常执行——拼音确认的回车把评论甚至未选词的原始拼音直接发送。MessageInput 用的是 keydown+状态判断,这里没对齐。",fix:"改 @keydown.enter 并检查 e.isComposing组合中直接 return。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"弹层与通用组件",title:"附件管理筛选/加载更多存在竞态,快速操作显示错误列表",files:["nl-um-vue-ts/src/components/common/AttachmentManager.vue:259-290"],desc:"loadAttachments 无请求序号也不取消旧请求:快速切换筛选时旧响应后到会覆盖新列表;加载更多在途时切筛选,旧筛选的第 2 页被 push 进新列表。页脚“已使用”也只累计已加载页,数字偏小。",fix:"自增 requestId 丢弃过期响应;总大小改由后端统计接口返回。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"弹层与通用组件",suspect:true,title:"多处直接使用后端相对路径 URLElectron 打包版图片/下载会失效",files:["nl-um-vue-ts/src/components/common/AttachmentManager.vue:79,294-308","nl-um-vue-ts/src/components/common/MentionPicker.vue:39-44","nl-um-vue-ts/src/components/moment/MomentPanel.vue:111"],desc:"聊天气泡组件都刻意通过 resolveImageUrl 给 /uploads/... 补 API_ORIGIN而这些组件直接把相对路径塞进 src。Web 端靠同源/代理能显示Electron 打包后以 file:// 加载会解析成 file:///uploads/... 全部裂图。",fix:"统一走 resolveImageUrlMentionPicker 的头像逻辑直接换成公共 Avatar 组件。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"朋友圈",title:"发布器 blob URL 泄漏;上传失败重试会把已成功的图片重复上传",files:["nl-um-vue-ts/src/components/moment/MomentPublisher.vue:274-322","nl-um-vue-ts/src/components/moment/MomentPublisherDark.vue:459-508"],desc:"createObjectURL 的预览只在手动删单张时 revoke发布成功或取消关闭从不释放逐张串行上传第 N 张失败则整体报错,已传成功的成为孤儿文件,重试时又从第 1 张全部重传产生重复附件。",fix:"关闭/成功时遍历 revoke每张图缓存已上传的 file_url重试跳过已成功项。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"通讯录与群组",title:"ContactDetailCard 切换联系人无竞态守卫,快速切换显示错人的资料/朋友圈",files:["nl-um-vue-ts/src/views/contact/ContactDetailCard.vue:448-519"],desc:"watch 里先乐观显示再 await 拉详情,无请求序号也不校验当前选中。快速点 A 再点 BA 的响应后到会把 B 的资料覆盖成 A出现“B 的资料卡挂着 A 的朋友圈”。",fix:"仿 ChatView 的 selectChatSeq 加自增序号,或 await 后比对当前选中 id 再写入。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"弹层与通用组件",title:"右键菜单的 divider 项被渲染成空白菜单项,点击直接抛 TypeError 且菜单卡住",files:["nl-um-vue-ts/src/views/contact/ContactCircle.vue:852,868,887","nl-um-vue-ts/src/components/common/ContextMenu.vue:8-35"],desc:"联系人右键菜单塞了三个 { type: 'divider' } as any但 ContextMenu 不认识 divider——渲染成可点击的空白行点到时执行 item.action() 抛 undefined is not a function异常发生在 hideMenu 之前,菜单卡在屏幕上。",fix:"ContextMenu 对 divider 渲染分隔线并跳过点击绑定,或删掉这三项。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"朋友圈",title:"动态详情页点“评论”按钮被误判为“回复评论”,提交脏的 reply_to_comment_id",files:["nl-um-vue-ts/src/views/moment/MomentDetailView.vue:40,142-145"],desc:"MomentCard 的 comment 事件携带整个 Moment 对象,详情页却把它当“被回复的评论”传给 openCommentInput弹窗标题显示“回复 {作者名}”,提交时 reply_to_comment_id 是动态 id 而非评论 id产生错乱的回复关系或后端报错。",fix:"改为 @comment=\"() => openCommentInput()\" 吸收第一个参数。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"消息与连接",title:"loadMoreMessages 无切换会话守卫,滚动补偿作用到新会话;翻页拼接不去重",files:["nl-um-vue-ts/src/views/chat/ChatView.vue:1122-1174"],desc:"加载历史期间切换会话,按旧会话高度差算的滚动补偿会施加到新会话(共享同一 DOM 容器位置突跳loadingHistory 也是全局单例。拼接又不按 id 去重,翻页在途时来新消息会带回重复消息。",fix:"进入时捕获 roomId、await 后校验一致再补偿;拼接前按 id 过滤。"},
{sev:"中",end:"PC端",kind:"交互bug",area:"通讯录与群组",title:"创建/加入群聊后把会话列表灌进联系人 store且会话侧边栏、群列表不刷新",files:["nl-um-vue-ts/src/views/contact/ContactCircle.vue:1040-1108"],desc:"操作成功后先把会话列表映射成 Contact 写进 chatStore.setContacts群会话、非好友会话混进好友列表再用 getContacts 覆盖回来——中间是脏窗口,第二个请求失败污染永久残留。新群也不出现在聊天 tab 会话列表和本页群聊 tab。",fix:"删掉“会话→联系人”的映射写入,改为分别刷新 loadConversations / loadGroupChats / getContacts。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"聊天界面",title:"正在翻阅历史消息时,收到任何新消息都会被强制拉回底部",files:["nl-im-uniapp/src/pages/chat/index.vue:593-597"],desc:"handleNewMessage 只要消息成功追加就无条件 scrollToBottom(true)。上翻查历史(尤其活跃群聊)时视口被反复拽到底部,历史消息基本没法看。",fix:"维护“距底部距离”,仅在底部附近或自己发的消息才自动滚底,否则显示“有新消息”悬浮提示。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"聊天界面",title:"媒体消息发送失败不回滚:气泡永久转圈;文本失败缓存和会话摘要残留",files:["nl-im-uniapp/src/pages/chat/index.vue:469-498,557-574"],desc:"图片/语音/文件上传成功后先乐观上屏并写入 chatStore 和会话摘要,随后 sendMessage 失败时 catch 只弹 toast 不移除——气泡看着已发出实际对方收不到重进页面又凭空消失。文本失败只回滚页面数组chatStore 副本和会话摘要不回滚。",fix:"失败路径统一从页面数组和 chatStore 移除(或标“发送失败”支持重发)并恢复会话摘要。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"通讯录与群组",title:"建群页直接点复选框无法选中成员(双重触发互相抵消)",files:["nl-im-uniapp/src/pages/group/create.vue:30-39"],desc:"行元素绑 @click=toggleSelect内部 wd-checkbox 又绑 @change=toggleSelect 且不阻止冒泡——点复选框先触发 change 再冒泡触发行 click一加一减选中状态不变最直觉的点击目标失效。",fix:"去掉 checkbox 的 @change 当纯展示(已受控),或给复选框区域加 @click.stop。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"通讯录与群组",title:"同意好友申请/删除好友后返回,通讯录列表不刷新;被删好友详情仍按好友渲染",files:["nl-im-uniapp/src/pages/contact/index.vue:377","nl-im-uniapp/src/pages/contact/requests.vue:74","nl-im-uniapp/src/pages/contact/detail.vue:322,454-463"],desc:"通讯录 onShow 只刷新好友申请不重载联系人:同意申请/删好友后 navigateBack新好友不出现、被删的仍在列表。且 loadContact 无视接口返回的 is_friend 恒置 true。",fix:"onShow 一并重载联系人或事件标脏按需刷新loadContact 读取响应的 is_friend。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"音视频通话",title:"微信小程序端“最小化”按钮无效:全屏通话层仍然盖住页面",files:["nl-im-uniapp/src/components/call/MiniProgramCallWindow.vue:7,145-148,246-251"],desc:"容器没有任何 minimized 的类绑定或切换,点最小化后 video 层、顶栏、控制栏照常全屏渲染,只是叠加了一个悬浮球——用户无法回到聊天页边聊边通话。",fix:"加 :class=\"{ minimized }\",最小化时隐藏 UI 并收缩容器live-pusher 保留在视图内避免断流)。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"音视频通话",title:"小程序主叫一拨号就显示“通话中”并开始计时,且无呼出超时",files:["nl-im-uniapp/src/composables/useMiniProgramCall.ts:452-455,302-310"],desc:"joinCallRoom 尾部无条件置 connected 并 startCallTimer——对方还在振铃甚至离线主叫 UI 已显示“通话中”且秒表走字最终时长把振铃时间也算进去H5 端有的 60 秒呼出超时这里没有,对方不接会永远挂着。",fix:"拨出阶段保持 outgoing收到 accepted 再置 connected 并计时;补 60 秒呼出超时。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"聊天界面",title:"@提及选择器一旦弹出就无法取消",files:["nl-im-uniapp/src/components/chat/MessageInput.vue:272-300","nl-im-uniapp/src/components/chat/MentionPicker.vue:55-58"],desc:"onInput 只在文本以 @ 结尾时置 show=true 从不置 falseMentionPicker 声明了 close 事件但组件内没有任何代码/UI 触发它。误输 @ 后删除字符、点击别处都关不掉面板,唯一出路是选一个人。",fix:"MentionPicker 加遮罩/关闭按钮 emit closeonInput 在末尾不再是 @ 时关闭面板。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"音视频通话",title:"群通话信令处理器被注册两个闭包实例,每条信令处理两遍",files:["nl-im-uniapp/src/composables/useGroupWebRTC.ts:98-101,398-399","nl-im-uniapp/src/components/call/GlobalCallProvider.vue:61,74"],desc:"handleSignalMessage 是每次 useGroupWebRTC() 新建的闭包GlobalCallProvider 注册一份,发起页 startGroupCall 又注册自己的一份——offer 对同一 PeerConnection 并发 setRemoteDescription、回发两份 answer协商状态机错乱导致部分成员黑屏/一直 connectingreject 的 toast 也弹两次。",fix:"把处理器提升为模块级单例,或 initListener 幂等化(删掉 startGroupCall 内的重复注册)。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"音视频通话",title:"来电/回铃提示只响一声,极易漏接",files:["nl-im-uniapp/src/composables/useWebRTC.ts:82-104","nl-im-uniapp/src/composables/useMiniProgramCall.ts:894-905"],desc:"H5 的 playRingtone 只用 WebAudio 播一个 200ms 蜂鸣不循环stopRingtone 管理的变量从未赋值,纯死代码);小程序端只振动一次约 400ms。整个振铃期只有一瞬间提示手机不在手上必然漏接。",fix:"改为循环播放铃声/振动uni.createInnerAudioContext loopstopRingtone 真正清理循环。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"弹层与通用组件",suspect:true,title:"非微信的小程序端(支付宝/百度/抖音)所有图标缺失,关键按钮不可见",files:["nl-im-uniapp/src/components/chat/MessageInput.vue:33-108","nl-im-uniapp/src/components/chat/MessageBubble.vue:76-117","nl-im-uniapp/src/components/common/AppTabBar.vue:150-219","nl-im-uniapp/src/components/common/PlusMenu.vue:18-27","nl-im-uniapp/src/components/common/AppDrawer.vue:34-42"],desc:"图标统一是 #ifdef H5 || APP-PLUSSVG+ #ifdef MP-WEIXINwd-icon两套分支项目声明支持支付宝/百度/抖音小程序但这些平台两个分支都被剔除——TabBar 只剩文字、语音/表情/发送/播放等图标全部消失。若实际只发微信小程序可降级。",fix:"小程序分支放宽为 #ifndef H5 || APP-PLUSwd-icon 全端可用),一处兜底所有小程序。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"消息与连接",title:"聊天页被叠层覆盖或 App 切后台时,仍自动为新消息发已读回执",files:["nl-im-uniapp/src/pages/chat/index.vue:599-602,347-351"],desc:"handleNewMessage 对每条非自发消息无条件 markMessagesRead不判断页面可见性。从聊天页 A 进资料页再进聊天页 B 时 A 的监听仍活跃、锁屏时页面栈不变——用户根本没看到的消息被标已读,本地未读角标与服务端已读状态互相矛盾。",fix:"维护页面可见标志onShow/onHide+栈顶判断仅可见时上报onShow 时批量补报。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"消息与连接",title:"心跳只发不收、无 ack 超时判定,半开连接下长时间“假在线”",files:["nl-im-uniapp/src/api/websocket/index.ts:382-401"],desc:"每 30s 发 heartbeat 仅在 send 回调 fail 时触发重连;后端会回 heartbeat_ack 但客户端没有任何消费/计时逻辑。移动网络 TCP 半开时客户端长时间自认在线却收不到消息。",fix:"记录最近一次收到 ack/任意消息的时间,连续 2 个周期无响应主动 close 触发重连。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"朋友圈",title:"发布动态可将视频与图片混选media_type 数据错乱",files:["nl-im-uniapp/src/pages/moment/publish.vue:64,155-227"],desc:"takeVideo 会清空已选内容,但反过来拍照/相册不检查已有视频——选完视频再拍照media_type 按第一项判为视频,图片混入 media_urls 被静默丢失;先选图再拍视频还会不经确认清空全部已选。",fix:"存在视频时隐藏拍照/相册入口互斥takeVideo 清空前弹确认。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"朋友圈",title:"发布成功后约 1 秒空窗内可重复提交,且会多退一层页面",files:["nl-im-uniapp/src/pages/moment/publish.vue:250-268"],desc:"成功后 setTimeout 1 秒才 navigateBack但 finally 立刻把 publishing 复位,按钮恢复可点——这 1 秒内再点会重复发布同一条动态,并再排队一个 navigateBack 连退两页跳出朋友圈。",fix:"成功分支保持按钮 loading 直到页面销毁,或立即 navigateBack。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"朋友圈",title:"动态详情页评论无防连点,快速点击产生重复评论",files:["nl-im-uniapp/src/pages/moment/detail.vue:172-183"],desc:"sendComment 没有发送中标志,@confirm 与按钮都能触发commentText 要等成功才清空——弱网下连点发出多条相同评论;叠加详情页评论发送后 UI 无反馈的问题,用户几乎必然重试。",fix:"加 sending 锁,请求发出前置 true 并禁用按钮。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"登录与设置",title:"搜索存在响应竞态,回车与防抖定时器重复触发请求",files:["nl-im-uniapp/src/pages/search/index.vue:277-315"],desc:"500ms 防抖触发 doSearch但回车直接调用时不清除 pending 定时器——先后发两次相同请求;多次在途请求无序号保护,慢回的旧响应覆盖新关键词的结果。",fix:"doSearch 开头 clearTimeout并用自增请求序号丢弃过期响应。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"登录与设置",title:"“记住登录”复选框完全无效token 无条件持久化",files:["nl-im-uniapp/src/pages/login/index.vue:44,119-123","nl-im-uniapp/src/stores/auth.ts:32-37"],desc:"login 无条件写入本地存储remember 只写一个无人读取的标记getRemember 全工程零调用)。取消勾选后下次启动依然自动登录,与控件语义相反。",fix:"不勾选时用内存/会话级存储,或启动时依据 remember 标记决定是否清除持久 token。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"登录与设置",title:"“我的”页面统计数据是写死的假数据",files:["nl-im-uniapp/src/pages/profile/index.vue:49-62"],desc:"“动态 12 / 好友 85 / 收藏 3”为模板硬编码所有用户任何时候看到同样的假数值具有误导性。",fix:"接入真实统计接口,或暂时隐藏该统计行。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"朋友圈",title:"朋友圈/搜索翻页后新内容“隐身”数秒才浮现",files:["nl-im-uniapp/src/pages/moment/index.vue:85,485-488","nl-im-uniapp/src/pages/search/index.vue:110,144,191"],desc:"入场动画延迟按全列表索引线性递增,第 2 页新增项延迟 1-2 秒、第 5 页达 4-5 秒,期间元素 opacity:0 完全不可见,上拉加载后像“没加载出来”。",fix:"延迟取模或封顶(如 Math.min(index % 20, 10) * 50或仅首屏应用动画。"},
{sev:"中",end:"移动端",kind:"优化",area:"弹层与通用组件",title:"21 个通用组件中 16 个是死代码,评论输入被实现了三遍",files:["nl-im-uniapp/src/components/index.ts:1-36","nl-im-uniapp/src/components/moment/CommentInput.vue","nl-im-uniapp/src/components/moment/MomentCommentInput.vue"],desc:"只有 AppNavBar/AppTabBar/AppAvatar/AppDrawer/PlusMenu 真正在用其余AppModal、ImagePreview、VideoPlayer、全部 8 个 moment 组件等)无任何页面引用;朋友圈页面全部内联重写了同样 UI。两套实现并行漂移页面里修过的 bug 在组件里原样存在。",fix:"明确取舍:让页面改用组件并回灌修复,或删除死代码目录,避免误用带病组件。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"弹层与通用组件",title:"AppAvatar 角标 prop 名传错,徽标数字永远显示不出来",files:["nl-im-uniapp/src/components/common/AppAvatar.vue:20-25"],desc:"wd-badge 的显示值 prop 是 modelValue此处写成 :value=\"badge\"徽标永远收不到数值。AppAvatar 是全局注册在用组件,目前恰好没人传 badge属“一用就坏”的潜伏 bug。",fix:"改为 :model-value=\"badge\" 并验证 :max 溢出显示。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"弹层与通用组件",title:"ImagePreview 所有图片共享同一缩放值movable-view 与 swiper 手势冲突",files:["nl-im-uniapp/src/components/common/ImagePreview.vue:13-31,119-133"],desc:"单个 scale ref 绑定所有 swiper-item 的 movable-view——放大当前图时相邻预渲染图同步放大切换时可见邻图已是放大态movable-view direction=all 还拦截水平拖动,放大后无法左右滑动切换图片。",fix:"缩放状态按 index 存数组scale 为 1 时禁用 movable-view 让手势透传 swiper。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"弹层与通用组件",title:"ImagePreview 的“保存图片”在 H5 端必然失败",files:["nl-im-uniapp/src/components/common/ImagePreview.vue:151-190"],desc:"uni.saveImageToPhotosAlbum 仅 App/小程序支持无条件编译分支——H5 点下载永远落入 catch 提示“保存失败”;授权失败分支的 uni.openSetting 也是小程序专属,在 H5/App 再次报错。",fix:"H5 分支改 <a download> 触发下载或提示长按保存openSetting 包进 #ifdef MP-WEIXIN。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"弹层与通用组件",title:"VideoPlayer 创建 VideoContext 未传组件实例,微信小程序端所有控制按钮失灵",files:["nl-im-uniapp/src/components/common/VideoPlayer.vue:260"],desc:"uni.createVideoContext 在自定义组件内必须传组件实例,否则微信小程序只在页面根作用域找 video——返回的 context 所有方法静默无效,自绘控制栏的播放/暂停/seek/全屏/倍速全部失灵H5 按 id 全局查找正常,掩盖了问题)。",fix:"改为 uni.createVideoContext(videoId, getCurrentInstance()?.proxy)。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"弹层与通用组件",title:"VideoPlayer 进度条点击 seek 用页面绝对坐标除以兜底宽度,定位系统性偏移",files:["nl-im-uniapp/src/components/common/VideoPlayer.vue:195-208"],desc:"e.currentTarget.offsetWidth 在非 H5 端取不到(落到 300px 兜底touch.x 是绝对坐标没减去进度条左侧偏移——点击跳转位置系统性偏移,且不支持拖动滑块。",fix:"用 SelectorQuery 取 boundingClientRect 按 (x-rect.left)/rect.width 计算,或改用 slider。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"弹层与通用组件",title:"VideoPlayer 全屏与缓冲状态会失步",files:["nl-im-uniapp/src/components/common/VideoPlayer.vue:169-177,219-223"],desc:"toggleFullscreen 手动翻转状态、不监听 @fullscreenchange——系统手势/返回键退出全屏后组件仍认为在全屏fixed 覆盖样式残留盖住页面;@waiting 置的 isBuffering 只有 play/error 能清除,卡顿恢复触发 playing 时加载遮罩可能永久卡住挡住交互。",fix:"以 @fullscreenchange 事件为全屏唯一状态源;@timeupdate 里顺手清除 isBuffering。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"朋友圈",title:"通知面板缺 @提及类型文案、点击不跳转动态、翻页无并发保护",files:["nl-im-uniapp/src/components/moment/MomentNotificationPanel.vue:122-166"],desc:"本地重复定义的类型表只覆盖 1-3类型 4@提及通知动作文本空白types/moment.ts 已有完整常量没复用点击通知只标已读不跳详情loadMore 的 loading 从不置位,快速触底并发多次可能重复追加。",fix:"复用导出的 NotificationTypeText点击 navigateTo 详情页loadMore 期间置 loading。"},
{sev:"中",end:"移动端",kind:"交互bug",area:"朋友圈",title:"朋友圈点赞快速连点产生竞态,计数与服务端漂移",files:["nl-im-uniapp/src/stores/moment.ts:173-195"],desc:"toggleLike 在 await 返回前不改本地状态也不加锁,快速连点读到同一个 wasLiked向服务端发两次相同操作本地计数执行两次与服务端漂移。",fix:"加进行中标志忽略后续点击,或改乐观更新+失败回滚。"},
{sev:"低",end:"两端",kind:"交互bug",area:"聊天界面",title:"表情插入总是追加到文本末尾,不在光标处",files:["nl-um-vue-ts/src/components/chat/MessageInput.vue:278-280","nl-im-uniapp/src/components/chat/MessageInput.vue:291-293"],desc:"两端 insertEmoji 都直接拼接到文本末尾,光标在中间时表情落到末尾,与 PC 端 @ 插入按 selectionStart 定位的行为不一致。",fix:"按 textarea 的 selectionStart 拼接并回设光标位置。"},
{sev:"低",end:"两端",kind:"优化",area:"消息与连接",title:"消息缓存按房间无上限增长;移动端还双份存储并残留死代码未读体系",files:["nl-um-vue-ts/src/stores/chat.ts:9,32-37","nl-im-uniapp/src/stores/chat.ts:12,34-108"],desc:"两端 messages 都是 Record<roomId, []> 只增不减,长时间挂机/多房间翻页内存无上限;移动端每个房间的消息同时存在页面数组和 chatStore 两份,且联系人维度未读计数体系全工程无调用方。",fix:"切出会话时截断到最近 N 条;删除无调用的未读方法。"},
{sev:"低",end:"PC端",kind:"交互bug",area:"音视频通话",title:"群通话来电没有铃声(单聊有)",files:["nl-um-vue-ts/src/components/chat/GroupCallIncoming.vue","nl-um-vue-ts/src/composables/useGroupWebRTC.ts"],desc:"单聊有循环铃声,群通话只弹全屏 UI 无任何声音提示,不看屏幕完全无感知,与单聊体验不一致。",fix:"incoming 置 true/false 时复用单聊的铃声播放与停止。"},
{sev:"低",end:"PC端",kind:"交互bug",area:"聊天界面",title:"语音气泡多条可同时播放,无时长信息时显示假的 00:10",files:["nl-um-vue-ts/src/components/chat/bubble/Audio.vue:44-87"],desc:"每个气泡各自持有 Audio 实例无全局互斥,连点几条语音混在一起同时响;时长兜底表达式恒等于 00:10无 duration 的消息显示假数据。",fix:"模块级单例记录当前播放实例,播新前停旧;无时长显示 --:-- 。"},
{sev:"低",end:"PC端",kind:"优化",area:"聊天界面",title:"防删遮罩 MutationObserver 两段约 60 行完全重复,触发即整页刷新",files:["nl-um-vue-ts/src/components/chat/MessageInput.vue:541-615"],desc:"onMounted 与 watch 是两份几乎逐行相同的 observer 构造;用全局类选择器定位自身(复用时会选错实例);触发后直接 location.reload 过于粗暴,且后端发送路径已有禁言校验,这套前端防护收益存疑。",fix:"抽 setupObserver() 复用并用模板 ref或直接删除依赖服务端校验。"},
{sev:"低",end:"PC端",kind:"交互bug",area:"弹层与通用组件",title:"右键菜单固定 150px 钳制导致长菜单溢出屏幕;滚动不关闭;不支持 ESC",files:["nl-um-vue-ts/src/stores/contextMenu.ts:25-26","nl-um-vue-ts/src/composables/useContextMenu.ts:23-40"],desc:"位置钳制假设菜单高度 150px联系人菜单实际约 350px屏幕下缘右键时底部条目含删除好友超出视口无法点击菜单显示期间滚动列表不关闭、按 ESC 也不关闭。",fix:"渲染后量实际高度做翻转/钳制;补 scroll(capture) 与 keydown(Escape) 关闭。"},
{sev:"低",end:"PC端",kind:"交互bug",area:"通讯录与群组",title:"“+”下拉菜单点击外部关闭用 .closest('.relative') 判定,大部分区域点击不关闭",files:["nl-um-vue-ts/src/views/contact/ContactCircle.vue:1121-1126"],desc:"relative 是 Tailwind 通用类,本页大量元素都有 relative 祖先,点这些区域被误判为“点在菜单内”——关闭行为随点击位置“随机”。",fix:"给菜单根节点加专属 class 判定,或用模板 ref + contains(target)。"},
{sev:"低",end:"PC端",kind:"交互bug",area:"通讯录与群组",title:"好友申请“接受/拒绝”、“添加好友”按钮均无防重复提交",files:["nl-um-vue-ts/src/views/contact/FriendNotifyView.vue:110-140","nl-um-vue-ts/src/views/contact/ContactView.vue:123-133","nl-um-vue-ts/src/views/chat/ChatView.vue:2502-2522"],desc:"三处裸 async 请求无 loading 锁,连点“接受”二次调用报错弹 toast搜索结果连点“添加好友”发出多条申请。项目已有 sendingFile 锁的正确范式未复用。",fix:"为每个请求加 processing 状态并禁用按钮。"},
{sev:"低",end:"PC端",kind:"交互bug",area:"聊天界面",title:"私聊头部在线状态绿点写死,离线用户也显示“在线”",files:["nl-um-vue-ts/src/views/chat/ChatView.vue:289"],desc:"呼吸绿点只有 !isGroupChat 一个条件,不读任何 is_online 字段——和任何人私聊都恒显在线。项目已有 checkUserOnline 轮询能力没接。",fix:"绑定真实在线字段,离线显示灰点或隐藏。"},
{sev:"低",end:"PC端",kind:"优化",area:"消息与连接",title:"filteredConversations O(n×m) 全量重算;会话/消息长列表无虚拟化",files:["nl-um-vue-ts/src/views/chat/ChatView.vue:889-922,175-264","nl-um-vue-ts/src/components/chat/MessageList.vue:39-48"],desc:"computed 在 map 内对每个会话 find 联系人,任意一条消息到达都触发平方级重算+全列表 diff会话列表和消息列表均为普通 v-for翻页累积数百条 DOM 全量保留。",fix:"先建 Map 索引降为 O(n+m);长列表引入虚拟滚动或限制渲染窗口。"},
{sev:"低",end:"PC端",kind:"交互bug",area:"朋友圈",title:"朋友圈通知“加载更多”失败后页码不回滚,永久跳过一页数据",files:["nl-um-vue-ts/src/stores/moment.ts:319-324,292-314"],desc:"fetchMoreNotifications 请求发出前就 page+=1失败只打日志不回滚下次直接请求再下一页中间 20 条通知被永久跳过。同文件 fetchMoreMoments 是成功后才递增的正确写法。",fix:"与 fetchMoreMoments 对齐,成功后再递增页码。"},
{sev:"低",end:"PC端",kind:"优化",area:"消息与连接",title:"loadConversations 无并发守卫,未知会话消息风暴触发全量重拉与 N+1 请求",files:["nl-um-vue-ts/src/stores/conversation.ts:35-49,92-109,165-169"],desc:"十余处调用点并发触发时在途请求互相覆盖,可能冲掉本地刚累加的未读数;收到未知会话的每条消息都触发全量 loadConversations群信息补拉还是逐个 getGroup。",fix:"入口加 loading 守卫;未知会话改用单会话接口 getConversationByRoom。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"通讯录与群组",title:"添加好友页“扫一扫”在 H5 端必报“扫码失败”;添加按钮可连点重复申请",files:["nl-im-uniapp/src/pages/contact/add.vue:300-327"],desc:"uni.scanCode 在 H5 无实现,消息页已有 #ifdef H5 的明确提示而本页没有H5 用户得到误导性报错handleAdd 无 in-flight 防抖,快速双击发出两次好友申请。",fix:"与消息页对齐加 H5 条件编译提示handleAdd 加 pending 标志。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"聊天界面",title:"加载历史/搜索定位后,锚点消息被固定导航栏遮住",files:["nl-im-uniapp/src/pages/chat/index.vue:635,641-658"],desc:"scroll-into-view 把目标消息顶边对齐到视口顶部,而导航栏是 fixed 覆盖层——每次“查看历史”后原首条消息、搜索跳转的目标消息都压在导航栏下面,看起来像定位不准。",fix:"给消息锚点加 scroll-margin-top或用 SelectorQuery 计算 scrollTop 减去导航栏高度。"},
{sev:"低",end:"移动端",kind:"优化",area:"聊天界面",title:"图片消息无占位尺寸、未开启懒加载,导致滚动跳动与全量加载",files:["nl-im-uniapp/src/components/chat/MessageBubble.vue:63-70"],desc:"image mode=widthFix 加载前高度为 0加载完撑开内容造成滚底不准和翻页抖动首屏所有图片立即请求。上传链路已带 extra.width/height 却没用。",fix:"用 extra 宽高按比例预设气泡高度,并给 image 加 lazy-load。"},
{sev:"低",end:"移动端",kind:"优化",area:"通讯录与群组",title:"中文联系人全部归入 # 分组A-Z 索引条形同虚设",files:["nl-im-uniapp/src/pages/contact/index.vue:370"],desc:"分组只判断首字符是否 A-Z中文昵称国内 IM 绝大多数场景)全部落进 # 组,字母索引和排序基本失效。",fix:"引入轻量拼音库(如 pinyin-pro按需取首字母做分组键。"},
{sev:"低",end:"移动端",kind:"优化",area:"消息与连接",title:"消息主页每次进入并发拉取两次会话列表;新会话消息风暴触发 N 次全量拉取",files:["nl-im-uniapp/src/pages/index/index.vue:264-265","nl-im-uniapp/src/stores/conversation.ts:23-28,155-158"],desc:"onMounted 与 onShow 首屏各拉一次Tab 切换是 reLaunch 重建页面等于每次切到消息页双拉loadConversations 无 in-flight 守卫,未知会话连来 N 条消息并发 N 次全量拉取+逐群补拉。",fix:"删掉 onMounted 那次loadConversations 入口去重,消息触发的重拉加防抖。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"通讯录与群组",title:"通讯录删除好友失败时无任何提示(错误被当作“取消”吞掉)",files:["nl-im-uniapp/src/pages/contact/index.vue:403"],desc:"try/catch 把弹窗取消和 API 失败混在一起静默吞掉:点了“确定”但接口失败时没有任何反馈。消息主页同类代码已用 isMessageBoxCancel 区分,此处不一致。",fix:"catch 中用 isMessageBoxCancel 区分,非取消时 toast 报错。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"登录与设置",title:"防抖自动搜索污染搜索历史,并强制跳走用户选中的 Tab",files:["nl-im-uniapp/src/pages/search/index.vue:296-308"],desc:"每次防抖搜索都 saveHistory——慢速输入会把中间态全部写进历史挤占上限每次搜索还按优先级重置 activeTab用户手动切到“聊天记录”后再打一个字就被拽回“联系人”。",fix:"历史只在回车确认或点击结果时保存activeTab 仅首次搜索自动切换。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"登录与设置",title:"设置页“主题设置”行的当前值永不刷新",files:["nl-im-uniapp/src/pages/settings/index.vue:268-272"],desc:"themeText 是 computed 但内部只读非响应式的 getStorageSync——首次求值后永久缓存从主题页改完返回仍显示旧文案。",fix:"改 ref 并在 onShow 重读 storage。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"登录与设置",title:"“清理缓存”是纯假动作",files:["nl-im-uniapp/src/pages/settings/index.vue:262,329-338"],desc:"缓存大小硬编码 12.5MB,点清理只是 1 秒后把文案改成 0KB 并把假字符串存到服务器,未清理任何本地存储/文件。",fix:"真实统计并清理getStorageInfo+缓存目录),或标注“开发中”。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"登录与设置",title:"编辑资料页头像上传成功提示“头像已更新”,实际尚未保存",files:["nl-im-uniapp/src/pages/profile/edit.vue:154-163"],desc:"上传成功只写入表单并 toast“已更新”但要点“保存修改”才提交——用户看到成功提示直接返回头像修改静默丢失。",fix:"提示改为“已上传,保存后生效”,或上传成功立即调用 updateUser。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"朋友圈",title:"朋友圈 store 吞掉所有加载异常,页面的错误分支永不可达",files:["nl-im-uniapp/src/stores/moment.ts:52-113,273-292","nl-im-uniapp/src/pages/moment/index.vue:318-329"],desc:"fetch 系列失败只 console.error 不抛出,页面 catch { loadState='error' } 形同虚设——断网时列表无任何提示,底部 loadmore 永远转圈且无重试入口。",fix:"store 的 fetch 失败时 rethrow与 fetchMomentDetail 一致),让页面进入 error 态。"},
{sev:"低",end:"移动端",kind:"优化",area:"音视频通话",title:"UnifiedCallWindow 是无人引用的死代码,一旦被使用会直接崩溃",files:["nl-im-uniapp/src/components/call/UnifiedCallWindow.vue:8-36","nl-im-uniapp/src/components/call/CallWindow.vue:198"],desc:"全工程无页面引用;其模板不传 CallWindow 的必填 props被挂载立即抛错onMounted 还会重复注册信令监听。CallWindow 里还残留占位域名 your-web-domain.com。",fix:"删除该组件及导出;顺手清理占位域名。"},
{sev:"低",end:"移动端",kind:"优化",area:"聊天界面",title:"SelectContactsModal 把 prop 直接用作 wd-popup 的 v-model",files:["nl-im-uniapp/src/components/chat/SelectContactsModal.vue:3"],desc:"点遮罩关闭时内部对只读 prop 赋值,开发环境每次打 mutate prop 警告,状态同步全靠 @close 兜底,属侥幸可用。",fix:"改 :model-value + @update:model-value 转发。"},
{sev:"低",end:"移动端",kind:"优化",area:"聊天界面",title:"MessageBubble 每次渲染重复 JSON.parse extra消息图片未懒加载",files:["nl-im-uniapp/src/components/chat/MessageBubble.vue:64-69,225-276"],desc:"urlCard/getMediaUrl/getFileName 等各自独立 parseMessageExtra——图片消息一次渲染至少解析两遍、文件消息三遍长会话滚动时随 re-render 反复放大。",fix:"收敛为一个 computed 供各处复用image 加 lazy-load。"},
{sev:"低",end:"移动端",kind:"优化",area:"音视频通话",title:"formatDuration 三处重复实现且行为不一致;四个通话窗口控制栏高度雷同",files:["nl-im-uniapp/src/composables/useWebRTC.ts:711-716","nl-im-uniapp/src/composables/useGroupWebRTC.ts:511-515","nl-im-uniapp/src/composables/useMiniProgramCall.ts:978-983"],desc:"三个 composable 各实现一遍 formatDuration群通话版没有小时位超 1 小时显示 61:23四个通话窗口的控制栏/接听拒绝按钮/头像波纹结构高度雷同。",fix:"formatDuration 抽到 utils/format 统一带小时位;控制栏抽 CallControls 公共组件。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"朋友圈",title:"ActionMenu 的出入场动画永远不会播放",files:["nl-im-uniapp/src/components/moment/ActionMenu.vue:2-6,59-75"],desc:"v-if 与 .active 绑同一变量:挂载首帧就带终态类,入场过渡没有起始帧;关闭时元素被直接销毁,退出动画也不播。精心编写的 0.2s 过渡全是死样式,菜单生硬闪现/闪没。",fix:"去掉 v-if 仅靠 class 切换,或改用 transition 包裹。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"朋友圈",suspect:true,title:"MomentCommentInput 静态 :focus=\"true\",第二次打开弹窗键盘不再自动弹起",files:["nl-im-uniapp/src/components/moment/MomentCommentInput.vue:23"],desc:"wd-popup 内容首次渲染后常驻 DOMfocus 属性恒为 true 不再变化——小程序/APP 端第二次打开评论弹窗键盘不自动弹起,需手动点输入框。",fix:"focus 改绑 refpopup @open 时先置 false 再 nextTick 置 true。"},
{sev:"低",end:"移动端",kind:"交互bug",area:"消息与连接",title:"storage 同步读写完全没有异常防护",files:["nl-im-uniapp/src/utils/storage.ts:18-160","nl-im-uniapp/src/stores/auth.ts:17-18"],desc:"所有 setStorageSync/getStorageSync 裸调用。H5 Safari 无痕/配额满时写入抛异常会中断登录流程auth store 顶层的 getToken 在 Pinia 初始化时执行,抛异常波及整个应用启动;小程序存储满同样会抛。",fix:"storage 模块统一 try/catch读失败返回 null、写失败静默降级。"}
];
const FINDINGS = FINDINGS_DATA;
const FILTER_DEFS = [
{ key: "sev", label: "严重度", options: ["高", "中", "低"] },
{ key: "end", label: "端", options: ["PC端", "移动端", "两端"] },
{ key: "kind", label: "类别", options: ["交互bug", "优化"] },
{ key: "area", label: "功能域", options: AREAS }
];
const state = { sev: "全部", end: "全部", kind: "全部", area: "全部" };
function renderStats() {
const total = FINDINGS.length;
const count = (fn) => FINDINGS.filter(fn).length;
const cards = [
{ v: total, label: "发现总数" },
{ v: count(f => f.sev === "高"), label: "高危", cls: "danger" },
{ v: count(f => f.sev === "中"), label: "中危", cls: "warning" },
{ v: count(f => f.sev === "低"), label: "低危" },
{ v: count(f => f.kind === "优化"), label: "其中优化项", cls: "info" }
];
document.getElementById("stats").innerHTML = cards.map(c =>
`<div class="stat"><div class="stat-value ${c.cls || ""}">${c.v}</div><div class="stat-label">${c.label}</div></div>`
).join("");
}
function renderChart() {
// 计算每个功能域的三档计数
const data = AREAS.map(a => ({
area: a,
high: FINDINGS.filter(f => f.area === a && f.sev === "高").length,
mid: FINDINGS.filter(f => f.area === a && f.sev === "中").length,
low: FINDINGS.filter(f => f.area === a && f.sev === "低").length,
}));
const maxVal = Math.max(...data.map(d => d.high + d.mid + d.low), 1);
// 画布坐标系(与 cursor/canvas BarChart horizontal 的视觉一致)
const W = 760, H = 300;
const m = { top: 12, right: 24, bottom: 28, left: 116 };
const plotW = W - m.left - m.right;
const plotH = H - m.top - m.bottom;
const n = data.length;
const slot = plotH / n;
const barH = Math.min(slot * 0.62, 22);
// x 轴刻度(向上取整到合适的"好看"刻度)
const tickStep = Math.max(1, Math.ceil(maxVal / 6));
const ticks = [];
for (let v = 0; v <= maxVal; v += tickStep) ticks.push(v);
const xScale = v => m.left + (v / ticks[ticks.length - 1]) * plotW;
const COLORS = { high: "var(--danger)", mid: "var(--warning)", low: "var(--neutral)" };
const LABELS = { high: "高危", mid: "中危", low: "低危" };
// 构造 SVG 片段
let parts = [];
// 网格线 + x 轴刻度
ticks.forEach(v => {
const x = xScale(v);
parts.push(`<line class="grid-line" x1="${x}" y1="${m.top}" x2="${x}" y2="${m.top + plotH}" />`);
parts.push(`<text class="tick-label" x="${x}" y="${H - m.bottom + 16}" text-anchor="middle">${v}</text>`);
});
// 底轴
parts.push(`<line class="axis-line" x1="${m.left}" y1="${m.top + plotH}" x2="${m.left + plotW}" y2="${m.top + plotH}" />`);
// 每条 bar
data.forEach((d, i) => {
const yCenter = m.top + slot * (i + 0.5);
const y = yCenter - barH / 2;
// 功能域名
parts.push(`<text class="cat-label" x="${m.left - 12}" y="${yCenter + 4}" text-anchor="end">${d.area}</text>`);
// 分段:堆叠顺序 高 -> 中 -> 低(与画布 stacked 一致)
const segs = [
{ key: "high", val: d.high },
{ key: "mid", val: d.mid },
{ key: "low", val: d.low },
];
let cursor = m.left;
segs.forEach(s => {
if (s.val <= 0) return;
const segW = (s.val / ticks[ticks.length - 1]) * plotW;
parts.push(
`<rect class="bar-seg" data-area="${d.area}" data-key="${s.key}" data-val="${s.val}" ` +
`x="${cursor}" y="${y}" width="${segW}" height="${barH}" rx="2" fill="${COLORS[s.key]}" />`
);
cursor += segW;
});
// 总数标签贴在 bar 右侧
const total = d.high + d.mid + d.low;
parts.push(`<text class="tick-label" x="${xScale(total) + 6}" y="${yCenter + 4}">${total}</text>`);
});
const svg = document.getElementById("chart-svg");
svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
svg.innerHTML = parts.join("");
// 图例
document.getElementById("chart-legend").innerHTML = ["high", "mid", "low"].map(k =>
`<span class="chart-legend-item"><span class="chart-legend-dot" style="background:${COLORS[k]}"></span>${LABELS[k]}</span>`
).join("");
// hover 交互:高亮当前 bar、其余变暗并显示 tooltip
const tooltip = document.getElementById("chart-tooltip");
function showTooltip(area, evt) {
const d = data.find(x => x.area === area);
tooltip.innerHTML =
`<div class="tt-title">${d.area}</div>` +
`<div class="tt-row"><span class="tt-dot" style="background:${COLORS.high}"></span>高危<span class="tt-val">${d.high}</span></div>` +
`<div class="tt-row"><span class="tt-dot" style="background:${COLORS.mid}"></span>中危<span class="tt-val">${d.mid}</span></div>` +
`<div class="tt-row"><span class="tt-dot" style="background:${COLORS.low}"></span>低危<span class="tt-val">${d.low}</span></div>`;
tooltip.style.display = "block";
moveTooltip(evt);
}
function moveTooltip(evt) {
const pad = 14;
let x = evt.clientX + pad;
let y = evt.clientY + pad;
const rect = tooltip.getBoundingClientRect();
if (x + rect.width > window.innerWidth) x = evt.clientX - rect.width - pad;
if (y + rect.height > window.innerHeight) y = evt.clientY - rect.height - pad;
tooltip.style.left = x + "px";
tooltip.style.top = y + "px";
}
function hideTooltip() {
tooltip.style.display = "none";
svg.classList.remove("has-hover");
svg.querySelectorAll(".bar-seg.hovered").forEach(el => el.classList.remove("hovered"));
}
svg.querySelectorAll(".bar-seg").forEach(seg => {
seg.addEventListener("mouseenter", e => {
svg.classList.add("has-hover");
svg.querySelectorAll(`.bar-seg[data-area="${seg.dataset.area}"]`).forEach(el => el.classList.add("hovered"));
showTooltip(seg.dataset.area, e);
});
seg.addEventListener("mousemove", moveTooltip);
seg.addEventListener("mouseleave", hideTooltip);
});
}
function renderFilters() {
const html = FILTER_DEFS.map(def => {
const pills = ["全部", ...def.options].map(opt =>
`<span class="pill ${state[def.key] === opt ? "active" : ""}" data-key="${def.key}" data-val="${opt}">${opt}</span>`
).join("");
return `<div class="filter-group"><span class="filter-label">${def.label}</span>${pills}</div>`;
}).join("");
document.getElementById("filters").innerHTML = html;
document.querySelectorAll("#filters .pill").forEach(el => {
el.addEventListener("click", () => {
state[el.dataset.key] = el.dataset.val;
renderFilters();
renderFindings();
});
});
}
function escapeHtml(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function renderFindings() {
const filtered = FINDINGS.filter(f =>
(state.sev === "全部" || f.sev === state.sev) &&
(state.end === "全部" || f.end === state.end) &&
(state.kind === "全部" || f.kind === state.kind) &&
(state.area === "全部" || f.area === state.area)
);
document.getElementById("detail-title").textContent = `问题明细(${filtered.length} / ${FINDINGS.length} 条)`;
if (filtered.length === 0) {
document.getElementById("findings").innerHTML = `<div class="empty">当前筛选条件下没有匹配的条目,请调整筛选。</div>`;
return;
}
const order = ["高", "中", "低"];
const sections = order.map(s => {
const items = filtered.filter(f => f.sev === s);
if (items.length === 0) return "";
const meta = SEV_META[s];
const list = items.map((f, i) => {
const tags = [];
if (f.kind === "优化") tags.push(`<span class="tag opt">优化</span>`);
if (f.suspect) tags.push(`<span class="tag suspect">疑似</span>`);
const files = f.files.map(file => `<code>${escapeHtml(file)}</code>`).join("");
return `
<div class="finding">
<div class="finding-summary" data-toggle="${i}-${s}">
<span class="chevron">▶</span>
<span class="finding-title">${escapeHtml(f.title)}${tags.join("")}</span>
<span class="finding-meta">${f.end} · ${f.area}</span>
</div>
<div class="finding-body">
<div class="file-list">${files}</div>
<div class="finding-desc">${escapeHtml(f.desc)}</div>
<div class="finding-fix"><strong>修复建议:</strong>${escapeHtml(f.fix)}</div>
</div>
</div>`;
}).join("");
return `
<div class="sev-header"><span class="sev-dot ${meta.cls}"></span>${meta.label}${items.length} 条)</div>
${list}`;
}).join("");
document.getElementById("findings").innerHTML = sections;
document.querySelectorAll(".finding-summary").forEach(el => {
el.addEventListener("click", () => {
el.parentElement.classList.toggle("open");
});
});
}
renderStats();
renderChart();
renderFilters();
renderFindings();
</script>
</body>
</html>