6 Commits

Author SHA1 Message Date
jacy a7d55b7293 merge(dev): 走马灯滚出终点调整
CI / verify (push) Has been cancelled
2026-08-03 17:52:18 +08:00
jacy 7e3dc131ad fix(mini-user): 走马灯全文滚出视口后再多走 10px
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 17:52:10 +08:00
jacy f09e00e16f merge(dev): 门店核销走马灯优化
CI / verify (push) Has been cancelled
2026-08-03 16:34:36 +08:00
jacy ff462bca9a fix(mini-user): 门店核销走马灯小程序轮播与布局优化
单条从右向左位移至 -10px 后停留,位置移至门店详情上方;修复小程序 opacity/transform 不生效问题。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:34:28 +08:00
jacy aa0e140fec merge(dev): 核销方式标记与统计
CI / verify (push) Has been cancelled
2026-08-03 16:21:47 +08:00
jacy dc30932012 feat(redeem): 核销记录标记扫码/手机号并支持方式统计
落库 RedeemChannel,门店记录页增加统计按钮,HQ 可按方式筛选。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-03 16:21:38 +08:00
13 changed files with 647 additions and 166 deletions
+88 -12
View File
@@ -1,12 +1,18 @@
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 { REDEEM_CHANNEL_LABELS, type RedeemChannel } from '@dukang/shared-types';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import { useAdminList } from '../lib/useAdminList';
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 };
store?: { name: string; cityName: string };
coupon?: { couponNo: string };
@@ -21,6 +27,7 @@ export default function RedeemRecordsPage() {
const qs = new URLSearchParams();
if (filters.redeemNo) qs.set('redeemNo', filters.redeemNo);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.channel) qs.set('channel', filters.channel);
return qs;
},
[filters],
@@ -30,6 +37,19 @@ export default function RedeemRecordsPage() {
const columns: ColumnsType<Row> = [
{ 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: ['store', 'name'] },
{ title: '核销额', dataIndex: 'amount', width: 90, render: (v) => `¥${v}` },
@@ -37,12 +57,19 @@ export default function RedeemRecordsPage() {
{ title: '券号', dataIndex: ['coupon', 'couponNo'], width: 160 },
{ title: '时间', dataIndex: 'createdAt', width: 160, render: fmtTime },
{
title: '操作', width: 80,
title: '操作',
width: 80,
render: (_, row) => (
<Button type="link" size="small" onClick={async () => {
setDetail(await request(`/admin/redeem-records/${row.id}`));
setDrawerOpen(true);
}}></Button>
<Button
type="link"
size="small"
onClick={async () => {
setDetail(await request(`/admin/redeem-records/${row.id}`));
setDrawerOpen(true);
}}
>
</Button>
),
},
];
@@ -50,16 +77,65 @@ export default function RedeemRecordsPage() {
return (
<div>
<Typography.Title level={4}></Typography.Title>
<Form form={form} 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
form={form}
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>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1000 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Table
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)}>
{detail && (
<Descriptions column={1} bordered size="small">
<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.settleAmount)}</Descriptions.Item>
<Descriptions.Item label="时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
+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;
}
@@ -1,16 +1,18 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { View, Text } from '@tarojs/components';
import Taro, { useReady } from '@tarojs/taro';
import Taro from '@tarojs/taro';
type StoreRedeemMarqueeProps = {
lines: string[];
};
/** 飘动速度 px/s */
const FLY_SPEED = 58;
const MIN_FLY_MS = 2200;
const FLY_SPEED = 56;
const MIN_FLY_MS = 2400;
const PAUSE_MIN_MS = 1000;
const PAUSE_MAX_MS = 5000;
const TICK_MS = 16;
/** 全文滚出视口后,再向左多走 10px */
const EXTRA_AFTER_EXIT_PX = 10;
function estimateTextWidth(text: string): number {
let w = 0;
@@ -24,18 +26,67 @@ function randomPauseMs() {
return PAUSE_MIN_MS + Math.floor(Math.random() * (PAUSE_MAX_MS - PAUSE_MIN_MS + 1));
}
function uid(prefix: string) {
return `${prefix}${Math.random().toString(36).slice(2, 10)}`;
/** 容器宽兜底(不依赖 DOM 测量,小程序首帧即可用) */
function getBoxWidthFallback(): number {
try {
const sys = Taro.getSystemInfoSync();
const screenW = Number(sys.windowWidth || sys.screenWidth || 375);
// 与 section 同宽:左右 var(--space-page)
return Math.max(220, Math.floor(screenW - 32));
} catch {
return 300;
}
}
function createQuery() {
const page = Taro.getCurrentInstance().page;
return page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
function measureBoxWidth(selector: string, fallback: number): Promise<number> {
return new Promise((resolve) => {
Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
resolve(w > 8 ? Math.ceil(w) : fallback);
});
} catch {
resolve(fallback);
}
});
});
}
function measureTextWidth(selector: string, text: string): Promise<number> {
const fallback = estimateTextWidth(text);
return new Promise((resolve) => {
Taro.nextTick(() => {
try {
const page = Taro.getCurrentInstance().page;
const query = page ? Taro.createSelectorQuery().in(page) : Taro.createSelectorQuery();
query
.select(selector)
.boundingClientRect()
.exec((res) => {
const w = Number(res?.[0]?.width || 0);
if (w > 8 && w < fallback * 3) resolve(Math.ceil(w));
else resolve(fallback);
});
} catch {
resolve(fallback);
}
});
});
}
/**
* 核销走马灯:单条从右向左位移飞出,间隔 1~5 秒随机再播下一条。
* H5 / 微信小程序均用 JS translateX,不依赖 CSS animation / Intl。
*
* 小程序注意:
* - 不用 useReady(子组件内不触发 → opacity 永远 0)
* - 不用 Text + transform(支持差),改用 View + left
* - 字宽用估算,避免屏外元素测宽失败
*/
export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
const items = useMemo(
@@ -46,64 +97,20 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
[lines],
);
const rootIdRef = useRef(uid('smr'));
const textIdRef = useRef(uid('smt'));
const rootIdRef = useRef(`smr${Math.random().toString(36).slice(2, 10)}`);
const textIdRef = useRef(`smt${Math.random().toString(36).slice(2, 10)}`);
const indexRef = useRef(0);
const boxWidthRef = useRef(0);
const boxWidthRef = useRef(getBoxWidthFallback());
const itemsKey = items.join('\n');
const [displayIndex, setDisplayIndex] = useState(0);
const [offset, setOffset] = useState(9999);
const [ready, setReady] = useState(false);
const measureBox = () =>
new Promise<number>((resolve) => {
Taro.nextTick(() => {
try {
createQuery()
.select(`#${rootIdRef.current}`)
.boundingClientRect()
.exec((res) => {
const box = Number(res?.[0]?.width || 0);
const next = box > 8 ? box : boxWidthRef.current || 300;
boxWidthRef.current = next;
resolve(next);
});
} catch {
resolve(boxWidthRef.current || 300);
}
});
});
const measureText = (text: string) =>
new Promise<number>((resolve) => {
const fallback = estimateTextWidth(text);
Taro.nextTick(() => {
try {
createQuery()
.select(`#${textIdRef.current}`)
.boundingClientRect()
.exec((res) => {
const tw = Number(res?.[0]?.width || 0);
if (tw > 8 && tw < fallback * 3) resolve(Math.ceil(tw));
else resolve(fallback);
});
} catch {
resolve(fallback);
}
});
});
useReady(() => {
void measureBox().then(() => setReady(true));
});
const [leftPx, setLeftPx] = useState(() => boxWidthRef.current);
useEffect(() => {
if (!items.length) return;
let cancelled = false;
const waiters = new Set<ReturnType<typeof setTimeout>>();
let rafId = 0;
let tickTimer: ReturnType<typeof setInterval> | undefined;
const sleep = (ms: number) =>
@@ -115,78 +122,65 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
waiters.add(id);
});
const clearAnim = () => {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
const clearTick = () => {
if (tickTimer) {
clearInterval(tickTimer);
tickTimer = undefined;
}
};
const fly = (start: number, end: number, durationMs: number) =>
const fly = (from: number, to: number, durationMs: number) =>
new Promise<void>((resolve) => {
const began = Date.now();
setOffset(start);
const step = () => {
setLeftPx(from);
clearTick();
tickTimer = setInterval(() => {
if (cancelled) {
clearAnim();
clearTick();
resolve();
return;
}
const t = Math.min(1, (Date.now() - began) / durationMs);
setOffset(start + (end - start) * t);
setLeftPx(from + (to - from) * t);
if (t >= 1) {
clearAnim();
clearTick();
resolve();
return;
}
if (typeof requestAnimationFrame === 'function') {
rafId = requestAnimationFrame(step);
}
};
clearAnim();
if (typeof requestAnimationFrame === 'function') {
rafId = requestAnimationFrame(step);
} else {
tickTimer = setInterval(step, 32);
}
}, TICK_MS);
});
const loop = async () => {
indexRef.current = 0;
setDisplayIndex(0);
await sleep(60);
const measured = await measureBoxWidth(`#${rootIdRef.current}`, boxWidthRef.current);
boxWidthRef.current = measured;
if (cancelled) return;
while (!cancelled) {
if (!items.length) break;
while (!cancelled && items.length) {
const idx = indexRef.current % items.length;
const text = items[idx];
const box = boxWidthRef.current;
setDisplayIndex(idx);
setOffset(9999);
const from = box;
setLeftPx(from);
await sleep(48);
if (cancelled) break;
const box = await measureBox();
const textW = await measureText(text);
if (cancelled) break;
const start = box;
const end = -textW;
const distance = start - end;
const textW = await measureTextWidth(`#${textIdRef.current}`, text);
// 全文 left 边缘移出容器左边界后再走 10px
const to = -(textW + EXTRA_AFTER_EXIT_PX);
const distance = from - to;
const durationMs = Math.max(MIN_FLY_MS, Math.round((distance / FLY_SPEED) * 1000));
await fly(start, end, durationMs);
await sleep(32);
if (cancelled) break;
await fly(from, to, durationMs);
if (cancelled) break;
// 飞出后随机停留 1~5 秒,再播下一条
await sleep(randomPauseMs());
if (cancelled) break;
@@ -198,7 +192,7 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
return () => {
cancelled = true;
clearAnim();
clearTick();
waiters.forEach(clearTimeout);
waiters.clear();
};
@@ -207,17 +201,15 @@ export default function StoreRedeemMarquee({ lines }: StoreRedeemMarqueeProps) {
if (!items.length) return null;
const current = items[displayIndex] || items[0];
const textStyle: CSSProperties = {
transform: `translateX(${offset}px)`,
WebkitTransform: `translateX(${offset}px)`,
opacity: ready ? 1 : 0,
};
const innerStyle: CSSProperties = { left: `${leftPx}px` };
return (
<View id={rootIdRef.current} className="store-detail-marquee">
<Text id={textIdRef.current} className="store-detail-marquee-text" style={textStyle}>
{current}
</Text>
<View className="store-detail-marquee-inner" style={innerStyle}>
<Text id={textIdRef.current} className="store-detail-marquee-text">
{current}
</Text>
</View>
</View>
);
}
@@ -342,8 +342,6 @@ export default function StoreDetailPage() {
<View className="store-detail-info-card">
<Text className="store-detail-name">{store.name}</Text>
{marqueeLines.length > 0 ? <StoreRedeemMarquee lines={marqueeLines} /> : null}
<View className="store-detail-row">
<Text className="store-detail-meta store-detail-meta--flex">
{store.district ? `${store.district} · ` : ''}
@@ -385,6 +383,12 @@ export default function StoreDetailPage() {
</View>
</View>
{marqueeLines.length > 0 ? (
<View className="store-detail-marquee-wrap">
<StoreRedeemMarquee key={marqueeLines.join('|')} lines={marqueeLines} />
</View>
) : null}
{intro ? (
<View className="store-detail-section">
<Text className="store-detail-section-title"></Text>
+13 -11
View File
@@ -127,9 +127,13 @@
font-size: 11px;
}
.store-detail-marquee-wrap {
margin: 0 var(--space-page) 12px;
}
.store-detail-marquee {
margin: 4px 0 12px;
padding: 10px 0;
margin: 0;
padding: 0;
border-radius: 8px;
background: #fff7f6;
border: 1px solid rgba(166, 29, 36, 0.12);
@@ -137,22 +141,20 @@
height: 40px;
box-sizing: border-box;
position: relative;
display: flex;
align-items: center;
}
.store-detail-marquee-inner {
position: absolute;
top: 50%;
margin-top: -10px;
white-space: nowrap;
}
.store-detail-marquee-text {
position: absolute;
left: 0;
top: 50%;
margin-top: -10px;
display: inline-block;
white-space: nowrap;
font-size: 12px;
line-height: 20px;
color: #a61d24;
will-change: transform;
pointer-events: none;
}
.store-detail-section {
+25
View File
@@ -19,14 +19,39 @@ export interface RedeemPreviewDto {
boundStoreId?: string | null;
}
/** 门店核销方式 */
export type RedeemChannel = 'SCAN' | 'PHONE';
export const REDEEM_CHANNEL_LABELS: Record<RedeemChannel, string> = {
SCAN: '扫码核销',
PHONE: '手机号核销',
};
export interface RedeemRecordDto {
id: string;
redeemNo: string;
amount: number;
settleAmount: number;
/** 核销方式:扫码 SCAN / 手机号 PHONE */
channel?: RedeemChannel;
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 {
sessionId: string;
totalBalance: number;
+17 -8
View File
@@ -52,6 +52,12 @@ enum RedeemPendingStatus {
REJECTED
}
/// Redeem channel: SCAN or PHONE
enum RedeemChannel {
SCAN
PHONE
}
enum ResourceMediaType {
IMAGE
VIDEO
@@ -1318,14 +1324,16 @@ model BenefitCoupon {
}
model RedeemRecord {
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
userId BigInt @map("user_id") @db.UnsignedBigInt
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
amount Decimal @db.Decimal(10, 2)
settleAmount Decimal @map("settle_amount") @db.Decimal(10, 2)
createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3)
id BigInt @id @default(autoincrement()) @db.UnsignedBigInt
redeemNo String @unique @map("redeem_no") @db.VarChar(32)
userId BigInt @map("user_id") @db.UnsignedBigInt
couponId BigInt @map("coupon_id") @db.UnsignedBigInt
storeId BigInt @map("store_id") @db.UnsignedBigInt
amount Decimal @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)
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
coupon BenefitCoupon @relation(fields: [couponId], references: [id], onDelete: Restrict)
@@ -1336,6 +1344,7 @@ model RedeemRecord {
allocations RedeemRecordAllocation[]
@@index([storeId, createdAt])
@@index([storeId, channel, createdAt])
@@map("user_redeem_record")
}
@@ -39,6 +39,9 @@ export class AdminRedeemService {
if (query.redeemNo) where.redeemNo = { contains: query.redeemNo };
if (query.storeId) where.storeId = BigInt(query.storeId);
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([
this.prisma.redeemRecord.findMany({
@@ -242,6 +242,11 @@ export class AdminRedeemRecordsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
userId?: string;
/** SCAN | PHONE */
@IsOptional()
@IsString()
channel?: string;
}
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')
sendPhoneLookupSms(@CurrentUser() user: AuthUser, @Body() body: RedeemPhoneSendLookupSmsDto) {
return this.redeemService.sendPhoneLookupSms(user.actorId, user.storeId!, body.phone);
@@ -178,6 +178,7 @@ export class RedeemService {
) {
const settlementRate = Number(account.store.settlementRate);
const settleAmount = calcRedeemSettleAmount(amount, settlementRate);
const redeemChannel = analyticsExtra?.channel === 'phone' ? 'PHONE' : 'SCAN';
let record;
try {
@@ -192,6 +193,7 @@ export class RedeemService {
storeId: account.storeId,
amount,
settleAmount,
channel: redeemChannel,
allocations: {
create: normalizedAllocations.map((item, index) => ({
couponId: BigInt(item.couponId),
@@ -1079,6 +1081,51 @@ export class RedeemService {
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) {
const binding = await this.prisma.storeAccountStore.findUniqueOrThrow({
where: { storeAccountId_storeId: { storeAccountId, storeId } },
@@ -1091,6 +1138,8 @@ export class RedeemService {
});
const todayCount = records.length;
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({
where: { storeId },
orderBy: { createdAt: 'desc' },
@@ -1100,6 +1149,8 @@ export class RedeemService {
store: binding.store,
todayCount,
todayAmount,
todayScanCount,
todayPhoneCount,
recentRecords: recent,
});
}