@@ -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 : 100 vh ; box - sizing : border - box ; background : # f7f5ef ; color : # 35483 e ; padding : 0 30 rpx calc ( 48 rpx + 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 : 40 rpx 4 rpx 28 rpx ; display : flex ; flex - direction : column ; gap : 14 rpx ; }
. eyebrow { font - size : 18 rpx ; letter - spacing : 4 rpx ; color : # 687 d6e ; }
. title { font - family : 'Songti SC' , 'STSong' , serif ; font - size : 40 rpx ; line - height : 1.6 ; }
. muted { color : # 73796 f ; font - size : 23 rpx ; }
. small { font - size : 22 rpx ; }
. month - nav { display : flex ; justify - content : space - between ; align - items : center ; border - top : 1 rpx solid # d9ddd2 ; border - bottom : 1 rpx solid # d9ddd2 ; button { width : 80 rpx ; line - height : 88 rpx ; font - size : 40 rpx ; } }
. month - label { padding : 20 rpx ; font - size : 33 rpx ; font - family : 'Songti SC' , 'STSong' , serif ; }
. toolbar { display : flex ; justify - content : space - between ; align - items : center ; font - size : 22 rpx ; color : # 677765 ; min - height : 76 rpx ; button { padding : 16 rpx 0 ; } }
. hero { background : # 344 f42 ; color : # faf7e9 ; border - radius : 12 rpx 12 rpx 48 rpx 12 rpx ; padding : 36 rpx ; }
. hero - label { font - size : 25 rpx ; letter - spacing : 3 rpx ; }
. hero - number { font - family : 'Baskerville' , 'Times New Roman' , serif ; font - size : 116 rpx ; line - height : 1.25 ; text { font - size : 26 rpx ; padding - left : 18 rpx ; } }
. hero - note { font - size : 21 rpx ; color : # d1dbc9 ; }
. hero - footer { display : flex ; margin - top : 30 rpx ; padding - top : 26 rpx ; border - top : 1 rpx solid # 6 d8070 ; gap : 18 rpx ; > view { flex : 1 ; display : flex ; flex - direction : column ; font - size : 21 rpx ; gap : 8 rpx ; } }
. metric { font - size : 40 rpx ; font - family : 'Baskerville' , 'Times New Roman' , serif ; font - variant - numeric : tabular - nums ; }
. unit { font - size : 22 rpx ; margin - left : 14 rpx ; }
. metrics { display : flex ; padding : 30 rpx 0 ; border - bottom : 1 rpx solid # d9ddd2 ; > view { flex : 1 ; display : flex ; flex - direction : column ; gap : 12 rpx ; font - size : 24 rpx ; padding - left : 30 rpx ; & : last - child { border - left : 1 rpx solid # d9ddd2 ; } } }
. notice { width : 100 % ; text - align : left ; margin - top : 26 rpx ; padding : 24 rpx ; display : flex ; flex - direction : column ; gap : 12 rpx ; background : # f0e5d2 ; color : # 805 c30 ; font - size : 23 rpx ; border - radius : 12 rpx ; }
. section { margin - top : 28 rpx ; background : # fffefa ; border : 1 rpx solid # e3e5da ; border - radius : 16 rpx ; padding : 28 rpx ; }
. heading { display : flex ; flex - wrap : wrap ; align - items : baseline ; justify - content : space - between ; gap : 12 rpx ; margin - bottom : 24 rpx ; font - size : 30 rpx ; }
. heading > text : first - child { font - family : 'Songti SC' , 'STSong' , serif ; }
. calendar { display : grid ; grid - template - columns : repeat ( 7 , minmax ( 0 , 1 fr ) ) ; gap : 8 rpx ; }
. weekday { text - align : center ; font - size : 21 rpx ; color : # 73796 f ; padding - bottom : 14 rpx ; }
. day { display : flex ; flex - direction : column ; justify - content : center ; align - items : center ; min - height : 92 rpx ; border - radius : 10 rpx ; font - size : 25 rpx ; border : 2 rpx solid transparent ; }
. day - count { font - size : 18 rpx ; margin - top : 7 rpx ; color : # 66785 f ; }
. taught { background : # e8eee2 ; }
. selected { border - color : # 344 f42 ; background : # 344 f42 ; color : # fff ; . day - count { color : # fff ; } }
. footnote { display : block ; color : # 73796 f ; font - size : 22 rpx ; line - height : 1.8 ; padding - top : 24 rpx ; }
. search { background : # f2f3ec ; border - radius : 10 rpx ; padding : 20 rpx ; font - size : 25 rpx ; margin - bottom : 14 rpx ; }
. student - row { display : flex ; align - items : center ; gap : 20 rpx ; width : 100 % ; padding : 24 rpx 0 ; text - align : left ; border - bottom : 1 rpx solid # eeeee5 ; }
. rank { color : # 77836 e ; font - family : 'Baskerville' , serif ; font - size : 26 rpx ; }
. student - main { flex : 1 ; min - width : 0 ; }
. student - name { display : block ; font - size : 28 rpx ; word - break : break - all ; }
. track { height : 5 rpx ; background : # eef0e6 ; margin : 14 rpx 0 8 rpx ; }
. fill { height : 100 % ; background : # 91 a180 ; }
. student - count { flex - shrink : 0 ; font - size : 34 rpx ; }
. more { padding : 24 rpx 0 0 ; width : 100 % ; font - size : 24 rpx ; color : # 52714 e ; }
. distribution { display : flex ; justify - content : space - between ; gap : 20 rpx ; padding : 20 rpx 0 ; border - bottom : 1 rpx solid # eeeee5 ; font - size : 24 rpx ; > text : first - child { flex : 1 ; word - break : break - all ; } }
. status - scroll { width : 100 % ; }
. status - tabs { display : flex ; gap : 12 rpx ; padding - bottom : 12 rpx ; white - space : nowrap ; button { flex - shrink : 0 ; padding : 16 rpx 20 rpx ; border - radius : 8 rpx ; background : # f1f2eb ; font - size : 22 rpx ; } . active { color : white ; background : # 344 f42 ; } }
. filters { margin - top : 16 rpx ; font - size : 22 rpx ; color : # 647 b58 ; display : flex ; flex - wrap : wrap ; align - items : center ; justify - content : space - between ; button { padding : 16 rpx 0 ; } }
. record { display : flex ; flex - direction : column ; gap : 12 rpx ; border - bottom : 1 rpx solid # e9ebdf ; padding : 24 rpx 0 ; }
. record - top { display : flex ; align - items : center ; justify - content : space - between ; gap : 16 rpx ; }
. record - name { font - size : 28 rpx ; text - align : left ; word - break : break - all ; }
. badge { flex - shrink : 0 ; font - size : 20 rpx ; color : # 867257 ; background : # f3eee4 ; padding : 6 rpx 12 rpx ; border - radius : 6 rpx ; }
. completed { color : # 53704 b ; background : # eaf0e2 ; }
. record - date { font - size : 25 rpx ; }
. notes { padding : 32 rpx 8 rpx ; display : flex ; flex - direction : column ; gap : 16 rpx ; font - size : 22 rpx ; line - height : 1.9 ; color : # 73796 f ; }
. notes - title { color : # 455 d49 ; font - size : 25 rpx ; }
. empty { padding : 80 rpx 28 rpx ; text - align : center ; font - size : 26 rpx ; line - height : 1.8 ; color : # 737 e70 ; }
. compact { padding : 36 rpx 20 rpx 0 ; }
. outline { padding : 20 rpx ; margin - top : 24 rpx ; border : 1 rpx solid # b2bfaa ; border - radius : 12 rpx ; }
< / style >