Compare commits

...

5 Commits

Author SHA1 Message Date
ljy 56dfffd126 fix:增加小程序客服 2026-07-21 22:24:53 +08:00
jacy bdf80e577b fix(mini-user): address review blockers
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 13:03:02 +08:00
jacy eb96b36d0b feat(redeem): streamline phone redemption confirmation
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:41:06 +08:00
jacy 36bec94639 fix(h5): temporarily disable WeChat camera flows
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 11:18:51 +08:00
jacy c11c7647b9 fix(h5): serve assets from dedicated domain roots
CI / verify (pull_request) Has been cancelled
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 10:51:23 +08:00
32 changed files with 712 additions and 615 deletions
@@ -1,8 +1,6 @@
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 { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError } from '../lib/toast'; import { toastError } from '../lib/toast';
type OssUploadFieldProps = { type OssUploadFieldProps = {
@@ -14,27 +12,10 @@ type OssUploadFieldProps = {
wide?: boolean; wide?: boolean;
compact?: boolean; compact?: boolean;
label?: string; label?: string;
/** 兼容现有调用:选图不再依赖 openId 绑定 */
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,
@@ -48,7 +29,6 @@ export default function OssUploadField({
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
function showUploadError(text: string) { function showUploadError(text: string) {
setError(text); setError(text);
@@ -57,17 +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)));
useEffect(() => {
if (!useWechatPicker) return;
weixinSdk.reset();
void weixinSdk.init().catch(() => {
/* 点击上传时会再次初始化 */
});
}, [useWechatPicker]);
async function persistUpload(file: File) { async function persistUpload(file: File) {
if (!file.size) { if (!file.size) {
@@ -95,60 +64,21 @@ export default function OssUploadField({
} }
} }
async function pickWechatImage() { function pickFile() {
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;
throw e;
} finally {
setUploading(false);
}
}
async function pickFile() {
if (uploading) return; if (uploading) return;
setError(''); setError('');
if (useWechatPicker) {
try {
await pickWechatImage();
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
const formatted = formatWechatUploadError(e);
setError(`${formatted},可改从系统相册选择`);
setShowAlbumFallback(true);
inputRef.current?.click();
}
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; 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, disabled: busy,
onClick: () => void pickFile(), onClick: pickFile,
}; };
return ( return (
@@ -162,7 +92,6 @@ export default function OssUploadField({
onChange={(e) => { onChange={(e) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
setShowAlbumFallback(false);
void uploadSelectedFile(file); void uploadSelectedFile(file);
} }
}} }}
@@ -191,24 +120,13 @@ 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 ? '上传中…' : 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>
); );
+4 -35
View File
@@ -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))}
/> />
))} ))}
+2 -1
View File
@@ -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: {
+1 -2
View File
@@ -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 />}>
+4 -137
View File
@@ -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>
); );
} }
+85 -210
View File
@@ -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>
+2 -1
View File
@@ -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: {
@@ -0,0 +1,50 @@
import { Button, Text } from '@tarojs/components';
import type { ReactNode } from 'react';
export type ContactCsSessionContext = {
orderId?: string;
orderNo?: string;
from?: string;
};
type ContactCsButtonProps = {
className?: string;
children?: ReactNode;
/** 客服会话来源上下文,便于客服后台识别 */
session?: ContactCsSessionContext;
};
const isWeapp = process.env.TARO_ENV === 'weapp';
/** 组装 session-from(微信限制约 1000 字符) */
export function buildCsSessionFrom(session?: ContactCsSessionContext): string {
if (!session) return 'dukang|from=mini-user';
const parts = ['dukang'];
if (session.from) parts.push(`from=${session.from}`);
if (session.orderNo) parts.push(`orderNo=${session.orderNo}`);
if (session.orderId) parts.push(`orderId=${session.orderId}`);
return parts.join('|');
}
/**
* 微信小程序客服入口(open-type=contact)。
* 非 weapp 环境不渲染,由调用方走电话等兜底。
*/
export default function ContactCsButton({
className = '',
children = '联系在线客服',
session,
}: ContactCsButtonProps) {
if (!isWeapp) return null;
return (
<Button
className={className}
openType="contact"
sessionFrom={buildCsSessionFrom(session)}
hoverClass="none"
>
{typeof children === 'string' ? <Text>{children}</Text> : children}
</Button>
);
}
+1 -18
View File
@@ -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 || '登录已过期,请重新登录');
+18 -3
View File
@@ -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 页,或回退 / 首页 */
+36 -8
View File
@@ -6,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) {
@@ -55,8 +66,8 @@ export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
}; };
} }
/** 上传 chooseAvatar 临时文件到 OSS,返回永久 URL */ /** 上传 chooseAvatar 临时文件到 OSS返回已登记到当前用户的真实资源。 */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<string> { export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api'); const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const token = getToken(); const token = getToken();
if (!token) throw new Error('请先登录'); if (!token) throw new Error('请先登录');
@@ -75,7 +86,11 @@ export async function uploadAvatarTempFile(tempFilePath: string): Promise<string
}, },
}); });
let body: { code?: number; message?: string; data?: { url?: string } } = {}; let body: {
code?: number;
message?: string;
data?: { resourceId?: string; url?: string; bucket?: string; ossKey?: string };
} = {};
try { try {
body = JSON.parse(String(res.data || '{}')) as typeof body; body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch { } catch {
@@ -84,14 +99,26 @@ export async function uploadAvatarTempFile(tempFilePath: string): Promise<string
if (res.statusCode === 401 || body.code === 401) { if (res.statusCode === 401 || body.code === 401) {
throw new Error(body.message || '登录已过期,请重新登录'); throw new Error(body.message || '登录已过期,请重新登录');
} }
if (res.statusCode >= 400 || body.code !== 0 || !body.data?.url) { if (
res.statusCode >= 400 ||
body.code !== 0 ||
!body.data?.resourceId ||
!body.data.url ||
!body.data.bucket ||
!body.data.ossKey
) {
throw new Error(body.message || '头像上传失败'); throw new Error(body.message || '头像上传失败');
} }
return body.data.url; return {
resourceId: body.data.resourceId,
url: body.data.url,
bucket: body.data.bucket,
ossKey: body.data.ossKey,
};
} }
export async function uploadMiniWechatProfile(info: MiniWechatProfile): Promise<UserProfile | null> { export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
if (!info.nickname && !info.avatarUrl) return null; if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
const { request } = await import('./api'); const { request } = await import('./api');
const updated = await request<UserProfile>('/auth/wechat/mini-profile', { const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
method: 'POST', method: 'POST',
@@ -124,6 +151,7 @@ export async function syncMiniWechatProfile(
if (process.env.TARO_ENV !== 'weapp') return null; if (process.env.TARO_ENV !== 'weapp') return null;
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile(); const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
if (!info?.nickname && !info?.avatarUrl) return null; if (!info?.nickname && !info?.avatarUrl) return null;
await uploadMiniWechatProfile(info); // 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
return info; return info;
} }
@@ -3,28 +3,42 @@ import Taro from '@tarojs/taro';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types'; import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import PageShell from '../../components/PageShell'; import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import ContactCsButton from '../../components/ContactCsButton';
import { toast } from '../../lib/api'; import { toast } from '../../lib/api';
const isWeapp = process.env.TARO_ENV === 'weapp';
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, ''); const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function dialPhone() {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
}
export default function CustomerServicePage() { export default function CustomerServicePage() {
return ( return (
<PageShell variant="sub" className="cs-page"> <PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" /> <SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page cs-body"> <View className="sub-page-body inset-page cs-body">
<Text className="cs-title">线</Text> <Text className="cs-brand"></Text>
<Text className="cs-phone">{CUSTOMER_SERVICE_PHONE}</Text> <Text className="cs-hint">
<Text className="cs-hint">9:00 - 21:00</Text> {isWeapp
<View ? '点击下方按钮,进入小程序在线客服会话'
className="cs-call-btn" : '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
onClick={() => { </Text>
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => <Text className="cs-hours">9:00 - 21:00</Text>
toast('无法拨打电话'),
); {isWeapp ? (
}} <ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
> ) : null}
<Text></Text>
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
<Text>
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
</Text>
</View> </View>
{!isWeapp ? (
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
) : null}
</View> </View>
</PageShell> </PageShell>
); );
+22 -18
View File
@@ -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,20 +49,26 @@ 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 variant="tab" className="home-page no-tab-header"> <PageShell variant="tab" className="home-page no-tab-header">
@@ -70,11 +76,11 @@ export default function HomePage() {
<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>
@@ -85,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 (
+24
View File
@@ -150,6 +150,19 @@ export default function LoginPage() {
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,
phoneValue?: string, phoneValue?: string,
@@ -370,6 +383,12 @@ export default function LoginPage() {
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">
@@ -523,6 +542,11 @@ export default function LoginPage() {
<WechatLoginButton loading={wxLoading} label="授权登录" 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>
); );
+47 -23
View File
@@ -50,11 +50,13 @@ export default function MinePage() {
const [draftAvatarUrl, setDraftAvatarUrl] = useState(''); const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
const [draftNickname, setDraftNickname] = useState(''); const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false); 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) { function applyProfile(me: UserProfile) {
@@ -63,6 +65,7 @@ export default function MinePage() {
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(() => []),
@@ -83,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(() => {
@@ -162,22 +174,20 @@ export default function MinePage() {
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */ /** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
async function handleAvatarTap() { async function handleAvatarTap() {
if (bindingWx || savingProfile) return;
if (!isLoggedIn()) { if (!isLoggedIn()) {
goLogin('/pages/mine/index'); goLogin('/pages/mine/index');
return; return;
} }
if (!isWeapp) { if (isWeapp) {
if (!profile?.hasWechat) { openProfileSheet();
const ok = await ensureWechatBound();
if (ok) loadProfile();
}
return; return;
} }
if (!profile?.hasWechat) { if (!profile?.hasWechat) {
const ok = await ensureWechatBound(); const ok = await ensureWechatBound();
if (!ok) return; if (ok) loadProfile();
} }
openProfileSheet(); return;
} }
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) { async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
@@ -202,16 +212,18 @@ export default function MinePage() {
} }
setSavingProfile(true); setSavingProfile(true);
try { try {
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (!ok) return;
}
let avatarUrl = draftAvatarUrl; let avatarUrl = draftAvatarUrl;
let avatarResourceId: string | undefined;
if (draftAvatarTemp) { if (draftAvatarTemp) {
avatarUrl = await uploadAvatarTempFile(draftAvatarTemp); const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
avatarUrl = uploaded.url;
avatarResourceId = uploaded.resourceId;
} }
const updated = await uploadMiniWechatProfile({ nickname, avatarUrl }); const updated = await uploadMiniWechatProfile({
if (updated) applyProfile({ ...updated, hasWechat: true }); nickname,
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
});
if (updated) applyProfile(updated);
setProfileSheetOpen(false); setProfileSheetOpen(false);
toast('头像昵称已更新', 'success'); toast('头像昵称已更新', 'success');
loadProfile(); loadProfile();
@@ -262,7 +274,9 @@ 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>
<View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}> <View className="mine-login-btn" onClick={() => goLogin('/pages/mine/index')}>
<Text></Text> <Text></Text>
</View> </View>
@@ -283,9 +297,10 @@ export default function MinePage() {
} }
const nickname = display.nickname || '用户'; const nickname = display.nickname || '用户';
const needProfileFill = isWeapp && needsWxProfileFill(display); const needProfileFill = isWeapp && needsWxProfileFill(display);
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
const memberLabel = needProfileFill const memberLabel = needProfileFill
? '点击头像完善资料' ? '点击头像完善资料'
: hasWechat : isWeapp || hasWechat
? '好客会员' ? '好客会员'
: canWxAuth : canWxAuth
? '点击头像授权' ? '点击头像授权'
@@ -305,7 +320,7 @@ export default function MinePage() {
> >
<View <View
className={`mine-avatar${ className={`mine-avatar${
!needProfileFill && hasWechat ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending' avatarProfileReady ? ' mine-avatar--wx-ok' : ' mine-avatar--wx-pending'
}`} }`}
> >
{renderAvatarContent(display.avatarUrl)} {renderAvatarContent(display.avatarUrl)}
@@ -313,13 +328,11 @@ export default function MinePage() {
{avatarClickable ? ( {avatarClickable ? (
<View <View
className={`mine-avatar-status${ className={`mine-avatar-status${
needProfileFill || !hasWechat avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
? ' mine-avatar-status--pending'
: ' mine-avatar-status--ok'
}`} }`}
> >
<Text> <Text>
{bindingWx ? '授权中' : needProfileFill || !hasWechat ? '去完善' : '更换'} {bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
</Text> </Text>
</View> </View>
) : null} ) : null}
@@ -330,9 +343,20 @@ export default function MinePage() {
onClick={avatarClickable ? () => void handleAvatarTap() : undefined} 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 && !needProfileFill ? ' mine-member-tag--wechat' : ''}`}> <Text className={`mine-member-tag${avatarProfileReady ? ' mine-member-tag--wechat' : ''}`}>
{memberLabel} {memberLabel}
</Text> </Text>
{profileLoadError ? (
<Text
className="mine-profile-retry"
onClick={(event) => {
event.stopPropagation();
loadProfile();
}}
>
</Text>
) : null}
</View> </View>
</View> </View>
</View> </View>
+48 -13
View File
@@ -5,6 +5,7 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader'; import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton'; import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady'; import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton';
import { request, toast } from '../../lib/api'; import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav'; import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone'; import { maskPhone } from '../../lib/phone';
@@ -14,6 +15,8 @@ import {
toWeappShareMessage, toWeappShareMessage,
} from '../../lib/wechat-share'; } from '../../lib/wechat-share';
const isWeapp = process.env.TARO_ENV === 'weapp';
type OrderItem = { type OrderItem = {
productName?: string; productName?: string;
productSpec?: string; productSpec?: string;
@@ -104,8 +107,20 @@ export default function OrderDetailPage() {
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) }); Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) });
} }
function goCustomerService() {
Taro.navigateTo({ url: '/pages/customer-service/index' });
}
const pageClass = [
'order-detail-page',
order ? 'order-detail-page--with-actions' : '',
canPay ? 'order-detail-page--with-pay' : '',
]
.filter(Boolean)
.join(' ');
return ( return (
<PageShell variant="sub" className={`order-detail-page${canPay ? ' order-detail-page--with-pay' : ''}`}> <PageShell variant="sub" className={pageClass}>
<WechatShareReady payload={sharePayload} /> <WechatShareReady payload={sharePayload} />
<SubPageHeader <SubPageHeader
title="订单详情" title="订单详情"
@@ -173,19 +188,39 @@ export default function OrderDetailPage() {
)} )}
</View> </View>
{canPay && order && ( {order ? (
<View className="pay-bar order-detail-pay-bar"> <View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}>
<View className="order-confirm-total"> {isWeapp ? (
<Text className="order-confirm-total-label"></Text> <ContactCsButton
<Text className="order-confirm-total-value"> className="order-detail-cs-btn"
¥{Number(order.payAmount ?? 0).toFixed(2)} session={{
</Text> from: 'order-detail',
</View> orderId: order.id,
<View className="order-confirm-submit" onClick={goPay}> orderNo: order.orderNo,
}}
</View> >
</ContactCsButton>
) : (
<View className="order-detail-cs-btn" onClick={goCustomerService}>
<Text></Text>
</View>
)}
{canPay ? (
<>
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{Number(order.payAmount ?? 0).toFixed(2)}
</Text>
</View>
<View className="order-confirm-submit" onClick={goPay}>
</View>
</>
) : null}
</View> </View>
)} ) : null}
</PageShell> </PageShell>
); );
} }
-4
View File
@@ -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;
+48
View File
@@ -326,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;
}
+8
View File
@@ -149,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;
+53 -1
View File
@@ -7,7 +7,8 @@
min-height: 100vh; min-height: 100vh;
} }
.order-detail-page--with-pay .sub-page-body { .order-detail-page--with-pay .sub-page-body,
.order-detail-page--with-actions .sub-page-body {
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px)); padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
} }
@@ -16,6 +17,57 @@
gap: 12px; gap: 12px;
} }
.order-detail-actionbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 10px;
padding: 10px var(--space-page) calc(10px + env(safe-area-inset-bottom, 0px));
background: var(--color-card, #fff);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
box-sizing: border-box;
}
.order-detail-actionbar--with-pay {
justify-content: space-between;
}
.order-detail-cs-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 96px;
height: 40px;
padding: 0 14px;
margin: 0;
border: 1px solid var(--color-outline, #c8c4be);
border-radius: 999px;
background: transparent;
color: var(--color-on-surface);
font-size: 14px;
font-weight: 600;
line-height: 1.2;
box-sizing: border-box;
}
.order-detail-cs-btn::after {
border: none;
}
.order-detail-actionbar .order-confirm-total {
flex: 1;
min-width: 0;
}
.order-detail-actionbar .order-confirm-submit {
flex-shrink: 0;
}
.order-card { .order-card {
background: var(--color-card); background: var(--color-card);
border-radius: var(--radius-lg); border-radius: var(--radius-lg);
+55 -3
View File
@@ -499,27 +499,70 @@
text-align: center; text-align: center;
} }
.cs-brand {
display: block;
font-family: var(--font-headline);
font-size: 22px;
font-weight: 700;
color: var(--color-on-surface);
margin-bottom: 10px;
}
.cs-title { .cs-title {
font-size: 14px; font-size: 14px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
margin-bottom: 12px; margin-bottom: 12px;
} }
.cs-phone { .cs-phone,
.cs-phone-display {
display: block;
font-family: var(--font-headline); font-family: var(--font-headline);
font-size: 32px; font-size: 28px;
font-weight: 700; font-weight: 700;
color: var(--color-heritage-red); color: var(--color-heritage-red);
margin-bottom: 8px; margin-top: 20px;
letter-spacing: 1px; letter-spacing: 1px;
} }
.cs-hint { .cs-hint {
display: block;
font-size: 14px;
color: var(--color-subtle-gray);
line-height: 1.5;
margin-bottom: 8px;
max-width: 280px;
}
.cs-hours {
display: block;
font-size: 12px; font-size: 12px;
color: var(--color-subtle-gray); color: var(--color-subtle-gray);
margin-bottom: 32px; margin-bottom: 32px;
} }
.cs-online-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 280px;
padding: 14px 24px;
margin: 0;
border: none;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
box-sizing: border-box;
}
.cs-online-btn::after {
border: none;
}
.cs-call-btn { .cs-call-btn {
padding: 12px 32px; padding: 12px 32px;
border-radius: 999px; border-radius: 999px;
@@ -528,3 +571,12 @@
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
} }
.cs-phone-link {
margin-top: 20px;
padding: 8px 12px;
font-size: 13px;
color: var(--color-subtle-gray);
text-decoration: underline;
text-underline-offset: 3px;
}
+16
View File
@@ -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> = {
@@ -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: {
@@ -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) {
@@ -1407,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;
@@ -1425,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);
} }
@@ -118,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
View File
@@ -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 |