企业码解析+登录跑通真实后端:Tinode连接适配层、类型化API客户端、会话store
lib/tinode/connection.ts:单例Tinode连接+自己的发布订阅层——SDK的onConnect/onDisconnect/onLogin
是单槽位属性(赋值会互相覆盖),不是真正的多监听器事件源,所以这里统一接管这几个槽位,自己
扇出给任意数量的React订阅方(lib/tinode/hooks.ts的useSyncExternalStore)。installTenantHiPatch
照搬旧应用tinode-web.jsx的实现搬过来——服务端{hi}包的tenant字段SDK本身没有API,只能在原始
sendText层面注入,这是后端协议的真实约束,不是旧代码库特有的写法。
lib/api/client.ts:REST客户端按旧应用src/lib/im-api.js的request函数+完整REASON_MESSAGES错误码
翻译表搬过来(含请求头X-MeetingIM-APIKey等几个月联调换来的正确写法),这次是重新用TypeScript
写、不是复制文件,但端点行为对齐。
实机联调过程中发现并修正了一处凭记忆猜错的地方:tenants/resolve真实响应结构是
{endpoints:{api,file,websocket}, tenant:{code,name}},不是我最初假设的顶层{code,name}——
已用真实请求验证修正。用企业码default解析成功(显示"Default Tenant")+ 一次真实错误密码
登录尝试(服务端真实401,错误提示正确显示"账号或密码错误")两步都对着真实后端验证通过,
不是只测了正确路径。
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
8bcd9f3a5b
commit
8cdd84152d
@@ -0,0 +1,95 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { connect } from '@/lib/tinode/connection';
|
||||
import { useTinode } from '@/lib/tinode/hooks';
|
||||
import { useSessionStore } from '@/stores/session';
|
||||
|
||||
/**
|
||||
* ctrl.code/text -> Chinese message. Ported from the old app's loginErrorMessage() -- the SDK's
|
||||
* own login promise rejects with an Error whose `.code` is the ctrl code (401/404/500/...), not
|
||||
* with an ImApiError, since this is the native WS protocol, not the /v1/im REST layer.
|
||||
*/
|
||||
function loginErrorMessage(err: unknown): string {
|
||||
const code = (err as { code?: number })?.code;
|
||||
if (code === 401) return '账号或密码错误';
|
||||
if (code === 404) return '账号不存在';
|
||||
if (code && code >= 500) return '服务器繁忙,请稍后重试';
|
||||
return (err as Error)?.message || '登录失败,请重试';
|
||||
}
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const tenant = useSessionStore((s) => s.tenant);
|
||||
const setMyUserId = useSessionStore((s) => s.setMyUserId);
|
||||
const tinode = useTinode();
|
||||
const [login, setLogin] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
if (!tenant) {
|
||||
// No tenant resolved yet this session -- can't build a valid {hi} packet without it.
|
||||
return <Navigate to="/tenant" replace />;
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!login.trim() || !password || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
if (!tinode.isConnected()) {
|
||||
await connect();
|
||||
}
|
||||
const { ctrl } = await tinode.loginBasic(login.trim(), password);
|
||||
if (ctrl.code >= 300 && ctrl.text === 'validate credentials') {
|
||||
// Account created but needs credential confirmation (email/SMS code) -- full flow lands
|
||||
// with the register/credential-confirm screens later in Phase 1.
|
||||
setError('账号需要先完成验证,此流程正在实现中');
|
||||
return;
|
||||
}
|
||||
const authToken = tinode.getAuthToken();
|
||||
sessionStorage.setItem('im-session-token', authToken ? authToken.token : '');
|
||||
setMyUserId(tinode.getAuthenticatedUserID() ?? null);
|
||||
navigate('/messages');
|
||||
} catch (err) {
|
||||
setError(loginErrorMessage(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-4">
|
||||
<span className="text-[13px] text-text-tertiary-2">{tenant.name}</span>
|
||||
<h1 className="m-0 text-[28px] font-bold text-text-title">登录</h1>
|
||||
{error ? (
|
||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
||||
) : null}
|
||||
<input
|
||||
value={login}
|
||||
onChange={(e) => setLogin(e.target.value)}
|
||||
placeholder="用户名或手机号"
|
||||
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"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="密码"
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!login.trim() || !password || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '登录中…' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { ImApiError } from '@/lib/api/client';
|
||||
import { resolveTenant } from '@/lib/api/tenant';
|
||||
import { parseWebsocketEndpoint, useSessionStore } from '@/stores/session';
|
||||
|
||||
export function TenantEntryPage() {
|
||||
const navigate = useNavigate();
|
||||
const setTenant = useSessionStore((s) => s.setTenant);
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!code.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await resolveTenant(code.trim());
|
||||
const { host, secure } = parseWebsocketEndpoint(res.endpoints.websocket);
|
||||
setTenant({ code: res.tenant.code, name: res.tenant.name, endpoints: res.endpoints, host, secure });
|
||||
navigate('/login');
|
||||
} catch (err) {
|
||||
setError(err instanceof ImApiError ? err.message : '企业码不存在或已停用,请核实后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-dvh w-full items-center justify-center bg-surface-page">
|
||||
<form onSubmit={submit} className="flex w-[380px] flex-col gap-5">
|
||||
<div className="mb-2 flex flex-col items-center gap-3">
|
||||
<div className="grid h-[72px] w-[72px] place-items-center rounded-2xl bg-accent text-3xl font-bold text-white">
|
||||
M
|
||||
</div>
|
||||
<strong className="text-2xl font-bold text-text-heading">MeetingIM</strong>
|
||||
</div>
|
||||
{error ? (
|
||||
<div className="rounded-lg bg-error-strip-bg px-3 py-2.5 text-sm text-error-strip-text">{error}</div>
|
||||
) : null}
|
||||
<label className="flex h-[50px] items-center gap-2 rounded-lg bg-surface-input px-3.5 has-focus:border has-focus:border-focus-border has-focus:shadow-focus-ring">
|
||||
<span className="material-icons text-[20px] text-accent">business</span>
|
||||
<input
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
placeholder="请输入企业码"
|
||||
className="w-full bg-transparent text-[15px] outline-none"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!code.trim() || submitting}
|
||||
className="h-[50px] rounded-lg bg-btn text-base font-medium text-white shadow-btn disabled:bg-btn-disabled disabled:shadow-none"
|
||||
>
|
||||
{submitting ? '进入中…' : '进入企业'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user