feat(web): complete groups/contacts/favorites/settings/calls/system domains + extensive review-round fixes

Rounds out the full rewrite with the remaining feature domains: group
management (admin panel, mute, files, announcements, QR-based invite/join),
contacts (friend profile, add-friend, blocklist, new-friends), favorites
(real media rendering across text/image/video/voice/file/link), settings
(profile/security/privacy/general/about), calls (Agora-backed audio/video
signaling + call history), and system messages/file assistant.

Also folds in a large batch of live-verified fixes surfaced by repeated
fresh code-review passes and hands-on E2E testing against the real
backend, including: WS reconnect not resubscribing any topic after a
disconnect, missing x-im-client-mid on every send path (protocol
compliance for retry dedup), group mute/member-settings/self-profile
endpoint corrections, relationship-state type fixes across search/QR/
friend-profile, tenant-switch logout gap, password-policy now read from
the real per-tenant config instead of guessed, NavRail highlight/
aria-current desync, several call-state-machine races, and responsive
layout fixes across all four breakpoints (mobile/tablet/laptop/desktop)
including a full mobile rebuild of favorites, call UI, and the settings
home/sub-list split.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-09 20:16:33 +08:00
co-authored by Claude Sonnet 5
parent 72b7b79caf
commit 5c3f3b7653
89 changed files with 10158 additions and 442 deletions
+67 -11
View File
@@ -12,6 +12,15 @@ import { useSessionStore } from '@/stores/session';
const PHONE_RE = /^\d{11}$/;
/** 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);
}
export function ResetPasswordPage() {
const navigate = useNavigate();
const tenant = useSessionStore((s) => s.tenant);
@@ -22,6 +31,10 @@ export function ResetPasswordPage() {
const [confirm, setConfirm] = useState('');
const [challengeId, setChallengeId] = useState('');
const [error, setError] = useState('');
// README §10.4's own error example ("密码至少 8 位,需包含字母和数字") is specifically an INLINE
// field error under the password input, not just the shared top banner every other validation
// failure here uses -- this mirrors that without touching the other (phone/code) failure paths.
const [passwordFieldError, setPasswordFieldError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [toast, setToast] = useState('');
@@ -34,15 +47,28 @@ export function ResetPasswordPage() {
}, [phone]);
if (!tenant) return <Navigate to="/tenant" replace />;
// 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.
const minPasswordLength = tenant.passwordPolicy?.minLength ?? 8;
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordFieldError('');
setError('');
if (!PHONE_RE.test(phone)) return setError('请输入 11 位手机号');
if (!challengeId || !/^\d{6}$/.test(code)) return setError('请先获取并输入 6 位验证码');
// Matches Register's rule (>= 8, no upper bound -- the backend's checkPasswordPolicy only
// enforces a minimum length, so the message says exactly that and nothing more).
if (password.length < 8) return setError('密码至少 8 位');
if (password !== confirm) return setError('两次输入的密码不一致');
// Matches Register's rule (no upper bound enforced client-side -- the backend's checkPasswordPolicy
// only enforces a minimum length, so the message says exactly that and nothing more). Inline-only
// (no setError) -- these two are the password fields' OWN validation, already shown right under
// the relevant input; also raising the shared top banner with the identical text duplicated the
// same message on screen twice for no reason phone/code's genuinely-separate failures don't share.
if (password.length < minPasswordLength) {
return setPasswordFieldError(`密码至少 ${minPasswordLength} 位`);
}
if (password !== confirm) {
return setPasswordFieldError('两次输入的密码不一致');
}
setSubmitting(true);
setError('');
@@ -58,12 +84,20 @@ export function ResetPasswordPage() {
};
return (
<div className="flex h-dvh w-full flex-col bg-surface-page px-6 py-8">
<Link to="/login" className="flex items-center gap-1 text-sm text-text-secondary no-underline">
<div className="flex h-dvh w-full flex-col overflow-y-auto bg-surface-page">
{/* 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. */}
<div className="flex h-12 flex-none items-center px-2 min-[900px]:hidden">
<Link to="/login" className="grid h-9 w-9 place-items-center text-text-heading no-underline">
<Icon name="arrow_back_ios_new" className="text-[20px]" />
</Link>
<strong className="flex-1 -ml-9 text-center text-[17px] font-semibold text-text-heading">重置密码</strong>
</div>
<Link to="/login" className="hidden items-center gap-1 px-6 pt-8 text-sm text-text-secondary no-underline min-[900px]:flex">
<Icon name="arrow_back_ios_new" className="text-[16px]" />
返回登录
</Link>
<div className="flex flex-1 items-center justify-center">
<div className="flex flex-1 items-center justify-center px-6 py-8">
<form onSubmit={submit} className="flex w-full max-w-[380px] flex-col gap-4">
<div className="flex flex-col gap-1">
<h1 className="m-0 text-[22px] font-bold text-text-title">重置密码</h1>
@@ -82,14 +116,16 @@ export function ResetPasswordPage() {
<span className="text-[15px] text-text-tertiary-2">+86</span>
<input
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
onChange={(e) => setPhone(normalizePhoneInput(e.target.value))}
placeholder="请输入手机号"
type="tel"
inputMode="numeric"
autoComplete="tel"
className="w-full bg-transparent text-[15px] outline-none"
/>
</label>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<input
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
@@ -100,8 +136,28 @@ export function ResetPasswordPage() {
<VerificationCodeButton phone={phone} scene="reset_password" onChallenge={setChallengeId} onError={setError} />
</div>
<PasswordInput value={password} onChange={setPassword} placeholder="新密码" autoComplete="new-password" />
<PasswordInput value={confirm} onChange={setConfirm} placeholder="再次输入新密码" autoComplete="new-password" />
<PasswordInput
value={password}
onChange={(v) => {
setPassword(v);
setPasswordFieldError('');
}}
placeholder="新密码"
autoComplete="new-password"
error={passwordFieldError && password.length < minPasswordLength ? passwordFieldError : undefined}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<PasswordInput
value={confirm}
onChange={(v) => {
setConfirm(v);
setPasswordFieldError('');
}}
placeholder="再次输入新密码"
autoComplete="new-password"
error={passwordFieldError && password.length >= minPasswordLength ? passwordFieldError : undefined}
maxLength={tenant.passwordPolicy?.maxLength}
/>
<button
type="submit"