- 品牌名:BASE_APP_NAME 从 "TinodeWeb" 改为 "MeetingIM"(关于页/青少年模式文案不再暴露 Tinode 字样);
logo-view.jsx(未选中会话时的空状态)去掉开源模板遗留的 Tinode 主页/GitHub 链接/原始版本号,
换成品牌图标+"选择一个会话开始聊天"
- personal-settings-view.jsx:删除 ChangePassword 页面里被误当正文渲染的开发注释(提及 Tinode 协议),
改回真正的代码注释
- base.css:.risk-info-page(安全提醒整页)补 margin:0 auto——原来复用模态弹窗的 .risk-dialog 样式,
宽屏下整块内容贴左显示,没有居中
- base.css:.group-member-picker/.group-settings-header/.group-member-section-title 的 strong/small
漏加入既有的 line-height:1.3 覆盖名单,继承通用 button{line-height:2.2rem},导致群成员列表/群聊设置
头部的姓名与角色/简介文字行间距异常大、贴底部分割线过近
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
770 lines
48 KiB
React
770 lines
48 KiB
React
import React from 'react';
|
||
import QRCode from 'qrcodejs';
|
||
|
||
import HashNavigation from '../lib/navigation.js';
|
||
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
|
||
import { BASE_APP_NAME, LINK_CONTACT_US } from '../config.js';
|
||
import { PACKAGE_VERSION } from '../version.js';
|
||
import {
|
||
getMeProfile, updateMeProfile, getFileUrl, uploadLargeFile,
|
||
getMePrivacy, updateMePrivacy, getYouthMode, enableYouthMode, disableYouthMode, verifyIndependentPassword,
|
||
createMyQRCode, getRealnameStatus
|
||
} from '../lib/im-api.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 PERSONAL_SETTINGS_VIEWS = [
|
||
'personal-center', 'personal-profile', 'profile-nickname', 'profile-birth-date',
|
||
'profile-signature', 'profile-gender', 'profile-region', 'personal-qrcode', 'android-settings',
|
||
'cache-clear-confirm', 'clear-history-confirm', 'change-password', 'delete-account-confirm',
|
||
'app-version', 'privacy-settings', 'friend-discovery-settings',
|
||
'youth-mode-off', 'youth-mode-on', 'youth-mode-verify',
|
||
'notification-permission', 'permission-settings-reminder', 'camera-permission-info',
|
||
'microphone-permission-info'
|
||
];
|
||
|
||
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
|
||
|
||
function Header({title, back = 'personal-center', action, actionLabel, onAction, actionDisabled}) {
|
||
return <header className="personal-header">
|
||
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
|
||
<h1>{title}</h1>
|
||
{action ? <button type="button" className="text" onClick={onAction} disabled={actionDisabled}>{actionLabel || action}</button> : <span />}
|
||
</header>;
|
||
}
|
||
|
||
function Toast({message, onDone}) {
|
||
React.useEffect(() => {
|
||
if (!message) return undefined;
|
||
const timer = window.setTimeout(onDone, 2100);
|
||
return () => window.clearTimeout(timer);
|
||
}, [message, onDone]);
|
||
return message ? <div className="personal-toast" role="status">{message}</div> : null;
|
||
}
|
||
|
||
function Toggle({checked, disabled, onChange}) {
|
||
return <button type="button" role="switch" aria-checked={checked} disabled={disabled}
|
||
className={'personal-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>;
|
||
}
|
||
|
||
function MenuRow({icon, title, value, route, onClick, danger}) {
|
||
return <button type="button" className={'personal-menu-row' + (danger ? ' danger' : '')}
|
||
onClick={onClick || (() => route && navigate(route))}>
|
||
{icon ? <Icon>{icon}</Icon> : null}
|
||
<span><strong>{title}</strong>{value ? <small>{value}</small> : null}</span>
|
||
{route || onClick ? <Icon>chevron_right</Icon> : null}
|
||
</button>;
|
||
}
|
||
|
||
// docs/images/我的.png 的"企业名称"字段实际上是租户名,不是组织架构的部门 -- 复用 Ch.9 workbench
|
||
// 已经验证过的读取方式(登录时 tenants/resolve 缓存进 localStorage['im-tenant'])。
|
||
function tenantName() {
|
||
try { return JSON.parse(window.localStorage.getItem('im-tenant') || '{}').name || ''; } catch (_) { return ''; }
|
||
}
|
||
|
||
function ImgFromFile({fileId, className}) {
|
||
const [url, setUrl] = React.useState(null);
|
||
React.useEffect(() => { let live = true; getFileUrl(fileId).then(u => live && setUrl(u)).catch(() => {}); return () => { live = false; }; }, [fileId]);
|
||
return url ? <img className={className} src={url} alt="" /> : <span className={className} />;
|
||
}
|
||
|
||
function PersonalCenter() {
|
||
// Was `useState(null)` + its own effect -- remounting this (e.g. tabbing back from 设置) blanked
|
||
// the name/avatar back to the loading placeholder for a beat even though nothing had changed,
|
||
// same "switching pages feels like it reloads every time" complaint this cache already fixed for
|
||
// the workbench/message list/address book.
|
||
const [profile] = useCachedData('me-profile', getMeProfile, []);
|
||
return <div className="personal-page personal-center-page"><Header title="我的" back="messages-home" /><main>
|
||
<button type="button" className="personal-identity" onClick={() => navigate('personal-profile')}>
|
||
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <span className="personal-avatar-fallback"><Icon>person</Icon></span>}
|
||
<span><strong>{(profile && (profile.nickname || profile.public_account)) || '…'}</strong>
|
||
<small>企业名称:{tenantName() || '…'}</small>
|
||
<small>ID: {(profile && profile.public_account) || '…'}</small></span>
|
||
<Icon>chevron_right</Icon>
|
||
</button>
|
||
<section><MenuRow icon="qr_code" title="我的二维码" route="personal-qrcode" /></section>
|
||
<section>
|
||
<MenuRow icon="bookmark_border" title="我的收藏" route="favorites" />
|
||
<MenuRow icon="report_problem" title="关于我们" route="app-version" />
|
||
<MenuRow icon="feedback" title="意见反馈" route="user-feedback" />
|
||
<MenuRow icon="settings" title="设置" route="android-settings" />
|
||
</section>
|
||
</main></div>;
|
||
}
|
||
|
||
// docs/images/个人信息.png 两张截图字段不完全一样(一张有用户名/职位/公司,另一张有ID号/地区)--
|
||
// 代码调研确认 /me/profile 真实字段只有 avatar/nickname/gender/birth_date/region/signature/email;
|
||
// "职位/公司"属于第9章组织架构数据源(这里没有反查接口,不展示);"ID号"、"用户名"是原型臆造字段,
|
||
// 后端没有对应概念(用户id恒为usrXXX格式),不做。
|
||
function PersonalProfile({onToast, tinode}) {
|
||
// Shares the 'me-profile' key with PersonalCenter's already-fixed cache -- this is the hub every
|
||
// field editor below navigates back to on save, so it's revisited constantly.
|
||
const [profile, setProfile] = React.useState(() => readCachedData('me-profile') || null);
|
||
const fileInputRef = React.useRef(null);
|
||
const [uploading, setUploading] = React.useState(false);
|
||
|
||
const load = () => getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});
|
||
React.useEffect(() => { load(); }, []);
|
||
|
||
const pickAvatar = () => fileInputRef.current && fileInputRef.current.click();
|
||
const handleAvatarFile = event => {
|
||
const file = event.target.files && event.target.files[0];
|
||
event.target.value = '';
|
||
if (!file || !tinode) return;
|
||
setUploading(true);
|
||
uploadLargeFile(tinode, file).then(({fileId}) => updateMeProfile({avatar_file_id: fileId, version: profile.version}))
|
||
.then(load).then(() => onToast('头像已更新'))
|
||
.catch(err => onToast(err.message || '头像上传失败')).finally(() => setUploading(false));
|
||
};
|
||
|
||
const genderLabel = {male: '男', female: '女', other: '其他'}[profile && profile.gender] || '未设置';
|
||
|
||
return <div className="personal-page"><Header title="个人信息" /><main className="personal-profile-list">
|
||
<button type="button" onClick={pickAvatar} disabled={uploading}>
|
||
<strong>头像</strong>
|
||
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <Icon>person</Icon>}
|
||
<Icon>chevron_right</Icon>
|
||
</button>
|
||
<input ref={fileInputRef} type="file" accept="image/*" style={{display: 'none'}} onChange={handleAvatarFile} />
|
||
<MenuRow title="昵称" value={profile && (profile.nickname || '未设置')} route="profile-nickname" />
|
||
<MenuRow title="性别" value={genderLabel} route="profile-gender" />
|
||
<MenuRow title="出生日期" value={(profile && profile.birth_date) || '未设置'} route="profile-birth-date" />
|
||
<MenuRow title="二维码名片" route="personal-qrcode" />
|
||
<MenuRow title="地区" value={(profile && profile.region) || '未设置'} route="profile-region" />
|
||
<MenuRow title="个性签名" value={(profile && profile.signature) || '此人很懒什么都没留下~'} route="profile-signature" />
|
||
</main></div>;
|
||
}
|
||
|
||
// Real field_rules confirmed against server/im_http.go: nickname max_length=32, signature
|
||
// max_length=120 -- the reference screenshots show 120/20 respectively (swapped and wrong),
|
||
// mock code already had the right numbers by luck; kept here but sourced from a constant that
|
||
// matches the server contract rather than the screenshots.
|
||
const FIELD_LIMITS = {nickname: 32, signature: 120};
|
||
|
||
function TextEditor({field, multiline, title, onToast}) {
|
||
const [profile, setProfile] = React.useState(null);
|
||
const [value, setValue] = React.useState('');
|
||
const [saving, setSaving] = React.useState(false);
|
||
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p[field] || ''); }).catch(() => {}); }, [field]);
|
||
|
||
const save = () => {
|
||
const normalized = value.trim();
|
||
if (field === 'nickname' && !normalized) { onToast('昵称不能为空'); return; }
|
||
setSaving(true);
|
||
updateMeProfile({[field]: normalized, version: profile.version}).then(_ => {
|
||
onToast('资料已保存');
|
||
window.setTimeout(() => navigate('personal-profile'), 500);
|
||
}).catch(err => onToast(err.message || '保存失败')).finally(() => setSaving(false));
|
||
};
|
||
|
||
const maxLength = FIELD_LIMITS[field];
|
||
return <div className="personal-page profile-editor-page">
|
||
<Header title={title} back="personal-profile" action="完成" onAction={save} actionDisabled={!profile || saving} />
|
||
<main>
|
||
<label>
|
||
{multiline ?
|
||
<textarea value={value} maxLength={maxLength} autoFocus placeholder="请输入……" onChange={event => setValue(event.target.value)} /> :
|
||
<input value={value} maxLength={maxLength} autoFocus onChange={event => setValue(event.target.value)} />}
|
||
<small>{value.length}/{maxLength}</small>
|
||
{value ? <button type="button" onClick={() => setValue('')} aria-label="清空"><Icon>cancel</Icon></button> : null}
|
||
</label>
|
||
<p>{field === 'nickname' ? '昵称将展示在联系人资料和聊天页面中' : '签名将展示在你的个人资料页'}</p>
|
||
</main>
|
||
</div>;
|
||
}
|
||
|
||
function BirthDateEditor({onToast}) {
|
||
const [profile, setProfile] = React.useState(null);
|
||
const [value, setValue] = React.useState('');
|
||
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.birth_date || ''); }).catch(() => {}); }, []);
|
||
const save = () => {
|
||
updateMeProfile({birth_date: value, version: profile.version}).then(_ => {
|
||
onToast('出生日期已保存');
|
||
window.setTimeout(() => navigate('personal-profile'), 500);
|
||
}).catch(err => onToast(err.message || '保存失败'));
|
||
};
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
return <div className="personal-page profile-editor-page">
|
||
<Header title="出生日期" back="personal-profile" action="完成" onAction={save} actionDisabled={!profile} />
|
||
<main className="birth-date-editor">
|
||
<Icon>cake</Icon>
|
||
<label><span>选择出生日期</span><input type="date" value={value} max={today} min="1900-01-01" onChange={event => setValue(event.target.value)} /></label>
|
||
<p>出生日期的可见范围由隐私设置决定</p>
|
||
</main>
|
||
</div>;
|
||
}
|
||
|
||
const GENDER_OPTIONS = [{value: 'male', label: '男'}, {value: 'female', label: '女'}, {value: 'other', label: '其他'}];
|
||
|
||
function GenderEditor({onToast}) {
|
||
const [profile, setProfile] = React.useState(null);
|
||
React.useEffect(() => { getMeProfile().then(setProfile).catch(() => {}); }, []);
|
||
const choose = value => {
|
||
updateMeProfile({gender: value, version: profile.version}).then(_ => {
|
||
onToast('性别已更新');
|
||
window.setTimeout(() => navigate('personal-profile'), 400);
|
||
}).catch(err => onToast(err.message || '保存失败'));
|
||
};
|
||
return <div className="personal-page"><Header title="性别" back="personal-profile" /><main className="gender-option-list">
|
||
{GENDER_OPTIONS.map(opt => <button type="button" key={opt.value} onClick={() => choose(opt.value)} disabled={!profile}>
|
||
<strong>{opt.label}</strong>{profile && profile.gender === opt.value ? <Icon>check</Icon> : null}
|
||
</button>)}
|
||
</main></div>;
|
||
}
|
||
|
||
function RegionEditor({onToast}) {
|
||
const [profile, setProfile] = React.useState(null);
|
||
const [value, setValue] = React.useState('');
|
||
React.useEffect(() => { getMeProfile().then(p => { setProfile(p); setValue(p.region || ''); }).catch(() => {}); }, []);
|
||
const save = () => {
|
||
updateMeProfile({region: value.trim(), version: profile.version}).then(_ => {
|
||
onToast('地区已保存');
|
||
window.setTimeout(() => navigate('personal-profile'), 500);
|
||
}).catch(err => onToast(err.message || '保存失败'));
|
||
};
|
||
return <div className="personal-page profile-editor-page">
|
||
<Header title="地区" back="personal-profile" action="完成" onAction={save} actionDisabled={!profile} />
|
||
<main><label><input value={value} maxLength={60} autoFocus placeholder="省份/城市" onChange={event => setValue(event.target.value)} /></label></main>
|
||
</div>;
|
||
}
|
||
|
||
function PersonalQr({onToast}) {
|
||
// Same shared 'me-profile' key as PersonalCenter/PersonalProfile -- this only needs it for the
|
||
// avatar/name display next to the code. `qr` itself is deliberately NOT cached: createMyQRCode()
|
||
// mints/renews a signed code each call (POST semantics, not a GET), so seeding a stale payload
|
||
// risks showing an already-superseded code that fails to scan -- worse than a brief spinner.
|
||
const [profile, setProfile] = React.useState(() => readCachedData('me-profile') || null);
|
||
const [qr, setQr] = React.useState(null);
|
||
const qrRef = React.useRef(null);
|
||
|
||
const load = () => {
|
||
createMyQRCode().then(setQr).catch(err => onToast(err.message || '二维码生成失败'));
|
||
};
|
||
React.useEffect(() => {
|
||
getMeProfile().then(p => { writeCachedData('me-profile', p); setProfile(p); }).catch(() => {});
|
||
load();
|
||
}, []);
|
||
|
||
React.useEffect(() => {
|
||
if (!qr || !qrRef.current) return;
|
||
qrRef.current.innerHTML = '';
|
||
new QRCode(qrRef.current, {text: qr.qr_payload || qr.qr_token, width: 210, height: 210, correctLevel: QRCode.CorrectLevel.M});
|
||
}, [qr]);
|
||
|
||
return <div className="personal-page personal-qr-page"><Header title="我的二维码" /><main>
|
||
<section>
|
||
<div className="personal-qr-profile">
|
||
{profile && profile.avatar_file_id ? <ImgFromFile fileId={profile.avatar_file_id} /> : <Icon>person</Icon>}
|
||
<span><strong>{(profile && profile.nickname) || (profile && profile.public_account)}</strong><small>{tenantName()}</small></span>
|
||
</div>
|
||
<div className="personal-qrcode" ref={qrRef} />
|
||
<p>扫一扫上面的二维码,加我为好友</p>
|
||
{qr && qr.expires_at ? <small>二维码将于 {new Date(qr.expires_at).toLocaleString()} 失效</small> : null}
|
||
</section>
|
||
<div className="personal-qr-actions">
|
||
<button type="button" onClick={() => { load(); onToast('二维码已刷新'); }}><Icon>refresh</Icon><span>刷新</span></button>
|
||
<button type="button" onClick={() => {
|
||
if (navigator.share && qr) {
|
||
navigator.share({title: '我的名片', text: qr.qr_payload || qr.qr_token}).catch(() => {});
|
||
} else {
|
||
onToast('当前环境不支持分享');
|
||
}
|
||
}}><Icon>share</Icon><span>分享</span></button>
|
||
</div>
|
||
</main></div>;
|
||
}
|
||
|
||
// docs/images/设置(iOS).png 只有7项,docs/images/设置(安卓).png 多了"青少年模式"/"实名认证"两行状态
|
||
// 展示 -- 两个平台业务能力理应一致(文字需求也这么写),采用更完整的安卓版列表,不再区分iOS/安卓两份。
|
||
function SettingsList({onToast, onLogout}) {
|
||
// 'youth-mode' is shared with the YouthMode page's own fetch of the same endpoint; 'me-realname'
|
||
// has no other reader today but gets the same treatment for consistency -- both status badges here
|
||
// otherwise flash a plausible-but-possibly-wrong value (realnameLabel's own fallback below shows
|
||
// "未认证" the instant this remounts, even while the still-unauthenticated-looking fetch is in
|
||
// flight) rather than a neutral loading placeholder, so caching avoids a *wrong* badge, not just a
|
||
// blank one.
|
||
const [youthSettings, setYouthSettings] = React.useState(() => readCachedData('youth-mode') || null);
|
||
const [realname, setRealname] = React.useState(() => readCachedData('me-realname') || null);
|
||
React.useEffect(() => {
|
||
getYouthMode().then(d => { writeCachedData('youth-mode', d); setYouthSettings(d); }).catch(() => {});
|
||
getRealnameStatus().then(d => { writeCachedData('me-realname', d); setRealname(d); }).catch(() => {});
|
||
}, []);
|
||
const youthEnabled = youthSettings ? youthSettings.enabled : null;
|
||
const realnameStatus = realname ? realname.status : null;
|
||
const realnameLabel = {verified: '已认证', pending: '审核中', rejected: '未通过'}[realnameStatus] || '未认证';
|
||
const logout = () => {
|
||
if (window.confirm('确定要退出当前登录吗?')) {
|
||
onLogout ? onLogout() : onToast('当前环境不支持退出登录');
|
||
}
|
||
};
|
||
return <div className="personal-page"><Header title="设置" /><main className="android-settings-list">
|
||
<section>
|
||
<MenuRow icon="delete_sweep" title="清除缓存" route="cache-clear-confirm" />
|
||
<MenuRow icon="mark_chat_read" title="清除所有聊天记录" route="clear-history-confirm" />
|
||
<MenuRow icon="password" title="修改密码" route="change-password" />
|
||
</section>
|
||
<section>
|
||
<MenuRow icon="lock_outline" title="隐私设置" route="privacy-settings" />
|
||
<MenuRow icon="block" title="黑名单" route="block-list" />
|
||
<MenuRow icon="child_care" title="青少年模式" value={youthEnabled == null ? '…' : (youthEnabled ? '已开启' : '未开启')}
|
||
route={youthEnabled ? 'youth-mode-on' : 'youth-mode-off'} />
|
||
<MenuRow icon="verified_user" title="实名认证" value={realnameLabel} route="realname" />
|
||
{/* security-support-view.jsx 的 FraudAwareness 组件自己的注释就写着这应该是个
|
||
"always-reachable safety-info page",但此前没有任何页面真的链接过去——只能靠手改
|
||
hash 才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
|
||
用的是同一套 hash 导航,可以直接指过去。 */}
|
||
<MenuRow icon="shield" title="安全教育" route="fraud-risk-warning" />
|
||
</section>
|
||
<section><MenuRow icon="no_accounts" title="注销账号" route="delete-account-confirm" danger /></section>
|
||
<button type="button" className="personal-logout" onClick={logout}>退出当前登录</button>
|
||
</main></div>;
|
||
}
|
||
|
||
function ConfirmSheet({icon, title, body, confirmLabel = '确认', confirmDanger = true, busy, onConfirm, onCancel, children}) {
|
||
return <div className="personal-sheet-backdrop"><section className="personal-confirm-sheet" role="dialog" aria-modal="true">
|
||
<Icon>{icon}</Icon><h2>{title}</h2><p>{body}</p>
|
||
{children}
|
||
<button type="button" className={confirmDanger ? 'danger' : 'primary'} disabled={busy} onClick={onConfirm}>{busy ? '处理中…' : confirmLabel}</button>
|
||
<button type="button" onClick={onCancel} disabled={busy}>取消</button>
|
||
</section></div>;
|
||
}
|
||
|
||
// No backend endpoint clears cache/history -- both are pure client-side operations (confirmed:
|
||
// server/main.go's ~74 routes have nothing for either). Cache clearing empties Cache Storage/IDB;
|
||
// clearing all history calls the native Tinode delMessagesAll(false) per open conversation, same
|
||
// method contact-management-view.jsx/group-management-view.jsx already use for single-topic clears.
|
||
function CacheClear({onToast}) {
|
||
const [busy, setBusy] = React.useState(false);
|
||
const [done, setDone] = React.useState(false);
|
||
const [freed, setFreed] = React.useState(0);
|
||
const confirm = async () => {
|
||
setBusy(true);
|
||
try {
|
||
// navigator.storage/caches can be entirely absent (private browsing in some browsers, older
|
||
// WebViews) rather than just rejecting -- referencing a missing global throws synchronously,
|
||
// which a bare .catch() on the call below can't catch (it never gets that far), so without
|
||
// this check the whole confirm() throws, `done` never becomes true, and the "已完成" state is
|
||
// never reached even though the button correctly stops spinning via `finally`.
|
||
const hasStorageEstimate = typeof navigator !== 'undefined' && navigator.storage && navigator.storage.estimate;
|
||
const hasCaches = typeof caches !== 'undefined';
|
||
const before = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
|
||
if (hasCaches) {
|
||
const keys = await caches.keys();
|
||
await Promise.all(keys.map(k => caches.delete(k)));
|
||
}
|
||
const after = hasStorageEstimate ? (await navigator.storage.estimate().catch(() => ({usage: 0}))).usage || 0 : 0;
|
||
setFreed(Math.max(0, before - after));
|
||
setDone(true);
|
||
} catch (err) {
|
||
onToast(err.message || '清理失败,请重试');
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
return <ConfirmSheet icon={done ? 'check_circle' : 'delete_sweep'} title={done ? '缓存已清理' : '清空缓存'}
|
||
body={done ? `已释放约 ${(freed / (1024 * 1024)).toFixed(1)} MB 存储空间。` : '确定清空本地的缓存数据?将清除缩略图、临时文件和可重新下载的数据,不会删除聊天记录、收藏或登录信息。'}
|
||
confirmLabel={done ? '完成' : '确定'} confirmDanger={!done} busy={busy}
|
||
onConfirm={done ? () => navigate('android-settings') : confirm} onCancel={() => navigate('android-settings')} />;
|
||
}
|
||
|
||
function ClearHistoryConfirm({tinode, chatList, onToast}) {
|
||
const [busy, setBusy] = React.useState(false);
|
||
const confirm = () => {
|
||
if (!tinode || !chatList || !chatList.length) { onToast('没有可清除的会话'); navigate('android-settings'); return; }
|
||
setBusy(true);
|
||
Promise.all(chatList.map(c => {
|
||
const topic = tinode.getTopic(c.name);
|
||
return topic ? topic.delMessagesAll(false).catch(() => {}) : Promise.resolve();
|
||
})).then(() => {
|
||
onToast('聊天记录已清空');
|
||
navigate('android-settings');
|
||
}).finally(() => setBusy(false));
|
||
};
|
||
return <ConfirmSheet icon="mark_chat_read" title="清除所有聊天记录"
|
||
body="确定清除所有聊天记录?此操作不可恢复,但不会影响好友/群聊关系和收藏内容。" busy={busy}
|
||
onConfirm={confirm} onCancel={() => navigate('android-settings')} />;
|
||
}
|
||
|
||
// Changing password has no REST endpoint -- it's native Tinode {acc} protocol
|
||
// (Tinode.updateAccountBasic), already wired end-to-end via tinode-web.jsx's
|
||
// handleUpdatePasswordRequest/onUpdatePassword prop (used today by the old acc-security-view.jsx).
|
||
// Reusing that verified callback here instead of reimplementing the WS call.
|
||
// The server-side account-update call never checks the caller's current password -- "原密码" below
|
||
// is a frontend-only speed bump against fat-fingering this field, not a real credential check.
|
||
function ChangePassword({onUpdatePassword, onToast}) {
|
||
const [oldPwd, setOldPwd] = React.useState('');
|
||
const [newPwd, setNewPwd] = React.useState('');
|
||
const [confirmPwd, setConfirmPwd] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
const submit = () => {
|
||
if (!oldPwd || newPwd.length < 6) { onToast('请输入原密码,新密码至少6位'); return; }
|
||
if (newPwd !== confirmPwd) { onToast('两次输入的新密码不一致'); return; }
|
||
if (!onUpdatePassword) { onToast('当前环境不支持修改密码'); return; }
|
||
if (busy) return;
|
||
setBusy(true);
|
||
// 等真实结果再报成功。原来是发出去就立刻弹"密码已更新"并跳走,服务端拒绝(密码策略不符等)
|
||
// 时用户看到的仍然是成功提示,密码其实没变。
|
||
Promise.resolve(onUpdatePassword(newPwd)).then(() => {
|
||
onToast('密码已更新');
|
||
window.setTimeout(() => navigate('android-settings'), 500);
|
||
}).catch(err => {
|
||
onToast((err && err.message) || '修改失败,请重试');
|
||
setBusy(false);
|
||
});
|
||
};
|
||
return <div className="personal-page profile-editor-page"><Header title="修改密码" back="android-settings" action="完成"
|
||
actionLabel={busy ? '处理中…' : '完成'} actionDisabled={busy} onAction={submit} /><main>
|
||
<label><span>原密码</span><input type="password" value={oldPwd} onChange={e => setOldPwd(e.target.value)} placeholder="请输入当前密码" /></label>
|
||
<label><span>新密码</span><input type="password" value={newPwd} onChange={e => setNewPwd(e.target.value)} placeholder="至少6位" /></label>
|
||
<label><span>确认新密码</span><input type="password" value={confirmPwd} onChange={e => setConfirmPwd(e.target.value)} placeholder="再次输入新密码" /></label>
|
||
</main></div>;
|
||
}
|
||
|
||
// Account deletion is also native Tinode ({del what="user" hard=true} -> delCurrentUser(true)),
|
||
// already wired via tinode-web.jsx's handleDeleteAccount/onDeleteAccount prop. Server-side there's
|
||
// no cooldown/soft-delete and no password re-check (confirmed against server/user.go) -- adding a
|
||
// password confirmation step here is a deliberate, conservative frontend-only safeguard given how
|
||
// irreversible and consequential this action is, not something the backend requires.
|
||
function DeleteAccountConfirm({onDeleteAccount, onToast}) {
|
||
const [step, setStep] = React.useState(1);
|
||
const [password, setPassword] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
const proceed = () => {
|
||
if (!password) { onToast('请输入密码以确认'); return; }
|
||
if (!onDeleteAccount) { onToast('当前环境不支持注销账号'); return; }
|
||
setBusy(true);
|
||
Promise.resolve(onDeleteAccount()).catch(err => { onToast(err.message || '注销失败'); setBusy(false); });
|
||
};
|
||
if (step === 1) {
|
||
// im_be's replyDelUser -> hub.stopTopicsForUser hard-deletes every group topic this account
|
||
// owns (not just leaves them) -- confirmed against hub.go's topic.owner==uid branch. Callers
|
||
// in those groups get no advance warning and no forced-ownership-transfer prompt, so this has
|
||
// to be disclosed here since it's the only place the user sees before confirming.
|
||
return <ConfirmSheet icon="warning" title="注销账号"
|
||
body="注销后账号数据将被永久删除且无法恢复,包括聊天记录、好友关系和收藏内容。如果你是某些群的群主,这些群也将被永久解散,其他成员将无法继续使用。确定要继续吗?"
|
||
confirmLabel="继续" onConfirm={() => setStep(2)} onCancel={() => navigate('android-settings')} />;
|
||
}
|
||
return <ConfirmSheet icon="no_accounts" title="输入密码确认注销" body="请输入登录密码以确认注销账号(该操作立即生效,不可撤销)。"
|
||
busy={busy} onConfirm={proceed} onCancel={() => navigate('android-settings')}>
|
||
<input type="password" autoFocus value={password} onChange={e => setPassword(e.target.value)} placeholder="登录密码" />
|
||
</ConfirmSheet>;
|
||
}
|
||
|
||
// docs/images/版本更新页面.png 其实是"关于我们"页(app图标+版本号+用户条款/隐私协议/检测新版本/联系我们),
|
||
// 品牌名图片里出现了5个不同版本(捷信密使/光讯通讯/笔墨讯使/光迅通讯...),判定为设计稿混用了多版本草稿,
|
||
// 统一用代码里已确立的 BASE_APP_NAME(纯品牌名,不带版本号后缀),不采信任何一个图片品牌名。
|
||
// "检测新版本"(及跳转应用商店的更新弹窗)是原型给移动端设计的,web版没有应用商店可跳,按要求隐藏。
|
||
function AppVersion({onToast}) {
|
||
const currentVersion = PACKAGE_VERSION;
|
||
|
||
return <div className="personal-page app-version-page"><Header title="关于我们" back="android-settings" /><main>
|
||
<section className="app-brand"><span><Icon>forum</Icon></span><h2>{BASE_APP_NAME}</h2><p>版本 {currentVersion}</p></section>
|
||
<section className="personal-menu-section">
|
||
<MenuRow title="用户条款" onClick={() => onToast('用户条款')} />
|
||
<MenuRow title="隐私协议" onClick={() => onToast('隐私协议')} />
|
||
{/* 参考图"版本更新页面.png"里这行是明确画出来的,之前漏掉了(跟"检测新版本"不一样,
|
||
那个是核对图片后判断web版没有应用商店可跳、主动决定不做,这里没有类似理由)。
|
||
图上那个联系邮箱本身很可能是设计稿混用多版本草稿的产物,不采信,复用代码里已有的
|
||
LINK_CONTACT_US——如果这不是最终要用的联系方式,需要产品另外提供真实地址。 */}
|
||
<MenuRow title="联系我们" onClick={() => { window.location.href = LINK_CONTACT_US; }} />
|
||
</section>
|
||
</main>
|
||
</div>;
|
||
}
|
||
|
||
// docs/images/隐私设置.png 只画了"添加我的方式"+"邀我入群需要验证"两项,但 UserPrivacySettings
|
||
// 结构体真实还有 profile_visibility/last_seen_visibility/read_receipts 三个字段 -- 图片大概率没拍全,
|
||
// 这三项是真实、有意义的字段,保留;"邀我入群需要验证"反过来是图片有但后端完全没有对应字段(代码调研
|
||
// 确认 UserPrivacySettings 里没有这个概念,群邀请入群逻辑本身也不检查任何隐私开关),故不做这一项,
|
||
// 记录为后端缺口而不是造一个点了没有效果的假开关。
|
||
function PrivacySettings({onToast}) {
|
||
// Shares 'me-privacy' with FriendDiscoverySettings' identical getMePrivacy() call -- a toggle page,
|
||
// not a text edit form, so a background refetch self-corrects any stale toggle state harmlessly
|
||
// (no risk of clobbering in-progress typing the way a free-text field would).
|
||
const [privacy, setPrivacy] = React.useState(() => readCachedData('me-privacy') || null);
|
||
React.useEffect(() => { getMePrivacy().then(p => { writeCachedData('me-privacy', p); setPrivacy(p); }).catch(() => {}); }, []);
|
||
const update = (key, value) => {
|
||
const prev = privacy;
|
||
setPrivacy({...privacy, [key]: value});
|
||
updateMePrivacy({[key]: value, version: privacy.version}).then(p => { writeCachedData('me-privacy', p); setPrivacy(p); })
|
||
.catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });
|
||
};
|
||
const methodSummary = () => {
|
||
if (!privacy) return '';
|
||
const labels = {account: '账号', phone: '手机号', qr: '二维码', group: '群聊'};
|
||
const on = Object.entries(privacy.add_me_methods || {}).filter(([, v]) => v).map(([k]) => labels[k] || k);
|
||
if (on.length === 4) return '全部允许';
|
||
return on.length ? on.join('、') : '全部关闭';
|
||
};
|
||
// 后端 userPrivacyPayload 会返回 editable{add_me_methods, profile_visibility, last_seen_visibility,
|
||
// read_receipts},用来表达"企业强制策略锁死了这一项"。原来完全没读这个字段,被锁的开关照样能点,
|
||
// 点了服务端 400 再回滚,用户只看到开关自己弹回去、不知道为什么。
|
||
const locked = key => !!(privacy && privacy.editable && privacy.editable[key] === false);
|
||
return <div className="personal-page"><Header title="隐私设置" back="android-settings" /><main className="privacy-list">
|
||
<section><MenuRow title="添加我的方式" value={methodSummary()} route="friend-discovery-settings" /></section>
|
||
{privacy ? <section>
|
||
<div><span><strong>显示消息已读状态</strong><small>{locked('read_receipts') ? '该项由企业统一设置,不可修改' : '关闭后你也无法查看对方的已读状态'}</small></span>
|
||
<Toggle checked={!!privacy.read_receipts} disabled={locked('read_receipts')} onChange={v => update('read_receipts', v)} /></div>
|
||
<div><span><strong>展示最后在线时间</strong><small>{locked('last_seen_visibility') ? '该项由企业统一设置,不可修改' : '仅向联系人显示'}</small></span>
|
||
{/* Backend's validVisibility only accepts everyone/friends/nobody -- 'contacts'/'hidden' were
|
||
rejected by the PATCH validator outright (every toggle attempt 400'd and silently reverted),
|
||
and even a lenient backend would enforce the opposite of what turning this "on" should do
|
||
(imCanViewLastSeen's default case treats any unrecognized value as hidden). */}
|
||
<Toggle checked={privacy.last_seen_visibility !== 'nobody'} disabled={locked('last_seen_visibility')} onChange={v => update('last_seen_visibility', v ? 'friends' : 'nobody')} /></div>
|
||
<div><span><strong>允许好友查看我的资料</strong><small>{locked('profile_visibility') ? '该项由企业统一设置,不可修改' : '关闭后仅你自己可见'}</small></span>
|
||
<Toggle checked={privacy.profile_visibility !== 'nobody'} disabled={locked('profile_visibility')} onChange={v => update('profile_visibility', v ? 'friends' : 'nobody')} /></div>
|
||
</section> : null}
|
||
</main></div>;
|
||
}
|
||
|
||
function FriendDiscoverySettings({onToast}) {
|
||
// Same shared 'me-privacy' key as PrivacySettings -- identical resource, so whichever of the two
|
||
// screens is opened first warms the cache for the other.
|
||
const [privacy, setPrivacy] = React.useState(() => readCachedData('me-privacy') || null);
|
||
React.useEffect(() => { getMePrivacy().then(p => { writeCachedData('me-privacy', p); setPrivacy(p); }).catch(() => {}); }, []);
|
||
const update = (key, value) => {
|
||
const prev = privacy;
|
||
const nextMethods = {...(privacy.add_me_methods || {}), [key]: value};
|
||
setPrivacy({...privacy, add_me_methods: nextMethods});
|
||
updateMePrivacy({add_me_methods: nextMethods, version: privacy.version}).then(p => { writeCachedData('me-privacy', p); setPrivacy(p); })
|
||
.catch(err => { onToast(err.message || '保存失败'); setPrivacy(prev); writeCachedData('me-privacy', prev); });
|
||
};
|
||
const rows = [{key: 'account', title: '账号'}, {key: 'phone', title: '手机号'}, {key: 'qr', title: '二维码'}, {key: 'group', title: '群聊'}];
|
||
// 同 PrivacySettings:企业强制策略锁死时不让点,避免"能点但必然 400 再弹回"。
|
||
const methodsLocked = !!(privacy && privacy.editable && privacy.editable.add_me_methods === false);
|
||
return <div className="personal-page"><Header title="添加我的方式" back="privacy-settings" /><main className="privacy-list">
|
||
<p className="privacy-note">{methodsLocked ? '添加方式由企业统一设置,不可修改。' : '关闭某种方式后,其他人将无法通过该渠道搜索或添加你。'}</p>
|
||
{privacy ? <section>{rows.map(row => <div key={row.key}>
|
||
<span><strong>可通过{row.title}添加我</strong></span>
|
||
<Toggle checked={!!(privacy.add_me_methods || {})[row.key]} disabled={methodsLocked} onChange={v => update(row.key, v)} />
|
||
</div>)}</section> : null}
|
||
</main></div>;
|
||
}
|
||
|
||
// docs/images/青少年模式未开启(安卓).png 的"每日不超过40分钟"/"加好友群聊每日各4次"是纯前端文案,
|
||
// 代码调研确认 YouthModeSettings.Restrictions 后端真实内容是三个类别码
|
||
// (night_quiet/content_filter/payment_guard),没有任何分钟数/次数字段 -- 这两个具体数字只作为说明
|
||
// 文案展示,不冒充是从后端配置读出来的。使用时长同理没有后端字段,这里做的是纯本地、按自然日重置的
|
||
// 近似计时(刷新/换设备会丢,不追求精确,比完全不显示更贴近原型但明确知道它不是权威数据)。
|
||
const YOUTH_RULES = [
|
||
`为呵护未成年人健康成长,${BASE_APP_NAME}推出青少年模式。该模式下部分功能将受限制使用`,
|
||
'每天开启后使用时长不超过 40 分钟',
|
||
'限制加好友/群聊次数(主动/被动加好友或群聊每日最多各 4 次)'
|
||
];
|
||
const YOUTH_USAGE_KEY = 'im-youth-mode-usage';
|
||
|
||
function useYouthUsageSeconds(active) {
|
||
const [seconds, setSeconds] = React.useState(0);
|
||
React.useEffect(() => {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
let stored = {};
|
||
try { stored = JSON.parse(window.localStorage.getItem(YOUTH_USAGE_KEY) || '{}'); } catch (_) { /* ignore */ }
|
||
if (stored.date !== today) stored = {date: today, seconds: 0};
|
||
setSeconds(stored.seconds || 0);
|
||
if (!active) return undefined;
|
||
const timer = window.setInterval(() => {
|
||
setSeconds(s => {
|
||
const next = s + 1;
|
||
window.localStorage.setItem(YOUTH_USAGE_KEY, JSON.stringify({date: today, seconds: next}));
|
||
return next;
|
||
});
|
||
}, 1000);
|
||
return () => window.clearInterval(timer);
|
||
}, [active]);
|
||
return seconds;
|
||
}
|
||
|
||
function formatHMS(totalSeconds) {
|
||
const h = Math.floor(totalSeconds / 3600);
|
||
const m = Math.floor((totalSeconds % 3600) / 60);
|
||
const s = totalSeconds % 60;
|
||
const pad = n => String(n).padStart(2, '0');
|
||
return `${pad(h)}:${pad(m)}:${pad(s)}`;
|
||
}
|
||
|
||
function YouthMode({onToast}) {
|
||
// Shares 'youth-mode' with SettingsList's badge fetch (see comment there) and gets patched directly
|
||
// by YouthModeVerify right after a successful enable/disable, so navigating back here shows the new
|
||
// state immediately instead of the stale cached one for a moment before self-correcting.
|
||
const [settings, setSettings] = React.useState(() => readCachedData('youth-mode') || null);
|
||
const usage = useYouthUsageSeconds(settings && settings.enabled);
|
||
React.useEffect(() => {
|
||
setSettings(readCachedData('youth-mode') || null);
|
||
getYouthMode().then(d => { writeCachedData('youth-mode', d); setSettings(d); }).catch(err => onToast(err.message || '加载失败'));
|
||
}, []);
|
||
|
||
const startEnable = () => {
|
||
if (!settings) return;
|
||
if (!settings.password_set) {
|
||
onToast('请先设置独立密码');
|
||
window.setTimeout(() => navigate('identity-check', {ym: 'enable'}), 400);
|
||
return;
|
||
}
|
||
navigate('youth-mode-verify', {ym: 'enable'});
|
||
};
|
||
|
||
if (!settings) return <div className="personal-page youth-mode-page"><Header title="青少年模式" back="android-settings" /></div>;
|
||
|
||
return <div className="personal-page youth-mode-page">
|
||
<Header title="青少年模式" back="android-settings" />
|
||
<main>
|
||
<Icon>{settings.enabled ? 'verified_user' : 'child_care'}</Icon>
|
||
<h2>{settings.enabled ? '青少年模式已开启' : '青少年模式未开启'}</h2>
|
||
<ul>{YOUTH_RULES.map(rule => <li key={rule}>{rule}</li>)}</ul>
|
||
{settings.enabled ? <p className="youth-usage">今日已使用:{formatHMS(usage)}</p> : null}
|
||
</main>
|
||
<footer>
|
||
<button type="button" className={settings.enabled ? 'outline' : ''}
|
||
onClick={() => settings.enabled ? navigate('youth-mode-verify', {ym: 'disable'}) : startEnable()}>
|
||
{settings.enabled ? '关闭青少年模式' : '开启青少年模式'}
|
||
</button>
|
||
<small>可在设置中查看青少年模式状态</small>
|
||
</footer>
|
||
</div>;
|
||
}
|
||
|
||
// docs/images/青少年模式提示(安卓).png 实际内容是"今日已达40分钟拦截层"(文件名误导),其中的
|
||
// "输入密码继续使用"选项代码调研确认后端没有任何"临时解锁但不关闭"的操作可对应 -- serveIMYouthModeEnable
|
||
// /Disable 只有开/关两种终态,没有"本次会话继续"的中间态。不做这个假按钮,只保留真实可行的"关闭"路径。
|
||
//
|
||
// 开关流程统一:已设置密码时,输入密码 -> POST .../independent-password/verify 换 action_token
|
||
// (action 按 enable/disable 传 enable_youth_mode/close_youth_mode)-> 调 enable/disable。
|
||
// 未设置密码时开启前必须先走 identity-check(短信验证码)设置密码,这一步不消费于开启本身。
|
||
function YouthModeVerify({hashParams, onToast}) {
|
||
const mode = (hashParams && hashParams.ym) || 'disable';
|
||
const [settings, setSettings] = React.useState(null);
|
||
const [password, setPassword] = React.useState('');
|
||
const [error, setError] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
const [lockedUntil, setLockedUntil] = React.useState(0);
|
||
const [now, setNow] = React.useState(Date.now());
|
||
const remainingLockSeconds = lockedUntil ? Math.max(0, Math.ceil((lockedUntil - now) / 1000)) : 0;
|
||
// Self-rescheduling tick, not setInterval -- naturally stops once the countdown reaches 0 instead
|
||
// of needing separate start/stop bookkeeping.
|
||
React.useEffect(() => {
|
||
if (remainingLockSeconds <= 0) return;
|
||
const id = window.setTimeout(() => setNow(Date.now()), 1000);
|
||
return () => window.clearTimeout(id);
|
||
}, [remainingLockSeconds]);
|
||
|
||
React.useEffect(() => { getYouthMode().then(setSettings).catch(() => {}); }, []);
|
||
|
||
const submit = () => {
|
||
if (password.length !== 4) { setError('请输入4位独立密码'); return; }
|
||
if (remainingLockSeconds > 0) { return; }
|
||
setBusy(true);
|
||
setError('');
|
||
// Server's serveIMYouthModeEnable consumes the token with a strict action='enable_youth_mode'
|
||
// match (ConsumeActionToken does an exact WHERE action=? lookup) -- minting it tagged
|
||
// 'set_independent_password' meant the lookup could never find it, so every enable attempt
|
||
// 403'd with ACTION_TOKEN_INVALID regardless of a correct password.
|
||
const action = mode === 'enable' ? 'enable_youth_mode' : 'close_youth_mode';
|
||
verifyIndependentPassword(password, 'youth_mode', action).then(({action_token}) => {
|
||
const call = mode === 'enable' ?
|
||
enableYouthMode(settings.policy_version, action_token) :
|
||
disableYouthMode(action_token);
|
||
return call;
|
||
}).then(_ => {
|
||
// Patch the shared cache with the now-known new state before navigating back to YouthMode/
|
||
// SettingsList -- without this they'd briefly show the pre-toggle cached value again and then
|
||
// flip, which is a more confusing flash than the plain loading state they had before caching.
|
||
if (settings) writeCachedData('youth-mode', {...settings, enabled: mode === 'enable'});
|
||
onToast(mode === 'enable' ? '青少年模式已开启' : '青少年模式已关闭');
|
||
window.setTimeout(() => navigate(mode === 'enable' ? 'youth-mode-on' : 'youth-mode-off'), 400);
|
||
}).catch(err => {
|
||
// Server locks out further attempts for retry_after_seconds after too many wrong passwords
|
||
// (any request during that window 429s regardless of whether THIS password is correct) --
|
||
// without tracking that window here, the button stayed enabled the whole time, letting the
|
||
// user fire off more requests that were guaranteed to fail before the lock even lifts.
|
||
if (err.reason === 'TOO_MANY_ATTEMPTS' && err.details && err.details.retry_after_seconds) {
|
||
setLockedUntil(Date.now() + err.details.retry_after_seconds * 1000);
|
||
setNow(Date.now());
|
||
}
|
||
setError(err.message || '验证失败');
|
||
}).finally(() => setBusy(false));
|
||
};
|
||
|
||
return <div className="personal-page youth-mode-page">
|
||
<Header title={mode === 'enable' ? '开启青少年模式' : '关闭青少年模式'} back={mode === 'enable' ? 'youth-mode-off' : 'youth-mode-on'} />
|
||
<main className="youth-password-gate">
|
||
<div className="auth-pin-boxes" aria-hidden="true">{[0, 1, 2, 3].map(i => <span key={i}>{password[i] ? '●' : ''}</span>)}</div>
|
||
<input className="auth-pin-input" type="text" inputMode="numeric" autoComplete="off" autoFocus
|
||
value={password} onChange={e => setPassword(e.target.value.replace(/\D/g, '').slice(0, 4))} aria-label="独立密码" />
|
||
{error ? <div className="auth-field-error">{error}</div> : null}
|
||
<button type="button" className="auth-primary" disabled={password.length !== 4 || busy || remainingLockSeconds > 0} onClick={submit}>
|
||
{busy ? '验证中…' : remainingLockSeconds > 0 ? `请 ${remainingLockSeconds} 秒后重试` : '确定'}
|
||
</button>
|
||
{/* 一旦设过密码,这个页面就是唯一入口,之前完全没有出路——真忘了密码只能不断输错直到被
|
||
限流,限流也只是让你等,不会给别的办法。identity-check 走的短信验证码本来就支持在
|
||
password_set:true 时重新设置密码(实测确认过),只是没有从这里链接过去。
|
||
必须带上当前 mode:IndependentPassword 设完新密码后,服务端每次都会无条件带回
|
||
enable_action_token(与调用者意图无关),原来因为不知道是从"关闭"这个流程点进来的,
|
||
会把这次重置误当成"继续开启",用户想关闭青少年模式却被弹"已开启"、状态也没有真的关掉。 */}
|
||
<button type="button" className="auth-text-button" onClick={() => navigate('identity-check', {ym: mode})}>忘记独立密码?</button>
|
||
</main>
|
||
</div>;
|
||
}
|
||
|
||
// 10.18-10.21:纯静态权限说明卡片,浏览器原生权限API(Notification.requestPermission()/
|
||
// getUserMedia())本身没有"用途说明"这个中间步骤,所以这一层完全是前端自己在真正触发系统权限
|
||
// 弹窗前插入的说明UI,不需要任何后端接口 -- 与代码调研确认的"清缓存/清聊天记录"是同一类判断。
|
||
const PERMISSIONS = {
|
||
notification: {icon: 'notifications_active', title: '获取推送权限', text: '向您及时送达重要通知、活动提醒、个性化内容更新等信息。', action: '开启通知', route: 'permission-settings-reminder'},
|
||
reminder: {icon: 'settings', title: '消息推送暂未开启', text: `前往【设置 - 通知管理】一键开启吧!`, action: '好的', secondaryAction: '暂不开启', route: 'android-settings'},
|
||
camera: {icon: 'photo_camera', title: '相机权限使用说明', text: '调用相机拍摄/扫码,为您提供拍照、扫码识别等服务。', action: '继续授权', route: 'messages-home'},
|
||
microphone: {icon: 'mic', title: '麦克风权限使用说明', text: '用于语音消息、语音输入和音视频通话,未授权时不会创建录音文件。', action: '继续授权', route: 'messages-home'}
|
||
};
|
||
|
||
function PermissionGuide({type, onToast}) {
|
||
const config = PERMISSIONS[type];
|
||
const proceed = () => {
|
||
if (type === 'notification' && 'Notification' in window) {
|
||
Notification.requestPermission().then(() => navigate(config.route));
|
||
return;
|
||
}
|
||
onToast(type === 'reminder' ? '请前往系统设置开启通知' : '等待系统授权结果');
|
||
window.setTimeout(() => navigate(config.route), 500);
|
||
};
|
||
return <div className="personal-page permission-guide-page">
|
||
<div className="permission-preview"><header><h1>消息</h1><Icon>menu</Icon></header></div>
|
||
<div className="permission-overlay"><section role="dialog" aria-modal="true">
|
||
<span><Icon>{config.icon}</Icon></span>
|
||
<h2>{config.title}</h2>
|
||
<p>{config.text}</p>
|
||
<button type="button" onClick={proceed}>{config.action}</button>
|
||
{config.secondaryAction ? <button type="button" onClick={() => navigate('messages-home')}>{config.secondaryAction}</button> : null}
|
||
</section></div>
|
||
</div>;
|
||
}
|
||
|
||
export default function PersonalSettingsView({view, hashParams, myUserId, tinode, chatList, onUpdatePassword, onDeleteAccount, onLogout}) {
|
||
const [toast, setToast] = React.useState('');
|
||
let content;
|
||
if (view === 'personal-center') content = <PersonalCenter />;
|
||
else if (view === 'personal-profile') content = <PersonalProfile onToast={setToast} tinode={tinode} />;
|
||
else if (view === 'profile-nickname') content = <TextEditor field="nickname" title="设置昵称" onToast={setToast} />;
|
||
else if (view === 'profile-signature') content = <TextEditor field="signature" title="个性签名" multiline onToast={setToast} />;
|
||
else if (view === 'profile-birth-date') content = <BirthDateEditor onToast={setToast} />;
|
||
else if (view === 'profile-gender') content = <GenderEditor onToast={setToast} />;
|
||
else if (view === 'profile-region') content = <RegionEditor onToast={setToast} />;
|
||
else if (view === 'personal-qrcode') content = <PersonalQr onToast={setToast} />;
|
||
else if (view === 'android-settings') content = <SettingsList onToast={setToast} onLogout={onLogout} />;
|
||
else if (view === 'cache-clear-confirm') content = <CacheClear onToast={setToast} />;
|
||
else if (view === 'clear-history-confirm') content = <ClearHistoryConfirm onToast={setToast} tinode={tinode} chatList={chatList} />;
|
||
else if (view === 'change-password') content = <ChangePassword onToast={setToast} onUpdatePassword={onUpdatePassword} />;
|
||
else if (view === 'delete-account-confirm') content = <DeleteAccountConfirm onToast={setToast} onDeleteAccount={onDeleteAccount} />;
|
||
else if (view === 'app-version') content = <AppVersion onToast={setToast} />;
|
||
else if (view === 'privacy-settings') content = <PrivacySettings onToast={setToast} />;
|
||
else if (view === 'friend-discovery-settings') content = <FriendDiscoverySettings onToast={setToast} />;
|
||
else if (view === 'youth-mode-off' || view === 'youth-mode-on') content = <YouthMode onToast={setToast} />;
|
||
else if (view === 'youth-mode-verify') content = <YouthModeVerify hashParams={hashParams} onToast={setToast} />;
|
||
else content = <PermissionGuide type={view === 'notification-permission' ? 'notification' : view === 'permission-settings-reminder' ? 'reminder' : view === 'camera-permission-info' ? 'camera' : 'microphone'} onToast={setToast} />;
|
||
return <>{content}<Toast message={toast} onDone={() => setToast('')} /></>;
|
||
}
|