发布商品,商品图片使用oss服务器地址

This commit is contained in:
2026-07-06 08:53:03 +08:00
parent a0466f023e
commit 5ba69eb935
60 changed files with 2776 additions and 157 deletions
+8
View File
@@ -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>
);
}
+104
View File
@@ -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>
);
}
+52 -11
View File
@@ -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>
+36 -33
View File
@@ -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 ?? []);
}
+38 -5
View File
@@ -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>
+3 -5
View File
@@ -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 -4
View File
@@ -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">
+20 -2
View File
@@ -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>
);