- 将 admin 相关的 store/utils 移入对应子目录(pages/admin/stores、pages/admin/utils) - 更新 manifest.json、pages.json 路由与配置 - 个人中心 ProfileMenu 移除顶部「我的会员卡 / 我的预约」快捷卡片,与 UserCard 会员卡入口合并 - 「我的预约」下移至菜单列表,紧邻「个人信息」 - 清理 profile/index.vue 中不再使用的 bookingStore / 计算属性 / 透传
280 lines
20 KiB
Vue
280 lines
20 KiB
Vue
<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>
|