Merge branch 'master' into feat/mobile
This commit is contained in:
95
src/App.vue
95
src/App.vue
@@ -1,21 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, watch } from "vue";
|
||||
import { BaseState, useBaseStore } from "@/stores/base.ts";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import {onMounted, watch} from "vue";
|
||||
import {BaseState, useBaseStore} from "@/stores/base.ts";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import useTheme from "@/hooks/theme.ts";
|
||||
import { shakeCommonDict } from "@/utils";
|
||||
import { routes } from "@/router.ts";
|
||||
import { get, set } from 'idb-keyval'
|
||||
import {loadJsLib, shakeCommonDict} from "@/utils";
|
||||
import {get, set} from 'idb-keyval'
|
||||
|
||||
import { useRoute } from "vue-router";
|
||||
import { DictId } from "@/types/types.ts";
|
||||
import { APP_VERSION, CAN_REQUEST, LOCAL_FILE_KEY, SAVE_DICT_KEY, SAVE_SETTING_KEY } from "@/config/env.ts";
|
||||
import { syncSetting } from "@/apis";
|
||||
import {useRoute} from "vue-router";
|
||||
import {DictId} from "@/types/types.ts";
|
||||
import {APP_VERSION, AppEnv, LOCAL_FILE_KEY, Origin, SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/config/env.ts";
|
||||
import {syncSetting} from "@/apis";
|
||||
import {useUserStore} from "@/stores/auth.ts";
|
||||
import MigrateDialog from "@/pages/MigrateDialog.vue";
|
||||
|
||||
const store = useBaseStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const userStore = useUserStore()
|
||||
const {setTheme} = useTheme()
|
||||
|
||||
let lastAudioFileIdList = []
|
||||
@@ -49,20 +51,24 @@ watch(store.$state, (n: BaseState) => {
|
||||
}
|
||||
})
|
||||
|
||||
watch(settingStore.$state, (n) => {
|
||||
watch(() => settingStore.$state, (n) => {
|
||||
set(SAVE_SETTING_KEY.key, JSON.stringify({val: n, version: SAVE_SETTING_KEY.version}))
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
syncSetting(null, settingStore.$state)
|
||||
}
|
||||
})
|
||||
}, {deep: true})
|
||||
|
||||
async function init() {
|
||||
await userStore.init()
|
||||
await store.init()
|
||||
await settingStore.init()
|
||||
store.load = true
|
||||
|
||||
setTheme(settingStore.theme)
|
||||
|
||||
if (!settingStore.first) {
|
||||
if (settingStore.first) {
|
||||
set(APP_VERSION.key, APP_VERSION.version)
|
||||
} else {
|
||||
get(APP_VERSION.key).then(r => {
|
||||
runtimeStore.isNew = r ? (APP_VERSION.version > Number(r)) : true
|
||||
})
|
||||
@@ -72,27 +78,38 @@ async function init() {
|
||||
|
||||
onMounted(init)
|
||||
|
||||
let transitionName = $ref('go')
|
||||
const route = useRoute()
|
||||
watch(() => route.path, (to, from) => {
|
||||
return transitionName = ''
|
||||
// console.log('watch', to, from)
|
||||
// //footer下面的5个按钮,对跳不要用动画
|
||||
let noAnimation = [
|
||||
'/pc/practice',
|
||||
'/pc/dict',
|
||||
'/mobile',
|
||||
'/'
|
||||
]
|
||||
if (noAnimation.indexOf(from) !== -1 && noAnimation.indexOf(to) !== -1) {
|
||||
return transitionName = ''
|
||||
//迁移数据
|
||||
let showTransfer = $ref(false)
|
||||
onMounted(() => {
|
||||
if (new URLSearchParams(window.location.search).get('from_old_site') === '1' && location.origin === Origin) {
|
||||
if (localStorage.getItem('__migrated_from_2study_top__')) return;
|
||||
setTimeout(() => {
|
||||
showTransfer = true
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const toDepth = routes.findIndex(v => v.path === to)
|
||||
const fromDepth = routes.findIndex(v => v.path === from)
|
||||
transitionName = toDepth > fromDepth ? 'go' : 'back'
|
||||
// console.log('transitionName', transitionName, toDepth, fromDepth)
|
||||
})
|
||||
|
||||
// let transitionName = $ref('go')
|
||||
// const route = useRoute()
|
||||
// watch(() => route.path, (to, from) => {
|
||||
// return transitionName = ''
|
||||
// console.log('watch', to, from)
|
||||
// //footer下面的5个按钮,对跳不要用动画
|
||||
// let noAnimation = [
|
||||
// '/pc/practice',
|
||||
// '/pc/dict',
|
||||
// '/mobile',
|
||||
// '/'
|
||||
// ]
|
||||
// if (noAnimation.indexOf(from) !== -1 && noAnimation.indexOf(to) !== -1) {
|
||||
// return transitionName = ''
|
||||
// }
|
||||
//
|
||||
// const toDepth = routes.findIndex(v => v.path === to)
|
||||
// const fromDepth = routes.findIndex(v => v.path === from)
|
||||
// transitionName = toDepth > fromDepth ? 'go' : 'back'
|
||||
// console.log('transitionName', transitionName, toDepth, fromDepth)
|
||||
// })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,8 +121,8 @@ watch(() => route.path, (to, from) => {
|
||||
<!-- </transition>-->
|
||||
<!-- </router-view>-->
|
||||
<router-view></router-view>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
<MigrateDialog
|
||||
v-model="showTransfer"
|
||||
@ok="init"
|
||||
/>
|
||||
</template>
|
||||
@@ -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(data, onUploadProgress) {
|
||||
return axiosInstance({
|
||||
url: 'dict/uploadImportData',
|
||||
method: 'post',
|
||||
@@ -59,3 +59,7 @@ export function uploadImportData(data,onUploadProgress) {
|
||||
onUploadProgress
|
||||
})
|
||||
}
|
||||
|
||||
export function getProgress() {
|
||||
return http<{ status: number; reason: string }>('dict/getProgress', null, null, 'get')
|
||||
}
|
||||
|
||||
69
src/apis/member.ts
Normal file
69
src/apis/member.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import http from '@/utils/http.ts'
|
||||
|
||||
export type LevelBenefits = {
|
||||
"level": {
|
||||
"id": number,
|
||||
"name": string,
|
||||
"code": string,
|
||||
"level": number,
|
||||
"price": string,
|
||||
"price_auto": string,
|
||||
"yearly_price": string,
|
||||
"description": string,
|
||||
"color": string,
|
||||
"icon": string,
|
||||
"is_active": number,
|
||||
"created_at": string,
|
||||
"updated_at": string
|
||||
},
|
||||
"benefits": {
|
||||
"code": string,
|
||||
"name": string,
|
||||
"type": boolean,
|
||||
"unit": null,
|
||||
"value": string
|
||||
}[]
|
||||
}
|
||||
|
||||
export type CouponInfo = {
|
||||
"id": number,
|
||||
"code": string,
|
||||
"name": string,
|
||||
"type": string,
|
||||
"value"?: string,
|
||||
"min_amount"?: string,
|
||||
"max_discount"?: string,
|
||||
"applicable_levels": {
|
||||
code: string,
|
||||
name: string,
|
||||
level: string,
|
||||
}[]
|
||||
"usage_limit": number,
|
||||
"total_usage": number,
|
||||
"start_date": string
|
||||
"end_date": string
|
||||
"is_active": number,
|
||||
"created_at": string
|
||||
"updated_at": string
|
||||
"is_valid": boolean,
|
||||
}
|
||||
|
||||
export function levelBenefits(params) {
|
||||
return http<LevelBenefits>('member/levelBenefits', null, params, 'get')
|
||||
}
|
||||
|
||||
export function orderCreate(params) {
|
||||
return http<{ orderNo: string }>('/member/orderCreate', params, null, 'post')
|
||||
}
|
||||
|
||||
export function orderStatus(params) {
|
||||
return http('/member/orderStatus', null, params, 'get')
|
||||
}
|
||||
|
||||
export function couponInfo(params) {
|
||||
return http<CouponInfo>('/member/couponInfo', null, params, 'get')
|
||||
}
|
||||
|
||||
export function setAutoRenewApi(params) {
|
||||
return http('/member/setAutoRenew', params, null, 'post')
|
||||
}
|
||||
116
src/apis/user.ts
Normal file
116
src/apis/user.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import http from '@/utils/http.ts'
|
||||
import { CodeType } from "@/types/types.ts";
|
||||
|
||||
// 用户登录接口
|
||||
export interface LoginParams {
|
||||
account?: string
|
||||
password?: string
|
||||
phone?: string
|
||||
code?: string
|
||||
type: 'code' | 'pwd'
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email?: string
|
||||
phone?: string
|
||||
username?: string
|
||||
avatar?: string,
|
||||
hasPwd?: boolean,
|
||||
member: {
|
||||
levelDesc: string,
|
||||
status: string,
|
||||
active: boolean,
|
||||
endDate: number,
|
||||
autoRenew: boolean,
|
||||
plan: string,
|
||||
planDesc: string,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 用户注册接口
|
||||
export interface RegisterParams {
|
||||
account: string
|
||||
password: string
|
||||
code: string
|
||||
}
|
||||
|
||||
export interface RegisterResponse {
|
||||
token: string
|
||||
user: {
|
||||
id: string
|
||||
email?: string
|
||||
phone: string
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
}
|
||||
}
|
||||
|
||||
// 发送验证码接口
|
||||
export interface SendCodeParams {
|
||||
val: string
|
||||
type: CodeType
|
||||
}
|
||||
|
||||
// 重置密码接口
|
||||
export interface ResetPasswordParams {
|
||||
account: string
|
||||
code: string
|
||||
newPassword: string
|
||||
}
|
||||
|
||||
// 微信登录接口
|
||||
export interface WechatLoginParams {
|
||||
code: string
|
||||
state?: string
|
||||
}
|
||||
|
||||
export function loginApi(params: LoginParams) {
|
||||
return http<{ token:string }>('user/login', params, null, 'post')
|
||||
}
|
||||
|
||||
export function registerApi(params: RegisterParams) {
|
||||
return http<RegisterResponse>('user/register', params, null, 'post')
|
||||
}
|
||||
|
||||
export function sendCode(params: SendCodeParams) {
|
||||
return http<boolean>('user/sendCode', null, params, 'get')
|
||||
}
|
||||
|
||||
export function resetPasswordApi(params: ResetPasswordParams) {
|
||||
return http<boolean>('user/resetPassword', params, null, 'post')
|
||||
}
|
||||
|
||||
export function wechatLogin(params: WechatLoginParams) {
|
||||
return http<User>('user/wechatLogin', params, null, 'post')
|
||||
}
|
||||
|
||||
export function refreshToken() {
|
||||
return http<{ token: string }>('user/refreshToken', null, null, 'post')
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
export function getUserInfo() {
|
||||
return http<User>('user/userInfo', null, null, 'get')
|
||||
}
|
||||
|
||||
// 设置密码
|
||||
export function setPassword(data) {
|
||||
return http('user/setPassword', data, null, 'post')
|
||||
}
|
||||
|
||||
// 修改邮箱
|
||||
export function changeEmailApi(data) {
|
||||
return http('user/changeEmail', data, null, 'post')
|
||||
}
|
||||
|
||||
// 修改手机号
|
||||
export function changePhoneApi(data) {
|
||||
return http('user/changePhone', data, null, 'post')
|
||||
}
|
||||
|
||||
// 修改用户信息
|
||||
export function updateUserInfoApi(data) {
|
||||
return http('user/updateUserInfo', data, null, 'post')
|
||||
}
|
||||
@@ -27,6 +27,7 @@
|
||||
}
|
||||
|
||||
|
||||
|
||||
@keyframes shake {
|
||||
10%,
|
||||
90% {
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
--color-font-2: rgb(46, 46, 46);
|
||||
--color-font-3: rgb(75, 85, 99);
|
||||
--color-font-active-1: white;
|
||||
--color-scrollbar: rgb(147, 173, 227);
|
||||
--color-scrollbar: #c1c1c1;
|
||||
|
||||
--color-sub-gray: #c0bfbf;
|
||||
|
||||
--article-width: 50vw;
|
||||
@@ -69,6 +70,11 @@
|
||||
|
||||
//修改的进度条底色
|
||||
--color-progress-bar: #d1d5df !important;
|
||||
|
||||
--color-label-bg: whitesmoke;
|
||||
--color-link: #2563EB;
|
||||
|
||||
--color-card-bg: white;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -118,6 +124,10 @@ html.dark {
|
||||
|
||||
--color-progress-bar: rgb(73, 77, 82) !important;
|
||||
|
||||
--color-label-bg: rgb(10, 10, 10);
|
||||
|
||||
--color-card-bg: rgb(30, 31, 34);
|
||||
|
||||
.footer {
|
||||
&.hide {
|
||||
--color-progress-bar: var(--color-third) !important;
|
||||
@@ -198,7 +208,7 @@ html, body {
|
||||
z-index: 1;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-size: .9rem;
|
||||
font-size: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -229,11 +239,18 @@ html, body {
|
||||
}
|
||||
|
||||
a {
|
||||
$main: rgb(64, 158, 255);
|
||||
color: $main;
|
||||
color: var(--color-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--color-link);
|
||||
@apply hover:opacity-80;
|
||||
}
|
||||
|
||||
.cp {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
::-webkit-scrollbar {
|
||||
@@ -406,10 +423,15 @@ a {
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply rounded-xl p-4 mb-5 box-border relative;
|
||||
@apply rounded-xl p-4 mb-8 shadow-lg box-border relative;
|
||||
background: var(--color-second);
|
||||
}
|
||||
|
||||
.card-white {
|
||||
@extend .card;
|
||||
background: var(--color-card-bg);
|
||||
}
|
||||
|
||||
.inline-center {
|
||||
@apply inline-flex justify-center items-center;
|
||||
}
|
||||
@@ -429,6 +451,8 @@ a {
|
||||
.line {
|
||||
width: 100%;
|
||||
border-bottom: 1px solid var(--color-item-border);
|
||||
@apply hover:text-blue-700;
|
||||
|
||||
}
|
||||
|
||||
.line-white {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {useAttrs} from "vue";
|
||||
import router from "@/router.ts";
|
||||
import { useAttrs } from "vue";
|
||||
import { useNav } from "@/utils";
|
||||
|
||||
const attrs = useAttrs()
|
||||
const router = useNav()
|
||||
|
||||
function onClick() {
|
||||
if (!attrs.onClick) {
|
||||
|
||||
@@ -7,7 +7,7 @@ interface IProps {
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
size?: 'small' | 'normal' | 'large',
|
||||
type?: 'primary' | 'link' | 'info'
|
||||
type?: 'primary' | 'link' | 'info' | 'orange'
|
||||
}
|
||||
|
||||
withDefaults(defineProps<IProps>(), {
|
||||
@@ -62,7 +62,7 @@ defineEmits(['click'])
|
||||
color: white;
|
||||
|
||||
& + .base-button {
|
||||
margin-left: var(--space);
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
@@ -76,8 +76,8 @@ defineEmits(['click'])
|
||||
}
|
||||
|
||||
&.small {
|
||||
border-radius: 0.2rem;
|
||||
padding: 0 0.8rem;
|
||||
border-radius: 0.3rem;
|
||||
padding: 0 0.6rem;
|
||||
height: 1.6rem;
|
||||
font-size: .8rem;
|
||||
}
|
||||
@@ -86,6 +86,7 @@ defineEmits(['click'])
|
||||
padding: 0 1.3rem;
|
||||
height: 2.4rem;
|
||||
font-size: 0.9rem;
|
||||
border-radius: .5rem;
|
||||
}
|
||||
|
||||
& > span {
|
||||
@@ -97,19 +98,19 @@ defineEmits(['click'])
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: var(--btn-primary);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.link {
|
||||
border-radius: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
&:hover {
|
||||
&:hover:not(.disabled) {
|
||||
border-bottom: 2px solid var(--color-font-2);
|
||||
}
|
||||
}
|
||||
@@ -118,6 +119,20 @@ defineEmits(['click'])
|
||||
background: var(--btn-info);
|
||||
border: 1px solid var(--color-main-text);
|
||||
color: var(--color-main-text);
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
&.orange {
|
||||
background: #FACC15;
|
||||
color: black;
|
||||
|
||||
&:hover:not(.disabled) {
|
||||
background: #fbe27e;
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
&.active {
|
||||
|
||||
@@ -14,6 +14,7 @@ import Checkbox from "@/components/base/checkbox/Checkbox.vue";
|
||||
import DeleteIcon from "@/components/icon/DeleteIcon.vue";
|
||||
import Dialog from "@/components/dialog/Dialog.vue";
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
import {Host} from "@/config/env.ts";
|
||||
|
||||
let list = defineModel('list')
|
||||
|
||||
@@ -283,7 +284,7 @@ defineRender(
|
||||
<div>短语:一行原文,一行译文;多个请换<span class="color-red">两</span>行</div>
|
||||
<div>同义词、同根词、词源:请前往官方字典,然后编辑其中某个单词,参考其格式</div>
|
||||
<div class="mt-6">
|
||||
模板下载地址:<a href="https://2study.top/libs/单词导入模板.xlsx">单词导入模板</a>
|
||||
模板下载地址:<a href={`https://${Host}/libs/单词导入模板.xlsx`}>单词导入模板</a>
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<BaseButton
|
||||
|
||||
@@ -94,7 +94,7 @@ const studyProgress = $computed(() => {
|
||||
top: 4px;
|
||||
right: -22px;
|
||||
padding: 1px 20px;
|
||||
background: whitesmoke;
|
||||
background: var(--color-label-bg);
|
||||
font-size: 11px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Close from "@/components/icon/Close.vue";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {watch} from "vue";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
|
||||
import {isMobile} from "@/utils";
|
||||
import {ProjectName, Host} from "@/config/env.ts";
|
||||
|
||||
let settingStore = useSettingStore()
|
||||
let showNotice = $ref(false)
|
||||
let show = $ref(false)
|
||||
let num = $ref(5)
|
||||
let timer = -1
|
||||
let mobile = $ref(isMobile())
|
||||
const isMac = /macintosh|mac os x/i.test(navigator.userAgent);
|
||||
|
||||
function toggleNotice() {
|
||||
showNotice = true
|
||||
settingStore.first = false
|
||||
timer = setInterval(() => {
|
||||
num--
|
||||
if (num <= 0) close()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function close() {
|
||||
clearInterval(timer)
|
||||
show = settingStore.first = false
|
||||
}
|
||||
|
||||
watch(() => settingStore.load, (n) => {
|
||||
if (n && settingStore.first) {
|
||||
setTimeout(() => {
|
||||
show = true
|
||||
}, 1000)
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="right">
|
||||
<div class="CollectNotice"
|
||||
:class="{mobile}"
|
||||
v-if="show">
|
||||
<div class="notice">
|
||||
坚持练习,提高外语能力。将
|
||||
<span class="active">「{{ ProjectName }}」</span>
|
||||
保存为书签,永不迷失!
|
||||
</div>
|
||||
<div class="wrapper">
|
||||
<transition name="fade">
|
||||
<div class="collect" v-if="showNotice">
|
||||
<div class="href-wrapper">
|
||||
<div class="round">
|
||||
<div class="href">{{ Host }}</div>
|
||||
<IconFluentStar12Regular width="22"/>
|
||||
</div>
|
||||
<div class="right">
|
||||
👈
|
||||
<IconFluentStar20Filled class="star" width="22"/>
|
||||
点亮它!
|
||||
</div>
|
||||
</div>
|
||||
<div class="collect-keyboard" v-if="!mobile">或使用收藏快捷键<span
|
||||
class="active">{{ isMac ? 'Command' : 'Ctrl' }} + D</span></div>
|
||||
</div>
|
||||
<BaseButton v-else size="large" @click="toggleNotice">我想收藏</BaseButton>
|
||||
</transition>
|
||||
</div>
|
||||
<div class="close-wrapper">
|
||||
<span v-show="showNotice"><span class="active">{{ num }}s</span> 后自动关闭</span>
|
||||
<Close @click="close" title="关闭"/>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.right-enter-active,
|
||||
.right-leave-active {
|
||||
transition: all .5s ease;
|
||||
}
|
||||
|
||||
.right-enter-from,
|
||||
.right-leave-to {
|
||||
transform: translateX(110%);
|
||||
}
|
||||
|
||||
.CollectNotice {
|
||||
position: fixed;
|
||||
right: var(--space);
|
||||
top: var(--space);
|
||||
z-index: 2;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: var(--color-notice-bg);
|
||||
padding: 1.8rem;
|
||||
border-radius: 0.7rem;
|
||||
width: 30rem;
|
||||
gap: 2.4rem;
|
||||
color: var(--color-font-1);
|
||||
line-height: 1.5;
|
||||
border: 1px solid var(--color-item-border);
|
||||
box-shadow: var(--shadow);
|
||||
box-sizing: border-box;
|
||||
|
||||
&.mobile {
|
||||
width: 95%;
|
||||
padding: 0.6rem;
|
||||
}
|
||||
|
||||
.notice {
|
||||
margin-top: 2.4rem;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--color-select-bg);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
.collect {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.href-wrapper {
|
||||
display: flex;
|
||||
font-size: 1rem;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
|
||||
.round {
|
||||
color: var(--color-font-1);
|
||||
border-radius: 3rem;
|
||||
padding: 0.6rem 0.6rem;
|
||||
padding-left: 1.2rem;
|
||||
gap: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: var(--color-primary);
|
||||
|
||||
.href {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.star {
|
||||
color: var(--color-select-bg);
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.collect-keyboard {
|
||||
margin-top: 1.2rem;
|
||||
font-size: 1rem;
|
||||
|
||||
span {
|
||||
margin-left: 0.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.close-wrapper {
|
||||
right: var(--space);
|
||||
top: var(--space);
|
||||
position: absolute;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
color: var(--color-font-1);
|
||||
gap: 0.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
29
src/components/Header.vue
Normal file
29
src/components/Header.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import BackIcon from "@/components/BackIcon.vue";
|
||||
import { useAttrs } from "vue";
|
||||
|
||||
interface IProps {
|
||||
title: string;
|
||||
showBackIcon?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<IProps>(), {
|
||||
title: '',
|
||||
showBackIcon: true,
|
||||
})
|
||||
|
||||
|
||||
const attrs = useAttrs()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-3 text-xl font-bold relative min-h-8">
|
||||
<BackIcon class="z-2 relative" v-bind="attrs" v-if="showBackIcon" />
|
||||
<span class="absolute text-center w-full left-0" @click.stop>{{ title }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -1,17 +1,31 @@
|
||||
<script lang="jsx">
|
||||
import {Teleport, Transition} from 'vue'
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
|
||||
export default {
|
||||
name: "PopConfirm",
|
||||
components: {
|
||||
Teleport,
|
||||
Transition
|
||||
Transition,
|
||||
BaseButton
|
||||
},
|
||||
props: {
|
||||
title: {
|
||||
type: String,
|
||||
type: [String, Array],
|
||||
default() {
|
||||
return ''
|
||||
},
|
||||
validator(value) {
|
||||
// Validate that array items have the correct structure
|
||||
if (Array.isArray(value)) {
|
||||
return value.every(item =>
|
||||
typeof item === 'object' &&
|
||||
item !== null &&
|
||||
typeof item.text === 'string' &&
|
||||
['normal', 'bold', 'red', 'redBold'].includes(item.type)
|
||||
)
|
||||
}
|
||||
return typeof value === 'string'
|
||||
}
|
||||
},
|
||||
disabled: {
|
||||
@@ -21,6 +35,17 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
titleItems() {
|
||||
if (typeof this.title === 'string') {
|
||||
return [{ text: this.title, type: 'normal' }]
|
||||
}
|
||||
if (Array.isArray(this.title)) {
|
||||
return this.title
|
||||
}
|
||||
return []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: false
|
||||
@@ -35,6 +60,27 @@ export default {
|
||||
})
|
||||
},
|
||||
methods: {
|
||||
getTextStyle(type) {
|
||||
const styles = {
|
||||
normal: {
|
||||
fontWeight: 'normal',
|
||||
color: 'inherit'
|
||||
},
|
||||
bold: {
|
||||
fontWeight: 'bold',
|
||||
color: 'inherit'
|
||||
},
|
||||
red: {
|
||||
fontWeight: 'normal',
|
||||
color: 'red'
|
||||
},
|
||||
redBold: {
|
||||
fontWeight: 'bold',
|
||||
color: 'red'
|
||||
}
|
||||
}
|
||||
return styles[type] || styles.normal
|
||||
},
|
||||
showPop(e) {
|
||||
if (this.disabled) return this.$emit('confirm')
|
||||
e?.stopPropagation()
|
||||
@@ -60,18 +106,26 @@ export default {
|
||||
render() {
|
||||
let Vnode = this.$slots.default()[0]
|
||||
return (
|
||||
<div class="pop-confirm">
|
||||
<div class="pop-confirm leading-none">
|
||||
<Teleport to="body">
|
||||
<Transition>
|
||||
<Transition name="fade">
|
||||
{
|
||||
this.show && (
|
||||
<div ref="tip" class="pop-confirm-content">
|
||||
<div class="text">
|
||||
{this.title}
|
||||
<div ref="tip" class="pop-confirm-content shadow-2xl">
|
||||
<div class="w-52 title-content">
|
||||
{this.titleItems.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={this.getTextStyle(item.type)}
|
||||
class="title-item"
|
||||
>
|
||||
{item.text}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div class="options">
|
||||
<div onClick={() => this.show = false}>取消</div>
|
||||
<div class="main" onClick={() => this.confirm()}>确认</div>
|
||||
<BaseButton type="info" size="small" onClick={() => this.show = false}>取消</BaseButton>
|
||||
<BaseButton size="small" onClick={() => this.confirm()}>确认</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -85,43 +139,27 @@ export default {
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
$bg-color: rgb(226, 226, 226);
|
||||
|
||||
.pop-confirm-content {
|
||||
position: fixed;
|
||||
background: var(--color-tooltip-bg);
|
||||
padding: 1rem;
|
||||
border-radius: .3rem;
|
||||
border-radius: .6rem;
|
||||
transform: translate(-50%, calc(-100% - .6rem));
|
||||
box-shadow: 0 0 6px 1px var(--color-tooltip-shadow);
|
||||
z-index: 999;
|
||||
|
||||
.text {
|
||||
color: var(--color-font-1);
|
||||
text-align: start;
|
||||
font-size: 1rem;
|
||||
width: 9rem;
|
||||
min-width: 9rem;
|
||||
.title-content {
|
||||
.title-item {
|
||||
margin-bottom: 0.25rem;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.options {
|
||||
margin-top: .9rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: .7rem;
|
||||
font-size: .9rem;
|
||||
|
||||
div {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.main {
|
||||
color: gray;
|
||||
background: $bg-color;
|
||||
padding: .2rem .6rem;
|
||||
border-radius: .24rem;
|
||||
}
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9,6 +9,7 @@ interface IProps {
|
||||
currentTime?: number;
|
||||
playbackRate?: number;
|
||||
disabled?: boolean;
|
||||
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
@@ -17,11 +18,13 @@ const props = withDefaults(defineProps<IProps>(), {
|
||||
volume: 1,
|
||||
currentTime: 0,
|
||||
playbackRate: 1,
|
||||
disabled: false
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
ended: []
|
||||
(e: 'ended'): [],
|
||||
(e: 'update-volume', volume: number): void,
|
||||
(e: 'update-speed', volume: number): void
|
||||
}>();
|
||||
|
||||
const attrs = useAttrs();
|
||||
@@ -30,17 +33,20 @@ const attrs = useAttrs();
|
||||
const audioRef = ref<HTMLAudioElement>();
|
||||
const progressBarRef = ref<HTMLDivElement>();
|
||||
const volumeBarRef = ref<HTMLDivElement>();
|
||||
const volumeFillRef = ref<HTMLElement>();
|
||||
|
||||
// 状态管理
|
||||
const isPlaying = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const duration = ref(0);
|
||||
const currentTime = ref(0);
|
||||
// const volume = ref(props.volume);
|
||||
const volume = ref(props.volume);
|
||||
const playbackRate = ref(props.playbackRate);
|
||||
const isDragging = ref(false);
|
||||
const isVolumeDragging = ref(false);
|
||||
const isVolumeHovering = ref(false); // 添加音量控制hover状态变量
|
||||
const volumePosition = ref('top') // 音量控制位置,'top'或'down'
|
||||
const error = ref('');
|
||||
|
||||
// 计算属性
|
||||
@@ -85,17 +91,18 @@ const toggleMute = () => {
|
||||
volume.value = 1;
|
||||
audioRef.value.volume = 1;
|
||||
}
|
||||
emit('update-volume', Math.floor(volume.value * 100));
|
||||
};
|
||||
|
||||
const changePlaybackRate = () => {
|
||||
if (!audioRef.value || props.disabled) return;
|
||||
|
||||
const rates = [0.5, 0.75, 1, 1.25, 1.5, 2];
|
||||
const currentIndex = rates.indexOf(playbackRate.value);
|
||||
const nextIndex = (currentIndex + 1) % rates.length;
|
||||
|
||||
playbackRate.value = rates[nextIndex];
|
||||
audioRef.value.playbackRate = playbackRate.value;
|
||||
// 提交更新播放速度事件
|
||||
emit('update-speed', playbackRate.value);
|
||||
};
|
||||
|
||||
// 事件处理
|
||||
@@ -108,6 +115,10 @@ const handleLoadedData = () => {
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
if (audioRef.value) {
|
||||
audioRef.value.volume = volume.value;
|
||||
}
|
||||
|
||||
duration.value = audioRef.value?.duration || 0;
|
||||
};
|
||||
|
||||
@@ -250,26 +261,18 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
let hasMoved = false;
|
||||
let lastVolume = 0; // 记录最后的音量
|
||||
const moveThreshold = 3; // 移动阈值,超过这个距离才认为是拖拽
|
||||
let lastVolume = 0; // 记录最后音量
|
||||
const moveThreshold = 3; // 超过这个距离才认为是拖拽
|
||||
|
||||
// 获取DOM元素引用
|
||||
const volumeFill = volumeBarRef.value.querySelector('.volume-fill') as HTMLElement;
|
||||
const volumeThumb = volumeBarRef.value.querySelector('.volume-thumb') as HTMLElement;
|
||||
const volumeFill = volumeFillRef.value;
|
||||
|
||||
|
||||
// 立即跳转到点击位置
|
||||
// 计算点击位置对应音量百分比(最上 100%,最下 0%)
|
||||
const clickY = event.clientY - rect.top;
|
||||
// 计算百分比,最上面是0%,最下面是100%
|
||||
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
|
||||
const percentage = 1 - Math.max(0, Math.min(1, clickY / rect.height));
|
||||
|
||||
// 直接更新DOM样式
|
||||
if (volumeFill && volumeThumb) {
|
||||
// 更新 UI 与音量
|
||||
if (volumeFill) {
|
||||
volumeFill.style.height = `${percentage * 100}%`;
|
||||
// 设置top而不是bottom
|
||||
volumeThumb.style.top = `${percentage * 100}%`;
|
||||
// 重置left样式
|
||||
volumeThumb.style.left = '50%';
|
||||
}
|
||||
|
||||
volume.value = percentage;
|
||||
@@ -277,6 +280,7 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
|
||||
lastVolume = percentage;
|
||||
isVolumeDragging.value = true;
|
||||
|
||||
// 鼠标移动时调整音量
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = Math.abs(e.clientX - startX);
|
||||
const deltaY = Math.abs(e.clientY - startY);
|
||||
@@ -286,46 +290,41 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
if (!hasMoved) return;
|
||||
|
||||
// 禁用过渡动画
|
||||
if (volumeFill && volumeThumb) {
|
||||
if (volumeFill) {
|
||||
volumeFill.style.transition = 'none';
|
||||
volumeThumb.style.transition = 'none';
|
||||
}
|
||||
|
||||
const rect = volumeBarRef.value!.getBoundingClientRect();
|
||||
const clickY = e.clientY - rect.top;
|
||||
// 计算百分比,最上面是0%,最下面是100%
|
||||
const percentage = Math.max(0, Math.min(1, clickY / rect.height));
|
||||
const moveY = e.clientY - rect.top;
|
||||
const percentage = 1 - Math.max(0, Math.min(1, moveY / rect.height));
|
||||
|
||||
// 直接更新DOM样式,不使用响应式变量
|
||||
if (volumeFill && volumeThumb) {
|
||||
if (volumeFill) {
|
||||
volumeFill.style.height = `${percentage * 100}%`;
|
||||
// 设置top而不是bottom
|
||||
volumeThumb.style.top = `${percentage * 100}%`;
|
||||
}
|
||||
|
||||
// 更新响应式变量和音频音量
|
||||
volume.value = percentage;
|
||||
lastVolume = percentage;
|
||||
// 实时更新音频音量
|
||||
if (audioRef.value) {
|
||||
audioRef.value.volume = percentage;
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标释放时结束拖动
|
||||
const handleMouseUp = () => {
|
||||
isVolumeDragging.value = false;
|
||||
|
||||
// 恢复过渡动画
|
||||
if (volumeFill && volumeThumb) {
|
||||
if (volumeFill) {
|
||||
volumeFill.style.transition = '';
|
||||
volumeThumb.style.transition = '';
|
||||
}
|
||||
|
||||
// 如果是拖拽,在结束时更新audio元素到最终音量
|
||||
if (hasMoved && audioRef.value) {
|
||||
audioRef.value.volume = lastVolume;
|
||||
}
|
||||
// 提交更新音量事件
|
||||
emit('update-volume', Math.floor(volume.value * 100));
|
||||
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
@@ -335,6 +334,20 @@ const handleVolumeMouseDown = (event: MouseEvent) => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// 音量控制鼠标移入事件,自动调整音量控制条位置
|
||||
const onVolumeSectionEnter = (e: MouseEvent) => {
|
||||
isVolumeHovering.value = true;
|
||||
const section = e.target as HTMLElement
|
||||
const top = section.getBoundingClientRect().top + window.scrollY
|
||||
const dropdownH = section.querySelector('.volume-dropdown').clientHeight
|
||||
if (top < dropdownH * 1.25) {
|
||||
volumePosition.value = 'down'
|
||||
} else {
|
||||
volumePosition.value = 'top'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 监听属性变化
|
||||
watch(() => props.src, (newSrc) => {
|
||||
if (audioRef.value) {
|
||||
@@ -377,52 +390,29 @@ watch(() => props.playbackRate, (newRate) => {
|
||||
}
|
||||
});
|
||||
|
||||
defineExpose({audioRef})
|
||||
defineExpose({ audioRef })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="custom-audio"
|
||||
:class="{ 'disabled': disabled||error, 'has-error': error }"
|
||||
v-bind="attrs"
|
||||
>
|
||||
<div class="custom-audio" :class="{ 'disabled': disabled || error, 'has-error': error }" v-bind="attrs">
|
||||
<!-- 隐藏的原生audio元素 -->
|
||||
<audio
|
||||
ref="audioRef"
|
||||
:src="src"
|
||||
preload="auto"
|
||||
:autoplay="autoplay"
|
||||
:loop="loop"
|
||||
:controls="false"
|
||||
@loadstart="handleLoadStart"
|
||||
@loadeddata="handleLoadedData"
|
||||
@loadedmetadata="handleLoadedMetadata"
|
||||
@canplaythrough="handleCanPlayThrough"
|
||||
@play="handlePlay"
|
||||
@pause="handlePause"
|
||||
@ended="handleEnded"
|
||||
@error="handleError"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@volumechange="handleVolumeChange"
|
||||
@ratechange="handleRateChange"
|
||||
/>
|
||||
<audio ref="audioRef" :src="src" preload="auto" :autoplay="autoplay" :loop="loop" :controls="false"
|
||||
@loadstart="handleLoadStart" @loadeddata="handleLoadedData" @loadedmetadata="handleLoadedMetadata"
|
||||
@canplaythrough="handleCanPlayThrough" @play="handlePlay" @pause="handlePause" @ended="handleEnded"
|
||||
@error="handleError" @timeupdate="handleTimeUpdate" @volumechange="handleVolumeChange"
|
||||
@ratechange="handleRateChange" />
|
||||
|
||||
<!-- 自定义控制界面 -->
|
||||
<div class="audio-container">
|
||||
<!-- 播放/暂停按钮 -->
|
||||
<button
|
||||
class="play-button"
|
||||
:class="{ 'loading': isLoading }"
|
||||
@click="togglePlay"
|
||||
:disabled="disabled"
|
||||
:aria-label="isPlaying ? '暂停' : '播放'"
|
||||
>
|
||||
<button class="play-button" :class="{ 'loading': isLoading }" @click="togglePlay" :disabled="disabled"
|
||||
:aria-label="isPlaying ? '暂停' : '播放'">
|
||||
<div v-if="isLoading" class="loading-spinner"></div>
|
||||
<svg v-else-if="isPlaying" class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
<svg v-else class="icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
@@ -431,70 +421,40 @@ defineExpose({audioRef})
|
||||
<!-- 时间显示 -->
|
||||
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
|
||||
<!-- 进度条 -->
|
||||
<div
|
||||
class="progress-container"
|
||||
@mousedown="handleProgressMouseDown"
|
||||
ref="progressBarRef"
|
||||
>
|
||||
<div class="progress-container" @mousedown="handleProgressMouseDown" ref="progressBarRef">
|
||||
<div class="progress-track">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: progress + '%' }"
|
||||
></div>
|
||||
<div
|
||||
class="progress-thumb"
|
||||
:style="{ left: progress + '%' }"
|
||||
></div>
|
||||
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
|
||||
<div class="progress-thumb" :style="{ left: progress + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 音量控制 -->
|
||||
<div
|
||||
class="volume-section"
|
||||
@mouseenter="isVolumeHovering = true"
|
||||
@mouseleave="isVolumeHovering = false"
|
||||
>
|
||||
<button
|
||||
class="volume-button"
|
||||
@click="toggleMute"
|
||||
:disabled="disabled"
|
||||
:aria-label="volume > 0 ? '静音' : '取消静音'"
|
||||
>
|
||||
<div class="volume-section" @mouseenter="onVolumeSectionEnter" @mouseleave="isVolumeHovering = false">
|
||||
<button class="volume-button" tabindex="-1" @click="toggleMute" :disabled="disabled"
|
||||
:aria-label="volume > 0 ? '静音' : '取消静音'">
|
||||
<IconBxVolumeMute v-if="volume === 0" class="icon"></IconBxVolumeMute>
|
||||
<IconBxVolumeLow v-else-if="volume < 0.5" class="icon"></IconBxVolumeLow>
|
||||
<IconBxVolumeFull v-else class="icon"></IconBxVolumeFull>
|
||||
</button>
|
||||
|
||||
<!-- 音量下拉控制条 -->
|
||||
<div class="volume-dropdown" :class="{ 'active': isVolumeHovering || isVolumeDragging }">
|
||||
<div
|
||||
class="volume-container"
|
||||
@mousedown="handleVolumeMouseDown"
|
||||
ref="volumeBarRef"
|
||||
>
|
||||
<div class="volume-dropdown" :class="[{ 'active': isVolumeHovering || isVolumeDragging }, volumePosition]">
|
||||
<div class="volume-container" @mousedown="handleVolumeMouseDown" ref="volumeBarRef">
|
||||
<div class="volume-track">
|
||||
<div
|
||||
class="volume-fill"
|
||||
:style="{ height: volumeProgress + '%', top: 0 }"
|
||||
></div>
|
||||
<div
|
||||
class="volume-thumb"
|
||||
:style="{ top: volumeProgress + '%' }"
|
||||
></div>
|
||||
<div class="volume-fill" ref="volumeFillRef" :style="{ height: volumeProgress + '%', bottom: 0 }"></div>
|
||||
</div>
|
||||
<div class="volume-num">
|
||||
<span>{{ Math.floor(volumeProgress) }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 播放速度控制 -->
|
||||
<button
|
||||
class="speed-button"
|
||||
@click="changePlaybackRate"
|
||||
:disabled="disabled"
|
||||
:aria-label="`播放速度: ${playbackRate}x`"
|
||||
>
|
||||
<button class="speed-button" @click="changePlaybackRate" :disabled="disabled"
|
||||
:aria-label="`播放速度: ${playbackRate}x`">
|
||||
{{ playbackRate }}x
|
||||
</button>
|
||||
</div>
|
||||
@@ -641,6 +601,7 @@ defineExpose({audioRef})
|
||||
.volume-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
@@ -671,13 +632,9 @@ defineExpose({audioRef})
|
||||
|
||||
.volume-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-primary);
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
padding: 8px;
|
||||
margin-top: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
@@ -688,6 +645,14 @@ defineExpose({audioRef})
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
&.top {
|
||||
bottom: 42px;
|
||||
}
|
||||
|
||||
&.down {
|
||||
top: 42px;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-container {
|
||||
@@ -705,35 +670,41 @@ defineExpose({audioRef})
|
||||
width: 6px;
|
||||
height: 100%;
|
||||
background: var(--color-second);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
// overflow: hidden;
|
||||
}
|
||||
|
||||
.volume-num {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
font-size: 12px;
|
||||
color: #333;
|
||||
transform: scale(0.85);
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.volume-fill {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: var(--fill-height);
|
||||
background: var(--color-fourth);
|
||||
border-radius: 2px;
|
||||
}
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
.volume-thumb {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: var(--thumb-top);
|
||||
transform: translate(-50%, -50%);
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background: var(--color-fourth);
|
||||
border-radius: 50%;
|
||||
box-shadow: var(--audio-volume-thumb-shadow);
|
||||
cursor: grab;
|
||||
opacity: 1;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 100%;
|
||||
background: var(--color-fourth);
|
||||
transform: translateY(-50%);
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.5);
|
||||
cursor: grab;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -772,6 +743,7 @@ defineExpose({audioRef})
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useAttrs, watch } from 'vue';
|
||||
import {defineComponent, ref, useAttrs, watch, computed} from 'vue';
|
||||
import Close from "@/components/icon/Close.vue";
|
||||
import { useDisableEventListener } from "@/hooks/event.ts";
|
||||
import {useDisableEventListener} from "@/hooks/event.ts";
|
||||
|
||||
defineOptions({
|
||||
name: "BaseInput",
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: [String, Number],
|
||||
placeholder: String,
|
||||
disabled: Boolean,
|
||||
autofocus: Boolean,
|
||||
error: Boolean,
|
||||
type: {
|
||||
type: String,
|
||||
default: 'text',
|
||||
@@ -21,40 +26,42 @@ const props = defineProps({
|
||||
default: false,
|
||||
},
|
||||
maxLength: Number,
|
||||
size: {
|
||||
type: String,
|
||||
default: 'normal',
|
||||
validator: (value: string) => ['normal', 'large'].includes(value)
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation']);
|
||||
const emit = defineEmits(['update:modelValue', 'input', 'change', 'focus', 'blur', 'validation', 'enter']);
|
||||
const attrs = useAttrs();
|
||||
|
||||
const inputValue = ref(props.modelValue);
|
||||
const errorMsg = ref('');
|
||||
let focus = $ref(false)
|
||||
let inputEl = $ref<HTMLDivElement>()
|
||||
const passwordVisible = ref(false)
|
||||
|
||||
const inputType = computed(() => {
|
||||
if (props.type === 'password') {
|
||||
return passwordVisible.value ? 'text' : 'password'
|
||||
}
|
||||
return props.type
|
||||
})
|
||||
|
||||
const togglePasswordVisibility = () => {
|
||||
passwordVisible.value = !passwordVisible.value
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (val) => {
|
||||
inputValue.value = val;
|
||||
validate(val);
|
||||
});
|
||||
|
||||
const validate = (val: string | number | null | undefined) => {
|
||||
let err = '';
|
||||
const strVal = val == null ? '' : String(val);
|
||||
if (props.required && !strVal.trim()) {
|
||||
err = '不能为空';
|
||||
} else if (props.maxLength && strVal.length > props.maxLength) {
|
||||
err = `长度不能超过 ${props.maxLength} 个字符`;
|
||||
}
|
||||
errorMsg.value = err;
|
||||
emit('validation', err === '', err);
|
||||
return err === '';
|
||||
};
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
inputValue.value = target.value;
|
||||
validate(target.value);
|
||||
emit('update:modelValue', target.value);
|
||||
emit('input', e);
|
||||
emit('change', e);
|
||||
};
|
||||
|
||||
const onChange = (e: Event) => {
|
||||
@@ -68,14 +75,15 @@ const onFocus = (e: FocusEvent) => {
|
||||
|
||||
const onBlur = (e: FocusEvent) => {
|
||||
focus = false
|
||||
validate(inputValue.value);
|
||||
emit('blur', e);
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
const onEnter = (e: KeyboardEvent) => {
|
||||
emit('enter', e);
|
||||
};
|
||||
|
||||
const clearInput = () => {
|
||||
inputValue.value = '';
|
||||
validate('');
|
||||
emit('update:modelValue', '');
|
||||
};
|
||||
|
||||
@@ -94,60 +102,97 @@ const vFocus = {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="base-input2"
|
||||
<div class="base-input"
|
||||
ref="inputEl"
|
||||
:class="{ 'is-disabled': disabled, 'has-error': errorMsg,focus }">
|
||||
:class="{ 'is-disabled': disabled, 'error': props.error, focus, [`base-input--${size}`]: true }">
|
||||
<slot name="subfix"></slot>
|
||||
<!-- PreIcon slot -->
|
||||
<div v-if="$slots.preIcon" class="pre-icon">
|
||||
<slot name="preIcon"></slot>
|
||||
</div>
|
||||
<IconFluentLockClosed20Regular class="pre-icon" v-if="type === 'password'"/>
|
||||
<IconFluentMail20Regular class="pre-icon" v-if="type === 'email'"/>
|
||||
<IconFluentPhone20Regular class="pre-icon" v-if="type === 'tel'"/>
|
||||
<IconFluentNumberSymbol20Regular class="pre-icon" v-if="type === 'code'"/>
|
||||
|
||||
<input
|
||||
v-bind="attrs"
|
||||
:type="type"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="inputValue"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
class="inner"
|
||||
v-focus="autofocus"
|
||||
:maxlength="maxLength"
|
||||
v-bind="attrs"
|
||||
:type="inputType"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:value="inputValue"
|
||||
@input="onInput"
|
||||
@change="onChange"
|
||||
@focus="onFocus"
|
||||
@blur="onBlur"
|
||||
@keydown.enter="onEnter"
|
||||
class="inner"
|
||||
v-focus="autofocus"
|
||||
:maxlength="maxLength"
|
||||
/>
|
||||
<slot name="prefix"></slot>
|
||||
<Close
|
||||
v-if="clearable && inputValue && !disabled"
|
||||
@click="clearInput"/>
|
||||
<div v-if="errorMsg" class="base-input2__error">{{ errorMsg }}</div>
|
||||
v-if="clearable && inputValue && !disabled"
|
||||
@click="clearInput"/>
|
||||
<!-- Password visibility toggle -->
|
||||
<div
|
||||
v-if="type === 'password' && !disabled"
|
||||
class="password-toggle"
|
||||
@click="togglePasswordVisibility"
|
||||
:title="passwordVisible ? '隐藏密码' : '显示密码'">
|
||||
<IconFluentEye16Regular v-if="!passwordVisible"/>
|
||||
<IconFluentEyeOff16Regular v-else/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.base-input2 {
|
||||
.base-input {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 4px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
padding: .2rem .3rem;
|
||||
transition: all .3s;
|
||||
align-items: center;
|
||||
background: var(--color-input-bg);
|
||||
|
||||
::placeholder {
|
||||
font-size: 0.9rem;
|
||||
color: darkgray;
|
||||
}
|
||||
|
||||
// normal size (default)
|
||||
&--normal {
|
||||
padding: .2rem .3rem;
|
||||
|
||||
.inner {
|
||||
height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
// large size
|
||||
&--large {
|
||||
padding: .4rem .6rem;
|
||||
border-radius: .5rem;
|
||||
|
||||
.inner {
|
||||
height: 2rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&.has-error {
|
||||
.base-input2__inner {
|
||||
border-color: #f56c6c;
|
||||
}
|
||||
|
||||
.base-input2__error {
|
||||
color: #f56c6c;
|
||||
font-size: 0.85rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
&.error {
|
||||
border-color: #f56c6c;
|
||||
background: rgba(245, 108, 108, 0.07);
|
||||
}
|
||||
|
||||
&.focus {
|
||||
@@ -159,8 +204,22 @@ const vFocus = {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
&__error {
|
||||
padding-left: 0.5rem;
|
||||
// PreIcon styling
|
||||
&.has-preicon {
|
||||
.inner {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.pre-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-input-color);
|
||||
opacity: 0.6;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
margin-right: 0.2rem;
|
||||
}
|
||||
|
||||
.inner {
|
||||
@@ -173,6 +232,24 @@ const vFocus = {
|
||||
height: 1.5rem;
|
||||
color: var(--color-input-color);
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--color-input-color);
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,14 +8,16 @@ interface IProps {
|
||||
strokeWidth?: number;
|
||||
color?: string;
|
||||
format?: (percentage: number) => string;
|
||||
size?: 'normal' | 'large';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
showText: true,
|
||||
textInside: false,
|
||||
strokeWidth: 6,
|
||||
color: '#93ADE3',
|
||||
color: '#409eff',
|
||||
format: (percentage) => `${percentage}%`,
|
||||
size: 'normal',
|
||||
});
|
||||
|
||||
const barStyle = computed(() => {
|
||||
@@ -26,13 +28,15 @@ const barStyle = computed(() => {
|
||||
});
|
||||
|
||||
const trackStyle = computed(() => {
|
||||
const height = props.size === 'large' ? props.strokeWidth * 2.5 : props.strokeWidth;
|
||||
return {
|
||||
height: `${props.strokeWidth}px`,
|
||||
height: `${height}px`,
|
||||
};
|
||||
});
|
||||
|
||||
const progressTextSize = computed(() => {
|
||||
return props.strokeWidth * 0.83 + 6;
|
||||
const baseSize = props.strokeWidth * 0.83 + 6;
|
||||
return props.size === 'large' ? baseSize * 1.2 : baseSize;
|
||||
});
|
||||
|
||||
const content = computed(() => {
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {ref, provide, watch, toRef} from 'vue'
|
||||
import {provide, ref, toRef} from 'vue'
|
||||
import type {FormField, FormModel, FormRules} from './types'
|
||||
|
||||
interface Field {
|
||||
prop: string
|
||||
@@ -14,8 +15,8 @@ interface Field {
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
model: Object,
|
||||
rules: Object // { word: [{required:true,...}, ...], name: [...] }
|
||||
model: Object as () => FormModel,
|
||||
rules: Object as () => FormRules
|
||||
})
|
||||
|
||||
const fields = ref<Field[]>([])
|
||||
@@ -25,7 +26,7 @@ const registerField = (field: Field) => {
|
||||
}
|
||||
|
||||
// 校验整个表单
|
||||
const validate = (cb): boolean => {
|
||||
function validate(cb) {
|
||||
let valid = true
|
||||
fields.value.forEach(f => {
|
||||
const fieldRules = props.rules?.[f.prop] || []
|
||||
@@ -35,10 +36,23 @@ const validate = (cb): boolean => {
|
||||
cb(valid)
|
||||
}
|
||||
|
||||
// 校验指定字段
|
||||
function validateField(fieldName: string, cb?: (valid: boolean) => void): boolean {
|
||||
const field = fields.value.find(f => f.prop === fieldName)
|
||||
if (field) {
|
||||
const fieldRules = props.rules?.[fieldName] || []
|
||||
const valid = field.validate(fieldRules)
|
||||
if (cb) cb(valid)
|
||||
return valid
|
||||
}
|
||||
if (cb) cb(true)
|
||||
return true
|
||||
}
|
||||
|
||||
provide('registerField', registerField)
|
||||
provide('formModel', toRef(props, 'model'))
|
||||
provide('formValidate', validate)
|
||||
provide('formRules', props.rules)
|
||||
|
||||
defineExpose({validate})
|
||||
defineExpose({validate, validateField})
|
||||
</script>
|
||||
|
||||
@@ -11,7 +11,7 @@ let error = $ref('')
|
||||
|
||||
// 拿到 form 的 model 和注册函数
|
||||
const formModel = inject<ref>('formModel')
|
||||
const registerField = inject('registerField')
|
||||
const registerField = inject<Function>('registerField')
|
||||
const formRules = inject('formRules', {})
|
||||
|
||||
const myRules = $computed(() => {
|
||||
@@ -19,9 +19,13 @@ const myRules = $computed(() => {
|
||||
})
|
||||
|
||||
// 校验函数
|
||||
const validate = (rules) => {
|
||||
const validate = (rules, isBlur = false) => {
|
||||
error = ''
|
||||
const val = formModel.value[props.prop]
|
||||
//为空并且是非主动触发检验的情况下,不检验
|
||||
if (isBlur && val.trim() === '') {
|
||||
return true
|
||||
}
|
||||
for (const rule of rules) {
|
||||
if (rule.required && (!val || !val.toString().trim())) {
|
||||
error = rule.message
|
||||
@@ -31,43 +35,93 @@ const validate = (rules) => {
|
||||
error = rule.message
|
||||
return false
|
||||
}
|
||||
if (rule.min && val && val.toString().length < rule.min) {
|
||||
error = rule.message
|
||||
return false
|
||||
}
|
||||
if (rule.max && val && val.toString().length > rule.max) {
|
||||
error = rule.message
|
||||
return false
|
||||
}
|
||||
if (rule.validator) {
|
||||
try {
|
||||
rule.validator(rule, val)
|
||||
} catch (e) {
|
||||
error = e.message
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// 自动触发 blur 校验
|
||||
const handleBlur = () => {
|
||||
function handleBlur() {
|
||||
const blurRules = myRules.filter((r) => r.trigger === 'blur')
|
||||
if (blurRules.length) validate(blurRules)
|
||||
if (blurRules.length) validate(blurRules, true)
|
||||
}
|
||||
|
||||
function handChange() {
|
||||
error = ''
|
||||
}
|
||||
|
||||
// 注册到 Form
|
||||
onMounted(() => {
|
||||
registerField && registerField({prop: props.prop, modelValue: value, validate})
|
||||
})
|
||||
|
||||
let slot = useSlots()
|
||||
|
||||
|
||||
function patchVNode(vnode, patchFn) {
|
||||
if (!vnode) return vnode
|
||||
|
||||
// 如果当前节点就是我们要找的 BaseInput
|
||||
if (vnode.type && vnode.type.name) {
|
||||
return patchFn(vnode)
|
||||
}
|
||||
|
||||
// 如果有子节点,则递归修改
|
||||
if (Array.isArray(vnode.children)) {
|
||||
vnode.children = vnode.children.map(child => patchVNode(child, patchFn))
|
||||
}
|
||||
|
||||
return vnode
|
||||
}
|
||||
|
||||
|
||||
defineRender(() => {
|
||||
let DefaultNode = slot.default()[0]
|
||||
return <div class="form-item mb-6 flex gap-space">
|
||||
let DefaultNode: any = slot.default()[0]
|
||||
|
||||
// 对 DefaultNode 深度查找 BaseInput 并加上 onBlur / error
|
||||
DefaultNode = patchVNode(DefaultNode, vnode => {
|
||||
return {
|
||||
...vnode,
|
||||
props: {
|
||||
...vnode.props,
|
||||
error: !!error,
|
||||
onBlur: handleBlur,
|
||||
onChange: handChange
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return <div class="form-item flex gap-space">
|
||||
{props.label &&
|
||||
<label class="w-20 flex items-start mt-1 justify-end">
|
||||
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
|
||||
</label>}
|
||||
<label class="w-20 flex items-start mt-1 justify-end">
|
||||
{myRules.length ? <span class="form-error">*</span> : null} {props.label}
|
||||
</label>}
|
||||
<div class="flex-1 relative">
|
||||
<DefaultNode onBlur={handleBlur}/>
|
||||
<div class="form-error absolute top-[100%] anim" style={{opacity: error ? 1 : 0}}>{error}</div>
|
||||
<DefaultNode/>
|
||||
<div class="form-error my-0.5 anim" style={{opacity: error ? 1 : 0}}>{error} </div>
|
||||
</div>
|
||||
</div>
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.form-item {
|
||||
|
||||
.form-error {
|
||||
color: #f56c6c;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.form-error {
|
||||
color: #f56c6c;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
65
src/components/base/form/types.ts
Normal file
65
src/components/base/form/types.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
// Form 组件的 TypeScript 类型定义
|
||||
|
||||
// 表单字段接口
|
||||
export interface FormField {
|
||||
prop: string
|
||||
modelValue: any
|
||||
validate: (rules: FormRule[]) => boolean
|
||||
}
|
||||
|
||||
// 表单规则接口
|
||||
export interface FormRule {
|
||||
required?: boolean
|
||||
message?: string
|
||||
pattern?: RegExp
|
||||
validator?: (rule: FormRule, value: any, callback: (error?: Error) => void) => void
|
||||
min?: number
|
||||
max?: number
|
||||
len?: number
|
||||
type?: string
|
||||
}
|
||||
|
||||
// 表单规则对象类型
|
||||
export type FormRules = Record<string, FormRule[]>
|
||||
|
||||
// 表单模型对象类型
|
||||
export type FormModel = Record<string, any>
|
||||
|
||||
// Form 组件的 Props 接口
|
||||
export interface FormProps {
|
||||
model?: FormModel
|
||||
rules?: FormRules
|
||||
}
|
||||
|
||||
// Form 组件的实例接口
|
||||
export interface FormInstance {
|
||||
/**
|
||||
* 校验整个表单
|
||||
* @param callback 校验完成后的回调函数,接收校验结果
|
||||
*/
|
||||
validate: (callback: (valid: boolean) => void) => void
|
||||
|
||||
/**
|
||||
* 校验指定字段
|
||||
* @param fieldName 要校验的字段名称
|
||||
* @param callback 可选的回调函数,接收校验结果
|
||||
* @returns 校验是否通过
|
||||
*/
|
||||
validateField: (fieldName: string, callback?: (valid: boolean) => void) => boolean
|
||||
}
|
||||
|
||||
// 注入的上下文类型
|
||||
export interface FormContext {
|
||||
registerField: (field: FormField) => void
|
||||
formModel: FormModel
|
||||
formValidate: (callback: (valid: boolean) => void) => void
|
||||
formRules: FormRules
|
||||
}
|
||||
|
||||
// 验证状态枚举
|
||||
export enum ValidateStatus {
|
||||
Success = 'success',
|
||||
Error = 'error',
|
||||
Validating = 'validating',
|
||||
Pending = 'pending'
|
||||
}
|
||||
@@ -188,7 +188,7 @@ async function cancel() {
|
||||
<style scoped lang="scss">
|
||||
|
||||
|
||||
$modal-mask-bg: rgba(#000, .45);
|
||||
$modal-mask-bg: rgba(#000, .6);
|
||||
$radius: .5rem;
|
||||
$time: 0.3s;
|
||||
$header-height: 4rem;
|
||||
@@ -196,11 +196,9 @@ $header-height: 4rem;
|
||||
@keyframes bounce-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -259,7 +257,6 @@ $header-height: 4rem;
|
||||
animation: bounce-in $time ease-out;
|
||||
|
||||
&.bounce-out {
|
||||
transform: scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import { Article } from "@/types/types.ts";
|
||||
import BaseList from "@/components/list/BaseList.vue";
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
list: Article[],
|
||||
showTranslate?: boolean
|
||||
}>(), {
|
||||
list: [],
|
||||
interface IProps {
|
||||
list: Article[];
|
||||
showTranslate?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
list: () => [] as Article[],
|
||||
showTranslate: true,
|
||||
})
|
||||
|
||||
@@ -62,27 +64,23 @@ function scrollToItem(index: number) {
|
||||
listRef?.scrollToItem(index)
|
||||
}
|
||||
|
||||
defineExpose({scrollToBottom, scrollToItem})
|
||||
defineExpose({ scrollToBottom, scrollToItem })
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list">
|
||||
<div class="search">
|
||||
<BaseInput
|
||||
clearable
|
||||
v-model="searchKey"
|
||||
>
|
||||
<BaseInput clearable v-model="searchKey">
|
||||
<template #subfix>
|
||||
<IconFluentSearch24Regular class="text-lg text-gray"/>
|
||||
<IconFluentSearch24Regular class="text-lg text-gray" />
|
||||
</template>
|
||||
</BaseInput>
|
||||
</div>
|
||||
<BaseList
|
||||
ref="listRef"
|
||||
@click="(e:any) => emit('click',e)"
|
||||
:list="localList"
|
||||
v-bind="$attrs">
|
||||
<BaseList ref="listRef"
|
||||
@click="(e: any) => emit('click', e)"
|
||||
:list="localList"
|
||||
v-bind="$attrs">
|
||||
<template v-slot:prefix="{ item, index }">
|
||||
<slot name="prefix" :item="item" :index="index"></slot>
|
||||
</template>
|
||||
@@ -91,7 +89,7 @@ defineExpose({scrollToBottom, scrollToItem})
|
||||
<div class="name"> {{ `${searchKey ? '' : (index + 1) + '. '}${item.title}` }}</div>
|
||||
</div>
|
||||
<div class="item-sub-title" v-if="item.titleTranslate && showTranslate">
|
||||
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
|
||||
<div class="item-translate"> {{ ` ${item.titleTranslate}` }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-slot:suffix="{ item, index }">
|
||||
|
||||
@@ -5,13 +5,13 @@ import { nextTick, watch } from 'vue'
|
||||
const props = withDefaults(defineProps<{
|
||||
list?: any[],
|
||||
activeIndex?: number,
|
||||
activeId?: number,
|
||||
activeId?: number | string,
|
||||
isActive?: boolean
|
||||
static?: boolean
|
||||
}>(), {
|
||||
list: [],
|
||||
activeIndex: -1,
|
||||
activeId: null,
|
||||
activeId: '',
|
||||
isActive: false,
|
||||
static: true
|
||||
})
|
||||
@@ -94,7 +94,7 @@ function scrollToItem(index: number) {
|
||||
|
||||
function itemIsActive(item: any, index: number) {
|
||||
return props.activeId ?
|
||||
props.activeId === item.id
|
||||
props.activeId == item.id
|
||||
: props.activeIndex === index
|
||||
}
|
||||
|
||||
|
||||
52
src/config/auth.ts
Normal file
52
src/config/auth.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
// 微信登录配置
|
||||
export const WECHAT_CONFIG = {
|
||||
// 微信开放平台AppID(需要在微信开放平台申请)
|
||||
appId: 'your_wechat_app_id',
|
||||
|
||||
// 微信授权回调地址
|
||||
redirectUri: `${window.location.origin}/wechat/callback`,
|
||||
|
||||
// 授权作用域
|
||||
scope: 'snsapi_userinfo',
|
||||
|
||||
// 授权状态参数
|
||||
state: 'wechat_login'
|
||||
}
|
||||
|
||||
// 获取微信授权URL
|
||||
export function getWechatAuthUrl(state?: string): string {
|
||||
const {appId, redirectUri, scope} = WECHAT_CONFIG
|
||||
const authState = state || Math.random().toString(36).substr(2, 15)
|
||||
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appId}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code&scope=${scope}&state=${authState}#wechat_redirect`
|
||||
}
|
||||
|
||||
// 手机号验证配置
|
||||
export const PHONE_CONFIG = {
|
||||
// 验证码长度
|
||||
codeLength: 6,
|
||||
|
||||
// 验证码发送间隔(秒)
|
||||
sendInterval: 60,
|
||||
|
||||
// 手机号正则表达式(中国大陆)
|
||||
phoneRegex: /^1[2-9]\d{9}$/
|
||||
}
|
||||
|
||||
// 邮箱配置
|
||||
export const EMAIL_CONFIG = {
|
||||
// 邮箱正则表达式
|
||||
emailRegex: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
|
||||
// 邮箱验证码长度
|
||||
codeLength: 6
|
||||
}
|
||||
|
||||
// 密码配置
|
||||
export const PASSWORD_CONFIG = {
|
||||
// 密码最小长度
|
||||
minLength: 9,
|
||||
|
||||
// 密码最大长度
|
||||
maxLength: 20
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
|
||||
export const GITHUB = 'https://github.com/SMGoro/TypeWords'
|
||||
export const ProjectName = 'Type Words'
|
||||
export const Host = '2study.smgoro.com'
|
||||
export const GITHUB = 'https://github.com/zyronon/TypeWords'
|
||||
export const Host = 'typewords.cc'
|
||||
export const EMAIL = 'zyronon@163.com'
|
||||
export const Origin = `https://${Host}`
|
||||
export const APP_NAME = 'Type Words'
|
||||
|
||||
@@ -16,11 +14,18 @@ const map = {
|
||||
}
|
||||
|
||||
export const ENV = Object.assign(map['DEV'], common)
|
||||
// export const IS_OFFICIAL = import.meta.env.DEV
|
||||
// export let IS_LOGIN = true
|
||||
export const IS_OFFICIAL = false
|
||||
export let IS_LOGIN = false
|
||||
export const CAN_REQUEST = IS_LOGIN && IS_OFFICIAL
|
||||
|
||||
export let AppEnv = {
|
||||
TOKEN: localStorage.getItem('token') ?? '',
|
||||
IS_OFFICIAL: false,
|
||||
IS_LOGIN: false,
|
||||
CAN_REQUEST: false
|
||||
}
|
||||
|
||||
AppEnv.IS_LOGIN = !!AppEnv.TOKEN
|
||||
AppEnv.CAN_REQUEST = AppEnv.IS_LOGIN && AppEnv.IS_OFFICIAL
|
||||
// console.log('AppEnv.CAN_REQUEST',AppEnv.CAN_REQUEST)
|
||||
|
||||
export const RESOURCE_PATH = ENV.API + 'static'
|
||||
|
||||
const BASE_URL = (import.meta as any).env?.BASE_URL || '/'
|
||||
@@ -53,13 +58,14 @@ export const SAVE_DICT_KEY = {
|
||||
}
|
||||
export const SAVE_SETTING_KEY = {
|
||||
key: 'typing-word-setting',
|
||||
version: 16
|
||||
version: 17
|
||||
}
|
||||
export const EXPORT_DATA_KEY = {
|
||||
key: 'typing-word-export',
|
||||
version: 4
|
||||
}
|
||||
export const LOCAL_FILE_KEY = 'typing-word-files'
|
||||
|
||||
export const PracticeSaveWordKey = {
|
||||
key: 'PracticeSaveWord',
|
||||
version: 1
|
||||
|
||||
@@ -1,126 +1,131 @@
|
||||
import { Article, DictId, PracticeArticleWordType, Sentence } from "@/types/types.ts";
|
||||
import { _nextTick, cloneDeep } from "@/utils";
|
||||
import { usePlayWordAudio } from "@/hooks/sound.ts";
|
||||
import { getSentenceAllText, getSentenceAllTranslateText } from "@/hooks/translate.ts";
|
||||
import { getDefaultArticleWord } from "@/types/func.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
|
||||
import { Article, DictId, PracticeArticleWordType, Sentence } from "@/types/types.ts"
|
||||
import { _nextTick, cloneDeep } from "@/utils"
|
||||
import { usePlayWordAudio } from "@/hooks/sound.ts"
|
||||
import { getSentenceAllText, getSentenceAllTranslateText } from "@/hooks/translate.ts"
|
||||
import { getDefaultArticleWord } from "@/types/func.ts"
|
||||
import { useSettingStore } from "@/stores/setting.ts"
|
||||
import { useBaseStore } from "@/stores/base.ts"
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts"
|
||||
|
||||
function parseSentence(sentence: string) {
|
||||
// 先统一一些常见的“智能引号” -> 直引号,避免匹配问题
|
||||
sentence = sentence
|
||||
.replace(/[\u2018\u2019\u201A\u201B]/g, "'") // 各种单引号 → '
|
||||
.replace(/[\u201C\u201D\u201E\u201F]/g, '"'); // 各种双引号 → "
|
||||
.replace(/[\u201C\u201D\u201E\u201F]/g, '"') // 各种双引号 → "
|
||||
|
||||
const len = sentence.length;
|
||||
const tokens = [];
|
||||
let i = 0;
|
||||
const len = sentence.length
|
||||
const tokens = []
|
||||
let i = 0
|
||||
|
||||
while (i < len) {
|
||||
const ch = sentence[i];
|
||||
const ch = sentence[i]
|
||||
|
||||
// 跳过空白(但不把空白作为 token)
|
||||
if (/\s/.test(ch)) {
|
||||
i++;
|
||||
continue;
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
const rest = sentence.slice(i);
|
||||
const rest = sentence.slice(i)
|
||||
|
||||
// 1) 货币 + 数字($1,000.50 或 ¥200 或 €100.5)
|
||||
let m = rest.match(/^[\$¥€£]\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/);
|
||||
let m = rest.match(/^[\$¥€£]\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/)
|
||||
if (m) {
|
||||
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number});
|
||||
i += m[0].length;
|
||||
continue;
|
||||
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number })
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
// 2) 数字/小数/百分比(100% 3.14 1,000.00)
|
||||
m = rest.match(/^\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/);
|
||||
m = rest.match(/^\d{1,3}(?:,\d{3})*(?:\.\d+)?%?/)
|
||||
if (m) {
|
||||
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number});
|
||||
i += m[0].length;
|
||||
continue;
|
||||
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Number })
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
// 3) 带点缩写或多段缩写(U.S. U.S.A. e.g. i.e. Ph.D.)
|
||||
m = rest.match(/^[A-Za-z]+(?:\.[A-Za-z]+)+\.?/);
|
||||
m = rest.match(/^[A-Za-z]+(?:\.[A-Za-z]+)+\.?/)
|
||||
if (m) {
|
||||
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word});
|
||||
i += m[0].length;
|
||||
continue;
|
||||
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word })
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
// 4) 单词(包含撇号/连字符,如 it's, o'clock, we'll, mother-in-law)
|
||||
m = rest.match(/^[A-Za-z0-9]+(?:[\'\-][A-Za-z0-9]+)*/);
|
||||
m = rest.match(/^[A-Za-z0-9]+(?:[\'\-][A-Za-z0-9]+)*/)
|
||||
if (m) {
|
||||
tokens.push({word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word});
|
||||
i += m[0].length;
|
||||
continue;
|
||||
tokens.push({ word: m[0], start: i, end: i + m[0].length, type: PracticeArticleWordType.Word })
|
||||
i += m[0].length
|
||||
continue
|
||||
}
|
||||
|
||||
// 5) 其它可视符号(标点)——单字符处理(连续标点会被循环拆为单字符)
|
||||
// 包括:.,!?;:"'()-[]{}<>/\\@#%^&*~`等非单词非空白字符
|
||||
if (/[^\w\s]/.test(ch)) {
|
||||
tokens.push({word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol});
|
||||
i += 1;
|
||||
continue;
|
||||
tokens.push({ word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol })
|
||||
i += 1
|
||||
continue
|
||||
}
|
||||
|
||||
// 6) 回退方案:把当前字符当作一个 token(防止意外丢失)
|
||||
tokens.push({word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol});
|
||||
i += 1;
|
||||
tokens.push({ word: ch, start: i, end: i + 1, type: PracticeArticleWordType.Symbol })
|
||||
i += 1
|
||||
}
|
||||
|
||||
// 计算 nextSpace:查看当前 token 的 end 到下一个 token 的 start 之间是否含空白
|
||||
const result = tokens.map((t, idx) => {
|
||||
const next = tokens[idx + 1];
|
||||
const between = next ? sentence.slice(t.end, next.start) : sentence.slice(t.end);
|
||||
const nextSpace = /\s/.test(between);
|
||||
return getDefaultArticleWord({word: t.word, nextSpace, type: t.type});
|
||||
});
|
||||
const next = tokens[idx + 1]
|
||||
const between = next ? sentence.slice(t.end, next.start) : sentence.slice(t.end)
|
||||
const nextSpace = /\s/.test(between)
|
||||
return getDefaultArticleWord({ word: t.word, nextSpace, type: t.type })
|
||||
})
|
||||
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
//生成文章段落数据
|
||||
export function genArticleSectionData(article: Article): number {
|
||||
let text = article.text.trim()
|
||||
let sections: Sentence[][] = []
|
||||
text.split('\n\n').filter(Boolean).map((sectionText, i) => {
|
||||
let section: Sentence[] = []
|
||||
sections.push(section)
|
||||
sectionText.trim().split('\n').filter(Boolean).map((item, i, arr) => {
|
||||
item = item.trim()
|
||||
//如果没有空格,导致修改一行一行的数据时,汇总时全没有空格了,库无法正常断句
|
||||
//所以要保证最后一个是空格,但防止用户打N个空格,就去掉再加上一个空格,只需要一个即可
|
||||
//2025/10/1:最后一句不需要空格
|
||||
if (i < arr.length - 1) item += ' '
|
||||
let sentence: Sentence = cloneDeep({
|
||||
text: item,
|
||||
translate: '',
|
||||
words: parseSentence(item),
|
||||
audioPosition: [0, 0],
|
||||
})
|
||||
section.push(sentence)
|
||||
text
|
||||
.split("\n\n")
|
||||
.filter(Boolean)
|
||||
.map((sectionText, i) => {
|
||||
let section: Sentence[] = []
|
||||
sections.push(section)
|
||||
sectionText
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((item, i, arr) => {
|
||||
item = item.trim()
|
||||
//如果没有空格,导致修改一行一行的数据时,汇总时全没有空格了,库无法正常断句
|
||||
//所以要保证最后一个是空格,但防止用户打N个空格,就去掉再加上一个空格,只需要一个即可
|
||||
//2025/10/1:最后一句不需要空格
|
||||
if (i < arr.length - 1) item += " "
|
||||
let sentence: Sentence = cloneDeep({
|
||||
text: item,
|
||||
translate: "",
|
||||
words: parseSentence(item),
|
||||
audioPosition: [0, 0]
|
||||
})
|
||||
section.push(sentence)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
sections = sections.filter(v => v.length)
|
||||
sections = sections.filter((v) => v.length)
|
||||
article.sections = sections
|
||||
|
||||
let failCount = 0
|
||||
let translateList = article.textTranslate?.split('\n\n') || []
|
||||
let translateList = article.textTranslate?.split("\n\n") || []
|
||||
for (let i = 0; i < article.sections.length; i++) {
|
||||
let v = article.sections[i]
|
||||
let sList = []
|
||||
try {
|
||||
let s = translateList[i]
|
||||
sList = s.split('\n')
|
||||
} catch (e) {
|
||||
}
|
||||
sList = s.split("\n")
|
||||
} catch (e) {}
|
||||
|
||||
for (let j = 0; j < v.length; j++) {
|
||||
let sentence = v[j]
|
||||
@@ -159,167 +164,167 @@ export function genArticleSectionData(article: Article): number {
|
||||
export function splitEnArticle2(text: string): string {
|
||||
text = text.trim()
|
||||
if (!text && false) {
|
||||
// text = `It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again. ' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train, ' she said. 'I'm coming to see you. '
|
||||
//
|
||||
// 'But I'm still having breakfast, ' I said.
|
||||
// 'What are you doing?' she asked.
|
||||
// 'I'm having breakfast, ' I repeated.
|
||||
// 'Dear me,$3.000' she said. 'Do you always get up so late? It's one o'clock!'`
|
||||
// text = `While it is yet to be seen what direction the second Trump administration will take globally in its China policy, VOA traveled to the main island of Mahe in Seychelles to look at how China and the U.S. have impacted the country, and how each is fairing in that competition for influence there.`
|
||||
// text = `It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again. ' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train, ' she said. 'I'm coming to see you. '
|
||||
//
|
||||
// 'But I'm still having breakfast, ' I said.
|
||||
// 'What are you doing?' she asked.
|
||||
// 'I'm having breakfast, ' I repeated.
|
||||
// 'Dear me,$3.000' she said. 'Do you always get up so late? It's one o'clock!'`
|
||||
// text = `While it is yet to be seen what direction the second Trump administration will take globally in its China policy, VOA traveled to the main island of Mahe in Seychelles to look at how China and the U.S. have impacted the country, and how each is fairing in that competition for influence there.`
|
||||
// text = "It was Sunday. I never get up early on Sundays. I sometimes stay in bed until lunchtime. Last Sunday I got up very late. I looked out of the window. It was dark outside. 'What a day!' I thought. 'It's raining again.' Just then, the telephone rang. It was my aunt Lucy. 'I've just arrived by train,' she said. 'I'm coming to see you.'\n\n 'But I'm still having breakfast,' I said.\n\n 'What are you doing?' she asked.\n\n 'I'm having breakfast,' I repeated.\n\n 'Dear me,' she said. 'Do you always get up so late? It's one o'clock!'"
|
||||
}
|
||||
|
||||
if (!text) return '';
|
||||
if (!text) return ""
|
||||
|
||||
const abbreviations = [
|
||||
'Mr', 'Mrs', 'Ms', 'Dr', 'Prof', 'Sr', 'Jr',
|
||||
'St', 'Co', 'Ltd', 'Inc', 'e.g', 'i.e', 'U.S.A', 'U.S', 'U.K', 'etc'
|
||||
];
|
||||
const abbreviations = ["Mr", "Mrs", "Ms", "Dr", "Prof", "Sr", "Jr", "St", "Co", "Ltd", "Inc", "e.g", "i.e", "U.S.A", "U.S", "U.K", "etc"]
|
||||
|
||||
function isSentenceEnd(text, idx) {
|
||||
const before = text.slice(0, idx + 1);
|
||||
const after = text.slice(idx + 1);
|
||||
const before = text.slice(0, idx + 1)
|
||||
const after = text.slice(idx + 1)
|
||||
|
||||
const abbrevPattern = new RegExp('\\b(' + abbreviations.join('|') + ')\\.$', 'i');
|
||||
if (abbrevPattern.test(before)) return false;
|
||||
if (/\d+\.$/.test(before)) return false;
|
||||
if (/\d+\.\d/.test(text.slice(idx - 1, idx + 2))) return false;
|
||||
if (/%/.test(after)) return false;
|
||||
if (/[\$¥€]\d/.test(before + after)) return false;
|
||||
const abbrevPattern = new RegExp("\\b(" + abbreviations.join("|") + ")\\.$", "i")
|
||||
if (abbrevPattern.test(before)) return false
|
||||
if (/\d+\.$/.test(before)) return false
|
||||
if (/\d+\.\d/.test(text.slice(idx - 1, idx + 2))) return false
|
||||
if (/%/.test(after)) return false
|
||||
if (/[\$¥€]\d/.test(before + after)) return false
|
||||
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
function normalizeQuotes(text) {
|
||||
const isWord = ch => /\w/.test(ch);
|
||||
let res = [];
|
||||
let singleOpen = false;
|
||||
let doubleOpen = false;
|
||||
const isWord = (ch) => /\w/.test(ch)
|
||||
let res = []
|
||||
let singleOpen = false
|
||||
let doubleOpen = false
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
const ch = text[i]
|
||||
if (ch === "'") {
|
||||
const prev = i > 0 ? text[i - 1] : '';
|
||||
const nxt = i + 1 < text.length ? text[i + 1] : '';
|
||||
const prev = i > 0 ? text[i - 1] : ""
|
||||
const nxt = i + 1 < text.length ? text[i + 1] : ""
|
||||
if (isWord(prev) && isWord(nxt)) {
|
||||
res.push("'");
|
||||
continue;
|
||||
res.push("'")
|
||||
continue
|
||||
}
|
||||
if (singleOpen) {
|
||||
if (res.length && res[res.length - 1] === ' ') res.pop();
|
||||
res.push("'");
|
||||
singleOpen = false;
|
||||
if (res.length && res[res.length - 1] === " ") res.pop()
|
||||
res.push("'")
|
||||
singleOpen = false
|
||||
} else {
|
||||
res.push("'");
|
||||
singleOpen = true;
|
||||
res.push("'")
|
||||
singleOpen = true
|
||||
}
|
||||
} else if (ch === '"') {
|
||||
if (doubleOpen) {
|
||||
if (res.length && res[res.length - 1] === ' ') res.pop();
|
||||
res.push('"');
|
||||
doubleOpen = false;
|
||||
if (res.length && res[res.length - 1] === " ") res.pop()
|
||||
res.push('"')
|
||||
doubleOpen = false
|
||||
} else {
|
||||
res.push('"');
|
||||
doubleOpen = true;
|
||||
res.push('"')
|
||||
doubleOpen = true
|
||||
}
|
||||
} else {
|
||||
res.push(ch);
|
||||
res.push(ch)
|
||||
}
|
||||
}
|
||||
return res.join('');
|
||||
return res.join("")
|
||||
}
|
||||
|
||||
let rawParagraphs = text.replaceAll('\n\n', '`^`').replaceAll('\n', '').split('`^`')
|
||||
let rawParagraphs = text.replaceAll("\n\n", "`^`").replaceAll("\n", "").split("`^`")
|
||||
|
||||
const formattedParagraphs = rawParagraphs.map(p => {
|
||||
p = p.trim();
|
||||
if (!p) return '';
|
||||
const formattedParagraphs = rawParagraphs.map((p) => {
|
||||
p = p.trim()
|
||||
if (!p) return ""
|
||||
|
||||
p = p.replace(/\n/g, ' ');
|
||||
p = normalizeQuotes(p);
|
||||
p = p.replace(/\n/g, " ")
|
||||
p = normalizeQuotes(p)
|
||||
|
||||
const tentative: string[] = p.match(/[^.!?。!?]+[.!?。!?'"”’)]*/g) || [];
|
||||
const tentative: string[] = p.match(/[^.!?。!?]+[.!?。!?'"”’)]*/g) || []
|
||||
|
||||
const sentences = [];
|
||||
tentative.forEach(segment => {
|
||||
segment = segment.trim();
|
||||
if (!segment) return;
|
||||
const sentences = []
|
||||
tentative.forEach((segment) => {
|
||||
segment = segment.trim()
|
||||
if (!segment) return
|
||||
|
||||
const lastCharIdx = segment.length - 1;
|
||||
const lastCharIdx = segment.length - 1
|
||||
if (/[.!?。!?]/.test(segment[lastCharIdx])) {
|
||||
const globalIdx = p.indexOf(segment);
|
||||
const globalIdx = p.indexOf(segment)
|
||||
if (!isSentenceEnd(p, globalIdx + segment.length - 1)) {
|
||||
if (sentences.length > 0) {
|
||||
sentences[sentences.length - 1] += ' ' + segment;
|
||||
sentences[sentences.length - 1] += " " + segment
|
||||
} else {
|
||||
sentences.push(segment);
|
||||
sentences.push(segment)
|
||||
}
|
||||
return;
|
||||
return
|
||||
}
|
||||
}
|
||||
sentences.push(segment);
|
||||
});
|
||||
sentences.push(segment)
|
||||
})
|
||||
|
||||
const finalSentences = [];
|
||||
let i = 0;
|
||||
const finalSentences = []
|
||||
let i = 0
|
||||
while (i < sentences.length) {
|
||||
let cur = sentences[i];
|
||||
let cur = sentences[i]
|
||||
if (i + 1 < sentences.length) {
|
||||
const nxt = sentences[i + 1];
|
||||
const nxt = sentences[i + 1]
|
||||
if (/['"”’)\]]$/.test(cur) && /^[a-z]|^(I|You|She|He|They|We)\b/i.test(nxt)) {
|
||||
finalSentences.push(cur + ' ' + nxt);
|
||||
i += 2;
|
||||
continue;
|
||||
finalSentences.push(cur + " " + nxt)
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
finalSentences.push(cur);
|
||||
i += 1;
|
||||
finalSentences.push(cur)
|
||||
i += 1
|
||||
}
|
||||
|
||||
return finalSentences.join('\n');
|
||||
});
|
||||
return finalSentences.join("\n")
|
||||
})
|
||||
|
||||
return formattedParagraphs.filter(p => p).join('\n\n');
|
||||
return formattedParagraphs.filter((p) => p).join("\n\n")
|
||||
}
|
||||
|
||||
export function splitCNArticle2(text: string): string {
|
||||
if (!text && false) {
|
||||
// text = "飞机误点了,侦探们在机场等了整整一上午。他们正期待从南非来的一个装着钻石的贵重包裹。数小时以前,有人向警方报告,说有人企图偷走这些钻石。当飞机到达时,一些侦探等候在主楼内,另一些侦探则守候在停机坪上。有两个人把包裹拿下飞机,进了海关。这时两个侦探把住门口,另外两个侦探打开了包裹。令他们吃惊的是,那珍贵的包裹里面装的全是石头和沙子!"
|
||||
// text = `那是个星期天,而在星期天我是从来不早起的,有时我要一直躺到吃午饭的时候。上个星期天,我起得很晚。我望望窗外,外面一片昏暗。“鬼天气!”我想,“又下雨了。”正在这时,电话铃响了。是我姑母露西打来的。“我刚下火车,”她说,“我这就来看你。”
|
||||
// “但我还在吃早饭,”我说。
|
||||
// “你在干什么?”她问道。
|
||||
// “我正在吃早饭,”我又说了一遍。
|
||||
// “天啊,”她说,“你总是起得这么晚吗?现在已经1点钟了!”`
|
||||
// text = `上星期我去看戏。我的座位很好,戏很有意思,但我却无法欣赏。一青年男子与一青年女子坐在我的身后,大声地说着话。我非常生气,因为我听不见演员在说什么。我回过头去怒视着那一男一女,他们却毫不理会。最后,我忍不住了,又一次回过头去,生气地说:“我一个字也听不见了!”
|
||||
// “不关你的事,”那男的毫不客气地说,“这是私人间的谈话!”`
|
||||
// text = `那是个星期天,而在星期天我是从来不早起的,有时我要一直躺到吃午饭的时候。上个星期天,我起得很晚。我望望窗外,外面一片昏暗。“鬼天气!”我想,“又下雨了。”正在这时,电话铃响了。是我姑母露西打来的。“我刚下火车,”她说,“我这就来看你。”
|
||||
// “但我还在吃早饭,”我说。
|
||||
// “你在干什么?”她问道。
|
||||
// “我正在吃早饭,”我又说了一遍。
|
||||
// “天啊,”她说,“你总是起得这么晚吗?现在已经1点钟了!”`
|
||||
// text = `上星期我去看戏。我的座位很好,戏很有意思,但我却无法欣赏。一青年男子与一青年女子坐在我的身后,大声地说着话。我非常生气,因为我听不见演员在说什么。我回过头去怒视着那一男一女,他们却毫不理会。最后,我忍不住了,又一次回过头去,生气地说:“我一个字也听不见了!”
|
||||
// “不关你的事,”那男的毫不客气地说,“这是私人间的谈话!”`
|
||||
}
|
||||
const segmenterJa = new Intl.Segmenter("zh-CN", {granularity: "sentence"});
|
||||
const segmenterJa = new Intl.Segmenter("zh-CN", { granularity: "sentence" })
|
||||
|
||||
let sectionTextList = text.replaceAll('\n\n', '`^`').replaceAll('\n', '').split('`^`')
|
||||
let sectionTextList = text.replaceAll("\n\n", "`^`").replaceAll("\n", "").split("`^`")
|
||||
|
||||
let s = sectionTextList.filter(v => v).map((rowSection, i) => {
|
||||
const segments = segmenterJa.segment(rowSection);
|
||||
let ss = ''
|
||||
Array.from(segments).map(sentenceRow => {
|
||||
let row = sentenceRow.segment
|
||||
if (row) {
|
||||
//这个库总是会把反引号给断句到上一行末尾
|
||||
//而 sentence-splitter 这个库总是会把反引号给断句到下一行开头
|
||||
if (row[row.length - 1] === "“") {
|
||||
row = row.substring(0, row.length - 1)
|
||||
ss += (row + '\n') + '“'
|
||||
} else {
|
||||
ss += (row + '\n')
|
||||
let s = sectionTextList
|
||||
.filter((v) => v)
|
||||
.map((rowSection, i) => {
|
||||
const segments = segmenterJa.segment(rowSection)
|
||||
let ss = ""
|
||||
Array.from(segments).map((sentenceRow) => {
|
||||
let row = sentenceRow.segment
|
||||
if (row) {
|
||||
//这个库总是会把反引号给断句到上一行末尾
|
||||
//而 sentence-splitter 这个库总是会把反引号给断句到下一行开头
|
||||
if (row[row.length - 1] === "“") {
|
||||
row = row.substring(0, row.length - 1)
|
||||
ss += row + "\n" + "“"
|
||||
} else {
|
||||
ss += row + "\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return ss
|
||||
})
|
||||
return ss
|
||||
}).join('\n').trim()
|
||||
.join("\n")
|
||||
.trim()
|
||||
return s
|
||||
}
|
||||
|
||||
export function getTranslateText(article: Article) {
|
||||
return article.textTranslate
|
||||
.split('\n\n').filter(v => v)
|
||||
return article.textTranslate.split("\n\n").filter((v) => v)
|
||||
}
|
||||
|
||||
export function usePlaySentenceAudio() {
|
||||
@@ -327,14 +332,14 @@ export function usePlaySentenceAudio() {
|
||||
const settingStore = useSettingStore()
|
||||
let timer = $ref(0)
|
||||
|
||||
function playSentenceAudio(sentence: Sentence, ref?: HTMLAudioElement,) {
|
||||
function playSentenceAudio(sentence: Sentence, ref?: HTMLAudioElement) {
|
||||
if (sentence.audioPosition?.length && ref && ref.src) {
|
||||
clearTimeout(timer)
|
||||
if (ref.played) {
|
||||
ref.pause()
|
||||
}
|
||||
let start = sentence.audioPosition[0];
|
||||
ref.volume = settingStore.wordSoundVolume / 100
|
||||
let start = sentence.audioPosition[0]
|
||||
// ref.volume = settingStore.wordSoundVolume / 100
|
||||
ref.currentTime = start
|
||||
ref.play()
|
||||
let end = sentence.audioPosition?.[1]
|
||||
@@ -342,9 +347,9 @@ export function usePlaySentenceAudio() {
|
||||
|
||||
if (end && end !== -1) {
|
||||
timer = setTimeout(() => {
|
||||
console.log('停')
|
||||
console.log("停")
|
||||
ref.pause()
|
||||
}, (end - start) / ref.playbackRate * 1000)
|
||||
}, ((end - start) / ref.playbackRate) * 1000)
|
||||
}
|
||||
} else {
|
||||
playWordAudio(sentence.text)
|
||||
@@ -361,8 +366,8 @@ export function syncBookInMyStudyList(study = false) {
|
||||
_nextTick(() => {
|
||||
const base = useBaseStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
let rIndex = base.article.bookList.findIndex(v => v.id === runtimeStore.editDict.id)
|
||||
let temp = cloneDeep(runtimeStore.editDict);
|
||||
let rIndex = base.article.bookList.findIndex((v) => v.id === runtimeStore.editDict.id)
|
||||
let temp = cloneDeep(runtimeStore.editDict)
|
||||
if (!temp.custom && temp.id !== DictId.articleCollect) {
|
||||
temp.custom = true
|
||||
}
|
||||
@@ -375,4 +380,4 @@ export function syncBookInMyStudyList(study = false) {
|
||||
if (study) base.article.studyIndex = base.article.bookList.length - 1
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Article, TaskWords, Word, WordPracticeMode} from "@/types/types.ts";
|
||||
import { Article, TaskWords, Word, WordPracticeMode } from "@/types/types.ts";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { getDefaultWord } from "@/types/func.ts";
|
||||
@@ -87,7 +87,7 @@ export function useArticleOptions() {
|
||||
|
||||
export function getCurrentStudyWord(): TaskWords {
|
||||
const store = useBaseStore()
|
||||
let data = {new: [], review: [], write: []}
|
||||
let data = {new: [], review: [], write: [], shuffle: []}
|
||||
let dict = store.sdict;
|
||||
let isTest = false
|
||||
let words = dict.words.slice()
|
||||
|
||||
19
src/main.ts
19
src/main.ts
@@ -1,8 +1,8 @@
|
||||
import { createApp } from 'vue'
|
||||
import {createApp} from 'vue'
|
||||
import './assets/css/style.scss'
|
||||
import 'virtual:uno.css';
|
||||
import App from './App.vue'
|
||||
import { createPinia } from "pinia"
|
||||
import {createPinia} from "pinia"
|
||||
import router from "@/router.ts";
|
||||
import VueVirtualScroller from 'vue-virtual-scroller'
|
||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||
@@ -13,6 +13,7 @@ import loadingDirective from './directives/loading.tsx'
|
||||
const pinia = createPinia()
|
||||
const app = createApp(App)
|
||||
|
||||
|
||||
app.use(VueVirtualScroller)
|
||||
app.use(pinia)
|
||||
app.use(router)
|
||||
@@ -21,5 +22,17 @@ app.directive('opacity', (el, binding) => {
|
||||
el.style.opacity = binding.value ? 1 : 0
|
||||
})
|
||||
app.directive('loading', loadingDirective)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// 注册Service Worker(pwa支持)
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/service-worker.js')
|
||||
.then(registration => {
|
||||
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||
})
|
||||
.catch(err => {
|
||||
console.log('ServiceWorker registration failed: ', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
115
src/pages/MigrateDialog.vue
Normal file
115
src/pages/MigrateDialog.vue
Normal file
@@ -0,0 +1,115 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {Origin} from "@/config/env.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {set} from 'idb-keyval'
|
||||
import {defineAsyncComponent} from "vue";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
const emit = defineEmits<{ ok: [] }>()
|
||||
|
||||
async function migrateFromOldSite() {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// 旧域名地址
|
||||
var OLD_ORIGIN = 'https://2study.top';
|
||||
// 需要迁移的 IndexedDB key
|
||||
var IDB_KEYS = [
|
||||
'type-words-app-version',
|
||||
'typing-word-dict',
|
||||
'typing-word-setting',
|
||||
'typing-word-files'
|
||||
];
|
||||
// 需要迁移的 localStorage key
|
||||
var LS_KEYS = [
|
||||
'PracticeSaveWord',
|
||||
'PracticeSaveArticle'
|
||||
];
|
||||
const migrateWin = window.open(`${OLD_ORIGIN}/migrate.html`, '_blank', 'width=400,height=400');
|
||||
|
||||
if (!migrateWin) return reject('弹窗被阻止,请在网址输入栏最右边,点击允许弹窗');
|
||||
|
||||
async function onMessage(event) {
|
||||
if (event.origin !== OLD_ORIGIN) return;
|
||||
if (event.data?.type !== 'MIGRATION_RESULT') return;
|
||||
const payload = event.data.payload;
|
||||
console.log('payload', payload);
|
||||
|
||||
// 写入 localStorage
|
||||
LS_KEYS.forEach(key => {
|
||||
if (payload.localStorage[key] !== undefined) {
|
||||
localStorage.setItem(key, payload.localStorage[key]);
|
||||
}
|
||||
});
|
||||
|
||||
// 写入 IndexedDB
|
||||
for (let key of IDB_KEYS) {
|
||||
if (payload.indexedDB[key] !== undefined) {
|
||||
await set(key, payload.indexedDB[key]);
|
||||
}
|
||||
}
|
||||
|
||||
window.removeEventListener('message', onMessage);
|
||||
resolve(true);
|
||||
}
|
||||
|
||||
window.addEventListener('message', onMessage);
|
||||
|
||||
// 等窗口加载完毕后发请求
|
||||
const timer = setInterval(() => {
|
||||
if (!migrateWin || migrateWin.closed) {
|
||||
clearInterval(timer);
|
||||
reject('迁移窗口已关闭');
|
||||
} else {
|
||||
try {
|
||||
migrateWin.postMessage({type: 'REQUEST_MIGRATION_DATA'}, OLD_ORIGIN);
|
||||
} catch (e) {
|
||||
// 跨域安全错误忽略,等窗口完全加载后再试
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
});
|
||||
}
|
||||
|
||||
async function transfer() {
|
||||
try {
|
||||
await migrateFromOldSite();
|
||||
localStorage.setItem('__migrated_from_2study_top__', '1');
|
||||
console.log('迁移完成');
|
||||
Toast.success('迁移完成')
|
||||
model.value = false
|
||||
emit('ok')
|
||||
|
||||
} catch (e) {
|
||||
Toast.error('迁移失败:' + e)
|
||||
console.error('迁移失败', e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model="model" title="迁移数据">
|
||||
<div class="px-4 flex-col center text-align-center w-100">
|
||||
<h2>
|
||||
本网站已启用新域名 <span class="color-blue">{{ Origin }}</span>
|
||||
</h2>
|
||||
<h3>
|
||||
老域名即将停用,由于浏览器安全限制,新老网站数据无法互通,需要您手动点击转移数据
|
||||
</h3>
|
||||
<h3>
|
||||
<BaseButton
|
||||
size="large"
|
||||
@click="transfer">
|
||||
转移数据
|
||||
</BaseButton>
|
||||
</h3>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
@@ -18,7 +18,7 @@ import dayjs from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import { useFetch } from "@vueuse/core";
|
||||
import { CAN_REQUEST, DICT_LIST, PracticeSaveArticleKey } from "@/config/env.ts";
|
||||
import { AppEnv, DICT_LIST, PracticeSaveArticleKey } from "@/config/env.ts";
|
||||
import { myDictList } from "@/apis";
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
@@ -36,7 +36,7 @@ watch(() => store.load, n => {
|
||||
}, {immediate: true})
|
||||
|
||||
async function init() {
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await myDictList({type: "article"})
|
||||
if (res.success) {
|
||||
store.setState(Object.assign(store.$state, res.data))
|
||||
@@ -214,7 +214,7 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR
|
||||
</div>
|
||||
<div class="flex flex-col justify-between items-end articles-summary__actions">
|
||||
<div class="flex gap-4 items-center" v-opacity="base.sbook.id">
|
||||
<div class="color-blue cursor-pointer" @click="router.push('/book-list')">更换</div>
|
||||
<div class="color-link cursor-pointer" @click="router.push('/book-list')">更换</div>
|
||||
</div>
|
||||
<BaseButton size="large"
|
||||
@click="startStudy"
|
||||
@@ -237,10 +237,10 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR
|
||||
</BaseIcon>
|
||||
</PopConfirm>
|
||||
|
||||
<div class="color-blue cursor-pointer" v-if="base.article.bookList.length > 1"
|
||||
<div class="color-link cursor-pointer" v-if="base.article.bookList.length > 1"
|
||||
@click="isMultiple = !isMultiple; selectIds = []">{{ isMultiple ? '取消' : '管理书籍' }}
|
||||
</div>
|
||||
<div class="color-blue cursor-pointer" @click="nav('book-detail', { isAdd: true })">创建个人书籍</div>
|
||||
<div class="color-link cursor-pointer" @click="nav('book-detail', { isAdd: true })">创建个人书籍</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="articles-list__grid mt-4">
|
||||
@@ -260,8 +260,8 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR
|
||||
<div class="card flex flex-col min-h-50 articles-list" v-loading="isFetching">
|
||||
<div class="articles-list__header">
|
||||
<div class="title">推荐</div>
|
||||
<div class="articles-list__actions">
|
||||
<div class="color-blue cursor-pointer" @click="router.push('/book-list')">更多</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="color-link cursor-pointer" @click="router.push('/book-list')">更多</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -277,8 +277,7 @@ const {data: recommendBookList, isFetching} = useFetch(resourceWrap(DICT_LIST.AR
|
||||
|
||||
<style scoped lang="scss">
|
||||
.stat {
|
||||
@apply rounded-xl p-4 box-border relative flex-1;
|
||||
background: white;
|
||||
@apply rounded-xl p-4 box-border relative flex-1 bg-[var(--bg-history)];
|
||||
border: 1px solid gainsboro;
|
||||
|
||||
.num {
|
||||
|
||||
@@ -20,7 +20,7 @@ import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
|
||||
import { MessageBox } from "@/utils/MessageBox.tsx";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { useFetch } from "@vueuse/core";
|
||||
import { CAN_REQUEST, DICT_LIST } from "@/config/env.ts";
|
||||
import { AppEnv, DICT_LIST } from "@/config/env.ts";
|
||||
import { detail } from "@/apis";
|
||||
|
||||
const runtimeStore = useRuntimeStore()
|
||||
@@ -93,7 +93,7 @@ async function init() {
|
||||
}
|
||||
|
||||
if (base.article.bookList.find(book => book.id === runtimeStore.editDict.id)) {
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await detail({id: runtimeStore.editDict.id})
|
||||
if (res.success) {
|
||||
runtimeStore.editDict.statistics = res.data.statistics
|
||||
@@ -225,7 +225,7 @@ function next() {
|
||||
<div
|
||||
class="item border border-item border-solid mt-2 p-2 bg-[var(--bg-history)] rounded-md flex justify-between"
|
||||
v-for="i in currentPractice">
|
||||
<span class="color-gray">{{ _dateFormat(i.startDate, 'YYYY/MM/DD HH:mm') }}</span>
|
||||
<span class="color-gray">{{ _dateFormat(i.startDate) }}</span>
|
||||
<span>{{ msToHourMinute(i.spend) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useRoute, useRouter } from "vue-router";
|
||||
import PracticeLayout from "@/components/PracticeLayout.vue";
|
||||
import ArticleAudio from "@/pages/article/components/ArticleAudio.vue";
|
||||
import VolumeSetting from "@/pages/article/components/VolumeSetting.vue";
|
||||
import { CAN_REQUEST, DICT_LIST, PracticeSaveArticleKey } from "@/config/env.ts";
|
||||
import { AppEnv, DICT_LIST, PracticeSaveArticleKey } from "@/config/env.ts";
|
||||
import { addStat, setDictProp } from "@/apis";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
|
||||
@@ -53,6 +53,7 @@ let typingArticleRef = $ref<any>()
|
||||
let loading = $ref<boolean>(false)
|
||||
let allWrongWords = new Set()
|
||||
let editArticle = $ref<Article>(getDefaultArticle())
|
||||
let audioRef = $ref<HTMLAudioElement>()
|
||||
let timer = $ref(0)
|
||||
let isFocus = true
|
||||
|
||||
@@ -132,10 +133,34 @@ async function init() {
|
||||
}
|
||||
}
|
||||
|
||||
const initAudio = () => {
|
||||
_nextTick(() => {
|
||||
audioRef.volume = settingStore.articleSoundVolume / 100
|
||||
audioRef.playbackRate = settingStore.articleSoundSpeed
|
||||
})
|
||||
}
|
||||
|
||||
const handleVolumeUpdate = (volume: number) => {
|
||||
settingStore.setState({
|
||||
articleSoundVolume: volume
|
||||
})
|
||||
}
|
||||
|
||||
const handleSpeedUpdate = (speed: number) => {
|
||||
settingStore.setState({
|
||||
articleSoundSpeed: speed
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
watch(() => store.load, (n) => {
|
||||
if (n && loading) init()
|
||||
}, {immediate: true})
|
||||
|
||||
watch(() => settingStore.$state, (n) => {
|
||||
initAudio()
|
||||
}, {immediate: true, deep: true})
|
||||
|
||||
onMounted(() => {
|
||||
if (store.sbook?.articles?.length) {
|
||||
articleData.list = cloneDeep(store.sbook.articles)
|
||||
@@ -238,6 +263,10 @@ function setArticle(val: Article) {
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => articleData.article.id, n => {
|
||||
console.log('articleData.article.id', n)
|
||||
})
|
||||
|
||||
async function complete() {
|
||||
clearInterval(timer)
|
||||
setTimeout(() => {
|
||||
@@ -254,7 +283,7 @@ async function complete() {
|
||||
wrong: statStore.wrong,
|
||||
}
|
||||
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await addStat({...data, type: 'article'})
|
||||
if (!res.success) {
|
||||
Toast.error(res.msg)
|
||||
@@ -337,13 +366,14 @@ async function changeArticle(val: ArticleItem) {
|
||||
store.sbook.lastLearnIndex = rIndex
|
||||
getCurrentPractice()
|
||||
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await setDictProp(null, store.sbook)
|
||||
if (!res.success) {
|
||||
Toast.error(res.msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
initAudio()
|
||||
}
|
||||
|
||||
const handlePlayNext = (nextArticle: Article) => {
|
||||
@@ -372,7 +402,6 @@ function onKeyUp() {
|
||||
}
|
||||
|
||||
async function onKeyDown(e: KeyboardEvent) {
|
||||
// console.log('e', e)
|
||||
switch (e.key) {
|
||||
case 'Backspace':
|
||||
typingArticleRef.del()
|
||||
@@ -414,13 +443,14 @@ onUnmounted(() => {
|
||||
timer && clearInterval(timer)
|
||||
})
|
||||
|
||||
let audioRef = $ref<HTMLAudioElement>()
|
||||
const {playSentenceAudio} = usePlaySentenceAudio()
|
||||
|
||||
function play2(e) {
|
||||
if (settingStore.articleSound || e.handle) {
|
||||
playSentenceAudio(e.sentence, audioRef)
|
||||
}
|
||||
_nextTick(() => {
|
||||
if (settingStore.articleSound || e.handle) {
|
||||
playSentenceAudio(e.sentence, audioRef)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const currentPractice = computed(() => {
|
||||
@@ -461,7 +491,7 @@ provide('currentPractice', currentPractice)
|
||||
:static="false"
|
||||
:show-translate="settingStore.translate"
|
||||
@click="changeArticle"
|
||||
:active-id="articleData.article.id"
|
||||
:active-id="articleData.article.id??''"
|
||||
:list="articleData.list ">
|
||||
<template v-slot:suffix="{item,index}">
|
||||
<BaseIcon
|
||||
@@ -519,7 +549,10 @@ provide('currentPractice', currentPractice)
|
||||
ref="audioRef"
|
||||
:article="articleData.article"
|
||||
:autoplay="settingStore.articleAutoPlayNext"
|
||||
@ended="settingStore.articleAutoPlayNext && next()"></ArticleAudio>
|
||||
@ended="settingStore.articleAutoPlayNext && next()"
|
||||
@update-speed="handleSpeedUpdate"
|
||||
@update-volume="handleVolumeUpdate"
|
||||
></ArticleAudio>
|
||||
<div class="flex flex-col items-center justify-center gap-1">
|
||||
<div class="flex gap-2 center">
|
||||
<VolumeSetting/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { Article } from "@/types/types.ts";
|
||||
import { watch } from "vue";
|
||||
import { ref, watch, nextTick } from "vue";
|
||||
import { get } from "idb-keyval";
|
||||
import Audio from "@/components/base/Audio.vue";
|
||||
import { LOCAL_FILE_KEY } from "@/config/env.ts";
|
||||
@@ -10,12 +10,43 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
ended: []
|
||||
(e: 'ended'): [],
|
||||
(e: 'update-volume', volume: number): void,
|
||||
(e: 'update-speed', volume: number): void
|
||||
}>();
|
||||
|
||||
|
||||
let file = $ref(null)
|
||||
let instance = $ref<{ audioRef: HTMLAudioElement }>({audioRef: null})
|
||||
let instance = $ref<{ audioRef: HTMLAudioElement }>({ audioRef: null })
|
||||
const pendingUpdates = ref({})
|
||||
|
||||
const handleVolumeUpdate = (volume: number) => {
|
||||
emit('update-volume', volume)
|
||||
}
|
||||
|
||||
const handleSpeedUpdate = (speed: number) => {
|
||||
emit('update-speed', speed)
|
||||
}
|
||||
|
||||
const setAudioRefValue = (key: string, value: any) => {
|
||||
if (instance?.audioRef) {
|
||||
switch (key) {
|
||||
case 'currentTime':
|
||||
instance.audioRef.currentTime = value;
|
||||
break;
|
||||
case 'volume':
|
||||
instance.audioRef.volume = value;
|
||||
break;
|
||||
case 'playbackRate':
|
||||
instance.audioRef.playbackRate = value;
|
||||
break;
|
||||
default:
|
||||
break
|
||||
}
|
||||
} else {
|
||||
// 如果audioRef还未初始化,先存起来,等初始化后再设置 => watch监听instance变化
|
||||
pendingUpdates.value[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.article.audioFileId, async () => {
|
||||
if (!props.article.audioSrc && props.article.audioFileId) {
|
||||
@@ -29,7 +60,15 @@ watch(() => props.article.audioFileId, async () => {
|
||||
} else {
|
||||
file = null
|
||||
}
|
||||
}, {immediate: true})
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听instance变化,设置之前pending的值
|
||||
watch(() => instance, (newVal) => {
|
||||
Object.entries(pendingUpdates.value).forEach(([key, value]) => {
|
||||
setAudioRefValue(key, value)
|
||||
});
|
||||
pendingUpdates.value = {};
|
||||
}, { immediate: true })
|
||||
|
||||
//转发一遍,这里Proxy的默认值不能为{},可能是vue做了什么
|
||||
defineExpose(new Proxy({
|
||||
@@ -52,21 +91,18 @@ defineExpose(new Proxy({
|
||||
return target[key]
|
||||
},
|
||||
set(_, key, value) {
|
||||
if (key === 'currentTime') instance.audioRef.currentTime = value
|
||||
if (key === 'volume') return instance.audioRef.volume = value
|
||||
setAudioRefValue(key as string, value)
|
||||
return true
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Audio v-bind="$attrs" ref="instance"
|
||||
v-if="props.article.audioSrc"
|
||||
:src="props.article.audioSrc"
|
||||
@ended="emit('ended')"/>
|
||||
<Audio v-bind="$attrs" ref="instance"
|
||||
v-else-if="file"
|
||||
:src="file"
|
||||
@ended="emit('ended')"
|
||||
/>
|
||||
<Audio v-bind="$attrs" ref="instance" v-if="props.article.audioSrc" :src="props.article.audioSrc"
|
||||
@ended="emit('ended')" @update-volume="handleVolumeUpdate" @update-speed="handleSpeedUpdate" />
|
||||
<Audio v-bind="$attrs" ref="instance" v-else-if="file" :src="file" @ended="emit('ended')"
|
||||
@update-volume="handleVolumeUpdate" @update-speed="handleSpeedUpdate" />
|
||||
</template>
|
||||
|
||||
@@ -12,8 +12,8 @@ import { Option, Select } from "@/components/base/select";
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
import Form from "@/components/base/form/Form.vue";
|
||||
import FormItem from "@/components/base/form/FormItem.vue";
|
||||
import { CAN_REQUEST } from "@/config/env.ts";
|
||||
import { addDict } from "@/apis";
|
||||
import { AppEnv } from "@/config/env.ts";
|
||||
|
||||
const props = defineProps<{
|
||||
isAdd: boolean,
|
||||
@@ -58,7 +58,7 @@ async function onSubmit() {
|
||||
Toast.warning('已有相同名称!')
|
||||
return
|
||||
} else {
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
loading = true
|
||||
let res = await addDict(null, data)
|
||||
loading = false
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, onMounted, onUnmounted, watch } from "vue"
|
||||
import {inject, onMounted, onUnmounted, watch} from "vue"
|
||||
import {Article, ArticleWord, PracticeArticleWordType, Sentence, ShortcutKey, Word} from "@/types/types.ts";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { usePlayBeep, usePlayCorrect, usePlayKeyboardAudio } from "@/hooks/sound.ts";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio} from "@/hooks/sound.ts";
|
||||
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
|
||||
import { _dateFormat, _nextTick, msToHourMinute, msToMinute, total } from "@/utils";
|
||||
import {_dateFormat, _nextTick, msToHourMinute, total} from "@/utils";
|
||||
import '@imengyu/vue3-context-menu/lib/vue3-context-menu.css'
|
||||
import ContextMenu from '@imengyu/vue3-context-menu'
|
||||
import { getTranslateText } from "@/hooks/article.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import QuestionForm from "@/pages/article/components/QuestionForm.vue";
|
||||
import { getDefaultArticle, getDefaultWord } from "@/types/func.ts";
|
||||
import {getDefaultArticle, getDefaultWord} from "@/types/func.ts";
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import TypingWord from "@/pages/article/components/TypingWord.vue";
|
||||
import Space from "@/pages/article/components/Space.vue";
|
||||
import { useWordOptions } from "@/hooks/dict.ts";
|
||||
import {useWordOptions} from "@/hooks/dict.ts";
|
||||
import nlp from "compromise/three";
|
||||
import { nanoid } from "nanoid";
|
||||
import { usePracticeStore } from "@/stores/practice.ts";
|
||||
import { PracticeSaveArticleKey } from "@/config/env.ts";
|
||||
import {nanoid} from "nanoid";
|
||||
import {usePracticeStore} from "@/stores/practice.ts";
|
||||
import {PracticeSaveArticleKey} from "@/config/env.ts";
|
||||
import useMobile from "@/hooks/useMobile.ts";
|
||||
|
||||
interface IProps {
|
||||
@@ -291,6 +290,7 @@ function nextSentence() {
|
||||
}
|
||||
|
||||
function onTyping(e: KeyboardEvent) {
|
||||
debugger
|
||||
if (!props.article.sections.length) return
|
||||
if (isTyping || isEnd) return;
|
||||
isTyping = true;
|
||||
@@ -308,7 +308,12 @@ function onTyping(e: KeyboardEvent) {
|
||||
// 检查下一个单词是否存在
|
||||
if (wordIndex + 1 < currentSentence.words.length) {
|
||||
wordIndex++;
|
||||
emit('nextWord', currentWord);
|
||||
currentWord = currentSentence.words[wordIndex]
|
||||
if ([PracticeArticleWordType.Symbol,PracticeArticleWordType.Number].includes(currentWord.type) && settingStore.ignoreSymbol){
|
||||
next()
|
||||
}else {
|
||||
emit('nextWord', currentWord);
|
||||
}
|
||||
} else {
|
||||
nextSentence()
|
||||
}
|
||||
@@ -318,12 +323,16 @@ function onTyping(e: KeyboardEvent) {
|
||||
if (e.code === 'Space') {
|
||||
next()
|
||||
} else {
|
||||
wrong = ' '
|
||||
playBeep()
|
||||
setTimeout(() => {
|
||||
wrong = ''
|
||||
wrong = input = ''
|
||||
}, 500)
|
||||
// 如果在第一个单词的最后一位上, 不按空格的直接输入下一个字母的话
|
||||
next()
|
||||
isTyping = false
|
||||
onTyping(e)
|
||||
// wrong = ' '
|
||||
// playBeep()
|
||||
// setTimeout(() => {
|
||||
// wrong = ''
|
||||
// wrong = input = ''
|
||||
// }, 500)
|
||||
}
|
||||
} else {
|
||||
//如果是首句首词
|
||||
@@ -473,8 +482,8 @@ function onContextMenu(e: MouseEvent, sentence: Sentence, i, j, w) {
|
||||
label: "收藏单词",
|
||||
onClick: () => {
|
||||
let word = props.article.sections[i][j].words[w]
|
||||
let doc = nlp(word.word)
|
||||
let text = word.word
|
||||
let doc = nlp(text)
|
||||
// 优先判断是不是动词
|
||||
if (doc.verbs().found) {
|
||||
text = doc.verbs().toInfinitive().text()
|
||||
@@ -702,7 +711,7 @@ const currentPractice = inject('currentPractice', [])
|
||||
<span :class="i === currentPractice.length-1 ? 'color-red':'color-gray'"
|
||||
>{{
|
||||
i === currentPractice.length - 1 ? '当前' : i + 1
|
||||
}}. {{ _dateFormat(item.startDate, 'YYYY/MM/DD HH:mm') }}</span>
|
||||
}}. {{ _dateFormat(item.startDate) }}</span>
|
||||
<span>{{ msToHourMinute(item.spend) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {GITHUB, ProjectName} from "@/config/env.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {defineAsyncComponent} from "vue";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
let showWechatDialog = $ref(false)
|
||||
let showXhsDialog = $ref(false)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between min-h-screen">
|
||||
<div class="center flex-col gap-8">
|
||||
<h1>{{ ProjectName }}</h1>
|
||||
<div class="text-center -mt-10">
|
||||
<h2>学习英语,一次敲击,一点进步</h2>
|
||||
<h2>记忆不再盲目,学习更高效,开源单词与文章练习工具</h2>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<BaseButton size="large" @click="$router.push('/words')">单词练习</BaseButton>
|
||||
<BaseButton size="large" @click="$router.push('/articles')">文章练习</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="center justify-center flex-col gap-2 w-full mb-4">
|
||||
<a href="https://skywork.ai/p/GrXQb4" class="w-60vw" target="_blank"><img src="/skywork-ai.png" alt="Skywork.AI" class="w-full rounded-lg"></a>
|
||||
<span>Skywork.AI:<a href="https://skywork.ai/p/GrXQb4" class="color-blue!" target="_blank">10 tasks in 1 hour, not 10 hours →Limited free spots: 127 left</a></span>
|
||||
</div>
|
||||
<div class="w-60vw">
|
||||
<div class="flex mb-5 gap-space">
|
||||
<div class="card">
|
||||
<div class="emoji">📚</div>
|
||||
<div class="title">单词练习</div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>三种输入模式:跟打 / 复习 / 默写</li>
|
||||
<li>智能模式:智能规划复习与默写</li>
|
||||
<li>自由模式:不受限制,自行规划</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="emoji">✍️</div>
|
||||
<div class="title">文章练习</div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>内置常见书籍,也可自行添加文章</li>
|
||||
<li>跟打 + 默写双模式,让背诵更高效</li>
|
||||
<li>支持边听边默写,强化记忆</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="emoji">📕</div>
|
||||
<div class="title">收藏、错词本、已掌握</div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>输入错误自动添加到错词本</li>
|
||||
<li>主动添加到已掌握,后续自动跳过</li>
|
||||
<li>主动添加到收藏中,以便巩固复习</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="emoji">🌐</div>
|
||||
<div class="title">海量词库</div>
|
||||
<div class="desc">
|
||||
内置小学、初中、高中、四六级、考研、雅思、托福、GRE、GMAT、SAT、BEC、专四、专八等词库
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex gap-space">
|
||||
<div class="card">
|
||||
<div class="emoji">🆓</div>
|
||||
<div class="title">免费开源</div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>完全开源,可审查、可修改</li>
|
||||
<li>免费使用</li>
|
||||
<li>私有部署</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="emoji">⚙️</div>
|
||||
<div class="title">高度自由</div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>丰富的键盘音效</li>
|
||||
<li>可自定义快捷键</li>
|
||||
<li>高度定制化的设置选项</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="emoji">🎨</div>
|
||||
<div class="title">简洁高效</div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>简洁设计,现代化UI,无广告</li>
|
||||
<li>界面清爽,操作简单</li>
|
||||
<li>不强制关注任何平台</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="emoji">🎯</div>
|
||||
<div class="title">个性学习</div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>自由添加词典与文章</li>
|
||||
<li>定制个性学习计划</li>
|
||||
<li>多种学习复习策略</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-60vw text-center" v-if="false">
|
||||
<h3 class="text-4xl">单词练习</h3>
|
||||
<img src="/word.png" alt="word.png" class="w-full rounded-xl">
|
||||
<h3 class="text-4xl">文章练习</h3>
|
||||
<img src="/article.png" alt="article.png" class="w-full rounded-xl">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="center gap-space my-10 bottom">
|
||||
<div class="center gap-1">
|
||||
<a
|
||||
:href="GITHUB"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="GITHUB 项目地址">
|
||||
<BaseIcon>
|
||||
<IconSimpleIconsGithub/>
|
||||
</BaseIcon>
|
||||
</a>
|
||||
|
||||
<BaseIcon @click="showWechatDialog = true">
|
||||
<IconSimpleIconsWechat/>
|
||||
</BaseIcon>
|
||||
<BaseIcon @click="showXhsDialog = true" >
|
||||
<IconSimpleIconsXiaohongshu/>
|
||||
</BaseIcon>
|
||||
<a
|
||||
href="https://x.com/typewords2"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="关注我的 X 账户 typewords2">
|
||||
<BaseIcon>
|
||||
<IconRiTwitterFill/>
|
||||
</BaseIcon>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:zyronon@163.com"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label="发送邮件到 zyronon@163.com">
|
||||
<BaseIcon>
|
||||
<IconMaterialSymbolsMail/>
|
||||
</BaseIcon>
|
||||
</a>
|
||||
</div>
|
||||
<div>蜀ICP备2025157466号</div>
|
||||
</div>
|
||||
|
||||
<Dialog v-model="showWechatDialog" title="Type Words 交流群">
|
||||
<div class="w-120 p-6 pt-0">
|
||||
<div class="mb-4">
|
||||
加入我们的用户社群后,您可以与我们的开发团队进行沟通,分享您的使用体验和建议,帮助我们改进产品,同时也能够及时了解我们的最新动态和更新内容。
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<img src="/wechat.png" alt="微信群二维码" class="w-60 rounded-lg">
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<Dialog v-model="showXhsDialog" title="小红书">
|
||||
<div class="w-120 p-6 pt-0">
|
||||
<div class="mb-4">
|
||||
关注小红书后,您可以获得开发团队的最新动态和更新内容,反馈您的使用体验和建议,帮助我们改进产品,同时也能够及时了解我们的最新动态和更新内容。
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<img src="/xhs.png" alt="小红书二维码" class="w-60 rounded-lg">
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
h1 {
|
||||
font-size: 5rem;
|
||||
background: linear-gradient(120deg, #bd34fe 30%, #41d1ff);
|
||||
-webkit-text-fill-color: transparent;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
margin: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply flex flex-col items-start gap-2 mb-0 w-25%;
|
||||
.emoji {
|
||||
display: inline-block;
|
||||
background: var(--color-third);
|
||||
padding: .6rem;
|
||||
border-radius: 0.4rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bottom {
|
||||
width: 100%;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #c4c4c4;
|
||||
}
|
||||
|
||||
a {
|
||||
color: unset;
|
||||
}
|
||||
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
h1 {
|
||||
font-size: 3rem;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.flex.gap-space {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.card {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.w-60vw {
|
||||
width: 90vw;
|
||||
}
|
||||
|
||||
.center.gap-space {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
padding-top: 1rem;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -8,13 +8,15 @@ import useTheme from "@/hooks/theme.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const router = useRouter()
|
||||
const {toggleTheme,getTheme} = useTheme()
|
||||
|
||||
const {toggleTheme, getTheme} = useTheme()
|
||||
|
||||
//首页为了seo被剥离出去了,现在是一个静态页面,用nginx 重定向控制对应的跳转
|
||||
function goHome() {
|
||||
window.location.href = '/';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -24,7 +26,7 @@ const {toggleTheme,getTheme} = useTheme()
|
||||
<div class="aside anim fixed" :class="{'expand':settingStore.sideExpand}">
|
||||
<div class="top">
|
||||
<Logo v-if="settingStore.sideExpand"/>
|
||||
<div class="row" @click="router.push('/')">
|
||||
<div class="row" @click="goHome">
|
||||
<IconFluentHome20Regular/>
|
||||
<span v-if="settingStore.sideExpand">主页</span>
|
||||
</div>
|
||||
@@ -49,14 +51,14 @@ const {toggleTheme,getTheme} = useTheme()
|
||||
</div>
|
||||
<div class="bottom flex justify-evenly ">
|
||||
<BaseIcon
|
||||
@click="settingStore.sideExpand = !settingStore.sideExpand">
|
||||
@click="settingStore.sideExpand = !settingStore.sideExpand">
|
||||
<IconFluentChevronLeft20Filled v-if="settingStore.sideExpand"/>
|
||||
<IconFluentChevronLeft20Filled class="transform-rotate-180" v-else/>
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
v-if="settingStore.sideExpand"
|
||||
:title="`切换主题(${settingStore.shortcutKeyMap[ShortcutKey.ToggleTheme]})`"
|
||||
@click="toggleTheme"
|
||||
v-if="settingStore.sideExpand"
|
||||
:title="`切换主题(${settingStore.shortcutKeyMap[ShortcutKey.ToggleTheme]})`"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<IconFluentWeatherMoon16Regular v-if="getTheme() === 'light'"/>
|
||||
<IconFluentWeatherSunny16Regular v-else/>
|
||||
@@ -1,17 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from "vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { getAudioFileUrl, usePlayAudio } from "@/hooks/sound.ts";
|
||||
import { getShortcutKey, useEventListener } from "@/hooks/event.ts";
|
||||
import { checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, loadJsLib, shakeCommonDict } from "@/utils";
|
||||
import {nextTick, ref, watch} from "vue";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {getAudioFileUrl, usePlayAudio} from "@/hooks/sound.ts";
|
||||
import {getShortcutKey, useEventListener} from "@/hooks/event.ts";
|
||||
import {checkAndUpgradeSaveDict, checkAndUpgradeSaveSetting, cloneDeep, loadJsLib, shakeCommonDict} from "@/utils";
|
||||
import {DefaultShortcutKeyMap, ShortcutKey, WordPracticeMode} from "@/types/types.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { saveAs } from "file-saver";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import {saveAs} from "file-saver";
|
||||
import {
|
||||
APP_NAME, APP_VERSION,
|
||||
EXPORT_DATA_KEY,
|
||||
APP_NAME, APP_VERSION, EMAIL,
|
||||
EXPORT_DATA_KEY, GITHUB,
|
||||
LOCAL_FILE_KEY,
|
||||
Origin,
|
||||
PracticeSaveArticleKey,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import dayjs from "dayjs";
|
||||
import BasePage from "@/components/BasePage.vue";
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import { Option, Select } from "@/components/base/select";
|
||||
import {Option, Select} from "@/components/base/select";
|
||||
import Switch from "@/components/base/Switch.vue";
|
||||
import Slider from "@/components/base/Slider.vue";
|
||||
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
|
||||
@@ -29,8 +29,9 @@ import InputNumber from "@/components/base/InputNumber.vue";
|
||||
import PopConfirm from "@/components/PopConfirm.vue";
|
||||
import Textarea from "@/components/base/Textarea.vue";
|
||||
import SettingItem from "@/pages/setting/SettingItem.vue";
|
||||
import { get, set } from "idb-keyval";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
import {get, set} from "idb-keyval";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import {useUserStore} from "@/stores/auth.ts";
|
||||
|
||||
const emit = defineEmits<{
|
||||
toggleDisabledDialogEscKey: [val: boolean]
|
||||
@@ -40,6 +41,8 @@ const tabIndex = $ref(0)
|
||||
const settingStore = useSettingStore()
|
||||
const runtimeStore = useRuntimeStore()
|
||||
const store = useBaseStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
//@ts-ignore
|
||||
const gitLastCommitHash = ref(LATEST_COMMIT_HASH);
|
||||
const simpleWords = $computed({
|
||||
@@ -95,7 +98,7 @@ useEventListener('keydown', (e: KeyboardEvent) => {
|
||||
} else {
|
||||
// 忽略单独的修饰键
|
||||
if (shortcutKey === 'Ctrl+' || shortcutKey === 'Alt+' || shortcutKey === 'Shift+' ||
|
||||
e.key === 'Control' || e.key === 'Alt' || e.key === 'Shift') {
|
||||
e.key === 'Control' || e.key === 'Alt' || e.key === 'Shift') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -424,8 +427,8 @@ function importOldData() {
|
||||
v-if="settingStore.ignoreSimpleWord"
|
||||
>
|
||||
<Textarea
|
||||
placeholder="多个单词用英文逗号隔号"
|
||||
v-model="simpleWords" :autosize="{minRows: 6, maxRows: 10}"/>
|
||||
placeholder="多个单词用英文逗号隔号"
|
||||
v-model="simpleWords" :autosize="{minRows: 6, maxRows: 10}"/>
|
||||
</SettingItem>
|
||||
|
||||
<!-- 音效-->
|
||||
@@ -453,16 +456,16 @@ function importOldData() {
|
||||
class="w-50!"
|
||||
>
|
||||
<Option
|
||||
v-for="item in SoundFileOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
v-for="item in SoundFileOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
>
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<span>{{ item.label }}</span>
|
||||
<VolumeIcon
|
||||
:time="100"
|
||||
@click="usePlayAudio(getAudioFileUrl(item.value)[0])"/>
|
||||
:time="100"
|
||||
@click="usePlayAudio(getAudioFileUrl(item.value)[0])"/>
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
@@ -554,7 +557,7 @@ function importOldData() {
|
||||
<div class="line"></div>
|
||||
<SettingItem mainTitle="自动切换"/>
|
||||
<SettingItem title="自动切换下一个单词"
|
||||
desc="未开启自动切换时,当输入完成后请使用 **空格键** 切换下一个"
|
||||
desc="仅在 **跟写** 时生效,听写、辨认、默写均不会自动切换,需要手动按 **空格键** 切换"
|
||||
>
|
||||
<Switch v-model="settingStore.autoNextWord"/>
|
||||
</SettingItem>
|
||||
@@ -580,16 +583,16 @@ function importOldData() {
|
||||
<SettingItem mainTitle="字体设置"/>
|
||||
<SettingItem title="外语字体">
|
||||
<Slider
|
||||
:min="10"
|
||||
:max="100"
|
||||
v-model="settingStore.fontSize.wordForeignFontSize"/>
|
||||
:min="10"
|
||||
:max="100"
|
||||
v-model="settingStore.fontSize.wordForeignFontSize"/>
|
||||
<span class="w-10 pl-5">{{ settingStore.fontSize.wordForeignFontSize }}px</span>
|
||||
</SettingItem>
|
||||
<SettingItem title="中文字体">
|
||||
<Slider
|
||||
:min="10"
|
||||
:max="100"
|
||||
v-model="settingStore.fontSize.wordTranslateFontSize"/>
|
||||
:min="10"
|
||||
:max="100"
|
||||
v-model="settingStore.fontSize.wordTranslateFontSize"/>
|
||||
<span class="w-10 pl-5">{{ settingStore.fontSize.wordTranslateFontSize }}px</span>
|
||||
</SettingItem>
|
||||
</div>
|
||||
@@ -618,8 +621,12 @@ function importOldData() {
|
||||
<Slider v-model="settingStore.articleSoundSpeed" :step="0.1" :min="0.5" :max="3"/>
|
||||
<span class="w-10 pl-5">{{ settingStore.articleSoundSpeed }}</span>
|
||||
</SettingItem>
|
||||
</div>
|
||||
|
||||
<div class="line"></div>
|
||||
<SettingItem title="输入时忽略符号/数字">
|
||||
<Switch v-model="settingStore.ignoreSymbol"/>
|
||||
</SettingItem>
|
||||
</div>
|
||||
|
||||
<div class="body" v-if="tabIndex === 3">
|
||||
<div class="row">
|
||||
@@ -634,7 +641,7 @@ function importOldData() {
|
||||
<input ref="shortcutInput" :value="item[1]?item[1]:'未设置快捷键'" readonly type="text"
|
||||
@blur="handleInputBlur">
|
||||
<span @click.stop="editShortcutKey = ''">按键盘进行设置,<span
|
||||
class="text-red!">设置完成点击这里</span></span>
|
||||
class="text-red!">设置完成点击这里</span></span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-if="item[1]">{{ item[1] }}</div>
|
||||
@@ -671,25 +678,73 @@ function importOldData() {
|
||||
@change="importData">
|
||||
</div>
|
||||
<PopConfirm
|
||||
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
|
||||
@confirm="importOldData">
|
||||
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
|
||||
@confirm="importOldData">
|
||||
<BaseButton>老版本数据导入</BaseButton>
|
||||
</PopConfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tabIndex === 5">
|
||||
<div class="item p-2">
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>更新日期:2025/10/26</div>
|
||||
<div>更新内容:进一步完善单词练习,解决复习数量太多的问题</div>
|
||||
<div>日期:2025/11/16</div>
|
||||
<div>内容:辨认单词时,不认识单词可以直接输入,自动标识为错误单词,无需按2</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/15</div>
|
||||
<div>内容:练习单词时,底部工具栏新增“跳到下一阶段”按钮</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/14</div>
|
||||
<div>内容:新增文章练习时可跳过空格:如果在单词的最后一位上,不按空格直接输入下一个字母的话,自动跳下一个单词, 按空格也自动跳下一个单词</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/13</div>
|
||||
<div>内容:新增文章练习时“输入时忽略符号/数字”选项</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/11/6</div>
|
||||
<div>内容:新增随机复习功能</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/10/30</div>
|
||||
<div>内容:集成PWA基础配置,支持用户以类App形式打开项目</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/10/26</div>
|
||||
<div>内容:进一步完善单词练习,解决复习数量太多的问题</div>
|
||||
</div>
|
||||
<div class="text-base mt-1">
|
||||
<ol>
|
||||
<li>
|
||||
<div class="title"><b>智能模式优化</b></div>
|
||||
<div class="desc">练习时新增四种练习模式:学习、复习、听写、默写。</div>
|
||||
<div class="desc">练习时新增四种练习模式:学习、辨认、听写、默写。</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="title"><b>学习模式</b></div>
|
||||
@@ -702,7 +757,7 @@ function importOldData() {
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<div class="title"><b>复习模式(新增)</b></div>
|
||||
<div class="title"><b>辨认模式(新增)</b></div>
|
||||
<div class="desc">
|
||||
<ul>
|
||||
<li>仅在复习已学单词时出现。</li>
|
||||
@@ -730,13 +785,20 @@ function importOldData() {
|
||||
<div>通过引入「复习」与「默写」两种模式,使复习流程更加灵活、高效。</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div class="item p-2">
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>更新日期:2025/9/14</div>
|
||||
<div>更新内容:完善文章编辑、导入、导出等功能</div>
|
||||
<div>日期:2025/10/8</div>
|
||||
<div>内容:文章支持自动播放下一篇</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/9/14</div>
|
||||
<div>内容:完善文章编辑、导入、导出等功能</div>
|
||||
</div>
|
||||
<div class="text-base mt-1">
|
||||
<div>1、文章的音频管理功能,目前已可添加音频、设置句子与音频的对应位置</div>
|
||||
@@ -744,24 +806,61 @@ function importOldData() {
|
||||
<div>3、单词可导入、导出</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/8/10</div>
|
||||
<div>内容:2.0版本发布,全新UI,全新逻辑,新增短语、例句、近义词等功能</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="log-item">
|
||||
<div class="mb-2">
|
||||
<div>
|
||||
<div>日期:2025/7/19</div>
|
||||
<div>内容:1.0版本发布</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="tabIndex === 6" class="center flex-col">
|
||||
<h1>Type Words</h1>
|
||||
|
||||
<!-- 用户信息部分 -->
|
||||
<div v-if="userStore.isLoggedIn && userStore.user" class="user-info-section mb-6">
|
||||
<div class="user-avatar mb-4">
|
||||
<img v-if="userStore.user.avatar" :src="userStore.user.avatar" alt="头像" class="avatar-img"/>
|
||||
<div v-else class="avatar-placeholder">
|
||||
{{ userStore.user.nickname?.charAt(0) || 'U' }}
|
||||
</div>
|
||||
</div>
|
||||
<h3 class="mb-2">{{ userStore.user.nickname || '用户' }}</h3>
|
||||
<p v-if="userStore.user.email" class="text-sm color-gray mb-1">{{ userStore.user.email }}</p>
|
||||
<p v-if="userStore.user.phone" class="text-sm color-gray">{{ userStore.user.phone }}</p>
|
||||
|
||||
<BaseButton
|
||||
@click="userStore.logout"
|
||||
type="info"
|
||||
class="mt-4"
|
||||
:loading="userStore.isLoading"
|
||||
>
|
||||
退出登录
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<p class="w-100 text-xl">
|
||||
感谢使用本项目!本项目是开源项目,如果觉得有帮助,请在 GitHub 点个 Star,您的支持是我持续改进的动力。
|
||||
</p>
|
||||
<p>
|
||||
GitHub地址:<a href="https://github.com/zyronon/TypeWords" target="_blank">https://github.com/zyronon/TypeWords</a>
|
||||
GitHub地址:<a :href="GITHUB" target="_blank">{{ GITHUB }}</a>
|
||||
</p>
|
||||
<p>
|
||||
反馈:<a
|
||||
href="https://github.com/zyronon/TypeWords/issues" target="_blank">https://github.com/zyronon/TypeWords/issues</a>
|
||||
反馈:<a :href="`${GITHUB}/issues`" target="_blank">{{ GITHUB }}/issues</a>
|
||||
</p>
|
||||
<p>
|
||||
作者邮箱:<a href="mailto:zyronon@163.com">zyronon@163.com</a>
|
||||
作者邮箱:<a :href="`mailto:${EMAIL}`">{{ EMAIL }}</a>
|
||||
</p>
|
||||
<div class="text-md color-gray mt-10">
|
||||
Build {{ gitLastCommitHash }}
|
||||
@@ -775,6 +874,80 @@ function importOldData() {
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.log-item {
|
||||
border-bottom: 1px solid var(--color-input-border);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
// 用户信息样式
|
||||
.user-info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
|
||||
.user-avatar {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 3px solid var(--color-select-bg);
|
||||
|
||||
.avatar-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
color: var(--color-font-1);
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.9rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.color-gray {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.mb-1 {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mb-2 {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mb-4 {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.mt-4 {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.setting {
|
||||
@apply text-lg;
|
||||
display: flex;
|
||||
|
||||
66
src/pages/user/Code.vue
Normal file
66
src/pages/user/Code.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {CodeType} from "@/types/types.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {sendCode} from "@/apis/user.ts";
|
||||
import {PHONE_CONFIG} from "@/config/auth.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
|
||||
let isSendingCode = $ref(false)
|
||||
let codeCountdown = $ref(0)
|
||||
|
||||
interface IProps {
|
||||
validateField: Function,
|
||||
type: CodeType
|
||||
val: any
|
||||
size?: any
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<IProps>(), {
|
||||
size: 'large',
|
||||
})
|
||||
|
||||
// 发送验证码
|
||||
async function sendVerificationCode() {
|
||||
let res = props.validateField()
|
||||
if (res) {
|
||||
try {
|
||||
isSendingCode = true
|
||||
const res = await sendCode({val: props.val, type: props.type})
|
||||
if (res.success) {
|
||||
codeCountdown = PHONE_CONFIG.sendInterval
|
||||
const timer = setInterval(() => {
|
||||
codeCountdown--
|
||||
if (codeCountdown <= 0) {
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, 1000)
|
||||
} else {
|
||||
Toast.error(res.msg || '发送失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Send code error:', error)
|
||||
Toast.error('发送验证码失败')
|
||||
} finally {
|
||||
isSendingCode = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseButton
|
||||
@click="sendVerificationCode"
|
||||
:disabled="isSendingCode || codeCountdown > 0"
|
||||
type="info"
|
||||
:size="props.size"
|
||||
style="border: 1px solid var(--color-input-border)"
|
||||
>
|
||||
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '发送验证码') }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
15
src/pages/user/Notice.vue
Normal file
15
src/pages/user/Notice.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-12 text-xs text-gray-400">
|
||||
<span>
|
||||
继续操作即表示你阅读并同意我们的
|
||||
<a href="/user-agreement.html" target="_blank" class="link">用户协议</a>
|
||||
与
|
||||
<a href="/privacy-policy.html" target="_blank" class="link">隐私政策</a>
|
||||
</span>
|
||||
<slot/>
|
||||
</div>
|
||||
</template>
|
||||
633
src/pages/user/User.vue
Normal file
633
src/pages/user/User.vue
Normal file
@@ -0,0 +1,633 @@
|
||||
<script setup lang="ts">
|
||||
import {onMounted} from 'vue'
|
||||
import {useUserStore} from '@/stores/auth.ts'
|
||||
import {useRouter} from 'vue-router'
|
||||
import BaseInput from '@/components/base/BaseInput.vue'
|
||||
import BasePage from "@/components/BasePage.vue";
|
||||
import {APP_NAME, EMAIL, GITHUB} from "@/config/env.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
|
||||
import {changeEmailApi, changePhoneApi, setPassword, updateUserInfoApi, User} from "@/apis/user.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import {CodeType} from "@/types/types.ts";
|
||||
import FormItem from "@/components/base/form/FormItem.vue";
|
||||
import Form from "@/components/base/form/Form.vue";
|
||||
import {FormInstance} from "@/components/base/form/types.ts";
|
||||
import {codeRules, emailRules, passwordRules, phoneRules} from "@/utils/validation.ts";
|
||||
import {_dateFormat, cloneDeep} from "@/utils";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import Code from "@/pages/user/Code.vue";
|
||||
import {MessageBox} from "@/utils/MessageBox.tsx";
|
||||
|
||||
const userStore = useUserStore()
|
||||
const router = useRouter()
|
||||
|
||||
let showChangePwd = $ref(false)
|
||||
let showChangeEmail = $ref(false)
|
||||
let showChangeUsername = $ref(false)
|
||||
let showChangePhone = $ref(false)
|
||||
let loading = $ref(false)
|
||||
|
||||
const handleLogout = () => {
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
const contactSupport = () => {
|
||||
console.log('Contact support')
|
||||
}
|
||||
|
||||
const goIssues = () => {
|
||||
window.open(GITHUB + '/issues', '_blank')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
userStore.fetchUserInfo()
|
||||
})
|
||||
|
||||
// 修改手机号
|
||||
// 修改手机号
|
||||
// 修改手机号
|
||||
let changePhoneFormRef = $ref<FormInstance>()
|
||||
let defaultFrom = {oldCode: '', phone: '', code: '', pwd: '',}
|
||||
let changePhoneForm = $ref(cloneDeep(defaultFrom))
|
||||
let changePhoneFormRules = {
|
||||
oldCode: codeRules,
|
||||
phone: [...phoneRules, {
|
||||
validator: (rule: any, value: any) => {
|
||||
if (userStore.user?.phone && value === userStore.user?.phone) {
|
||||
throw new Error('新手机号与原手机号一致')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},],
|
||||
code: codeRules,
|
||||
pwd: passwordRules
|
||||
}
|
||||
|
||||
function showChangePhoneForm() {
|
||||
showChangePhone = showChangeUsername = showChangeEmail = showChangePwd = false
|
||||
showChangePhone = true
|
||||
changePhoneForm = cloneDeep(defaultFrom)
|
||||
}
|
||||
|
||||
function changePhone() {
|
||||
changePhoneFormRef.validate(async valid => {
|
||||
if (valid) {
|
||||
try {
|
||||
loading = true
|
||||
const res = await changePhoneApi(changePhoneForm)
|
||||
if (res.success) {
|
||||
Toast.success('修改成功')
|
||||
await userStore.fetchUserInfo()
|
||||
showChangePhone = false
|
||||
} else {
|
||||
Toast.error(res.msg || '修改失败')
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error || '修改失败,请重试')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修改用户名
|
||||
// 修改用户名
|
||||
// 修改用户名
|
||||
let changeUsernameFormRef = $ref<FormInstance>()
|
||||
let changeUsernameForm = $ref({username: ''})
|
||||
let changeUsernameFormRules = {
|
||||
username: [{required: true, message: '请输入用户名', trigger: 'blur'}],
|
||||
}
|
||||
|
||||
function showChangeUsernameForm() {
|
||||
showChangePhone = showChangeUsername = showChangeEmail = showChangePwd = false
|
||||
showChangeUsername = true
|
||||
changeUsernameForm = cloneDeep({username: userStore.user?.username ?? '',})
|
||||
}
|
||||
|
||||
function changeUsername() {
|
||||
changeUsernameFormRef.validate(async valid => {
|
||||
if (valid) {
|
||||
try {
|
||||
loading = true
|
||||
const res = await updateUserInfoApi(changeUsernameForm)
|
||||
if (res.success) {
|
||||
Toast.success('修改成功')
|
||||
await userStore.fetchUserInfo()
|
||||
showChangeUsername = false
|
||||
} else {
|
||||
Toast.error(res.msg || '修改失败')
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error || '修改失败,请重试')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修改邮箱
|
||||
// 修改邮箱
|
||||
// 修改邮箱
|
||||
let changeEmailFormRef = $ref<FormInstance>()
|
||||
|
||||
let changeEmailForm = $ref({
|
||||
email: '',
|
||||
pwd: '',
|
||||
code: '',
|
||||
})
|
||||
let changeEmailFormRules = {
|
||||
email: [
|
||||
...emailRules, {
|
||||
validator: (rule: any, value: any) => {
|
||||
if (userStore.user?.email && value === userStore.user?.email) {
|
||||
throw new Error('该邮箱与当前一致')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
}
|
||||
],
|
||||
pwd: passwordRules,
|
||||
code: codeRules,
|
||||
}
|
||||
|
||||
function showChangeEmailForm() {
|
||||
showChangePhone = showChangeUsername = showChangeEmail = showChangePwd = false
|
||||
showChangeEmail = true
|
||||
changeEmailForm = cloneDeep({email: userStore.user?.email ?? '', pwd: '', code: '',})
|
||||
}
|
||||
|
||||
function changeEmail() {
|
||||
changeEmailFormRef.validate(async valid => {
|
||||
if (valid) {
|
||||
try {
|
||||
loading = true
|
||||
const res = await changeEmailApi(changeEmailForm)
|
||||
if (res.success) {
|
||||
Toast.success('修改成功')
|
||||
await userStore.fetchUserInfo()
|
||||
showChangeEmail = false
|
||||
} else {
|
||||
Toast.error(res.msg || '修改失败')
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error || '修改失败,请重试')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
// 修改密码
|
||||
// 修改密码
|
||||
let changePwdFormRef = $ref<FormInstance>()
|
||||
const defaultChangePwdForm = {
|
||||
oldPwd: '',
|
||||
newPwd: '',
|
||||
confirmPwd: '',
|
||||
}
|
||||
let changePwdForm = $ref(cloneDeep(defaultChangePwdForm))
|
||||
let changePwdFormRules = {
|
||||
oldPwd: passwordRules,
|
||||
newPwd: passwordRules,
|
||||
confirmPwd: [
|
||||
{required: true, message: '请再次输入新密码', trigger: 'blur'},
|
||||
{
|
||||
validator: (rule: any, value: any) => {
|
||||
if (value !== changePwdForm.newPwd) {
|
||||
throw new Error('两次密码输入不一致')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function showChangePwdForm() {
|
||||
showChangePhone = showChangeUsername = showChangeEmail = showChangePwd = false
|
||||
showChangePwd = true
|
||||
changePwdForm = cloneDeep(defaultChangePwdForm)
|
||||
}
|
||||
|
||||
function changePwd() {
|
||||
changePwdFormRef.validate(async valid => {
|
||||
if (valid) {
|
||||
try {
|
||||
loading = true
|
||||
const res = await setPassword(changePwdForm)
|
||||
if (res.success) {
|
||||
Toast.success('密码设置成功,请重新登录')
|
||||
showChangePwd = false
|
||||
userStore.logout()
|
||||
} else {
|
||||
Toast.error(res.msg || '设置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error || '设置密码失败,请重试')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const member = $computed<User['member']>(() => userStore.user?.member ?? {} as any)
|
||||
|
||||
const memberEndDate = $computed(() => {
|
||||
if (member?.endDate === null) return '永久'
|
||||
return member?.endDate
|
||||
})
|
||||
|
||||
function subscribe() {
|
||||
router.push('/vip')
|
||||
|
||||
}
|
||||
|
||||
function onFileChange(e) {
|
||||
console.log('e', e)
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePage>
|
||||
<!-- Unauthenticated View -->
|
||||
<div v-if="!userStore.isLogin" class="center h-screen">
|
||||
<div class="card-white text-center flex-col gap-6 w-110">
|
||||
<div class="w-20 h-20 bg-blue-100 rounded-full center mx-auto">
|
||||
<IconFluentPerson20Regular class="text-3xl text-blue-600"/>
|
||||
</div>
|
||||
<h1 class="text-2xl font-bold">
|
||||
<IconFluentHandWave20Regular class="text-xl translate-y-1 mr-2 shrink-0"/>
|
||||
<span>欢迎使用</span>
|
||||
</h1>
|
||||
<p class="">登录,开启您的学习之旅</p>
|
||||
<div>保存进度、同步数据、解锁个性化内容</div>
|
||||
<BaseButton
|
||||
@click="router.push('/login')"
|
||||
size="large"
|
||||
class="w-full mt-4"
|
||||
>
|
||||
登录
|
||||
</BaseButton>
|
||||
<p class="text-sm text-gray-500">
|
||||
还没有账户?
|
||||
<router-link to="/login?register=1" class="line">立即注册</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated View -->
|
||||
<div v-else class="w-full flex gap-4">
|
||||
<!-- Main Account Settings -->
|
||||
<div class="card-white flex-1 flex flex-col gap-2 px-6">
|
||||
<h1 class="text-2xl font-bold mt-0">帐户</h1>
|
||||
|
||||
<!-- 用户名-->
|
||||
<div class="item">
|
||||
<div class="flex-1">
|
||||
<div class="mb-2">用户名</div>
|
||||
<div class="flex items-center gap-2" v-if="userStore.user?.username">
|
||||
<IconFluentPerson20Regular class="text-base"/>
|
||||
<span>{{ userStore.user?.username }}</span>
|
||||
</div>
|
||||
<div v-else class="text-xs">在此设置用户名</div>
|
||||
</div>
|
||||
<BaseIcon @click="showChangeUsernameForm">
|
||||
<IconFluentTextEditStyle20Regular/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
<div v-if="showChangeUsername">
|
||||
<Form
|
||||
ref="changeUsernameFormRef"
|
||||
:rules="changeUsernameFormRules"
|
||||
:model="changeUsernameForm">
|
||||
<FormItem prop="username">
|
||||
<BaseInput
|
||||
v-model="changeUsernameForm.username"
|
||||
type="text"
|
||||
size="large"
|
||||
placeholder="请输入用户名"
|
||||
autofocus
|
||||
>
|
||||
<template #preIcon>
|
||||
<IconFluentPerson20Regular class="text-base"/>
|
||||
</template>
|
||||
</BaseInput>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<div class="text-align-end mb-2">
|
||||
<BaseButton type="info" @click="showChangeUsername = false">取消</BaseButton>
|
||||
<BaseButton @click="changeUsername">保存</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
|
||||
<!-- 手机号-->
|
||||
<div class="item">
|
||||
<div class="flex-1">
|
||||
<div class="mb-2">手机号</div>
|
||||
<div class="flex items-center gap-2" v-if="userStore.user?.phone">
|
||||
<IconFluentMail20Regular class="text-base"/>
|
||||
<span>{{ userStore.user?.phone }}</span>
|
||||
</div>
|
||||
<div v-else class="text-xs">在此设置手机号</div>
|
||||
</div>
|
||||
<BaseIcon @click="showChangePhoneForm">
|
||||
<IconFluentTextEditStyle20Regular/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
<div v-if="showChangePhone">
|
||||
<Form
|
||||
ref="changePhoneFormRef"
|
||||
:rules="changePhoneFormRules"
|
||||
:model="changePhoneForm">
|
||||
<FormItem prop="oldCode" v-if="userStore.user?.phone">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="changePhoneForm.oldCode"
|
||||
type="code"
|
||||
autofocus
|
||||
placeholder="请输入原手机号验证码"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
/>
|
||||
<Code :validate-field="() => true"
|
||||
:type="CodeType.ChangePhoneOld"
|
||||
:val="userStore.user.phone"/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem prop="phone">
|
||||
<BaseInput
|
||||
v-model="changePhoneForm.phone"
|
||||
type="tel"
|
||||
size="large"
|
||||
placeholder="请输入新手机号"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="code">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="changePhoneForm.code"
|
||||
type="code"
|
||||
placeholder="请输入新手机号验证码"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
/>
|
||||
<Code :validate-field="() => changePhoneFormRef.validateField('phone')"
|
||||
:type="CodeType.ChangePhoneNew"
|
||||
:val="changePhoneForm.phone"/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem prop="pwd" v-if="!userStore.user?.phone">
|
||||
<BaseInput
|
||||
v-model="changePhoneForm.pwd"
|
||||
type="password"
|
||||
size="large"
|
||||
placeholder="请输入原密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<div class="flex justify-between items-end mb-2">
|
||||
<span class="link text-sm cp"
|
||||
@click="MessageBox.notice(`请提供证明信息发送邮件到 ${EMAIL} 进行申诉`,'人工申诉')"
|
||||
v-if="userStore.user?.phone">原手机号不可用,点此申诉</span>
|
||||
<span v-else></span>
|
||||
<div>
|
||||
<BaseButton type="info" @click="showChangePhone = false">取消</BaseButton>
|
||||
<BaseButton @click="changePhone">保存</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
|
||||
<!-- Email Section -->
|
||||
<div class="item">
|
||||
<div class="flex-1">
|
||||
<div class="mb-2">电子邮箱</div>
|
||||
<div class="flex items-center gap-2" v-if="userStore.user?.email">
|
||||
<IconFluentMail20Regular class="text-base"/>
|
||||
<span>{{ userStore.user?.email }}</span>
|
||||
</div>
|
||||
<div v-else class="text-xs">在此设置邮箱</div>
|
||||
</div>
|
||||
<BaseIcon @click="showChangeEmailForm">
|
||||
<IconFluentTextEditStyle20Regular/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
<div v-if="showChangeEmail">
|
||||
<Form
|
||||
ref="changeEmailFormRef"
|
||||
:rules="changeEmailFormRules"
|
||||
:model="changeEmailForm">
|
||||
<FormItem prop="email">
|
||||
<BaseInput
|
||||
v-model="changeEmailForm.email"
|
||||
type="email"
|
||||
size="large"
|
||||
placeholder="请输入邮箱地址"
|
||||
autofocus
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="code">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="changeEmailForm.code"
|
||||
type="code"
|
||||
placeholder="请输入验证码"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
/>
|
||||
<Code :validate-field="() => changeEmailFormRef.validateField('email')"
|
||||
:type="CodeType.ChangeEmail"
|
||||
:val="changeEmailForm.email"/>
|
||||
</div>
|
||||
</FormItem>
|
||||
<FormItem prop="pwd" v-if="userStore.user?.hasPwd">
|
||||
<BaseInput
|
||||
v-model="changePwdForm.pwd"
|
||||
type="password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<div class="text-align-end mb-2">
|
||||
<BaseButton type="info" @click="showChangeEmail = false">取消</BaseButton>
|
||||
<BaseButton @click="changeEmail">保存</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
|
||||
|
||||
<!-- Password Section -->
|
||||
<div class="item">
|
||||
<div class="flex-1">
|
||||
<div class="mb-2">设置密码</div>
|
||||
<div class="text-xs">在此输入密码</div>
|
||||
</div>
|
||||
<BaseIcon @click="showChangePwdForm">
|
||||
<IconFluentTextEditStyle20Regular/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
<div v-if="showChangePwd">
|
||||
<Form
|
||||
ref="changePwdFormRef"
|
||||
:rules="changePwdFormRules"
|
||||
:model="changePwdForm">
|
||||
<FormItem prop="oldPwd" v-if="userStore.user.hasPwd">
|
||||
<BaseInput
|
||||
v-model="changePwdForm.oldPwd"
|
||||
placeholder="旧密码"
|
||||
type="password"
|
||||
size="large"
|
||||
autofocus
|
||||
/>
|
||||
</FormItem>
|
||||
|
||||
<FormItem prop="newPwd">
|
||||
<BaseInput
|
||||
v-model="changePwdForm.newPwd"
|
||||
type="password"
|
||||
size="large"
|
||||
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength}位)`"
|
||||
:min="PASSWORD_CONFIG.minLength"
|
||||
:max="PASSWORD_CONFIG.maxLength"
|
||||
autofocus
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="confirmPwd">
|
||||
<BaseInput
|
||||
v-model="changePwdForm.confirmPwd"
|
||||
type="password"
|
||||
size="large"
|
||||
placeholder="请再次输入新密码"
|
||||
:min="PASSWORD_CONFIG.minLength"
|
||||
:max="PASSWORD_CONFIG.maxLength"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
<div class="text-align-end mb-2">
|
||||
<BaseButton type="info" @click="showChangePwd = false">取消</BaseButton>
|
||||
<BaseButton :loading="loading" @click="changePwd">保存</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
|
||||
|
||||
<!-- Contact Support -->
|
||||
<div class="item cp"
|
||||
v-if="false"
|
||||
@click="contactSupport">
|
||||
<div class="flex-1">
|
||||
联系 {{ APP_NAME }} 客服
|
||||
</div>
|
||||
<IconFluentChevronLeft28Filled class="rotate-180"/>
|
||||
</div>
|
||||
<!-- <div class="line"></div>-->
|
||||
|
||||
<!-- 同步进度-->
|
||||
<div class="item cp relative">
|
||||
<div class="flex-1">
|
||||
<div class="">同步进度</div>
|
||||
<!-- <div class="text-xs mt-2">在此输入密码</div>-->
|
||||
</div>
|
||||
<IconFluentChevronLeft28Filled class="rotate-180"/>
|
||||
<input type="file" accept=".json,.zip,application/json,application/zip"
|
||||
@change="onFileChange"
|
||||
class="absolute left-0 top-0 w-full h-full bg-red cp opacity-0"/>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
|
||||
<!-- 去github issue-->
|
||||
<div class="item cp"
|
||||
@click="goIssues">
|
||||
<div class="flex-1">
|
||||
给 {{ APP_NAME }} 提交意见
|
||||
</div>
|
||||
<IconFluentChevronLeft28Filled class="rotate-180"/>
|
||||
</div>
|
||||
<div class="line"></div>
|
||||
|
||||
<!-- Logout Button -->
|
||||
<div class="center w-full mt-4">
|
||||
<BaseButton
|
||||
@click="handleLogout"
|
||||
size="large"
|
||||
class="w-[80%]"
|
||||
>
|
||||
登出
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-center mt-2">
|
||||
<a href="/user-agreement.html" target="_blank" class="text-gray-500 hover:text-gray-700">用户协议</a>
|
||||
、
|
||||
<a href="/privacy-policy.html" target="_blank" class="text-gray-500 hover:text-gray-700">隐私政策</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Subscription Information -->
|
||||
<div class="card-white w-80">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<IconFluentCrown20Regular class="text-2xl text-yellow-500"/>
|
||||
<div class="text-lg font-bold">订阅信息</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
|
||||
<template v-if="userStore.user?.member">
|
||||
<div>
|
||||
<div class="mb-1">当前计划</div>
|
||||
<div class="text-base font-bold">{{ member?.planDesc }}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1">状态</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full" :class="member?.active ?'bg-green-500':'bg-red-500'"></div>
|
||||
<span class="text-base font-medium" :class="member?.active ?'text-green-700':'text-red-700'">
|
||||
{{ member?.status }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1">到期时间</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<IconFluentCalendarDate20Regular class="text-lg"/>
|
||||
<span class="text-base font-medium">{{ memberEndDate }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-1">自动续费</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-2 h-2 rounded-full"
|
||||
:class="member?.autoRenew ? 'bg-blue-500' : 'bg-gray-400'"
|
||||
></div>
|
||||
<span class="text-base font-medium"
|
||||
:class="member?.autoRenew ? 'text-blue-700' : 'text-gray-600'">
|
||||
{{ member?.autoRenew ? '已开启' : '已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="text-base" v-else>当前无订阅</div>
|
||||
|
||||
<BaseButton class="w-full" size="large" @click="subscribe">{{
|
||||
userStore.user?.member ? '管理订阅' : '会员介绍'
|
||||
}}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.item {
|
||||
@apply flex items-center justify-between min-h-14;
|
||||
}
|
||||
</style>
|
||||
518
src/pages/user/VipIntro.vue
Normal file
518
src/pages/user/VipIntro.vue
Normal file
@@ -0,0 +1,518 @@
|
||||
<script setup lang="ts">
|
||||
import BasePage from '@/components/BasePage.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useUserStore} from '@/stores/auth.ts'
|
||||
import {User} from "@/apis/user.ts";
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
|
||||
import Header from "@/components/Header.vue";
|
||||
import {
|
||||
CouponInfo,
|
||||
couponInfo,
|
||||
LevelBenefits,
|
||||
levelBenefits,
|
||||
orderCreate,
|
||||
orderStatus,
|
||||
setAutoRenewApi
|
||||
} from "@/apis/member.ts";
|
||||
import Radio from "@/components/base/radio/Radio.vue";
|
||||
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
|
||||
import {APP_NAME} from "@/config/env.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import {_dateFormat, _nextTick} from "@/utils";
|
||||
import InputNumber from "@/components/base/InputNumber.vue";
|
||||
import dayjs from "dayjs";
|
||||
import BaseInput from "@/components/base/BaseInput.vue";
|
||||
import PopConfirm from "@/components/PopConfirm.vue";
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
interface Plan {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
unit: '月' | '年'
|
||||
highlight?: string
|
||||
autoRenew?: boolean
|
||||
}
|
||||
|
||||
let loading = $ref(false);
|
||||
let selectedPaymentMethod = $ref('wechat')
|
||||
let selectedPlanId = $ref('')
|
||||
let duration = $ref(1)
|
||||
const member = $computed<User['member']>(() => userStore.user?.member ?? {} as any)
|
||||
|
||||
const memberEndDate = $computed(() => {
|
||||
if (member?.endDate === null) return '永久'
|
||||
return member?.endDate
|
||||
})
|
||||
|
||||
let data = $ref<LevelBenefits>({} as any)
|
||||
const plans: Plan[] = $computed(() => {
|
||||
let list = []
|
||||
if (data?.level) {
|
||||
list.push({
|
||||
id: 'month',
|
||||
name: '月付',
|
||||
price: data.level.price,
|
||||
unit: '月',
|
||||
},)
|
||||
list.push({
|
||||
id: 'month_auto',
|
||||
name: '连续包月',
|
||||
price: data.level.price_auto,
|
||||
unit: '月',
|
||||
highlight: '性价比更高',
|
||||
autoRenew: true,
|
||||
},)
|
||||
list.push({
|
||||
id: 'year',
|
||||
name: '年度会员',
|
||||
price: data.level.yearly_price,
|
||||
unit: '年',
|
||||
highlight: '年度优惠',
|
||||
},)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
// Payment methods - WeChat and Alipay
|
||||
const paymentMethods = [
|
||||
{
|
||||
id: 'wechat',
|
||||
name: '微信支付',
|
||||
description: '使用微信支付'
|
||||
},
|
||||
{
|
||||
id: 'alipay',
|
||||
name: '支付宝',
|
||||
description: '使用支付宝支付'
|
||||
}
|
||||
]
|
||||
|
||||
const currentPlan = $computed(() => {
|
||||
return plans.find(v => v.id === member?.plan) ?? null
|
||||
})
|
||||
|
||||
const selectPlan = $computed(() => {
|
||||
return plans.find(v => v.id === selectedPlanId) ?? null
|
||||
})
|
||||
|
||||
// Calculate original price based on plan type
|
||||
const originalPrice = $computed(() => {
|
||||
return selectPlan?.id === 'month_auto' ? Number(selectPlan?.price) : Number(duration) * Number(selectPlan?.price)
|
||||
})
|
||||
|
||||
// check Is it enough for a discount
|
||||
const enoughDiscount = $computed(() => {
|
||||
if (coupon.is_valid) {
|
||||
if (coupon.min_amount) {
|
||||
const minAmount = Number(coupon.min_amount)
|
||||
return originalPrice > minAmount
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
const endPrice = $computed(() => {
|
||||
if (!coupon.is_valid) {
|
||||
return Number(originalPrice.toFixed(2))
|
||||
}
|
||||
|
||||
if (coupon.type === 'free_trial') return 0
|
||||
|
||||
if (!enoughDiscount) {
|
||||
return Number(originalPrice.toFixed(2))
|
||||
}
|
||||
|
||||
let discountAmount = 0
|
||||
if (coupon.type === 'discount') {
|
||||
// Discount coupon: e.g., 0.8 means 20% off
|
||||
const discountRate = Number(coupon.value)
|
||||
discountAmount = originalPrice * (1 - discountRate)
|
||||
|
||||
// Apply max_discount limit if available
|
||||
if (coupon.max_discount) {
|
||||
const maxDiscount = Number(coupon.max_discount)
|
||||
discountAmount = Math.min(discountAmount, maxDiscount)
|
||||
}
|
||||
} else if (coupon.type === 'amount') {
|
||||
// Amount coupon: fixed amount off
|
||||
discountAmount = Number(coupon.value)
|
||||
}
|
||||
|
||||
const finalPrice = Math.max(originalPrice - discountAmount, 0)
|
||||
return finalPrice.toFixed(2)
|
||||
}
|
||||
)
|
||||
|
||||
const startDate = $computed(() => {
|
||||
if (member?.active) {
|
||||
return member.endDate
|
||||
} else {
|
||||
return _dateFormat(Date.now())
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
let res = await levelBenefits({levelCode: 'basic'})
|
||||
if (res.success) {
|
||||
data = res.data
|
||||
}
|
||||
})
|
||||
|
||||
let loading2 = $ref(false);
|
||||
|
||||
async function toggleAutoRenew() {
|
||||
if (loading2) return
|
||||
loading2 = true
|
||||
let res = await setAutoRenewApi({autoRenew: false})
|
||||
if (res.success) {
|
||||
Toast.success('取消成功')
|
||||
userStore.init()
|
||||
} else {
|
||||
Toast.error(res.msg || '取消失败')
|
||||
}
|
||||
loading2 = false
|
||||
}
|
||||
|
||||
// Get button text based on current plan
|
||||
function getPlanButtonText(plan: Plan) {
|
||||
if (plan.id === selectedPlanId) return '已选中'
|
||||
if (plan.id === currentPlan?.id) return '当前计划'
|
||||
return '选择'
|
||||
}
|
||||
|
||||
function goPurchase(plan: Plan) {
|
||||
if (!userStore.isLogin) {
|
||||
router.push({path: '/login', query: {redirect: '/vip'}})
|
||||
return
|
||||
}
|
||||
selectedPlanId = plan.id
|
||||
_nextTick(() => {
|
||||
let el = document.getElementById('pay')
|
||||
el.scrollIntoView({behavior: "smooth"})
|
||||
})
|
||||
}
|
||||
|
||||
let startLoop = $ref(false)
|
||||
let orderNo = $ref('')
|
||||
let timer: number = $ref()
|
||||
let showCouponInput = $ref(false)
|
||||
let coupon = $ref<CouponInfo>({code: ''} as CouponInfo)
|
||||
|
||||
watch(() => startLoop, (n) => {
|
||||
if (n) {
|
||||
clearInterval(timer)
|
||||
timer = setInterval(() => {
|
||||
orderStatus({orderNo}).then(res => {
|
||||
if (res?.success) {
|
||||
if (res.data?.payment_status === 'paid') {
|
||||
Toast.success('付款成功')
|
||||
userStore.init()
|
||||
startLoop = false
|
||||
selectedPlanId = undefined
|
||||
}
|
||||
} else {
|
||||
startLoop = false
|
||||
Toast.error(res.msg || '付款失败')
|
||||
}
|
||||
})
|
||||
}, 1000)
|
||||
} else {
|
||||
clearInterval(timer)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
startLoop = false
|
||||
clearInterval(timer)
|
||||
})
|
||||
|
||||
async function handlePayment() {
|
||||
if (loading) return
|
||||
loading = true
|
||||
let data = {
|
||||
plan: selectedPlanId,
|
||||
duration: Number(duration),
|
||||
payment_method: selectedPaymentMethod,
|
||||
couponCode: coupon.is_valid ? coupon.code : undefined
|
||||
}
|
||||
let res = await orderCreate(data)
|
||||
if (res.success) {
|
||||
orderNo = res.data.orderNo
|
||||
startLoop = true
|
||||
} else {
|
||||
Toast.error(res.msg || '付款失败')
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
let couponLoading = $ref(false)
|
||||
|
||||
async function getCouponInfo() {
|
||||
if (showCouponInput) {
|
||||
if (!coupon.code) return
|
||||
if (couponLoading) return
|
||||
couponLoading = true
|
||||
let res = await couponInfo(coupon)
|
||||
if (res.success) {
|
||||
if (res.data.is_valid) {
|
||||
coupon = res.data
|
||||
} else {
|
||||
coupon = {code: coupon.code} as CouponInfo
|
||||
Toast.info('优惠券已失效')
|
||||
}
|
||||
} else {
|
||||
coupon = {code: coupon.code} as CouponInfo
|
||||
Toast.error(res.msg || '优惠券无效')
|
||||
}
|
||||
couponLoading = false
|
||||
} else {
|
||||
showCouponInput = true
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePage>
|
||||
<div class="space-y-6">
|
||||
<div class="card-white">
|
||||
<Header title="会员介绍"></Header>
|
||||
<div class="grid grid-cols-3 grid-rows-3 gap-3">
|
||||
<div class="text-lg items-center" v-for="f in data.benefits" :key="f.name">
|
||||
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
|
||||
<span>
|
||||
<span>{{ f.name }}</span>
|
||||
<span v-if="f.value !== 'true'">{{ `(${f.value}${f.unit ?? ''})` }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="member?.active" class="card-white bg-green-50 dark:bg-item border border-green-200 mt-3 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
|
||||
<div>
|
||||
<div class="font-semibold text-green-800">当前计划:{{ currentPlan?.name }}</div>
|
||||
<div class="text-sm text-green-600">
|
||||
到期时间:{{ memberEndDate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-align-end space-y-2">
|
||||
<div v-if="member.autoRenew" class="flex items-center gap-space">
|
||||
<div class="flex items-center text-sm text-gray-600">
|
||||
<IconFluentArrowRepeatAll20Regular class="mr-1"/>
|
||||
<span>自动续费已开启</span>
|
||||
</div>
|
||||
<PopConfirm
|
||||
title="确认取消?"
|
||||
@confirm="toggleAutoRenew"
|
||||
>
|
||||
<BaseButton size="small" type="info" :loading="loading2">关闭</BaseButton>
|
||||
</PopConfirm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between">
|
||||
<div class="title">选择适合您的套餐</div>
|
||||
<div class="subtitle">三种方案,按需选择</div>
|
||||
</div>
|
||||
|
||||
<div class="plans">
|
||||
<div v-for="p in plans" :key="p.id"
|
||||
class="card-white p-0 overflow-hidden flex flex-col">
|
||||
<div class="text-2xl font-bold bg-gray-300 dark:bg-third px-6 py-4">{{ p.name }}</div>
|
||||
<div class="p-6 flex flex-col justify-between flex-1">
|
||||
<div class="plan-head">
|
||||
<div class="price">
|
||||
<span class="amount">¥{{ p.price }}</span>
|
||||
<span class="unit">/ 每{{ p.unit }}</span>
|
||||
</div>
|
||||
<div v-if="p.highlight" class="tag">{{ p.highlight }}</div>
|
||||
</div>
|
||||
<div v-if="p.autoRenew" class="text-sm flex items-center mt-4">
|
||||
<IconFluentArrowRepeatAll20Regular class="mr-2"/>
|
||||
开启自动续费,可随时关闭
|
||||
</div>
|
||||
<BaseButton class="w-full mt-4" size="large"
|
||||
:type="(p.id === currentPlan?.id || p.id === selectedPlanId) ? 'primary' : 'info'"
|
||||
:disabled="p.id === currentPlan?.id" @click="goPurchase(p)">
|
||||
{{ getPlanButtonText(p) }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="pay" class="mb-50" v-if="selectedPlanId">
|
||||
<!-- Page Header -->
|
||||
<div class="text-center mb-6">
|
||||
<h1 class="text-xl font-semibold mb-2">安全支付</h1>
|
||||
<p class="">选择支付方式完成订单</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="center">
|
||||
<div class="card-white w-7/10">
|
||||
<div class="flex items-center justify-between gap-6 ">
|
||||
<div class="center gap-2" v-if="!showCouponInput">
|
||||
<IconStreamlineDiscountPercentCoupon/>
|
||||
<span>有优惠券?</span>
|
||||
</div>
|
||||
<BaseInput v-else v-model="coupon.code"
|
||||
placeholder="请输入优惠券"
|
||||
autofocus
|
||||
@enter="getCouponInfo"
|
||||
/>
|
||||
<BaseButton size="large"
|
||||
:loading="couponLoading"
|
||||
@click="getCouponInfo">{{ showCouponInput ? '确定' : '在此兑换!' }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 border border-green-200 rounded-lg px-4 py-3 mt-4"
|
||||
v-if="coupon.is_valid">
|
||||
<div class="font-medium">优惠券: {{ coupon.name }}</div>
|
||||
<div class="flex justify-between w-full mt-2">
|
||||
<span v-if="coupon.type === 'discount'">折扣券:{{ (Number(coupon.value) * 10).toFixed(1) }}折</span>
|
||||
<span v-else-if="coupon.type === 'amount'">立减券:¥{{ Number(coupon.value).toFixed(2) }}</span>
|
||||
<span v-else-if="coupon.type === 'free_trial'">折扣: -100%</span>
|
||||
|
||||
<!-- Coupon restrictions -->
|
||||
<div v-if="coupon.min_amount || coupon.max_discount">
|
||||
<span v-if="coupon.min_amount">满{{ Number(coupon.min_amount).toFixed(2) }}元可用</span>
|
||||
<span v-if="coupon.max_discount && coupon.type === 'discount'">
|
||||
· 最高减{{ Number(coupon.max_discount).toFixed(2) }}元
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<!-- Left Card: Payment Method Selection -->
|
||||
<div class="card-white">
|
||||
<div class="text-lg font-medium mb-4">选择支付方式</div>
|
||||
<RadioGroup v-model="selectedPaymentMethod">
|
||||
<div class="space-y-3 w-full">
|
||||
<div v-for="method in paymentMethods" :key="method.id"
|
||||
@click=" selectedPaymentMethod = method.id"
|
||||
class="flex p-4 border rounded-lg cp transition-all duration-200 hover:bg-item"
|
||||
:class="selectedPaymentMethod === method.id && 'bg-item'">
|
||||
<div class="flex items-center flex-1 gap-4">
|
||||
<IconSimpleIconsWechat class="text-xl color-green-500" v-if="method.id === 'wechat'"/>
|
||||
<IconUiwAlipay class="text-xl color-blue" v-else/>
|
||||
<div>
|
||||
<div class="font-medium color-main">{{ method.name }}</div>
|
||||
<div class="text-sm text-gray-500">{{ method.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Radio :value="method.id" label=""></Radio>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<!-- Right Card: Order Summary -->
|
||||
<div class="card-white">
|
||||
<div class="text-lg font-semibold mb-4">订单概要</div>
|
||||
|
||||
<!-- Plan Info -->
|
||||
<div class="mb-4">
|
||||
<div class="text-purple-600 text-sm mb-2">付费方案({{ selectPlan?.name }})订阅</div>
|
||||
<div class="mb-4">从 {{ startDate }} 开始:</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<!-- Price -->
|
||||
<div class="flex items-baseline">
|
||||
<span class="font-semibold"
|
||||
:class="selectPlan?.id === 'month_auto' ? 'text-3xl' : 'text-xl'">
|
||||
¥{{ selectPlan?.price }}
|
||||
</span>
|
||||
<span class="ml-2">/ {{ selectPlan?.unit }}</span>
|
||||
</div>
|
||||
<div v-if="selectPlan?.id !== 'month_auto'">
|
||||
<InputNumber :min="1" v-model="duration"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="coupon.is_valid" class="mb-4">
|
||||
<div class="flex items-baseline text-gray-500 line-through" v-if="enoughDiscount">
|
||||
<span class="text-lg">原价:¥{{ Number(originalPrice).toFixed(2) }}</span>
|
||||
<span class="ml-2">/ {{ selectPlan?.unit }}</span>
|
||||
</div>
|
||||
<div class="text-sm">
|
||||
<div v-if="enoughDiscount" class="text-green-600 flex items-center">
|
||||
<IconStreamlineDiscountPercentCoupon class="mr-2"/>
|
||||
<span>已优惠:¥{{ (Number(originalPrice) - Number(endPrice)).toFixed(2) }}</span>
|
||||
</div>
|
||||
<span v-else>优惠券不可用:未满足条件</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Final Price -->
|
||||
<div class="flex items-baseline mb-4">
|
||||
<span class="text-2xl font-semibold">总计:</span>
|
||||
<span class="text-3xl font-semibold">¥{{ endPrice }}</span>
|
||||
</div>
|
||||
|
||||
<div class="bg-second text-sm px-4 py-3 rounded-lg mb-4 text-gray-600">
|
||||
会员属于虚拟服务,一经购买激活后不支持退款。请在购买前仔细阅读权益说明,确认符合您的需求再进行支付。
|
||||
</div>
|
||||
|
||||
<!-- Payment Button -->
|
||||
<BaseButton class="w-full" size="large" :loading="loading || startLoop"
|
||||
:type="!!selectedPaymentMethod ? 'primary' : 'info'" :disabled="!selectedPaymentMethod"
|
||||
@click="handlePayment">
|
||||
付款
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BasePage>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.plans {
|
||||
display: grid;
|
||||
gap: 3rem;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.plan-head {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
|
||||
.price {
|
||||
@apply flex items-end gap-1;
|
||||
}
|
||||
|
||||
.amount {
|
||||
@apply text-4xl font-500;
|
||||
}
|
||||
|
||||
.unit {
|
||||
@apply text-base text-gray-500;
|
||||
}
|
||||
|
||||
.desc {
|
||||
@apply text-sm text-gray-600;
|
||||
}
|
||||
|
||||
.tag {
|
||||
@apply text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded w-fit;
|
||||
}
|
||||
</style>
|
||||
@@ -1,29 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import { onMounted } from "vue";
|
||||
import { IS_LOGIN } from "@/config/env.ts";
|
||||
import router from "@/router.ts";
|
||||
|
||||
onMounted(() => {
|
||||
if (!IS_LOGIN) {
|
||||
}
|
||||
router.push({path: "/login"});
|
||||
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col justify-between min-h-screen">
|
||||
<div class="center flex-col gap-8">
|
||||
onMounted(() => {
|
||||
if (!IS_LOGIN) {
|
||||
router.push({path: "/login"});
|
||||
}
|
||||
})
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,627 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="tsx">
|
||||
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 { uploadImportData } from "@/apis";
|
||||
import {APP_NAME} from "@/config/env.ts";
|
||||
import {useUserStore} from "@/stores/auth.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} from "@/types/types.ts";
|
||||
import Code from "@/pages/user/Code.vue";
|
||||
import {isNewUser, useNav} from "@/utils";
|
||||
import Header from "@/components/Header.vue";
|
||||
import PopConfirm from "@/components/PopConfirm.vue";
|
||||
|
||||
function sync() {
|
||||
// 状态管理
|
||||
const userStore = useUserStore()
|
||||
const route = useRoute()
|
||||
const router = useNav()
|
||||
|
||||
// 页面状态
|
||||
let currentMode = $ref<'login' | 'register' | 'forgot'>('login')
|
||||
let loginType = $ref<'code' | 'password'>('code') // 默认验证码登录
|
||||
let loading = $ref(false)
|
||||
let showWechatQR = $ref(true)
|
||||
let wechatQRUrl = $ref('https://open.weixin.qq.com/connect/qrcode/041GmMJM2wfM0w3D')
|
||||
// 微信二维码状态:idle-正常/等待扫码,scanned-已扫码待确认,expired-已过期,cancelled-已取消
|
||||
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)
|
||||
let isImporting = $ref(true)
|
||||
const QR_EXPIRE_TIME = 5 * 60 * 1000 // 5分钟过期
|
||||
|
||||
|
||||
let phoneLoginForm = $ref({phone: '', code: ''})
|
||||
let phoneLoginFormRef = $ref<FormInstance>()
|
||||
let phoneLoginFormRules = {
|
||||
phone: phoneRules,
|
||||
code: codeRules
|
||||
}
|
||||
|
||||
async function handleAudioChange(e) {
|
||||
let uploadFile = e.target?.files?.[0]
|
||||
if (!uploadFile) return
|
||||
let data = new FormData();
|
||||
data.append("file", uploadFile);
|
||||
let res = await uploadImportData(data, e => {
|
||||
console.log('e', e)
|
||||
|
||||
let loginForm2 = $ref({account: '', password: ''})
|
||||
let loginForm2Ref = $ref<FormInstance>()
|
||||
let loginForm2Rules = {
|
||||
account: accountRules,
|
||||
password: passwordRules,
|
||||
}
|
||||
|
||||
|
||||
const registerForm = $ref({
|
||||
account: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
code: ''
|
||||
})
|
||||
let registerFormRef = $ref<FormInstance>()
|
||||
// 注册表单规则和引用
|
||||
let registerFormRules = {
|
||||
account: accountRules,
|
||||
code: codeRules,
|
||||
password: passwordRules,
|
||||
confirmPassword: [
|
||||
{required: true, message: '请再次输入密码', trigger: 'blur'},
|
||||
{
|
||||
validator: (rule: any, value: any) => {
|
||||
if (value !== registerForm.password) {
|
||||
throw new Error('两次密码输入不一致')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
const forgotForm = $ref({
|
||||
account: '',
|
||||
code: '',
|
||||
newPassword: '',
|
||||
confirmPassword: ''
|
||||
})
|
||||
let forgotFormRef = $ref<FormInstance>()
|
||||
// 忘记密码表单规则和引用
|
||||
let forgotFormRules = {
|
||||
account: accountRules,
|
||||
code: codeRules,
|
||||
newPassword: passwordRules,
|
||||
confirmPassword: [
|
||||
{required: true, message: '请再次输入新密码', trigger: 'blur'},
|
||||
{
|
||||
validator: (rule: any, value: any) => {
|
||||
if (value !== forgotForm.newPassword) {
|
||||
throw new Error('两次密码输入不一致')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
const currentFormRef = $computed<FormInstance>(() => {
|
||||
if (currentMode === 'login') {
|
||||
if (loginType == 'code') return phoneLoginFormRef
|
||||
else return loginForm2Ref
|
||||
} else if (currentMode === 'register') return registerFormRef
|
||||
else return forgotFormRef
|
||||
})
|
||||
|
||||
// 统一登录处理
|
||||
async function handleLogin() {
|
||||
currentFormRef.validate(async (valid) => {
|
||||
if (!valid) return;
|
||||
try {
|
||||
loading = true
|
||||
let data = {}
|
||||
//手机号登录
|
||||
if (loginType === 'code') {
|
||||
data = {...phoneLoginForm, type: 'code'}
|
||||
} else {
|
||||
//密码登录
|
||||
data = {...loginForm2, type: 'pwd'}
|
||||
}
|
||||
let res = await loginApi(data as LoginParams)
|
||||
if (res.success) {
|
||||
userStore.setToken(res.data.token)
|
||||
Toast.success('登录成功')
|
||||
router.back()
|
||||
} else {
|
||||
Toast.error(res.msg || '登录失败')
|
||||
if (res.code === 499) {
|
||||
loginType = 'code'
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error('登录失败,请重试')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
console.log('res', res)
|
||||
console.log(uploadFile)
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function s() {
|
||||
const taskId = await fetch('/startImport').then(r => r.json()).then(d => d.taskId);
|
||||
|
||||
const timer = setInterval(async () => {
|
||||
const res = await fetch(`/getProgress/${taskId}`).then(r => r.json());
|
||||
console.log(`当前进度: ${res.progress}%`);
|
||||
if (res.progress >= 100) clearInterval(timer);
|
||||
}, 1000);
|
||||
// 注册
|
||||
async function handleRegister() {
|
||||
registerFormRef.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
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('/')
|
||||
} else {
|
||||
Toast.error(res.msg || '注册失败')
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error('注册失败,请重试')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 忘记密码
|
||||
async function handleForgotPassword() {
|
||||
forgotFormRef.validate(async (valid) => {
|
||||
if (!valid) return
|
||||
try {
|
||||
loading = true
|
||||
const res = await resetPasswordApi(forgotForm)
|
||||
if (res.success) {
|
||||
Toast.success('密码重置成功,请重新登录')
|
||||
switchMode('login')
|
||||
} else {
|
||||
Toast.error(res.msg || '重置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
Toast.error(error || '重置密码失败,请重试')
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 清除二维码相关定时器
|
||||
function clearQRTimers() {
|
||||
if (qrExpireTimer) {
|
||||
clearTimeout(qrExpireTimer)
|
||||
qrExpireTimer = null
|
||||
}
|
||||
if (qrCheckInterval) {
|
||||
clearInterval(qrCheckInterval)
|
||||
qrCheckInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新二维码
|
||||
async function refreshQRCode() {
|
||||
clearQRTimers()
|
||||
qrStatus = 'idle'
|
||||
await handleWechatLogin()
|
||||
}
|
||||
|
||||
// 微信登录 - 显示二维码
|
||||
async function handleWechatLogin() {
|
||||
try {
|
||||
showWechatQR = true
|
||||
qrStatus = 'idle'
|
||||
|
||||
// 这里应该调用后端获取二维码
|
||||
// const response = await getWechatQR()
|
||||
// wechatQRUrl = response.qrUrl
|
||||
|
||||
// 暂时使用占位二维码
|
||||
wechatQRUrl = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgZmlsbD0iI2Y1ZjVmNSIvPgogIDx0ZXh0IHg9IjUwJSIgeT0iNTAlIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkeT0iLjNlbSIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OTk5OSI+55So5o6l566h55CG6L295Lit6K+B77yBPC90ZXh0Pgo8L3N2Zz4K'
|
||||
|
||||
// 模拟轮询检查扫码状态
|
||||
qrCheckInterval = setInterval(async () => {
|
||||
// 这里应该轮询后端检查扫码状态
|
||||
// const result = await checkWechatLoginStatus()
|
||||
// if (result.scanned) qrStatus = 'scanned'
|
||||
// if (result.success) {
|
||||
// clearQRTimers()
|
||||
// showWechatQR = false
|
||||
// qrStatus = 'idle'
|
||||
// // 登录成功处理
|
||||
// }
|
||||
}, 2000)
|
||||
|
||||
// 设置二维码过期
|
||||
qrExpireTimer = setTimeout(() => {
|
||||
qrStatus = 'expired'
|
||||
clearInterval(qrCheckInterval!)
|
||||
qrCheckInterval = null
|
||||
Toast.info('二维码已过期,请点击刷新')
|
||||
}, QR_EXPIRE_TIME)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Wechat login error:', error)
|
||||
Toast.error('微信登录失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换模式
|
||||
function switchMode(mode: 'login' | 'register' | 'forgot') {
|
||||
currentMode = mode
|
||||
// 切换到注册或忘记密码模式时,隐藏微信扫码
|
||||
if (mode === 'register' || mode === 'forgot') {
|
||||
if (showWechatQR) {
|
||||
clearQRTimers()
|
||||
showWechatQR = false
|
||||
qrStatus = 'idle'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户主动取消登录(示例:可在需要的地方调用)
|
||||
function cancelWechatLogin() {
|
||||
qrStatus = 'cancelled'
|
||||
qrStatus = 'cancelled'
|
||||
qrStatus = 'cancelled'
|
||||
}
|
||||
|
||||
// 初始化页面
|
||||
onMounted(() => {
|
||||
console.log('route.query', route.query)
|
||||
if (route.query?.register) {
|
||||
currentMode = 'register'
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理定时器
|
||||
onBeforeUnmount(() => {
|
||||
clearQRTimers()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="center h-screen">
|
||||
<div class=" flex flex-col gap-6 w-100">
|
||||
<h1 class="mb-0 text-align-center">{{ APP_NAME }}</h1>
|
||||
<div class="flex center">
|
||||
<span class="shrink-0">账户:</span>
|
||||
<BaseInput type="text"/>
|
||||
</div>
|
||||
<div class="flex center">
|
||||
<span class="shrink-0">密码:</span>
|
||||
<BaseInput type="password"/>
|
||||
</div>
|
||||
<BaseButton class="w-full">登录</BaseButton>
|
||||
<BaseButton class="w-full" @click="sync">同步</BaseButton>
|
||||
<div class="upload relative">
|
||||
<BaseButton>上传</BaseButton>
|
||||
<input type="file"
|
||||
accept=".zip,.json"
|
||||
@change="handleAudioChange"
|
||||
class="w-full h-full absolute left-0 top-0 opacity-0"/>
|
||||
</div>
|
||||
<div class="center min-h-screen">
|
||||
<div class="card-white p-2" v-if="!waitForImportConfirmation">
|
||||
<!-- 登录区域容器 - 弹框形式 -->
|
||||
<div class="flex gap-2">
|
||||
<!-- 左侧登录区域 -->
|
||||
<div class="flex-1 w-80 p-3">
|
||||
<!-- 登录选项 -->
|
||||
<div v-if="currentMode === 'login'">
|
||||
<div class="mb-6 text-center text-2xl font-bold">{{ APP_NAME }}</div>
|
||||
|
||||
<div class="w-full flex justify-end gap-4">
|
||||
<div>注册</div>
|
||||
<div>忘记密码</div>
|
||||
<!-- 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'"
|
||||
>
|
||||
<div>
|
||||
<span>验证码登录</span>
|
||||
<div
|
||||
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'"
|
||||
>
|
||||
<div>
|
||||
<span>密码登录</span>
|
||||
<div
|
||||
v-opacity="loginType === 'password'"
|
||||
class="mt-1 h-0.5 bg-blue-600"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码登录表单 -->
|
||||
<Form
|
||||
v-if="loginType === 'code'"
|
||||
ref="phoneLoginFormRef"
|
||||
:rules="phoneLoginFormRules"
|
||||
:model="phoneLoginForm">
|
||||
<FormItem prop="phone">
|
||||
<BaseInput v-model="phoneLoginForm.phone"
|
||||
type="tel"
|
||||
name="username"
|
||||
autocomplete="tel"
|
||||
size="large"
|
||||
placeholder="请输入手机号"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="code">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="phoneLoginForm.code"
|
||||
type="code"
|
||||
size="large"
|
||||
:max-length="PHONE_CONFIG.codeLength"
|
||||
placeholder="请输入验证码"
|
||||
/>
|
||||
<Code :validate-field="() => phoneLoginFormRef.validateField('phone')"
|
||||
:type="CodeType.Login"
|
||||
:val="phoneLoginForm.phone"/>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<!-- 密码登录表单 -->
|
||||
<Form
|
||||
v-else
|
||||
ref="loginForm2Ref"
|
||||
:rules="loginForm2Rules"
|
||||
:model="loginForm2">
|
||||
<FormItem prop="account">
|
||||
<BaseInput v-model="loginForm2.account"
|
||||
type="email"
|
||||
name="username"
|
||||
autocomplete="email"
|
||||
size="large"
|
||||
placeholder="请输入手机号/邮箱地址"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="password">
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="loginForm2.password"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</div>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<Notice>
|
||||
<span v-if="loginType === 'code'">,未注册的手机号将自动注册</span>
|
||||
</Notice>
|
||||
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleLogin"
|
||||
>
|
||||
登录
|
||||
</BaseButton>
|
||||
|
||||
<!-- 底部操作链接 - 只在密码登录时显示 -->
|
||||
<div class="mt-4 flex justify-between text-sm" v-opacity="loginType !== 'code'">
|
||||
<div class="link cp" @click="switchMode('forgot')">忘记密码?</div>
|
||||
<div class="link cp" @click="switchMode('register')">注册账号</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 注册模式 -->
|
||||
<div v-else-if="currentMode === 'register'">
|
||||
<Header @click="switchMode('login')" title="注册新账号"/>
|
||||
|
||||
<Form
|
||||
ref="registerFormRef"
|
||||
:rules="registerFormRules"
|
||||
:model="registerForm">
|
||||
<FormItem prop="account">
|
||||
<BaseInput
|
||||
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"
|
||||
/>
|
||||
<Code :validate-field="() => registerFormRef.validateField('account')"
|
||||
:type="CodeType.Register"
|
||||
:val="registerForm.account"/>
|
||||
</div>
|
||||
</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} 位)`"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="confirmPassword">
|
||||
<BaseInput
|
||||
v-model="registerForm.confirmPassword"
|
||||
type="password"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
size="large"
|
||||
placeholder="请再次输入密码"
|
||||
/>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<Notice/>
|
||||
|
||||
<BaseButton
|
||||
class="w-full"
|
||||
size="large"
|
||||
:loading="loading"
|
||||
@click="handleRegister"
|
||||
>
|
||||
注册
|
||||
</BaseButton>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 忘记密码模式 -->
|
||||
<div v-else-if="currentMode === 'forgot'">
|
||||
<Header @click="switchMode('login')" title="重置密码"/>
|
||||
|
||||
<Form
|
||||
ref="forgotFormRef"
|
||||
:rules="forgotFormRules"
|
||||
:model="forgotForm">
|
||||
<FormItem prop="account">
|
||||
<BaseInput
|
||||
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"
|
||||
/>
|
||||
<Code :validate-field="() => forgotFormRef.validateField('account')"
|
||||
:type="CodeType.ResetPwd"
|
||||
:val="forgotForm.account"/>
|
||||
</div>
|
||||
</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} 位)`"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem prop="confirmPassword">
|
||||
<BaseInput
|
||||
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"
|
||||
>
|
||||
重置密码
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧微信二维码 - 只在登录模式时显示 -->
|
||||
<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' }"
|
||||
/>
|
||||
<!-- 扫描成功蒙层 -->
|
||||
<div
|
||||
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>
|
||||
<div class="text-xs text-gray-600">微信中轻触允许即可登录</div>
|
||||
</div>
|
||||
<!-- 取消登录蒙层 -->
|
||||
<div
|
||||
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>
|
||||
<div class="text-xs text-gray-600">你可<span class="color-link" @click="refreshQRCode">再次登录</span>,或关闭窗口
|
||||
</div>
|
||||
</div>
|
||||
<!-- 过期蒙层 -->
|
||||
<div
|
||||
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"/>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4 center gap-space">
|
||||
<IconIxWechatLogo class="text-xl color-green"/>
|
||||
<span class="text-sm text-gray-600">微信扫码登录</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<div class="flex gap-space justify-end">
|
||||
<PopConfirm :title="[
|
||||
{text:'您的用户数据将以压缩包自动下载到您的电脑中',type:'normal'},
|
||||
{text:'随后用户数据将被移除',type:'redBold'},
|
||||
{text:'是否确认继续?',type:'normal'},
|
||||
]">
|
||||
<BaseButton type="info">放弃数据</BaseButton>
|
||||
</PopConfirm>
|
||||
|
||||
<BaseButton>确认同步</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<style scoped lang="scss">
|
||||
|
||||
<style scoped lang="scss">
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { getCurrentStudyWord } from "@/hooks/dict.ts";
|
||||
import PracticeSettingDialog from "@/pages/word/components/PracticeSettingDialog.vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { MessageBox } from "@/utils/MessageBox.tsx";
|
||||
import { CAN_REQUEST, Origin, PracticeSaveWordKey } from "@/config/env.ts";
|
||||
import { AppEnv, Origin, PracticeSaveWordKey } from "@/config/env.ts";
|
||||
import { detail } from "@/apis";
|
||||
import useMobile from "@/hooks/useMobile.ts";
|
||||
|
||||
@@ -202,7 +202,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
if (base.word.bookList.find(book => book.id === runtimeStore.editDict.id)) {
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await detail({id: runtimeStore.editDict.id})
|
||||
if (res.success) {
|
||||
runtimeStore.editDict.statistics = res.data.statistics
|
||||
@@ -243,7 +243,7 @@ async function startPractice() {
|
||||
wordPracticeMode: settingStore.wordPracticeMode
|
||||
})
|
||||
let currentStudy = getCurrentStudyWord()
|
||||
nav('practice-words/' + store.sdict.id, {}, currentStudy)
|
||||
nav('practice-words/' + store.sdict.id, {}, {taskWords:currentStudy})
|
||||
}
|
||||
|
||||
async function addMyStudyList() {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {onMounted, provide, ref, watch} from "vue";
|
||||
import {onMounted, provide, ref, toRef, watch} from "vue";
|
||||
|
||||
import Statistics from "@/pages/word/Statistics.vue";
|
||||
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
|
||||
@@ -50,6 +49,7 @@ let taskWords = $ref<TaskWords>({
|
||||
new: [],
|
||||
review: [],
|
||||
write: [],
|
||||
shuffle: [],
|
||||
})
|
||||
|
||||
let data = $ref<PracticeData>({
|
||||
@@ -60,10 +60,9 @@ let data = $ref<PracticeData>({
|
||||
})
|
||||
let isTypingWrongWord = ref(false)
|
||||
|
||||
let practiceMode = ref(WordPracticeType.FollowWrite)
|
||||
provide('isTypingWrongWord', isTypingWrongWord)
|
||||
provide('practiceData', data)
|
||||
provide('practiceMode', practiceMode)
|
||||
provide('practiceTaskWords', taskWords)
|
||||
|
||||
async function loadDict() {
|
||||
// console.log('load好了开始加载')
|
||||
@@ -100,7 +99,7 @@ watch(() => store.load, (n) => {
|
||||
onMounted(() => {
|
||||
//如果是从单词学习主页过来的,就直接使用;否则等待加载
|
||||
if (runtimeStore.routeData) {
|
||||
initData(runtimeStore.routeData, true)
|
||||
initData(runtimeStore.routeData.taskWords, true)
|
||||
} else {
|
||||
loading = true
|
||||
}
|
||||
@@ -124,27 +123,45 @@ function initData(initVal: TaskWords, init: boolean = false) {
|
||||
initData(initVal, true)
|
||||
}
|
||||
} else {
|
||||
taskWords = initVal
|
||||
if (taskWords.new.length === 0) {
|
||||
if (taskWords.review.length) {
|
||||
settingStore.wordPracticeType = WordPracticeType.Identify
|
||||
statStore.step = 3
|
||||
data.words = taskWords.review
|
||||
} else {
|
||||
if (taskWords.write.length) {
|
||||
// taskWords = initVal
|
||||
//不能直接赋值,会导致 inject 的数据为默认值
|
||||
taskWords = Object.assign(taskWords, initVal)
|
||||
//如果 shuffle 数组不为空,就说明是复习
|
||||
if (taskWords.shuffle.length === 0) {
|
||||
if (taskWords.new.length === 0) {
|
||||
if (taskWords.review.length) {
|
||||
settingStore.wordPracticeType = WordPracticeType.Identify
|
||||
data.words = taskWords.write
|
||||
statStore.step = 6
|
||||
statStore.step = 3
|
||||
data.words = taskWords.review
|
||||
} else {
|
||||
Toast.warning('没有可学习的单词!')
|
||||
router.push('/word')
|
||||
if (taskWords.write.length) {
|
||||
settingStore.wordPracticeType = WordPracticeType.Identify
|
||||
data.words = taskWords.write
|
||||
statStore.step = 6
|
||||
} else {
|
||||
Toast.warning('没有可学习的单词!')
|
||||
router.push('/word')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settingStore.wordPracticeType = WordPracticeType.FollowWrite
|
||||
data.words = taskWords.new
|
||||
statStore.step = 0
|
||||
}
|
||||
statStore.total = taskWords.review.length + taskWords.new.length + taskWords.write.length
|
||||
statStore.newWordNumber = taskWords.new.length
|
||||
statStore.reviewWordNumber = taskWords.review.length
|
||||
statStore.writeWordNumber = taskWords.write.length
|
||||
} else {
|
||||
settingStore.wordPracticeType = WordPracticeType.FollowWrite
|
||||
data.words = taskWords.new
|
||||
statStore.step = 0
|
||||
settingStore.wordPracticeType = WordPracticeType.Dictation
|
||||
data.words = taskWords.shuffle
|
||||
statStore.step = 10
|
||||
statStore.total = taskWords.shuffle.length
|
||||
statStore.newWordNumber = 0
|
||||
statStore.reviewWordNumber = 0
|
||||
statStore.writeWordNumber = statStore.total
|
||||
}
|
||||
|
||||
data.index = 0
|
||||
data.wrongWords = []
|
||||
data.excludeWords = []
|
||||
@@ -152,11 +169,6 @@ function initData(initVal: TaskWords, init: boolean = false) {
|
||||
statStore.startDate = Date.now()
|
||||
statStore.inputWordNumber = 0
|
||||
statStore.wrong = 0
|
||||
statStore.total = taskWords.review.length + taskWords.new.length + taskWords.write.length
|
||||
statStore.newWordNumber = taskWords.new.length
|
||||
statStore.reviewWordNumber = taskWords.review.length
|
||||
statStore.writeWordNumber = taskWords.write.length
|
||||
statStore.index = 0
|
||||
isTypingWrongWord.value = false
|
||||
}
|
||||
}
|
||||
@@ -194,22 +206,29 @@ watch(() => settingStore.wordPracticeType, (n) => {
|
||||
}
|
||||
}, {immediate: true})
|
||||
|
||||
const groupSize = 7
|
||||
|
||||
function wordLoop() {
|
||||
// return data.index++
|
||||
let d = Math.floor(data.index / 6) - 1
|
||||
if (data.index > 0 && data.index % 6 === (d < 0 ? 0 : d)) {
|
||||
if (settingStore.wordPracticeType === WordPracticeType.FollowWrite) {
|
||||
// 学习模式
|
||||
if (settingStore.wordPracticeType === WordPracticeType.FollowWrite) {
|
||||
data.index++
|
||||
// 到达一个组末尾,就切换到拼写模式
|
||||
if (data.index % groupSize === 0) {
|
||||
settingStore.wordPracticeType = WordPracticeType.Spell
|
||||
data.index -= 6
|
||||
} else {
|
||||
settingStore.wordPracticeType = WordPracticeType.FollowWrite
|
||||
data.index++
|
||||
data.index -= groupSize // 回到刚学单词开头
|
||||
}
|
||||
} else {
|
||||
// 拼写模式
|
||||
data.index++
|
||||
// 拼写走完一组,切回跟写模式
|
||||
if (data.index % groupSize === 0) {
|
||||
settingStore.wordPracticeType = WordPracticeType.FollowWrite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let toastInstance: ToastInstance = null
|
||||
|
||||
function goNextStep(originList, mode, msg) {
|
||||
//每次都判断,因为每次都可能新增已掌握的单词
|
||||
let list = originList.filter(v => (!data.excludeWords.includes(v.word)))
|
||||
@@ -228,12 +247,8 @@ function goNextStep(originList, mode, msg) {
|
||||
}
|
||||
}
|
||||
|
||||
let toastInstance: ToastInstance = null
|
||||
|
||||
async function next(isTyping: boolean = true) {
|
||||
if (isTyping) {
|
||||
statStore.inputWordNumber++
|
||||
}
|
||||
if (isTyping) statStore.inputWordNumber++
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.Free) {
|
||||
if (data.index === data.words.length - 1) {
|
||||
data.wrongWords = data.wrongWords.filter(v => (!data.excludeWords.includes(v.word)))
|
||||
@@ -244,7 +259,7 @@ async function next(isTyping: boolean = true) {
|
||||
data.words = shuffle(cloneDeep(data.wrongWords))
|
||||
data.index = 0
|
||||
data.wrongWords = []
|
||||
}else {
|
||||
} else {
|
||||
console.log('自由模式,全完学完了')
|
||||
showStatDialog = true
|
||||
localStorage.removeItem(PracticeSaveWordKey.key)
|
||||
@@ -256,19 +271,8 @@ async function next(isTyping: boolean = true) {
|
||||
if (data.index === data.words.length - 1) {
|
||||
if (statStore.step === 0 || isTypingWrongWord.value) {
|
||||
if (settingStore.wordPracticeType !== WordPracticeType.Spell) {
|
||||
let i = data.index
|
||||
i--
|
||||
let d = Math.floor(i / 6) - 1
|
||||
while (i % 6 !== (d < 0 ? 0 : d)) {
|
||||
i--
|
||||
d = Math.floor(i / 6) - 1
|
||||
}
|
||||
console.log('i', i)
|
||||
if (i <= 0) i = -1
|
||||
if (i + 1 == data.index) {
|
||||
data.index = 0
|
||||
}
|
||||
data.index = i + 1
|
||||
//回到最后一组的开始位置
|
||||
data.index = Math.floor(data.index / groupSize) * groupSize
|
||||
emitter.emit(EventKey.resetWord)
|
||||
settingStore.wordPracticeType = WordPracticeType.Spell
|
||||
return
|
||||
@@ -304,9 +308,9 @@ async function next(isTyping: boolean = true) {
|
||||
return goNextStep(shuffle(taskWords.write), WordPracticeType.Listen, '开始听写之前')
|
||||
}
|
||||
|
||||
//开始复写之前
|
||||
//开始辨认之前
|
||||
if (statStore.step === 5) {
|
||||
return goNextStep(taskWords.write, WordPracticeType.Identify, '开始复写之前')
|
||||
return goNextStep(taskWords.write, WordPracticeType.Identify, '开始辨认之前')
|
||||
}
|
||||
|
||||
//开始默写上次
|
||||
@@ -319,9 +323,9 @@ async function next(isTyping: boolean = true) {
|
||||
return goNextStep(shuffle(taskWords.review), WordPracticeType.Listen, '开始听写上次')
|
||||
}
|
||||
|
||||
//开始复写昨日
|
||||
//开始辨认昨日
|
||||
if (statStore.step === 2) {
|
||||
return goNextStep(taskWords.review, WordPracticeType.Identify, '开始复写昨日')
|
||||
return goNextStep(taskWords.review, WordPracticeType.Identify, '开始辨认昨日')
|
||||
}
|
||||
|
||||
//开始默写新词
|
||||
@@ -346,6 +350,13 @@ async function next(isTyping: boolean = true) {
|
||||
savePracticeData()
|
||||
}
|
||||
|
||||
function skipStep(){
|
||||
data.index = data.words.length - 1
|
||||
settingStore.wordPracticeType = WordPracticeType.Spell
|
||||
data.wrongWords = []
|
||||
next(false)
|
||||
}
|
||||
|
||||
function onWordKnow() {
|
||||
//标记模式时,用户认识的单词加入到排除里面,后续不再复习
|
||||
let rIndex = data.excludeWords.findIndex(v => v === word.word)
|
||||
@@ -388,7 +399,7 @@ function onKeyUp(e: KeyboardEvent) {
|
||||
typingRef.hideWord()
|
||||
}
|
||||
|
||||
async function onKeyDown(e: KeyboardEvent) {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
// console.log('onKeyDown', e)
|
||||
switch (e.key) {
|
||||
case 'Backspace':
|
||||
@@ -401,21 +412,27 @@ useOnKeyboardEventListener(onKeyDown, onKeyUp)
|
||||
|
||||
function repeat() {
|
||||
console.log('重学一遍')
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
|
||||
if (store.sdict.lastLearnIndex === 0 && store.sdict.complete) {
|
||||
//如果是刚刚完成,那么学习进度要从length减回去,因为lastLearnIndex为0了,同时改complete为false
|
||||
store.sdict.lastLearnIndex = store.sdict.length - statStore.newWordNumber
|
||||
store.sdict.complete = false
|
||||
let temp = cloneDeep(taskWords)
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
//随机练习单独处理
|
||||
if (taskWords.shuffle.length) {
|
||||
temp.shuffle = shuffle(temp.shuffle.filter(v => !ignoreList.includes(v.word)))
|
||||
} else {
|
||||
//将学习进度减回去
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex - statStore.newWordNumber
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
|
||||
if (store.sdict.lastLearnIndex === 0 && store.sdict.complete) {
|
||||
//如果是刚刚完成,那么学习进度要从length减回去,因为lastLearnIndex为0了,同时改complete为false
|
||||
store.sdict.lastLearnIndex = store.sdict.length - statStore.newWordNumber
|
||||
store.sdict.complete = false
|
||||
} else {
|
||||
//将学习进度减回去
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex - statStore.newWordNumber
|
||||
}
|
||||
//排除已掌握单词
|
||||
temp.new = temp.new.filter(v => !ignoreList.includes(v.word))
|
||||
temp.review = temp.review.filter(v => !ignoreList.includes(v.word))
|
||||
temp.write = temp.write.filter(v => !ignoreList.includes(v.word))
|
||||
}
|
||||
emitter.emit(EventKey.resetWord)
|
||||
let temp = cloneDeep(taskWords)
|
||||
//排除已掌握单词
|
||||
temp.new = temp.new.filter(v => !store.knownWords.includes(v.word))
|
||||
temp.review = temp.review.filter(v => !store.knownWords.includes(v.word))
|
||||
temp.write = temp.write.filter(v => !store.knownWords.includes(v.word))
|
||||
initData(temp)
|
||||
}
|
||||
|
||||
@@ -477,16 +494,26 @@ function togglePanel() {
|
||||
}
|
||||
|
||||
function continueStudy() {
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
|
||||
//这里判断是否显示结算弹框,如果显示了结算弹框的话,就不用加进度了
|
||||
if (!showStatDialog) {
|
||||
console.log('没学完,强行跳过')
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
|
||||
let temp = cloneDeep(taskWords)
|
||||
//随机练习单独处理
|
||||
if (taskWords.shuffle.length) {
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
temp.shuffle = shuffle(store.sdict.words.filter(v => !ignoreList.includes(v.word))).slice(0, runtimeStore.routeData.total)
|
||||
if (showStatDialog) showStatDialog = false
|
||||
} else {
|
||||
console.log('学完了,正常下一组')
|
||||
showStatDialog = false
|
||||
if (settingStore.wordPracticeMode === WordPracticeMode.System) settingStore.dictation = false
|
||||
//这里判断是否显示结算弹框,如果显示了结算弹框的话,就不用加进度了
|
||||
if (!showStatDialog) {
|
||||
console.log('没学完,强行跳过')
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
|
||||
} else {
|
||||
console.log('学完了,正常下一组')
|
||||
showStatDialog = false
|
||||
}
|
||||
temp = getCurrentStudyWord()
|
||||
}
|
||||
initData(getCurrentStudyWord())
|
||||
emitter.emit(EventKey.resetWord)
|
||||
initData(temp)
|
||||
}
|
||||
|
||||
function randomWrite() {
|
||||
@@ -533,8 +560,8 @@ useEvents([
|
||||
|
||||
<template>
|
||||
<PracticeLayout
|
||||
v-loading="loading"
|
||||
panelLeft="var(--word-panel-margin-left)">
|
||||
v-loading="loading"
|
||||
panelLeft="var(--word-panel-margin-left)">
|
||||
<template v-slot:practice>
|
||||
<div class="practice-word">
|
||||
<div class="absolute z-1 top-4 w-full" v-if="settingStore.showNearWord">
|
||||
@@ -543,16 +570,16 @@ useEvents([
|
||||
v-if="prevWord">
|
||||
<IconFluentArrowLeft16Regular class="arrow" width="22"/>
|
||||
<Tooltip
|
||||
:title="`上一个(${settingStore.shortcutKeyMap[ShortcutKey.Previous]})`"
|
||||
:title="`上一个(${settingStore.shortcutKeyMap[ShortcutKey.Previous]})`"
|
||||
>
|
||||
<div class="word">{{ prevWord.word }}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div class="center gap-2 cursor-pointer float-right "
|
||||
<div class="center gap-2 cursor-pointer float-right mr-3"
|
||||
@click="next(false)"
|
||||
v-if="nextWord">
|
||||
<Tooltip
|
||||
:title="`下一个(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
|
||||
:title="`下一个(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
|
||||
>
|
||||
<div class="word" :class="settingStore.dictation && 'word-shadow'">{{ nextWord.word }}</div>
|
||||
</Tooltip>
|
||||
@@ -560,11 +587,11 @@ useEvents([
|
||||
</div>
|
||||
</div>
|
||||
<TypeWord
|
||||
ref="typingRef"
|
||||
:word="word"
|
||||
@wrong="onTypeWrong"
|
||||
@complete="next"
|
||||
@know="onWordKnow"
|
||||
ref="typingRef"
|
||||
:word="word"
|
||||
@wrong="onTypeWrong"
|
||||
@complete="next"
|
||||
@know="onWordKnow"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -576,41 +603,41 @@ useEvents([
|
||||
<span>{{ store.sdict.name }} ({{ store.sdict.lastLearnIndex }} / {{ store.sdict.length }})</span>
|
||||
|
||||
<BaseIcon
|
||||
@click="continueStudy"
|
||||
:title="`下一组(${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`">
|
||||
@click="continueStudy"
|
||||
:title="`下一组(${settingStore.shortcutKeyMap[ShortcutKey.NextChapter]})`">
|
||||
<IconFluentArrowRight16Regular class="arrow" width="22"/>
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="randomWrite"
|
||||
:title="`随机默写(${settingStore.shortcutKeyMap[ShortcutKey.RandomWrite]})`">
|
||||
@click="randomWrite"
|
||||
:title="`随机默写(${settingStore.shortcutKeyMap[ShortcutKey.RandomWrite]})`">
|
||||
<IconFluentArrowShuffle16Regular class="arrow" width="22"/>
|
||||
</BaseIcon>
|
||||
</div>
|
||||
</template>
|
||||
<div class="panel-page-item pl-4">
|
||||
<WordList
|
||||
v-if="data.words.length"
|
||||
:is-active="settingStore.showPanel"
|
||||
:static="false"
|
||||
:show-word="!settingStore.dictation"
|
||||
:show-translate="settingStore.translate"
|
||||
:list="data.words"
|
||||
:activeIndex="data.index"
|
||||
@click="(val:any) => data.index = val.index"
|
||||
v-if="data.words.length"
|
||||
:is-active="settingStore.showPanel"
|
||||
:static="false"
|
||||
:show-word="!settingStore.dictation"
|
||||
:show-translate="settingStore.translate"
|
||||
:list="data.words"
|
||||
:activeIndex="data.index"
|
||||
@click="(val:any) => data.index = val.index"
|
||||
>
|
||||
<template v-slot:suffix="{item,index}">
|
||||
<BaseIcon
|
||||
:class="!isWordCollect(item)?'collect':'fill'"
|
||||
@click.stop="toggleWordCollect(item)"
|
||||
:title="!isWordCollect(item) ? '收藏' : '取消收藏'">
|
||||
:class="!isWordCollect(item)?'collect':'fill'"
|
||||
@click.stop="toggleWordCollect(item)"
|
||||
:title="!isWordCollect(item) ? '收藏' : '取消收藏'">
|
||||
<IconFluentStar16Regular v-if="!isWordCollect(item)"/>
|
||||
<IconFluentStar16Filled v-else/>
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
:class="!isWordSimple(item)?'collect':'fill'"
|
||||
@click.stop="toggleWordSimple(item)"
|
||||
:title="!isWordSimple(item) ? '标记为已掌握' : '取消标记已掌握'">
|
||||
:class="!isWordSimple(item)?'collect':'fill'"
|
||||
@click.stop="toggleWordSimple(item)"
|
||||
:title="!isWordSimple(item) ? '标记为已掌握' : '取消标记已掌握'">
|
||||
<IconFluentCheckmarkCircle16Regular v-if="!isWordSimple(item)"/>
|
||||
<IconFluentCheckmarkCircle16Filled v-else/>
|
||||
</BaseIcon>
|
||||
@@ -622,11 +649,12 @@ useEvents([
|
||||
</template>
|
||||
<template v-slot:footer>
|
||||
<Footer
|
||||
:is-simple="isWordSimple(word)"
|
||||
@toggle-simple="toggleWordSimpleWrapper"
|
||||
:is-collect="isWordCollect(word)"
|
||||
@toggle-collect="toggleWordCollect(word)"
|
||||
@skip="next(false)"
|
||||
:is-simple="isWordSimple(word)"
|
||||
@toggle-simple="toggleWordSimpleWrapper"
|
||||
:is-collect="isWordCollect(word)"
|
||||
@toggle-collect="toggleWordCollect(word)"
|
||||
@skip="next(false)"
|
||||
@skipStep="skipStep"
|
||||
/>
|
||||
</template>
|
||||
</PracticeLayout>
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import {ShortcutKey, Statistics} from "@/types/types.ts";
|
||||
import {PracticeData, ShortcutKey, Statistics, TaskWords, WordPracticeMode} from "@/types/types.ts";
|
||||
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {usePracticeStore} from "@/stores/practice.ts";
|
||||
import dayjs from "dayjs";
|
||||
import isBetween from "dayjs/plugin/isBetween";
|
||||
import {defineAsyncComponent, watch} from "vue";
|
||||
import {defineAsyncComponent, inject, watch} from "vue";
|
||||
import isoWeek from 'dayjs/plugin/isoWeek'
|
||||
import {msToHourMinute, msToMinute} from "@/utils";
|
||||
|
||||
dayjs.extend(isoWeek)
|
||||
dayjs.extend(isBetween);
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
|
||||
const store = useBaseStore()
|
||||
const settingStore = useSettingStore()
|
||||
const statStore = usePracticeStore()
|
||||
const model = defineModel({default: false})
|
||||
let list = $ref([])
|
||||
let dictIsEnd = $ref(false)
|
||||
let practiceTaskWords = inject<TaskWords>('practiceTaskWords')
|
||||
|
||||
function calcWeekList() {
|
||||
// 获取本周的起止时间
|
||||
@@ -68,12 +69,16 @@ watch(model, (newVal) => {
|
||||
complete: store.sdict.complete,
|
||||
str: `name:${store.sdict.name},per:${store.sdict.perDayStudyNumber},spend:${Number(statStore.spend / 1000 / 60).toFixed(1)},index:${store.sdict.lastLearnIndex}`
|
||||
})
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
|
||||
if (store.sdict.lastLearnIndex >= store.sdict.length) {
|
||||
dictIsEnd = true;
|
||||
store.sdict.complete = true
|
||||
store.sdict.lastLearnIndex = 0
|
||||
//如果 shuffle 数组不为空,就说明是复习,不用修改 lastLearnIndex
|
||||
if (!practiceTaskWords.shuffle.length) {
|
||||
store.sdict.lastLearnIndex = store.sdict.lastLearnIndex + statStore.newWordNumber
|
||||
if (store.sdict.lastLearnIndex >= store.sdict.length) {
|
||||
dictIsEnd = true;
|
||||
store.sdict.complete = true
|
||||
store.sdict.lastLearnIndex = 0
|
||||
}
|
||||
}
|
||||
|
||||
store.sdict.statistics.push(data as any)
|
||||
calcWeekList(); // 新增:计算本周学习记录
|
||||
}
|
||||
@@ -97,33 +102,41 @@ function options(emitType: string) {
|
||||
|
||||
<template>
|
||||
<Dialog
|
||||
:close-on-click-bg="false"
|
||||
:header="false"
|
||||
:keyboard="false"
|
||||
:show-close="false"
|
||||
v-model="model">
|
||||
:close-on-click-bg="false"
|
||||
: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">已完成今日任务</div>
|
||||
<div class="center text-2xl mb-2">已完成{{ practiceTaskWords.shuffle.length ? '随机复习' : '今日任务' }}</div>
|
||||
<div class="flex">
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="text-xl text-center flex flex-col justify-around">
|
||||
<div>非常棒! 坚持了 <span class="color-emerald-500 font-bold text-2xl">
|
||||
{{ dayjs().diff(statStore.startDate, 'm') }}</span>分钟
|
||||
<div>非常棒! 坚持了 <span class="color-emerald-500 font-bold text-2xl">{{msToHourMinute(statStore.spend) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center gap-10">
|
||||
@@ -149,29 +162,29 @@ function options(emitType: string) {
|
||||
<div class="title text-align-center mb-2">本周学习记录</div>
|
||||
<div class="flex gap-4 color-gray">
|
||||
<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"
|
||||
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 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center gap-4 ">
|
||||
<BaseButton
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
|
||||
@click="options(EventKey.repeatStudy)">
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.RepeatChapter]"
|
||||
@click="options(EventKey.repeatStudy)">
|
||||
重学一遍
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
|
||||
@click="options(EventKey.continueStudy)">
|
||||
{{ dictIsEnd ? '重新练习' : '再来一组' }}
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextChapter]"
|
||||
@click="options(EventKey.continueStudy)">
|
||||
{{ dictIsEnd ? '从头开始练习' : '再来一组' }}
|
||||
</BaseButton>
|
||||
<BaseButton
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextRandomWrite]"
|
||||
@click="options(EventKey.randomWrite)">
|
||||
继续默写
|
||||
:keyboard="settingStore.shortcutKeyMap[ShortcutKey.NextRandomWrite]"
|
||||
@click="options(EventKey.randomWrite)">
|
||||
继续默写
|
||||
</BaseButton>
|
||||
<BaseButton @click="$router.back">
|
||||
返回主页
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
import { useBaseStore } from "@/stores/base.ts";
|
||||
import { useRouter } from "vue-router";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import { _getAccomplishDate, _getDictDataByUrl, resourceWrap, useNav } from "@/utils";
|
||||
import { _getAccomplishDate, _getDictDataByUrl, resourceWrap, shuffle, useNav } from "@/utils";
|
||||
import BasePage from "@/components/BasePage.vue";
|
||||
import {DictResource, WordPracticeMode} from "@/types/types.ts";
|
||||
import { DictResource, WordPracticeMode } from "@/types/types.ts";
|
||||
import { watch } from "vue";
|
||||
import { getCurrentStudyWord } from "@/hooks/dict.ts";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
@@ -18,11 +18,11 @@ import DeleteIcon from "@/components/icon/DeleteIcon.vue";
|
||||
import PracticeSettingDialog from "@/pages/word/components/PracticeSettingDialog.vue";
|
||||
import ChangeLastPracticeIndexDialog from "@/pages/word/components/ChangeLastPracticeIndexDialog.vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import CollectNotice from "@/components/CollectNotice.vue";
|
||||
import { useFetch } from "@vueuse/core";
|
||||
import { CAN_REQUEST, DICT_LIST, PracticeSaveWordKey } from "@/config/env.ts";
|
||||
import { AppEnv, DICT_LIST, PracticeSaveWordKey } from "@/config/env.ts";
|
||||
import { myDictList } from "@/apis";
|
||||
import PracticeWordListDialog from "@/pages/word/components/PracticeWordListDialog.vue";
|
||||
import ShufflePracticeSettingDialog from "@/pages/word/components/ShufflePracticeSettingDialog.vue";
|
||||
|
||||
|
||||
const store = useBaseStore()
|
||||
@@ -35,7 +35,8 @@ let isSaveData = $ref(false)
|
||||
let currentStudy = $ref({
|
||||
new: [],
|
||||
review: [],
|
||||
write: []
|
||||
write: [],
|
||||
shuffle: [],
|
||||
})
|
||||
|
||||
watch(() => store.load, n => {
|
||||
@@ -43,7 +44,7 @@ watch(() => store.load, n => {
|
||||
}, {immediate: true})
|
||||
|
||||
async function init() {
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await myDictList({type: "word"})
|
||||
if (res.success) {
|
||||
store.setState(Object.assign(store.$state, res.data))
|
||||
@@ -85,7 +86,9 @@ function startPractice() {
|
||||
complete: store.sdict.complete,
|
||||
wordPracticeMode: settingStore.wordPracticeMode
|
||||
})
|
||||
nav('practice-words/' + store.sdict.id, {}, currentStudy)
|
||||
//把是否是第一次设置为false
|
||||
settingStore.first = false
|
||||
nav('practice-words/' + store.sdict.id, {}, {taskWords: currentStudy})
|
||||
} else {
|
||||
window.umami?.track('no-dict')
|
||||
Toast.warning('请先选择一本词典')
|
||||
@@ -93,15 +96,17 @@ function startPractice() {
|
||||
}
|
||||
|
||||
let showPracticeSettingDialog = $ref(false)
|
||||
let showShufflePracticeSettingDialog = $ref(false)
|
||||
let showChangeLastPracticeIndexDialog = $ref(false)
|
||||
let showPracticeWordListDialog = $ref(false)
|
||||
|
||||
async function goDictDetail(val: DictResource) {
|
||||
if (!val.id) return nav('dict-list')
|
||||
runtimeStore.editDict = getDefaultDict(val)
|
||||
nav('dict-detail', {})
|
||||
}
|
||||
|
||||
let isMultiple = $ref(false)
|
||||
let isManageDict = $ref(false)
|
||||
let selectIds = $ref([])
|
||||
|
||||
function handleBatchDel() {
|
||||
@@ -156,6 +161,26 @@ async function savePracticeSetting() {
|
||||
currentStudy = getCurrentStudyWord()
|
||||
}
|
||||
|
||||
async function onShufflePracticeSettingOk(total) {
|
||||
window.umami?.track('startShuffleStudyWord', {
|
||||
name: store.sdict.name,
|
||||
index: store.sdict.lastLearnIndex,
|
||||
perDayStudyNumber: store.sdict.perDayStudyNumber,
|
||||
total,
|
||||
custom: store.sdict.custom,
|
||||
complete: store.sdict.complete,
|
||||
})
|
||||
isSaveData = false
|
||||
localStorage.removeItem(PracticeSaveWordKey.key)
|
||||
|
||||
let ignoreList = [store.allIgnoreWords, store.knownWords][settingStore.ignoreSimpleWord ? 0 : 1]
|
||||
currentStudy.shuffle = shuffle(store.sdict.words.slice(0, store.sdict.lastLearnIndex).filter(v => !ignoreList.includes(v.word))).slice(0, total)
|
||||
nav('practice-words/' + store.sdict.id, {}, {
|
||||
taskWords: currentStudy,
|
||||
total //用于再来一组时,随机出正确的长度,因为练习中可能会点击已掌握,导致重学一遍之后长度变少,如果再来一组,此时长度就不正确
|
||||
})
|
||||
}
|
||||
|
||||
async function saveLastPracticeIndex(e) {
|
||||
Toast.success('修改成功')
|
||||
runtimeStore.editDict.lastLearnIndex = e
|
||||
@@ -171,97 +196,195 @@ const {
|
||||
isFetching
|
||||
} = useFetch(resourceWrap(DICT_LIST.WORD.RECOMMENDED)).json()
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BasePage>
|
||||
<div class="card flex gap-10 words-page-main">
|
||||
<div class="flex-1 flex flex-col gap-2">
|
||||
<div class="flex">
|
||||
<div class="bg-third px-3 h-14 rounded-md flex items-center dict-selector">
|
||||
<span @click="goDictDetail(store.sdict)"
|
||||
class="text-lg font-bold cursor-pointer">{{ store.sdict.name || '请选择词典开始学习' }}</span>
|
||||
<BaseIcon title="切换词典"
|
||||
class="ml-4"
|
||||
@click="router.push('/dict-list')"
|
||||
>
|
||||
<IconFluentArrowSort20Regular v-if="store.sdict.name"/>
|
||||
<IconFluentAdd20Filled v-else/>
|
||||
</BaseIcon>
|
||||
<div class="card flex gap-8">
|
||||
<div class="flex-1 flex flex-col justify-between">
|
||||
<div class="flex gap-3">
|
||||
<div class="p-1 center rounded-full bg-white">
|
||||
<IconFluentBookNumber20Filled class="text-xl color-link"/>
|
||||
</div>
|
||||
<div
|
||||
@click="goDictDetail(store.sdict)"
|
||||
class="text-2xl font-bold cursor-pointer">
|
||||
{{ store.sdict.name || '当前无正在学习的词典' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end gap-space progress-section">
|
||||
<div class="flex-1">
|
||||
<div class="text-sm flex justify-between">
|
||||
<span>{{ progressTextLeft }}</span>
|
||||
<span>{{ progressTextRight }} / {{ store.sdict.words.length }}</span>
|
||||
</div>
|
||||
<Progress class="mt-1" :percentage="store.currentStudyProgress" :show-text="false"></Progress>
|
||||
</div>
|
||||
<PopConfirm
|
||||
:disabled="!isSaveData"
|
||||
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
|
||||
@confirm="check(()=>showChangeLastPracticeIndexDialog = true)">
|
||||
<div class="color-blue cursor-pointer">更改</div>
|
||||
</PopConfirm>
|
||||
|
||||
</div>
|
||||
<div class="text-sm text-align-end completion-date">
|
||||
预计完成日期:{{ _getAccomplishDate(store.sdict.words.length, store.sdict.perDayStudyNumber) }}
|
||||
<template v-if="store.sdict.id">
|
||||
<div class="mt-4 flex flex-col gap-2">
|
||||
<div class="">当前进度:{{ progressTextLeft }}</div>
|
||||
<Progress size="large" :percentage="store.currentStudyProgress" :show-text="false"></Progress>
|
||||
<div class="text-sm flex justify-between">
|
||||
<span>已完成 {{ progressTextRight }} 词 / 共 {{ store.sdict.words.length }} 词</span>
|
||||
<span v-if="store.sdict.id">
|
||||
预计完成日期:{{ _getAccomplishDate(store.sdict.words.length, store.sdict.perDayStudyNumber) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center mt-4 gap-4">
|
||||
<BaseButton type="info"
|
||||
size="small"
|
||||
@click="router.push('/dict-list')">
|
||||
<div class="center gap-1">
|
||||
<IconFluentArrowSwap20Regular/>
|
||||
<span>选择词典</span>
|
||||
</div>
|
||||
</BaseButton>
|
||||
<PopConfirm
|
||||
:disabled="!isSaveData"
|
||||
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
|
||||
@confirm="check(()=>showChangeLastPracticeIndexDialog = true)">
|
||||
<BaseButton type="info"
|
||||
size="small"
|
||||
v-if="store.sdict.id"
|
||||
>
|
||||
<div class="center gap-1">
|
||||
<IconFluentSlideTextTitleEdit20Regular/>
|
||||
<span>更改进度</span>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</PopConfirm>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex items-center gap-4 mt-2 flex-1" v-else>
|
||||
<div class="title">请选择一本词典开始学习</div>
|
||||
<BaseButton type="primary" size="large" @click="router.push('/dict-list')">
|
||||
<div class="center gap-1">
|
||||
<IconFluentAdd16Regular/>
|
||||
<span>选择词典</span>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-3/10 flex flex-col justify-evenly task-section">
|
||||
<div class="center gap-2">
|
||||
<span class="text-xl">{{ isSaveData ? '上次学习任务' : '今日任务' }}</span>
|
||||
<span class="color-blue cursor-pointer" @click="showPracticeWordListDialog = true">词表</span>
|
||||
<div class="flex-1" :class="!store.sdict.id && 'opacity-30 cursor-not-allowed'">
|
||||
<div class="flex justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="p-2 center rounded-full bg-white ">
|
||||
<IconFluentStar20Filled class="text-lg color-amber"/>
|
||||
</div>
|
||||
<div class="text-xl font-bold">
|
||||
{{ isSaveData ? '上次任务' : '今日任务' }}
|
||||
</div>
|
||||
<span class="color-link cursor-pointer"
|
||||
v-if="store.sdict.id"
|
||||
@click="showPracticeWordListDialog = true">词表</span>
|
||||
|
||||
</div>
|
||||
<div class="flex gap-1 items-center"
|
||||
v-if="store.sdict.id"
|
||||
>
|
||||
每日目标
|
||||
<div style="color:#ac6ed1;"
|
||||
class="bg-third px-2 h-10 flex center text-2xl rounded">
|
||||
{{ store.sdict.id ? store.sdict.perDayStudyNumber : 0 }}
|
||||
</div>
|
||||
个单词
|
||||
<PopConfirm
|
||||
:disabled="!isSaveData"
|
||||
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
|
||||
@confirm="check(()=>showPracticeSettingDialog = true)">
|
||||
<BaseButton
|
||||
type="info" size="small">更改
|
||||
</BaseButton>
|
||||
</PopConfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex task-numbers">
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="text-4xl font-bold">{{ currentStudy.new.length }}</div>
|
||||
<div class="text">新词</div>
|
||||
<div class="flex mt-4 justify-between">
|
||||
<div class="stat">
|
||||
<div class="num">{{ currentStudy.new.length }}</div>
|
||||
<div class="txt">新词数</div>
|
||||
</div>
|
||||
<template v-if="settingStore.wordPracticeMode === WordPracticeMode.System">
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="text-4xl font-bold">{{ currentStudy.review.length }}</div>
|
||||
<div class="text">复习上次</div>
|
||||
<div class="stat">
|
||||
<div class="num">{{ currentStudy.review.length }}</div>
|
||||
<div class="txt">复习上次</div>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<div class="text-4xl font-bold">{{ currentStudy.write.length }}
|
||||
</div>
|
||||
<div class="text">复习之前</div>
|
||||
<div class="stat">
|
||||
<div class="num">{{ currentStudy.write.length }}</div>
|
||||
<div class="txt">复习之前</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-end mt-4">
|
||||
<BaseButton size="large"
|
||||
class="flex-1"
|
||||
:disabled="!store.sdict.id"
|
||||
:loading="loading"
|
||||
@click="startPractice">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
|
||||
<IconFluentArrowCircleRight16Regular class="text-xl"/>
|
||||
</div>
|
||||
</BaseButton>
|
||||
|
||||
<div class="flex flex-col items-end justify-around settings-section">
|
||||
<div class="flex gap-1 items-center daily-goal">
|
||||
每日目标
|
||||
<div style="color:#ac6ed1;"
|
||||
class="bg-third px-2 h-10 flex center text-2xl rounded">
|
||||
{{ store.sdict.id ? store.sdict.perDayStudyNumber : 0 }}
|
||||
<div
|
||||
v-if="false"
|
||||
class="w-full flex box-border cp color-white">
|
||||
<div
|
||||
@click="startPractice"
|
||||
class="flex-1 rounded-l-lg center gap-2 py-1 bg-[var(--btn-primary)] hover:opacity-50">
|
||||
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
|
||||
<IconFluentArrowCircleRight16Regular class="text-xl"/>
|
||||
</div>
|
||||
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="w-10 rounded-r-lg h-full center bg-[var(--btn-primary)] hover:bg-gray border-solid border-2 border-l-gray border-transparent box-border">
|
||||
<IconFluentChevronDown20Regular/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="space-y-2 pt-2 absolute z-2 right-0 border rounded opacity-0 scale-95
|
||||
group-hover:opacity-100 group-hover:scale-100
|
||||
transition-all duration-150 pointer-events-none group-hover:pointer-events-auto"
|
||||
>
|
||||
<div>
|
||||
<BaseButton
|
||||
size="large" type="orange"
|
||||
:loading="loading"
|
||||
@click="check(()=>showShufflePracticeSettingDialog = true)">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">随机复习</span>
|
||||
<IconFluentArrowShuffle20Filled class="text-xl"/>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
<div>
|
||||
<BaseButton
|
||||
size="large" type="orange"
|
||||
:loading="loading"
|
||||
@click="check(()=>showShufflePracticeSettingDialog = true)">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">重新学习</span>
|
||||
<IconFluentArrowShuffle20Filled class="text-xl"/>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
个单词
|
||||
<PopConfirm
|
||||
:disabled="!isSaveData"
|
||||
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
|
||||
@confirm="check(()=>showPracticeSettingDialog = true)">
|
||||
<span class="color-blue cursor-pointer">更改</span>
|
||||
</PopConfirm>
|
||||
|
||||
<BaseButton
|
||||
v-if="store.sdict.id && store.sdict.lastLearnIndex"
|
||||
size="large" type="orange"
|
||||
:loading="loading"
|
||||
@click="check(()=>showShufflePracticeSettingDialog = true)">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">随机复习</span>
|
||||
<IconFluentArrowShuffle20Filled class="text-xl"/>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
<BaseButton size="large" :disabled="!store.sdict.name"
|
||||
:loading="loading"
|
||||
@click="startPractice">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="line-height-[2]">{{ isSaveData ? '继续学习' : '开始学习' }}</span>
|
||||
<IconFluentArrowCircleRight16Regular class="text-xl"/>
|
||||
</div>
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col">
|
||||
<div class="card flex flex-col">
|
||||
<div class="flex justify-between">
|
||||
<div class="title">我的词典</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
@@ -271,25 +394,25 @@ const {
|
||||
</BaseIcon>
|
||||
</PopConfirm>
|
||||
|
||||
<div class="color-blue cursor-pointer" v-if="store.word.bookList.length > 3"
|
||||
@click="isMultiple = !isMultiple; selectIds = []">{{ isMultiple ? '取消' : '管理词典' }}
|
||||
<div class="color-link cursor-pointer" v-if="store.word.bookList.length > 3"
|
||||
@click="isManageDict = !isManageDict; selectIds = []">{{ isManageDict ? '取消' : '管理词典' }}
|
||||
</div>
|
||||
<div class="color-blue cursor-pointer" @click="nav('dict-detail', { isAdd: true })">创建个人词典</div>
|
||||
<div class="color-link cursor-pointer" @click="nav('dict-detail', { isAdd: true })">创建个人词典</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 flex-wrap mt-4">
|
||||
<Book :is-add="false" quantifier="个词" :item="item" :checked="selectIds.includes(item.id)"
|
||||
@check="() => toggleSelect(item)" :show-checkbox="isMultiple && j >= 3"
|
||||
@check="() => toggleSelect(item)" :show-checkbox="isManageDict && j >= 3"
|
||||
v-for="(item, j) in store.word.bookList" @click="goDictDetail(item)"/>
|
||||
<Book :is-add="true" @click="router.push('/dict-list')"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card flex flex-col overflow-hidden" v-loading="isFetching">
|
||||
<div class="card flex flex-col overflow-hidden" v-loading="isFetching">
|
||||
<div class="flex justify-between">
|
||||
<div class="title">推荐</div>
|
||||
<div class="flex gap-4 items-center">
|
||||
<div class="color-blue cursor-pointer" @click="router.push('/dict-list')">更多</div>
|
||||
<div class="color-link cursor-pointer" @click="router.push('/dict-list')">更多</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -317,324 +440,23 @@ const {
|
||||
v-model="showPracticeWordListDialog"
|
||||
/>
|
||||
|
||||
<CollectNotice/>
|
||||
<ShufflePracticeSettingDialog
|
||||
v-model="showShufflePracticeSettingDialog"
|
||||
@ok="onShufflePracticeSettingOk"/>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
// 移动端适配
|
||||
@media (max-width: 768px) {
|
||||
.words-page-main {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
.dict-selector {
|
||||
padding: 0.5rem 0.8rem;
|
||||
height: auto;
|
||||
min-height: 3rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
|
||||
span {
|
||||
font-size: 1rem;
|
||||
flex: 1;
|
||||
word-break: break-word;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.base-icon {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 11;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
|
||||
.flex-1 {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.color-blue {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.completion-date {
|
||||
text-align: left;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.task-section {
|
||||
width: 100%;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--color-item-border);
|
||||
border-bottom: 1px solid var(--color-item-border);
|
||||
|
||||
.center {
|
||||
margin-bottom: 1rem;
|
||||
|
||||
span:first-child {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.color-blue {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.task-numbers {
|
||||
gap: 1rem;
|
||||
|
||||
.flex-1 {
|
||||
.text-4xl {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
|
||||
.daily-goal {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
text-align: center;
|
||||
|
||||
.bg-third {
|
||||
padding: 0.3rem 0.8rem;
|
||||
height: auto;
|
||||
min-height: 2.5rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.color-blue {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.base-button {
|
||||
width: 100%;
|
||||
padding: 0.8rem;
|
||||
font-size: 1rem;
|
||||
min-height: 48px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 我的词典和推荐部分
|
||||
.card.flex.flex-col {
|
||||
.flex.justify-between {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
|
||||
.title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.flex.gap-4 {
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.color-blue {
|
||||
font-size: 0.8rem;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.base-icon {
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flex.gap-4.flex-wrap {
|
||||
// 改为grid布局,自适应列数
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(6rem, 1fr));
|
||||
gap: 0.8rem;
|
||||
|
||||
.book {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 8rem; // 增加最小高度,确保有足够空间
|
||||
padding: 0.6rem;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
|
||||
> div:first-child {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
padding-bottom: 2.5rem; // 为底部元素留出空间
|
||||
}
|
||||
|
||||
.text-base, .title {
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.3;
|
||||
word-break: break-word;
|
||||
margin-bottom: 0.4rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3; // 标题最多3行
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
// 移动端隐藏描述
|
||||
.text-sm, .sub-title {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.absolute.bottom-4 {
|
||||
position: absolute;
|
||||
bottom: 2.2rem;
|
||||
right: 0.6rem;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.absolute.bottom-2 {
|
||||
position: absolute;
|
||||
bottom: 0.4rem;
|
||||
left: 0.6rem;
|
||||
right: 0.6rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.stat {
|
||||
@apply w-31% box-border flex flex-col items-center justify-center rounded-xl p-2 bg-[var(--bg-history)];
|
||||
border: 1px solid gainsboro;
|
||||
|
||||
// 超小屏幕适配
|
||||
@media (max-width: 480px) {
|
||||
.words-page-main {
|
||||
gap: 0.8rem;
|
||||
|
||||
.dict-selector {
|
||||
padding: 0.4rem 0.6rem;
|
||||
|
||||
span {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.task-section {
|
||||
padding: 0.8rem 0;
|
||||
|
||||
.center {
|
||||
margin-bottom: 0.8rem;
|
||||
|
||||
span:first-child {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
.task-numbers {
|
||||
gap: 0.8rem;
|
||||
|
||||
.flex-1 {
|
||||
.text-4xl {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
gap: 0.8rem;
|
||||
|
||||
.daily-goal {
|
||||
.bg-third {
|
||||
padding: 0.2rem 0.6rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
.num {
|
||||
@apply color-[#409eff] text-4xl font-bold;
|
||||
}
|
||||
|
||||
.card.flex.flex-col {
|
||||
.flex.gap-4.flex-wrap {
|
||||
// 窄屏最小1-2列
|
||||
grid-template-columns: repeat(auto-fill, minmax(5rem, 1fr));
|
||||
gap: 0.6rem;
|
||||
|
||||
.book {
|
||||
min-height: 7rem;
|
||||
padding: 0.5rem;
|
||||
|
||||
> div:first-child {
|
||||
padding-bottom: 2.2rem;
|
||||
}
|
||||
|
||||
.text-base, .title {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
-webkit-line-clamp: 2; // 超小屏标题最多2行
|
||||
}
|
||||
|
||||
.absolute.bottom-4 {
|
||||
font-size: 0.65rem;
|
||||
bottom: 2rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
.absolute.bottom-2 {
|
||||
bottom: 0.3rem;
|
||||
left: 0.5rem;
|
||||
right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.txt {
|
||||
@apply color-gray-500;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,12 +3,12 @@
|
||||
import { inject, Ref, watch } from "vue"
|
||||
import { usePracticeStore } from "@/stores/practice.ts";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import { PracticeData, WordPracticeType, ShortcutKey } from "@/types/types.ts";
|
||||
import { PracticeData, WordPracticeType, ShortcutKey, TaskWords } from "@/types/types.ts";
|
||||
import BaseIcon from "@/components/BaseIcon.vue";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import Progress from '@/components/base/Progress.vue'
|
||||
|
||||
const statisticsStore = usePracticeStore()
|
||||
const statStore = usePracticeStore()
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
defineProps<{
|
||||
@@ -22,6 +22,7 @@ const emit = defineEmits<{
|
||||
toggleSimple: [],
|
||||
edit: [],
|
||||
skip: [],
|
||||
skipStep:[]
|
||||
}>()
|
||||
|
||||
let practiceData = inject<PracticeData>('practiceData')
|
||||
@@ -33,8 +34,12 @@ function format(val: number, suffix: string = '', check: number = -1) {
|
||||
|
||||
const status = $computed(() => {
|
||||
if (isTypingWrongWord.value) return '复习错词'
|
||||
return getStepStr(statStore.step)
|
||||
})
|
||||
|
||||
function getStepStr(step: number) {
|
||||
let str = ''
|
||||
switch (statisticsStore.step) {
|
||||
switch (step) {
|
||||
case 0:
|
||||
str += `学习新词`
|
||||
break
|
||||
@@ -45,7 +50,7 @@ const status = $computed(() => {
|
||||
str += `默写新词`
|
||||
break
|
||||
case 3:
|
||||
str += `复习上次学习`
|
||||
str += `辨认上次学习`
|
||||
break
|
||||
case 4:
|
||||
str += '听写上次学习'
|
||||
@@ -54,7 +59,7 @@ const status = $computed(() => {
|
||||
str += '默写上次学习'
|
||||
break
|
||||
case 6:
|
||||
str += '复习之前学习'
|
||||
str += '辨认之前学习'
|
||||
break
|
||||
case 7:
|
||||
str += '听写之前学习'
|
||||
@@ -62,9 +67,15 @@ const status = $computed(() => {
|
||||
case 8:
|
||||
str += '默写之前学习'
|
||||
break
|
||||
case 9:
|
||||
str += '学习完成'
|
||||
break
|
||||
case 10:
|
||||
str += '随机复习'
|
||||
break
|
||||
}
|
||||
return str
|
||||
})
|
||||
}
|
||||
|
||||
const progress = $computed(() => {
|
||||
if (!practiceData.words.length) return 0
|
||||
@@ -96,22 +107,29 @@ const progress = $computed(() => {
|
||||
<div class="name">{{ status }}</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="num">{{ statisticsStore.total }}</div>
|
||||
<div class="num">{{ statStore.total }}</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">单词总数</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="num">{{ format(statisticsStore.inputWordNumber, '', 0) }}</div>
|
||||
<div class="num">{{ format(statStore.inputWordNumber, '', 0) }}</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">总输入数</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="num">{{ format(statisticsStore.wrong, '', 0) }}</div>
|
||||
<div class="num">{{ format(statStore.wrong, '', 0) }}</div>
|
||||
<div class="line"></div>
|
||||
<div class="name">总错误数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-center items-center">
|
||||
<div class="flex gap-2 justify-center items-center">
|
||||
<BaseIcon
|
||||
v-if="statStore.step < 9"
|
||||
@click="emit('skipStep')"
|
||||
:title="`跳到下一阶段:${getStepStr(statStore.step+1)}`">
|
||||
<IconFluentArrowRight16Regular/>
|
||||
</BaseIcon>
|
||||
|
||||
<BaseIcon
|
||||
:class="!isSimple?'collect':'fill'"
|
||||
@click="$emit('toggleSimple')"
|
||||
@@ -129,7 +147,7 @@ const progress = $computed(() => {
|
||||
</BaseIcon>
|
||||
<BaseIcon
|
||||
@click="emit('skip')"
|
||||
:title="`跳过(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`">
|
||||
:title="`跳过当前单词(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`">
|
||||
<IconFluentArrowBounce20Regular class="transform-rotate-180"/>
|
||||
</BaseIcon>
|
||||
|
||||
@@ -202,7 +220,6 @@ const progress = $computed(() => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: .3rem;
|
||||
width: 6rem;
|
||||
color: gray;
|
||||
|
||||
.line {
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import {_getAccomplishDays} from "@/utils";
|
||||
import Radio from "@/components/base/radio/Radio.vue";
|
||||
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
|
||||
import { _getAccomplishDays } from "@/utils";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import Checkbox from "@/components/base/checkbox/Checkbox.vue";
|
||||
import Slider from "@/components/base/Slider.vue";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
import {defineAsyncComponent, watch} from "vue";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import { defineAsyncComponent, watch } from "vue";
|
||||
import { useSettingStore } from "@/stores/setting.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import ChangeLastPracticeIndexDialog from "@/pages/word/components/ChangeLastPracticeIndexDialog.vue";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import { useRuntimeStore } from "@/stores/runtime.ts";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
|
||||
69
src/pages/word/components/ShufflePracticeSettingDialog.vue
Normal file
69
src/pages/word/components/ShufflePracticeSettingDialog.vue
Normal file
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
|
||||
import Slider from "@/components/base/Slider.vue";
|
||||
import {defineAsyncComponent, watch} from "vue";
|
||||
import {useBaseStore} from "@/stores/base.ts";
|
||||
|
||||
const Dialog = defineAsyncComponent(() => import('@/components/dialog/Dialog.vue'))
|
||||
|
||||
const store = useBaseStore()
|
||||
|
||||
const model = defineModel()
|
||||
|
||||
const emit = defineEmits<{
|
||||
ok: [val: number];
|
||||
}>()
|
||||
|
||||
let num = $ref(0)
|
||||
let min = $ref(0)
|
||||
|
||||
watch(() => model.value, (n) => {
|
||||
if (n) {
|
||||
num = Math.floor(store.sdict.lastLearnIndex / 3)
|
||||
num = num > 50 ? 50 : num
|
||||
min = num < 10 ? num : 10
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model="model" title="随机复习设置"
|
||||
:footer="true"
|
||||
@ok="emit('ok',num)">
|
||||
<div class="target-modal color-main">
|
||||
<div class="flex gap-4 items-end mb-2">
|
||||
<span>随机复习:<span class="font-bold">{{ store.sdict.name }}</span></span>
|
||||
<span class="text-3xl mx-2 lh">{{ num }}</span>个单词
|
||||
</div>
|
||||
<div class="flex gap-space">
|
||||
<span class="shrink-0">随机数量</span>
|
||||
<Slider :min="min"
|
||||
:step="10"
|
||||
show-text
|
||||
class="mt-1"
|
||||
:max="store.sdict.lastLearnIndex"
|
||||
v-model="num"/>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
.target-modal {
|
||||
width: 30rem;
|
||||
padding: 0 var(--space);
|
||||
|
||||
.lh {
|
||||
color: rgb(176, 116, 211)
|
||||
}
|
||||
|
||||
.mode-item {
|
||||
@apply w-50% border border-blue border-solid p-2 rounded-lg cursor-pointer;
|
||||
}
|
||||
|
||||
.active {
|
||||
@apply bg-blue color-white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import {WordPracticeType, ShortcutKey, Word, WordPracticeMode} from "@/types/types.ts";
|
||||
import {ShortcutKey, Word, WordPracticeType} from "@/types/types.ts";
|
||||
import VolumeIcon from "@/components/icon/VolumeIcon.vue";
|
||||
import {useSettingStore} from "@/stores/setting.ts";
|
||||
import {usePlayBeep, usePlayCorrect, usePlayKeyboardAudio, usePlayWordAudio} from "@/hooks/sound.ts";
|
||||
import {emitter, EventKey, useEvents} from "@/utils/eventBus.ts";
|
||||
import {inject, onMounted, onUnmounted, Ref, watch} from "vue";
|
||||
import {onMounted, onUnmounted, watch} from "vue";
|
||||
import SentenceHightLightWord from "@/pages/word/components/SentenceHightLightWord.vue";
|
||||
import {usePracticeStore} from "@/stores/practice.ts";
|
||||
import {getDefaultWord} from "@/types/func.ts";
|
||||
import {_nextTick, last, sleep} from "@/utils";
|
||||
import {_nextTick, last} from "@/utils";
|
||||
import BaseButton from "@/components/BaseButton.vue";
|
||||
import Space from "@/pages/article/components/Space.vue";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import Tooltip from "@/components/base/Tooltip.vue";
|
||||
|
||||
interface IProps {
|
||||
word: Word,
|
||||
@@ -104,9 +103,7 @@ function repeat() {
|
||||
wordRepeatCount++
|
||||
inputLock = false
|
||||
|
||||
if (settingStore.wordSound) {
|
||||
volumeIconRef?.play()
|
||||
}
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
}, settingStore.waitTimeForChangeWord)
|
||||
}
|
||||
|
||||
@@ -144,6 +141,7 @@ function unknown(e) {
|
||||
if (!showWordResult) {
|
||||
showWordResult = true
|
||||
emit('wrong')
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -152,25 +150,45 @@ function unknown(e) {
|
||||
|
||||
async function onTyping(e: KeyboardEvent) {
|
||||
let word = props.word.word
|
||||
// 输入完成会锁死不能再输入
|
||||
if (inputLock) {
|
||||
// 因为输入完成会锁死不能再输入,所以在这里判断空格键切换到下一个单词
|
||||
if (e.code === 'Space' && input.toLowerCase() === word.toLowerCase()) {
|
||||
showWordResult = inputLock = false
|
||||
emit('complete')
|
||||
} else {
|
||||
//当显示单词时,提示用户正确按键
|
||||
if (showWordResult) {
|
||||
pressNumber++
|
||||
if (pressNumber >= 3) {
|
||||
Toast.info(right ? '请按空格键切换' : '请按删除键重新输入', {duration: 2000})
|
||||
pressNumber = 0
|
||||
//判断是否是空格键以便切换到下一个单词
|
||||
if (e.code === 'Space') {
|
||||
//正确时就切换到下一个单词
|
||||
if (right) {
|
||||
showWordResult = inputLock = false
|
||||
emit('complete')
|
||||
} else {
|
||||
if (showWordResult) {
|
||||
// 错误时,提示用户按删除键,仅默写需要提示
|
||||
pressNumber++
|
||||
if (pressNumber >= 3) {
|
||||
Toast.info('请按删除键重新输入', {duration: 2000})
|
||||
pressNumber = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
//当正确时,提醒用户按空格键切下一个
|
||||
if (right) {
|
||||
pressNumber++
|
||||
if (pressNumber >= 3) {
|
||||
Toast.info('请按空格键继续', {duration: 2000})
|
||||
pressNumber = 0
|
||||
}
|
||||
} else {
|
||||
//当错误时,按任意键重新输入
|
||||
showWordResult = inputLock = false
|
||||
input = wrong = ''
|
||||
onTyping(e)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
inputLock = true
|
||||
let letter = e.key
|
||||
console.log('letter',letter)
|
||||
//默写特殊逻辑
|
||||
if (settingStore.wordPracticeType === WordPracticeType.Dictation) {
|
||||
if (e.code === 'Space') {
|
||||
@@ -185,12 +203,12 @@ async function onTyping(e: KeyboardEvent) {
|
||||
} else {
|
||||
//未显示单词,则播放正确音乐,并在后面设置为 showWordResult 为 true 来显示单词
|
||||
playCorrect()
|
||||
volumeIconRef?.play()
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
}
|
||||
} else {
|
||||
//错误处理
|
||||
playBeep()
|
||||
volumeIconRef?.play()
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
emit('wrong')
|
||||
}
|
||||
showWordResult = true
|
||||
@@ -203,12 +221,19 @@ async function onTyping(e: KeyboardEvent) {
|
||||
playKeyboardAudio()
|
||||
updateCurrentWordInfo();
|
||||
inputLock = false
|
||||
} else if (settingStore.wordPracticeType === WordPracticeType.Identify && !showWordResult) {
|
||||
//当辨认模式下,按1和2会单独处理,如果按其他键则自动默认为不认识
|
||||
showWordResult = true
|
||||
emit('wrong')
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
inputLock = false
|
||||
onTyping(e)
|
||||
} else {
|
||||
let right = false
|
||||
if (settingStore.ignoreCase) {
|
||||
right = letter.toLowerCase() === word[input.length].toLowerCase()
|
||||
} else {
|
||||
right = letter === props.word.word[input.length]
|
||||
right = letter === word[input.length]
|
||||
}
|
||||
if (right) {
|
||||
input += letter
|
||||
@@ -218,10 +243,11 @@ async function onTyping(e: KeyboardEvent) {
|
||||
emit('wrong')
|
||||
wrong = letter
|
||||
playBeep()
|
||||
volumeIconRef?.play()
|
||||
await sleep(500)
|
||||
if (settingStore.inputWrongClear) input = ''
|
||||
wrong = ''
|
||||
if (settingStore.wordSound) volumeIconRef?.play()
|
||||
setTimeout(() => {
|
||||
if (settingStore.inputWrongClear) input = ''
|
||||
wrong = ''
|
||||
}, 500)
|
||||
}
|
||||
// 更新当前单词信息
|
||||
updateCurrentWordInfo();
|
||||
@@ -273,15 +299,22 @@ function del() {
|
||||
|
||||
function showWord() {
|
||||
if (settingStore.allowWordTip) {
|
||||
showFullWord = true
|
||||
}
|
||||
//系统设定的默认模式情况下,如果看了单词统计到错词里面去
|
||||
switch (statStore.step) {
|
||||
case 1:
|
||||
case 3:
|
||||
case 4:
|
||||
if (settingStore.wordPracticeType === WordPracticeType.Dictation || settingStore.dictation) {
|
||||
emit('wrong')
|
||||
break
|
||||
}
|
||||
showFullWord = true
|
||||
//系统设定的默认模式情况下,如果看了单词统计到错词里面去
|
||||
switch (statStore.step) {
|
||||
case 1:
|
||||
case 2:
|
||||
case 4:
|
||||
case 5:
|
||||
case 7:
|
||||
case 8:
|
||||
case 10:
|
||||
emit('wrong')
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +323,9 @@ function hideWord() {
|
||||
}
|
||||
|
||||
function play() {
|
||||
if (settingStore.wordPracticeType === WordPracticeType.Dictation || settingStore.dictation) {
|
||||
emit('wrong')
|
||||
}
|
||||
volumeIconRef?.play()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as VueRouter from 'vue-router'
|
||||
import {RouteRecordRaw} from 'vue-router'
|
||||
import WordsPage from "@/pages/word/WordsPage.vue";
|
||||
import PC from "@/pages/index.vue";
|
||||
import Layout from "@/pages/layout.vue";
|
||||
import ArticlesPage from "@/pages/article/ArticlesPage.vue";
|
||||
import PracticeArticles from "@/pages/article/PracticeArticles.vue";
|
||||
import DictDetail from "@/pages/word/DictDetail.vue";
|
||||
@@ -10,17 +10,17 @@ import BookDetail from "@/pages/article/BookDetail.vue";
|
||||
import DictList from "@/pages/word/DictList.vue";
|
||||
import BookList from "@/pages/article/BookList.vue";
|
||||
import Setting from "@/pages/setting/Setting.vue";
|
||||
import Home from "@/pages/home/index.vue";
|
||||
import Login from "@/pages/user/login.vue";
|
||||
import User from "@/pages/user/index.vue";
|
||||
import User from "@/pages/user/User.vue";
|
||||
import VipIntro from "@/pages/user/VipIntro.vue";
|
||||
// import { useAuthStore } from "@/stores/auth.ts";
|
||||
|
||||
export const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
component: PC,
|
||||
redirect: '/',
|
||||
component: Layout,
|
||||
children: [
|
||||
{path: '/', component: Home},
|
||||
{path: '/', redirect: '/words'},
|
||||
{path: 'words', component: WordsPage},
|
||||
{path: 'word', redirect: '/words'},
|
||||
{path: 'practice-words/:id', component: PracticeWords},
|
||||
@@ -37,11 +37,12 @@ export const routes: RouteRecordRaw[] = [
|
||||
{path: 'setting', component: Setting},
|
||||
{path: 'login', component: Login},
|
||||
{path: 'user', component: User},
|
||||
{path: 'vip', component: VipIntro},
|
||||
]
|
||||
},
|
||||
{path: '/batch-edit-article', component: () => import("@/pages/article/BatchEditArticlePage.vue")},
|
||||
{path: '/test', component: () => import("@/pages/test/test.vue")},
|
||||
{path: '/:pathMatch(.*)*', redirect: '/word'},
|
||||
{path: '/:pathMatch(.*)*', redirect: '/words'},
|
||||
]
|
||||
|
||||
const router = VueRouter.createRouter({
|
||||
@@ -58,8 +59,30 @@ const router = VueRouter.createRouter({
|
||||
},
|
||||
})
|
||||
|
||||
router.beforeEach((to: any, from: any) => {
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to: any, from: any) => {
|
||||
return true
|
||||
|
||||
// const userStore = useAuthStore()
|
||||
//
|
||||
// // 公共路由,不需要登录验证
|
||||
// const publicRoutes = ['/login', '/wechat/callback', '/user-agreement', '/privacy-policy']
|
||||
//
|
||||
// // 如果目标路由是公共路由,直接放行
|
||||
// if (publicRoutes.includes(to.path)) {
|
||||
// return true
|
||||
// }
|
||||
//
|
||||
// // 如果用户未登录,跳转到登录页
|
||||
// if (!userStore.isLoggedIn) {
|
||||
// // 尝试初始化认证状态
|
||||
// const isInitialized = await userStore.initAuth()
|
||||
// if (!isInitialized) {
|
||||
// return {path: '/login', query: {redirect: to.fullPath}}
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return true
|
||||
// console.log('beforeEach-to',to.path)
|
||||
// console.log('beforeEach-from',from.path)
|
||||
// const runtimeStore = useRuntimeStore()
|
||||
|
||||
78
src/stores/auth.ts
Normal file
78
src/stores/auth.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {ref} from 'vue'
|
||||
import {getUserInfo, User} from '@/apis/user.ts'
|
||||
import {AppEnv} from "@/config/env.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const isLogin = ref<boolean>(false)
|
||||
|
||||
// 设置token
|
||||
const setToken = (newToken: string) => {
|
||||
isLogin.value = true
|
||||
AppEnv.TOKEN = newToken
|
||||
AppEnv.IS_LOGIN = !!AppEnv.TOKEN
|
||||
AppEnv.CAN_REQUEST = AppEnv.IS_LOGIN && AppEnv.IS_OFFICIAL
|
||||
localStorage.setItem('token', newToken)
|
||||
}
|
||||
|
||||
// 清除token
|
||||
const clearToken = () => {
|
||||
AppEnv.IS_LOGIN = AppEnv.CAN_REQUEST = false
|
||||
AppEnv.TOKEN = ''
|
||||
localStorage.removeItem('token')
|
||||
isLogin.value = false
|
||||
user.value = null
|
||||
}
|
||||
|
||||
// 设置用户信息
|
||||
const setUser = (userInfo: User) => {
|
||||
user.value = userInfo
|
||||
isLogin.value = true
|
||||
}
|
||||
|
||||
// 登出
|
||||
function logout() {
|
||||
clearToken()
|
||||
Toast.success('已退出登录')
|
||||
//这行会引起hrm失效
|
||||
// router.push('/')
|
||||
}
|
||||
|
||||
// 获取用户信息
|
||||
async function fetchUserInfo() {
|
||||
if (!AppEnv.CAN_REQUEST) return false
|
||||
try {
|
||||
const res = await getUserInfo()
|
||||
if (res.success) {
|
||||
setUser(res.data)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Get user info error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 初始化用户状态
|
||||
async function init() {
|
||||
const success = await fetchUserInfo()
|
||||
if (!success) {
|
||||
clearToken()
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
isLogin,
|
||||
setToken,
|
||||
clearToken,
|
||||
setUser,
|
||||
logout,
|
||||
fetchUserInfo,
|
||||
init
|
||||
}
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { _getStudyProgress, checkAndUpgradeSaveDict, shakeCommonDict } from "@/u
|
||||
import { shallowReactive } from "vue";
|
||||
import { getDefaultDict } from "@/types/func.ts";
|
||||
import { get, set } from 'idb-keyval'
|
||||
import { CAN_REQUEST, IS_LOGIN, IS_OFFICIAL, SAVE_DICT_KEY } from "@/config/env.ts";
|
||||
import { AppEnv, SAVE_DICT_KEY } from "@/config/env.ts";
|
||||
import { add2MyDict, dictListVersion, myDictList } from "@/apis";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface BaseState {
|
||||
dictListVersion: number
|
||||
}
|
||||
|
||||
export const DefaultBaseState = (): BaseState => ({
|
||||
export const getDefaultBaseState = (): BaseState => ({
|
||||
simpleWords: [
|
||||
'a', 'an',
|
||||
'i', 'my', 'me', 'you', 'your', 'he', 'his', 'she', 'her', 'it',
|
||||
@@ -51,7 +51,7 @@ export const DefaultBaseState = (): BaseState => ({
|
||||
|
||||
export const useBaseStore = defineStore('base', {
|
||||
state: (): BaseState => {
|
||||
return DefaultBaseState()
|
||||
return getDefaultBaseState()
|
||||
},
|
||||
getters: {
|
||||
collectWord(): Dict {
|
||||
@@ -125,13 +125,13 @@ export const useBaseStore = defineStore('base', {
|
||||
try {
|
||||
let configStr: string = await get(SAVE_DICT_KEY.key)
|
||||
let data = checkAndUpgradeSaveDict(configStr)
|
||||
if (IS_OFFICIAL) {
|
||||
if (AppEnv.IS_OFFICIAL) {
|
||||
let r = await dictListVersion()
|
||||
if (r.success) {
|
||||
data.dictListVersion = r.data
|
||||
}
|
||||
}
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await myDictList()
|
||||
if (res.success) {
|
||||
Object.assign(data, res.data)
|
||||
@@ -147,7 +147,7 @@ export const useBaseStore = defineStore('base', {
|
||||
},
|
||||
//改变词典
|
||||
async changeDict(val: Dict) {
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let r = await add2MyDict(val)
|
||||
if (!r.success) {
|
||||
return Toast.error(r.msg)
|
||||
@@ -175,7 +175,7 @@ export const useBaseStore = defineStore('base', {
|
||||
},
|
||||
//改变书籍
|
||||
async changeBook(val: Dict) {
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let r = await add2MyDict(val)
|
||||
if (!r.success) {
|
||||
return Toast.error(r.msg)
|
||||
|
||||
@@ -5,14 +5,11 @@ export interface PracticeState {
|
||||
startDate: number,
|
||||
spend: number,
|
||||
total: number,
|
||||
index: number,//当前输入的第几个,用于和total计算进度
|
||||
newWordNumber: number,
|
||||
reviewWordNumber: number,
|
||||
writeWordNumber: number,
|
||||
inputWordNumber: number,//当前总输入了多少个单词(不包含跳过)
|
||||
wrong: number,
|
||||
startIndex: number,
|
||||
endIndex: number,
|
||||
}
|
||||
|
||||
export const usePracticeStore = defineStore('practice', {
|
||||
@@ -22,9 +19,6 @@ export const usePracticeStore = defineStore('practice', {
|
||||
spend: 0,
|
||||
startDate: Date.now(),
|
||||
total: 0,
|
||||
index: 0,
|
||||
startIndex: 0,
|
||||
endIndex: 0,
|
||||
newWordNumber: 0,
|
||||
reviewWordNumber: 0,
|
||||
writeWordNumber: 0,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { defineStore } from "pinia"
|
||||
import { checkAndUpgradeSaveSetting, cloneDeep } from "@/utils";
|
||||
import {DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType} from "@/types/types.ts";
|
||||
import { DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType } from "@/types/types.ts";
|
||||
import { get } from "idb-keyval";
|
||||
import { CAN_REQUEST, SAVE_SETTING_KEY } from "@/config/env.ts";
|
||||
import { AppEnv, SAVE_SETTING_KEY } from "@/config/env.ts";
|
||||
import { getSetting } from "@/apis";
|
||||
|
||||
export interface SettingState {
|
||||
@@ -54,6 +54,7 @@ export interface SettingState {
|
||||
autoNextWord: boolean //自动切换下一个单词
|
||||
inputWrongClear: boolean //单词输入错误,清空已输入内容
|
||||
mobileNavCollapsed: boolean // 移动端底部导航栏收缩状态
|
||||
ignoreSymbol: boolean //过滤符号
|
||||
}
|
||||
|
||||
export const getDefaultSettingState = (): SettingState => ({
|
||||
@@ -70,7 +71,7 @@ export const getDefaultSettingState = (): SettingState => ({
|
||||
|
||||
keyboardSound: true,
|
||||
keyboardSoundVolume: 100,
|
||||
keyboardSoundFile: '机械键盘2',
|
||||
keyboardSoundFile: '笔记本键盘',
|
||||
|
||||
effectSound: true,
|
||||
effectSoundVolume: 100,
|
||||
@@ -105,6 +106,7 @@ export const getDefaultSettingState = (): SettingState => ({
|
||||
autoNextWord: true,
|
||||
inputWrongClear: false,
|
||||
mobileNavCollapsed: false,
|
||||
ignoreSymbol: true
|
||||
})
|
||||
|
||||
export const useSettingStore = defineStore('setting', {
|
||||
@@ -117,15 +119,9 @@ export const useSettingStore = defineStore('setting', {
|
||||
},
|
||||
init() {
|
||||
return new Promise(async resolve => {
|
||||
//TODO 后面记得删除了
|
||||
let configStr = localStorage.getItem(SAVE_SETTING_KEY.key)
|
||||
let configStr2 = await get(SAVE_SETTING_KEY.key)
|
||||
if (configStr2) {
|
||||
//兼容localStorage.getItem
|
||||
configStr = configStr2
|
||||
}
|
||||
let configStr = await get(SAVE_SETTING_KEY.key)
|
||||
let data = checkAndUpgradeSaveSetting(configStr)
|
||||
if (CAN_REQUEST) {
|
||||
if (AppEnv.CAN_REQUEST) {
|
||||
let res = await getSetting()
|
||||
if (res.success) {
|
||||
Object.assign(data, res.data)
|
||||
|
||||
@@ -200,6 +200,7 @@ export interface TaskWords {
|
||||
new: Word[],
|
||||
review: Word[],
|
||||
write: Word[],
|
||||
shuffle: Word[],
|
||||
}
|
||||
|
||||
export class DictId {
|
||||
@@ -228,4 +229,13 @@ export enum WordPracticeType {
|
||||
Identify,
|
||||
Listen,
|
||||
Dictation
|
||||
}
|
||||
}
|
||||
|
||||
export enum CodeType {
|
||||
Login = 0,
|
||||
Register = 1,
|
||||
ResetPwd = 2,
|
||||
ChangeEmail = 3,
|
||||
ChangePhoneNew = 4,
|
||||
ChangePhoneOld = 5
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
import { ENV } from "@/config/env.ts";
|
||||
import axios, {AxiosInstance} from 'axios'
|
||||
import {AppEnv, ENV} from "@/config/env.ts";
|
||||
import Toast from "@/components/base/toast/Toast.ts";
|
||||
import App from "@/App.vue";
|
||||
|
||||
export const axiosInstance: AxiosInstance = axios.create({
|
||||
baseURL: ENV.API,
|
||||
@@ -9,10 +10,7 @@ export const axiosInstance: AxiosInstance = axios.create({
|
||||
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
// console.log('config', config)
|
||||
// if (config.url === 'https://api.fanyi.baidu.com/api/trans/vip/translate') {
|
||||
// config.url = '/baidu'
|
||||
// }
|
||||
if (AppEnv.CAN_REQUEST) config.headers.token = AppEnv.TOKEN
|
||||
return config
|
||||
},
|
||||
error => Promise.reject(error),
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import {BaseState, DefaultBaseState, useBaseStore} from "@/stores/base.ts";
|
||||
import {BaseState, getDefaultBaseState, useBaseStore} from "@/stores/base.ts";
|
||||
import {getDefaultSettingState, SettingState} from "@/stores/setting.ts";
|
||||
import {Dict, DictId, DictResource, DictType} from "@/types/types.ts";
|
||||
import {useRouter} from "vue-router";
|
||||
import {useRuntimeStore} from "@/stores/runtime.ts";
|
||||
import dayjs from 'dayjs'
|
||||
import axios from "axios";
|
||||
import {ENV, IS_OFFICIAL, RESOURCE_PATH, SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/config/env.ts";
|
||||
import {AppEnv, RESOURCE_PATH, SAVE_DICT_KEY, SAVE_SETTING_KEY} from "@/config/env.ts";
|
||||
import {nextTick} from "vue";
|
||||
import Toast from '@/components/base/toast/Toast.ts'
|
||||
import {getDefaultDict, getDefaultWord} from "@/types/func.ts";
|
||||
@@ -29,7 +28,7 @@ export function checkAndUpgradeSaveDict(val: any) {
|
||||
// console.log(configStr)
|
||||
// console.log('s', new Blob([val]).size)
|
||||
// val = ''
|
||||
let defaultState = DefaultBaseState()
|
||||
let defaultState = getDefaultBaseState()
|
||||
if (val) {
|
||||
try {
|
||||
let data: any
|
||||
@@ -138,10 +137,10 @@ export function useNav() {
|
||||
router.push({path, query})
|
||||
}
|
||||
|
||||
return {nav, back: router.back}
|
||||
return {nav, push: nav, back: router.back}
|
||||
}
|
||||
|
||||
export function _dateFormat(val: any, format?: string): string {
|
||||
export function _dateFormat(val: any, format: string = 'YYYY/MM/DD HH:mm'): string {
|
||||
if (!val) return
|
||||
if (String(val).length === 10) {
|
||||
val = val * 1000
|
||||
@@ -243,7 +242,7 @@ export function convertToWord(raw: any) {
|
||||
|
||||
// 1. trans
|
||||
const trans = safeSplit(raw.trans, '\n').map(line => {
|
||||
const match = line.match(/^([^\s.]+\.?)\s*(.*)$/);
|
||||
const match = safeString(line).match(/^([^\s.]+\.?)\s*(.*)$/);
|
||||
if (match) {
|
||||
let pos = safeString(match[1]);
|
||||
let cn = safeString(match[2]);
|
||||
@@ -440,7 +439,7 @@ export function total(arr, key) {
|
||||
}
|
||||
|
||||
export function resourceWrap(resource: string, version?: number) {
|
||||
if (IS_OFFICIAL) {
|
||||
if (AppEnv.IS_OFFICIAL) {
|
||||
if (resource.includes('.json')) resource = resource.replace('.json', '');
|
||||
if (!resource.includes('http')) resource = RESOURCE_PATH + resource
|
||||
if (version === undefined) {
|
||||
@@ -450,4 +449,13 @@ export function resourceWrap(resource: string, version?: number) {
|
||||
return `${resource}_v${version}.json`
|
||||
}
|
||||
return resource;
|
||||
}
|
||||
|
||||
// check if it is a new user
|
||||
export async function isNewUser() {
|
||||
let isNew = false
|
||||
let base = useBaseStore()
|
||||
console.log(JSON.stringify(base.$state))
|
||||
console.log(JSON.stringify(getDefaultBaseState()))
|
||||
return JSON.stringify(base.$state) === JSON.stringify({...getDefaultBaseState(), ...{load: true}})
|
||||
}
|
||||
54
src/utils/validation.ts
Normal file
54
src/utils/validation.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// 邮箱验证
|
||||
import {EMAIL_CONFIG, PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
|
||||
|
||||
export const validateEmail = (email: string): boolean => {
|
||||
return EMAIL_CONFIG.emailRegex.test(email)
|
||||
}
|
||||
// 手机号验证(中国大陆)
|
||||
export const validatePhone = (phone: string): boolean => {
|
||||
return PHONE_CONFIG.phoneRegex.test(phone)
|
||||
}
|
||||
|
||||
export const codeRules = [
|
||||
{required: true, message: '请输入验证码', trigger: 'blur'},
|
||||
{min: PHONE_CONFIG.codeLength, message: `请输入 ${PHONE_CONFIG.codeLength} 位验证码`, trigger: 'blur'},
|
||||
]
|
||||
export const accountRules = [
|
||||
{required: true, message: '请输入手机号/邮箱地址', trigger: 'blur'},
|
||||
{
|
||||
validator: (rule: any, value: any) => {
|
||||
if (!validatePhone(value) && !validateEmail(value)) {
|
||||
throw new Error('请输入有效的手机号或邮箱地址')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},
|
||||
]
|
||||
export const emailRules = [
|
||||
{required: true, message: '请输入邮箱地址', trigger: 'blur'},
|
||||
{
|
||||
validator: (rule: any, value: any) => {
|
||||
if (!validateEmail(value)) {
|
||||
throw new Error('请输入有效的邮箱地址')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},
|
||||
]
|
||||
export const phoneRules = [
|
||||
{required: true, message: '请输入手机号', trigger: 'blur'},
|
||||
{
|
||||
validator: (rule: any, value: any) => {
|
||||
if (!validatePhone(value)) {
|
||||
throw new Error('请输入有效的手机号')
|
||||
}
|
||||
}, trigger: 'blur'
|
||||
},
|
||||
]
|
||||
export const passwordRules = [
|
||||
{required: true, message: '请输入密码', trigger: 'blur'},
|
||||
{
|
||||
min: PASSWORD_CONFIG.minLength,
|
||||
max: PASSWORD_CONFIG.maxLength,
|
||||
message: `密码长度为 ${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位`,
|
||||
trigger: 'blur'
|
||||
},
|
||||
]
|
||||
Reference in New Issue
Block a user