Dev #9
@@ -47,8 +47,10 @@ pnpm dev:partner # http://localhost:5175
|
|||||||
| 端 | 手机号 |
|
| 端 | 手机号 |
|
||||||
|----|--------|
|
|----|--------|
|
||||||
| C端用户 | 13800000001 |
|
| C端用户 | 13800000001 |
|
||||||
| 门店 | 13900000001 |
|
|
||||||
| 合伙人 | 13700000001 |
|
| 合伙人 | 13700000001 |
|
||||||
|
| HQ | 13600000001 |
|
||||||
|
|
||||||
|
门店登录使用录入门店时绑定的手机号;seed 仅预置「郑州老城店」联调账号 `13910000001`。
|
||||||
|
|
||||||
## 主链路冒烟
|
## 主链路冒烟
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ import StoreMediaPage from './pages/StoreMediaPage';
|
|||||||
import ProductsPage from './pages/ProductsPage';
|
import ProductsPage from './pages/ProductsPage';
|
||||||
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||||
import ResourcesPage from './pages/ResourcesPage';
|
import ResourcesPage from './pages/ResourcesPage';
|
||||||
import StorePayoutsPage from './pages/StorePayoutsPage';
|
import StoreBillsPage from './pages/StoreBillsPage';
|
||||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||||
import TicketsPage from './pages/TicketsPage';
|
import TicketsPage from './pages/TicketsPage';
|
||||||
import UserLogsPage from './pages/UserLogsPage';
|
import UserLogsPage from './pages/UserLogsPage';
|
||||||
@@ -60,7 +60,8 @@ export default function App() {
|
|||||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||||
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
<Route path="/store-bills" element={<StoreBillsPage />} />
|
||||||
|
<Route path="/store-payouts" element={<Navigate to="/store-bills" replace />} />
|
||||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||||
<Route path="/tickets" element={<TicketsPage />} />
|
<Route path="/tickets" element={<TicketsPage />} />
|
||||||
<Route path="/logs/users" element={<UserLogsPage />} />
|
<Route path="/logs/users" element={<UserLogsPage />} />
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
children: [
|
children: [
|
||||||
{ key: '/stores', label: '门店列表' },
|
{ key: '/stores', label: '门店列表' },
|
||||||
{ key: '/store-accounts', label: '门店账户' },
|
{ key: '/store-accounts', label: '门店账户' },
|
||||||
|
{ key: '/store-bills', label: '门店账单' },
|
||||||
{ key: '/store-media', label: '门店资源' },
|
{ key: '/store-media', label: '门店资源' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -62,7 +63,6 @@ const MENU_ITEMS: MenuProps['items'] = [
|
|||||||
{ key: '/benefit/ledgers', label: '流水' },
|
{ key: '/benefit/ledgers', label: '流水' },
|
||||||
{ key: '/redeem-records', label: '核销记录' },
|
{ key: '/redeem-records', label: '核销记录' },
|
||||||
{ key: '/redeem/debug', label: '核销调试' },
|
{ key: '/redeem/debug', label: '核销调试' },
|
||||||
{ key: '/store-payouts', label: '门店打款' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type StoreCreateForm = {
|
|||||||
districtCode?: string;
|
districtCode?: string;
|
||||||
name: string;
|
name: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
|
smsCode: string;
|
||||||
address: string;
|
address: string;
|
||||||
intro?: string;
|
intro?: string;
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
@@ -16,22 +17,21 @@ export type StoreCreateForm = {
|
|||||||
bankAccountName: string;
|
bankAccountName: string;
|
||||||
bankAccountNo: string;
|
bankAccountNo: string;
|
||||||
bankBranch: string;
|
bankBranch: string;
|
||||||
accountPhone?: string;
|
|
||||||
accountName?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const PHONE_RE = /^1\d{10}$/;
|
const PHONE_RE = /^1\d{10}$/;
|
||||||
const BANK_RE = /^\d{16,19}$/;
|
const BANK_RE = /^\d{16,19}$/;
|
||||||
|
|
||||||
export function validateStoreCreateStep1(
|
export function validateStoreCreateStep1(
|
||||||
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'address' | 'intro'>,
|
form: Pick<StoreCreateForm, 'partnerId' | 'cityId' | 'regionCodes' | 'name' | 'phone' | 'smsCode' | 'address' | 'intro'>,
|
||||||
): string | null {
|
): string | null {
|
||||||
if (!form.partnerId) return '请选择开城合伙人';
|
if (!form.partnerId) return '请选择开城合伙人';
|
||||||
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
if (!form.regionCodes || form.regionCodes.length < 3) return '请选择省 / 市 / 区县';
|
||||||
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划';
|
if (!form.cityId) return '所选地区未匹配到开城城市,请先在「开城 → 开城城市」配置对应区划';
|
||||||
if (!form.name?.trim()) return '请填写门店名称';
|
if (!form.name?.trim()) return '请填写门店名称';
|
||||||
if (!form.phone?.trim()) return '请填写联系电话';
|
if (!form.phone?.trim()) return '请填写门店手机号';
|
||||||
if (!PHONE_RE.test(form.phone.trim())) return '联系电话须为11位手机号';
|
if (!PHONE_RE.test(form.phone.trim())) return '门店手机号须为11位手机号';
|
||||||
|
if (!form.smsCode?.trim()) return '请填写短信验证码';
|
||||||
if (!form.address?.trim()) return '请填写详细地址';
|
if (!form.address?.trim()) return '请填写详细地址';
|
||||||
if (form.intro?.trim()) {
|
if (form.intro?.trim()) {
|
||||||
const len = form.intro.trim().length;
|
const len = form.intro.trim().length;
|
||||||
|
|||||||
@@ -59,7 +59,10 @@ export default function StoreAccountsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
<Space style={{ marginBottom: 16, width: '100%', justifyContent: 'space-between' }}>
|
||||||
|
<Space direction="vertical" size={0}>
|
||||||
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
<Typography.Title level={4} style={{ margin: 0 }}>门店账户</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">新建门店时自动开通主账号;「新建账户」仅用于补录历史无账号门店</Typography.Text>
|
||||||
|
</Space>
|
||||||
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新建账户</Button>
|
<Button type="primary" onClick={() => { void loadStores(); setCreateOpen(true); }}>新建账户</Button>
|
||||||
</Space>
|
</Space>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Descriptions, Drawer, Form, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { request, type Paginated } from '../lib/api';
|
||||||
|
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
|
||||||
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
|
type Row = {
|
||||||
|
id: string;
|
||||||
|
redeemAmount: number;
|
||||||
|
payoutAmount: number;
|
||||||
|
settlementRate: number;
|
||||||
|
status: string;
|
||||||
|
expectedPayAt: string;
|
||||||
|
paidAt?: string;
|
||||||
|
createdAt: string;
|
||||||
|
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||||
|
redeemRecord?: { redeemNo: string; amount?: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoreOption = { id: string; name: string; phone: string };
|
||||||
|
|
||||||
|
const PAYOUT_STATUS_LABELS: Record<string, string> = {
|
||||||
|
PENDING: '待打款',
|
||||||
|
PAID: '已打款',
|
||||||
|
};
|
||||||
|
|
||||||
|
const PAYOUT_STATUS_COLORS: Record<string, string> = {
|
||||||
|
PENDING: 'orange',
|
||||||
|
PAID: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StoreBillsPage() {
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||||
|
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||||
|
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||||
|
'/admin/store-payouts',
|
||||||
|
() => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (filters.status) qs.set('status', filters.status);
|
||||||
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
|
return qs;
|
||||||
|
},
|
||||||
|
[filters],
|
||||||
|
);
|
||||||
|
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||||
|
.then((res) => setStores(res.items))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function confirmPayout(id: string) {
|
||||||
|
await request(`/admin/store-payouts/${id}/confirm`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ remark: '财务确认打款' }),
|
||||||
|
});
|
||||||
|
message.success('已确认打款');
|
||||||
|
reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<Row> = [
|
||||||
|
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
|
||||||
|
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
|
||||||
|
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
|
||||||
|
{
|
||||||
|
title: '核销单号',
|
||||||
|
dataIndex: ['redeemRecord', 'redeemNo'],
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (v) => v || '—',
|
||||||
|
},
|
||||||
|
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
|
||||||
|
{
|
||||||
|
title: '到账金额',
|
||||||
|
dataIndex: 'payoutAmount',
|
||||||
|
width: 100,
|
||||||
|
render: (v, row) => `¥${v}${row.settlementRate ? ` (${Math.round(row.settlementRate * 100)}%)` : ''}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '打款状态',
|
||||||
|
dataIndex: 'status',
|
||||||
|
width: 100,
|
||||||
|
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
|
||||||
|
},
|
||||||
|
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
||||||
|
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
width: 140,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
|
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
||||||
|
setDrawerOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
|
{row.status === 'PENDING' && (
|
||||||
|
<Button type="link" size="small" onClick={() => void confirmPayout(row.id)}>
|
||||||
|
确认打款
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
|
||||||
|
<Typography.Title level={4} style={{ margin: 0 }}>门店核销账单</Typography.Title>
|
||||||
|
<Typography.Text type="secondary">
|
||||||
|
每笔核销对应一条 T+1 打款账单,可按门店筛选查看
|
||||||
|
</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="storeId" label="门店">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
placeholder="全部门店"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
optionFilterProp="label"
|
||||||
|
options={stores.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: `${s.name}(${s.phone})`,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="status" label="打款状态">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 120 }}
|
||||||
|
placeholder="全部"
|
||||||
|
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">查询</Button>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}>重置</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
<Table
|
||||||
|
rowKey="id"
|
||||||
|
className="admin-table-nowrap"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1200 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
|
{detail && (
|
||||||
|
<Descriptions column={1} bordered size="small">
|
||||||
|
<Descriptions.Item label="门店">{String((detail.store as { name?: string })?.name ?? '—')}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销单号">
|
||||||
|
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="到账金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="结算比例">
|
||||||
|
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="状态">
|
||||||
|
{PAYOUT_STATUS_LABELS[String(detail.status)] || String(detail.status)}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="实际打款">
|
||||||
|
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
)}
|
||||||
|
</Drawer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Typography, message } from 'antd';
|
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
|
||||||
import { request } from '../lib/api';
|
|
||||||
import { fmtTime } from '../lib/constants';
|
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
|
||||||
|
|
||||||
type Row = {
|
|
||||||
id: string;
|
|
||||||
redeemAmount: number;
|
|
||||||
payoutAmount: number;
|
|
||||||
status: string;
|
|
||||||
expectedPayAt: string;
|
|
||||||
paidAt?: string;
|
|
||||||
store?: { name: string; cityName: string };
|
|
||||||
redeemRecord?: { redeemNo: string };
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function StorePayoutsPage() {
|
|
||||||
const [form] = Form.useForm();
|
|
||||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
|
||||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
|
||||||
'/admin/store-payouts',
|
|
||||||
() => {
|
|
||||||
const qs = new URLSearchParams();
|
|
||||||
if (filters.status) qs.set('status', filters.status);
|
|
||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
|
||||||
return qs;
|
|
||||||
},
|
|
||||||
[filters],
|
|
||||||
);
|
|
||||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
||||||
|
|
||||||
async function confirmPayout(id: string) {
|
|
||||||
await request(`/admin/store-payouts/${id}/confirm`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: JSON.stringify({ remark: '财务确认打款' }),
|
|
||||||
});
|
|
||||||
message.success('已确认打款');
|
|
||||||
reload();
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
|
||||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
|
||||||
{ title: '城市', dataIndex: ['store', 'cityName'], width: 100 },
|
|
||||||
{ title: '核销额', dataIndex: 'redeemAmount', width: 90, render: (v) => `¥${v}` },
|
|
||||||
{ title: '打款额', dataIndex: 'payoutAmount', width: 90, render: (v) => `¥${v}` },
|
|
||||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
|
||||||
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
|
|
||||||
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
|
|
||||||
{
|
|
||||||
title: '操作', width: 140,
|
|
||||||
render: (_, row) => (
|
|
||||||
<>
|
|
||||||
<Button type="link" size="small" onClick={async () => {
|
|
||||||
setDetail(await request(`/admin/store-payouts/${row.id}`));
|
|
||||||
setDrawerOpen(true);
|
|
||||||
}}>详情</Button>
|
|
||||||
{row.status === 'PENDING' && (
|
|
||||||
<Button type="link" size="small" onClick={() => confirmPayout(row.id)}>确认打款</Button>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Typography.Title level={4}>门店打款(T+1)</Typography.Title>
|
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
|
||||||
<Form.Item name="status" label="状态">
|
|
||||||
<Select allowClear style={{ width: 120 }} options={[
|
|
||||||
{ value: 'PENDING', label: '待打款' },
|
|
||||||
{ value: 'PAID', label: '已打款' },
|
|
||||||
]} />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
|
||||||
<Button type="primary" htmlType="submit">筛选</Button>
|
|
||||||
</Form>
|
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
|
||||||
<Drawer title="打款详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
|
||||||
{detail && (
|
|
||||||
<Descriptions column={1} bordered size="small">
|
|
||||||
<Descriptions.Item label="核销额">¥{String(detail.redeemAmount)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="打款额">¥{String(detail.payoutAmount)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="预计打款">{fmtTime(String(detail.expectedPayAt))}</Descriptions.Item>
|
|
||||||
</Descriptions>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -81,6 +81,7 @@ export default function StoresPage() {
|
|||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [createStep, setCreateStep] = useState(0);
|
const [createStep, setCreateStep] = useState(0);
|
||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
|
const [smsCooldown, setSmsCooldown] = useState(0);
|
||||||
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
const [partners, setPartners] = useState<PartnerOption[]>([]);
|
||||||
const [cities, setCities] = useState<CityOption[]>([]);
|
const [cities, setCities] = useState<CityOption[]>([]);
|
||||||
const [optionsLoading, setOptionsLoading] = useState(false);
|
const [optionsLoading, setOptionsLoading] = useState(false);
|
||||||
@@ -138,9 +139,38 @@ export default function StoresPage() {
|
|||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setCreateStep(0);
|
setCreateStep(0);
|
||||||
setCreateError('');
|
setCreateError('');
|
||||||
|
setSmsCooldown(0);
|
||||||
createForm.resetFields();
|
createForm.resetFields();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function sendCreateSms() {
|
||||||
|
const phone = String(createForm.getFieldValue('phone') ?? '').trim();
|
||||||
|
if (!/^1\d{10}$/.test(phone)) {
|
||||||
|
message.error('请先填写正确的11位门店手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (smsCooldown > 0) return;
|
||||||
|
try {
|
||||||
|
await request('/admin/stores/phone/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone }),
|
||||||
|
});
|
||||||
|
message.success('验证码已发送');
|
||||||
|
setSmsCooldown(60);
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
setSmsCooldown((s) => {
|
||||||
|
if (s <= 1) {
|
||||||
|
clearInterval(timer);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return s - 1;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
} catch (e) {
|
||||||
|
message.error(e instanceof Error ? e.message : '发送失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openCreateModal() {
|
function openCreateModal() {
|
||||||
void loadOptions();
|
void loadOptions();
|
||||||
createForm.setFieldsValue({
|
createForm.setFieldsValue({
|
||||||
@@ -160,7 +190,7 @@ export default function StoresPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'address']);
|
await createForm.validateFields(['partnerId', 'regionCodes', 'cityId', 'name', 'phone', 'smsCode', 'address']);
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -188,6 +218,7 @@ export default function StoresPage() {
|
|||||||
city: values.city,
|
city: values.city,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
phone: values.phone.trim(),
|
phone: values.phone.trim(),
|
||||||
|
smsCode: values.smsCode.trim(),
|
||||||
district: values.district.trim(),
|
district: values.district.trim(),
|
||||||
address: values.address.trim(),
|
address: values.address.trim(),
|
||||||
intro: values.intro?.trim() || undefined,
|
intro: values.intro?.trim() || undefined,
|
||||||
@@ -197,8 +228,6 @@ export default function StoresPage() {
|
|||||||
bankAccountName: values.bankAccountName.trim(),
|
bankAccountName: values.bankAccountName.trim(),
|
||||||
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
bankAccountNo: values.bankAccountNo.replace(/\s/g, ''),
|
||||||
bankBranch: values.bankBranch.trim(),
|
bankBranch: values.bankBranch.trim(),
|
||||||
accountPhone: values.accountPhone?.trim() || undefined,
|
|
||||||
accountName: values.accountName?.trim() || undefined,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
message.success('门店已创建');
|
message.success('门店已创建');
|
||||||
@@ -208,7 +237,7 @@ export default function StoresPage() {
|
|||||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||||
const fields = e as { errorFields?: Array<{ name: string[] }> };
|
const fields = e as { errorFields?: Array<{ name: string[] }> };
|
||||||
const first = fields.errorFields?.[0]?.name?.[0];
|
const first = fields.errorFields?.[0]?.name?.[0];
|
||||||
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone') {
|
if (first === 'partnerId' || first === 'cityId' || first === 'regionCodes' || first === 'name' || first === 'phone' || first === 'smsCode') {
|
||||||
setCreateStep(0);
|
setCreateStep(0);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -370,21 +399,25 @@ export default function StoresPage() {
|
|||||||
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
<Form.Item name="name" label="门店名称" rules={[{ required: true, message: '请填写门店名称' }]}>
|
||||||
<Input placeholder="请输入门店名称" />
|
<Input placeholder="请输入门店名称" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="phone" label="联系电话" rules={[{ required: true, message: '请填写联系电话' }]}>
|
<Form.Item name="phone" label="门店手机号(登录账号)" rules={[{ required: true, message: '请填写门店手机号' }]}>
|
||||||
<Input placeholder="11位手机号" />
|
<Input placeholder="11位手机号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item label="短信验证" required>
|
||||||
|
<Space.Compact style={{ width: '100%' }}>
|
||||||
|
<Form.Item name="smsCode" noStyle rules={[{ required: true, message: '请填写验证码' }]}>
|
||||||
|
<Input placeholder="验证码" maxLength={6} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button disabled={smsCooldown > 0} onClick={() => void sendCreateSms()}>
|
||||||
|
{smsCooldown > 0 ? `${smsCooldown}s` : '获取验证码'}
|
||||||
|
</Button>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
<Form.Item name="address" label="详细地址" rules={[{ required: true, message: '请填写详细地址' }]}>
|
||||||
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
<Input.TextArea rows={2} placeholder="请输入详细门牌号" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="intro" label="门店简介">
|
<Form.Item name="intro" label="门店简介">
|
||||||
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
<Input.TextArea rows={3} placeholder="选填,10~500字" showCount maxLength={500} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="accountPhone" label="店长手机">
|
|
||||||
<Input placeholder="默认同门店电话" />
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item name="accountName" label="店长姓名">
|
|
||||||
<Input placeholder="默认同门店名" />
|
|
||||||
</Form.Item>
|
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
|
<div style={{ display: createStep === 1 ? 'block' : 'none' }}>
|
||||||
<Typography.Paragraph type="secondary">
|
<Typography.Paragraph type="secondary">
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ export type StoreDraftForm = {
|
|||||||
phone: string;
|
phone: string;
|
||||||
address: string;
|
address: string;
|
||||||
intro: string;
|
intro: string;
|
||||||
accountPhone: string;
|
|
||||||
accountName: string;
|
|
||||||
coverUrl: string;
|
coverUrl: string;
|
||||||
envPhotoUrls: string[];
|
envPhotoUrls: string[];
|
||||||
contractUrl: string;
|
contractUrl: string;
|
||||||
@@ -35,8 +33,6 @@ export const defaultStoreForm = (): StoreDraftForm => ({
|
|||||||
phone: '',
|
phone: '',
|
||||||
address: '',
|
address: '',
|
||||||
intro: '',
|
intro: '',
|
||||||
accountPhone: '',
|
|
||||||
accountName: '',
|
|
||||||
coverUrl: '',
|
coverUrl: '',
|
||||||
envPhotoUrls: ['', '', ''],
|
envPhotoUrls: ['', '', ''],
|
||||||
contractUrl: '',
|
contractUrl: '',
|
||||||
@@ -67,8 +63,6 @@ function normalizeForm(raw: Record<string, unknown>): StoreDraftForm {
|
|||||||
phone: String(raw.phone ?? base.phone),
|
phone: String(raw.phone ?? base.phone),
|
||||||
address: String(raw.address ?? base.address),
|
address: String(raw.address ?? base.address),
|
||||||
intro: String(raw.intro ?? base.intro),
|
intro: String(raw.intro ?? base.intro),
|
||||||
accountPhone: String(raw.accountPhone ?? base.accountPhone),
|
|
||||||
accountName: String(raw.accountName ?? base.accountName),
|
|
||||||
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
coverUrl: String(raw.coverUrl ?? base.coverUrl),
|
||||||
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
envPhotoUrls: normalizeStringArray(raw.envPhotoUrls, 3),
|
||||||
contractUrl: String(raw.contractUrl ?? base.contractUrl),
|
contractUrl: String(raw.contractUrl ?? base.contractUrl),
|
||||||
|
|||||||
@@ -148,8 +148,6 @@ export default function StoreCreatePage() {
|
|||||||
bankAccountName: form.bankAccountName.trim(),
|
bankAccountName: form.bankAccountName.trim(),
|
||||||
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
bankAccountNo: form.bankAccountNo.replace(/\s/g, ''),
|
||||||
bankBranch: form.bankBranch.trim(),
|
bankBranch: form.bankBranch.trim(),
|
||||||
accountPhone: form.accountPhone.trim() || undefined,
|
|
||||||
accountName: form.accountName.trim() || undefined,
|
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
clearStoreDraft();
|
clearStoreDraft();
|
||||||
@@ -216,10 +214,10 @@ export default function StoreCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
<label>联系电话 <span className="text-primary">*</span></label>
|
<label>联系电话(门店登录账号) <span className="text-primary">*</span></label>
|
||||||
<div className="partner-field-input">
|
<div className="partner-field-input">
|
||||||
<span className="material-symbols-outlined">call</span>
|
<span className="material-symbols-outlined">call</span>
|
||||||
<input type="tel" placeholder="请输入联系电话" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
|
<input type="tel" placeholder="请输入11位手机号" value={form.phone} onChange={(e) => patchForm({ phone: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-field">
|
<div className="partner-field">
|
||||||
@@ -233,20 +231,6 @@ export default function StoreCreatePage() {
|
|||||||
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
<span className="label-md text-muted">{form.intro.length} / 500</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="partner-field">
|
|
||||||
<label>店长手机</label>
|
|
||||||
<div className="partner-field-input">
|
|
||||||
<span className="material-symbols-outlined">smartphone</span>
|
|
||||||
<input type="tel" placeholder="默认同门店电话" value={form.accountPhone} onChange={(e) => patchForm({ accountPhone: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="partner-field">
|
|
||||||
<label>店长姓名</label>
|
|
||||||
<div className="partner-field-input">
|
|
||||||
<span className="material-symbols-outlined">person</span>
|
|
||||||
<input placeholder="默认同门店名" value={form.accountName} onChange={(e) => patchForm({ accountName: e.target.value })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
<div className="partner-info-banner">
|
<div className="partner-info-banner">
|
||||||
<div className="partner-bills-icon">
|
<div className="partner-bills-icon">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import AuthGate from './components/AuthGate';
|
||||||
import TabLayout from './layouts/TabLayout';
|
import TabLayout from './layouts/TabLayout';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import HomePage from './pages/HomePage';
|
import HomePage from './pages/HomePage';
|
||||||
@@ -10,6 +11,7 @@ import MinePage from './pages/MinePage';
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<AuthGate>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||||
@@ -22,5 +24,6 @@ export default function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</AuthGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = new Set(['/login']);
|
||||||
|
|
||||||
|
export default function AuthGate({ children }: { children: React.ReactNode }) {
|
||||||
|
const { ready, authenticated } = useStoreSession();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (!ready) {
|
||||||
|
return (
|
||||||
|
<div className="session-boot">
|
||||||
|
<p className="session-boot-text">加载中…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authenticated && location.pathname === '/login') {
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!authenticated && !PUBLIC_PATHS.has(location.pathname)) {
|
||||||
|
return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
type WechatScanAuthModalProps = {
|
||||||
|
open: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
error?: string;
|
||||||
|
onAuthorize: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function WechatScanAuthModal({
|
||||||
|
open,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
onAuthorize,
|
||||||
|
onCancel,
|
||||||
|
}: WechatScanAuthModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="shop-scan-auth-overlay" role="dialog" aria-modal="true" aria-labelledby="shop-scan-auth-title">
|
||||||
|
<div className="shop-scan-auth-card">
|
||||||
|
<div className="shop-scan-auth-icon">
|
||||||
|
<span className="material-symbols-outlined shop-fill-icon">qr_code_scanner</span>
|
||||||
|
</div>
|
||||||
|
<h2 id="shop-scan-auth-title" className="shop-scan-auth-title">微信授权</h2>
|
||||||
|
<p className="shop-scan-auth-desc">
|
||||||
|
扫码核销需使用微信相机扫描用户核销码。请先完成微信授权并允许使用摄像头。
|
||||||
|
</p>
|
||||||
|
{error && <p className="shop-scan-auth-error" role="alert">{error}</p>}
|
||||||
|
<div className="shop-scan-auth-actions">
|
||||||
|
<button type="button" className="shop-scan-auth-cancel" onClick={onCancel} disabled={loading}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button type="button" className="shop-scan-auth-confirm" onClick={onAuthorize} disabled={loading}>
|
||||||
|
{loading ? '跳转授权中…' : '微信授权'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react';
|
||||||
|
import {
|
||||||
|
clearAuth,
|
||||||
|
ensureSession,
|
||||||
|
saveAuth,
|
||||||
|
type ShopSessionPayload,
|
||||||
|
type StoreSessionStore,
|
||||||
|
} from '../lib/api';
|
||||||
|
|
||||||
|
type StoreSessionContextValue = {
|
||||||
|
ready: boolean;
|
||||||
|
authenticated: boolean;
|
||||||
|
store: StoreSessionStore | null;
|
||||||
|
applySession: (session: ShopSessionPayload) => void;
|
||||||
|
resetSession: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StoreSessionContext = createContext<StoreSessionContextValue | null>(null);
|
||||||
|
|
||||||
|
export function StoreSessionProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
const [store, setStore] = useState<StoreSessionStore | null>(null);
|
||||||
|
|
||||||
|
const applySession = useCallback((session: ShopSessionPayload) => {
|
||||||
|
saveAuth(session);
|
||||||
|
setAuthenticated(true);
|
||||||
|
if (session.store) setStore(session.store);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetSession = useCallback(() => {
|
||||||
|
clearAuth();
|
||||||
|
setAuthenticated(false);
|
||||||
|
setStore(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const result = await ensureSession();
|
||||||
|
if (cancelled) return;
|
||||||
|
setAuthenticated(result.authenticated);
|
||||||
|
setStore(result.store);
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) resetSession();
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setReady(true);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [resetSession]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ ready, authenticated, store, applySession, resetSession }),
|
||||||
|
[ready, authenticated, store, applySession, resetSession],
|
||||||
|
);
|
||||||
|
|
||||||
|
return <StoreSessionContext.Provider value={value}>{children}</StoreSessionContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useStoreSession() {
|
||||||
|
const ctx = useContext(StoreSessionContext);
|
||||||
|
if (!ctx) throw new Error('useStoreSession must be used within StoreSessionProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet } from 'react-router-dom';
|
||||||
import { isLoggedIn } from '../lib/api';
|
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
{ to: '/', end: true, icon: 'home', label: '首页' },
|
{ to: '/', end: true, icon: 'home', label: '首页' },
|
||||||
@@ -8,11 +7,6 @@ const TABS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export default function TabLayout() {
|
export default function TabLayout() {
|
||||||
const navigate = useNavigate();
|
|
||||||
if (!isLoggedIn()) {
|
|
||||||
navigate('/login');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
+162
-16
@@ -1,27 +1,173 @@
|
|||||||
export const apiBase = '/api/v1';
|
export const apiBase = '/api/v1';
|
||||||
|
const CLIENT_APP = 'SHOP_H5';
|
||||||
|
|
||||||
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
export type StoreSessionStore = {
|
||||||
const token = localStorage.getItem('accessToken');
|
id: string;
|
||||||
const headers: Record<string, string> = {
|
storeId: string;
|
||||||
'Content-Type': 'application/json',
|
name: string;
|
||||||
'X-Client-App': clientApp,
|
phone: string;
|
||||||
...(options.headers as Record<string, string>),
|
storeName: string;
|
||||||
};
|
};
|
||||||
if (token) headers.Authorization = `Bearer ${token}`;
|
|
||||||
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
export type StoreProfile = {
|
||||||
const json = await res.json();
|
id: string;
|
||||||
if (json.code !== 0) throw new Error(json.message || '请求失败');
|
storeId: string;
|
||||||
return json.data as T;
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
status?: string;
|
||||||
|
store?: { id: string; name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShopSessionPayload = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
store?: StoreSessionStore;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCESS_TOKEN = 'accessToken';
|
||||||
|
const REFRESH_TOKEN = 'refreshToken';
|
||||||
|
const LAST_PHONE = 'shopLastPhone';
|
||||||
|
const STORE_PROFILE = 'shopStoreProfile';
|
||||||
|
|
||||||
|
const AUTH_RECOVERY_EXEMPT_PATHS = ['/shop/auth/token/refresh', '/shop/auth/sms/send', '/shop/auth/login/sms'];
|
||||||
|
|
||||||
|
export function getLastPhone() {
|
||||||
|
return localStorage.getItem(LAST_PHONE) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function saveAuth(data: { accessToken: string }) {
|
export function getStoreProfile(): StoreSessionStore | null {
|
||||||
localStorage.setItem('accessToken', data.accessToken);
|
try {
|
||||||
|
const raw = localStorage.getItem(STORE_PROFILE);
|
||||||
|
return raw ? (JSON.parse(raw) as StoreSessionStore) : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveAuth(data: ShopSessionPayload) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN, data.accessToken);
|
||||||
|
if (data.refreshToken) localStorage.setItem(REFRESH_TOKEN, data.refreshToken);
|
||||||
|
if (data.store) {
|
||||||
|
localStorage.setItem(STORE_PROFILE, JSON.stringify(data.store));
|
||||||
|
localStorage.setItem(LAST_PHONE, data.store.phone);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearAuth() {
|
export function clearAuth() {
|
||||||
localStorage.removeItem('accessToken');
|
localStorage.removeItem(ACCESS_TOKEN);
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN);
|
||||||
|
localStorage.removeItem(STORE_PROFILE);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isLoggedIn() {
|
export function isLoggedIn() {
|
||||||
return !!localStorage.getItem('accessToken');
|
return !!localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileFromMe(me: StoreProfile): StoreSessionStore {
|
||||||
|
return {
|
||||||
|
id: me.id,
|
||||||
|
storeId: me.storeId,
|
||||||
|
name: me.name,
|
||||||
|
phone: me.phone,
|
||||||
|
storeName: me.store?.name ?? me.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rawRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
token?: string | null,
|
||||||
|
): Promise<T> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': CLIENT_APP,
|
||||||
|
...(options.headers as Record<string, string>),
|
||||||
|
};
|
||||||
|
const authToken = token ?? localStorage.getItem(ACCESS_TOKEN);
|
||||||
|
if (authToken) headers.Authorization = `Bearer ${authToken}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${apiBase}${path}`, { ...options, headers });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) {
|
||||||
|
const err = new Error(json.message || '请求失败') as Error & { status?: number };
|
||||||
|
err.status = json.code;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return json.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSession(): Promise<ShopSessionPayload | null> {
|
||||||
|
const refreshToken = localStorage.getItem(REFRESH_TOKEN);
|
||||||
|
if (!refreshToken) return null;
|
||||||
|
try {
|
||||||
|
const data = await rawRequest<ShopSessionPayload>(
|
||||||
|
'/shop/auth/token/refresh',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken }),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
saveAuth(data);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestWithAuthRetry<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit = {},
|
||||||
|
retried = false,
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await rawRequest<T>(path, options);
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { status?: number };
|
||||||
|
const canRecover =
|
||||||
|
err.status === 401 &&
|
||||||
|
!retried &&
|
||||||
|
!AUTH_RECOVERY_EXEMPT_PATHS.some((p) => path.startsWith(p));
|
||||||
|
if (!canRecover) throw e;
|
||||||
|
const refreshed = await refreshSession();
|
||||||
|
if (!refreshed) {
|
||||||
|
clearAuth();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
return requestWithAuthRetry<T>(path, options, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function request<T>(clientApp: string, path: string, options: RequestInit = {}): Promise<T> {
|
||||||
|
void clientApp;
|
||||||
|
return requestWithAuthRetry<T>(path, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ensureSession(): Promise<{ authenticated: boolean; store: StoreSessionStore | null }> {
|
||||||
|
if (!isLoggedIn()) {
|
||||||
|
return { authenticated: false, store: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const me = await rawRequest<StoreProfile>('/shop/auth/me');
|
||||||
|
const store = profileFromMe(me);
|
||||||
|
saveAuth({
|
||||||
|
accessToken: localStorage.getItem(ACCESS_TOKEN) ?? '',
|
||||||
|
refreshToken: localStorage.getItem(REFRESH_TOKEN) ?? '',
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
return { authenticated: true, store };
|
||||||
|
} catch (e) {
|
||||||
|
const err = e as Error & { status?: number };
|
||||||
|
if (err.status === 401) {
|
||||||
|
const refreshed = await refreshSession();
|
||||||
|
if (refreshed?.store) {
|
||||||
|
return { authenticated: true, store: refreshed.store };
|
||||||
|
}
|
||||||
|
clearAuth();
|
||||||
|
return { authenticated: false, store: null };
|
||||||
|
}
|
||||||
|
const cached = getStoreProfile();
|
||||||
|
if (cached) return { authenticated: true, store: cached };
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** 从微信扫码结果解析核销 token(32 位 hex 或带 token 参数的 URL) */
|
||||||
|
export function parseRedeemTokenFromScan(raw: string): string | null {
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
if (/^[a-f0-9]{32}$/i.test(trimmed)) {
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = trimmed.startsWith('http') ? new URL(trimmed) : new URL(trimmed, 'https://local.invalid');
|
||||||
|
const fromQuery = url.searchParams.get('token');
|
||||||
|
if (fromQuery && /^[a-f0-9]{32}$/i.test(fromQuery)) {
|
||||||
|
return fromQuery.toLowerCase();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* not a URL */
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexMatch = trimmed.match(/[a-f0-9]{32}/i);
|
||||||
|
return hexMatch ? hexMatch[0].toLowerCase() : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { WechatLoginResult } from '@dukang/shared-types';
|
||||||
|
import { isWechatEnv, weixinSdk } from './weixin';
|
||||||
|
import { request, saveAuth, type ShopSessionPayload } from './api';
|
||||||
|
|
||||||
|
export type ShopAccountProfile = {
|
||||||
|
id: string;
|
||||||
|
storeId: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
wxOpenId?: string | null;
|
||||||
|
store?: { name: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchShopAccount(): Promise<ShopAccountProfile> {
|
||||||
|
return request<ShopAccountProfile>('SHOP_H5', '/shop/auth/me');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsWechatAuth(profile: ShopAccountProfile | null): boolean {
|
||||||
|
return isWechatEnv() && !!profile && !profile.wxOpenId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionFromWechatLogin(result: WechatLoginResult): ShopSessionPayload | null {
|
||||||
|
if (!result.accessToken || !result.refreshToken) return null;
|
||||||
|
const store = result.store;
|
||||||
|
return {
|
||||||
|
accessToken: result.accessToken,
|
||||||
|
refreshToken: result.refreshToken,
|
||||||
|
store: store
|
||||||
|
? {
|
||||||
|
id: String(store.id ?? ''),
|
||||||
|
storeId: String(store.storeId ?? ''),
|
||||||
|
name: String(store.name ?? ''),
|
||||||
|
phone: String(store.phone ?? ''),
|
||||||
|
storeName: String(store.storeName ?? store.name ?? ''),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveShopWechatAuth(result: WechatLoginResult): boolean {
|
||||||
|
const session = sessionFromWechatLogin(result);
|
||||||
|
if (!session) return false;
|
||||||
|
saveAuth(session);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authorizeShopWechat(): Promise<WechatLoginResult | void> {
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
throw new Error('请在微信内打开以完成授权');
|
||||||
|
}
|
||||||
|
return weixinSdk.login();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleShopWechatCallback(): Promise<WechatLoginResult | null> {
|
||||||
|
if (!isWechatEnv()) return null;
|
||||||
|
return weixinSdk.handleOAuthCallback();
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ export const weixinSdk = createWeixinSdk({
|
|||||||
apiBase: '/api/v1',
|
apiBase: '/api/v1',
|
||||||
clientApp: 'SHOP_H5',
|
clientApp: 'SHOP_H5',
|
||||||
getAccessToken: () => localStorage.getItem('accessToken'),
|
getAccessToken: () => localStorage.getItem('accessToken'),
|
||||||
|
wechatLoginPath: '/shop/auth/login/wechat',
|
||||||
});
|
});
|
||||||
|
|
||||||
export { isWechatEnv };
|
export { isWechatEnv };
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
import { BrowserRouter } from 'react-router-dom';
|
import { BrowserRouter } from 'react-router-dom';
|
||||||
|
import { StoreSessionProvider } from './contexts/StoreSessionContext';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
import './styles.css';
|
import './styles.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode><BrowserRouter><App /></BrowserRouter></React.StrictMode>,
|
<React.StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<StoreSessionProvider>
|
||||||
|
<App />
|
||||||
|
</StoreSessionProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useNavigate } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import { isLoggedIn, request } from '../lib/api';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
import { parseRedeemTokenFromScan } from '../lib/redeem-scan';
|
||||||
|
import {
|
||||||
|
authorizeShopWechat,
|
||||||
|
fetchShopAccount,
|
||||||
|
handleShopWechatCallback,
|
||||||
|
needsWechatAuth,
|
||||||
|
saveShopWechatAuth,
|
||||||
|
sessionFromWechatLogin,
|
||||||
|
} from '../lib/wechat-auth';
|
||||||
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
import { isWechatEnv, weixinSdk } from '../lib/weixin';
|
||||||
|
import WechatScanAuthModal from '../components/WechatScanAuthModal';
|
||||||
|
|
||||||
|
const PENDING_SCAN_KEY = 'shop_pending_scan';
|
||||||
|
|
||||||
function formatMoney(n: number) {
|
function formatMoney(n: number) {
|
||||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
return n.toLocaleString('zh-CN', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
|
||||||
@@ -9,41 +22,107 @@ function formatMoney(n: number) {
|
|||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { applySession } = useStoreSession();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
const [dash, setDash] = useState<Record<string, unknown> | null>(null);
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
|
const [scanMsg, setScanMsg] = useState('');
|
||||||
|
const [scanning, setScanning] = useState(false);
|
||||||
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
||||||
|
const [authLoading, setAuthLoading] = useState(false);
|
||||||
|
const [authError, setAuthError] = useState('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) {
|
request<Record<string, unknown>>('SHOP_H5', '/shop/dashboard').then((d) => {
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
request('SHOP_H5', '/shop/dashboard').then((d) => {
|
|
||||||
setDash(d);
|
setDash(d);
|
||||||
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
|
setOpen(String((d.store as Record<string, unknown>)?.status) === 'OPEN');
|
||||||
});
|
});
|
||||||
}, [navigate]);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isWechatEnv() || !searchParams.get('code')) return;
|
||||||
|
void handleShopWechatCallback()
|
||||||
|
.then((result) => {
|
||||||
|
if (!result) return;
|
||||||
|
const session = sessionFromWechatLogin(result);
|
||||||
|
if (session) {
|
||||||
|
saveShopWechatAuth(result);
|
||||||
|
applySession(session);
|
||||||
|
}
|
||||||
|
setAuthModalOpen(false);
|
||||||
|
setAuthError('');
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
const shouldScan = sessionStorage.getItem(PENDING_SCAN_KEY) === '1';
|
||||||
|
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||||
|
if (shouldScan) {
|
||||||
|
void runScan();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||||
|
});
|
||||||
|
}, [searchParams, applySession, setSearchParams]);
|
||||||
|
|
||||||
|
async function runScan() {
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setScanning(true);
|
||||||
|
setScanMsg('');
|
||||||
|
try {
|
||||||
|
await weixinSdk.init();
|
||||||
|
const raw = await weixinSdk.scanQrCode();
|
||||||
|
if (!raw) return;
|
||||||
|
const token = parseRedeemTokenFromScan(raw);
|
||||||
|
if (!token) {
|
||||||
|
setScanMsg('无法识别核销码,请扫描用户出示的核销二维码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
||||||
|
} catch (e) {
|
||||||
|
setScanMsg(e instanceof Error ? e.message : '扫码失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setScanning(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleScan() {
|
||||||
|
setScanMsg('');
|
||||||
|
if (!isWechatEnv()) {
|
||||||
|
setScanMsg('请在微信内打开门店端进行扫码核销');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const profile = await fetchShopAccount();
|
||||||
|
if (needsWechatAuth(profile)) {
|
||||||
|
setAuthModalOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await runScan();
|
||||||
|
} catch (e) {
|
||||||
|
setScanMsg(e instanceof Error ? e.message : '无法发起扫码');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWechatAuth() {
|
||||||
|
setAuthLoading(true);
|
||||||
|
setAuthError('');
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(PENDING_SCAN_KEY, '1');
|
||||||
|
await authorizeShopWechat();
|
||||||
|
} catch (e) {
|
||||||
|
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||||
|
setAuthError(e instanceof Error ? e.message : '微信授权失败');
|
||||||
|
setAuthLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const store = dash?.store as Record<string, unknown> | undefined;
|
const store = dash?.store as Record<string, unknown> | undefined;
|
||||||
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
const recent = (dash?.recentRecords as Array<Record<string, unknown>>) || [];
|
||||||
const openTime = String(store?.openTime || '10:00');
|
const openTime = String(store?.openTime || '10:00');
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
const closeTime = String(store?.closeTime || '22:00');
|
||||||
|
|
||||||
async function handleScan() {
|
|
||||||
if (isWechatEnv()) {
|
|
||||||
try {
|
|
||||||
await weixinSdk.init();
|
|
||||||
const token = await weixinSdk.scanQrCode();
|
|
||||||
if (token) {
|
|
||||||
navigate(`/redeem?token=${encodeURIComponent(token)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* fall through to manual redeem page */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
navigate('/redeem');
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-home-page">
|
<div className="shop-home-page">
|
||||||
<header className="shop-home-header">
|
<header className="shop-home-header">
|
||||||
@@ -72,10 +151,16 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-scan">
|
<section className="shop-home-scan">
|
||||||
<button type="button" className="shop-home-scan-btn" onClick={handleScan}>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="shop-home-scan-btn"
|
||||||
|
disabled={scanning}
|
||||||
|
onClick={() => void handleScan()}
|
||||||
|
>
|
||||||
<span className="material-symbols-outlined">qr_code_scanner</span>
|
<span className="material-symbols-outlined">qr_code_scanner</span>
|
||||||
</button>
|
</button>
|
||||||
<p className="shop-home-scan-label">扫码核销</p>
|
<p className="shop-home-scan-label">{scanning ? '正在打开相机…' : '扫码核销'}</p>
|
||||||
|
{scanMsg && <p className="shop-home-scan-msg" role="alert">{scanMsg}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="shop-home-status">
|
<section className="shop-home-status">
|
||||||
@@ -121,6 +206,18 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<WechatScanAuthModal
|
||||||
|
open={authModalOpen}
|
||||||
|
loading={authLoading}
|
||||||
|
error={authError}
|
||||||
|
onAuthorize={() => void startWechatAuth()}
|
||||||
|
onCancel={() => {
|
||||||
|
setAuthModalOpen(false);
|
||||||
|
setAuthError('');
|
||||||
|
sessionStorage.removeItem(PENDING_SCAN_KEY);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import AppImage from '@dukang/shared-ui/AppImage';
|
import AppImage from '@dukang/shared-ui/AppImage';
|
||||||
import { request, saveAuth } from '../lib/api';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
import { getLastPhone, getStoreProfile, request, type ShopSessionPayload } from '../lib/api';
|
||||||
|
|
||||||
function maskPhone(phone: string) {
|
function maskPhone(phone: string) {
|
||||||
if (phone.length < 7) return phone;
|
if (phone.length < 7) return phone;
|
||||||
@@ -10,15 +11,20 @@ function maskPhone(phone: string) {
|
|||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { applySession } = useStoreSession();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const quick = params.get('quick') === '1';
|
const quick = params.get('quick') === '1';
|
||||||
const [phone, setPhone] = useState('13900000001');
|
const savedProfile = getStoreProfile();
|
||||||
const [code, setCode] = useState('123456');
|
const [phone, setPhone] = useState(getLastPhone());
|
||||||
const [agreed, setAgreed] = useState(false);
|
const [code, setCode] = useState('');
|
||||||
|
const [agreed, setAgreed] = useState(true);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [msg, setMsg] = useState('');
|
const [msg, setMsg] = useState('');
|
||||||
const [codeCooldown, setCodeCooldown] = useState(0);
|
const [codeCooldown, setCodeCooldown] = useState(0);
|
||||||
|
|
||||||
|
const quickStoreName = savedProfile?.storeName ?? '门店管理中心';
|
||||||
|
const quickPhone = savedProfile?.phone || phone;
|
||||||
|
|
||||||
function ensureAgreed() {
|
function ensureAgreed() {
|
||||||
if (!agreed) {
|
if (!agreed) {
|
||||||
setMsg('请先阅读并同意用户协议');
|
setMsg('请先阅读并同意用户协议');
|
||||||
@@ -35,7 +41,7 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
||||||
});
|
});
|
||||||
setMsg('验证码已发送(Mock: 123456)');
|
setMsg('验证码已发送');
|
||||||
setCodeCooldown(60);
|
setCodeCooldown(60);
|
||||||
const timer = setInterval(() => {
|
const timer = setInterval(() => {
|
||||||
setCodeCooldown((c) => {
|
setCodeCooldown((c) => {
|
||||||
@@ -51,20 +57,22 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login(options?: { quick?: boolean }) {
|
||||||
if (!ensureAgreed()) return;
|
if (!options?.quick && !ensureAgreed()) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMsg('');
|
setMsg('');
|
||||||
try {
|
try {
|
||||||
|
if (options?.quick) {
|
||||||
await request('SHOP_H5', '/shop/auth/sms/send', {
|
await request('SHOP_H5', '/shop/auth/sms/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, scene: 'STORE_LOGIN' }),
|
body: JSON.stringify({ phone: quickPhone, scene: 'STORE_LOGIN' }),
|
||||||
});
|
});
|
||||||
const data = await request<{ accessToken: string }>('SHOP_H5', '/shop/auth/login/sms', {
|
}
|
||||||
|
const data = await request<ShopSessionPayload>('SHOP_H5', '/shop/auth/login/sms', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ phone, code }),
|
body: JSON.stringify({ phone: options?.quick ? quickPhone : phone, code }),
|
||||||
});
|
});
|
||||||
saveAuth(data);
|
applySession(data);
|
||||||
navigate('/');
|
navigate('/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '登录失败');
|
setMsg(e instanceof Error ? e.message : '登录失败');
|
||||||
@@ -93,8 +101,8 @@ export default function LoginPage() {
|
|||||||
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
<span className="material-symbols-outlined shop-fill-icon">storefront</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="shop-quick-store-name">门店管理中心</h2>
|
<h2 className="shop-quick-store-name">{quickStoreName}</h2>
|
||||||
<p className="shop-quick-store-phone">{maskPhone(phone)}</p>
|
<p className="shop-quick-store-phone">{maskPhone(quickPhone)}</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="shop-quick-verified">
|
<span className="shop-quick-verified">
|
||||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
|
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 14 }}>verified_user</span>
|
||||||
@@ -115,7 +123,7 @@ export default function LoginPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="shop-quick-login-btn"
|
className="shop-quick-login-btn"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={login}
|
onClick={() => void login({ quick: true })}
|
||||||
>
|
>
|
||||||
<span>{loading ? '登录中...' : '一键登录'}</span>
|
<span>{loading ? '登录中...' : '一键登录'}</span>
|
||||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||||
@@ -192,7 +200,7 @@ export default function LoginPage() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="shop-login-submit"
|
className="shop-login-submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={login}
|
onClick={() => void login()}
|
||||||
>
|
>
|
||||||
<span>{loading ? '登录中...' : '登录'}</span>
|
<span>{loading ? '登录中...' : '登录'}</span>
|
||||||
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
{!loading && <span className="material-symbols-outlined">arrow_forward</span>}
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { clearAuth, isLoggedIn, request } from '../lib/api';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
export default function MinePage() {
|
export default function MinePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { resetSession } = useStoreSession();
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoggedIn()) {
|
|
||||||
navigate('/login');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
request('SHOP_H5', '/shop/store').then(setStore);
|
request('SHOP_H5', '/shop/store').then(setStore);
|
||||||
}, [navigate]);
|
}, []);
|
||||||
|
|
||||||
const openTime = String(store?.openTime || '09:30');
|
const openTime = String(store?.openTime || '09:30');
|
||||||
const closeTime = String(store?.closeTime || '22:00');
|
const closeTime = String(store?.closeTime || '22:00');
|
||||||
@@ -65,7 +63,7 @@ export default function MinePage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-mine-logout"
|
className="shop-mine-logout"
|
||||||
onClick={() => { clearAuth(); navigate('/login'); }}
|
onClick={() => { resetSession(); navigate('/login'); }}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
||||||
退出登录
|
退出登录
|
||||||
|
|||||||
@@ -34,9 +34,13 @@ export default function RedeemConfirmPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const scanned = searchParams.get('token');
|
const scanned = searchParams.get('token')?.trim();
|
||||||
if (scanned) setToken(scanned);
|
if (!scanned) {
|
||||||
}, [searchParams]);
|
navigate('/', { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setToken(scanned);
|
||||||
|
}, [searchParams, navigate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
@@ -60,7 +64,7 @@ export default function RedeemConfirmPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!token.trim()) {
|
if (!token.trim()) {
|
||||||
setMsg('请在开发者选项中输入核销码');
|
setMsg('请先扫码获取核销码');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -71,7 +75,7 @@ export default function RedeemConfirmPage() {
|
|||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify({ token }),
|
||||||
});
|
});
|
||||||
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
sessionStorage.setItem('lastRedeemResult', JSON.stringify(result));
|
||||||
navigate('/redeem/success', { state: { result, storeName } });
|
navigate('/redeem/success', { state: { result, storeName, user: preview?.user } });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg(e instanceof Error ? e.message : '核销失败');
|
setMsg(e instanceof Error ? e.message : '核销失败');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -80,7 +84,7 @@ export default function RedeemConfirmPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const previewAmount = preview?.amount ?? 0;
|
const previewAmount = preview?.amount ?? 0;
|
||||||
const userLabel = preview?.user?.nickname || preview?.user?.phone || preview?.user?.userNo || '待扫码确认';
|
const userLabel = preview?.user?.nickname || preview?.user?.phone || preview?.user?.userNo || '—';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="shop-redeem-page">
|
<div className="shop-redeem-page">
|
||||||
@@ -161,21 +165,10 @@ export default function RedeemConfirmPage() {
|
|||||||
<span className="material-symbols-outlined shop-fill-icon">
|
<span className="material-symbols-outlined shop-fill-icon">
|
||||||
{loading ? 'sync' : 'check_circle'}
|
{loading ? 'sync' : 'check_circle'}
|
||||||
</span>
|
</span>
|
||||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '等待扫码'}</span>
|
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '加载中…'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||||
|
|
||||||
<details className="shop-redeem-dev">
|
|
||||||
<summary>开发者选项 · 手动输入核销码</summary>
|
|
||||||
<div className="shop-redeem-dev-body">
|
|
||||||
<input
|
|
||||||
value={token}
|
|
||||||
onChange={(e) => setToken(e.target.value)}
|
|
||||||
placeholder="粘贴用户核销码"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ export default function RedeemSuccessPage() {
|
|||||||
}, [location.state]);
|
}, [location.state]);
|
||||||
|
|
||||||
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
|
const storeName = (location.state as { storeName?: string })?.storeName || '当前门店';
|
||||||
const amount = Number(result?.amount || 100);
|
const user = (location.state as { user?: { nickname?: string; phone?: string; userNo?: string } })?.user;
|
||||||
|
const userLabel = user?.nickname || user?.phone || user?.userNo || '—';
|
||||||
|
const amount = Number(result?.amount ?? 0);
|
||||||
const redeemNo = String(result?.redeemNo || '—');
|
const redeemNo = String(result?.redeemNo || '—');
|
||||||
const createdAt = result?.createdAt
|
const createdAt = result?.createdAt
|
||||||
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
|
? new Date(String(result.createdAt)).toLocaleString('zh-CN')
|
||||||
@@ -58,8 +60,7 @@ export default function RedeemSuccessPage() {
|
|||||||
<span className="material-symbols-outlined">person</span>
|
<span className="material-symbols-outlined">person</span>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
<div style={{ textAlign: 'right' }}>
|
||||||
<div className="shop-success-detail-value">杜康用户</div>
|
<div className="shop-success-detail-value">{userLabel}</div>
|
||||||
<div className="shop-success-detail-label">待完善</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -78,7 +79,7 @@ export default function RedeemSuccessPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="shop-success-actions">
|
<div className="shop-success-actions">
|
||||||
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/redeem')}>
|
<button type="button" className="shop-success-primary-btn" onClick={() => navigate('/')}>
|
||||||
<span>继续核销</span>
|
<span>继续核销</span>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>qr_code_scanner</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { clearAuth, request } from '../lib/api';
|
import { useStoreSession } from '../contexts/StoreSessionContext';
|
||||||
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
export default function StatusPage() {
|
export default function StatusPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { resetSession } = useStoreSession();
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
const [store, setStore] = useState<Record<string, unknown> | null>(null);
|
||||||
const [lastUpdate, setLastUpdate] = useState('');
|
const [lastUpdate, setLastUpdate] = useState('');
|
||||||
@@ -58,7 +60,7 @@ export default function StatusPage() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="shop-status-logout app-page-header-action app-page-header-action--end"
|
className="shop-status-logout app-page-header-action app-page-header-action--end"
|
||||||
onClick={() => { clearAuth(); navigate('/login'); }}
|
onClick={() => { resetSession(); navigate('/login'); }}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>logout</span>
|
||||||
退出登录
|
退出登录
|
||||||
|
|||||||
+123
-34
@@ -152,23 +152,31 @@
|
|||||||
.shop-login-code-row {
|
.shop-login-code-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-login-code-row .shop-login-input-wrap {
|
.shop-login-code-row .shop-login-input-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-login-code-btn {
|
.shop-login-code-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 48px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
padding: 0 16px;
|
padding: 0 14px;
|
||||||
background: #ffdad7;
|
background: #ffdad7;
|
||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
font-family: var(--font-label);
|
font-family: var(--font-label);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.02em;
|
||||||
|
line-height: 1.2;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -618,6 +626,106 @@
|
|||||||
color: var(--color-heritage-red);
|
color: var(--color-heritage-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-home-scan-msg {
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-card {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 28px 24px 24px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-card);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-icon {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
margin: 0 auto 16px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(166, 29, 36, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-icon .material-symbols-outlined {
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-family: var(--font-headline);
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-desc {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--color-on-surface-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-error {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-heritage-red);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-cancel,
|
||||||
|
.shop-scan-auth-confirm {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 44px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-family: var(--font-label);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-cancel {
|
||||||
|
border: 1px solid var(--color-outline-variant);
|
||||||
|
background: var(--color-surface);
|
||||||
|
color: var(--color-on-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-confirm {
|
||||||
|
border: none;
|
||||||
|
background: var(--color-heritage-red);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shop-scan-auth-cancel:disabled,
|
||||||
|
.shop-scan-auth-confirm:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-home-status {
|
.shop-home-status {
|
||||||
background: var(--color-card);
|
background: var(--color-card);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
@@ -1008,38 +1116,6 @@
|
|||||||
color: var(--color-subtle-gray);
|
color: var(--color-subtle-gray);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-redeem-dev {
|
|
||||||
margin-top: 8px;
|
|
||||||
border: 1px dashed var(--color-outline-variant);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shop-redeem-dev summary {
|
|
||||||
padding: 12px 16px;
|
|
||||||
font-family: var(--font-label);
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--color-subtle-gray);
|
|
||||||
cursor: pointer;
|
|
||||||
list-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shop-redeem-dev summary::-webkit-details-marker {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shop-redeem-dev-body {
|
|
||||||
padding: 0 16px 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shop-redeem-dev-body input {
|
|
||||||
width: 100%;
|
|
||||||
padding: 12px;
|
|
||||||
border: 1px solid var(--color-outline-variant);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.shop-redeem-error {
|
.shop-redeem-error {
|
||||||
color: var(--color-error);
|
color: var(--color-error);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1918,3 +1994,16 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-boot {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--color-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-boot-text {
|
||||||
|
color: var(--color-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,3 +13,6 @@ DEPLOY_WEBHOOK_HOST=127.0.0.1
|
|||||||
DEPLOY_WEBHOOK_PORT=8095
|
DEPLOY_WEBHOOK_PORT=8095
|
||||||
DEPLOY_LOG_FILE=/var/log/dukang/deploy.log
|
DEPLOY_LOG_FILE=/var/log/dukang/deploy.log
|
||||||
DEPLOY_LOCK_FILE=/var/run/dukang-deploy.lock
|
DEPLOY_LOCK_FILE=/var/run/dukang-deploy.lock
|
||||||
|
|
||||||
|
# Prisma db push 遇到 schema 变更警告时自动继续(开发/测试环境可开)
|
||||||
|
PRISMA_ACCEPT_DATA_LOSS=true
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ sed -i 's/\r$//' "$ENV_FILE" 2>/dev/null || true
|
|||||||
find "$APP_ROOT/deploy" -maxdepth 1 -name '*.sh' -exec sed -i 's/\r$//' {} + 2>/dev/null || true
|
find "$APP_ROOT/deploy" -maxdepth 1 -name '*.sh' -exec sed -i 's/\r$//' {} + 2>/dev/null || true
|
||||||
|
|
||||||
log "RUN remote-release.sh"
|
log "RUN remote-release.sh"
|
||||||
bash "$APP_ROOT/deploy/remote-release.sh"
|
RELEASE_ARGS=()
|
||||||
|
if [[ "${PRISMA_ACCEPT_DATA_LOSS:-false}" == "true" ]]; then
|
||||||
|
RELEASE_ARGS+=(--accept-data-loss)
|
||||||
|
fi
|
||||||
|
bash "$APP_ROOT/deploy/remote-release.sh" "${RELEASE_ARGS[@]}"
|
||||||
|
|
||||||
log "DONE auto-release ($REMOTE_SHA)"
|
log "DONE auto-release ($REMOTE_SHA)"
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export enum ActorType {
|
|||||||
export enum SmsScene {
|
export enum SmsScene {
|
||||||
USER_LOGIN = 'USER_LOGIN',
|
USER_LOGIN = 'USER_LOGIN',
|
||||||
STORE_LOGIN = 'STORE_LOGIN',
|
STORE_LOGIN = 'STORE_LOGIN',
|
||||||
|
STORE_ACCOUNT_OPEN = 'STORE_ACCOUNT_OPEN',
|
||||||
PARTNER_LOGIN = 'PARTNER_LOGIN',
|
PARTNER_LOGIN = 'PARTNER_LOGIN',
|
||||||
HQ_LOGIN = 'HQ_LOGIN',
|
HQ_LOGIN = 'HQ_LOGIN',
|
||||||
BIND_PHONE = 'BIND_PHONE',
|
BIND_PHONE = 'BIND_PHONE',
|
||||||
|
|||||||
@@ -23,18 +23,29 @@ export async function scanQrCode(config: WeixinSdkConfig): Promise<string | null
|
|||||||
clientApp: config.clientApp,
|
clientApp: config.clientApp,
|
||||||
getAccessToken: config.getAccessToken,
|
getAccessToken: config.getAccessToken,
|
||||||
});
|
});
|
||||||
if (window.wx?.scanQRCode) {
|
if (!window.wx?.scanQRCode) {
|
||||||
return new Promise((resolve) => {
|
throw new Error('当前微信版本不支持扫码,请升级微信后重试');
|
||||||
|
}
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
window.wx!.scanQRCode!({
|
window.wx!.scanQRCode!({
|
||||||
needResult: 1,
|
needResult: 1,
|
||||||
scanType: ['qrCode', 'barCode'],
|
scanType: ['qrCode', 'barCode'],
|
||||||
success: (res) => resolve(res.resultStr || null),
|
success: (res) => resolve(res.resultStr || null),
|
||||||
fail: () => resolve(null),
|
fail: (res) => {
|
||||||
});
|
const msg = res.errMsg || '扫码失败';
|
||||||
});
|
if (/cancel/i.test(msg)) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
if (/permission|auth|denied|授权|拒绝|camera/i.test(msg)) {
|
||||||
|
reject(new Error('相机权限未开启,请在微信设置中允许使用摄像头'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
reject(new Error(msg));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const manual = typeof window !== 'undefined' ? window.prompt('当前环境无法调起微信扫码,请手动输入核销码') : null;
|
throw new Error('请在微信内打开以使用扫码核销');
|
||||||
return manual?.trim() || null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ async function main() {
|
|||||||
console.log('5. Shop login + redeem preview');
|
console.log('5. Shop login + redeem preview');
|
||||||
const shopLogin = await loginSms(
|
const shopLogin = await loginSms(
|
||||||
'SHOP_H5',
|
'SHOP_H5',
|
||||||
'13900000001',
|
'13910000001',
|
||||||
'STORE_LOGIN',
|
'STORE_LOGIN',
|
||||||
'/shop/auth/login/sms',
|
'/shop/auth/login/sms',
|
||||||
'/shop/auth/sms/send',
|
'/shop/auth/sms/send',
|
||||||
|
|||||||
+17
-1
@@ -121,14 +121,30 @@ async function main() {
|
|||||||
body: JSON.stringify({ couponId: coupons[0].id, amount: Number(coupons[0].balance) + 1 }),
|
body: JSON.stringify({ couponId: coupons[0].id, amount: Number(coupons[0].balance) + 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('7b. Shop sms guard + session');
|
||||||
|
const unboundMsg = await expectFail('SHOP_H5', '/shop/auth/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: '13899999999', scene: 'STORE_LOGIN' }),
|
||||||
|
});
|
||||||
|
if (!unboundMsg.includes('未绑定门店')) {
|
||||||
|
throw new Error(`Expected unbound store message, got: ${unboundMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('8. Shop confirm redeem');
|
console.log('8. Shop confirm redeem');
|
||||||
const shopLogin = await loginSms(
|
const shopLogin = await loginSms(
|
||||||
'SHOP_H5',
|
'SHOP_H5',
|
||||||
'13900000001',
|
'13910000001',
|
||||||
'STORE_LOGIN',
|
'STORE_LOGIN',
|
||||||
'/shop/auth/login/sms',
|
'/shop/auth/login/sms',
|
||||||
'/shop/auth/sms/send',
|
'/shop/auth/sms/send',
|
||||||
);
|
);
|
||||||
|
const shopMe = await req('SHOP_H5', '/shop/auth/me', { token: shopLogin.accessToken });
|
||||||
|
if (!shopMe?.phone) throw new Error('Shop /shop/auth/me failed');
|
||||||
|
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken: shopLogin.refreshToken }),
|
||||||
|
});
|
||||||
|
if (!refreshed?.accessToken) throw new Error('Shop token refresh failed');
|
||||||
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
const preview = await req('SHOP_H5', '/shop/redeem/preview', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: shopLogin.accessToken,
|
token: shopLogin.accessToken,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
/**
|
||||||
|
* 门店登录 / 建店短信校验专项冒烟(需 API 已启动且 MOCK_SMS 开启)
|
||||||
|
* 用法: node scripts/test-shop-auth.mjs
|
||||||
|
*/
|
||||||
|
const API = process.env.SMOKE_API ?? 'http://localhost:3000/api/v1';
|
||||||
|
const MOCK_SMS_CODE = process.env.MOCK_SMS_CODE ?? '123456';
|
||||||
|
|
||||||
|
async function req(clientApp, path, options = {}) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': clientApp,
|
||||||
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||||
|
};
|
||||||
|
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code !== 0) throw new Error(`${path}: ${json.message}`);
|
||||||
|
return json.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectFail(clientApp, path, options = {}) {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Client-App': clientApp,
|
||||||
|
...(options.token ? { Authorization: `Bearer ${options.token}` } : {}),
|
||||||
|
};
|
||||||
|
const res = await fetch(`${API}${path}`, { ...options, headers, body: options.body });
|
||||||
|
const json = await res.json();
|
||||||
|
if (json.code === 0) throw new Error(`${path}: expected failure`);
|
||||||
|
return json.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('1. STORE_LOGIN unbound phone');
|
||||||
|
const unbound = await expectFail('SHOP_H5', '/shop/auth/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: '13899999999', scene: 'STORE_LOGIN' }),
|
||||||
|
});
|
||||||
|
if (!unbound.includes('未绑定门店')) throw new Error(`unexpected: ${unbound}`);
|
||||||
|
|
||||||
|
console.log('2. STORE_LOGIN bound phone');
|
||||||
|
await req('SHOP_H5', '/shop/auth/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: '13910000001', scene: 'STORE_LOGIN' }),
|
||||||
|
});
|
||||||
|
const login = await req('SHOP_H5', '/shop/auth/login/sms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: '13910000001', code: MOCK_SMS_CODE }),
|
||||||
|
});
|
||||||
|
if (!login.accessToken || !login.refreshToken) throw new Error('login missing tokens');
|
||||||
|
|
||||||
|
console.log('3. Shop session me + refresh');
|
||||||
|
const me = await req('SHOP_H5', '/shop/auth/me', { token: login.accessToken });
|
||||||
|
if (me.phone !== '13910000001') throw new Error('me phone mismatch');
|
||||||
|
const refreshed = await req('SHOP_H5', '/shop/auth/token/refresh', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ refreshToken: login.refreshToken }),
|
||||||
|
});
|
||||||
|
if (!refreshed.accessToken) throw new Error('refresh failed');
|
||||||
|
|
||||||
|
console.log('4. STORE_ACCOUNT_OPEN occupied phone');
|
||||||
|
await req('HQ_WEB', '/admin/auth/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: '13600000001', scene: 'HQ_LOGIN' }),
|
||||||
|
});
|
||||||
|
const admin = await req('HQ_WEB', '/admin/auth/login/sms', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ phone: '13600000001', code: MOCK_SMS_CODE }),
|
||||||
|
});
|
||||||
|
const occupied = await expectFail('HQ_WEB', '/admin/stores/phone/sms/send', {
|
||||||
|
method: 'POST',
|
||||||
|
token: admin.accessToken,
|
||||||
|
body: JSON.stringify({ phone: '13910000001' }),
|
||||||
|
});
|
||||||
|
if (!occupied.includes('已绑定')) throw new Error(`unexpected: ${occupied}`);
|
||||||
|
|
||||||
|
console.log('\n✅ shop-auth tests passed');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((e) => {
|
||||||
|
console.error(e);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -143,7 +143,7 @@ async function main() {
|
|||||||
partnerId: partner.id,
|
partnerId: partner.id,
|
||||||
categoryId: categories[0].id,
|
categoryId: categories[0].id,
|
||||||
name: '郑州老城店',
|
name: '郑州老城店',
|
||||||
phone: '0371-88880001',
|
phone: '13910000001',
|
||||||
province: '河南省',
|
province: '河南省',
|
||||||
cityName: '郑州市',
|
cityName: '郑州市',
|
||||||
district: '金水区',
|
district: '金水区',
|
||||||
@@ -165,7 +165,7 @@ async function main() {
|
|||||||
partnerId: partner.id,
|
partnerId: partner.id,
|
||||||
categoryId: categories[1].id,
|
categoryId: categories[1].id,
|
||||||
name: '郑州美食城店',
|
name: '郑州美食城店',
|
||||||
phone: '0371-88880002',
|
phone: '13910000002',
|
||||||
province: '河南省',
|
province: '河南省',
|
||||||
cityName: '郑州市',
|
cityName: '郑州市',
|
||||||
district: '二七区',
|
district: '二七区',
|
||||||
@@ -179,11 +179,7 @@ async function main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await prisma.storeAccount.create({
|
await prisma.storeAccount.create({
|
||||||
data: { storeId: store1.id, phone: '13900000001', name: '老城店店长' },
|
data: { storeId: store1.id, phone: '13910000001', name: '郑州老城店' },
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.storeAccount.create({
|
|
||||||
data: { storeId: store2.id, phone: '13900000002', name: '美食城店长' },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
@@ -233,7 +229,7 @@ async function main() {
|
|||||||
stores: 2,
|
stores: 2,
|
||||||
testPhones: {
|
testPhones: {
|
||||||
user: '13800000001',
|
user: '13800000001',
|
||||||
store: '13900000001',
|
store: '13910000001',
|
||||||
partner: '13700000001',
|
partner: '13700000001',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -153,25 +153,23 @@ async function main() {
|
|||||||
const storeDefs = [
|
const storeDefs = [
|
||||||
{
|
{
|
||||||
name: '郑州老城店',
|
name: '郑州老城店',
|
||||||
phone: '0371-88880001',
|
phone: '13910000001',
|
||||||
district: '金水区',
|
district: '金水区',
|
||||||
address: '花园路100号',
|
address: '花园路100号',
|
||||||
intro: '正宗河南菜,欢迎核销好客权益',
|
intro: '正宗河南菜,欢迎核销好客权益',
|
||||||
img: 'https://picsum.photos/seed/store1/400/300',
|
img: 'https://picsum.photos/seed/store1/400/300',
|
||||||
categoryId: categories[0].id,
|
categoryId: categories[0].id,
|
||||||
accountPhone: '13900000001',
|
withAccount: true,
|
||||||
accountName: '老城店店长',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '郑州美食城店',
|
name: '郑州美食城店',
|
||||||
phone: '0371-88880002',
|
phone: '13910000002',
|
||||||
district: '二七区',
|
district: '二七区',
|
||||||
address: '大学路200号',
|
address: '大学路200号',
|
||||||
intro: '地方特色餐饮',
|
intro: '地方特色餐饮',
|
||||||
img: 'https://picsum.photos/seed/store2/400/300',
|
img: 'https://picsum.photos/seed/store2/400/300',
|
||||||
categoryId: categories[1].id,
|
categoryId: categories[1].id,
|
||||||
accountPhone: '13900000002',
|
withAccount: false,
|
||||||
accountName: '美食城店长',
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -198,10 +196,12 @@ async function main() {
|
|||||||
});
|
});
|
||||||
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
|
const cover = await createMockResource(ResourceOwnerType.STORE, store.id, ResourceBizType.COVER, def.img);
|
||||||
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
await prisma.store.update({ where: { id: store.id }, data: { coverResourceId: cover.id } });
|
||||||
|
if (def.withAccount) {
|
||||||
await prisma.storeAccount.create({
|
await prisma.storeAccount.create({
|
||||||
data: { storeId: store.id, phone: def.accountPhone, name: def.accountName },
|
data: { storeId: store.id, phone: def.phone, name: def.name },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -252,7 +252,6 @@ async function main() {
|
|||||||
stores: storeDefs.length,
|
stores: storeDefs.length,
|
||||||
testPhones: {
|
testPhones: {
|
||||||
user: '13800000001',
|
user: '13800000001',
|
||||||
store: '13900000001',
|
|
||||||
partner: '13700000001',
|
partner: '13700000001',
|
||||||
hq: '13600000001',
|
hq: '13600000001',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -112,9 +112,30 @@ export class ShopAuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login/wechat')
|
@Post('login/wechat')
|
||||||
wechatLogin(@Body() dto: LoginWechatDto) {
|
@UseGuards(OptionalJwtAuthGuard)
|
||||||
|
wechatLogin(@Req() req: Request, @Body() dto: LoginWechatDto) {
|
||||||
|
const user = (req as Request & { user?: AuthUser }).user;
|
||||||
|
if (user?.actorType === 'STORE') {
|
||||||
|
return this.authService.bindStoreWechat(
|
||||||
|
user.actorId,
|
||||||
|
dto.code,
|
||||||
|
ClientApp.SHOP_H5,
|
||||||
|
dto.platform ?? 'h5',
|
||||||
|
);
|
||||||
|
}
|
||||||
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
return this.authService.loginStoreWechat(dto.code, ClientApp.SHOP_H5, dto.platform ?? 'h5');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('token/refresh')
|
||||||
|
refresh(@Body() dto: RefreshTokenDto) {
|
||||||
|
return this.authService.refreshAccessToken(dto.refreshToken, ClientApp.SHOP_H5);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('me')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
me(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.authService.getMe(user.actorType, user.actorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Controller('partner/auth')
|
@Controller('partner/auth')
|
||||||
|
|||||||
@@ -80,6 +80,8 @@ export class AuthService {
|
|||||||
switch (scene) {
|
switch (scene) {
|
||||||
case SmsScene.STORE_LOGIN:
|
case SmsScene.STORE_LOGIN:
|
||||||
return ClientApp.SHOP_H5;
|
return ClientApp.SHOP_H5;
|
||||||
|
case SmsScene.STORE_ACCOUNT_OPEN:
|
||||||
|
return ClientApp.HQ_WEB;
|
||||||
case SmsScene.PARTNER_LOGIN:
|
case SmsScene.PARTNER_LOGIN:
|
||||||
case SmsScene.PARTNER_STAFF_ADD:
|
case SmsScene.PARTNER_STAFF_ADD:
|
||||||
return ClientApp.PARTNER_H5;
|
return ClientApp.PARTNER_H5;
|
||||||
@@ -148,6 +150,24 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async assertSmsSendAllowed(phone: string, scene: SmsScene) {
|
||||||
|
if (scene === SmsScene.STORE_LOGIN) {
|
||||||
|
const account = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||||
|
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
||||||
|
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (scene === SmsScene.STORE_ACCOUNT_OPEN) {
|
||||||
|
const existing = await this.prisma.storeAccount.findUnique({ where: { phone } });
|
||||||
|
if (existing) throw new BadRequestException('该手机号已绑定门店');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifySmsCode(phone: string, code: string, scene: SmsScene) {
|
||||||
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
|
await this.smsProvider.verify(normalizedPhone, code, scene);
|
||||||
|
}
|
||||||
|
|
||||||
private async verifySmsForUser(
|
private async verifySmsForUser(
|
||||||
phone: string,
|
phone: string,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -178,6 +198,7 @@ export class AuthService {
|
|||||||
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
|
if (!Object.values(SmsScene).includes(scene as SmsScene)) {
|
||||||
throw new BadRequestException('无效的验证码场景');
|
throw new BadRequestException('无效的验证码场景');
|
||||||
}
|
}
|
||||||
|
await this.assertSmsSendAllowed(normalizedPhone, scene as SmsScene);
|
||||||
const clientApp = opts?.clientApp ?? this.clientAppForScene(scene);
|
const clientApp = opts?.clientApp ?? this.clientAppForScene(scene);
|
||||||
const actorRef = await this.resolveSmsActorRef(normalizedPhone, scene, opts?.guestUserId);
|
const actorRef = await this.resolveSmsActorRef(normalizedPhone, scene, opts?.guestUserId);
|
||||||
const userId = actorRef?.refType === 'USER' ? actorRef.refId : opts?.guestUserId;
|
const userId = actorRef?.refType === 'USER' ? actorRef.refId : opts?.guestUserId;
|
||||||
@@ -254,17 +275,40 @@ export class AuthService {
|
|||||||
async refreshAccessToken(refreshToken: string, clientApp: ClientApp) {
|
async refreshAccessToken(refreshToken: string, clientApp: ClientApp) {
|
||||||
try {
|
try {
|
||||||
const payload = this.jwtService.verify(refreshToken);
|
const payload = this.jwtService.verify(refreshToken);
|
||||||
if (payload.clientApp !== clientApp || payload.actorType !== 'USER') {
|
if (payload.clientApp !== clientApp) {
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
|
if (payload.actorType === 'USER') {
|
||||||
const user = await this.assertActiveUser(BigInt(payload.actorId));
|
const user = await this.assertActiveUser(BigInt(payload.actorId));
|
||||||
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
return this.buildSessionResponse(user, clientApp, user.deviceKey);
|
||||||
|
}
|
||||||
|
if (payload.actorType === 'STORE' && clientApp === ClientApp.SHOP_H5) {
|
||||||
|
return this.buildStoreSessionResponse(BigInt(payload.actorId), clientApp);
|
||||||
|
}
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof UnauthorizedException) throw err;
|
if (err instanceof UnauthorizedException) throw err;
|
||||||
throw new UnauthorizedException('Invalid refresh token');
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async buildStoreSessionResponse(accountId: bigint, clientApp: ClientApp) {
|
||||||
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
|
where: { id: accountId },
|
||||||
|
include: { store: true },
|
||||||
|
});
|
||||||
|
if (!account || account.status !== 'ACTIVE') {
|
||||||
|
throw new UnauthorizedException('Invalid refresh token');
|
||||||
|
}
|
||||||
|
return this.issueToken('STORE', account.id, clientApp, false, undefined, {
|
||||||
|
id: account.id.toString(),
|
||||||
|
storeId: account.storeId.toString(),
|
||||||
|
name: account.name,
|
||||||
|
phone: account.phone,
|
||||||
|
storeName: account.store.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
async loginUser(phone: string, code: string, clientApp: ClientApp, guestId?: bigint) {
|
||||||
const normalizedPhone = this.assertMobilePhone(phone);
|
const normalizedPhone = this.assertMobilePhone(phone);
|
||||||
const existingUser = await this.prisma.user.findUnique({
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
@@ -396,7 +440,8 @@ export class AuthService {
|
|||||||
where: { phone: normalizedPhone },
|
where: { phone: normalizedPhone },
|
||||||
include: { store: true },
|
include: { store: true },
|
||||||
});
|
});
|
||||||
if (!account) throw new BadRequestException('门店账号不存在');
|
if (!account) throw new BadRequestException('该手机号未绑定门店');
|
||||||
|
if (account.status !== 'ACTIVE') throw new BadRequestException('门店账号已停用');
|
||||||
await this.prisma.storeAccount.update({
|
await this.prisma.storeAccount.update({
|
||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: { lastLoginAt: new Date() },
|
data: { lastLoginAt: new Date() },
|
||||||
@@ -792,6 +837,50 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async bindStoreWechat(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
code: string,
|
||||||
|
clientApp: ClientApp,
|
||||||
|
platform: 'h5' | 'mini' = 'h5',
|
||||||
|
) {
|
||||||
|
this.assertWechatEnabled();
|
||||||
|
const session =
|
||||||
|
platform === 'mini'
|
||||||
|
? await this.wechatProvider.code2Session(code)
|
||||||
|
: await this.wechatProvider.oauth2AccessToken(code);
|
||||||
|
|
||||||
|
const account = await this.prisma.storeAccount.findUnique({
|
||||||
|
where: { id: storeAccountId },
|
||||||
|
include: { store: true },
|
||||||
|
});
|
||||||
|
if (!account) throw new BadRequestException('门店账号不存在');
|
||||||
|
|
||||||
|
const conflict = await this.prisma.storeAccount.findFirst({
|
||||||
|
where: { wxOpenId: session.openId, id: { not: storeAccountId } },
|
||||||
|
});
|
||||||
|
if (conflict) {
|
||||||
|
throw new BadRequestException('该微信已绑定其他门店账号');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.storeAccount.update({
|
||||||
|
where: { id: storeAccountId },
|
||||||
|
data: {
|
||||||
|
wxOpenId: session.openId,
|
||||||
|
wxUnionId: session.unionId ?? account.wxUnionId,
|
||||||
|
lastLoginAt: new Date(),
|
||||||
|
},
|
||||||
|
include: { store: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.issueToken('STORE', updated.id, clientApp, false, undefined, {
|
||||||
|
id: updated.id.toString(),
|
||||||
|
storeId: updated.storeId.toString(),
|
||||||
|
name: updated.name,
|
||||||
|
phone: updated.phone,
|
||||||
|
storeName: updated.store.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async bindPartnerWechat(
|
async bindPartnerWechat(
|
||||||
partnerAccountId: bigint,
|
partnerAccountId: bigint,
|
||||||
code: string,
|
code: string,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
|
|||||||
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||||
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
import { AdminRedeemDebugService } from './admin-redeem-debug.service';
|
||||||
import type {
|
import {
|
||||||
AdminRedeemDebugCreateTokenDto,
|
AdminRedeemDebugCreateTokenDto,
|
||||||
AdminRedeemDebugStoreTokenDto,
|
AdminRedeemDebugStoreTokenDto,
|
||||||
} from './dto/admin-mutate.dto';
|
} from './dto/admin-mutate.dto';
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ export class AdminStoresController {
|
|||||||
return this.service.listStores(query);
|
return this.service.listStores(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('phone/sms/send')
|
||||||
|
sendOpenSms(@Body() body: { phone: string }) {
|
||||||
|
return this.service.sendStoreOpenSms(body.phone);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
detail(@Param('id') id: string) {
|
detail(@Param('id') id: string) {
|
||||||
return this.service.detailStore(BigInt(id));
|
return this.service.detailStore(BigInt(id));
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
|
import { ClientApp, SmsScene } from '@dukang/shared-types';
|
||||||
import { PrismaService } from '../../common/prisma/prisma.module';
|
import { PrismaService } from '../../common/prisma/prisma.module';
|
||||||
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
|
||||||
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
import { mapStoreCompat } from '../../common/compat/v31-compat';
|
||||||
|
import { AuthService } from '../iam/auth.service';
|
||||||
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
import type { AdminStoreAccountsQueryDto, AdminStoreMediaQueryDto, AdminStoresQueryDto } from './dto/admin-query.dto';
|
||||||
import type {
|
import type {
|
||||||
CreateStoreAccountDto,
|
CreateStoreAccountDto,
|
||||||
@@ -16,7 +18,16 @@ import type {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AdminStoresService {
|
export class AdminStoresService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly authService: AuthService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async sendStoreOpenSms(phone: string) {
|
||||||
|
return this.authService.sendSms(phone, SmsScene.STORE_ACCOUNT_OPEN, {
|
||||||
|
clientApp: ClientApp.HQ_WEB,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async listStores(query: AdminStoresQueryDto) {
|
async listStores(query: AdminStoresQueryDto) {
|
||||||
const page = query.page ?? 1;
|
const page = query.page ?? 1;
|
||||||
@@ -153,6 +164,16 @@ export class AdminStoresService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async createStore(dto: CreateStoreDto) {
|
async createStore(dto: CreateStoreDto) {
|
||||||
|
const normalizedPhone = dto.phone.trim();
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||||
|
throw new BadRequestException('请输入正确的手机号码');
|
||||||
|
}
|
||||||
|
await this.authService.verifySmsCode(normalizedPhone, dto.smsCode, SmsScene.STORE_ACCOUNT_OPEN);
|
||||||
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||||
|
where: { phone: normalizedPhone },
|
||||||
|
});
|
||||||
|
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
|
||||||
|
|
||||||
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
|
const partner = await this.prisma.partner.findUnique({ where: { id: BigInt(dto.partnerId) } });
|
||||||
if (!partner) throw new BadRequestException('开城合伙人不存在');
|
if (!partner) throw new BadRequestException('开城合伙人不存在');
|
||||||
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
|
const city = await this.prisma.commonCity.findUnique({ where: { id: BigInt(dto.cityId) } });
|
||||||
@@ -167,7 +188,7 @@ export class AdminStoresService {
|
|||||||
partnerId: partner.id,
|
partnerId: partner.id,
|
||||||
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
|
categoryId: dto.categoryId ? BigInt(dto.categoryId) : null,
|
||||||
name: dto.name,
|
name: dto.name,
|
||||||
phone: dto.phone,
|
phone: normalizedPhone,
|
||||||
province: dto.province ?? city.province,
|
province: dto.province ?? city.province,
|
||||||
cityName: dto.city ?? city.name,
|
cityName: dto.city ?? city.name,
|
||||||
district: dto.district ?? '',
|
district: dto.district ?? '',
|
||||||
@@ -243,7 +264,7 @@ export class AdminStoresService {
|
|||||||
await this.prisma.storeAccount.create({
|
await this.prisma.storeAccount.create({
|
||||||
data: {
|
data: {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
phone: dto.accountPhone ?? dto.phone,
|
phone: normalizedPhone,
|
||||||
name: dto.accountName ?? dto.name,
|
name: dto.accountName ?? dto.name,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export class CreateStoreDto {
|
|||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
phone: string;
|
phone: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
smsCode: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
categoryId?: string;
|
categoryId?: string;
|
||||||
@@ -52,10 +56,6 @@ export class CreateStoreDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
coverUrl?: string;
|
coverUrl?: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
accountPhone?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
accountName?: string;
|
accountName?: string;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export class SettlementService {
|
|||||||
skip: (page - 1) * pageSize,
|
skip: (page - 1) * pageSize,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
include: {
|
include: {
|
||||||
store: { select: { id: true, name: true, cityName: true } },
|
store: { select: { id: true, name: true, cityName: true, phone: true } },
|
||||||
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
|
redeemRecord: { select: { redeemNo: true, amount: true, userId: true } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -83,6 +83,15 @@ export class StoreService {
|
|||||||
|
|
||||||
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
async createStore(partnerAccountId: bigint, body: Record<string, unknown>) {
|
||||||
const account = await this.getPartnerAccount(partnerAccountId);
|
const account = await this.getPartnerAccount(partnerAccountId);
|
||||||
|
const normalizedPhone = String(body.phone).trim();
|
||||||
|
if (!/^1[3-9]\d{9}$/.test(normalizedPhone)) {
|
||||||
|
throw new BadRequestException('联系电话须为11位手机号');
|
||||||
|
}
|
||||||
|
const existingAccount = await this.prisma.storeAccount.findUnique({
|
||||||
|
where: { phone: normalizedPhone },
|
||||||
|
});
|
||||||
|
if (existingAccount) throw new BadRequestException('该手机号已绑定门店');
|
||||||
|
|
||||||
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
|
const city = await this.resolvePartnerCity(account.partnerId, body.cityId);
|
||||||
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
const coverUrl = body.coverUrl ? String(body.coverUrl).trim() : '';
|
||||||
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
const envPhotoUrls = Array.isArray(body.envPhotoUrls)
|
||||||
@@ -96,7 +105,7 @@ export class StoreService {
|
|||||||
partnerId: account.partnerId,
|
partnerId: account.partnerId,
|
||||||
categoryId: body.categoryId ? BigInt(String(body.categoryId)) : null,
|
categoryId: body.categoryId ? BigInt(String(body.categoryId)) : null,
|
||||||
name: String(body.name),
|
name: String(body.name),
|
||||||
phone: String(body.phone),
|
phone: normalizedPhone,
|
||||||
province: String(body.province ?? city.province ?? '河南省'),
|
province: String(body.province ?? city.province ?? '河南省'),
|
||||||
cityName: String(body.city ?? city.name ?? '郑州市'),
|
cityName: String(body.city ?? city.name ?? '郑州市'),
|
||||||
district: String(body.district ?? ''),
|
district: String(body.district ?? ''),
|
||||||
@@ -174,11 +183,8 @@ export class StoreService {
|
|||||||
await this.prisma.storeAccount.create({
|
await this.prisma.storeAccount.create({
|
||||||
data: {
|
data: {
|
||||||
storeId: store.id,
|
storeId: store.id,
|
||||||
phone: await this.resolveStoreAccountPhone(
|
phone: normalizedPhone,
|
||||||
String(body.accountPhone ?? body.phone),
|
name: String(body.name),
|
||||||
store.id,
|
|
||||||
),
|
|
||||||
name: String(body.accountName ?? body.name),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,13 +316,4 @@ export class StoreService {
|
|||||||
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
if (!city) throw new BadRequestException('合伙人未绑定开城');
|
||||||
return city;
|
return city;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async resolveStoreAccountPhone(phone: string, storeId: bigint): Promise<string> {
|
|
||||||
const normalized = phone.trim();
|
|
||||||
const existing = await this.prisma.storeAccount.findUnique({ where: { phone: normalized } });
|
|
||||||
if (!existing) return normalized;
|
|
||||||
const suffix = String(storeId).slice(-4);
|
|
||||||
const candidate = `${normalized.slice(0, 15)}${suffix}`.slice(0, 20);
|
|
||||||
return candidate;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user