681 lines
83 KiB
HTML
681 lines
83 KiB
HTML
<!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-ts(PC 端 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:"三个方法同时更新列表项与 currentMoment(id 相同时两份都改),两端同步修复。"},
|
||
{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 变成无效 attribute,ref 拿到的也是组件实例而非真实 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:"朋友圈详情页图片、视频未经 resolveImageUrl,APP/小程序端全部裂图",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 30s,uploadAttachment 没有覆写。发视频、大文件或上行带宽低时 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.parse,localStorage 数据损坏启动即白屏且不自愈",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/@pause,togglePlay 乐观取反(自动播放策略拒绝时不回滚),画中画里的操作也不同步,之后点播放按钮会执行相反操作。",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:"多处直接使用后端相对路径 URL,Electron 打包版图片/下载会失效",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:"统一走 resolveImageUrl;MentionPicker 的头像逻辑直接换成公共 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 再点 B,A 的响应后到会把 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 从不置 false;MentionPicker 声明了 close 事件但组件内没有任何代码/UI 触发它。误输 @ 后删除字符、点击别处都关不掉面板,唯一出路是选一个人。",fix:"MentionPicker 加遮罩/关闭按钮 emit close;onInput 在末尾不再是 @ 时关闭面板。"},
|
||
{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,协商状态机错乱导致部分成员黑屏/一直 connecting;reject 的 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 loop),stopRingtone 真正清理循环。"},
|
||
{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-PLUS(SVG)+ #ifdef MP-WEIXIN(wd-icon)两套分支,项目声明支持支付宝/百度/抖音小程序,但这些平台两个分支都被剔除——TabBar 只剩文字、语音/表情/发送/播放等图标全部消失。若实际只发微信小程序可降级。",fix:"小程序分支放宽为 #ifndef H5 || APP-PLUS(wd-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 内容首次渲染后常驻 DOM,focus 属性恒为 true 不再变化——小程序/APP 端第二次打开评论弹窗键盘不自动弹起,需手动点输入框。",fix:"focus 改绑 ref,popup @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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||
}
|
||
|
||
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>
|