代下单功能

This commit is contained in:
2026-07-12 11:40:04 +08:00
parent d949301bc1
commit de01d36cdb
46 changed files with 2657 additions and 253 deletions
+2 -1
View File
@@ -14,7 +14,7 @@ import BillsPage from './pages/BillsPage';
import SettlementPage from './pages/SettlementPage';
import ReshipPage from './pages/ReshipPage';
import WeeklyReportPage from './pages/WeeklyReportPage';
import LeaderboardPage from './pages/LeaderboardPage';
import ProxyOrderPage from './pages/ProxyOrderPage';
import StaffListPage from './pages/StaffListPage';
import StaffCreatePage from './pages/StaffCreatePage';
@@ -32,6 +32,7 @@ function PrimaryRoutes() {
<Route path="/center/settlement" element={<SettlementPage />} />
<Route path="/center/staff" element={<StaffListPage />} />
<Route path="/center/staff/new" element={<StaffCreatePage />} />
<Route path="/proxy-order" element={<ProxyOrderPage />} />
<Route path="/reshipments" element={<ReshipPage />} />
<Route path="/reports/weekly" element={<WeeklyReportPage />} />
<Route path="/leaderboard" element={<LeaderboardPage />} />
+6
View File
@@ -100,6 +100,12 @@ export default function HomePage() {
<section className="partner-bento-card">
<div className="partner-quick-actions">
<Link to="/proxy-order" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">shopping_cart_checkout</span>
</div>
<span className="partner-quick-action-label"></span>
</Link>
<Link to="/stores/new" className="partner-quick-action">
<div className="partner-quick-action-icon partner-quick-action-icon--red">
<span className="material-symbols-outlined">add_business</span>
@@ -0,0 +1,330 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PageHeader from '@dukang/shared-ui/PageHeader';
import ChinaRegionPicker from '../components/ChinaRegionPicker';
import { request } from '../lib/api';
import { formatRegionLabel, parseRegionCodes } from '../lib/china-region';
import { toastError, toastSuccess } from '../lib/toast';
import type {
PartnerProxyOrderCreateRequest,
PartnerProxyOrderOptions,
PartnerProxyOrderPreviewResult,
} from '@dukang/shared-types';
function fmtMoney(n: number) {
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
export default function ProxyOrderPage() {
const navigate = useNavigate();
const [options, setOptions] = useState<PartnerProxyOrderOptions | null>(null);
const [loadingOptions, setLoadingOptions] = useState(true);
const [phone, setPhone] = useState('');
const [smsCode, setSmsCode] = useState('');
const [receiverName, setReceiverName] = useState('');
const [regionCodes, setRegionCodes] = useState<string[]>([]);
const [addressDetail, setAddressDetail] = useState('');
const [productId, setProductId] = useState('');
const [quantity, setQuantity] = useState(2);
const [promoCodeId, setPromoCodeId] = useState('');
const [preview, setPreview] = useState<PartnerProxyOrderPreviewResult | null>(null);
const [previewLoading, setPreviewLoading] = useState(false);
const [smsCooldown, setSmsCooldown] = useState(0);
const [submitting, setSubmitting] = useState(false);
const [msg, setMsg] = useState('');
const region = useMemo(() => parseRegionCodes(regionCodes), [regionCodes]);
const regionLabel = region ? formatRegionLabel(region) : '';
useEffect(() => {
request<PartnerProxyOrderOptions>('PARTNER_H5', '/partner/proxy-orders/options')
.then((data) => {
setOptions(data);
if (data.products[0]) setProductId(data.products[0].id);
})
.catch((e) => toastError(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoadingOptions(false));
}, []);
useEffect(() => {
if (!productId || quantity < 1) {
setPreview(null);
return;
}
const timer = setTimeout(() => {
setPreviewLoading(true);
request<PartnerProxyOrderPreviewResult>('PARTNER_H5', '/partner/proxy-orders/preview', {
method: 'POST',
body: JSON.stringify({
productId,
quantity,
receiverCity: region.city || undefined,
receiverDistrict: region.district || undefined,
}),
silent: true,
})
.then(setPreview)
.catch(() => setPreview(null))
.finally(() => setPreviewLoading(false));
}, 300);
return () => clearTimeout(timer);
}, [productId, quantity, region.city, region.district]);
async function sendSms() {
setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
return;
}
try {
const res = await request<{ maskedPhone: string }>('PARTNER_H5', '/partner/proxy-orders/send-sms', {
method: 'POST',
body: JSON.stringify({ phone: phone.trim() }),
silent: true,
});
setMsg(`验证码已发送至 ${res.maskedPhone}`);
setSmsCooldown(60);
const timer = setInterval(() => {
setSmsCooldown((s) => {
if (s <= 1) {
clearInterval(timer);
return 0;
}
return s - 1;
});
}, 1000);
} catch (e) {
setMsg(e instanceof Error ? e.message : '发送失败');
}
}
async function submit() {
setMsg('');
if (!/^1\d{10}$/.test(phone.trim())) {
setMsg('请输入有效手机号');
return;
}
if (!smsCode.trim()) {
setMsg('请输入验证码');
return;
}
if (!region?.province || !region.city || !region.district) {
setMsg('请选择省市区');
return;
}
if (!addressDetail.trim()) {
setMsg('请填写详细地址');
return;
}
if (!productId) {
setMsg('请选择商品');
return;
}
const payload: PartnerProxyOrderCreateRequest = {
phone: phone.trim(),
smsCode: smsCode.trim(),
receiverName: receiverName.trim() || undefined,
province: region.province,
city: region.city,
district: region.district,
addressDetail: addressDetail.trim(),
productId,
quantity,
promoCodeId: promoCodeId || undefined,
};
setSubmitting(true);
try {
const order = await request<{ id: string; orderNo: string }>('PARTNER_H5', '/partner/proxy-orders', {
method: 'POST',
body: JSON.stringify(payload),
silent: true,
});
toastSuccess(`代下单成功:${order.orderNo}`);
navigate(`/orders/${order.id}`);
} catch (e) {
setMsg(e instanceof Error ? e.message : '下单失败');
} finally {
setSubmitting(false);
}
}
const selectedProduct = options?.products.find((p) => p.id === productId);
return (
<div className="page partner-proxy-order-page">
<PageHeader title="代下单" onBack={() => navigate(-1)} />
<main className="partner-form-card" style={{ margin: '0 16px 24px' }}>
{loadingOptions ? (
<p className="label-md text-muted">广</p>
) : (
<>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<input
className="partner-input"
placeholder="11 位手机号"
value={phone}
onChange={(e) => setPhone(e.target.value.replace(/\D/g, '').slice(0, 11))}
/>
</div>
<button
type="button"
className="btn btn-secondary"
disabled={smsCooldown > 0}
onClick={() => void sendSms()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="线下代发货确认码"
value={smsCode}
onChange={(e) => setSmsCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
placeholder="默认:用户+手机尾号"
value={receiverName}
onChange={(e) => setReceiverName(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<ChinaRegionPicker value={regionCodes} onChange={setRegionCodes} />
{regionLabel && <p className="label-md text-muted" style={{ marginTop: 8 }}>{regionLabel}</p>}
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-field-input partner-field-input--block">
<textarea
rows={3}
placeholder="街道、门牌号等"
value={addressDetail}
onChange={(e) => setAddressDetail(e.target.value)}
/>
</div>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<select
className="partner-input"
value={productId}
onChange={(e) => setProductId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
>
{(options?.products ?? []).map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.spec}¥{fmtMoney(p.price)}
</option>
))}
</select>
</section>
<section className="partner-form-section">
<label className="partner-form-label"></label>
<div className="partner-input-wrap">
<input
className="partner-input"
type="number"
min={1}
value={quantity}
onChange={(e) => setQuantity(Math.max(1, Number(e.target.value) || 1))}
/>
</div>
</section>
{(options?.promoCodes.length ?? 0) > 0 && (
<section className="partner-form-section">
<label className="partner-form-label">广</label>
<select
className="partner-input"
value={promoCodeId}
onChange={(e) => setPromoCodeId(e.target.value)}
style={{ width: '100%', padding: '12px 14px', borderRadius: 12, border: '1px solid var(--color-border)' }}
>
<option value=""></option>
{options!.promoCodes.map((p) => (
<option key={p.id} value={p.id}>
{p.name}{p.code}
</option>
))}
</select>
</section>
)}
<section className="partner-proxy-fee-card">
<h3 className="headline-md"></h3>
{previewLoading ? (
<p className="label-md text-muted"></p>
) : preview ? (
<>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">¥{fmtMoney(preview.unitPrice)}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">×{quantity}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">{preview.deliveryType === 'LOCAL' ? '同城' : '跨城'}</span>
</div>
<div className="partner-proxy-fee-row">
<span className="label-md text-muted"></span>
<span className="body-md">¥{fmtMoney(preview.benefitAmount)}</span>
</div>
<div className="partner-proxy-fee-row partner-proxy-fee-row--total">
<span className="headline-md"></span>
<span className="amount-lg text-primary">¥{fmtMoney(preview.payAmount)}</span>
</div>
</>
) : (
<p className="label-md text-muted">
{selectedProduct ? '请确认数量与地址后查看费用' : '请选择商品'}
</p>
)}
</section>
{msg && <p className="partner-form-error" role="alert">{msg}</p>}
<button
type="button"
className="btn btn-primary btn-block"
disabled={submitting || !preview}
onClick={() => void submit()}
style={{ marginTop: 16 }}
>
{submitting ? '提交中…' : '验证码确认并代下单'}
</button>
<p className="label-md text-muted" style={{ marginTop: 12, lineHeight: 1.5 }}>
/线
</p>
</>
)}
</main>
</div>
);
}
+25
View File
@@ -3031,3 +3031,28 @@ body {
color: var(--color-outline, #8d706e);
pointer-events: none;
}
.partner-form-section {
margin-bottom: 16px;
}
.partner-proxy-fee-card {
margin-top: 8px;
padding: 16px;
border-radius: 16px;
background: rgba(166, 29, 36, 0.04);
border: 1px solid rgba(166, 29, 36, 0.08);
}
.partner-proxy-fee-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
}
.partner-proxy-fee-row--total {
margin-top: 8px;
padding-top: 12px;
border-top: 1px dashed rgba(166, 29, 36, 0.15);
}