派了Ch1-2/3-4/5-6/7/8/9/10-11共7个agent对全部11章重新深度测试。后端问题已核实追加进
docs/IM-后端问题清单-2026-09-04.md(新增6-9条,发给用户);本次是前端问题的修复。
**REASON_MESSAGES系统性补全(最高优先级,多个agent独立发现同一个缺口)**
im-api.js的REASON_MESSAGES表新增22个条目,最重要的是PERMISSION_DENIED——后端
server/im_http.go里403响应统一用这个reason(30余处调用),但表里原来只有一个从未
被后端实际发送过的死条目FORBIDDEN,导致全站几乎每一个403从工程一开始就在展示原始
英文"permission denied"。一并补上VERSION_CONFLICT/MALFORMED_REQUEST(Ch9 agent实测
命中)、NOT_FOUND、VERIFICATION_RATE_LIMITED(动态文案,同TOO_MANY_ATTEMPTS模式)等。
新增translateReason()导出函数并用于send-message.jsx的sendFavorite——favorites/forward
等几个批量接口恒定200、真实成败包在items[].reason里,imRequest的自动翻译够不到这种
per-item场景,之前直接显示原始reason字符串。tinode-web.jsx同理补了CTRL_ERROR_TEXT_CN
(原生WS {ctrl}协议错误,同样绕开REASON_MESSAGES)并修了原表从未真正匹配过的bug——
CommError会把状态码拼进message变成"authentication failed (401)",原表按裸文本精确
匹配,看着正常纯粹是因为401/403兜底文案凑巧一样,一旦命中"unknown authentication
scheme"这类会被兜底文案误导、"policy violation"这类会直接漏出英文。uploadLargeFile/
createChatUploader/createFileDownloader同样绕开REASON_MESSAGES(走原生{ctrl}协议),
补了UPLOAD_CTRL_TEXT_CN小词典。已用真实账号端到端验证:favorites/forward到P2P目标
触发的PERMISSION_DENIED现在正确显示"没有权限执行此操作";draft接口版本冲突正确显示
"内容已被修改,请刷新后重试"。
**编辑消息时打开附件面板会静默覆盖被编辑消息(Ch3-4 agent发现,数据损坏级别)**
sendMessage对editing状态一律用head.replace,但onAttachFile/onAttachImage/onAttachAudio
原来只在forwardMessage时置空,编辑态没有同款门禁——编辑一条消息不改文字、点"+"选张图片
或名片,会直接把附件内容替换掉被编辑消息的原文,无任何确认。messages-view.jsx新增
editingMessage门禁,和forwardMessage一样把三个attach回调置空(等价于隐藏整个"+"按钮)。
已用真实浏览器验证:编辑态下"+"按钮消失,取消编辑后正常恢复。
**草稿防抖单一共享定时器丢字(Ch1-2、Ch3-4两个agent独立发现)**
handleComposerChange用单个this.draftSaveTimer,话题A打字触发600ms保存后300ms内切到
话题B,会把A的定时器clearTimeout掉,那次输入永久丢失;version也在定时器触发时才重新
读state,此时可能已经在别的话题上,退化成硬编码0——叠加后端draft接口对version=0"无条件
跳过乐观锁"的行为(已记入后端清单新增6),可能静默覆盖别的标签页写入的更新草稿。改为
按话题分开的定时器map,version在调用的当下(保证仍在该话题)同步捕获进闭包。
**呼叫状态机两处真实bug(Ch8 agent此前发现,本次重新核实live代码后确认+修复)**
1. 通话中导航离开messages-view.jsx整个组件树(切到工作台/个人设置,或移动端切回会话
列表)时,没有任何hangup清理——componentSetup已有的"切换话题时正确挂断"逻辑只在
componentDidUpdate里,组件真正卸载时不会触发。CallPanel自己的componentWillUnmount
只做本地清理,不通知对方也不重置tinode-web.jsx顶层callState,导致下次切回通话话题
时remount出一个callState卡在CALL_STATE_IN_PROGRESS的"僵尸"面板——start()走"我们刚
接听"分支直接return,从不调getUserMedia,本地媒体永久拿不到。补了对称的
componentWillUnmount挂断逻辑。
2. this.callTimeoutTimer声明后从未被真正setTimeout赋值——呼出无人接听或accept信令
(fire-and-forget的{note}包)被丢会永久卡在"呼叫中"。补了armCallRingTimeout(),
60秒未接通按跟手动取消完全一样的路径挂断(不发明新的通话结果语义),在收到对方
accept信号的两处分支都正确清timer。
**其余前端修复(按文件)**
- messages-view.jsx: @提醒mentionedUsers只在切话题时清空,同一话题内后续消息里字面
文本"@张三"会被静默解析成真实提醒(发送后清空);jumpToMessage点开已撤回/删除的
搜索结果原来只console.error静默失败,补了用户可见的错误提示;批量收藏对
favorites/batch共享Idempotency-Key导致的静默丢失(已知后端问题)不再照单全收
"收藏成功",改为发送后重新拉取真实收藏列表核对每条是否真的落地。
- audio-recorder.jsx: getUserMedia权限弹窗未决出结果时组件被卸载,补_unmounted
标记避免在死实例上继续跑mediaRecorder.start()。
- send-message.jsx: disabled变为true时(对方拉黑/被禁言等实时ACS变化)没有强制结束
正在进行的按住录音,可能remount出无用户手势的"自动录音";mouseup/touchend覆盖不到
拖出窗口/切换标签页/权限弹窗抢焦点的释放场景,补了blur/visibilitychange兜底。
- tinode-web.jsx: messages-menu视图和messages-home共用MessageCenterView实例无key,
从messages-home以非整体刷新方式切到messages-menu时菜单打不开,补key={view}。
- message-center-view.jsx: 实名认证驳回提醒的dismiss key只按用户+状态区分,同一状态
的两次独立驳回会互相误压制,改按submission_id区分。
- contact-management-view.jsx: Profile/Settings靠getUserProfile刷新拉黑状态,但该
接口对被拉黑对象直接403,导致"之前会话就已拉黑"的场景完全感知不到——叠加
getConversationContext兜底(该接口对拉黑目标正确返回200+relationship)。
- group-management-view.jsx: AdminList显示群主/管理员姓名漏了group_nickname兜底;
JoinRequests从聊天页横幅进入时返回按钮硬编码回group-settings(应回聊天本身),
从Governance进入时返回group-settings而非group-governance(与AdminList/Muted不一致)。
- workplace-collaboration-view.jsx: OrganizationDirectory/DeptNode的部门树加载失败
被无条件当成"暂无数据"渲染,补loadError区分+重试链接。
- personal-settings-view.jsx: CacheClear对navigator.storage/caches整个不存在的情况
只有react出了范围的.catch()覆盖不到的同步抛错,加了特性检测+try/catch;
YouthModeVerify触发TOO_MANY_ATTEMPTS锁定后确定按钮没有禁用/倒计时,会继续发出
注定失败的请求。
- security-support-view.jsx: 意见反馈category字段在config未加载完成前可为空但没有
提交前校验,会打到后端400。
- use-cached-data.js: bootstrap等首次拉取失败且无缓存兜底时永久停在undefined,加了
一次3秒后重试(仅在完全没有缓存值时触发,已有缓存值维持原"显示旧数据"行为不变)。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
258 lines
18 KiB
React
258 lines
18 KiB
React
import React from 'react';
|
||
|
||
import HashNavigation from '../lib/navigation.js';
|
||
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
|
||
import { readCachedData, writeCachedData } 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');
|
||
const [config, setConfig] = React.useState(() => cachedFeedbackConfig || null);
|
||
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;
|
||
|
||
React.useEffect(() => {
|
||
getFeedbackConfig().then(data => {
|
||
writeCachedData('feedback-config', data);
|
||
setConfig(data); setCategory((data.categories && data.categories[0]) || 'other');
|
||
}).catch(() => {});
|
||
}, []);
|
||
|
||
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('')} /></>;
|
||
}
|