3 Commits

Author SHA1 Message Date
jacy 270cebc79a fix(partner): move store-open phone verify to step 3
CI / verify (pull_request) Has been cancelled
Confirm phone and SMS after bank details, then submit directly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-22 10:22:53 +08:00
jacy ba21cc89c6 Merge commit '56dfffd12647e1128ac4360aa9a4044361f0f7ab' into dev_jacy
CI / verify (pull_request) Has been cancelled
2026-07-22 09:05:51 +08:00
ljy 56dfffd126 fix:增加小程序客服 2026-07-21 22:24:53 +08:00
7 changed files with 359 additions and 235 deletions
+9 -9
View File
@@ -121,18 +121,11 @@ const PHONE_RE = /^1\d{10}$/;
const BANK_RE = /^\d{16,19}$/;
export function validateStoreStep1(
form: Pick<
StoreDraftForm,
'regionCodes' | 'cityId' | 'name' | 'phone' | 'storeSmsCode' | 'address' | 'intro'
>,
form: Pick<StoreDraftForm, 'regionCodes' | 'cityId' | 'name' | 'address' | 'intro'>,
): string | null {
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
if (!form.cityId) return '所选地区未匹配到开城城市,请联系总部配置开城区划';
if (!form.name.trim()) return '请填写门店名称';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
if (!form.address.trim()) return '请填写详细地址';
if (form.intro.trim()) {
const len = form.intro.trim().length;
@@ -158,11 +151,18 @@ export function patchEnvPhotoAt(urls: string[], index: number, url: string): str
}
export function validateStoreStep3(
form: Pick<StoreDraftForm, 'bankAccountName' | 'bankAccountNo' | 'bankBranch'>,
form: Pick<
StoreDraftForm,
'bankAccountName' | 'bankAccountNo' | 'bankBranch' | 'phone' | 'storeSmsCode'
>,
): string | null {
if (!form.bankAccountName.trim()) return '请填写户主姓名';
if (!form.bankAccountNo.trim()) return '请填写银行卡号';
if (!BANK_RE.test(form.bankAccountNo.replace(/\s/g, ''))) return '银行卡号须为 16~19 位数字';
if (!form.bankBranch.trim()) return '请填写开户支行';
if (!form.phone.trim()) return '请填写联系电话';
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
if (!form.storeSmsCode.trim()) return '请输入门店手机号验证码';
if (!/^\d{4,6}$/.test(form.storeSmsCode.trim())) return '验证码格式不正确';
return null;
}
+118 -197
View File
@@ -93,8 +93,6 @@ export default function StoreCreatePage() {
const [submitting, setSubmitting] = useState(false);
const [checkingPhone, setCheckingPhone] = useState(false);
const [cities, setCities] = useState<OpenCityOption[]>([]);
const [citiesError, setCitiesError] = useState('');
@@ -354,54 +352,10 @@ export default function StoreCreatePage() {
const msg = validateStoreStep1(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
if (msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
} else {
reportFormError(msg);
}
reportFormError(msg);
return;
}
setCheckingPhone(true);
setSubmitError('');
setFieldErrors({});
try {
const phoneCheck = await checkStorePhoneAvailable(form.phone.trim());
if (!phoneCheck.available) {
const phoneMsg = phoneCheck.message ?? '该手机号已绑定门店,请更换';
setFieldErrors({ phone: phoneMsg });
return;
}
if (phoneCheck.needConfirm) {
const ok = window.confirm(
phoneCheck.message ??
`该手机号已是门店主账号(已绑 ${phoneCheck.existingStoreCount ?? 0} 家店),确认后将追加绑定新店。是否继续?`,
);
if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
return;
}
}
} catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
return;
} finally {
setCheckingPhone(false);
}
}
if (step === 2) {
@@ -426,6 +380,14 @@ export default function StoreCreatePage() {
const msg = validateStoreStep3(form);
if (msg) {
if (isPhoneValidationMessage(msg)) {
if (msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: msg });
} else {
setFieldErrors({ phone: msg });
}
return;
}
reportFormError(msg);
return;
}
@@ -433,15 +395,6 @@ export default function StoreCreatePage() {
const step1Msg = validateStoreStep1(form);
if (step1Msg) {
if (isPhoneValidationMessage(step1Msg)) {
if (step1Msg.includes('验证码')) {
setFieldErrors({ storeSmsCode: step1Msg });
} else {
setFieldErrors({ phone: step1Msg });
}
goStep(1);
return;
}
reportFormError(step1Msg);
goStep(1);
return;
@@ -481,6 +434,8 @@ export default function StoreCreatePage() {
});
setSubmitting(false);
return;
}
@@ -492,6 +447,7 @@ export default function StoreCreatePage() {
);
if (!ok) {
setFieldErrors({ phone: '已取消绑定已有主账号,请更换手机号或确认后继续' });
setSubmitting(false);
return;
}
confirmBindExisting = true;
@@ -499,6 +455,7 @@ export default function StoreCreatePage() {
} catch (e) {
reportFormError(e instanceof Error ? e.message : '手机号校验失败');
setSubmitting(false);
return;
}
@@ -567,7 +524,7 @@ export default function StoreCreatePage() {
}
if (/验证码/.test(message)) {
setFieldErrors({ storeSmsCode: message });
goStep(1);
goStep(3);
return;
}
setSubmitError(message);
@@ -584,7 +541,7 @@ export default function StoreCreatePage() {
const progress = step === 1 ? 0 : step === 2 ? 50 : 100;
const nextDisabled = submitting || checkingPhone;
const nextDisabled = submitting;
@@ -692,104 +649,6 @@ export default function StoreCreatePage() {
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input
type="tel"
placeholder="请输入11位手机号"
value={form.phone}
onChange={(e) => patchForm({ phone: e.target.value })}
/>
</div>
{fieldErrors.phone && (
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input
className="partner-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="请输入短信验证码"
value={form.storeSmsCode}
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
/>
</div>
<button
type="button"
className="partner-code-btn"
disabled={smsCooldown > 0 || checkingPhone}
onClick={() => void sendStorePhoneCode()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
{smsHint && (
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
)}
{fieldErrors.storeSmsCode && (
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
)}
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
@@ -976,46 +835,6 @@ export default function StoreCreatePage() {
</div>
<section className="partner-form-card">
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input
type="tel"
placeholder="请输入11位手机号"
value={form.phone}
onChange={(e) => patchForm({ phone: e.target.value })}
/>
</div>
{fieldErrors.phone && (
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
</section>
<section className="partner-form-card">
<div className="partner-field">
@@ -1056,6 +875,108 @@ export default function StoreCreatePage() {
</div>
<section className="partner-form-card">
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-field-input">
<span className="material-symbols-outlined">call</span>
<input
type="tel"
placeholder="请输入11位手机号"
value={form.phone}
onChange={(e) => patchForm({ phone: e.target.value })}
/>
</div>
{fieldErrors.phone && (
<p className="partner-field-error" role="alert">{fieldErrors.phone}</p>
)}
<p className="label-md text-muted" style={{ marginTop: 8 }}>
</p>
</div>
<div className="partner-field">
<label> <span className="text-primary">*</span></label>
<div className="partner-input-row">
<div className="partner-input-wrap" style={{ flex: 1 }}>
<span className="material-symbols-outlined partner-input-icon">shield</span>
<input
className="partner-input"
type="text"
inputMode="numeric"
maxLength={6}
placeholder="请输入短信验证码"
value={form.storeSmsCode}
onChange={(e) => patchForm({ storeSmsCode: e.target.value.replace(/\D/g, '') })}
/>
</div>
<button
type="button"
className="partner-code-btn"
disabled={smsCooldown > 0 || submitting}
onClick={() => void sendStorePhoneCode()}
>
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
</button>
</div>
{smsHint && (
<p className="label-md text-muted" style={{ marginTop: 8 }}>{smsHint}</p>
)}
{fieldErrors.storeSmsCode && (
<p className="partner-field-error" role="alert">{fieldErrors.storeSmsCode}</p>
)}
</div>
</section>
</>
)}
@@ -1074,7 +995,7 @@ export default function StoreCreatePage() {
<button type="button" className="partner-btn-primary" onClick={() => void handleNext()} disabled={nextDisabled}>
<span>{checkingPhone ? '校验中…' : '下一步'}</span>
<span></span>
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>navigate_next</span>
@@ -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>
);
}
@@ -3,28 +3,42 @@ import Taro from '@tarojs/taro';
import { CUSTOMER_SERVICE_PHONE } from '@dukang/shared-types';
import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ContactCsButton from '../../components/ContactCsButton';
import { toast } from '../../lib/api';
const isWeapp = process.env.TARO_ENV === 'weapp';
const DIAL_PHONE = CUSTOMER_SERVICE_PHONE.replace(/-/g, '');
function dialPhone() {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() => toast('无法拨打电话'));
}
export default function CustomerServicePage() {
return (
<PageShell variant="sub" className="cs-page">
<SubPageHeader title="联系客服" />
<View className="sub-page-body inset-page cs-body">
<Text className="cs-title">线</Text>
<Text className="cs-phone">{CUSTOMER_SERVICE_PHONE}</Text>
<Text className="cs-hint">9:00 - 21:00</Text>
<View
className="cs-call-btn"
onClick={() => {
Taro.makePhoneCall({ phoneNumber: DIAL_PHONE }).catch(() =>
toast('无法拨打电话'),
);
}}
>
<Text></Text>
<Text className="cs-brand"></Text>
<Text className="cs-hint">
{isWeapp
? '点击下方按钮,进入小程序在线客服会话'
: '请在微信小程序内打开以使用在线客服,或拨打客服电话'}
</Text>
<Text className="cs-hours">9:00 - 21:00</Text>
{isWeapp ? (
<ContactCsButton className="cs-online-btn" session={{ from: 'customer-service' }} />
) : null}
<View className={isWeapp ? 'cs-phone-link' : 'cs-call-btn'} onClick={dialPhone}>
<Text>
{isWeapp ? `或拨打客服电话 ${CUSTOMER_SERVICE_PHONE}` : '拨打客服电话'}
</Text>
</View>
{!isWeapp ? (
<Text className="cs-phone-display">{CUSTOMER_SERVICE_PHONE}</Text>
) : null}
</View>
</PageShell>
);
+48 -13
View File
@@ -5,6 +5,7 @@ import PageShell from '../../components/PageShell';
import SubPageHeader from '../../components/SubPageHeader';
import ShareNavButton from '../../components/ShareNavButton';
import WechatShareReady from '../../components/WechatShareReady';
import ContactCsButton from '../../components/ContactCsButton';
import { request, toast } from '../../lib/api';
import { buildPayUrl } from '../../lib/checkout-nav';
import { maskPhone } from '../../lib/phone';
@@ -14,6 +15,8 @@ import {
toWeappShareMessage,
} from '../../lib/wechat-share';
const isWeapp = process.env.TARO_ENV === 'weapp';
type OrderItem = {
productName?: string;
productSpec?: string;
@@ -104,8 +107,20 @@ export default function OrderDetailPage() {
Taro.navigateTo({ url: buildPayUrl({ orderId: order.id }) });
}
function goCustomerService() {
Taro.navigateTo({ url: '/pages/customer-service/index' });
}
const pageClass = [
'order-detail-page',
order ? 'order-detail-page--with-actions' : '',
canPay ? 'order-detail-page--with-pay' : '',
]
.filter(Boolean)
.join(' ');
return (
<PageShell variant="sub" className={`order-detail-page${canPay ? ' order-detail-page--with-pay' : ''}`}>
<PageShell variant="sub" className={pageClass}>
<WechatShareReady payload={sharePayload} />
<SubPageHeader
title="订单详情"
@@ -173,19 +188,39 @@ export default function OrderDetailPage() {
)}
</View>
{canPay && order && (
<View className="pay-bar order-detail-pay-bar">
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{Number(order.payAmount ?? 0).toFixed(2)}
</Text>
</View>
<View className="order-confirm-submit" onClick={goPay}>
</View>
{order ? (
<View className={`order-detail-actionbar${canPay ? ' order-detail-actionbar--with-pay' : ''}`}>
{isWeapp ? (
<ContactCsButton
className="order-detail-cs-btn"
session={{
from: 'order-detail',
orderId: order.id,
orderNo: order.orderNo,
}}
>
</ContactCsButton>
) : (
<View className="order-detail-cs-btn" onClick={goCustomerService}>
<Text></Text>
</View>
)}
{canPay ? (
<>
<View className="order-confirm-total">
<Text className="order-confirm-total-label"></Text>
<Text className="order-confirm-total-value">
¥{Number(order.payAmount ?? 0).toFixed(2)}
</Text>
</View>
<View className="order-confirm-submit" onClick={goPay}>
</View>
</>
) : null}
</View>
)}
) : null}
</PageShell>
);
}
+53 -1
View File
@@ -7,7 +7,8 @@
min-height: 100vh;
}
.order-detail-page--with-pay .sub-page-body {
.order-detail-page--with-pay .sub-page-body,
.order-detail-page--with-actions .sub-page-body {
padding-bottom: calc(88px + env(safe-area-inset-bottom, 0px));
}
@@ -16,6 +17,57 @@
gap: 12px;
}
.order-detail-actionbar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 50;
display: flex;
align-items: center;
gap: 10px;
padding: 10px var(--space-page) calc(10px + env(safe-area-inset-bottom, 0px));
background: var(--color-card, #fff);
box-shadow: 0 -4px 16px rgba(0, 0, 0, 0.06);
box-sizing: border-box;
}
.order-detail-actionbar--with-pay {
justify-content: space-between;
}
.order-detail-cs-btn {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
min-width: 96px;
height: 40px;
padding: 0 14px;
margin: 0;
border: 1px solid var(--color-outline, #c8c4be);
border-radius: 999px;
background: transparent;
color: var(--color-on-surface);
font-size: 14px;
font-weight: 600;
line-height: 1.2;
box-sizing: border-box;
}
.order-detail-cs-btn::after {
border: none;
}
.order-detail-actionbar .order-confirm-total {
flex: 1;
min-width: 0;
}
.order-detail-actionbar .order-confirm-submit {
flex-shrink: 0;
}
.order-card {
background: var(--color-card);
border-radius: var(--radius-lg);
+55 -3
View File
@@ -499,27 +499,70 @@
text-align: center;
}
.cs-brand {
display: block;
font-family: var(--font-headline);
font-size: 22px;
font-weight: 700;
color: var(--color-on-surface);
margin-bottom: 10px;
}
.cs-title {
font-size: 14px;
color: var(--color-subtle-gray);
margin-bottom: 12px;
}
.cs-phone {
.cs-phone,
.cs-phone-display {
display: block;
font-family: var(--font-headline);
font-size: 32px;
font-size: 28px;
font-weight: 700;
color: var(--color-heritage-red);
margin-bottom: 8px;
margin-top: 20px;
letter-spacing: 1px;
}
.cs-hint {
display: block;
font-size: 14px;
color: var(--color-subtle-gray);
line-height: 1.5;
margin-bottom: 8px;
max-width: 280px;
}
.cs-hours {
display: block;
font-size: 12px;
color: var(--color-subtle-gray);
margin-bottom: 32px;
}
.cs-online-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
max-width: 280px;
padding: 14px 24px;
margin: 0;
border: none;
border-radius: 999px;
background: var(--color-heritage-red);
color: #fff;
font-size: 15px;
font-weight: 600;
line-height: 1.4;
box-sizing: border-box;
}
.cs-online-btn::after {
border: none;
}
.cs-call-btn {
padding: 12px 32px;
border-radius: 999px;
@@ -528,3 +571,12 @@
font-size: 15px;
font-weight: 600;
}
.cs-phone-link {
margin-top: 20px;
padding: 8px 12px;
font-size: 13px;
color: var(--color-subtle-gray);
text-decoration: underline;
text-underline-offset: 3px;
}