后端显示门店账单

This commit is contained in:
2026-07-07 00:04:07 +08:00
parent 7ca9fc8a35
commit 1fcafac2a7
12 changed files with 233 additions and 126 deletions
+3 -2
View File
@@ -21,7 +21,7 @@ import StoreMediaPage from './pages/StoreMediaPage';
import ProductsPage from './pages/ProductsPage';
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage';
import StorePayoutsPage from './pages/StorePayoutsPage';
import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import TicketsPage from './pages/TicketsPage';
import UserLogsPage from './pages/UserLogsPage';
@@ -60,7 +60,8 @@ export default function App() {
<Route path="/benefit/ledgers" element={<BenefitLedgersPage />} />
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
<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="/tickets" element={<TicketsPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
+1 -1
View File
@@ -40,6 +40,7 @@ const MENU_ITEMS: MenuProps['items'] = [
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-bills', label: '门店账单' },
{ key: '/store-media', label: '门店资源' },
],
},
@@ -62,7 +63,6 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/benefit/ledgers', label: '流水' },
{ key: '/redeem-records', label: '核销记录' },
{ key: '/redeem/debug', label: '核销调试' },
{ key: '/store-payouts', label: '门店打款' },
],
},
{ key: '/partner-bills', icon: <TeamOutlined />, label: '合伙人结算' },
+205
View File
@@ -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>
);
}
+1 -2
View File
@@ -9,7 +9,6 @@ function maskPhone(phone: string) {
return `${phone.slice(0, 3)} **** ${phone.slice(-4)}`;
}
const DEV_DEFAULT_PHONE = import.meta.env.DEV ? '13900000001' : '';
const DEV_DEFAULT_CODE = import.meta.env.DEV ? '123456' : '';
export default function LoginPage() {
@@ -18,7 +17,7 @@ export default function LoginPage() {
const [params] = useSearchParams();
const quick = params.get('quick') === '1';
const savedProfile = getStoreProfile();
const [phone, setPhone] = useState(getLastPhone() || DEV_DEFAULT_PHONE);
const [phone, setPhone] = useState(getLastPhone());
const [code, setCode] = useState(DEV_DEFAULT_CODE);
const [agreed, setAgreed] = useState(false);
const [loading, setLoading] = useState(false);