feat: 支持上课统计功能;优化会员管理默认筛选状态

This commit is contained in:
richarjiang
2026-09-09 10:04:29 +08:00
parent 30ebc1c344
commit 99c5c211ad
17 changed files with 594 additions and 20 deletions

View File

@@ -71,12 +71,12 @@ pnpm deploy:server # 部署后端到生产环境
### 卡类型枚举
- `CardTypeCategory` (TIMES/DURATION/TRIAL) 定义在 `packages/shared/src/enums.ts`
- 会员管理筛选使用特殊值 `NONE` 表示无卡/无有效会员(不在枚举中)
- 会员管理筛选使用特殊值 `ACTIVE` 表示持有 ACTIVE 状态会员卡的会员用户(页面默认),`NONE` 表示无卡/无有效会员(两者不在卡种枚举中)
- 前端选项硬编码在 `src/pages/admin/members.vue``cardTypeOptions`,需与枚举保持同步
### 管理后台 API 模式
- `/admin/members` 支持 `page`, `limit`, `search`, `cardType` 参数
- `cardType=NONE` → 无有效会员的用户;其他值对应 `CardTypeCategory`
- `cardType=ACTIVE` → 持有任意 ACTIVE 状态会员卡;`cardType=NONE` → 无 ACTIVE 状态会员卡;省略参数查看全部用户;其他值对应 `CardTypeCategory`
- 预约统计total/completed/cancelled通过 `groupBy` 批量查询
### 筛选组件模式
@@ -91,3 +91,10 @@ pnpm deploy:server # 部署后端到生产环境
- 补录不创建预约或时段;只增加累计已完成节数,不推测上课日期、天数、时长,不参与月度统计、活跃网格或邀请奖励。
- 可选择从本人有限次会员卡扣次;补录与扣次必须事务提交,保存实际扣次快照,撤销只返还实际扣次。请求标识用于幂等重试。
- Prisma 迁移按 `YYYYMMDDHHmmss_description/migration.sql` 存放;补录表采用增量迁移,回退说明维护在 `docs/lesson-supplement.md`,不删除审计记录。
### 月度教学统计
- 统计归属 admin 模块,服务放 `admin/teaching-analytics.service.ts`,测试放 `admin/__tests__`;共享契约放 `shared/src/types/teaching-analytics.ts`,页面为 `pages/admin/analytics.vue`
- 按 TimeSlot.date 所属自然月查询,不按预约创建或核销日期;日期列以 UTC 日历值读取,中国时间用于判断课程结束。
- 当前无老师归属字段统计范围是工作室operatorId 不是授课老师。COMPLETED 是系统完成状态,不代表签到。
- 已上课程按时段去重,时长按已完成时段累加;上课人次按 COMPLETED 预约计数,学员按 userId 去重。取消、未出席、待确认、已确认独立计数。无日期补录不参与。
- 月历、学员排行和会员卡分布统计已完成记录;明细可组合日期、学员、状态筛选。新增测试目录只放该服务的 *.spec.ts。

View File

@@ -0,0 +1,47 @@
# 月度教学统计
入口:管理中心 → 统计分析。页面为 `packages/app/src/pages/admin/analytics.vue`,接口为 `GET /api/admin/teaching-analytics?month=YYYY-MM`
## 统计口径
| 指标 | 定义 |
| --- | --- |
| 已上课程 | COMPLETED 预约关联的时段数,同一时段只计一次 |
| 已完成上课人次 | COMPLETED 预约数 |
| 本月上课学员 | 已完成预约的 userId 去重,不按姓名合并 |
| 授课小时 | 已完成时段的排课分钟数之和除以 60同一时段不重复累计 |
| 上课天数 | 已完成课程日期去重 |
| 人均上课次数 | 上课人次除以上课学员数,无学员时显示横线 |
| 上月参考 | 上一个完整自然月的已上课程节数;当月未结束时明确提示,不给误导性环比 |
| 待核对 | 已过中国时间课程结束时刻、仍待确认或已确认的预约 |
月份以 TimeSlot.date 的日历日期为准,查询使用月初包含、下月初不包含的区间。预约创建时间、完成时间和取消时间不影响月份归属。取消、未出席、待确认和已确认不计入已上课程。
当前模型没有老师归属、课程名称或课程类别,因此统计范围明确为工作室;不能把 operatorId 当作授课老师。用卡分布代表预约所用会员卡的当前卡种名称,不代表课程类别、实际消课收入或老师课酬。同名卡种合并显示。
系统存在自动完成预约任务COMPLETED 不等于现场签到,不提供签到率。没有课程日期的历史累计补录不计入本报告。
## 页面与交互
- 米白底、松绿色总览、宋体标题与衬线数字,延续原有课表风格。
- 月份选择器、前后月切换、回到本月、手动刷新与下拉刷新;支持 2000—2099 年。
- 月历显示每天已完成课程节数,点击日期定位明细,再次点击解除日期条件。
- 学员排行支持姓名搜索,显示上课次数、天数与最近上课日期;点击查看该学员全月已完成明细。
- 明细支持日期、学员、预约状态组合筛选;每个状态显示当前日期/学员条件内的记录数。清除筛选恢复全月全部记录。
- 明细显示课程日期、起止时间、学员、用卡与状态,点击姓名进入会员详情。
- 排行每次增加 10 人、明细每次增加 20 条,避免一次渲染过多节点。接口返回当月完整记录,不以列表显示上限截断统计。
- 切月清空旧数据和筛选,请求序号防止慢响应覆盖新月份。错误提示与空月独立显示,失败不伪装成零数据。
## 实现边界与验证
接口继承管理中心的 JWT 和 ADMIN 权限保护。一次查询读取当月及上月必要关联字段,无逐学员查询;只向前端返回当月明细与两个月的汇总。未新增数据库表或迁移。
服务测试覆盖课程去重、同名不同学员、状态排除、空月、跨年、闰年、月会员管理 页面中,默认是查看全部用户,这个查看效率太低了,我希望默认看到的是会员用户增加这么一个筛选, 至于新用户,我可以选择筛选无卡用户就行了,这样的体验会更加好一些参数验证、中国时间结束判断以及接口权限元数据。
验证通过:共享包构建、后端构建、前端类型检查、微信小程序构建;新增统计测试 12 项通过。全量测试 231 项通过,原有 scheduler 测试 8 项因缺少 FlashSaleService mock 失败。
使用真实 Vue 页面与 SCSS、模拟数据进行了 390px 手机宽度预览,验证学员钻取、日期筛选、快速切月、空月和错误状态。浏览器预览仅模拟小程序容器;微信真机、原生月份选择器以及线上数据联调仍需发布前验收。
设计参考:[TeamUp 出勤报表](https://support.goteamup.com/en/articles/9327465-reports-class-attendances-all-attendances),借鉴按学员、日期和状态追溯出勤记录的交互,不复制其产品界面。
上线需要部署后端并发布小程序,本次仅实现与本地验证,未执行生产部署。

View File

@@ -3,6 +3,7 @@
"autoscan": true
},
"pages": [
{ "path": "pages/admin/analytics", "style": { "navigationStyle": "custom", "enablePullDownRefresh": true } },
{
"path": "pages/home/index",
"style": {

View File

@@ -0,0 +1,279 @@
<template>
<view class="report" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="统计分析" show-back />
<view class="intro">
<text class="eyebrow">PILATES · MONTHLY REVIEW</text>
<text class="title">每一节课都有迹可循</text>
<text class="muted">工作室月度统计 · 按课程日期归档</text>
</view>
<view class="month-nav">
<button aria-label="上个月" :disabled="month === '2000-01'" @tap="shiftMonth(-1)"></button>
<picker mode="date" fields="month" :value="month" start="2000-01" end="2099-12" @change="changeMonth($event.detail.value)">
<view class="month-label">{{ month.replace('-', ' 年 ') }} </view>
</picker>
<button aria-label="下个月" :disabled="month === '2099-12'" @tap="shiftMonth(1)"></button>
</view>
<view class="toolbar">
<button v-if="month !== currentMonth" @tap="changeMonth(currentMonth)">回到本月</button>
<text v-else>本月持续更新</text>
<button :disabled="loading" @tap="load">刷新数据 </button>
</view>
<view v-if="!loggedIn || !isAdmin" class="empty">仅登录后的管理员可查看教学统计</view>
<view v-else-if="loading" class="empty">正在整理这个月的上课记录</view>
<view v-else-if="error" class="empty"><text>{{ error }}</text><button class="outline" @tap="load">重新加载</button></view>
<template v-else-if="report">
<view class="hero">
<text class="hero-label">已上课程</text>
<view class="hero-number">{{ report.summary.sessions }}<text></text></view>
<text class="hero-note">{{ report.previousMonth }} 全月 {{ report.previous.sessions }} · {{ month === currentMonth ? '本月尚未结束' : '按完整自然月统计' }}</text>
<view class="hero-footer">
<view><text class="metric">{{ hours(report.summary.minutes) }}</text><text>授课小时</text></view>
<view><text class="metric">{{ report.summary.teachingDays }}</text><text>上课天数</text></view>
<view><text class="metric">{{ average }}</text><text>人均上课次数</text></view>
</view>
</view>
<view class="metrics">
<view><text class="metric">{{ report.summary.attendances }}<text class="unit">人次</text></text><text>已完成上课</text></view>
<view><text class="metric">{{ report.summary.students }}<text class="unit"></text></text><text>本月上课学员</text></view>
</view>
<button v-if="reviewCount" class="notice" @tap="showReview"><text>{{ reviewCount }} 条已过结束时间的预约仍待处理</text><text>核对明细 </text></button>
<view v-if="!report.records.length" class="empty compact">这个月暂无预约记录可切换月份查看历史教学情况</view>
<view class="section">
<view class="heading"><text>上课日历</text><text class="muted small">数字为已完成课程节数</text></view>
<view class="calendar">
<text v-for="label in weekdays" :key="label" class="weekday">{{ label }}</text>
<view v-for="n in offset" :key="`blank-${n}`" />
<button v-for="day in days" :key="day.date" class="day" :class="{ taught: day.count > 0, selected: selectedDate === day.date }" :aria-label="`${day.date},已上${day.count}节`" @tap="selectDay(day.date)">
<text>{{ day.day }}</text><text class="day-count">{{ day.count ? `${day.count}` : '·' }}</text>
</button>
</view>
<text class="footnote">点击日期查看当天明细再次点击取消筛选</text>
</view>
<view class="section">
<view class="heading"><text>学员上课排行</text><text class="muted small">已完成 · {{ students.length }} </text></view>
<input v-model="search" class="search" placeholder="搜索学员姓名" :maxlength="60" @input="studentLimit = 10" />
<text v-if="!filteredStudents.length" class="footnote">{{ search ? '没有匹配的上课学员' : '本月还没有已完成的上课记录' }}</text>
<button v-for="(student, index) in filteredStudents.slice(0, studentLimit)" :key="student.id" class="student-row" @tap="selectStudent(student)">
<text class="rank">{{ String(index + 1).padStart(2, '0') }}</text>
<view class="student-main">
<text class="student-name">{{ student.name }}</text>
<view class="track"><view class="fill" :style="{ width: `${student.count / maxCount * 100}%` }" /></view>
<text class="small muted">{{ student.days }} · 最近 {{ student.last.slice(5) }}</text>
</view>
<text class="student-count">{{ student.count }}<text class="small"> </text></text>
</button>
<button v-if="filteredStudents.length > studentLimit" class="more" @tap="studentLimit += 10">查看更多学员</button>
</view>
<view class="section">
<view class="heading"><text>上课用卡分布</text><text class="muted small">已完成人次</text></view>
<view v-for="card in cards" :key="card.name" class="distribution"><text>{{ card.name }}</text><text>{{ card.count }} 人次 · {{ Math.round(card.count / report.summary.attendances * 100) }}%</text></view>
<text v-if="!cards.length" class="footnote">暂无已完成记录</text>
</view>
<view id="lesson-details" class="section">
<view class="heading"><text>上课明细</text><text class="muted small">{{ filteredRecords.length }} </text></view>
<scroll-view scroll-x class="status-scroll"><view class="status-tabs">
<button v-for="item in statuses" :key="item.value" :class="{ active: status === item.value }" @tap="setStatus(item.value)">{{ item.label }} {{ countStatus(item.value) }}</button>
</view></scroll-view>
<view v-if="selectedDate || selectedStudent || reviewOnly" class="filters">
<text>{{ selectedDate || '全月' }}{{ selectedStudent ? ` · ${selectedStudent.name}` : '' }}{{ reviewOnly ? ' · 待核对' : '' }}</text>
<button @tap="clearFilters">清除筛选 ×</button>
</view>
<text v-if="!filteredRecords.length" class="footnote">当前条件下没有上课记录</text>
<view v-for="row in filteredRecords.slice(0, detailLimit)" :key="row.id" class="record">
<view class="record-top"><button class="record-name" @tap="openMember(row.userId)">{{ row.nickname || '未命名学员' }} </button><text class="badge" :class="{ completed: row.status === 'COMPLETED' }">{{ statusName(row.status) }}</text></view>
<text class="record-date">{{ row.date.slice(5).replace('-', '月') }} · {{ row.startTime }}{{ row.endTime }}</text>
<text class="muted small">{{ row.cardName }}{{ row.needsReview ? ' · 已过结束时间,请核对' : '' }}</text>
</view>
<button v-if="filteredRecords.length > detailLimit" class="more" @tap="detailLimit += 20">再显示 20 </button>
<text v-else-if="filteredRecords.length" class="footnote">已显示全部 {{ filteredRecords.length }} 条记录</text>
</view>
<view class="notes">
<text class="notes-title">关于这份月报</text>
<text>统计整个工作室暂不区分老师已上课程按已完成预约的时段去重同一节课多人参加只计 1 节课多人次授课时长按该时段排课时长计算</text>
<text>已完成包含系统自动完成不代表现场签到其他状态不计入已上课程没有日期的历史补录不计入月报用卡分布按当前卡种名称归类</text>
<text>更新于 {{ updatedLabel }} · 下拉可刷新</text>
</view>
</template>
</view>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { storeToRefs } from 'pinia'
import type { TeachingAnalytics } from '@mp-pilates/shared'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { useAdminStore } from '../../stores/admin'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { getErrorMessage } from '../../utils/auth'
const navBarHeight = `${getSystemLayout().navBarHeight}px`
const { loggedIn, isAdmin } = storeToRefs(useUserStore())
const store = useAdminStore()
const currentMonth = ref(chinaMonth())
const month = ref(currentMonth.value)
const report = ref<TeachingAnalytics | null>(null)
const loading = ref(false)
const error = ref('')
const selectedDate = ref('')
const selectedStudent = ref<{ id: string; name: string } | null>(null)
const status = ref('ALL')
const reviewOnly = ref(false)
const search = ref('')
const studentLimit = ref(10)
const detailLimit = ref(20)
let requestId = 0
const weekdays = ['一', '二', '三', '四', '五', '六', '日']
const statuses = [
{ value: 'ALL', label: '全部' }, { value: 'COMPLETED', label: '已完成' },
{ value: 'CONFIRMED', label: '已确认' }, { value: 'PENDING_CONFIRMATION', label: '待确认' },
{ value: 'CANCELLED', label: '已取消' }, { value: 'NO_SHOW', label: '未出席' },
]
const rows = computed(() => report.value?.records ?? [])
const completed = computed(() => rows.value.filter(row => row.status === 'COMPLETED'))
const average = computed(() => report.value?.summary.students ? (report.value.summary.attendances / report.value.summary.students).toFixed(1) : '—')
const reviewCount = computed(() => rows.value.filter(row => row.needsReview).length)
const offset = computed(() => (new Date(`${month.value}-01T00:00:00Z`).getUTCDay() + 6) % 7)
const days = computed(() => {
const [year, number] = month.value.split('-').map(Number)
const counts = new Map<string, Set<string>>()
completed.value.forEach(row => {
if (!counts.has(row.date)) counts.set(row.date, new Set())
counts.get(row.date)!.add(row.slotId)
})
return Array.from({ length: new Date(Date.UTC(year, number, 0)).getUTCDate() }, (_, i) => {
const date = `${month.value}-${String(i + 1).padStart(2, '0')}`
return { date, day: i + 1, count: counts.get(date)?.size ?? 0 }
})
})
const students = computed(() => {
const map = new Map<string, { id: string; name: string; count: number; dates: Set<string>; last: string }>()
completed.value.forEach(row => {
const student = map.get(row.userId) ?? { id: row.userId, name: row.nickname || '未命名学员', count: 0, dates: new Set<string>(), last: row.date }
student.count++
student.dates.add(row.date)
if (row.date > student.last) student.last = row.date
map.set(row.userId, student)
})
return [...map.values()].map(({ dates, ...student }) => ({ ...student, days: dates.size }))
.sort((a, b) => b.count - a.count || a.id.localeCompare(b.id))
})
const filteredStudents = computed(() => students.value.filter(student => student.name.toLowerCase().includes(search.value.trim().toLowerCase())))
const maxCount = computed(() => students.value[0]?.count || 1)
const cards = computed(() => {
const map = new Map<string, number>()
completed.value.forEach(row => map.set(row.cardName, (map.get(row.cardName) ?? 0) + 1))
return [...map].map(([name, count]) => ({ name, count })).sort((a, b) => b.count - a.count)
})
const scopedRecords = computed(() => rows.value.filter(row =>
(!selectedDate.value || row.date === selectedDate.value) &&
(!selectedStudent.value || row.userId === selectedStudent.value.id) &&
(!reviewOnly.value || row.needsReview)))
const filteredRecords = computed(() => scopedRecords.value.filter(row => status.value === 'ALL' || row.status === status.value).slice().reverse())
const updatedLabel = computed(() => report.value ? new Date(new Date(report.value.generatedAt).getTime() + 8 * 3600000).toISOString().slice(0, 16).replace('T', ' ') : '')
function chinaMonth() { return new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 7) }
function hours(minutes: number) { return Number((minutes / 60).toFixed(1)) }
function countStatus(value: string) { return scopedRecords.value.filter(row => value === 'ALL' || row.status === value).length }
function statusName(value: string) { return statuses.find(item => item.value === value)?.label ?? value }
function clearFilters() {
selectedDate.value = ''; selectedStudent.value = null; reviewOnly.value = false
status.value = 'ALL'; detailLimit.value = 20
}
function setStatus(value: string) { status.value = value; detailLimit.value = 20 }
async function scrollDetails() {
await nextTick()
uni.pageScrollTo({ selector: '#lesson-details', offsetTop: -getSystemLayout().navBarHeight - 12, duration: 250 })
}
function selectDay(date: string) {
selectedDate.value = selectedDate.value === date ? '' : date
reviewOnly.value = false; detailLimit.value = 20; scrollDetails()
}
function selectStudent(student: { id: string; name: string }) {
selectedStudent.value = { id: student.id, name: student.name }
selectedDate.value = ''; reviewOnly.value = false; setStatus('COMPLETED'); scrollDetails()
}
function showReview() { clearFilters(); reviewOnly.value = true; scrollDetails() }
function openMember(id: string) { uni.navigateTo({ url: `/pages/admin/member-detail?userId=${encodeURIComponent(id)}` }) }
function shiftMonth(amount: number) {
const [year, number] = month.value.split('-').map(Number)
changeMonth(new Date(Date.UTC(year, number - 1 + amount, 1)).toISOString().slice(0, 7))
}
function changeMonth(value: string) {
if (value === month.value || value < '2000-01' || value > '2099-12') return
month.value = value; clearFilters(); search.value = ''; studentLimit.value = 10; load()
}
async function load() {
const id = ++requestId
report.value = null; error.value = ''; loading.value = false
if (!loggedIn.value || !isAdmin.value) return
loading.value = true
try {
const data = await store.fetchTeachingAnalytics(month.value)
if (id === requestId) report.value = data
} catch (err) {
if (id === requestId) error.value = getErrorMessage(err, '统计暂时无法加载,请重试')
} finally {
if (id === requestId) loading.value = false
}
}
onShow(() => { currentMonth.value = chinaMonth(); load() })
onPullDownRefresh(async () => { try { await load() } finally { uni.stopPullDownRefresh() } })
</script>
<style scoped lang="scss">
.report { min-height: 100vh; box-sizing: border-box; background: #f7f5ef; color: #35483e; padding: 0 30rpx calc(48rpx + env(safe-area-inset-bottom)); }
button { margin: 0; padding: 0; background: transparent; border-radius: 0; font-size: inherit; color: inherit; line-height: 1.5; &::after { border: 0; } &:active { opacity: .65; } }
.intro { padding: 40rpx 4rpx 28rpx; display: flex; flex-direction: column; gap: 14rpx; }
.eyebrow { font-size: 18rpx; letter-spacing: 4rpx; color: #687d6e; }
.title { font-family: 'Songti SC', 'STSong', serif; font-size: 40rpx; line-height: 1.6; }
.muted { color: #73796f; font-size: 23rpx; }
.small { font-size: 22rpx; }
.month-nav { display: flex; justify-content: space-between; align-items: center; border-top: 1rpx solid #d9ddd2; border-bottom: 1rpx solid #d9ddd2; button { width: 80rpx; line-height: 88rpx; font-size: 40rpx; } }
.month-label { padding: 20rpx; font-size: 33rpx; font-family: 'Songti SC', 'STSong', serif; }
.toolbar { display: flex; justify-content: space-between; align-items: center; font-size: 22rpx; color: #677765; min-height: 76rpx; button { padding: 16rpx 0; } }
.hero { background: #344f42; color: #faf7e9; border-radius: 12rpx 12rpx 48rpx 12rpx; padding: 36rpx; }
.hero-label { font-size: 25rpx; letter-spacing: 3rpx; }
.hero-number { font-family: 'Baskerville', 'Times New Roman', serif; font-size: 116rpx; line-height: 1.25; text { font-size: 26rpx; padding-left: 18rpx; } }
.hero-note { font-size: 21rpx; color: #d1dbc9; }
.hero-footer { display: flex; margin-top: 30rpx; padding-top: 26rpx; border-top: 1rpx solid #6d8070; gap: 18rpx; > view { flex: 1; display: flex; flex-direction: column; font-size: 21rpx; gap: 8rpx; } }
.metric { font-size: 40rpx; font-family: 'Baskerville', 'Times New Roman', serif; font-variant-numeric: tabular-nums; }
.unit { font-size: 22rpx; margin-left: 14rpx; }
.metrics { display: flex; padding: 30rpx 0; border-bottom: 1rpx solid #d9ddd2; > view { flex: 1; display: flex; flex-direction: column; gap: 12rpx; font-size: 24rpx; padding-left: 30rpx; &:last-child { border-left: 1rpx solid #d9ddd2; } } }
.notice { width: 100%; text-align: left; margin-top: 26rpx; padding: 24rpx; display: flex; flex-direction: column; gap: 12rpx; background: #f0e5d2; color: #805c30; font-size: 23rpx; border-radius: 12rpx; }
.section { margin-top: 28rpx; background: #fffefa; border: 1rpx solid #e3e5da; border-radius: 16rpx; padding: 28rpx; }
.heading { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 12rpx; margin-bottom: 24rpx; font-size: 30rpx; }
.heading > text:first-child { font-family: 'Songti SC', 'STSong', serif; }
.calendar { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 8rpx; }
.weekday { text-align: center; font-size: 21rpx; color: #73796f; padding-bottom: 14rpx; }
.day { display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 92rpx; border-radius: 10rpx; font-size: 25rpx; border: 2rpx solid transparent; }
.day-count { font-size: 18rpx; margin-top: 7rpx; color: #66785f; }
.taught { background: #e8eee2; }
.selected { border-color: #344f42; background: #344f42; color: #fff; .day-count { color: #fff; } }
.footnote { display: block; color: #73796f; font-size: 22rpx; line-height: 1.8; padding-top: 24rpx; }
.search { background: #f2f3ec; border-radius: 10rpx; padding: 20rpx; font-size: 25rpx; margin-bottom: 14rpx; }
.student-row { display: flex; align-items: center; gap: 20rpx; width: 100%; padding: 24rpx 0; text-align: left; border-bottom: 1rpx solid #eeeee5; }
.rank { color: #77836e; font-family: 'Baskerville', serif; font-size: 26rpx; }
.student-main { flex: 1; min-width: 0; }
.student-name { display: block; font-size: 28rpx; word-break: break-all; }
.track { height: 5rpx; background: #eef0e6; margin: 14rpx 0 8rpx; }
.fill { height: 100%; background: #91a180; }
.student-count { flex-shrink: 0; font-size: 34rpx; }
.more { padding: 24rpx 0 0; width: 100%; font-size: 24rpx; color: #52714e; }
.distribution { display: flex; justify-content: space-between; gap: 20rpx; padding: 20rpx 0; border-bottom: 1rpx solid #eeeee5; font-size: 24rpx; > text:first-child { flex: 1; word-break: break-all; } }
.status-scroll { width: 100%; }
.status-tabs { display: flex; gap: 12rpx; padding-bottom: 12rpx; white-space: nowrap; button { flex-shrink: 0; padding: 16rpx 20rpx; border-radius: 8rpx; background: #f1f2eb; font-size: 22rpx; } .active { color: white; background: #344f42; } }
.filters { margin-top: 16rpx; font-size: 22rpx; color: #647b58; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; button { padding: 16rpx 0; } }
.record { display: flex; flex-direction: column; gap: 12rpx; border-bottom: 1rpx solid #e9ebdf; padding: 24rpx 0; }
.record-top { display: flex; align-items: center; justify-content: space-between; gap: 16rpx; }
.record-name { font-size: 28rpx; text-align: left; word-break: break-all; }
.badge { flex-shrink: 0; font-size: 20rpx; color: #867257; background: #f3eee4; padding: 6rpx 12rpx; border-radius: 6rpx; }
.completed { color: #53704b; background: #eaf0e2; }
.record-date { font-size: 25rpx; }
.notes { padding: 32rpx 8rpx; display: flex; flex-direction: column; gap: 16rpx; font-size: 22rpx; line-height: 1.9; color: #73796f; }
.notes-title { color: #455d49; font-size: 25rpx; }
.empty { padding: 80rpx 28rpx; text-align: center; font-size: 26rpx; line-height: 1.8; color: #737e70; }
.compact { padding: 36rpx 20rpx 0; }
.outline { padding: 20rpx; margin-top: 24rpx; border: 1rpx solid #b2bfaa; border-radius: 12rpx; }
</style>

View File

@@ -27,6 +27,16 @@
</view>
</view>
<view class="section-header"><text class="section-title">教学报告</text></view>
<view class="list">
<view class="list-item" @tap="navigate('/pages/admin/analytics')">
<view class="item-left">
<view class="item-icon-wrap icon--subscribe"><text class="item-icon-text"></text></view>
<view class="item-text-group"><text class="item-title">统计分析</text><text class="item-desc">月度课次 · 学员出勤 · 上课明细</text></view>
</view>
<view class="item-arrow"><text class="arrow-text"></text></view>
</view>
</view>
<!-- Section header: 课程管理 -->
<view class="section-header">
<text class="section-title">课程管理</text>

View File

@@ -3,6 +3,7 @@
<CustomNavBar title="会员管理" show-back />
<view class="filter-bar">
<view class="search-field">
<input
class="search-input"
v-model="searchQuery"
@@ -14,6 +15,7 @@
<view v-if="searchQuery" class="search-clear" @tap="onClear">
<text class="search-clear-icon">×</text>
</view>
</view>
<picker
class="type-picker"
mode="selector"
@@ -35,7 +37,7 @@
<view class="stats-row">
<view class="stat-item">
<text class="stat-value">{{ total }}</text>
<text class="stat-label">会员</text>
<text class="stat-label">用户</text>
</view>
</view>
@@ -47,7 +49,7 @@
<view class="empty-icon-wrap">
<view class="empty-icon-person" />
</view>
<text class="empty-text">{{ searchQuery ? '未找到匹配的会员' : '暂无会员数据' }}</text>
<text class="empty-text">{{ searchQuery ? '未找到匹配的用户' : '当前筛选下暂无用户' }}</text>
</view>
<view v-else class="member-list">
@@ -116,17 +118,25 @@ const hasMore = ref(false)
const LIMIT = 20
const cardTypeOptions = [
{ label: '全部', value: '' },
{ label: '会员用户', value: 'ACTIVE' },
{ label: '全部用户', value: '' },
{ label: '体验卡', value: 'TRIAL' },
{ label: '次卡', value: 'TIMES' },
{ label: '月卡', value: 'DURATION' },
{ label: '无卡', value: 'NONE' },
{ label: '无卡用户', value: 'NONE' },
]
const cardTypeIndex = ref(0)
let requestId = 0
let cardTypeDebounceTimer: ReturnType<typeof setTimeout> | null = null
function onCardTypeChange(e: { detail: { value: number } }) {
cardTypeIndex.value = Number(e.detail.value)
// Invalidate the old filter immediately, including during the debounce window.
requestId++
loading.value = true
members.value = []
total.value = 0
hasMore.value = false
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
cardTypeDebounceTimer = setTimeout(() => {
loadMembers(true)
@@ -135,25 +145,32 @@ function onCardTypeChange(e: { detail: { value: number } }) {
}
onUnmounted(() => {
requestId++
if (cardTypeDebounceTimer) clearTimeout(cardTypeDebounceTimer)
})
async function loadMembers(reset = false) {
if (loading.value) return
if (loading.value && !reset) return
const id = ++requestId
const requestedPage = reset ? 1 : page.value + 1
if (reset) {
page.value = 1
members.value = []
total.value = 0
hasMore.value = false
}
loading.value = true
try {
const search = searchQuery.value.trim()
const cardType = cardTypeOptions[cardTypeIndex.value].value
const result = await adminStore.fetchMembers({
page: page.value,
page: requestedPage,
limit: LIMIT,
...(search ? { search } : {}),
...(cardType ? { cardType } : {}),
})
if (id !== requestId) return
page.value = requestedPage
if (reset) {
members.value = [...result.items]
} else {
@@ -162,14 +179,15 @@ async function loadMembers(reset = false) {
total.value = result.total
hasMore.value = members.value.length < result.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
if (id === requestId) loading.value = false
}
}
async function refreshVisibleMembers() {
if (loading.value) return
const id = ++requestId
loading.value = true
try {
const search = searchQuery.value.trim()
@@ -181,14 +199,15 @@ async function refreshVisibleMembers() {
...(search ? { search } : {}),
...(cardType ? { cardType } : {}),
})
if (id !== requestId) return
members.value = [...result.items]
total.value = result.total
page.value = Math.max(1, Math.ceil(members.value.length / LIMIT) || 1)
hasMore.value = members.value.length < result.total
} catch {
uni.showToast({ title: '加载失败', icon: 'none' })
if (id === requestId) uni.showToast({ title: '加载失败', icon: 'none' })
} finally {
loading.value = false
if (id === requestId) loading.value = false
}
}
@@ -203,7 +222,6 @@ function onClear() {
onReachBottom(() => {
if (!hasMore.value || loading.value) return
page.value++
loadMembers(false)
})
@@ -244,19 +262,27 @@ onShow(() => {
position: relative;
}
.search-field {
flex: 1;
min-width: 0;
position: relative;
}
.search-input {
flex: 1;
min-width: 0;
height: 72rpx;
background: $bg-page;
border-radius: 36rpx;
padding: 0 28rpx;
padding: 0 60rpx 0 24rpx;
font-size: 26rpx;
color: $text-primary;
}
.search-clear {
position: absolute;
right: 260rpx;
right: 12rpx;
top: 14rpx;
width: 44rpx;
height: 44rpx;
display: flex;
@@ -297,7 +323,7 @@ onShow(() => {
.type-picker-text {
font-size: 24rpx;
color: $text-secondary;
max-width: 80rpx;
max-width: 120rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;

View File

@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
import { ref } from 'vue'
import { get, post, put, del } from '../utils/request'
import type {
TeachingAnalytics,
CardType,
CreateCardTypeDto,
UpdateCardTypeDto,
@@ -287,7 +288,12 @@ export const useAdminStore = defineStore('admin', () => {
return del<{ deleted: boolean }>(`/admin/flash-sales/${id}`)
}
async function fetchTeachingAnalytics(month: string): Promise<TeachingAnalytics> {
return get<TeachingAnalytics>('/admin/teaching-analytics', { month })
}
return {
fetchTeachingAnalytics,
// State
cardTypes,
studioConfig,

View File

@@ -0,0 +1,75 @@
import 'reflect-metadata'
import { BadRequestException } from '@nestjs/common'
import { BookingStatus, UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../../prisma/prisma.service'
import { TeachingAnalyticsService } from '../teaching-analytics.service'
import { AdminController } from '../admin.controller'
import { ROLES_KEY } from '../../auth/roles.decorator'
function booking(id: string, userId: string, slotId: string, date: string, status = BookingStatus.COMPLETED) {
return {
id, userId, status, user: { nickname: '同名学员' },
timeSlot: { id: slotId, date: new Date(`${date}T00:00:00Z`), startTime: '09:00', endTime: '10:30' },
membership: { cardType: { name: '次卡' } },
}
}
describe('TeachingAnalyticsService', () => {
const findMany = jest.fn()
const service = new TeachingAnalyticsService({ booking: { findMany } } as unknown as PrismaService)
beforeEach(() => { jest.useFakeTimers().setSystemTime(new Date('2026-09-09T03:00:00Z')); findMany.mockReset() })
afterEach(() => jest.useRealTimers())
it.each(['2026-13', '2026-00', '2026-9', '', '2026-09-01', '1999-12', undefined])('rejects invalid month %s before querying', async month => {
await expect(service.getMonthly(month as string)).rejects.toBeInstanceOf(BadRequestException)
expect(findMany).not.toHaveBeenCalled()
})
it('counts shared sessions and duration once, students by identity, and excludes other statuses', async () => {
findMany.mockResolvedValue([
booking('previous', 'a', 'old', '2026-08-31'),
booking('1', 'a', 'one', '2026-09-01'), booking('2', 'b', 'one', '2026-09-01'),
booking('3', 'a', 'two', '2026-09-03'),
...[BookingStatus.CANCELLED, BookingStatus.NO_SHOW, BookingStatus.CONFIRMED, BookingStatus.PENDING_CONFIRMATION]
.map((status, index) => booking(`other${index}`, 'c', `other${index}`, '2026-09-04', status)),
])
const result = await service.getMonthly('2026-09')
expect(result.summary).toEqual({ sessions: 2, attendances: 3, students: 2, minutes: 180, teachingDays: 2 })
expect(result.previous.sessions).toBe(1)
expect(result.records).toHaveLength(7)
expect(result.records.every(row => row.date.startsWith('2026-09'))).toBe(true)
expect(findMany).toHaveBeenCalledTimes(1)
})
it.each([
['2026-01', '2025-12-01', '2026-02-01'],
['2024-02', '2024-01-01', '2024-03-01'],
])('uses half-open course date boundaries for %s', async (month, from, to) => {
findMany.mockResolvedValue([])
const result = await service.getMonthly(month)
expect(findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { timeSlot: { date: { gte: new Date(`${from}T00:00:00Z`), lt: new Date(`${to}T00:00:00Z`) } } },
}))
expect(result.previousMonth).toBe(from.slice(0, 7))
expect(result.summary).toEqual({ sessions: 0, attendances: 0, students: 0, minutes: 0, teachingDays: 0 })
})
it('flags only unfinished bookings past their China-time end, without converting status', async () => {
const future = booking('future', 'b', 'future', '2026-09-09', BookingStatus.CONFIRMED)
future.timeSlot.endTime = '11:30'
findMany.mockResolvedValue([
booking('past', 'a', 'past', '2026-09-09', BookingStatus.CONFIRMED), future,
booking('cancel', 'c', 'cancel', '2026-09-09', BookingStatus.CANCELLED),
])
const result = await service.getMonthly('2026-09')
expect(result.records.map(row => row.needsReview)).toEqual([true, false, false])
expect(result.summary.sessions).toBe(0)
expect(result.records[0].status).toBe(BookingStatus.CONFIRMED)
})
it('inherits the admin-only controller role and authentication guards', () => {
expect(Reflect.getMetadata(ROLES_KEY, AdminController)).toEqual([UserRole.ADMIN])
const guards = Reflect.getMetadata('__guards__', AdminController) as Array<{ name: string }>
expect(guards.map(guard => guard.name)).toEqual(['JwtAuthGuard', 'RolesGuard'])
})
})

View File

@@ -1,4 +1,5 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { TeachingAnalyticsService } from './teaching-analytics.service'
import { Controller, Get, Query, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { Roles } from '../auth/roles.decorator'
import { RolesGuard } from '../auth/roles.guard'
@@ -15,7 +16,12 @@ interface AdminStats {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.ADMIN)
export class AdminController {
constructor(private readonly prisma: PrismaService) {}
constructor(private readonly prisma: PrismaService, private readonly analytics: TeachingAnalyticsService) {}
@Get('teaching-analytics')
getTeachingAnalytics(@Query('month') month: string) {
return this.analytics.getMonthly(month)
}
@Get('stats')
async getStats(): Promise<AdminStats> {

View File

@@ -1,7 +1,9 @@
import { TeachingAnalyticsService } from './teaching-analytics.service'
import { Module } from '@nestjs/common'
import { AdminController } from './admin.controller'
@Module({
controllers: [AdminController],
providers: [TeachingAnalyticsService],
})
export class AdminModule {}

View File

@@ -0,0 +1,58 @@
import { BadRequestException, Injectable } from '@nestjs/common'
import { BookingStatus, type TeachingAnalytics, type TeachingAnalyticsRecord, type TeachingAnalyticsSummary } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
@Injectable()
export class TeachingAnalyticsService {
constructor(private readonly prisma: PrismaService) {}
async getMonthly(month: string): Promise<TeachingAnalytics> {
if (typeof month !== 'string' || !/^(20\d{2})-(0[1-9]|1[0-2])$/.test(month)) {
throw new BadRequestException('月份格式应为 YYYY-MM范围为 2000—2099 年')
}
const [year, number] = month.split('-').map(Number)
const start = new Date(Date.UTC(year, number - 1, 1))
const previousStart = new Date(Date.UTC(year, number - 2, 1))
const end = new Date(Date.UTC(year, number, 1))
const now = new Date()
const bookings = await this.prisma.booking.findMany({
where: { timeSlot: { date: { gte: previousStart, lt: end } } },
select: {
id: true, userId: true, status: true,
user: { select: { nickname: true } },
timeSlot: { select: { id: true, date: true, startTime: true, endTime: true } },
membership: { select: { cardType: { select: { name: true } } } },
},
orderBy: [{ timeSlot: { date: 'asc' } }, { timeSlot: { startTime: 'asc' } }, { id: 'asc' }],
})
const rows: TeachingAnalyticsRecord[] = bookings.map((booking) => {
const slot = booking.timeSlot
const date = slot.date.toISOString().slice(0, 10)
const unfinished = booking.status === BookingStatus.CONFIRMED || booking.status === BookingStatus.PENDING_CONFIRMATION
return {
id: booking.id, userId: booking.userId, nickname: booking.user.nickname,
slotId: slot.id, date, startTime: slot.startTime, endTime: slot.endTime,
cardName: booking.membership.cardType.name, status: booking.status as BookingStatus,
needsReview: unfinished && new Date(`${date}T${slot.endTime}:00+08:00`).getTime() < now.getTime(),
}
})
const records = rows.filter((row) => row.date >= start.toISOString().slice(0, 10))
return {
month, generatedAt: now.toISOString(), records,
summary: this.summarize(records), previousMonth: previousStart.toISOString().slice(0, 7),
previous: this.summarize(rows.filter((row) => row.date < start.toISOString().slice(0, 10))),
}
}
private summarize(rows: TeachingAnalyticsRecord[]): TeachingAnalyticsSummary {
const completed = rows.filter((row) => row.status === BookingStatus.COMPLETED)
const slots = new Map(completed.map((row) => [row.slotId, row]))
const minutes = [...slots.values()].reduce((total, slot) => {
const parse = (time: string): number => Number(time.slice(0, 2)) * 60 + Number(time.slice(3, 5))
return total + Math.max(0, parse(slot.endTime) - parse(slot.startTime))
}, 0)
return { sessions: slots.size, attendances: completed.length,
students: new Set(completed.map((row) => row.userId)).size,
teachingDays: new Set(completed.map((row) => row.date)).size, minutes }
}
}

View File

@@ -1,6 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing'
import { BadRequestException, NotFoundException } from '@nestjs/common'
import { UserService } from '../user.service'
import { UserController } from '../user.controller'
import { PrismaService } from '../../prisma/prisma.service'
import {
MembershipStatus,
@@ -457,6 +458,24 @@ describe('UserService', () => {
expect(mockPrisma.lessonSupplement.groupBy).toHaveBeenCalledWith({ by: ['userId'], where: { userId: { in: ['user-1'] }, revokedAt: null }, _sum: { quantity: true } })
})
describe('member filters', () => {
it.each([
['ACTIVE', { memberships: { some: { status: MembershipStatus.ACTIVE } } }],
['NONE', { NOT: { memberships: { some: { status: MembershipStatus.ACTIVE } } } }],
['TIMES', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TIMES } } } }],
['DURATION', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.DURATION } } } }],
['TRIAL', { memberships: { some: { status: MembershipStatus.ACTIVE, cardType: { type: CardTypeCategory.TRIAL } } } }],
[undefined, {}],
])('passes %s through the controller and applies the same filter to list and count', async (filter, where) => {
mockPrisma.user.findMany.mockResolvedValue([])
mockPrisma.user.count.mockResolvedValue(0)
const controller = new UserController(service)
await controller.getMembers('2', '20', undefined, filter as string | undefined)
expect(mockPrisma.user.findMany).toHaveBeenCalledWith(expect.objectContaining({ where, skip: 20, take: 20 }))
expect(mockPrisma.user.count).toHaveBeenCalledWith({ where })
})
})
describe('getMemberDetail', () => {
const cardType = {
id: 'ct-1',

View File

@@ -76,7 +76,7 @@ export class UserController {
@Query('cardType') cardType?: string,
) {
const validCardType =
cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE')
cardType && cardType !== 'undefined' && (VALID_CARD_TYPES.has(cardType) || cardType === 'NONE' || cardType === 'ACTIVE')
? cardType
: undefined
return this.userService.getMembers(

View File

@@ -382,9 +382,11 @@ export class UserService {
}
: {}
// cardType filter: NONE = no active membership, otherwise filter by card type category
// ACTIVE and NONE are complementary membership-status filters.
if (cardType === 'NONE') {
where.NOT = { memberships: { some: { status: MembershipStatus.ACTIVE } } }
} else if (cardType === 'ACTIVE') {
where.memberships = { some: { status: MembershipStatus.ACTIVE } }
} else if (cardType && VALID_CARD_TYPES.has(cardType)) {
where.memberships = {
some: {

View File

@@ -53,6 +53,9 @@ export type {
// Types
export type {
TeachingAnalytics,
TeachingAnalyticsRecord,
TeachingAnalyticsSummary,
User,
UserProfileResponse,
UpdateProfileDto,

View File

@@ -59,3 +59,5 @@ export type {
InviteActivitySummary,
} from './invite'
export { FlashSalePhase } from './flash-sale'
export type { TeachingAnalytics, TeachingAnalyticsRecord, TeachingAnalyticsSummary } from './teaching-analytics'

View File

@@ -0,0 +1,31 @@
import type { BookingStatus } from '../enums'
export interface TeachingAnalyticsRecord {
id: string
userId: string
nickname: string
slotId: string
date: string
startTime: string
endTime: string
cardName: string
status: BookingStatus
needsReview: boolean
}
export interface TeachingAnalyticsSummary {
sessions: number
attendances: number
students: number
minutes: number
teachingDays: number
}
export interface TeachingAnalytics {
month: string
generatedAt: string
summary: TeachingAnalyticsSummary
previousMonth: string
previous: TeachingAnalyticsSummary
records: TeachingAnalyticsRecord[]
}