手机号核销确认
This commit is contained in:
@@ -22,16 +22,25 @@ export default function RedeemDebugPage() {
|
||||
const [createForm] = Form.useForm();
|
||||
const [previewForm] = Form.useForm();
|
||||
const [confirmForm] = Form.useForm();
|
||||
const [phoneLookupForm] = Form.useForm();
|
||||
const [phoneBalanceForm] = Form.useForm();
|
||||
const [phonePrepareForm] = Form.useForm();
|
||||
const [phoneConfirmForm] = Form.useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [createResult, setCreateResult] = useState<ApiResult | null>(null);
|
||||
const [previewResult, setPreviewResult] = useState<ApiResult | null>(null);
|
||||
const [confirmResult, setConfirmResult] = useState<ApiResult | null>(null);
|
||||
const [phoneLookupResult, setPhoneLookupResult] = useState<ApiResult | null>(null);
|
||||
const [phoneBalanceResult, setPhoneBalanceResult] = useState<ApiResult | null>(null);
|
||||
const [phonePrepareResult, setPhonePrepareResult] = useState<ApiResult | null>(null);
|
||||
const [phoneConfirmResult, setPhoneConfirmResult] = useState<ApiResult | null>(null);
|
||||
|
||||
async function invoke(
|
||||
path: string,
|
||||
body: unknown,
|
||||
setResult: (v: ApiResult | null) => void,
|
||||
successMsg: string,
|
||||
onSuccess?: (res: ApiResult) => void,
|
||||
) {
|
||||
setLoading(true);
|
||||
setResult(null);
|
||||
@@ -42,10 +51,21 @@ export default function RedeemDebugPage() {
|
||||
});
|
||||
setResult(res);
|
||||
message.success(successMsg);
|
||||
onSuccess?.(res);
|
||||
if (path.includes('create-token') && res.token) {
|
||||
previewForm.setFieldsValue({ token: res.token });
|
||||
confirmForm.setFieldsValue({ token: res.token });
|
||||
}
|
||||
if (path.includes('phone/balance') && res.sessionId) {
|
||||
phonePrepareForm.setFieldsValue({
|
||||
storeId: phoneBalanceForm.getFieldValue('storeId'),
|
||||
sessionId: res.sessionId,
|
||||
});
|
||||
phoneConfirmForm.setFieldsValue({
|
||||
storeId: phoneBalanceForm.getFieldValue('storeId'),
|
||||
sessionId: res.sessionId,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '调用失败');
|
||||
} finally {
|
||||
@@ -53,129 +73,311 @@ export default function RedeemDebugPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const tokenTabItems = [
|
||||
{
|
||||
key: 'create',
|
||||
label: '1. 生成核销码',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="userId"
|
||||
label="用户"
|
||||
rules={[{ required: true, message: '请填写用户 ID、编号或手机号' }]}
|
||||
>
|
||||
<Input placeholder="数据库 ID / 用户编号 / 手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="couponId" label="指定券 ID(可选)">
|
||||
<Input placeholder="不填则自动分摊" />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="绑定门店 ID(可选)">
|
||||
<Input placeholder="绑定后仅该门店可核销" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void createForm.validateFields().then((values) => {
|
||||
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
|
||||
});
|
||||
}}
|
||||
>
|
||||
生成核销码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={createResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
label: '2. 预览核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={previewForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = previewForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
|
||||
}}
|
||||
>
|
||||
预览
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={previewResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'confirm',
|
||||
label: '3. 确认核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={confirmForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = confirmForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
|
||||
}}
|
||||
>
|
||||
确认核销
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={confirmResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const phoneTabItems = [
|
||||
{
|
||||
key: 'phone-lookup',
|
||||
label: '1. 发送查权益验证码',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={phoneLookupForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
|
||||
<Input placeholder="11 位手机号" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void phoneLookupForm.validateFields().then((values) => {
|
||||
phoneBalanceForm.setFieldsValue({
|
||||
storeId: values.storeId,
|
||||
phone: values.phone,
|
||||
});
|
||||
void invoke(
|
||||
'/admin/redeem/debug/phone/send-lookup-sms',
|
||||
values,
|
||||
setPhoneLookupResult,
|
||||
'查权益验证码已发送',
|
||||
);
|
||||
});
|
||||
}}
|
||||
>
|
||||
发送验证码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={phoneLookupResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone-balance',
|
||||
label: '2. 验证并查权益',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={phoneBalanceForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="用户手机号" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="验证码" rules={[{ required: true }]}>
|
||||
<Input placeholder="Mock 默认 123456" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void phoneBalanceForm.validateFields().then((values) => {
|
||||
void invoke('/admin/redeem/debug/phone/balance', values, setPhoneBalanceResult, '权益查询成功');
|
||||
});
|
||||
}}
|
||||
>
|
||||
查询权益
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={phoneBalanceResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone-prepare',
|
||||
label: '3. 发送核销确认码',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={phonePrepareForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
|
||||
<Input placeholder="上一步返回的 sessionId" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void phonePrepareForm.validateFields().then((values) => {
|
||||
phoneConfirmForm.setFieldsValue({
|
||||
storeId: values.storeId,
|
||||
sessionId: values.sessionId,
|
||||
});
|
||||
void invoke('/admin/redeem/debug/phone/prepare', values, setPhonePrepareResult, '核销确认码已发送');
|
||||
});
|
||||
}}
|
||||
>
|
||||
发送确认码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={phonePrepareResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'phone-confirm',
|
||||
label: '4. 确认核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={phoneConfirmForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sessionId" label="会话 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="核销确认验证码" rules={[{ required: true }]}>
|
||||
<Input placeholder="Mock 默认 123456" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void phoneConfirmForm.validateFields().then((values) => {
|
||||
void invoke('/admin/redeem/debug/phone/confirm', values, setPhoneConfirmResult, '手机号核销成功');
|
||||
});
|
||||
}}
|
||||
>
|
||||
确认核销
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={phoneConfirmResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>核销调试</Typography.Title>
|
||||
|
||||
<Tabs
|
||||
defaultActiveKey="token"
|
||||
items={[
|
||||
{
|
||||
key: 'create',
|
||||
label: '1. 生成核销码',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={createForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="userId"
|
||||
label="用户"
|
||||
rules={[{ required: true, message: '请填写用户 ID、编号或手机号' }]}
|
||||
>
|
||||
<Input placeholder="数据库 ID / 用户编号 / 手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="核销金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} max={500} step={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="couponId" label="指定券 ID(可选)">
|
||||
<Input placeholder="不填则自动分摊" />
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="绑定门店 ID(可选)">
|
||||
<Input placeholder="绑定后仅该门店可核销" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
void createForm.validateFields().then((values) => {
|
||||
void invoke('/admin/redeem/debug/create-token', values, setCreateResult, '核销码已生成');
|
||||
});
|
||||
}}
|
||||
>
|
||||
生成核销码
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={createResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
key: 'token',
|
||||
label: '扫码核销',
|
||||
children: <Tabs items={tokenTabItems} />,
|
||||
},
|
||||
{
|
||||
key: 'preview',
|
||||
label: '2. 预览核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={previewForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Button
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = previewForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/preview', values, setPreviewResult, '预览成功');
|
||||
}}
|
||||
>
|
||||
预览
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={previewResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'confirm',
|
||||
label: '3. 确认核销',
|
||||
children: (
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card size="small" title="参数">
|
||||
<Form form={confirmForm} layout="vertical">
|
||||
<Form.Item name="storeId" label="门店 ID" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="token" label="核销码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
loading={loading}
|
||||
onClick={() => {
|
||||
const values = confirmForm.getFieldsValue();
|
||||
void invoke('/admin/redeem/debug/confirm', values, setConfirmResult, '核销成功');
|
||||
}}
|
||||
>
|
||||
确认核销
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} lg={14}>
|
||||
<Card size="small" title="响应">
|
||||
<ResultBox data={confirmResult} />
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
),
|
||||
key: 'phone',
|
||||
label: '手机号核销',
|
||||
children: <Tabs items={phoneTabItems} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,7 @@ import TabLayout from './layouts/TabLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import HomePage from './pages/HomePage';
|
||||
import RedeemConfirmPage from './pages/RedeemConfirmPage';
|
||||
import PhoneRedeemPage from './pages/PhoneRedeemPage';
|
||||
import RedeemSuccessPage from './pages/RedeemSuccessPage';
|
||||
import RecordsPage from './pages/RecordsPage';
|
||||
import StatusPage from './pages/StatusPage';
|
||||
@@ -15,6 +16,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
<Route element={<TabLayout />}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
|
||||
@@ -172,6 +172,10 @@ export default function HomePage() {
|
||||
</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>
|
||||
手机号核销
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
<section className="shop-home-status">
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type BalanceResult = {
|
||||
sessionId: string;
|
||||
totalBalance: number;
|
||||
maskedPhone: string;
|
||||
user?: { nickname?: string; phone?: string; userNo?: string };
|
||||
};
|
||||
|
||||
function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type Step = 'lookup' | 'amount' | 'confirm';
|
||||
|
||||
export default function PhoneRedeemPage() {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState<Step>('lookup');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [lookupCode, setLookupCode] = useState('');
|
||||
const [confirmCode, setConfirmCode] = useState('');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [balance, setBalance] = useState<BalanceResult | null>(null);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [lookupCooldown, setLookupCooldown] = useState(0);
|
||||
const [confirmCooldown, setConfirmCooldown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => {
|
||||
setStoreName(String(s.name || '当前门店'));
|
||||
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
|
||||
})
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (lookupCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setLookupCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [lookupCooldown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (confirmCooldown <= 0) return;
|
||||
const timer = window.setTimeout(() => setConfirmCooldown((v) => v - 1), 1000);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [confirmCooldown]);
|
||||
|
||||
async function sendLookupSms() {
|
||||
if (!/^1\d{10}$/.test(phone.trim())) {
|
||||
setMsg('请输入正确的手机号');
|
||||
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) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
}
|
||||
const value = Number(amount);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
setMsg('请输入有效核销金额');
|
||||
return;
|
||||
}
|
||||
if (balance && value > balance.totalBalance) {
|
||||
setMsg('核销金额不能超过可用权益');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/redeem/phone/prepare', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ sessionId: balance?.sessionId, amount: value }),
|
||||
});
|
||||
setConfirmCooldown(60);
|
||||
setStep('confirm');
|
||||
setMsg('确认验证码已发送至用户手机,请向用户索取后输入');
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '发起核销失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRedeem() {
|
||||
if (!confirmCode.trim()) {
|
||||
setMsg('请输入确认验证码');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMsg('');
|
||||
try {
|
||||
const result = await request<Record<string, unknown>>('SHOP_H5', '/shop/redeem/phone/confirm', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
sessionId: balance?.sessionId,
|
||||
code: confirmCode.trim(),
|
||||
}),
|
||||
});
|
||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||
navigate('/redeem/success', {
|
||||
state: { result, storeName, user: balance?.user },
|
||||
});
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const userLabel = balance?.user?.nickname || balance?.maskedPhone || '—';
|
||||
|
||||
return (
|
||||
<div className="shop-redeem-page">
|
||||
<header className="shop-redeem-header">
|
||||
<button type="button" className="shop-redeem-back" onClick={() => navigate(-1)} aria-label="返回">
|
||||
<span className="material-symbols-outlined">arrow_back</span>
|
||||
</button>
|
||||
<h1 className="app-page-title">手机号核销</h1>
|
||||
</header>
|
||||
|
||||
<main className="shop-redeem-main">
|
||||
{storeClosed && (
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店当前未营业,无法核销</p>
|
||||
)}
|
||||
|
||||
<section className="shop-redeem-card">
|
||||
<div className="shop-redeem-banner">
|
||||
<div className="shop-redeem-banner-icon">
|
||||
<span className="material-symbols-outlined shop-fill-icon">smartphone</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-redeem-banner-label">当前登录核销门店</p>
|
||||
<h2 className="shop-redeem-banner-name">{storeName}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shop-redeem-body">
|
||||
{step === 'lookup' && (
|
||||
<>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">用户手机号</label>
|
||||
<input
|
||||
className="shop-phone-input"
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入用户手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
||||
/>
|
||||
</div>
|
||||
<div className="shop-phone-field">
|
||||
<label className="shop-phone-label">验证码</label>
|
||||
<div className="shop-phone-code-row">
|
||||
<input
|
||||
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-redeem-user">
|
||||
<div className="shop-redeem-user-left">
|
||||
<span className="material-symbols-outlined">person</span>
|
||||
<span>用户</span>
|
||||
</div>
|
||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
||||
{userLabel}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-redeem-amount-section">
|
||||
<p className="shop-redeem-amount-label">可用好客权益</p>
|
||||
<div className="shop-redeem-amount">
|
||||
<span className="shop-redeem-amount-symbol">¥</span>
|
||||
<span className="shop-redeem-amount-value">{formatAmount(balance.totalBalance)}</span>
|
||||
</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-redeem-details">
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>用户</span>
|
||||
<span>{userLabel}</span>
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>核销金额</span>
|
||||
<span>¥{formatAmount(Number(amount))}</span>
|
||||
</div>
|
||||
</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
|
||||
type="button"
|
||||
className="shop-redeem-confirm-btn"
|
||||
disabled={loading || storeClosed}
|
||||
onClick={() => void confirmRedeem()}
|
||||
>
|
||||
{loading ? '正在核销…' : `确认核销 ¥${formatAmount(Number(amount))}`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-phone-link-btn"
|
||||
onClick={() => {
|
||||
setStep('amount');
|
||||
setConfirmCode('');
|
||||
}}
|
||||
>
|
||||
返回修改金额
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -639,6 +639,82 @@
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.shop-home-phone-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 16px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(139, 26, 26, 0.2);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: var(--color-heritage-red);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.shop-home-phone-link .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.shop-phone-field {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.shop-phone-label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--color-on-surface-variant);
|
||||
}
|
||||
|
||||
.shop-phone-input {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
background: #fff;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.shop-phone-code-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.shop-phone-code-row .shop-phone-input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.shop-phone-code-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--color-heritage-red);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shop-phone-code-btn:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.shop-phone-link-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
padding: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-on-surface-variant);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.shop-scan-auth-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
Reference in New Issue
Block a user