@@ -0,0 +1,63 @@
|
|||||||
|
import { Button, Text, View } from '@tarojs/components';
|
||||||
|
|
||||||
|
type PhoneQuickLoginButtonProps = {
|
||||||
|
loading?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 须已主动勾选协议后才挂载 getPhoneNumber,避免未同意即拉起授权 */
|
||||||
|
agreed: boolean;
|
||||||
|
onRequireAgree: () => void;
|
||||||
|
onGetPhoneNumber: (phoneCode: string) => void;
|
||||||
|
onFail?: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 小程序手机号快捷登录(open-type=getPhoneNumber)。
|
||||||
|
* 文案不得使用「微信」字样或仿官方图标,以符合审核要求。
|
||||||
|
*/
|
||||||
|
export default function PhoneQuickLoginButton({
|
||||||
|
loading = false,
|
||||||
|
disabled = false,
|
||||||
|
agreed,
|
||||||
|
onRequireAgree,
|
||||||
|
onGetPhoneNumber,
|
||||||
|
onFail,
|
||||||
|
}: PhoneQuickLoginButtonProps) {
|
||||||
|
const inactive = loading || disabled;
|
||||||
|
const className = `login-phone-quick-btn${inactive ? ' login-phone-quick-btn--disabled' : ''}`;
|
||||||
|
const label = loading ? '登录中...' : '手机号快捷登录';
|
||||||
|
|
||||||
|
if (!agreed) {
|
||||||
|
return (
|
||||||
|
<View className={className} onClick={inactive ? undefined : onRequireAgree}>
|
||||||
|
<Text className="login-phone-quick-btn__text">{label}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={className}
|
||||||
|
openType={inactive ? undefined : 'getPhoneNumber'}
|
||||||
|
hoverClass="none"
|
||||||
|
onGetPhoneNumber={(e) => {
|
||||||
|
if (inactive) return;
|
||||||
|
const detail = e.detail as {
|
||||||
|
errMsg?: string;
|
||||||
|
code?: string;
|
||||||
|
errno?: number;
|
||||||
|
};
|
||||||
|
if (!detail?.code) {
|
||||||
|
const denied =
|
||||||
|
detail?.errMsg?.includes('deny') ||
|
||||||
|
detail?.errMsg?.includes('cancel') ||
|
||||||
|
detail?.errno === 103;
|
||||||
|
onFail?.(denied ? '已取消手机号授权' : detail?.errMsg || '获取手机号失败');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onGetPhoneNumber(detail.code);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className="login-phone-quick-btn__text">{label}</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,22 +3,16 @@ import { View, Text } from '@tarojs/components';
|
|||||||
type WechatLoginButtonProps = {
|
type WechatLoginButtonProps = {
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** 默认「授权登录」,避免使用「微信」字样与官方风格图标 */
|
||||||
|
label?: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function WechatIcon() {
|
/** 授权登录按钮(无微信品牌元素,满足小程序审核) */
|
||||||
return (
|
|
||||||
<View className="wechat-login-icon" aria-hidden>
|
|
||||||
<View className="wechat-login-icon__big" />
|
|
||||||
<View className="wechat-login-icon__small" />
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 微信授权一键登录按钮(对齐 h5-user login-wechat-btn) */
|
|
||||||
export default function WechatLoginButton({
|
export default function WechatLoginButton({
|
||||||
loading = false,
|
loading = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
label = '授权登录',
|
||||||
onClick,
|
onClick,
|
||||||
}: WechatLoginButtonProps) {
|
}: WechatLoginButtonProps) {
|
||||||
const inactive = loading || disabled;
|
const inactive = loading || disabled;
|
||||||
@@ -28,10 +22,7 @@ export default function WechatLoginButton({
|
|||||||
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
|
className={`login-wechat-btn${inactive ? ' login-wechat-btn--disabled' : ''}`}
|
||||||
onClick={inactive ? undefined : onClick}
|
onClick={inactive ? undefined : onClick}
|
||||||
>
|
>
|
||||||
<WechatIcon />
|
<Text className="login-wechat-btn__text">{loading ? '授权中...' : label}</Text>
|
||||||
<Text className="login-wechat-btn__text">
|
|
||||||
{loading ? '授权中...' : '微信一键授权'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
} from '@dukang/shared-types';
|
} from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import WechatLoginButton from '../../components/WechatLoginButton';
|
import WechatLoginButton from '../../components/WechatLoginButton';
|
||||||
|
import PhoneQuickLoginButton from '../../components/PhoneQuickLoginButton';
|
||||||
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
|
import { BRAND_LOGO_WIDE_URL } from '@dukang/shared-types';
|
||||||
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
import { finishLoginNavigate, forceReloadAfterAccountMerge } from '../../lib/auth-nav';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +25,8 @@ import {
|
|||||||
} from '../../lib/mini-wechat-profile';
|
} from '../../lib/mini-wechat-profile';
|
||||||
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
import { isLoggedIn, request, saveAuth, toast, type SessionPayload } from '../../lib/api';
|
||||||
|
|
||||||
|
const IS_WEAPP = process.env.TARO_ENV === 'weapp';
|
||||||
|
|
||||||
function normalizePhone(value: string) {
|
function normalizePhone(value: string) {
|
||||||
return value.replace(/\D/g, '').slice(0, 11);
|
return value.replace(/\D/g, '').slice(0, 11);
|
||||||
}
|
}
|
||||||
@@ -32,6 +35,44 @@ function isValidPhone(phone: string) {
|
|||||||
return /^1[3-9]\d{9}$/.test(phone);
|
return /^1[3-9]\d{9}$/.test(phone);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AgreementRow({
|
||||||
|
agreed,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
agreed: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="login-agreement" onClick={onToggle}>
|
||||||
|
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
||||||
|
{agreed ? <Text>✓</Text> : null}
|
||||||
|
</View>
|
||||||
|
<Text className="login-agreement-text">
|
||||||
|
请阅读并勾选同意
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《用户服务协议》
|
||||||
|
</Text>
|
||||||
|
和
|
||||||
|
<Text
|
||||||
|
className="login-agreement-link"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
《隐私政策》
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const returnTo = router.params.return || '';
|
const returnTo = router.params.return || '';
|
||||||
@@ -44,8 +85,10 @@ export default function LoginPage() {
|
|||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [wxLoading, setWxLoading] = useState(false);
|
const [wxLoading, setWxLoading] = useState(false);
|
||||||
|
const [phoneQuickLoading, setPhoneQuickLoading] = useState(false);
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [cooldown, setCooldown] = useState(0);
|
const [cooldown, setCooldown] = useState(0);
|
||||||
|
/** 须用户主动勾选,禁止默认同意 */
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [sentHint, setSentHint] = useState('');
|
const [sentHint, setSentHint] = useState('');
|
||||||
@@ -53,6 +96,7 @@ export default function LoginPage() {
|
|||||||
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
const [wxSessionKey, setWxSessionKey] = useState<string | null>(initialWxSessionKey);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||||
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
const [completeMode, setCompleteMode] = useState<'phone' | 'wechat' | null>(null);
|
||||||
|
const [showSmsForm, setShowSmsForm] = useState(!IS_WEAPP);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request<ClientRuntimeConfig>('/common/client-config')
|
request<ClientRuntimeConfig>('/common/client-config')
|
||||||
@@ -65,7 +109,6 @@ export default function LoginPage() {
|
|||||||
setCompleteMode(null);
|
setCompleteMode(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 完善资料场景才拉 profile;普通登录勿抢跑 /auth/me,避免旧 token 401 与短信登录竞态
|
|
||||||
if (!needPhone && !needWechat) {
|
if (!needPhone && !needWechat) {
|
||||||
setCompleteMode(null);
|
setCompleteMode(null);
|
||||||
return;
|
return;
|
||||||
@@ -76,6 +119,7 @@ export default function LoginPage() {
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (needPhone && !me.phoneVerified) {
|
if (needPhone && !me.phoneVerified) {
|
||||||
setCompleteMode('phone');
|
setCompleteMode('phone');
|
||||||
|
setShowSmsForm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (needWechat && !me.hasWechat) {
|
if (needWechat && !me.hasWechat) {
|
||||||
@@ -100,7 +144,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先勾选并同意用户协议');
|
setMsg('请先阅读并勾选同意《用户服务协议》和《隐私政策》');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -108,18 +152,18 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
function applySessionAndLeave(
|
function applySessionAndLeave(
|
||||||
data: SessionPayload | WechatLoginResult,
|
data: SessionPayload | WechatLoginResult,
|
||||||
phone?: string,
|
phoneValue?: string,
|
||||||
wxInfo?: MiniWechatProfile | null,
|
wxInfo?: MiniWechatProfile | null,
|
||||||
successToast = '登录成功',
|
successToast = '登录成功',
|
||||||
) {
|
) {
|
||||||
if (!data.accessToken) return;
|
if (!data.accessToken) return;
|
||||||
if (phone) saveUserPhone(phone);
|
if (phoneValue) saveUserPhone(phoneValue);
|
||||||
saveAuth({
|
saveAuth({
|
||||||
accessToken: data.accessToken,
|
accessToken: data.accessToken,
|
||||||
refreshToken: data.refreshToken,
|
refreshToken: data.refreshToken,
|
||||||
});
|
});
|
||||||
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
|
void syncMiniWechatProfile(wxInfo ?? getCachedWxProfile());
|
||||||
if (!phone) {
|
if (!phoneValue) {
|
||||||
void fetchUserProfile()
|
void fetchUserProfile()
|
||||||
.then((me) => resolveDefaultUserPhone(me))
|
.then((me) => resolveDefaultUserPhone(me))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -133,7 +177,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
|
function handleWechatLoginResult(result: WechatLoginResult, wxInfo?: MiniWechatProfile | null) {
|
||||||
// 微信授权成功即登录;手机号改为下单页可选绑定
|
|
||||||
if (result.accessToken) {
|
if (result.accessToken) {
|
||||||
applySessionAndLeave(result, undefined, wxInfo);
|
applySessionAndLeave(result, undefined, wxInfo);
|
||||||
return;
|
return;
|
||||||
@@ -141,11 +184,49 @@ export default function LoginPage() {
|
|||||||
if (result.needBindPhone && result.wxSessionKey) {
|
if (result.needBindPhone && result.wxSessionKey) {
|
||||||
setBindMode(true);
|
setBindMode(true);
|
||||||
setWxSessionKey(result.wxSessionKey);
|
setWxSessionKey(result.wxSessionKey);
|
||||||
setMsg('微信授权成功,可绑定手机号(也可跳过,稍后在下单时再绑定)');
|
setShowSmsForm(true);
|
||||||
|
setMsg('授权成功,可绑定手机号(也可稍后在下单时再绑定)');
|
||||||
setSentHint('');
|
setSentHint('');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMsg('微信登录未完成,请重试或使用手机号登录');
|
setMsg('登录未完成,请重试或使用手机号登录');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPhoneQuickLogin(phoneCode: string) {
|
||||||
|
if (!ensureAgreed()) return;
|
||||||
|
setPhoneQuickLoading(true);
|
||||||
|
setMsg('');
|
||||||
|
setSentHint('');
|
||||||
|
try {
|
||||||
|
let loginCode: string | undefined;
|
||||||
|
try {
|
||||||
|
const loginRes = await Taro.login();
|
||||||
|
loginCode = loginRes.code || undefined;
|
||||||
|
} catch {
|
||||||
|
/* openId 绑定失败不阻断手机号登录 */
|
||||||
|
}
|
||||||
|
const data = await request<WechatLoginResult>('/auth/login/wechat-phone', {
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
phoneCode,
|
||||||
|
...(loginCode ? { loginCode } : {}),
|
||||||
|
platform: 'mini',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!data?.accessToken) {
|
||||||
|
setMsg('登录成功但未返回令牌,请重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profilePhone =
|
||||||
|
typeof data.user === 'object' && data.user && 'phone' in data.user
|
||||||
|
? String((data.user as { phone?: string }).phone || '')
|
||||||
|
: '';
|
||||||
|
applySessionAndLeave(data, profilePhone || undefined);
|
||||||
|
} catch (e) {
|
||||||
|
setMsg(e instanceof Error ? e.message : '手机号快捷登录失败');
|
||||||
|
} finally {
|
||||||
|
setPhoneQuickLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onSendCode() {
|
async function onSendCode() {
|
||||||
@@ -200,7 +281,6 @@ export default function LoginPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (completeMode === 'phone' && isLoggedIn()) {
|
if (completeMode === 'phone' && isLoggedIn()) {
|
||||||
// bind 返回新 session(合并账号后旧 guest JWT 立刻失效),必须落盘后再离开
|
|
||||||
const data = await request<SessionPayload>('/auth/phone/bind', {
|
const data = await request<SessionPayload>('/auth/phone/bind', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { phone: normalized, code: code.trim() },
|
data: { phone: normalized, code: code.trim() },
|
||||||
@@ -234,7 +314,6 @@ export default function LoginPage() {
|
|||||||
setSentHint('');
|
setSentHint('');
|
||||||
setWxLoading(true);
|
setWxLoading(true);
|
||||||
try {
|
try {
|
||||||
// 登录仅绑定 openId;资料展示走账号昵称/头像
|
|
||||||
const wxInfo = getCachedWxProfile();
|
const wxInfo = getCachedWxProfile();
|
||||||
|
|
||||||
if (completeMode === 'wechat' && isLoggedIn()) {
|
if (completeMode === 'wechat' && isLoggedIn()) {
|
||||||
@@ -246,12 +325,13 @@ export default function LoginPage() {
|
|||||||
setBindMode(true);
|
setBindMode(true);
|
||||||
setWxSessionKey(result.wxSessionKey);
|
setWxSessionKey(result.wxSessionKey);
|
||||||
setCompleteMode('phone');
|
setCompleteMode('phone');
|
||||||
|
setShowSmsForm(true);
|
||||||
setMsg('请绑定手机号完成认证');
|
setMsg('请绑定手机号完成认证');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
if (wxInfo) await syncMiniWechatProfile(wxInfo);
|
if (wxInfo) await syncMiniWechatProfile(wxInfo);
|
||||||
toast('微信授权成功', 'success');
|
toast('授权成功', 'success');
|
||||||
finishLoginNavigate(returnTo);
|
finishLoginNavigate(returnTo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -260,11 +340,11 @@ export default function LoginPage() {
|
|||||||
const result = await loginWithWechat();
|
const result = await loginWithWechat();
|
||||||
if (result) handleWechatLoginResult(result, wxInfo);
|
if (result) handleWechatLoginResult(result, wxInfo);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const raw = e instanceof Error ? e.message : '微信登录失败';
|
const raw = e instanceof Error ? e.message : '授权登录失败';
|
||||||
const hint = /invalid code/i.test(raw)
|
const hint = /invalid code/i.test(raw)
|
||||||
? process.env.TARO_ENV === 'weapp'
|
? process.env.TARO_ENV === 'weapp'
|
||||||
? '微信 code 无效:请确认后端 WX_MINI_APP_ID 与小程序 appid 一致,或本地联调使用 MOCK_WECHAT'
|
? '授权失败:请确认后端小程序 AppID 配置正确'
|
||||||
: '微信授权失败:请确认公众号 WX_APP_ID / 网页授权域名配置正确'
|
: '授权失败:请确认公众号网页授权域名配置正确'
|
||||||
: raw;
|
: raw;
|
||||||
setMsg(hint);
|
setMsg(hint);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -274,18 +354,19 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const displayMsg = msg || sentHint;
|
const displayMsg = msg || sentHint;
|
||||||
const codeDisabled = cooldown > 0 || sending;
|
const codeDisabled = cooldown > 0 || sending;
|
||||||
const showWechatLogin =
|
const showAuthLogin =
|
||||||
(completeMode === 'wechat' || (!bindMode && !completeMode)) &&
|
(completeMode === 'wechat' || (!IS_WEAPP && !bindMode && !completeMode)) &&
|
||||||
(process.env.TARO_ENV === 'weapp' || wxAuthorize);
|
(IS_WEAPP || wxAuthorize);
|
||||||
const showSmsForm = completeMode !== 'wechat';
|
const showPhoneQuick =
|
||||||
|
IS_WEAPP && completeMode !== 'wechat' && !bindMode && completeMode !== 'phone';
|
||||||
const cardTitle =
|
const cardTitle =
|
||||||
completeMode === 'phone'
|
completeMode === 'phone'
|
||||||
? '验证手机号'
|
? '验证手机号'
|
||||||
: bindMode
|
: bindMode
|
||||||
? '绑定手机号'
|
? '绑定手机号'
|
||||||
: completeMode === 'wechat'
|
: completeMode === 'wechat'
|
||||||
? '微信授权'
|
? '授权登录'
|
||||||
: '手机验证码登录';
|
: '手机号快捷登录';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell variant="plain" className="login-page">
|
<PageShell variant="plain" className="login-page">
|
||||||
@@ -301,7 +382,7 @@ export default function LoginPage() {
|
|||||||
{completeMode === 'phone'
|
{completeMode === 'phone'
|
||||||
? '建议绑定手机号'
|
? '建议绑定手机号'
|
||||||
: completeMode === 'wechat'
|
: completeMode === 'wechat'
|
||||||
? '完成微信授权'
|
? '完成授权登录'
|
||||||
: '欢迎来到杜康好客'}
|
: '欢迎来到杜康好客'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="login-welcome-sub">
|
<Text className="login-welcome-sub">
|
||||||
@@ -317,46 +398,52 @@ export default function LoginPage() {
|
|||||||
<View className="login-main">
|
<View className="login-main">
|
||||||
{completeMode === 'wechat' ? (
|
{completeMode === 'wechat' ? (
|
||||||
<View className="login-card">
|
<View className="login-card">
|
||||||
<Text className="login-card-title">微信一键授权</Text>
|
<Text className="login-card-title">授权登录</Text>
|
||||||
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
<Text className="login-msg login-msg--hint" style={{ marginBottom: 16 }}>
|
||||||
使用微信支付前需授权微信账号
|
使用支付功能前需完成授权登录
|
||||||
</Text>
|
</Text>
|
||||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
{displayMsg ? (
|
||||||
{agreed ? <Text>✓</Text> : null}
|
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||||
</View>
|
{displayMsg}
|
||||||
<Text className="login-agreement-text">
|
|
||||||
我已阅读并同意
|
|
||||||
<Text
|
|
||||||
className="login-agreement-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《用户协议》
|
|
||||||
</Text>
|
</Text>
|
||||||
和
|
) : null}
|
||||||
<Text
|
{showAuthLogin ? (
|
||||||
className="login-agreement-link"
|
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《隐私政策》
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{showWechatLogin ? (
|
|
||||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<View className="login-card">
|
<View className="login-card">
|
||||||
<Text className="login-card-title">{cardTitle}</Text>
|
<Text className="login-card-title">{cardTitle}</Text>
|
||||||
|
|
||||||
<View className="login-field">
|
<AgreementRow agreed={agreed} onToggle={() => setAgreed((v) => !v)} />
|
||||||
|
|
||||||
|
{showPhoneQuick ? (
|
||||||
|
<PhoneQuickLoginButton
|
||||||
|
loading={phoneQuickLoading}
|
||||||
|
agreed={agreed}
|
||||||
|
onRequireAgree={() => ensureAgreed()}
|
||||||
|
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||||||
|
onFail={(message) => setMsg(message)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showPhoneQuick ? (
|
||||||
|
<View className="login-divider" style={{ marginTop: 20 }}>
|
||||||
|
<View className="login-divider-line" />
|
||||||
|
<Text
|
||||||
|
className="login-divider-text"
|
||||||
|
onClick={() => setShowSmsForm((v) => !v)}
|
||||||
|
>
|
||||||
|
{showSmsForm ? '收起验证码登录' : '使用验证码登录'}
|
||||||
|
</Text>
|
||||||
|
<View className="login-divider-line" />
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{(showSmsForm || !showPhoneQuick) && (
|
||||||
|
<>
|
||||||
|
<View className="login-field" style={showPhoneQuick ? { marginTop: 8 } : undefined}>
|
||||||
<Text className="login-field-prefix">+86</Text>
|
<Text className="login-field-prefix">+86</Text>
|
||||||
<Input
|
<Input
|
||||||
className="login-field-input"
|
className="login-field-input"
|
||||||
@@ -389,40 +476,6 @@ export default function LoginPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{displayMsg ? (
|
|
||||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
|
||||||
{displayMsg}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<View className="login-agreement" onClick={() => setAgreed((v) => !v)}>
|
|
||||||
<View className={`login-agreement-check${agreed ? ' login-agreement-check--on' : ''}`}>
|
|
||||||
{agreed ? <Text>✓</Text> : null}
|
|
||||||
</View>
|
|
||||||
<Text className="login-agreement-text">
|
|
||||||
我已阅读并同意
|
|
||||||
<Text
|
|
||||||
className="login-agreement-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/user-agreement/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《用户协议》
|
|
||||||
</Text>
|
|
||||||
和
|
|
||||||
<Text
|
|
||||||
className="login-agreement-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《隐私政策》
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View
|
<View
|
||||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||||
onClick={loading ? undefined : () => void login()}
|
onClick={loading ? undefined : () => void login()}
|
||||||
@@ -434,9 +487,18 @@ export default function LoginPage() {
|
|||||||
? '完成验证'
|
? '完成验证'
|
||||||
: bindMode
|
: bindMode
|
||||||
? '绑定并登录'
|
? '绑定并登录'
|
||||||
: '登录'}
|
: '验证码登录'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayMsg ? (
|
||||||
|
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||||
|
{displayMsg}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{completeMode === 'phone' ? (
|
{completeMode === 'phone' ? (
|
||||||
<View
|
<View
|
||||||
className="login-skip-bind"
|
className="login-skip-bind"
|
||||||
@@ -451,14 +513,14 @@ export default function LoginPage() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showWechatLogin && completeMode !== 'wechat' ? (
|
{showAuthLogin && completeMode !== 'wechat' ? (
|
||||||
<>
|
<>
|
||||||
<View className="login-divider">
|
<View className="login-divider">
|
||||||
<View className="login-divider-line" />
|
<View className="login-divider-line" />
|
||||||
<Text className="login-divider-text">或者</Text>
|
<Text className="login-divider-text">或者</Text>
|
||||||
<View className="login-divider-line" />
|
<View className="login-divider-line" />
|
||||||
</View>
|
</View>
|
||||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ export default function MinePage() {
|
|||||||
throw new Error('wx profile helpers missing');
|
throw new Error('wx profile helpers missing');
|
||||||
}
|
}
|
||||||
const nickname = display.nickname || '用户';
|
const nickname = display.nickname || '用户';
|
||||||
const memberLabel = hasWechat ? '好客会员' : canWxAuth ? '微信未授权' : '未授权微信';
|
const memberLabel = hasWechat ? '好客会员' : canWxAuth ? '未完成授权' : '未完成授权';
|
||||||
void needsWxProfileFill(display);
|
void needsWxProfileFill(display);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View, Text, ScrollView } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import { getLegalDocument } from '@dukang/shared-types';
|
import { getLegalDocument } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
@@ -8,21 +8,25 @@ export default function PrivacyPolicyPage() {
|
|||||||
return (
|
return (
|
||||||
<PageShell variant="sub" className="legal-page">
|
<PageShell variant="sub" className="legal-page">
|
||||||
<SubPageHeader title={doc.title} />
|
<SubPageHeader title={doc.title} />
|
||||||
<View className="sub-page-body inset-page">
|
<View className="sub-page-body inset-page legal-body">
|
||||||
<ScrollView scrollY className="legal-scroll">
|
<Text className="legal-updated" selectable>
|
||||||
<Text className="legal-updated">更新日期:{doc.updatedAt}</Text>
|
更新日期:{doc.updatedAt}
|
||||||
<Text className="legal-intro">{doc.intro}</Text>
|
</Text>
|
||||||
|
<Text className="legal-intro" selectable>
|
||||||
|
{doc.intro}
|
||||||
|
</Text>
|
||||||
{doc.sections.map((section) => (
|
{doc.sections.map((section) => (
|
||||||
<View key={section.heading} className="legal-section">
|
<View key={section.heading} className="legal-section">
|
||||||
<Text className="legal-heading">{section.heading}</Text>
|
<Text className="legal-heading" selectable>
|
||||||
|
{section.heading}
|
||||||
|
</Text>
|
||||||
{section.paragraphs.map((p, i) => (
|
{section.paragraphs.map((p, i) => (
|
||||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
|
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
|
||||||
{p}
|
{p}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationBarTitleText: '用户协议',
|
navigationBarTitleText: '用户服务协议',
|
||||||
navigationStyle: 'custom',
|
navigationStyle: 'custom',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { View, Text, ScrollView } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import { getLegalDocument } from '@dukang/shared-types';
|
import { getLegalDocument } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
import SubPageHeader from '../../components/SubPageHeader';
|
import SubPageHeader from '../../components/SubPageHeader';
|
||||||
@@ -8,21 +8,25 @@ export default function UserAgreementPage() {
|
|||||||
return (
|
return (
|
||||||
<PageShell variant="sub" className="legal-page">
|
<PageShell variant="sub" className="legal-page">
|
||||||
<SubPageHeader title={doc.title} />
|
<SubPageHeader title={doc.title} />
|
||||||
<View className="sub-page-body inset-page">
|
<View className="sub-page-body inset-page legal-body">
|
||||||
<ScrollView scrollY className="legal-scroll">
|
<Text className="legal-updated" selectable>
|
||||||
<Text className="legal-updated">更新日期:{doc.updatedAt}</Text>
|
更新日期:{doc.updatedAt}
|
||||||
<Text className="legal-intro">{doc.intro}</Text>
|
</Text>
|
||||||
|
<Text className="legal-intro" selectable>
|
||||||
|
{doc.intro}
|
||||||
|
</Text>
|
||||||
{doc.sections.map((section) => (
|
{doc.sections.map((section) => (
|
||||||
<View key={section.heading} className="legal-section">
|
<View key={section.heading} className="legal-section">
|
||||||
<Text className="legal-heading">{section.heading}</Text>
|
<Text className="legal-heading" selectable>
|
||||||
|
{section.heading}
|
||||||
|
</Text>
|
||||||
{section.paragraphs.map((p, i) => (
|
{section.paragraphs.map((p, i) => (
|
||||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
|
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
|
||||||
{p}
|
{p}
|
||||||
</Text>
|
</Text>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</ScrollView>
|
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
.legal-page .legal-scroll {
|
.legal-page .legal-body {
|
||||||
height: 100%;
|
min-height: calc(100vh - var(--nav-bar-height, 88px));
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 40px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding-bottom: 32px;
|
background: var(--color-background, #f7f4ef);
|
||||||
}
|
}
|
||||||
|
|
||||||
.legal-updated {
|
.legal-updated {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--color-on-surface-variant, #8d706e);
|
color: #8d706e;
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legal-intro {
|
.legal-intro {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
line-height: 1.7;
|
line-height: 1.75;
|
||||||
color: var(--color-on-surface, #1f1a17);
|
color: #1f1a17;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -27,14 +30,15 @@
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--color-on-surface, #1f1a17);
|
color: #1f1a17;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legal-paragraph {
|
.legal-paragraph {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
line-height: 1.7;
|
line-height: 1.75;
|
||||||
color: var(--color-on-surface-variant, #5c504c);
|
color: #3d3530;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -234,7 +234,8 @@
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wechat-btn {
|
.login-wechat-btn,
|
||||||
|
.login-phone-quick-btn {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -250,52 +251,33 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1);
|
box-shadow: 0 8px 24px rgba(166, 29, 36, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wechat-btn:active {
|
.login-wechat-btn:active,
|
||||||
|
.login-phone-quick-btn:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wechat-btn--disabled {
|
.login-wechat-btn--disabled,
|
||||||
|
.login-phone-quick-btn--disabled {
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-wechat-btn__text {
|
.login-wechat-btn__text,
|
||||||
|
.login-phone-quick-btn__text {
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
line-height: 26px;
|
line-height: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wechat-login-icon {
|
/* 重置小程序 Button 默认样式,避免绿边/微信绿 */
|
||||||
position: relative;
|
.login-phone-quick-btn::after {
|
||||||
width: 24px;
|
border: none;
|
||||||
height: 24px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wechat-login-icon__big,
|
|
||||||
.wechat-login-icon__small {
|
|
||||||
position: absolute;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wechat-login-icon__big {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
left: 0;
|
|
||||||
top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.wechat-login-icon__small {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
right: 0;
|
|
||||||
bottom: 3px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-footer {
|
.login-footer {
|
||||||
|
|||||||
@@ -21,10 +21,10 @@ export type LegalDocument = {
|
|||||||
*/
|
*/
|
||||||
export const USER_AGREEMENT: LegalDocument = {
|
export const USER_AGREEMENT: LegalDocument = {
|
||||||
id: 'user-agreement',
|
id: 'user-agreement',
|
||||||
title: '用户协议',
|
title: '用户服务协议',
|
||||||
updatedAt: '2026-07-15',
|
updatedAt: '2026-07-21',
|
||||||
intro:
|
intro:
|
||||||
'欢迎使用「杜康好客」平台(含微信小程序、微信内置浏览器 H5 及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您勾选同意并继续使用,即视为已阅读并接受本协议全部内容。',
|
'欢迎使用「杜康好客」平台(含小程序、移动网页及门店端/合伙人端相关服务,以下统称「本平台」)。请您在注册、登录或以其他方式使用本服务前仔细阅读并充分理解本协议。您须主动勾选同意后继续使用,不得默认强制同意;勾选即视为已阅读并接受本协议全部内容。',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
heading: '一、服务说明',
|
heading: '一、服务说明',
|
||||||
@@ -36,7 +36,7 @@ export const USER_AGREEMENT: LegalDocument = {
|
|||||||
{
|
{
|
||||||
heading: '二、账号注册与安全',
|
heading: '二、账号注册与安全',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
'您可通过手机号验证码、微信授权等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。',
|
'您可通过手机号快捷登录、手机号验证码等方式注册或登录。您应保证提供的信息真实、准确、完整,并及时更新。',
|
||||||
'您应妥善保管账号、验证码及设备。因您自身原因导致的账号被盗用、信息泄露等风险,由您自行承担;如发现异常请立即联系客服。',
|
'您应妥善保管账号、验证码及设备。因您自身原因导致的账号被盗用、信息泄露等风险,由您自行承担;如发现异常请立即联系客服。',
|
||||||
'您不得利用本平台从事违法违规、侵害他人权益或扰乱平台秩序的行为,否则我们有权限制或终止服务。',
|
'您不得利用本平台从事违法违规、侵害他人权益或扰乱平台秩序的行为,否则我们有权限制或终止服务。',
|
||||||
],
|
],
|
||||||
@@ -88,20 +88,21 @@ export const USER_AGREEMENT: LegalDocument = {
|
|||||||
export const PRIVACY_POLICY: LegalDocument = {
|
export const PRIVACY_POLICY: LegalDocument = {
|
||||||
id: 'privacy-policy',
|
id: 'privacy-policy',
|
||||||
title: '隐私政策',
|
title: '隐私政策',
|
||||||
updatedAt: '2026-07-15',
|
updatedAt: '2026-07-21',
|
||||||
intro:
|
intro:
|
||||||
'杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您勾选同意,即表示您已充分理解并同意我们按本政策处理相关个人信息。',
|
'杜康好客平台运营方(以下简称「我们」)深知个人信息对您的重要性,将按《中华人民共和国个人信息保护法》等相关法律法规保护您的个人信息。请您在使用服务前仔细阅读本政策。您须主动勾选同意后,我们才会按本政策处理相关个人信息;我们不会默认勾选或强制同意。',
|
||||||
sections: [
|
sections: [
|
||||||
{
|
{
|
||||||
heading: '一、我们如何收集与使用个人信息',
|
heading: '一、我们如何收集与使用个人信息',
|
||||||
paragraphs: [
|
paragraphs: [
|
||||||
'为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能收集并使用下列信息:',
|
'为向您提供注册登录、下单支付、配送履约、门店核销、客服支持等核心功能,我们可能在取得您授权同意后收集并使用下列信息:',
|
||||||
'1)账号信息:手机号码、验证码、微信 OpenID/UnionID、昵称与头像(若您授权微信);用于注册登录、账号绑定与安全保障。',
|
'1)账号信息:手机号码、验证码、开放平台账号标识(OpenID/UnionID,若您授权)、昵称与头像(若您主动授权);用于注册登录、账号绑定与安全保障。',
|
||||||
'2)交易信息:订单内容、收货地址、支付状态、配送状态、权益与核销记录;用于履约、售后与对账。',
|
'2)交易信息:订单内容、收货地址、支付状态、配送状态、权益与核销记录;用于履约、售后与对账。',
|
||||||
'3)位置信息:在您授权后获取大致位置或精确位置,用于展示所在城市商品与附近门店;您可拒绝授权,我们将使用默认开城城市兜底。',
|
'3)位置信息:在您授权后获取大致位置或精确位置,用于展示所在城市商品与附近门店;您可拒绝授权,我们将使用默认开城城市兜底。',
|
||||||
'4)设备与日志信息:设备型号、操作系统、网络类型、崩溃日志、操作日志等;用于安全风控、故障排查与服务优化。',
|
'4)设备与日志信息:设备型号、操作系统、网络类型、崩溃日志、操作日志等;用于安全风控、故障排查与服务优化。',
|
||||||
'5)您主动提供的其他信息:如客服沟通内容、反馈建议等。',
|
'5)您主动提供的其他信息:如客服沟通内容、反馈建议等。',
|
||||||
'我们不会以默认勾选等方式强制您同意本政策;未征得同意前,我们不会超范围收集与实现业务功能无关的个人信息。',
|
'收集目的与方式:仅在实现上述业务功能所必需的范围内,通过您主动填写、授权组件或系统必要日志收集;未征得同意前,我们不会超范围收集与业务无关的个人信息。',
|
||||||
|
'我们不会以默认勾选等方式强制您同意本政策。',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -109,7 +110,7 @@ export const PRIVACY_POLICY: LegalDocument = {
|
|||||||
paragraphs: [
|
paragraphs: [
|
||||||
'我们不会向第三方出售您的个人信息。仅在以下情形共享:',
|
'我们不会向第三方出售您的个人信息。仅在以下情形共享:',
|
||||||
'1)获得您的明确同意;',
|
'1)获得您的明确同意;',
|
||||||
'2)为实现支付、短信、配送、地图/定位、微信登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;',
|
'2)为实现支付、短信、配送、地图/定位、账号登录与支付等功能,与必要的服务提供商共享履行服务所必需的信息,并要求其依法保护;',
|
||||||
'3)根据法律法规、行政或司法机关要求;',
|
'3)根据法律法规、行政或司法机关要求;',
|
||||||
'4)在合并、分立、资产转让等情形下,如涉及个人信息转移,我们将要求新的持有方继续受本政策约束,或重新征得您的同意。',
|
'4)在合并、分立、资产转让等情形下,如涉及个人信息转移,我们将要求新的持有方继续受本政策约束,或重新征得您的同意。',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ type TokenCache = { accessToken: string; expiresAt: number };
|
|||||||
type TicketCache = { ticket: string; expiresAt: number };
|
type TicketCache = { ticket: string; expiresAt: number };
|
||||||
|
|
||||||
const ACCESS_TOKEN_KEY = 'wechat:access_token';
|
const ACCESS_TOKEN_KEY = 'wechat:access_token';
|
||||||
|
const MINI_ACCESS_TOKEN_KEY = 'wechat:mini_access_token';
|
||||||
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
const JSAPI_TICKET_KEY = 'wechat:jsapi_ticket';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -255,7 +256,7 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
if (platform === 'h5') {
|
if (platform === 'h5') {
|
||||||
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
throw new InternalServerErrorException('H5 请使用短信绑定手机号');
|
||||||
}
|
}
|
||||||
const accessToken = await this.getAccessToken();
|
const accessToken = await this.getMiniAccessToken();
|
||||||
const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
const apiUrl = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||||||
const data = await this.fetchJson<{
|
const data = await this.fetchJson<{
|
||||||
errcode?: number;
|
errcode?: number;
|
||||||
@@ -433,6 +434,37 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
return data.access_token;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 小程序 access_token(getPhoneNumber 等 wxa 接口必须用小程序 AppID) */
|
||||||
|
private async getMiniAccessToken(): Promise<string> {
|
||||||
|
const appId = this.miniAppId;
|
||||||
|
const appSecret = this.miniAppSecret;
|
||||||
|
if (!appId || !appSecret) {
|
||||||
|
throw new InternalServerErrorException(
|
||||||
|
'小程序未配置:请设置 WX_MINI_APP_ID / WX_MINI_APP_SECRET',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const cached = await this.redis.getJson<TokenCache>(MINI_ACCESS_TOKEN_KEY);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.accessToken;
|
||||||
|
|
||||||
|
const url = new URL('https://api.weixin.qq.com/cgi-bin/token');
|
||||||
|
url.searchParams.set('grant_type', 'client_credential');
|
||||||
|
url.searchParams.set('appid', appId);
|
||||||
|
url.searchParams.set('secret', appSecret);
|
||||||
|
const data = await this.fetchJson<{ access_token?: string; expires_in?: number; errcode?: number; errmsg?: string }>(
|
||||||
|
url.toString(),
|
||||||
|
);
|
||||||
|
if (!data.access_token) {
|
||||||
|
throw new InternalServerErrorException(data.errmsg || '获取小程序 access_token 失败');
|
||||||
|
}
|
||||||
|
const ttl = Math.max((data.expires_in ?? 7200) - 300, 60);
|
||||||
|
await this.redis.setJson(
|
||||||
|
MINI_ACCESS_TOKEN_KEY,
|
||||||
|
{ accessToken: data.access_token, expiresAt: Date.now() + ttl * 1000 },
|
||||||
|
ttl,
|
||||||
|
);
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
private async getJsapiTicket(): Promise<string> {
|
private async getJsapiTicket(): Promise<string> {
|
||||||
const cached = await this.redis.getJson<TicketCache>(JSAPI_TICKET_KEY);
|
const cached = await this.redis.getJson<TicketCache>(JSAPI_TICKET_KEY);
|
||||||
if (cached && cached.expiresAt > Date.now()) return cached.ticket;
|
if (cached && cached.expiresAt > Date.now()) return cached.ticket;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
CheckPartnerPhoneDto,
|
CheckPartnerPhoneDto,
|
||||||
LoginSmsDto,
|
LoginSmsDto,
|
||||||
LoginWechatDto,
|
LoginWechatDto,
|
||||||
|
LoginWechatPhoneDto,
|
||||||
RefreshTokenDto,
|
RefreshTokenDto,
|
||||||
SendSmsDto,
|
SendSmsDto,
|
||||||
} from './dto/auth.dto';
|
} from './dto/auth.dto';
|
||||||
@@ -74,6 +75,23 @@ export class UserAuthController {
|
|||||||
return this.authService.loginUserWechat(dto.code, clientApp, platform, guestId);
|
return this.authService.loginUserWechat(dto.code, clientApp, platform, guestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 小程序手机号快捷登录(getPhoneNumber) */
|
||||||
|
@Post('auth/login/wechat-phone')
|
||||||
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
|
wechatPhoneLogin(@Req() req: Request, @Body() dto: LoginWechatPhoneDto) {
|
||||||
|
const guest = (req as Request & { user?: AuthUser }).user;
|
||||||
|
const guestId = guest?.actorType === 'USER' ? guest.actorId : undefined;
|
||||||
|
const clientApp = resolveUserClientApp(req);
|
||||||
|
const platform = dto.platform ?? (clientApp === ClientApp.USER_MINI ? 'mini' : 'h5');
|
||||||
|
return this.authService.loginUserWechatPhone(
|
||||||
|
dto.phoneCode,
|
||||||
|
clientApp,
|
||||||
|
platform,
|
||||||
|
guestId,
|
||||||
|
dto.loginCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('auth/wechat/bind-phone')
|
@Post('auth/wechat/bind-phone')
|
||||||
bindPhoneLegacy(@Req() req: Request, @Body() dto: BindWechatPhoneDto) {
|
bindPhoneLegacy(@Req() req: Request, @Body() dto: BindWechatPhoneDto) {
|
||||||
return this.authService.bindWechatPhone(
|
return this.authService.bindWechatPhone(
|
||||||
|
|||||||
@@ -751,19 +751,13 @@ export class AuthService {
|
|||||||
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
|
return this.issueToken('PARTNER', account.id, clientApp, false, undefined, undefined, this.partnerTokenPayload(account, primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
/** 手机号已验证后建号/登录并签发会话(短信登录与微信手机号快捷登录共用) */
|
||||||
const normalizedPhone = this.assertMobilePhone(phone);
|
private async issueUserSessionByVerifiedPhone(
|
||||||
const existingUser = await this.prisma.user.findUnique({
|
normalizedPhone: string,
|
||||||
where: { phone: normalizedPhone },
|
clientApp: ClientApp,
|
||||||
select: { id: true },
|
guestId: bigint | undefined,
|
||||||
});
|
method: 'sms' | 'wechat_phone',
|
||||||
await this.verifySmsForUser(
|
) {
|
||||||
normalizedPhone,
|
|
||||||
code,
|
|
||||||
SmsScene.USER_LOGIN,
|
|
||||||
clientApp,
|
|
||||||
guestId ?? existingUser?.id,
|
|
||||||
);
|
|
||||||
let user: UserRow | null = await this.prisma.user.findUnique({
|
let user: UserRow | null = await this.prisma.user.findUnique({
|
||||||
where: { phone: normalizedPhone },
|
where: { phone: normalizedPhone },
|
||||||
include: { avatar: true },
|
include: { avatar: true },
|
||||||
@@ -816,12 +810,12 @@ export class AuthService {
|
|||||||
if (guestId && guestId !== user.id) {
|
if (guestId && guestId !== user.id) {
|
||||||
user = await this.mergeUsers(guestId, user.id);
|
user = await this.mergeUsers(guestId, user.id);
|
||||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||||
eventName: 'sms_login',
|
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
||||||
extraJson: { method: 'sms', accountMerged: true },
|
extraJson: { method, accountMerged: true },
|
||||||
});
|
});
|
||||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||||
eventName: 'login_success',
|
eventName: 'login_success',
|
||||||
extraJson: { method: 'sms', accountMerged: true },
|
extraJson: { method, accountMerged: true },
|
||||||
});
|
});
|
||||||
return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged: true });
|
return this.buildSessionResponse(user, clientApp, user.deviceKey, { accountMerged: true });
|
||||||
} else {
|
} else {
|
||||||
@@ -832,17 +826,70 @@ export class AuthService {
|
|||||||
if (!user) throw new BadRequestException('登录失败');
|
if (!user) throw new BadRequestException('登录失败');
|
||||||
|
|
||||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||||
eventName: 'sms_login',
|
eventName: method === 'sms' ? 'sms_login' : 'wechat_phone_login',
|
||||||
extraJson: { method: 'sms' },
|
extraJson: { method },
|
||||||
});
|
});
|
||||||
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
this.analyticsService.trackOneSafe(user.id, clientApp, {
|
||||||
eventName: 'login_success',
|
eventName: 'login_success',
|
||||||
extraJson: { method: 'sms' },
|
extraJson: { method },
|
||||||
});
|
});
|
||||||
|
|
||||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||||
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { phone: normalizedPhone },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await this.verifySmsForUser(
|
||||||
|
normalizedPhone,
|
||||||
|
code,
|
||||||
|
SmsScene.USER_LOGIN,
|
||||||
|
clientApp,
|
||||||
|
guestId ?? existingUser?.id,
|
||||||
|
);
|
||||||
|
return this.issueUserSessionByVerifiedPhone(normalizedPhone, clientApp, guestId, 'sms');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 小程序 getPhoneNumber:用微信返回的 phoneCode 登录/注册,可选 loginCode 绑定 openId */
|
||||||
|
async loginUserWechatPhone(
|
||||||
|
phoneCode: string,
|
||||||
|
clientApp: ClientApp,
|
||||||
|
platform: 'h5' | 'mini' = 'mini',
|
||||||
|
guestId?: bigint,
|
||||||
|
loginCode?: string,
|
||||||
|
) {
|
||||||
|
this.assertWechatEnabled();
|
||||||
|
if (platform !== 'mini') {
|
||||||
|
throw new BadRequestException('仅小程序支持手机号快捷登录');
|
||||||
|
}
|
||||||
|
const phone = await this.wechatProvider.getPhoneNumberByCode(phoneCode, platform);
|
||||||
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
|
const session = await this.issueUserSessionByVerifiedPhone(
|
||||||
|
normalizedPhone,
|
||||||
|
clientApp,
|
||||||
|
guestId,
|
||||||
|
'wechat_phone',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loginCode?.trim() && session.actorId) {
|
||||||
|
try {
|
||||||
|
await this.bindUserWechat(
|
||||||
|
BigInt(session.actorId),
|
||||||
|
{ code: loginCode.trim() },
|
||||||
|
clientApp,
|
||||||
|
'mini',
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* 绑定 openId 失败不阻断已成功的手机号登录 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
|
async bindPhone(actorId: bigint, phone: string, code: string, clientApp: ClientApp) {
|
||||||
const normalizedPhone = this.assertMobilePhone(phone);
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
await this.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId);
|
await this.verifySmsForUser(normalizedPhone, code, SmsScene.BIND_PHONE, clientApp, actorId);
|
||||||
|
|||||||
@@ -55,6 +55,22 @@ export class LoginWechatDto {
|
|||||||
platform?: 'h5' | 'mini';
|
platform?: 'h5' | 'mini';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 小程序 getPhoneNumber 返回的 phoneCode,可选附带 wx.login code 绑定 openId */
|
||||||
|
export class LoginWechatPhoneDto {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phoneCode: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
loginCode?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['h5', 'mini'])
|
||||||
|
@IsOptional()
|
||||||
|
platform?: 'h5' | 'mini';
|
||||||
|
}
|
||||||
|
|
||||||
export class BindWechatPhoneDto {
|
export class BindWechatPhoneDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
Reference in New Issue
Block a user