Compare commits
18 Commits
792b543ba8
...
bdf80e577b
| Author | SHA1 | Date | |
|---|---|---|---|
| bdf80e577b | |||
| eb96b36d0b | |||
| 36bec94639 | |||
| c11c7647b9 | |||
| 83ed90ef67 | |||
| 73dbf6effb | |||
| b8adfe98a3 | |||
| 6c6fb10490 | |||
| 935ab0d1d4 | |||
| 350a086a73 | |||
| c5f526d51d | |||
| e1f5130c18 | |||
| bc0b0cafd6 | |||
| ade68972a9 | |||
| 47b39e3ba0 | |||
| cf73bbae23 | |||
| d1b207422c | |||
| 4a29d8297c |
@@ -15,3 +15,4 @@ coverage/
|
|||||||
server/dukang-api/prisma/migrations/
|
server/dukang-api/prisma/migrations/
|
||||||
debug_v3.xlsx
|
debug_v3.xlsx
|
||||||
~$debug_v3.xlsx
|
~$debug_v3.xlsx
|
||||||
|
deploy/auto-release.env
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { getPartnerProfile, hasPartnerWxSession } from '../lib/api';
|
import { getPartnerProfile } from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { partnerHomePath } from '../lib/partnerAccess';
|
import { partnerHomePath } from '../lib/partnerAccess';
|
||||||
|
|
||||||
@@ -18,10 +18,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
const profile = getPartnerProfile();
|
|
||||||
if (profile && hasPartnerWxSession() && profile.hasWechat) {
|
|
||||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
|
||||||
}
|
|
||||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
import { uploadFileToOss, type OssMediaType } from '../lib/upload';
|
||||||
import { enqueueUpload } from '../lib/upload-lock';
|
import { enqueueUpload } from '../lib/upload-lock';
|
||||||
import {
|
|
||||||
authorizePartnerWechat,
|
|
||||||
fetchClientConfig,
|
|
||||||
fetchPartnerProfile,
|
|
||||||
needsWechatAuth,
|
|
||||||
type PartnerProfile,
|
|
||||||
} from '../lib/wechat-auth';
|
|
||||||
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
|
||||||
import { toastError } from '../lib/toast';
|
import { toastError } from '../lib/toast';
|
||||||
import type { ClientRuntimeConfig } from '@dukang/shared-types';
|
|
||||||
|
|
||||||
type OssUploadFieldProps = {
|
type OssUploadFieldProps = {
|
||||||
value?: string;
|
value?: string;
|
||||||
@@ -22,27 +12,10 @@ type OssUploadFieldProps = {
|
|||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
label?: string;
|
label?: string;
|
||||||
/** 父级已确认微信授权时可跳过检查 */
|
|
||||||
wechatReady?: boolean;
|
|
||||||
onWechatReadyChange?: (ready: boolean) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MAX_MB = 10;
|
const DEFAULT_MAX_MB = 10;
|
||||||
|
|
||||||
function formatWechatUploadError(e: unknown): string {
|
|
||||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
|
||||||
const formatted = formatChooseImageFailMessage(msg);
|
|
||||||
if (formatted) return formatted;
|
|
||||||
if (/invalid signature/i.test(msg)) {
|
|
||||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名,并刷新页面后重试';
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
function acceptsImages(accept: string) {
|
|
||||||
return accept.includes('image');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function OssUploadField({
|
export default function OssUploadField({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
@@ -52,15 +25,10 @@ export default function OssUploadField({
|
|||||||
wide,
|
wide,
|
||||||
compact,
|
compact,
|
||||||
label,
|
label,
|
||||||
wechatReady,
|
|
||||||
onWechatReadyChange,
|
|
||||||
}: OssUploadFieldProps) {
|
}: OssUploadFieldProps) {
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [authorizing, setAuthorizing] = useState(false);
|
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [profile, setProfile] = useState<PartnerProfile | null>(null);
|
|
||||||
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
|
|
||||||
|
|
||||||
function showUploadError(text: string) {
|
function showUploadError(text: string) {
|
||||||
setError(text);
|
setError(text);
|
||||||
@@ -69,35 +37,6 @@ export default function OssUploadField({
|
|||||||
|
|
||||||
const resolvedAccept =
|
const resolvedAccept =
|
||||||
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
accept ?? (mediaType === 'VIDEO' ? 'video/*' : mediaType === 'FILE' ? 'image/*,.pdf' : 'image/*');
|
||||||
const inWechat = isWechatEnv();
|
|
||||||
const useWechatPicker =
|
|
||||||
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
|
|
||||||
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
|
|
||||||
const onWechatReadyChangeRef = useRef(onWechatReadyChange);
|
|
||||||
onWechatReadyChangeRef.current = onWechatReadyChange;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useWechatPicker) return;
|
|
||||||
void fetchClientConfig()
|
|
||||||
.then(setClientConfig)
|
|
||||||
.catch(() => {
|
|
||||||
/* 未登录等场景由上传接口报错 */
|
|
||||||
});
|
|
||||||
if (wechatReady === true) return;
|
|
||||||
void fetchPartnerProfile()
|
|
||||||
.then(setProfile)
|
|
||||||
.catch(() => {
|
|
||||||
/* 未登录等场景由上传接口报错 */
|
|
||||||
});
|
|
||||||
}, [useWechatPicker, wechatReady]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!useWechatPicker || needsAuth) return;
|
|
||||||
weixinSdk.reset();
|
|
||||||
void weixinSdk.init().catch(() => {
|
|
||||||
/* 点击上传时会再次初始化 */
|
|
||||||
});
|
|
||||||
}, [useWechatPicker, needsAuth]);
|
|
||||||
|
|
||||||
async function persistUpload(file: File) {
|
async function persistUpload(file: File) {
|
||||||
if (!file.size) {
|
if (!file.size) {
|
||||||
@@ -125,112 +64,38 @@ export default function OssUploadField({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startWechatAuth() {
|
function pickFile() {
|
||||||
setAuthorizing(true);
|
if (uploading) return;
|
||||||
setError('');
|
setError('');
|
||||||
try {
|
|
||||||
const result = await authorizePartnerWechat();
|
|
||||||
if (result) {
|
|
||||||
const me = await fetchPartnerProfile();
|
|
||||||
setProfile(me);
|
|
||||||
if (me.hasWechat) onWechatReadyChangeRef.current?.(true);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const text = e instanceof Error ? e.message : '微信授权失败';
|
|
||||||
showUploadError(text);
|
|
||||||
} finally {
|
|
||||||
setAuthorizing(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickWechatImage() {
|
|
||||||
setUploading(true);
|
|
||||||
setError('');
|
|
||||||
try {
|
|
||||||
weixinSdk.reset();
|
|
||||||
await weixinSdk.init();
|
|
||||||
// 选图 + 上传须在同一个队列任务内完成,避免嵌套 enqueueUpload 死锁
|
|
||||||
await enqueueUpload(async () => {
|
|
||||||
const files = await weixinSdk.chooseImages({
|
|
||||||
count: 1,
|
|
||||||
sourceType: ['album', 'camera'],
|
|
||||||
});
|
|
||||||
if (!files?.[0]) return;
|
|
||||||
await persistUpload(files[0]);
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
|
||||||
if (/cancel/i.test(msg)) return;
|
|
||||||
showUploadError(formatWechatUploadError(e));
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pickFile() {
|
|
||||||
if (uploading || authorizing) return;
|
|
||||||
setError('');
|
|
||||||
|
|
||||||
if (useWechatPicker && needsAuth) {
|
|
||||||
const text = '请先完成微信授权后再上传照片';
|
|
||||||
showUploadError(text);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (useWechatPicker) {
|
|
||||||
try {
|
|
||||||
await pickWechatImage();
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : '无法打开相册';
|
|
||||||
if (/cancel/i.test(msg)) return;
|
|
||||||
showUploadError(formatWechatUploadError(e));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
inputRef.current?.click();
|
inputRef.current?.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
const isImage = mediaType === 'IMAGE' && value;
|
const isImage = mediaType === 'IMAGE' && value;
|
||||||
const isFile = mediaType === 'FILE' && value;
|
const isFile = mediaType === 'FILE' && value;
|
||||||
const busy = uploading || authorizing;
|
const busy = uploading;
|
||||||
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
|
const pickerLabel = label ?? (mediaType === 'IMAGE' ? '从系统相册选择' : '点击上传');
|
||||||
|
|
||||||
const triggerProps = {
|
const triggerProps = {
|
||||||
type: 'button' as const,
|
type: 'button' as const,
|
||||||
disabled: busy || needsAuth,
|
disabled: busy,
|
||||||
onClick: () => void pickFile(),
|
onClick: pickFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-oss-upload">
|
<div className="partner-oss-upload">
|
||||||
{needsAuth && (
|
<input
|
||||||
<div className="partner-wechat-auth-hint" role="status">
|
ref={inputRef}
|
||||||
<p className="body-md">上传照片需先完成微信授权绑定</p>
|
type="file"
|
||||||
<button
|
accept={resolvedAccept}
|
||||||
type="button"
|
className="partner-oss-upload-input"
|
||||||
className="partner-btn-outline"
|
disabled={busy}
|
||||||
style={{ marginTop: 8, width: '100%' }}
|
onChange={(e) => {
|
||||||
disabled={authorizing}
|
const file = e.target.files?.[0];
|
||||||
onClick={() => void startWechatAuth()}
|
if (file) {
|
||||||
>
|
void uploadSelectedFile(file);
|
||||||
{authorizing ? '跳转授权中…' : '微信授权绑定'}
|
}
|
||||||
</button>
|
}}
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
{!useWechatPicker && (
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
accept={resolvedAccept}
|
|
||||||
className="partner-oss-upload-input"
|
|
||||||
disabled={busy}
|
|
||||||
onChange={(e) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) void uploadSelectedFile(file);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{isImage ? (
|
{isImage ? (
|
||||||
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
<button {...triggerProps} className={`partner-upload-preview${wide ? ' partner-upload-preview--wide' : ''}`}>
|
||||||
<img src={value} alt={label ?? '已上传'} />
|
<img src={value} alt={label ?? '已上传'} />
|
||||||
@@ -255,10 +120,10 @@ export default function OssUploadField({
|
|||||||
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
|
className={`partner-upload-dashed${wide ? ' partner-upload-dashed--wide' : ''}${compact ? ' partner-upload-dashed--compact' : ''}`}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
|
<span className="material-symbols-outlined text-primary" style={{ fontSize: compact ? 28 : 36 }}>
|
||||||
{busy ? 'hourglass_top' : 'add_a_photo'}
|
{busy ? 'hourglass_top' : 'photo_library'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-primary" style={{ fontWeight: 500 }}>
|
<span className="text-primary" style={{ fontWeight: 500 }}>
|
||||||
{uploading ? '上传中…' : needsAuth ? '请先微信授权' : pickerLabel}
|
{uploading ? '上传中…' : pickerLabel}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const PARTNER_PROFILE = 'partnerProfile';
|
|||||||
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
const SESSION_EXPIRES_AT = 'partnerSessionExpiresAt';
|
||||||
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
export const PARTNER_WX_BOUND = 'partnerWxBound';
|
||||||
|
|
||||||
/** 微信验证通过后的免登录时长 */
|
/** 手机号或微信验证通过后的免登录时长 */
|
||||||
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
export const PARTNER_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||||
@@ -77,7 +77,7 @@ export function isPartnerSessionExpired() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function touchPartnerSession() {
|
export function touchPartnerSession() {
|
||||||
if (!hasPartnerWxSession()) return;
|
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,11 +90,16 @@ export function saveAuth(data: PartnerSessionPayload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||||
|
export function saveRememberedSession(data: PartnerSessionPayload) {
|
||||||
|
saveAuth(data);
|
||||||
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
||||||
|
}
|
||||||
|
|
||||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||||
export function saveWechatSession(data: PartnerSessionPayload) {
|
export function saveWechatSession(data: PartnerSessionPayload) {
|
||||||
saveAuth(data);
|
saveRememberedSession(data);
|
||||||
localStorage.setItem(PARTNER_WX_BOUND, '1');
|
localStorage.setItem(PARTNER_WX_BOUND, '1');
|
||||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + PARTNER_SESSION_TTL_MS));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||||
|
|||||||
@@ -1,25 +1,14 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
import { useRef, useState, type RefObject } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
|
||||||
import { WECHAT_INAPP_REQUIRED_MSG } from '@dukang/weixin-sdk';
|
|
||||||
import {
|
import {
|
||||||
getLastPhone,
|
getLastPhone,
|
||||||
getPartnerProfile,
|
getPartnerProfile,
|
||||||
hasPartnerWxSession,
|
|
||||||
request,
|
request,
|
||||||
saveAuth,
|
saveRememberedSession,
|
||||||
type PartnerSessionPayload,
|
type PartnerSessionPayload,
|
||||||
} from '../lib/api';
|
} from '../lib/api';
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
import { partnerHomePath } from '../lib/partnerAccess';
|
import { partnerHomePath } from '../lib/partnerAccess';
|
||||||
import {
|
|
||||||
bindPartnerWechatAfterSmsLogin,
|
|
||||||
canPartnerUseWechatLogin,
|
|
||||||
fetchClientConfig,
|
|
||||||
loginPartnerWithWechat,
|
|
||||||
PARTNER_WECHAT_LOGIN_HINT,
|
|
||||||
} from '../lib/wechat-auth';
|
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
|
||||||
|
|
||||||
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
const REMEMBER_PHONE_KEY = 'partner_remember_phone';
|
||||||
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
const REMEMBER_FLAG_KEY = 'partner_remember_account';
|
||||||
@@ -31,7 +20,7 @@ function AgreementCheckbox({
|
|||||||
}: {
|
}: {
|
||||||
agreed: boolean;
|
agreed: boolean;
|
||||||
onChange: (next: boolean) => void;
|
onChange: (next: boolean) => void;
|
||||||
inputRef?: RefObject<HTMLLabelElement | null>;
|
inputRef?: RefObject<HTMLLabelElement>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
|
<label className="partner-checkbox-row partner-checkbox-row--agreement" ref={inputRef}>
|
||||||
@@ -50,11 +39,6 @@ function AgreementCheckbox({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function maskPhone(phone: string) {
|
|
||||||
if (phone.length < 7) return phone;
|
|
||||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadRememberedPhone(): { phone: string; remember: boolean } {
|
function loadRememberedPhone(): { phone: string; remember: boolean } {
|
||||||
try {
|
try {
|
||||||
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
|
const remember = localStorage.getItem(REMEMBER_FLAG_KEY) === '1';
|
||||||
@@ -76,19 +60,9 @@ function formatPartnerError(e: unknown): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatWechatError(e: unknown): string {
|
|
||||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
|
||||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
|
||||||
return '该微信尚未绑定合伙人账号,请先使用手机验证码登录,登录后将自动关联微信';
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession, refresh, account } = usePartnerSession();
|
const { applySession, refresh, account } = usePartnerSession();
|
||||||
const [params] = useSearchParams();
|
|
||||||
const quick = params.get('quick') === '1';
|
|
||||||
const savedProfile = getPartnerProfile();
|
const savedProfile = getPartnerProfile();
|
||||||
const remembered = loadRememberedPhone();
|
const remembered = loadRememberedPhone();
|
||||||
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
|
const [phone, setPhone] = useState(remembered.phone || getLastPhone());
|
||||||
@@ -96,22 +70,10 @@ export default function LoginPage() {
|
|||||||
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
const [rememberAccount, setRememberAccount] = useState(remembered.remember);
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [wxLoading, setWxLoading] = useState(false);
|
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
|
||||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchClientConfig()
|
|
||||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
|
||||||
.catch(() => setWxAuthorize(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const quickName = savedProfile?.name ?? '城市合伙人';
|
|
||||||
const quickCompany = savedProfile?.companyName ?? '';
|
|
||||||
const quickPhone = savedProfile?.phone || phone;
|
|
||||||
|
|
||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先勾选并同意用户协议');
|
setMsg('请先勾选并同意用户协议');
|
||||||
@@ -175,14 +137,9 @@ export default function LoginPage() {
|
|||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
silent: true,
|
silent: true,
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
saveRememberedSession(data);
|
||||||
applySession(data);
|
applySession(data);
|
||||||
persistRememberAccount(phone);
|
persistRememberAccount(phone);
|
||||||
if (isWechatEnv() && wxAuthorize) {
|
|
||||||
setMsg('登录成功,正在关联微信…');
|
|
||||||
await bindPartnerWechatAfterSmsLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await finishLoginNavigate();
|
await finishLoginNavigate();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(formatPartnerError(e));
|
setMsg(formatPartnerError(e));
|
||||||
@@ -191,116 +148,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wechatLogin() {
|
|
||||||
if (!ensureAgreed()) return;
|
|
||||||
setMsg('');
|
|
||||||
if (!isWechatEnv()) {
|
|
||||||
setMsg(WECHAT_INAPP_REQUIRED_MSG);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canUseWechat = await canPartnerUseWechatLogin({
|
|
||||||
profile: savedProfile,
|
|
||||||
phone,
|
|
||||||
});
|
|
||||||
if (!canUseWechat) {
|
|
||||||
setMsg(PARTNER_WECHAT_LOGIN_HINT);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWxLoading(true);
|
|
||||||
try {
|
|
||||||
const session = await loginPartnerWithWechat();
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
await finishLoginNavigate();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(formatWechatError(e));
|
|
||||||
} finally {
|
|
||||||
setWxLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quick) {
|
|
||||||
const canWechatQuick =
|
|
||||||
wxAuthorize &&
|
|
||||||
isWechatEnv() &&
|
|
||||||
hasPartnerWxSession() &&
|
|
||||||
!!savedProfile &&
|
|
||||||
savedProfile.hasWechat === true;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="partner-auth-page partner-auth-page--quick">
|
|
||||||
<header className="partner-auth-brand">
|
|
||||||
<div className="partner-quick-avatar" style={{ width: 120, height: 120, margin: '0 auto 16px' }}>
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 48 }}>wine_bar</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="partner-auth-title" style={{ fontSize: 20 }}>杜康好客</h1>
|
|
||||||
<p className="partner-auth-subtitle" style={{ fontSize: 12, letterSpacing: '0.2em', textTransform: 'uppercase' }}>城市合伙人端</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="partner-glass-card">
|
|
||||||
<div className="partner-quick-badge">已识别账号</div>
|
|
||||||
<div className="partner-quick-avatar">
|
|
||||||
<span className="material-symbols-outlined">person</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ textAlign: 'center' }}>
|
|
||||||
<h2 className="headline-md">
|
|
||||||
{quickName}
|
|
||||||
{quickCompany ? (
|
|
||||||
<span className="text-muted body-md" style={{ fontWeight: 400 }}> ({quickCompany})</span>
|
|
||||||
) : null}
|
|
||||||
</h2>
|
|
||||||
<p className="text-muted body-md" style={{ letterSpacing: '0.1em', marginTop: 4 }}>
|
|
||||||
{quickPhone ? maskPhone(quickPhone) : '暂无已保存账号'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<nav style={{ width: '100%', maxWidth: 384, marginTop: 16 }}>
|
|
||||||
{msg && <p className="partner-auth-msg" style={{ color: 'var(--color-error, #d33)', fontSize: 13, textAlign: 'center', marginBottom: 12 }}>{msg}</p>}
|
|
||||||
<AgreementCheckbox
|
|
||||||
agreed={agreed}
|
|
||||||
onChange={setAgreed}
|
|
||||||
inputRef={agreementRef}
|
|
||||||
/>
|
|
||||||
{canWechatQuick ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="partner-btn-primary"
|
|
||||||
style={{ marginTop: 16 }}
|
|
||||||
disabled={wxLoading}
|
|
||||||
onClick={() => void wechatLogin()}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined">chat</span>
|
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="partner-auth-msg" style={{ textAlign: 'center', marginTop: 12, marginBottom: 12 }}>
|
|
||||||
{wxAuthorize && !isWechatEnv()
|
|
||||||
? '请在微信内打开以使用一键登录'
|
|
||||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!canWechatQuick && (
|
|
||||||
<Link to="/login" className="partner-btn-primary" style={{ display: 'block', textAlign: 'center', textDecoration: 'none', marginTop: 12 }}>
|
|
||||||
验证码登录
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<Link to="/login" className="partner-btn-ghost" style={{ display: 'block', marginTop: 12 }}>切换账号</Link>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<footer className="partner-auth-footer">
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
||||||
<span className="material-symbols-outlined">verified_user</span>
|
|
||||||
<span className="label-md" style={{ fontSize: 10, letterSpacing: '0.1em', textTransform: 'uppercase' }}>
|
|
||||||
{canWechatQuick ? '微信验证 · 7 天内免登录' : 'Secured by Dukang Heritage'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="partner-auth-page">
|
<div className="partner-auth-page">
|
||||||
<div className="partner-auth-brand">
|
<div className="partner-auth-brand">
|
||||||
@@ -367,25 +214,12 @@ export default function LoginPage() {
|
|||||||
<span>{loading ? '登录中...' : '登录'}</span>
|
<span>{loading ? '登录中...' : '登录'}</span>
|
||||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="partner-auth-msg" style={{ textAlign: 'center' }}>
|
||||||
{wxAuthorize && (
|
手机号验证成功后,7 天内无需再次输入验证码
|
||||||
<>
|
</p>
|
||||||
<div className="partner-auth-divider"><span>其他登录方式</span></div>
|
|
||||||
<button type="button" className="partner-btn-wechat" onClick={() => void wechatLogin()} disabled={wxLoading}>
|
|
||||||
<svg viewBox="0 0 24 24" fill="#07C160" aria-hidden>
|
|
||||||
<path d="M8.25 4.5C4.52 4.5 1.5 7.04 1.5 10.17c0 1.78.98 3.37 2.5 4.48l-.63 1.88 2.19-1.09c.84.24 1.74.38 2.69.38.25 0 .5 0 .75-.03-.16-.53-.25-1.09-.25-1.66 0-3.13 3.02-5.67 6.75-5.67.57 0 1.13.06 1.66.17C15.17 6.13 12 4.5 8.25 4.5zm10.5 6.33c-3.11 0-5.62 2.12-5.62 4.73 0 2.61 2.51 4.73 5.62 4.73.79 0 1.54-.14 2.24-.38l1.83.91-.53-1.57c1.27-.92 2.08-2.25 2.08-3.73 0-2.61-2.51-4.73-5.62-4.73z" />
|
|
||||||
</svg>
|
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
{hasPartnerWxSession() && savedProfile?.hasWechat && (
|
|
||||||
<Link to="/login?quick=1" className="partner-link">微信快捷登录</Link>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<footer className="partner-auth-footer">
|
<footer className="partner-auth-footer">
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||||
<span className="material-symbols-outlined">verified_user</span>
|
<span className="material-symbols-outlined">verified_user</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -17,8 +17,6 @@ import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
|
|||||||
|
|
||||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||||
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
|
||||||
|
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -79,16 +77,10 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { account, refresh } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
|
|
||||||
const accountId = account?.id;
|
const accountId = account?.id;
|
||||||
|
|
||||||
const wechatReady = !!account?.hasWechat;
|
|
||||||
|
|
||||||
const handleWechatReadyChange = useCallback(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
const saved = loadStoreDraft(accountId);
|
const saved = loadStoreDraft(accountId);
|
||||||
@@ -142,17 +134,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (step !== 2 || !isWechatEnv()) return;
|
|
||||||
void refresh();
|
|
||||||
weixinSdk.reset();
|
|
||||||
void weixinSdk.init().catch(() => {
|
|
||||||
/* OssUploadField 点击时会再次初始化 */
|
|
||||||
});
|
|
||||||
}, [step, refresh]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
void fetchPartnerCities()
|
void fetchPartnerCities()
|
||||||
@@ -895,13 +876,9 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={form.coverUrl}
|
value={form.coverUrl}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(coverUrl) => patchForm({ coverUrl })}
|
onChange={(coverUrl) => patchForm({ coverUrl })}
|
||||||
|
|
||||||
label="点击或拖拽上传"
|
label="从系统相册选择"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -931,10 +908,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={url}
|
value={url}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
@@ -951,7 +924,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>上传签约协议首页与盖章页</p>
|
||||||
|
|
||||||
<OssUploadField
|
<OssUploadField
|
||||||
|
|
||||||
@@ -963,10 +936,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={form.contractUrl}
|
value={form.contractUrl}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||||
|
|
||||||
label="上传合同副本"
|
label="上传合同副本"
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export default function StoreDetailPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [mediaSaving, setMediaSaving] = useState(false);
|
const [mediaSaving, setMediaSaving] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
const [wechatReady, setWechatReady] = useState(false);
|
|
||||||
|
|
||||||
function applyStore(data: Record<string, unknown>) {
|
function applyStore(data: Record<string, unknown>) {
|
||||||
setStore(data);
|
setStore(data);
|
||||||
@@ -284,10 +283,8 @@ export default function StoreDetailPage() {
|
|||||||
bizType="STORE_TITLE"
|
bizType="STORE_TITLE"
|
||||||
mediaType="IMAGE"
|
mediaType="IMAGE"
|
||||||
value={coverUrl}
|
value={coverUrl}
|
||||||
wechatReady={wechatReady}
|
|
||||||
onWechatReadyChange={setWechatReady}
|
|
||||||
onChange={setCoverUrl}
|
onChange={setCoverUrl}
|
||||||
label="点击更换门头照"
|
label="从系统相册选择"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -336,8 +333,6 @@ export default function StoreDetailPage() {
|
|||||||
bizType="STORE_ENV"
|
bizType="STORE_ENV"
|
||||||
mediaType="IMAGE"
|
mediaType="IMAGE"
|
||||||
value={url}
|
value={url}
|
||||||
wechatReady={wechatReady}
|
|
||||||
onWechatReadyChange={setWechatReady}
|
|
||||||
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/partner/',
|
// 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
|
||||||
|
base: process.env.VITE_PUBLIC_BASE ?? '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import LegalPage from './pages/LegalPage';
|
import LegalPage from './pages/LegalPage';
|
||||||
import SelectStorePage from './pages/SelectStorePage';
|
import SelectStorePage from './pages/SelectStorePage';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
|
||||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||||
import RecordsPage from './pages/RecordsPage';
|
import RecordsPage from './pages/RecordsPage';
|
||||||
@@ -22,7 +21,7 @@ export default function App() {
|
|||||||
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||||
<Route path="/select-store" element={<SelectStorePage />} />
|
<Route path="/select-store" element={<SelectStorePage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
<Route path="/redeem" element={<Navigate to="/redeem/phone" replace />} />
|
||||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||||
<Route element={<TabLayout />}>
|
<Route element={<TabLayout />}>
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { Navigate, useLocation } from 'react-router-dom';
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
import { getStoreProfile, hasShopWxSession } from '../lib/api';
|
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
const PUBLIC_PATHS = new Set(['/login', '/legal/user-agreement', '/legal/privacy-policy']);
|
||||||
@@ -26,10 +25,6 @@ export default function AuthGate({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
const profile = getStoreProfile();
|
|
||||||
if (profile && hasShopWxSession() && location.pathname !== '/login') {
|
|
||||||
return <Navigate to="/login?quick=1" replace state={{ from: location }} />;
|
|
||||||
}
|
|
||||||
return <Navigate to="/login" replace state={{ from: location }} />;
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
const [photoResourceId, setPhotoResourceId] = useState('');
|
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||||
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
|
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
|
||||||
|
|
||||||
async function handleFile(file: File) {
|
async function handleFile(file: File) {
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
@@ -49,7 +50,11 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const text = e instanceof Error ? e.message : '选图失败';
|
const text = e instanceof Error ? e.message : '选图失败';
|
||||||
if (!/cancel/i.test(text)) setMsg(text);
|
if (!/cancel/i.test(text)) {
|
||||||
|
setMsg(`${text},可改从系统相册选择`);
|
||||||
|
setShowAlbumFallback(true);
|
||||||
|
inputRef.current?.click();
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
}
|
}
|
||||||
@@ -120,11 +125,13 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
capture="environment"
|
|
||||||
hidden
|
hidden
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
if (file) void handleFile(file);
|
if (file) {
|
||||||
|
setShowAlbumFallback(false);
|
||||||
|
void handleFile(file);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{previewUrl && (
|
{previewUrl && (
|
||||||
@@ -134,6 +141,16 @@ export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props)
|
|||||||
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
|
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
|
||||||
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
|
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
|
||||||
</button>
|
</button>
|
||||||
|
{showAlbumFallback && isWechatEnv() && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-btn-outline"
|
||||||
|
disabled={uploading}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
从系统相册选择
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-redeem-confirm-btn"
|
className="shop-redeem-confirm-btn"
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const STORE_PROFILE = 'shopStoreProfile';
|
|||||||
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
const SESSION_EXPIRES_AT = 'shopSessionExpiresAt';
|
||||||
export const SHOP_WX_BOUND = 'shopWxBound';
|
export const SHOP_WX_BOUND = 'shopWxBound';
|
||||||
|
|
||||||
/** 微信验证通过后的免登录时长 */
|
/** 手机号或微信验证通过后的免登录时长 */
|
||||||
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
export const SHOP_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
const AUTH_RECOVERY_EXEMPT_PATHS = [
|
||||||
@@ -87,7 +87,7 @@ export function isShopSessionExpired() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function touchShopSession() {
|
export function touchShopSession() {
|
||||||
if (!hasShopWxSession()) return;
|
if (!localStorage.getItem(REFRESH_TOKEN)) return;
|
||||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,11 +119,16 @@ export function saveAuth(data: ShopSessionPayload) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 手机号验证成功后写入 7 天免验证码会话 */
|
||||||
|
export function saveRememberedSession(data: ShopSessionPayload) {
|
||||||
|
saveAuth(data);
|
||||||
|
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
||||||
|
}
|
||||||
|
|
||||||
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
/** 微信登录/绑定成功后写入 7 天免登录 session */
|
||||||
export function saveWechatSession(data: ShopSessionPayload) {
|
export function saveWechatSession(data: ShopSessionPayload) {
|
||||||
saveAuth(data);
|
saveRememberedSession(data);
|
||||||
localStorage.setItem(SHOP_WX_BOUND, '1');
|
localStorage.setItem(SHOP_WX_BOUND, '1');
|
||||||
localStorage.setItem(SESSION_EXPIRES_AT, String(Date.now() + SHOP_SESSION_TTL_MS));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth(options?: { keepProfile?: boolean }) {
|
export function clearAuth(options?: { keepProfile?: boolean }) {
|
||||||
|
|||||||
@@ -1,47 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
|
||||||
import {
|
|
||||||
authorizeShopWechat,
|
|
||||||
checkNeedsWechatAuth,
|
|
||||||
fetchShopAccount,
|
|
||||||
handleShopWechatCallback,
|
|
||||||
handleShopWechatLoginResult,
|
|
||||||
} from '../lib/wechat-auth';
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
|
||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
|
||||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
|
||||||
|
|
||||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
function formatMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatScanError(e: unknown): string {
|
|
||||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
|
||||||
if (/invalid signature/i.test(msg)) {
|
|
||||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
|
||||||
}
|
|
||||||
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
|
||||||
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [scanMsg, setScanMsg] = useState('');
|
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
|
||||||
const [authError, setAuthError] = useState('');
|
|
||||||
|
|
||||||
const loadDashboard = useCallback(() => {
|
const loadDashboard = useCallback(() => {
|
||||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||||
@@ -58,7 +26,6 @@ export default function HomePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onResume() {
|
function onResume() {
|
||||||
setScanning(false);
|
|
||||||
void loadDashboard();
|
void loadDashboard();
|
||||||
}
|
}
|
||||||
function onVisibility() {
|
function onVisibility() {
|
||||||
@@ -74,88 +41,6 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
}, [loadDashboard]);
|
}, [loadDashboard]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWechatEnv() || !searchParams.get('code')) return;
|
|
||||||
void handleShopWechatCallback()
|
|
||||||
.then((result) => {
|
|
||||||
if (!result) return;
|
|
||||||
const session = handleShopWechatLoginResult(result);
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
}
|
|
||||||
setAuthModalOpen(false);
|
|
||||||
setAuthError('');
|
|
||||||
stripOAuthParamsFromLocation();
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
if (shouldScan) {
|
|
||||||
window.setTimeout(() => void runScan(), 0);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
|
||||||
});
|
|
||||||
}, [searchParams, applySession, setSearchParams]);
|
|
||||||
|
|
||||||
async function runScan() {
|
|
||||||
if (!isWechatEnv()) {
|
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setScanning(true);
|
|
||||||
setScanMsg('');
|
|
||||||
try {
|
|
||||||
await weixinSdk.init();
|
|
||||||
const raw = await weixinSdk.scanQrCode();
|
|
||||||
if (!raw) {
|
|
||||||
void loadDashboard();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const token = parseRedeemTokenFromScan(raw);
|
|
||||||
if (!token) {
|
|
||||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
|
||||||
} catch (e) {
|
|
||||||
setScanMsg(formatScanError(e));
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleScan() {
|
|
||||||
setScanMsg('');
|
|
||||||
if (!isWechatEnv()) {
|
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const profile = await fetchShopAccount();
|
|
||||||
if (await checkNeedsWechatAuth(profile)) {
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runScan();
|
|
||||||
} catch (e) {
|
|
||||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startWechatAuth() {
|
|
||||||
setAuthLoading(true);
|
|
||||||
setAuthError('');
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
|
||||||
await authorizeShopWechat();
|
|
||||||
} catch (e) {
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
|
||||||
setAuthLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = dash?.store as Record<string, unknown> | undefined;
|
const store = dash?.store as Record<string, unknown> | undefined;
|
||||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||||
const openTime = String(store?.openTime || '10:00');
|
const openTime = String(store?.openTime || '10:00');
|
||||||
@@ -189,20 +74,13 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-scan">
|
<section className="shop-home-scan">
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/redeem/phone"
|
||||||
className="shop-home-scan-btn"
|
className="shop-home-scan-btn"
|
||||||
disabled={scanning}
|
|
||||||
onClick={() => void handleScan()}
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
|
||||||
</button>
|
|
||||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
|
||||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
|
||||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
|
||||||
<span className="material-symbols-outlined">smartphone</span>
|
<span className="material-symbols-outlined">smartphone</span>
|
||||||
手机号核销
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<p className="shop-home-scan-label">手机号核销</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-status">
|
<section className="shop-home-status">
|
||||||
@@ -249,17 +127,6 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WechatScanAuthModal
|
|
||||||
open={authModalOpen}
|
|
||||||
loading={authLoading}
|
|
||||||
error={authError}
|
|
||||||
onAuthorize={() => void startWechatAuth()}
|
|
||||||
onCancel={() => {
|
|
||||||
setAuthModalOpen(false);
|
|
||||||
setAuthError('');
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,14 @@
|
|||||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
import { useRef, useState, type RefObject } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
|
||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
import { getLastPhone, getStoreProfile, hasShopWxSession, request, saveAuth, type ShopSessionPayload } from '../lib/api';
|
|
||||||
import { routeAfterShopLogin } from './SelectStorePage';
|
|
||||||
import {
|
import {
|
||||||
bindShopWechatAfterSmsLogin,
|
getLastPhone,
|
||||||
fetchClientConfig,
|
request,
|
||||||
handleShopWechatCallback,
|
saveRememberedSession,
|
||||||
handleShopWechatLoginResult,
|
type ShopSessionPayload,
|
||||||
loginShopWithWechat,
|
} from '../lib/api';
|
||||||
} from '../lib/wechat-auth';
|
import { routeAfterShopLogin } from './SelectStorePage';
|
||||||
import { isWechatEnv } from '../lib/weixin';
|
|
||||||
|
|
||||||
function maskPhone(phone: string) {
|
|
||||||
if (phone.length < 7) return phone;
|
|
||||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatWechatError(e: unknown): string {
|
|
||||||
const text = e instanceof Error ? e.message : '微信登录失败';
|
|
||||||
if (text.includes('首次登录') || text.includes('手机验证码')) {
|
|
||||||
return '该微信尚未绑定门店账号,请先使用手机验证码登录,登录后将自动关联微信';
|
|
||||||
}
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ShopAgreementCheckbox({
|
function ShopAgreementCheckbox({
|
||||||
agreed,
|
agreed,
|
||||||
@@ -35,7 +17,7 @@ function ShopAgreementCheckbox({
|
|||||||
}: {
|
}: {
|
||||||
agreed: boolean;
|
agreed: boolean;
|
||||||
onChange: (next: boolean) => void;
|
onChange: (next: boolean) => void;
|
||||||
labelRef?: RefObject<HTMLLabelElement | null>;
|
labelRef?: RefObject<HTMLLabelElement>;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<label className="shop-login-agreement" ref={labelRef}>
|
<label className="shop-login-agreement" ref={labelRef}>
|
||||||
@@ -61,44 +43,14 @@ function ShopAgreementCheckbox({
|
|||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
const { applySession } = useStoreSession();
|
||||||
const [params, setSearchParams] = useSearchParams();
|
|
||||||
const quick = params.get('quick') === '1';
|
|
||||||
const savedProfile = getStoreProfile();
|
|
||||||
const [phone, setPhone] = useState(getLastPhone());
|
const [phone, setPhone] = useState(getLastPhone());
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [wxLoading, setWxLoading] = useState(false);
|
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(false);
|
|
||||||
const agreementRef = useRef<HTMLLabelElement>(null);
|
const agreementRef = useRef<HTMLLabelElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchClientConfig()
|
|
||||||
.then((config) => setWxAuthorize(isWxAuthorizeEnabled(config)))
|
|
||||||
.catch(() => setWxAuthorize(false));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWechatEnv() || !wxAuthorize || !params.get('code')) return;
|
|
||||||
void handleShopWechatCallback()
|
|
||||||
.then((result) => {
|
|
||||||
if (!result) return;
|
|
||||||
const session = handleShopWechatLoginResult(result);
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
stripOAuthParamsFromLocation();
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
routeAfterShopLogin(session, navigate);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => setMsg(formatWechatError(e)));
|
|
||||||
}, [applySession, navigate, params, setSearchParams, wxAuthorize]);
|
|
||||||
|
|
||||||
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
|
|
||||||
const quickPhone = savedProfile?.phone || phone;
|
|
||||||
|
|
||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先阅读并同意用户协议');
|
setMsg('请先阅读并同意用户协议');
|
||||||
@@ -141,13 +93,8 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone, code }),
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
saveRememberedSession(data);
|
||||||
applySession(data);
|
applySession(data);
|
||||||
if (isWechatEnv() && wxAuthorize) {
|
|
||||||
setMsg('登录成功,正在关联微信…');
|
|
||||||
await bindShopWechatAfterSmsLogin();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
routeAfterShopLogin(data, navigate);
|
routeAfterShopLogin(data, navigate);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
@@ -156,103 +103,6 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function wechatLogin() {
|
|
||||||
if (!ensureAgreed()) return;
|
|
||||||
setMsg('');
|
|
||||||
if (!isWechatEnv()) {
|
|
||||||
setMsg('请在微信内打开以使用微信一键登录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setWxLoading(true);
|
|
||||||
try {
|
|
||||||
const session = await loginShopWithWechat();
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
routeAfterShopLogin(session, navigate);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(formatWechatError(e));
|
|
||||||
} finally {
|
|
||||||
setWxLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (quick) {
|
|
||||||
const canWechatQuick = wxAuthorize && isWechatEnv() && hasShopWxSession() && !!savedProfile;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="shop-quick-login-page">
|
|
||||||
<header className="shop-quick-header">
|
|
||||||
<AppImage src="/logo.png" alt="杜康好客" wrapperClassName="shop-quick-logo" fit="contain" />
|
|
||||||
<h1 className="shop-quick-welcome">欢迎回来</h1>
|
|
||||||
<div className="shop-quick-welcome-line" />
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="shop-quick-store-card">
|
|
||||||
<div className="shop-quick-store-inner">
|
|
||||||
<div className="shop-quick-store-icon">
|
|
||||||
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
|
|
||||||
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
|
|
||||||
</div>
|
|
||||||
<span className="shop-quick-verified">
|
|
||||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
|
|
||||||
认证门店
|
|
||||||
</span>
|
|
||||||
<div className="shop-quick-switch">
|
|
||||||
<Link to="/login">
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>sync</span>
|
|
||||||
切换账号
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div className="shop-quick-actions">
|
|
||||||
{msg && <p className="shop-login-msg">{msg}</p>}
|
|
||||||
<ShopAgreementCheckbox
|
|
||||||
agreed={agreed}
|
|
||||||
onChange={setAgreed}
|
|
||||||
labelRef={agreementRef}
|
|
||||||
/>
|
|
||||||
{canWechatQuick ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-quick-login-btn shop-quick-login-btn--wechat"
|
|
||||||
disabled={wxLoading}
|
|
||||||
onClick={() => void wechatLogin()}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined">chat</span>
|
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<p className="shop-login-msg" style={{ textAlign: 'center' }}>
|
|
||||||
{wxAuthorize && !isWechatEnv()
|
|
||||||
? '请在微信内打开以使用一键登录'
|
|
||||||
: '请使用验证码登录并绑定微信后,即可 7 天内免登录'}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{!canWechatQuick && (
|
|
||||||
<Link to="/login" className="shop-quick-login-btn" style={{ textAlign: 'center', textDecoration: 'none' }}>
|
|
||||||
验证码登录
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<div className="shop-quick-secure">
|
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 16 }}>lock</span>
|
|
||||||
<span>{canWechatQuick ? '微信验证 · 7 天内免登录' : '加密环境安全登录中'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<footer className="shop-quick-footer">
|
|
||||||
<p className="shop-login-footer-brand">SECURED BY DUKANG HERITAGE</p>
|
|
||||||
<p style={{ fontSize: 10, fontFamily: 'var(--font-label)' }}>© 2024 杜康酒业门店管理系统</p>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-login-page">
|
<div className="shop-login-page">
|
||||||
<header className="shop-login-hero">
|
<header className="shop-login-hero">
|
||||||
@@ -322,26 +172,9 @@ export default function LoginPage() {
|
|||||||
<span>{loading ? '登录中...' : '登录'}</span>
|
<span>{loading ? '登录中...' : '登录'}</span>
|
||||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||||
</button>
|
</button>
|
||||||
|
<p className="shop-login-msg" style={{ textAlign: 'center', marginTop: 12 }}>
|
||||||
{wxAuthorize && (
|
手机号验证成功后,7 天内无需再次输入验证码
|
||||||
<>
|
</p>
|
||||||
<div className="shop-login-divider">
|
|
||||||
<span className="shop-login-divider-line" />
|
|
||||||
<span className="shop-login-divider-text">或者</span>
|
|
||||||
<span className="shop-login-divider-line" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-login-wechat"
|
|
||||||
disabled={wxLoading}
|
|
||||||
onClick={() => void wechatLogin()}
|
|
||||||
>
|
|
||||||
<span className="material-symbols-outlined">chat</span>
|
|
||||||
<span>{wxLoading ? '登录中...' : '微信一键登录'}</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -350,11 +183,6 @@ export default function LoginPage() {
|
|||||||
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
|
<span className="material-symbols-outlined shop-fill-icon" style={{ opacity: 0.3, fontSize: 20, color: 'var(--color-outline)' }}>
|
||||||
security
|
security
|
||||||
</span>
|
</span>
|
||||||
{hasShopWxSession() && savedProfile && (
|
|
||||||
<p style={{ marginTop: 16, textAlign: 'center' }}>
|
|
||||||
<Link to="/login?quick=1" className="text-primary body-md">微信快捷登录</Link>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
type BalanceResult = {
|
|
||||||
sessionId: string;
|
|
||||||
totalBalance: number;
|
|
||||||
maskedPhone: string;
|
|
||||||
user?: { nickname?: string; phone?: string; userNo?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = 'lookup' | 'amount' | 'confirm';
|
|
||||||
|
|
||||||
export default function PhoneRedeemPage() {
|
export default function PhoneRedeemPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [step, setStep] = useState<Step>('lookup');
|
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [lookupCode, setLookupCode] = useState('');
|
|
||||||
const [confirmCode, setConfirmCode] = useState('');
|
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [balance, setBalance] = useState<BalanceResult | null>(null);
|
const [confirmCode, setConfirmCode] = useState('');
|
||||||
|
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [storeClosed, setStoreClosed] = useState(false);
|
const [storeClosed, setStoreClosed] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [lookupCooldown, setLookupCooldown] = useState(0);
|
|
||||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
|
|||||||
.catch(() => setStoreName('当前门店'));
|
.catch(() => setStoreName('当前门店'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (lookupCooldown <= 0) return;
|
|
||||||
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [lookupCooldown]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (confirmCooldown <= 0) return;
|
if (confirmCooldown <= 0) return;
|
||||||
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [confirmCooldown]);
|
}, [confirmCooldown]);
|
||||||
|
|
||||||
async function sendLookupSms() {
|
async function sendConfirmSms() {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||||
setMsg('请输入正确的手机号');
|
setMsg('请输入正确的手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
|
||||||
setMsg('');
|
|
||||||
try {
|
|
||||||
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: phone.trim() }),
|
|
||||||
});
|
|
||||||
setLookupCooldown(60);
|
|
||||||
setMsg('验证码已发送至用户手机');
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queryBalance() {
|
|
||||||
if (!lookupCode.trim()) {
|
|
||||||
setMsg('请输入验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setMsg('');
|
|
||||||
try {
|
|
||||||
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
|
|
||||||
});
|
|
||||||
setBalance(res);
|
|
||||||
setStep('amount');
|
|
||||||
setMsg('');
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(e instanceof Error ? e.message : '查询失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareRedeem() {
|
|
||||||
if (storeClosed) {
|
if (storeClosed) {
|
||||||
setMsg('门店未营业,无法核销');
|
setMsg('门店未营业,无法核销');
|
||||||
return;
|
return;
|
||||||
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
|
|||||||
setMsg('请输入有效核销金额');
|
setMsg('请输入有效核销金额');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (balance && value > balance.totalBalance) {
|
|
||||||
setMsg('核销金额不能超过可用权益');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
|
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
|
body: JSON.stringify({ phone: phone.trim(), amount: value }),
|
||||||
});
|
});
|
||||||
|
setPrepared(result);
|
||||||
|
setConfirmCode('');
|
||||||
setConfirmCooldown(60);
|
setConfirmCooldown(60);
|
||||||
setStep('confirm');
|
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||||
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '发起核销失败');
|
setPrepared(null);
|
||||||
|
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRedeem() {
|
async function confirmRedeem() {
|
||||||
|
if (!prepared) {
|
||||||
|
setMsg('请先发送核销验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!confirmCode.trim()) {
|
if (!confirmCode.trim()) {
|
||||||
setMsg('请输入确认验证码');
|
setMsg('请输入确认验证码');
|
||||||
return;
|
return;
|
||||||
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
|
|||||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sessionId: balance?.sessionId,
|
sessionId: prepared.sessionId,
|
||||||
code: confirmCode.trim(),
|
code: confirmCode.trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||||
navigate('/redeem/success', {
|
navigate('/redeem/success', {
|
||||||
state: { result, storeName, user: balance?.user },
|
state: { result, storeName, user: prepared.user },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||||
@@ -151,7 +97,9 @@ export default function PhoneRedeemPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
|
const amountValue = Number(amount);
|
||||||
|
const canSendCode =
|
||||||
|
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-redeem-page">
|
<div className="shop-redeem-page">
|
||||||
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-redeem-body">
|
<div className="shop-redeem-body">
|
||||||
{step === 'lookup' && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">用户手机号</label>
|
||||||
<div className="shop-phone-field">
|
<input
|
||||||
<label className="shop-phone-label">用户手机号</label>
|
className="shop-phone-input"
|
||||||
<input
|
type="tel"
|
||||||
className="shop-phone-input"
|
maxLength={11}
|
||||||
type="tel"
|
placeholder="请输入用户手机号"
|
||||||
maxLength={11}
|
value={phone}
|
||||||
placeholder="请输入用户手机号"
|
disabled={loading}
|
||||||
value={phone}
|
onChange={(e) => {
|
||||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
setPhone(e.target.value.replace(/\D/g, ''));
|
||||||
/>
|
setPrepared(null);
|
||||||
</div>
|
setConfirmCode('');
|
||||||
<div className="shop-phone-field">
|
setConfirmCooldown(0);
|
||||||
<label className="shop-phone-label">验证码</label>
|
}}
|
||||||
<div className="shop-phone-code-row">
|
/>
|
||||||
<input
|
</div>
|
||||||
className="shop-phone-input"
|
|
||||||
type="text"
|
|
||||||
maxLength={6}
|
|
||||||
placeholder="用户收到的验证码"
|
|
||||||
value={lookupCode}
|
|
||||||
onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-phone-code-btn"
|
|
||||||
disabled={loading || lookupCooldown > 0 || !phone.trim()}
|
|
||||||
onClick={() => void sendLookupSms()}
|
|
||||||
>
|
|
||||||
{lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-redeem-confirm-btn"
|
|
||||||
disabled={loading || storeClosed}
|
|
||||||
onClick={() => void queryBalance()}
|
|
||||||
>
|
|
||||||
查询权益
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'amount' && balance && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">待核销金额</label>
|
||||||
<div className="shop-redeem-user">
|
<input
|
||||||
<div className="shop-redeem-user-left">
|
className="shop-phone-input"
|
||||||
<span className="material-symbols-outlined">person</span>
|
type="number"
|
||||||
<span>用户</span>
|
min={0.01}
|
||||||
</div>
|
step={0.01}
|
||||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
placeholder="请输入待核销金额"
|
||||||
{userLabel}
|
value={amount}
|
||||||
</span>
|
disabled={loading}
|
||||||
</div>
|
onChange={(e) => {
|
||||||
<div className="shop-redeem-amount-section">
|
setAmount(e.target.value);
|
||||||
<p className="shop-redeem-amount-label">可用好客权益</p>
|
setPrepared(null);
|
||||||
<div className="shop-redeem-amount">
|
setConfirmCode('');
|
||||||
<span className="shop-redeem-amount-symbol">¥</span>
|
setConfirmCooldown(0);
|
||||||
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
|
}}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="shop-phone-field">
|
|
||||||
<label className="shop-phone-label">核销金额</label>
|
|
||||||
<input
|
|
||||||
className="shop-phone-input"
|
|
||||||
type="number"
|
|
||||||
min={0.01}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="请输入核销金额"
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-redeem-confirm-btn"
|
|
||||||
disabled={loading || storeClosed || balance.totalBalance <= 0}
|
|
||||||
onClick={() => void prepareRedeem()}
|
|
||||||
>
|
|
||||||
发送确认验证码并核销
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-phone-link-btn"
|
|
||||||
onClick={() => {
|
|
||||||
setStep('lookup');
|
|
||||||
setBalance(null);
|
|
||||||
setAmount('');
|
|
||||||
setLookupCode('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
更换手机号
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'confirm' && balance && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">核销验证码</label>
|
||||||
<div className="shop-redeem-details">
|
<div className="shop-phone-code-row">
|
||||||
<div className="shop-redeem-detail-row">
|
<input
|
||||||
<span>用户</span>
|
className="shop-phone-input"
|
||||||
<span>{userLabel}</span>
|
type="text"
|
||||||
</div>
|
inputMode="numeric"
|
||||||
<div className="shop-redeem-detail-row">
|
maxLength={6}
|
||||||
<span>核销金额</span>
|
placeholder="输入用户收到的验证码"
|
||||||
<span>¥{formatAmount(Number(amount))}</span>
|
value={confirmCode}
|
||||||
</div>
|
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||||
</div>
|
/>
|
||||||
<div className="shop-phone-field">
|
|
||||||
<label className="shop-phone-label">核销确认验证码</label>
|
|
||||||
<input
|
|
||||||
className="shop-phone-input"
|
|
||||||
type="text"
|
|
||||||
maxLength={6}
|
|
||||||
placeholder="用户手机收到的确认码"
|
|
||||||
value={confirmCode}
|
|
||||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
|
||||||
/>
|
|
||||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
|
||||||
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-redeem-confirm-btn"
|
className="shop-phone-code-btn"
|
||||||
disabled={loading || storeClosed}
|
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
|
||||||
onClick={() => void confirmRedeem()}
|
onClick={() => void sendConfirmSms()}
|
||||||
>
|
>
|
||||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
|
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||||
className="shop-phone-link-btn"
|
验证码将发送到用户手机号,验证成功后直接完成核销。
|
||||||
onClick={() => {
|
</p>
|
||||||
setStep('amount');
|
</div>
|
||||||
setConfirmCode('');
|
|
||||||
}}
|
<button
|
||||||
>
|
type="button"
|
||||||
返回修改金额
|
className="shop-redeem-confirm-btn"
|
||||||
</button>
|
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
|
||||||
</>
|
onClick={() => void confirmRedeem()}
|
||||||
)}
|
>
|
||||||
|
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||||
|
</button>
|
||||||
|
|
||||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/shop/',
|
// 独立域名 shop.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
|
||||||
|
base: process.env.VITE_PUBLIC_BASE ?? '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -52,8 +52,7 @@ scroll-view::-webkit-scrollbar,
|
|||||||
.taro-scroll::-webkit-scrollbar,
|
.taro-scroll::-webkit-scrollbar,
|
||||||
.taro-scroll-view::-webkit-scrollbar,
|
.taro-scroll-view::-webkit-scrollbar,
|
||||||
.taro-scroll-view__scroll-x::-webkit-scrollbar,
|
.taro-scroll-view__scroll-x::-webkit-scrollbar,
|
||||||
.taro-scroll-view__scroll-y::-webkit-scrollbar,
|
.taro-scroll-view__scroll-y::-webkit-scrollbar {
|
||||||
*::-webkit-scrollbar {
|
|
||||||
display: none;
|
display: none;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
pnpm --filter @dukang/mini-user dev
|
pnpm --filter @dukang/mini-user dev
|
||||||
# → http://localhost:5177
|
# → http://localhost:5177
|
||||||
|
|
||||||
# 微信小程序
|
# 微信小程序(本地联调,development)
|
||||||
pnpm --filter @dukang/mini-user dev:weapp
|
pnpm --filter @dukang/mini-user dev:weapp
|
||||||
|
|
||||||
|
# 微信小程序(生产构建 → API: https://api.dukanghaoke.com)
|
||||||
|
pnpm build:mini-user:weapp
|
||||||
# 用微信开发者工具打开 apps/mini-user(miniprogramRoot = dist/)
|
# 用微信开发者工具打开 apps/mini-user(miniprogramRoot = dist/)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -18,7 +21,7 @@ pnpm --filter @dukang/mini-user dev:weapp
|
|||||||
|
|
||||||
| 环节 | 文件 | 说明 |
|
| 环节 | 文件 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | 本地默认 `http://localhost:3000`;`NODE_ENV=production` 默认 `https://dkapi.runxian.top`;可用 `VITE_API_TARGET` 覆盖 |
|
| **编译注入** | `config/index.ts` → `defineConstants.TARO_APP_API_ORIGIN` | `dev:weapp` / watch → `localhost:3000`;`build:weapp`(`--mode production`)→ `https://api.dukanghaoke.com`;可用 `VITE_API_TARGET` 覆盖 |
|
||||||
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
| **运行时拼装** | `src/lib/api.ts` → `resolveApiBase()` | `{origin}/api/v1` |
|
||||||
|
|
||||||
### 微信登录 `invalid code`
|
### 微信登录 `invalid code`
|
||||||
@@ -45,10 +48,10 @@ pnpm --filter @dukang/mini-user dev:weapp
|
|||||||
**连远程 API**:
|
**连远程 API**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$env:VITE_API_TARGET="https://dkapi.runxian.top"; pnpm --filter @dukang/mini-user dev
|
$env:VITE_API_TARGET="https://api.dukanghaoke.com"; pnpm --filter @dukang/mini-user dev
|
||||||
```
|
```
|
||||||
|
|
||||||
并在 `dkapi.runxian.top` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
|
并在 `api.dukanghaoke.com` 所在服务器配置 `WX_MINI_APP_ID=wxda31c8e8e85051e7` 及对应 AppSecret。
|
||||||
|
|
||||||
## 页面结构(18 页)
|
## 页面结构(18 页)
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,16 @@ import { createRequire } from 'node:module';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { defineConfig } from '@tarojs/cli';
|
import { defineConfig } from '@tarojs/cli';
|
||||||
|
|
||||||
|
/** watch / --mode development 视为本地联调;其余(含 build:weapp)走生产 */
|
||||||
|
const isDevMode =
|
||||||
|
process.env.NODE_ENV === 'development' ||
|
||||||
|
process.argv.includes('--watch') ||
|
||||||
|
process.argv.includes('development');
|
||||||
|
|
||||||
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
/** 小程序/H5 请求的后端 origin(不含 /api);本地默认本机,生产构建默认远程 */
|
||||||
const API_ORIGIN =
|
const API_ORIGIN =
|
||||||
process.env.VITE_API_TARGET ??
|
process.env.VITE_API_TARGET ??
|
||||||
(process.env.NODE_ENV === 'production' ? 'https://dkapi.runxian.top' : 'http://localhost:3000');
|
(isDevMode ? 'http://localhost:3000' : 'https://api.dukanghaoke.com');
|
||||||
|
|
||||||
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
const requireFromApp = createRequire(path.resolve(__dirname, '../package.json'));
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ export default defineConfig(async () => ({
|
|||||||
},
|
},
|
||||||
mini: {
|
mini: {
|
||||||
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
/** dev:weapp 预览模式需开启,否则 React hooks 在 Vite 下会失效 */
|
||||||
debugReact: process.env.NODE_ENV !== 'production',
|
debugReact: isDevMode,
|
||||||
postcss: {
|
postcss: {
|
||||||
pxtransform: { enable: true, config: {} },
|
pxtransform: { enable: true, config: {} },
|
||||||
cssModules: { enable: false },
|
cssModules: { enable: false },
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
"dev": "node ../../scripts/dev-mini-user-h5.mjs",
|
"dev": "node ../../scripts/dev-mini-user-h5.mjs",
|
||||||
"dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
|
"dev:vite": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --watch",
|
||||||
"dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
|
"dev:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --watch --mode development",
|
||||||
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5",
|
"build": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type h5 --mode production",
|
||||||
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp",
|
"build:weapp": "node --max-old-space-size=8192 ./node_modules/@tarojs/cli/bin/taro build --type weapp --mode production",
|
||||||
"lint": "echo ok"
|
"lint": "echo ok"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"postcss": false,
|
"postcss": false,
|
||||||
"minified": false
|
"minified": false
|
||||||
},
|
},
|
||||||
"compileType": "miniprogram"
|
"compileType": "miniprogram",
|
||||||
|
"preloadBackgroundData": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,19 +39,13 @@ body::-webkit-scrollbar,
|
|||||||
#app::-webkit-scrollbar,
|
#app::-webkit-scrollbar,
|
||||||
.taro_page::-webkit-scrollbar,
|
.taro_page::-webkit-scrollbar,
|
||||||
.taro_router::-webkit-scrollbar,
|
.taro_router::-webkit-scrollbar,
|
||||||
.taro-tabbar__panel::-webkit-scrollbar,
|
.taro-tabbar__panel::-webkit-scrollbar {
|
||||||
*::-webkit-scrollbar {
|
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
height: 0 !important;
|
height: 0 !important;
|
||||||
display: none !important;
|
display: none !important;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
|
/* H5:页面内已渲染 UserTabBar,隐藏 Taro 自带底栏,避免双层 Tab */
|
||||||
.taro-tabbar__tabbar,
|
.taro-tabbar__tabbar,
|
||||||
.taro-tabbar__border {
|
.taro-tabbar__border {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,25 +7,24 @@ type TabMainHeaderProps = {
|
|||||||
extra?: ReactNode;
|
extra?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isH5 = process.env.TARO_ENV === 'h5';
|
/**
|
||||||
|
* Tab 页顶栏:仅在有右侧扩展内容时渲染。
|
||||||
/** Tab 页顶栏:适配刘海屏 + 微信胶囊避让;H5 不展示标题(与微信系统标题重复) */
|
* 小程序 / H5 标题走系统导航栏,避免自定义顶栏造成顶部留白。
|
||||||
|
*/
|
||||||
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
|
export default function TabMainHeader({ title, extra }: TabMainHeaderProps) {
|
||||||
const metrics = useNavBarMetrics();
|
const metrics = useNavBarMetrics();
|
||||||
|
|
||||||
// H5:系统标题已展示;无右侧内容时整栏不渲染,避免顶部留白
|
if (!extra) {
|
||||||
if (isH5 && !extra) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
|
<View className="tab-main-header" style={navBarStyle(metrics)} aria-label={title}>
|
||||||
{!isH5 ? <Text className="tab-main-header__title">{title}</Text> : null}
|
{process.env.TARO_ENV !== 'h5' ? (
|
||||||
<View
|
<Text className="tab-main-header__title">{title}</Text>
|
||||||
className="tab-main-header__content"
|
) : null}
|
||||||
style={tabNavContentStyle(metrics)}
|
<View className="tab-main-header__content" style={tabNavContentStyle(metrics)}>
|
||||||
>
|
<View className="tab-main-header__extra">{extra}</View>
|
||||||
{extra ? <View className="tab-main-header__extra">{extra}</View> : null}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { ClientApp } from '@dukang/shared-types';
|
import { ClientApp } from '@dukang/shared-types';
|
||||||
import { goLogin, forceReloadAfterAccountMerge } from './auth-nav';
|
import { forceReloadAfterAccountMerge } from './auth-nav';
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const origin =
|
const origin =
|
||||||
@@ -54,10 +54,6 @@ export function isLoggedIn(): boolean {
|
|||||||
return !!getToken();
|
return !!getToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redirectToLogin() {
|
|
||||||
goLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
Taro.reLaunch({ url: '/pages/home/index' });
|
Taro.reLaunch({ url: '/pages/home/index' });
|
||||||
@@ -76,17 +72,6 @@ function parseBody(data: unknown): { code?: number; message?: string } {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOnLoginPage(): boolean {
|
|
||||||
try {
|
|
||||||
const pages = Taro.getCurrentPages();
|
|
||||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
|
||||||
const route = cur?.route || '';
|
|
||||||
return route.includes('pages/login');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
|
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
|
||||||
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
|
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
|
||||||
const header: Record<string, string> = {
|
const header: Record<string, string> = {
|
||||||
@@ -115,8 +100,6 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
|
|||||||
if (/账号已合并/.test(mergedMsg)) {
|
if (/账号已合并/.test(mergedMsg)) {
|
||||||
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
|
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
|
||||||
forceReloadAfterAccountMerge();
|
forceReloadAfterAccountMerge();
|
||||||
} else if (!isOnLoginPage()) {
|
|
||||||
redirectToLogin();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(body?.message || '登录已过期,请重新登录');
|
throw new Error(body?.message || '登录已过期,请重新登录');
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ const TAB_PAGES = new Set([
|
|||||||
'/pages/mine/index',
|
'/pages/mine/index',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let loginNavigationPending = false;
|
||||||
|
|
||||||
|
function isLoginPageActive(): boolean {
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
const current = pages[pages.length - 1] as { route?: string } | undefined;
|
||||||
|
return !!current?.route?.includes('pages/login/');
|
||||||
|
}
|
||||||
|
|
||||||
function currentPagePath(): string {
|
function currentPagePath(): string {
|
||||||
const pages = Taro.getCurrentPages();
|
const pages = Taro.getCurrentPages();
|
||||||
const cur = pages[pages.length - 1] as
|
const cur = pages[pages.length - 1] as
|
||||||
@@ -25,6 +33,7 @@ function currentPagePath(): string {
|
|||||||
|
|
||||||
/** 跳转登录页;默认带回当前页作为 return */
|
/** 跳转登录页;默认带回当前页作为 return */
|
||||||
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
||||||
|
if (loginNavigationPending || isLoginPageActive()) return;
|
||||||
const returnTo = returnPath ?? currentPagePath();
|
const returnTo = returnPath ?? currentPagePath();
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
|
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
|
||||||
@@ -34,9 +43,15 @@ export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
|
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
|
||||||
Taro.navigateTo({ url }).catch(() => {
|
loginNavigationPending = true;
|
||||||
Taro.redirectTo({ url });
|
void Taro.navigateTo({ url })
|
||||||
});
|
.catch(() => Taro.redirectTo({ url }))
|
||||||
|
.finally(() => {
|
||||||
|
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
|
||||||
|
setTimeout(() => {
|
||||||
|
loginNavigationPending = false;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录成功后回到 return 页,或回退 / 首页 */
|
/** 登录成功后回到 return 页,或回退 / 首页 */
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { request } from './api';
|
|
||||||
|
|
||||||
import type { UserProfile } from './api';
|
import type { UserProfile } from './api';
|
||||||
|
|
||||||
export type MiniWechatProfile = {
|
export type MiniWechatProfile = {
|
||||||
@@ -8,6 +6,17 @@ export type MiniWechatProfile = {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type MiniWechatProfileUpdate = MiniWechatProfile & {
|
||||||
|
avatarResourceId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UploadedAvatarResource = {
|
||||||
|
resourceId: string;
|
||||||
|
url: string;
|
||||||
|
bucket: string;
|
||||||
|
ossKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
|
const WX_PROFILE_CACHE_KEY = 'wx_mini_profile_cache';
|
||||||
|
|
||||||
export function cacheWxProfile(info: MiniWechatProfile) {
|
export function cacheWxProfile(info: MiniWechatProfile) {
|
||||||
@@ -32,77 +41,117 @@ export function getCachedWxProfile(): MiniWechatProfile | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isDefaultMiniNickname(nickname?: string | null): boolean {
|
export function isDefaultMiniNickname(nickname?: string | null): boolean {
|
||||||
if (!nickname || nickname === '访客') return true;
|
if (!nickname || nickname === '访客' || nickname === '微信用户' || nickname === '用户') return true;
|
||||||
return /^用户\d{4}$/.test(nickname);
|
return /^用户\d{4}$/.test(nickname);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 是否缺少可展示的微信头像/昵称(需走 chooseAvatar + nickname 填写) */
|
||||||
|
export function needsWxProfileFill(profile: UserProfile | null | undefined): boolean {
|
||||||
|
if (!profile) return true;
|
||||||
|
return !profile.avatarUrl || isDefaultMiniNickname(profile.nickname);
|
||||||
|
}
|
||||||
|
|
||||||
export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
|
export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
|
||||||
if (!profile.hasWechat) return profile;
|
|
||||||
const cached = getCachedWxProfile();
|
const cached = getCachedWxProfile();
|
||||||
if (!cached) return profile;
|
if (!cached && !profile.hasWechat) return profile;
|
||||||
|
const nickname =
|
||||||
|
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
|
||||||
|
cached?.nickname ||
|
||||||
|
profile.nickname ||
|
||||||
|
'微信用户';
|
||||||
return {
|
return {
|
||||||
...profile,
|
...profile,
|
||||||
nickname:
|
nickname,
|
||||||
cached.nickname ||
|
avatarUrl: profile.avatarUrl || cached?.avatarUrl || null,
|
||||||
(!isDefaultMiniNickname(profile.nickname) ? profile.nickname : undefined) ||
|
|
||||||
profile.nickname ||
|
|
||||||
'微信用户',
|
|
||||||
avatarUrl: profile.avatarUrl || cached.avatarUrl || null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用户点击触发:拉取微信昵称/头像 */
|
/** 上传 chooseAvatar 临时文件到 OSS,并返回已登记到当前用户的真实资源。 */
|
||||||
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
|
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
|
||||||
if (process.env.TARO_ENV !== 'weapp') {
|
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
|
||||||
throw new Error('请在微信小程序中授权');
|
const token = getToken();
|
||||||
}
|
if (!token) throw new Error('请先登录');
|
||||||
const res = await Taro.getUserProfile({ desc: '用于完善会员资料' });
|
|
||||||
const info: MiniWechatProfile = {
|
|
||||||
nickname: res.userInfo?.nickName?.trim(),
|
|
||||||
avatarUrl: res.userInfo?.avatarUrl?.trim(),
|
|
||||||
};
|
|
||||||
if (!info.nickname && !info.avatarUrl) {
|
|
||||||
throw new Error('未获取到微信头像或昵称');
|
|
||||||
}
|
|
||||||
cacheWxProfile(info);
|
|
||||||
return info;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise<MiniWechatProfile | null> {
|
const res = await Taro.uploadFile({
|
||||||
if (!info.nickname && !info.avatarUrl) return null;
|
url: `${API_BASE}/common/resources/upload`,
|
||||||
|
filePath: tempFilePath,
|
||||||
|
name: 'file',
|
||||||
|
formData: {
|
||||||
|
bizType: 'AVATAR',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'X-Client-App': CLIENT_APP,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let body: {
|
||||||
|
code?: number;
|
||||||
|
message?: string;
|
||||||
|
data?: { resourceId?: string; url?: string; bucket?: string; ossKey?: string };
|
||||||
|
} = {};
|
||||||
try {
|
try {
|
||||||
const updated = await request<{
|
body = JSON.parse(String(res.data || '{}')) as typeof body;
|
||||||
nickname?: string | null;
|
|
||||||
avatarUrl?: string | null;
|
|
||||||
}>('/auth/wechat/mini-profile', {
|
|
||||||
method: 'POST',
|
|
||||||
data: info,
|
|
||||||
});
|
|
||||||
if (updated?.nickname || updated?.avatarUrl) {
|
|
||||||
cacheWxProfile({
|
|
||||||
nickname: updated.nickname ?? info.nickname,
|
|
||||||
avatarUrl: updated.avatarUrl ?? info.avatarUrl,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return info;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
throw new Error('头像上传响应异常');
|
||||||
}
|
}
|
||||||
|
if (res.statusCode === 401 || body.code === 401) {
|
||||||
|
throw new Error(body.message || '登录已过期,请重新登录');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
res.statusCode >= 400 ||
|
||||||
|
body.code !== 0 ||
|
||||||
|
!body.data?.resourceId ||
|
||||||
|
!body.data.url ||
|
||||||
|
!body.data.bucket ||
|
||||||
|
!body.data.ossKey
|
||||||
|
) {
|
||||||
|
throw new Error(body.message || '头像上传失败');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
resourceId: body.data.resourceId,
|
||||||
|
url: body.data.url,
|
||||||
|
bucket: body.data.bucket,
|
||||||
|
ossKey: body.data.ossKey,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 绑定后上报微信资料(优先使用已拉取的信息,避免重复弹窗) */
|
export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
|
||||||
export async function syncMiniWechatProfile(prefetched?: MiniWechatProfile | null): Promise<MiniWechatProfile | null> {
|
if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
|
||||||
if (process.env.TARO_ENV !== 'weapp') return null;
|
const { request } = await import('./api');
|
||||||
|
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
|
||||||
|
method: 'POST',
|
||||||
|
data: info,
|
||||||
|
});
|
||||||
|
cacheWxProfile({
|
||||||
|
nickname: updated?.nickname ?? info.nickname,
|
||||||
|
avatarUrl: updated?.avatarUrl ?? info.avatarUrl,
|
||||||
|
});
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
let info = prefetched ?? null;
|
/**
|
||||||
if (!info) {
|
* 兼容旧调用:getUserProfile 已无法拿到真实头像昵称。
|
||||||
try {
|
* 始终导出为函数,避免循环依赖/旧包出现 “is not a function”。
|
||||||
info = await fetchMiniWechatUserInfo();
|
*/
|
||||||
} catch {
|
export async function fetchMiniWechatUserInfo(): Promise<MiniWechatProfile> {
|
||||||
return getCachedWxProfile();
|
const cached = getCachedWxProfile();
|
||||||
}
|
if (cached?.nickname || cached?.avatarUrl) {
|
||||||
|
return cached;
|
||||||
}
|
}
|
||||||
|
// 不再弹 getUserProfile;引导走「我的」页 chooseAvatar / nickname
|
||||||
|
throw new Error('请在「我的」页点击头像完善微信头像和昵称');
|
||||||
|
}
|
||||||
|
|
||||||
await uploadMiniWechatProfile(info);
|
/** 绑定后上报微信资料(优先使用已拉取的信息) */
|
||||||
|
export async function syncMiniWechatProfile(
|
||||||
|
prefetched?: MiniWechatProfile | null,
|
||||||
|
): Promise<MiniWechatProfile | null> {
|
||||||
|
if (process.env.TARO_ENV !== 'weapp') return null;
|
||||||
|
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
|
||||||
|
if (!info?.nickname && !info?.avatarUrl) return null;
|
||||||
|
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
|
||||||
|
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
|
||||||
return info;
|
return info;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,32 @@ import { isWxAuthorizeEnabled } from '@dukang/shared-types';
|
|||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { saveAuth } from './api';
|
import { saveAuth } from './api';
|
||||||
import { syncMiniWechatProfile } from './mini-wechat-profile';
|
import {
|
||||||
|
fetchMiniWechatUserInfo,
|
||||||
|
mergeWxDisplayProfile,
|
||||||
|
needsWxProfileFill,
|
||||||
|
syncMiniWechatProfile,
|
||||||
|
} from './mini-wechat-profile';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 兼容旧分包对资料 helper 的引用,避免 tree-shake 后出现 is not a function
|
||||||
|
*(开发者工具热更新时常见旧页 + 新 common 混用)
|
||||||
|
*/
|
||||||
|
export { fetchMiniWechatUserInfo, mergeWxDisplayProfile, needsWxProfileFill };
|
||||||
|
|
||||||
|
/** 强制保留导出绑定,防止打包器删掉未引用的 re-export */
|
||||||
|
const _wxProfileCompat = {
|
||||||
|
fetchMiniWechatUserInfo,
|
||||||
|
mergeWxDisplayProfile,
|
||||||
|
needsWxProfileFill,
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof _wxProfileCompat.needsWxProfileFill !== 'function' ||
|
||||||
|
typeof _wxProfileCompat.fetchMiniWechatUserInfo !== 'function' ||
|
||||||
|
typeof _wxProfileCompat.mergeWxDisplayProfile !== 'function'
|
||||||
|
) {
|
||||||
|
throw new Error('mini-wechat-profile helpers missing');
|
||||||
|
}
|
||||||
import {
|
import {
|
||||||
authorizeWechatForPay,
|
authorizeWechatForPay,
|
||||||
fetchClientConfig,
|
fetchClientConfig,
|
||||||
|
|||||||
@@ -106,14 +106,6 @@ export default function BenefitPage() {
|
|||||||
<Text>康</Text>
|
<Text>康</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="benefit-hero-actions">
|
|
||||||
<Text
|
|
||||||
className="benefit-hero-link"
|
|
||||||
onClick={() => Taro.navigateTo({ url: '/pages/benefit-detail/index' })}
|
|
||||||
>
|
|
||||||
查看权益明细 ›
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View
|
<View
|
||||||
className="benefit-hero-cta"
|
className="benefit-hero-cta"
|
||||||
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
onClick={() => Taro.navigateTo({ url: '/pages/redeem/index' })}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationStyle: 'custom',
|
|
||||||
navigationBarTitleText: '杜康好客',
|
navigationBarTitleText: '杜康好客',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { View, Text } from '@tarojs/components';
|
import { View, Text } from '@tarojs/components';
|
||||||
import Taro, { useDidShow } from '@tarojs/taro';
|
import Taro, { useDidShow } from '@tarojs/taro';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
@@ -21,9 +21,9 @@ type Product = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AROMA_TABS = [
|
const AROMA_TABS = [
|
||||||
{ key: 'QINGXIANG', label: '清香型', open: true },
|
{ key: 'QINGXIANG', label: '清香型' },
|
||||||
{ key: 'JIANGXIANG', label: '酱香型', open: false },
|
{ key: 'JIANGXIANG', label: '酱香型' },
|
||||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
{ key: 'NONGXIANG', label: '浓香型' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
@@ -49,35 +49,38 @@ export default function HomePage() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [cityCode]);
|
}, [cityCode]);
|
||||||
|
|
||||||
function onAromaTabClick(key: string, open: boolean) {
|
const availableAromas = useMemo(
|
||||||
if (!open) {
|
() =>
|
||||||
toast('暂未开放');
|
AROMA_TABS.filter((item) =>
|
||||||
return;
|
products.some((product) => product.aromaType === item.key),
|
||||||
|
),
|
||||||
|
[products],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loading || availableAromas.length === 0) return;
|
||||||
|
if (!availableAromas.some((item) => item.key === tab)) {
|
||||||
|
setTab(availableAromas[0].key);
|
||||||
}
|
}
|
||||||
setTab(key);
|
}, [availableAromas, loading, tab]);
|
||||||
}
|
|
||||||
|
|
||||||
function openProductDetail(id: string) {
|
function openProductDetail(id: string) {
|
||||||
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = products.filter((p) => p.aromaType === tab);
|
const filtered = products.filter((p) => p.aromaType === tab);
|
||||||
const onSale = tab === 'QINGXIANG';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell variant="tab" className="home-page no-tab-header">
|
||||||
variant="tab"
|
|
||||||
className={`home-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
|
||||||
>
|
|
||||||
<TabMainHeader title="杜康好客" />
|
<TabMainHeader title="杜康好客" />
|
||||||
|
|
||||||
<View className="home-aroma-nav">
|
<View className="home-aroma-nav">
|
||||||
<View className="home-aroma-tabs">
|
<View className="home-aroma-tabs">
|
||||||
{AROMA_TABS.map((t) => (
|
{availableAromas.map((t) => (
|
||||||
<Text
|
<Text
|
||||||
key={t.key}
|
key={t.key}
|
||||||
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
|
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
|
||||||
onClick={() => onAromaTabClick(t.key, t.open)}
|
onClick={() => setTab(t.key)}
|
||||||
>
|
>
|
||||||
{t.label}
|
{t.label}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -88,12 +91,10 @@ export default function HomePage() {
|
|||||||
|
|
||||||
<View className="home-product-list">
|
<View className="home-product-list">
|
||||||
{loading ? <View className="home-empty">加载中…</View> : null}
|
{loading ? <View className="home-empty">加载中…</View> : null}
|
||||||
{!loading && !onSale ? <View className="home-empty">该香型暂未上线,敬请期待</View> : null}
|
{!loading && products.length === 0 ? (
|
||||||
{!loading && onSale && filtered.length === 0 ? (
|
<View className="home-empty">当前城市暂无在售商品</View>
|
||||||
<View className="home-empty">暂无商品</View>
|
|
||||||
) : null}
|
) : null}
|
||||||
{!loading &&
|
{!loading &&
|
||||||
onSale &&
|
|
||||||
filtered.map((p) => {
|
filtered.map((p) => {
|
||||||
const images = getProductImages(p);
|
const images = getProductImages(p);
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -18,13 +19,14 @@ import {
|
|||||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
import { fetchUserProfile } from '../../lib/pay-wechat';
|
||||||
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
import { saveUserPhone, resolveDefaultUserPhone } from '../../lib/user-phone';
|
||||||
import {
|
import {
|
||||||
fetchMiniWechatUserInfo,
|
|
||||||
getCachedWxProfile,
|
getCachedWxProfile,
|
||||||
syncMiniWechatProfile,
|
syncMiniWechatProfile,
|
||||||
type MiniWechatProfile,
|
type MiniWechatProfile,
|
||||||
} 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);
|
||||||
}
|
}
|
||||||
@@ -33,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 || '';
|
||||||
@@ -45,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('');
|
||||||
@@ -54,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')
|
||||||
@@ -66,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;
|
||||||
@@ -77,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) {
|
||||||
@@ -101,26 +144,39 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先勾选并同意用户协议');
|
setMsg('请先阅读并勾选同意《用户服务协议》和《隐私政策》');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cancelLogin() {
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
if (pages.length > 1) {
|
||||||
|
Taro.navigateBack().catch(() => {
|
||||||
|
Taro.switchTab({ url: '/pages/home/index' });
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Taro.switchTab({ url: '/pages/home/index' }).catch(() => {
|
||||||
|
Taro.reLaunch({ url: '/pages/home/index' });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
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(() => {});
|
||||||
@@ -134,7 +190,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;
|
||||||
@@ -142,11 +197,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() {
|
||||||
@@ -201,7 +294,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() },
|
||||||
@@ -235,15 +327,7 @@ export default function LoginPage() {
|
|||||||
setSentHint('');
|
setSentHint('');
|
||||||
setWxLoading(true);
|
setWxLoading(true);
|
||||||
try {
|
try {
|
||||||
let wxInfo: MiniWechatProfile | null = null;
|
const wxInfo = getCachedWxProfile();
|
||||||
if (process.env.TARO_ENV === 'weapp') {
|
|
||||||
try {
|
|
||||||
wxInfo = await fetchMiniWechatUserInfo();
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completeMode === 'wechat' && isLoggedIn()) {
|
if (completeMode === 'wechat' && isLoggedIn()) {
|
||||||
const result = await bindWechatForUser(wxInfo);
|
const result = await bindWechatForUser(wxInfo);
|
||||||
@@ -254,12 +338,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) {
|
||||||
await syncMiniWechatProfile(wxInfo);
|
if (wxInfo) await syncMiniWechatProfile(wxInfo);
|
||||||
toast('微信授权成功', 'success');
|
toast('授权成功', 'success');
|
||||||
finishLoginNavigate(returnTo);
|
finishLoginNavigate(returnTo);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -268,11 +353,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 {
|
||||||
@@ -282,21 +367,28 @@ 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">
|
||||||
|
<View className="login-nav">
|
||||||
|
<View className="login-nav-back" onClick={cancelLogin}>
|
||||||
|
<Text className="login-nav-back-icon">‹</Text>
|
||||||
|
<Text>返回</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<View className="login-header">
|
<View className="login-header">
|
||||||
<View className="login-logo-wrap">
|
<View className="login-logo-wrap">
|
||||||
<View className="login-logo">
|
<View className="login-logo">
|
||||||
@@ -309,7 +401,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">
|
||||||
@@ -325,77 +417,100 @@ 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
|
|
||||||
className="login-agreement-link"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
Taro.navigateTo({ url: '/pages/privacy-policy/index' });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
《隐私政策》
|
|
||||||
</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
) : null}
|
||||||
{showWechatLogin ? (
|
{showAuthLogin ? (
|
||||||
<WechatLoginButton loading={wxLoading} onClick={() => void wechatLogin()} />
|
<WechatLoginButton loading={wxLoading} label="授权登录" 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)} />
|
||||||
<Text className="login-field-prefix">+86</Text>
|
|
||||||
<Input
|
|
||||||
className="login-field-input"
|
|
||||||
type="number"
|
|
||||||
maxlength={11}
|
|
||||||
placeholder="请输入手机号"
|
|
||||||
value={phone}
|
|
||||||
onInput={(e) => {
|
|
||||||
setPhone(normalizePhone(e.detail.value));
|
|
||||||
setMsg('');
|
|
||||||
setSentHint('');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className="login-field">
|
{showPhoneQuick ? (
|
||||||
<Input
|
<PhoneQuickLoginButton
|
||||||
className="login-field-input"
|
loading={phoneQuickLoading}
|
||||||
type="number"
|
agreed={agreed}
|
||||||
maxlength={6}
|
onRequireAgree={() => ensureAgreed()}
|
||||||
placeholder="请输入验证码"
|
onGetPhoneNumber={(phoneCode) => void onPhoneQuickLogin(phoneCode)}
|
||||||
value={code}
|
onFail={(message) => setMsg(message)}
|
||||||
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
|
||||||
/>
|
/>
|
||||||
<Text
|
) : null}
|
||||||
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
|
||||||
onClick={() => void onSendCode()}
|
{showPhoneQuick ? (
|
||||||
>
|
<View className="login-divider" style={{ marginTop: 20 }}>
|
||||||
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
<View className="login-divider-line" />
|
||||||
</Text>
|
<Text
|
||||||
</View>
|
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>
|
||||||
|
<Input
|
||||||
|
className="login-field-input"
|
||||||
|
type="number"
|
||||||
|
maxlength={11}
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
value={phone}
|
||||||
|
onInput={(e) => {
|
||||||
|
setPhone(normalizePhone(e.detail.value));
|
||||||
|
setMsg('');
|
||||||
|
setSentHint('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className="login-field">
|
||||||
|
<Input
|
||||||
|
className="login-field-input"
|
||||||
|
type="number"
|
||||||
|
maxlength={6}
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
value={code}
|
||||||
|
onInput={(e) => setCode(e.detail.value.replace(/\D/g, '').slice(0, 6))}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
className={`login-get-code${codeDisabled ? ' login-get-code--disabled' : ''}`}
|
||||||
|
onClick={() => void onSendCode()}
|
||||||
|
>
|
||||||
|
{sending ? '发送中...' : cooldown > 0 ? `${cooldown}s 后重新获取` : '获取验证码'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View
|
||||||
|
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
||||||
|
onClick={loading ? undefined : () => void login()}
|
||||||
|
>
|
||||||
|
<Text className="login-sms-btn__text">
|
||||||
|
{loading
|
||||||
|
? '处理中...'
|
||||||
|
: completeMode === 'phone'
|
||||||
|
? '完成验证'
|
||||||
|
: bindMode
|
||||||
|
? '绑定并登录'
|
||||||
|
: '验证码登录'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{displayMsg ? (
|
{displayMsg ? (
|
||||||
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
<Text className={`login-msg${sentHint && !msg ? ' login-msg--hint' : ''}`}>
|
||||||
@@ -403,48 +518,6 @@ export default function LoginPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : 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
|
|
||||||
className={`login-sms-btn${loading ? ' login-sms-btn--disabled' : ''}`}
|
|
||||||
onClick={loading ? undefined : () => void login()}
|
|
||||||
>
|
|
||||||
<Text className="login-sms-btn__text">
|
|
||||||
{loading
|
|
||||||
? '处理中...'
|
|
||||||
: completeMode === 'phone'
|
|
||||||
? '完成验证'
|
|
||||||
: bindMode
|
|
||||||
? '绑定并登录'
|
|
||||||
: '登录'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{completeMode === 'phone' ? (
|
{completeMode === 'phone' ? (
|
||||||
<View
|
<View
|
||||||
className="login-skip-bind"
|
className="login-skip-bind"
|
||||||
@@ -459,16 +532,21 @@ 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 className="login-cancel-btn" onClick={cancelLogin}>
|
||||||
|
<Text>暂不登录,继续浏览</Text>
|
||||||
|
</View>
|
||||||
|
<Text className="login-cancel-hint">无需登录也可浏览商品和门店</Text>
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationStyle: 'custom',
|
|
||||||
navigationBarTitleText: '我的',
|
navigationBarTitleText: '我的',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { View, Text, Image } from '@tarojs/components';
|
import { View, Text, Image, Button, Input } from '@tarojs/components';
|
||||||
import Taro, { useDidShow } from '@tarojs/taro';
|
import Taro, { useDidShow } from '@tarojs/taro';
|
||||||
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
|
import { isWxAuthorizeEnabled, type ClientRuntimeConfig } from '@dukang/shared-types';
|
||||||
import PageShell from '../../components/PageShell';
|
import PageShell from '../../components/PageShell';
|
||||||
@@ -8,10 +8,13 @@ import UserTabBar, { shouldRenderPageTabBar, syncTabBarSelected } from '../../co
|
|||||||
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
|
import { BRAND_LOGO_MARK_URL } from '@dukang/shared-types';
|
||||||
import { goLogin } from '../../lib/auth-nav';
|
import { goLogin } from '../../lib/auth-nav';
|
||||||
import { bindWechatForUser } from '../../lib/wechat-auth';
|
import { bindWechatForUser } from '../../lib/wechat-auth';
|
||||||
import { fetchUserProfile } from '../../lib/pay-wechat';
|
|
||||||
import {
|
import {
|
||||||
fetchMiniWechatUserInfo,
|
fetchMiniWechatUserInfo,
|
||||||
|
isDefaultMiniNickname,
|
||||||
mergeWxDisplayProfile,
|
mergeWxDisplayProfile,
|
||||||
|
needsWxProfileFill,
|
||||||
|
uploadAvatarTempFile,
|
||||||
|
uploadMiniWechatProfile,
|
||||||
} from '../../lib/mini-wechat-profile';
|
} from '../../lib/mini-wechat-profile';
|
||||||
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
import { isLoggedIn, logout, request, toast, type UserProfile } from '../../lib/api';
|
||||||
import { isWechatEnv } from '../../lib/weixin';
|
import { isWechatEnv } from '../../lib/weixin';
|
||||||
@@ -29,6 +32,8 @@ const SERVICES = [
|
|||||||
{ icon: '关', label: '关于我们', action: 'about' as const },
|
{ icon: '关', label: '关于我们', action: 'about' as const },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const isWeapp = process.env.TARO_ENV === 'weapp';
|
||||||
|
|
||||||
function formatMoney(amount: number) {
|
function formatMoney(amount: number) {
|
||||||
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
@@ -40,15 +45,27 @@ export default function MinePage() {
|
|||||||
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
const [orderCounts, setOrderCounts] = useState<Record<string, number>>({});
|
||||||
const [wxAuthorize, setWxAuthorize] = useState(true);
|
const [wxAuthorize, setWxAuthorize] = useState(true);
|
||||||
const [bindingWx, setBindingWx] = useState(false);
|
const [bindingWx, setBindingWx] = useState(false);
|
||||||
|
const [profileSheetOpen, setProfileSheetOpen] = useState(false);
|
||||||
|
const [draftAvatarTemp, setDraftAvatarTemp] = useState('');
|
||||||
|
const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
|
||||||
|
const [draftNickname, setDraftNickname] = useState('');
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false);
|
||||||
|
const [profileLoadError, setProfileLoadError] = useState('');
|
||||||
|
|
||||||
function resetGuestState() {
|
function resetGuestState() {
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
setBenefitBalance(0);
|
setBenefitBalance(0);
|
||||||
setOrderCounts({});
|
setOrderCounts({});
|
||||||
|
setProfileLoadError('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyProfile(me: UserProfile) {
|
||||||
|
setProfile(mergeWxDisplayProfile(me));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadProfile() {
|
function loadProfile() {
|
||||||
if (!isLoggedIn()) return;
|
if (!isLoggedIn()) return;
|
||||||
|
setProfileLoadError('');
|
||||||
Promise.all([
|
Promise.all([
|
||||||
request<UserProfile>('/auth/me'),
|
request<UserProfile>('/auth/me'),
|
||||||
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
|
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
|
||||||
@@ -57,7 +74,7 @@ export default function MinePage() {
|
|||||||
),
|
),
|
||||||
])
|
])
|
||||||
.then(([me, coupons, ...totals]) => {
|
.then(([me, coupons, ...totals]) => {
|
||||||
setProfile(mergeWxDisplayProfile(me));
|
applyProfile(me);
|
||||||
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
|
const balance = (coupons as Array<Record<string, unknown>>).reduce((sum, c) => {
|
||||||
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
if (String(c.status) === 'ACTIVE') return sum + Number(c.balance || 0);
|
||||||
return sum;
|
return sum;
|
||||||
@@ -69,7 +86,16 @@ export default function MinePage() {
|
|||||||
});
|
});
|
||||||
setOrderCounts(counts);
|
setOrderCounts(counts);
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch((error) => {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
setAuthed(false);
|
||||||
|
resetGuestState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = error instanceof Error ? error.message : '个人资料加载失败';
|
||||||
|
setProfileLoadError(message);
|
||||||
|
toast('个人资料加载失败,请点击重试');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
@@ -89,93 +115,125 @@ export default function MinePage() {
|
|||||||
.catch(() => setWxAuthorize(true));
|
.catch(() => setWxAuthorize(true));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function handleAvatarTap() {
|
async function ensureWechatBound(): Promise<boolean> {
|
||||||
if (!isLoggedIn()) {
|
if (profile?.hasWechat) return true;
|
||||||
goLogin('/pages/mine/index');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (bindingWx) return;
|
|
||||||
|
|
||||||
let current = profile;
|
|
||||||
if (!current) {
|
|
||||||
try {
|
|
||||||
current = await fetchUserProfile();
|
|
||||||
setProfile(current);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (current?.hasWechat) return;
|
|
||||||
|
|
||||||
if (!wxAuthorize) {
|
if (!wxAuthorize) {
|
||||||
toast('当前环境未开启微信授权');
|
toast('当前环境未开启微信授权');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
setBindingWx(true);
|
setBindingWx(true);
|
||||||
try {
|
try {
|
||||||
if (process.env.TARO_ENV === 'h5') {
|
if (!isWeapp) {
|
||||||
if (!isWechatEnv()) {
|
if (!isWechatEnv()) {
|
||||||
toast('请在微信内打开后授权');
|
toast('请在微信内打开后授权');
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
const result = await bindWechatForUser();
|
const result = await bindWechatForUser();
|
||||||
if (!result.ok && 'redirecting' in result && result.redirecting) {
|
if (!result.ok && 'redirecting' in result && result.redirecting) return false;
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||||
goLogin('/pages/mine/index', {
|
goLogin('/pages/mine/index', {
|
||||||
bindMode: '1',
|
bindMode: '1',
|
||||||
wxSessionKey: result.wxSessionKey,
|
wxSessionKey: result.wxSessionKey,
|
||||||
});
|
});
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (result.ok) {
|
if (result.ok && result.profile) {
|
||||||
setProfile(
|
applyProfile({ ...result.profile, hasWechat: true });
|
||||||
mergeWxDisplayProfile({
|
return true;
|
||||||
...(result.profile ?? {}),
|
|
||||||
id: result.profile?.id ?? profile?.id ?? '',
|
|
||||||
hasWechat: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
loadProfile();
|
|
||||||
toast('微信授权成功', 'success');
|
|
||||||
}
|
}
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let wxInfo = null;
|
const result = await bindWechatForUser(null);
|
||||||
try {
|
|
||||||
wxInfo = await fetchMiniWechatUserInfo();
|
|
||||||
} catch (e) {
|
|
||||||
toast(e instanceof Error ? e.message : '需要授权微信头像和昵称');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await bindWechatForUser(wxInfo);
|
|
||||||
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
if (!result.ok && 'needBindPhone' in result && result.needBindPhone) {
|
||||||
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
|
goLogin('/pages/mine/index', { bindMode: '1', wxSessionKey: result.wxSessionKey });
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (result.ok) {
|
if (result.ok && result.profile) {
|
||||||
const merged = mergeWxDisplayProfile({
|
applyProfile({ ...result.profile, hasWechat: true });
|
||||||
...(result.profile ?? {}),
|
return true;
|
||||||
id: result.profile?.id ?? profile?.id ?? '',
|
|
||||||
hasWechat: true,
|
|
||||||
nickname: result.profile?.nickname || wxInfo.nickname || profile?.nickname,
|
|
||||||
avatarUrl: result.profile?.avatarUrl || wxInfo.avatarUrl || profile?.avatarUrl,
|
|
||||||
});
|
|
||||||
setProfile(merged);
|
|
||||||
loadProfile();
|
|
||||||
toast('微信授权成功', 'success');
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e instanceof Error ? e.message : '微信授权失败');
|
toast(e instanceof Error ? e.message : '授权失败');
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBindingWx(false);
|
setBindingWx(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openProfileSheet(me?: UserProfile | null) {
|
||||||
|
const base = mergeWxDisplayProfile(
|
||||||
|
me || profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
|
||||||
|
);
|
||||||
|
setDraftAvatarTemp('');
|
||||||
|
setDraftAvatarUrl(base.avatarUrl || '');
|
||||||
|
setDraftNickname(isDefaultMiniNickname(base.nickname) ? '' : base.nickname || '');
|
||||||
|
setProfileSheetOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
|
||||||
|
async function handleAvatarTap() {
|
||||||
|
if (bindingWx || savingProfile) return;
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
goLogin('/pages/mine/index');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWeapp) {
|
||||||
|
openProfileSheet();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!profile?.hasWechat) {
|
||||||
|
const ok = await ensureWechatBound();
|
||||||
|
if (ok) loadProfile();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
|
||||||
|
const tempPath = e.detail?.avatarUrl?.trim();
|
||||||
|
if (!tempPath) {
|
||||||
|
toast('未获取到头像,请重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDraftAvatarTemp(tempPath);
|
||||||
|
setDraftAvatarUrl(tempPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveWxProfile() {
|
||||||
|
const nickname = draftNickname.trim();
|
||||||
|
if (!nickname) {
|
||||||
|
toast('请填写昵称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!draftAvatarTemp && !draftAvatarUrl) {
|
||||||
|
toast('请选择头像');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingProfile(true);
|
||||||
|
try {
|
||||||
|
let avatarUrl = draftAvatarUrl;
|
||||||
|
let avatarResourceId: string | undefined;
|
||||||
|
if (draftAvatarTemp) {
|
||||||
|
const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
|
||||||
|
avatarUrl = uploaded.url;
|
||||||
|
avatarResourceId = uploaded.resourceId;
|
||||||
|
}
|
||||||
|
const updated = await uploadMiniWechatProfile({
|
||||||
|
nickname,
|
||||||
|
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
|
||||||
|
});
|
||||||
|
if (updated) applyProfile(updated);
|
||||||
|
setProfileSheetOpen(false);
|
||||||
|
toast('头像昵称已更新', 'success');
|
||||||
|
loadProfile();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSavingProfile(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleService(item: (typeof SERVICES)[number]) {
|
function handleService(item: (typeof SERVICES)[number]) {
|
||||||
if ('url' in item && item.url) {
|
if ('url' in item && item.url) {
|
||||||
Taro.navigateTo({ url: item.url });
|
Taro.navigateTo({ url: item.url });
|
||||||
@@ -190,17 +248,6 @@ export default function MinePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveDisplayProfile(profile: UserProfile | null, hasWechat: boolean) {
|
|
||||||
if (!profile) {
|
|
||||||
return { nickname: '用户', avatarUrl: null as string | null };
|
|
||||||
}
|
|
||||||
const merged = hasWechat ? mergeWxDisplayProfile(profile) : profile;
|
|
||||||
return {
|
|
||||||
nickname: merged.nickname || '用户',
|
|
||||||
avatarUrl: merged.avatarUrl || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderAvatarContent(displayAvatarUrl: string | null) {
|
function renderAvatarContent(displayAvatarUrl: string | null) {
|
||||||
if (displayAvatarUrl) {
|
if (displayAvatarUrl) {
|
||||||
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
|
return <Image className="mine-avatar-img" src={displayAvatarUrl} mode="aspectFill" />;
|
||||||
@@ -210,10 +257,7 @@ export default function MinePage() {
|
|||||||
|
|
||||||
if (!authed) {
|
if (!authed) {
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell variant="tab" className="mine-page no-tab-header">
|
||||||
variant="tab"
|
|
||||||
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
|
||||||
>
|
|
||||||
<TabMainHeader title="我的" />
|
<TabMainHeader title="我的" />
|
||||||
<View className="mine-header">
|
<View className="mine-header">
|
||||||
<View className="mine-header-texture" />
|
<View className="mine-header-texture" />
|
||||||
@@ -230,11 +274,10 @@ export default function MinePage() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className="mine-login-gate">
|
<View className="mine-login-gate">
|
||||||
<View className="mine-login-gate-hint">登录后管理订单与个人信息</View>
|
<View className="mine-login-gate-hint">
|
||||||
<View
|
登录后管理订单与个人信息;无需登录也可浏览商品和门店
|
||||||
className="mine-login-btn"
|
</View>
|
||||||
onClick={() => goLogin('/pages/mine/index')}
|
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
|
||||||
>
|
|
||||||
<Text>去登录</Text>
|
<Text>去登录</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -244,42 +287,75 @@ export default function MinePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasWechat = !!profile?.hasWechat;
|
const hasWechat = !!profile?.hasWechat;
|
||||||
const canWxBind = wxAuthorize && process.env.TARO_ENV === 'weapp';
|
const canWxAuth = wxAuthorize && (isWeapp || isWechatEnv());
|
||||||
const display = resolveDisplayProfile(profile, hasWechat);
|
const display = mergeWxDisplayProfile(
|
||||||
const nickname = display.nickname;
|
profile || { id: '', nickname: null, avatarUrl: null, hasWechat: false },
|
||||||
const memberLabel = hasWechat ? '好客会员' : canWxBind ? '微信未授权' : '未授权微信';
|
);
|
||||||
|
// 强制保留 common 导出,避免开发者工具「旧页 + 新 common」混用时报 is not a function
|
||||||
|
if (typeof needsWxProfileFill !== 'function' || typeof fetchMiniWechatUserInfo !== 'function') {
|
||||||
|
throw new Error('wx profile helpers missing');
|
||||||
|
}
|
||||||
|
const nickname = display.nickname || '用户';
|
||||||
|
const needProfileFill = isWeapp && needsWxProfileFill(display);
|
||||||
|
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
|
||||||
|
const memberLabel = needProfileFill
|
||||||
|
? '点击头像完善资料'
|
||||||
|
: isWeapp || hasWechat
|
||||||
|
? '好客会员'
|
||||||
|
: canWxAuth
|
||||||
|
? '点击头像授权'
|
||||||
|
: '好客会员';
|
||||||
|
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
|
||||||
|
const previewAvatar = draftAvatarUrl || display.avatarUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell variant="tab" className="mine-page no-tab-header">
|
||||||
variant="tab"
|
|
||||||
className={`mine-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
|
||||||
>
|
|
||||||
<TabMainHeader title="我的" />
|
<TabMainHeader title="我的" />
|
||||||
<View className="mine-header">
|
<View className="mine-header">
|
||||||
<View className="mine-header-texture" />
|
<View className="mine-header-texture" />
|
||||||
<View className="mine-profile">
|
<View className="mine-profile">
|
||||||
<View
|
<View
|
||||||
className={`mine-avatar-wrap${!hasWechat && canWxBind ? ' mine-avatar-wrap--action' : ''}`}
|
className={`mine-avatar-wrap${avatarClickable ? ' mine-avatar-wrap--action' : ''}`}
|
||||||
onClick={() => void handleAvatarTap()}
|
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
|
||||||
>
|
>
|
||||||
<View
|
<View
|
||||||
className={`mine-avatar${hasWechat ? ' mine-avatar--wx-ok' : canWxBind ? ' mine-avatar--wx-pending' : ''}`}
|
className={`mine-avatar${
|
||||||
|
avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{renderAvatarContent(display.avatarUrl)}
|
{renderAvatarContent(display.avatarUrl)}
|
||||||
</View>
|
</View>
|
||||||
{!hasWechat && canWxBind ? (
|
{avatarClickable ? (
|
||||||
<View className="mine-avatar-status mine-avatar-status--pending">
|
<View
|
||||||
<Text>{bindingWx ? '授权中' : '去授权'}</Text>
|
className={`mine-avatar-status${
|
||||||
|
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Text>
|
||||||
|
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<View>
|
|
||||||
|
<View
|
||||||
|
className="mine-profile-meta"
|
||||||
|
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
|
||||||
|
>
|
||||||
<Text className="mine-profile-name">{nickname}</Text>
|
<Text className="mine-profile-name">{nickname}</Text>
|
||||||
<Text className={`mine-member-tag${hasWechat ? ' mine-member-tag--wechat' : ''}`}>
|
<Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}>
|
||||||
{memberLabel}
|
{memberLabel}
|
||||||
</Text>
|
</Text>
|
||||||
{!hasWechat && canWxBind ? (
|
{profileLoadError ? (
|
||||||
<Text className="mine-wechat-hint">点击头像完成微信授权</Text>
|
<Text
|
||||||
|
className="mine-profile-retry"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
loadProfile();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
资料加载失败,点击重试
|
||||||
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -376,6 +452,58 @@ export default function MinePage() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
{shouldRenderPageTabBar() ? <UserTabBar selected={3} /> : null}
|
||||||
|
|
||||||
|
{profileSheetOpen ? (
|
||||||
|
<View className="mine-profile-sheet-mask" onClick={() => !savingProfile && setProfileSheetOpen(false)}>
|
||||||
|
<View className="mine-profile-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Text className="mine-profile-sheet-title">完善头像与昵称</Text>
|
||||||
|
<Text className="mine-profile-sheet-hint">
|
||||||
|
点击头像选择,并填写昵称后保存(用于个人中心展示)
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
className="mine-profile-avatar-btn"
|
||||||
|
openType="chooseAvatar"
|
||||||
|
hoverClass="none"
|
||||||
|
onChooseAvatar={onChooseAvatar}
|
||||||
|
>
|
||||||
|
<View className="mine-profile-avatar-preview">
|
||||||
|
{previewAvatar ? (
|
||||||
|
<Image className="mine-avatar-img" src={previewAvatar} mode="aspectFill" />
|
||||||
|
) : (
|
||||||
|
<Image className="mine-avatar-img" src={BRAND_LOGO_MARK_URL} mode="aspectFit" />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text className="mine-profile-avatar-tip">点击选择头像</Text>
|
||||||
|
</Button>
|
||||||
|
<View className="mine-profile-nickname-wrap">
|
||||||
|
<Text className="mine-profile-nickname-label">昵称</Text>
|
||||||
|
<Input
|
||||||
|
className="mine-profile-nickname-input"
|
||||||
|
type="nickname"
|
||||||
|
maxlength={32}
|
||||||
|
placeholder="点击填写昵称"
|
||||||
|
value={draftNickname}
|
||||||
|
onInput={(e) => setDraftNickname(e.detail.value)}
|
||||||
|
onBlur={(e) => setDraftNickname(e.detail.value.trim())}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<View className="mine-profile-sheet-actions">
|
||||||
|
<View
|
||||||
|
className="mine-profile-sheet-cancel"
|
||||||
|
onClick={() => !savingProfile && setProfileSheetOpen(false)}
|
||||||
|
>
|
||||||
|
<Text>取消</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`mine-profile-sheet-save${savingProfile ? ' is-disabled' : ''}`}
|
||||||
|
onClick={savingProfile ? undefined : () => void saveWxProfile()}
|
||||||
|
>
|
||||||
|
<Text>{savingProfile ? '保存中...' : '保存'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
{doc.sections.map((section) => (
|
<Text className="legal-intro" selectable>
|
||||||
<View key={section.heading} className="legal-section">
|
{doc.intro}
|
||||||
<Text className="legal-heading">{section.heading}</Text>
|
</Text>
|
||||||
{section.paragraphs.map((p, i) => (
|
{doc.sections.map((section) => (
|
||||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
|
<View key={section.heading} className="legal-section">
|
||||||
{p}
|
<Text className="legal-heading" selectable>
|
||||||
</Text>
|
{section.heading}
|
||||||
))}
|
</Text>
|
||||||
</View>
|
{section.paragraphs.map((p, i) => (
|
||||||
))}
|
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
|
||||||
</ScrollView>
|
{p}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -101,8 +101,10 @@ export default function RedeemPage() {
|
|||||||
className="redeem-input"
|
className="redeem-input"
|
||||||
type="digit"
|
type="digit"
|
||||||
placeholder="输入核销金额"
|
placeholder="输入核销金额"
|
||||||
|
placeholderClass="redeem-input-placeholder"
|
||||||
value={amount}
|
value={amount}
|
||||||
onInput={(e) => setAmount(e.detail.value)}
|
onInput={(e) => setAmount(e.detail.value)}
|
||||||
|
style={{ textAlign: 'center' }}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<View className="redeem-amount-foot">
|
<View className="redeem-amount-foot">
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export default definePageConfig({
|
export default definePageConfig({
|
||||||
navigationStyle: 'custom',
|
|
||||||
navigationBarTitleText: '门店',
|
navigationBarTitleText: '门店',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -73,10 +73,7 @@ export default function StoresPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageShell
|
<PageShell variant="tab" className="store-page no-tab-header">
|
||||||
variant="tab"
|
|
||||||
className={`store-page${process.env.TARO_ENV === 'h5' ? ' h5-no-tab-header' : ''}`}
|
|
||||||
>
|
|
||||||
<TabMainHeader title="门店" />
|
<TabMainHeader title="门店" />
|
||||||
<View className="store-toolbar">
|
<View className="store-toolbar">
|
||||||
<View className="store-location" onClick={() => setRegionOpen(true)}>
|
<View className="store-location" onClick={() => setRegionOpen(true)}>
|
||||||
|
|||||||
@@ -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>
|
||||||
{doc.sections.map((section) => (
|
<Text className="legal-intro" selectable>
|
||||||
<View key={section.heading} className="legal-section">
|
{doc.intro}
|
||||||
<Text className="legal-heading">{section.heading}</Text>
|
</Text>
|
||||||
{section.paragraphs.map((p, i) => (
|
{doc.sections.map((section) => (
|
||||||
<Text key={`${section.heading}-${i}`} className="legal-paragraph">
|
<View key={section.heading} className="legal-section">
|
||||||
{p}
|
<Text className="legal-heading" selectable>
|
||||||
</Text>
|
{section.heading}
|
||||||
))}
|
</Text>
|
||||||
</View>
|
{section.paragraphs.map((p, i) => (
|
||||||
))}
|
<Text key={`${section.heading}-${i}`} className="legal-paragraph" selectable>
|
||||||
</ScrollView>
|
{p}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -120,19 +120,7 @@
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.benefit-hero-actions {
|
|
||||||
display: flex;
|
|
||||||
margin-top: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefit-hero-link {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--color-heritage-red);
|
|
||||||
margin-right: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.benefit-hero-cta {
|
.benefit-hero-cta {
|
||||||
flex: 1;
|
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: var(--radius-full);
|
border-radius: var(--radius-full);
|
||||||
background: var(--color-heritage-red);
|
background: var(--color-heritage-red);
|
||||||
|
|||||||
@@ -81,10 +81,6 @@
|
|||||||
border-bottom-color: var(--color-heritage-red);
|
border-bottom-color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-aroma-tab--muted {
|
|
||||||
opacity: 0.65;
|
|
||||||
}
|
|
||||||
|
|
||||||
.home-product-list {
|
.home-product-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -344,3 +326,51 @@
|
|||||||
.login-agreement-link {
|
.login-agreement-link {
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.login-nav {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 8px var(--space-page) 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-nav-back {
|
||||||
|
min-width: 72px;
|
||||||
|
min-height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-nav-back-icon {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-cancel-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 48px;
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 1px solid var(--color-heritage-red);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-sizing: border-box;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-cancel-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mine-profile-meta {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.mine-avatar-status {
|
.mine-avatar-status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -86,13 +91,6 @@
|
|||||||
background: rgba(255, 255, 255, 0.25);
|
background: rgba(255, 255, 255, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mine-wechat-hint {
|
|
||||||
display: block;
|
|
||||||
margin-top: 4px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: rgba(255, 255, 255, 0.75);
|
|
||||||
}
|
|
||||||
|
|
||||||
.mine-avatar {
|
.mine-avatar {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
@@ -151,6 +149,14 @@
|
|||||||
color: rgba(255, 255, 255, 0.9);
|
color: rgba(255, 255, 255, 0.9);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mine-profile-retry {
|
||||||
|
display: block;
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.mine-main {
|
.mine-main {
|
||||||
margin-top: -28px;
|
margin-top: -28px;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -369,3 +375,126 @@
|
|||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-mask {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
background: rgba(20, 16, 14, 0.45);
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 480px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20px 20px 0 0;
|
||||||
|
padding: 20px 20px calc(20px + env(safe-area-inset-bottom, 0px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1f1a17;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #8d706e;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-avatar-btn {
|
||||||
|
margin: 20px auto 0;
|
||||||
|
padding: 0;
|
||||||
|
width: auto;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
line-height: 1.2;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-avatar-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-avatar-preview {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 2px solid rgba(166, 29, 36, 0.25);
|
||||||
|
background: #f7f4ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-avatar-tip {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-nickname-wrap {
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-nickname-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #5c504c;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-nickname-input {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #f7f4ef;
|
||||||
|
font-size: 15px;
|
||||||
|
color: #1f1a17;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-actions {
|
||||||
|
margin-top: 22px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-cancel,
|
||||||
|
.mine-profile-sheet-save {
|
||||||
|
flex: 1;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-cancel {
|
||||||
|
background: #f0ebe4;
|
||||||
|
color: #5c504c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-save {
|
||||||
|
background: var(--color-heritage-red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mine-profile-sheet-save.is-disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,11 +20,15 @@
|
|||||||
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* H5 无 Tab 顶栏:贴顶,去掉标题栏留白 */
|
/* 无自定义 Tab 顶栏:贴顶(标题走小程序/H5 系统导航栏) */
|
||||||
|
.page-shell.no-tab-header,
|
||||||
.page-shell.h5-no-tab-header {
|
.page-shell.h5-no-tab-header {
|
||||||
--nav-bar-height: 0px;
|
--nav-bar-height: 0px;
|
||||||
|
--nav-status-bar-height: 0px;
|
||||||
|
--nav-content-height: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.page-shell.no-tab-header .home-aroma-nav,
|
||||||
.page-shell.h5-no-tab-header .home-aroma-nav {
|
.page-shell.h5-no-tab-header .home-aroma-nav {
|
||||||
top: 0;
|
top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,16 +47,20 @@
|
|||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.redeem-input-placeholder {
|
||||||
|
color: var(--color-subtle-gray);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小程序原生 input:text-align 需落到组件自身与内部节点 */
|
||||||
|
.redeem-input,
|
||||||
.redeem-input input,
|
.redeem-input input,
|
||||||
.redeem-input .taro-input,
|
.redeem-input .taro-input,
|
||||||
.redeem-input .weui-input {
|
.redeem-input .weui-input {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 100% !important;
|
height: 52px !important;
|
||||||
min-height: 0 !important;
|
min-height: 0 !important;
|
||||||
padding: 0 !important;
|
padding: 0 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
@@ -65,8 +69,8 @@
|
|||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
font-size: 24px !important;
|
font-size: 24px !important;
|
||||||
font-weight: 700 !important;
|
font-weight: 700 !important;
|
||||||
line-height: normal !important;
|
line-height: 52px !important;
|
||||||
color: inherit;
|
color: var(--color-heritage-red);
|
||||||
text-align: center !important;
|
text-align: center !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const fs = require('fs');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const path = process.argv[2] || '/opt/dukang/server/dukang-api/.env.production';
|
||||||
|
|
||||||
|
function stripQuotes(s) {
|
||||||
|
const t = s.trim();
|
||||||
|
if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) {
|
||||||
|
return t.slice(1, -1);
|
||||||
|
}
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEnvValue(text, key) {
|
||||||
|
const re = new RegExp(`^${key}=(.*)$`, 'm');
|
||||||
|
const m = text.match(re);
|
||||||
|
if (!m) return null;
|
||||||
|
return stripQuotes(m[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryKey(name, key) {
|
||||||
|
const literalN = (key.match(/\\n/g) || []).length;
|
||||||
|
const realN = (key.match(/\n/g) || []).length;
|
||||||
|
const hasBegin = /BEGIN (RSA )?PRIVATE KEY/.test(key);
|
||||||
|
try {
|
||||||
|
crypto.createPrivateKey(key);
|
||||||
|
console.log(`${name}: OK begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length}`);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(
|
||||||
|
`${name}: FAIL ${e.message} begin=${hasBegin} literalN=${literalN} realN=${realN} len=${key.length} head=${JSON.stringify(key.slice(0, 48))}`,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = fs.readFileSync(path, 'utf8');
|
||||||
|
const raw = parseEnvValue(text, 'WX_MCH_PRIVATE_KEY');
|
||||||
|
if (!raw) {
|
||||||
|
console.log('MISSING WX_MCH_PRIVATE_KEY in', path);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('file=', path);
|
||||||
|
console.log('MOCK_PAY=', parseEnvValue(text, 'MOCK_PAY'));
|
||||||
|
console.log('WX_MINI_APP_ID=', parseEnvValue(text, 'WX_MINI_APP_ID'));
|
||||||
|
console.log('WX_APP_ID=', parseEnvValue(text, 'WX_APP_ID'));
|
||||||
|
console.log('WX_MCH_ID=', parseEnvValue(text, 'WX_MCH_ID'));
|
||||||
|
console.log('WX_PAY_NOTIFY_URL=', parseEnvValue(text, 'WX_PAY_NOTIFY_URL'));
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
as_is: raw,
|
||||||
|
unescape_n: raw.replace(/\\n/g, '\n'),
|
||||||
|
unescape_twice: raw.replace(/\\\\n/g, '\\n').replace(/\\n/g, '\n'),
|
||||||
|
strip_cr_unesc: raw.replace(/\\n/g, '\n').replace(/\r/g, ''),
|
||||||
|
// dotenv style sometimes leaves surrounding quotes in process.env
|
||||||
|
quoted_unesc: stripQuotes(raw).replace(/\\n/g, '\n'),
|
||||||
|
};
|
||||||
|
|
||||||
|
let ok = false;
|
||||||
|
for (const [name, key] of Object.entries(variants)) {
|
||||||
|
if (tryKey(name, key)) ok = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// also simulate dotenv load
|
||||||
|
try {
|
||||||
|
const dotenv = require('dotenv');
|
||||||
|
const parsed = dotenv.parse(text);
|
||||||
|
const fromDotenv = parsed.WX_MCH_PRIVATE_KEY || '';
|
||||||
|
console.log('dotenv_raw_literalN=', (fromDotenv.match(/\\n/g) || []).length, 'realN=', (fromDotenv.match(/\n/g) || []).length);
|
||||||
|
tryKey('dotenv_as_is', fromDotenv);
|
||||||
|
tryKey('dotenv_unescape', fromDotenv.replace(/\\n/g, '\n'));
|
||||||
|
} catch (e) {
|
||||||
|
console.log('dotenv skip', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(ok ? 0 : 2);
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
# 复制为 auto-release.env(勿提交 Git)
|
|
||||||
# 服务器路径: /opt/dukang-haoke/deploy/auto-release.env
|
|
||||||
|
|
||||||
APP_ROOT=/opt/dukang-haoke
|
|
||||||
GIT_REMOTE=origin
|
|
||||||
GIT_BRANCH=dev
|
|
||||||
DEPLOY_GIT_REF=refs/heads/dev
|
|
||||||
|
|
||||||
# Webhook 密钥(CodeUp 配置「Secret Token」时使用同一值)
|
|
||||||
DEPLOY_WEBHOOK_SECRET=f85777004ea4435bf8b4d5813de8255d286a85d34ce2b30a
|
|
||||||
|
|
||||||
DEPLOY_WEBHOOK_HOST=127.0.0.1
|
|
||||||
DEPLOY_WEBHOOK_PORT=8095
|
|
||||||
DEPLOY_LOG_FILE=/var/log/dukang/deploy.log
|
|
||||||
DEPLOY_LOCK_FILE=/var/run/dukang-deploy.lock
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# 复制为 auto-release.env(勿提交 Git)
|
# 复制为 auto-release.env(勿提交 Git)
|
||||||
# 服务器路径: /opt/dukang-haoke/deploy/auto-release.env
|
# 服务器路径: /opt/dukang/deploy/auto-release.env
|
||||||
|
|
||||||
APP_ROOT=/opt/dukang-haoke
|
APP_ROOT=/opt/dukang
|
||||||
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
|
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
|
||||||
GIT_REMOTE=origin
|
GIT_REMOTE=origin
|
||||||
GIT_BRANCH=dev
|
GIT_BRANCH=dev
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ set -euo pipefail
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
ENV_FILE="$SCRIPT_DIR/auto-release.env"
|
ENV_FILE="$SCRIPT_DIR/auto-release.env"
|
||||||
|
|
||||||
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
|
APP_ROOT="${APP_ROOT:-/opt/dukang}"
|
||||||
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
GIT_REMOTE="${GIT_REMOTE:-origin}"
|
||||||
GIT_BRANCH="${GIT_BRANCH:-dev}"
|
GIT_BRANCH="${GIT_BRANCH:-dev}"
|
||||||
LOCK_FILE="${DEPLOY_LOCK_FILE:-/var/run/dukang-deploy.lock}"
|
LOCK_FILE="${DEPLOY_LOCK_FILE:-/var/run/dukang-deploy.lock}"
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ DEPLOY_USER=root
|
|||||||
DEPLOY_PORT=22
|
DEPLOY_PORT=22
|
||||||
# DEPLOY_SSH_KEY=~/.ssh/id_rsa
|
# DEPLOY_SSH_KEY=~/.ssh/id_rsa
|
||||||
|
|
||||||
APP_ROOT=/opt/dukang-haoke
|
APP_ROOT=/opt/dukang
|
||||||
# 生产仓库(SSH)
|
# 生产仓库(SSH)
|
||||||
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
|
GIT_REPO_URL=git@git.yqidian.com:jacy/dukang.git
|
||||||
GIT_REMOTE=origin
|
GIT_REMOTE=origin
|
||||||
|
|||||||
+1
-1
@@ -9,7 +9,7 @@ DEPLOY_HOST=""
|
|||||||
DEPLOY_USER="root"
|
DEPLOY_USER="root"
|
||||||
DEPLOY_PORT="22"
|
DEPLOY_PORT="22"
|
||||||
DEPLOY_SSH_KEY=""
|
DEPLOY_SSH_KEY=""
|
||||||
APP_ROOT="/opt/dukang-haoke"
|
APP_ROOT="/opt/dukang"
|
||||||
GIT_REMOTE="origin"
|
GIT_REMOTE="origin"
|
||||||
GIT_BRANCH="dev"
|
GIT_BRANCH="dev"
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** PM2 ecosystem — 杜康好客生产部署 */
|
/** PM2 ecosystem — 杜康好客生产部署 */
|
||||||
const APP_ROOT = '/opt/dukang-haoke';
|
const APP_ROOT = '/opt/dukang';
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 启用 api.dukanghaoke.com:扩证书 + 装 nginx 配置
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOMAIN_API=api.dukanghaoke.com
|
||||||
|
CERT_NAME=user.dukanghaoke.com
|
||||||
|
EMAIL="${CERTBOT_EMAIL:-admin@dukanghaoke.com}"
|
||||||
|
APP_ROOT="${APP_ROOT:-/opt/dukang}"
|
||||||
|
NGINX_SRC="$APP_ROOT/deploy/nginx-dukanghaoke.conf"
|
||||||
|
NGINX_DST="/etc/nginx/sites-available/dukang"
|
||||||
|
|
||||||
|
mkdir -p /var/www/certbot/.well-known/acme-challenge
|
||||||
|
mkdir -p /var/log/nginx/dukang
|
||||||
|
|
||||||
|
echo "==> 1. 临时 HTTP 放行 api 域名(便于 http-01)"
|
||||||
|
# 先写入含 api 的 80 server,若证书尚未含 api,443 块可暂用现有证书
|
||||||
|
if [[ -f "$NGINX_SRC" ]]; then
|
||||||
|
install -m 644 "$NGINX_SRC" "$NGINX_DST"
|
||||||
|
ln -sfn "$NGINX_DST" /etc/nginx/sites-enabled/dukang
|
||||||
|
fi
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
echo "==> 2. 扩展证书加入 $DOMAIN_API"
|
||||||
|
certbot certonly --nginx \
|
||||||
|
--cert-name "$CERT_NAME" \
|
||||||
|
--expand \
|
||||||
|
-d user.dukanghaoke.com \
|
||||||
|
-d shop.dukanghaoke.com \
|
||||||
|
-d partner.dukanghaoke.com \
|
||||||
|
-d admin.dukanghaoke.com \
|
||||||
|
-d api.dukanghaoke.com \
|
||||||
|
--email "$EMAIL" \
|
||||||
|
--agree-tos \
|
||||||
|
--non-interactive \
|
||||||
|
--keep-until-expiring
|
||||||
|
|
||||||
|
echo "==> 3. 重载 nginx"
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
echo "==> 4. 健康检查"
|
||||||
|
sleep 1
|
||||||
|
curl -sf -o /dev/null -w "api-https:%{http_code} content-type:%{content_type}\n" \
|
||||||
|
"https://${DOMAIN_API}/api/v1/health"
|
||||||
|
curl -sf "https://${DOMAIN_API}/api/v1/health"; echo
|
||||||
|
echo "==> 完成"
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# 杜康好客 — CodeUp Webhook 反代(挂到 dkapi.runxian.top 443/80 server 块内)
|
# 杜康好客 — CodeUp Webhook 反代(挂到 api.dukanghaoke.com 443/80 server 块内)
|
||||||
# setup-webhook.sh 会自动 include 此文件
|
# setup-webhook.sh 会自动 include 此文件
|
||||||
|
|
||||||
location = /hooks/deploy {
|
location = /hooks/deploy {
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
# 杜康好客 — dukanghaoke.com
|
||||||
|
# HTTP → HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name user.dukanghaoke.com shop.dukanghaoke.com partner.dukanghaoke.com admin.dukanghaoke.com api.dukanghaoke.com;
|
||||||
|
|
||||||
|
location ^~ /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
default_type "text/plain";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# api.dukanghaoke.com → 8090
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name api.dukanghaoke.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
include /opt/dukang/deploy/nginx-deploy-webhook.conf;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# user.dukanghaoke.com → 8091
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name user.dukanghaoke.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8091;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# shop.dukanghaoke.com → 8092
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name shop.dukanghaoke.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8092;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# partner.dukanghaoke.com → 8093
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name partner.dukanghaoke.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8093;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# admin.dukanghaoke.com → 8094
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name admin.dukanghaoke.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/user.dukanghaoke.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/user.dukanghaoke.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
client_max_body_size 50m;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://127.0.0.1:8090;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8094;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
# 在服务器上执行:拉取后的常规发版(不覆盖 .env.production、默认不 seed)
|
# 在服务器上执行:拉取后的常规发版(不覆盖 .env.production、默认不 seed)
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
APP_ROOT="${APP_ROOT:-/opt/dukang-haoke}"
|
APP_ROOT="${APP_ROOT:-/opt/dukang}"
|
||||||
DEPLOY_DIR="$APP_ROOT/deploy"
|
DEPLOY_DIR="$APP_ROOT/deploy"
|
||||||
|
|
||||||
SKIP_BUILD=false
|
SKIP_BUILD=false
|
||||||
@@ -47,7 +47,7 @@ export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=8192}"
|
|||||||
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
|
export TARO_H5_PUBLIC_PATH="${TARO_H5_PUBLIC_PATH:-/user/}"
|
||||||
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
|
export TARO_H5_ROUTER_BASENAME="${TARO_H5_ROUTER_BASENAME:-/user}"
|
||||||
# C 端 H5 编译期注入的 API origin(勿落到 localhost)
|
# C 端 H5 编译期注入的 API origin(勿落到 localhost)
|
||||||
export VITE_API_TARGET="${VITE_API_TARGET:-https://dkapi.runxian.top}"
|
export VITE_API_TARGET="${VITE_API_TARGET:-https://api.dukanghaoke.com}"
|
||||||
pnpm approve-builds --all 2>/dev/null || true
|
pnpm approve-builds --all 2>/dev/null || true
|
||||||
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
pnpm install --frozen-lockfile 2>/dev/null || pnpm install
|
||||||
|
|
||||||
|
|||||||
+19
-7
@@ -30,13 +30,25 @@ HOOK_CONF="$DEPLOY_DIR/nginx-deploy-webhook.conf"
|
|||||||
rm -f /etc/nginx/conf.d/dukang-deploy-webhook.conf
|
rm -f /etc/nginx/conf.d/dukang-deploy-webhook.conf
|
||||||
|
|
||||||
MARKER="include $HOOK_CONF;"
|
MARKER="include $HOOK_CONF;"
|
||||||
for conf in /etc/nginx/conf.d/dukang-runxian.conf /etc/nginx/conf.d/dukang-runxian-ssl.conf; do
|
for conf in \
|
||||||
if [[ -f "$conf" ]] && grep -q 'server_name dkapi.runxian.top' "$conf"; then
|
/etc/nginx/sites-available/dukang \
|
||||||
# 清理旧错误 include
|
/etc/nginx/conf.d/dukang-runxian.conf \
|
||||||
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
|
/etc/nginx/conf.d/dukang-runxian-ssl.conf
|
||||||
|
do
|
||||||
|
if [[ ! -f "$conf" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# 清理旧错误 include
|
||||||
|
sed -i '\|include /etc/nginx/conf.d/dukang-deploy-webhook.conf;|d' "$conf"
|
||||||
|
if grep -q 'server_name api.dukanghaoke.com' "$conf"; then
|
||||||
|
if ! grep -qF "$MARKER" "$conf"; then
|
||||||
|
sed -i "/server_name api.dukanghaoke.com;/a\\ $MARKER" "$conf"
|
||||||
|
echo " 已 patch $conf (api.dukanghaoke.com)"
|
||||||
|
fi
|
||||||
|
elif grep -q 'server_name dkapi.runxian.top' "$conf"; then
|
||||||
if ! grep -qF "$MARKER" "$conf"; then
|
if ! grep -qF "$MARKER" "$conf"; then
|
||||||
sed -i "/server_name dkapi.runxian.top;/a\\ $MARKER" "$conf"
|
sed -i "/server_name dkapi.runxian.top;/a\\ $MARKER" "$conf"
|
||||||
echo " 已 patch $conf"
|
echo " 已 patch $conf (dkapi.runxian.top)"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -62,14 +74,14 @@ SECRET="$(grep DEPLOY_WEBHOOK_SECRET "$ENV_FILE" | cut -d= -f2- | tr -d '\"')"
|
|||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " Webhook 已就绪"
|
echo " Webhook 已就绪"
|
||||||
echo " URL: https://dkapi.runxian.top/hooks/deploy"
|
echo " URL: https://api.dukanghaoke.com/hooks/deploy"
|
||||||
echo " Secret: $SECRET"
|
echo " Secret: $SECRET"
|
||||||
echo " Branch: dev (refs/heads/dev)"
|
echo " Branch: dev (refs/heads/dev)"
|
||||||
echo " Log: $LOG_DIR/deploy.log"
|
echo " Log: $LOG_DIR/deploy.log"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "CodeUp 配置:仓库 → 设置 → Webhooks → 添加"
|
echo "CodeUp 配置:仓库 → 设置 → Webhooks → 添加"
|
||||||
echo " URL: https://dkapi.runxian.top/hooks/deploy"
|
echo " URL: https://api.dukanghaoke.com/hooks/deploy"
|
||||||
echo " Secret Token: (与上方 Secret 相同)"
|
echo " Secret Token: (与上方 Secret 相同)"
|
||||||
echo " 触发事件: Push events"
|
echo " 触发事件: Push events"
|
||||||
echo " 分支过滤: dev"
|
echo " 分支过滤: dev"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ DEPLOY_HOST=""
|
|||||||
DEPLOY_USER="root"
|
DEPLOY_USER="root"
|
||||||
DEPLOY_PORT="22"
|
DEPLOY_PORT="22"
|
||||||
DEPLOY_SSH_KEY=""
|
DEPLOY_SSH_KEY=""
|
||||||
APP_ROOT="/opt/dukang-haoke"
|
APP_ROOT="/opt/dukang"
|
||||||
|
|
||||||
TARGET="${1:-production}"
|
TARGET="${1:-production}"
|
||||||
case "$TARGET" in
|
case "$TARGET" in
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
"dev:hq": "pnpm --filter @dukang/mini-hq dev",
|
"dev:hq": "pnpm --filter @dukang/mini-hq dev",
|
||||||
"dev:mini-user": "pnpm --filter @dukang/mini-user dev",
|
"dev:mini-user": "pnpm --filter @dukang/mini-user dev",
|
||||||
"dev:mini-user:weapp": "pnpm --filter @dukang/mini-user dev:weapp",
|
"dev:mini-user:weapp": "pnpm --filter @dukang/mini-user dev:weapp",
|
||||||
|
"build:mini-user:weapp": "pnpm --filter @dukang/mini-user build:weapp",
|
||||||
"preview:hq": "pnpm --filter @dukang/mini-hq build && pnpm --filter @dukang/mini-hq preview",
|
"preview:hq": "pnpm --filter @dukang/mini-hq build && pnpm --filter @dukang/mini-hq preview",
|
||||||
"build": "pnpm -r build",
|
"build": "pnpm -r build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
|
|||||||
@@ -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)在合并、分立、资产转让等情形下,如涉及个人信息转移,我们将要求新的持有方继续受本政策约束,或重新征得您的同意。',
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -45,6 +45,22 @@ export interface RedeemPhonePrepareDto {
|
|||||||
expireInSeconds: number;
|
expireInSeconds: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RedeemPhoneDirectPrepareRequest {
|
||||||
|
phone: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemPhoneDirectPrepareResult extends RedeemPhonePrepareDto {
|
||||||
|
totalBalance: number;
|
||||||
|
maskedPhone: string;
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
userNo?: string | null;
|
||||||
|
nickname?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
|
export type RedeemPendingStatus = 'PENDING' | 'COMPLETED' | 'REJECTED';
|
||||||
|
|
||||||
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
|
export const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ WX_MCH_PRIVATE_KEY=
|
|||||||
WX_API_V3_KEY=
|
WX_API_V3_KEY=
|
||||||
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
# 微信平台公钥证书 PEM(生产环境必填,用于回调验签;开发可暂留空)
|
||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||||
|
|
||||||
# 腾讯位置服务(逆地理编码,微信定位展示城市)
|
# 腾讯位置服务(逆地理编码,微信定位展示城市)
|
||||||
TENCENT_LBS_KEY=
|
TENCENT_LBS_KEY=
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
|
|
||||||
NODE_ENV=production
|
NODE_ENV=production
|
||||||
|
|
||||||
DATABASE_URL="mysql://dukang:CHANGE_ME@localhost:3306/dukang_haoke"
|
# 生产:阿里云 RDS(密码中的 @ ! 需 URL 编码为 %40 %21)
|
||||||
|
DATABASE_URL="mysql://dukangadmin:CHANGE_ME@rm-xxxxxxxx.mysql.rds.aliyuncs.com:3306/dukang_prod?charset=utf8mb4"
|
||||||
REDIS_URL="redis://localhost:6379"
|
REDIS_URL="redis://localhost:6379"
|
||||||
JWT_SECRET="CHANGE_ME-strong-random-secret"
|
JWT_SECRET="CHANGE_ME-strong-random-secret"
|
||||||
JWT_EXPIRES_IN="7d"
|
JWT_EXPIRES_IN="7d"
|
||||||
@@ -36,7 +37,7 @@ WX_MCH_SERIAL_NO=
|
|||||||
WX_MCH_PRIVATE_KEY=
|
WX_MCH_PRIVATE_KEY=
|
||||||
WX_API_V3_KEY=
|
WX_API_V3_KEY=
|
||||||
WX_PLATFORM_CERT=
|
WX_PLATFORM_CERT=
|
||||||
WX_PAY_NOTIFY_URL=https://dkapi.runxian.top/api/v1/callbacks/wechat/pay
|
WX_PAY_NOTIFY_URL=https://api.dukanghaoke.com/api/v1/callbacks/wechat/pay
|
||||||
|
|
||||||
OSS_ACCESS_KEY_ID=
|
OSS_ACCESS_KEY_ID=
|
||||||
OSS_ACCESS_KEY_SECRET=
|
OSS_ACCESS_KEY_SECRET=
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ enum EventType {
|
|||||||
BENEFIT_LEDGER
|
BENEFIT_LEDGER
|
||||||
HQ_OPERATION
|
HQ_OPERATION
|
||||||
PROMO_TOUCH
|
PROMO_TOUCH
|
||||||
|
TICKET_COLLAB
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ActorType {
|
enum ActorType {
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ enum EventType {
|
|||||||
BENEFIT_LEDGER
|
BENEFIT_LEDGER
|
||||||
HQ_OPERATION
|
HQ_OPERATION
|
||||||
PROMO_TOUCH
|
PROMO_TOUCH
|
||||||
|
TICKET_COLLAB
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ActorType {
|
enum ActorType {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { normalizePemEnv } from '../../integrations/wechat/wechat-pay.util';
|
||||||
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
|
import { SYSTEM_CONFIG_KEY_SET } from './system-config.registry';
|
||||||
|
|
||||||
|
const PEM_ENV_KEYS = new Set(['WX_MCH_PRIVATE_KEY', 'WX_PLATFORM_CERT']);
|
||||||
|
|
||||||
function apiRoot() {
|
function apiRoot() {
|
||||||
return resolve(__dirname, '..', '..');
|
return resolve(__dirname, '..', '..');
|
||||||
}
|
}
|
||||||
@@ -11,6 +14,11 @@ export function resolveEnvFilePath() {
|
|||||||
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
|
return resolve(apiRoot(), isProduction ? '.env.production' : '.env');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeConfigValue(key: string, value: string): string {
|
||||||
|
if (PEM_ENV_KEYS.has(key)) return normalizePemEnv(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
/** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */
|
/** 启动前从 DB 覆盖 process.env(在 Nest 创建前调用) */
|
||||||
export async function preloadSystemConfigEnv(): Promise<number> {
|
export async function preloadSystemConfigEnv(): Promise<number> {
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
@@ -18,7 +26,7 @@ export async function preloadSystemConfigEnv(): Promise<number> {
|
|||||||
const rows = await prisma.systemConfig.findMany();
|
const rows = await prisma.systemConfig.findMany();
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) {
|
if (SYSTEM_CONFIG_KEY_SET.has(row.configKey)) {
|
||||||
process.env[row.configKey] = row.value;
|
process.env[row.configKey] = normalizeConfigValue(row.configKey, row.value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return rows.length;
|
return rows.length;
|
||||||
@@ -41,7 +49,7 @@ export async function preloadSystemConfigEnv(): Promise<number> {
|
|||||||
export function applyEnvOverlay(values: Record<string, string>) {
|
export function applyEnvOverlay(values: Record<string, string>) {
|
||||||
for (const [key, value] of Object.entries(values)) {
|
for (const [key, value] of Object.entries(values)) {
|
||||||
if (SYSTEM_CONFIG_KEY_SET.has(key)) {
|
if (SYSTEM_CONFIG_KEY_SET.has(key)) {
|
||||||
process.env[key] = value;
|
process.env[key] = normalizeConfigValue(key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,17 +205,31 @@ export class SystemConfigService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeByMeta(meta: { type: string }, raw: string): string {
|
private normalizeByMeta(meta: { key?: string; type: string }, raw: string): string {
|
||||||
if (meta.type === 'boolean') {
|
if (meta.type === 'boolean') {
|
||||||
return raw === 'true' || raw === '1' ? 'true' : 'false';
|
return raw === 'true' || raw === '1' ? 'true' : 'false';
|
||||||
}
|
}
|
||||||
|
if (meta.key === 'WX_MCH_PRIVATE_KEY' || meta.key === 'WX_PLATFORM_CERT') {
|
||||||
|
let value = raw.trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
return value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n').trim();
|
||||||
|
}
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatEnvLine(key: string, value: string): string {
|
function formatEnvLine(key: string, value: string): string {
|
||||||
if (/[\s#"'\\]/.test(value)) {
|
const needsQuote = /[\s#"'\\]/.test(value) || value.includes('\n') || value.includes('\r');
|
||||||
return `${key}="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
if (!needsQuote) return `${key}=${value}`;
|
||||||
}
|
const escaped = value
|
||||||
return `${key}=${value}`;
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\r\n/g, '\\n')
|
||||||
|
.replace(/\n/g, '\\n')
|
||||||
|
.replace(/"/g, '\\"');
|
||||||
|
return `${key}="${escaped}"`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,3 +67,22 @@ export function safeEqual(a: string, b: string): boolean {
|
|||||||
if (ba.length !== bb.length) return false;
|
if (ba.length !== bb.length) return false;
|
||||||
return timingSafeEqual(ba, bb);
|
return timingSafeEqual(ba, bb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 规范化 .env / system_config 中的 PEM:
|
||||||
|
* - 去掉外层引号(DB/表单常把整段含引号写入)
|
||||||
|
* - 把字面量 \\n 转成真实换行
|
||||||
|
* OpenSSL 报 1E08010C DECODER unsupported 时多半是这两类污染。
|
||||||
|
*/
|
||||||
|
export function normalizePemEnv(raw: string | undefined | null): string {
|
||||||
|
if (!raw) return '';
|
||||||
|
let value = String(raw).trim();
|
||||||
|
if (
|
||||||
|
(value.startsWith('"') && value.endsWith('"')) ||
|
||||||
|
(value.startsWith("'") && value.endsWith("'"))
|
||||||
|
) {
|
||||||
|
value = value.slice(1, -1).trim();
|
||||||
|
}
|
||||||
|
value = value.replace(/\\r\\n/g, '\n').replace(/\\n/g, '\n').replace(/\r\n/g, '\n');
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type { IWechatProvider, WechatCodeSession, WechatOAuthSession } from './w
|
|||||||
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
import { logWechatAuth, type WechatActorRef } from './wechat-log.util';
|
||||||
import {
|
import {
|
||||||
decryptPayResource,
|
decryptPayResource,
|
||||||
|
normalizePemEnv,
|
||||||
verifyPaySignature,
|
verifyPaySignature,
|
||||||
type WechatPayNotifyEnvelope,
|
type WechatPayNotifyEnvelope,
|
||||||
} from './wechat-pay.util';
|
} from './wechat-pay.util';
|
||||||
@@ -15,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()
|
||||||
@@ -27,10 +29,10 @@ export class WechatApiProvider implements IWechatProvider {
|
|||||||
private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim();
|
private readonly miniAppSecret = (process.env.WX_MINI_APP_SECRET ?? this.appSecret).trim();
|
||||||
private readonly mchId = process.env.WX_MCH_ID ?? '';
|
private readonly mchId = process.env.WX_MCH_ID ?? '';
|
||||||
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
|
private readonly mchSerialNo = process.env.WX_MCH_SERIAL_NO ?? '';
|
||||||
private readonly mchPrivateKey = (process.env.WX_MCH_PRIVATE_KEY ?? '').replace(/\\n/g, '\n');
|
private readonly mchPrivateKey = normalizePemEnv(process.env.WX_MCH_PRIVATE_KEY);
|
||||||
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
private readonly apiV3Key = process.env.WX_API_V3_KEY ?? '';
|
||||||
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
private readonly notifyUrl = process.env.WX_PAY_NOTIFY_URL ?? '';
|
||||||
private readonly platformCert = (process.env.WX_PLATFORM_CERT ?? '').replace(/\\n/g, '\n');
|
private readonly platformCert = normalizePemEnv(process.env.WX_PLATFORM_CERT);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
@@ -254,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;
|
||||||
@@ -432,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;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { ClientConfigController } from './client-config.controller';
|
|||||||
import { WechatLocationService } from './wechat-location.service';
|
import { WechatLocationService } from './wechat-location.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [IamModule, IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
|
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
|
||||||
controllers: [
|
controllers: [
|
||||||
ResourceController,
|
ResourceController,
|
||||||
EventController,
|
EventController,
|
||||||
|
|||||||
@@ -102,6 +102,9 @@ export class ResourceService {
|
|||||||
});
|
});
|
||||||
throw new BadRequestException(message);
|
throw new BadRequestException(message);
|
||||||
}
|
}
|
||||||
|
if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) {
|
||||||
|
throw new BadRequestException('头像仅支持图片文件');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.oss.putObject({
|
const result = await this.oss.putObject({
|
||||||
@@ -125,6 +128,24 @@ export class ResourceService {
|
|||||||
externalNo: result.ossKey,
|
externalNo: result.ossKey,
|
||||||
status: 'SUCCESS',
|
status: 'SUCCESS',
|
||||||
});
|
});
|
||||||
|
if (actor?.refType === 'USER' && dto.bizType === 'AVATAR' && dto.mediaType === 'IMAGE') {
|
||||||
|
const resource = await this.prisma.commonResource.create({
|
||||||
|
data: {
|
||||||
|
ownerType: 'USER',
|
||||||
|
ownerId: actor.refId,
|
||||||
|
bizType: 'AVATAR',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
ossBucket: result.bucket,
|
||||||
|
ossKey: result.ossKey,
|
||||||
|
url: result.url,
|
||||||
|
fileName: file.originalname || 'avatar',
|
||||||
|
fileSize: BigInt(file.size),
|
||||||
|
mimeType: file.mimetype,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return serializeBigInt({ ...result, resourceId: resource.id });
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await logOssUpload(this.prisma, {
|
await logOssUpload(this.prisma, {
|
||||||
@@ -138,6 +159,37 @@ export class ResourceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOwnedActiveAvatar(resourceId: bigint, userId: bigint) {
|
||||||
|
const resource = await this.prisma.commonResource.findFirst({
|
||||||
|
where: {
|
||||||
|
id: resourceId,
|
||||||
|
ownerType: 'USER',
|
||||||
|
ownerId: userId,
|
||||||
|
bizType: 'AVATAR',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户');
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOwnedActiveAvatarByUrl(url: string, userId: bigint) {
|
||||||
|
const resource = await this.prisma.commonResource.findFirst({
|
||||||
|
where: {
|
||||||
|
url,
|
||||||
|
ownerType: 'USER',
|
||||||
|
ownerId: userId,
|
||||||
|
bizType: 'AVATAR',
|
||||||
|
mediaType: 'IMAGE',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
if (!resource) throw new BadRequestException('头像资源无效或不属于当前用户');
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
async register(dto: RegisterResourceDto) {
|
async register(dto: RegisterResourceDto) {
|
||||||
const resource = await this.prisma.commonResource.create({
|
const resource = await this.prisma.commonResource.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
|
forwardRef,
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
@@ -22,6 +23,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
|
|||||||
import { verifyPassword } from '../../common/crypto/password.util';
|
import { verifyPassword } from '../../common/crypto/password.util';
|
||||||
import { AnalyticsService } from '../analytics/analytics.service';
|
import { AnalyticsService } from '../analytics/analytics.service';
|
||||||
import { UserAddressService } from './user-address.service';
|
import { UserAddressService } from './user-address.service';
|
||||||
|
import { ResourceService } from '../common/resource.service';
|
||||||
|
|
||||||
import type { User } from '@prisma/client';
|
import type { User } from '@prisma/client';
|
||||||
|
|
||||||
@@ -62,6 +64,7 @@ export class AuthService {
|
|||||||
private readonly analyticsService: AnalyticsService,
|
private readonly analyticsService: AnalyticsService,
|
||||||
private readonly smsCodeStore: SmsCodeStore,
|
private readonly smsCodeStore: SmsCodeStore,
|
||||||
private readonly userAddressService: UserAddressService,
|
private readonly userAddressService: UserAddressService,
|
||||||
|
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private assertMobilePhone(phone: string) {
|
private assertMobilePhone(phone: string) {
|
||||||
@@ -751,19 +754,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 +813,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 +829,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);
|
||||||
@@ -1360,12 +1410,9 @@ export class AuthService {
|
|||||||
|
|
||||||
async updateMiniWechatProfile(
|
async updateMiniWechatProfile(
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
input: { nickname?: string; avatarUrl?: string },
|
input: { nickname?: string; avatarUrl?: string; avatarResourceId?: string },
|
||||||
) {
|
) {
|
||||||
const user = await this.assertActiveUser(userId);
|
const user = await this.assertActiveUser(userId);
|
||||||
if (!user.wxOpenId) {
|
|
||||||
throw new BadRequestException('请先完成微信授权');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: {
|
const data: {
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
@@ -1378,37 +1425,23 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const avatarUrl = input.avatarUrl?.trim();
|
const avatarUrl = input.avatarUrl?.trim();
|
||||||
if (avatarUrl) {
|
const avatarResourceId = input.avatarResourceId?.trim();
|
||||||
if (user.avatarResourceId) {
|
if (avatarResourceId) {
|
||||||
await this.prisma.commonResource.update({
|
let resourceId: bigint;
|
||||||
where: { id: user.avatarResourceId },
|
try {
|
||||||
data: { url: avatarUrl },
|
resourceId = BigInt(avatarResourceId);
|
||||||
});
|
} catch {
|
||||||
} else {
|
throw new BadRequestException('头像资源编号无效');
|
||||||
const avatar = await this.prisma.commonResource.create({
|
|
||||||
data: {
|
|
||||||
ownerType: 'USER',
|
|
||||||
ownerId: userId,
|
|
||||||
bizType: 'AVATAR',
|
|
||||||
mediaType: 'IMAGE',
|
|
||||||
ossBucket: 'wechat',
|
|
||||||
ossKey: `wx-avatar/${user.wxOpenId}`,
|
|
||||||
url: avatarUrl,
|
|
||||||
status: 'ACTIVE',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
data.avatarResourceId = avatar.id;
|
|
||||||
}
|
}
|
||||||
|
const avatar = await this.resourceService.getOwnedActiveAvatar(resourceId, userId);
|
||||||
|
data.avatarResourceId = avatar.id;
|
||||||
|
} else if (avatarUrl) {
|
||||||
|
// 兼容已发布旧客户端:只接受刚由当前用户上传并登记过的真实资源 URL。
|
||||||
|
const avatar = await this.resourceService.getOwnedActiveAvatarByUrl(avatarUrl, userId);
|
||||||
|
data.avatarResourceId = avatar.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.nickname && !data.avatarResourceId) {
|
if (!data.nickname && !data.avatarResourceId) {
|
||||||
if (avatarUrl && user.avatarResourceId) {
|
|
||||||
const refreshed = await this.prisma.user.findUnique({
|
|
||||||
where: { id: userId },
|
|
||||||
include: { avatar: true },
|
|
||||||
});
|
|
||||||
return this.formatUserProfile(refreshed ?? user);
|
|
||||||
}
|
|
||||||
return this.formatUserProfile(user);
|
return this.formatUserProfile(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -102,6 +118,10 @@ export class MiniWechatProfileDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsOptional()
|
||||||
|
avatarResourceId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CheckPartnerPhoneDto {
|
export class CheckPartnerPhoneDto {
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
|
|||||||
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
|
||||||
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
|
||||||
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
import { StoreMembershipService } from '../../common/guards/store-membership.service';
|
||||||
|
import { CommonModule } from '../common/common.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
IntegrationsModule,
|
IntegrationsModule,
|
||||||
|
forwardRef(() => CommonModule),
|
||||||
forwardRef(() => AnalyticsModule),
|
forwardRef(() => AnalyticsModule),
|
||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
|
||||||
|
import type { RedeemPhoneDirectPrepareRequest } from '@dukang/shared-types';
|
||||||
|
|
||||||
export class RedeemPhoneSendLookupSmsDto {
|
export class RedeemPhoneSendLookupSmsDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -28,6 +29,17 @@ export class RedeemPhonePrepareDto {
|
|||||||
amount: number;
|
amount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RedeemPhoneDirectPrepareDto implements RedeemPhoneDirectPrepareRequest {
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
phone: string;
|
||||||
|
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsNumber()
|
||||||
|
@Min(0.01)
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class RedeemPhoneConfirmDto {
|
export class RedeemPhoneConfirmDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|||||||
import {
|
import {
|
||||||
RedeemPhoneBalanceDto,
|
RedeemPhoneBalanceDto,
|
||||||
RedeemPhoneConfirmDto,
|
RedeemPhoneConfirmDto,
|
||||||
|
RedeemPhoneDirectPrepareDto,
|
||||||
RedeemPhonePrepareDto,
|
RedeemPhonePrepareDto,
|
||||||
RedeemPhoneSendLookupSmsDto,
|
RedeemPhoneSendLookupSmsDto,
|
||||||
} from './dto/phone-redeem.dto';
|
} from './dto/phone-redeem.dto';
|
||||||
@@ -107,6 +108,16 @@ export class ShopRedeemController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('phone/prepare-direct')
|
||||||
|
phonePrepareDirect(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneDirectPrepareDto) {
|
||||||
|
return this.redeemService.preparePhoneRedeemDirect(
|
||||||
|
user.actorId,
|
||||||
|
user.storeId!,
|
||||||
|
body.phone,
|
||||||
|
body.amount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('phone/confirm')
|
@Post('phone/confirm')
|
||||||
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
|
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
|
||||||
return this.redeemService.confirmPhoneRedeem(
|
return this.redeemService.confirmPhoneRedeem(
|
||||||
|
|||||||
@@ -311,6 +311,62 @@ export class RedeemService {
|
|||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async preparePhoneRedeemDirect(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
phone: string,
|
||||||
|
amount: number,
|
||||||
|
) {
|
||||||
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
|
const normalizedPhone = this.normalizeMobilePhone(phone);
|
||||||
|
const user = await this.resolveUserByPhone(normalizedPhone);
|
||||||
|
const { allocations, totalBalance } = await this.computeDirectAllocations(user.id, amount);
|
||||||
|
const sessionId = randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
await this.authService.sendSms(normalizedPhone, SmsScene.REDEEM_PHONE_CONFIRM, {
|
||||||
|
clientApp: ClientApp.SHOP_H5,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.redis.setJson(
|
||||||
|
this.phoneSessionKey(sessionId),
|
||||||
|
{
|
||||||
|
userId: user.id.toString(),
|
||||||
|
phone: normalizedPhone,
|
||||||
|
storeAccountId: storeAccountId.toString(),
|
||||||
|
storeId: account.storeId.toString(),
|
||||||
|
amount,
|
||||||
|
allocations,
|
||||||
|
confirmPrepared: true,
|
||||||
|
} satisfies PhoneRedeemSession,
|
||||||
|
REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.analyticsService.trackStoreOneSafe(storeAccountId, ClientApp.SHOP_H5, {
|
||||||
|
storeId: account.storeId,
|
||||||
|
eventName: 'store_redeem_phone_prepare',
|
||||||
|
extraJson: {
|
||||||
|
sessionId,
|
||||||
|
amount,
|
||||||
|
phone: this.maskPhoneForStore(normalizedPhone),
|
||||||
|
flow: 'direct',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return serializeBigInt({
|
||||||
|
sessionId,
|
||||||
|
amount,
|
||||||
|
totalBalance,
|
||||||
|
maskedPhone: this.maskPhoneForStore(normalizedPhone),
|
||||||
|
expireInSeconds: REDEEM_PHONE_SESSION_TTL_SECONDS,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
userNo: user.userNo,
|
||||||
|
nickname: user.nickname,
|
||||||
|
phone: this.maskPhoneForStore(normalizedPhone),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) {
|
async preparePhoneRedeem(storeAccountId: bigint, storeId: bigint, sessionId: string, amount: number) {
|
||||||
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
|
||||||
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
const session = await this.loadPhoneSession(sessionId, storeAccountId);
|
||||||
|
|||||||
+1
-1
@@ -254,7 +254,7 @@
|
|||||||
| 模块 | 要点 |
|
| 模块 | 要点 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 |
|
| 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 |
|
||||||
| 核销 | 扫码大按钮 + 手机号通道;今日汇总;弱网处理 |
|
| 核销 | 扫码大按钮 + 手机号通道;手机号、金额、验证码与确认核销同页完成,先按手机号和金额发送验证码,验证成功后直接核销;今日汇总;弱网处理 |
|
||||||
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
|
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
|
||||||
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
|
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
|
||||||
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |
|
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |
|
||||||
|
|||||||
Reference in New Issue
Block a user