门店页面修改
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
type WechatScanAuthModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string;
|
||||||
|
onAuthorize: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WechatScanAuthModal({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onAuthorize,
|
||||||
|
onCancel,
|
||||||
|
}: WechatScanAuthModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
|
||||||
|
<div className="shop-scan-auth-card">
|
||||||
|
<div className="shop-scan-auth-icon">
|
||||||
|
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
|
||||||
|
</div>
|
||||||
|
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">微信授权</h2>
|
||||||
|
<p className="shop-scan-auth-desc">
|
||||||
|
扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。
|
||||||
|
</p>
|
||||||
|
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
|
||||||
|
<div className="shop-scan-auth-actions">
|
||||||
|
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
|
||||||
|
{loading ? '跳转授权中…' : '微信授权'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||||
|
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
||||||
|
const fromQuery = url.searchParams.get('token');
|
||||||
|
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
|
||||||
|
return fromQuery.toLowerCase();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not a URL */
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||||
|
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||||
|
import { isWechatEnv, weixinSdk } from './weixin';
|
||||||
|
import { request, saveAuth, type ShopSessionPayload } from './api';
|
||||||
|
|
||||||
|
export type ShopAccountProfile = {
|
||||||
|
id: string;
|
||||||
|
storeId: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
wxOpenId?: string | null;
|
||||||
|
store?: { name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
|
||||||
|
return request<ShopAccountProfile>('SHOP_H5', '/shop/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsWechatAuth(profile: ShopAccountProfile | null): boolean {
|
||||||
|
return isWechatEnv() && !!profile && !profile.wxOpenId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
||||||
|
if (!result.accessToken || !result.refreshToken) return null;
|
||||||
|
const store = result.store;
|
||||||
|
return {
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
refreshToken: result.refreshToken,
|
||||||
|
store: store
|
||||||
|
? {
|
||||||
|
id: String(store.id ?? ''),
|
||||||
|
storeId: String(store.storeId ?? ''),
|
||||||
|
name: String(store.name ?? ''),
|
||||||
|
phone: String(store.phone ?? ''),
|
||||||
|
storeName: String(store.storeName ?? store.name ?? ''),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveShopWechatAuth(result: WechatLoginResult): boolean {
|
||||||
|
const session = sessionFromWechatLogin(result);
|
||||||
|
if (!session) return false;
|
||||||
|
saveAuth(session);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
throw new Error('请在微信内打开以完成授权');
|
||||||
|
}
|
||||||
|
return weixinSdk.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> {
|
||||||
|
if (!isWechatEnv()) return null;
|
||||||
|
return weixinSdk.handleOAuthCallback();
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export const weixinSdk = createWeixinSdk({
|
|||||||
apiBase: '/api/v1',
|
apiBase: '/api/v1',
|
||||||
clientApp: 'SHOP_H5',
|
clientApp: 'SHOP_H5',
|
||||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||||
|
wechatLoginPath: '/shop/auth/login/wechat',
|
||||||
});
|
});
|
||||||
|
|
||||||
export { isWechatEnv };
|
export { isWechatEnv };
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } 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,
|
||||||
|
fetchShopAccount,
|
||||||
|
handleShopWechatCallback,
|
||||||
|
needsWechatAuth,
|
||||||
|
saveShopWechatAuth,
|
||||||
|
sessionFromWechatLogin,
|
||||||
|
} from '../lib/wechat-auth';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
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 });
|
||||||
@@ -9,37 +22,107 @@ function formatMoney(n: number) {
|
|||||||
|
|
||||||
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('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard').then((d) => {
|
||||||
setDash(d);
|
setDash(d);
|
||||||
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
|
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWechatEnv() || !searchParams.get('code')) return;
|
||||||
|
void handleShopWechatCallback()
|
||||||
|
.then((result) => {
|
||||||
|
if (!result) return;
|
||||||
|
const session = sessionFromWechatLogin(result);
|
||||||
|
if (session) {
|
||||||
|
saveShopWechatAuth(result);
|
||||||
|
applySession(session);
|
||||||
|
}
|
||||||
|
setAuthModalOpen(false);
|
||||||
|
setAuthError('');
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
||||||
|
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||||
|
if (shouldScan) {
|
||||||
|
void runScan();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||||
|
});
|
||||||
|
}, [searchParams, applySession, setSearchParams]);
|
||||||
|
|
||||||
|
async function runScan() {
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
navigate('/redeem');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setScanning(true);
|
||||||
|
setScanMsg('');
|
||||||
|
try {
|
||||||
|
await weixinSdk.init();
|
||||||
|
const raw = await weixinSdk.scanQrCode();
|
||||||
|
if (!raw) return;
|
||||||
|
const token = parseRedeemTokenFromScan(raw);
|
||||||
|
if (!token) {
|
||||||
|
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||||
|
} catch (e) {
|
||||||
|
setScanMsg(e instanceof Error ? e.message : '扫码失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScan() {
|
||||||
|
setScanMsg('');
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
navigate('/redeem');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const profile = await fetchShopAccount();
|
||||||
|
if (needsWechatAuth(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');
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
const closeTime = String(store?.closeTime || '22:00');
|
||||||
|
|
||||||
async function handleScan() {
|
|
||||||
if (isWechatEnv()) {
|
|
||||||
try {
|
|
||||||
await weixinSdk.init();
|
|
||||||
const token = await weixinSdk.scanQrCode();
|
|
||||||
if (token) {
|
|
||||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* fall through to manual redeem page */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
navigate('/redeem');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-home-page">
|
<div className="shop-home-page">
|
||||||
<header className="shop-home-header">
|
<header className="shop-home-header">
|
||||||
@@ -68,10 +151,16 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-scan">
|
<section className="shop-home-scan">
|
||||||
<button type="button" className="shop-home-scan-btn" onClick={handleScan}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-home-scan-btn"
|
||||||
|
disabled={scanning}
|
||||||
|
onClick={() => void handleScan()}
|
||||||
|
>
|
||||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="shop-home-scan-label">扫码核销</p>
|
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
||||||
|
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-status">
|
<section className="shop-home-status">
|
||||||
@@ -117,6 +206,18 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ function maskPhone(phone: string) {
|
|||||||
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEV_DEFAULT_CODE = import.meta.env.DEV ? '123456' : '';
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
const { applySession } = useStoreSession();
|
||||||
@@ -18,8 +16,8 @@ export default function LoginPage() {
|
|||||||
const quick = params.get('quick') === '1';
|
const quick = params.get('quick') === '1';
|
||||||
const savedProfile = getStoreProfile();
|
const savedProfile = getStoreProfile();
|
||||||
const [phone, setPhone] = useState(getLastPhone());
|
const [phone, setPhone] = useState(getLastPhone());
|
||||||
const [code, setCode] = useState(DEV_DEFAULT_CODE);
|
const [code, setCode] = useState('');
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [agreed, setAgreed] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
@@ -43,7 +41,7 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
||||||
});
|
});
|
||||||
setMsg(import.meta.env.DEV ? '验证码已发送(Mock: 123456)' : '验证码已发送');
|
setMsg('验证码已发送');
|
||||||
setCodeCooldown(60);
|
setCodeCooldown(60);
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCodeCooldown((c) => {
|
setCodeCooldown((c) => {
|
||||||
@@ -202,7 +200,7 @@ export default function LoginPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="shop-login-submit"
|
className="shop-login-submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={login}
|
onClick={() => void login()}
|
||||||
>
|
>
|
||||||
<span>{loading ? '登录中...' : '登录'}</span>
|
<span>{loading ? '登录中...' : '登录'}</span>
|
||||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ export default function RedeemConfirmPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
setMsg('请在开发者选项中输入核销码');
|
setMsg('请先扫码获取核销码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|||||||
+110
-2
@@ -152,23 +152,31 @@
|
|||||||
.shop-login-code-row {
|
.shop-login-code-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-login-code-row .shop-login-input-wrap {
|
.shop-login-code-row .shop-login-input-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-login-code-btn {
|
.shop-login-code-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0 16px;
|
padding: 0 14px;
|
||||||
background: #ffdad7;
|
background: #ffdad7;
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
font-family: var(--font-label);
|
font-family: var(--font-label);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -618,6 +626,106 @@
|
|||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-home-scan-msg {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 28px 24px 24px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-card);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(166, 29, 36, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-icon .material-symbols-outlined {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-desc {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-error {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-cancel,
|
||||||
|
.shop-scan-auth-confirm {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-cancel {
|
||||||
|
border: 1px solid var(--color-outline-variant);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-confirm {
|
||||||
|
border: none;
|
||||||
|
background: var(--color-heritage-red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-cancel:disabled,
|
||||||
|
.shop-scan-auth-confirm:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-home-status {
|
.shop-home-status {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
|
|||||||
@@ -23,16 +23,28 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null
|
|||||||
clientApp: config.clientApp,
|
clientApp: config.clientApp,
|
||||||
getAccessToken: config.getAccessToken,
|
getAccessToken: config.getAccessToken,
|
||||||
});
|
});
|
||||||
if (window.wx?.scanQRCode) {
|
if (!window.wx?.scanQRCode) {
|
||||||
return new Promise((resolve) => {
|
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
|
||||||
window.wx!.scanQRCode!({
|
|
||||||
needResult: 1,
|
|
||||||
scanType: ['qrCode', 'barCode'],
|
|
||||||
success: (res) => resolve(res.resultStr || null),
|
|
||||||
fail: () => resolve(null),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
window.wx!.scanQRCode!({
|
||||||
|
needResult: 1,
|
||||||
|
scanType: ['qrCode', 'barCode'],
|
||||||
|
success: (res) => resolve(res.resultStr || null),
|
||||||
|
fail: (res) => {
|
||||||
|
const msg = res.errMsg || '扫码失败';
|
||||||
|
if (/cancel/i.test(msg)) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/permission|auth|denied|授权|拒绝|camera/i.test(msg)) {
|
||||||
|
reject(new Error('相机权限未开启,请在微信设置中允许使用摄像头'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(msg));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null;
|
const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null;
|
||||||
|
|||||||
@@ -112,7 +112,17 @@ export class ShopAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/wechat')
|
@Post('login/wechat')
|
||||||
wechatLogin(@Body() dto: LoginWechatDto) {
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
|
wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) {
|
||||||
|
const user = (req as Request & { user?: AuthUser }).user;
|
||||||
|
if (user?.actorType === 'STORE') {
|
||||||
|
return this.authService.bindStoreWechat(
|
||||||
|
user.actorId,
|
||||||
|
dto.code,
|
||||||
|
ClientApp.SHOP_H5,
|
||||||
|
dto.platform ?? 'h5',
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -837,6 +837,50 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bindStoreWechat(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
code: string,
|
||||||
|
clientApp: ClientApp,
|
||||||
|
platform: 'h5' | 'mini' = 'h5',
|
||||||
|
) {
|
||||||
|
this.assertWechatEnabled();
|
||||||
|
const session =
|
||||||
|
platform === 'mini'
|
||||||
|
? await this.wechatProvider.code2Session(code)
|
||||||
|
: await this.wechatProvider.oauth2AccessToken(code);
|
||||||
|
|
||||||
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
|
where: { id: storeAccountId },
|
||||||
|
include: { store: true },
|
||||||
|
});
|
||||||
|
if (!account) throw new BadRequestException('门店账号不存在');
|
||||||
|
|
||||||
|
const conflict = await this.prisma.storeAccount.findFirst({
|
||||||
|
where: { wxOpenId: session.openId, id: { not: storeAccountId } },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
throw new BadRequestException('该微信已绑定其他门店账号');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.storeAccount.update({
|
||||||
|
where: { id: storeAccountId },
|
||||||
|
data: {
|
||||||
|
wxOpenId: session.openId,
|
||||||
|
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
},
|
||||||
|
include: { store: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.issueToken('STORE', updated.id, clientApp, false, undefined, {
|
||||||
|
id: updated.id.toString(),
|
||||||
|
storeId: updated.storeId.toString(),
|
||||||
|
name: updated.name,
|
||||||
|
phone: updated.phone,
|
||||||
|
storeName: updated.store.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async bindPartnerWechat(
|
async bindPartnerWechat(
|
||||||
partnerAccountId: bigint,
|
partnerAccountId: bigint,
|
||||||
code: string,
|
code: string,
|
||||||
|
|||||||
Reference in New Issue
Block a user