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
+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>