feat(redeem): 核销记录标记扫码/手机号并支持方式统计

落库 RedeemChannel,门店记录页增加统计按钮,HQ 可按方式筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 16:21:38 +08:00
parent 54fca99208
commit dc30932012
10 changed files with 531 additions and 48 deletions
+3
View File
@@ -190,6 +190,9 @@ export default function HomePage() {
<div className="shop-home-stat">
<p className="shop-home-stat-label"></p>
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</p>
<p className="shop-home-stat-sub">
{Number(dash?.todayScanCount || 0)} · {Number(dash?.todayPhoneCount || 0)}
</p>
</div>
<div className="shop-home-stat">
<p className="shop-home-stat-label"></p>
+162 -27
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
import { request } from '../lib/api';
type RangeKey = 'today' | '7d' | '30d';
@@ -23,27 +24,53 @@ function inRange(dateStr: string, range: RangeKey) {
return d >= start;
}
function channelLabel(channel: unknown): string {
const key = channel === 'PHONE' ? 'PHONE' : 'SCAN';
return REDEEM_CHANNEL_LABELS[key];
}
export default function RecordsPage() {
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
const [range, setRange] = useState<RangeKey>('today');
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [storeName, setStoreName] = useState('');
const [statsOpen, setStatsOpen] = useState(false);
const [statsLoading, setStatsLoading] = useState(false);
const [stats, setStats] = useState<RedeemStatsDto | null>(null);
const loadRecords = useCallback(() => {
return Promise.all([
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
setRecords(d.list || []);
}),
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records?pageSize=200').then(
(d) => {
setRecords(d.list || []);
},
),
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
.then((s) => setStoreName(String(s.name || '')))
.catch(() => {}),
]);
}, []);
const loadStats = useCallback(async (r: RangeKey) => {
setStatsLoading(true);
try {
const data = await request<RedeemStatsDto>('SHOP_H5', `/shop/redeem/stats?range=${r}`);
setStats(data);
} catch {
setStats(null);
} finally {
setStatsLoading(false);
}
}, []);
useEffect(() => {
void loadRecords();
}, [loadRecords]);
useEffect(() => {
if (statsOpen) void loadStats(range);
}, [statsOpen, range, loadStats]);
const filtered = useMemo(() => {
return records.filter((r) => {
if (!inRange(String(r.createdAt), range)) return false;
@@ -71,11 +98,13 @@ export default function RecordsPage() {
<div className="shop-records-main">
<nav className="shop-records-filters">
<div className="shop-records-range-tabs">
{([
['today', '今日'],
['7d', '近7日'],
['30d', '近30日'],
] as const).map(([key, label]) => (
{(
[
['today', '日'],
['7d', '近7日'],
['30d', '近30日'],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
@@ -86,21 +115,35 @@ export default function RecordsPage() {
</button>
))}
</div>
<div className="shop-records-status-chips">
{([
['all', '全部'],
['pending', '待打款'],
['paid', '已打款'],
] as const).map(([key, label]) => (
<button
key={key}
type="button"
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
onClick={() => setStatusFilter(key)}
>
{label}
</button>
))}
<div className="shop-records-status-row">
<div className="shop-records-status-chips">
{(
[
['all', '全部'],
['pending', '待打款'],
['paid', '已打款'],
] as const
).map(([key, label]) => (
<button
key={key}
type="button"
className={`shop-records-chip${statusFilter === key ? ' active' : ''}`}
onClick={() => setStatusFilter(key)}
>
{label}
</button>
))}
</div>
<button
type="button"
className="shop-records-stats-btn"
onClick={() => setStatsOpen(true)}
>
<span className="material-symbols-outlined" style={{ fontSize: 18 }}>
bar_chart
</span>
</button>
</div>
</nav>
@@ -116,7 +159,10 @@ export default function RecordsPage() {
</div>
</div>
<p className="shop-records-summary-note">
<span className="material-symbols-outlined shop-fill-icon" style={{ fontSize: 16, color: 'var(--color-success-green)' }}>
<span
className="material-symbols-outlined shop-fill-icon"
style={{ fontSize: 16, color: 'var(--color-success-green)' }}
>
check_circle
</span>
: {summary.rate}% ({summary.rate / 10})
@@ -138,16 +184,29 @@ export default function RecordsPage() {
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);
const channel = (r.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel;
return (
<article key={String(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 className="shop-record-time" style={{ margin: 0 }}>
</span>
<span>{r.redeemNo ? String(r.redeemNo) : '—'}</span>
</div>
<p className="shop-record-time">
: {new Date(String(r.createdAt)).toLocaleString('zh-CN', { hour12: false }).slice(0, 16)}
:{' '}
{new Date(String(r.createdAt))
.toLocaleString('zh-CN', { hour12: false })
.slice(0, 16)}
</p>
<p className="shop-record-channel">
<span
className={`shop-record-channel-tag${channel === 'PHONE' ? ' phone' : ''}`}
>
{channelLabel(channel)}
</span>
</p>
</div>
<span className={`shop-record-badge ${paid ? 'paid' : 'pending'}`}>
@@ -172,7 +231,9 @@ export default function RecordsPage() {
</p>
{storeName && (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>restaurant</span>
<span className="material-symbols-outlined" style={{ fontSize: 12 }}>
restaurant
</span>
{storeName}
</span>
)}
@@ -190,6 +251,80 @@ export default function RecordsPage() {
</div>
)}
</div>
{statsOpen && (
<div className="shop-stats-overlay" role="dialog" aria-modal="true" aria-label="核销方式统计">
<button
type="button"
className="shop-stats-backdrop"
aria-label="关闭"
onClick={() => setStatsOpen(false)}
/>
<div className="shop-stats-sheet">
<div className="shop-stats-sheet-head">
<h2></h2>
<button type="button" className="shop-stats-close" onClick={() => setStatsOpen(false)}>
<span className="material-symbols-outlined">close</span>
</button>
</div>
<p className="shop-stats-range-hint">
{range === 'today' ? '今日' : range === '7d' ? '近7日' : '近30日'}
</p>
{statsLoading ? (
<p className="shop-records-empty"></p>
) : !stats ? (
<p className="shop-records-empty"></p>
) : (
<>
<div className="shop-stats-total">
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-stats-total-value">{stats.totalCount}</p>
</div>
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-stats-total-value">¥{formatMoney(stats.totalAmount)}</p>
</div>
<div>
<p className="shop-records-summary-label"></p>
<p className="shop-stats-total-value">¥{formatMoney(stats.totalSettleAmount)}</p>
</div>
</div>
<div className="shop-stats-channel-list">
{stats.byChannel.map((b) => (
<div key={b.channel} className="shop-stats-channel-card">
<div className="shop-stats-channel-title">
<span
className={`shop-record-channel-tag${b.channel === 'PHONE' ? ' phone' : ''}`}
>
{REDEEM_CHANNEL_LABELS[b.channel]}
</span>
<strong>{b.count} </strong>
</div>
<div className="shop-stats-channel-row">
<span></span>
<span>¥{formatMoney(b.amount)}</span>
</div>
<div className="shop-stats-channel-row">
<span></span>
<span>¥{formatMoney(b.settleAmount)}</span>
</div>
<div className="shop-stats-bar">
<div
className={`shop-stats-bar-fill${b.channel === 'PHONE' ? ' phone' : ''}`}
style={{
width: `${stats.totalCount > 0 ? Math.round((b.count / stats.totalCount) * 100) : 0}%`,
}}
/>
</div>
</div>
))}
</div>
</>
)}
</div>
</div>
)}
</PullToRefresh>
);
}
+167 -1
View File
@@ -587,6 +587,12 @@
line-height: 1.1;
}
.shop-home-stat-sub {
margin-top: 6px;
font-size: 11px;
color: rgba(255, 255, 255, 0.75);
}
.shop-home-scan {
display: flex;
flex-direction: column;
@@ -1549,18 +1555,42 @@
border-radius: 2px;
}
.shop-records-status-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
}
.shop-records-status-chips {
display: flex;
gap: 8px;
padding: 8px 16px;
padding: 0;
overflow-x: auto;
scrollbar-width: none;
flex: 1;
min-width: 0;
}
.shop-records-status-chips::-webkit-scrollbar {
display: none;
}
.shop-records-stats-btn {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 4px;
border: 1px solid var(--color-primary);
background: transparent;
color: var(--color-primary);
border-radius: var(--radius-full);
padding: 4px 12px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
.shop-records-chip {
flex-shrink: 0;
border: none;
@@ -1578,6 +1608,142 @@
color: var(--color-on-primary);
}
.shop-record-channel {
margin: 6px 0 0;
}
.shop-record-channel-tag {
display: inline-flex;
align-items: center;
font-size: 11px;
line-height: 1;
padding: 4px 8px;
border-radius: var(--radius-full);
background: rgba(59, 130, 246, 0.12);
color: #1d4ed8;
}
.shop-record-channel-tag.phone {
background: rgba(168, 85, 247, 0.12);
color: #7e22ce;
}
.shop-stats-overlay {
position: fixed;
inset: 0;
z-index: 80;
display: flex;
align-items: flex-end;
justify-content: center;
}
.shop-stats-backdrop {
position: absolute;
inset: 0;
border: none;
background: rgba(0, 0, 0, 0.45);
}
.shop-stats-sheet {
position: relative;
width: 100%;
max-width: 480px;
max-height: 78vh;
overflow: auto;
background: var(--color-surface);
border-radius: 16px 16px 0 0;
padding: 16px 16px calc(16px + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.12);
}
.shop-stats-sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.shop-stats-sheet-head h2 {
margin: 0;
font-size: 17px;
font-weight: 700;
}
.shop-stats-close {
border: none;
background: transparent;
padding: 4px;
color: inherit;
cursor: pointer;
}
.shop-stats-range-hint {
margin: 0 0 12px;
font-size: 12px;
color: var(--color-on-surface-variant);
}
.shop-stats-total {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 14px;
padding: 12px;
border-radius: 12px;
background: var(--color-surface-container-low, #f5f5f4);
}
.shop-stats-total-value {
margin: 4px 0 0;
font-size: 15px;
font-weight: 700;
}
.shop-stats-channel-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.shop-stats-channel-card {
border: 1px solid var(--color-surface-container-highest);
border-radius: 12px;
padding: 12px;
}
.shop-stats-channel-title {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.shop-stats-channel-row {
display: flex;
justify-content: space-between;
font-size: 13px;
color: var(--color-on-surface-variant);
margin-bottom: 4px;
}
.shop-stats-bar {
margin-top: 8px;
height: 6px;
border-radius: 999px;
background: var(--color-surface-container-highest);
overflow: hidden;
}
.shop-stats-bar-fill {
height: 100%;
background: #3b82f6;
border-radius: 999px;
}
.shop-stats-bar-fill.phone {
background: #a855f7;
}
.shop-records-main {
padding-top: 0;
}