Compare commits
5 Commits
83ed90ef67
...
dev_ljy
| Author | SHA1 | Date | |
|---|---|---|---|
| 56dfffd126 | |||
| bdf80e577b | |||
| eb96b36d0b | |||
| 36bec94639 | |||
| c11c7647b9 |
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
@@ -17,8 +17,6 @@ import { checkStorePhoneAvailable, sendStorePhoneSms } from '../lib/storePhone';
|
|||||||
|
|
||||||
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
import { fetchPartnerCities, type OpenCityOption } from '../lib/upload';
|
||||||
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
|
||||||
|
|
||||||
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
import { usePartnerSession } from '../contexts/PartnerSessionContext';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -79,16 +77,10 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const { account, refresh } = usePartnerSession();
|
const { account } = usePartnerSession();
|
||||||
|
|
||||||
const accountId = account?.id;
|
const accountId = account?.id;
|
||||||
|
|
||||||
const wechatReady = !!account?.hasWechat;
|
|
||||||
|
|
||||||
const handleWechatReadyChange = useCallback(() => {
|
|
||||||
void refresh();
|
|
||||||
}, [refresh]);
|
|
||||||
|
|
||||||
const [params, setParams] = useSearchParams();
|
const [params, setParams] = useSearchParams();
|
||||||
|
|
||||||
const saved = loadStoreDraft(accountId);
|
const saved = loadStoreDraft(accountId);
|
||||||
@@ -142,17 +134,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (step !== 2 || !isWechatEnv()) return;
|
|
||||||
void refresh();
|
|
||||||
weixinSdk.reset();
|
|
||||||
void weixinSdk.init().catch(() => {
|
|
||||||
/* OssUploadField 点击时会再次初始化 */
|
|
||||||
});
|
|
||||||
}, [step, refresh]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
||||||
void fetchPartnerCities()
|
void fetchPartnerCities()
|
||||||
@@ -895,13 +876,9 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={form.coverUrl}
|
value={form.coverUrl}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(coverUrl) => patchForm({ coverUrl })}
|
onChange={(coverUrl) => patchForm({ coverUrl })}
|
||||||
|
|
||||||
label="点击或拖拽上传"
|
label="从系统相册选择"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -931,10 +908,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={url}
|
value={url}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
onChange={(nextUrl) => patchEnvPhotoUrl(index, nextUrl)}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
@@ -951,7 +924,7 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
<h3 className="headline-md">签约合同 <span className="text-primary">*</span></h3>
|
||||||
|
|
||||||
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>拍照上传签约协议首页与盖章页</p>
|
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}>上传签约协议首页与盖章页</p>
|
||||||
|
|
||||||
<OssUploadField
|
<OssUploadField
|
||||||
|
|
||||||
@@ -963,10 +936,6 @@ export default function StoreCreatePage() {
|
|||||||
|
|
||||||
value={form.contractUrl}
|
value={form.contractUrl}
|
||||||
|
|
||||||
wechatReady={wechatReady}
|
|
||||||
|
|
||||||
onWechatReadyChange={handleWechatReadyChange}
|
|
||||||
|
|
||||||
onChange={(contractUrl) => patchForm({ contractUrl })}
|
onChange={(contractUrl) => patchForm({ contractUrl })}
|
||||||
|
|
||||||
label="上传合同副本"
|
label="上传合同副本"
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ export default function StoreDetailPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [mediaSaving, setMediaSaving] = useState(false);
|
const [mediaSaving, setMediaSaving] = useState(false);
|
||||||
const [actionError, setActionError] = useState('');
|
const [actionError, setActionError] = useState('');
|
||||||
const [wechatReady, setWechatReady] = useState(false);
|
|
||||||
|
|
||||||
function applyStore(data: Record<string, unknown>) {
|
function applyStore(data: Record<string, unknown>) {
|
||||||
setStore(data);
|
setStore(data);
|
||||||
@@ -284,10 +283,8 @@ export default function StoreDetailPage() {
|
|||||||
bizType="STORE_TITLE"
|
bizType="STORE_TITLE"
|
||||||
mediaType="IMAGE"
|
mediaType="IMAGE"
|
||||||
value={coverUrl}
|
value={coverUrl}
|
||||||
wechatReady={wechatReady}
|
|
||||||
onWechatReadyChange={setWechatReady}
|
|
||||||
onChange={setCoverUrl}
|
onChange={setCoverUrl}
|
||||||
label="点击更换门头照"
|
label="从系统相册选择"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -336,8 +333,6 @@ export default function StoreDetailPage() {
|
|||||||
bizType="STORE_ENV"
|
bizType="STORE_ENV"
|
||||||
mediaType="IMAGE"
|
mediaType="IMAGE"
|
||||||
value={url}
|
value={url}
|
||||||
wechatReady={wechatReady}
|
|
||||||
onWechatReadyChange={setWechatReady}
|
|
||||||
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
onChange={(nextUrl) => setEnvPhotoUrls((prev) => patchEnvPhotoAt(prev, index, nextUrl))}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/partner/',
|
// 独立域名 partner.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
|
||||||
|
base: process.env.VITE_PUBLIC_BASE ?? '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import LoginPage from './pages/LoginPage';
|
|||||||
import LegalPage from './pages/LegalPage';
|
import LegalPage from './pages/LegalPage';
|
||||||
import SelectStorePage from './pages/SelectStorePage';
|
import SelectStorePage from './pages/SelectStorePage';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
|
||||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||||
import RecordsPage from './pages/RecordsPage';
|
import RecordsPage from './pages/RecordsPage';
|
||||||
@@ -22,7 +21,7 @@ export default function App() {
|
|||||||
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||||
<Route path="/select-store" element={<SelectStorePage />} />
|
<Route path="/select-store" element={<SelectStorePage />} />
|
||||||
<Route path="/staff" element={<StaffPage />} />
|
<Route path="/staff" element={<StaffPage />} />
|
||||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
<Route path="/redeem" element={<Navigate to="/redeem/phone" replace />} />
|
||||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||||
<Route element={<TabLayout />}>
|
<Route element={<TabLayout />}>
|
||||||
|
|||||||
@@ -1,47 +1,15 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate } from 'react-router-dom';
|
||||||
import { useStoreSession } from '../contexts/StoreSessionContext';
|
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
|
||||||
import {
|
|
||||||
authorizeShopWechat,
|
|
||||||
checkNeedsWechatAuth,
|
|
||||||
fetchShopAccount,
|
|
||||||
handleShopWechatCallback,
|
|
||||||
handleShopWechatLoginResult,
|
|
||||||
} from '../lib/wechat-auth';
|
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
|
||||||
import { stripOAuthParamsFromLocation } from '@dukang/weixin-sdk';
|
|
||||||
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
|
||||||
|
|
||||||
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
function formatMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatScanError(e: unknown): string {
|
|
||||||
const msg = e instanceof Error ? e.message : '扫码失败,请重试';
|
|
||||||
if (/invalid signature/i.test(msg)) {
|
|
||||||
return '微信 JSSDK 签名校验失败:请确认公众号已配置 JS 接口安全域名为 user.runxian.top,并刷新页面后重试';
|
|
||||||
}
|
|
||||||
if (/offline verifying|权限验证中|接口未就绪/i.test(msg)) {
|
|
||||||
return `${msg}(可在 URL 后加 ?wxdebug=1 开启 JSSDK 调试查看详情)`;
|
|
||||||
}
|
|
||||||
return msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { applySession } = useStoreSession();
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [scanMsg, setScanMsg] = useState('');
|
|
||||||
const [scanning, setScanning] = useState(false);
|
|
||||||
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
||||||
const [authLoading, setAuthLoading] = useState(false);
|
|
||||||
const [authError, setAuthError] = useState('');
|
|
||||||
|
|
||||||
const loadDashboard = useCallback(() => {
|
const loadDashboard = useCallback(() => {
|
||||||
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
return request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard')
|
||||||
@@ -58,7 +26,6 @@ export default function HomePage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onResume() {
|
function onResume() {
|
||||||
setScanning(false);
|
|
||||||
void loadDashboard();
|
void loadDashboard();
|
||||||
}
|
}
|
||||||
function onVisibility() {
|
function onVisibility() {
|
||||||
@@ -74,88 +41,6 @@ export default function HomePage() {
|
|||||||
};
|
};
|
||||||
}, [loadDashboard]);
|
}, [loadDashboard]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isWechatEnv() || !searchParams.get('code')) return;
|
|
||||||
void handleShopWechatCallback()
|
|
||||||
.then((result) => {
|
|
||||||
if (!result) return;
|
|
||||||
const session = handleShopWechatLoginResult(result);
|
|
||||||
if (session) {
|
|
||||||
applySession(session);
|
|
||||||
}
|
|
||||||
setAuthModalOpen(false);
|
|
||||||
setAuthError('');
|
|
||||||
stripOAuthParamsFromLocation();
|
|
||||||
setSearchParams({}, { replace: true });
|
|
||||||
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
if (shouldScan) {
|
|
||||||
window.setTimeout(() => void runScan(), 0);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
|
||||||
});
|
|
||||||
}, [searchParams, applySession, setSearchParams]);
|
|
||||||
|
|
||||||
async function runScan() {
|
|
||||||
if (!isWechatEnv()) {
|
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setScanning(true);
|
|
||||||
setScanMsg('');
|
|
||||||
try {
|
|
||||||
await weixinSdk.init();
|
|
||||||
const raw = await weixinSdk.scanQrCode();
|
|
||||||
if (!raw) {
|
|
||||||
void loadDashboard();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const token = parseRedeemTokenFromScan(raw);
|
|
||||||
if (!token) {
|
|
||||||
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
|
||||||
} catch (e) {
|
|
||||||
setScanMsg(formatScanError(e));
|
|
||||||
} finally {
|
|
||||||
setScanning(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleScan() {
|
|
||||||
setScanMsg('');
|
|
||||||
if (!isWechatEnv()) {
|
|
||||||
setScanMsg('请在微信内打开门店端进行扫码核销');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const profile = await fetchShopAccount();
|
|
||||||
if (await checkNeedsWechatAuth(profile)) {
|
|
||||||
setAuthModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runScan();
|
|
||||||
} catch (e) {
|
|
||||||
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startWechatAuth() {
|
|
||||||
setAuthLoading(true);
|
|
||||||
setAuthError('');
|
|
||||||
try {
|
|
||||||
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
|
||||||
await authorizeShopWechat();
|
|
||||||
} catch (e) {
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
|
||||||
setAuthLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const store = dash?.store as Record<string, unknown> | undefined;
|
const store = dash?.store as Record<string, unknown> | undefined;
|
||||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||||
const openTime = String(store?.openTime || '10:00');
|
const openTime = String(store?.openTime || '10:00');
|
||||||
@@ -189,20 +74,13 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-scan">
|
<section className="shop-home-scan">
|
||||||
<button
|
<Link
|
||||||
type="button"
|
to="/redeem/phone"
|
||||||
className="shop-home-scan-btn"
|
className="shop-home-scan-btn"
|
||||||
disabled={scanning}
|
|
||||||
onClick={() => void handleScan()}
|
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
|
||||||
</button>
|
|
||||||
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
|
||||||
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
|
||||||
<Link to="/redeem/phone" className="shop-home-phone-link">
|
|
||||||
<span className="material-symbols-outlined">smartphone</span>
|
<span className="material-symbols-outlined">smartphone</span>
|
||||||
手机号核销
|
|
||||||
</Link>
|
</Link>
|
||||||
|
<p className="shop-home-scan-label">手机号核销</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-status">
|
<section className="shop-home-status">
|
||||||
@@ -249,17 +127,6 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<WechatScanAuthModal
|
|
||||||
open={authModalOpen}
|
|
||||||
loading={authLoading}
|
|
||||||
error={authError}
|
|
||||||
onAuthorize={() => void startWechatAuth()}
|
|
||||||
onCancel={() => {
|
|
||||||
setAuthModalOpen(false);
|
|
||||||
setAuthError('');
|
|
||||||
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,22 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import type { RedeemPhoneDirectPrepareResult } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
type BalanceResult = {
|
|
||||||
sessionId: string;
|
|
||||||
totalBalance: number;
|
|
||||||
maskedPhone: string;
|
|
||||||
user?: { nickname?: string; phone?: string; userNo?: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatAmount(n: number) {
|
function formatAmount(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
type Step = 'lookup' | 'amount' | 'confirm';
|
|
||||||
|
|
||||||
export default function PhoneRedeemPage() {
|
export default function PhoneRedeemPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [step, setStep] = useState<Step>('lookup');
|
|
||||||
const [phone, setPhone] = useState('');
|
const [phone, setPhone] = useState('');
|
||||||
const [lookupCode, setLookupCode] = useState('');
|
|
||||||
const [confirmCode, setConfirmCode] = useState('');
|
|
||||||
const [amount, setAmount] = useState('');
|
const [amount, setAmount] = useState('');
|
||||||
const [balance, setBalance] = useState<BalanceResult | null>(null);
|
const [confirmCode, setConfirmCode] = useState('');
|
||||||
|
const [prepared, setPrepared] = useState<RedeemPhoneDirectPrepareResult | null>(null);
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
const [storeClosed, setStoreClosed] = useState(false);
|
const [storeClosed, setStoreClosed] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [lookupCooldown, setLookupCooldown] = useState(0);
|
|
||||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -39,62 +28,17 @@ export default function PhoneRedeemPage() {
|
|||||||
.catch(() => setStoreName('当前门店'));
|
.catch(() => setStoreName('当前门店'));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (lookupCooldown <= 0) return;
|
|
||||||
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [lookupCooldown]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (confirmCooldown <= 0) return;
|
if (confirmCooldown <= 0) return;
|
||||||
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [confirmCooldown]);
|
}, [confirmCooldown]);
|
||||||
|
|
||||||
async function sendLookupSms() {
|
async function sendConfirmSms() {
|
||||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||||
setMsg('请输入正确的手机号');
|
setMsg('请输入正确的手机号');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
|
||||||
setMsg('');
|
|
||||||
try {
|
|
||||||
await request('SHOP_H5', '/shop/redeem/phone/send-lookup-sms', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: phone.trim() }),
|
|
||||||
});
|
|
||||||
setLookupCooldown(60);
|
|
||||||
setMsg('验证码已发送至用户手机');
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(e instanceof Error ? e.message : '发送失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function queryBalance() {
|
|
||||||
if (!lookupCode.trim()) {
|
|
||||||
setMsg('请输入验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setMsg('');
|
|
||||||
try {
|
|
||||||
const res = await request<BalanceResult>('SHOP_H5', '/shop/redeem/phone/balance', {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ phone: phone.trim(), code: lookupCode.trim() }),
|
|
||||||
});
|
|
||||||
setBalance(res);
|
|
||||||
setStep('amount');
|
|
||||||
setMsg('');
|
|
||||||
} catch (e) {
|
|
||||||
setMsg(e instanceof Error ? e.message : '查询失败');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function prepareRedeem() {
|
|
||||||
if (storeClosed) {
|
if (storeClosed) {
|
||||||
setMsg('门店未营业,无法核销');
|
setMsg('门店未营业,无法核销');
|
||||||
return;
|
return;
|
||||||
@@ -104,28 +48,30 @@ export default function PhoneRedeemPage() {
|
|||||||
setMsg('请输入有效核销金额');
|
setMsg('请输入有效核销金额');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (balance && value > balance.totalBalance) {
|
|
||||||
setMsg('核销金额不能超过可用权益');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
|
const result = await request<RedeemPhoneDirectPrepareResult>('SHOP_H5', '/shop/redeem/phone/prepare-direct', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
|
body: JSON.stringify({ phone: phone.trim(), amount: value }),
|
||||||
});
|
});
|
||||||
|
setPrepared(result);
|
||||||
|
setConfirmCode('');
|
||||||
setConfirmCooldown(60);
|
setConfirmCooldown(60);
|
||||||
setStep('confirm');
|
setMsg(`验证码已发送至 ${result.maskedPhone},验证成功后将直接核销`);
|
||||||
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '发起核销失败');
|
setPrepared(null);
|
||||||
|
setMsg(e instanceof Error ? e.message : '发送验证码失败');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmRedeem() {
|
async function confirmRedeem() {
|
||||||
|
if (!prepared) {
|
||||||
|
setMsg('请先发送核销验证码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!confirmCode.trim()) {
|
if (!confirmCode.trim()) {
|
||||||
setMsg('请输入确认验证码');
|
setMsg('请输入确认验证码');
|
||||||
return;
|
return;
|
||||||
@@ -136,13 +82,13 @@ export default function PhoneRedeemPage() {
|
|||||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
sessionId: balance?.sessionId,
|
sessionId: prepared.sessionId,
|
||||||
code: confirmCode.trim(),
|
code: confirmCode.trim(),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||||
navigate('/redeem/success', {
|
navigate('/redeem/success', {
|
||||||
state: { result, storeName, user: balance?.user },
|
state: { result, storeName, user: prepared.user },
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||||
@@ -151,7 +97,9 @@ export default function PhoneRedeemPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
|
const amountValue = Number(amount);
|
||||||
|
const canSendCode =
|
||||||
|
/^1\d{10}$/.test(phone.trim()) && Number.isFinite(amountValue) && amountValue > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-redeem-page">
|
<div className="shop-redeem-page">
|
||||||
@@ -179,150 +127,77 @@ export default function PhoneRedeemPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-redeem-body">
|
<div className="shop-redeem-body">
|
||||||
{step === 'lookup' && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">用户手机号</label>
|
||||||
<div className="shop-phone-field">
|
<input
|
||||||
<label className="shop-phone-label">用户手机号</label>
|
className="shop-phone-input"
|
||||||
<input
|
type="tel"
|
||||||
className="shop-phone-input"
|
maxLength={11}
|
||||||
type="tel"
|
placeholder="请输入用户手机号"
|
||||||
maxLength={11}
|
value={phone}
|
||||||
placeholder="请输入用户手机号"
|
disabled={loading}
|
||||||
value={phone}
|
onChange={(e) => {
|
||||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
setPhone(e.target.value.replace(/\D/g, ''));
|
||||||
/>
|
setPrepared(null);
|
||||||
</div>
|
setConfirmCode('');
|
||||||
<div className="shop-phone-field">
|
setConfirmCooldown(0);
|
||||||
<label className="shop-phone-label">验证码</label>
|
}}
|
||||||
<div className="shop-phone-code-row">
|
/>
|
||||||
<input
|
</div>
|
||||||
className="shop-phone-input"
|
|
||||||
type="text"
|
|
||||||
maxLength={6}
|
|
||||||
placeholder="用户收到的验证码"
|
|
||||||
value={lookupCode}
|
|
||||||
onChange={(e) => setLookupCode(e.target.value.replace(/\D/g, ''))}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-phone-code-btn"
|
|
||||||
disabled={loading || lookupCooldown > 0 || !phone.trim()}
|
|
||||||
onClick={() => void sendLookupSms()}
|
|
||||||
>
|
|
||||||
{lookupCooldown > 0 ? `${lookupCooldown}s` : '获取验证码'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-redeem-confirm-btn"
|
|
||||||
disabled={loading || storeClosed}
|
|
||||||
onClick={() => void queryBalance()}
|
|
||||||
>
|
|
||||||
查询权益
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'amount' && balance && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">待核销金额</label>
|
||||||
<div className="shop-redeem-user">
|
<input
|
||||||
<div className="shop-redeem-user-left">
|
className="shop-phone-input"
|
||||||
<span className="material-symbols-outlined">person</span>
|
type="number"
|
||||||
<span>用户</span>
|
min={0.01}
|
||||||
</div>
|
step={0.01}
|
||||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
placeholder="请输入待核销金额"
|
||||||
{userLabel}
|
value={amount}
|
||||||
</span>
|
disabled={loading}
|
||||||
</div>
|
onChange={(e) => {
|
||||||
<div className="shop-redeem-amount-section">
|
setAmount(e.target.value);
|
||||||
<p className="shop-redeem-amount-label">可用好客权益</p>
|
setPrepared(null);
|
||||||
<div className="shop-redeem-amount">
|
setConfirmCode('');
|
||||||
<span className="shop-redeem-amount-symbol">¥</span>
|
setConfirmCooldown(0);
|
||||||
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
|
}}
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="shop-phone-field">
|
|
||||||
<label className="shop-phone-label">核销金额</label>
|
|
||||||
<input
|
|
||||||
className="shop-phone-input"
|
|
||||||
type="number"
|
|
||||||
min={0.01}
|
|
||||||
step={0.01}
|
|
||||||
placeholder="请输入核销金额"
|
|
||||||
value={amount}
|
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-redeem-confirm-btn"
|
|
||||||
disabled={loading || storeClosed || balance.totalBalance <= 0}
|
|
||||||
onClick={() => void prepareRedeem()}
|
|
||||||
>
|
|
||||||
发送确认验证码并核销
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="shop-phone-link-btn"
|
|
||||||
onClick={() => {
|
|
||||||
setStep('lookup');
|
|
||||||
setBalance(null);
|
|
||||||
setAmount('');
|
|
||||||
setLookupCode('');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
更换手机号
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{step === 'confirm' && balance && (
|
<div className="shop-phone-field">
|
||||||
<>
|
<label className="shop-phone-label">核销验证码</label>
|
||||||
<div className="shop-redeem-details">
|
<div className="shop-phone-code-row">
|
||||||
<div className="shop-redeem-detail-row">
|
<input
|
||||||
<span>用户</span>
|
className="shop-phone-input"
|
||||||
<span>{userLabel}</span>
|
type="text"
|
||||||
</div>
|
inputMode="numeric"
|
||||||
<div className="shop-redeem-detail-row">
|
maxLength={6}
|
||||||
<span>核销金额</span>
|
placeholder="输入用户收到的验证码"
|
||||||
<span>¥{formatAmount(Number(amount))}</span>
|
value={confirmCode}
|
||||||
</div>
|
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
||||||
</div>
|
/>
|
||||||
<div className="shop-phone-field">
|
|
||||||
<label className="shop-phone-label">核销确认验证码</label>
|
|
||||||
<input
|
|
||||||
className="shop-phone-input"
|
|
||||||
type="text"
|
|
||||||
maxLength={6}
|
|
||||||
placeholder="用户手机收到的确认码"
|
|
||||||
value={confirmCode}
|
|
||||||
onChange={(e) => setConfirmCode(e.target.value.replace(/\D/g, ''))}
|
|
||||||
/>
|
|
||||||
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
|
||||||
{confirmCooldown > 0 ? `${confirmCooldown}s 后可重新发送` : '未收到可向用户确认或返回上一步重发'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-redeem-confirm-btn"
|
className="shop-phone-code-btn"
|
||||||
disabled={loading || storeClosed}
|
disabled={loading || confirmCooldown > 0 || storeClosed || !canSendCode}
|
||||||
onClick={() => void confirmRedeem()}
|
onClick={() => void sendConfirmSms()}
|
||||||
>
|
>
|
||||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
|
{confirmCooldown > 0 ? `${confirmCooldown}s` : '发送验证码'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<p className="shop-redeem-hint" style={{ marginTop: 8 }}>
|
||||||
className="shop-phone-link-btn"
|
验证码将发送到用户手机号,验证成功后直接完成核销。
|
||||||
onClick={() => {
|
</p>
|
||||||
setStep('amount');
|
</div>
|
||||||
setConfirmCode('');
|
|
||||||
}}
|
<button
|
||||||
>
|
type="button"
|
||||||
返回修改金额
|
className="shop-redeem-confirm-btn"
|
||||||
</button>
|
disabled={loading || storeClosed || !prepared || !confirmCode.trim()}
|
||||||
</>
|
onClick={() => void confirmRedeem()}
|
||||||
)}
|
>
|
||||||
|
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(amountValue || 0)}`}
|
||||||
|
</button>
|
||||||
|
|
||||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import react from '@vitejs/plugin-react';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '/shop/',
|
// 独立域名 shop.dukanghaoke.com 部署在根路径;旧的子路径部署可显式覆盖。
|
||||||
|
base: process.env.VITE_PUBLIC_BASE ?? '/',
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
@@ -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,6 +1,6 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
import { ClientApp } from '@dukang/shared-types';
|
import { ClientApp } from '@dukang/shared-types';
|
||||||
import { goLogin, forceReloadAfterAccountMerge } from './auth-nav';
|
import { forceReloadAfterAccountMerge } from './auth-nav';
|
||||||
|
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const origin =
|
const origin =
|
||||||
@@ -54,10 +54,6 @@ export function isLoggedIn(): boolean {
|
|||||||
return !!getToken();
|
return !!getToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function redirectToLogin() {
|
|
||||||
goLogin();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function logout() {
|
export function logout() {
|
||||||
clearAuth();
|
clearAuth();
|
||||||
Taro.reLaunch({ url: '/pages/home/index' });
|
Taro.reLaunch({ url: '/pages/home/index' });
|
||||||
@@ -76,17 +72,6 @@ function parseBody(data: unknown): { code?: number; message?: string } {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOnLoginPage(): boolean {
|
|
||||||
try {
|
|
||||||
const pages = Taro.getCurrentPages();
|
|
||||||
const cur = pages[pages.length - 1] as { route?: string } | undefined;
|
|
||||||
const route = cur?.route || '';
|
|
||||||
return route.includes('pages/login');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
|
/** 统一请求:注入 CLIENT_APP + Bearer,解包 { code, message, data } */
|
||||||
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
|
export async function request<T = unknown>(path: string, options: ReqOptions = {}): Promise<T> {
|
||||||
const header: Record<string, string> = {
|
const header: Record<string, string> = {
|
||||||
@@ -115,8 +100,6 @@ export async function request<T = unknown>(path: string, options: ReqOptions = {
|
|||||||
if (/账号已合并/.test(mergedMsg)) {
|
if (/账号已合并/.test(mergedMsg)) {
|
||||||
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
|
// 合并后旧 JWT 失效:强制刷新,不引导「重新登录」
|
||||||
forceReloadAfterAccountMerge();
|
forceReloadAfterAccountMerge();
|
||||||
} else if (!isOnLoginPage()) {
|
|
||||||
redirectToLogin();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new Error(body?.message || '登录已过期,请重新登录');
|
throw new Error(body?.message || '登录已过期,请重新登录');
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ const TAB_PAGES = new Set([
|
|||||||
'/pages/mine/index',
|
'/pages/mine/index',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
let loginNavigationPending = false;
|
||||||
|
|
||||||
|
function isLoginPageActive(): boolean {
|
||||||
|
const pages = Taro.getCurrentPages();
|
||||||
|
const current = pages[pages.length - 1] as { route?: string } | undefined;
|
||||||
|
return !!current?.route?.includes('pages/login/');
|
||||||
|
}
|
||||||
|
|
||||||
function currentPagePath(): string {
|
function currentPagePath(): string {
|
||||||
const pages = Taro.getCurrentPages();
|
const pages = Taro.getCurrentPages();
|
||||||
const cur = pages[pages.length - 1] as
|
const cur = pages[pages.length - 1] as
|
||||||
@@ -25,6 +33,7 @@ function currentPagePath(): string {
|
|||||||
|
|
||||||
/** 跳转登录页;默认带回当前页作为 return */
|
/** 跳转登录页;默认带回当前页作为 return */
|
||||||
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
||||||
|
if (loginNavigationPending || isLoginPageActive()) return;
|
||||||
const returnTo = returnPath ?? currentPagePath();
|
const returnTo = returnPath ?? currentPagePath();
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
|
if (returnTo) parts.push(`return=${encodeURIComponent(returnTo)}`);
|
||||||
@@ -34,9 +43,15 @@ export function goLogin(returnPath?: string, extras?: Record<string, string>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
|
const url = parts.length ? `/pages/login/index?${parts.join('&')}` : '/pages/login/index';
|
||||||
Taro.navigateTo({ url }).catch(() => {
|
loginNavigationPending = true;
|
||||||
Taro.redirectTo({ url });
|
void Taro.navigateTo({ url })
|
||||||
});
|
.catch(() => Taro.redirectTo({ url }))
|
||||||
|
.finally(() => {
|
||||||
|
// 等路由栈稳定后再释放,拦截同一轮请求触发的重复登录跳转。
|
||||||
|
setTimeout(() => {
|
||||||
|
loginNavigationPending = false;
|
||||||
|
}, 500);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 登录成功后回到 return 页,或回退 / 首页 */
|
/** 登录成功后回到 return 页,或回退 / 首页 */
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -254,7 +254,7 @@
|
|||||||
| 模块 | 要点 |
|
| 模块 | 要点 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 |
|
| 登录 | 主账号/店员;多店选店(Wave 2);7 天免登 |
|
||||||
| 核销 | 扫码大按钮 + 手机号通道;今日汇总;弱网处理 |
|
| 核销 | 扫码大按钮 + 手机号通道;手机号、金额、验证码与确认核销同页完成,先按手机号和金额发送验证码,验证成功后直接核销;今日汇总;弱网处理 |
|
||||||
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
|
| 记录结算 | 筛今日/7日/1月/全部;到账金额×60%;T+1 出账 |
|
||||||
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
|
| 提现 | 未出账可提(FIN 护栏);提现记录;结算异议 3 工作日 |
|
||||||
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |
|
| 账号 | 主账号管理店员(Wave 2);待处理核销单(Wave 3) |
|
||||||
|
|||||||
Reference in New Issue
Block a user