This commit is contained in:
Zyronon
2025-11-20 19:57:11 +08:00
committed by GitHub
parent f8246c3255
commit 771b5fa50f
5 changed files with 442 additions and 244 deletions

View File

@@ -1,4 +1,4 @@
import http, { axiosInstance } from "@/utils/http.ts";
import http, {axiosInstance, AxiosResponse} from "@/utils/http.ts";
import { Dict } from "@/types/types.ts";
import { cloneDeep } from "@/utils";
@@ -48,7 +48,7 @@ export function addDict(params?, data?) {
return http<Dict>('dict/addDict', remove(data), remove(params), 'post')
}
export function uploadImportData(data, onUploadProgress) {
export function uploadImportData<T>(data, onUploadProgress): Promise<AxiosResponse<T>> {
return axiosInstance({
url: 'dict/uploadImportData',
method: 'post',

View File

@@ -71,7 +71,7 @@ export function loginApi(params: LoginParams) {
}
export function registerApi(params: RegisterParams) {
return http<RegisterResponse>('user/register', params, null, 'post')
return http<{ token:string }>('user/register', params, null, 'post')
}
export function sendCode(params: SendCodeParams) {

View File

@@ -1,26 +1,26 @@
<script setup lang="tsx">
import { onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {onBeforeUnmount, onMounted} from 'vue'
import {useRoute} from 'vue-router'
import BaseInput from "@/components/base/BaseInput.vue";
import BaseButton from "@/components/BaseButton.vue";
import { APP_NAME } from "@/config/env.ts";
import { useUserStore } from "@/stores/user.ts";
import { loginApi, LoginParams, registerApi, resetPasswordApi } from "@/apis/user.ts";
import { accountRules, codeRules, passwordRules, phoneRules } from "@/utils/validation.ts";
import {APP_NAME} from "@/config/env.ts";
import {useUserStore} from "@/stores/user.ts";
import {loginApi, LoginParams, registerApi, resetPasswordApi} from "@/apis/user.ts";
import {accountRules, codeRules, passwordRules, phoneRules} from "@/utils/validation.ts";
import Toast from "@/components/base/toast/Toast.ts";
import FormItem from "@/components/base/form/FormItem.vue";
import Form from "@/components/base/form/Form.vue";
import Notice from "@/pages/user/Notice.vue";
import { FormInstance } from "@/components/base/form/types.ts";
import { PASSWORD_CONFIG, PHONE_CONFIG } from "@/config/auth.ts";
import { CodeType, ImportStatus } from "@/types/types.ts";
import {FormInstance} from "@/components/base/form/types.ts";
import {PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
import {CodeType, ImportStatus} from "@/types/types.ts";
import Code from "@/pages/user/Code.vue";
import { isNewUser, useNav } from "@/utils";
import {isNewUser, sleep, useNav} from "@/utils";
import Header from "@/components/Header.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import { useExport } from "@/hooks/export.ts";
import { getProgress, upload, uploadImportData } from "@/apis";
import { Exception } from "sass";
import {useExport} from "@/hooks/export.ts";
import {getProgress, upload, uploadImportData} from "@/apis";
import {Exception} from "sass";
// 状态管理
const userStore = useUserStore()
@@ -37,6 +37,7 @@ let wechatQRUrl = $ref('https://open.weixin.qq.com/connect/qrcode/041GmMJM2wfM0w
let qrStatus = $ref<'idle' | 'scanned' | 'expired' | 'cancelled'>('idle')
let qrExpireTimer: ReturnType<typeof setTimeout> | null = null
let qrCheckInterval: ReturnType<typeof setInterval> | null = null
let waitForImportConfirmation = $ref(true)
const QR_EXPIRE_TIME = 5 * 60 * 1000 // 5分钟过期
@@ -114,6 +115,11 @@ const currentFormRef = $computed<FormInstance>(() => {
else return forgotFormRef
})
function loginSuccess(token: string) {
// userStore.setToken(token)
waitForImportConfirmation = true
}
// 统一登录处理
async function handleLogin() {
currentFormRef.validate(async (valid) => {
@@ -130,9 +136,7 @@ async function handleLogin() {
}
let res = await loginApi(data as LoginParams)
if (res.success) {
userStore.setToken(res.data.token)
Toast.success('登录成功')
router.back()
loginSuccess(res.data.token)
} else {
Toast.error(res.msg || '登录失败')
if (res.code === 499) {
@@ -155,11 +159,9 @@ async function handleRegister() {
loading = true
let res = await registerApi(registerForm)
if (res.success) {
userStore.setToken(res.data.token)
userStore.setUser(res.data.user as any)
Toast.success('注册成功')
// 跳转到首页或用户中心
router.push('/')
await sleep(1500)
loginSuccess(res.data.token)
} else {
Toast.error(res.msg || '注册失败')
}
@@ -285,48 +287,77 @@ onBeforeUnmount(() => {
clearInterval(timer)
})
enum ImportStep {
CONFIRMATION,//等待确认
PROCESSING,//处理中
SUCCESS,//成功
FAIL,//失败
}
const {exportData} = useExport()
let waitForImportConfirmation = $ref(true)
let importStep = $ref<ImportStep>(ImportStep.CONFIRMATION)
let isImporting = $ref(false)
let reason = $ref('')
let timer = $ref(-1)
let requestCount = $ref(0)
async function startSync() {
isImporting = true
reason = '导出数据中'
let res = await exportData('')
reason = '上传数据中'
let formData = new FormData()
formData.append('file', res, "example.zip")
uploadImportData(formData, progressEvent => {
let percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
reason = `上传进度(${percent}%)`
}).then((result: any) => {
importStep = ImportStep.PROCESSING
return
if (importStep === ImportStep.PROCESSING) return
try {
importStep = ImportStep.PROCESSING
reason = '导出数据中'
let res = await exportData('')
reason = '上传数据中'
let formData = new FormData()
formData.append('file', res, "example.zip")
let result = await uploadImportData(formData, progressEvent => {
let percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
reason = `上传进度(${percent}%)`
})
if (result.success) {
reason = `上传完成; 正在解析中`
clearInterval(timer)
timer = setInterval(() => {
getProgress().then(r => {
if (r.success) {
if (r.data.status === ImportStatus.Success) {
reason = '同步完成'
clearInterval(timer)
} else if (r.data.status === ImportStatus.Success) {
reason = '同步失败'
} else {
reason = r.data.reason
timer = setInterval(async () => {
let r = await getProgress()
if (r.success) {
if (r.data.status === ImportStatus.Success) {
reason = '同步完成'
clearInterval(timer)
importStep = ImportStep.SUCCESS
} else if (r.data.status === ImportStatus.Fail) {
throw new Error('同步失败,请联系管理员')
} else {
reason = r.data.reason
if (requestCount > 15) {
throw new Error('同步失败,请联系管理员')
}
if (reason === '解析文件中') {
requestCount++
}
}
})
} else {
throw new Error('无同步记录')
}
}, 2000)
} else {
throw new Error('同步失败')
throw new Error(`同步失败,${result.msg ? ('原因: ' + result.msg) : ''},请联系管理员`)
}
}).catch(error => {
} catch (error) {
Toast.error(error.message || '同步失败')
reason = error.message || '同步失败'
isImporting = false
});
clearInterval(timer)
importStep = ImportStep.FAIL
}
}
function logout() {
waitForImportConfirmation = false
}
function forgetData() {
}
</script>
@@ -344,28 +375,28 @@ async function startSync() {
<!-- Tab切换 -->
<div class="center gap-8 mb-6">
<div
class="center cp transition-colors"
:class="loginType === 'code' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'code'"
class="center cp transition-colors"
:class="loginType === 'code' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'code'"
>
<div>
<span>验证码登录</span>
<div
v-opacity="loginType === 'code'"
class="mt-1 h-0.5 bg-blue-600"
v-opacity="loginType === 'code'"
class="mt-1 h-0.5 bg-blue-600"
></div>
</div>
</div>
<div
class="center cp transition-colors"
:class="loginType === 'password' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'password'"
class="center cp transition-colors"
:class="loginType === 'password' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'password'"
>
<div>
<span>密码登录</span>
<div
v-opacity="loginType === 'password'"
class="mt-1 h-0.5 bg-blue-600"
v-opacity="loginType === 'password'"
class="mt-1 h-0.5 bg-blue-600"
></div>
</div>
</div>
@@ -373,10 +404,10 @@ async function startSync() {
<!-- 验证码登录表单 -->
<Form
v-if="loginType === 'code'"
ref="phoneLoginFormRef"
:rules="phoneLoginFormRules"
:model="phoneLoginForm">
v-if="loginType === 'code'"
ref="phoneLoginFormRef"
:rules="phoneLoginFormRules"
:model="phoneLoginForm">
<FormItem prop="phone">
<BaseInput v-model="phoneLoginForm.phone"
type="tel"
@@ -389,11 +420,11 @@ async function startSync() {
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="phoneLoginForm.code"
type="code"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
v-model="phoneLoginForm.code"
type="code"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
/>
<Code :validate-field="() => phoneLoginFormRef.validateField('phone')"
:type="CodeType.Login"
@@ -404,10 +435,10 @@ async function startSync() {
<!-- 密码登录表单 -->
<Form
v-else
ref="loginForm2Ref"
:rules="loginForm2Rules"
:model="loginForm2">
v-else
ref="loginForm2Ref"
:rules="loginForm2Rules"
:model="loginForm2">
<FormItem prop="account">
<BaseInput v-model="loginForm2.account"
type="email"
@@ -420,12 +451,12 @@ async function startSync() {
<FormItem prop="password">
<div class="flex gap-2">
<BaseInput
v-model="loginForm2.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
placeholder="请输入密码"
v-model="loginForm2.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
placeholder="请输入密码"
/>
</div>
</FormItem>
@@ -436,10 +467,10 @@ async function startSync() {
</Notice>
<BaseButton
class="w-full"
size="large"
:loading="loading"
@click="handleLogin"
class="w-full"
size="large"
:loading="loading"
@click="handleLogin"
>
登录
</BaseButton>
@@ -456,27 +487,27 @@ async function startSync() {
<Header @click="switchMode('login')" title="注册新账号"/>
<Form
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
<FormItem prop="account">
<BaseInput
v-model="registerForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
v-model="registerForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="registerForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="registerForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => registerFormRef.validateField('account')"
:type="CodeType.Register"
@@ -485,22 +516,22 @@ async function startSync() {
</FormItem>
<FormItem prop="password">
<BaseInput
v-model="registerForm.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
v-model="registerForm.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="registerForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入密码"
v-model="registerForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入密码"
/>
</FormItem>
</Form>
@@ -508,10 +539,10 @@ async function startSync() {
<Notice/>
<BaseButton
class="w-full"
size="large"
:loading="loading"
@click="handleRegister"
class="w-full"
size="large"
:loading="loading"
@click="handleRegister"
>
注册
</BaseButton>
@@ -523,27 +554,27 @@ async function startSync() {
<Header @click="switchMode('login')" title="重置密码"/>
<Form
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
<FormItem prop="account">
<BaseInput
v-model="forgotForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
v-model="forgotForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="forgotForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="forgotForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => forgotFormRef.validateField('account')"
:type="CodeType.ResetPwd"
@@ -552,31 +583,31 @@ async function startSync() {
</FormItem>
<FormItem prop="newPassword">
<BaseInput
v-model="forgotForm.newPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
v-model="forgotForm.newPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="forgotForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入新密码"
v-model="forgotForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入新密码"
/>
</FormItem>
</Form>
<BaseButton
class="w-full mt-2"
size="large"
:loading="loading"
@click="handleForgotPassword"
class="w-full mt-2"
size="large"
:loading="loading"
@click="handleForgotPassword"
>
重置密码
</BaseButton>
@@ -587,16 +618,16 @@ async function startSync() {
<div v-if="currentMode === 'login'" class="center flex-col bg-gray-100 rounded-xl px-12">
<div class="relative w-40 h-40 bg-white rounded-xl overflow-hidden shadow-xl">
<img
v-if="showWechatQR"
:src="wechatQRUrl"
alt="微信登录二维码"
class="w-full h-full"
:class="{ 'opacity-30': qrStatus === 'expired' }"
v-if="showWechatQR"
:src="wechatQRUrl"
alt="微信登录二维码"
class="w-full h-full"
:class="{ 'opacity-30': qrStatus === 'expired' }"
/>
<!-- 扫描成功蒙层 -->
<div
v-if="qrStatus === 'scanned'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
v-if="qrStatus === 'scanned'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
>
<IconFluentCheckmarkCircle20Filled class="color-green text-4xl"/>
<div class="text-base text-gray-700 font-medium">扫描成功</div>
@@ -604,8 +635,8 @@ async function startSync() {
</div>
<!-- 取消登录蒙层 -->
<div
v-if="qrStatus === 'cancelled'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
v-if="qrStatus === 'cancelled'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
>
<IconFluentErrorCircle20Regular class="color-red text-4xl"/>
<div class="text-base text-gray-700 font-medium">你已取消此次登录</div>
@@ -614,12 +645,12 @@ async function startSync() {
</div>
<!-- 过期蒙层 -->
<div
v-if=" qrStatus === 'expired'"
class="absolute top-0 left-0 right-0 bottom-0 bg-opacity-95 center backdrop-blur-sm"
v-if=" qrStatus === 'expired'"
class="absolute top-0 left-0 right-0 bottom-0 bg-opacity-95 center backdrop-blur-sm"
>
<IconFluentArrowClockwise20Regular
@click="refreshQRCode"
class="cp text-4xl"/>
@click="refreshQRCode"
class="cp text-4xl"/>
</div>
</div>
<p class="mt-4 center gap-space">
@@ -633,45 +664,74 @@ async function startSync() {
<div v-else class="card-white p-6 w-100">
<div class="title">同步数据确认</div>
<div class="flex flex-col justify-between h-60">
<div v-if="!isImporting">
<h2>检测到您本地存在使用记录</h2>
<h3>是否需要同步到账户中?</h3>
</div>
<div v-else>
<h3 class="text-align-center">正在导入中</h3>
<ol class="pl-4">
<li>
您的用户数据已自动下载到您的电脑中
</li>
<li>
随后将开始数据同步
</li>
<li>
如果您的数据量很大,这将是一个耗时操作
</li>
<li class="color-red-5 font-bold">
请耐心等待,请勿关闭此页面
</li>
</ol>
<div class="flex items-center justify-between gap-2 mt-10">
<span>当前进度: {{ reason }}</span>
<IconEosIconsLoading class="text-xl"/>
<template v-if="importStep === ImportStep.CONFIRMATION">
<div>
<h2>检测到您本地存在使用记录</h2>
<h3>是否需要同步到账户中?</h3>
</div>
</div>
<div class="flex gap-space justify-end" v-if="!isImporting">
<BaseButton type="info" @click="waitForImportConfirmation = false">退出登录</BaseButton>
<div class="flex gap-space justify-end">
<template v-if="importStep === ImportStep.CONFIRMATION">
<BaseButton type="info" @click="logout">退出登录</BaseButton>
<PopConfirm :title="[
<PopConfirm :title="[
{text:'您的用户数据将以压缩包自动下载到您的电脑中,以便您随时恢复',type:'normal'},
{text:'随后网站的用户数据将被删除',type:'redBold'},
{text:'是否确认继续?',type:'normal'},
]">
<BaseButton type="info">放弃数据</BaseButton>
</PopConfirm>
]"
@confirm="forgetData"
>
<BaseButton type="info">放弃数据</BaseButton>
</PopConfirm>
</template>
<BaseButton @click="startSync">确认同步</BaseButton>
</div>
</template>
<template v-if="importStep === ImportStep.PROCESSING">
<div>
<h3 class="text-align-center">正在导入中</h3>
<ol class="pl-4">
<li>
您的用户数据已自动下载到您的电脑中,以便随时恢复
</li>
<li>
随后将开始数据同步
</li>
<li>
如果您的数据量很大,这将是一个耗时操作
</li>
<li class="color-red-5 font-bold">
请耐心等待,请勿关闭此页面
</li>
</ol>
<div class="flex items-center justify-between gap-2 mt-10">
<span>当前进度: {{ reason }}</span>
<IconEosIconsLoading class="text-xl"/>
</div>
</div>
</template>
<template v-if="importStep === ImportStep.FAIL">
<div>
<h3 class="text-align-center">同步失败</h3>
<div class="mt-10">
<span>{{ reason }}</span>
</div>
</div>
<BaseButton @click="startSync">确认同步</BaseButton>
</div>
<div class="flex gap-space justify-end">
<BaseButton type="info" @click="logout">退出登录</BaseButton>
<PopConfirm :title="[
{text:'您的用户数据将以压缩包自动下载到您的电脑中,以便您随时恢复',type:'normal'},
{text:'随后网站的用户数据将被删除',type:'redBold'},
{text:'是否确认继续?',type:'normal'},
]"
@confirm="forgetData"
>
<BaseButton type="info">放弃数据</BaseButton>
</PopConfirm>
<BaseButton @click="startSync">再次同步</BaseButton>
</div>
</template>
</div>
</div>
</div>

View File

@@ -98,6 +98,39 @@ function options(emitType: string) {
close()
}
// 计算学习进度百分比
const studyProgress = $computed(() => {
if (!store.sdict.length) return 0
return Math.round((store.sdict.lastLearnIndex / store.sdict.length) * 100)
})
// 计算正确率
const accuracyRate = $computed(() => {
if (statStore.total === 0) return 100
return Math.round(((statStore.total - statStore.wrong) / statStore.total) * 100)
})
// 获取鼓励文案
const encouragementText = $computed(() => {
const rate = accuracyRate
if (rate >= 95) return '🎉 太棒了!继续保持!'
if (rate >= 85) return '👍 表现很好,再接再厉!'
if (rate >= 70) return '💪 不错的成绩,继续加油!'
return '🌟 每次练习都是进步,坚持下去!'
})
// 格式化学习时间
const formattedStudyTime = $computed(() => {
const time = msToHourMinute(statStore.spend)
return time.replace('小时', 'h ').replace('分钟', 'm')
})
// 获取星期标签
function getDayLabel(index: number) {
const days = ['一', '二', '三', '四', '五', '六', '日']
return days[index]
}
</script>
<template>
@@ -106,93 +139,198 @@ function options(emitType: string) {
:header="false"
:keyboard="false"
:show-close="false"
v-model="model">
<div class="w-140 bg-white color-black p-6 relative flex flex-col gap-6">
<div class="w-full flex flex-col justify-evenly">
<div class="center text-2xl mb-2">已完成{{ practiceTaskWords.shuffle.length ? '随机复习' : '今日任务' }}</div>
<div class="flex">
<div v-if="practiceTaskWords.shuffle.length"
class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">随机复习</div>
<div class="text-4xl font-bold">{{ practiceTaskWords.shuffle.length }}</div>
</div>
class="statistics-modal">
<div class="p-8 bg-white rounded-2xl max-w-2xl mx-auto">
<!-- Header Section -->
<div class="text-center mb-8 relative">
<div
class="text-3xl font-bold mb-2 bg-gradient-to-r from-purple-500 to-purple-700 bg-clip-text text-transparent">
<template v-if="practiceTaskWords.shuffle.length">
🎯 随机复习完成
</template>
<template v-else>
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">新词数</div>
<div class="text-4xl font-bold">{{ statStore.newWordNumber }}</div>
</div>
<template v-if="settingStore.wordPracticeMode !== WordPracticeMode.Free">
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">复习上次</div>
<div class="text-4xl font-bold">{{ statStore.reviewWordNumber }}</div>
</div>
<div class="flex-1 flex flex-col items-center">
<div class="text-sm color-gray">复习之前</div>
<div class="text-4xl font-bold">{{ statStore.writeWordNumber }}</div>
</div>
</template>
🎉 今日任务完成
</template>
</div>
<p class="text-gray-600 font-medium text-lg">{{ encouragementText }}</p>
</div>
<div class="text-xl text-center flex flex-col justify-around">
<div>非常棒! 坚持了 <span class="color-emerald-500 font-bold text-2xl">{{msToHourMinute(statStore.spend) }}</span>
<!-- Main Stats Grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-2">
<!-- Study Time -->
<div class="item">
<IconFluentClock20Regular class="text-purple-500 mx-auto mb-2"/>
<div class="text-sm text-gray-600 mb-1 font-medium">学习时长</div>
<div class="text-xl font-bold text-gray-900">{{ formattedStudyTime }}</div>
</div>
</div>
<div class="flex justify-center gap-10">
<div class="flex justify-center items-center py-3 px-10 rounded-md color-red-500 flex-col"
style="background: rgb(254,236,236)">
<div class="text-3xl">{{ statStore.wrong }}</div>
<div class="center gap-2">
<IconFluentDismiss20Regular class="text-xl"/>
错词
<!-- Accuracy Rate -->
<div class="item">
<IconFluentTarget20Regular class="text-purple-500 mx-auto mb-2"/>
<div class="text-sm text-gray-600 mb-1 font-medium">正确率</div>
<div class="text-xl font-bold text-gray-900 mb-2">{{ accuracyRate }}%</div>
<div class="w-full bg-gray-200 rounded-full h-1">
<div
class="h-1 rounded-full transition-all duration-300"
:class="{
'bg-green-500': accuracyRate >= 95,
'bg-yellow-500': accuracyRate >= 85 && accuracyRate < 95,
'bg-red-500': accuracyRate < 85
}"
:style="{ width: accuracyRate + '%' }">
</div>
</div>
</div>
<div class="flex justify-center items-center py-3 px-10 rounded-md color-emerald-500 flex-col"
style="background: rgb(231,248,241)">
<div class="text-3xl">{{ statStore.total - statStore.wrong }}</div>
<div class="center gap-2">
<IconFluentCheckmark20Regular class="text-xl"/>
正确
<!-- Total Words -->
<div class="item">
<IconFluentBook20Regular class="text-purple-500 mx-auto mb-2"/>
<div class="text-sm text-gray-600 mb-1 font-medium">总词数</div>
<div class="text-xl font-bold text-gray-900">{{ statStore.total }}</div>
</div>
<!-- New Words -->
<div class="item">
<IconFluentSparkle20Regular class="text-purple-500 mx-auto mb-2"/>
<div class="text-sm text-gray-600 mb-1 font-medium">新词</div>
<div class="text-xl font-bold text-gray-900">{{ statStore.newWordNumber }}</div>
</div>
</div>
<!-- Word Breakdown -->
<div class="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-2 mb-2">
<div class="text-center mb-4 title">学习详情</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="flex items-center gap-3 p-3 bg-white rounded-lg shadow-sm">
<div class="w-6 h-6 text-green-500">
<IconFluentCheckmark20Regular/>
</div>
<div>
<div class="text-sm text-gray-600">正确</div>
<div class="text-lg font-bold text-gray-900">{{ statStore.total - statStore.wrong }}</div>
</div>
</div>
<div class="flex items-center gap-3 p-3 bg-white rounded-lg shadow-sm">
<div class="w-6 h-6 text-red-500">
<IconFluentDismiss20Regular/>
</div>
<div>
<div class="text-sm text-gray-600">错误</div>
<div class="text-lg font-bold text-gray-900">{{ statStore.wrong }}</div>
</div>
</div>
<div class="flex items-center gap-3 p-3 bg-white rounded-lg shadow-sm">
<div class="w-6 h-6 text-yellow-500">
<IconFluentArrowRepeatAll20Regular/>
</div>
<div>
<div class="text-sm text-gray-600">复习</div>
<div class="text-lg font-bold text-gray-900">{{
statStore.reviewWordNumber + statStore.writeWordNumber
}}
</div>
</div>
</div>
</div>
</div>
<div class="center flex-col">
<div class="title text-align-center mb-2">本周学习记录</div>
<div class="flex gap-4 color-gray">
<!-- Weekly Progress -->
<div class="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-2 mb-2">
<div class="text-center mb-4">
<div class="text-xl font-semibold text-gray-900 mb-1">本周学习记录</div>
<div class="text-sm text-gray-600">坚持就是胜利</div>
</div>
<div class="flex justify-between gap-2">
<div
class="w-8 h-8 rounded-md center"
:class="item ? 'bg-emerald-500 color-white' : 'bg-gray-200'"
v-for="(item, i) in list"
:key="i"
>{{ i + 1 }}
class="flex-1 text-center p-2 rounded-lg transition-all duration-300 cursor-pointer"
:class="item ? 'bg-green-500 text-white shadow-lg' : 'bg-white text-gray-700 hover:shadow-md'"
>
<div class="font-semibold mb-1">{{ i + 1 }}</div>
<div class="w-2 h-2 rounded-full mx-auto mb-1"
:class="item ? 'bg-white bg-opacity-30' : 'bg-gray-300'"></div>
<div class="text-xs font-medium">{{ getDayLabel(i) }}</div>
</div>
</div>
</div>
<div class="flex justify-center gap-4 ">
<!-- Progress Overview -->
<div class="bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-6 mb-8">
<div class="flex justify-between items-center mb-3">
<div class="text-xl font-semibold text-gray-900">学习进度</div>
<div class="text-2xl font-bold text-purple-600">{{ studyProgress }}%</div>
</div>
<div class="w-full bg-gray-200 rounded-full h-3 mb-3">
<div
class="h-3 rounded-full bg-gradient-to-r from-purple-500 to-purple-700 transition-all duration-500"
:style="{ width: studyProgress + '%' }">
</div>
</div>
<div class="flex justify-between text-sm text-gray-600 font-medium">
<span>已学习: {{ store.sdict.lastLearnIndex }}</span>
<span>总词数: {{ store.sdict.length }}</span>
</div>
</div>
<!-- Action Buttons -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
@click="options(EventKey.repeatStudy)">
@click="options(EventKey.repeatStudy)"
class="flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-all duration-300 hover:-translate-y-1 hover:shadow-lg bg-gradient-to-r from-yellow-500 to-orange-500 text-white">
<IconFluentArrowClockwise20Regular class="w-5 h-5"/>
重学一遍
</BaseButton>
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
@click="options(EventKey.continueStudy)">
@click="options(EventKey.continueStudy)"
class="flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-all duration-300 hover:-translate-y-1 hover:shadow-lg"
:class="dictIsEnd ? 'bg-gradient-to-r from-purple-500 to-purple-600 text-white' : 'bg-gradient-to-r from-green-500 to-green-600 text-white'">
<IconFluentPlay20Regular class="w-5 h-5"/>
{{ dictIsEnd ? '从头开始练习' : '再来一组' }}
</BaseButton>
<BaseButton
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextRandomWrite]"
@click="options(EventKey.randomWrite)">
@click="options(EventKey.randomWrite)"
class="flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-all duration-300 hover:-translate-y-1 hover:shadow-lg bg-gradient-to-r from-blue-500 to-blue-600 text-white">
<IconFluentPen20Regular class="w-5 h-5"/>
继续默写
</BaseButton>
<BaseButton @click="$router.back">
<BaseButton @click="$router.back"
class="flex items-center justify-center gap-2 py-3 px-4 rounded-xl font-medium transition-all duration-300 hover:-translate-y-1 hover:shadow-lg bg-gradient-to-r from-gray-500 to-gray-600 text-white">
<IconFluentHome20Regular class="w-5 h-5"/>
返回主页
</BaseButton>
<!-- <BaseButton>-->
<!-- 分享-->
<!-- </BaseButton>-->
</div>
</div>
</Dialog>
</template>
</template>
<style scoped>
/* Custom animation for pulse effect */
@keyframes pulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.05);
}
}
.animate-pulse {
animation: pulse 2s infinite;
}
/* Custom gradient text utility */
.text-gradient {
background: linear-gradient(135deg, #667eea, #764ba2);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.item{
@apply bg-gradient-to-br from-gray-50 to-gray-100 rounded-xl p-4 text-center hover:shadow-lg transition-all duration-300 hover:-translate-y-1 border border-gray-100;
}
</style>

View File

@@ -84,7 +84,7 @@ axiosInstance.interceptors.response.use(
},
)
type AxiosResponse<T> = { code: number, data: T, success: boolean, msg: string }
export type AxiosResponse<T> = { code: number, data: T, success: boolean, msg: string }
async function request<T>(url, data = {}, params = {}, method): Promise<AxiosResponse<T>> {
return axiosInstance({