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:
co-authored by
Claude Sonnet 5
parent
72b7b79caf
commit
5c3f3b7653
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user