Initial commit
This commit is contained in:
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user