Initial commit

This commit is contained in:
tanxin
2026-10-29 10:08:39 +08:00
commit 4c6243a274
124 changed files with 17359 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
<template>
<view class="page-container">
<view class="header-panel"><text class="header-title">预警信息</text><text class="header-subtitle">{{ studentName }}的学习动态</text></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true">
<view v-if="isLoading" class="status-panel"><text class="status-text">预警信息加载中</text></view>
<view v-else-if="alerts.length === 0" class="status-panel"><text class="status-text">暂无预警信息</text></view>
<view v-else v-for="alert in alerts" :key="alert.id" class="alert-card">
<view class="alert-card-top"><view class="alert-level-dot" :class="levelDotClass(alert.level)"></view><text class="alert-title">{{ alert.title }}</text><text v-if="!alert.isRead" class="unread-mark">未读</text></view>
<text class="alert-content">{{ alert.content }}</text>
<text class="alert-time">{{ formatTime(alert.occurredAt) }}</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { apiGetStudentAlerts, type StudentAlert, type StudentAlertLevel } from '../../api/student'
import { homeStudentProfile } from '../../store/home'
const alerts = ref<StudentAlert[]>([])
const isLoading = ref(true)
const studentName = computed((): string => homeStudentProfile.value?.name ?? '孩子')
onLoad(() => {
loadAlerts()
})
async function loadAlerts() {
const studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
isLoading.value = false
return
}
const res = await apiGetStudentAlerts(studentId, 1, 20)
if (res && res.data) {
alerts.value = res.data.list
}
isLoading.value = false
}
function levelDotClass(level: StudentAlertLevel): string {
return level === 'low' ? 'alert-level-low' : level === 'medium' ? 'alert-level-medium' : 'alert-level-high'
}
function formatTime(value: string): string {
return value.length > 0 ? value.substring(5, 16).replace('T', ' ') : ''
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: column; padding: 18px 20px; background-color: #5d3de8; }
.header-title { font-size: 21px; font-weight: 700; color: #ffffff; }
.header-subtitle { margin-top: 6px; font-size: 13px; color: #e5ddff; }
.content-scroll { flex: 1; padding: 16px 20px; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 160px; }
.status-text { font-size: 14px; color: #8992a5; }
.alert-card { display: flex; flex-direction: column; margin-bottom: 12px; padding: 16px; border-radius: 14px; background-color: #ffffff; }
.alert-card-top { display: flex; flex-direction: row; align-items: center; }
.alert-level-dot { width: 9px; height: 9px; margin-right: 8px; border-radius: 5px; }
.alert-level-high { background-color: #e25a5a; }
.alert-level-medium { background-color: #e8a129; }
.alert-level-low { background-color: #4d87cf; }
.alert-title { flex: 1; font-size: 15px; font-weight: 700; color: #30384a; }
.unread-mark { padding: 3px 7px; border-radius: 8px; font-size: 10px; color: #554be9; background-color: #eeefff; }
.alert-content { margin-top: 10px; font-size: 13px; line-height: 20px; color: #626c7e; }
.alert-time { margin-top: 10px; font-size: 11px; color: #9aa2b1; }
</style>
+75
View File
@@ -0,0 +1,75 @@
<template>
<view class="page-container">
<view class="header-panel"><view class="header-back" @tap="goHome"><UniIcons type="back" class="header-back-icon"></UniIcons></view><view class="header-copy"><text class="header-title">成长激励</text><text class="header-subtitle">{{ growth.studentName }} · {{ growth.studentGrade }}·{{ growth.studentClass }}</text></view><view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true"><view v-if="isLoading" class="status-panel"><text class="status-text">成长数据加载中</text></view><view v-else class="content-panel"><view class="points-card"><view class="points-heading"><view class="points-copy"><text class="points-label">积分余额</text><text class="points-value">{{ growth.pointsBalance }}</text></view><view class="level-badge"><text class="level-label">等级</text><text class="level-value">Lv.{{ growth.level }}</text></view></view><view class="points-stats"><view class="points-stat"><text class="points-stat-value">{{ growth.rank }}</text><text class="points-stat-label">班级排名</text></view><view class="points-stat points-stat-middle"><text class="points-stat-value">{{ growth.streakDays }}</text><text class="points-stat-label">未能连续</text></view><view class="points-stat"><text class="points-stat-value">{{ growth.totalPoints }}</text><text class="points-stat-label">获得总量</text></view></view></view><view class="flow-card"><text class="section-title">积分流水</text><text class="section-description">记录孩子每天做了哪些正向行为</text><view class="flow-list"><view v-for="record in growth.records" :key="record.id" class="flow-item"><view class="flow-icon" :class="'flow-icon-' + record.icon"><UniIcons :type="record.icon === 'calendar' ? 'calendar' : record.icon === 'assignment' ? 'checkbox' : record.icon === 'streak' ? 'fire' : record.icon === 'accuracy' ? 'circle-filled' : record.icon === 'mastery' ? 'star-filled' : 'compose'" class="flow-icon-glyph"></UniIcons></view><view class="flow-copy"><text class="flow-title">{{ record.title }}</text><text class="flow-time">{{ record.timeText }}</text></view><text class="flow-points">+{{ record.points }}</text></view></view></view></view></scroll-view>
</view>
</template>
<script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentGrowth, apiGetStudentHomeProfile, type StudentGrowth } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
const growth = ref<StudentGrowth>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pointsBalance: 0, level: 0, rank: 0, streakDays: 0, totalPoints: 0, records: [] })
const isLoading = ref(true)
onLoad(() => { loadGrowth() })
async function loadGrowth() {
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (studentId) {
const res = await apiGetStudentGrowth(studentId)
if (res && res.data) growth.value = res.data
}
isLoading.value = false
}
function goHome() { uni.reLaunch({ url: '/pages/index/index' }) }
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #554be9; }
.content-scroll { flex: 1; }
.content-panel { display: flex; flex-direction: column; padding: 16px 20px 28px; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.points-card { display: flex; flex-direction: column; padding: 16px; border-radius: 16px; background-color: #ff9e19; }
.points-heading { display: flex; flex-direction: row; justify-content: space-between; }
.points-copy { display: flex; flex-direction: column; }
.points-label { font-size: 12px; color: #fff4d8; }
.points-value { margin-top: 2px; font-size: 31px; font-weight: 700; color: #ffffff; }
.level-badge { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 54px; height: 54px; border-radius: 18px; background-color: rgba(255, 255, 255, 0.22); }
.level-label { font-size: 10px; color: #fff4d8; }
.level-value { margin-top: 2px; font-size: 15px; font-weight: 700; color: #ffffff; }
.points-stats { display: flex; flex-direction: row; margin-top: 11px; padding: 10px 0; border-radius: 12px; background-color: rgba(255, 255, 255, 0.18); }
.points-stat { display: flex; flex-direction: column; align-items: center; flex: 1; }
.points-stat-middle { border-left: 1px solid rgba(255, 255, 255, 0.3); border-right: 1px solid rgba(255, 255, 255, 0.3); }
.points-stat-value { font-size: 16px; font-weight: 700; color: #ffffff; }
.points-stat-label { margin-top: 4px; font-size: 10px; color: #fff4d8; }
.flow-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 16px; background-color: #ffffff; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.section-description { margin-top: 6px; font-size: 11px; color: #8992a5; }
.flow-list { display: flex; flex-direction: column; margin-top: 12px; }
.flow-item { display: flex; flex-direction: row; align-items: center; margin-bottom: 8px; padding: 10px; border-radius: 12px; background-color: #f6f8fb; }
.flow-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: 8px; background-color: #ffffff; }
.flow-icon-glyph { font-size: 16px; color: #ff9e19; }
.flow-copy { display: flex; flex-direction: column; flex: 1; margin-left: 10px; }
.flow-title { font-size: 13px; color: #242b3d; }
.flow-time { margin-top: 3px; font-size: 10px; color: #94a0b1; }
.flow-points { font-size: 13px; font-weight: 700; color: #f29900; }
</style>
+649
View File
@@ -0,0 +1,649 @@
<template>
<view class="page-container">
<!-- 固定顶部信息区 -->
<view class="header-panel">
<view class="header-copy">
<text class="header-greeting">下午好,王女士</text>
<text class="header-title">孩子今天的学习情况</text>
<text class="header-update">数据更新时间 {{ metricsUpdateText }} · 系统定时预计暂停,非实时</text>
</view>
<view class="notification-button" @tap="goAlerts">
<view class="notification-bell"></view>
<view class="notification-clapper"></view>
<view class="notification-dot"></view>
</view>
</view>
<!-- 仅此区域滚动 -->
<scroll-view class="content-scroll" direction="vertical" scroll-y="true" :refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="handleRefresh">
<view class="student-panel">
<view class="student-row">
<view class="student-avatar">
<image v-if="student.avatar" class="student-avatar-image" :src="student.avatar" mode="aspectFill"></image>
<text v-else class="avatar-face">{{ studentInitial }}</text>
</view>
<view class="student-identity">
<view class="student-name-row">
<text class="student-name">{{ student.name }}</text>
<text class="student-grade">{{ student.grade }}</text>
</view>
<view class="student-level-row">
<view class="level-medal"></view>
<text class="student-level-label">综合等级</text>
<text class="student-level-value">{{ levelText }}</text>
</view>
</view>
<view class="switch-button" @tap="openStudentPicker">
<view class="switch-icon"></view>
<text class="switch-text">切换</text>
</view>
</view>
<view class="metrics-grid">
<view class="metric-item">
<text class="metric-label">今日上线</text>
<text class="metric-value" :class="metrics.onlineToday ? 'metric-green' : ''">{{ metrics.onlineToday ? '已上线' : '未上线' }}</text>
</view>
<view class="metric-item">
<text class="metric-label">今日打卡</text>
<text class="metric-value" :class="metrics.checkedInToday ? 'metric-green' : ''">{{ metrics.checkedInToday ? '已打卡' : '未打卡' }}</text>
</view>
<view class="metric-item">
<text class="metric-label">今日学习时长</text>
<text class="metric-value">{{ learningDurationText }}</text>
</view>
<view class="metric-item">
<text class="metric-label">今日正确率</text>
<text class="metric-value">{{ accuracyText }}</text>
</view>
<view class="metric-item">
<text class="metric-label">本周学习天数</text>
<text class="metric-value metric-blue">{{ metrics.learningDaysThisWeek }}/7 天</text>
</view>
<view class="metric-item">
<text class="metric-label">待完成作业</text>
<text class="metric-value metric-orange">{{ metrics.pendingHomeworkCount }} 个</text>
</view>
</view>
</view>
<view class="reminder-section">
<view class="section-heading">
<text class="section-title">预警提醒</text>
<text class="section-link" @tap="goAlerts">全部预警 ></text>
</view>
<view class="reminder-list">
<view v-if="alerts.length === 0" class="reminder-empty"><text class="reminder-empty-text">暂无预警信息</text></view>
<view v-else v-for="alert in alerts" :key="alert.id" class="reminder-item"><text class="reminder-tag" :class="alertLevelClass(alert.level)">{{ alert.title }}</text><text class="reminder-text">{{ alert.content }}</text></view>
</view>
</view>
<view class="entry-section">
<text class="section-title">常用入口</text>
<view class="entry-grid">
<view class="entry-card" @tap="goTodayLearning"><UniIcons type="calendar" class="entry-icon"></UniIcons><text class="entry-title">今日学习</text></view>
<view class="entry-card" @tap="goStudyReport"><UniIcons type="calendar-filled" class="entry-icon"></UniIcons><text class="entry-title">周报月报</text></view>
<view class="entry-card" @tap="goVocabulary"><UniIcons type="font" class="entry-icon"></UniIcons><text class="entry-title">英语词汇</text></view>
<view class="entry-card" @tap="goGrowth"><UniIcons type="gift-filled" class="entry-icon"></UniIcons><text class="entry-title">成长激励</text></view>
</view>
</view>
</scroll-view>
<MainTabBar active-tab="home"></MainTabBar>
<page-container :show="showStudentPicker" position="bottom" :round="true" :overlay="true" :close-on-slide-down="true" @beforeleave="closeStudentPicker">
<view class="student-picker-panel">
<view class="student-picker-header">
<text class="student-picker-title">选择孩子</text>
<text class="student-picker-subtitle">选择后将更新首页学习信息</text>
</view>
<scroll-view class="student-picker-list" direction="vertical" scroll-y="true">
<view v-if="isStudentsLoading" class="student-picker-empty"><text class="student-picker-empty-text">学生信息加载中</text></view>
<view v-else-if="boundStudents.length === 0" class="student-picker-empty"><text class="student-picker-empty-text">暂无已绑定学生</text></view>
<view v-else v-for="item in boundStudents" :key="item.id" class="student-picker-item" :class="selectedStudentId === item.id ? 'student-picker-item-selected' : ''" @tap="selectStudent(item.id)">
<view class="student-picker-avatar"><image v-if="item.avatar" class="student-picker-avatar-image" :src="item.avatar" mode="aspectFill"></image><text v-else class="student-picker-avatar-text">{{ item.name.substring(0, 1) }}</text></view>
<view class="student-picker-info"><view class="student-picker-name-row"><text class="student-picker-name">{{ item.name }}</text><text class="student-picker-grade">{{ item.grade }}</text></view><text class="student-picker-detail">账号:{{ item.account }}</text><text class="student-picker-detail">学校:{{ item.school }}</text></view>
<view v-if="selectedStudentId === item.id" class="student-picker-check"><text class="student-picker-check-text">已选</text></view>
</view>
</scroll-view>
<view class="student-picker-footer"><view class="student-picker-cancel" @tap="closeStudentPicker"><text class="student-picker-cancel-text">取消</text></view><view class="student-picker-confirm" @tap="confirmStudent"><text class="student-picker-confirm-text">确定</text></view></view>
</view>
</page-container>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetBoundStudents, apiGetStudentAlerts, apiGetStudentHomeMetrics, apiGetStudentHomeProfile, type BoundStudentSummary, type StudentAlert, type StudentAlertLevel, type StudentHomeMetrics, type StudentHomeProfile } from '../../api/student'
import { homeStudentAlerts, homeStudentMetrics, homeStudentProfile, setHomeStudentAlerts, setHomeStudentMetrics, setHomeStudentProfile } from '../../store/home'
const loadingStudent = ref<StudentHomeProfile>({
id: '',
name: '加载中',
account: '',
grade: '',
school: '',
avatar: '',
comprehensiveLevel: 'qualified'
})
const isRefreshing = ref(false)
const showStudentPicker = ref(false)
const isStudentsLoading = ref(false)
const boundStudents = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref('')
const student = computed((): StudentHomeProfile => homeStudentProfile.value ?? loadingStudent.value)
const metrics = computed((): StudentHomeMetrics => homeStudentMetrics.value ?? {
studentId: '', onlineToday: false, checkedInToday: false, learningMinutesToday: 0,
accuracyToday: null, learningDaysThisWeek: 0, pendingHomeworkCount: 0, updatedAt: ''
})
const alerts = computed((): StudentAlert[] => homeStudentAlerts.value ?? [])
const studentInitial = computed((): string => {
return student.value.name.length > 0 ? student.value.name.substring(0, 1) : '学'
})
const levelText = computed((): string => {
if (student.value.comprehensiveLevel === 'excellent') return '优秀'
if (student.value.comprehensiveLevel === 'good') return '良好'
if (student.value.comprehensiveLevel === 'qualified') return '合格'
return '待提升'
})
const learningDurationText = computed((): string => {
const hours = Math.floor(metrics.value.learningMinutesToday / 60)
const minutes = metrics.value.learningMinutesToday % 60
return hours > 0 ? `${hours}h${minutes}m` : `${minutes}m`
})
const accuracyText = computed((): string => {
return metrics.value.accuracyToday === null ? '--' : `${metrics.value.accuracyToday}%`
})
const metricsUpdateText = computed((): string => {
return metrics.value.updatedAt.length > 0 ? metrics.value.updatedAt.substring(5, 16).replace('T', ' ') : '--'
})
onLoad(() => {
if (!homeStudentProfile.value || !homeStudentMetrics.value || homeStudentAlerts.value === null) {
loadHomeData()
}
})
async function loadStudentHomeProfile(studentId?: string, showLoading: boolean = true) {
const res = await apiGetStudentHomeProfile(studentId, showLoading)
if (res && res.data) {
setHomeStudentProfile(res.data)
}
}
async function loadStudentHomeMetrics(studentId: string, showLoading: boolean = true) {
const res = await apiGetStudentHomeMetrics(studentId, showLoading)
if (res && res.data) {
setHomeStudentMetrics(res.data)
}
}
async function loadStudentAlerts(studentId: string, showLoading: boolean = true) {
const res = await apiGetStudentAlerts(studentId, 1, 20, 3, showLoading)
if (res && res.data) {
setHomeStudentAlerts(res.data.list)
}
}
async function loadHomeData(studentId?: string, showLoading: boolean = true) {
await loadStudentHomeProfile(studentId, showLoading)
if (student.value.id) {
await Promise.all([
loadStudentHomeMetrics(student.value.id, showLoading),
loadStudentAlerts(student.value.id, showLoading)
])
}
}
async function handleRefresh() {
if (isRefreshing.value) return
isRefreshing.value = true
await loadHomeData(student.value.id, false)
isRefreshing.value = false
}
async function openStudentPicker() {
showStudentPicker.value = true
selectedStudentId.value = student.value.id
if (boundStudents.value.length > 0) return
isStudentsLoading.value = true
const res = await apiGetBoundStudents()
if (res && res.data) {
boundStudents.value = res.data.students
if (!selectedStudentId.value && res.data.currentStudentId) {
selectedStudentId.value = res.data.currentStudentId
}
}
isStudentsLoading.value = false
}
function selectStudent(studentId: string) {
selectedStudentId.value = studentId
}
function alertLevelClass(level: StudentAlertLevel): string {
return level === 'low' ? 'reminder-blue' : level === 'medium' ? 'reminder-yellow' : 'reminder-red'
}
function goAlerts() {
uni.navigateTo({ url: '/pages/alerts/index' })
}
function goTodayLearning() {
uni.navigateTo({ url: '/pages/today-learning/index' })
}
function goStudyReport() {
uni.navigateTo({ url: '/pages/study/index' })
}
function goVocabulary() {
uni.navigateTo({ url: '/pages/vocabulary/index' })
}
function goGrowth() {
uni.navigateTo({ url: '/pages/growth/index' })
}
function closeStudentPicker() {
showStudentPicker.value = false
}
async function confirmStudent() {
if (!selectedStudentId.value) {
uni.showToast({ title: '请选择学生', icon: 'none' })
return
}
closeStudentPicker()
if (selectedStudentId.value !== student.value.id) {
await loadHomeData(selectedStudentId.value)
}
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #f5f7fb;
}
.header-panel {
display: flex;
flex-direction: row;
align-items: flex-start;
justify-content: space-between;
padding: 15px 20px 17px;
background: linear-gradient(135deg, #5d3de8 0%, #8236e9 100%);
border-radius: 0 0 0 0;
}
.header-copy {
display: flex;
flex-direction: column;
flex: 1;
}
.header-greeting {
font-size: 13px;
color: #e5ddff;
margin-bottom: 5px;
}
.header-title {
font-size: 20px;
font-weight: 700;
color: #ffffff;
margin-bottom: 8px;
}
.header-update {
font-size: 10px;
color: #e5ddff;
line-height: 15px;
}
.notification-button {
position: relative;
width: 42px;
height: 42px;
margin-top: 2px;
margin-left: 12px;
border-radius: 22px;
background-color: rgba(255, 255, 255, 0.18);
}
.notification-bell {
position: absolute;
left: 14px;
top: 13px;
width: 14px;
height: 14px;
border-radius: 8px 8px 3px 3px;
background-color: #ffffff;
}
.notification-clapper {
position: absolute;
left: 12px;
bottom: 11px;
width: 18px;
height: 3px;
border-radius: 2px;
background-color: #ffffff;
}
.notification-dot {
position: absolute;
right: 9px;
top: 9px;
width: 7px;
height: 7px;
border-radius: 4px;
background-color: #ffd64a;
}
.content-scroll {
flex: 1;
padding: 12px 20px 18px;
}
.student-panel,
.reminder-section {
padding: 16px;
background-color: #ffffff;
border-radius: 16px;
margin-bottom: 12px;
}
.student-row {
display: flex;
flex-direction: row;
align-items: center;
}
.student-avatar {
position: relative;
width: 48px;
height: 48px;
border-radius: 15px;
background-color: #7161ed;
margin-right: 12px;
}
.student-avatar-image {
width: 48px;
height: 48px;
border-radius: 15px;
}
.student-picker-panel { display: flex; flex-direction: column; background-color: #ffffff; }
.student-picker-header { display: flex; flex-direction: column; padding: 18px 20px 12px; }
.student-picker-title { font-size: 18px; font-weight: 700; color: #242b3d; }
.student-picker-subtitle { margin-top: 5px; font-size: 12px; color: #8992a5; }
.student-picker-list { max-height: 340px; padding: 0 20px; }
.student-picker-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; }
.student-picker-empty-text { font-size: 13px; color: #8992a5; }
.student-picker-item { display: flex; flex-direction: row; align-items: center; padding: 14px 12px; margin-bottom: 10px; border-radius: 13px; background-color: #f7f9fc; }
.student-picker-item-selected { background-color: #f0efff; }
.student-picker-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 46px; height: 46px; border-radius: 14px; background-color: #7161ed; }
.student-picker-avatar-image { width: 46px; height: 46px; border-radius: 14px; }
.student-picker-avatar-text { font-size: 18px; font-weight: 700; color: #ffffff; }
.student-picker-info { display: flex; flex-direction: column; flex: 1; margin-left: 11px; }
.student-picker-name-row { display: flex; flex-direction: row; align-items: center; margin-bottom: 5px; }
.student-picker-name { margin-right: 8px; font-size: 15px; font-weight: 700; color: #2a3042; }
.student-picker-grade { padding: 2px 6px; border-radius: 7px; font-size: 10px; color: #554be9; background-color: #e8e7ff; }
.student-picker-detail { margin-top: 2px; font-size: 11px; color: #7e8799; }
.student-picker-check { padding: 4px 7px; border-radius: 9px; background-color: #554be9; }
.student-picker-check-text { font-size: 10px; color: #ffffff; }
.student-picker-footer { display: flex; flex-direction: row; padding: 12px 20px calc(12px + var(--uni-safe-area-inset-bottom)); border-top: 1px solid #edf0f5; }
.student-picker-cancel, .student-picker-confirm { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; height: 44px; border-radius: 11px; }
.student-picker-cancel { margin-right: 10px; background-color: #f0f2f7; }
.student-picker-confirm { background-color: #554be9; }
.student-picker-cancel-text { font-size: 15px; color: #596275; }
.student-picker-confirm-text { font-size: 15px; color: #ffffff; }
.avatar-face {
position: absolute;
left: 8px;
top: 12px;
font-size: 20px;
font-weight: 700;
color: #ffd69c;
}
.avatar-cap {
position: absolute;
right: 4px;
top: 6px;
font-size: 15px;
font-weight: 700;
color: #35394d;
}
.student-identity {
display: flex;
flex-direction: column;
flex: 1;
}
.student-name-row,
.student-level-row {
display: flex;
flex-direction: row;
align-items: center;
}
.student-name-row {
margin-bottom: 8px;
}
.student-name {
font-size: 17px;
font-weight: 700;
color: #22283a;
margin-right: 8px;
}
.student-grade {
padding: 2px 7px;
border-radius: 8px;
font-size: 11px;
color: #635ce8;
background-color: #eef0ff;
}
.level-medal {
width: 13px;
height: 13px;
border-radius: 7px;
background-color: #f4a400;
margin-right: 7px;
}
.student-level-label,
.metric-label {
font-size: 11px;
color: #8992a5;
}
.student-level-label {
margin-right: 9px;
}
.student-level-value {
font-size: 12px;
font-weight: 600;
color: #18a56b;
}
.switch-button {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px 10px;
border-radius: 17px;
background-color: #eef1ff;
}
.switch-icon {
width: 13px;
height: 10px;
border-radius: 7px 7px 3px 3px;
background-color: #5b58e9;
margin-right: 5px;
}
.switch-text {
font-size: 12px;
color: #5b58e9;
}
.metrics-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
margin-top: 13px;
}
.metric-item {
display: flex;
flex-direction: column;
width: 31.5%;
padding: 10px 8px;
margin-bottom: 7px;
border-radius: 12px;
background-color: #f7f9fc;
}
.metric-label {
margin-bottom: 7px;
}
.metric-value {
font-size: 15px;
font-weight: 700;
color: #20283a;
}
.metric-green {
color: #079b69;
}
.metric-blue {
color: #4b62d8;
}
.metric-orange {
color: #ee8900;
}
.section-heading {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.section-title {
font-size: 16px;
font-weight: 700;
color: #242b3d;
}
.section-link {
font-size: 12px;
color: #514ce8;
}
.reminder-list {
display: flex;
flex-direction: column;
}
.reminder-item {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 11px 10px;
margin-bottom: 8px;
border-radius: 12px;
background-color: #f7f9fc;
}
.reminder-item-last {
margin-bottom: 0;
}
.reminder-tag {
flex-shrink: 0;
padding: 3px 7px;
margin-right: 9px;
border-radius: 9px;
font-size: 10px;
}
.reminder-yellow {
color: #e69300;
background-color: #fff5d9;
}
.reminder-red {
color: #ed4a5c;
background-color: #fff0f2;
}
.reminder-text {
flex: 1;
font-size: 12px;
line-height: 20px;
color: #596275;
}
.reminder-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 72px; }
.reminder-empty-text { font-size: 13px; color: #8992a5; }
.reminder-blue { color: #3c79cf; background-color: #e8f1ff; }
.entry-section {
padding-bottom: 10px;
}
.entry-grid {
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: 12px;
}
.entry-card {
display: flex;
flex-direction: column;
align-items: center;
width: 23%;
padding: 8px 2px;
border-radius: 8px;
background-color: #ffffff;
}
.entry-icon {
margin-bottom: 9px;
font-size: 24px;
line-height: 24px;
color: #6558e9;
}
.entry-title {
font-size: 13px;
font-weight: 600;
color: #2a3042;
}
</style>
+360
View File
@@ -0,0 +1,360 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 步骤指示器 -->
<view class="steps-bar">
<view class="step-item">
<view :class="step >= 1 ? 'step-dot-active' : 'step-dot'"></view>
<text :class="step >= 1 ? 'step-label-active' : 'step-label'">验证手机</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view :class="step >= 2 ? 'step-dot-active' : 'step-dot'"></view>
<text :class="step >= 2 ? 'step-label-active' : 'step-label'">设置密码</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view :class="step >= 3 ? 'step-dot-active' : 'step-dot'"></view>
<text :class="step >= 3 ? 'step-label-active' : 'step-label'">完成</text>
</view>
</view>
<!-- 步骤一: 验证手机号 -->
<view v-if="step === 1" class="form-section">
<text class="section-title">验证手机号</text>
<text class="section-desc">请输入注册时使用的手机号</text>
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="code"
placeholder="请输入验证码"
maxlength="6"
/>
<countdown-btn
ref="countdownBtnRef"
:phone="phone"
codeType="forgot-password"
@send="handleSendCode"
/>
</view>
<view
class="btn-main"
:class="canStep1 ? 'btn-active' : 'btn-disabled'"
@tap="handleStep1"
>
<text class="btn-text">下一步</text>
</view>
</view>
<!-- 步骤二: 设置新密码 -->
<view v-if="step === 2" class="form-section">
<text class="section-title">设置新密码</text>
<text class="section-desc">请设置6-20位新密码</text>
<view class="input-wrapper">
<input
class="input-field"
type="password"
v-model="newPassword"
placeholder="请输入新密码(6-20位)"
maxlength="20"
/>
</view>
<view class="input-wrapper">
<input
class="input-field"
type="password"
v-model="confirmPassword"
placeholder="请再次输入新密码"
maxlength="20"
/>
</view>
<view
class="btn-main"
:class="canStep2 ? 'btn-active' : 'btn-disabled'"
@tap="handleStep2"
>
<text class="btn-text">完成</text>
</view>
</view>
<!-- 步骤三: 完成 -->
<view v-if="step === 3" class="success-section">
<view class="success-icon-wrap">
<view class="success-mark"></view>
</view>
<text class="success-title">密码重置成功</text>
<text class="success-desc">请使用新密码登录</text>
<view class="btn-main btn-active" @tap="handleGoLogin">
<text class="btn-text">去登录</text>
</view>
</view>
<!-- 返回登录 -->
<text class="back-text" @tap="handleBack">返回登录</text>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { apiSendCode, apiVerifyCode, apiResetPassword } from '../../api/auth'
import CountdownBtn from '../../components/countdown-btn.uvue'
const step = ref(1)
const phone = ref('')
const code = ref('')
const resetToken = ref('')
const newPassword = ref('')
const confirmPassword = ref('')
const countdownBtnRef = ref<any>(null)
const canStep1 = computed((): boolean => {
return /^1[3-9]\d{9}$/.test(phone.value) && code.value.length === 6
})
const canStep2 = computed((): boolean => {
return (
newPassword.value.length >= 6 &&
newPassword.value.length <= 20 &&
newPassword.value === confirmPassword.value
)
})
async function handleSendCode(p: string) {
if (!/^1[3-9]\d{9}$/.test(p)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
const res = await apiSendCode({ phone: p, type: 'forgot-password' })
if (res && res.code === 0) {
uni.showToast({ title: '验证码已发送', icon: 'success' })
countdownBtnRef.value?.startCountdown()
}
}
async function handleStep1() {
if (!canStep1.value) return
const res = await apiVerifyCode({ phone: phone.value, code: code.value })
if (res && res.data) {
resetToken.value = res.data.token
step.value = 2
}
}
async function handleStep2() {
if (!canStep2.value) {
if (newPassword.value !== confirmPassword.value) {
uni.showToast({ title: '两次密码输入不一致', icon: 'none' })
}
return
}
const res = await apiResetPassword({
phone: phone.value,
newPassword: newPassword.value,
resetToken: resetToken.value
})
if (res && res.code === 0) {
step.value = 3
}
}
function handleGoLogin() {
uni.navigateTo({ url: '/pages/login/index' })
}
function handleBack() {
uni.navigateBack()
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #FFFFFF;
}
.content-scroll {
flex: 1;
padding: 24px 24px calc(32px + var(--uni-safe-area-inset-bottom));
}
/* 步骤条 */
.steps-bar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 40px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
}
.step-dot {
width: 24px;
height: 24px;
border-radius: 12px;
background-color: #E5E7EB;
margin-bottom: 4px;
}
.step-dot-active {
width: 24px;
height: 24px;
border-radius: 12px;
background-color: #6C63FF;
margin-bottom: 4px;
}
.step-label {
font-size: 12px;
color: #9CA3AF;
}
.step-label-active {
font-size: 12px;
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E7EB;
margin: 0 8px;
margin-bottom: 20px;
}
/* 表单 */
.form-section {
display: flex;
flex-direction: column;
}
.section-title {
font-size: 20px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.section-desc {
font-size: 14px;
color: #6B7280;
margin-bottom: 28px;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 52px;
background-color: #F9FAFB;
border-radius: 12px;
padding: 0 16px;
margin-bottom: 16px;
}
.input-field {
flex: 1;
font-size: 16px;
color: #1F2937;
}
/* 按钮 */
.btn-main {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 52px;
border-radius: 26px;
margin-top: 8px;
}
.btn-active {
background-color: #6C63FF;
}
.btn-disabled {
background-color: #E5E7EB;
}
.btn-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
/* 成功页 */
.success-section {
display: flex;
flex-direction: column;
align-items: center;
padding-top: 40px;
}
.success-icon-wrap {
width: 80px;
height: 80px;
background-color: #ECFDF5;
border-radius: 40px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 24px;
}
.success-mark {
width: 28px;
height: 14px;
border-left: 4px solid #10B981;
border-bottom: 4px solid #10B981;
transform: rotate(-45deg);
}
.success-title {
font-size: 22px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.success-desc {
font-size: 14px;
color: #6B7280;
margin-bottom: 40px;
}
/* 返回 */
.back-text {
font-size: 14px;
color: #6C63FF;
text-align: center;
margin-top: 20px;
}
</style>
+333
View File
@@ -0,0 +1,333 @@
<template>
<view class="login-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- Logo区域 -->
<view class="logo-section">
<image class="logo" src="/static/logo.png" mode="aspectFit"></image>
<text class="app-title">家长学情</text>
<text class="app-subtitle">陪伴孩子的每一次进步</text>
</view>
<!-- 表单区域 -->
<view class="form-section">
<text class="form-title">登录家长端</text>
<text class="form-subtitle">使用注册的手机号与密码登录,查看孩子学情</text>
<!-- 手机号输入 -->
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="form.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<!-- 密码输入 -->
<view class="input-wrapper">
<input
class="input-field"
type="password"
v-model="form.password"
placeholder="请输入密码"
maxlength="20"
/>
</view>
<!-- 记住密码和忘记密码 -->
<view class="option-row">
<view class="remember-row" @tap="form.remember = !form.remember">
<view :class="form.remember ? 'checkbox-checked' : 'checkbox'"></view>
<text class="remember-text">记住密码</text>
</view>
<text class="forgot-text" @tap="handleForgotPassword">忘记密码?</text>
</view>
<!-- 登录按钮 -->
<view
class="btn-login"
:class="canSubmit ? 'btn-login-active' : 'btn-login-disabled'"
@tap="handleLogin"
>
<text class="btn-text">登录</text>
</view>
<!-- 切换验证码登录 -->
<text class="switch-text" @tap="handleSwitchToCodeLogin">验证码登录</text>
</view>
<!-- 底部注册入口 -->
<view class="footer-section">
<text class="footer-text">还没有账号? </text>
<text class="register-text" @tap="handleRegister">注册</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { apiLogin } from '../../api/auth'
import { setUserInfo, saveRememberInfo, getRememberInfo } from '../../store/user'
type LoginForm = {
phone: string
password: string
remember: boolean
}
const form = reactive<LoginForm>({
phone: '',
password: '',
remember: false
})
// 计算是否可以提交
const canSubmit = computed((): boolean => {
const phoneReg = /^1[3-9]\d{9}$/
return phoneReg.test(form.phone) && form.password.length >= 6
})
onLoad(() => {
// 加载记住的密码信息
const rememberInfo = getRememberInfo()
if (rememberInfo.remember) {
form.phone = rememberInfo.phone
form.password = rememberInfo.password
form.remember = true
}
})
/**
* 登录
*/
async function handleLogin() {
if (!canSubmit.value) return
const res = await apiLogin({
phone: form.phone,
password: form.password,
remember: form.remember
})
if (res && res.data) {
// 保存用户信息
setUserInfo(res.data)
// 保存记住密码信息
saveRememberInfo(form.phone, form.password, form.remember)
uni.showToast({
title: '登录成功',
icon: 'success'
})
// 跳转首页
setTimeout(() => {
uni.reLaunch({
url: '/pages/index/index'
})
}, 1000)
}
}
/**
* 切换到验证码登录
*/
function handleSwitchToCodeLogin() {
uni.navigateTo({
url: '/pages/login/verify-code'
})
}
/**
* 忘记密码
*/
function handleForgotPassword() {
uni.navigateTo({
url: '/pages/login/forgot-password'
})
}
/**
* 注册
*/
function handleRegister() {
uni.navigateTo({
url: '/pages/register/index'
})
}
</script>
<style>
.login-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #FFFFFF;
}
.content-scroll {
flex: 1;
padding: 24px 24px calc(24px + var(--uni-safe-area-inset-bottom));
}
/* Logo区域 */
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
}
.logo {
width: 80px;
height: 80px;
border-radius: 16px;
margin-bottom: 16px;
}
.app-title {
font-size: 24px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.app-subtitle {
font-size: 14px;
color: #9CA3AF;
}
/* 表单区域 */
.form-section {
display: flex;
flex-direction: column;
flex: 1;
}
.form-title {
font-size: 20px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.form-subtitle {
font-size: 14px;
color: #6B7280;
margin-bottom: 32px;
}
/* 输入框 */
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 52px;
background-color: #F9FAFB;
border-radius: 12px;
padding: 0 16px;
margin-bottom: 16px;
}
.input-field {
flex: 1;
font-size: 16px;
color: #1F2937;
}
/* 选项行 */
.option-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 24px;
}
.remember-row {
display: flex;
flex-direction: row;
align-items: center;
}
.checkbox {
width: 18px;
height: 18px;
border: 2px solid #D1D5DB;
border-radius: 4px;
margin-right: 8px;
}
.checkbox-checked {
width: 18px;
height: 18px;
background-color: #6C63FF;
border-radius: 4px;
margin-right: 8px;
}
.remember-text {
font-size: 14px;
color: #6B7280;
}
.forgot-text {
font-size: 14px;
color: #6C63FF;
}
/* 登录按钮 */
.btn-login {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 52px;
border-radius: 26px;
margin-bottom: 20px;
}
.btn-login-active {
background-color: #6C63FF;
}
.btn-login-disabled {
background-color: #E5E7EB;
}
.btn-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
/* 切换文字 */
.switch-text {
font-size: 14px;
color: #6C63FF;
text-align: center;
}
/* 底部区域 */
.footer-section {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: auto;
padding-top: 24px;
}
.footer-text {
font-size: 14px;
color: #6B7280;
}
.register-text {
font-size: 14px;
color: #6C63FF;
font-weight: 600;
}
</style>
+230
View File
@@ -0,0 +1,230 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 顶部 -->
<view class="logo-section">
<image class="logo" src="/static/logo.png" mode="aspectFit"></image>
<text class="page-title">验证码登录</text>
<text class="page-subtitle">使用手机验证码快速登录</text>
</view>
<!-- 表单区域 -->
<view class="form-section">
<!-- 手机号输入 -->
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<!-- 验证码输入 -->
<view class="input-wrapper">
<input
class="input-field"
type="number"
v-model="code"
placeholder="请输入验证码"
maxlength="6"
/>
<countdown-btn
ref="countdownBtnRef"
:phone="phone"
codeType="login"
@send="handleSendCode"
/>
</view>
<!-- 登录按钮 -->
<view
class="btn-login"
:class="canSubmit ? 'btn-active' : 'btn-disabled'"
@tap="handleLogin"
>
<text class="btn-text">登录</text>
</view>
<!-- 切换到密码登录 -->
<text class="switch-text" @tap="handleBack">密码登录</text>
</view>
<!-- 底部注册 -->
<view class="footer-section">
<text class="footer-text">还没有账号? </text>
<text class="register-text" @tap="handleRegister">注册</text>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { apiLoginByCode, apiSendCode } from '../../api/auth'
import CountdownBtn from '../../components/countdown-btn.uvue'
import { setUserInfo } from '../../store/user'
const phone = ref('')
const code = ref('')
const countdownBtnRef = ref<any>(null)
const canSubmit = computed((): boolean => {
const phoneReg = /^1[3-9]\d{9}$/
return phoneReg.test(phone.value) && code.value.length === 6
})
/**
* 发送验证码
*/
async function handleSendCode(p: string) {
if (!/^1[3-9]\d{9}$/.test(p)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
return
}
const res = await apiSendCode({ phone: p, type: 'login' })
if (res && res.code === 0) {
uni.showToast({ title: '验证码已发送', icon: 'success' })
countdownBtnRef.value?.startCountdown()
}
}
/**
* 登录
*/
async function handleLogin() {
if (!canSubmit.value) return
const res = await apiLoginByCode({ phone: phone.value, code: code.value })
if (res && res.data) {
setUserInfo(res.data)
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => {
uni.reLaunch({ url: '/pages/index/index' })
}, 1000)
}
}
function handleBack() {
uni.navigateBack()
}
function handleRegister() {
uni.navigateTo({ url: '/pages/register/index' })
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #FFFFFF;
}
.content-scroll {
flex: 1;
padding: 24px 24px calc(24px + var(--uni-safe-area-inset-bottom));
}
.logo-section {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 32px;
}
.logo {
width: 80px;
height: 80px;
border-radius: 16px;
margin-bottom: 16px;
}
.page-title {
font-size: 24px;
font-weight: 600;
color: #1F2937;
margin-bottom: 8px;
}
.page-subtitle {
font-size: 14px;
color: #9CA3AF;
}
.form-section {
display: flex;
flex-direction: column;
flex: 1;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 52px;
background-color: #F9FAFB;
border-radius: 12px;
padding: 0 16px;
margin-bottom: 16px;
}
.input-field {
flex: 1;
font-size: 16px;
color: #1F2937;
}
.btn-login {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 52px;
border-radius: 26px;
margin-bottom: 20px;
margin-top: 8px;
}
.btn-active {
background-color: #6C63FF;
}
.btn-disabled {
background-color: #E5E7EB;
}
.btn-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
.switch-text {
font-size: 14px;
color: #6C63FF;
text-align: center;
}
.footer-section {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: auto;
padding-top: 24px;
}
.footer-text {
font-size: 14px;
color: #6B7280;
}
.register-text {
font-size: 14px;
color: #6C63FF;
font-weight: 600;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical" scroll-y="true">
<view class="parent-card">
<view class="parent-avatar">
<text class="parent-avatar-text">👩</text>
</view>
<view class="parent-copy">
<text class="parent-name">{{ profileName }}</text>
<text class="parent-detail">{{ listFailed ? '学生信息加载失败' : isLoading ? '学生信息加载中' : '已关联 ' + students.length + ' 名学生' }} · 手机尾号</text>
<text class="parent-phone">6688</text>
</view>
<text class="parent-account">家长账号</text>
</view>
<view class="children-card">
<view class="section-heading">
<text class="section-title">我的孩子</text>
<text class="section-hint">点卡片可切换查看</text>
</view>
<view v-if="isLoading" class="loading-row">
<text class="loading-text">孩子信息加载中</text>
</view>
<view v-else-if="listFailed" class="loading-row" @tap="loadStudents"><text class="loading-text">加载失败,点击重试</text></view>
<view v-else v-for="(student, index) in students" :key="student.id" class="child-row" :class="selectedStudentId === student.id ? 'child-row-selected' : ''" @tap="selectStudent(student.id)">
<view class="child-avatar" :class="'child-avatar-' + index">
<text class="child-avatar-text">{{ student.name.substring(1, 2) }}</text>
</view>
<view class="child-copy">
<view class="child-name-row">
<text class="child-name">{{ student.name }}</text>
<text class="child-role">{{ index === 0 ? '儿子' : index === 1 ? '侄子' : '女儿' }}</text>
<text class="child-status">{{ index === 1 ? '中上' : '优秀' }}</text>
</view>
<text class="child-school">{{ student.school }} · {{ student.grade }}({{ index + 1 }})班</text>
</view>
<text class="child-action" :class="selectedStudentId === student.id ? 'child-action-selected' : ''">{{ selectedStudentId === student.id ? '当前查看' : '切换' }}</text>
</view>
<view class="binding-actions">
<view class="scan-button" @tap="openVerification('scan')">
<text class="binding-icon">▦</text>
<text class="binding-text binding-text-primary">扫码绑定</text>
</view>
<view class="manual-button" @tap="openVerification('manual')">
<text class="binding-icon binding-icon-dark">▤</text>
<text class="binding-text">手动输入</text>
</view>
</view>
<text class="binding-note">扫码或手动输入可验证学生信息;新增绑定功能尚需接入真实接口。如需解绑,请联系运营人员。</text>
</view>
<view class="explanation-card">
<text class="section-title">数据说明</text>
<text class="explanation-text">● 报告类数据(日报、周报、月报)由定时任务预计算,非实时,页面均展示“数据更新时间”。</text>
</view>
<view class="logout-card" @tap="handleLogout">
<text class="logout-text">退出登录</text>
</view>
</scroll-view>
<MainTabBar active-tab="profile"></MainTabBar>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import { apiGetBoundStudents, type BoundStudentSummary } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
import type { UserInfo } from '../../api/auth'
import { clearUserInfo } from '../../store/user'
const user = uni.getStorageSync('userInfo') as UserInfo | null
const profileName = user?.nickname || '大大怪'
const students = ref<BoundStudentSummary[]>([])
const selectedStudentId = ref(homeStudentProfile.value?.id ?? '')
const isLoading = ref(true)
const listFailed = ref(false)
onLoad(() => { loadStudents() })
async function loadStudents() {
isLoading.value = true
listFailed.value = false
try {
const res = await apiGetBoundStudents()
if (res && res.code === 0 && res.data) {
students.value = res.data.students
if (!selectedStudentId.value) selectedStudentId.value = res.data.currentStudentId ?? ''
} else listFailed.value = true
} catch (_error) { listFailed.value = true }
finally { isLoading.value = false }
}
function selectStudent(studentId: string) {
selectedStudentId.value = studentId
const student = students.value.find((item) => item.id === studentId)
if (student) setHomeStudentProfile({ id: student.id, name: student.name, account: student.account, grade: student.grade, school: student.school, avatar: student.avatar, comprehensiveLevel: 'good' })
}
function openVerification(mode: string) { uni.navigateTo({ url: '/pages/profile/verify-student?mode=' + mode }) }
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出当前账号吗?',
confirmText: '退出登录',
cancelText: '取消',
success: (result) => {
if (result.confirm) {
clearUserInfo()
uni.reLaunch({ url: '/pages/login/index' })
}
}
})
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.content-scroll { flex: 1; }
.parent-card { display: flex; flex-direction: row; align-items: center; margin: 20px 14px 0; padding: 18px 16px; border-radius: 22px 22px 0 0; background-color: #6542eb; }
.parent-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 46px; height: 46px; border-radius: 14px; background-color: #8469f1; }
.parent-avatar-text { font-size: 25px; }
.parent-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.parent-name { font-size: 18px; font-weight: 700; color: #ffffff; }
.parent-detail { margin-top: 4px; font-size: 11px; color: #eee9ff; }
.parent-phone { margin-top: 1px; font-size: 11px; color: #eee9ff; }
.parent-account { padding: 7px 11px; border-radius: 16px; font-size: 11px; color: #ffffff; background-color: #8064ed; }
.children-card { display: flex; flex-direction: column; margin: 0 14px; padding: 16px; border-radius: 0 0 16px 16px; background-color: #ffffff; }
.explanation-card { display: flex; flex-direction: column; margin: 0 14px; padding: 16px; background-color: #ffffff; }
.section-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.section-hint { font-size: 11px; color: #8c9ab0; }
.loading-row { display: flex; flex-direction: column; align-items: center; padding: 26px 0; }
.loading-text { font-size: 12px; color: #8992a5; }
.child-row { display: flex; flex-direction: row; align-items: center; margin-top: 10px; padding: 10px; border: 1px solid #edf0f5; border-radius: 15px; background-color: #ffffff; }
.child-row-selected { border: 2px solid #554be9; padding: 9px; background-color: #fafaff; }
.child-avatar { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 42px; height: 42px; border-radius: 12px; }
.child-avatar-0 { background-color: #6558e9; }
.child-avatar-1 { background-color: #12a9df; }
.child-avatar-2 { background-color: #e63a9c; }
.child-avatar-text { font-size: 18px; color: #ffffff; }
.child-copy { display: flex; flex-direction: column; flex: 1; margin-left: 10px; min-width: 0; }
.child-name-row { display: flex; flex-direction: row; align-items: center; }
.child-name { font-size: 14px; color: #242b3d; }
.child-role { margin-left: 7px; padding: 2px 6px; border-radius: 8px; font-size: 9px; color: #7c8ba1; background-color: #f1f4f8; }
.child-status { margin-left: 7px; padding: 2px 6px; border-radius: 8px; font-size: 9px; color: #16a979; background-color: #e7faf2; }
.child-school { margin-top: 4px; font-size: 10px; color: #8a99ae; }
.child-action { flex-shrink: 0; padding: 5px 8px; border-radius: 10px; font-size: 9px; color: #6e7c91; background-color: #f1f4f8; }
.child-action-selected { color: #ffffff; background-color: #554be9; }
.binding-actions { display: flex; flex-direction: row; margin-top: 10px; }
.scan-button { display: flex; flex-direction: row; align-items: center; justify-content: center; flex: 1; height: 38px; border-radius: 11px; background-color: #554be9; }
.manual-button { display: flex; flex-direction: row; align-items: center; justify-content: center; flex: 1; height: 38px; border-radius: 11px; margin-left: 8px; background-color: #f1f4f8; }
.binding-icon { margin-right: 6px; font-size: 14px; color: #ffffff; }
.binding-icon-dark { color: #65758b; }
.binding-text { font-size: 12px; color: #65758b; }
.binding-text-primary { color: #ffffff; }
.binding-note { margin-top: 9px; padding: 10px; font-size: 10px; line-height: 17px; color: #8a99ae; background-color: #f6f8fb; }
.explanation-card { margin-top: 16px; border-radius: 16px 16px 0 0; }
.explanation-text { margin-top: 14px; font-size: 11px; line-height: 20px; color: #718097; }
.logout-card { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 48px; margin: 16px 14px 24px; border-radius: 14px; background-color: #ffffff; }
.logout-text { font-size: 14px; color: #ec5365; }
</style>
+130
View File
@@ -0,0 +1,130 @@
<template>
<view class="page">
<scroll-view class="content" direction="vertical">
<view class="intro">
<text class="title">验证孩子的学生信息</text>
<text class="description">扫码或输入学生账号和姓名进行验证。验证不等于绑定,新增绑定仍需接入真实接口。</text>
</view>
<view class="card">
<text class="section-title">已绑定学生</text>
<text v-if="loading" class="hint">正在加载已绑定列表...</text>
<view v-else-if="loadFailed" class="retry" @tap="loadStudents"><text class="retry-text">列表加载失败,点击重试</text></view>
<text v-else class="hint">当前 {{ students.length }} / 3 名{{ students.length >= 3 ? ',已达到上限' : '' }}</text>
</view>
<view class="card">
<view class="tabs">
<view class="tab" :class="mode === 'scan' ? 'tab-active' : ''" @tap="changeMode('scan')"><text class="tab-text" :class="mode === 'scan' ? 'tab-text-active' : ''">扫码验证</text></view>
<view class="tab" :class="mode === 'manual' ? 'tab-active' : ''" @tap="changeMode('manual')"><text class="tab-text" :class="mode === 'manual' ? 'tab-text-active' : ''">手动输入</text></view>
</view>
<view v-if="mode === 'scan'" class="form">
<text class="section-title">对准学生二维码</text>
<view v-if="visible && !loading && !loadFailed && !verified" class="scanner"><QrScanner @scan="handleScan" @error="handleCameraError"></QrScanner></view>
<text class="hint">二维码格式为学生账号,学生姓名。请允许使用摄像头。</text>
</view>
<view v-else class="form">
<text class="label">学生账号</text>
<input class="input" v-model="account" placeholder="请输入学生账号" />
<text class="label">学生姓名</text>
<input class="input" v-model="name" placeholder="请输入学生姓名" />
<view class="submit" @tap="handleManual"><text class="submit-text">{{ verifying ? '验证中...' : '验证学生' }}</text></view>
</view>
</view>
<view v-if="verified" class="result">
<text class="result-title">已验证,尚未绑定</text>
<text class="result-detail">{{ verified.name }} · {{ verified.account }}</text>
<text class="result-detail">当前没有登录后新增绑定接口,需接入真实接口才能完成绑定。验证结果可能来自项目配置的 mock 服务。</text>
<view class="retry" @tap="verified = null"><text class="retry-text">验证其他学生</text></view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import QrScanner from '../../components/qr-scanner.uvue'
import { apiGetBoundStudents, apiVerifyStudentQRCode, apiVerifyStudentAccount, type BoundStudentSummary, type StudentInfo } from '../../api/student'
const students = ref<BoundStudentSummary[]>([])
const loading = ref(true)
const loadFailed = ref(false)
const verifying = ref(false)
const visible = ref(false)
const mode = ref('scan')
const account = ref('')
const name = ref('')
const verified = ref<StudentInfo | null>(null)
onLoad((options) => { if (options?.mode === 'manual') mode.value = 'manual'; loadStudents() })
onShow(() => { visible.value = true })
onHide(() => { visible.value = false })
async function loadStudents() {
loading.value = true
loadFailed.value = false
try {
const res = await apiGetBoundStudents()
if (res && res.code === 0 && res.data) students.value = res.data.students
else loadFailed.value = true
} catch (_error) { loadFailed.value = true }
finally { loading.value = false }
}
function changeMode(value: string) { if (!verifying.value) { mode.value = value; verified.value = null } }
function handleCameraError() { uni.showToast({ title: '无法使用摄像头,请检查权限', icon: 'none' }) }
function handleScan(qrCode: string) {
const separator = qrCode.indexOf(',')
const studentAccount = separator > 0 ? qrCode.slice(0, separator).trim() : ''
const studentName = separator > 0 ? qrCode.slice(separator + 1).trim() : ''
if (!studentAccount || !studentName) { uni.showToast({ title: '二维码格式无效', icon: 'none' }); return }
verify(studentAccount, studentName, qrCode)
}
function handleManual() {
const studentAccount = account.value.trim()
const studentName = name.value.trim()
if (!studentAccount || !studentName) { uni.showToast({ title: '请输入学生账号和姓名', icon: 'none' }); return }
if (!/^[\u4e00-\u9fa5]{2,10}$/.test(studentName)) { uni.showToast({ title: '姓名需为2至10个汉字', icon: 'none' }); return }
verify(studentAccount, studentName, '')
}
async function verify(studentAccount: string, studentName: string, qrCode: string) {
if (verifying.value) return
verified.value = null
if (loading.value || loadFailed.value) { uni.showToast({ title: '请先加载已绑定学生列表', icon: 'none' }); return }
if (students.value.some(item => item.account === studentAccount)) { uni.showToast({ title: '该学生已绑定', icon: 'none' }); return }
if (students.value.length >= 3) { uni.showToast({ title: '最多绑定3名学生', icon: 'none' }); return }
verifying.value = true
try {
const res = qrCode ? await apiVerifyStudentQRCode({ qrCode, account: studentAccount, name: studentName }) : await apiVerifyStudentAccount({ account: studentAccount, name: studentName })
if (res && res.code === 0 && res.data) {
if (students.value.some(item => item.id === res.data!.id || item.account === res.data!.account)) uni.showToast({ title: '该学生已绑定', icon: 'none' })
else verified.value = res.data
}
} catch (_error) { uni.showToast({ title: '学生信息验证失败', icon: 'none' }) }
finally { verifying.value = false }
}
</script>
<style>
.page { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.content { flex: 1; }
.intro { display: flex; flex-direction: column; margin: 20px 14px 0; padding: 20px 16px; border-radius: 16px; background-color: #6542eb; }
.title { font-size: 18px; font-weight: 700; color: #ffffff; }
.description { margin-top: 8px; font-size: 12px; line-height: 20px; color: #eee9ff; }
.card { display: flex; flex-direction: column; margin: 14px 14px 0; padding: 16px; border-radius: 16px; background-color: #ffffff; }
.section-title { font-size: 15px; font-weight: 700; color: #242b3d; }
.hint { margin-top: 10px; font-size: 12px; line-height: 19px; color: #8a99ae; }
.retry { margin-top: 12px; }
.retry-text { font-size: 12px; color: #554be9; }
.tabs { display: flex; flex-direction: row; padding: 4px; border-radius: 12px; background-color: #f1f4f8; }
.tab { display: flex; flex-direction: column; flex: 1; align-items: center; justify-content: center; height: 38px; border-radius: 9px; }
.tab-active { background-color: #554be9; }
.tab-text { font-size: 13px; color: #65758b; }
.tab-text-active { color: #ffffff; }
.form { display: flex; flex-direction: column; margin-top: 18px; }
.scanner { margin-top: 12px; }
.label { margin-top: 12px; font-size: 13px; color: #242b3d; }
.input { height: 44px; margin-top: 8px; padding: 0 12px; border-radius: 10px; font-size: 14px; background-color: #f5f7fb; }
.submit { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 44px; margin-top: 20px; border-radius: 11px; background-color: #554be9; }
.submit-text { font-size: 14px; color: #ffffff; }
.result { display: flex; flex-direction: column; margin: 14px 14px 24px; padding: 16px; border-radius: 16px; background-color: #ffffff; }
.result-title { font-size: 15px; font-weight: 700; color: #554be9; }
.result-detail { margin-top: 8px; font-size: 12px; line-height: 20px; color: #65758b; }
</style>
+322
View File
@@ -0,0 +1,322 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 步骤进度 -->
<view class="step-indicator">
<view class="step-item">
<view class="step-dot step-dot-completed"></view>
<text class="step-text">扫码绑定孩子</text>
</view>
<view class="step-line step-line-completed"></view>
<view class="step-item">
<view class="step-dot step-dot-completed"></view>
<text class="step-text">家长账号与密码</text>
</view>
<view class="step-line step-line-completed"></view>
<view class="step-item step-active">
<view class="step-dot step-dot-active"></view>
<text class="step-text step-text-active">注册完成</text>
</view>
</view>
<!-- 成功图标 -->
<view class="success-section">
<view class="success-icon-wrap">
<view class="success-mark"></view>
</view>
<text class="success-title">注册完成</text>
<text class="success-desc">已绑定 {{ students.length }} 名学生</text>
<text class="success-tip">使用手机号即可登录家长端查看孩子学情</text>
</view>
<!-- 已绑定学生列表 -->
<view class="student-list-section">
<view class="section-header">
<text class="section-title">已绑定学生</text>
<text class="section-count">共 {{ students.length }} 名</text>
</view>
<view class="student-item" v-for="(student, index) in students" :key="student.id">
<text class="student-index">{{ index + 1 }}</text>
<image class="student-avatar" :src="student.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<text class="student-account">账号 {{ student.account }}</text>
</view>
</view>
</view>
<!-- 操作按钮 -->
<view class="button-group">
<view class="btn-primary" @tap="handleGoHome">
<text class="btn-primary-text">去登录</text>
</view>
<view class="btn-secondary" @tap="handleGoLogin">
<text class="btn-secondary-text">返回首页</text>
</view>
</view>
</scroll-view>
</view>
</template>
<script setup lang="ts">
import { registrationStudents } from '../../store/registration'
const students = registrationStudents
function handleGoHome() {
uni.reLaunch({
url: '/pages/login/index'
})
}
function handleGoLogin() {
uni.reLaunch({
url: '/pages/index/index'
})
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #F5F5F5;
}
.content-scroll {
flex: 1;
padding: 16px;
padding-bottom: calc(16px + var(--uni-safe-area-inset-bottom));
}
/* 步骤指示器 */
.step-indicator {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 24px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #E5E5E5;
margin-bottom: 8px;
}
.step-dot-completed {
background-color: #10B981;
}
.step-dot-active {
background-color: #6C63FF;
width: 12px;
height: 12px;
border-radius: 6px;
}
.step-text {
font-size: 11px;
color: #999999;
text-align: center;
}
.step-text-active {
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E5E5;
margin: 0 4px;
margin-bottom: 20px;
}
.step-line-completed {
background-color: #10B981;
}
/* 成功区域 */
.success-section {
display: flex;
flex-direction: column;
align-items: center;
padding: 32px 16px;
background-color: #FFFFFF;
border-radius: 12px;
margin-bottom: 24px;
}
.success-icon-wrap {
width: 80px;
height: 80px;
background-color: #ECFDF5;
border-radius: 40px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 20px;
}
.success-check {
font-size: 0;
}
.success-mark {
width: 28px;
height: 14px;
border-left: 4px solid #10B981;
border-bottom: 4px solid #10B981;
transform: rotate(-45deg);
}
.success-title {
font-size: 24px;
font-weight: 700;
color: #1F2937;
margin-bottom: 8px;
}
.success-desc {
font-size: 16px;
color: #6B7280;
margin-bottom: 8px;
}
.success-tip {
font-size: 13px;
color: #9CA3AF;
}
/* 学生列表 */
.student-list-section {
display: flex;
flex-direction: column;
padding: 16px;
background-color: #FFFFFF;
border-radius: 12px;
margin-bottom: 32px;
}
.section-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.section-title {
font-size: 16px;
font-weight: 600;
color: #333333;
}
.section-count {
font-size: 13px;
color: #999999;
}
.student-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #F3F4F6;
}
.student-index {
width: 28px;
height: 28px;
background-color: #EEF2FF;
border-radius: 14px;
color: #6C63FF;
font-size: 14px;
font-weight: 600;
text-align: center;
line-height: 28px;
margin-right: 12px;
flex-shrink: 0;
}
.student-avatar {
width: 44px;
height: 44px;
border-radius: 22px;
margin-right: 12px;
background-color: #F5F5F5;
}
.student-info {
display: flex;
flex-direction: column;
flex: 1;
}
.student-name {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 4px;
}
.student-account {
font-size: 12px;
color: #999999;
}
/* 操作按钮 */
.button-group {
display: flex;
flex-direction: column;
}
.btn-primary {
height: 52px;
background-color: #6C63FF;
border-radius: 26px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.btn-primary-text {
font-size: 16px;
font-weight: 600;
color: #FFFFFF;
}
.btn-secondary {
height: 52px;
background-color: #FFFFFF;
border-radius: 26px;
border: 1px solid #E5E5E5;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.btn-secondary-text {
font-size: 16px;
color: #6B7280;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
<template>
<view></view>
</template>
<script setup lang="ts">
import { resetRegistrationStudents } from '../../store/registration'
onLoad(() => {
resetRegistrationStudents()
uni.redirectTo({
url: '/pages/register/scan-student'
})
})
</script>
+546
View File
@@ -0,0 +1,546 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 步骤进度 -->
<view class="step-indicator">
<view class="step-item step-active">
<view class="step-dot step-dot-active"></view>
<text class="step-text step-text-active">扫码绑定孩子</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">家长账号与密码</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">注册完成</text>
</view>
</view>
<!-- 提示信息 -->
<view class="info-banner">
<text class="info-text">先扫描孩子的学生二维码,或输入学生账号完成绑定。再设置家长账号,最多可绑定 3 个孩子。</text>
</view>
<!-- 切换按钮 -->
<view class="toggle-buttons">
<view class="toggle-btn toggle-btn-inactive toggle-btn-first" @tap="handleTabChange('scan')">
<text class="toggle-btn-text toggle-btn-text-inactive">扫码绑定</text>
</view>
<view class="toggle-btn toggle-btn-active" @tap="handleTabChange('input')">
<text class="toggle-btn-text toggle-btn-text-active">输入学生账号</text>
</view>
</view>
<!-- 表单区域 -->
<view class="form-section">
<view class="form-header">
<text class="form-title">填写要绑定的学生信息</text>
<text class="form-description">学生账号与姓名需与学生信息保持一致,校验通过后即可加入下方已绑定列表。</text>
</view>
<view class="form-group">
<text class="form-label">学生账号</text>
<input
class="form-input"
type="text"
v-model="studentForm.account"
placeholder="输入学生的账号或账号"
/>
</view>
<view class="form-group">
<text class="form-label">学生姓名</text>
<input
class="form-input"
type="text"
v-model="studentForm.name"
placeholder="需与学生本人姓名一致"
/>
</view>
<view
class="add-student-btn"
@tap="handleAddStudent"
>
<text class="add-student-btn-text">确认添加学生</text>
</view>
</view>
<!-- 已绑定学生列表 -->
<view class="student-list" v-if="studentList.length > 0">
<view class="list-title-row">
<text class="list-title">已绑定学生</text>
<uni-badge-view :text="studentList.length + '/3'" badge-class="student-count-badge"></uni-badge-view>
</view>
<view class="student-item" v-for="(student, index) in studentList" :key="student.id">
<image class="student-avatar" :src="student.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<text class="student-detail">{{ student.grade }} {{ student.class }} | {{ student.account }}</text>
</view>
<button class="remove-btn" @tap="handleRemoveStudent(index)">移除</button>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="footer-button-container">
<view
class="footer-button"
@tap="handleNext"
>
<text class="footer-button-text">下一步</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { apiVerifyStudentAccount } from '../../api/student'
import { registrationStudents } from '../../store/registration'
type StudentForm = {
account: string
name: string
}
const studentForm = reactive<StudentForm>({
account: '',
name: ''
})
const studentList = registrationStudents
const MAX_STUDENTS = 3
const canAdd = computed((): boolean => {
return studentForm.account.trim().length > 0 && studentForm.name.trim().length > 0
})
const canNext = computed((): boolean => {
return studentList.value.length > 0
})
/**
* 切换标签页
*/
function handleTabChange(tab: string) {
if (tab === 'scan') {
uni.redirectTo({
url: '/pages/register/scan-student'
})
}
}
/**
* 添加学生
*/
async function handleAddStudent() {
if (!canAdd.value) return
const account = studentForm.account.trim()
const name = studentForm.name.trim()
// 检查是否达到上限
if (studentList.value.length >= MAX_STUDENTS) {
uni.showToast({
title: `最多只能绑定${MAX_STUDENTS}个孩子`,
icon: 'none'
})
return
}
if (studentList.value.some(student => student.account === account)) {
uni.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
// 验证姓名格式
const nameReg = /^[\u4e00-\u9fa5]{2,10}$/
if (!nameReg.test(name)) {
uni.showToast({
title: '请输入正确的学生姓名(2-10个汉字)',
icon: 'none'
})
return
}
uni.showLoading({ title: '验证中...' })
const res = await apiVerifyStudentAccount({
account,
name
})
uni.hideLoading()
if (res && res.code === 0 && res.data) {
// 检查是否重复绑定
const exists = studentList.value.find(s => s.account === res.data!.account)
if (exists) {
uni.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
// 添加到列表
studentList.value.push(res.data)
// 清空表单
studentForm.account = ''
studentForm.name = ''
uni.showToast({
title: '添加成功',
icon: 'success'
})
} else {
uni.showToast({
title: res?.message || '学生信息验证失败,请检查账号和姓名是否正确',
icon: 'none'
})
}
}
/**
* 移除学生
*/
function handleRemoveStudent(index: number) {
uni.showModal({
title: '提示',
content: '确定要移除该学生吗?',
success: (res) => {
if (res.confirm) {
studentList.value.splice(index, 1)
uni.showToast({
title: '已移除',
icon: 'success'
})
}
}
})
}
/**
* 下一步
*/
function handleNext() {
if (!canNext.value) {
uni.showToast({
title: '请至少绑定一个学生',
icon: 'none'
})
return
}
uni.navigateTo({
url: '/pages/register/parent-account'
})
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #F5F5F5;
}
.content-scroll {
flex: 1;
padding: 16px 16px calc(96px + var(--uni-safe-area-inset-bottom));
}
/* 步骤指示器 - 复用扫码页样式 */
.step-indicator {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #E5E5E5;
margin-bottom: 8px;
}
.step-dot-active {
background-color: #6C63FF;
width: 12px;
height: 12px;
border-radius: 6px;
}
.step-text {
font-size: 11px;
color: #999999;
text-align: center;
}
.step-text-active {
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E5E5;
margin: 0 4px;
margin-bottom: 20px;
}
/* 提示信息 */
.info-banner {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 12px;
background-color: #EEF2FF;
border-radius: 8px;
margin-bottom: 16px;
}
.info-icon {
font-size: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.info-text {
flex: 1;
font-size: 13px;
color: #4F46E5;
line-height: 1.6;
}
/* 切换按钮 */
.toggle-buttons {
display: flex;
flex-direction: row;
margin-bottom: 24px;
}
.toggle-btn-first {
margin-right: 12px;
}
.toggle-btn {
flex: 1;
height: 40px;
border-radius: 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.toggle-btn-active {
background-color: #6C63FF;
}
.toggle-btn-inactive {
background-color: #FFFFFF;
border: 1px solid #E5E5E5;
}
.toggle-btn-text {
font-size: 14px;
}
.toggle-btn-text-active {
color: #FFFFFF;
}
.toggle-btn-text-inactive {
color: #666666;
}
/* 表单区域 */
.form-section {
display: flex;
flex-direction: column;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.form-header {
display: flex;
flex-direction: column;
margin-bottom: 20px;
}
.form-title {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 8px;
}
.form-description {
font-size: 13px;
color: #666666;
line-height: 1.6;
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.form-label {
font-size: 14px;
color: #333333;
margin-bottom: 8px;
font-weight: 500;
}
.form-input {
height: 44px;
padding: 0 16px;
background-color: #F8F9FA;
border-radius: 8px;
border: 1px solid #E5E5E5;
font-size: 14px;
color: #333333;
}
.add-student-btn {
height: 40px;
background-color: #EEF2FF;
border-radius: 8px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-top: 8px;
}
.add-student-btn-text {
font-size: 14px;
color: #6C63FF;
}
/* 学生列表 */
.student-list {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.list-title {
font-size: 14px;
font-weight: 600;
color: #333333;
margin-bottom: 12px;
}
.list-title-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.student-count-badge {
background-color: #6C63FF;
color: #FFFFFF;
}
.student-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 8px;
}
.student-avatar {
width: 48px;
height: 48px;
border-radius: 24px;
margin-right: 12px;
background-color: #F5F5F5;
}
.student-info {
display: flex;
flex-direction: column;
flex: 1;
}
.student-name {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 4px;
}
.student-detail {
font-size: 12px;
color: #999999;
}
.remove-btn {
width: 56px;
height: 32px;
padding: 0;
font-size: 12px;
color: #EF4444;
background-color: #FEF2F2;
}
/* 底部按钮 */
.footer-button-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 16px calc(16px + var(--uni-safe-area-inset-bottom));
background-color: #F5F5F5;
}
.footer-button {
height: 48px;
background-color: #6C63FF;
border-radius: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.footer-button-text {
font-size: 16px;
color: #FFFFFF;
font-weight: 500;
}
</style>
+496
View File
@@ -0,0 +1,496 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<view class="page-back-action" @tap="handleBackToLogin">
<text class="page-back-text">返回登录</text>
</view>
<!-- 步骤进度 -->
<view class="step-indicator">
<view class="step-item">
<view class="step-dot step-dot-completed"></view>
<text class="step-text">扫码绑定孩子</text>
</view>
<view class="step-line step-line-completed"></view>
<view class="step-item step-active">
<view class="step-dot step-dot-active"></view>
<text class="step-text step-text-active">家长账号与密码</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">注册完成</text>
</view>
</view>
<!-- 已绑定学生展示 -->
<view class="student-summary">
<view class="summary-title-row">
<text class="summary-title">已绑定学生</text>
<uni-badge-view :text="students.length.toString()" badge-class="student-count-badge"></uni-badge-view>
</view>
<view class="student-card" v-for="student in students" :key="student.id">
<image class="student-avatar-small" :src="student.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
<text class="student-name-small">{{ student.name }}</text>
</view>
</view>
<!-- 表单区域 -->
<view class="form-section">
<text class="form-title">设置家长账号信息</text>
<view class="form-group">
<text class="form-label">家长姓名</text>
<input
class="form-input"
type="text"
v-model="form.parentName"
placeholder="请输入家长姓名"
maxlength="10"
/>
</view>
<view class="form-group">
<text class="form-label">手机号</text>
<input
class="form-input"
type="number"
v-model="form.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</view>
<view class="form-group">
<text class="form-label">与学生的关系</text>
<picker class="relationship-picker" :value="relationshipIndex" :range="relationshipOptions" @change="handleRelationshipChange">
<view class="relationship-picker-content">
<text :class="form.relationship ? 'relationship-value' : 'relationship-placeholder'">{{ form.relationship || '请选择与学生的关系' }}</text>
</view>
</picker>
</view>
<view class="form-group">
<text class="form-label">登录密码</text>
<view class="input-wrapper">
<input
class="form-input-inner"
type="password"
v-model="form.password"
placeholder="请设置登录密码(6-20位)"
maxlength="20"
/>
</view>
</view>
<view class="form-group">
<text class="form-label">确认密码</text>
<view class="input-wrapper">
<input
class="form-input-inner"
type="password"
v-model="form.confirmPassword"
placeholder="请再次输入密码"
maxlength="20"
/>
</view>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="footer-button-container">
<view
class="footer-button"
:class="canSubmit ? '' : 'footer-button-disabled'"
@tap="handleSubmit"
>
<text class="footer-button-text" :class="canSubmit ? '' : 'footer-button-text-disabled'">完成注册</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { apiRegister } from '../../api/auth'
import { apiCheckPhone } from '../../api/student'
import { registrationStudents } from '../../store/registration'
import { setUserInfo } from '../../store/user'
type ParentAccountForm = {
parentName: string
phone: string
relationship: string
password: string
confirmPassword: string
}
const form = reactive<ParentAccountForm>({
parentName: '',
phone: '',
relationship: '',
password: '',
confirmPassword: ''
})
const students = registrationStudents
const relationshipOptions = ['爸爸', '妈妈', '爷爷', '奶奶', '外公', '外婆', '其他亲属']
const relationshipIndex = ref(0)
const canSubmit = computed((): boolean => {
const parentNameReg = /^[\u4e00-\u9fa5]{2,10}$/
const phoneReg = /^1[3-9]\d{9}$/
return (
parentNameReg.test(form.parentName) &&
phoneReg.test(form.phone) &&
form.relationship.length > 0 &&
form.password.length >= 6 &&
form.password.length <= 20 &&
form.password === form.confirmPassword
)
})
function handleRelationshipChange(event: UniPickerChangeEvent) {
const index = event.detail.value as number
relationshipIndex.value = index
form.relationship = relationshipOptions[index]
}
/**
* 提交注册
*/
async function handleSubmit() {
if (!canSubmit.value) {
if (!form.parentName) {
uni.showToast({ title: '请输入家长姓名', icon: 'none' })
} else if (!/^[\u4e00-\u9fa5]{2,10}$/.test(form.parentName)) {
uni.showToast({ title: '请输入正确的家长姓名(2-10个汉字)', icon: 'none' })
} else if (!form.phone) {
uni.showToast({ title: '请输入手机号', icon: 'none' })
} else if (!/^1[3-9]\d{9}$/.test(form.phone)) {
uni.showToast({ title: '请输入正确的手机号', icon: 'none' })
} else if (!form.relationship) {
uni.showToast({ title: '请选择与学生的关系', icon: 'none' })
} else if (form.password.length < 6 || form.password.length > 20) {
uni.showToast({ title: '密码长度应为6-20位', icon: 'none' })
} else if (form.password !== form.confirmPassword) {
uni.showToast({ title: '两次输入的密码不一致', icon: 'none' })
}
return
}
// 检查手机号是否已注册
uni.showLoading({ title: '检查中...' })
const checkRes = await apiCheckPhone(form.phone)
uni.hideLoading()
if (checkRes && checkRes.data && checkRes.data.exists) {
uni.showToast({
title: '该手机号已注册,请直接登录',
icon: 'none'
})
return
}
// 提交注册
uni.showLoading({ title: '注册中...' })
const studentIds = students.value.map(s => s.id)
const res = await apiRegister({
parentName: form.parentName,
phone: form.phone,
relationship: form.relationship,
password: form.password,
students: studentIds
})
uni.hideLoading()
if (res && res.code === 0 && res.data) {
// 保存用户信息
setUserInfo(res.data)
// 跳转到注册完成页
uni.redirectTo({
url: '/pages/register/complete'
})
} else {
uni.showToast({
title: res?.message || '注册失败,请稍后重试',
icon: 'none'
})
}
}
function handleBackToLogin() {
uni.reLaunch({
url: '/pages/login/index'
})
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #F5F5F5;
}
.content-scroll {
flex: 1;
padding: 16px 16px calc(96px + var(--uni-safe-area-inset-bottom));
}
.page-back-action {
align-self: flex-start;
padding: 8px 0;
margin-bottom: 8px;
}
.page-back-text {
font-size: 14px;
color: #6C63FF;
}
/* 步骤指示器 */
.step-indicator {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #E5E5E5;
margin-bottom: 8px;
}
.step-dot-completed {
background-color: #10B981;
width: 8px;
height: 8px;
}
.step-dot-active {
background-color: #6C63FF;
width: 12px;
height: 12px;
border-radius: 6px;
}
.footer-button-text-disabled {
color: #999999;
}
.step-text {
font-size: 11px;
color: #999999;
text-align: center;
}
.step-text-active {
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E5E5;
margin: 0 4px;
margin-bottom: 20px;
}
.step-line-completed {
background-color: #10B981;
}
/* 学生摘要 */
.student-summary {
display: flex;
flex-direction: column;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.summary-title {
font-size: 14px;
font-weight: 600;
color: #333333;
margin-bottom: 12px;
}
.student-card {
display: flex;
flex-direction: row;
align-items: center;
padding: 8px;
background-color: #F8F9FA;
border-radius: 6px;
margin-bottom: 8px;
}
.student-avatar-small {
width: 32px;
height: 32px;
border-radius: 16px;
margin-right: 8px;
background-color: #E5E5E5;
}
.student-name-small {
font-size: 14px;
color: #333333;
}
/* 表单区域 */
.form-section {
display: flex;
flex-direction: column;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.form-title {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 20px;
}
.form-group {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.form-label {
font-size: 14px;
color: #333333;
margin-bottom: 8px;
font-weight: 500;
}
.form-input {
height: 44px;
padding: 0 16px;
background-color: #F8F9FA;
border-radius: 8px;
border: 1px solid #E5E5E5;
font-size: 14px;
color: #333333;
}
.relationship-picker {
height: 44px;
background-color: #F8F9FA;
border-radius: 8px;
border: 1px solid #E5E5E5;
}
.relationship-picker-content {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 16px;
}
.summary-title-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.student-count-badge {
background-color: #6C63FF;
color: #FFFFFF;
}
.relationship-value {
font-size: 14px;
color: #333333;
}
.relationship-placeholder {
font-size: 14px;
color: #999999;
}
.relationship-picker-arrow {
font-size: 16px;
color: #999999;
}
.input-wrapper {
display: flex;
flex-direction: row;
align-items: center;
height: 44px;
background-color: #F8F9FA;
border-radius: 8px;
border: 1px solid #E5E5E5;
padding: 0 16px;
}
.form-input-inner {
flex: 1;
font-size: 14px;
color: #333333;
}
/* 底部按钮 */
.footer-button-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 16px calc(16px + var(--uni-safe-area-inset-bottom));
background-color: #F5F5F5;
}
.footer-button {
height: 48px;
background-color: #6C63FF;
border-radius: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.footer-button-disabled {
background-color: #E5E5E5;
}
.footer-button-text {
font-size: 16px;
color: #FFFFFF;
font-weight: 500;
}
.footer-button-text-disabled {
color: #999999;
}
</style>
+515
View File
@@ -0,0 +1,515 @@
<template>
<view class="page-container">
<scroll-view class="content-scroll" direction="vertical">
<!-- 步骤进度 -->
<view class="step-indicator">
<view class="step-item step-active">
<view class="step-dot step-dot-active"></view>
<text class="step-text step-text-active">扫码绑定孩子</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">家长账号与密码</text>
</view>
<view class="step-line"></view>
<view class="step-item">
<view class="step-dot"></view>
<text class="step-text">注册完成</text>
</view>
</view>
<!-- 提示信息 -->
<view class="info-banner">
<text class="info-text">先扫描孩子的学生二维码,或输入学生账号完成绑定。再设置家长账号,最多可绑定 3 个孩子。</text>
</view>
<!-- 切换按钮 -->
<view class="toggle-buttons">
<view class="toggle-btn toggle-btn-active toggle-btn-first" @tap="handleTabChange('scan')">
<text class="toggle-btn-text toggle-btn-text-active">扫码绑定</text>
</view>
<view class="toggle-btn toggle-btn-inactive" @tap="handleTabChange('input')">
<text class="toggle-btn-text toggle-btn-text-inactive">输入学生账号</text>
</view>
</view>
<!-- 扫码区域 -->
<view class="scan-section">
<view class="scan-status">
<text class="status-text">对准学生二维码</text>
</view>
<view class="scan-area">
<QrScanner :active="studentList.length < MAX_STUDENTS" @scan="handleScannedQRCode" @error="handleCameraError"></QrScanner>
</view>
<text class="scan-hint">将学生二维码放入框内,即可自动识别</text>
<text class="scan-note">学生账号/密码可以在学校指定平台或在班主任或家长群可获取</text>
</view>
<!-- 已绑定学生列表 -->
<view class="student-list" v-if="studentList.length > 0">
<view class="list-title-row">
<text class="list-title">已绑定学生</text>
<uni-badge-view :text="studentList.length + '/3'" badge-class="student-count-badge"></uni-badge-view>
</view>
<view class="student-item" v-for="(student, index) in studentList" :key="student.id">
<image class="student-avatar" :src="student.avatar || '/static/avatar-default.png'" mode="aspectFill"></image>
<view class="student-info">
<text class="student-name">{{ student.name }}</text>
<text class="student-detail">{{ student.grade }} {{ student.class }} | {{ student.account }}</text>
</view>
<button class="remove-btn" @tap="handleRemoveStudent(index)">移除</button>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="footer-button-container">
<view
class="footer-button"
:class="canNext ? '' : 'footer-button-disabled'"
@tap="handleNext"
>
<text class="footer-button-text" :class="canNext ? '' : 'footer-button-text-disabled'">下一步</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { apiVerifyStudentQRCode } from '../../api/student'
import { registrationStudents } from '../../store/registration'
import QrScanner from '../../components/qr-scanner.uvue'
const studentList = registrationStudents
const MAX_STUDENTS = 3
const isVerifying = ref(false)
const canNext = computed((): boolean => {
return studentList.value.length > 0
})
/**
* 切换标签页
*/
function handleTabChange(tab: string) {
if (tab === 'input') {
uni.redirectTo({
url: '/pages/register/input-student'
})
}
}
async function handleScannedQRCode(qrCode: string) {
if (!qrCode || isVerifying.value) return
const separatorIndex = qrCode.indexOf(',')
if (separatorIndex <= 0 || separatorIndex === qrCode.length - 1) {
uni.showToast({
title: '二维码格式无效',
icon: 'none'
})
return
}
const account = qrCode.slice(0, separatorIndex).trim()
const name = qrCode.slice(separatorIndex + 1).trim()
if (!account || !name) return
isVerifying.value = true
try {
await verifyStudentQRCode(qrCode, account, name)
} finally {
isVerifying.value = false
}
}
function handleCameraError() {
uni.showToast({
title: '无法使用摄像头,请检查权限',
icon: 'none'
})
}
/**
* 验证学生二维码
*/
async function verifyStudentQRCode(qrCode: string, account: string, name: string) {
// 检查是否达到上限
if (studentList.value.length >= MAX_STUDENTS) {
uni.showToast({
title: `最多只能绑定${MAX_STUDENTS}个孩子`,
icon: 'none'
})
return
}
if (studentList.value.some(student => student.account === account)) {
uni.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
uni.showLoading({ title: '验证中...' })
const res = await apiVerifyStudentQRCode({ qrCode, account, name })
uni.hideLoading()
if (res && res.code === 0 && res.data) {
// 检查是否重复绑定
const exists = studentList.value.find(s => s.account === res.data!.account)
if (exists) {
uni.showToast({
title: '该学生已添加',
icon: 'none'
})
return
}
// 添加到列表
studentList.value.push(res.data)
uni.showToast({
title: '绑定成功',
icon: 'success'
})
} else {
uni.showToast({
title: res?.message || '学生信息验证失败',
icon: 'none'
})
}
}
/**
* 移除学生
*/
function handleRemoveStudent(index: number) {
uni.showModal({
title: '提示',
content: '确定要移除该学生吗?',
success: (res) => {
if (res.confirm) {
studentList.value.splice(index, 1)
uni.showToast({
title: '已移除',
icon: 'success'
})
}
}
})
}
/**
* 下一步
*/
function handleNext() {
if (!canNext.value) {
uni.showToast({
title: '请至少绑定一个学生',
icon: 'none'
})
return
}
uni.navigateTo({
url: '/pages/register/parent-account'
})
}
</script>
<style>
.page-container {
display: flex;
flex-direction: column;
flex: 1;
background-color: #F5F5F5;
}
.content-scroll {
flex: 1;
padding: 16px 16px calc(96px + var(--uni-safe-area-inset-bottom));
}
/* 步骤指示器 */
.step-indicator {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 16px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 16px;
}
.step-item {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
}
.step-dot {
width: 8px;
height: 8px;
border-radius: 4px;
background-color: #E5E5E5;
margin-bottom: 8px;
}
.step-dot-active {
background-color: #6C63FF;
width: 12px;
height: 12px;
border-radius: 6px;
}
.step-text {
font-size: 11px;
color: #999999;
text-align: center;
}
.step-text-active {
color: #6C63FF;
font-weight: 600;
}
.step-line {
flex: 1;
height: 2px;
background-color: #E5E5E5;
margin: 0 4px;
margin-bottom: 20px;
}
/* 提示信息 */
.info-banner {
display: flex;
flex-direction: row;
align-items: flex-start;
padding: 12px;
background-color: #EEF2FF;
border-radius: 8px;
margin-bottom: 16px;
}
.info-icon {
font-size: 16px;
margin-right: 8px;
flex-shrink: 0;
}
.info-text {
flex: 1;
font-size: 13px;
color: #4F46E5;
line-height: 1.6;
}
/* 切换按钮 */
.toggle-buttons {
display: flex;
flex-direction: row;
margin-bottom: 24px;
}
.toggle-btn-first {
margin-right: 12px;
}
.toggle-btn {
flex: 1;
height: 40px;
border-radius: 20px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.toggle-btn-active {
background-color: #6C63FF;
}
.toggle-btn-inactive {
background-color: #FFFFFF;
border: 1px solid #E5E5E5;
}
.toggle-btn-text {
font-size: 14px;
}
.toggle-btn-text-active {
color: #FFFFFF;
}
.toggle-btn-text-inactive {
color: #666666;
}
/* 扫码区域 */
.scan-section {
display: flex;
flex-direction: column;
margin-bottom: 24px;
}
.scan-status {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background-color: #1E293B;
border-radius: 8px 8px 0 0;
}
.status-text {
font-size: 14px;
color: #FFFFFF;
}
.status-icon {
font-size: 18px;
}
.scan-area {
background-color: #0F172A;
padding: 16px;
border-radius: 0 0 8px 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.scan-hint {
font-size: 14px;
color: #F8FAFC;
text-align: center;
margin-top: 16px;
}
.scan-note {
font-size: 12px;
color: #94A3B8;
text-align: center;
margin-top: 8px;
line-height: 1.5;
}
/* 学生列表 */
.student-list {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.list-title {
font-size: 14px;
font-weight: 600;
color: #333333;
margin-bottom: 12px;
}
.list-title-row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.student-count-badge {
background-color: #6C63FF;
color: #FFFFFF;
}
.student-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 12px;
background-color: #FFFFFF;
border-radius: 8px;
margin-bottom: 8px;
}
.student-avatar {
width: 48px;
height: 48px;
border-radius: 24px;
margin-right: 12px;
background-color: #F5F5F5;
}
.student-info {
display: flex;
flex-direction: column;
flex: 1;
}
.student-name {
font-size: 16px;
font-weight: 600;
color: #333333;
margin-bottom: 4px;
}
.student-detail {
font-size: 12px;
color: #999999;
}
.remove-btn {
width: 56px;
height: 32px;
padding: 0;
font-size: 12px;
color: #EF4444;
background-color: #FEF2F2;
}
/* 底部按钮 */
.footer-button-container {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 16px 16px calc(16px + var(--uni-safe-area-inset-bottom));
background-color: #F5F5F5;
}
.footer-button {
height: 48px;
background-color: #6C63FF;
border-radius: 24px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.footer-button-disabled {
background-color: #E5E5E5;
}
.footer-button-text {
font-size: 16px;
color: #FFFFFF;
font-weight: 500;
}
.footer-button-text-disabled {
color: #999999;
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<template>
<view class="page-container">
<view class="header-panel">
<view class="header-copy"><text class="header-title">学情报告</text><text class="header-subtitle">{{ report.studentName }} · {{ report.studentGrade }} · {{ report.studentClass }}</text></view>
<view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view>
</view>
<view class="report-meta"><text class="report-meta-text">{{ activeReport.rangeText }} · 数据更新到 {{ report.updatedAtText }}</text></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="refreshReport">
<view v-if="isLoading" class="status-panel"><text class="status-text">学情报告加载中</text></view>
<view v-else class="content-panel">
<view class="filters-card">
<view class="period-tabs"><view class="period-tab" :class="activePeriod === 'week' ? 'period-tab-active' : ''" @tap="setPeriod('week')"><text class="period-tab-text" :class="activePeriod === 'week' ? 'period-tab-text-active' : ''">周报</text></view><view class="period-tab" :class="activePeriod === 'month' ? 'period-tab-active' : ''" @tap="setPeriod('month')"><text class="period-tab-text" :class="activePeriod === 'month' ? 'period-tab-text-active' : ''">月报</text></view></view>
<scroll-view class="subject-scroll" direction="horizontal" scroll-x="true"><view class="subject-tabs"><view v-for="subject in report.subjects" :key="subject.id" class="subject-tab" :class="activeSubjectId === subject.id ? 'subject-tab-active' : ''" @tap="setSubject(subject.id)"><text class="subject-tab-text" :class="activeSubjectId === subject.id ? 'subject-tab-text-active' : ''">{{ subject.name }}</text></view></view></scroll-view>
</view>
<view class="summary-card"><text class="summary-title">{{ activeReport.statusTitle }}</text><text class="summary-description">{{ activeReport.statusDescription }}</text><view class="summary-stats"><view class="summary-stat"><text class="summary-value">{{ activeReport.newMasteredPoints }} 个</text><text class="summary-label">新攻克知识点</text></view><view class="summary-stat summary-stat-middle"><text class="summary-value">{{ activeReport.consecutiveDays }} 天</text><text class="summary-label">连续学习</text></view><view class="summary-stat"><text class="summary-value">{{ activeReport.averageAccuracy }}%</text><text class="summary-label">平均正确率</text></view></view></view>
<view class="trend-card"><text class="section-title">已掌握知识点成长曲线</text><view class="trend-legend"><view class="legend-item"><view class="legend-mark legend-mark-student"></view><text class="legend-text">本学生</text></view><view class="legend-item"><view class="legend-mark legend-mark-average"></view><text class="legend-text">班级平均</text></view></view><StudyTrendChart :scale-labels="activeReport.scaleLabels" :points="activeTrend"></StudyTrendChart></view>
<view class="activity-card"><text class="section-title">{{ reportPeriodLabel }}学习活跃度与效率</text><text class="activity-summary">{{ activeReport.activity.summary }}</text><text class="activity-total">学习 {{ activeReport.activity.activeDays }} 天,累计 {{ activeReport.activity.totalDurationText }}</text><StudyActivityChart :points="activeReport.activity.points"></StudyActivityChart><view class="activity-legend"><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-student"></view><text class="legend-text">高质量(有产出)</text></view><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-plan"></view><text class="legend-text">普通学习(无新产出)</text></view><view class="legend-item activity-legend-item"><view class="legend-mark legend-mark-mastery"></view><text class="legend-text">新增掌握</text></view></view></view>
<view v-if="activeSubjectId === 'all'" class="mastery-card"><text class="section-title">各科知识点掌握情况</text><view v-for="mastery in activeReport.subjectMasteries" :key="mastery.subjectName" class="mastery-item"><view class="mastery-heading"><text class="mastery-subject">{{ mastery.subjectName }}</text><text class="mastery-value">{{ mastery.masteredCount }} / {{ mastery.totalCount }}</text></view><view class="mastery-track"><view class="mastery-progress" :style="{ width: mastery.masteredPercent + '%' }"></view><view class="mastery-remaining"></view></view></view><view class="mastery-legend"><view class="mastery-legend-item"><view class="mastery-legend-color mastery-legend-mastered"></view><text class="mastery-legend-text">已掌握</text></view><view class="mastery-legend-item"><view class="mastery-legend-color mastery-legend-unmastered"></view><text class="mastery-legend-text">未掌握</text></view></view></view>
</view>
</scroll-view>
<MainTabBar active-tab="study"></MainTabBar>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import StudyActivityChart from '../../components/study-activity-chart.uvue'
import StudyTrendChart from '../../components/study-trend-chart.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentHomeProfile, apiGetStudentStudyReport, type StudentStudyReport, type StudyReportActivity, type StudyReportPeriod, type StudyReportSubjectMastery, type StudyReportTrendPoint } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
const emptyActivity: StudyReportActivity = { summary: '', activeDays: 0, totalDurationText: '', points: [] }
const emptyPeriod: StudyReportPeriod = { rangeText: '', statusTitle: '', statusDescription: '', newMasteredPoints: 0, consecutiveDays: 0, averageAccuracy: 0, scaleLabels: [], trends: [], activity: emptyActivity, subjectMasteries: [] as StudyReportSubjectMastery[] }
const report = ref<StudentStudyReport>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', updatedAtText: '', subjects: [], week: emptyPeriod, month: emptyPeriod })
const isLoading = ref(true)
const isRefreshing = ref(false)
const activePeriod = ref('week')
const activeSubjectId = ref('all')
const activeReport = computed((): StudyReportPeriod => activePeriod.value === 'week' ? report.value.week : report.value.month)
const reportPeriodLabel = computed((): string => activePeriod.value === 'week' ? '本周' : '本月')
const activeTrend = computed((): StudyReportTrendPoint[] => {
for (let index = 0; index < activeReport.value.trends.length; index++) {
const trend = activeReport.value.trends[index]
if (trend.subjectId === activeSubjectId.value) return trend.points
}
return activeReport.value.trends.length > 0 ? activeReport.value.trends[0].points : []
})
onLoad(() => {
loadReport()
})
async function loadReport() {
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (!studentId) { isLoading.value = false; return }
const res = await apiGetStudentStudyReport(studentId)
if (res && res.data) report.value = res.data
isLoading.value = false
}
async function refreshReport() {
isRefreshing.value = true
await loadReport()
isRefreshing.value = false
}
function setPeriod(period: string) {
activePeriod.value = period
}
function setSubject(subjectId: string) {
activeSubjectId.value = subjectId
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #6558e9; }
.report-meta { padding: 8px 18px; background-color: #ffffff; border-top: 1px solid #edf0f5; }
.report-meta-text { font-size: 11px; color: #8992a5; }
.content-scroll { flex: 1; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.content-panel { display: flex; flex-direction: column; padding: 12px 18px 24px; }
.filters-card { display: flex; flex-direction: column; padding: 12px; border-radius: 8px; background-color: #ffffff; }
.period-tabs { display: flex; flex-direction: row; }
.period-tab { padding: 7px 14px; margin-right: 8px; border-radius: 16px; background-color: #f1f4f8; }
.period-tab-active { background-color: #554be9; }
.period-tab-text { font-size: 12px; color: #64748b; }
.period-tab-text-active { color: #ffffff; }
.subject-scroll { margin-top: 10px; }
.subject-tabs { display: flex; flex-direction: row; }
.subject-tab { flex-shrink: 0; padding: 7px 14px; margin-right: 8px; border-radius: 16px; background-color: #f1f4f8; }
.subject-tab-active { background-color: #554be9; }
.subject-tab-text { font-size: 12px; color: #64748b; }
.subject-tab-text-active { color: #ffffff; }
.summary-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 14px; background-color: #6545e8; }
.summary-title { font-size: 15px; font-weight: 700; color: #ffffff; }
.summary-description { margin-top: 8px; font-size: 12px; line-height: 19px; color: #efeaff; }
.summary-stats { display: flex; flex-direction: row; margin-top: 14px; padding: 10px 0; border-radius: 8px; background-color: #7659ee; }
.summary-stat { display: flex; flex-direction: column; align-items: center; flex: 1; }
.summary-stat-middle { border-left: 1px solid #927cf3; border-right: 1px solid #927cf3; }
.summary-value { font-size: 15px; font-weight: 700; color: #ffffff; }
.summary-label { margin-top: 4px; font-size: 10px; color: #e9e2ff; }
.trend-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.trend-legend { display: flex; flex-direction: row; justify-content: flex-end; margin-top: 12px; }
.legend-item { display: flex; flex-direction: row; align-items: center; margin-left: 12px; }
.legend-mark { width: 11px; height: 5px; margin-right: 5px; border-radius: 2px; }
.legend-mark-student { background-color: #6558e9; }
.legend-mark-average { background-color: #9aa8bc; }
.legend-mark-plan { background-color: #d3dce8; }
.legend-mark-mastery { width: 7px; height: 7px; border-radius: 4px; background-color: #ffab26; }
.legend-text { font-size: 10px; color: #596275; }
.activity-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.activity-summary { margin-top: 8px; font-size: 12px; color: #596275; }
.activity-total { margin-top: 4px; font-size: 12px; color: #8992a5; }
.activity-legend { display: flex; flex-direction: row; flex-wrap: wrap; margin-top: 6px; }
.activity-legend-item { margin-left: 0; margin-right: 12px; margin-bottom: 4px; }
.mastery-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.mastery-legend { display: flex; flex-direction: row; margin-top: 16px; }
.mastery-legend-item { display: flex; flex-direction: row; align-items: center; margin-right: 16px; }
.mastery-legend-color { width: 8px; height: 8px; margin-right: 5px; border-radius: 2px; }
.mastery-legend-mastered { background-color: #16bd88; }
.mastery-legend-unmastered { background-color: #f34d54; }
.mastery-legend-text { font-size: 11px; color: #8992a5; }
.mastery-item { display: flex; flex-direction: column; margin-top: 14px; }
.mastery-heading { display: flex; flex-direction: row; justify-content: space-between; }
.mastery-subject { font-size: 13px; color: #4a5568; }
.mastery-value { font-size: 13px; font-weight: 700; color: #242b3d; }
.mastery-track { display: flex; flex-direction: row; height: 10px; margin-top: 7px; overflow: hidden; border-radius: 5px; background-color: #f1f4f8; }
.mastery-progress { height: 10px; border-radius: 5px 0 0 5px; background-color: #16bd88; }
.mastery-remaining { flex: 1; border-radius: 0 5px 5px 0; background-color: #f34d54; }
</style>
+85
View File
@@ -0,0 +1,85 @@
<template>
<view class="page-container"><view class="header-panel"><view class="header-copy"><text class="header-title">作业任务</text><text class="header-subtitle">{{ tasks.studentName }} · 待完成 {{ tasks.pendingCount }} 个</text></view><view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view></view><view class="filter-panel"><view v-for="filter in filters" :key="filter.id" class="filter-tab" :class="activeFilter === filter.id ? 'filter-tab-active' : ''" @tap="setFilter(filter.id)"><text class="filter-text" :class="activeFilter === filter.id ? 'filter-text-active' : ''">{{ filter.name }}</text></view></view><scroll-view class="content-scroll" direction="vertical" scroll-y="true" refresher-enabled="true" :refresher-triggered="isRefreshing" @refresherrefresh="refreshTasks"><view v-if="isLoading" class="status-panel"><text class="status-text">任务加载中</text></view><view v-else class="content-panel"><view v-for="group in visibleGroups" :key="group.id" class="group-card"><view class="group-heading"><view class="group-tags"><text class="subject-tag">{{ group.subject }}</text><text class="unit-tag">{{ group.unit }}</text><text class="lesson-title">{{ group.lesson }}</text></view><text class="knowledge-count">{{ group.knowledgeCount > 0 ? group.knowledgeCount + ' 个知识点' : '暂无学习内容' }}</text></view><text class="lesson-time">● {{ group.startText }}-{{ group.endText }}</text><view v-if="group.note" class="group-note"><text class="group-note-icon">i</text><text class="group-note-text">{{ group.note }}</text></view><view v-for="task in group.tasks" :key="task.id" v-show="matchesFilter(task)" class="detail-task"><view class="detail-task-heading"><text class="detail-task-title">{{ task.title }}</text><text class="detail-task-status" :class="'detail-task-status-' + task.status">{{ task.statusText }}</text></view><text class="detail-task-result" :class="'detail-task-result-' + task.status">● {{ task.statusText }} · 共 {{ task.status === 'success' ? '2' : '3' }} 次</text><text v-if="task.accuracyText" class="detail-task-meta">{{ task.accuracyText }} · {{ task.answerText }} · {{ task.deadlineText }}</text></view></view><view v-if="visibleGroups.length === 0" class="empty-panel"><text class="empty-text">暂无对应任务</text></view></view></scroll-view><MainTabBar active-tab="tasks"></MainTabBar></view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentHomeProfile, apiGetStudentLearningTasks, type LearningTaskItem, type StudentLearningTasks } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
const filters = [{ id: 'all', name: '全部' }, { id: 'pending', name: '待完成' }, { id: 'completed', name: '已完成' }, { id: 'failed', name: '未通过' }]
const activeFilter = ref('all')
const isLoading = ref(true)
const isRefreshing = ref(false)
const tasks = ref<StudentLearningTasks>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', pendingCount: 0, completedCount: 0, failedCount: 0, groups: [] })
const visibleGroups = computed(() => tasks.value.groups.filter((group) => group.tasks.length === 0 || group.tasks.some((task) => matchesFilter(task))))
onLoad(() => { loadTasks() })
async function loadTasks() {
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) { setHomeStudentProfile(profileRes.data); studentId = profileRes.data.id }
}
if (studentId) { const res = await apiGetStudentLearningTasks(studentId); if (res && res.data) tasks.value = res.data }
isLoading.value = false
}
async function refreshTasks() {
isRefreshing.value = true
await loadTasks()
isRefreshing.value = false
}
function setFilter(filter: string) { activeFilter.value = filter }
function matchesFilter(task: LearningTaskItem): boolean {
if (activeFilter.value === 'all') return true
if (activeFilter.value === 'pending') return task.status === 'not_started' || task.status === 'in_progress'
if (activeFilter.value === 'completed') return task.status === 'success'
return task.status === 'failed'
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; justify-content: space-between; padding: 16px 20px; background-color: #ffffff; border-bottom: 1px solid #edf0f5; }
.header-copy { display: flex; flex-direction: column; }
.header-title { font-size: 18px; font-weight: 700; color: #242b3d; }
.header-subtitle { margin-top: 4px; font-size: 11px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #554be9; }
.filter-panel { display: flex; flex-direction: row; padding: 12px 20px; background-color: #ffffff; }
.filter-tab { display: flex; flex-direction: column; align-items: center; flex: 1; margin-right: 8px; padding: 7px 0; border-radius: 16px; text-align: center; background-color: #f1f4f8; }
.filter-tab-active { background-color: #554be9; }
.filter-text { font-size: 11px; color: #64748b; }
.filter-text-active { color: #ffffff; }
.content-scroll { flex: 1; }
.content-panel { display: flex; flex-direction: column; padding: 14px 16px 24px; }
.status-panel, .empty-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 60px 0; }
.status-text, .empty-text { font-size: 13px; color: #8992a5; }
.group-card { display: flex; flex-direction: column; margin-bottom: 12px; padding: 16px; border-radius: 15px; background-color: #ffffff; }
.group-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.group-tags { display: flex; flex-direction: row; align-items: center; flex: 1; }
.subject-tag, .unit-tag { padding: 3px 7px; margin-right: 6px; border-radius: 8px; font-size: 10px; color: #554be9; background-color: #eeefff; }
.unit-tag { color: #1598dc; background-color: #e6f5ff; }
.lesson-title { font-size: 13px; font-weight: 700; color: #242b3d; }
.knowledge-count { flex-shrink: 0; font-size: 10px; color: #8992a5; }
.lesson-time { margin-top: 7px; font-size: 11px; color: #8ca0bd; }
.group-note { display: flex; flex-direction: row; align-items: flex-start; margin-top: 12px; padding: 12px; border-radius: 12px; background-color: #f6f8fb; }
.group-note-icon { width: 12px; height: 12px; margin-right: 7px; border-radius: 6px; text-align: center; font-size: 9px; color: #ffffff; background-color: #9aa8bc; }
.group-note-text { flex: 1; font-size: 11px; line-height: 18px; color: #68778b; }
.detail-task { display: flex; flex-direction: column; margin-top: 10px; padding: 12px; border: 1px solid #edf0f5; border-radius: 12px; background-color: #f9fbfd; }
.detail-task-heading { display: flex; flex-direction: row; justify-content: space-between; }
.detail-task-title { font-size: 13px; color: #242b3d; }
.detail-task-status { padding: 3px 7px; border-radius: 8px; font-size: 10px; }
.detail-task-status-success { color: #16a979; background-color: #e8fbf3; }
.detail-task-status-in_progress { color: #5147e9; background-color: #eeefff; }
.detail-task-status-failed { color: #f34d54; background-color: #fff0f1; }
.detail-task-result { margin-top: 8px; font-size: 11px; }
.detail-task-result-success { color: #16a979; }
.detail-task-result-in_progress { color: #5147e9; }
.detail-task-result-failed { color: #f34d54; }
.detail-task-meta { margin-top: 8px; padding-top: 8px; border-top: 1px solid #e4eaf1; font-size: 10px; color: #8992a5; }
</style>
+147
View File
@@ -0,0 +1,147 @@
<template>
<view class="page-container">
<view class="header-panel">
<view class="header-back" @tap="goBack"><UniIcons type="back" class="header-back-icon"></UniIcons></view>
<view class="header-copy"><text class="header-title">今日学习</text><text class="header-subtitle">{{ detail.dateText }} {{ detail.studentName }}</text></view>
<view class="header-action" @tap="goBack"><UniIcons type="home-filled" class="header-action-icon"></UniIcons></view>
</view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true">
<view v-if="isLoading" class="status-panel"><text class="status-text">今日学习信息加载中</text></view>
<view v-else class="content-panel">
<view class="overview-card">
<view class="overview-header"><text class="section-title">今日学习概览</text><text class="checkin-status" :class="detail.checkedIn ? 'checkin-complete' : 'checkin-pending'">{{ detail.checkedIn ? '已打卡' : '未打卡' }}</text></view>
<view class="metrics-grid">
<view class="metric-card"><text class="metric-label">学习时长</text><text class="metric-value">{{ detail.learningMinutes }} 分钟</text></view>
<view class="metric-card"><text class="metric-label">答题时长</text><text class="metric-value">{{ detail.answerMinutes }} 分钟</text></view>
<view class="metric-card"><text class="metric-label">答题数量</text><text class="metric-value metric-blue">{{ detail.answerCount }} 题</text></view>
<view class="metric-card"><text class="metric-label">完成学习包</text><text class="metric-value metric-purple">{{ detail.completedPackages }} / {{ detail.totalPackages }}</text></view>
<view class="metric-card"><text class="metric-label">攻克知识点</text><text class="metric-value metric-green">{{ detail.masteredPoints }} 个</text></view>
</view>
</view>
<view class="courses-card">
<text class="section-title">今日课表与每课打卡</text>
<view class="course-list">
<view v-for="course in detail.courses" :key="course.id" class="course-item">
<text class="course-time">{{ course.time }}</text>
<view class="course-copy"><text class="course-subject">{{ course.subject }}</text><text class="course-content">{{ course.content }}</text></view>
<text class="course-status" :class="course.checkedIn ? 'course-complete' : 'course-pending'">{{ course.checkedIn ? '已打卡' : '未打卡' }}</text>
</view>
</view>
</view>
<view class="score-card">
<text class="section-title">正确率(对比昨日)</text>
<text class="score-description">整体正确率较昨日全部答题目统计,与昨日对比</text>
<view class="score-row"><text class="score-label">整体正确率</text><view class="score-values"><text class="score-value">{{ detail.score.accuracy }}%</text><text class="score-change">+{{ detail.score.change }}%</text></view></view>
<text class="score-yesterday">昨日 {{ detail.score.yesterdayAccuracy }}%</text>
<view class="score-track"><view class="score-progress" :style="{ width: detail.score.accuracy + '%' }"></view></view>
</view>
<view class="points-card">
<view class="points-header"><text class="section-title">今日获得积分</text><text class="points-link">积分明细 ›</text></view>
<view class="points-total"><text class="points-number">+{{ detail.pointsTotal }}</text><text class="points-unit">积分</text></view>
<view class="points-list">
<view v-for="item in detail.pointDetails" :key="item.id" class="point-item"><text class="point-label">{{ item.label }}</text><text class="point-value">+{{ item.points }}</text></view>
</view>
</view>
</view>
</scroll-view>
<MainTabBar active-tab="home"></MainTabBar>
</view>
</template>
<script setup lang="ts">
import MainTabBar from '../../components/main-tab-bar.uvue'
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetTodayLearningDetail, type TodayLearningDetail } from '../../api/student'
import { homeStudentProfile } from '../../store/home'
const isLoading = ref(true)
const detail = ref<TodayLearningDetail>({
studentId: '', studentName: '', dateText: '', checkedIn: false, learningMinutes: 0, answerMinutes: 0,
answerCount: 0, completedPackages: 0, totalPackages: 0, masteredPoints: 0, courses: [],
score: { accuracy: 0, yesterdayAccuracy: 0, change: 0 }, pointsTotal: 0, pointDetails: []
})
onLoad(() => {
loadTodayLearning()
})
async function loadTodayLearning() {
const studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
isLoading.value = false
return
}
const res = await apiGetTodayLearningDetail(studentId)
if (res && res.data) {
detail.value = res.data
}
isLoading.value = false
}
function goBack() {
uni.navigateBack()
}
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #6558e9; }
.content-scroll { flex: 1; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.content-panel { display: flex; flex-direction: column; padding: 16px 18px 24px; }
.overview-card { display: flex; flex-direction: column; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.overview-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.section-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.checkin-status { padding: 4px 10px; border-radius: 12px; font-size: 11px; }
.checkin-complete { color: #00a66a; background-color: #e9fbf3; }
.checkin-pending { color: #f08a1a; background-color: #fff4e5; }
.metrics-grid { display: flex; flex-direction: row; flex-wrap: wrap; justify-content: space-between; margin-top: 14px; }
.metric-card { display: flex; flex-direction: column; width: 48%; margin-bottom: 10px; padding: 12px; border-radius: 8px; background-color: #f6f8fb; }
.metric-label { font-size: 11px; color: #8992a5; }
.metric-value { margin-top: 6px; font-size: 15px; font-weight: 700; color: #20283a; }
.metric-blue { color: #008ddb; }
.metric-purple { color: #6558e9; }
.metric-green { color: #00a66a; }
.courses-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.course-list { display: flex; flex-direction: column; margin-top: 14px; }
.course-item { display: flex; flex-direction: row; align-items: center; min-height: 58px; margin-bottom: 10px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; }
.course-time { width: 56px; font-size: 12px; color: #596275; }
.course-copy { display: flex; flex-direction: column; flex: 1; }
.course-subject { font-size: 13px; font-weight: 600; color: #2a3042; }
.course-content { margin-top: 4px; font-size: 12px; color: #596275; }
.course-status { padding: 4px 7px; border-radius: 10px; font-size: 10px; }
.course-complete { color: #00a66a; background-color: #e9fbf3; }
.course-pending { color: #f08a1a; background-color: #fff4e5; }
.score-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.score-description { margin-top: 6px; font-size: 11px; color: #8992a5; }
.score-row { display: flex; flex-direction: row; align-items: center; justify-content: space-between; margin-top: 14px; }
.score-label { font-size: 12px; color: #596275; }
.score-values { display: flex; flex-direction: row; align-items: center; }
.score-value { font-size: 13px; font-weight: 700; color: #20283a; }
.score-change { margin-left: 8px; font-size: 12px; color: #00a66a; }
.score-yesterday { margin-top: 6px; font-size: 12px; color: #8992a5; }
.score-track { height: 8px; margin-top: 8px; border-radius: 4px; background-color: #edf1f7; }
.score-progress { height: 8px; border-radius: 4px; background-color: #7659ee; }
.points-card { display: flex; flex-direction: column; margin-top: 16px; padding: 16px; border-radius: 8px; background-color: #ffffff; }
.points-header { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.points-link { font-size: 12px; color: #514ce8; }
.points-total { display: flex; flex-direction: row; align-items: baseline; margin-top: 10px; }
.points-number { font-size: 24px; font-weight: 700; color: #ee9500; }
.points-unit { margin-left: 5px; font-size: 12px; color: #8992a5; }
.points-list { display: flex; flex-direction: column; margin-top: 12px; }
.point-item { display: flex; flex-direction: row; justify-content: space-between; margin-bottom: 8px; padding: 10px 12px; border-radius: 8px; background-color: #f6f8fb; }
.point-label { font-size: 12px; color: #596275; }
.point-value { font-size: 12px; font-weight: 600; color: #ee9500; }
</style>
+92
View File
@@ -0,0 +1,92 @@
<template>
<view class="page-container">
<view class="header-panel"><view class="header-back" @tap="goHome"><UniIcons type="back" class="header-back-icon"></UniIcons></view><view class="header-copy"><text class="header-title">英语词汇</text><text class="header-subtitle">{{ vocabulary.studentName }} · {{ vocabulary.studentGrade }}·{{ vocabulary.studentClass }}</text></view><view class="header-action"><UniIcons type="person-filled" class="header-action-icon"></UniIcons></view></view>
<scroll-view class="content-scroll" direction="vertical" scroll-y="true"><view v-if="isLoading" class="status-panel"><text class="status-text">词汇书加载中</text></view><view v-else class="content-panel"><view class="list-heading"><text class="list-title">全部词书</text><text class="book-count">{{ vocabulary.bookCount }} 本</text></view><text class="list-description">每本词书的总词量与已学进度</text><view v-for="book in vocabulary.books" :key="book.id" class="book-card" :class="'book-card-' + book.color"><view class="book-heading"><view class="book-icon" :class="'book-icon-' + book.color"><UniIcons type="font" class="book-icon-glyph" :class="'book-icon-glyph-' + book.color"></UniIcons></view><view class="book-copy"><text class="book-name">{{ book.name }}</text><view class="book-meta"><text class="book-total">{{ book.subtitle }}</text><text class="book-level">{{ book.levelText }}</text></view></view><text class="book-status" :class="'book-status-' + book.color">{{ book.statusText }}</text></view><view class="progress-heading"><text class="progress-label">学习进度</text><text class="progress-value">{{ book.learnedWords > 0 ? '已学 ' + book.learnedWords + ' / ' + book.totalWords + ' 词 · ' + progressPercent(book) + '%' : '未开始' }}</text></view><view class="progress-track"><view class="progress-fill" :class="'progress-fill-' + book.color" :style="{ width: progressPercent(book) + '%' }"></view></view></view></view></scroll-view>
</view>
</template>
<script setup lang="ts">
import UniIcons from '../../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
import { apiGetStudentHomeProfile, apiGetStudentVocabulary, type StudentVocabulary, type VocabularyBook } from '../../api/student'
import { homeStudentProfile, setHomeStudentProfile } from '../../store/home'
const vocabulary = ref<StudentVocabulary>({ studentId: '', studentName: '', studentGrade: '', studentClass: '', bookCount: 0, books: [] })
const isLoading = ref(true)
onLoad(() => { loadVocabulary() })
async function loadVocabulary() {
let studentId = homeStudentProfile.value?.id ?? ''
if (!studentId) {
const profileRes = await apiGetStudentHomeProfile(undefined, false)
if (profileRes && profileRes.data) {
setHomeStudentProfile(profileRes.data)
studentId = profileRes.data.id
}
}
if (studentId) {
const res = await apiGetStudentVocabulary(studentId)
if (res && res.data) vocabulary.value = res.data
}
isLoading.value = false
}
function progressPercent(book: VocabularyBook): number {
if (book.totalWords === 0) return 0
return Math.round(book.learnedWords / book.totalWords * 100)
}
function goHome() { uni.reLaunch({ url: '/pages/index/index' }) }
</script>
<style>
.page-container { display: flex; flex-direction: column; flex: 1; background-color: #f5f7fb; }
.header-panel { display: flex; flex-direction: row; align-items: center; padding: 12px 18px; background-color: #ffffff; }
.header-back { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f1f4f8; }
.header-back-icon { font-size: 18px; color: #66758d; }
.header-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; }
.header-title { font-size: 17px; font-weight: 700; color: #22283a; }
.header-subtitle { margin-top: 3px; font-size: 12px; color: #8992a5; }
.header-action { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 18px; background-color: #f0f1ff; }
.header-action-icon { font-size: 18px; color: #554be9; }
.content-scroll { flex: 1; }
.content-panel { display: flex; flex-direction: column; padding: 18px 22px 28px; }
.status-panel { display: flex; flex-direction: column; align-items: center; justify-content: center; height: 260px; }
.status-text { font-size: 13px; color: #8992a5; }
.list-heading { display: flex; flex-direction: row; align-items: center; justify-content: space-between; }
.list-title { font-size: 16px; font-weight: 700; color: #242b3d; }
.book-count { font-size: 12px; color: #8992a5; }
.list-description { margin-top: 6px; font-size: 11px; color: #8992a5; }
.book-card { display: flex; flex-direction: column; margin-top: 12px; padding: 16px; border: 1px solid; border-radius: 16px; }
.book-card-green { border-color: #8ee8ca; background-color: #f0fff9; }
.book-card-yellow { border-color: #ffd66e; background-color: #fffaf0; }
.book-card-blue { border-color: #9cd7ff; background-color: #eff8ff; }
.book-card-gray { border-color: #dbe2eb; background-color: #f2f6fa; }
.book-heading { display: flex; flex-direction: row; align-items: flex-start; }
.book-icon { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 11px; }
.book-icon-green { background-color: #16bd88; }
.book-icon-yellow { background-color: #fff0c5; }
.book-icon-blue { background-color: #d9f0ff; }
.book-icon-gray { background-color: #ffffff; }
.book-icon-glyph { font-size: 20px; color: #ffffff; }
.book-icon-glyph-yellow, .book-icon-glyph-blue { color: #1598dc; }
.book-copy { display: flex; flex-direction: column; flex: 1; margin-left: 12px; min-width: 0; }
.book-name { font-size: 14px; font-weight: 700; color: #203329; }
.book-meta { display: flex; flex-direction: row; align-items: center; margin-top: 5px; }
.book-total { font-size: 10px; color: #657487; }
.book-level { margin-left: 9px; padding: 2px 6px; border-radius: 8px; font-size: 9px; color: #657487; background-color: #e4f8ef; }
.book-status { flex-shrink: 0; padding: 7px 11px; border-radius: 16px; font-size: 10px; }
.book-status-green { color: #ffffff; background-color: #16bd88; }
.book-status-yellow { color: #d99000; border: 1px solid #ffd66e; background-color: #fff4cf; }
.book-status-blue { color: #168ed0; border: 1px solid #9cd7ff; background-color: #e2f4ff; }
.book-status-gray { color: #68778b; border: 1px solid #d5dee9; background-color: #ffffff; }
.progress-heading { display: flex; flex-direction: row; justify-content: space-between; margin-top: 13px; }
.progress-label, .progress-value { font-size: 11px; color: #567084; }
.progress-value { text-align: right; }
.progress-track { height: 6px; margin-top: 7px; overflow: hidden; border-radius: 3px; background-color: rgba(150, 170, 185, 0.18); }
.progress-fill { height: 6px; border-radius: 3px; }
.progress-fill-green { background-color: #16bd88; }
.progress-fill-yellow { background-color: #ffc126; }
.progress-fill-blue { background-color: #159fe8; }
.progress-fill-gray { background-color: #ccd5df; }
</style>