chore(init): 初始化解忧商行项目并完成 Phase 0 基座
This commit is contained in:
@@ -0,0 +1,444 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Card,
|
||||
Table,
|
||||
Typography,
|
||||
Space,
|
||||
Select,
|
||||
Tag,
|
||||
DatePicker,
|
||||
Button,
|
||||
message,
|
||||
Modal,
|
||||
Alert,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Statistic,
|
||||
Row,
|
||||
Col,
|
||||
Empty,
|
||||
} from 'antd'
|
||||
import {
|
||||
DollarCircleOutlined,
|
||||
FileTextOutlined,
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
createSettlementBatch,
|
||||
formatMoney,
|
||||
listSettlementBatches,
|
||||
listSettlementItems,
|
||||
listSites,
|
||||
settleBatch,
|
||||
cancelSettlementBatch,
|
||||
getMe,
|
||||
type SettlementBatch,
|
||||
type SettlementItem,
|
||||
type Site,
|
||||
} from '../api'
|
||||
import { useMember } from '../MemberContext'
|
||||
import type { Dayjs } from 'dayjs'
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
settled: { label: '已释放入账', color: 'success' },
|
||||
draft: { label: '待释放入账', color: 'warning' },
|
||||
pending: { label: '待释放入账', color: 'warning' },
|
||||
processing: { label: '结算处理中', color: 'processing' },
|
||||
cancelled: { label: '已作废', color: 'default' },
|
||||
}
|
||||
|
||||
const PARTY_ROLE_LABELS: Record<string, string> = {
|
||||
seller: '渠道代销分润 (40%)',
|
||||
distributor_sell: '渠道代销分润 (40%)',
|
||||
distributor_b: '渠道代销分润 (40%)',
|
||||
receiver: '号源出租分润 (35%)',
|
||||
distributor_receive: '号源出租分润 (35%)',
|
||||
distributor_a: '号源出租分润 (35%)',
|
||||
two_party_distributor: '分销商分润',
|
||||
platform: '平台服务费',
|
||||
}
|
||||
|
||||
export default function Settlements() {
|
||||
const member = useMember()
|
||||
const [sites, setSites] = useState<Site[]>([])
|
||||
const [siteID, setSiteID] = useState<number | undefined>(member?.site_id)
|
||||
const [batches, setBatches] = useState<SettlementBatch[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [range, setRange] = useState<[Dayjs | null, Dayjs | null] | null>(null)
|
||||
const [itemsFor, setItemsFor] = useState<SettlementBatch | null>(null)
|
||||
const [items, setItems] = useState<SettlementItem[]>([])
|
||||
const [itemsLoading, setItemsLoading] = useState(false)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const load = useCallback(async (id: number) => {
|
||||
setLoading(true)
|
||||
try {
|
||||
setBatches(await listSettlementBatches(id))
|
||||
} catch {
|
||||
message.error('结算账单加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
let currentMember = member
|
||||
if (!currentMember) {
|
||||
try {
|
||||
currentMember = await getMe()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const activeSiteID = currentMember?.site_id
|
||||
if (activeSiteID) {
|
||||
setSiteID(activeSiteID)
|
||||
void load(activeSiteID)
|
||||
}
|
||||
|
||||
if (currentMember?.is_superadmin) {
|
||||
try {
|
||||
const rows = await listSites()
|
||||
setSites(rows)
|
||||
if (!activeSiteID && rows.length > 0) {
|
||||
setSiteID(rows[0].id)
|
||||
void load(rows[0].id)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
void init()
|
||||
}, [member, load])
|
||||
|
||||
async function handleCreate() {
|
||||
if (!siteID || !range?.[0] || !range?.[1]) {
|
||||
message.warning('请选择站点和结算周期')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const batch = await createSettlementBatch(siteID, range[0].toISOString(), range[1].toISOString())
|
||||
message.success(`结算批次 ${batch.batch_no} 已创建,合计金额 ${formatMoney(batch.total_amount_cent)}`)
|
||||
void load(siteID)
|
||||
} catch (error: unknown) {
|
||||
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
|
||||
message.error(respMessage ?? '批次创建失败')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSettle(batch: SettlementBatch) {
|
||||
Modal.confirm({
|
||||
title: '立即释放入账?',
|
||||
content: `即将把批次【${batch.batch_no}】的 ${formatMoney(batch.total_amount_cent)} 转入商户可提现余额,人工批次会绕过冷却期。`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await settleBatch(batch.id)
|
||||
message.success(`批次 ${batch.batch_no} 已释放入账`)
|
||||
if (siteID) void load(siteID)
|
||||
} catch (error: unknown) {
|
||||
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
|
||||
message.error(respMessage ?? '释放失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCancel(batch: SettlementBatch) {
|
||||
Modal.confirm({
|
||||
title: '作废未入账批次?',
|
||||
content: `作废【${batch.batch_no}】后,其中订单可重新进入自动释放或新的人工批次。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
try {
|
||||
await cancelSettlementBatch(batch.id)
|
||||
message.success(`批次 ${batch.batch_no} 已作废`)
|
||||
if (siteID) void load(siteID)
|
||||
} catch (error: unknown) {
|
||||
const respMessage = (error as { response?: { data?: { message?: string } } })?.response?.data?.message
|
||||
message.error(respMessage ?? '作废失败')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function showItems(batch: SettlementBatch) {
|
||||
setItemsFor(batch)
|
||||
setItemsLoading(true)
|
||||
try {
|
||||
setItems(await listSettlementItems(batch.id))
|
||||
} catch {
|
||||
message.error('结算对账明细加载失败')
|
||||
} finally {
|
||||
setItemsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isSuperadmin = member?.is_superadmin
|
||||
|
||||
return (
|
||||
<Space direction="vertical" className="page" style={{ width: '100%' }} size="middle">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<div>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
||||
{isSuperadmin ? '结算管理与批次对账' : '结算对账单与财务对账'}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">
|
||||
{isSuperadmin
|
||||
? '冷却期满后系统按站点按天自动释放入账;也可手工创建批次立即入账,或作废未入账草稿以免挡住自动释放'
|
||||
: '订单完成冷却期结束后系统自动释放入账,此处查看对账批次与入账明细'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isSuperadmin && sites.length > 1 && (
|
||||
<Select
|
||||
placeholder="切换分销网站"
|
||||
style={{ width: 220 }}
|
||||
value={siteID}
|
||||
options={sites.map((site) => ({ label: `${site.site_name} (${site.site_code})`, value: site.id }))}
|
||||
onChange={(value) => {
|
||||
setSiteID(value)
|
||||
void load(value)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="自动结算与对账说明"
|
||||
description="订单完成后进入站点结算冷却期(由主站后台按商户配置,默认 7 天,0 天为立即释放),期满系统自动生成结算批次并将收益释放到「资金与提现」的可提现余额;退款或维权中的订单会暂停释放。"
|
||||
/>
|
||||
|
||||
{/* Superadmin batch creation controls */}
|
||||
{isSuperadmin && (
|
||||
<Card title="管理员手工出账(立即结算)" size="small" style={{ background: '#f8fafc' }}>
|
||||
<Space wrap>
|
||||
<Typography.Text strong>选择结算周期:</Typography.Text>
|
||||
<DatePicker.RangePicker showTime onChange={(value) => setRange(value)} />
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate} loading={creating}>
|
||||
生成结算批次并结算
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title={isSuperadmin ? '结算批次列表' : '历史结算对账单'}>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
dataSource={batches}
|
||||
pagination={false}
|
||||
scroll={{ x: 1000 }}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="暂无结算批次。订单完成冷却期结束后系统会自动出具对账单并入账。"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '账单批次号',
|
||||
dataIndex: 'batch_no',
|
||||
width: 220,
|
||||
render: (v: string) => (
|
||||
<div style={{ whiteSpace: 'nowrap' }}>
|
||||
<Typography.Text
|
||||
strong
|
||||
copyable={{ text: v, tooltips: ['复制批次号', '已复制'] }}
|
||||
className="mono-code"
|
||||
style={{
|
||||
fontSize: 13,
|
||||
whiteSpace: 'nowrap',
|
||||
wordBreak: 'keep-all',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结算周期开始',
|
||||
dataIndex: 'period_start',
|
||||
width: 170,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontSize: 13, color: '#595959' }}>
|
||||
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结算周期截止',
|
||||
dataIndex: 'period_end',
|
||||
width: 170,
|
||||
render: (v: string) => (
|
||||
<span style={{ fontSize: 13, color: '#595959' }}>
|
||||
{v ? new Date(v).toLocaleString('zh-CN', { hour12: false }) : '-'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '账单合计收益',
|
||||
dataIndex: 'total_amount_cent',
|
||||
width: 160,
|
||||
render: (v: number) => (
|
||||
<Typography.Text strong style={{ color: '#389e0d', fontSize: 16 }}>
|
||||
+{formatMoney(v)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '结算状态',
|
||||
dataIndex: 'status',
|
||||
width: 130,
|
||||
render: (v: string) => {
|
||||
const conf = STATUS_LABELS[v] || { label: v, color: 'default' }
|
||||
return <Tag color={conf.color}>{conf.label}</Tag>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 240,
|
||||
render: (_: unknown, row: SettlementBatch) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => void showItems(row)}
|
||||
>
|
||||
查看明细
|
||||
</Button>
|
||||
{isSuperadmin && row.status !== 'settled' && row.status !== 'cancelled' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ color: '#52c41a' }}
|
||||
onClick={() => void handleSettle(row)}
|
||||
>
|
||||
立即释放入账
|
||||
</Button>
|
||||
)}
|
||||
{isSuperadmin && row.status !== 'settled' && row.status !== 'cancelled' && (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => void handleCancel(row)}
|
||||
>
|
||||
作废
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Itemized Settlement Details Modal */}
|
||||
<Modal
|
||||
open={itemsFor !== null}
|
||||
title={
|
||||
<Space>
|
||||
<DollarCircleOutlined style={{ color: '#52c41a' }} />
|
||||
<span>结算账单对账明细 · {itemsFor?.batch_no ?? ''}</span>
|
||||
</Space>
|
||||
}
|
||||
footer={null}
|
||||
width={680}
|
||||
onCancel={() => setItemsFor(null)}
|
||||
destroyOnClose
|
||||
>
|
||||
{itemsFor && (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<div
|
||||
style={{
|
||||
background: '#f8fafc',
|
||||
border: '1px solid #e2e8f0',
|
||||
borderRadius: 8,
|
||||
padding: '12px 16px',
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="账单结算总额"
|
||||
value={((itemsFor.total_amount_cent || 0) / 100).toFixed(2)}
|
||||
precision={2}
|
||||
prefix="+¥"
|
||||
valueStyle={{ color: '#389e0d', fontWeight: 'bold' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>结算状态</div>
|
||||
<Tag
|
||||
color={STATUS_LABELS[itemsFor.status]?.color || 'default'}
|
||||
style={{ fontSize: 13, padding: '2px 8px' }}
|
||||
>
|
||||
{STATUS_LABELS[itemsFor.status]?.label || itemsFor.status}
|
||||
</Tag>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<div style={{ color: '#8c8c8c', fontSize: 12, marginBottom: 4 }}>包含明细笔数</div>
|
||||
<strong style={{ fontSize: 18 }}>{items.length} 笔</strong>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={itemsLoading}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
scroll={{ x: 550 }}
|
||||
columns={[
|
||||
{
|
||||
title: '关联订单 ID',
|
||||
dataIndex: 'order_id',
|
||||
render: (v: number) => <Typography.Text strong className="mono-code">订单 #{v}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
title: '分润角色 / 收益类型',
|
||||
dataIndex: 'party_role',
|
||||
render: (v: string) => (
|
||||
<Tag color="blue">{PARTY_ROLE_LABELS[v] || v}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '分成到账金额',
|
||||
dataIndex: 'amount_cent',
|
||||
render: (v: number) => (
|
||||
<Typography.Text strong style={{ color: '#389e0d', fontSize: 15 }}>
|
||||
+{formatMoney(v)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '入账状态',
|
||||
dataIndex: 'status',
|
||||
render: (v: string) => (
|
||||
<Tag color={v === 'settled' ? 'success' : 'default'}>
|
||||
{v === 'settled' ? '已入账钱包' : v || '待打款'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
</Space>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user