6ee6fd64dd
Introduce page edit sessions with draft preview and patch API, chat skill picker, user memory profile, h5ApiBase resolution, voice WAV transport, and scripts for 105/g2 deployment and Plaza local dev. Co-authored-by: Cursor <cursoragent@cursor.com>
486 lines
15 KiB
TypeScript
486 lines
15 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import {
|
||
getMe,
|
||
getWechatAuthConfig,
|
||
login,
|
||
loginLegacy,
|
||
register,
|
||
resetPassword,
|
||
} from '../api/client';
|
||
import type { CapabilityMap, PortalUser } from '../types';
|
||
import {
|
||
buildWechatAuthorizeUrl,
|
||
isMobileExternalBrowser,
|
||
isWechatContext,
|
||
isWechatEntryUrl,
|
||
readWechatAuthError,
|
||
readWechatPendingToken,
|
||
} from '../utils/wechat';
|
||
import { TKMindAvatar } from './TKMindAvatar';
|
||
import { resolvePlazaHomeUrl } from '../utils/publicUrl';
|
||
import { WechatBindGate } from './WechatBindGate';
|
||
import { WechatDesktopLogin } from './WechatDesktopLogin';
|
||
import { WechatIcon } from './WechatIcon';
|
||
import { WechatOpenGuide } from './WechatOpenGuide';
|
||
|
||
type Mode = 'login' | 'register' | 'reset';
|
||
|
||
const MODE_META: Record<Mode, { title: string; desc: string }> = {
|
||
login: { title: 'TKMind', desc: '登录你的账号' },
|
||
register: { title: '创建账号', desc: '填写信息完成注册' },
|
||
reset: { title: '重置密码', desc: '验证注册邮箱后设置新密码' },
|
||
};
|
||
|
||
export function AuthView({
|
||
legacyMode,
|
||
onAuth,
|
||
}: {
|
||
legacyMode?: boolean;
|
||
onAuth: (
|
||
user?: PortalUser | null,
|
||
capabilities?: CapabilityMap,
|
||
grantedSkills?: string[],
|
||
) => void;
|
||
}) {
|
||
const [mode, setMode] = useState<Mode>('login');
|
||
const [username, setUsername] = useState('');
|
||
const [password, setPassword] = useState('');
|
||
const [confirmPassword, setConfirmPassword] = useState('');
|
||
const [displayName, setDisplayName] = useState('');
|
||
const [email, setEmail] = useState('');
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [success, setSuccess] = useState<string | null>(null);
|
||
const [submitting, setSubmitting] = useState(false);
|
||
const [wechatEnabled, setWechatEnabled] = useState(false);
|
||
const [wechatContext, setWechatContext] = useState(() =>
|
||
isWechatContext({ search: window.location.search }),
|
||
);
|
||
const [mobileExternal, setMobileExternal] = useState(() =>
|
||
isMobileExternalBrowser({ search: window.location.search }),
|
||
);
|
||
const [showPasswordLogin, setShowPasswordLogin] = useState(false);
|
||
const [showScanLogin, setShowScanLogin] = useState(false);
|
||
const [pendingToken, setPendingToken] = useState<string | null>(() => readWechatPendingToken());
|
||
const searchParams = new URLSearchParams(window.location.search);
|
||
const fromPlaza = (searchParams.get('utm_source') ?? '').toLowerCase() === 'plaza';
|
||
const returnTo = searchParams.get('return_to');
|
||
|
||
useEffect(() => {
|
||
const wechatError = readWechatAuthError();
|
||
if (wechatError) {
|
||
setError(wechatError);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (legacyMode) return;
|
||
void getWechatAuthConfig().then((config) => {
|
||
setWechatEnabled(config.enabled);
|
||
setWechatContext((prev) =>
|
||
isWechatContext({
|
||
search: window.location.search,
|
||
serverInWechat: config.inWechat,
|
||
}) || prev,
|
||
);
|
||
setMobileExternal(
|
||
isMobileExternalBrowser({
|
||
search: window.location.search,
|
||
serverInWechat: config.inWechat,
|
||
}),
|
||
);
|
||
|
||
const params = new URLSearchParams(window.location.search);
|
||
if (
|
||
config.enabled &&
|
||
isWechatEntryUrl(window.location.search) &&
|
||
isWechatContext({
|
||
search: window.location.search,
|
||
serverInWechat: config.inWechat,
|
||
}) &&
|
||
!params.get('wechat_pending') &&
|
||
!params.get('wechat_error')
|
||
) {
|
||
window.location.replace(
|
||
buildWechatAuthorizeUrl({
|
||
returnTo: returnTo ?? undefined,
|
||
utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat',
|
||
utmMedium: params.get('utm_medium') ?? undefined,
|
||
utmCampaign: params.get('utm_campaign') ?? undefined,
|
||
intent: 'login',
|
||
}),
|
||
);
|
||
}
|
||
});
|
||
}, [legacyMode, returnTo]);
|
||
|
||
const isLogin = mode === 'login' && !legacyMode;
|
||
const showWechatOneClick = isLogin && wechatContext && wechatEnabled;
|
||
const showMobileWechatGuide = isLogin && mobileExternal && wechatEnabled;
|
||
const showDesktopWechat = isLogin && wechatEnabled && !wechatContext && !mobileExternal;
|
||
const isDesktopScanMode = showDesktopWechat && showScanLogin;
|
||
const showAccountForm =
|
||
!isDesktopScanMode &&
|
||
(legacyMode ||
|
||
mode !== 'login' ||
|
||
mobileExternal ||
|
||
showPasswordLogin ||
|
||
!showWechatOneClick ||
|
||
showDesktopWechat);
|
||
|
||
const switchToPasswordLogin = () => {
|
||
setShowScanLogin(false);
|
||
setError(null);
|
||
setSuccess(null);
|
||
};
|
||
|
||
const switchToScanLogin = () => {
|
||
setShowScanLogin(true);
|
||
setError(null);
|
||
setSuccess(null);
|
||
};
|
||
|
||
const switchMode = (next: Mode) => {
|
||
setMode(next);
|
||
setError(null);
|
||
setSuccess(null);
|
||
setConfirmPassword('');
|
||
setShowScanLogin(false);
|
||
};
|
||
|
||
const handleWechatLogin = () => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
window.location.href = buildWechatAuthorizeUrl({
|
||
returnTo: returnTo ?? undefined,
|
||
utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat',
|
||
utmMedium: params.get('utm_medium') ?? undefined,
|
||
utmCampaign: params.get('utm_campaign') ?? undefined,
|
||
intent: 'login',
|
||
});
|
||
};
|
||
|
||
const handleLogin = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
if (legacyMode) {
|
||
await loginLegacy(password);
|
||
onAuth(null);
|
||
return;
|
||
}
|
||
const user = await login(username, password);
|
||
const me = await getMe().catch(() => null);
|
||
onAuth(user, me?.capabilities, me?.grantedSkills);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '登录失败');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleRegister = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
setSubmitting(true);
|
||
setError(null);
|
||
try {
|
||
await register(
|
||
username,
|
||
password,
|
||
displayName || undefined,
|
||
email,
|
||
{
|
||
utm_source: searchParams.get('utm_source') ?? undefined,
|
||
utm_medium: searchParams.get('utm_medium') ?? undefined,
|
||
utm_campaign: searchParams.get('utm_campaign') ?? undefined,
|
||
ref: searchParams.get('ref') ?? undefined,
|
||
},
|
||
);
|
||
const user = await login(username, password);
|
||
const me = await getMe().catch(() => null);
|
||
sessionStorage.setItem('tkmind_suggest_wechat_bind', '1');
|
||
onAuth(user, me?.capabilities, me?.grantedSkills);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '注册失败');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleReset = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (password !== confirmPassword) {
|
||
setError('两次输入的密码不一致');
|
||
return;
|
||
}
|
||
setSubmitting(true);
|
||
setError(null);
|
||
setSuccess(null);
|
||
try {
|
||
await resetPassword(username, email, password);
|
||
setSuccess('密码已重置,请使用新密码登录');
|
||
setPassword('');
|
||
setConfirmPassword('');
|
||
setTimeout(() => switchMode('login'), 1500);
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : '重置失败');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
if (pendingToken) {
|
||
return (
|
||
<WechatBindGate
|
||
pendingToken={pendingToken}
|
||
returnTo={returnTo}
|
||
onAuth={onAuth}
|
||
/>
|
||
);
|
||
}
|
||
|
||
const meta = legacyMode
|
||
? { title: 'TKMind', desc: '内部访问入口' }
|
||
: fromPlaza
|
||
? {
|
||
...MODE_META[mode],
|
||
desc:
|
||
mode === 'register'
|
||
? '注册后将返回 Plaza 继续浏览'
|
||
: '登录后将返回 Plaza 继续浏览',
|
||
}
|
||
: MODE_META[mode];
|
||
|
||
const plazaBackUrl = (() => {
|
||
if (!returnTo) return resolvePlazaHomeUrl();
|
||
try {
|
||
return new URL(returnTo, window.location.origin).toString();
|
||
} catch {
|
||
return resolvePlazaHomeUrl();
|
||
}
|
||
})();
|
||
|
||
const onSubmit =
|
||
mode === 'register' && !legacyMode
|
||
? handleRegister
|
||
: mode === 'reset' && !legacyMode
|
||
? handleReset
|
||
: handleLogin;
|
||
|
||
const submitLabel =
|
||
submitting
|
||
? '处理中…'
|
||
: legacyMode
|
||
? '解锁'
|
||
: mode === 'register'
|
||
? '注册并登录'
|
||
: mode === 'reset'
|
||
? '确认重置'
|
||
: '登录';
|
||
|
||
const submitDisabled =
|
||
submitting ||
|
||
(legacyMode
|
||
? !password.trim()
|
||
: mode === 'login'
|
||
? !username.trim() || !password.trim()
|
||
: mode === 'register'
|
||
? !username.trim() || !password.trim() || !email.trim()
|
||
: !username.trim() ||
|
||
!email.trim() ||
|
||
!password.trim() ||
|
||
!confirmPassword.trim());
|
||
|
||
const showAltLogin =
|
||
isLogin && wechatEnabled && (showMobileWechatGuide || (showDesktopWechat && !showScanLogin));
|
||
|
||
return (
|
||
<div className="login-container">
|
||
<div className="login-card">
|
||
{!legacyMode && fromPlaza && (
|
||
<div className="login-plaza-back">
|
||
<a href={plazaBackUrl}>← 返回广场</a>
|
||
</div>
|
||
)}
|
||
{!legacyMode && (
|
||
<div className="login-topbar">
|
||
{mode === 'login' ? (
|
||
<>
|
||
<button type="button" className="login-link" onClick={() => switchMode('register')}>
|
||
注册
|
||
</button>
|
||
<span className="login-topbar-sep" aria-hidden="true" />
|
||
<button type="button" className="login-link" onClick={() => switchMode('reset')}>
|
||
忘记密码
|
||
</button>
|
||
</>
|
||
) : (
|
||
<button type="button" className="login-link" onClick={() => switchMode('login')}>
|
||
返回登录
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
<form className="login-form" onSubmit={onSubmit}>
|
||
<div className="login-brand">
|
||
<TKMindAvatar size="md" className="login-brand-avatar" />
|
||
<h1 className="login-title">{meta.title}</h1>
|
||
<p className="login-desc">{meta.desc}</p>
|
||
</div>
|
||
|
||
{isDesktopScanMode && (
|
||
<>
|
||
<WechatDesktopLogin
|
||
returnTo={returnTo}
|
||
onBindingGate={(token) => setPendingToken(token)}
|
||
onComplete={() => window.location.reload()}
|
||
onError={(message) => setError(message)}
|
||
/>
|
||
{error && <p className="login-error">{error}</p>}
|
||
{success && <p className="login-success">{success}</p>}
|
||
<button type="button" className="login-mode-switch" onClick={switchToPasswordLogin}>
|
||
账号密码登录
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{showWechatOneClick && (
|
||
<>
|
||
<button
|
||
type="button"
|
||
className="login-wechat-btn"
|
||
onClick={handleWechatLogin}
|
||
disabled={submitting}
|
||
>
|
||
<WechatIcon className="login-wechat-icon" />
|
||
微信一键登录
|
||
</button>
|
||
{!showPasswordLogin ? (
|
||
<button
|
||
type="button"
|
||
className="login-link login-password-toggle"
|
||
onClick={() => setShowPasswordLogin(true)}
|
||
>
|
||
已有账号?密码登录
|
||
</button>
|
||
) : (
|
||
<div className="login-divider" aria-hidden="true">
|
||
<span>或</span>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{showAccountForm && (
|
||
<>
|
||
<div className="login-fields">
|
||
{!legacyMode && (
|
||
<input
|
||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||
type="text"
|
||
placeholder="用户名"
|
||
value={username}
|
||
autoComplete="username"
|
||
autoFocus={isLogin}
|
||
onChange={(e) => setUsername(e.target.value)}
|
||
/>
|
||
)}
|
||
|
||
{mode === 'register' && !legacyMode && (
|
||
<>
|
||
<input
|
||
className="login-input"
|
||
type="email"
|
||
placeholder="邮箱"
|
||
value={email}
|
||
autoComplete="email"
|
||
required
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
/>
|
||
<input
|
||
className="login-input"
|
||
type="text"
|
||
placeholder="显示名称(可选)"
|
||
value={displayName}
|
||
onChange={(e) => setDisplayName(e.target.value)}
|
||
/>
|
||
</>
|
||
)}
|
||
|
||
{mode === 'reset' && !legacyMode && (
|
||
<input
|
||
className="login-input"
|
||
type="email"
|
||
placeholder="注册邮箱"
|
||
value={email}
|
||
autoComplete="email"
|
||
required
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
/>
|
||
)}
|
||
|
||
<input
|
||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||
type="password"
|
||
placeholder={mode === 'reset' ? '新密码(至少 6 位)' : '密码'}
|
||
value={password}
|
||
autoComplete={
|
||
mode === 'register' || mode === 'reset' ? 'new-password' : 'current-password'
|
||
}
|
||
autoFocus={legacyMode}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
/>
|
||
|
||
{mode === 'reset' && !legacyMode && (
|
||
<input
|
||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||
type="password"
|
||
placeholder="确认新密码"
|
||
value={confirmPassword}
|
||
autoComplete="new-password"
|
||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{error && <p className="login-error">{error}</p>}
|
||
{success && <p className="login-success">{success}</p>}
|
||
|
||
<button type="submit" className="login-btn" disabled={submitDisabled}>
|
||
{submitLabel}
|
||
</button>
|
||
</>
|
||
)}
|
||
|
||
{showAltLogin && (
|
||
<div className="login-alt-methods">
|
||
<div className="login-divider" aria-hidden="true">
|
||
<span>其他登录方式</span>
|
||
</div>
|
||
|
||
{showDesktopWechat && (
|
||
<button
|
||
type="button"
|
||
className="login-wechat-secondary"
|
||
onClick={switchToScanLogin}
|
||
disabled={submitting}
|
||
>
|
||
<WechatIcon className="login-wechat-icon" />
|
||
微信扫码登录
|
||
</button>
|
||
)}
|
||
|
||
{showMobileWechatGuide && (
|
||
<WechatOpenGuide
|
||
disabled={submitting}
|
||
onCopied={() => setSuccess('链接已复制,请在微信中打开')}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</form>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|