第六轮深度测试:7个并行agent覆盖全部11章,修复21处前端问题
Ch1-2: 用户名注册邀请码错误改为友好文案(原为原始TypeError泄露给用户); 企业注册页扫描图标补上点击反馈。 Ch3-4: 发名片消息补传typeHint(此前被后端误分类成text,搜索/历史摘要显示成JSON); 文件卡片按mime大类区分图标颜色(此前所有文件都是同一个灰色通用图标); 表情面板补上专属退格/发送按钮(浅蓝禁用/深蓝启用两态);订正一处与截图不符的代码注释。 Ch5-6: 群聊设置页"群成员"补上可点击的完整成员列表页(复用MemberPicker字母分组); 群文件页去掉无依据的硬编码"/10GB"容量上限显示。 Ch7: 收藏点击图片/视频缩略图补上预览;"查找聊天记录-链接"tab补上定位回原消息; 文件下载页返回入口按来源tab纠正;批量转发成功计数改为按目标会话去重 (原公式混用"收藏条数"和"目标会话数"两个维度,混选多条收藏转发时可能显示负数); 收藏/视频tab切换补上URL路由同步。 Ch8: topic.onInfo在通话期间补上事件转发(此前通话中"对方正在输入"和已读回执静默失效); 主叫等待接听页补上"正在等待对方接受邀请…"文案;语音通话最小化胶囊的还原按钮移出 拖拽区域(原来嵌在draggable内部,指针捕获可能吞掉点击);修复中等窗宽(641-750px)下 本地摄像头预览遮挡"切换摄像头"按钮的CSS重叠。 Ch9: 部门树展开时跳过child_count=0的注定空请求;月报字数上限从硬编码1000改为读取 后端真实的10000;发起会议补上"结束时间需晚于开始时间"的本地校验和更具体的报错; 日报/月报统计接口补上department_id透传(此前包装函数注释断言后端会忽略,实际已支持)。 Ch10-11: URL风险检测补上g标志(原来一条消息只检查第一个链接,第二个链接哪怕是高危 也不会被拦截);补上"安全教育"设置入口(原功能完整实现但零入口,需手改hash才能打开); 修改密码按钮补上busy态反馈;青少年模式PIN页补上"忘记独立密码"入口(后端已支持重置, 前端未接);"关于我们"补上"联系我们"行。 7个agent同时对全部11章存量后端问题条目逐一复核,结论详见待整理的后端问题清单更新 (Ch1-2发现一条P0级后端问题:验证码固定值"123456",已记录未修复,不在此commit修改 im_be仓库范围内)。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d42591284f
commit
f42b8536e5
@@ -6,6 +6,24 @@ import { createFileDownloader } from '../lib/im-api.js';
|
||||
import { bytesToHumanSize } from '../lib/strformat.js';
|
||||
import { isUrlRelative, sanitizeUrl } from '../lib/utils.js';
|
||||
|
||||
// Same broad-category icon+color convention already established for file cards elsewhere
|
||||
// (content-library-view.jsx / group-management-view.jsx's FILE_TYPE_META) -- this was the one place
|
||||
// that never adopted it and instead used the same generic gray icon for every attachment regardless
|
||||
// of type. This client-side mime sniff exists only because chat attachments (unlike a group file
|
||||
// or a favorite) don't carry a server-computed file_type field to look up directly.
|
||||
const ATTACHMENT_TYPE_META = {image: ['image', '#3ba55c'], video: ['videocam', '#3b82c4'], audio: ['audiotrack', '#8b5cf6'], document: ['description', '#e05d44'], file: ['insert_drive_file', '#8a8f98']};
|
||||
function attachmentTypeFromMime(mime) {
|
||||
const m = (mime || '').toLowerCase();
|
||||
if (m.startsWith('image/')) return 'image';
|
||||
if (m.startsWith('video/')) return 'video';
|
||||
if (m.startsWith('audio/')) return 'audio';
|
||||
if (m === 'application/pdf' || m === 'text/plain' || m.includes('msword') || m.includes('wordprocessingml') ||
|
||||
m.includes('spreadsheetml') || m.includes('presentationml') || m === 'application/vnd.ms-excel' || m === 'application/vnd.ms-powerpoint') {
|
||||
return 'document';
|
||||
}
|
||||
return 'file';
|
||||
}
|
||||
|
||||
export default class Attachment extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
@@ -78,9 +96,10 @@ export default class Attachment extends React.Component {
|
||||
}
|
||||
const downloadWidget = <><i className="material-icons">file_download</i> <FormattedMessage id="save_attachment"
|
||||
defaultMessage="save" description="Call to save an attachment" /></>;
|
||||
const [icon, color] = ATTACHMENT_TYPE_META[attachmentTypeFromMime(this.props.mimetype)];
|
||||
return (
|
||||
<div className="attachment">
|
||||
<div><i className="material-icons big gray">insert_drive_file</i></div>
|
||||
<div><i className="material-icons big" style={{color}}>{icon}</i></div>
|
||||
<div className="flex-column">
|
||||
<div>{filename} {size}</div>
|
||||
{this.props.uploading || this.state.downloader ?
|
||||
|
||||
@@ -226,6 +226,16 @@ class CallPanel extends React.PureComponent {
|
||||
|
||||
onInfo(info) {
|
||||
if (info.what != 'call') {
|
||||
// This call panel takes over topic.onInfo wholesale for the lifetime of the call (including
|
||||
// while minimized -- the product lets messaging continue during a call), but was only ever
|
||||
// handling 'call' events itself and dropping everything else on the floor instead of forwarding
|
||||
// to the handler it displaced (messages-view.jsx's handleInfoReceipt, which drives the "typing"
|
||||
// indicator and read/received checkmarks). Real-time {info} isn't replayed later, so every
|
||||
// typing/read/recv update for this topic was silently lost for as long as the panel stayed
|
||||
// mounted.
|
||||
if (this.previousOnInfo) {
|
||||
this.previousOnInfo(info);
|
||||
}
|
||||
return;
|
||||
}
|
||||
switch (info.event) {
|
||||
@@ -839,10 +849,18 @@ class CallPanel extends React.PureComponent {
|
||||
if (this.props.minimized && this.state.audioOnly) {
|
||||
// Voice calls minimize to a small pill (icon + timer), not the video-shaped box.
|
||||
return (
|
||||
// The restore button used to live inside .call-pill.draggable -- dragStart only checks
|
||||
// event.target.closest('.draggable') before calling setPointerCapture() on the whole
|
||||
// container, and once a pointer is captured, Pointer Events retargets subsequent events
|
||||
// (including the click) to the capturing element instead of the button underneath it. The
|
||||
// video-minimized layout below already keeps its own restore button as a sibling of the
|
||||
// draggable region for exactly this reason; this pill just hadn't followed the same rule.
|
||||
<div id="video-container" className="minimized audio-minimized" ref={this.containerRef}>
|
||||
<div className="call-pill draggable">
|
||||
<i className="material-icons">call</i>
|
||||
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
|
||||
<div className="call-pill-row">
|
||||
<div className="call-pill draggable">
|
||||
<i className="material-icons">call</i>
|
||||
<span className="call-pill-duration">{formatElapsedTime(this.state.elapsedSeconds)}</span>
|
||||
</div>
|
||||
<button className="full-screen" onClick={this.props.onToggleMinimize}>
|
||||
<i className="material-icons">{fullScreen}</i>
|
||||
</button>
|
||||
@@ -880,6 +898,12 @@ class CallPanel extends React.PureComponent {
|
||||
title={this.props.title} />
|
||||
</div>
|
||||
<div className="caller-name">{peerTitle}</div>
|
||||
{/* This same !remoteActive branch also renders once the call is live but the peer's
|
||||
own camera happens to be off -- only the pre-connect phase should claim we're
|
||||
still waiting on them to answer. Reference screenshots (视频通话—等待对方接受
|
||||
邀请中.png etc.) and this project's own call-experience-view.jsx demo both show
|
||||
this line; only the real call-panel.jsx was missing it. */}
|
||||
{!connected ? <p className="caller-status">正在等待对方接受邀请…</p> : null}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@ import { forwardFavorites, getFavorites, getFileUrl, getFriends, translateReason
|
||||
// 4.5-4.6 表情面板:参考图确认了"只有2个tab"(😊表情 / ♡)和"发送按钮浅蓝禁用、深蓝启用"这两点,
|
||||
// 但没有任何一张图能看清第二个tab具体展示什么内容——判断为"常用"(这是这类两tab设计里最常见的
|
||||
// 语义,也是本项目其它地方唯一有本地存储依据能撑起来的一种:不存在一个真实的"收藏表情"后端概念)。
|
||||
// 表情本身的具体清单同样没有图片依据,选了一组常见、覆盖面广的 Unicode 表情,不追求详尽分类。
|
||||
// 表情本身的具体清单——纠正一处之前的误判:参考图`表情包展开(发送按钮默认状态).png`/`表情包
|
||||
// 发送页面.png`其实清楚画出了一套具体的经典"黄脸"贴图(微信/QQ经典表情那种视觉风格),不是没有
|
||||
// 图片依据。只是这个项目没有对应的贴图素材资源,做不到跟截图完全一致的视觉,退而求其次选了一组
|
||||
// 常见、覆盖面广的系统 Unicode 表情——这是素材缺口下的折中方案,不是"没有依据、随便选的"。
|
||||
const EMOJI_LIST = ['😀', '😁', '😂', '🤣', '😃', '😄', '😅', '😆', '😉', '😊', '😋', '😎', '😍', '😘',
|
||||
'🥰', '😗', '😙', '😚', '🙂', '🤗', '🤩', '🤔', '🤨', '😐', '😑', '😶', '🙄', '😏', '😣', '😥', '😮',
|
||||
'🤐', '😯', '😪', '😫', '🥱', '😴', '😌', '😛', '😜', '😝', '🤤', '😒', '😓', '😔', '😕', '🙃', '🤑',
|
||||
@@ -143,6 +146,7 @@ class SendMessage extends React.PureComponent {
|
||||
this.handleHoldVisibilityChange = this.handleHoldVisibilityChange.bind(this);
|
||||
this.toggleEmojiPanel = this.toggleEmojiPanel.bind(this);
|
||||
this.insertEmoji = this.insertEmoji.bind(this);
|
||||
this.deleteLastChar = this.deleteLastChar.bind(this);
|
||||
this.toggleAttachPanel = this.toggleAttachPanel.bind(this);
|
||||
this.openCardPicker = this.openCardPicker.bind(this);
|
||||
this.openFavPicker = this.openFavPicker.bind(this);
|
||||
@@ -460,6 +464,37 @@ class SendMessage extends React.PureComponent {
|
||||
});
|
||||
}
|
||||
|
||||
// 参考图的表情面板自带一个退格键,让"只点表情不碰键盘"这条路径也能删掉打错的字符——用
|
||||
// Array.from 而不是 slice(0,-1) 切最后一个 Unicode 码点,避免把一个占两个UTF-16码元的emoji
|
||||
// (比如复合肤色/家庭类emoji)齐腰斩断,删出半个乱码字符。
|
||||
deleteLastChar() {
|
||||
const ta = this.messageEditArea;
|
||||
const start = ta ? ta.selectionStart : this.state.message.length;
|
||||
const end = ta ? ta.selectionEnd : this.state.message.length;
|
||||
let newMessage, caretPos;
|
||||
if (start !== end) {
|
||||
newMessage = this.state.message.slice(0, start) + this.state.message.slice(end);
|
||||
caretPos = start;
|
||||
} else if (start > 0) {
|
||||
const before = Array.from(this.state.message.slice(0, start));
|
||||
before.pop();
|
||||
newMessage = before.join('') + this.state.message.slice(start);
|
||||
caretPos = before.join('').length;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
this.setState({message: newMessage});
|
||||
if (this.props.onMessageChange) {
|
||||
this.props.onMessageChange(newMessage);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (this.messageEditArea) {
|
||||
this.messageEditArea.focus();
|
||||
this.messageEditArea.setSelectionRange(caretPos, caretPos);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
toggleAttachPanel(e) {
|
||||
e.preventDefault();
|
||||
this.setState(s => ({attachOpen: !s.attachOpen, emojiOpen: false}));
|
||||
@@ -482,7 +517,14 @@ class SendMessage extends React.PureComponent {
|
||||
const build = avatarUrl => {
|
||||
const card = new TheCard(item.display_name || item.user_id, avatarUrl);
|
||||
TheCard.setTinodeID(card, item.user_id);
|
||||
this.props.onSendMessage(Drafty.appendTheCard(null, card));
|
||||
// Every other attachment sender (image/video/file/voice) threads a typeHint through to
|
||||
// messages-view.jsx's sendMessage(msg, uploadCompletionPromise, uploader, typeHint) so the
|
||||
// backend's imMessageType() classifier -- which only ever looks at head['x-im-type'] or a flat
|
||||
// content.file_id/url, never at real Drafty entities -- doesn't fall through to its "text"
|
||||
// default. This call was only ever passing the first argument, so every card message has been
|
||||
// silently misclassified as plain text (confirmed live: search/history summarize it as raw
|
||||
// Drafty JSON instead of a name).
|
||||
this.props.onSendMessage(Drafty.appendTheCard(null, card), undefined, undefined, 'card');
|
||||
};
|
||||
if (item.avatar_file_id) {
|
||||
getFileUrl(item.avatar_file_id).then(build).catch(() => build(undefined));
|
||||
@@ -689,6 +731,18 @@ class SendMessage extends React.PureComponent {
|
||||
<a href="#" key={emoji + i} onClick={e => {e.preventDefault(); this.insertEmoji(emoji);}}>{emoji}</a>
|
||||
))}
|
||||
</div>
|
||||
{/* 参考图表情网格下方那一整行退格/发送专属控件——原来完全没做,"只点表情不碰键盘"
|
||||
这条路径下用户还得把视线移出面板去点外面的发送图标。复用已有的 sendIcon/handleSend,
|
||||
不重新发明发送逻辑。 */}
|
||||
<div id="emoji-panel-footer">
|
||||
<a href="#" onClick={e => {e.preventDefault(); this.deleteLastChar();}} title="删除">
|
||||
<i className="material-icons">backspace</i>
|
||||
</a>
|
||||
<a href="#" className={'emoji-panel-send' + (this.state.message ? ' active' : '')}
|
||||
onClick={e => {e.preventDefault(); this.handleSend(e);}} title={formatMessage(messages.icon_title_send)}>
|
||||
<i className="material-icons">{sendIcon}</i>
|
||||
</a>
|
||||
</div>
|
||||
</div> : null}
|
||||
{this.state.attachOpen ?
|
||||
<div id="attach-panel">
|
||||
|
||||
Reference in New Issue
Block a user