Add WeChat login binding gate and context-aware auth UX.
Stop auto-creating duplicate accounts on OAuth, add bind-or-register gate, PC scan login, mobile open-in-WeChat guide, and fix localhost session cookies. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+209
-79
@@ -1,9 +1,26 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getMe, getWechatAuthConfig, login, loginLegacy, register, resetPassword } from '../api/client';
|
||||
import {
|
||||
getMe,
|
||||
getWechatAuthConfig,
|
||||
login,
|
||||
loginLegacy,
|
||||
register,
|
||||
resetPassword,
|
||||
} from '../api/client';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import { buildWechatAuthorizeUrl, isWechatContext, readWechatAuthError } from '../utils/wechat';
|
||||
import {
|
||||
buildWechatAuthorizeUrl,
|
||||
isMobileExternalBrowser,
|
||||
isWechatContext,
|
||||
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';
|
||||
|
||||
@@ -34,9 +51,16 @@ export function AuthView({
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [wechatEnabled, setWechatEnabled] = useState(false);
|
||||
const [scanEnabled, setScanEnabled] = 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');
|
||||
@@ -52,22 +76,54 @@ export function AuthView({
|
||||
if (legacyMode) return;
|
||||
void getWechatAuthConfig().then((config) => {
|
||||
setWechatEnabled(config.enabled);
|
||||
setScanEnabled(Boolean(config.scanEnabled));
|
||||
setWechatContext((prev) =>
|
||||
isWechatContext({
|
||||
search: window.location.search,
|
||||
serverInWechat: config.inWechat,
|
||||
}) || prev,
|
||||
);
|
||||
setMobileExternal(
|
||||
isMobileExternalBrowser({
|
||||
search: window.location.search,
|
||||
serverInWechat: config.inWechat,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}, [legacyMode]);
|
||||
|
||||
const showWechatLogin = !legacyMode && mode === 'login' && wechatContext && wechatEnabled;
|
||||
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 = () => {
|
||||
@@ -77,6 +133,7 @@ export function AuthView({
|
||||
utmSource: params.get('utm_source') ?? params.get('from') ?? 'wechat',
|
||||
utmMedium: params.get('utm_medium') ?? undefined,
|
||||
utmCampaign: params.get('utm_campaign') ?? undefined,
|
||||
intent: 'login',
|
||||
});
|
||||
};
|
||||
|
||||
@@ -119,6 +176,7 @@ export function AuthView({
|
||||
);
|
||||
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 : '注册失败');
|
||||
@@ -149,6 +207,16 @@ export function AuthView({
|
||||
}
|
||||
};
|
||||
|
||||
if (pendingToken) {
|
||||
return (
|
||||
<WechatBindGate
|
||||
pendingToken={pendingToken}
|
||||
returnTo={returnTo}
|
||||
onAuth={onAuth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = legacyMode
|
||||
? { title: 'TKMind', desc: '内部访问入口' }
|
||||
: fromPlaza
|
||||
@@ -201,6 +269,9 @@ export function AuthView({
|
||||
!password.trim() ||
|
||||
!confirmPassword.trim());
|
||||
|
||||
const showAltLogin =
|
||||
isLogin && wechatEnabled && (showMobileWechatGuide || (showDesktopWechat && !showScanLogin));
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
@@ -236,7 +307,24 @@ export function AuthView({
|
||||
<p className="login-desc">{meta.desc}</p>
|
||||
</div>
|
||||
|
||||
{showWechatLogin && (
|
||||
{isDesktopScanMode && (
|
||||
<>
|
||||
<WechatDesktopLogin
|
||||
scanEnabled={scanEnabled}
|
||||
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"
|
||||
@@ -244,90 +332,132 @@ export function AuthView({
|
||||
onClick={handleWechatLogin}
|
||||
disabled={submitting}
|
||||
>
|
||||
<WechatIcon className="login-wechat-icon" />
|
||||
微信一键登录
|
||||
</button>
|
||||
<div className="login-divider" aria-hidden="true">
|
||||
<span>或</span>
|
||||
</div>
|
||||
{!showPasswordLogin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="login-link login-password-toggle"
|
||||
onClick={() => setShowPasswordLogin(true)}
|
||||
>
|
||||
已有账号?密码登录
|
||||
</button>
|
||||
) : (
|
||||
<div className="login-divider" aria-hidden="true">
|
||||
<span>或</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="login-fields">
|
||||
{!legacyMode && (
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
autoFocus={!showWechatLogin}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{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)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{mode === 'register' && !legacyMode && (
|
||||
<>
|
||||
<input
|
||||
className="login-input"
|
||||
type="email"
|
||||
placeholder="邮箱"
|
||||
value={email}
|
||||
autoComplete="email"
|
||||
required
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
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)}
|
||||
/>
|
||||
<input
|
||||
className="login-input"
|
||||
type="text"
|
||||
placeholder="显示名称(可选)"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(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('链接已复制,请在微信中打开')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,8 @@ import { openAvatarPicker } from '../utils/userAvatar';
|
||||
import { HistorySidebar } from './HistorySidebar';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
import { ChatPanel } from './ChatPanel';
|
||||
import { BalanceRing } from './BalanceRing';
|
||||
import { WechatBindPrompt } from './WechatBindPrompt';
|
||||
import { WechatAccountButton } from './WechatAccountButton';
|
||||
import type { MindSpaceSaveCategory } from '../types';
|
||||
|
||||
export function ChatView({
|
||||
@@ -144,6 +145,7 @@ export function ChatView({
|
||||
<button type="button" className="ghost-btn" onClick={() => void handleNewSession()}>
|
||||
新会话
|
||||
</button>
|
||||
{user && <WechatAccountButton returnTo={window.location.pathname} />}
|
||||
{onLogout && (
|
||||
<button type="button" className="ghost-btn logout-btn" onClick={onLogout}>
|
||||
登出
|
||||
@@ -156,6 +158,8 @@ export function ChatView({
|
||||
<div className="banner banner-offline">网络已断开,请检查连接后重试</div>
|
||||
)}
|
||||
|
||||
{user && <WechatBindPrompt returnTo={window.location.pathname} />}
|
||||
|
||||
{typeof balanceCents === 'number' && balanceCents <= 0 && (
|
||||
<div className="banner banner-error">
|
||||
<span>余额不足,请充值后继续使用</span>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getWechatBindingStatus } from '../api/client';
|
||||
import { buildWechatAuthorizeUrl } from '../utils/wechat';
|
||||
|
||||
export function WechatAccountButton({ returnTo }: { returnTo?: string }) {
|
||||
const [bound, setBound] = useState<boolean | null>(null);
|
||||
const [nickname, setNickname] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void getWechatBindingStatus().then((status) => {
|
||||
if (!status.enabled) {
|
||||
setBound(null);
|
||||
return;
|
||||
}
|
||||
setBound(status.bound);
|
||||
setNickname(status.nickname ?? null);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (bound === null) return null;
|
||||
|
||||
if (bound) {
|
||||
return (
|
||||
<span className="ghost-btn wechat-account-bound" title="已绑定微信">
|
||||
微信已绑{nickname ? ` · ${nickname}` : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="ghost-btn"
|
||||
onClick={() => {
|
||||
window.location.href = buildWechatAuthorizeUrl({
|
||||
intent: 'bind',
|
||||
returnTo: returnTo ?? window.location.pathname,
|
||||
});
|
||||
}}
|
||||
>
|
||||
绑定微信
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
completeWechatBind,
|
||||
completeWechatRegister,
|
||||
getMe,
|
||||
getWechatPendingProfile,
|
||||
} from '../api/client';
|
||||
import type { CapabilityMap, PortalUser } from '../types';
|
||||
import type { WechatPendingProfile } from '../api/client';
|
||||
import { TKMindAvatar } from './TKMindAvatar';
|
||||
|
||||
type Step = 'choose' | 'bind';
|
||||
|
||||
export function WechatBindGate({
|
||||
pendingToken,
|
||||
returnTo,
|
||||
onAuth,
|
||||
}: {
|
||||
pendingToken: string;
|
||||
returnTo?: string | null;
|
||||
onAuth: (
|
||||
user?: PortalUser | null,
|
||||
capabilities?: CapabilityMap,
|
||||
grantedSkills?: string[],
|
||||
) => void;
|
||||
}) {
|
||||
const [profile, setProfile] = useState<WechatPendingProfile | null>(null);
|
||||
const [step, setStep] = useState<Step>('choose');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
void getWechatPendingProfile(pendingToken)
|
||||
.then((data) => setProfile(data))
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : '绑定会话无效');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [pendingToken]);
|
||||
|
||||
const finishAuth = async (user: PortalUser) => {
|
||||
const me = await getMe().catch(() => null);
|
||||
onAuth(user, me?.capabilities, me?.grantedSkills);
|
||||
const target = returnTo || profile?.returnTo;
|
||||
if (target && target !== '/') {
|
||||
try {
|
||||
const url = new URL(target, window.location.origin);
|
||||
if (url.origin === window.location.origin) {
|
||||
window.location.href = url.toString();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid return target
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleNewUser = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await completeWechatRegister(pendingToken);
|
||||
await finishAuth(result.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '注册失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBindExisting = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await completeWechatBind(pendingToken, username, password);
|
||||
await finishAuth(result.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '绑定失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayName = profile?.nickname?.trim() || '微信用户';
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card wechat-bind-card">
|
||||
<div className="login-brand">
|
||||
{profile?.avatarUrl ? (
|
||||
<img src={profile.avatarUrl} alt="" className="wechat-bind-avatar" />
|
||||
) : (
|
||||
<TKMindAvatar size="md" className="login-brand-avatar" />
|
||||
)}
|
||||
<h1 className="login-title">你好,{displayName}</h1>
|
||||
<p className="login-desc">
|
||||
{step === 'choose'
|
||||
? '请选择如何使用这个微信账号'
|
||||
: '验证已有账号后,微信将与该账号绑定'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading && <p className="login-desc">正在加载微信信息…</p>}
|
||||
|
||||
{!loading && step === 'choose' && (
|
||||
<div className="wechat-bind-options">
|
||||
<button
|
||||
type="button"
|
||||
className="wechat-bind-option wechat-bind-option-primary"
|
||||
disabled={submitting}
|
||||
onClick={() => void handleNewUser()}
|
||||
>
|
||||
<strong>我是新用户</strong>
|
||||
<span>快速开始,稍后可设置密码</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="wechat-bind-option"
|
||||
disabled={submitting}
|
||||
onClick={() => {
|
||||
setStep('bind');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<strong>我已有账号</strong>
|
||||
<span>绑定微信,保留原有内容</span>
|
||||
</button>
|
||||
<p className="wechat-bind-hint">
|
||||
绑定后,微信登录将进入此账号,你的 MindSpace 内容不会丢失。
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && step === 'bind' && (
|
||||
<form className="wechat-bind-form" onSubmit={handleBindExisting}>
|
||||
<div className="login-fields">
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="text"
|
||||
placeholder="用户名"
|
||||
value={username}
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className={`login-input${error ? ' login-input-error' : ''}`}
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
value={password}
|
||||
autoComplete="current-password"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="login-error">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
className="login-btn"
|
||||
disabled={submitting || !username.trim() || !password.trim()}
|
||||
>
|
||||
{submitting ? '绑定中…' : '验证并绑定'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="login-link wechat-bind-back"
|
||||
onClick={() => {
|
||||
setStep('choose');
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
返回选择
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{error && step === 'choose' && <p className="login-error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { getWechatBindingStatus } from '../api/client';
|
||||
import { buildWechatAuthorizeUrl } from '../utils/wechat';
|
||||
|
||||
export function WechatBindPrompt({
|
||||
returnTo,
|
||||
onDismiss,
|
||||
}: {
|
||||
returnTo?: string;
|
||||
onDismiss?: () => void;
|
||||
}) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [bound, setBound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void getWechatBindingStatus().then((status) => {
|
||||
const suggested = sessionStorage.getItem('tkmind_suggest_wechat_bind') === '1';
|
||||
if (suggested) sessionStorage.removeItem('tkmind_suggest_wechat_bind');
|
||||
if (status.enabled && !status.bound && suggested) {
|
||||
setVisible(true);
|
||||
}
|
||||
setBound(status.bound);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!visible || bound) return null;
|
||||
|
||||
const handleBind = () => {
|
||||
window.location.href = buildWechatAuthorizeUrl({
|
||||
intent: 'bind',
|
||||
returnTo: returnTo ?? window.location.pathname,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDismiss = () => {
|
||||
setVisible(false);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="banner banner-info wechat-bind-banner">
|
||||
<span>绑定微信,下次在微信里可一键登录</span>
|
||||
<button type="button" className="banner-action" onClick={handleBind}>
|
||||
立即绑定
|
||||
</button>
|
||||
<button type="button" className="banner-dismiss" onClick={handleDismiss}>
|
||||
稍后再说
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { buildPageUrlForWechatOpen } from '../utils/wechat';
|
||||
import { WechatScanLogin } from './WechatScanLogin';
|
||||
|
||||
/** PC 浏览器微信登录:优先开放平台扫码,否则展示「页面链接」二维码供手机微信打开 */
|
||||
export function WechatDesktopLogin({
|
||||
scanEnabled,
|
||||
returnTo,
|
||||
onBindingGate,
|
||||
onComplete,
|
||||
onError,
|
||||
}: {
|
||||
scanEnabled: boolean;
|
||||
returnTo?: string | null;
|
||||
onBindingGate: (pendingToken: string) => void;
|
||||
onComplete: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [pageQrDataUrl, setPageQrDataUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (scanEnabled) return;
|
||||
const url = buildPageUrlForWechatOpen();
|
||||
void QRCode.toDataURL(url, { margin: 1, width: 180 }).then(setPageQrDataUrl).catch(() => {
|
||||
onError('无法生成微信登录二维码');
|
||||
});
|
||||
}, [scanEnabled, onError]);
|
||||
|
||||
return (
|
||||
<div className="login-wechat-scan-card">
|
||||
<div className="login-wechat-scan-head">
|
||||
<span>微信扫码登录</span>
|
||||
</div>
|
||||
{scanEnabled ? (
|
||||
<WechatScanLogin
|
||||
returnTo={returnTo}
|
||||
onBindingGate={onBindingGate}
|
||||
onComplete={onComplete}
|
||||
onError={onError}
|
||||
/>
|
||||
) : (
|
||||
<div className="login-wechat-scan-body">
|
||||
{pageQrDataUrl ? (
|
||||
<img src={pageQrDataUrl} alt="微信扫码登录" className="login-wechat-scan-qr" />
|
||||
) : (
|
||||
<div className="login-wechat-scan-loading">正在生成二维码…</div>
|
||||
)}
|
||||
<p className="login-wechat-scan-tip">请使用微信扫一扫</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export function WechatIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M8.5 4C5.46 4 3 6.04 3 8.64c0 1.37.74 2.62 1.94 3.53L4 17l4.2-1.8c.58.1 1.18.16 1.8.16 3.04 0 5.5-2.04 5.5-4.64S11.54 4 8.5 4Zm10 2c-2.55 0-4.62 1.7-4.62 3.8 0 2.1 2.07 3.8 4.62 3.8.5 0 .98-.07 1.43-.2L21 16l-1.55-3.1c.8-.72 1.3-1.68 1.3-2.74C20.75 7.7 18.68 6 16.5 6ZM8.5 5.5c2.2 0 4 1.47 4 3.14 0 1.67-1.8 3.14-4 3.14-.55 0-1.08-.05-1.58-.15l-.32-.07-.95.4.35-1.1-.23-.2C5.2 10.3 4.5 9.5 4.5 8.64 4.5 6.97 6.3 5.5 8.5 5.5Zm8 1.5c1.84 0 3.25 1.22 3.25 2.8 0 .98-.52 1.86-1.35 2.42l-.25.18.4 1.2-1.05-.55-.3.08c-.43.11-.88.17-1.35.17-1.84 0-3.25-1.22-3.25-2.8s1.41-2.8 3.25-2.8Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useState } from 'react';
|
||||
import { buildPageUrlForWechatOpen, copyWechatOpenLink, isIosBrowser } from '../utils/wechat';
|
||||
|
||||
export function WechatOpenGuide({
|
||||
disabled,
|
||||
onCopied,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
onCopied?: () => void;
|
||||
}) {
|
||||
const [guideOpen, setGuideOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const ios = isIosBrowser();
|
||||
|
||||
const handleOpen = async () => {
|
||||
const ok = await copyWechatOpenLink();
|
||||
setCopied(ok);
|
||||
if (ok) onCopied?.();
|
||||
setGuideOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="login-wechat-secondary"
|
||||
onClick={() => void handleOpen()}
|
||||
disabled={disabled}
|
||||
>
|
||||
在微信中打开
|
||||
</button>
|
||||
|
||||
{guideOpen && (
|
||||
<div
|
||||
className="wechat-open-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="在微信中打开"
|
||||
onClick={() => setGuideOpen(false)}
|
||||
>
|
||||
<div className="wechat-open-guide" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="wechat-open-close"
|
||||
aria-label="关闭"
|
||||
onClick={() => setGuideOpen(false)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<h2>请在微信中打开</h2>
|
||||
{copied ? (
|
||||
<p className="wechat-open-success">链接已复制</p>
|
||||
) : (
|
||||
<p className="wechat-open-warning">未能自动复制,请手动复制下方链接</p>
|
||||
)}
|
||||
{ios ? (
|
||||
<ol className="wechat-open-steps">
|
||||
<li>点击浏览器右上角 <strong>···</strong></li>
|
||||
<li>选择 <strong>在微信中打开</strong></li>
|
||||
<li>打开后即可使用微信一键登录</li>
|
||||
</ol>
|
||||
) : (
|
||||
<ol className="wechat-open-steps">
|
||||
<li>打开微信,将链接发给「文件传输助手」或任意聊天</li>
|
||||
<li>在微信里点击该链接</li>
|
||||
<li>打开后即可使用微信一键登录</li>
|
||||
</ol>
|
||||
)}
|
||||
<input
|
||||
className="wechat-open-url"
|
||||
readOnly
|
||||
value={buildPageUrlForWechatOpen()}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="login-btn"
|
||||
onClick={() => void copyWechatOpenLink().then((ok) => setCopied(ok))}
|
||||
>
|
||||
再次复制链接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import QRCode from 'qrcode';
|
||||
import { pollWechatScanLogin, startWechatScanLogin } from '../api/client';
|
||||
|
||||
export function WechatScanLogin({
|
||||
returnTo,
|
||||
onBindingGate,
|
||||
onComplete,
|
||||
onError,
|
||||
}: {
|
||||
returnTo?: string | null;
|
||||
onBindingGate: (pendingToken: string) => void;
|
||||
onComplete: () => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expired, setExpired] = useState(false);
|
||||
const stateRef = useRef<string | null>(null);
|
||||
const pollRef = useRef<number | null>(null);
|
||||
|
||||
const stopPolling = () => {
|
||||
if (pollRef.current !== null) {
|
||||
window.clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const start = async () => {
|
||||
stopPolling();
|
||||
setLoading(true);
|
||||
setExpired(false);
|
||||
setQrDataUrl(null);
|
||||
try {
|
||||
const session = await startWechatScanLogin(returnTo ?? undefined);
|
||||
stateRef.current = session.state;
|
||||
const dataUrl = await QRCode.toDataURL(session.qrUrl, {
|
||||
margin: 1,
|
||||
width: 180,
|
||||
});
|
||||
setQrDataUrl(dataUrl);
|
||||
const expiresAt = Date.now() + session.expiresInMs;
|
||||
pollRef.current = window.setInterval(() => {
|
||||
const state = stateRef.current;
|
||||
if (!state) return;
|
||||
if (Date.now() > expiresAt) {
|
||||
stopPolling();
|
||||
setExpired(true);
|
||||
return;
|
||||
}
|
||||
void pollWechatScanLogin(state)
|
||||
.then((result) => {
|
||||
if (result.status === 'pending') return;
|
||||
stopPolling();
|
||||
if (result.status === 'complete') {
|
||||
onComplete();
|
||||
return;
|
||||
}
|
||||
if (result.status === 'binding_gate' && result.pendingToken) {
|
||||
onBindingGate(result.pendingToken);
|
||||
return;
|
||||
}
|
||||
if (result.status === 'expired') {
|
||||
setExpired(true);
|
||||
return;
|
||||
}
|
||||
onError(result.message ?? '微信扫码登录失败');
|
||||
})
|
||||
.catch((err) => {
|
||||
stopPolling();
|
||||
onError(err instanceof Error ? err.message : '微信扫码登录失败');
|
||||
});
|
||||
}, 2000);
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : '无法启动扫码登录');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void start();
|
||||
return () => stopPolling();
|
||||
}, [returnTo]);
|
||||
|
||||
return (
|
||||
<div className="login-wechat-scan-body">
|
||||
{loading && <div className="login-wechat-scan-loading">正在生成二维码…</div>}
|
||||
{!loading && qrDataUrl && !expired && (
|
||||
<img src={qrDataUrl} alt="微信扫码登录" className="login-wechat-scan-qr" />
|
||||
)}
|
||||
{expired && <div className="login-wechat-scan-loading">二维码已过期</div>}
|
||||
<p className="login-wechat-scan-tip">
|
||||
{expired ? (
|
||||
<button type="button" className="login-link" onClick={() => void start()}>
|
||||
刷新二维码
|
||||
</button>
|
||||
) : (
|
||||
'请使用微信扫一扫,在手机上确认登录'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user