Merge branch 'refs/heads/pr' into pr-159

# Conflicts:
#	package-lock.json
#	src/App.vue
#	src/pages/article/PracticeArticles.vue
#	src/stores/setting.ts
This commit is contained in:
Zyronon
2025-11-16 22:37:01 +08:00
51 changed files with 3301 additions and 504 deletions

View File

@@ -87,7 +87,7 @@
3. 在项目根目录下,打开命令行,运行`npm install`来下载依赖。
4. 执行`npm run dev`来启动项目,项目默认地址为[`http://localhost:3000`](http://localhost:3000)
5. 在浏览器中打开[`http://localhost:3000`](http://localhost:3000) 来访问项目。
6. 执行`npm run build-nocdn`打包项目文件
6. 执行`npm run build`打包项目文件
## 功能与建议

16
components.d.ts vendored
View File

@@ -29,6 +29,7 @@ declare module 'vue' {
Empty: typeof import('./src/components/Empty.vue')['default']
Form: typeof import('./src/components/base/form/Form.vue')['default']
FormItem: typeof import('./src/components/base/form/FormItem.vue')['default']
Header: typeof import('./src/components/Header.vue')['default']
IconBxVolume: typeof import('~icons/bx/volume')['default']
IconBxVolumeFull: typeof import('~icons/bx/volume-full')['default']
IconBxVolumeLow: typeof import('~icons/bx/volume-low')['default']
@@ -39,8 +40,10 @@ declare module 'vue' {
IconFluentAddSquare20Regular: typeof import('~icons/fluent/add-square20-regular')['default']
IconFluentArrowBounce20Regular: typeof import('~icons/fluent/arrow-bounce20-regular')['default']
IconFluentArrowCircleRight16Regular: typeof import('~icons/fluent/arrow-circle-right16-regular')['default']
IconFluentArrowClockwise20Regular: typeof import('~icons/fluent/arrow-clockwise20-regular')['default']
IconFluentArrowLeft16Regular: typeof import('~icons/fluent/arrow-left16-regular')['default']
IconFluentArrowMove20Regular: typeof import('~icons/fluent/arrow-move20-regular')['default']
IconFluentArrowRepeatAll20Regular: typeof import('~icons/fluent/arrow-repeat-all20-regular')['default']
IconFluentArrowRight16Regular: typeof import('~icons/fluent/arrow-right16-regular')['default']
IconFluentArrowShuffle16Regular: typeof import('~icons/fluent/arrow-shuffle16-regular')['default']
IconFluentArrowShuffle20Filled: typeof import('~icons/fluent/arrow-shuffle20-filled')['default']
@@ -48,25 +51,36 @@ declare module 'vue' {
IconFluentArrowSwap20Regular: typeof import('~icons/fluent/arrow-swap20-regular')['default']
IconFluentBookLetter20Regular: typeof import('~icons/fluent/book-letter20-regular')['default']
IconFluentBookNumber20Filled: typeof import('~icons/fluent/book-number20-filled')['default']
IconFluentCalendarDate20Regular: typeof import('~icons/fluent/calendar-date20-regular')['default']
IconFluentCheckmark20Regular: typeof import('~icons/fluent/checkmark20-regular')['default']
IconFluentCheckmarkCircle16Filled: typeof import('~icons/fluent/checkmark-circle16-filled')['default']
IconFluentCheckmarkCircle16Regular: typeof import('~icons/fluent/checkmark-circle16-regular')['default']
IconFluentCheckmarkCircle20Filled: typeof import('~icons/fluent/checkmark-circle20-filled')['default']
IconFluentCheckmarkCircle20Regular: typeof import('~icons/fluent/checkmark-circle20-regular')['default']
IconFluentChevronDown20Regular: typeof import('~icons/fluent/chevron-down20-regular')['default']
IconFluentChevronLeft20Filled: typeof import('~icons/fluent/chevron-left20-filled')['default']
IconFluentChevronLeft28Filled: typeof import('~icons/fluent/chevron-left28-filled')['default']
IconFluentCrown20Regular: typeof import('~icons/fluent/crown20-regular')['default']
IconFluentDatabasePerson20Regular: typeof import('~icons/fluent/database-person20-regular')['default']
IconFluentDelete20Regular: typeof import('~icons/fluent/delete20-regular')['default']
IconFluentDismiss20Regular: typeof import('~icons/fluent/dismiss20-regular')['default']
IconFluentDismissCircle16Regular: typeof import('~icons/fluent/dismiss-circle16-regular')['default']
IconFluentDismissCircle20Filled: typeof import('~icons/fluent/dismiss-circle20-filled')['default']
IconFluentErrorCircle20Filled: typeof import('~icons/fluent/error-circle20-filled')['default']
IconFluentErrorCircle20Regular: typeof import('~icons/fluent/error-circle20-regular')['default']
IconFluentEye16Regular: typeof import('~icons/fluent/eye16-regular')['default']
IconFluentEyeOff16Regular: typeof import('~icons/fluent/eye-off16-regular')['default']
IconFluentHandWave20Regular: typeof import('~icons/fluent/hand-wave20-regular')['default']
IconFluentHome20Regular: typeof import('~icons/fluent/home20-regular')['default']
IconFluentKeyboardLayoutFloat20Regular: typeof import('~icons/fluent/keyboard-layout-float20-regular')['default']
IconFluentLockClosed20Regular: typeof import('~icons/fluent/lock-closed20-regular')['default']
IconFluentMail20Regular: typeof import('~icons/fluent/mail20-regular')['default']
IconFluentMyLocation20Regular: typeof import('~icons/fluent/my-location20-regular')['default']
IconFluentNumberSymbol20Regular: typeof import('~icons/fluent/number-symbol20-regular')['default']
IconFluentPaddingLeft20Regular: typeof import('~icons/fluent/padding-left20-regular')['default']
IconFluentPayment20Regular: typeof import('~icons/fluent/payment20-regular')['default']
IconFluentPerson20Regular: typeof import('~icons/fluent/person20-regular')['default']
IconFluentPhone20Regular: typeof import('~icons/fluent/phone20-regular')['default']
IconFluentPlay20Regular: typeof import('~icons/fluent/play20-regular')['default']
IconFluentQuestionCircle20Regular: typeof import('~icons/fluent/question-circle20-regular')['default']
IconFluentReplay20Regular: typeof import('~icons/fluent/replay20-regular')['default']
@@ -90,7 +104,9 @@ declare module 'vue' {
IconFluentWeatherMoon16Regular: typeof import('~icons/fluent/weather-moon16-regular')['default']
IconFluentWeatherSunny16Regular: typeof import('~icons/fluent/weather-sunny16-regular')['default']
IconIconParkOutlineAddMusic: typeof import('~icons/icon-park-outline/add-music')['default']
IconIxWechatLogo: typeof import('~icons/ix/wechat-logo')['default']
IconPhExportLight: typeof import('~icons/ph/export-light')['default']
IconSimpleIconsWechat: typeof import('~icons/simple-icons/wechat')['default']
IconSystemUiconsImport: typeof import('~icons/system-uicons/import')['default']
InputNumber: typeof import('./src/components/base/InputNumber.vue')['default']
List: typeof import('./src/components/list/List.vue')['default']

View File

@@ -42,6 +42,7 @@
"@iconify-json/fluent": "^1.2.28",
"@iconify-json/icon-park-outline": "^1.2.4",
"@iconify-json/icon-park-solid": "^1.2.4",
"@iconify-json/ix": "^1.2.10",
"@iconify-json/material-symbols": "^1.2.33",
"@iconify-json/oui": "^1.2.6",
"@iconify-json/ph": "^1.2.2",

10
pnpm-lock.yaml generated
View File

@@ -75,6 +75,9 @@ importers:
'@iconify-json/icon-park-solid':
specifier: ^1.2.4
version: 1.2.4
'@iconify-json/ix':
specifier: ^1.2.10
version: 1.2.10
'@iconify-json/material-symbols':
specifier: ^1.2.33
version: 1.2.33
@@ -514,6 +517,9 @@ packages:
'@iconify-json/icon-park-solid@1.2.4':
resolution: {integrity: sha512-030MChSP6lCY7N+U5J5R7YguHTGcm7qQEI/ivBjk77El/i8yJatoj568cwwXGM8c6HEU/kIxEE4m3O/6w0WBGg==}
'@iconify-json/ix@1.2.10':
resolution: {integrity: sha512-2NMqsW+sMyH+cpRnRW6mVqJM/q3Mbb7UVY9NWJJEJfHGn1SbzZde/jpgEmTZe5jMJMPQGWhaCzbGsTMrFim+3Q==}
'@iconify-json/material-symbols@1.2.33':
resolution: {integrity: sha512-Bs0X1+/vpJydW63olrGh60zkR8/Y70sI14AIWaP7Z6YQXukzWANH4q3I0sIPklbIn1oL6uwLvh0zQyd6Vh79LQ==}
@@ -4145,6 +4151,10 @@ snapshots:
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/ix@1.2.10':
dependencies:
'@iconify/types': 2.0.0
'@iconify-json/material-symbols@1.2.33':
dependencies:
'@iconify/types': 2.0.0

100
public/privacy-policy.html Normal file
View File

@@ -0,0 +1,100 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>隐私政策</title>
</head>
<body
style="display:flex;justify-content:center">
<div class="privacy-page"
style="width: 60vw;"
>
<h1 style="text-align: center">隐私政策</h1>
<div class="content">
<section>
<h2>一、引言</h2>
<p>
我们非常重视您的隐私保护。本隐私政策说明了我们如何收集、使用、存储和保护您的个人信息。在使用本应用之前,请您仔细阅读本隐私政策。</p>
</section>
<section>
<h2>二、信息收集</h2>
<p>我们可能收集以下信息:</p>
<p><strong>1. 账户信息:</strong>当您注册账户时,我们会收集您的手机号、邮箱地址、密码等信息。</p>
<p><strong>2. 学习数据:</strong>我们会记录您的学习进度、学习记录、练习数据等信息,以便为您提供个性化的学习服务。
</p>
<p><strong>3. 设备信息:</strong>我们可能收集您的设备型号、操作系统版本、唯一设备标识符等信息,用于改善服务质量和安全性。
</p>
<p><strong>4. 日志信息:</strong>当您使用本应用时我们可能自动收集某些信息包括IP地址、访问时间、访问页面等。
</p>
</section>
<section>
<h2>三、信息使用</h2>
<p>我们使用收集的信息用于以下目的:</p>
<p>1. 提供、维护和改进我们的服务;</p>
<p>2. 处理您的注册、登录、学习记录等请求;</p>
<p>3. 向您发送服务通知、更新和安全提醒;</p>
<p>4. 进行数据分析,以改善用户体验和服务质量;</p>
<p>5. 检测、预防和解决技术问题;</p>
<p>6. 遵守法律法规要求。</p>
</section>
<section>
<h2>四、信息存储</h2>
<p>1. 我们采用行业标准的安全措施来保护您的个人信息,防止未经授权的访问、使用或泄露。</p>
<p>2. 您的个人信息将存储在安全的服务器上,我们会对数据进行加密处理。</p>
<p>3. 我们仅在为实现本隐私政策所述目的所必需的期间内保留您的个人信息。</p>
</section>
<section>
<h2>五、信息共享</h2>
<p>我们不会向第三方出售、交易或转让您的个人信息,除非:</p>
<p>1. 获得您的明确同意;</p>
<p>2. 法律法规要求或司法机关、行政机关依法要求提供;</p>
<p>3. 为履行我们的服务协议或本隐私政策,我们可能需要与我们的服务提供商共享某些信息。</p>
</section>
<section>
<h2>六、Cookie和类似技术</h2>
<p>
我们可能使用Cookie和类似技术来收集信息、改善用户体验和分析服务使用情况。您可以通过浏览器设置管理Cookie但这可能影响某些功能的正常使用。</p>
</section>
<section>
<h2>七、您的权利</h2>
<p>根据相关法律法规,您对自己的个人信息享有以下权利:</p>
<p>1. <strong>访问权:</strong>您有权访问我们持有的关于您的个人信息;</p>
<p>2. <strong>更正权:</strong>您有权要求更正不准确的个人信息;</p>
<p>3. <strong>删除权:</strong>在特定情况下,您有权要求删除您的个人信息;</p>
<p>4. <strong>撤回同意:</strong>您有权随时撤回您之前给予的同意;</p>
<p>5. <strong>投诉权:</strong>如果您认为我们对您个人信息的处理违反了相关法律法规,您有权向相关监管部门投诉。
</p>
</section>
<section>
<h2>八、未成年人保护</h2>
<p>
我们非常重视未成年人的个人信息保护。如果您是未成年人,建议您请您的父母或监护人仔细阅读本隐私政策,并在征得您的父母或监护人同意的前提下使用我们的服务。</p>
</section>
<section>
<h2>九、隐私政策更新</h2>
<p>
我们可能会不时更新本隐私政策。我们会在本页面上发布新的隐私政策,并通过适当方式通知您。如果您不同意更新后的隐私政策,您可以选择停止使用我们的服务。</p>
</section>
<section>
<h2>十、联系我们</h2>
<p>如果您对本隐私政策有任何疑问、意见或建议,或需要行使您的相关权利,请通过以下方式联系我们:</p>
<p>邮箱zyronon@163.com</p>
</section>
<div class="update-time">
<p>最后更新时间2025年11月11日</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,83 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>用户协议</title>
</head>
<body
style="display:flex;justify-content:center">
<div class="privacy-page"
style="width: 60vw;"
>
<h1 style="text-align: center">用户协议</h1>
<div class="content">
<section>
<h2>一、总则</h2>
<p>欢迎使用本应用!在使用本应用之前,请您仔细阅读本用户协议(以下简称"本协议")。当您注册、登录、使用(以下统称"使用")本应用时,即表示您已阅读、理解并同意接受本协议的全部内容。</p>
</section>
<section>
<h2>二、服务内容</h2>
<p>本应用为用户提供单词学习、文章阅读等在线教育服务。我们保留随时修改或中断服务而不需通知用户的权利,我们行使修改或中断服务的权利,不需对用户或第三方负责。</p>
</section>
<section>
<h2>三、用户账户</h2>
<p>1. 用户在使用本应用前需要注册一个账户。用户应当使用真实、准确、完整的信息注册账户。</p>
<p>2. 用户有责任维护账户信息的安全,对账户下的所有活动负责。</p>
<p>3. 用户不得将账户转让、出售或以其他方式提供给第三方使用。</p>
</section>
<section>
<h2>四、用户行为规范</h2>
<p>用户在使用本应用时,应当遵守相关法律法规,不得从事以下行为:</p>
<p>1. 发布、传播违法、有害、威胁、辱骂、骚扰、侵权、诽谤、淫秽、暴力或其他不当内容;</p>
<p>2. 侵犯他人知识产权、隐私权或其他合法权益;</p>
<p>3. 干扰或破坏本应用的正常运行;</p>
<p>4. 使用自动化工具或脚本进行数据采集、批量操作等;</p>
<p>5. 其他违反法律法规或本协议的行为。</p>
</section>
<section>
<h2>五、知识产权</h2>
<p>1. 本应用的所有内容,包括但不限于文字、图片、音频、视频、软件、程序、版面设计等,均受知识产权法保护。</p>
<p>2. 未经我们书面许可,用户不得复制、传播、展示、镜像、上传、下载本应用的任何内容。</p>
</section>
<section>
<h2>六、隐私保护</h2>
<p>我们重视用户的隐私保护。关于我们如何收集、使用、存储和保护您的个人信息,请详见《隐私政策》。</p>
</section>
<section>
<h2>七、免责声明</h2>
<p>1. 用户明确同意使用本应用的风险由用户个人承担。</p>
<p>2. 我们不对因不可抗力或非我们原因造成的服务中断或终止承担责任。</p>
<p>3. 我们不对用户在使用本应用过程中产生的任何直接、间接、偶然、特殊及后续的损害承担责任。</p>
</section>
<section>
<h2>八、协议修改</h2>
<p>我们有权随时修改本协议的任何条款。一旦本协议的内容发生变动,我们将会通过适当方式向用户提示修改内容。如果用户不同意我们对本协议相关条款所做的修改,用户有权停止使用本应用。如果用户继续使用本应用,则视为用户接受我们对本协议相关条款所做的修改。</p>
</section>
<section>
<h2>九、法律适用与争议解决</h2>
<p>1. 本协议的订立、执行和解释及争议的解决均应适用中华人民共和国法律。</p>
<p>2. 如双方就本协议内容或其执行发生任何争议,双方应尽量友好协商解决;协商不成时,任何一方均可向我们所在地的人民法院提起诉讼。</p>
</section>
<section>
<h2>十、其他</h2>
<p>1. 本协议构成双方对本协议之约定事项及其他有关事宜的完整协议,除本协议规定的之外,未赋予本协议各方其他权利。</p>
<p>2. 如本协议中的任何条款无论因何种原因完全或部分无效或不具有执行力,本协议的其余条款仍应有效并且有约束力。</p>
</section>
<div class="update-time">
<p>最后更新时间2025年11月11日</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -5,17 +5,18 @@ 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 { 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 { APP_VERSION, AppEnv, LOCAL_FILE_KEY, SAVE_DICT_KEY, SAVE_SETTING_KEY } from "@/config/env.ts";
import { syncSetting } from "@/apis";
import { useUserStore } from "@/stores/auth.ts";
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
const userStore = useUserStore()
const { setTheme } = useTheme()
let lastAudioFileIdList = []
@@ -51,15 +52,17 @@ watch(store.$state, (n: BaseState) => {
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) {
@@ -80,19 +83,19 @@ 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'
// 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>
@@ -106,6 +109,4 @@ watch(() => route.path, (to, from) => {
<!-- </transition>-->
<!-- </router-view>-->
<router-view></router-view>
</template>
<style scoped lang="scss"></style>
</template>

View File

@@ -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')
}

30
src/apis/member.ts Normal file
View File

@@ -0,0 +1,30 @@
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 function levelBenefits(params) {
return http<LevelBenefits>('member/levelBenefits', null, params, 'get')
}

115
src/apis/user.ts Normal file
View File

@@ -0,0 +1,115 @@
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,
}
}
// 用户注册接口
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')
}

View File

@@ -27,6 +27,7 @@
}
@keyframes shake {
10%,
90% {

View File

@@ -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;
@@ -71,7 +72,7 @@
--color-progress-bar: #d1d5df !important;
--color-label-bg: whitesmoke;
--color-link: rgb(64, 158, 255)
--color-link: #2563EB;
}
.footer {
@@ -183,7 +184,7 @@ html, body {
z-index: 1;
height: 100%;
width: 100%;
font-size: .9rem;
font-size: 1rem;
display: flex;
flex-direction: column;
}
@@ -218,6 +219,14 @@ a {
text-decoration: none;
}
.link {
color: var(--color-link);
@apply hover:opacity-80;
}
.cp {
@apply cursor-pointer;
}
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
@@ -390,7 +399,7 @@ 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);
}
@@ -413,6 +422,8 @@ a {
.line {
width: 100%;
border-bottom: 1px solid var(--color-item-border);
@apply hover:text-blue-700;
}
.line-white {

View File

@@ -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) {

View File

@@ -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:not(.disabled) {
opacity: .6;
}
&.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,11 +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 {

29
src/components/Header.vue Normal file
View 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>

View File

@@ -1,11 +1,13 @@
<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: {
@@ -60,18 +62,18 @@ 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">
<div ref="tip" class="pop-confirm-content shadow-2xl">
<div class="w-50">
{this.title}
</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 +87,18 @@ 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;
}
.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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} &nbsp;</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>

View 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'
}

View File

@@ -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;
}
}

52
src/config/auth.ts Normal file
View 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
}

View File

@@ -1,8 +1,6 @@
import { useBaseStore } from "@/stores/base.ts";
export const GITHUB = 'https://github.com/zyronon/TypeWords'
export const ProjectName = 'Type Words'
export const Host = '2study.top'
export const EMAIL = 'zyronon@163.com'
export const Origin = `https://${Host}`
export const APP_NAME = 'Type Words'
@@ -16,11 +14,17 @@ 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
export const RESOURCE_PATH = ENV.API + 'static'
export const DICT_LIST = {
@@ -58,6 +62,7 @@ export const EXPORT_DATA_KEY = {
version: 4
}
export const LOCAL_FILE_KEY = 'typing-word-files'
export const PracticeSaveWordKey = {
key: 'PracticeSaveWord',
version: 1

View File

@@ -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'
@@ -22,7 +22,6 @@ app.directive('opacity', (el, binding) => {
el.style.opacity = binding.value ? 1 : 0
})
app.directive('loading', loadingDirective)
app.mount('#app')
// 注册Service Worker(pwa支持)

View File

@@ -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))

View File

@@ -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>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, provide, watch, nextTick } from "vue";
import { computed, onMounted, onUnmounted, provide, watch } from "vue";
import { useBaseStore } from "@/stores/base.ts";
import { emitter, EventKey, useEvents } from "@/utils/eventBus.ts";
import { useSettingStore } from "@/stores/setting.ts";
@@ -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";
@@ -278,7 +278,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)
@@ -361,7 +361,7 @@ 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)
@@ -457,26 +457,43 @@ const currentPractice = computed(() => {
provide('currentPractice', currentPractice)
</script>
<template>
<PracticeLayout v-loading="loading" panelLeft="var(--article-panel-margin-left)">
<PracticeLayout
v-loading="loading"
panelLeft="var(--article-panel-margin-left)">
<template v-slot:practice>
<TypingArticle ref="typingArticleRef" @wrong="wrong" @next="next" @nextWord="nextWord" @play="play2"
@replay="setArticle(articleData.article)" @complete="complete" :article="articleData.article" />
<TypingArticle
ref="typingArticleRef"
@wrong="wrong"
@next="next"
@nextWord="nextWord"
@play="play2"
@replay="setArticle(articleData.article)"
@complete="complete"
:article="articleData.article"
/>
</template>
<template v-slot:panel>
<Panel :style="{ width: 'var(--article-panel-width)' }">
<Panel :style="{width:'var(--article-panel-width)'}">
<template v-slot:title>
<span>{{
store.sbook.name
}} ({{ store.sbook.lastLearnIndex + 1 }} / {{ articleData.list.length }})</span>
<span>{{
store.sbook.name
}} ({{ store.sbook.lastLearnIndex + 1 }} / {{ articleData.list.length }})</span>
</template>
<div class="panel-page-item pl-4">
<ArticleList :isActive="settingStore.showPanel" :static="false" :show-translate="settingStore.translate"
@click="changeArticle" :active-id="articleData.article.id" :list="articleData.list">
<template v-slot:suffix="{ item, index }">
<BaseIcon :class="!isArticleCollect(item) ? 'collect' : 'fill'" @click.stop="toggleArticleCollect(item)"
:title="!isArticleCollect(item) ? '收藏' : '取消收藏'">
<IconFluentStar16Regular v-if="!isArticleCollect(item)" />
<IconFluentStar16Filled v-else />
<ArticleList
:isActive="settingStore.showPanel"
:static="false"
:show-translate="settingStore.translate"
@click="changeArticle"
:active-id="articleData.article.id"
:list="articleData.list ">
<template v-slot:suffix="{item,index}">
<BaseIcon
:class="!isArticleCollect(item) ? 'collect' : 'fill'"
@click.stop="toggleArticleCollect(item)"
:title="!isArticleCollect(item) ? '收藏' : '取消收藏'">
<IconFluentStar16Regular v-if="!isArticleCollect(item)"/>
<IconFluentStar16Filled v-else/>
</BaseIcon>
</template>
</ArticleList>
@@ -485,9 +502,12 @@ provide('currentPractice', currentPractice)
</template>
<template v-slot:footer>
<div class="footer">
<Tooltip :title="settingStore.showToolbar ? '收起' : '展开'">
<IconFluentChevronLeft20Filled @click="settingStore.showToolbar = !settingStore.showToolbar" class="arrow"
:class="!settingStore.showToolbar && 'down'" color="#999" />
<Tooltip :title="settingStore.showToolbar?'收起':'展开'">
<IconFluentChevronLeft20Filled
@click="settingStore.showToolbar = !settingStore.showToolbar"
class="arrow"
:class="!settingStore.showToolbar && 'down'"
color="#999"/>
</Tooltip>
<div class="bottom">
<div class="flex justify-between items-center gap-2">
@@ -506,7 +526,7 @@ provide('currentPractice', currentPractice)
<div class="num center gap-1">
{{ statStore.total }}
<Tooltip>
<IconFluentQuestionCircle20Regular width="18" />
<IconFluentQuestionCircle20Regular width="18"/>
<template #reference>
<div>
统计词数{{ settingStore.ignoreSimpleWord ? '不包含' : '包含' }}简单词不包含已掌握
@@ -519,29 +539,40 @@ provide('currentPractice', currentPractice)
<div class="name">单词总数</div>
</div>
</div>
<ArticleAudio ref="audioRef" :article="articleData.article" :autoplay="settingStore.articleAutoPlayNext"
@ended="settingStore.articleAutoPlayNext && next()" @update-speed="handleSpeedUpdate"
@update-volume="handleVolumeUpdate"></ArticleAudio>
<ArticleAudio
ref="audioRef"
:article="articleData.article"
:autoplay="settingStore.articleAutoPlayNext"
@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 />
<BaseIcon :title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`" @click="skip">
<IconFluentArrowBounce20Regular class="transform-rotate-180" />
<VolumeSetting/>
<BaseIcon
:title="`下一句(${settingStore.shortcutKeyMap[ShortcutKey.Next]})`"
@click="skip">
<IconFluentArrowBounce20Regular class="transform-rotate-180"/>
</BaseIcon>
<BaseIcon :title="`重听(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
@click="play">
<IconFluentReplay20Regular />
<BaseIcon
:title="`重听(${settingStore.shortcutKeyMap[ShortcutKey.PlayWordPronunciation]})`"
@click="play">
<IconFluentReplay20Regular/>
</BaseIcon>
<BaseIcon @click="settingStore.dictation = !settingStore.dictation"
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`">
<IconFluentEyeOff16Regular v-if="settingStore.dictation" />
<IconFluentEye16Regular v-else />
<BaseIcon
@click="settingStore.dictation = !settingStore.dictation"
:title="`开关默写模式(${settingStore.shortcutKeyMap[ShortcutKey.ToggleDictation]})`"
>
<IconFluentEyeOff16Regular v-if="settingStore.dictation"/>
<IconFluentEye16Regular v-else/>
</BaseIcon>
<BaseIcon :title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
@click="settingStore.translate = !settingStore.translate">
<IconFluentTranslate16Regular v-if="settingStore.translate" />
<IconFluentTranslateOff16Regular v-else />
<BaseIcon
:title="`开关释义显示(${settingStore.shortcutKeyMap[ShortcutKey.ToggleShowTranslate]})`"
@click="settingStore.translate = !settingStore.translate">
<IconFluentTranslate16Regular v-if="settingStore.translate"/>
<IconFluentTranslateOff16Regular v-else/>
</BaseIcon>
<!-- <BaseIcon-->
@@ -549,9 +580,10 @@ provide('currentPractice', currentPractice)
<!-- icon="tabler:edit"-->
<!-- @click="emitter.emit(ShortcutKey.EditArticle)"-->
<!-- />-->
<BaseIcon @click="settingStore.showPanel = !settingStore.showPanel"
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`">
<IconFluentTextListAbcUppercaseLtr20Regular />
<BaseIcon
@click="settingStore.showPanel = !settingStore.showPanel"
:title="`面板(${settingStore.shortcutKeyMap[ShortcutKey.TogglePanel]})`">
<IconFluentTextListAbcUppercaseLtr20Regular/>
</BaseIcon>
</div>
</div>
@@ -561,12 +593,17 @@ provide('currentPractice', currentPractice)
</template>
</PracticeLayout>
<EditSingleArticleModal v-model="showEditArticle" :article="editArticle" @save="saveArticle" />
<EditSingleArticleModal
v-model="showEditArticle"
:article="editArticle"
@save="saveArticle"
/>
<ConflictNotice />
<ConflictNotice/>
</template>
<style scoped lang="scss">
.footer {
width: var(--article-toolbar-width);

View File

@@ -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

View File

@@ -437,15 +437,15 @@ function onContextMenu(e: MouseEvent, sentence: Sentence, i, j, w) {
onClick: () => {
let word = props.article.sections[i][j].words[w]
let text = word.word
let doc = nlp(text)
// 优先判断是不是动词
if (doc.verbs().found) {
text = doc.verbs().toInfinitive().text()
}
// 如果是名词(复数 → 单数)
if (doc.nouns().found) {
text = doc.nouns().toSingular().text()
}
// let doc = nlp(text)
// // 优先判断是不是动词
// if (doc.verbs().found) {
// text = doc.verbs().toInfinitive().text()
// }
// // 如果是名词(复数 → 单数)
// if (doc.nouns().found) {
// text = doc.nouns().toSingular().text()
// }
if (!text.length) text = word.word
console.log('text', text)
toggleWordCollect(getDefaultWord({word: text, id: nanoid()}))
@@ -645,7 +645,7 @@ const currentPractice = inject('currentPractice', [])
<span :class="i === currentPractice.length-1 ? 'color-red':'color-gray'"
>{{
i === currentPractice.length - 1 ? '当前' : i + 1
}}.&nbsp;&nbsp;{{ _dateFormat(item.startDate, 'YYYY/MM/DD HH:mm') }}</span>
}}.&nbsp;&nbsp;{{ _dateFormat(item.startDate) }}</span>
<span>{{ msToHourMinute(item.spend) }}</span>
</div>
</div>

View File

@@ -44,10 +44,10 @@ function goHome() {
<span v-if="settingStore.sideExpand">设置</span>
<div class="red-point" :class="!settingStore.sideExpand && 'top-1 right-0'" v-if="runtimeStore.isNew"></div>
</div>
<!-- <div class="row" @click="router.push('/user')">-->
<!-- <IconFluentPerson20Regular/>-->
<!-- <span v-if="settingStore.sideExpand">用户</span>-->
<!-- </div>-->
<!-- <div class="row" @click="router.push('/user')">-->
<!-- <IconFluentPerson20Regular/>-->
<!-- <span v-if="settingStore.sideExpand">用户</span>-->
<!-- </div>-->
</div>
<div class="bottom flex justify-evenly ">
<BaseIcon

View File

@@ -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 { DefaultShortcutKeyMap, ShortcutKey, WordPracticeMode } from "@/types/types.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 {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,7 @@ const tabIndex = $ref(0)
const settingStore = useSettingStore()
const runtimeStore = useRuntimeStore()
const store = useBaseStore()
const userStore = useUserStore()
//@ts-ignore
const gitLastCommitHash = ref(LATEST_COMMIT_HASH);
@@ -96,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;
}
@@ -425,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>
<!-- 音效-->
@@ -454,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>
@@ -581,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>
@@ -639,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>
@@ -676,8 +678,8 @@ function importOldData() {
@change="importData">
</div>
<PopConfirm
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
@confirm="importOldData">
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
@confirm="importOldData">
<BaseButton>老版本数据导入</BaseButton>
</PopConfirm>
</div>
@@ -687,46 +689,62 @@ function importOldData() {
<div class="log-item">
<div class="mb-2">
<div>
<div>更新日期2025/11/14</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/13</div>
<div>更新内容新增文章练习时输入时忽略符号/数字选项</div>
<div>日期2025/11/15</div>
<div>内容练习单词底部工具栏新增跳到下一阶段按钮</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>更新日期2025/11/6</div>
<div>更新内容新增随机复习功能</div>
<div>日期2025/11/14</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>日期2025/11/13</div>
<div>内容新增文章练习时输入时忽略符号/数字选项</div>
</div>
</div>
</div>
<div class="log-item">
<div class="mb-2">
<div>
<div>更新日期2025/10/26</div>
<div>更新内容进一步完善单词练习解决复习数量太多的问题</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>
@@ -739,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>
@@ -771,16 +789,16 @@ function importOldData() {
<div class="log-item">
<div class="mb-2">
<div>
<div>更新日期2025/10/8</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>日期2025/9/14</div>
<div>内容完善文章编辑导入导出等功能</div>
</div>
<div class="text-base mt-1">
<div>1文章的音频管理功能目前已可添加音频设置句子与音频的对应位置</div>
@@ -789,22 +807,60 @@ function importOldData() {
</div>
</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 }}
@@ -818,11 +874,80 @@ function importOldData() {
<style scoped lang="scss">
.log-item{
.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
View 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
View 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>

217
src/pages/user/Pay.vue Normal file
View File

@@ -0,0 +1,217 @@
<script setup lang="ts">
import { ref } from 'vue'
import BasePage from '@/components/BasePage.vue'
import BaseButton from '@/components/BaseButton.vue'
// Payment method selection
const selectedPaymentMethod = ref('')
const agreeToTerms = ref(false)
// Payment methods - WeChat and Alipay
const paymentMethods = [
{
id: 'wechat',
name: '微信支付',
icon: '💚',
description: '使用微信支付'
},
{
id: 'alipay',
name: '支付宝',
icon: '💙',
description: '使用支付宝支付'
}
]
// Order data (this would typically come from props or store)
const orderData = {
planName: '月度会员',
price: 9.99,
currency: 'US$',
unit: '每月',
startDate: new Date().toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
}),
vatText: '(含增值税)'
}
function selectPaymentMethod(methodId: string) {
selectedPaymentMethod.value = methodId
}
function handlePayment() {
if (!selectedPaymentMethod.value) {
alert('请选择支付方式')
return
}
if (!agreeToTerms.value) {
alert('请同意服务条款')
return
}
// TODO: Implement payment processing
console.log('Processing payment with:', selectedPaymentMethod.value)
}
function handleChangePlan() {
// TODO: Navigate back to plan selection
console.log('Change plan clicked')
}
</script>
<template>
<BasePage>
<div class="pay-page min-h-screen py-8">
<!-- Page Header -->
<div class="text-center mb-8">
<h1 class="text-2xl font-semibold text-gray-900 mb-2">安全支付</h1>
<p class="text-gray-600">选择支付方式完成订单</p>
</div>
<!-- Main Content -->
<div class="max-w-6xl mx-auto px-4">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Card: Payment Method Selection -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h2 class="text-lg font-medium text-gray-900 mb-4">Choose a way to pay</h2>
<div class="space-y-3">
<div
v-for="method in paymentMethods"
:key="method.id"
@click="selectPaymentMethod(method.id)"
class="flex items-center p-4 border rounded-lg cursor-pointer transition-all duration-200"
:class="[
selectedPaymentMethod === method.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
]"
>
<div class="flex items-center flex-1">
<span class="text-2xl mr-3">{{ method.icon }}</span>
<div>
<div class="font-medium text-gray-900">{{ method.name }}</div>
<div class="text-sm text-gray-500">{{ method.description }}</div>
</div>
</div>
<div
class="w-5 h-5 rounded-full border-2 flex items-center justify-center"
:class="[
selectedPaymentMethod === method.id
? 'border-blue-500 bg-blue-500'
: 'border-gray-300'
]"
>
<div
v-if="selectedPaymentMethod === method.id"
class="w-2 h-2 bg-white rounded-full"
></div>
</div>
</div>
</div>
</div>
<!-- Right Card: Order Summary -->
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900">订单概要</h2>
<button
@click="handleChangePlan"
class="px-3 py-1 text-sm text-gray-600 border border-gray-300 rounded-full hover:bg-gray-50 transition-colors"
>
更改
</button>
</div>
<!-- Plan Info -->
<div class="mb-4">
<div class="text-purple-600 text-sm mb-2">付费方案月费订阅</div>
<div class="text-gray-900 mb-4">
{{ orderData.startDate }} 开始:
</div>
</div>
<!-- Price -->
<div class="flex items-baseline mb-4">
<span class="text-3xl font-semibold text-gray-900">{{ orderData.currency }}{{ orderData.price }}</span>
<span class="text-gray-600 ml-2">/ {{ orderData.unit }}</span>
</div>
<div class="text-sm text-gray-500 mb-6">{{ orderData.vatText }}</div>
<!-- Info Box -->
<div class="bg-gray-50 rounded-lg p-4 mb-6">
<p class="text-sm text-gray-600 mb-2">
你将于 {{ orderData.startDate }} 付费
</p>
<p class="text-sm text-gray-600">
在试订期间和订阅开始前的24小时内你可随时通过 "账户" > "订阅" 页面取消或改订
</p>
</div>
<!-- Terms Checkbox -->
<div class="flex items-start mb-6">
<input
type="checkbox"
id="terms"
v-model="agreeToTerms"
class="mt-1 w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
>
<label for="terms" class="ml-3 text-sm text-gray-700">
我同意 Lingvist
<a href="#" class="text-purple-600 hover:text-purple-700 underline">服务条款</a>
</label>
</div>
<!-- Payment Button -->
<BaseButton
class="w-full"
size="large"
:type="selectedPaymentMethod && agreeToTerms ? 'primary' : 'default'"
:disabled="!selectedPaymentMethod || !agreeToTerms"
@click="handlePayment"
>
选择
</BaseButton>
</div>
</div>
</div>
</div>
</BasePage>
</template>
<style scoped lang="scss">
.pay-page {
@apply min-h-screen;
}
/* Custom radio button style */
.payment-method {
@apply flex items-center p-4 border rounded-lg cursor-pointer transition-all duration-200;
&:hover {
@apply border-gray-300 bg-gray-50;
}
&.selected {
@apply border-blue-500 bg-blue-50;
}
}
.payment-radio {
@apply w-5 h-5 rounded-full border-2 flex items-center justify-center;
&.selected {
@apply border-blue-500 bg-blue-500;
}
&.unselected {
@apply border-gray-300;
}
}
.radio-dot {
@apply w-2 h-2 bg-white rounded-full;
}
</style>

616
src/pages/user/User.vue Normal file
View File

@@ -0,0 +1,616 @@
<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')
}
</script>
<template>
<BasePage>
<!-- Unauthenticated View -->
<div v-if="!userStore.isLogin" class="center h-screen">
<div class="card bg-white shadow-lg 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 -->
<!-- todo 夜间背景色-->
<div class="card bg-reverse-white shadow-lg 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 cp" @click="showChangePwdForm">
<div class="flex-1">
<div class="mb-2">设置密码</div>
<div class="text-xs">在此输入密码</div>
</div>
<IconFluentChevronLeft28Filled
class="transition-transform"
:class="['rotate-270','rotate-180'][showChangePwd?0:1]"/>
</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"
@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 -->
<!-- todo 夜间背景色-->
<div class="card bg-reverse-white shadow-lg 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?.levelDesc }}</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>

311
src/pages/user/VipIntro.vue Normal file
View File

@@ -0,0 +1,311 @@
<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, ref } from "vue";
import Header from "@/components/Header.vue";
import { LevelBenefits, levelBenefits } 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";
const router = useRouter()
const userStore = useUserStore()
interface Plan {
id: string
name: string
price: number
unit: '月' | '年'
highlight?: string
autoRenew?: boolean
}
let selectedPaymentMethod = $ref('wechat')
let selectedSubscribePlan = $ref(undefined)
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: 'monthly',
name: '月付',
price: data.level.price,
unit: '月',
},)
list.push({
id: 'monthly-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
})
const currentPlan = $computed(() => {
return plans.find(v => v.id === selectedSubscribePlan) ?? null
})
onMounted(async () => {
let res = await levelBenefits({levelCode: 'basic'})
if (res.success) {
data = res.data
}
})
// Toggle auto-renewal
function toggleAutoRenew() {
// TODO: Implement API call to toggle auto-renewal
console.log('Toggle auto-renewal:', !member.autoRenew)
}
// Get button text based on current plan
function getPlanButtonText(plan: Plan) {
if (plan.id === selectedSubscribePlan) return '当前计划'
if (!member?.active) return '选择'
}
function goPurchase(plan: Plan) {
if (!userStore.isLogin) {
router.push({path: '/login', query: {redirect: '/vip'}})
return
}
selectedSubscribePlan = plan.id
_nextTick(() => {
let el = document.getElementById('pay')
el.scrollIntoView({behavior: "smooth"})
})
}
// Payment methods - WeChat and Alipay
const paymentMethods = [
{
id: 'wechat',
name: '微信支付',
description: '使用微信支付'
},
{
id: 'alipay',
name: '支付宝',
description: '使用支付宝支付'
}
]
function handlePayment() {
console.log('Processing payment with:', selectedPaymentMethod)
}
</script>
<template>
<BasePage>
<div class="space-y-6">
<div>
<Header title="会员介绍"></Header>
<div class="center">
<div>
<div class="text-lg flex 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>
<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 bg-reverse-white shadow-lg p-0 shadow-lg overflow-hidden flex flex-col">
<div class="plan-name">{{ 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 === selectedSubscribePlan ? 'primary' : 'info'"
@click="goPurchase(p)">
{{ getPlanButtonText(p) }}
</BaseButton>
</div>
</div>
</div>
<!-- Membership Status Display -->
<div v-if="member?.active" class="card bg-green-50 border border-green-200 mt-3 mb-6 shadow-lg">
<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">当前计划{{ member?.levelDesc }}</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>
<BaseButton
size="small"
type="info"
@click="toggleAutoRenew">
关闭
</BaseButton>
</div>
</div>
</div>
</div>
</div>
<div id="pay" class="mb-50" v-if="member?.plan !== selectedSubscribePlan">
<!-- Page Header -->
<div class="text-center mb-6">
<h1 class="text-xl font-semibold mb-2">安全支付</h1>
<p class="">选择支付方式完成订单</p>
</div>
<!-- Main Content -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Card: Payment Method Selection -->
<div class="card bg-white shadow-lg">
<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"
:class="[
selectedPaymentMethod === method.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
]"
>
<div class="flex items-center flex-1 gap-2">
<IconSimpleIconsWechat class="text-xl"/>
<div>
<div class="font-medium">{{ 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 bg-white shadow-lg">
<div class="text-lg font-semibold mb-4">订单概要</div>
<!-- Plan Info -->
<div class="mb-4">
<div class="text-purple-600 text-sm mb-2">付费方案{{ currentPlan.name }}订阅</div>
<div class="text-gray-900 mb-4">
{{ _dateFormat(Date.now()) }} 开始:
</div>
</div>
<!-- Price -->
<div class="flex items-baseline mb-4">
<span class="text-3xl font-semibold text-gray-900">{{ currentPlan.price }}</span>
<span class="text-gray-600 ml-2">/ {{ currentPlan.unit }}</span>
</div>
<!-- Payment Button -->
<BaseButton
class="w-full"
size="large"
: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;
}
.plan-name {
@apply text-2xl font-bold bg-gray-300 px-6 py-4;
}
.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>

View File

@@ -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>

View File

@@ -1,66 +1,588 @@
<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, sendCode} 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 BackIcon from "@/components/BackIcon.vue";
import {useNav} from "@/utils";
import Header from "@/components/Header.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 codeCountdown = $ref(0)
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
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)
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="rounded-2xl p-2 bg-white shadow-lg">
<!-- 登录区域容器 - 弹框形式 -->
<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>
</template>
<style scoped lang="scss">
<style scoped lang="scss">
</style>

View File

@@ -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";
const runtimeStore = useRuntimeStore()
@@ -196,7 +196,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

View File

@@ -248,9 +248,7 @@ function goNextStep(originList, mode, msg) {
}
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)))
@@ -310,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, '开始辨认之前')
}
//开始默写上次
@@ -325,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, '开始辨认昨日')
}
//开始默写新词
@@ -352,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)
@@ -649,6 +654,7 @@ useEvents([
:is-collect="isWordCollect(word)"
@toggle-collect="toggleWordCollect(word)"
@skip="next(false)"
@skipStep="skipStep"
/>
</template>
</PracticeLayout>

View File

@@ -1,26 +1,26 @@
<script setup lang="ts">
import {useBaseStore} from "@/stores/base.ts";
import {useRouter} from "vue-router";
import { useBaseStore } from "@/stores/base.ts";
import { useRouter } from "vue-router";
import BaseIcon from "@/components/BaseIcon.vue";
import {_getAccomplishDate, _getDictDataByUrl, resourceWrap, shuffle, 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 {watch} from "vue";
import {getCurrentStudyWord} from "@/hooks/dict.ts";
import {useRuntimeStore} from "@/stores/runtime.ts";
import { DictResource, WordPracticeMode } from "@/types/types.ts";
import { watch } from "vue";
import { getCurrentStudyWord } from "@/hooks/dict.ts";
import { useRuntimeStore } from "@/stores/runtime.ts";
import Book from "@/components/Book.vue";
import PopConfirm from "@/components/PopConfirm.vue";
import Progress from '@/components/base/Progress.vue';
import Toast from '@/components/base/toast/Toast.ts';
import BaseButton from "@/components/BaseButton.vue";
import {getDefaultDict} from "@/types/func.ts";
import { getDefaultDict } from "@/types/func.ts";
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 {useFetch} from "@vueuse/core";
import {CAN_REQUEST, DICT_LIST, PracticeSaveWordKey} from "@/config/env.ts";
import {myDictList} from "@/apis";
import { useSettingStore } from "@/stores/setting.ts";
import { useFetch } from "@vueuse/core";
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";
@@ -44,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))
@@ -208,52 +208,68 @@ const {
<IconFluentBookNumber20Filled class="text-xl color-link"/>
</div>
<div
@click="goDictDetail(store.sdict)"
class="text-2xl font-bold cursor-pointer">
{{ store.sdict.name || '请选择词典开始学习' }}
@click="goDictDetail(store.sdict)"
class="text-2xl font-bold cursor-pointer">
{{ store.sdict.name || '当前无正在学习的词典' }}
</div>
</div>
<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">
<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 mt-4 gap-4">
<BaseButton type="info" @click="router.push('/dict-list')">
<div class="center gap-1">
<IconFluentArrowSwap20Regular/>
<span>{{ store.sdict.name ? '切换' : '选择' }}词典</span>
</div>
</BaseButton>
<PopConfirm
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showChangeLastPracticeIndexDialog = true)">
</div>
<div class="flex items-center mt-4 gap-4">
<BaseButton type="info"
v-if="store.sdict.id"
>
size="small"
@click="router.push('/dict-list')">
<div class="center gap-1">
<IconFluentSlideTextTitleEdit20Regular/>
<span>更改进度</span>
<IconFluentArrowSwap20Regular/>
<span>选择词典</span>
</div>
</BaseButton>
</PopConfirm>
<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="flex-1">
<div class="flex-1" :class="!store.sdict.id && 'opacity-30 cursor-not-allowed'">
<div class="flex justify-between">
<div class="flex items-center gap-3">
<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 ? '上次学习任务' : '今日任务' }}
{{ isSaveData ? '上次任务' : '今日任务' }}
</div>
<span class="color-link cursor-pointer"
v-if="store.sdict.id"
@@ -270,11 +286,11 @@ const {
</div>
个单词
<PopConfirm
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showPracticeSettingDialog = true)">
:disabled="!isSaveData"
title="当前存在未完成的学习任务,修改会重新生成学习任务,是否继续?"
@confirm="check(()=>showPracticeSettingDialog = true)">
<BaseButton
type="info" size="small">更改
type="info" size="small">更改
</BaseButton>
</PopConfirm>
</div>
@@ -306,11 +322,59 @@ const {
<IconFluentArrowCircleRight16Regular class="text-xl"/>
</div>
</BaseButton>
<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>
<BaseButton
v-if="store.sdict.id && store.sdict.lastLearnIndex"
size="large" type="orange"
:loading="loading"
@click="check(()=>showShufflePracticeSettingDialog = true)">
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"/>
@@ -320,7 +384,7 @@ const {
</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">
@@ -344,7 +408,7 @@ const {
</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">
@@ -362,23 +426,23 @@ const {
</BasePage>
<PracticeSettingDialog
:show-left-option="false"
v-model="showPracticeSettingDialog"
@ok="savePracticeSetting"/>
:show-left-option="false"
v-model="showPracticeSettingDialog"
@ok="savePracticeSetting"/>
<ChangeLastPracticeIndexDialog
v-model="showChangeLastPracticeIndexDialog"
@ok="saveLastPracticeIndex"
v-model="showChangeLastPracticeIndexDialog"
@ok="saveLastPracticeIndex"
/>
<PracticeWordListDialog
:data="currentStudy"
v-model="showPracticeWordListDialog"
:data="currentStudy"
v-model="showPracticeWordListDialog"
/>
<ShufflePracticeSettingDialog
v-model="showShufflePracticeSettingDialog"
@ok="onShufflePracticeSettingOk"/>
v-model="showShufflePracticeSettingDialog"
@ok="onShufflePracticeSettingOk"/>
</template>

View File

@@ -3,7 +3,7 @@
import { inject, Ref, watch } from "vue"
import { usePracticeStore } from "@/stores/practice.ts";
import { useSettingStore } from "@/stores/setting.ts";
import {PracticeData, WordPracticeType, ShortcutKey, TaskWords} 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'
@@ -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 (statStore.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,12 +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
@@ -115,6 +123,13 @@ const progress = $computed(() => {
</div>
</div>
<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')"
@@ -132,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>
@@ -207,7 +222,6 @@ const progress = $computed(() => {
flex-direction: column;
align-items: center;
gap: .3rem;
width: 6rem;
color: gray;
.line {

View File

@@ -189,6 +189,7 @@ async function onTyping(e: KeyboardEvent) {
}
inputLock = true
let letter = e.key
console.log('letter',letter)
//默写特殊逻辑
if (settingStore.wordPracticeType === WordPracticeType.Dictation) {
if (e.code === 'Space') {
@@ -221,6 +222,13 @@ 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) {
@@ -293,14 +301,18 @@ function del() {
function showWord() {
if (settingStore.allowWordTip) {
showFullWord = true
}
//系统设定的默认模式情况下,如果看了单词统计到错词里面去
switch (statStore.step) {
case 1:
case 3:
case 4:
emit('wrong')
break
//系统设定的默认模式情况下,如果看了单词统计到错词里面去
switch (statStore.step) {
case 1:
case 2:
case 4:
case 5:
case 7:
case 8:
case 10:
emit('wrong')
break
}
}
}
@@ -309,6 +321,9 @@ function hideWord() {
}
function play() {
if (settingStore.wordPracticeType === WordPracticeType.Dictation) {
emit('wrong')
}
volumeIconRef?.play()
}

View File

@@ -11,7 +11,10 @@ import DictList from "@/pages/word/DictList.vue";
import BookList from "@/pages/article/BookList.vue";
import Setting from "@/pages/setting/Setting.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 Pay from "@/pages/user/Pay.vue";
// import { useAuthStore } from "@/stores/auth.ts";
export const routes: RouteRecordRaw[] = [
{
@@ -35,11 +38,13 @@ export const routes: RouteRecordRaw[] = [
{path: 'setting', component: Setting},
{path: 'login', component: Login},
{path: 'user', component: User},
{path: 'vip', component: VipIntro},
{path: 'pay', component: Pay},
]
},
{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({
@@ -56,8 +61,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()

79
src/stores/auth.ts Normal file
View File

@@ -0,0 +1,79 @@
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() {
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() {
if (AppEnv.CAN_REQUEST) {
const success = await fetchUserInfo()
if (!success) {
clearToken()
}
}
}
return {
user,
isLogin,
setToken,
clearToken,
setUser,
logout,
fetchUserInfo,
init
}
})

View File

@@ -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";
@@ -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)

View File

@@ -1,48 +1,48 @@
import { defineStore } from "pinia"
import { checkAndUpgradeSaveSetting, cloneDeep } from "@/utils"
import { DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType } from "@/types/types.ts"
import { get } from "idb-keyval"
import { CAN_REQUEST, SAVE_SETTING_KEY } from "@/config/env.ts"
import { getSetting } from "@/apis"
import {defineStore} from "pinia"
import {checkAndUpgradeSaveSetting, cloneDeep} from "@/utils";
import {DefaultShortcutKeyMap, WordPracticeMode, WordPracticeType} from "@/types/types.ts";
import {get} from "idb-keyval";
import {CAN_REQUEST, SAVE_SETTING_KEY} from "@/config/env.ts";
import {getSetting} from "@/apis";
export interface SettingState {
soundType: string
soundType: string,
wordSound: boolean
wordSoundVolume: number
wordSoundSpeed: number
wordSound: boolean,
wordSoundVolume: number,
wordSoundSpeed: number,
articleSound: boolean
articleAutoPlayNext: boolean
articleSoundVolume: number
articleSoundSpeed: number
articleSound: boolean,
articleAutoPlayNext: boolean,
articleSoundVolume: number,
articleSoundSpeed: number,
keyboardSound: boolean
keyboardSoundVolume: number
keyboardSoundFile: string
keyboardSound: boolean,
keyboardSoundVolume: number,
keyboardSoundFile: string,
effectSound: boolean
effectSoundVolume: number
effectSound: boolean,
effectSoundVolume: number,
repeatCount: number //重复次数
repeatCustomCount?: number //自定义重复次数
dictation: boolean //显示默写
translate: boolean //显示翻译
repeatCount: number, //重复次数
repeatCustomCount?: number, //自定义重复次数
dictation: boolean,//显示默写
translate: boolean, //显示翻译
showNearWord: boolean //显示上/下一个词
ignoreCase: boolean //忽略大小写
allowWordTip: boolean //默写时时否允许查看提示
waitTimeForChangeWord: number // 切下一个词的等待时间
fontSize: {
articleForeignFontSize: number
articleTranslateFontSize: number
wordForeignFontSize: number
wordTranslateFontSize: number
}
showToolbar: boolean //收起/展开工具栏
showPanel: boolean // 收起/展开面板
sideExpand: boolean //收起/展开左侧侧边栏
theme: string
shortcutKeyMap: Record<string, string>
articleForeignFontSize: number,
articleTranslateFontSize: number,
wordForeignFontSize: number,
wordTranslateFontSize: number,
},
showToolbar: boolean, //收起/展开工具栏
showPanel: boolean, // 收起/展开面板
sideExpand: boolean, //收起/展开左侧侧边栏
theme: string,
shortcutKeyMap: Record<string, string>,
first: boolean
firstTime: number
load: boolean
@@ -57,7 +57,7 @@ export interface SettingState {
}
export const getDefaultSettingState = (): SettingState => ({
soundType: "us",
soundType: 'us',
wordSound: true,
wordSoundVolume: 100,
@@ -70,7 +70,7 @@ export const getDefaultSettingState = (): SettingState => ({
keyboardSound: true,
keyboardSoundVolume: 100,
keyboardSoundFile: "笔记本键盘",
keyboardSoundFile: '笔记本键盘',
effectSound: true,
effectSoundVolume: 100,
@@ -87,12 +87,12 @@ export const getDefaultSettingState = (): SettingState => ({
articleForeignFontSize: 48,
articleTranslateFontSize: 20,
wordForeignFontSize: 48,
wordTranslateFontSize: 20
wordTranslateFontSize: 20,
},
showToolbar: true,
showPanel: true,
sideExpand: false,
theme: "auto",
theme: 'auto',
shortcutKeyMap: cloneDeep(DefaultShortcutKeyMap),
first: true,
firstTime: Date.now(),
@@ -107,7 +107,7 @@ export const getDefaultSettingState = (): SettingState => ({
ignoreSymbol: true
})
export const useSettingStore = defineStore("setting", {
export const useSettingStore = defineStore('setting', {
state: (): SettingState => {
return getDefaultSettingState()
},
@@ -119,15 +119,15 @@ export const useSettingStore = defineStore("setting", {
return new Promise(async resolve => {
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)
}
}
this.setState({ ...data, load: true })
this.setState({...data, load: true})
resolve(true)
})
}
},
}
})

View File

@@ -229,4 +229,13 @@ export enum WordPracticeType {
Identify,
Listen,
Dictation
}
}
export enum CodeType {
Login = 0,
Register = 1,
ResetPwd = 2,
ChangeEmail = 3,
ChangePhoneNew = 4,
ChangePhoneOld = 5
}

View File

@@ -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),

View File

@@ -1,14 +1,13 @@
import {BaseState, DefaultBaseState, 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 { BaseState, DefaultBaseState, 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 {nextTick} from "vue";
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";
import { getDefaultDict, getDefaultWord } from "@/types/func.ts";
import duration from "dayjs/plugin/duration";
dayjs.extend(duration);
@@ -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
@@ -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) {

54
src/utils/validation.ts Normal file
View 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'
},
]