feat: audit diff, mock SMS 999888, env photos, proxy pay lock, mini-user UX

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 20:47:28 +08:00
parent d90847bad0
commit e93e4b8f84
23 changed files with 524 additions and 135 deletions
+10 -3
View File
@@ -82,12 +82,18 @@ function timeToMinutes(hhmm: string): number {
return h * 60 + m;
}
export const MIN_ENV_PHOTO_COUNT = 3;
export function normalizeStringArray(urls: unknown, minLen: number): string[] {
const arr = Array.isArray(urls) ? urls.map((u) => String(u ?? '')) : [];
while (arr.length < minLen) arr.push('');
return arr;
}
export function addEnvPhotoSlot(urls: string[]): string[] {
return [...urls, ''];
}
export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | undefined): StoreDraftForm {
const base = defaultStoreForm();
if (!raw) return base;
@@ -104,7 +110,7 @@ export function normalizeStoreDraftForm(raw: Partial<StoreDraftForm> | null | un
openTime2: String(raw.openTime2 ?? base.openTime2),
closeTime2: String(raw.closeTime2 ?? base.closeTime2),
avgPrice: String(raw.avgPrice ?? base.avgPrice),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, MIN_ENV_PHOTO_COUNT),
packages: Array.isArray(raw.packages)
? raw.packages.map((p, i) => ({
name: String((p as { name?: string }).name ?? ''),
@@ -215,13 +221,14 @@ export function validateStoreStep2(
): string | null {
if (!form.coverUrl.trim()) return '请上传门头照';
const envCount = form.envPhotoUrls.filter((u) => u.trim()).length;
if (envCount < 3) return '请上传至少 3 张环境照片';
if (envCount < MIN_ENV_PHOTO_COUNT) return `请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`;
if (!form.contractUrl.trim()) return '请上传签约合同';
return null;
}
export function patchEnvPhotoAt(urls: string[], index: number, url: string): string[] {
const envPhotoUrls = normalizeStringArray(urls, 3);
const envPhotoUrls = [...urls];
while (envPhotoUrls.length <= index) envPhotoUrls.push('');
envPhotoUrls[index] = url;
return envPhotoUrls;
}
@@ -59,6 +59,7 @@ export default function ProxyOrderDetailPage() {
const [payMethod, setPayMethod] = useState<ProxyPayMethod>('NATIVE');
const [codeUrl, setCodeUrl] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [cancelling, setCancelling] = useState(false);
const pollRef = useRef<number | null>(null);
function stopPoll() {
@@ -77,7 +78,12 @@ export default function ProxyOrderDetailPage() {
useEffect(() => {
if (!id) return;
void request<PartnerProxyOrderListItem>('PARTNER_H5', `/partner/proxy-orders/${id}`)
.then(setOrder)
.then((data) => {
setOrder(data);
if (data.proxyPayMethod) {
setPayMethod(data.proxyPayMethod);
}
})
.catch((e) => setError(e instanceof Error ? e.message : '加载失败'));
}, [id]);
@@ -129,6 +135,15 @@ export default function ProxyOrderDetailPage() {
async function startPay(method: ProxyPayMethod) {
if (!id) return;
const locked = order?.proxyPayMethod;
if (locked && locked !== method) {
setPayMsg(
locked === 'JSAPI'
? '该订单已发起微信代付,请先取消支付后重新下单'
: '该订单已生成收款码,请先取消支付后重新下单',
);
return;
}
setPaying(true);
setPayMsg('');
setPayMethod(method);
@@ -166,12 +181,14 @@ export default function ProxyOrderDetailPage() {
if (pay.mode === 'native' && pay.codeUrl) {
setCodeUrl(pay.codeUrl);
setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
startPoll(id);
return;
}
if (pay.mode === 'jsapi' && pay.prepay) {
setCodeUrl(null);
setOrder((prev) => (prev ? { ...prev, proxyPayMethod: method } : prev));
await invokeWechatPay(pay.prepay, {
apiBase: '/api/v1',
clientApp: 'PARTNER_H5',
@@ -191,14 +208,27 @@ export default function ProxyOrderDetailPage() {
}
}
const autoPayStartedRef = useRef(false);
useEffect(() => {
if (!id || !unpaid || autoPayStartedRef.current) return;
autoPayStartedRef.current = true;
void startPay('NATIVE');
// 仅首次进入未支付详情时自动拉收款码
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id, unpaid]);
async function cancelPay() {
if (!id) return;
setCancelling(true);
setPayMsg('');
stopPoll();
try {
await request('PARTNER_H5', `/partner/proxy-orders/${id}/cancel-pay`, {
method: 'POST',
body: '{}',
});
toastSuccess('已取消支付,可重新下单并选择其他支付方式');
navigate('/center/proxy-orders');
} catch (e) {
setPayMsg(e instanceof Error ? e.message : '取消失败');
toastError(e instanceof Error ? e.message : '取消失败');
} finally {
setCancelling(false);
}
}
const lockedPayMethod = order?.proxyPayMethod ?? null;
const img = order?.imageResource?.url || '';
const isOnSite = String(order?.deliveryType || '').toUpperCase() === 'ON_SITE_PICKUP';
@@ -285,6 +315,7 @@ export default function ProxyOrderDetailPage() {
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
onClick={() => void startPay('NATIVE')}
>
@@ -292,11 +323,21 @@ export default function ProxyOrderDetailPage() {
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
onClick={() => void startPay('JSAPI')}
>
</button>
</div>
{lockedPayMethod ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'}
</p>
) : (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
)}
{payMethod === 'NATIVE' ? (
<div style={{ textAlign: 'center', marginTop: 16 }}>
@@ -338,6 +379,16 @@ export default function ProxyOrderDetailPage() {
{payMsg}
</p>
) : null}
<button
type="button"
className="btn btn-block"
style={{ marginTop: 16 }}
disabled={cancelling || paying}
onClick={() => void cancelPay()}
>
{cancelling ? '取消中…' : '取消支付'}
</button>
</section>
) : null}
+68 -8
View File
@@ -70,6 +70,8 @@ export default function ProxyOrderPage() {
const [codeUrl, setCodeUrl] = useState<string | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [paying, setPaying] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [lockedPayMethod, setLockedPayMethod] = useState<ProxyPayMethod | null>(null);
const pollRef = useRef<number | null>(null);
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
@@ -234,8 +236,17 @@ export default function ProxyOrderPage() {
}
async function startPay(orderId: string, method: ProxyPayMethod) {
if (lockedPayMethod && lockedPayMethod !== method) {
setMsg(
lockedPayMethod === 'JSAPI'
? '该订单已发起微信代付,请先取消支付后重新下单'
: '该订单已生成收款码,请先取消支付后重新下单',
);
return;
}
setPaying(true);
setMsg('');
setPayMethod(method);
try {
if (method === 'JSAPI') {
if (!isWechatEnv()) {
@@ -266,11 +277,14 @@ export default function ProxyOrderPage() {
if (pay.mode === 'native' && pay.codeUrl) {
setCodeUrl(pay.codeUrl);
setLockedPayMethod(method);
startPoll(orderId);
return;
}
if (pay.mode === 'jsapi' && pay.prepay) {
setCodeUrl(null);
setLockedPayMethod(method);
await invokeWechatPay(pay.prepay, {
apiBase: '/api/v1',
clientApp: 'PARTNER_H5',
@@ -289,6 +303,40 @@ export default function ProxyOrderPage() {
}
}
async function cancelPay(orderId: string) {
setCancelling(true);
setMsg('');
stopPoll();
try {
await request('PARTNER_H5', `/partner/proxy-orders/${orderId}/cancel-pay`, {
method: 'POST',
body: '{}',
});
toastSuccess('已取消支付,可重新下单并选择其他支付方式');
setStep('form');
setCreated(null);
setCodeUrl(null);
setLockedPayMethod(null);
} catch (e) {
setMsg(e instanceof Error ? e.message : '取消失败');
} finally {
setCancelling(false);
}
}
function switchPayMethod(method: ProxyPayMethod) {
if (lockedPayMethod && lockedPayMethod !== method) {
setMsg(
lockedPayMethod === 'JSAPI'
? '该订单已发起微信代付,请先取消支付后重新下单'
: '该订单已生成收款码,请先取消支付后重新下单',
);
return;
}
setPayMethod(method);
setMsg('');
}
async function submit() {
setMsg('');
const err = validateForm();
@@ -322,6 +370,7 @@ export default function ProxyOrderPage() {
setCreated(order);
setStep('pay');
setCodeUrl(null);
setLockedPayMethod(null);
await startPay(order.id, payMethod);
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
@@ -359,24 +408,25 @@ export default function ProxyOrderPage() {
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'NATIVE' ? ' is-active' : ''}`}
onClick={() => {
setPayMethod('NATIVE');
void startPay(created.id, 'NATIVE');
}}
disabled={!!lockedPayMethod && lockedPayMethod !== 'NATIVE'}
onClick={() => switchPayMethod('NATIVE')}
>
</button>
<button
type="button"
className={`partner-proxy-mode-tab${payMethod === 'JSAPI' ? ' is-active' : ''}`}
onClick={() => {
setPayMethod('JSAPI');
void startPay(created.id, 'JSAPI');
}}
disabled={!!lockedPayMethod && lockedPayMethod !== 'JSAPI'}
onClick={() => switchPayMethod('JSAPI')}
>
</button>
</div>
{lockedPayMethod ? (
<p className="label-md text-muted" style={{ marginTop: 8 }}>
{lockedPayMethod === 'NATIVE' ? '收款码' : '微信代付'}
</p>
) : null}
</section>
{payMethod === 'NATIVE' ? (
@@ -419,6 +469,16 @@ export default function ProxyOrderPage() {
{msg}
</p>
) : null}
<button
type="button"
className="btn btn-block"
style={{ marginTop: 16 }}
disabled={cancelling || paying}
onClick={() => void cancelPay(created.id)}
>
{cancelling ? '取消中…' : '取消支付'}
</button>
</>
) : loadingOptions ? (
<p className="label-md text-muted"></p>
+14 -2
View File
@@ -40,7 +40,8 @@ import {
validateStoreStep3,
patchEnvPhotoAt,
addEnvPhotoSlot,
MIN_ENV_PHOTO_COUNT,
} from '../lib/storeDraft';
import StorePackagesForm from '../components/StorePackagesForm';
import { normalizePackageFormItems, validatePackageFormItems } from '../lib/storePackages';
@@ -956,7 +957,7 @@ export default function StoreCreatePage() {
<h3 className="headline-md"> <span className="text-primary">*</span></h3>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}> 3 </p>
<p className="label-md text-muted" style={{ margin: '4px 0 12px' }}> {MIN_ENV_PHOTO_COUNT} </p>
<div className="partner-upload-grid">
@@ -982,6 +983,17 @@ export default function StoreCreatePage() {
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={() =>
setForm((prev) => ({ ...prev, envPhotoUrls: addEnvPhotoSlot(prev.envPhotoUrls) }))
}
>
</button>
</section>
+13 -6
View File
@@ -5,7 +5,7 @@ import { request } from '../lib/api';
import { toastError, toastSuccess } from '../lib/toast';
import { usePartnerSession } from '../contexts/PartnerSessionContext';
import { canManagePartnerStore } from '../lib/partnerAccess';
import { normalizeStringArray, patchEnvPhotoAt } from '../lib/storeDraft';
import { MIN_ENV_PHOTO_COUNT, normalizeStringArray, patchEnvPhotoAt, addEnvPhotoSlot } from '../lib/storeDraft';
import OssUploadField from '../components/OssUploadField';
import TencentLocPickerOverlay from '../components/TencentLocPickerOverlay';
import {
@@ -18,7 +18,6 @@ import {
} from '../lib/storeStatus';
const STATUS_OPTIONS: StoreStatusValue[] = ['OPEN', 'PAUSED', 'CLOSED'];
const ENV_SLOT_COUNT = 3;
function uniqueEnvUrls(urls: string[]): string[] {
const seen = new Set<string>();
@@ -81,7 +80,7 @@ export default function StoreDetailPage() {
.map((m) => String(m.url || '')),
)
: [];
setEnvPhotoUrls(normalizeStringArray(envFromMedia, ENV_SLOT_COUNT));
setEnvPhotoUrls(normalizeStringArray(envFromMedia, MIN_ENV_PHOTO_COUNT));
}
useEffect(() => {
@@ -196,8 +195,8 @@ export default function StoreDetailPage() {
setActionError('请上传门头照');
return;
}
if (nextEnv.length < ENV_SLOT_COUNT) {
setActionError(`请上传至少 ${ENV_SLOT_COUNT} 张环境照片`);
if (nextEnv.length < MIN_ENV_PHOTO_COUNT) {
setActionError(`请上传至少 ${MIN_ENV_PHOTO_COUNT} 张环境照片`);
return;
}
setMediaSaving(true);
@@ -400,7 +399,7 @@ export default function StoreDetailPage() {
<h3 className="headline-md" style={{ borderLeft: '4px solid var(--color-heritage-red)', paddingLeft: 12 }}></h3>
<span className="label-md text-muted">
{canMutate && !readOnly
? ` ${ENV_SLOT_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length}`
? `至少 ${MIN_ENV_PHOTO_COUNT} 张 · 已选 ${uniqueEnvUrls(envPhotoUrls).length}`
: envPhotos.length
? `已上传 ${envPhotos.length}`
: '暂无照片'}
@@ -420,6 +419,14 @@ export default function StoreDetailPage() {
/>
))}
</div>
<button
type="button"
className="partner-btn-outline"
style={{ width: '100%', marginTop: 12 }}
onClick={() => setEnvPhotoUrls((prev) => addEnvPhotoSlot(prev))}
>
</button>
<button
type="button"
className="partner-btn-outline"