89fd333702
Add client-logging SDK, expanded event taxonomy, API observability, admin domain events UI, and move SENTRY_DSN to HQ system settings with @sentry/node bootstrap after config preload. Co-authored-by: Cursor <cursoragent@cursor.com>
237 lines
8.4 KiB
TypeScript
237 lines
8.4 KiB
TypeScript
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 { useStorePageView } from '../lib/usePageView';
|
||
|
||
type StatusFilter = 'all' | StoreWithdrawStatus;
|
||
|
||
function formatMoney(n: number) {
|
||
return n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||
}
|
||
|
||
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]);
|
||
|
||
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.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?.hasPendingRequest ? ' · 已有待审核申请' : ''}
|
||
{!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(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>
|
||
);
|
||
}
|