feat: batch update - gift card breakdown spec, backend APIs, miniprogram pages, ETL finance recharge, docs & migrations

This commit is contained in:
Neo
2026-03-20 01:43:48 +08:00
parent 075caf067f
commit 79f9a0e1da
437 changed files with 118603 additions and 976 deletions

View File

@@ -0,0 +1,14 @@
{
"navigationBarTitleText": "服务记录",
"navigationBarBackgroundColor": "#ffffff",
"navigationBarTextStyle": "black",
"enablePullDownRefresh": true,
"usingComponents": {
"ai-float-button": "/components/ai-float-button/ai-float-button",
"dev-fab": "/components/dev-fab/dev-fab",
"service-record-card": "/components/service-record-card/service-record-card",
"t-loading": "tdesign-miniprogram/loading/loading",
"t-icon": "tdesign-miniprogram/icon/icon",
"t-empty": "tdesign-miniprogram/empty/empty"
}
}

View File

@@ -0,0 +1,264 @@
import { mockCustomerDetail, mockCustomers } from '../../utils/mock-data'
import type { ConsumptionRecord } from '../../utils/mock-data'
import { sortByTimestamp } from '../../utils/sort'
/** 服务记录(对齐 task-detail ServiceRecord 结构,复用 service-record-card 组件) */
interface ServiceRecord extends ConsumptionRecord {
/** 台桌号,如 "A12号台" */
table: string
/** 课程类型标签,如 "基础课" */
type: string
/** 课程样式 class 后缀basic / vip / tip / recharge */
typeClass: 'basic' | 'vip' | 'tip' | 'recharge'
/** 卡片类型course=普通课recharge=充值提成 */
recordType: 'course' | 'recharge'
/** 折算后小时数(原始数字,组件负责加 h 后缀) */
duration: number
/** 折算前小时数(原始数字,组件负责加 h 后缀) */
durationRaw: number
/** 到手金额(原始数字,组件负责加 ¥ 前缀) */
income: number
/** 是否预估金额 */
isEstimate: boolean
/** 商品/饮品描述 */
drinks: string
/** 显示用日期,如 "2月5日 15:00 - 17:00" */
date: string
}
Page({
data: {
/** 页面状态 */
pageState: 'loading' as 'loading' | 'empty' | 'error' | 'normal',
/** 客户 ID */
customerId: '',
/** 客户名 */
customerName: '',
/** 客户名首字 */
customerInitial: '',
/** 客户电话(脱敏) */
customerPhone: '139****5678',
/** 客户电话(完整,查看后显示) */
customerPhoneFull: '13900005678',
/** 手机号是否已展开 */
phoneVisible: false,
/** 累计服务次数 */
totalServiceCount: 0,
/** 关系指数 */
relationIndex: '0.85',
/** 当前月份标签 */
monthLabel: '',
/** 当前年 */
currentYear: 2026,
/** 当前月 */
currentMonth: 2,
/** 最小年月(数据起始) */
minYearMonth: 202601,
/** 最大年月(当前月) */
maxYearMonth: 202602,
/** 是否可切换上月 */
canPrev: true,
/** 是否可切换下月 */
canNext: false,
/** 月度统计 */
monthCount: '6次',
monthHours: '11.5h',
monthRelation: '0.85',
/** 当前月的服务记录 */
records: [] as ServiceRecord[],
/** 所有记录(原始) */
allRecords: [] as ConsumptionRecord[],
/** 是否还有更多 */
hasMore: false,
/** 加载更多中 */
loadingMore: false,
},
onLoad(options) {
const id = options?.customerId || options?.id || ''
this.setData({ customerId: id })
this.loadData(id)
},
/** 加载数据 */
loadData(id: string) {
this.setData({ pageState: 'loading' })
setTimeout(() => {
// TODO: 替换为真实 API 调用
const customer = mockCustomers.find((c) => c.id === id)
const detail = customer
? { ...mockCustomerDetail, id: customer.id, name: customer.name }
: mockCustomerDetail
const allRecords = sortByTimestamp(detail.consumptionRecords || [], 'date') as ConsumptionRecord[]
const name = detail.name || '客户'
this.setData({
customerName: name,
customerInitial: name[0] || '?',
allRecords,
totalServiceCount: allRecords.length,
})
this.updateMonthView()
}, 400)
},
/** 根据当前月份筛选并更新视图 */
updateMonthView() {
const { currentYear, currentMonth, allRecords } = this.data
const monthLabel = `${currentYear}${currentMonth}`
// 筛选当月记录
const monthPrefix = `${currentYear}-${String(currentMonth).padStart(2, '0')}`
const monthRecords = allRecords.filter((r) => r.date.startsWith(monthPrefix))
// 转换为展示格式(对齐 task-detail ServiceRecord复用 service-record-card 组件)
// income / duration 均传原始数字,由组件统一加 ¥ 和 h
const records: ServiceRecord[] = monthRecords.map((r) => {
const d = new Date(r.date)
const month = d.getMonth() + 1
const day = d.getDate()
const dateLabel = `${month}${day}`
const timeRange = this.generateTimeRange(r.duration)
const isRecharge = r.project.includes('充值')
return {
...r,
table: this.getTableNo(r.id),
type: this.getTypeLabel(r.project),
typeClass: this.getTypeClass(r.project) as 'basic' | 'vip' | 'tip' | 'recharge',
recordType: (isRecharge ? 'recharge' : 'course') as 'course' | 'recharge',
duration: isRecharge ? 0 : parseFloat((r.duration / 60).toFixed(1)),
durationRaw: 0,
income: r.amount,
isEstimate: false,
drinks: '',
date: isRecharge ? dateLabel : `${dateLabel} ${timeRange}`,
}
})
// 月度统计
const totalMinutes = monthRecords.reduce((sum, r) => sum + r.duration, 0)
const monthCount = monthRecords.length + '次'
const monthHours = (totalMinutes / 60).toFixed(1) + 'h'
// 边界判断
const yearMonth = currentYear * 100 + currentMonth
const canPrev = yearMonth > this.data.minYearMonth
const canNext = yearMonth < this.data.maxYearMonth
const isEmpty = records.length === 0 && allRecords.length === 0
this.setData({
monthLabel,
records,
monthCount,
monthHours,
canPrev,
canNext,
pageState: isEmpty ? 'empty' : 'normal',
})
},
/** 生成模拟时间段 */
generateTimeRange(durationMin: number): string {
const startHour = 14 + Math.floor(Math.random() * 6)
const endMin = startHour * 60 + durationMin
const endHour = Math.floor(endMin / 60)
const endMinute = endMin % 60
return `${startHour}:00 - ${endHour}:${String(endMinute).padStart(2, '0')}`
},
/** 课程类型标签 */
getTypeLabel(project: string): string {
if (project.includes('小组')) return '小组课'
if (project.includes('1v1')) return '基础课'
if (project.includes('充值')) return '充值'
if (project.includes('斯诺克')) return '斯诺克'
return '基础课'
},
/** 课程类型样式(对齐 service-record-card typeClass prop*/
getTypeClass(project: string): string {
if (project.includes('充值')) return 'recharge'
if (project.includes('小组')) return 'vip'
if (project.includes('斯诺克')) return 'vip'
return 'basic'
},
/** 模拟台号 */
getTableNo(id: string): string {
const tables = ['A12号台', '3号台', 'VIP1号房', '5号台', 'VIP2号房', '8号台']
const idx = parseInt(id.replace(/\D/g, '') || '0', 10) % tables.length
return tables[idx]
},
/** 切换到上一月 */
onPrevMonth() {
if (!this.data.canPrev) return
let { currentYear, currentMonth } = this.data
currentMonth--
if (currentMonth < 1) {
currentMonth = 12
currentYear--
}
this.setData({ currentYear, currentMonth })
this.updateMonthView()
},
/** 切换到下一月 */
onNextMonth() {
if (!this.data.canNext) return
let { currentYear, currentMonth } = this.data
currentMonth++
if (currentMonth > 12) {
currentMonth = 1
currentYear++
}
this.setData({ currentYear, currentMonth })
this.updateMonthView()
},
/** 下拉刷新 */
onPullDownRefresh() {
this.loadData(this.data.customerId)
setTimeout(() => wx.stopPullDownRefresh(), 600)
},
/** 重试 */
onRetry() {
this.loadData(this.data.customerId)
},
/** 触底加载 */
onReachBottom() {
// Mock 阶段数据有限,不做分页
if (this.data.loadingMore || !this.data.hasMore) return
this.setData({ loadingMore: true })
setTimeout(() => {
this.setData({ loadingMore: false, hasMore: false })
}, 500)
},
/** 查看/隐藏手机号 */
onTogglePhone() {
this.setData({ phoneVisible: !this.data.phoneVisible })
},
/** 复制手机号 */
onCopyPhone() {
const phone = this.data.customerPhoneFull
wx.setClipboardData({
data: phone,
success: () => {
wx.showToast({ title: '手机号码已复制', icon: 'none' })
},
})
},
/** 返回上一页 */
onBack() {
wx.navigateBack()
},
})

View File

@@ -0,0 +1,123 @@
<!-- 加载态toast 浮层,不白屏) -->
<view class="g-toast-loading" wx:if="{{pageState === 'loading'}}">
<view class="g-toast-loading-inner">
<t-loading theme="circular" size="40rpx" />
<text class="g-toast-loading-text">加载中...</text>
</view>
</view>
<!-- 空态 -->
<view class="page-empty" wx:elif="{{pageState === 'empty'}}">
<t-empty description="暂无服务记录" />
</view>
<!-- 错误态 -->
<view class="page-error" wx:elif="{{pageState === 'error'}}">
<t-icon name="close-circle" size="120rpx" color="#e34d59" />
<text class="error-text">加载失败,请点击重试</text>
<view class="retry-btn" hover-class="retry-btn--hover" bindtap="onRetry">重试</view>
</view>
<!-- 正常态 -->
<block wx:elif="{{pageState === 'normal'}}">
<!-- Banner复用 task-detail 样式)-->
<view class="banner-area">
<image src="/assets/images/banner-bg-coral-aurora.svg" class="banner-bg-svg" mode="scaleToFill" />
<view class="banner-content">
<view class="customer-info">
<view class="avatar-box">
<text class="avatar-text">{{customerInitial}}</text>
</view>
<view class="info-right">
<view class="name-row">
<text class="customer-name">{{customerName}}</text>
<view class="name-badges">
<text class="name-badge">服务 <text class="badge-highlight">{{totalServiceCount}}</text> 次</text>
</view>
</view>
<view class="sub-stats">
<view class="sub-info">
<text class="phone">{{phoneVisible ? customerPhoneFull : customerPhone}}</text>
<view class="phone-toggle-btn" bindtap="{{phoneVisible ? 'onCopyPhone' : 'onTogglePhone'}}" hover-class="phone-toggle-btn--hover">
<text class="phone-toggle-text">{{phoneVisible ? '复制' : '查看'}}</text>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
<!-- 月份切换 -->
<view class="month-switcher">
<view class="month-btn {{canPrev ? '' : 'disabled'}}" bindtap="onPrevMonth">
<t-icon name="chevron-left" size="32rpx" color="{{canPrev ? '#777777' : '#dcdcdc'}}" />
</view>
<text class="month-label">{{monthLabel}}</text>
<view class="month-btn {{canNext ? '' : 'disabled'}}" bindtap="onNextMonth">
<t-icon name="chevron-right" size="32rpx" color="{{canNext ? '#777777' : '#dcdcdc'}}" />
</view>
</view>
<!-- 月度统计概览 -->
<view class="month-summary">
<view class="summary-item">
<text class="summary-label">本月服务</text>
<text class="summary-value">{{monthCount}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">服务时长</text>
<text class="summary-value value-primary">{{monthHours}}</text>
</view>
<view class="summary-divider"></view>
<view class="summary-item">
<text class="summary-label">关系指数</text>
<text class="summary-value value-warning">{{monthRelation}}</text>
</view>
</view>
<!-- 记录列表service-record-card 组件)-->
<view class="records-container">
<!-- 无当月记录 -->
<view class="no-month-data" wx:if="{{records.length === 0}}">
<t-icon name="chart-bar" size="100rpx" color="#dcdcdc" />
<text class="no-month-text">本月暂无服务记录</text>
</view>
<service-record-card
wx:for="{{records}}"
wx:key="id"
time="{{item.date}}"
course-label="{{item.type}}"
type-class="{{item.typeClass}}"
type="{{item.recordType}}"
table-no="{{item.table}}"
hours="{{item.duration}}"
hours-raw="{{item.durationRaw}}"
drinks="{{item.drinks}}"
income="{{item.income}}"
is-estimate="{{item.isEstimate}}"
/>
<!-- 底部提示 -->
<view class="list-footer" wx:if="{{records.length > 0}}">
<text class="footer-text">— 已加载全部记录 —</text>
</view>
<!-- 加载更多 -->
<view class="loading-more" wx:if="{{loadingMore}}">
<t-loading theme="circular" size="40rpx" />
</view>
</view>
<!-- AI 悬浮按钮 -->
<ai-float-button customerId="{{customerId}}" />
</block>
<dev-fab />

View File

@@ -0,0 +1,285 @@
/* pages/customer-service-records/customer-service-records.wxss */
page {
background-color: #f3f3f3;
}
/* ========== 页面状态 ========== */
.page-loading,
.page-empty,
.page-error {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24rpx;
}
.error-text {
font-size: 26rpx;
color: var(--color-error, #e34d59);
}
.retry-btn {
margin-top: 8rpx;
padding: 16rpx 48rpx;
background: var(--color-primary, #0052d9);
color: #ffffff;
font-size: 28rpx;
border-radius: 22rpx;
}
.retry-btn--hover {
opacity: 0.8;
}
/* ========== Banner与 task-detail 一致)========== */
.banner-area {
position: relative;
height: 202rpx;
overflow: hidden;
background: linear-gradient(135deg, #ff6b4a, #ff8a65);
}
.banner-bg-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 270%;
z-index: 0;
}
.banner-content {
position: relative;
z-index: 2;
padding: 44rpx 0 36rpx 44rpx;
display: flex;
flex-direction: column;
justify-content: center;
}
.customer-info {
display: flex;
align-items: center;
gap: 28rpx;
padding-top: 8rpx;
}
.avatar-box {
width: 116rpx;
height: 116rpx;
border-radius: 30rpx;
background: rgba(255, 255, 255, 0.20);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.15);
}
.avatar-text {
font-size: 44rpx;
font-weight: 700;
color: #ffffff;
line-height: 1;
}
.info-right {
flex: 1;
min-width: 0;
}
.name-row {
display: flex;
align-items: center;
gap: 30rpx;
margin-bottom: 14rpx;
flex-wrap: nowrap;
}
.name-badges {
display: flex;
align-items: center;
gap: 20rpx;
flex-shrink: 0;
font-weight: 500;
}
.name-badge {
font-size: 24rpx;
color: rgba(255, 255, 255, 0.75);
background: rgba(255, 255, 255, 0.18);
padding: 4rpx 20rpx;
border-radius: 20rpx;
line-height: 1.4;
}
.badge-highlight {
color: #ffffff;
font-weight: 700;
font-size: 20rpx;
}
.customer-name {
font-size: 36rpx;
font-weight: 600;
color: #ffffff;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
}
.customer-phone {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.55);
line-height: 1.4;
}
.sub-stats {
display: flex;
align-items: center;
gap: 32rpx;
}
.sub-stat {
font-size: 26rpx;
color: rgba(255, 255, 255, 0.70);
}
.stat-highlight {
color: #ffffff;
font-weight: 700;
font-size: 26rpx;
}
.sub-info {
display: flex;
align-items: center;
gap: 22rpx;
margin-bottom: 10rpx;
}
.phone {
font-size: 26rpx;
line-height: 36rpx;
color: rgba(255, 255, 255, 0.70);
}
.phone-toggle-btn {
padding: 4rpx 14rpx;
background: rgba(255, 255, 255, 0.20);
border-radius: 8rpx;
display: flex;
align-items: center;
}
.phone-toggle-text {
font-size: 22rpx;
line-height: 32rpx;
color: rgba(255, 255, 255, 0.90);
}
.phone-toggle-btn--hover {
opacity: 0.7;
}
/* ========== 月份切换 ========== */
.month-switcher {
display: flex;
align-items: center;
justify-content: center;
gap: 48rpx;
background: #ffffff;
padding: 24rpx 32rpx;
border-bottom: 2rpx solid #eeeeee;
}
.month-btn {
padding: 12rpx;
border-radius: 50%;
}
.month-btn.disabled {
opacity: 0.3;
}
.month-label {
font-size: 26rpx;
font-weight: 600;
color: #242424;
}
/* ========== 月度统计 ========== */
.month-summary {
display: flex;
align-items: flex-start;
background: #ffffff;
padding: 24rpx 0;
border-bottom: 2rpx solid #eeeeee;
}
.summary-item {
flex: 1;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
gap: 4rpx;
}
.summary-label {
font-size: 20rpx;
color: #a6a6a6;
line-height: 29rpx;
}
.summary-value {
font-size: 34rpx;
font-weight: 700;
color: #242424;
font-variant-numeric: tabular-nums;
line-height: 44rpx;
}
.value-primary { color: #0052d9; }
.value-warning { color: #ed7b2f; }
.summary-divider {
width: 2rpx;
height: 64rpx;
background: #eeeeee;
margin-top: 4rpx;
}
/* ========== 记录列表 ========== */
.records-container {
padding: 24rpx 30rpx;
padding-bottom: 100rpx;
display: flex;
flex-direction: column;
gap: 20rpx;
}
/* 无当月数据 */
.no-month-data {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 80rpx 0;
gap: 20rpx;
}
.no-month-text {
font-size: 26rpx;
color: #a6a6a6;
}
/* 底部提示 */
.list-footer {
text-align: center;
padding: 20rpx 0 8rpx;
}
.footer-text {
font-size: 20rpx;
color: #c5c5c5;
}
/* 加载更多 */
.loading-more {
display: flex;
justify-content: center;
padding: 24rpx 0;
}