Files
im_webapp/src/views/personal-settings-view.jsx
T
Your NameandClaude Sonnet 5 f2218a822e 修复用户实测反馈的4处问题:品牌名泄露、修改密码页注释泄露、安全提醒页未居中、群相关行间距
- 品牌名: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>
2026-09-05 13:06:18 +08:00

770 lines
48 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React from 'react';
import 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('')} /></>;
}