修复全页面走查发现的问题(第二批:登录、通讯录、群聊)

第1章
- tinode-web: 手输密码登录失败会 navigateTo('') 把用户弹回企业码页,刚输的账号丢了,而
  TenantEntry 不渲染 FormAlert,那句错误提示根本看不到。改成只有存储的 token 失效才回登录入口。
- auth-onboarding IdentityCheck: 限流 429 时服务端会连同未过期的 challenge_id 一起返回,
  原来只显示错误文案、challengeId 留空,于是 submit 开头的守卫静默吞掉点击——输完验证码点
  "确定"毫无反应也无提示。同文件 VerificationButton 早就处理对了,这里是漏抄。
- auth-onboarding Register: 手机号注册路径硬传 invite_code:'',把用户填的邀请码丢了。后端
  user.go:175-181 对邀请码的校验和 RecordUse 不区分注册路径,UI 上也摆着这个输入框。

第5章
- 好友列表主副行显示同一串字:后端在有备注时把 display_name 也设成备注(im_http.go:8500-8507),
  两个字段同值。加"不同才显示"的守卫。
- 拉黑确认弹窗把设置页当背景渲染并摆出"操作之后"的样子,但背景组件自己的 getUserProfile
  回来后又把开关改回未拉黑、"删除好友"红条重新冒出来。加 pinBlocked 挡住这次覆盖。

第6章
- 群列表把群名显示成 topic id、成员数恒为 0。根因在后端:handler 用 store.Users.GetSubs,而
  store 自己的注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835),
  groupListItemPayload 却去读 sub.GetPublic()/GetSubCnt()(应该用 GetTopics)。已记为后端问题;
  前端先用本地 Tinode 会话缓存兜底(消息首页一直这么取,是准的)。
- 禁言页每行头像画成群组图标(Avatar 兜底图标默认 'group',没传 icon="person"),
  且顺带从成员表带上真实头像。
- 禁言页"以上成员不允许发言"被当成空状态用,语义正好反了——有禁言成员时这句消失、
  一个都没有时反而显示。改为列表下方常驻注脚,空态另给文案,并补 .group-inline-note 样式。
- bytesToGB 只输出到 MB,1.4GB 的文件显示成 "1433.6 MB"。
- 群文件"来自"直接显示裸 uploader_id(groupFilePayload 不给昵称),拉成员表交叉引用。
- 群公告从聊天页横幅进入后返回,会把会话一起关掉:navigate 只写 query 参数,path[1] 仍为空,
  handleHashRoute 就把 topicSelected 清成 null。Header 支持 onBack,显式把 topic 写回 path[1]。
- 群成员点头像打不开非好友资料页:目标页先读 'profile-seed:' 缓存,因为 getUserProfile 对
  非好友直接 403。第5章入口一直有 seed,这里漏了。
- 群简介上限 225 超过后端硬限 200(im_http.go:4667),写满必被打回。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-03 18:40:53 +08:00
co-authored by Claude Opus 5
parent 5a07bb4e3b
commit 02b98cceae
7 changed files with 224 additions and 42 deletions
+19 -1
View File
@@ -422,7 +422,11 @@ function Register({errorText, errorLevel, onImRegisterRequest}) {
// set a nickname afterwards).
const secretAccount = form.username || ('+86' + form.phone);
onImRegisterRequest(secretAccount, form.password, form.username || '',
{username: form.username || '', phone: form.phone, invite_code: ''},
// 邀请码不能在手机号路径上丢掉:后端 user.go:175-181 对邀请码的校验和 RecordUse 都不区分
// 注册路径(`if inviteCode != ""` 就会校验并记录),而且 user.go:169 的
// inviteRequired 在短信通道关闭时对手机号注册同样为真。UI 上明明摆着邀请码输入框,
// 原来却硬传空串,用户填了也被静默丢弃,邀请来源统计全丢。
{username: form.username || '', phone: form.phone, invite_code: form.invite || ''},
{meth: 'tel', val: form.phone, resp: verified.verification_token, params: {challenge_id: challengeId}});
} else {
onImRegisterRequest(form.username, form.password, form.username,
@@ -546,6 +550,20 @@ function IdentityCheck({authenticated}) {
setSeconds(data.resend_after_seconds || 60);
} catch (err) {
setError(err.reason === 'REALNAME_REQUIRED' ? '需要先完成实名认证并绑定手机号' : (err.message || '获取验证码失败'));
// 限流(429)时服务端会连同还没过期的 challenge_id 和剩余冷却秒数一起返回
// (im_http.go:6361-6367)。原来这里只把错误文案显示出来就完了,challengeId 一直是空串,
// 于是 submit() 开头的 `if (!challengeId) return` 会静默吞掉点击——按钮看着能点,
// 输完 6 位验证码点"确定"毫无反应,也没有任何提示。同文件的 VerificationButton
// 早就处理对了,这里是漏抄。
if (err.details && err.details.retry_after_seconds) {
setSeconds(err.details.retry_after_seconds);
}
if (err.details && err.details.challenge_id) {
setChallengeId(err.details.challenge_id);
}
if (err.details && err.details.phone_mask) {
setPhoneMask(err.details.phone_mask);
}
} finally {
setBusy(false);
}
+12 -4
View File
@@ -148,7 +148,11 @@ function AddressBook({myUserId, onTopicSelected}) {
{loading ? <p className="people-empty-hint">加载中…</p> : sortedKeys.length === 0 ? <p className="people-empty-hint">暂无好友</p> : null}
{sortedKeys.map(initial => <section className="people-group" id={'people-group-' + initial} key={initial}><h2>{initial}</h2>
{groups[initial].map(person => <button type="button" key={person.user_id} onClick={() => { seedProfile(person); navigate('friend-profile', person.user_id); }}>
<Avatar person={person} /><span><strong>{person.remark || person.display_name}</strong>{person.remark ? <small>{person.display_name}</small> : null}</span>
{/* 副行只在确实是另一个名字时才显示。后端在有备注时会把 display_name 也设成备注
(im_http.go:8500-8507,且合并守卫会挡住用 peer 真实昵称覆盖),两个字段同值,
原来那句 `person.remark ? <small>{display_name}</small>` 会把同一串字渲染两遍。
后端目前没有单独的原昵称字段,所以拿不到"备注(主)/昵称(副)"的第二行,已记为后端缺口。 */}
<Avatar person={person} /><span><strong>{person.remark || person.display_name}</strong>{person.remark && person.remark !== person.display_name ? <small>{person.display_name}</small> : null}</span>
</button>)}
</section>)}
</main>
@@ -377,7 +381,7 @@ function Profile({uid, friend, onTopicSelected, onToast}) {
function Toggle({checked, onChange}) { return <button type="button" role="switch" aria-checked={checked} className={'people-switch' + (checked ? ' checked' : '')} onClick={() => onChange(!checked)}><i /></button>; }
function Settings({uid, initialBlocked, onToast}) {
function Settings({uid, initialBlocked, pinBlocked, onToast}) {
// Same seed-from-what-we-already-know as Profile/Verification -- getUserProfile can 403 even for a
// real, current friend (see seedProfile's own comment), which used to leave this page's header
// blank instead of just stuck like Profile, but no less wrong.
@@ -390,7 +394,11 @@ function Settings({uid, initialBlocked, onToast}) {
const [remarkDraft, setRemarkDraft] = React.useState('');
React.useEffect(() => {
if (!uid) return;
getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {});
// pinBlocked:确认弹窗把这个页面当背景渲染,并用 initialBlocked 摆出"操作之后"的样子。
// 这时不能让这次拉取回来的真实关系(还是 friend)把它改回去——原来会导致弹窗一弹出来,
// 背后的拉黑开关自己跳回"未拉黑",同时"删除好友"红条又冒出来,跟参考图不符也跟弹窗
// 正在确认的这件事自相矛盾。
getUserProfile(uid).then(p => { writeCachedData('profile-seed:' + uid, p); setProfile(p); if (!pinBlocked) setBlocked(p.relationship === 'blocked_by_me'); }).catch(() => {});
// GET /users/{id}/profile never exposes the real remark (backend's userProfilePayload always
// calls userSummaryPayload(user, "") -- confirmed against im_be/server/im_http.go:7844 --
// folding an empty remark into display_name instead of returning a separate field), only the
@@ -480,7 +488,7 @@ function ConfirmSheet({type, uid, tinode, onToast}) {
call.then(() => { onToast(config.toast); navigate(config.route, config.keepUid ? uid : undefined); })
.catch(err => onToast(err.message || '操作失败')).finally(() => setBusy(false));
};
return <div className="people-page"><Settings uid={uid} initialBlocked={type === 'block'} onToast={onToast} />
return <div className="people-page"><Settings uid={uid} initialBlocked={type === 'block'} pinBlocked={true} onToast={onToast} />
<div className="people-sheet-backdrop"><section className="people-confirm-sheet" role="dialog" aria-modal="true"><i /><h2>{config.title}</h2><p>{config.text}</p>
<button type="button" className="danger" disabled={busy} onClick={confirm}>{config.action}</button>
<button type="button" onClick={() => navigate('friend-settings', uid)}>取消</button>
+86 -16
View File
@@ -24,8 +24,24 @@ const navigate = (view, topic) => {
// 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for
// viewing a group member who isn't necessarily a friend -- that route keys off `uid`, not `topic`, so it
// can't go through this file's own `navigate` helper above without clobbering the wrong hash param.
const navigateToProfile = userId => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
// 除了跳转,还必须先把已知资料写进 'profile-seed:' 缓存 —— 目标页(第5章的 Profile)会先读这个种子,
// 因为 GET /users/{id}/profile 对非好友按隐私默认直接 403(imPrivacyAllowsProfile)。群成员里
// 大部分不是好友,不 seed 的话点进去就是"资料加载失败,请重试"。第5章自己的入口
// (contact-management-view.jsx 的 seedProfile)一直是这么做的,这里漏了。
const navigateToProfile = (userId, member) => {
if (member && userId) {
writeCachedData('profile-seed:' + userId, {
user_id: userId,
display_name: member.group_nickname || member.display_name || userId,
avatar_file_id: member.avatar_file_id,
...(member.signature ? {signature: member.signature} : {}),
...(member.region ? {region: member.region} : {}),
...(member.gender ? {gender: member.gender} : {})
});
}
HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
};
export const GROUP_MANAGEMENT_VIEWS = [
'group-list', 'group-empty', 'group-invite', 'group-events', 'group-join-requests',
@@ -60,6 +76,27 @@ function groupBy(list, keyFn) {
return Object.keys(groups).sort().map(key => [key, groups[key]]);
}
// GET /v1/im/groups 的 name/member_count 目前不可信:handler 用的是 store.Users.GetSubs,而 store 自己的
// 注释就写着它 "Does not load Public/Trusted or Private"(store.go:834-835),可 groupListItemPayload
// 偏偏去读 sub.GetPublic() 取名字、sub.GetSubCnt() 取人数——于是每个群都返回自己的 topic id 当名字、
// 0 当人数(应该用 GetTopics,它会把 Public 拷到 subscription 上)。已记入后端问题清单。
// 在后端修好之前,用本地 Tinode 会话缓存兜底——消息首页的群名一直是从这里取的,是准的。
function groupDisplayName(tinode, group) {
if (group.name && group.name !== group.topic) return group.name;
const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null;
const pub = t && t.public;
// 群的 public 用的是 `name`(实测本地缓存就是 {name:"扫码测试群", version:1}),后端自己的
// groupName() 也是按 name/display_name/fn 这个顺序找的,保持一致。
const local = pub && (pub.name || pub.display_name || pub.fn);
return local || group.name || group.topic;
}
function groupMemberCount(tinode, group) {
if (group.member_count) return group.member_count;
const t = tinode && tinode.getTopic ? tinode.getTopic(group.topic) : null;
return (t && t.subcnt) || 0;
}
function shortDate(iso) {
if (!iso) return '';
const d = new Date(iso);
@@ -82,9 +119,11 @@ const GROUPS_DEMO = {
};
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'group-list', backTopic, right, onRight}) {
// onBack:少数页面的返回目标不是"另一个侧栏视图",而是要关掉侧栏回到某个会话(需要写 path[1],
// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。
function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) {
return <header className="group-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backTopic); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!HashNavigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
</header>;
@@ -118,7 +157,7 @@ function Toggle({checked, onChange, disabled}) {
className={'group-switch' + (checked ? ' checked' : '')} onClick={() => !disabled && onChange(!checked)}><i /></button>;
}
function GroupList({onToast}) {
function GroupList({tinode, onToast}) {
// Only the unfiltered (query === '') result is worth caching -- caching a search result under the
// same key would show stale search results instead of the real list on the next plain visit.
const [lists, setLists] = React.useState(() => readCachedData('groups') || {owned: null, managed: null, joined: null});
@@ -163,7 +202,7 @@ function GroupList({onToast}) {
current.length ? current.map(group =>
<button type="button" key={group.topic} onClick={() => navigate('group-settings', group.topic)}>
<Avatar item={group} />
<span><strong>{group.name}</strong><small>{group.member_count} 位成员</small></span>
<span><strong>{groupDisplayName(tinode, group)}</strong><small>{groupMemberCount(tinode, group)} 位成员</small></span>
<Icon>chevron_right</Icon>
</button>) :
<div className="group-empty"><Icon>groups</Icon><strong>这里还没有群聊</strong>
@@ -268,7 +307,13 @@ function Announcement({topic, myRole, fromChat, onToast}) {
// handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical
// path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so
// "back" can return to the chat (close the sidepanel) instead of always forcing group-settings.
const backView = fromChat ? '' : 'group-settings';
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该
// 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数,
// path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。
// 这里显式把 topic 写回 path[1]。
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
}, [topic]);
React.useEffect(() => {
if (!topic) return;
const cached = readCachedData('group-announcement:' + topic);
@@ -286,9 +331,9 @@ function Announcement({topic, myRole, fromChat, onToast}) {
setData(d); setEditing(false); onToast('群公告已发布');
}).catch(err => onToast(err.message || '发布失败')).finally(() => setSaving(false));
};
if (!data) return <div className="group-page"><Header title="群公告" back={backView} backTopic={topic} /><main className="group-announcement-detail"><p className="group-inline-empty">{loadError ? '群公告加载失败' : '加载中…'}</p></main></div>;
if (!data) return <div className="group-page"><Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined} /><main className="group-announcement-detail"><p className="group-inline-empty">{loadError ? '群公告加载失败' : '加载中…'}</p></main></div>;
return <div className="group-page">
<Header title="群公告" back={backView} backTopic={topic}
<Header title="群公告" back={fromChat ? undefined : 'group-settings'} backTopic={topic} onBack={fromChat ? backToChat : undefined}
right={canEdit ? '发布' : null} onRight={canEdit ? publish : undefined} />
<main className="group-announcement-detail">
{canEdit ? <textarea autoFocus={editing} value={draft} maxLength={2000}
@@ -377,7 +422,7 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
<strong>群成员</strong><small>共 {group.member_count} 位成员</small>
</div>
<div className="group-member-grid">
{members.slice(0, 9).map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(item.user_id)}>
{members.slice(0, 9).map(item => <button type="button" key={item.user_id} onClick={() => navigateToProfile(item.user_id, item)}>
<Avatar item={item} icon="person" /><small>{item.group_nickname || item.display_name}</small>
</button>)}
<button type="button" onClick={() => onToast('请通过转发/分享群二维码邀请成员')}>
@@ -712,6 +757,7 @@ function Muted({topic, onToast}) {
// everyone still in the group, and fetching it here is one extra request for the whole list
// (not per-row), unlike JoinRequests' per-item getUserProfile lookup.
const [names, setNames] = React.useState({});
const [avatars, setAvatars] = React.useState({});
const [loadError, setLoadError] = React.useState(false);
const load = React.useCallback(() => {
if (!topic) return;
@@ -722,8 +768,13 @@ function Muted({topic, onToast}) {
}).catch(() => { onToast('加载失败'); setLoadError(true); });
getGroupMembers(topic).then(data => {
const map = {};
(data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });
const avatarMap = {};
(data.items || []).forEach(m => {
map[m.user_id] = m.group_nickname || m.display_name || m.user_id;
if (m.avatar_file_id) avatarMap[m.user_id] = {avatar_file_id: m.avatar_file_id};
});
setNames(map);
setAvatars(avatarMap);
}).catch(() => {});
}, [topic]);
React.useEffect(() => { setItems((topic && readCachedData('group-muted:' + topic)) || null); load(); }, [topic, load]);
@@ -736,9 +787,15 @@ function Muted({topic, onToast}) {
<main className="group-muted-list">
{items === null ? <p className="group-inline-empty">{loadError ? '成员列表加载失败' : '加载中…'}</p> :
items.length ? items.map(item => <article key={item.user_id}>
<Avatar item={{}} /><span><strong>{names[item.user_id] || item.user_id}</strong><small>{item.expires_at ? `禁言至 ${shortDate(item.expires_at)}` : '永久禁言'}</small></span>
{/* 必须传 icon="person":Avatar 的兜底图标默认是 'group',禁言的是人不是群,
原来每一行都画成群组图标。getGroupMutes 本身不返回 avatar_file_id
(im_http.go:7083-7096),但同组件已经为了解析显示名拉过整份成员表,顺带把头像也带上。 */}
<Avatar item={avatars[item.user_id] || {}} icon="person" /><span><strong>{names[item.user_id] || item.user_id}</strong><small>{item.expires_at ? `禁言至 ${shortDate(item.expires_at)}` : '永久禁言'}</small></span>
<button type="button" onClick={() => unmute(item.user_id)}>解除</button>
</article>) : <div className="group-inline-empty">以上成员不允许发言</div>}
</article>) : <div className="group-inline-empty">暂无禁言成员</div>}
{/* "以上成员不允许发言"在参考图里是列表下方的常驻灰色注脚,不是空状态文案。
原来把它当空态用,结果有禁言成员时这句话消失、一个都没有时反而显示它,语义正好反了。 */}
{items && items.length ? <p className="group-inline-note">以上成员不允许发言</p> : null}
</main>
</div>;
}
@@ -797,6 +854,9 @@ function GroupFiles({topic, tinode, onToast}) {
const [menuOpen, setMenuOpen] = React.useState(false);
const [uploading, setUploading] = React.useState(null);
const [loadError, setLoadError] = React.useState(false);
// groupFilePayload 只给 uploader_id、不给昵称(im_http.go:6532-6559),原来直接把裸 user_id
// 显示在"来自"位置。跟 Muted 页一样,拉一次成员表交叉引用出可读的名字。
const [uploaderNames, setUploaderNames] = React.useState({});
const inputRefs = {file: React.useRef(null), image: React.useRef(null), video: React.useRef(null)};
const load = React.useCallback(() => {
if (!topic) return;
@@ -807,6 +867,14 @@ function GroupFiles({topic, tinode, onToast}) {
}).catch(() => { onToast('加载失败'); setLoadError(true); });
}, [topic, query]);
React.useEffect(() => { load(); }, [load]);
React.useEffect(() => {
if (!topic) return;
getGroupMembers(topic).then(data => {
const map = {};
(data.items || []).forEach(m => { map[m.user_id] = m.group_nickname || m.display_name || m.user_id; });
setUploaderNames(map);
}).catch(() => {});
}, [topic]);
const usedBytes = (files || []).reduce((sum, f) => sum + (f.file_size || 0), 0);
const pick = kind => { setMenuOpen(false); inputRefs[kind].current && inputRefs[kind].current.click(); };
const onFile = e => {
@@ -846,7 +914,7 @@ function GroupFiles({topic, tinode, onToast}) {
const [icon, color] = FILE_TYPE_META[file.file_type] || FILE_TYPE_META.file;
return <article key={file.group_file_id}>
<span className="group-file-type" style={{backgroundColor: color}}><Icon>{icon}</Icon></span>
<div><strong>{file.file_name || '未命名文件'}</strong><small>{bytesToGB(file.file_size)} · {file.uploader_id}</small></div>
<div><strong>{file.file_name || '未命名文件'}</strong><small>来自 {uploaderNames[file.uploader_id] || file.uploader_id} · {bytesToGB(file.file_size)}</small></div>
<button type="button" onClick={() => remove(file.group_file_id)}><Icon>delete_outline</Icon></button>
</article>;
})}
@@ -854,10 +922,12 @@ function GroupFiles({topic, tinode, onToast}) {
</div>;
}
// 名字叫 bytesToGB 却只输出到 MB —— 1.4GB 的文件会显示成 "1433.6 MB"。补上 GB 档。
function bytesToGB(bytes) {
if (!bytes) return '0 KB';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
return (bytes / (1024 * 1024 * 1024)).toFixed(2) + ' GB';
}
function GroupNameEditor({topic, onToast}) {
@@ -941,7 +1011,7 @@ export default function GroupManagementView({view, hashParams, myUserId, tinode,
}).catch(() => {});
}, [topic, myUserId]);
let content;
if (view === 'group-list' || view === 'group-empty') content = <GroupList onToast={setToast} />;
if (view === 'group-list' || view === 'group-empty') content = <GroupList tinode={tinode} onToast={setToast} />;
else if (['group-invite', 'group-events', 'group-new-request', 'group-announcement', 'group-summary', 'group-role-message'].includes(view)) content = <GroupChat mode={view} onToast={setToast} />;
else if (view === 'group-join-requests') content = <JoinRequests topic={topic} onToast={setToast} />;
else if (view === 'group-announcement-detail') content = <Announcement topic={topic} myRole={myRole} fromChat={hashParams && hashParams.from === 'chat'} onToast={setToast} />;
+6 -1
View File
@@ -957,9 +957,14 @@ class TinodeWeb extends React.Component {
console.warn("Login failed", err);
if (!autoLogin) {
if (token) {
// 存的 token 失效(过期/被撤销)——这属于"会话没了",回到登录入口是对的。
this.handleLogout();
HashNavigation.navigateTo('');
}
HashNavigation.navigateTo('');
// 用户手输账号密码失败(401 之类)时**不要**导航。原来这里无条件 navigateTo(''),
// 会把 sidePanelSelected 清成 '',render 里 '' 又落到 'tenant',于是密码打错一次就被
// 弹回企业码页,刚输的账号丢了;而 TenantEntry 不渲染 FormAlert、chapterOne 分支也不渲染
// ErrorPanel,那句错误提示用户根本看不到。留在登录页,errorText 才能显示出来。
}
});
}