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
+90
View File
@@ -0,0 +1,90 @@
<template>
<view class="countdown-btn-wrap">
<text
class="countdown-btn-text"
:class="canSend ? 'countdown-btn-active' : 'countdown-btn-disabled'"
@tap="handleTap"
>{{ btnText }}</text>
</view>
</template>
<script setup lang="ts">
type Props = {
phone?: string
codeType?: string
}
const props = withDefaults(defineProps<Props>(), {
phone: '',
codeType: 'login'
})
const emit = defineEmits<{
(e: 'send', phone: string): void
}>()
const COUNTDOWN = 60
const canSend = ref(true)
const countdown = ref(COUNTDOWN)
const btnText = ref('获取验证码')
let timer : number | null = null
function handleTap() {
if (!canSend.value) return
emit('send', props.phone)
}
/**
* 开始倒计时 - 由父组件在发送成功后调用
*/
function startCountdown() {
canSend.value = false
countdown.value = COUNTDOWN
btnText.value = `${countdown.value}s后重发`
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
stopCountdown()
} else {
btnText.value = `${countdown.value}s后重发`
}
}, 1000)
}
function stopCountdown() {
if (timer !== null) {
clearInterval(timer)
timer = null
}
canSend.value = true
countdown.value = COUNTDOWN
btnText.value = '重新发送'
}
onUnmounted(() => {
stopCountdown()
})
defineExpose({ startCountdown })
</script>
<style>
.countdown-btn-wrap {
flex-shrink: 0;
}
.countdown-btn-text {
font-size: 14px;
padding: 4px 8px;
}
.countdown-btn-active {
color: #6C63FF;
}
.countdown-btn-disabled {
color: #BBBBBB;
}
</style>
+64
View File
@@ -0,0 +1,64 @@
<template>
<view class="footer-nav">
<view class="nav-item" :class="activeTab === 'home' ? 'nav-item-active' : ''" @tap="goPage('/pages/index/index')">
<view class="nav-icon-slot"><UniIcons :type="activeTab === 'home' ? 'home-filled' : 'home'" class="nav-icon" :class="activeTab === 'home' ? 'nav-icon-active' : ''"></UniIcons></view>
<text class="nav-label" :class="activeTab === 'home' ? 'nav-label-active' : ''">首页</text>
</view>
<view class="nav-item" :class="activeTab === 'study' ? 'nav-item-active' : ''" @tap="goPage('/pages/study/index')">
<view class="nav-icon-slot"><view class="nav-chart">
<view class="nav-chart-bar nav-chart-short" :class="activeTab === 'study' ? 'nav-chart-active' : ''"></view>
<view class="nav-chart-bar nav-chart-tall" :class="activeTab === 'study' ? 'nav-chart-active' : ''"></view>
<view class="nav-chart-bar nav-chart-medium" :class="activeTab === 'study' ? 'nav-chart-active' : ''"></view>
</view></view>
<text class="nav-label" :class="activeTab === 'study' ? 'nav-label-active' : ''">学情</text>
</view>
<view class="nav-item" :class="activeTab === 'tasks' ? 'nav-item-active' : ''" @tap="goPage('/pages/tasks/index')">
<view class="nav-icon-slot"><UniIcons :type="activeTab === 'tasks' ? 'checkbox-filled' : 'checkbox'" class="nav-icon" :class="activeTab === 'tasks' ? 'nav-icon-active' : ''"></UniIcons></view>
<text class="nav-label" :class="activeTab === 'tasks' ? 'nav-label-active' : ''">任务</text>
</view>
<view class="nav-item" :class="activeTab === 'profile' ? 'nav-item-active' : ''" @tap="goPage('/pages/profile/index')">
<view class="nav-icon-slot"><UniIcons :type="activeTab === 'profile' ? 'person-filled' : 'person'" class="nav-icon" :class="activeTab === 'profile' ? 'nav-icon-active' : ''"></UniIcons></view>
<text class="nav-label" :class="activeTab === 'profile' ? 'nav-label-active' : ''">我的</text>
</view>
</view>
</template>
<script setup lang="ts">
import UniIcons from '../uni_modules/uni-icons-x/components/uni-icons/uni-icons.uvue'
const props = defineProps<{ activeTab: string }>()
function goPage(url: string) {
const pages = getCurrentPages()
const route = normalizeRoute(url)
const currentPage = pages[pages.length - 1]
if (currentPage && normalizeRoute(currentPage.route) === route) return
for (let index = 0; index < pages.length; index++) {
if (normalizeRoute(pages[index].route) === route) {
uni.navigateBack({ delta: pages.length - index - 1 })
return
}
}
uni.navigateTo({ url })
}
function normalizeRoute(route: string): string {
return route.split('?')[0].replace(/^\/+/, '')
}
</script>
<style>
.footer-nav { display: flex; flex-direction: row; align-items: flex-start; justify-content: space-around; padding: 10px 14px calc(9px + var(--uni-safe-area-inset-bottom)); background-color: #ffffff; border-top: 1px solid #e5e8f0; }
.nav-item { display: flex; flex-direction: column; align-items: center; width: 25%; }
.nav-icon-slot { display: flex; flex-direction: row; align-items: center; justify-content: center; width: 26px; height: 26px; margin-bottom: 5px; }
.nav-icon { font-size: 22px; color: #9da8ba; }
.nav-icon-active { color: #554be9; }
.nav-chart { display: flex; flex-direction: row; align-items: flex-end; justify-content: center; width: 22px; height: 22px; padding-bottom: 2px; }
.nav-chart-bar { width: 4px; margin: 0 1px; border-radius: 2px; background-color: #9da8ba; }
.nav-chart-short { height: 9px; }
.nav-chart-tall { height: 18px; }
.nav-chart-medium { height: 13px; }
.nav-chart-active { background-color: #554be9; }
.nav-label { font-size: 11px; color: #9da8ba; }
.nav-label-active { color: #554be9; }
</style>
+149
View File
@@ -0,0 +1,149 @@
<template>
<view class="qr-scanner">
<!-- #ifdef WEB -->
<view :id="videoContainerId" class="qr-scanner-video"></view>
<!-- #endif -->
<!-- #ifndef WEB -->
<camera
class="qr-scanner-camera"
mode="scanCode"
device-position="back"
@scancode="handleCameraScan"
@error="handleCameraError"
></camera>
<!-- #endif -->
</view>
</template>
<script setup lang="ts">
// #ifdef WEB
import { BrowserQRCodeReader } from '@zxing/browser'
import type { IScannerControls } from '@zxing/browser'
// #endif
type Props = {
cooldown?: number
active?: boolean
}
const props = withDefaults(defineProps<Props>(), {
cooldown: 5000,
active: true
})
const emit = defineEmits<{
(e: 'scan', result: string): void
(e: 'error'): void
}>()
let lastScannedAt = 0
// #ifdef WEB
const videoContainerId = `qr-scanner-${Date.now()}`
let scannerControls: IScannerControls | null = null
let scanVideo: HTMLVideoElement | null = null
let isPageActive = false
let restartTimer: number | null = null
// #endif
function emitScan(result: string) {
const now = Date.now()
if (!props.active || !result || now - lastScannedAt < props.cooldown) return
lastScannedAt = now
emit('scan', result)
}
function handleCameraScan(event: any) {
emitScan(event.detail?.result as string ?? '')
}
function handleCameraError() {
emit('error')
}
// #ifdef WEB
function getScanVideo(): HTMLVideoElement | null {
if (scanVideo != null) return scanVideo
const container = document.getElementById(videoContainerId)
if (container == null) return null
const video = document.createElement('video') as HTMLVideoElement
video.autoplay = true
video.muted = true
video.playsInline = true
video.setAttribute('style', 'display:block;width:100%;height:100%;object-fit:cover;background-color:#000000;')
container.appendChild(video)
scanVideo = video
return video
}
async function startWebScanner() {
if (!isPageActive || !props.active || scannerControls != null) return
const video = getScanVideo()
if (video == null) {
handleCameraError()
return
}
try {
const reader = new BrowserQRCodeReader()
scannerControls = await reader.decodeFromConstraints(
{ video: { facingMode: 'environment' } },
video,
(result, _error, controls) => {
if (!result) return
controls.stop()
scannerControls = null
emitScan(result.getText())
scheduleRestart()
}
)
} catch (_error) {
handleCameraError()
}
}
function scheduleRestart() {
if (!isPageActive || !props.active || restartTimer != null) return
restartTimer = setTimeout(() => {
restartTimer = null
void startWebScanner()
}, props.cooldown)
}
function stopWebScanner() {
if (restartTimer != null) {
clearTimeout(restartTimer)
restartTimer = null
}
if (scannerControls != null) {
scannerControls.stop()
scannerControls = null
}
}
watch(() => props.active, (active) => {
if (active) {
void startWebScanner()
} else {
stopWebScanner()
}
})
onMounted(() => {
isPageActive = true
void startWebScanner()
})
onBeforeUnmount(() => {
isPageActive = false
stopWebScanner()
scanVideo = null
})
// #endif
</script>
<style>
.qr-scanner { width: 100%; height: 224px; overflow: hidden; border-radius: 16px; border: 2px solid #6C63FF; background-color: #000000; }
.qr-scanner-camera { width: 100%; height: 100%; }
.qr-scanner-video { width: 100%; height: 100%; }
</style>
+29
View File
@@ -0,0 +1,29 @@
<template>
<view class="activity-chart"><view class="activity-scale"><text class="activity-scale-text">时长(分)</text><text class="activity-scale-text">200</text><text class="activity-scale-text">100</text><text class="activity-scale-text">0</text></view><view class="activity-columns"><view v-for="point in points" :key="point.label" class="activity-column"><view class="activity-bar-wrap"><view class="activity-grid-line activity-grid-line-top"></view><view class="activity-grid-line activity-grid-line-middle"></view><view class="activity-grid-line activity-grid-line-bottom"></view><view class="activity-planned-bar" :style="{ height: point.plannedHeight + 'px' }"></view><view class="activity-study-bar" :style="{ height: point.studyHeight + 'px' }"></view><view v-if="point.newMasteredCount > 0" class="activity-mastery-point" :style="{ bottom: point.newMasteredHeight + 'px' }"><text class="activity-mastery-count">{{ point.newMasteredCount }}</text></view></view><text class="activity-day">{{ point.label }}</text></view></view><view class="activity-mastery-scale"><text class="activity-mastery-scale-text">新增掌握(个)</text><text class="activity-mastery-scale-text">3 个</text><text class="activity-mastery-scale-text">2 个</text><text class="activity-mastery-scale-text">1 个</text><text class="activity-mastery-scale-text">0 个</text></view></view>
</template>
<script setup lang="ts">
import type { StudyReportActivityPoint } from '../api/student'
defineProps<{ points: StudyReportActivityPoint[] }>()
</script>
<style>
.activity-chart { display: flex; flex-direction: row; height: 150px; margin-top: 10px; }
.activity-scale { display: flex; flex-direction: column; justify-content: space-between; width: 38px; padding-bottom: 20px; }
.activity-scale-text { font-size: 9px; color: #8992a5; }
.activity-columns { display: flex; flex-direction: row; justify-content: space-between; flex: 1; padding-bottom: 20px; }
.activity-column { display: flex; flex-direction: column; align-items: center; justify-content: flex-end; height: 130px; }
.activity-bar-wrap { position: relative; display: flex; flex-direction: row; align-items: flex-end; height: 108px; }
.activity-grid-line { position: absolute; left: 0; right: 0; width: 34px; height: 1px; background-color: #edf0f5; }
.activity-grid-line-top { top: 0; }
.activity-grid-line-middle { top: 53px; }
.activity-grid-line-bottom { bottom: 0; }
.activity-planned-bar { width: 9px; margin-right: 4px; border-radius: 4px 4px 0 0; background-color: #d3dce8; }
.activity-study-bar { width: 9px; border-radius: 4px 4px 0 0; background-color: #6558e9; }
.activity-mastery-point { position: absolute; right: -4px; display: flex; flex-direction: column; align-items: center; justify-content: center; width: 10px; height: 10px; border-radius: 5px; background-color: #ffab26; }
.activity-mastery-count { position: absolute; bottom: 11px; font-size: 9px; color: #f08b00; }
.activity-day { margin-top: 6px; font-size: 10px; color: #8992a5; }
.activity-mastery-scale { display: flex; flex-direction: column; justify-content: space-between; width: 42px; padding-bottom: 20px; }
.activity-mastery-scale-text { font-size: 9px; color: #8992a5; }
</style>
+57
View File
@@ -0,0 +1,57 @@
<template>
<view class="trend-chart"><view class="scale-column"><text v-for="scale in scaleLabels" :key="scale" class="scale-label">{{ scale }}</text></view><view class="trend-plot"><view class="trend-lines"><view class="trend-grid-line trend-grid-line-top"></view><view class="trend-grid-line trend-grid-line-upper"></view><view class="trend-grid-line trend-grid-line-lower"></view><view class="trend-grid-line trend-grid-line-bottom"></view><view v-for="segment in averageSegments" :key="segment.key" class="trend-line-segment trend-line-average" :style="segment.style"></view><view v-for="segment in studentSegments" :key="segment.key" class="trend-line-segment trend-line-student" :style="segment.style"></view><view v-for="point in points" :key="point.label + '-average'" class="trend-point trend-point-average" :style="pointStyle(point, false)"></view><view v-for="point in points" :key="point.label + '-student'" class="trend-point trend-point-student" :style="pointStyle(point, true)"></view></view><view class="trend-labels"><text v-for="point in points" :key="point.label" class="trend-day">{{ point.label }}</text></view></view></view>
</template>
<script setup lang="ts">
import type { StudyReportTrendPoint } from '../api/student'
type TrendLineSegment = { key: string, style: string }
const props = defineProps<{ scaleLabels: string[], points: StudyReportTrendPoint[] }>()
const studentSegments = computed((): TrendLineSegment[] => buildSegments(true))
const averageSegments = computed((): TrendLineSegment[] => buildSegments(false))
function buildSegments(isStudent: boolean): TrendLineSegment[] {
const segments: TrendLineSegment[] = []
if (props.points.length < 2) return segments
const chartWidth = 270
const horizontalStep = (chartWidth - 16) / (props.points.length - 1)
for (let index = 0; index < props.points.length - 1; index++) {
const currentHeight = isStudent ? props.points[index].valueHeight : props.points[index].averageHeight
const nextHeight = isStudent ? props.points[index + 1].valueHeight : props.points[index + 1].averageHeight
const verticalStep = nextHeight - currentHeight
const lineWidth = Math.sqrt(horizontalStep * horizontalStep + verticalStep * verticalStep) / chartWidth * 100
const angle = -Math.atan2(verticalStep, horizontalStep) * 180 / Math.PI
const left = 8 + horizontalStep * index
segments.push({ key: `${isStudent ? 'student' : 'average'}-${index}`, style: `left:${left / chartWidth * 100}%;bottom:${currentHeight + 8}px;width:${lineWidth}%;transform:rotate(${angle}deg);` })
}
return segments
}
function pointStyle(point: StudyReportTrendPoint, isStudent: boolean): string {
const index = props.points.indexOf(point)
const left = props.points.length > 1 ? 8 + (270 - 16) / (props.points.length - 1) * index : 135
const pointHeight = isStudent ? point.valueHeight : point.averageHeight
return `left:${left / 270 * 100}%;bottom:${pointHeight + 8}px;`
}
</script>
<style>
.trend-chart { display: flex; flex-direction: row; height: 132px; margin-top: 10px; }
.scale-column { display: flex; flex-direction: column; justify-content: space-between; width: 35px; padding-bottom: 18px; }
.scale-label { font-size: 10px; color: #8992a5; }
.trend-plot { display: flex; flex-direction: column; flex: 1; padding-bottom: 18px; border-bottom: 1px solid #edf0f5; }
.trend-lines { position: relative; height: 90px; }
.trend-grid-line { position: absolute; left: 0; right: 0; height: 1px; background-color: #edf0f5; }
.trend-grid-line-top { top: 8px; }
.trend-grid-line-upper { top: 32px; }
.trend-grid-line-lower { top: 56px; }
.trend-grid-line-bottom { bottom: 8px; }
.trend-line-segment { position: absolute; height: 2px; transform-origin: left center; }
.trend-line-student { background-color: #6558e9; }
.trend-line-average { height: 1px; background-color: #9aa8bc; }
.trend-point { position: absolute; width: 7px; height: 7px; margin-left: -4px; margin-bottom: -4px; border-radius: 4px; background-color: #ffffff; }
.trend-point-student { border: 2px solid #6558e9; }
.trend-point-average { border: 2px solid #9aa8bc; }
.trend-labels { display: flex; flex-direction: row; justify-content: space-between; margin-top: 6px; }
.trend-day { font-size: 10px; color: #8992a5; }
</style>