发布商品,商品图片使用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>
);
}