: null;
}
function Toggle({checked, disabled, onChange}) {
return ;
}
function MenuRow({icon, title, value, route, onClick, danger}) {
return ;
}
// 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 ? : ;
}
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
;
}
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
;
}
// 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
;
}
// 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 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 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
;
}
// 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 setStep(2)} onCancel={() => navigate('android-settings')} />;
}
return navigate('android-settings')}>
setPassword(e.target.value)} placeholder="登录密码" />
;
}
// docs/images/版本更新页面.png 其实是"关于我们"页(app图标+版本号+用户条款/隐私协议/检测新版本/联系我们),
// 品牌名图片里出现了5个不同版本(捷信密使/光讯通讯/笔墨讯使/光迅通讯...),判定为设计稿混用了多版本草稿,
// 统一用代码里已确立的 BASE_APP_NAME(纯品牌名,不带版本号后缀),不采信任何一个图片品牌名。
// "检测新版本"(及跳转应用商店的更新弹窗)是原型给移动端设计的,web版没有应用商店可跳,按要求隐藏。
function AppVersion({onToast}) {
const currentVersion = PACKAGE_VERSION;
return
展示最后在线时间{locked('last_seen_visibility') ? '该项由企业统一设置,不可修改' : '仅向联系人显示'}
{/* 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). */}
update('last_seen_visibility', v ? 'friends' : 'nobody')} />
;
}
// 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