feat(redeem): 核销记录标记扫码/手机号并支持方式统计
落库 RedeemChannel,门店记录页增加统计按钮,HQ 可按方式筛选。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,12 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button, Descriptions, Drawer, Form, Input, Table, Typography } from 'antd';
|
import { Button, Descriptions, Drawer, Form, Input, Select, Table, Tag, Typography } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
import { fmtTime } from '../lib/constants';
|
import { fmtTime } from '../lib/constants';
|
||||||
import { useAdminList } from '../lib/useAdminList';
|
import { useAdminList } from '../lib/useAdminList';
|
||||||
|
|
||||||
type Row = {
|
type Row = {
|
||||||
id: string; redeemNo: string; amount: number; settleAmount: number; createdAt: string;
|
id: string;
|
||||||
|
redeemNo: string;
|
||||||
|
amount: number;
|
||||||
|
settleAmount: number;
|
||||||
|
channel?: RedeemChannel;
|
||||||
|
createdAt: string;
|
||||||
user?: { userNo: string; phone: string | null };
|
user?: { userNo: string; phone: string | null };
|
||||||
store?: { name: string; cityName: string };
|
store?: { name: string; cityName: string };
|
||||||
coupon?: { couponNo: string };
|
coupon?: { couponNo: string };
|
||||||
@@ -21,6 +27,7 @@ export default function RedeemRecordsPage() {
|
|||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
|
||||||
if (filters.storeId) qs.set('storeId', filters.storeId);
|
if (filters.storeId) qs.set('storeId', filters.storeId);
|
||||||
|
if (filters.channel) qs.set('channel', filters.channel);
|
||||||
return qs;
|
return qs;
|
||||||
},
|
},
|
||||||
[filters],
|
[filters],
|
||||||
@@ -30,6 +37,19 @@ export default function RedeemRecordsPage() {
|
|||||||
|
|
||||||
const columns: ColumnsType<Row> = [
|
const columns: ColumnsType<Row> = [
|
||||||
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
{ title: '核销号', dataIndex: 'redeemNo', width: 180 },
|
||||||
|
{
|
||||||
|
title: '方式',
|
||||||
|
dataIndex: 'channel',
|
||||||
|
width: 110,
|
||||||
|
render: (v: RedeemChannel | undefined) => {
|
||||||
|
const channel = v === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||||
|
return (
|
||||||
|
<Tag color={channel === 'PHONE' ? 'purple' : 'blue'}>
|
||||||
|
{REDEEM_CHANNEL_LABELS[channel]}
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
{ title: '用户', dataIndex: ['user', 'userNo'], width: 110 },
|
||||||
{ title: '门店', dataIndex: ['store', 'name'] },
|
{ title: '门店', dataIndex: ['store', 'name'] },
|
||||||
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
|
||||||
@@ -37,12 +57,19 @@ export default function RedeemRecordsPage() {
|
|||||||
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
|
||||||
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
|
||||||
{
|
{
|
||||||
title: '操作', width: 80,
|
title: '操作',
|
||||||
|
width: 80,
|
||||||
render: (_, row) => (
|
render: (_, row) => (
|
||||||
<Button type="link" size="small" onClick={async () => {
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
onClick={async () => {
|
||||||
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
setDetail(await request(`/admin/redeem-records/${row.id}`));
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
}}>详情</Button>
|
}}
|
||||||
|
>
|
||||||
|
详情
|
||||||
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -50,16 +77,65 @@ export default function RedeemRecordsPage() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Typography.Title level={4}>核销记录</Typography.Title>
|
<Typography.Title level={4}>核销记录</Typography.Title>
|
||||||
<Form form={form} layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
|
<Form
|
||||||
<Form.Item name="redeemNo" label="核销号"><Input allowClear /></Form.Item>
|
form={form}
|
||||||
<Form.Item name="storeId" label="门店ID"><Input allowClear /></Form.Item>
|
layout="inline"
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
onFinish={(v) => {
|
||||||
|
setFilters(v);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Form.Item name="redeemNo" label="核销号">
|
||||||
|
<Input allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="storeId" label="门店ID">
|
||||||
|
<Input allowClear />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="channel" label="方式">
|
||||||
|
<Select
|
||||||
|
allowClear
|
||||||
|
style={{ width: 140 }}
|
||||||
|
options={[
|
||||||
|
{ value: 'SCAN', label: '扫码核销' },
|
||||||
|
{ value: 'PHONE', label: '手机号核销' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item>
|
||||||
|
<Button type="primary" htmlType="submit">
|
||||||
|
查询
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
|
<Table
|
||||||
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
|
rowKey="id"
|
||||||
|
loading={loading}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={data?.items ?? []}
|
||||||
|
scroll={{ x: 1100 }}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
pageSize,
|
||||||
|
total: data?.total ?? 0,
|
||||||
|
showSizeChanger: true,
|
||||||
|
onChange: (p, ps) => {
|
||||||
|
setPage(p);
|
||||||
|
setPageSize(ps);
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
<Drawer title="核销详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
|
||||||
{detail && (
|
{detail && (
|
||||||
<Descriptions column={1} bordered size="small">
|
<Descriptions column={1} bordered size="small">
|
||||||
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
<Descriptions.Item label="核销号">{String(detail.redeemNo)}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="方式">
|
||||||
|
{
|
||||||
|
REDEEM_CHANNEL_LABELS[
|
||||||
|
(detail.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
<Descriptions.Item label="核销额">¥{String(detail.amount)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
<Descriptions.Item label="结算额">¥{String(detail.settleAmount)}</Descriptions.Item>
|
||||||
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ export default function HomePage() {
|
|||||||
<div className="shop-home-stat">
|
<div className="shop-home-stat">
|
||||||
<p className="shop-home-stat-label">今日核销笔数</p>
|
<p className="shop-home-stat-label">今日核销笔数</p>
|
||||||
<p className="shop-home-stat-value">{Number(dash?.todayCount || 0)}</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>
|
||||||
<div className="shop-home-stat">
|
<div className="shop-home-stat">
|
||||||
<p className="shop-home-stat-label">今日到账金额</p>
|
<p className="shop-home-stat-label">今日到账金额</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
import PullToRefresh from '@dukang/shared-ui/PullToRefresh';
|
||||||
|
import { REDEEM_CHANNEL_LABELS, type RedeemChannel, type RedeemStatsDto } from '@dukang/shared-types';
|
||||||
import { request } from '../lib/api';
|
import { request } from '../lib/api';
|
||||||
|
|
||||||
type RangeKey = 'today' | '7d' | '30d';
|
type RangeKey = 'today' | '7d' | '30d';
|
||||||
@@ -23,27 +24,53 @@ function inRange(dateStr: string, range: RangeKey) {
|
|||||||
return d >= start;
|
return d >= start;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function channelLabel(channel: unknown): string {
|
||||||
|
const key = channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||||
|
return REDEEM_CHANNEL_LABELS[key];
|
||||||
|
}
|
||||||
|
|
||||||
export default function RecordsPage() {
|
export default function RecordsPage() {
|
||||||
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
|
const [records, setRecords] = useState<Array<Record<string, unknown>>>([]);
|
||||||
const [range, setRange] = useState<RangeKey>('today');
|
const [range, setRange] = useState<RangeKey>('today');
|
||||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
|
||||||
const [storeName, setStoreName] = useState('');
|
const [storeName, setStoreName] = useState('');
|
||||||
|
const [statsOpen, setStatsOpen] = useState(false);
|
||||||
|
const [statsLoading, setStatsLoading] = useState(false);
|
||||||
|
const [stats, setStats] = useState<RedeemStatsDto | null>(null);
|
||||||
|
|
||||||
const loadRecords = useCallback(() => {
|
const loadRecords = useCallback(() => {
|
||||||
return Promise.all([
|
return Promise.all([
|
||||||
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records').then((d) => {
|
request<{ list: Array<Record<string, unknown>> }>('SHOP_H5', '/shop/redeem/records?pageSize=200').then(
|
||||||
|
(d) => {
|
||||||
setRecords(d.list || []);
|
setRecords(d.list || []);
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
request<Record<string, unknown>>('SHOP_H5', '/shop/store')
|
||||||
.then((s) => setStoreName(String(s.name || '')))
|
.then((s) => setStoreName(String(s.name || '')))
|
||||||
.catch(() => {}),
|
.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(() => {
|
useEffect(() => {
|
||||||
void loadRecords();
|
void loadRecords();
|
||||||
}, [loadRecords]);
|
}, [loadRecords]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (statsOpen) void loadStats(range);
|
||||||
|
}, [statsOpen, range, loadStats]);
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return records.filter((r) => {
|
return records.filter((r) => {
|
||||||
if (!inRange(String(r.createdAt), range)) return false;
|
if (!inRange(String(r.createdAt), range)) return false;
|
||||||
@@ -71,11 +98,13 @@ export default function RecordsPage() {
|
|||||||
<div className="shop-records-main">
|
<div className="shop-records-main">
|
||||||
<nav className="shop-records-filters">
|
<nav className="shop-records-filters">
|
||||||
<div className="shop-records-range-tabs">
|
<div className="shop-records-range-tabs">
|
||||||
{([
|
{(
|
||||||
|
[
|
||||||
['today', '今日'],
|
['today', '今日'],
|
||||||
['7d', '近7日'],
|
['7d', '近7日'],
|
||||||
['30d', '近30日'],
|
['30d', '近30日'],
|
||||||
] as const).map(([key, label]) => (
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -86,12 +115,15 @@ export default function RecordsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="shop-records-status-row">
|
||||||
<div className="shop-records-status-chips">
|
<div className="shop-records-status-chips">
|
||||||
{([
|
{(
|
||||||
|
[
|
||||||
['all', '全部'],
|
['all', '全部'],
|
||||||
['pending', '待打款'],
|
['pending', '待打款'],
|
||||||
['paid', '已打款'],
|
['paid', '已打款'],
|
||||||
] as const).map(([key, label]) => (
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
<button
|
<button
|
||||||
key={key}
|
key={key}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -102,6 +134,17 @@ export default function RecordsPage() {
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</nav>
|
||||||
|
|
||||||
<section className="shop-records-summary">
|
<section className="shop-records-summary">
|
||||||
@@ -116,7 +159,10 @@ export default function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="shop-records-summary-note">
|
<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
|
check_circle
|
||||||
</span>
|
</span>
|
||||||
结算比例: {summary.rate}% (按{summary.rate / 10}折结算)
|
结算比例: {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 payout = r.payout as { paidAt?: string | null; status?: string } | undefined;
|
||||||
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
const paid = Boolean(payout?.paidAt) || payout?.status === 'PAID' || Boolean(r.paidAt);
|
||||||
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
const paidAt = payout?.paidAt || (typeof r.paidAt === 'string' ? r.paidAt : null);
|
||||||
|
const channel = (r.channel === 'PHONE' ? 'PHONE' : 'SCAN') as RedeemChannel;
|
||||||
return (
|
return (
|
||||||
<article key={String(r.id)} className="shop-record-card">
|
<article key={String(r.id)} className="shop-record-card">
|
||||||
<div className="shop-record-card-top">
|
<div className="shop-record-card-top">
|
||||||
<div>
|
<div>
|
||||||
<div className="shop-record-order">
|
<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>
|
<span>{r.redeemNo ? String(r.redeemNo) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="shop-record-time">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<span className={`shop-record-badge ${paid ? 'paid' : 'pending'}`}>
|
<span className={`shop-record-badge ${paid ? 'paid' : 'pending'}`}>
|
||||||
@@ -172,7 +231,9 @@ export default function RecordsPage() {
|
|||||||
</p>
|
</p>
|
||||||
{storeName && (
|
{storeName && (
|
||||||
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
<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}
|
{storeName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -190,6 +251,80 @@ export default function RecordsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</PullToRefresh>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+167
-1
@@ -587,6 +587,12 @@
|
|||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-home-stat-sub {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
.shop-home-scan {
|
.shop-home-scan {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1549,18 +1555,42 @@
|
|||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shop-records-status-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
.shop-records-status-chips {
|
.shop-records-status-chips {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shop-records-status-chips::-webkit-scrollbar {
|
.shop-records-status-chips::-webkit-scrollbar {
|
||||||
display: none;
|
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 {
|
.shop-records-chip {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -1578,6 +1608,142 @@
|
|||||||
color: var(--color-on-primary);
|
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 {
|
.shop-records-main {
|
||||||
padding-top: 0;
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,14 +19,39 @@ export interface RedeemPreviewDto {
|
|||||||
boundStoreId?: string | null;
|
boundStoreId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 门店核销方式 */
|
||||||
|
export type RedeemChannel = 'SCAN' | 'PHONE';
|
||||||
|
|
||||||
|
export const REDEEM_CHANNEL_LABELS: Record<RedeemChannel, string> = {
|
||||||
|
SCAN: '扫码核销',
|
||||||
|
PHONE: '手机号核销',
|
||||||
|
};
|
||||||
|
|
||||||
export interface RedeemRecordDto {
|
export interface RedeemRecordDto {
|
||||||
id: string;
|
id: string;
|
||||||
redeemNo: string;
|
redeemNo: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
settleAmount: number;
|
settleAmount: number;
|
||||||
|
/** 核销方式:扫码 SCAN / 手机号 PHONE */
|
||||||
|
channel?: RedeemChannel;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RedeemChannelStatsBucket {
|
||||||
|
channel: RedeemChannel;
|
||||||
|
count: number;
|
||||||
|
amount: number;
|
||||||
|
settleAmount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RedeemStatsDto {
|
||||||
|
range: 'today' | '7d' | '30d';
|
||||||
|
totalCount: number;
|
||||||
|
totalAmount: number;
|
||||||
|
totalSettleAmount: number;
|
||||||
|
byChannel: RedeemChannelStatsBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface RedeemPhoneBalanceDto {
|
export interface RedeemPhoneBalanceDto {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
totalBalance: number;
|
totalBalance: number;
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ enum RedeemPendingStatus {
|
|||||||
REJECTED
|
REJECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Redeem channel: SCAN or PHONE
|
||||||
|
enum RedeemChannel {
|
||||||
|
SCAN
|
||||||
|
PHONE
|
||||||
|
}
|
||||||
|
|
||||||
enum ResourceMediaType {
|
enum ResourceMediaType {
|
||||||
IMAGE
|
IMAGE
|
||||||
VIDEO
|
VIDEO
|
||||||
@@ -1325,6 +1331,8 @@ model RedeemRecord {
|
|||||||
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
storeId BigInt @map("store_id") @db.UnsignedBigInt
|
||||||
amount Decimal @db.Decimal(10, 2)
|
amount Decimal @db.Decimal(10, 2)
|
||||||
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
|
||||||
|
/// SCAN=qrcode, PHONE=phone
|
||||||
|
channel RedeemChannel @default(SCAN)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
|
||||||
|
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
||||||
@@ -1336,6 +1344,7 @@ model RedeemRecord {
|
|||||||
allocations RedeemRecordAllocation[]
|
allocations RedeemRecordAllocation[]
|
||||||
|
|
||||||
@@index([storeId, createdAt])
|
@@index([storeId, createdAt])
|
||||||
|
@@index([storeId, channel, createdAt])
|
||||||
@@map("user_redeem_record")
|
@@map("user_redeem_record")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export class AdminRedeemService {
|
|||||||
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
|
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
|
||||||
if (query.storeId) where.storeId = BigInt(query.storeId);
|
if (query.storeId) where.storeId = BigInt(query.storeId);
|
||||||
if (query.userId) where.userId = BigInt(query.userId);
|
if (query.userId) where.userId = BigInt(query.userId);
|
||||||
|
if (query.channel === 'SCAN' || query.channel === 'PHONE') {
|
||||||
|
where.channel = query.channel;
|
||||||
|
}
|
||||||
|
|
||||||
const [items, total] = await Promise.all([
|
const [items, total] = await Promise.all([
|
||||||
this.prisma.redeemRecord.findMany({
|
this.prisma.redeemRecord.findMany({
|
||||||
|
|||||||
@@ -242,6 +242,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
userId?: string;
|
userId?: string;
|
||||||
|
|
||||||
|
/** SCAN | PHONE */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
channel?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
export class AdminStoreRatingsQueryDto extends PaginationQueryDto {
|
||||||
|
|||||||
@@ -96,6 +96,16 @@ export class ShopRedeemController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('stats')
|
||||||
|
stats(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query('range') range?: string,
|
||||||
|
) {
|
||||||
|
const normalized =
|
||||||
|
range === '7d' || range === '30d' || range === 'today' ? range : 'today';
|
||||||
|
return this.redeemService.getShopRedeemStats(user.actorId, user.storeId!, normalized);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('phone/send-lookup-sms')
|
@Post('phone/send-lookup-sms')
|
||||||
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
|
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
|
||||||
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
|
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export class RedeemService {
|
|||||||
) {
|
) {
|
||||||
const settlementRate = Number(account.store.settlementRate);
|
const settlementRate = Number(account.store.settlementRate);
|
||||||
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
|
||||||
|
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
|
||||||
|
|
||||||
let record;
|
let record;
|
||||||
try {
|
try {
|
||||||
@@ -192,6 +193,7 @@ export class RedeemService {
|
|||||||
storeId: account.storeId,
|
storeId: account.storeId,
|
||||||
amount,
|
amount,
|
||||||
settleAmount,
|
settleAmount,
|
||||||
|
channel: redeemChannel,
|
||||||
allocations: {
|
allocations: {
|
||||||
create: normalizedAllocations.map((item, index) => ({
|
create: normalizedAllocations.map((item, index) => ({
|
||||||
couponId: BigInt(item.couponId),
|
couponId: BigInt(item.couponId),
|
||||||
@@ -1079,6 +1081,51 @@ export class RedeemService {
|
|||||||
return { list: serializeBigInt(list), total, page, pageSize };
|
return { list: serializeBigInt(list), total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getShopRedeemStats(
|
||||||
|
storeAccountId: bigint,
|
||||||
|
storeId: bigint,
|
||||||
|
range: 'today' | '7d' | '30d' = 'today',
|
||||||
|
) {
|
||||||
|
await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
|
});
|
||||||
|
const start = new Date();
|
||||||
|
start.setHours(0, 0, 0, 0);
|
||||||
|
if (range === '7d') start.setDate(start.getDate() - 6);
|
||||||
|
if (range === '30d') start.setDate(start.getDate() - 29);
|
||||||
|
|
||||||
|
const records = await this.prisma.redeemRecord.findMany({
|
||||||
|
where: { storeId, createdAt: { gte: start } },
|
||||||
|
select: { channel: true, amount: true, settleAmount: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const buckets: Record<'SCAN' | 'PHONE', { count: number; amount: number; settleAmount: number }> = {
|
||||||
|
SCAN: { count: 0, amount: 0, settleAmount: 0 },
|
||||||
|
PHONE: { count: 0, amount: 0, settleAmount: 0 },
|
||||||
|
};
|
||||||
|
for (const r of records) {
|
||||||
|
const key = r.channel === 'PHONE' ? 'PHONE' : 'SCAN';
|
||||||
|
buckets[key].count += 1;
|
||||||
|
buckets[key].amount += Number(r.amount);
|
||||||
|
buckets[key].settleAmount += Number(r.settleAmount);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byChannel = (['SCAN', 'PHONE'] as const).map((channel) => ({
|
||||||
|
channel,
|
||||||
|
count: buckets[channel].count,
|
||||||
|
amount: Number(buckets[channel].amount.toFixed(2)),
|
||||||
|
settleAmount: Number(buckets[channel].settleAmount.toFixed(2)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
range,
|
||||||
|
totalCount: records.length,
|
||||||
|
totalAmount: Number(byChannel.reduce((s, b) => s + b.amount, 0).toFixed(2)),
|
||||||
|
totalSettleAmount: Number(byChannel.reduce((s, b) => s + b.settleAmount, 0).toFixed(2)),
|
||||||
|
byChannel,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
|
async getShopDashboard(storeAccountId: bigint, storeId: bigint) {
|
||||||
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
|
||||||
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
where: { storeAccountId_storeId: { storeAccountId, storeId } },
|
||||||
@@ -1091,6 +1138,8 @@ export class RedeemService {
|
|||||||
});
|
});
|
||||||
const todayCount = records.length;
|
const todayCount = records.length;
|
||||||
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
const todayAmount = records.reduce((sum, r) => sum + Number(r.amount), 0);
|
||||||
|
const todayScanCount = records.filter((r) => r.channel !== 'PHONE').length;
|
||||||
|
const todayPhoneCount = records.filter((r) => r.channel === 'PHONE').length;
|
||||||
const recent = await this.prisma.redeemRecord.findMany({
|
const recent = await this.prisma.redeemRecord.findMany({
|
||||||
where: { storeId },
|
where: { storeId },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -1100,6 +1149,8 @@ export class RedeemService {
|
|||||||
store: binding.store,
|
store: binding.store,
|
||||||
todayCount,
|
todayCount,
|
||||||
todayAmount,
|
todayAmount,
|
||||||
|
todayScanCount,
|
||||||
|
todayPhoneCount,
|
||||||
recentRecords: recent,
|
recentRecords: recent,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user