Files
dukang/apps/h5-shop/src/pages/WithdrawPage.tsx
T
jacy fc2e5b65de
CI / verify (pull_request) Has been cancelled
v3.5.3版本更新1
2026-08-20 18:54:15 +08:00

238 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import { formatMoney, toMoneyNumber } from '../lib/money';
import { useStorePageView } from '../lib/usePageView';
import { useRedeemSuccessListener } from '../lib/useRedeemSuccessBus';
type StatusFilter = 'all' | StoreWithdrawStatus;
export default function WithdrawPage() {
useStorePageView('store_withdraw_view');
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]);
// v3.5.1 #2:核销成功后即时刷新可提余额与提现按钮状态
useRedeemSuccessListener(() => {
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.availableAmount > 0 &&
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(toMoneyNumber(summary?.availableAmount))}
</p>
</div>
<div>
<p className="shop-records-summary-label">今日剩余额度</p>
<p className="shop-records-summary-value">
¥ {formatMoney(toMoneyNumber(summary?.remainingDailyLimit))}
</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?.hasBankAccount ? ' · 请先完善收款账户' : ''}
</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(toMoneyNumber(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>
);
}