Compare commits

...

3 Commits

Author SHA1 Message Date
jacy 83ed90ef67 feat(h5-auth): remember SMS sessions and simplify media access
CI / verify (pull_request) Has been cancelled
Hide WeChat login for shop and partner users, keep verified sessions for seven days, and allow JSSDK camera or album access without an OpenID binding.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:41:17 +08:00
jacy 73dbf6effb chore(deploy): stop tracking auto-release.env; feat(mini-user): avatar profile sheet
CI / verify (pull_request) Has been cancelled
Remove secrets file from the index (keep local). Mine page can choose avatar and nickname via WeChat components.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:31:07 +08:00
jacy b8adfe98a3 ignore 2026-07-21 10:19:09 +08:00
12 changed files with 379 additions and 501 deletions
+1
View File
@@ -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
View File
@@ -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,9 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, 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 { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin'; 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,7 +14,7 @@ type OssUploadFieldProps = {
wide?: boolean; wide?: boolean;
compact?: boolean; compact?: boolean;
label?: string; label?: string;
/** 父级已确认微信授权时可跳过检查 */ /** 兼容现有调用:选图不再依赖 openId 绑定 */
wechatReady?: boolean; wechatReady?: boolean;
onWechatReadyChange?: (ready: boolean) => void; onWechatReadyChange?: (ready: boolean) => void;
}; };
@@ -52,15 +44,11 @@ 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 [showAlbumFallback, setShowAlbumFallback] = useState(false);
const [clientConfig, setClientConfig] = useState<ClientRuntimeConfig | null>(null);
function showUploadError(text: string) { function showUploadError(text: string) {
setError(text); setError(text);
@@ -72,32 +60,14 @@ export default function OssUploadField({
const inWechat = isWechatEnv(); const inWechat = isWechatEnv();
const useWechatPicker = const useWechatPicker =
inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept))); inWechat && (mediaType === 'IMAGE' || (mediaType === 'FILE' && acceptsImages(resolvedAccept)));
const needsAuth = useWechatPicker && needsWechatAuth(profile, clientConfig) && wechatReady !== true;
const onWechatReadyChangeRef = useRef(onWechatReadyChange);
onWechatReadyChangeRef.current = onWechatReadyChange;
useEffect(() => { useEffect(() => {
if (!useWechatPicker) return; 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(); weixinSdk.reset();
void weixinSdk.init().catch(() => { void weixinSdk.init().catch(() => {
/* 点击上传时会再次初始化 */ /* 点击上传时会再次初始化 */
}); });
}, [useWechatPicker, needsAuth]); }, [useWechatPicker]);
async function persistUpload(file: File) { async function persistUpload(file: File) {
if (!file.size) { if (!file.size) {
@@ -125,24 +95,6 @@ export default function OssUploadField({
} }
} }
async function startWechatAuth() {
setAuthorizing(true);
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() { async function pickWechatImage() {
setUploading(true); setUploading(true);
setError(''); setError('');
@@ -161,29 +113,26 @@ export default function OssUploadField({
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册'; const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return; if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e)); throw e;
} finally { } finally {
setUploading(false); setUploading(false);
} }
} }
async function pickFile() { async function pickFile() {
if (uploading || authorizing) return; if (uploading) return;
setError(''); setError('');
if (useWechatPicker && needsAuth) {
const text = '请先完成微信授权后再上传照片';
showUploadError(text);
return;
}
if (useWechatPicker) { if (useWechatPicker) {
try { try {
await pickWechatImage(); await pickWechatImage();
} catch (e) { } catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册'; const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return; if (/cancel/i.test(msg)) return;
showUploadError(formatWechatUploadError(e)); const formatted = formatWechatUploadError(e);
setError(`${formatted},可改从系统相册选择`);
setShowAlbumFallback(true);
inputRef.current?.click();
} }
return; return;
} }
@@ -193,44 +142,31 @@ export default function OssUploadField({
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 ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
const triggerProps = { const triggerProps = {
type: 'button' as const, type: 'button' as const,
disabled: busy || needsAuth, disabled: busy,
onClick: () => void pickFile(), onClick: () => void 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) {
> setShowAlbumFallback(false);
{authorizing ? '跳转授权中…' : '微信授权绑定'} void uploadSelectedFile(file);
</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 ?? '已上传'} />
@@ -258,10 +194,21 @@ export default function OssUploadField({
{busy ? 'hourglass_top' : 'add_a_photo'} {busy ? 'hourglass_top' : 'add_a_photo'}
</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>
)} )}
{showAlbumFallback && useWechatPicker && (
<button
type="button"
className="partner-btn-outline"
style={{ marginTop: 8, width: '100%' }}
disabled={busy}
onClick={() => inputRef.current?.click()}
>
从系统相册选择
</button>
)}
{error && <p className="partner-form-error" role="alert">{error}</p>} {error && <p className="partner-form-error" role="alert">{error}</p>}
</div> </div>
); );
+9 -4
View File
@@ -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 }) {
+8 -174
View File
@@ -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>
-5
View File
@@ -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"
+9 -4
View File
@@ -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 }) {
+13 -185
View File
@@ -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>
); );
+158 -16
View File
@@ -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';
@@ -10,8 +10,11 @@ import { goLogin } from '../../lib/auth-nav';
import { bindWechatForUser } from '../../lib/wechat-auth'; import { bindWechatForUser } from '../../lib/wechat-auth';
import { import {
fetchMiniWechatUserInfo, fetchMiniWechatUserInfo,
isDefaultMiniNickname,
mergeWxDisplayProfile, mergeWxDisplayProfile,
needsWxProfileFill, 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';
@@ -42,6 +45,11 @@ 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);
function resetGuestState() { function resetGuestState() {
setProfile(null); setProfile(null);
@@ -135,22 +143,83 @@ export default function MinePage() {
} }
return false; return false;
} catch (e) { } catch (e) {
toast(e instanceof Error ? e.message : '微信授权失败'); toast(e instanceof Error ? e.message : '授权失败');
return false; return false;
} finally { } finally {
setBindingWx(false); setBindingWx(false);
} }
} }
/** 未绑定微信时点击头像走授权(小程序 openId / H5 公众号 OAuth) */ 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() { async function handleAvatarTap() {
if (!isLoggedIn()) { if (!isLoggedIn()) {
goLogin('/pages/mine/index'); goLogin('/pages/mine/index');
return; return;
} }
if (profile?.hasWechat) return; if (!isWeapp) {
await ensureWechatBound(); if (!profile?.hasWechat) {
loadProfile(); const ok = await ensureWechatBound();
if (ok) loadProfile();
}
return;
}
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (!ok) return;
}
openProfileSheet();
}
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 {
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (!ok) return;
}
let avatarUrl = draftAvatarUrl;
if (draftAvatarTemp) {
avatarUrl = await uploadAvatarTempFile(draftAvatarTemp);
}
const updated = await uploadMiniWechatProfile({ nickname, avatarUrl });
if (updated) applyProfile({ ...updated, hasWechat: true });
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]) {
@@ -213,8 +282,16 @@ export default function MinePage() {
throw new Error('wx profile helpers missing'); throw new Error('wx profile helpers missing');
} }
const nickname = display.nickname || '用户'; const nickname = display.nickname || '用户';
const memberLabel = hasWechat ? '好客会员' : canWxAuth ? '未完成授权' : '未完成授权'; const needProfileFill = isWeapp && needsWxProfileFill(display);
void needsWxProfileFill(display); const memberLabel = needProfileFill
? '点击头像完善资料'
: hasWechat
? '好客会员'
: canWxAuth
? '点击头像授权'
: '好客会员';
const avatarClickable = isWeapp || (!hasWechat && canWxAuth);
const previewAvatar = draftAvatarUrl || display.avatarUrl;
return ( return (
<PageShell variant="tab" className="mine-page no-tab-header"> <PageShell variant="tab" className="mine-page no-tab-header">
@@ -223,24 +300,37 @@ export default function MinePage() {
<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 && canWxAuth ? ' mine-avatar-wrap--action' : ''}`} className={`mine-avatar-wrap${avatarClickable ? ' mine-avatar-wrap--action' : ''}`}
onClick={!hasWechat && canWxAuth ? () => void handleAvatarTap() : undefined} onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
> >
<View <View
className={`mine-avatar${hasWechat ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'}`} className={`mine-avatar${
!needProfileFill && hasWechat ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
}`}
> >
{renderAvatarContent(display.avatarUrl)} {renderAvatarContent(display.avatarUrl)}
</View> </View>
{!hasWechat && canWxAuth ? ( {avatarClickable ? (
<View className="mine-avatar-status mine-avatar-status--pending"> <View
<Text>{bindingWx ? '授权中' : '去授权'}</Text> className={`mine-avatar-status${
needProfileFill || !hasWechat
? ' mine-avatar-status--pending'
: ' mine-avatar-status--ok'
}`}
>
<Text>
{bindingWx ? '授权中' : needProfileFill || !hasWechat ? '去完善' : '更换'}
</Text>
</View> </View>
) : null} ) : null}
</View> </View>
<View className="mine-profile-meta"> <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${hasWechat && !needProfileFill ? ' mine-member-tag--wechat' : ''}`}>
{memberLabel} {memberLabel}
</Text> </Text>
</View> </View>
@@ -338,6 +428,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>
); );
} }
+123
View File
@@ -367,3 +367,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;
}
-15
View File
@@ -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