feat(settlement): 门店未出账手动提现与总部审核(OPT-010)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,7 @@ import ProductsPage from './pages/ProductsPage';
|
||||
import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
|
||||
import ResourcesPage from './pages/ResourcesPage';
|
||||
import StoreBillsPage from './pages/StoreBillsPage';
|
||||
import StoreWithdrawalsPage from './pages/StoreWithdrawalsPage';
|
||||
import PartnerBillsPage from './pages/PartnerBillsPage';
|
||||
import WineryBillsPage from './pages/WineryBillsPage';
|
||||
import LogisticsBillsPage from './pages/LogisticsBillsPage';
|
||||
@@ -96,11 +97,13 @@ export default function App() {
|
||||
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
|
||||
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
|
||||
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
|
||||
<Route path="/finance/store-withdrawals" element={<StoreWithdrawalsPage />} />
|
||||
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
|
||||
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
|
||||
<Route path="/finance/logistics-bills" element={<LogisticsBillsPage />} />
|
||||
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
|
||||
<Route path="/store-withdrawals" element={<Navigate to="/finance/store-withdrawals" replace />} />
|
||||
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
|
||||
<Route path="/tickets" element={<TicketsPage />} />
|
||||
<Route path="/tickets/support" element={<SupportTicketsPage />} />
|
||||
|
||||
@@ -74,6 +74,7 @@ const MENU_ITEMS: MenuProps['items'] = [
|
||||
label: '财务',
|
||||
children: [
|
||||
{ key: '/finance/store-bills', label: '门店账单' },
|
||||
{ key: '/finance/store-withdrawals', label: '门店提现审' },
|
||||
{ key: '/finance/partner-bills', label: '合伙人账单' },
|
||||
{ key: '/finance/winery-bills', label: '酒厂账单' },
|
||||
{ key: '/finance/logistics-bills', label: '物流对账' },
|
||||
@@ -157,6 +158,7 @@ function menuAllowed(key: string, permissionKeys: string[]): boolean {
|
||||
'/fulfillment-providers': 'partners',
|
||||
'finance-group': 'finance',
|
||||
'/finance/store-bills': 'finance',
|
||||
'/finance/store-withdrawals': 'finance',
|
||||
'/finance/partner-bills': 'finance',
|
||||
'/finance/winery-bills': 'finance',
|
||||
'/finance/logistics-bills': 'finance',
|
||||
|
||||
@@ -85,6 +85,8 @@ export type DashboardStats = {
|
||||
pendingBills?: number;
|
||||
pendingPartnerDraftBills?: number;
|
||||
openTickets?: number;
|
||||
pendingStoreWithdrawals?: number;
|
||||
overdueStoreWithdrawals?: number;
|
||||
ordersByStatus: Array<{ status: string; count: number }>;
|
||||
};
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export type StoreCreateForm = {
|
||||
settlementRate?: number;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
withdrawWhitelistEnabled?: boolean;
|
||||
};
|
||||
|
||||
const PHONE_RE = /^1\d{10}$/;
|
||||
|
||||
@@ -717,6 +717,26 @@ export default function DashboardPage() {
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card
|
||||
loading={loading}
|
||||
title="待审门店提现"
|
||||
extra={<Link to="/finance/store-withdrawals">去处理</Link>}
|
||||
>
|
||||
<Statistic
|
||||
value={stats?.pendingStoreWithdrawals ?? 0}
|
||||
suffix="笔"
|
||||
valueStyle={{
|
||||
color: (stats?.overdueStoreWithdrawals ?? 0) > 0 ? '#cf1322' : (stats?.pendingStoreWithdrawals ?? 0) > 0 ? '#fa8c16' : undefined,
|
||||
}}
|
||||
/>
|
||||
<Typography.Text type="secondary">
|
||||
{(stats?.overdueStoreWithdrawals ?? 0) > 0
|
||||
? `超时未审 ${stats?.overdueStoreWithdrawals} 笔(FIN-003)`
|
||||
: '工作日 T+0 审完'}
|
||||
</Typography.Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card loading={loading} title="待处理工单">
|
||||
<Statistic value={stats?.openTickets ?? 0} suffix="个" />
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import { STORE_WITHDRAW_STATUS_LABELS, type StoreWithdrawStatus } from '@dukang/shared-types';
|
||||
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;
|
||||
withdrawNo: string;
|
||||
amount: number;
|
||||
payoutCount: number;
|
||||
status: StoreWithdrawStatus;
|
||||
appliedAt: string;
|
||||
reviewedAt?: string | null;
|
||||
paidAt?: string | null;
|
||||
paymentRef?: string | null;
|
||||
rejectReason?: string | null;
|
||||
overdue?: boolean;
|
||||
store?: { id: string; name: string; cityName: string; phone?: string };
|
||||
};
|
||||
|
||||
type StoreOption = { id: string; name: string; phone: string };
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
PENDING_REVIEW: 'orange',
|
||||
REJECTED: 'red',
|
||||
PAID: 'green',
|
||||
};
|
||||
|
||||
export default function StoreWithdrawalsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [filters, setFilters] = useState<Record<string, string>>({ status: 'PENDING_REVIEW' });
|
||||
const [stores, setStores] = useState<StoreOption[]>([]);
|
||||
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
|
||||
'/admin/store-withdrawals',
|
||||
() => {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.status) qs.set('status', filters.status);
|
||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
|
||||
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
|
||||
return qs;
|
||||
},
|
||||
[filters],
|
||||
);
|
||||
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [overdueSummary, setOverdueSummary] = useState<{
|
||||
pendingCount: number;
|
||||
overdueCount: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
|
||||
.then((res) => setStores(res.items))
|
||||
.catch(() => {});
|
||||
void request<{ pendingCount: number; overdueCount: number }>(
|
||||
'/admin/store-withdrawals/overdue-summary',
|
||||
)
|
||||
.then(setOverdueSummary)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function openDetail(id: string) {
|
||||
const d = await request<Record<string, unknown>>(`/admin/store-withdrawals/${id}`);
|
||||
setDetail(d);
|
||||
setDrawerOpen(true);
|
||||
}
|
||||
|
||||
function approve(id: string) {
|
||||
let paymentRef = '';
|
||||
Modal.confirm({
|
||||
title: '审核通过并标记已结算?',
|
||||
content: (
|
||||
<Input
|
||||
placeholder="打款凭证号(可选)"
|
||||
onChange={(e) => {
|
||||
paymentRef = e.target.value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
okText: '通过并结算',
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
await request(`/admin/store-withdrawals/${id}/approve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ paymentRef: paymentRef.trim() || undefined }),
|
||||
});
|
||||
message.success('已通过并标记已结算');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
void request<{ pendingCount: number; overdueCount: number }>(
|
||||
'/admin/store-withdrawals/overdue-summary',
|
||||
)
|
||||
.then(setOverdueSummary)
|
||||
.catch(() => {});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function reject(id: string) {
|
||||
let reason = '';
|
||||
Modal.confirm({
|
||||
title: '驳回提现申请?',
|
||||
content: (
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
placeholder="请填写驳回理由"
|
||||
onChange={(e) => {
|
||||
reason = e.target.value;
|
||||
}}
|
||||
/>
|
||||
),
|
||||
okText: '确认驳回',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
if (!reason.trim()) {
|
||||
message.error('请填写驳回理由');
|
||||
throw new Error('reason required');
|
||||
}
|
||||
await request(`/admin/store-withdrawals/${id}/reject`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ reason: reason.trim() }),
|
||||
});
|
||||
message.success('已驳回');
|
||||
setDrawerOpen(false);
|
||||
reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const columns: ColumnsType<Row> = [
|
||||
{
|
||||
title: '提现单号',
|
||||
dataIndex: 'withdrawNo',
|
||||
width: 180,
|
||||
render: (v, row) => (
|
||||
<Space>
|
||||
<Typography.Link onClick={() => void openDetail(row.id)}>{v}</Typography.Link>
|
||||
{row.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '门店',
|
||||
dataIndex: ['store', 'name'],
|
||||
width: 160,
|
||||
render: (_, row) => (
|
||||
<div>
|
||||
<div>{row.store?.name || '—'}</div>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{row.store?.cityName} {row.store?.phone}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '金额',
|
||||
dataIndex: 'amount',
|
||||
width: 110,
|
||||
render: (v) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{ title: '明细笔数', dataIndex: 'payoutCount', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: StoreWithdrawStatus) => (
|
||||
<Tag color={STATUS_COLORS[v]}>{STORE_WITHDRAW_STATUS_LABELS[v] ?? v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '申请时间',
|
||||
dataIndex: 'appliedAt',
|
||||
width: 170,
|
||||
render: (v) => fmtTime(v),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 180,
|
||||
fixed: 'right',
|
||||
render: (_, row) => (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => void openDetail(row.id)}>
|
||||
详情
|
||||
</Button>
|
||||
{row.status === 'PENDING_REVIEW' ? (
|
||||
<>
|
||||
<Button size="small" type="primary" onClick={() => approve(row.id)}>
|
||||
通过
|
||||
</Button>
|
||||
<Button size="small" danger onClick={() => reject(row.id)}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const detailItems = (detail?.items as Array<Record<string, unknown>> | undefined) ?? [];
|
||||
const storeAccount = detail?.storeAccount as
|
||||
| {
|
||||
name?: string;
|
||||
phone?: string;
|
||||
bankAccountName?: string;
|
||||
bankAccountNo?: string;
|
||||
bankBranch?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
门店提现审
|
||||
</Typography.Title>
|
||||
{overdueSummary ? (
|
||||
<Typography.Paragraph type="secondary">
|
||||
待审 {overdueSummary.pendingCount} 笔
|
||||
{overdueSummary.overdueCount > 0 ? (
|
||||
<Typography.Text type="danger">
|
||||
{' '}
|
||||
· 超时未审 {overdueSummary.overdueCount} 笔(FIN-003)
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16, gap: 8 }}
|
||||
initialValues={{ status: 'PENDING_REVIEW' }}
|
||||
onFinish={(v) => {
|
||||
const range = v.range as [Dayjs, Dayjs] | undefined;
|
||||
setFilters({
|
||||
status: v.status || '',
|
||||
storeId: v.storeId || '',
|
||||
dateFrom: range?.[0]?.format('YYYY-MM-DD') || '',
|
||||
dateTo: range?.[1]?.format('YYYY-MM-DD') || '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
options={[
|
||||
{ value: 'PENDING_REVIEW', label: '待审核' },
|
||||
{ value: 'PAID', label: '已结算' },
|
||||
{ value: 'REJECTED', label: '已驳回' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="storeId" label="门店">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 220 }}
|
||||
options={stores.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name} (${s.phone})`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="申请日">
|
||||
<DatePicker.RangePicker />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
查询
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={data?.items ?? []}
|
||||
scroll={{ x: 1100 }}
|
||||
rowClassName={(row) => (row.overdue ? 'ant-table-row-selected' : '')}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: data?.total ?? 0,
|
||||
showSizeChanger: true,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
title="提现详情"
|
||||
width={640}
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
extra={
|
||||
detail?.status === 'PENDING_REVIEW' ? (
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => approve(String(detail.id))}>
|
||||
通过
|
||||
</Button>
|
||||
<Button danger onClick={() => reject(String(detail.id))}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{detail ? (
|
||||
<>
|
||||
<Descriptions column={1} size="small" bordered>
|
||||
<Descriptions.Item label="提现单号">{String(detail.withdrawNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={STATUS_COLORS[String(detail.status)]}>
|
||||
{STORE_WITHDRAW_STATUS_LABELS[detail.status as StoreWithdrawStatus] ??
|
||||
String(detail.status)}
|
||||
</Tag>
|
||||
{detail.overdue ? <Tag color="magenta">超时</Tag> : null}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="金额">¥{Number(detail.amount).toFixed(2)}</Descriptions.Item>
|
||||
<Descriptions.Item label="明细笔数">{String(detail.payoutCount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请时间">{fmtTime(String(detail.appliedAt))}</Descriptions.Item>
|
||||
{detail.rejectReason ? (
|
||||
<Descriptions.Item label="驳回理由">{String(detail.rejectReason)}</Descriptions.Item>
|
||||
) : null}
|
||||
{detail.paymentRef ? (
|
||||
<Descriptions.Item label="打款凭证">{String(detail.paymentRef)}</Descriptions.Item>
|
||||
) : null}
|
||||
<Descriptions.Item label="收款户名">
|
||||
{storeAccount?.bankAccountName || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收款账号">
|
||||
{storeAccount?.bankAccountNo || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开户行">{storeAccount?.bankBranch || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<Typography.Title level={5} style={{ marginTop: 24 }}>
|
||||
关联结算明细
|
||||
</Typography.Title>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(r) => String((r as { id?: string }).id)}
|
||||
pagination={false}
|
||||
dataSource={detailItems}
|
||||
columns={[
|
||||
{
|
||||
title: '核销单号',
|
||||
render: (_, r) => {
|
||||
const payout = (r as { storePayout?: { redeemRecord?: { redeemNo?: string } } })
|
||||
.storePayout;
|
||||
return payout?.redeemRecord?.redeemNo || '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '结算额',
|
||||
render: (_, r) => {
|
||||
const payout = (r as { storePayout?: { payoutAmount?: number } }).storePayout;
|
||||
return `¥${Number(payout?.payoutAmount ?? 0).toFixed(2)}`;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -373,6 +373,7 @@ type StoreRow = {
|
||||
coverUrl: string | null;
|
||||
createdAt: string;
|
||||
visibilityWhitelistEnabled?: boolean;
|
||||
withdrawWhitelistEnabled?: boolean;
|
||||
visibilityPhones?: string[];
|
||||
cityRef?: { name: string; code: string };
|
||||
partner?: { companyName: string };
|
||||
@@ -592,6 +593,7 @@ export default function StoresPage() {
|
||||
bankAccountNo: account?.bankAccountNo || undefined,
|
||||
bankBranch: account?.bankBranch || undefined,
|
||||
visibilityWhitelistEnabled: !!d.visibilityWhitelistEnabled,
|
||||
withdrawWhitelistEnabled: !!d.withdrawWhitelistEnabled,
|
||||
visibilityPhones: Array.isArray(d.visibilityPhones)
|
||||
? (d.visibilityPhones as string[])
|
||||
: [],
|
||||
@@ -635,6 +637,7 @@ export default function StoresPage() {
|
||||
bankAccountNo: v.bankAccountNo ?? null,
|
||||
bankBranch: v.bankBranch ?? null,
|
||||
visibilityWhitelistEnabled: !!v.visibilityWhitelistEnabled,
|
||||
withdrawWhitelistEnabled: !!v.withdrawWhitelistEnabled,
|
||||
visibilityPhones: ((v.visibilityPhones as string[] | undefined) ?? [])
|
||||
.map((p) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
@@ -811,6 +814,7 @@ export default function StoresPage() {
|
||||
bankBranch: values.bankBranch.trim(),
|
||||
settlementRate: Number(values.settlementRate ?? 60) / 100,
|
||||
visibilityWhitelistEnabled: !!values.visibilityWhitelistEnabled,
|
||||
withdrawWhitelistEnabled: !!values.withdrawWhitelistEnabled,
|
||||
visibilityPhones: (values.visibilityPhones ?? [])
|
||||
.map((p: string) => String(p || '').replace(/\D/g, '').trim())
|
||||
.filter(Boolean),
|
||||
@@ -1180,6 +1184,14 @@ export default function StoresPage() {
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="withdrawWhitelistEnabled"
|
||||
label="未出账提现白名单"
|
||||
valuePropName="checked"
|
||||
extra="FIN-001:开启后该门店可对未出账余额发起手动提现"
|
||||
>
|
||||
<Switch checkedChildren="允许" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankAccountName" label="结算户名">
|
||||
<Input placeholder="开户名" />
|
||||
</Form.Item>
|
||||
@@ -1426,6 +1438,14 @@ export default function StoresPage() {
|
||||
<Form.Item name="settlementRate" label="核销结算比例 %" initialValue={60} rules={[{ required: true, message: '请填写结算比例' }]}>
|
||||
<InputNumber min={0} max={100} precision={2} style={{ width: '100%' }} addonAfter="%" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="withdrawWhitelistEnabled"
|
||||
label="未出账提现白名单"
|
||||
valuePropName="checked"
|
||||
initialValue={false}
|
||||
>
|
||||
<Switch checkedChildren="允许" unCheckedChildren="关闭" />
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
|
||||
@@ -12,6 +12,7 @@ import RecordsPage from './pages/RecordsPage';
|
||||
import StatusPage from './pages/StatusPage';
|
||||
import MinePage from './pages/MinePage';
|
||||
import StaffPage from './pages/StaffPage';
|
||||
import WithdrawPage from './pages/WithdrawPage';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
@@ -22,6 +23,7 @@ export default function App() {
|
||||
<Route path="/legal/privacy-policy" element={<LegalPage docId="privacy-policy" />} />
|
||||
<Route path="/select-store" element={<SelectStorePage />} />
|
||||
<Route path="/staff" element={<StaffPage />} />
|
||||
<Route path="/withdraw" element={<WithdrawPage />} />
|
||||
<Route path="/redeem" element={<RedeemConfirmPage />} />
|
||||
<Route path="/redeem/phone" element={<PhoneRedeemPage />} />
|
||||
<Route path="/redeem/success" element={<RedeemSuccessPage />} />
|
||||
|
||||
@@ -141,6 +141,10 @@ export default function MinePage() {
|
||||
切换门店
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/withdraw')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>account_balance_wallet</span>
|
||||
结算提现
|
||||
</button>
|
||||
{profile?.isPrimary ? (
|
||||
<button type="button" className="shop-mine-action" onClick={() => navigate('/staff')}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 20 }}>group</span>
|
||||
|
||||
@@ -48,7 +48,8 @@ export default function RecordsPage() {
|
||||
return records.filter((r) => {
|
||||
if (!inRange(String(r.createdAt), range)) return false;
|
||||
if (statusFilter === 'all') return true;
|
||||
const isPaid = Boolean(r.paidAt);
|
||||
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||
const isPaid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||
if (statusFilter === 'paid') return isPaid;
|
||||
return !isPaid;
|
||||
});
|
||||
@@ -134,7 +135,9 @@ export default function RecordsPage() {
|
||||
{filtered.map((r) => {
|
||||
const amount = Number(r.amount || 0);
|
||||
const settle = Number(r.settleAmount || 0);
|
||||
const paid = Boolean(r.paidAt);
|
||||
const payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
||||
return (
|
||||
<article key={String(r.id)} className="shop-record-card">
|
||||
<div className="shop-record-card-top">
|
||||
@@ -162,7 +165,11 @@ export default function RecordsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="shop-record-footer">
|
||||
<p>{paid ? `打款时间: ${new Date(String(r.createdAt)).toLocaleDateString('zh-CN')}` : '预计打款: T+1工作日'}</p>
|
||||
<p>
|
||||
{paid
|
||||
? `打款时间: ${paidAt ? new Date(String(paidAt)).toLocaleDateString('zh-CN') : '—'}`
|
||||
: '预计打款: T+1工作日'}
|
||||
</p>
|
||||
{storeName && (
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>restaurant</span>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
STORE_WITHDRAW_STATUS_LABELS,
|
||||
type StoreWithdrawRequestDto,
|
||||
type StoreWithdrawStatus,
|
||||
type StoreWithdrawSummaryDto,
|
||||
} from '@dukang/shared-types';
|
||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||
import { request } from '../lib/api';
|
||||
|
||||
type StatusFilter = 'all' | StoreWithdrawStatus;
|
||||
|
||||
function formatMoney(n: number) {
|
||||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
export default function WithdrawPage() {
|
||||
const navigate = useNavigate();
|
||||
const [summary, setSummary] = useState<StoreWithdrawSummaryDto | null>(null);
|
||||
const [items, setItems] = useState<StoreWithdrawRequestDto[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [msg, setMsg] = useState('');
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [summaryRes, listRes] = await Promise.all([
|
||||
request<StoreWithdrawSummaryDto>('SHOP_H5', '/shop/withdraw/summary'),
|
||||
request<{ items: StoreWithdrawRequestDto[] }>('SHOP_H5', '/shop/withdraw/requests?pageSize=50'),
|
||||
]);
|
||||
setSummary(summaryRes);
|
||||
setItems(listRes.items || []);
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '加载失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (statusFilter === 'all') return items;
|
||||
return items.filter((r) => r.status === statusFilter);
|
||||
}, [items, statusFilter]);
|
||||
|
||||
async function applyWithdraw() {
|
||||
if (!summary || submitting) return;
|
||||
if (!summary.isPrimary) {
|
||||
setMsg('仅主账号可申请提现');
|
||||
return;
|
||||
}
|
||||
if (!(summary.availableAmount > 0)) {
|
||||
setMsg('暂无可提未出账余额');
|
||||
return;
|
||||
}
|
||||
const ok = window.confirm(
|
||||
`确认申请提现 ¥${formatMoney(summary.availableAmount)}?\n审核通过后将打款至入驻收款账户。`,
|
||||
);
|
||||
if (!ok) return;
|
||||
setSubmitting(true);
|
||||
setMsg('');
|
||||
try {
|
||||
await request('SHOP_H5', '/shop/withdraw', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
setMsg('提现申请已提交,请等待总部审核');
|
||||
await load();
|
||||
} catch (e) {
|
||||
setMsg(e instanceof Error ? e.message : '提现申请失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canApply =
|
||||
!!summary?.isPrimary &&
|
||||
!!summary.whitelistEnabled &&
|
||||
summary.availableAmount > 0 &&
|
||||
!summary.hasPendingRequest &&
|
||||
summary.hasBankAccount &&
|
||||
!submitting;
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={load} className="shop-records-page shop-withdraw-page">
|
||||
<header className="shop-records-header" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="shop-withdraw-back"
|
||||
onClick={() => navigate(-1)}
|
||||
aria-label="返回"
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ fontSize: 22 }}>
|
||||
arrow_back
|
||||
</span>
|
||||
</button>
|
||||
<h1 className="app-page-title" style={{ margin: 0 }}>
|
||||
结算提现
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="shop-records-main">
|
||||
<section className="shop-records-summary">
|
||||
<div className="shop-records-summary-grid">
|
||||
<div>
|
||||
<p className="shop-records-summary-label">可提未出账余额</p>
|
||||
<p className="shop-records-summary-value">
|
||||
¥ {formatMoney(summary?.availableAmount ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-records-summary-label">今日剩余额度</p>
|
||||
<p className="shop-records-summary-value">
|
||||
¥ {formatMoney(summary?.remainingDailyLimit ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="shop-records-summary-note">
|
||||
<span
|
||||
className="material-symbols-outlined shop-fill-icon"
|
||||
style={{ fontSize: 16, color: 'var(--color-success-green)' }}
|
||||
>
|
||||
info
|
||||
</span>
|
||||
单日上限 ¥{formatMoney(summary?.dailyLimit ?? 5000)}
|
||||
{summary && !summary.whitelistEnabled ? ' · 未开通未出账提现白名单' : ''}
|
||||
{summary?.hasPendingRequest ? ' · 已有待审核申请' : ''}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{summary && !summary.isPrimary ? (
|
||||
<p className="shop-records-empty">仅主账号可申请提现,店员可查看记录</p>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="shop-withdraw-btn"
|
||||
disabled={!canApply}
|
||||
onClick={() => void applyWithdraw()}
|
||||
>
|
||||
{submitting ? '提交中…' : '申请提现'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{msg ? <p className="shop-withdraw-msg">{msg}</p> : null}
|
||||
|
||||
<nav className="shop-records-filters" style={{ marginTop: 16 }}>
|
||||
<div className="shop-records-status-chips">
|
||||
{(
|
||||
[
|
||||
['all', '全部'],
|
||||
['PENDING_REVIEW', '待审核'],
|
||||
['PAID', '已结算'],
|
||||
['REJECTED', '已驳回'],
|
||||
] as const
|
||||
).map(([key, label]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
|
||||
onClick={() => setStatusFilter(key)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="shop-records-list-head">
|
||||
<h3 className="shop-records-list-title">提现记录</h3>
|
||||
<span className="shop-records-list-count">共 {filtered.length} 笔</span>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="shop-records-empty">暂无提现记录</p>
|
||||
) : (
|
||||
<div className="shop-records-list">
|
||||
{filtered.map((r) => {
|
||||
const status = r.status as StoreWithdrawStatus;
|
||||
const badgeClass =
|
||||
status === 'PAID' ? 'paid' : status === 'REJECTED' ? 'rejected' : 'pending';
|
||||
return (
|
||||
<article key={r.id} className="shop-record-card">
|
||||
<div className="shop-record-card-top">
|
||||
<div>
|
||||
<div className="shop-record-order">
|
||||
<span className="shop-record-time" style={{ margin: 0 }}>
|
||||
单号
|
||||
</span>
|
||||
<span>{r.withdrawNo}</span>
|
||||
</div>
|
||||
<p className="shop-record-time">
|
||||
申请时间:{' '}
|
||||
{new Date(r.appliedAt)
|
||||
.toLocaleString('zh-CN', { hour12: false })
|
||||
.slice(0, 16)}
|
||||
</p>
|
||||
</div>
|
||||
<span className={`shop-record-badge ${badgeClass}`}>
|
||||
{STORE_WITHDRAW_STATUS_LABELS[status] ?? status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="shop-record-amounts">
|
||||
<div>
|
||||
<p className="shop-record-amount-label">提现金额</p>
|
||||
<p className="shop-record-amount-value red">¥{formatMoney(Number(r.amount))}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="shop-record-amount-label">明细笔数</p>
|
||||
<p className="shop-record-amount-value">{r.payoutCount} 笔</p>
|
||||
</div>
|
||||
</div>
|
||||
{status === 'REJECTED' && r.rejectReason ? (
|
||||
<div className="shop-record-footer">
|
||||
<p>驳回原因: {r.rejectReason}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{status === 'PAID' && r.paidAt ? (
|
||||
<div className="shop-record-footer">
|
||||
<p>
|
||||
结算时间:{' '}
|
||||
{new Date(r.paidAt).toLocaleString('zh-CN', { hour12: false }).slice(0, 16)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
@@ -1714,6 +1714,46 @@
|
||||
color: var(--color-aged-amber);
|
||||
}
|
||||
|
||||
.shop-record-badge.rejected {
|
||||
background: rgba(180, 35, 24, 0.1);
|
||||
color: var(--color-error-red, #b42318);
|
||||
}
|
||||
|
||||
.shop-withdraw-back {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 4px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-withdraw-btn {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
height: 44px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
background: var(--color-primary, #8b1a1a);
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.shop-withdraw-btn:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.shop-withdraw-msg {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--color-aged-amber);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.shop-record-amounts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
Reference in New Issue
Block a user