Convert im_webapp from static mock prototypes to real backend integration

Implements all 11 product chapters (auth, messaging, contacts, groups,
files/favorites, calls, workplace/reports, settings, security) against the
real /v1/im REST API and native Tinode WS protocol, replacing the hardcoded
mock data the ba8f48ee prototype commit shipped with. Adds src/lib/im-api.js
as the REST client, plus supporting fixes across messages-view.jsx and the
attachment/upload/download pipeline (wrong SDK auth headers, group topic
naming, seq/timezone edge cases) found via live end-to-end testing against
the real backend.

Backup checkpoint before reconciling with origin/dev's parallel rewrite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 11:40:53 +08:00
co-authored by Claude Sonnet 5
parent ba8f48ee83
commit 251c9de87c
36 changed files with 52812 additions and 5199 deletions
+205 -45
View File
@@ -1,29 +1,26 @@
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 => HashNavigation.navigateTo(view);
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'
];
const REPORT_REASON_KEY = 'im-demo-abuse-reason';
const REPORT_REASONS = [
{code: 'fraud', label: '诈骗或骗取钱财', description: '诱导转账、刷单、投资或提供验证码', icon: 'currency_yuan', required: true},
{code: 'harassment', label: '骚扰或恶意联系', description: '频繁打扰、辱骂、威胁或跟踪', icon: 'record_voice_over'},
{code: 'sexual', label: '色情或低俗内容', description: '发送色情图片、文字或相关服务信息', icon: 'visibility_off'},
{code: 'illegal', label: '违法违禁内容', description: '赌博、毒品、暴力或其他违法信息', icon: 'gavel'},
{code: 'misinformation', label: '谣言或虚假信息', description: '发布误导性、冒充身份或不实信息', icon: 'campaign'},
{code: 'infringement', label: '侵权行为', description: '侵犯隐私、肖像、著作权或商标权', icon: 'copyright'},
{code: 'other', label: '其他违规行为', description: '不属于以上类型的问题', icon: 'more_horiz', required: true}
];
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={() => navigate(back)} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
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}) {
@@ -35,53 +32,216 @@ function Toast({message, onDone}) {
return message ? <div className="support-toast" role="status">{message}</div> : null;
}
function ConversationPreview() {
return <div className="risk-conversation-preview"><header><button type="button"><Icon>arrow_back_ios_new</Icon></button><span><strong>安妮</strong><small>在线</small></span><Icon>more_vert</Icon></header><main><p>今天 14:28</p><div className="peer">这个内部项目收益很高,名额有限。</div><div className="peer">先转一笔保证金,我马上帮你办理。</div><div className="mine">好的,我现在转账。</div></main><footer><span>输入消息</span><Icon>add_circle_outline</Icon></footer></div>;
// 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}) {
return <div className="support-page risk-warning-page"><Header title="安全提醒" back={secondary ? 'fraud-risk-warning' : 'personal-center'} /><main className="risk-info-page">
<span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'shield_lock'}</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>;
}
function RiskWarning({secondary, onToast}) {
const continueAction = () => {
if (!secondary) return navigate('fraud-risk-confirmation');
onToast('已记录风险确认,将返回原操作');
window.setTimeout(() => navigate('chat-standard'), 750);
};
return <div className="support-page risk-warning-page"><ConversationPreview /><div className="risk-overlay"><section className={'risk-dialog' + (secondary ? ' secondary' : '')} role="alertdialog" aria-modal="true"><span className="risk-symbol"><Icon>{secondary ? 'warning_amber' : 'shield_lock'}</Icon></span><h2>安全提醒</h2>{secondary ? <><p className="risk-emphasis">当前操作可能涉及诈骗风险,请再次确认。</p><p>请勿轻信陌生人的刷单返利、投资理财、网络贷款等信息。验证码、密码和转账信息不要提供给任何人。</p><aside><Icon>phone_in_talk</Icon><span>如遇诈骗,请立即停止操作并拨打 110 报警。</span></aside></> : <><p>为保障你的账号和资金安全,请注意:</p><ul><li><Icon>money_off</Icon>不参与刷单返利或高收益投资</li><li><Icon>badge</Icon>不出租、出借或出售账号和实名信息</li><li><Icon>password</Icon>不向任何人泄露验证码和密码</li><li><Icon>link_off</Icon>不打开来源不明的链接或安装包</li></ul></>}<button type="button" className={secondary ? 'danger' : 'primary'} onClick={continueAction}>{secondary ? '我已确认风险,仍要继续' : '我已阅读,继续'}</button><button type="button" onClick={() => {onToast('操作已取消'); window.setTimeout(() => navigate('chat-standard'), 650);}}>{secondary ? '停止操作' : '取消'}</button></section></div></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>;
}
function ReportReasons() {
const select = reason => {window.sessionStorage.setItem(REPORT_REASON_KEY, JSON.stringify(reason)); navigate('abuse-report-details');};
return <div className="support-page"><Header title="举报" back="friend-settings" /><main className="report-reasons"><section className="report-target"><img src="img/bkg/p09.jpg" alt="" /><span><strong>举报安妮</strong><small>举报人身份不会向对方公开</small></span><Icon>verified_user</Icon></section><h2>请选择举报原因</h2><section className="report-reason-list">{REPORT_REASONS.map(reason => <button type="button" key={reason.code} onClick={() => select(reason)}><span><Icon>{reason.icon}</Icon></span><span><strong>{reason.label}</strong><small>{reason.description}</small></span><Icon>chevron_right</Icon></button>)}</section></main></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({onToast}) {
const reason = (() => {try { return JSON.parse(window.sessionStorage.getItem(REPORT_REASON_KEY)) || REPORT_REASONS[0]; } catch (_) { return REPORT_REASONS[0]; }})();
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 [submitted, setSubmitted] = React.useState(false);
const addEvidence = () => {if (evidence.length >= 5) return onToast('最多上传 5 个证据文件'); setEvidence(items => items.concat({id: Date.now(), name: `聊天截图_${items.length + 1}.png`}));};
const submit = () => {if (reason.required && !description.trim()) return onToast('请补充问题描述'); setSubmitted(true);};
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>ABR-20260825-1024</dd></div><div><dt>当前状态</dt><dd>审核中</dd></div></dl><button type="button" onClick={() => navigate('messages-home')}>完成</button></main> : <main className="report-details"><section className="report-selected-reason"><Icon>{reason.icon}</Icon><span><small>举报原因</small><strong>{reason.label}</strong></span><button type="button" onClick={() => navigate('abuse-report-reasons')}>修改</button></section><label className="support-textarea"><span>问题描述{reason.required ? <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>图片或文件,最多 5 个</small></span><em>{evidence.length}/5</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" onClick={addEvidence}><Icon>add_photo_alternate</Icon><span>添加证据</span></button></div></section><aside className="report-privacy-note"><Icon>lock</Icon><span>证据仅用于平台审核,将按照安全策略保存和访问。</span></aside></main>} {!submitted ? <footer className="support-submit-bar"><button type="button" onClick={submit}>提交举报</button></footer> : null}</div>;
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>;
}
function UserFeedback({onToast}) {
const [category, setCategory] = React.useState('bug');
// 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 [contact, setContact] = React.useState('');
const [attachments, setAttachments] = React.useState(0);
const [images, setImages] = React.useState([]);
const [uploading, setUploading] = React.useState(false);
const [diagnostics, setDiagnostics] = React.useState(false);
const [submitted, setSubmitted] = React.useState(false);
const submit = () => {if (!content.trim()) return onToast('请填写反馈内容'); setSubmitted(true);};
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>FB-20260825-0836</dd></div><div><dt>提交信息</dt><dd>Android 1.2.4</dd></div></dl><button type="button" onClick={() => navigate('personal-center')}>返回我的</button></main></div>;
const categories = [{id: 'bug', label: '功能异常', icon: 'bug_report'}, {id: 'suggestion', label: '产品建议', icon: 'lightbulb'}, {id: 'complaint', label: '服务投诉', icon: 'support_agent'}, {id: 'other', label: '其他', icon: 'more_horiz'}];
return <div className="support-page feedback-page"><Header title="意见反馈" back="personal-center" /><main><section className="feedback-categories"><h2>反馈类型</h2><div>{categories.map(item => <button type="button" className={category === item.id ? 'active' : ''} key={item.id} onClick={() => setCategory(item.id)}><Icon>{item.icon}</Icon><span>{item.label}</span></button>)}</div></section><label className="support-textarea"><span>问题或建议<b>*</b></span><textarea value={content} maxLength="1000" onChange={event => setContent(event.target.value)} placeholder="请详细描述你遇到的问题或建议" /><small>{content.length}/1000</small></label><section className="feedback-upload"><header><span><strong>截图或附件</strong><small>最多 5 个,单个不超过 20 MB</small></span><em>{attachments}/5</em></header><button type="button" onClick={() => attachments < 5 ? setAttachments(value => value + 1) : onToast('最多上传 5 个附件')}><Icon>add_photo_alternate</Icon><span>{attachments ? `已选择 ${attachments} 个附件` : '添加附件'}</span></button></section><label className="feedback-contact"><span>联系方式</span><input value={contact} maxLength="80" onChange={event => setContact(event.target.value)} placeholder="手机号或邮箱(选填)" /></label><label className="diagnostics-consent"><input type="checkbox" checked={diagnostics} onChange={event => setDiagnostics(event.target.checked)} /><span><strong>同意上传脱敏诊断信息</strong><small>仅包含 Android 版本、应用版本和错误日志,不包含聊天内容、密码或 Token。</small></span></label></main><footer className="support-submit-bar"><button type="button" onClick={submit}>提交反馈</button></footer></div>;
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; }
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}) {
export default function SecuritySupportView({view, hashParams, tinode}) {
const [toast, setToast] = React.useState('');
let content;
if (view === 'fraud-risk-warning' || view === 'fraud-risk-confirmation') content = <RiskWarning secondary={view === 'fraud-risk-confirmation'} onToast={setToast} />;
else if (view === 'abuse-report-reasons') content = <ReportReasons />;
else if (view === 'abuse-report-details') content = <ReportDetails onToast={setToast} />;
else content = <UserFeedback onToast={setToast} />;
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('')} /></>;
}