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 { enqueueUpload } from '../lib/upload-lock';
import { formatChooseImageFailMessage } from '@dukang/weixin-sdk';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { toastError } from '../lib/toast';
type OssUploadFieldProps = {
@@ -14,27 +12,10 @@ type OssUploadFieldProps = {
wide?: boolean;
compact?: boolean;
label?: string;
/** 兼容现有调用:选图不再依赖 openId 绑定 */
wechatReady?: boolean;
onWechatReadyChange?: (ready: boolean) => void;
};
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({
value,
onChange,
@@ -48,7 +29,6 @@ export default function OssUploadField({
const inputRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const [showAlbumFallback, setShowAlbumFallback] = useState(false);
function showUploadError(text: string) {
setError(text);
@@ -57,17 +37,6 @@ export default function OssUploadField({
const resolvedAccept =
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) {
if (!file.size) {
@@ -95,60 +64,21 @@ export default function OssUploadField({
}
}
async function pickWechatImage() {
setUploading(true);
setError('');
try {
weixinSdk.reset();
await weixinSdk.init();
// 选图 + 上传须在同一个队列任务内完成,避免嵌套 enqueueUpload 死锁
await enqueueUpload(async () => {
const files = await weixinSdk.chooseImages({
count: 1,
sourceType: ['album', 'camera'],
});
if (!files?.[0]) return;
await persistUpload(files[0]);
});
} catch (e) {
const msg = e instanceof Error ? e.message : '无法打开相册';
if (/cancel/i.test(msg)) return;
throw e;
} finally {
setUploading(false);
}
}
async function pickFile() {
function pickFile() {
if (uploading) return;
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();
}
const isImage = mediaType === 'IMAGE' && value;
const isFile = mediaType === 'FILE' && value;
const busy = uploading;
const pickerLabel = label ?? (useWechatPicker ? '拍照 / 从相册选择' : '点击上传');
const pickerLabel = label ?? (mediaType === 'IMAGE' ? '从系统相册选择' : '点击上传');
const triggerProps = {
type: 'button' as const,
disabled: busy,
onClick: () => void pickFile(),
onClick: pickFile,
};
return (
@@ -162,7 +92,6 @@ export default function OssUploadField({
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setShowAlbumFallback(false);
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' : ''}`}
>
<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 className="text-primary" style={{ fontWeight: 500 }}>
{uploading ? '上传中…' : pickerLabel}
</span>
</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>}
</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';
@@ -17,8 +17,6 @@ import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
import { isWechatEnv, weixinSdk } from '../lib/weixin';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import {
@@ -79,16 +77,10 @@ export default function StoreCreatePage() {
const navigate = useNavigate();
const { account, refresh } = usePartnerSession();
const { account } = usePartnerSession();
const accountId = account?.id;
const wechatReady = !!account?.hasWechat;
const handleWechatReadyChange = useCallback(() => {
void refresh();
}, [refresh]);
const [params, setParams] = useSearchParams();
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(() => {
void fetchPartnerCities()
@@ -895,13 +876,9 @@ export default function StoreCreatePage() {
value={form.coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(coverUrl) => patchForm({ coverUrl })}
label="点击或拖拽上传"
label="从系统相册选择"
/>
@@ -931,10 +908,6 @@ export default function StoreCreatePage() {
value={url}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
/>
@@ -951,7 +924,7 @@ export default function StoreCreatePage() {
<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
@@ -963,10 +936,6 @@ export default function StoreCreatePage() {
value={form.contractUrl}
wechatReady={wechatReady}
onWechatReadyChange={handleWechatReadyChange}
onChange={(contractUrl) => patchForm({ contractUrl })}
label="上传合同副本"
@@ -47,7 +47,6 @@ export default function StoreDetailPage() {
const [saving, setSaving] = useState(false);
const [mediaSaving, setMediaSaving] = useState(false);
const [actionError, setActionError] = useState('');
const [wechatReady, setWechatReady] = useState(false);
function applyStore(data: Record<string, unknown>) {
setStore(data);
@@ -284,10 +283,8 @@ export default function StoreDetailPage() {
bizType="STORE_TITLE"
mediaType="IMAGE"
value={coverUrl}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
onChange={setCoverUrl}
label="点击更换门头照"
label="从系统相册选择"
/>
</div>
)}
@@ -336,8 +333,6 @@ export default function StoreDetailPage() {
bizType="STORE_ENV"
mediaType="IMAGE"
value={url}
wechatReady={wechatReady}
onWechatReadyChange={setWechatReady}
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';
export default defineConfig({
base: '/partner/',
// 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()],
resolve: {
alias: {
+1 -2
View File
@@ -5,7 +5,6 @@ import LoginPage from './pages/LoginPage';
import LegalPage from './pages/LegalPage';
import SelectStorePage from './pages/SelectStorePage';
import HomePage from './pages/HomePage';
import RedeemConfirmPage from './pages/RedeemConfirmPage';
import PhoneRedeemPage from './pages/PhoneRedeemPage';
import RedeemSuccessPage from './pages/RedeemSuccessPage';
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="/select-store" element={<SelectStorePage />} />
<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/success" element={<RedeemSuccessPage />} />
<Route element={<TabLayout />}>
+4 -137
View File
@@ -1,47 +1,15 @@
import { useCallback, useEffect, useState } from 'react';
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
import { useStoreSession } from '../contexts/StoreSessionContext';
import { Link, useNavigate } from 'react-router-dom';
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) {
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() {
const navigate = useNavigate();
const { applySession } = useStoreSession();
const [searchParams, setSearchParams] = useSearchParams();
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
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(() => {
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
@@ -58,7 +26,6 @@ export default function HomePage() {
useEffect(() => {
function onResume() {
setScanning(false);
void loadDashboard();
}
function onVisibility() {
@@ -74,88 +41,6 @@ export default function HomePage() {
};
}, [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 recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
const openTime = String(store?.openTime || '10:00');
@@ -189,20 +74,13 @@ export default function HomePage() {
</section>
<section className="shop-home-scan">
<button
type="button"
<Link
to="/redeem/phone"
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>
</Link>
<p className="shop-home-scan-label"></p>
</section>
<section className="shop-home-status">
@@ -249,17 +127,6 @@ export default function HomePage() {
</section>
</div>
<WechatScanAuthModal
open={authModalOpen}
loading={authLoading}
error={authError}
onAuthorize={() => void startWechatAuth()}
onCancel={() => {
setAuthModalOpen(false);
setAuthError('');
sessionStorage.removeItem(PENDING_SCAN_KEY);
}}
/>
</div>
);
}
+85 -210
View File
@@ -1,33 +1,22 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
import { request } from '../lib/api';
type BalanceResult = {
sessionId: string;
totalBalance: number;
maskedPhone: string;
user?: { nickname?: string; phone?: string; userNo?: string };
};
function formatAmount(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
type Step = 'lookup' | 'amount' | 'confirm';
export default function PhoneRedeemPage() {
const navigate = useNavigate();
const [step, setStep] = useState<Step>('lookup');
const [phone, setPhone] = useState('');
const [lookupCode, setLookupCode] = useState('');
const [confirmCode, setConfirmCode] = 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 [storeClosed, setStoreClosed] = useState(false);
const [msg, setMsg] = useState('');
const [loading, setLoading] = useState(false);
const [lookupCooldown, setLookupCooldown] = useState(0);
const [confirmCooldown, setConfirmCooldown] = useState(0);
useEffect(() => {
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
.catch(() => setStoreName('当前门店'));
}, []);
useEffect(() => {
if (lookupCooldown <= 0) return;
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [lookupCooldown]);
useEffect(() => {
if (confirmCooldown <= 0) return;
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
return () => window.clearTimeout(timer);
}, [confirmCooldown]);
async function sendLookupSms() {
async function sendConfirmSms() {
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入正确的手机号');
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) {
setMsg('门店未营业,无法核销');
return;
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
setMsg('请输入有效核销金额');
return;
}
if (balance && value > balance.totalBalance) {
setMsg('核销金额不能超过可用权益');
return;
}
setLoading(true);
setMsg('');
try {
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
method: 'POST',
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
body: JSON.stringify({ phone: phone.trim(), amount: value }),
});
setPrepared(result);
setConfirmCode('');
setConfirmCooldown(60);
setStep('confirm');
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发起核销失败');
setPrepared(null);
setMsg(e instanceof Error ? e.message : '发送验证码失败');
} finally {
setLoading(false);
}
}
async function confirmRedeem() {
if (!prepared) {
setMsg('请先发送核销验证码');
return;
}
if (!confirmCode.trim()) {
setMsg('请输入确认验证码');
return;
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
method: 'POST',
body: JSON.stringify({
sessionId: balance?.sessionId,
sessionId: prepared.sessionId,
code: confirmCode.trim(),
}),
});
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
navigate('/redeem/success', {
state: { result, storeName, user: balance?.user },
state: { result, storeName, user: prepared.user },
});
} catch (e) {
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 (
<div className="shop-redeem-page">
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
</div>
<div className="shop-redeem-body">
{step === 'lookup' && (
<>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="tel"
maxLength={11}
placeholder="请输入用户手机号"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
/>
</div>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<div className="shop-phone-code-row">
<input
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>
</>
)}
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<input
className="shop-phone-input"
type="tel"
maxLength={11}
placeholder="请输入用户手机号"
value={phone}
disabled={loading}
onChange={(e) => {
setPhone(e.target.value.replace(/\D/g, ''));
setPrepared(null);
setConfirmCode('');
setConfirmCooldown(0);
}}
/>
</div>
{step === 'amount' && balance && (
<>
<div className="shop-redeem-user">
<div className="shop-redeem-user-left">
<span className="material-symbols-outlined">person</span>
<span></span>
</div>
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
{userLabel}
</span>
</div>
<div className="shop-redeem-amount-section">
<p className="shop-redeem-amount-label"></p>
<div className="shop-redeem-amount">
<span className="shop-redeem-amount-symbol">¥</span>
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
</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>
</>
)}
<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}
disabled={loading}
onChange={(e) => {
setAmount(e.target.value);
setPrepared(null);
setConfirmCode('');
setConfirmCooldown(0);
}}
/>
</div>
{step === 'confirm' && balance && (
<>
<div className="shop-redeem-details">
<div className="shop-redeem-detail-row">
<span></span>
<span>{userLabel}</span>
</div>
<div className="shop-redeem-detail-row">
<span></span>
<span>¥{formatAmount(Number(amount))}</span>
</div>
</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>
<div className="shop-phone-field">
<label className="shop-phone-label"></label>
<div className="shop-phone-code-row">
<input
className="shop-phone-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="输入用户收到的验证码"
value={confirmCode}
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
/>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed}
onClick={() => void confirmRedeem()}
className="shop-phone-code-btn"
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
onClick={() => void sendConfirmSms()}
>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
</button>
<button
type="button"
className="shop-phone-link-btn"
onClick={() => {
setStep('amount');
setConfirmCode('');
}}
>
</button>
</>
)}
</div>
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
</p>
</div>
<button
type="button"
className="shop-redeem-confirm-btn"
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
onClick={() => void confirmRedeem()}
>
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
</button>
{msg && <p className="shop-redeem-error">{msg}</p>}
</div>
+2 -1
View File
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
base: '/shop/',
// 独立域名 shop.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
base: process.env.VITE_PUBLIC_BASE ?? '/',
plugins: [react()],
resolve: {
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 { ClientApp } from '@dukang/shared-types';
import { goLogin, forceReloadAfterAccountMerge } from './auth-nav';
import { forceReloadAfterAccountMerge } from './auth-nav';
function resolveApiBase(): string {
const origin =
@@ -54,10 +54,6 @@ export function isLoggedIn(): boolean {
return !!getToken();
}
export function redirectToLogin() {
goLogin();
}
export function logout() {
clearAuth();
Taro.reLaunch({ url: '/pages/home/index' });
@@ -76,17 +72,6 @@ function parseBody(data: unknown): { code?: number; message?: string } {
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 } */
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
const header: Record<string, string> = {
@@ -115,8 +100,6 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
if (/账号已合并/.test(mergedMsg)) {
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
forceReloadAfterAccountMerge();
} else if (!isOnLoginPage()) {
redirectToLogin();
}
}
throw new Error(body?.message || '登录已过期,请重新登录');
+18 -3
View File
@@ -7,6 +7,14 @@ const TAB_PAGES = new Set([
'/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 {
const pages = Taro.getCurrentPages();
const cur = pages[pages.length - 1] as
@@ -25,6 +33,7 @@ function currentPagePath(): string {
/** 跳转登录页;默认带回当前页作为 return */
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
if (loginNavigationPending || isLoginPageActive()) return;
const returnTo = returnPath ?? currentPagePath();
const parts: string[] = [];
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';
Taro.navigateTo({ url }).catch(() => {
Taro.redirectTo({ url });
});
loginNavigationPending = true;
void Taro.navigateTo({ url })
.catch(() => Taro.redirectTo({ url }))
.finally(() => {
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
setTimeout(() => {
loginNavigationPending = false;
}, 500);
});
}
/** 登录成功后回到 return 页,或回退 / 首页 */
+36 -8
View File
@@ -6,6 +6,17 @@ export type MiniWechatProfile = {
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';
export function cacheWxProfile(info: MiniWechatProfile) {
@@ -55,8 +66,8 @@ export function mergeWxDisplayProfile(profile: UserProfile): UserProfile {
};
}
/** 上传 chooseAvatar 临时文件到 OSS,返回永久 URL */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<string> {
/** 上传 chooseAvatar 临时文件到 OSS返回已登记到当前用户的真实资源。 */
export async function uploadAvatarTempFile(tempFilePath: string): Promise<UploadedAvatarResource> {
const { API_BASE, CLIENT_APP, getToken } = await import('./api');
const token = getToken();
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 {
body = JSON.parse(String(res.data || '{}')) as typeof body;
} catch {
@@ -84,14 +99,26 @@ export async function uploadAvatarTempFile(tempFilePath: string): Promise<string
if (res.statusCode === 401 || body.code === 401) {
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 || '头像上传失败');
}
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> {
if (!info.nickname && !info.avatarUrl) return null;
export async function uploadMiniWechatProfile(info: MiniWechatProfileUpdate): Promise<UserProfile | null> {
if (!info.nickname && !info.avatarUrl && !info.avatarResourceId) return null;
const { request } = await import('./api');
const updated = await request<UserProfile>('/auth/wechat/mini-profile', {
method: 'POST',
@@ -124,6 +151,7 @@ export async function syncMiniWechatProfile(
if (process.env.TARO_ENV !== 'weapp') return null;
const info = prefetched?.nickname || prefetched?.avatarUrl ? prefetched : getCachedWxProfile();
if (!info?.nickname && !info?.avatarUrl) return null;
await uploadMiniWechatProfile(info);
// 缓存头像 URL 可能来自历史微信资料,未经过当前 OSS 上传登记;这里只同步昵称。
if (info.nickname) await uploadMiniWechatProfile({ nickname: info.nickname });
return info;
}
@@ -3,28 +3,42 @@ import Taro from '@tarojs/taro';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ContactCsButton from '../../components/ContactCsButton';
import { toast } from '../../lib/api';
const isWeapp = process.env.TARO_ENV === 'weapp';
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function dialPhone() {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
}
export default function CustomerServicePage() {
return (
<PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page cs-body">
<Text className="cs-title">线</Text>
<Text className="cs-phone">{CUSTOMER_SERVICE_PHONE}</Text>
<Text className="cs-hint">9:00 - 21:00</Text>
<View
className="cs-call-btn"
onClick={() => {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() =>
toast('无法拨打电话'),
);
}}
>
<Text></Text>
<Text className="cs-brand"></Text>
<Text className="cs-hint">
{isWeapp
? '点击下方按钮,进入小程序在线客服会话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
</Text>
<Text className="cs-hours">9:00 - 21:00</Text>
{isWeapp ? (
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null}
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
<Text>
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
</Text>
</View>
{!isWeapp ? (
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
) : null}
</View>
</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 Taro, { useDidShow } from '@tarojs/taro';
import PageShell from '../../components/PageShell';
@@ -21,9 +21,9 @@ type Product = {
};
const AROMA_TABS = [
{ key: 'QINGXIANG', label: '清香型', open: true },
{ key: 'JIANGXIANG', label: '酱香型', open: false },
{ key: 'NONGXIANG', label: '浓香型', open: false },
{ key: 'QINGXIANG', label: '清香型' },
{ key: 'JIANGXIANG', label: '酱香型' },
{ key: 'NONGXIANG', label: '浓香型' },
] as const;
export default function HomePage() {
@@ -49,20 +49,26 @@ export default function HomePage() {
.finally(() => setLoading(false));
}, [cityCode]);
function onAromaTabClick(key: string, open: boolean) {
if (!open) {
toast('暂未开放');
return;
const availableAromas = useMemo(
() =>
AROMA_TABS.filter((item) =>
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) {
Taro.navigateTo({ url: `/pages/product-detail/index?id=${id}` });
}
const filtered = products.filter((p) => p.aromaType === tab);
const onSale = tab === 'QINGXIANG';
return (
<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-tabs">
{AROMA_TABS.map((t) => (
{availableAromas.map((t) => (
<Text
key={t.key}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}${!t.open ? ' home-aroma-tab--muted' : ''}`}
onClick={() => onAromaTabClick(t.key, t.open)}
className={`home-aroma-tab${tab === t.key ? ' home-aroma-tab--active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</Text>
@@ -85,12 +91,10 @@ export default function HomePage() {
<View className="home-product-list">
{loading ? <View className="home-empty"></View> : null}
{!loading && !onSale ? <View className="home-empty">线</View> : null}
{!loading && onSale && filtered.length === 0 ? (
<View className="home-empty"></View>
{!loading && products.length === 0 ? (
<View className="home-empty"></View>
) : null}
{!loading &&
onSale &&
filtered.map((p) => {
const images = getProductImages(p);
return (
+24
View File
@@ -150,6 +150,19 @@ export default function LoginPage() {
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(
data: SessionPayload | WechatLoginResult,
phoneValue?: string,
@@ -370,6 +383,12 @@ export default function LoginPage() {
return (
<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-logo-wrap">
<View className="login-logo">
@@ -523,6 +542,11 @@ export default function LoginPage() {
<WechatLoginButton loading={wxLoading} label="授权登录" onClick={() => void wechatLogin()} />
</>
) : null}
<View className="login-cancel-btn" onClick={cancelLogin}>
<Text></Text>
</View>
<Text className="login-cancel-hint"></Text>
</View>
</PageShell>
);
+47 -23
View File
@@ -50,11 +50,13 @@ export default function MinePage() {
const [draftAvatarUrl, setDraftAvatarUrl] = useState('');
const [draftNickname, setDraftNickname] = useState('');
const [savingProfile, setSavingProfile] = useState(false);
const [profileLoadError, setProfileLoadError] = useState('');
function resetGuestState() {
setProfile(null);
setBenefitBalance(0);
setOrderCounts({});
setProfileLoadError('');
}
function applyProfile(me: UserProfile) {
@@ -63,6 +65,7 @@ export default function MinePage() {
function loadProfile() {
if (!isLoggedIn()) return;
setProfileLoadError('');
Promise.all([
request<UserProfile>('/auth/me'),
request<Array<Record<string, unknown>>>('/benefit/coupons').catch(() => []),
@@ -83,7 +86,16 @@ export default function MinePage() {
});
setOrderCounts(counts);
})
.catch(() => {});
.catch((error) => {
if (!isLoggedIn()) {
setAuthed(false);
resetGuestState();
return;
}
const message = error instanceof Error ? error.message : '个人资料加载失败';
setProfileLoadError(message);
toast('个人资料加载失败,请点击重试');
});
}
useDidShow(() => {
@@ -162,22 +174,20 @@ export default function MinePage() {
/** 点击头像:绑定账号后用 chooseAvatar + nickname 获取头像昵称 */
async function handleAvatarTap() {
if (bindingWx || savingProfile) return;
if (!isLoggedIn()) {
goLogin('/pages/mine/index');
return;
}
if (!isWeapp) {
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (ok) loadProfile();
}
if (isWeapp) {
openProfileSheet();
return;
}
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (!ok) return;
if (ok) loadProfile();
}
openProfileSheet();
return;
}
async function onChooseAvatar(e: { detail?: { avatarUrl?: string } }) {
@@ -202,16 +212,18 @@ export default function MinePage() {
}
setSavingProfile(true);
try {
if (!profile?.hasWechat) {
const ok = await ensureWechatBound();
if (!ok) return;
}
let avatarUrl = draftAvatarUrl;
let avatarResourceId: string | undefined;
if (draftAvatarTemp) {
avatarUrl = await uploadAvatarTempFile(draftAvatarTemp);
const uploaded = await uploadAvatarTempFile(draftAvatarTemp);
avatarUrl = uploaded.url;
avatarResourceId = uploaded.resourceId;
}
const updated = await uploadMiniWechatProfile({ nickname, avatarUrl });
if (updated) applyProfile({ ...updated, hasWechat: true });
const updated = await uploadMiniWechatProfile({
nickname,
...(avatarResourceId ? { avatarUrl, avatarResourceId } : {}),
});
if (updated) applyProfile(updated);
setProfileSheetOpen(false);
toast('头像昵称已更新', 'success');
loadProfile();
@@ -262,7 +274,9 @@ export default function MinePage() {
</View>
</View>
<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')}>
<Text></Text>
</View>
@@ -283,9 +297,10 @@ export default function MinePage() {
}
const nickname = display.nickname || '用户';
const needProfileFill = isWeapp && needsWxProfileFill(display);
const avatarProfileReady = isWeapp ? !needProfileFill : hasWechat;
const memberLabel = needProfileFill
? '点击头像完善资料'
: hasWechat
: isWeapp || hasWechat
? '好客会员'
: canWxAuth
? '点击头像授权'
@@ -305,7 +320,7 @@ export default function MinePage() {
>
<View
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)}
@@ -313,13 +328,11 @@ export default function MinePage() {
{avatarClickable ? (
<View
className={`mine-avatar-status${
needProfileFill || !hasWechat
? ' mine-avatar-status--pending'
: ' mine-avatar-status--ok'
avatarProfileReady ? ' mine-avatar-status--ok' : ' mine-avatar-status--pending'
}`}
>
<Text>
{bindingWx ? '授权中' : needProfileFill || !hasWechat ? '去完善' : '更换'}
{bindingWx ? '授权中' : avatarProfileReady ? '更换' : '去完善'}
</Text>
</View>
) : null}
@@ -330,9 +343,20 @@ export default function MinePage() {
onClick={avatarClickable ? () => void handleAvatarTap() : undefined}
>
<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}
</Text>
{profileLoadError ? (
<Text
className="mine-profile-retry"
onClick={(event) => {
event.stopPropagation();
loadProfile();
}}
>
</Text>
) : null}
</View>
</View>
</View>
+48 -13
View File
@@ -5,6 +5,7 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone';
@@ -14,6 +15,8 @@ import {
toWeappShareMessage,
} from '../../lib/wechat-share';
const isWeapp = process.env.TARO_ENV === 'weapp';
type OrderItem = {
productName?: string;
productSpec?: string;
@@ -104,8 +107,20 @@ export default function OrderDetailPage() {
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 (
<PageShell variant="sub" className={`order-detail-page${canPay ? ' order-detail-page--with-pay' : ''}`}>
<PageShell variant="sub" className={pageClass}>
<WechatShareReady payload={sharePayload} />
<SubPageHeader
title="订单详情"
@@ -173,19 +188,39 @@ export default function OrderDetailPage() {
)}
</View>
{canPay && order && (
<View className="pay-bar order-detail-pay-bar">
<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>
{order ? (
<View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}>
{isWeapp ? (
<ContactCsButton
className="order-detail-cs-btn"
session={{
from: 'order-detail',
orderId: order.id,
orderNo: order.orderNo,
}}
>
</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>
)}
) : null}
</PageShell>
);
}
-4
View File
@@ -81,10 +81,6 @@
border-bottom-color: var(--color-heritage-red);
}
.home-aroma-tab--muted {
opacity: 0.65;
}
.home-product-list {
display: flex;
flex-direction: column;
+48
View File
@@ -326,3 +326,51 @@
.login-agreement-link {
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);
}
.mine-profile-retry {
display: block;
margin-top: 6px;
font-size: 11px;
color: #fff;
text-decoration: underline;
}
.mine-main {
margin-top: -28px;
position: relative;
+53 -1
View File
@@ -7,7 +7,8 @@
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));
}
@@ -16,6 +17,57 @@
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 {
background: var(--color-card);
border-radius: var(--radius-lg);
+55 -3
View File
@@ -499,27 +499,70 @@
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 {
font-size: 14px;
color: var(--color-subtle-gray);
margin-bottom: 12px;
}
.cs-phone {
.cs-phone,
.cs-phone-display {
display: block;
font-family: var(--font-headline);
font-size: 32px;
font-size: 28px;
font-weight: 700;
color: var(--color-heritage-red);
margin-bottom: 8px;
margin-top: 20px;
letter-spacing: 1px;
}
.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;
color: var(--color-subtle-gray);
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 {
padding: 12px 32px;
border-radius: 999px;
@@ -528,3 +571,12 @@
font-size: 15px;
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;
}
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 const REDEEM_PENDING_STATUS_LABELS: Record<RedeemPendingStatus, string> = {
@@ -16,7 +16,7 @@ import { ClientConfigController } from './client-config.controller';
import { WechatLocationService } from './wechat-location.service';
@Module({
imports: [IamModule, IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
imports: [forwardRef(() => IamModule), IntegrationsModule, SystemConfigModule, forwardRef(() => AnalyticsModule)],
controllers: [
ResourceController,
EventController,
@@ -102,6 +102,9 @@ export class ResourceService {
});
throw new BadRequestException(message);
}
if (dto.bizType === 'AVATAR' && !file.mimetype?.startsWith('image/')) {
throw new BadRequestException('头像仅支持图片文件');
}
try {
const result = await this.oss.putObject({
@@ -125,6 +128,24 @@ export class ResourceService {
externalNo: result.ossKey,
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;
} catch (err) {
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) {
const resource = await this.prisma.commonResource.create({
data: {
@@ -2,6 +2,7 @@ import { randomUUID } from 'crypto';
import {
BadRequestException,
ForbiddenException,
forwardRef,
Inject,
Injectable,
NotFoundException,
@@ -22,6 +23,7 @@ import { serializeBigInt } from '../../common/decorators/current-user.decorator'
import { verifyPassword } from '../../common/crypto/password.util';
import { AnalyticsService } from '../analytics/analytics.service';
import { UserAddressService } from './user-address.service';
import { ResourceService } from '../common/resource.service';
import type { User } from '@prisma/client';
@@ -62,6 +64,7 @@ export class AuthService {
private readonly analyticsService: AnalyticsService,
private readonly smsCodeStore: SmsCodeStore,
private readonly userAddressService: UserAddressService,
@Inject(forwardRef(() => ResourceService)) private readonly resourceService: ResourceService,
) {}
private assertMobilePhone(phone: string) {
@@ -1407,12 +1410,9 @@ export class AuthService {
async updateMiniWechatProfile(
userId: bigint,
input: { nickname?: string; avatarUrl?: string },
input: { nickname?: string; avatarUrl?: string; avatarResourceId?: string },
) {
const user = await this.assertActiveUser(userId);
if (!user.wxOpenId) {
throw new BadRequestException('请先完成微信授权');
}
const data: {
nickname?: string;
@@ -1425,37 +1425,23 @@ export class AuthService {
}
const avatarUrl = input.avatarUrl?.trim();
if (avatarUrl) {
if (user.avatarResourceId) {
await this.prisma.commonResource.update({
where: { id: user.avatarResourceId },
data: { url: avatarUrl },
});
} else {
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 avatarResourceId = input.avatarResourceId?.trim();
if (avatarResourceId) {
let resourceId: bigint;
try {
resourceId = BigInt(avatarResourceId);
} catch {
throw new BadRequestException('头像资源编号无效');
}
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 (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);
}
@@ -118,6 +118,10 @@ export class MiniWechatProfileDto {
@IsString()
@IsOptional()
avatarUrl?: string;
@IsString()
@IsOptional()
avatarResourceId?: string;
}
export class CheckPartnerPhoneDto {
@@ -24,10 +24,12 @@ import { PartnerPrimaryGuard } from '../../common/guards/partner-primary.guard';
import { PartnerPermissionGuard } from '../../common/guards/partner-permission.guard';
import { ShopStoreGuard } from '../../common/guards/shop-store.guard';
import { StoreMembershipService } from '../../common/guards/store-membership.service';
import { CommonModule } from '../common/common.module';
@Module({
imports: [
IntegrationsModule,
forwardRef(() => CommonModule),
forwardRef(() => AnalyticsModule),
JwtModule.register({
secret: process.env.JWT_SECRET || 'dukang-prev1-dev-secret',
@@ -1,5 +1,6 @@
import { Type } from 'class-transformer';
import { IsNotEmpty, IsNumber, IsString, Min } from 'class-validator';
import type { RedeemPhoneDirectPrepareRequest } from '@dukang/shared-types';
export class RedeemPhoneSendLookupSmsDto {
@IsString()
@@ -28,6 +29,17 @@ export class RedeemPhonePrepareDto {
amount: number;
}
export class RedeemPhoneDirectPrepareDto implements RedeemPhoneDirectPrepareRequest {
@IsString()
@IsNotEmpty()
phone: string;
@Type(() => Number)
@IsNumber()
@Min(0.01)
amount: number;
}
export class RedeemPhoneConfirmDto {
@IsString()
@IsNotEmpty()
@@ -6,6 +6,7 @@ import { CurrentUser } from '../../common/decorators/current-user.decorator';
import {
RedeemPhoneBalanceDto,
RedeemPhoneConfirmDto,
RedeemPhoneDirectPrepareDto,
RedeemPhonePrepareDto,
RedeemPhoneSendLookupSmsDto,
} 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')
phoneConfirm(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneConfirmDto) {
return this.redeemService.confirmPhoneRedeem(
@@ -311,6 +311,62 @@ export class RedeemService {
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) {
const account = await this.loadOpenStoreAccount(storeAccountId, storeId);
const session = await this.loadPhoneSession(sessionId, storeAccountId);
+1 -1
View File
@@ -254,7 +254,7 @@
| 模块 | 要点 |
|------|------|
| 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 |
| 核销 | 扫码大按钮 + 手机号通道;今日汇总;弱网处理 |
| 核销 | 扫码大按钮 + 手机号通道;手机号、金额、验证码与确认核销同页完成,先按手机号和金额发送验证码,验证成功后直接核销;今日汇总;弱网处理 |
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3 |