wip
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user