From 46b3288880de3bec46aa3673c872ac7e598eeb42 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 10 Sep 2026 19:04:59 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E4=B8=8B=E7=BA=BF=E8=87=AA?= =?UTF-8?q?=E4=B8=BB=E6=B3=A8=E5=86=8C=E4=B8=8E=E5=8D=8F=E8=AE=AE=E5=8B=BE?= =?UTF-8?q?=E9=80=89=EF=BC=8C=E4=BF=AE=E5=A4=8D=E8=BD=AC=E5=8F=91=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E6=8A=A5=E9=94=99=EF=BC=8C=E8=BD=AC=E5=8F=91=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=99=A8=E5=88=86=E7=BB=84+=E5=85=A8=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按产品决定移除面向国内客户不适用的自主注册入口:删除 /register 路由 及 RegisterPage、登录页"马上注册"链接、登录/注册共用的协议勾选组件 Agreement,以及仅注册流程使用的 registerAccount/queueAccExtra 等 死代码;登录不再依赖勾选协议才能提交。 修复转发消息报错(真实高频bug,不限于个别场景):`forwardMessage`/ `sendText` 发送前只是 `tinode.getTopic()` 取一个本地话题对象,从未 真正订阅——SDK 要求话题处于 attached 状态才允许发布,而只有当前正 打开的会话才满足这一点,一旦切换过/离开过某个会话(哪怕之前订阅过 预览),它就会变回未订阅状态。也就是说转发给"当前会话之外"几乎任何 一个联系人都会失败。新增 `ensureTopicAttached()`,在转发前按需临时 订阅目标话题,单条转发和多选批量转发两处都已接入,实机验证对未打开 过的好友/群转发均成功。 转发选择器(ForwardPickerModal)按好友/群聊分组显示(原来混在一个 列表里),并加了"全选/取消全选",避免多个接收方时逐条点选。 Co-Authored-By: Claude Sonnet 5 --- web/src/App.tsx | 2 - web/src/app/RequireAuth.tsx | 10 +- web/src/features/auth/LoginPage.tsx | 13 +- web/src/features/auth/RegisterPage.tsx | 243 ------------------ web/src/features/auth/ResetPasswordPage.tsx | 8 +- .../features/auth/components/Agreement.tsx | 39 --- .../components/VerificationCodeButton.tsx | 2 +- .../features/messages/ConversationView.tsx | 38 ++- .../features/messages/ForwardPickerModal.tsx | 105 ++++++-- web/src/lib/api/auth.ts | 23 +- web/src/lib/tinode/connection.ts | 116 +-------- web/src/lib/tinode/topics.ts | 32 ++- web/src/stores/session.ts | 4 +- 13 files changed, 161 insertions(+), 474 deletions(-) delete mode 100644 web/src/features/auth/RegisterPage.tsx delete mode 100644 web/src/features/auth/components/Agreement.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 891af9c6..f883410d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,7 +5,6 @@ import { AppShell } from '@/app/AppShell'; import { RequireAuth } from '@/app/RequireAuth'; import { EnterpriseRegisterPage } from '@/features/auth/EnterpriseRegisterPage'; import { LoginPage } from '@/features/auth/LoginPage'; -import { RegisterPage } from '@/features/auth/RegisterPage'; import { ResetPasswordPage } from '@/features/auth/ResetPasswordPage'; import { TenantEntryPage } from '@/features/auth/TenantEntryPage'; import { TenantFeedbackPage } from '@/features/auth/TenantFeedbackPage'; @@ -34,7 +33,6 @@ const queryClient = new QueryClient(); const router = createBrowserRouter([ { path: 'tenant', element: }, { path: 'login', element: }, - { path: 'register', element: }, { path: 'reset-password', element: }, { path: 'enterprise-register', element: }, { path: 'feedback', element: }, diff --git a/web/src/app/RequireAuth.tsx b/web/src/app/RequireAuth.tsx index dc2b10f9..29da0a8a 100644 --- a/web/src/app/RequireAuth.tsx +++ b/web/src/app/RequireAuth.tsx @@ -70,11 +70,11 @@ export function RequireAuth() { }, [tenant, myUserId, hasToken, setMyUserId, clearTenant]); // Single place that ensures the `me` topic gets subscribed once `myUserId` is known, regardless - // of HOW it became known (session restore above, or a fresh login/register that already called - // setMyUserId before this component ever mounted) -- LoginPage/RegisterPage used to each fire - // their own subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly - // logged-in user with an empty conversation list and no error on a transient failure. Centralizing - // here means it happens exactly once per authenticated mount and surfaces the same Toast either way. + // of HOW it became known (session restore above, or a fresh login that already called + // setMyUserId before this component ever mounted) -- LoginPage used to fire its own + // subscribeMeTopic() with a swallowed .catch(() => {}), silently leaving a freshly logged-in user + // with an empty conversation list and no error on a transient failure. Centralizing here means it + // happens exactly once per authenticated mount and surfaces the same Toast either way. useEffect(() => { if (!myUserId || meSubscribedRef.current) return; meSubscribedRef.current = true; diff --git a/web/src/features/auth/LoginPage.tsx b/web/src/features/auth/LoginPage.tsx index c4eb32fa..e3333f8d 100644 --- a/web/src/features/auth/LoginPage.tsx +++ b/web/src/features/auth/LoginPage.tsx @@ -3,7 +3,6 @@ import { useEffect, useState } from 'react'; import { Link, Navigate, useNavigate } from 'react-router-dom'; import { Icon } from '@/app/Icon'; -import { Agreement } from '@/features/auth/components/Agreement'; import { PasswordInput } from '@/features/auth/components/PasswordInput'; import { BASE_APP_NAME } from '@/lib/config'; import { getTinode, loginBasic } from '@/lib/tinode/connection'; @@ -16,7 +15,6 @@ export function LoginPage() { const setMyUserId = useSessionStore((s) => s.setMyUserId); const [login, setLogin] = useState(''); const [password, setPassword] = useState(''); - const [agreed, setAgreed] = useState(false); const [error, setError] = useState(''); const [submitting, setSubmitting] = useState(false); @@ -37,7 +35,7 @@ export function LoginPage() { const submit = async (e: React.FormEvent) => { e.preventDefault(); - if (!login.trim() || !password || !agreed || submitting) return; + if (!login.trim() || !password || submitting) return; setSubmitting(true); setError(''); try { @@ -116,18 +114,11 @@ export function LoginPage() { -

- 没有账户?{' '} - - 马上注册 - -

- diff --git a/web/src/features/auth/RegisterPage.tsx b/web/src/features/auth/RegisterPage.tsx deleted file mode 100644 index 078e5f13..00000000 --- a/web/src/features/auth/RegisterPage.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { Link, Navigate, useNavigate } from 'react-router-dom'; - -import { Icon } from '@/app/Icon'; -import { Agreement } from '@/features/auth/components/Agreement'; -import { PasswordInput } from '@/features/auth/components/PasswordInput'; -import { VerificationCodeButton } from '@/features/auth/components/VerificationCodeButton'; -import { verifyRegisterCode } from '@/lib/api/auth'; -import { ImApiError } from '@/lib/api/client'; -import { getTinode, registerAccount } from '@/lib/tinode/connection'; -import { registerErrorMessage, validateCredentialsMessage } from '@/lib/tinode/errors'; -import { useSessionStore } from '@/stores/session'; - -const USERNAME_RE = /^[a-z0-9]{1,10}$/; -const PHONE_RE = /^\d{11}$/; -const INVITE_RE = /^[A-Za-z]{5}$/; - -/** Pasting a number copied as "+86138..."/"86138..." (very common from a contacts app) strips to a - * 13-digit all-digit string; naively taking the first 11 keeps "86"+9 real digits and silently - * drops the real last 2. Strip a leading 86 first when the digits-only string is longer than 11. */ -function normalizePhoneInput(raw: string): string { - const digits = raw.replace(/\D/g, ''); - const withoutCountryCode = digits.length > 11 && digits.startsWith('86') ? digits.slice(2) : digits; - return withoutCountryCode.slice(0, 11); -} - -interface FormErrors { - username?: string; - phone?: string; - code?: string; - password?: string; - invite?: string; -} - -export function RegisterPage() { - const navigate = useNavigate(); - const tenant = useSessionStore((s) => s.tenant); - const setMyUserId = useSessionStore((s) => s.setMyUserId); - - const [username, setUsername] = useState(''); - const [phone, setPhone] = useState(''); - const [code, setCode] = useState(''); - const [password, setPassword] = useState(''); - const [invite, setInvite] = useState(''); - const [challengeId, setChallengeId] = useState(''); - const [agreed, setAgreed] = useState(false); - const [errors, setErrors] = useState({}); - const [formError, setFormError] = useState(''); - const [submitting, setSubmitting] = useState(false); - - // A challengeId/code pair is only valid for the phone number it was issued for -- without this, - // editing the phone after requesting a code (typo fix) leaves a stale challengeId in place that - // verifyRegisterCode() has no way to cross-check against the NEW phone in the field. - useEffect(() => { - setChallengeId(''); - setCode(''); - }, [phone]); - - if (!tenant) return ; - - const usingPhone = !!phone; - // `tenants/resolve`'s own real per-tenant password_policy (docs/IM-前后端对接文档.md:534) -- read - // off the tenant instead of a hardcoded guess, so this can never drift from ResetPasswordPage's - // own minimum (they'd silently diverged to two different hardcoded numbers before this existed). - const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8; - - const submit = async (e: React.FormEvent) => { - e.preventDefault(); - const next: FormErrors = {}; - if (!username && !phone) next.username = '用户名和手机号至少填写一项'; - if (username && !USERNAME_RE.test(username)) next.username = '用户名仅支持 1-10 位小写字母或数字'; - if (usingPhone) { - if (!PHONE_RE.test(phone)) next.phone = '请输入 11 位手机号'; - if (!challengeId) next.code = '请先获取验证码'; - else if (!/^\d{6}$/.test(code)) next.code = '请输入 6 位验证码'; - } else if (!INVITE_RE.test(invite)) { - next.invite = '用户名注册需填写 5 位字母邀请码'; - } - if (password.length < minPasswordLength) next.password = `密码至少 ${minPasswordLength} 位`; - setErrors(next); - setFormError(''); - if (Object.keys(next).length || !agreed || submitting) return; - - setSubmitting(true); - let verificationToken: string | undefined; - try { - if (usingPhone) { - const verified = await verifyRegisterCode(challengeId, code); - verificationToken = verified.verification_token; - } - } catch (err) { - setErrors((e) => ({ ...e, code: err instanceof ImApiError ? err.message : '验证码错误或已过期' })); - setSubmitting(false); - return; - } - try { - const ctrl = await registerAccount({ - username, - phone, - password, - inviteCode: invite, - verificationToken, - challengeId: usingPhone ? challengeId : undefined, - }); - if (ctrl.code >= 300 && ctrl.text === 'validate credentials') { - setFormError(validateCredentialsMessage(ctrl.params as { reason?: string; cred?: string[] } | undefined)); - return; - } - const authToken = getTinode().getAuthToken(); - sessionStorage.setItem('im-session-token', authToken ? authToken.token : ''); - setMyUserId((ctrl.params?.user as string | undefined) ?? null); - navigate('/messages'); - } catch (err) { - setFormError(registerErrorMessage(err)); - } finally { - setSubmitting(false); - } - }; - - return ( -
-
- - - - 注册 -
-
-
-

创建账号

- {formError ? ( -
- - {formError} -
- ) : null} - -
- setUsername(e.target.value.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 10))} - placeholder="用户名(1-10位小写字母或数字)" - autoComplete="username" - className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none focus:border focus:border-focus-border focus:shadow-focus-ring ${ - errors.username ? 'border border-danger-text' : '' - }`} - /> - {errors.username ?
{errors.username}
: null} -
- -
-
- 或 -
-
- -
- - {errors.phone ?
{errors.phone}
: null} -
- -
-
- setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} - placeholder="短信验证码" - inputMode="numeric" - autoComplete="one-time-code" - className={`h-[50px] flex-1 rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${ - errors.code ? 'border border-danger-text' : '' - }`} - /> - setErrors((e) => ({ ...e, code: msg }))} - /> -
- {errors.code ?
{errors.code}
: null} -
- - - -
- {/* 邀请码大小写敏感(唯一索引按 ascii_bin 排序),不能像其它字段那样统一大小写 */} - setInvite(e.target.value.replace(/[^A-Za-z]/g, '').slice(0, 5))} - placeholder="邀请码(5位英文字母,区分大小写)" - autoCapitalize="off" - autoCorrect="off" - className={`h-[50px] rounded-lg bg-surface-input px-3.5 text-[15px] outline-none ${ - errors.invite ? 'border border-danger-text' : '' - }`} - /> - {errors.invite ?
{errors.invite}
: null} -
- - {!usingPhone ? ( -

未绑定手机号无法自助找回密码,忘记密码后只能联系企业管理员重置

- ) : null} - - - - 已有账号?返回登录 - - - -
-
- ); -} diff --git a/web/src/features/auth/ResetPasswordPage.tsx b/web/src/features/auth/ResetPasswordPage.tsx index 370db8e0..9692d553 100644 --- a/web/src/features/auth/ResetPasswordPage.tsx +++ b/web/src/features/auth/ResetPasswordPage.tsx @@ -47,9 +47,9 @@ export function ResetPasswordPage() { }, [phone]); if (!tenant) return ; - // Same real per-tenant policy RegisterPage reads (docs/IM-前后端对接文档.md:534) -- was hardcoded - // to 8 here, matching Register's OWN hardcode by coincidence, not because either ever read the - // real value; SecuritySettingsPage's in-place change had drifted to a DIFFERENT hardcoded 6. + // Real per-tenant policy (docs/IM-前后端对接文档.md:534) -- was hardcoded to 8 here, not because + // anything ever read the real value; SecuritySettingsPage's in-place change had drifted to a + // DIFFERENT hardcoded 6. const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8; const submit = async (e: React.FormEvent) => { @@ -86,7 +86,7 @@ export function ResetPasswordPage() { return (
{/* README §10.7: mobile gets its own 48px icon+centered-title bar, not the desktop text link - scaled down -- same pattern as RegisterPage's own min-[900px]:hidden header. */} + scaled down. */}
diff --git a/web/src/features/auth/components/Agreement.tsx b/web/src/features/auth/components/Agreement.tsx deleted file mode 100644 index 0b69765c..00000000 --- a/web/src/features/auth/components/Agreement.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Icon } from '@/app/Icon'; -import { BASE_APP_NAME } from '@/lib/config'; - -interface AgreementProps { - checked: boolean; - onChange: (checked: boolean) => void; - prefix?: string; -} - -/** README/`01-account.dc.html`'s exact 16x16 `border-radius:4px` box with a custom check glyph -- - * `accent-color` alone only tints the native widget's fill and leaves its unchecked border/size/ - * radius to the browser/OS, which doesn't match this spec on any platform. */ -export function Agreement({ checked, onChange, prefix = `同意${BASE_APP_NAME}` }: AgreementProps) { - return ( - - ); -} diff --git a/web/src/features/auth/components/VerificationCodeButton.tsx b/web/src/features/auth/components/VerificationCodeButton.tsx index ce4585d6..f0f9619c 100644 --- a/web/src/features/auth/components/VerificationCodeButton.tsx +++ b/web/src/features/auth/components/VerificationCodeButton.tsx @@ -7,7 +7,7 @@ const PHONE_RE = /^\d{11}$/; interface VerificationCodeButtonProps { phone: string; - scene: 'register' | 'reset_password'; + scene: 'reset_password'; onChallenge: (challengeId: string) => void; onError: (message: string) => void; } diff --git a/web/src/features/messages/ConversationView.tsx b/web/src/features/messages/ConversationView.tsx index a84959ff..ef6d76b8 100644 --- a/web/src/features/messages/ConversationView.tsx +++ b/web/src/features/messages/ConversationView.tsx @@ -28,7 +28,7 @@ import { buildMentionedContent, detectMentionTrigger, type PendingMention } from import { sendFile, sendImage, sendVoice } from '@/lib/tinode/attachments'; import { useChatList, useTinode, useTopicMessages } from '@/lib/tinode/hooks'; import { forwardMessage, sendPrebuiltContent, sendReply, sendText } from '@/lib/tinode/send'; -import { closeConversation, openConversation } from '@/lib/tinode/topics'; +import { closeConversation, ensureTopicAttached, openConversation } from '@/lib/tinode/topics'; import { useCallStore } from '@/stores/calls'; import { useMessageJumpStore } from '@/stores/messageJump'; import { useSessionStore } from '@/stores/session'; @@ -469,16 +469,23 @@ export function ConversationView() { // actually said first. const orderedSeqs = [...selectedSeqs].sort((a, b) => a - b); for (const targetTopic of targetTopics) { - const target = tinode.getTopic(targetTopic); - for (const seq of orderedSeqs) { - const msg = topic.findMessage(seq); - // A message can be recalled by its sender AFTER it was already selected (the checkbox - // itself refuses a NEW selection of a recalled message, but can't retroactively un-select - // one recalled mid-session) -- forwarding null content would silently send an empty bubble - // with a misleading "forwarded from" tag rather than the message the user actually picked. - if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败')); - } - if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败')); + // A target that isn't the conversation currently open (i.e. almost any real forward target) + // is not actually subscribed, and publishing on an unsubscribed topic is rejected outright by + // the SDK -- see ensureTopicAttached's own comment for why this is the common case, not an + // edge case. + ensureTopicAttached(targetTopic) + .then((target) => { + for (const seq of orderedSeqs) { + const msg = topic.findMessage(seq); + // A message can be recalled by its sender AFTER it was already selected (the checkbox + // itself refuses a NEW selection of a recalled message, but can't retroactively un-select + // one recalled mid-session) -- forwarding null content would silently send an empty bubble + // with a misleading "forwarded from" tag rather than the message the user actually picked. + if (msg && !isRecalled(msg)) forwardMessage(topicId, msg, target).catch((err) => showError(err, '转发失败')); + } + if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败')); + }) + .catch((err) => showError(err, '转发失败')); } setBulkForwardOpen(false); exitSelectMode(); @@ -815,9 +822,12 @@ export function ConversationView() { onClose={() => setForwardMsg(null)} onPick={(targetTopics, comment) => { for (const targetTopic of targetTopics) { - const target = tinode.getTopic(targetTopic); - forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败')); - if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败')); + ensureTopicAttached(targetTopic) + .then((target) => { + forwardMessage(topicId, forwardMsg, target).catch((err) => showError(err, '转发失败')); + if (comment) sendText(target, comment).catch((err) => showError(err, '附言发送失败')); + }) + .catch((err) => showError(err, '转发失败')); } setForwardMsg(null); }} diff --git a/web/src/features/messages/ForwardPickerModal.tsx b/web/src/features/messages/ForwardPickerModal.tsx index 5ddfc22c..c5be9fea 100644 --- a/web/src/features/messages/ForwardPickerModal.tsx +++ b/web/src/features/messages/ForwardPickerModal.tsx @@ -1,9 +1,11 @@ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; + +import type { Contact } from 'tinode-sdk'; import { Icon } from '@/app/Icon'; import { avatarBgClass, avatarLetter } from '@/lib/avatar'; import { useEscapeKey } from '@/lib/hooks'; -import { useChatList } from '@/lib/tinode/hooks'; +import { useChatList, useTinode } from '@/lib/tinode/hooks'; const MAX_COMMENT_LENGTH = 200; @@ -24,12 +26,26 @@ interface ForwardPickerModalProps { */ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) { const contacts = useChatList(); + const tinode = useTinode(); const [query, setQuery] = useState(''); const [selected, setSelected] = useState>(new Set()); const [comment, setComment] = useState(''); useEscapeKey(onClose); const filtered = contacts.filter((c) => !query.trim() || (c.public?.fn || '').includes(query.trim())); + // The list used to mix friends and groups in one flat run -- every other list in this app + // (ChatListColumn's pinned/sorted rows, ContactsListColumn) already keeps the two visually + // separate, so this was the odd one out and made a specific group hard to spot among dozens of + // P2P contacts. + const friends = useMemo( + () => filtered.filter((c) => !!c.topic && !tinode.getTopic(c.topic).isGroupType()), + [filtered, tinode], + ); + const groups = useMemo( + () => filtered.filter((c) => !!c.topic && tinode.getTopic(c.topic).isGroupType()), + [filtered, tinode], + ); + const toggle = (topic: string) => { setSelected((prev) => { const next = new Set(prev); @@ -39,11 +55,50 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) }); }; + const visibleTopics = useMemo(() => filtered.map((c) => c.topic).filter((t): t is string => !!t), [filtered]); + const allVisibleSelected = visibleTopics.length > 0 && visibleTopics.every((t) => selected.has(t)); + const toggleSelectAll = () => { + setSelected((prev) => { + if (allVisibleSelected) { + // Only drop the ones the CURRENT search actually shows -- a selection made under an earlier, + // now-cleared query shouldn't be wiped out by un-selecting "all" of a narrower view. + const next = new Set(prev); + for (const t of visibleTopics) next.delete(t); + return next; + } + return new Set([...prev, ...visibleTopics]); + }); + }; + const send = () => { if (selected.size === 0) return; onPick([...selected], comment.trim()); }; + const renderRow = (c: Contact) => { + const isSelected = !!c.topic && selected.has(c.topic); + return ( + + ); + }; + return (
e.stopPropagation()}> @@ -66,31 +121,31 @@ export function ForwardPickerModal({ onPick, onClose }: ForwardPickerModalProps) className="ml-2 w-full bg-transparent text-sm outline-none placeholder:text-text-placeholder-2" />
+ {visibleTopics.length ? ( + + ) : null}
- {filtered.map((c) => { - const isSelected = !!c.topic && selected.has(c.topic); - return ( - - ); - })} + {friends.length ? ( + <> +
好友
+ {friends.map(renderRow)} + + ) : null} + {groups.length ? ( + <> +
群聊
+ {groups.map(renderRow)} + + ) : null} + {!friends.length && !groups.length ? ( +
+ + 未找到相关联系人 +
+ ) : null}