Files
im_webapp/src/views/security-support-view.jsx
T
Your NameandClaude Sonnet 5 4a30cfa6b3 第四轮深度测试:修复7个agent发现的问题,重点是编辑态附件覆盖的完整旁路和通话面板崩溃防护
再次派7个agent覆盖全部11章,验证第三轮修复+找新问题。本次委托agent专门交叉核对
上一轮每一项修复是否真的生效,多数确认有效,但抓出2处"看似修好、实则有旁路"的情况
和若干新问题。

**编辑消息+拖放文件仍可静默覆盖被编辑消息(上一轮修复的完整旁路,Ch3-4 agent发现)**
上一轮给`onAttachFile`/`onAttachImage`/`onAttachAudio`三个prop加了editingMessage门禁,
但`isDragEnabled()`(messages-view.jsx)没有检查这个状态——拖放文件到聊天窗口走的是
`handleFileDrop`直接调用`handleAttachImageOrVideo`/`handleAttachFile`类方法本身,完全
绕开了被置null的props。编辑态下不点"+"号、直接把文件拖进聊天窗口,依然会静默替换掉
被编辑消息的原文。已给`isDragEnabled()`补上同款判断,这是`handleDrop`和渲染层拖放提示
两处调用的唯一门禁函数,一次性堵住。

同一个agent还发现编辑态下点击"回复"另一条消息会静默放弃当前编辑(`handlePickReply`
覆盖`this.state.reply`但不清`contentToEdit`,发送时值走了`reply`分支而不是`editing`
分支,编辑框里的原文被当成对另一条消息的新回复发出去,原消息未被修改)——已在
`handleShowMessageContextMenu`里比照`menu_item_edit`的年龄过滤模式,编辑态下直接不
提供"回复"这个菜单项。

**实名认证驳回提醒的dismissKey修复上一轮实际没生效(Ch1-2 agent发现,已重新修复)**
上一轮把dismissKey从纯status改成"submission_id或status",但live验证发现
`bootstrap.security.realname`这个数据源根本不包含submission_id字段(后端serveIMBootstrap
的实现只有status/required/allow_skip/remind_after四个硬编码字段)——所以这个修复在
真实环境下永远退化回status,等于没有变化,两次独立驳回依然会互相误压制提醒。这次改为
额外调用专用的`GET /security/realname`接口(该接口的实现确实会返回submission_id),
用它的数据驱动dismissKey和提醒触发条件,bootstrap的字段只保留用于remind_after/
allow_skip这些它独有的字段。已用真实账号验证dismissKey现在正确从这个新数据源生成。

**呼叫面板未卸载防护补全到call-panel.jsx(Ch8 agent发现,同类问题上一轮只修了
audio-recorder.jsx)**
`start()`/`unmuteVideo()`/`handleSwitchCameraClick()`三处的getUserMedia/
enumerateDevices异步链,结尾都会touch `this.localRef.current`/`this.state.localStream`,
但都没检查组件是否已经卸载——权限弹窗未决出结果时通话结束(呼出超时/收到挂断/对方
主动挂断),这些promise resolve后会在死实例上抛TypeError,被各自的catch吞掉后又会展示
一句过期的原始英文报错。三处都补了`this._unmounted`检查,拿到的媒体流/轨道直接停止
而不是继续使用。

**其余修复**:`call-message.jsx`补上和`call-status.jsx`一致的"进行中"状态文案(原来
通话进行中且消息还没有最终时长时显示"0:00"而不是"进行中",已用真实渲染验证过两个
组件行为不一致);`tinode-web.jsx`的`handleCallClose`补上重置`callSeq`(避免跨通话
残留旧值)+ 更新`armCallRingTimeout`注释准确描述它是后端30秒建立超时的二级兜底而非
主要防线(后端`call_establishment_timeout`默认30秒,已有独立机制处理"直接不接"的
常见场景);`REASON_MESSAGES`补充`API_KEY_REQUIRED`/`METHOD_NOT_ALLOWED`(此前记录过
一次真实的部署期API Key配置漂移事故,属于这两个reason的真实触发场景);
`message-center-view.jsx`的`NOTIFICATION_TEXT`补充风险确认"取消"结果的文案(发消息
前风险提示点"取消发送"会产生这个系统通知,此前只收录了"继续");会话列表"置顶"从
只变背景色改为真正排到列表最前(原来只有CSS着色,没有任何排序逻辑,已用真实群聊
置顶验证过排序生效且系统消息/文件助手两个固定入口仍保持在最前);好友聊天设置页
置顶/免打扰开关在初始GET未完成前禁用(避免`version:null`打到后端400);
`content-library-view.jsx`的`DownloadPage`修复带外附件下载使用未做域名归一化的裸
相对路径(生产环境webapp和文件域名跨域时会必现下载失败,已拆出`resolveFileUrl`
辅助函数只做URL拼装不做预先fetch,跟`getFileUrl`的实际下载区分开);
`security-support-view.jsx`的意见反馈分类配置改用`useCachedData`获得重试能力(原来
独立实现的边车缓存逻辑没享受到本轮`use-cached-data.js`的重试修复)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:03:41 +08:00

266 lines
19 KiB
React
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.
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
import { readCachedData, useCachedData } from '../lib/use-cached-data.js';
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
};
export const SECURITY_SUPPORT_VIEWS = [
'fraud-risk-warning', 'fraud-risk-confirmation', 'abuse-report-reasons',
'abuse-report-details', 'user-feedback'
];
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back}) {
return <header className="support-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
}
function Toast({message, onDone}) {
React.useEffect(() => {
if (!message) return undefined;
const timer = window.setTimeout(onDone, 2200);
return () => window.clearTimeout(timer);
}, [message, onDone]);
return message ? <div className="support-toast" role="status">{message}</div> : null;
}
// Backend has no "content risk scan" or "post-send fraud alert" mechanism (confirmed against
// im_be/server/im_http.go and session.go -- grepping for 诈骗/反诈/110/fraud in the whole backend
// only turns up abuse-report reason labels, no scanning logic and no system-notification event type
// for it). The two reference images (反诈弹窗(安卓).png / 反诈弹窗(二次提示安卓).png) show generic
// anti-fraud education content that isn't tied to any specific message or backend-driven trigger --
// there's no field anywhere (bootstrap, profile, settings) indicating "has this been shown before".
// So this is implemented as a standalone, always-reachable safety-info page (not something that
// pops up automatically), reusing the two designs' content/copy verbatim for their real interactive
// counterpart: the pre-send URL risk check below (RiskConfirmDialog), which DOES have a real backend
// (POST /security/urls/check + POST /security/risk-challenges/{id}/confirm).
function FraudAwareness({secondary}) {
// 这里必须带上 risk-dialog:本页的 ul/li/aside/.risk-emphasis/button.primary 全部样式都挂在
// base.css 的 .risk-dialog 下(14 条规则),而 .risk-info-page 在 base.css 里一条规则都没有——
// 只写 risk-info-page 的话整页会渲染成带原生项目符号的裸列表。
// 图标名也修正了:shield_lock 只存在于 Material Symbols,index.html 加载的是经典 Material Icons,
// 那套字体里没有这个字形,会原样显示 "shield_lock" 这串文本。改用仓库已在用的 verified_user。
return <div className="support-page risk-warning-page"><Header title="安全提醒" back={secondary ? 'fraud-risk-warning' : 'personal-center'} /><main className="risk-info-page risk-dialog">
<span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'verified_user'}</Icon></span>
{secondary ? <>
<p className="risk-emphasis">您发送的内容可能涉及诈骗风险,请注意甄别。</p>
<p>切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。</p>
<aside><Icon>phone_in_talk</Icon><span>如遇诈骗请立即拨打 110 报警。</span></aside>
</> : <>
<p>为保障您的账号及资金安全,请您注意:</p>
<ul>
<li><Icon>receipt_long</Icon>不参与刷单返利活动</li>
<li><Icon>currency_yuan</Icon>不轻信高收益投资、理财信息</li>
<li><Icon>person_off</Icon>不出租、出借、出售账号及实名信息</li>
<li><Icon>lock</Icon>不向任何人泄露验证码、密码等敏感信息</li>
</ul>
<p className="risk-emphasis">请提高安全防范意识,谨防电信网络诈骗。</p>
</>}
<button type="button" className="primary" onClick={() => navigate(secondary ? 'fraud-risk-warning' : 'personal-center')}>{secondary ? '我已知晓' : '我已阅读并知晓'}</button>
</main></div>;
}
// Exported for messages-view.jsx to render inline when composing a message that contains a URL --
// this is the one piece of Ch.11 with a genuine backend mechanism behind it (checkURL/confirmRiskChallenge,
// verified against im_http.go:1184-1285). `level`/`blocked` come straight from the checkURL response.
export function RiskConfirmDialog({level, blocked, onConfirm, onCancel}) {
return <div className="risk-overlay"><section className="risk-dialog secondary" role="alertdialog" aria-modal="true">
<span className="risk-symbol"><Icon>{blocked ? 'block' : 'warning_amber'}</Icon></span>
<h2>安全提醒</h2>
{blocked ? <p className="risk-emphasis">检测到该内容存在较高安全风险,暂无法发送。</p> : <>
<p className="risk-emphasis">您发送的内容可能涉及诈骗风险,请注意甄别。</p>
<p>切勿轻信陌生人的投资理财、兼职刷单、网络贷款等信息,谨防上当受骗。如遇诈骗请立即拨打 110 报警。</p>
</>}
{blocked
? <button type="button" onClick={onCancel}>我已知晓</button>
: <><button type="button" className="danger" onClick={onConfirm}>我已知晓,继续发送</button><button type="button" onClick={onCancel}>取消发送</button></>}
</section></div>;
}
// target_type is one of user/group/message only (verified against server/store/store.go's
// AbuseReasons validation and im_http.go's normalizeIMAbuseTarget switch -- there is no
// "group_member" variant server-side; reporting a group member uses target_type=user and simply
// loses the "which group" context, which is a real backend limitation, not something fixable here).
function ReportReasons({hashParams, onToast}) {
const targetType = (hashParams && hashParams.target_type) || 'user';
const targetName = (hashParams && hashParams.target_name) || '';
const [reasons, setReasons] = React.useState(null);
React.useEffect(() => {
getAbuseReasons(targetType).then(data => setReasons(Array.isArray(data) ? data : (data && (data.reasons || data.items)) || []))
.catch(err => { onToast(err.message || '加载失败'); setReasons([]); });
}, [targetType]);
const select = reason => navigate('abuse-report-details', {...hashParams, reason_code: reason.reason_code || reason.code, reason_label: reason.label, requires_description: reason.requires_description ? '1' : ''});
{/* 'personal-center' made sense when contact-management-view.jsx's friend-settings page was the
only entry point; tinode-web.jsx's handleReportTopic (block-and-report from inside a chat,
which closes that chat before landing here) now also routes here, and for that origin
'personal-center' is a non-sequitur -- messages-home is a reasonable default for both. */}
return <div className="support-page"><Header title="举报" back="messages-home" /><main className="report-reasons">
{targetName ? <section className="report-target"><Icon>account_circle</Icon><span><strong>举报{targetName}</strong><small>举报人身份不会向对方公开</small></span></section> : null}
<h2>请选择举报原因</h2>
{reasons === null ? <p className="report-loading">加载中…</p> : <section className="report-reason-list">
{reasons.map(reason => <button type="button" key={reason.reason_code || reason.code} onClick={() => select(reason)}>
<span><strong>{reason.label}</strong></span><Icon>chevron_right</Icon>
</button>)}
</section>}
</main></div>;
}
function ReportDetails({hashParams, tinode, onToast}) {
const reasonLabel = (hashParams && hashParams.reason_label) || '';
const reasonCode = (hashParams && hashParams.reason_code) || '';
const requiresDescription = (hashParams && hashParams.requires_description) === '1';
const [description, setDescription] = React.useState('');
const [evidence, setEvidence] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(null);
const fileInputRef = React.useRef(null);
const addEvidence = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
if (evidence.length >= 9) { onToast('最多上传 9 个证据文件'); return; }
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => setEvidence(items => items.concat({id: fileId, name: file.name})))
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
};
const submit = () => {
if (requiresDescription && !description.trim()) { onToast('请补充问题描述'); return; }
const target = {type: hashParams.target_type || 'user'};
if (hashParams.target_type === 'message') { target.topic = hashParams.topic; target.seq = String(hashParams.seq); }
else if (hashParams.target_type === 'group') { target.topic = hashParams.topic || hashParams.target_id; }
else { target.user_id = hashParams.target_id; }
setSubmitting(true);
submitAbuseReport({target, reason_code: reasonCode, description: description.trim(), evidence_file_ids: evidence.map(e => e.id)})
.then(result => setSubmitted(result))
.catch(err => onToast(err.message || '提交失败'))
.finally(() => setSubmitting(false));
};
return <div className="support-page"><Header title="举报" back="abuse-report-reasons" />
{submitted ? <main className="support-success"><Icon>task_alt</Icon><h2>举报已受理</h2><p>我们会依据平台规则核实处理,举报人身份不会向被举报方公开。</p><dl><div><dt>受理编号</dt><dd>{submitted.report_id}</dd></div></dl><button type="button" onClick={() => navigate('messages-home')}>完成</button></main>
: <main className="report-details">
<section className="report-selected-reason"><span><small>举报原因</small><strong>{reasonLabel}</strong></span><button type="button" onClick={() => navigate('abuse-report-reasons', hashParams)}>修改</button></section>
<label className="support-textarea"><span>问题描述{requiresDescription ? <b>*</b> : null}</span><textarea value={description} maxLength="500" onChange={event => setDescription(event.target.value)} placeholder="请描述具体情况、发生时间和相关行为" /><small>{description.length}/500</small></label>
<section className="evidence-section">
<header><span><strong>证据材料</strong><small>图片或文件,最多 9 个</small></span><em>{evidence.length}/9</em></header>
<div>
{evidence.map(file => <article key={file.id}><Icon>image</Icon><span>{file.name}</span><button type="button" onClick={() => setEvidence(items => items.filter(item => item.id !== file.id))} aria-label="删除证据"><Icon>close</Icon></button></article>)}
<button type="button" className="evidence-upload" disabled={uploading} onClick={() => fileInputRef.current && fileInputRef.current.click()}><Icon>add_photo_alternate</Icon><span>{uploading ? '上传中…' : '添加证据'}</span></button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={addEvidence} />
</div>
</section>
<aside className="report-privacy-note"><Icon>lock</Icon><span>证据仅用于平台审核,将按照安全策略保存和访问。</span></aside>
</main>}
{!submitted ? <footer className="support-submit-bar"><button type="button" disabled={submitting} onClick={submit}>{submitting ? '提交中…' : '提交举报'}</button></footer> : null}
</div>;
}
// category is REQUIRED server-side (POST /feedback rejects empty category) but the reference image
// (意见反馈.png) shows no category-selection UI at all -- picking a sensible default silently would
// mean every feedback submission gets misfiled under one bucket. A minimal category picker is added
// here (using the real categories from GET /feedback/config, not the mock's invented four-item list)
// since it's a real, meaningful, backend-required field -- same judgment call as Ch.6's "my nickname
// in this group" editor: no reference image, but a real backend capability worth a minimal UI for.
// `contact` (image has no input for it) is left unset -- it's optional server-side, so omitting it
// doesn't break the request, and adding a field the design never showed would be inventing UI.
function UserFeedback({tinode, onToast}) {
// Fixed/parameterless config (max files, categories, max length, diagnostics notice) reached from a
// single entry point (personal-center's "意见反馈" row) off the same hub whose own profile fetch is
// already cached -- seeding both `config` and its derived default `category` together from the same
// cached object avoids a flash where the category picker renders (gated on `config` alone) with
// nothing selected yet.
const cachedFeedbackConfig = readCachedData('feedback-config');
// Was its own useState + one-shot useEffect with no retry -- a failed first fetch with nothing
// cached yet left `config` stuck at null for the rest of the page's life (the category picker
// below never renders since it's gated on `config`, but content/submit stay visible the whole
// time), and the only way out was a full page reload. Routing through useCachedData gets the same
// "one retry after 3s if there's truly nothing cached yet" behavior bootstrap and friends already
// have, for free.
const [config] = useCachedData('feedback-config', getFeedbackConfig, []);
const [category, setCategory] = React.useState(() => (cachedFeedbackConfig && cachedFeedbackConfig.categories && cachedFeedbackConfig.categories[0]) || '');
const [content, setContent] = React.useState('');
const [images, setImages] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
const [diagnostics, setDiagnostics] = React.useState(false);
const [submitting, setSubmitting] = React.useState(false);
const [submitted, setSubmitted] = React.useState(null);
const fileInputRef = React.useRef(null);
const maxFiles = (config && config.attachment_policy && config.attachment_policy.max_files) || 5;
const maxLength = (config && config.max_length) || 300;
// Only fills in the default once, the first time a fetch (initial or the useCachedData retry)
// actually resolves without one already picked synchronously above from a warm cache -- doesn't
// clobber a category the user already changed.
React.useEffect(() => {
if (config && config.categories && config.categories.length && !category) {
setCategory(config.categories[0]);
}
}, [config]);
const CATEGORY_LABELS = {bug: '功能异常', suggestion: '产品建议', complaint: '服务投诉', other: '其他'};
const addImage = event => {
const file = event.target.files && event.target.files[0];
event.target.value = '';
if (!file || !tinode) return;
if (images.length >= maxFiles) { onToast(`最多上传 ${maxFiles} 张图片`); return; }
setUploading(true);
uploadLargeFile(tinode, file).then(({fileId}) => setImages(items => items.concat({id: fileId})))
.catch(err => onToast(err.message || '上传失败')).finally(() => setUploading(false));
};
const submit = () => {
if (!content.trim()) { onToast('请填写反馈内容'); return; }
// category is a required backend field (empty string 400s as MALFORMED_REQUEST) but the picker
// that sets it only renders once `config` has loaded -- if getFeedbackConfig() is still in
// flight or failed, `category` can still be '' here while content/submit stay visible the whole
// time, so this needs its own explicit guard rather than relying on the picker having run.
if (!category) { onToast('请选择反馈类型'); return; }
setSubmitting(true);
submitFeedback({category, content: content.trim(), attachment_file_ids: images.map(i => i.id), include_diagnostics: diagnostics})
.then(result => setSubmitted(result))
.catch(err => onToast(err.message || '提交失败'))
.finally(() => setSubmitting(false));
};
if (submitted) return <div className="support-page"><Header title="意见反馈" back="personal-center" /><main className="support-success"><Icon>mark_email_read</Icon><h2>反馈已受理</h2><p>感谢你的反馈,我们会尽快查看并用于改进产品。</p><dl><div><dt>反馈编号</dt><dd>{submitted.feedback_id}</dd></div></dl><button type="button" onClick={() => navigate('personal-center')}>返回我的</button></main></div>;
return <div className="support-page feedback-page"><Header title="意见反馈" back="personal-center" /><main>
{config && config.categories && config.categories.length > 1 ? <section className="feedback-categories"><h2>反馈类型</h2><div>
{config.categories.map(id => <button type="button" className={category === id ? 'active' : ''} key={id} onClick={() => setCategory(id)}>{CATEGORY_LABELS[id] || id}</button>)}
</div></section> : null}
<label className="support-textarea">
<span>问题反馈<b>*</b></span>
<textarea value={content} maxLength={maxLength} onChange={event => setContent(event.target.value)} placeholder="为了更好的帮您解决问题,请在提交问题时上传截图信息,方便我们更快速的帮助您解决问题。" />
<small>{content.length}/{maxLength}</small>
</label>
<section className="feedback-upload">
<header><span><strong>图片</strong></span><em>{images.length}/{maxFiles}</em></header>
<button type="button" disabled={uploading} onClick={() => fileInputRef.current && fileInputRef.current.click()}><Icon>add_a_photo</Icon><span>{uploading ? '上传中…' : '点击上传'}</span></button>
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={addImage} />
</section>
{config && config.diagnostics_notice ? <label className="diagnostics-consent"><input type="checkbox" checked={diagnostics} onChange={event => setDiagnostics(event.target.checked)} /><span><strong>同意上传脱敏诊断信息</strong><small>{config.diagnostics_notice}</small></span></label> : null}
</main><footer className="support-submit-bar"><button type="button" disabled={submitting} onClick={submit}>{submitting ? '提交中…' : '提交'}</button></footer></div>;
}
export default function SecuritySupportView({view, hashParams, tinode}) {
const [toast, setToast] = React.useState('');
let content;
if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = <FraudAwareness secondary={view === 'fraud-risk-confirmation'} />;
else if (view === 'abuse-report-reasons') content = <ReportReasons hashParams={hashParams} onToast={setToast} />;
else if (view === 'abuse-report-details') content = <ReportDetails hashParams={hashParams} tinode={tinode} onToast={setToast} />;
else content = <UserFeedback tinode={tinode} onToast={setToast} />;
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
}