发布商品,商品图片使用oss服务器地址
This commit is contained in:
@@ -18,6 +18,10 @@ import CitiesPage from './pages/CitiesPage';
|
||||
import StoreMediaPage from './pages/StoreMediaPage';
|
||||
import ProductsPage from './pages/ProductsPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import StorePayoutsPage from './pages/StorePayoutsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import TicketsPage from './pages/TicketsPage';
|
||||
import ThirdPartyLogsPage from './pages/ThirdPartyLogsPage';
|
||||
|
||||
function RequireAuth({ children }: { children: React.ReactNode }) {
|
||||
if (!getToken()) return <Navigate to="/login" replace />;
|
||||
@@ -49,6 +53,10 @@ export default function App() {
|
||||
<Route path="/benefit/coupons" element={<BenefitCouponsPage />} />
|
||||
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
|
||||
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
|
||||
<Route path="/store-payouts" element={<StorePayoutsPage />} />
|
||||
<Route path="/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/third-party-logs" element={<ThirdPartyLogsPage />} />
|
||||
<Route path="/deliveries" element={<DeliveriesPage />} />
|
||||
<Route path="/hq-accounts" element={<HqAccountsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -52,8 +52,12 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
{ key: '/benefit/coupons', label: '权益券' },
|
||||
{ key: '/benefit/ledgers', label: '流水' },
|
||||
{ key: '/redeem-records', label: '核销记录' },
|
||||
{ key: '/store-payouts', label: '门店打款' },
|
||||
],
|
||||
},
|
||||
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
|
||||
{ key: '/tickets', icon: <CarOutlined />, label: '工单中心' },
|
||||
{ key: '/third-party-logs', icon: <CloudUploadOutlined />, label: '第三方日志' },
|
||||
{ key: '/deliveries', icon: <CarOutlined />, label: '配送单' },
|
||||
{ key: '/hq-accounts', icon: <SafetyOutlined />, label: 'HQ账户' },
|
||||
];
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import dayjs from 'dayjs';
|
||||
import { request } from '../lib/api';
|
||||
import { fmtTime } from '../lib/constants';
|
||||
import { useAdminList } from '../lib/useAdminList';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
billNo: string;
|
||||
orderCommission: number;
|
||||
redeemCommission: number;
|
||||
totalAmount: number;
|
||||
status: string;
|
||||
periodStart: string;
|
||||
periodEnd: string;
|
||||
partner?: { companyName: string };
|
||||
};
|
||||
|
||||
export default function PartnerBillsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/partner-bills',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [genOpen, setGenOpen] = useState(false);
|
||||
const [genForm] = Form.useForm();
|
||||
|
||||
async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) {
|
||||
await request('/admin/partner-bills/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
partnerId: values.partnerId,
|
||||
year: values.month.year(),
|
||||
month: values.month.month() + 1,
|
||||
}),
|
||||
});
|
||||
message.success('账单已生成');
|
||||
setGenOpen(false);
|
||||
reload();
|
||||
}
|
||||
|
||||
async function confirmBill(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/confirm`, { method: 'POST' });
|
||||
message.success('已确认');
|
||||
reload();
|
||||
}
|
||||
|
||||
async function markPaid(id: string) {
|
||||
await request(`/admin/partner-bills/${id}/mark-paid`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: `PAY-${Date.now()}` }),
|
||||
});
|
||||
message.success('已标记打款');
|
||||
reload();
|
||||
}
|
||||
|
||||
async function exportCsv() {
|
||||
const result = await request<{ csv: string }>('/admin/partner-bills/export');
|
||||
const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'partner-bills.csv';
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '账单号', dataIndex: 'billNo', width: 180 },
|
||||
{ title: '合伙人', dataIndex: ['partner', 'companyName'] },
|
||||
{ title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` },
|
||||
{
|
||||
title: '操作', width: 180,
|
||||
render: (_, row) => (
|
||||
<>
|
||||
{row.status === 'DRAFT' && <Button type="link" size="small" onClick={() => confirmBill(row.id)}>确认</Button>}
|
||||
{row.status === 'CONFIRMED' && <Button type="link" size="small" onClick={() => markPaid(row.id)}>标记打款</Button>}
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>合伙人结算(T+30)</Typography.Title>
|
||||
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
|
||||
<Button type="primary" onClick={() => setGenOpen(true)}>生成账单</Button>
|
||||
<Button onClick={exportCsv}>导出 CSV</Button>
|
||||
</div>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'DRAFT', label: '草稿' },
|
||||
{ value: 'CONFIRMED', label: '已确认' },
|
||||
{ value: 'PAID', label: '已打款' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partnerId" 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: 1100 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
|
||||
<Form form={genForm} layout="vertical" onFinish={generateBill}>
|
||||
<Form.Item name="partnerId" label="合伙人 ID" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}><DatePicker picker="month" style={{ width: '100%' }} /></Form.Item>
|
||||
<Button type="primary" htmlType="submit" block>生成</Button>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } 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;
|
||||
provider: string;
|
||||
scene: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
externalNo?: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function ThirdPartyLogsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
|
||||
'/common/third-party-logs',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.provider) qs.set('provider', filters.provider);
|
||||
if (filters.refId) qs.set('refId', filters.refId);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: 'Provider', dataIndex: 'provider', width: 120 },
|
||||
{ title: '场景', dataIndex: 'scene', width: 120 },
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '外部单号', dataIndex: 'externalNo', width: 180 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/common/third-party-logs/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>第三方日志</Typography.Title>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="provider" label="Provider"><Input allowClear placeholder="WECHAT_PAY" /></Form.Item>
|
||||
<Form.Item name="refId" 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: 900 }}
|
||||
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="Provider">{String(detail.provider)}</Descriptions.Item>
|
||||
<Descriptions.Item label="场景">{String(detail.scene)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="外部单号">{String(detail.externalNo ?? '—')}</Descriptions.Item>
|
||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Descriptions, Drawer, Form, 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;
|
||||
ticketNo: string;
|
||||
ticketType: string;
|
||||
status: string;
|
||||
refType: string;
|
||||
refId: string;
|
||||
remark?: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function TicketsPage() {
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/tickets',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.ticketType) qs.set('ticketType', filters.ticketType);
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
async function approve(id: string) {
|
||||
await request(`/admin/tickets/${id}/approve`, { method: 'POST', body: JSON.stringify({}) });
|
||||
message.success('已审批通过');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
async function reject(id: string) {
|
||||
await request(`/admin/tickets/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '驳回' }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
reload();
|
||||
setDrawerOpen(false);
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{ title: '工单号', dataIndex: 'ticketNo', width: 180 },
|
||||
{ title: '类型', dataIndex: 'ticketType', width: 100 },
|
||||
{ title: '状态', dataIndex: 'status', width: 90 },
|
||||
{ title: '关联', width: 140, render: (_, r) => `${r.refType}#${r.refId}` },
|
||||
{ title: '备注', dataIndex: 'remark', ellipsis: true },
|
||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||
{
|
||||
title: '操作', width: 80,
|
||||
render: (_, row) => (
|
||||
<Button type="link" size="small" onClick={async () => {
|
||||
setDetail(await request(`/admin/tickets/${row.id}`));
|
||||
setDrawerOpen(true);
|
||||
}}>详情</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4}>工单中心</Typography.Title>
|
||||
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
||||
<Form.Item name="ticketType" label="类型">
|
||||
<Select allowClear style={{ width: 120 }} options={[
|
||||
{ value: 'REFUND', label: '退款' },
|
||||
{ value: 'RESHIPMENT', label: '补发' },
|
||||
{ value: 'ALERT', label: '异常' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态"><Input allowClear placeholder="PENDING" /></Form.Item>
|
||||
<Button type="primary" htmlType="submit">筛选</Button>
|
||||
</Form>
|
||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 900 }}
|
||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
||||
<Drawer title="工单详情" width={480} open={drawerOpen} onClose={() => setDrawerOpen(false)}
|
||||
extra={detail && (detail.status === 'PENDING' || detail.status === 'OPEN') ? (
|
||||
<>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))} style={{ marginRight: 8 }}>通过</Button>
|
||||
<Button danger onClick={() => reject(String(detail.id))}>驳回</Button>
|
||||
</>
|
||||
) : null}>
|
||||
{detail && (
|
||||
<Descriptions column={1} bordered size="small">
|
||||
<Descriptions.Item label="工单号">{String(detail.ticketNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{String(detail.ticketType)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{String(detail.status)}</Descriptions.Item>
|
||||
<Descriptions.Item label="关联">{String(detail.refType)} #{String(detail.refId)}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注">{String(detail.remark ?? '—')}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,14 @@ function formatAmount(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
type Preview = {
|
||||
amount: number;
|
||||
expireInSeconds: number;
|
||||
user?: { userNo?: string; phone?: string; nickname?: string };
|
||||
redeemType?: string;
|
||||
boundStoreId?: string | null;
|
||||
};
|
||||
|
||||
export default function RedeemConfirmPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -13,11 +21,15 @@ export default function RedeemConfirmPage() {
|
||||
const [msg, setMsg] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [storeName, setStoreName] = useState('');
|
||||
const [previewAmount, setPreviewAmount] = useState(100);
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const [storeClosed, setStoreClosed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||
.then((s) => setStoreName(String(s.name || '当前门店')))
|
||||
.then((s) => {
|
||||
setStoreName(String(s.name || '当前门店'));
|
||||
if (s.status && s.status !== 'OPEN') setStoreClosed(true);
|
||||
})
|
||||
.catch(() => setStoreName('当前门店'));
|
||||
}, []);
|
||||
|
||||
@@ -26,7 +38,27 @@ export default function RedeemConfirmPage() {
|
||||
if (scanned) setToken(scanned);
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token.trim()) {
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
request<Preview>('SHOP_H5', '/shop/redeem/preview', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ token }),
|
||||
})
|
||||
.then(setPreview)
|
||||
.catch((e) => {
|
||||
setPreview(null);
|
||||
setMsg(e instanceof Error ? e.message : '无法预览核销码');
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
async function confirm() {
|
||||
if (storeClosed) {
|
||||
setMsg('门店未营业,无法核销');
|
||||
return;
|
||||
}
|
||||
if (!token.trim()) {
|
||||
setMsg('请在开发者选项中输入核销码');
|
||||
return;
|
||||
@@ -47,6 +79,9 @@ export default function RedeemConfirmPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const previewAmount = preview?.amount ?? 0;
|
||||
const userLabel = preview?.user?.nickname || preview?.user?.phone || preview?.user?.userNo || '待扫码确认';
|
||||
|
||||
return (
|
||||
<div className="shop-redeem-page">
|
||||
<header className="shop-redeem-header">
|
||||
@@ -57,6 +92,9 @@ export default function RedeemConfirmPage() {
|
||||
</header>
|
||||
|
||||
<main className="shop-redeem-main">
|
||||
{storeClosed && (
|
||||
<p className="shop-redeem-error" style={{ marginBottom: 12 }}>门店当前未营业,无法核销</p>
|
||||
)}
|
||||
<section className="shop-redeem-card">
|
||||
<div className="shop-redeem-banner">
|
||||
<div className="shop-redeem-banner-icon">
|
||||
@@ -75,7 +113,7 @@ export default function RedeemConfirmPage() {
|
||||
<span>下单用户</span>
|
||||
</div>
|
||||
<span className="headline-md" style={{ fontFamily: 'var(--font-headline)', fontWeight: 600 }}>
|
||||
待扫码确认
|
||||
{userLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -91,7 +129,7 @@ export default function RedeemConfirmPage() {
|
||||
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-aged-amber)' }}>
|
||||
confirmation_number
|
||||
</span>
|
||||
好客权益
|
||||
好客权益 · {preview?.redeemType === 'COUPON' ? '单据核销' : '直接核销'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -102,8 +140,14 @@ export default function RedeemConfirmPage() {
|
||||
</div>
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>有效期</span>
|
||||
<span>永久</span>
|
||||
<span>{preview ? `${preview.expireInSeconds} 秒` : '—'}</span>
|
||||
</div>
|
||||
{preview?.boundStoreId && (
|
||||
<div className="shop-redeem-detail-row">
|
||||
<span>绑定门店</span>
|
||||
<span>仅限指定门店</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{msg && <p className="shop-redeem-error">{msg}</p>}
|
||||
@@ -111,13 +155,13 @@ export default function RedeemConfirmPage() {
|
||||
<button
|
||||
type="button"
|
||||
className={`shop-redeem-confirm-btn${loading ? ' success' : ''}`}
|
||||
disabled={loading}
|
||||
disabled={loading || storeClosed || !preview}
|
||||
onClick={confirm}
|
||||
>
|
||||
<span className="material-symbols-outlined shop-fill-icon">
|
||||
{loading ? 'sync' : 'check_circle'}
|
||||
</span>
|
||||
<span>{loading ? '正在核销...' : `确认核销 ¥${formatAmount(previewAmount)}`}</span>
|
||||
<span>{loading ? '正在核销...' : preview ? `确认核销 ¥${formatAmount(previewAmount)}` : '等待扫码'}</span>
|
||||
</button>
|
||||
|
||||
<p className="shop-redeem-hint">请核对金额后点击确认</p>
|
||||
@@ -127,10 +171,7 @@ export default function RedeemConfirmPage() {
|
||||
<div className="shop-redeem-dev-body">
|
||||
<input
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
if (e.target.value) setPreviewAmount(100);
|
||||
}}
|
||||
onChange={(e) => setToken(e.target.value)}
|
||||
placeholder="粘贴用户核销码"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,44 +1,47 @@
|
||||
/** Stitch 用户端-商品详情页 原型图(杜康·白水古酿) */
|
||||
const STITCH_CAROUSEL = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDiJm2VWrwCv8wnC-dBgSfvlf66izs6faELgWXlyIAUpYWOKrLwfeyB0c0XT0vmDVJnfIkzbLNm_4NYASwH_ce7BotJDLCJcd3SfnxKIe7eso-c4mzzR-4LTv4y3ELhpXHfxVyu-5LVUEwuofvUuzdJELV6CK4MIcLW_9rMaOuXSADfz0mpP-MspQvhKhxJ0wpdAiBxBq8rqHNSKjx8dU7lcVc_smZGunmtkbhmnjAn4JU8nCDnvuDU5HECng82FbFM6rzdkFHoYR0',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDzBBRn0yOqhRJ4CbTEOx1aF4fJVIhsbIZgFR9RgdB5E0xcs_RdR1khLyR0OzysGzkW_tnrZTb0avVEZ91Nd81KRItrlTjrEFvrYj0Qag45iRo5wioY8E2gK5NGhILvDpWxakuSPIGGp00nLY_5HuuLwr-0_8ZabaUFAR4C9loXIX_lgCAgRMt7An_H0AitIOBvwOfNVTMkz-P7dXQFzSUvYpFvcmvzAOIWsbipnTrgNU5H8Os37-soM-eWUCfNtJUaD_uqQ3mwJI8',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuDSSmH1ygwminKXiiIqOymnukbKJfnhfHnmCJTbNN2BEN2yF3vPtoMYOBAsDHxuldT9xg_ZBZhjh6QJjabvhu_HFB3WcNU53q_AjsD0mVWXInongiXqjOh8R-B2QW9Jfs786j3TSi2gVE57Ad1WskJji-xytI3aFEuk873xGXgdkn6EgzoAMOsKRaWF27DE3GBa48qAARYR92aEyMU_hcte6L2lkaF9brXshSmujiA_3ACK21TLsT2DCJ1Djacvh25J0LZ8v7BYVkk',
|
||||
] as const;
|
||||
/** 商品无图时的占位图 */
|
||||
export const PRODUCT_IMAGE_FALLBACK = '/images/1.png';
|
||||
|
||||
const STITCH_DETAIL = [
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuA2-IVt-apnEkj9QQ4rkjN5lb0oymgiJX1XfzAH8pRzSFzMVjYDtlMWE8GwpS7I6sth7CXJiKwNm9c-hpsYKqQ6pyb48yUO6NG8vky4E6qCjwgaCsCnlvoOVLroG4bmmL16-xl4-o28ZMvtzMoCKIiUK-_dQF7lx66nwnxFOP7PcUddDK-UItoO-Gp5iqxf6kp_-t_tjoPpo_ba25DBPG1QThlI8IJYqb9bNng5mIQzdnNul24rBy_JmgS5nsaYK0Wvo7907WT3ch4',
|
||||
'https://lh3.googleusercontent.com/aida-public/AB6AXuBe0yoN6VDKu2SVqwOy9RedE-6Rh56-a_5ygo5raDOU3Y65m1fz9hNmqbrIpvTW8RsqAwPd3zfHnw96Bb4Ct7jtmq-pil1MEvPBL4G3C8Sym_LVuEzK---hgdim1wVx-qP1v1EPex2fXpVQEY27rEVINVaXk2L1F5elWKQhMHWVXjU8B2jvtyNzmlXBpynsocnCgcwM4RhaqYdVf1JZxcfScmJ34dO3QAUIli-RPzEYynLtnW2x4lEbRrpAdBnK2fuSggLnJU1nfSw',
|
||||
] as const;
|
||||
export type ProductImageSource = {
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
};
|
||||
|
||||
/** 本地商品图占位 */
|
||||
export const PRODUCT_IMAGE_INDEX = [
|
||||
'/images/1.png',
|
||||
'/images/2.png',
|
||||
'/images/3.png',
|
||||
] as const;
|
||||
|
||||
export function getProductImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) {
|
||||
return [...STITCH_CAROUSEL.slice(0, 2)];
|
||||
function uniqueUrls(urls: Array<string | null | undefined>) {
|
||||
const seen = new Set<string>();
|
||||
const result: string[] = [];
|
||||
for (const url of urls) {
|
||||
if (!url || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
result.push(url);
|
||||
}
|
||||
const img = PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
|
||||
return [img];
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getProductMainImage(productIndex = 0): string {
|
||||
if (productIndex === 0) return STITCH_CAROUSEL[0];
|
||||
return PRODUCT_IMAGE_INDEX[productIndex % PRODUCT_IMAGE_INDEX.length] ?? '/images/1.png';
|
||||
/** 首页/列表轮播图:优先 CAROUSEL,否则封面 */
|
||||
export function getProductImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
|
||||
const main = source?.mainImageUrl;
|
||||
if (main) return [main];
|
||||
|
||||
return [PRODUCT_IMAGE_FALLBACK];
|
||||
}
|
||||
|
||||
/** 详情页轮播(首商品用 Stitch 三图,其余单图) */
|
||||
export function getProductCarouselImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) return [...STITCH_CAROUSEL];
|
||||
const img = getProductMainImage(productIndex);
|
||||
return [img];
|
||||
/** 单张主图:封面优先 */
|
||||
export function getProductMainImage(source?: ProductImageSource | null): string {
|
||||
return source?.mainImageUrl ?? source?.carouselUrls?.[0] ?? PRODUCT_IMAGE_FALLBACK;
|
||||
}
|
||||
|
||||
/** 详情页顶部轮播 */
|
||||
export function getProductCarouselImages(source?: ProductImageSource | null): string[] {
|
||||
const carousel = uniqueUrls(source?.carouselUrls ?? []);
|
||||
if (carousel.length > 0) return carousel;
|
||||
return getProductImages(source);
|
||||
}
|
||||
|
||||
/** 详情页图文长图 */
|
||||
export function getProductDetailImages(productIndex = 0): string[] {
|
||||
if (productIndex === 0) return [...STITCH_DETAIL];
|
||||
return [];
|
||||
export function getProductDetailImages(source?: ProductImageSource | null): string[] {
|
||||
return uniqueUrls(source?.detailImageUrls ?? []);
|
||||
}
|
||||
|
||||
@@ -13,27 +13,50 @@ type Product = {
|
||||
subtitle: string;
|
||||
price: number;
|
||||
benefitDisplay: number;
|
||||
mainImageUrl: string;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
detailImageUrls?: string[] | null;
|
||||
aromaType: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
type City = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
const AROMA_TABS = [
|
||||
{ key: 'QINGXIANG', label: '清香型', open: true },
|
||||
{ key: 'JIANGXIANG', label: '酱香型', open: false },
|
||||
{ key: 'NONGXIANG', label: '浓香型', open: false },
|
||||
];
|
||||
|
||||
const CITY_STORAGE_KEY = 'dukang_selected_city';
|
||||
|
||||
export default function HomePage() {
|
||||
const [tab, setTab] = useState('QINGXIANG');
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [cities, setCities] = useState<City[]>([]);
|
||||
const [cityCode, setCityCode] = useState(() => localStorage.getItem(CITY_STORAGE_KEY) || '410100');
|
||||
const [toast, setToast] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
request<Product[]>('USER_H5', '/catalog/products').then(setProducts);
|
||||
request<City[]>('USER_H5', '/catalog/cities').then((list) => {
|
||||
setCities(list);
|
||||
if (!list.some((c) => c.code === cityCode) && list[0]) {
|
||||
setCityCode(list[0].code);
|
||||
}
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cityCode) return;
|
||||
localStorage.setItem(CITY_STORAGE_KEY, cityCode);
|
||||
request<Product[]>('USER_H5', `/catalog/products?cityCode=${encodeURIComponent(cityCode)}`).then(setProducts);
|
||||
}, [cityCode]);
|
||||
|
||||
function showToast(message: string) {
|
||||
setToast(message);
|
||||
window.setTimeout(() => setToast(''), 2200);
|
||||
@@ -47,6 +70,7 @@ export default function HomePage() {
|
||||
setTab(key);
|
||||
}
|
||||
|
||||
const selectedCity = cities.find((c) => c.code === cityCode);
|
||||
const filtered = products.filter((p) => p.aromaType === tab);
|
||||
const onSale = tab === 'QINGXIANG';
|
||||
|
||||
@@ -57,7 +81,16 @@ export default function HomePage() {
|
||||
extra={(
|
||||
<div className="tab-main-city">
|
||||
<span className="material-symbols-outlined">location_on</span>
|
||||
<span>郑州市</span>
|
||||
<select
|
||||
value={cityCode}
|
||||
onChange={(e) => setCityCode(e.target.value)}
|
||||
style={{ border: 'none', background: 'transparent', font: 'inherit', color: 'inherit' }}
|
||||
>
|
||||
{cities.map((c) => (
|
||||
<option key={c.code} value={c.code}>{c.name}</option>
|
||||
))}
|
||||
{!cities.length && <option value={cityCode}>{selectedCity?.name ?? '郑州市'}</option>}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
@@ -78,10 +111,10 @@ export default function HomePage() {
|
||||
<section className="home-product-list">
|
||||
{!onSale && <div className="home-empty">该香型暂未上线,敬请期待</div>}
|
||||
{onSale &&
|
||||
filtered.map((p, index) => (
|
||||
filtered.map((p) => (
|
||||
<article key={p.id} className="home-product-card">
|
||||
<Link to={`/product/${p.id}`} className="home-product-link">
|
||||
<ProductCarousel images={getProductImages(index)} alt={p.name} />
|
||||
<ProductCarousel images={getProductImages(p)} alt={p.name} />
|
||||
<div className="home-product-body">
|
||||
<div className="home-product-row">
|
||||
<h3 className="home-product-name">{p.name}</h3>
|
||||
|
||||
@@ -4,8 +4,6 @@ import SubPageHeader from '../components/SubPageHeader';
|
||||
import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import { request } from '../lib/api';
|
||||
import { buildProductDetailUrl } from '../lib/navigation';
|
||||
import { STITCH_ORDER_PRODUCT_IMAGE } from '../lib/order-images';
|
||||
import { tryGetClientGpsLocation } from '../lib/client-location';
|
||||
import { getProductMainImage } from '../lib/product-images';
|
||||
import PhoneVerifySheet from '../components/PhoneVerifySheet';
|
||||
import { useUserSession } from '../contexts/UserSessionContext';
|
||||
@@ -27,6 +25,8 @@ type PreviewProduct = {
|
||||
spec: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
mainImageUrl?: string | null;
|
||||
carouselUrls?: string[] | null;
|
||||
};
|
||||
|
||||
type OrderPreview = {
|
||||
@@ -113,9 +113,7 @@ export default function OrderConfirmPage() {
|
||||
|
||||
const isCross = forceCross || preview?.deliveryType === 'CROSS_CITY';
|
||||
const minQty = isCross ? (preview?.city?.crossMinQty ?? 6) : (preview?.city?.localMinQty ?? 2);
|
||||
const productIndex = productId ? Math.max(0, Number(productId) - 1) : 0;
|
||||
const productImage =
|
||||
productIndex === 0 ? STITCH_ORDER_PRODUCT_IMAGE : getProductMainImage(productIndex);
|
||||
const productImage = preview?.product ? getProductMainImage(preview.product) : getProductMainImage();
|
||||
|
||||
async function doSubmit() {
|
||||
const clientLocation = await tryGetClientGpsLocation();
|
||||
|
||||
@@ -145,6 +145,7 @@ export default function OrderDetailPage() {
|
||||
const productImage = item?.productImage || STITCH_ORDER_PRODUCT_IMAGE;
|
||||
const canEditAddress = order ? EDITABLE_STATUSES.has(order.status) : false;
|
||||
const canConfirmReceive = order?.status === 'PENDING_RECEIVE' && !isReship;
|
||||
const canRefund = order && ['PENDING_SHIP', 'PENDING_RECEIVE', 'COMPLETED', 'OUT_WAREHOUSE', 'SHIPPING'].includes(order.status);
|
||||
const productTotal = Number(order?.productAmount ?? order?.payAmount ?? 0);
|
||||
const freightTotal = Number(order?.freightAmount ?? 0);
|
||||
|
||||
@@ -174,6 +175,22 @@ export default function OrderDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestRefund() {
|
||||
if (!id || !canRefund) return;
|
||||
setConfirming(true);
|
||||
try {
|
||||
await request('USER_H5', `/trade/orders/${id}/refund-requests`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ remark: '用户申请退款' }),
|
||||
});
|
||||
await loadOrder();
|
||||
} catch (e) {
|
||||
window.alert(e instanceof Error ? e.message : '申请退款失败');
|
||||
} finally {
|
||||
setConfirming(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!order) return <div className="empty">加载中...</div>;
|
||||
|
||||
return (
|
||||
@@ -380,6 +397,11 @@ export default function OrderDetailPage() {
|
||||
<span className="material-symbols-outlined">headset_mic</span>
|
||||
联系客服
|
||||
</button>
|
||||
{canRefund && order?.status !== 'REFUNDING' && order?.status !== 'REFUNDED' && (
|
||||
<button type="button" className="order-detail-action-outline" disabled={confirming} onClick={requestRefund}>
|
||||
申请退款
|
||||
</button>
|
||||
)}
|
||||
{canConfirmReceive && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -4,8 +4,9 @@ import AppImage from '@dukang/shared-ui/AppImage';
|
||||
import ProductCarousel from '../components/ProductCarousel';
|
||||
import { request } from '../lib/api';
|
||||
import { getProductCarouselImages, getProductDetailImages } from '../lib/product-images';
|
||||
import type { ProductImageSource } from '../lib/product-images';
|
||||
|
||||
type Product = {
|
||||
type Product = ProductImageSource & {
|
||||
name: string;
|
||||
subtitle?: string;
|
||||
price: number;
|
||||
@@ -22,7 +23,6 @@ export default function ProductDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const [product, setProduct] = useState<Product | null>(null);
|
||||
const [headerSolid, setHeaderSolid] = useState(false);
|
||||
const imageIndex = id ? Math.max(0, Number(id) - 1) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (id) request<Product>('USER_H5', `/catalog/products/${id}`).then(setProduct);
|
||||
@@ -39,8 +39,8 @@ export default function ProductDetailPage() {
|
||||
if (!product) return <div className="empty">加载中...</div>;
|
||||
|
||||
const benefit = Number(product.benefitAmount ?? product.price);
|
||||
const carouselImages = getProductCarouselImages(imageIndex);
|
||||
const detailImages = getProductDetailImages(imageIndex);
|
||||
const carouselImages = getProductCarouselImages(product);
|
||||
const detailImages = getProductDetailImages(product);
|
||||
|
||||
return (
|
||||
<div className="product-detail-page">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import PageHeader from '@dukang/shared-ui/PageHeader';
|
||||
|
||||
@@ -5,6 +6,19 @@ export default function RedeemCodePage() {
|
||||
const navigate = useNavigate();
|
||||
const token = sessionStorage.getItem('redeemToken') || '';
|
||||
const amount = sessionStorage.getItem('redeemAmount') || '0';
|
||||
const [secondsLeft, setSecondsLeft] = useState(300);
|
||||
|
||||
useEffect(() => {
|
||||
const expireAt = sessionStorage.getItem('redeemExpireAt');
|
||||
if (!expireAt) return;
|
||||
const tick = () => {
|
||||
const left = Math.max(0, Math.floor((new Date(expireAt).getTime() - Date.now()) / 1000));
|
||||
setSecondsLeft(left);
|
||||
};
|
||||
tick();
|
||||
const id = window.setInterval(tick, 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="page-no-tab" style={{ textAlign: 'center' }}>
|
||||
@@ -14,10 +28,14 @@ export default function RedeemCodePage() {
|
||||
<div className="label-md text-muted">核销金额</div>
|
||||
<div className="amount-xl" style={{ margin: '8px 0 24px' }}>¥{amount}</div>
|
||||
<div className="code-box">{token}</div>
|
||||
<p className="label-md text-muted" style={{ marginTop: 16 }}>5 分钟内有效</p>
|
||||
<p className="label-md text-muted" style={{ marginTop: 16 }}>
|
||||
{secondsLeft > 0 ? `${secondsLeft} 秒后过期` : '已过期,请重新生成'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/redeem/success')}>模拟核销完成</button>
|
||||
<button type="button" className="btn btn-outline btn-block" onClick={() => navigate('/benefit')}>
|
||||
返回权益页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user