弱网核销功能
This commit is contained in:
@@ -0,0 +1,159 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
import { uploadRedeemPendingPhoto } from '../lib/upload';
|
||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||
import type { RedeemPendingSubmitResult } from '@dukang/shared-types';
|
||||
|
||||
type Props = {
|
||||
redeemToken: string;
|
||||
failCount: number;
|
||||
};
|
||||
|
||||
export default function WeakNetFallbackPanel({ redeemToken, failCount }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [photoResourceId, setPhotoResourceId] = useState('');
|
||||
const [result, setResult] = useState<RedeemPendingSubmitResult | null>(null);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
async function handleFile(file: File) {
|
||||
setUploading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const registered = await uploadRedeemPendingPhoto(file);
|
||||
setPhotoResourceId(registered.id);
|
||||
setPreviewUrl(registered.url);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '上传失败');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickPhoto() {
|
||||
setMsg('');
|
||||
if (isWechatEnv()) {
|
||||
try {
|
||||
setUploading(true);
|
||||
await weixinSdk.init();
|
||||
const files = await weixinSdk.chooseImages({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
});
|
||||
if (files?.[0]) {
|
||||
await handleFile(files[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
const text = e instanceof Error ? e.message : '选图失败';
|
||||
if (!/cancel/i.test(text)) setMsg(text);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
inputRef.current?.click();
|
||||
}
|
||||
|
||||
async function submitPending() {
|
||||
if (!photoResourceId) {
|
||||
setMsg('请先拍摄或上传核销码照片');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const res = await request<RedeemPendingSubmitResult>('SHOP_H5', '/shop/redeem/pending', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
token: redeemToken,
|
||||
photoResourceId,
|
||||
failCount,
|
||||
}),
|
||||
});
|
||||
setResult(res);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '提交失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
void navigator.clipboard?.writeText(text).then(
|
||||
() => setMsg('已复制'),
|
||||
() => setMsg('复制失败,请手动长按复制'),
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="shop-weaknet-panel" role="alert">
|
||||
<h3 className="headline-md" style={{ marginBottom: 8 }}>弱网核销兜底</h3>
|
||||
<p className="body-md" style={{ marginBottom: 12, lineHeight: 1.5 }}>
|
||||
扫码核销网络失败已达 {failCount} 次。请记录用户手机号尝试「手机号核销」,
|
||||
或拍下用户核销码照片提交客服人工处理。
|
||||
</p>
|
||||
|
||||
<div className="shop-weaknet-token">
|
||||
<span className="label-md text-muted">核销码 ID(追查用)</span>
|
||||
<button type="button" className="shop-weaknet-copy" onClick={() => copyText(redeemToken)}>
|
||||
{redeemToken}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{result ? (
|
||||
<div className="shop-weaknet-success">
|
||||
<p className="body-md">已提交客服,待处理单号:</p>
|
||||
<button type="button" className="shop-weaknet-copy" onClick={() => copyText(result.pendingNo)}>
|
||||
{result.pendingNo}
|
||||
</button>
|
||||
<p className="label-md text-muted" style={{ marginTop: 8 }}>
|
||||
请告知用户保留核销码编号,客服将尽快人工补核销。
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
hidden
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleFile(file);
|
||||
}}
|
||||
/>
|
||||
{previewUrl && (
|
||||
<img src={previewUrl} alt="核销码照片预览" className="shop-weaknet-preview" />
|
||||
)}
|
||||
<div className="shop-weaknet-actions">
|
||||
<button type="button" className="shop-redeem-confirm-btn" disabled={uploading} onClick={() => void pickPhoto()}>
|
||||
{uploading ? '上传中…' : previewUrl ? '重新拍照' : '拍照 / 选图'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={submitting || !photoResourceId}
|
||||
onClick={() => void submitPending()}
|
||||
>
|
||||
{submitting ? '提交中…' : '提交客服人工核销'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-btn-outline"
|
||||
onClick={() => navigate('/redeem/phone')}
|
||||
>
|
||||
去手机号核销
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <p className="shop-redeem-error" style={{ marginTop: 12 }}>{msg}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -124,10 +124,19 @@ async function rawRequest<T>(
|
||||
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||
|
||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||
const json = await res.json();
|
||||
let json: { code: number; message?: string; data?: T };
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
const err = new Error(res.ok ? '接口返回异常' : `网络异常(HTTP ${res.status})`) as Error & {
|
||||
status?: number;
|
||||
};
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||
err.status = json.code;
|
||||
err.status = res.status >= 500 ? res.status : json.code;
|
||||
throw err;
|
||||
}
|
||||
return json.data as T;
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { REDEEM_WEAKNET_FAIL_THRESHOLD } from '@dukang/shared-types';
|
||||
import type { RedeemErrorClass, RedeemFailureReportResult } from '@dukang/shared-types';
|
||||
import { request } from './api';
|
||||
|
||||
export function isNetworkError(e: unknown): boolean {
|
||||
const err = e as Error & { status?: number };
|
||||
const status = err.status;
|
||||
const message = (err.message ?? String(e)).toLowerCase();
|
||||
if (status != null && status >= 500) return true;
|
||||
if (status === 0 || status === 408 || status === 429 || status === 502 || status === 503 || status === 504) {
|
||||
return true;
|
||||
}
|
||||
if (/failed to fetch|network|timeout|timed out|abort|offline|连接|网络|超时/.test(message)) {
|
||||
return true;
|
||||
}
|
||||
// 无 status 的 fetch 失败通常是网络类
|
||||
if (status == null && e instanceof TypeError) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function reportRedeemFailure(
|
||||
token: string,
|
||||
step: 'preview' | 'confirm',
|
||||
error: unknown,
|
||||
): Promise<RedeemFailureReportResult | null> {
|
||||
const err = error as Error & { status?: number };
|
||||
const errorClass: RedeemErrorClass = isNetworkError(error) ? 'NETWORK' : 'BUSINESS';
|
||||
try {
|
||||
return await request<RedeemFailureReportResult>('SHOP_H5', '/shop/redeem/failures', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
errorClass,
|
||||
message: err.message?.slice(0, 200),
|
||||
step,
|
||||
}),
|
||||
});
|
||||
} catch {
|
||||
if (errorClass === 'NETWORK') {
|
||||
return {
|
||||
failCount: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
thresholdReached: true,
|
||||
threshold: REDEEM_WEAKNET_FAIL_THRESHOLD,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export { REDEEM_WEAKNET_FAIL_THRESHOLD };
|
||||
@@ -0,0 +1,91 @@
|
||||
import { apiBase } from './api';
|
||||
import { getStoreProfile } from './api';
|
||||
|
||||
const UPLOAD_TIMEOUT_MS = 120_000;
|
||||
|
||||
export type UploadFileResult = {
|
||||
url: string;
|
||||
ossKey: string;
|
||||
bucket: string;
|
||||
mock: boolean;
|
||||
};
|
||||
|
||||
export type RegisteredResource = {
|
||||
id: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
async function uploadFileToOss(file: File, bizType: string): Promise<UploadFileResult> {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('bizType', bizType);
|
||||
formData.append('mediaType', 'IMAGE');
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = { 'X-Client-App': 'SHOP_H5' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), UPLOAD_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/common/resources/upload`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code === 401) {
|
||||
localStorage.removeItem('accessToken');
|
||||
throw new Error('未登录');
|
||||
}
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '上传失败');
|
||||
}
|
||||
return json.data as UploadFileResult;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
throw new Error('上传超时,请检查网络后重试');
|
||||
}
|
||||
throw e;
|
||||
} finally {
|
||||
window.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function registerResource(upload: UploadFileResult, fileName: string): Promise<RegisteredResource> {
|
||||
const profile = getStoreProfile();
|
||||
if (!profile?.storeId) throw new Error('门店信息缺失,请重新登录');
|
||||
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Client-App': 'SHOP_H5',
|
||||
};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${apiBase}/common/resources`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
ownerType: 'STORE',
|
||||
ownerId: profile.storeId,
|
||||
bizType: 'REDEEM_PENDING_PHOTO',
|
||||
mediaType: 'IMAGE',
|
||||
ossKey: upload.ossKey,
|
||||
url: upload.url,
|
||||
ossBucket: upload.bucket,
|
||||
fileName,
|
||||
}),
|
||||
});
|
||||
const json = await res.json();
|
||||
if (json.code !== 0) throw new Error(json.message || '登记资源失败');
|
||||
return { id: String(json.data.id), url: String(json.data.url) };
|
||||
}
|
||||
|
||||
/** 上传核销码照片并登记为 CommonResource,返回 resourceId */
|
||||
export async function uploadRedeemPendingPhoto(file: File): Promise<RegisteredResource> {
|
||||
const uploaded = await uploadFileToOss(file, 'REDEEM_PENDING_PHOTO');
|
||||
return registerResource(uploaded, file.name || 'redeem-pending.jpg');
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import WeakNetFallbackPanel from '../components/WeakNetFallbackPanel';
|
||||
import { request } from '../lib/api';
|
||||
import { reportRedeemFailure } from '../lib/redeem-failure';
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
@@ -23,6 +25,8 @@ export default function RedeemConfirmPage() {
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [failCount, setFailCount] = useState(0);
|
||||
const [showWeakNet, setShowWeakNet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
@@ -52,9 +56,16 @@ export default function RedeemConfirmPage() {
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch((e) => {
|
||||
.catch(async (e) => {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
const report = await reportRedeemFailure(token, 'preview', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
setShowWeakNet(true);
|
||||
} else if (report) {
|
||||
setFailCount(report.failCount);
|
||||
}
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
@@ -78,6 +89,13 @@ export default function RedeemConfirmPage() {
|
||||
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
const report = await reportRedeemFailure(token, 'confirm', e);
|
||||
if (report?.thresholdReached) {
|
||||
setFailCount(report.failCount);
|
||||
setShowWeakNet(true);
|
||||
} else if (report) {
|
||||
setFailCount(report.failCount);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -139,8 +157,10 @@ export default function RedeemConfirmPage() {
|
||||
|
||||
<div className="shop-redeem-details">
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>券编号</span>
|
||||
<span>{token ? `…${token.slice(-8)}` : '扫码后显示'}</span>
|
||||
<span>核销码 ID</span>
|
||||
<span style={{ wordBreak: 'break-all', maxWidth: '60%', textAlign: 'right' }}>
|
||||
{token || '扫码后显示'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>有效期</span>
|
||||
@@ -152,26 +172,39 @@ export default function RedeemConfirmPage() {
|
||||
<span>仅限指定门店</span>
|
||||
</div>
|
||||
)}
|
||||
{failCount > 0 && !showWeakNet && (
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>网络失败</span>
|
||||
<span>{failCount} / 5 次</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||
disabled={loading || storeClosed || !preview}
|
||||
onClick={confirm}
|
||||
>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{loading ? 'sync' : 'check_circle'}
|
||||
</span>
|
||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
|
||||
</button>
|
||||
|
||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||
{!showWeakNet && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||
disabled={loading || storeClosed || !preview}
|
||||
onClick={() => void confirm()}
|
||||
>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{loading ? 'sync' : 'check_circle'}
|
||||
</span>
|
||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
|
||||
</button>
|
||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{showWeakNet && token && (
|
||||
<WeakNetFallbackPanel redeemToken={token} failCount={failCount} />
|
||||
)}
|
||||
|
||||
<div className="shop-redeem-ornament">
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 64, color: 'var(--color-heritage-red)' }}>
|
||||
wine_bar
|
||||
|
||||
@@ -1203,6 +1203,71 @@
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.shop-weaknet-panel {
|
||||
margin: 0 16px 16px;
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(166, 29, 36, 0.06);
|
||||
border: 1px solid rgba(166, 29, 36, 0.18);
|
||||
}
|
||||
|
||||
.shop-weaknet-token {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.shop-weaknet-copy {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px dashed rgba(166, 29, 36, 0.35);
|
||||
background: #fff;
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--color-on-surface, #1c1b1b);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-weaknet-preview {
|
||||
width: 100%;
|
||||
max-height: 220px;
|
||||
object-fit: contain;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.shop-weaknet-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shop-weaknet-success {
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(46, 125, 50, 0.08);
|
||||
}
|
||||
|
||||
.shop-btn-outline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--color-heritage-red, #a61d24);
|
||||
background: transparent;
|
||||
color: var(--color-heritage-red, #a61d24);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.shop-redeem-ornament {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user