This commit is contained in:
Zyronon
2025-11-12 11:58:11 +00:00
parent bcb921bbf4
commit 9585031e64
14 changed files with 679 additions and 829 deletions

View File

@@ -12,10 +12,12 @@ import { useRoute } from "vue-router";
import { DictId } from "@/types/types.ts";
import { APP_VERSION, CAN_REQUEST, LOCAL_FILE_KEY, SAVE_DICT_KEY, SAVE_SETTING_KEY } from "@/config/env.ts";
import { syncSetting } from "@/apis";
import {useAuthStore} from "@/stores/auth.ts";
const store = useBaseStore()
const runtimeStore = useRuntimeStore()
const settingStore = useSettingStore()
const authStore = useAuthStore()
const {setTheme} = useTheme()
let lastAudioFileIdList = []
@@ -60,6 +62,8 @@ async function init() {
await store.init()
await settingStore.init()
store.load = true
await authStore.init()
setTheme(settingStore.theme)
if (!settingStore.first) {
@@ -78,19 +82,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>

View File

@@ -1,4 +1,5 @@
import http from '@/utils/http.ts'
import {CodeType} from "@/types/types.ts";
// 用户登录接口
export interface LoginParams {
@@ -22,11 +23,9 @@ export interface LoginResponse {
// 用户注册接口
export interface RegisterParams {
email?: string
phone: string
account: string
password: string
code: string
nickname?: string
}
export interface RegisterResponse {
@@ -42,15 +41,13 @@ export interface RegisterResponse {
// 发送验证码接口
export interface SendCodeParams {
email?: string
phone: string
type: 'login' | 'register' | 'reset_password'
val: string
type: CodeType
}
// 重置密码接口
export interface ResetPasswordParams {
email?: string
phone: string
account: string
code: string
newPassword: string
}
@@ -61,24 +58,7 @@ export interface WechatLoginParams {
state?: string
}
// API 函数定义
export function loginApi(params: LoginParams) {
// 暂时直接返回成功响应,等待后端接入
// return Promise.resolve({
// success: true,
// code: 200,
// msg: '登录成功',
// data: {
// token: 'mock_token_' + Date.now(),
// user: {
// id: '1',
// account: params.account ?? 'account',
// phone: params.phone ?? 'phone',
// nickname: '测试用户',
// avatar: ''
// }
// }
// })
return http<LoginResponse>('user/login', params, null, 'post')
}
@@ -98,10 +78,6 @@ export function wechatLogin(params: WechatLoginParams) {
return http<LoginResponse>('user/wechatLogin', params, null, 'post')
}
export function logoutApi() {
return http<boolean>('user/logout', null, null, 'post')
}
export function refreshToken() {
return http<{ token: string }>('user/refreshToken', null, null, 'post')
}

View File

@@ -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
@@ -55,7 +59,7 @@ const validate = (rules) => {
// 自动触发 blur 校验
function handleBlur() {
const blurRules = myRules.filter((r) => r.trigger === 'blur')
if (blurRules.length) validate(blurRules)
if (blurRules.length) validate(blurRules, true)
}
function handChange() {

View File

@@ -1,7 +1,4 @@
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 Origin = `https://${Host}`
export const APP_NAME = 'Type Words'
@@ -18,9 +15,20 @@ 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 IS_OFFICIAL = true
export let IS_LOGIN = (!!localStorage.getItem('token')) || false
export let CAN_REQUEST = IS_LOGIN && IS_OFFICIAL
export let AppEnv = {
TOKEN: localStorage.getItem('token') ?? '',
IS_OFFICIAL: true,
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 +66,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,14 +1,13 @@
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'
import './types/global.d.ts'
import loadingDirective from './directives/loading.tsx'
import { useAuthStore } from './stores/auth.ts'
const pinia = createPinia()
@@ -22,12 +21,7 @@ app.directive('opacity', (el, binding) => {
el.style.opacity = binding.value ? 1 : 0
})
app.directive('loading', loadingDirective)
// 初始化认证状态
const authStore = useAuthStore()
authStore.initAuth().then(() => {
app.mount('#app')
})
app.mount('#app')
// 注册Service Worker(pwa支持)
if ('serviceWorker' in navigator) {

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,
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,9 +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 { useAuthStore } from "@/stores/auth.ts";
import {get, set} from "idb-keyval";
import {useRuntimeStore} from "@/stores/runtime.ts";
import {useAuthStore} from "@/stores/auth.ts";
const emit = defineEmits<{
toggleDisabledDialogEscKey: [val: boolean]
@@ -98,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;
}
@@ -427,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>
<!-- 音效-->
@@ -456,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>
@@ -583,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>
@@ -637,7 +637,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>
@@ -674,8 +674,8 @@ function importOldData() {
@change="importData">
</div>
<PopConfirm
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
@confirm="importOldData">
title="导入老版本数据前,请先备份当前数据,确定要导入老版本数据吗?"
@confirm="importOldData">
<BaseButton>老版本数据导入</BaseButton>
</PopConfirm>
</div>
@@ -771,15 +771,31 @@ 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="authStore.isLoggedIn && authStore.user" class="user-info-section mb-6">
<div class="user-avatar mb-4">
<img v-if="authStore.user.avatar" :src="authStore.user.avatar" alt="头像" class="avatar-img" />
<img v-if="authStore.user.avatar" :src="authStore.user.avatar" alt="头像" class="avatar-img"/>
<div v-else class="avatar-placeholder">
{{ authStore.user.nickname?.charAt(0) || 'U' }}
</div>
@@ -787,26 +803,25 @@ function importOldData() {
<h3 class="mb-2">{{ authStore.user.nickname || '用户' }}</h3>
<p v-if="authStore.user.email" class="text-sm color-gray mb-1">{{ authStore.user.email }}</p>
<p v-if="authStore.user.phone" class="text-sm color-gray">{{ authStore.user.phone }}</p>
<BaseButton
@click="authStore.logout"
type="info"
class="mt-4"
<BaseButton
@click="authStore.logout"
type="info"
class="mt-4"
:loading="authStore.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>
@@ -823,7 +838,7 @@ function importOldData() {
<style scoped lang="scss">
.log-item{
.log-item {
border-bottom: 1px solid var(--color-input-border);
margin-bottom: 1rem;
}
@@ -839,20 +854,20 @@ function importOldData() {
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%;
@@ -865,33 +880,33 @@ function importOldData() {
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;
}

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

@@ -0,0 +1,310 @@
<script setup lang="ts">
import {computed, ref} from 'vue'
import {Calendar, ChevronRight, CreditCard, Crown, Mail, User} from 'lucide-vue-next'
import {useAuthStore} 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, GITHUB} from "@/config/env.ts";
import BaseButton from "@/components/BaseButton.vue";
const authStore = useAuthStore()
const router = useRouter()
// Check login state
const isLoggedIn = computed(() => authStore.isLogin)
// Form data
const username = ref('Brian W')
const email = ref('ttentau@gmail.com')
const receiveNotifications = ref(false)
// Mock subscription data (you can replace with real data from your API)
const subscriptionData = ref({
plan: 'Premium',
status: 'active',
expiresAt: '2025-12-31',
autoRenew: true,
paymentMethod: '信用卡 ****1234'
})
// UI state
const isEditingUsername = ref(false)
const isEditingEmail = ref(false)
const showPasswordSection = ref(false)
// Handlers
const handleLogin = () => {
router.push('/login')
}
const handleLogout = () => {
authStore.logout()
router.push('/login')
}
const editUsername = () => {
isEditingUsername.value = true
}
const saveUsername = () => {
isEditingUsername.value = false
// Here you would typically save to backend
}
const editEmail = () => {
isEditingEmail.value = true
}
const saveEmail = () => {
isEditingEmail.value = false
// Here you would typically save to backend
}
const toggleNotifications = () => {
receiveNotifications.value = !receiveNotifications.value
}
const downloadPersonalInfo = () => {
console.log('Download personal info')
}
const deleteAccount = () => {
if (confirm('确定要删除您的账户吗?此操作无法撤销。')) {
console.log('Delete account')
}
}
const contactSupport = () => {
console.log('Contact support')
}
const leaveTrustpilotReview = () => {
window.open(GITHUB + '/issues', '_blank')
}
</script>
<template>
<BasePage>
<!-- Unauthenticated View -->
<div v-if="!isLoggedIn" class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-lg p-8 text-center">
<div class="mb-8">
<div class="w-20 h-20 bg-blue-100 rounded-full flex items-center justify-center mx-auto mb-4">
<User class="w-10 h-10 text-blue-600"/>
</div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">欢迎使用</h1>
<p class="text-gray-600">请登录以管理您的账户</p>
</div>
<button
@click="handleLogin"
class="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 px-6 rounded-xl transition-colors duration-200 mb-4"
>
登录
</button>
<p class="text-sm text-gray-500">
还没有账户
<a href="#" class="text-blue-600 hover:text-blue-700 font-medium">立即注册</a>
</p>
</div>
</div>
<!-- Authenticated View -->
<div v-else class="w-full max-w-4xl">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Account Settings -->
<div class="lg:col-span-2">
<div class="card">
<!-- Header -->
<div class="px-6 border-b border-gray-200">
<h1 class="text-xl font-bold text-gray-900">帐户</h1>
</div>
<!-- Username Section -->
<div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 mb-1">用户名</div>
<div class="flex items-center gap-3">
<User class="w-4 h-4 text-gray-500"/>
<BaseInput
v-if="isEditingUsername"
v-model="username"
type="text"
size="normal"
@blur="saveUsername"
@keyup.enter="saveUsername"
class="flex-1 max-w-xs"
autofocus
/>
<span v-else class="text-gray-900">{{ username }}</span>
</div>
</div>
<IconFluentTextEditStyle20Regular
@click="isEditingUsername ? saveUsername() : editUsername()"
class="text-xl"/>
</div>
<div class="border-t border-gray-200"></div>
<!-- Email Section -->
<div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 mb-1">电子邮箱</div>
<div class="flex items-center gap-3">
<Mail class="w-4 h-4 text-gray-500"/>
<BaseInput
v-if="isEditingEmail"
v-model="email"
type="email"
size="normal"
@blur="saveEmail"
@keyup.enter="saveEmail"
class="flex-1 max-w-xs"
autofocus
/>
<span v-else class="text-gray-900">{{ email }}</span>
</div>
</div>
<IconFluentTextEditStyle20Regular
@click="isEditingEmail ? saveEmail() : editEmail()"
class="text-xl"/>
</div>
<div class="border-t border-gray-200"></div>
<!-- Password Section -->
<div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 mb-1">设置密码</div>
<div class="text-sm text-gray-500">在此输入密码</div>
</div>
<IconFluentChevronLeft28Filled @click="showPasswordSection = !showPasswordSection"
class="transition-transform"
:class="['rotate-270','rotate-180'][showPasswordSection?0:1]"/>
</div>
<div class="border-t border-gray-200"></div>
<!-- Notification Toggle -->
<div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 mb-1">同意接收优惠信息</div>
<div class="text-sm text-gray-500">第一时间掌握 Lingvist 的各种优惠及最新消息</div>
</div>
<button
@click="toggleNotifications"
class="relative inline-flex h-6 w-11 items-center rounded-full transition-colors"
:class="receiveNotifications ? 'bg-blue-600' : 'bg-gray-200'"
>
<span
class="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
:class="receiveNotifications ? 'translate-x-6' : 'translate-x-1'"
/>
</button>
</div>
<div class="border-t border-gray-200"></div>
<!-- Contact Support -->
<div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors cursor-pointer"
@click="contactSupport">
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 mb-1">联系{{ APP_NAME }}客服</div>
</div>
<ChevronRight class="w-5 h-5 text-gray-400"/>
</div>
<div class="border-t border-gray-200"></div>
<!-- Trustpilot Review -->
<div class="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors cursor-pointer"
@click="leaveTrustpilotReview">
<div class="flex-1">
<div class="text-sm font-medium text-gray-700 mb-1"> {{ APP_NAME }} 上留下评论</div>
</div>
<ChevronRight class="w-5 h-5 text-gray-400"/>
</div>
<!-- Logout Button -->
<div class="px-6 py-6 border-t border-gray-200">
<button
@click="handleLogout"
class="w-full bg-gray-800 hover:bg-gray-900 text-white font-semibold py-3 px-6 rounded-xl transition-colors duration-200"
>
登出
</button>
</div>
<!-- Footer Links -->
<div class="px-6 py-4 border-t border-gray-200 text-center">
<div class="text-sm text-gray-500">
<a href="/user-agreement.html" class="text-gray-500 hover:text-gray-700 underline">用户协议</a>
<a href="/privacy-policy.html" class="text-gray-500 hover:text-gray-700 underline">隐私政策</a>
</div>
</div>
</div>
</div>
<!-- Subscription Information -->
<div class="lg:col-span-1">
<div class="card">
<div class="flex items-center gap-3 mb-4">
<Crown class="w-6 h-6 text-yellow-500"/>
<h2 class="text-lg font-bold text-gray-900">订阅信息</h2>
</div>
<div class="space-y-4">
<div>
<div class="text-sm text-gray-500 mb-1">当前计划</div>
<div class="text-lg font-semibold text-gray-900">{{ subscriptionData.plan }}</div>
</div>
<div>
<div class="text-sm text-gray-500 mb-1">状态</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-sm font-medium text-green-700">{{
subscriptionData.status === 'active' ? '活跃' : '已过期'
}}</span>
</div>
</div>
<div>
<div class="text-sm text-gray-500 mb-1">到期时间</div>
<div class="flex items-center gap-2">
<Calendar class="w-4 h-4 text-gray-400"/>
<span class="text-sm font-medium text-gray-900">{{ subscriptionData.expiresAt }}</span>
</div>
</div>
<div>
<div class="text-sm text-gray-500 mb-1">自动续费</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2" :class="subscriptionData.autoRenew ? 'bg-blue-500' : 'bg-gray-400'"
rounded-full></div>
<span class="text-sm font-medium"
:class="subscriptionData.autoRenew ? 'text-blue-700' : 'text-gray-600'">
{{ subscriptionData.autoRenew ? '已开启' : '已关闭' }}
</span>
</div>
</div>
<div>
<div class="text-sm text-gray-500 mb-1">付款方式</div>
<div class="flex items-center gap-2">
<CreditCard class="w-4 h-4 text-gray-400"/>
<span class="text-sm font-medium text-gray-900">{{ subscriptionData.paymentMethod }}</span>
</div>
</div>
<div class="pt-4 border-t border-gray-200">
<BaseButton class="w-full">管理订阅</BaseButton>
</div>
</div>
</div>
</div>
</div>
</div>
</BasePage>
</template>

View File

@@ -1,408 +0,0 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from "vue";
import { useAuthStore } from "@/stores/auth.ts";
import { useRouter } from "vue-router";
import BaseButton from "@/components/BaseButton.vue";
import Toast from "@/components/base/toast/Toast.ts";
import { uploadImportData, getProgress } from "@/apis/index.ts";
const authStore = useAuthStore();
const router = useRouter();
// 页面状态
const isLoading = ref(false);
// 上传与后台处理状态
const isUploading = ref(false);
const isProcessing = ref(false);
const uploadPercent = ref(0);
const processStatus = ref<number | null>(null); // 0=导入中,1=完成,2=失败
const processReason = ref("");
const processElapsedSec = ref(0);
const hasPendingProcessing = ref(false); // 进入页面首次检查,是否存在后台处理中任务
const pollingAbort = ref(false); // 页面退出时标记,用于结束轮询
const fileInputRef = ref<HTMLInputElement | null>(null);
// 退出登录
const handleLogout = async () => {
isLoading.value = true;
try {
await authStore.logout();
} finally {
isLoading.value = false;
}
};
// 跳转到设置页面
const goToSettings = () => {
router.push("/setting");
};
onMounted(async () => {
if (!authStore.isLoggedIn) {
// return;
}
if (!authStore.user) {
authStore.fetchUserInfo();
}
// 进入页面先查一次进度
try {
const res = await getProgress();
// 如果 success 为 true说明有任务在处理显示“查看同步进度”按钮
hasPendingProcessing.value = res.success;
} catch (e) {
hasPendingProcessing.value = false;
}
});
onUnmounted(() => {
// 退出页面,轮询应该结束
pollingAbort.value = true;
});
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
const startPolling = async () => {
isProcessing.value = true;
pollingAbort.value = false;
processElapsedSec.value = 0;
try {
while (true) {
const res = await getProgress();
const {status, reason} = (res as any).data || {};
processStatus.value = status;
processReason.value = reason || "";
if (pollingAbort.value) break;
if (status !== 0) break;
processElapsedSec.value += 1;
await sleep(1000);
}
} finally {
isProcessing.value = false;
hasPendingProcessing.value = false;
}
if (processStatus.value === 1) {
Toast.success("数据同步成功");
} else if (processStatus.value === 2) {
Toast.error(processReason.value || "导入失败");
}
};
const resetSync = () => {
isUploading.value = false;
isProcessing.value = false;
uploadPercent.value = 0;
processStatus.value = null;
processReason.value = "";
processElapsedSec.value = 0;
hasPendingProcessing.value = false;
};
const handleSyncClick = () => {
fileInputRef.value?.click();
};
const onFileSelected = async (e: Event) => {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = ""; // 重置,便于重复选择同一文件
if (!file) return;
const ext = file.name.split(".").pop()?.toLowerCase();
if (!ext || (ext !== "zip" && ext !== "json")) {
Toast.warning("仅支持上传 zip 或 json 文件");
return;
}
try {
// 1) 上传阶段:显示上传进度条
isUploading.value = true;
const formData = new FormData();
formData.append("file", file);
await uploadImportData(formData, (event: ProgressEvent) => {
if (event.total) {
uploadPercent.value = Math.round((event.loaded / event.total) * 100);
}
});
// 上传完成后,隐藏进度条
isUploading.value = false;
// 2) 后台处理阶段:提示可查看,并由用户点击后开始轮询
hasPendingProcessing.value = true;
await startPolling();
} catch (err: any) {
isUploading.value = false;
isProcessing.value = false;
Toast.error(err?.message || "上传或导入失败");
}
};
</script>
<template>
<div class="user-page">
<div class="profile-card">
<div class="profile-header">
<div class="avatar-wrap">
<div class="avatar ring">
<img v-if="authStore.user?.avatar" :src="authStore.user.avatar" alt="头像"/>
<div v-else class="avatar-placeholder">
{{ authStore.user?.nickname?.charAt(0) || "U" }}
</div>
</div>
</div>
<div class="headline">
<h2>{{ authStore.user?.nickname || "用户" }}</h2>
<p v-if="authStore.user?.email">{{ authStore.user.email }}</p>
<p v-if="authStore.user?.phone">{{ authStore.user.phone }}</p>
</div>
</div>
<div class="actions">
<BaseButton
class="w-full"
size="large"
type="primary"
:disabled="isUploading || isProcessing"
@click="handleSyncClick"
>
同步数据
</BaseButton>
<!-- 如果进入页面检测到有后台任务则显示查看按钮点击后开始轮询 -->
<BaseButton
v-if="hasPendingProcessing && !isProcessing && !isUploading"
class="w-full"
size="large"
@click="startPolling"
>
查看同步进度
</BaseButton>
<BaseButton class="w-full" size="large" @click="goToSettings">
系统设置
</BaseButton>
<BaseButton
class="w-full"
size="large"
type="info"
:loading="isLoading"
@click="handleLogout"
>
退出登录
</BaseButton>
</div>
<input
ref="fileInputRef"
type="file"
accept=".zip,.json"
class="hidden"
@change="onFileSelected"
/>
<!-- 上传进度仅在上传阶段显示 -->
<div v-if="isUploading" class="sync-progress">
<div class="progress-bar">
<div class="progress-fill" :style="{ width: uploadPercent + '%' }"></div>
</div>
<div class="progress-text">
<span>上传中{{ uploadPercent }}%</span>
</div>
</div>
<!-- 后台处理提示与轮询状态展示 -->
<div v-if="isProcessing" class="processing">
<div class="spinner"></div>
<div class="processing-text">
<span>后台正在处理...</span>
<span class="elapsed">已用时{{ Math.floor(processElapsedSec / 60) }}{{ processElapsedSec % 60 }}</span>
<span v-if="processReason" class="hint">{{ processReason }}</span>
</div>
</div>
<!-- 完成/失败提示 -->
<div v-if="processStatus === 1" class="result success">导入完成</div>
<div v-if="processStatus === 2" class="result fail">导入失败{{ processReason }}</div>
</div>
</div>
</template>
<style scoped>
.user-page {
max-width: 760px;
margin: 0 auto;
padding: 2rem 1.25rem 3rem;
}
.profile-card {
background: #fff;
border-radius: 18px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.profile-header {
position: relative;
padding: 2.25rem 2rem 1.5rem;
background: linear-gradient(135deg, #6b73ff 0%, #000dff 100%);
color: #fff;
display: flex;
align-items: center;
gap: 1.25rem;
}
.avatar-wrap {
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
width: 88px;
height: 88px;
border-radius: 50%;
overflow: hidden;
background: #fff1;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.ring {
position: relative;
}
.ring::before {
content: "";
position: absolute;
inset: -4px;
border-radius: 50%;
background: linear-gradient(135deg, #fff, rgba(255, 255, 255, 0.2));
-webkit-mask: radial-gradient(circle at center, transparent 62%, #000 63%);
}
.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;
}
.headline h2 {
margin: 0 0 0.25rem 0;
font-size: 1.6rem;
font-weight: 700;
}
.headline p {
margin: 0.1rem 0;
opacity: 0.9;
}
.actions {
display: grid;
grid-template-columns: 1fr;
gap: 0.9rem;
padding: 1.25rem;
}
.sync-progress {
padding: 0 1.25rem 1.25rem;
}
.progress-bar {
width: 100%;
height: 10px;
border-radius: 999px;
background: #f1f2f6;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #6b73ff 0%, #00d4ff 100%);
transition: width 0.3s ease;
}
.progress-text {
display: flex;
justify-content: space-between;
margin-top: 0.5rem;
font-size: 0.9rem;
color: #444;
}
.reason {
color: #d33;
}
.processing {
padding: 0 1.25rem 1.25rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.spinner {
width: 18px;
height: 18px;
border: 2px solid #c7c9d3;
border-top-color: #6b73ff;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.processing-text {
display: flex;
flex-direction: column;
font-size: 0.95rem;
color: #333;
}
.elapsed {
margin-top: 2px;
font-size: 0.85rem;
color: #666;
}
.hint {
margin-top: 2px;
font-size: 0.85rem;
color: #555;
}
.result {
padding: 0 1.25rem 1.25rem;
font-size: 0.95rem;
}
.success {
color: #2e7d32;
}
.fail {
color: #d33;
}
.hidden {
display: none;
}
</style>

View File

@@ -1,18 +1,20 @@
<script setup lang="tsx">
import { onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {onBeforeUnmount, onMounted} from 'vue'
import {useRoute} from 'vue-router'
import BaseInput from "@/components/base/BaseInput.vue";
import BaseButton from "@/components/BaseButton.vue";
import { APP_NAME } from "@/config/env.ts";
import { useAuthStore } from "@/stores/auth.ts";
import { sendCode } from "@/apis/user.ts";
import { validateEmail, validatePhone } from "@/utils/validation.ts";
import {APP_NAME} from "@/config/env.ts";
import {useAuthStore} from "@/stores/auth.ts";
import {loginApi, LoginParams, registerApi, resetPasswordApi, sendCode} from "@/apis/user.ts";
import {validateEmail, validatePhone} 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 {FormInstance} from "@/components/base/form/types.ts";
import {PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
import {CodeType} from "@/types/types.ts";
import router from "@/router.ts";
// 状态管理
const authStore = useAuthStore()
@@ -21,8 +23,8 @@ const route = useRoute()
// 页面状态
let currentMode = $ref<'login' | 'register' | 'forgot'>('login')
let loginType = $ref<'code' | 'password'>('code') // 默认验证码登录
let isLoading = $ref(false)
let isSendingCode = $ref(false)
let loading = $ref(false)
let codeCountdown = $ref(0)
let showWechatQR = $ref(true)
let wechatQRUrl = $ref('https://open.weixin.qq.com/connect/qrcode/041GmMJM2wfM0w3D')
@@ -84,7 +86,7 @@ let loginForm2Rules = {
const registerForm = $ref({
phone: '',
account: '',
password: '',
confirmPassword: '',
code: ''
@@ -92,7 +94,7 @@ const registerForm = $ref({
let registerFormRef = $ref<FormInstance>()
// 注册表单规则和引用
let registerFormRules = {
phone: phoneRules,
account: accountRules,
code: codeRules,
password: passwordRules,
confirmPassword: [
@@ -132,12 +134,6 @@ let forgotFormRules = {
],
}
// 表单数据
const loginForm = $ref({
account: '', // 支持邮箱或手机号
password: ''
})
const currentFormRef = $computed<FormInstance>(() => {
if (currentMode === 'login') {
if (loginType == 'code') return phoneLoginFormRef
@@ -147,14 +143,13 @@ const currentFormRef = $computed<FormInstance>(() => {
})
// 发送验证码
async function sendVerificationCode(phone: string, type: 'login' | 'register' | 'reset_password', fileName: string) {
async function sendVerificationCode(val: string, type: CodeType, fileName: string) {
let res = currentFormRef.validateField(fileName)
if (res) {
try {
isSendingCode = true
const response = await sendCode({phone, type})
if (response.success) {
Toast.success('验证码已发送')
const res = await sendCode({val, type})
if (res.success) {
codeCountdown = PHONE_CONFIG.sendInterval
const timer = setInterval(() => {
codeCountdown--
@@ -163,7 +158,7 @@ async function sendVerificationCode(phone: string, type: 'login' | 'register' |
}
}, 1000)
} else {
Toast.error(response.msg || '发送失败')
Toast.error(res.msg || '发送失败')
}
} catch (error) {
console.error('Send code error:', error)
@@ -178,19 +173,38 @@ async function sendVerificationCode(phone: string, type: 'login' | 'register' |
async function handleLogin() {
currentFormRef.validate(async (valid) => {
if (!valid) return;
//手机号登录
if (loginType === 'code') {
await authStore.login({
phone: phoneLoginForm.phone,
code: phoneLoginForm.code,
type: 'code'
})
} else {
await authStore.login({
account: loginForm.account,
password: loginForm.password,
type: 'pwd'
})
try {
loading = true
let data = {}
//手机号登录
if (loginType === 'code') {
data = {
phone: phoneLoginForm.phone,
code: phoneLoginForm.code,
type: 'code'
}
} else {
//密码登录
data = {
account: loginForm2.account,
password: loginForm2.password,
type: 'pwd'
}
}
let res = await loginApi(data as LoginParams)
if (res.success) {
authStore.setToken(res.data.token)
authStore.setUser(res.data.user)
Toast.success('登录成功')
// 跳转到首页或用户中心
router.push('/')
} else {
Toast.error(res.msg || '登录失败')
}
} catch (error) {
Toast.error('登录失败,请重试')
} finally {
loading = false
}
})
}
@@ -199,13 +213,27 @@ async function handleLogin() {
async function handleRegister() {
registerFormRef.validate(async (valid) => {
if (!valid) return
await authStore.register({
phone: registerForm.phone,
password: registerForm.password,
code: registerForm.code,
nickname: undefined,
email: undefined
})
try {
loading = true
let res = await registerApi({
account: registerForm.account,
password: registerForm.password,
code: registerForm.code,
})
if (res.success) {
authStore.setToken(res.data.token)
authStore.setUser(res.data.user)
Toast.success('注册成功')
// 跳转到首页或用户中心
router.push('/')
} else {
Toast.error(res.msg || '注册失败')
}
} catch (error) {
Toast.error('注册失败,请重试')
} finally {
loading = false
}
})
}
@@ -213,17 +241,23 @@ async function handleRegister() {
async function handleForgotPassword() {
forgotFormRef.validate(async (valid) => {
if (!valid) return
const response = await authStore.resetPassword({
phone: forgotForm.account,
email: undefined,
code: forgotForm.code,
newPassword: forgotForm.newPassword
})
if (response.success) {
Toast.success('密码重置成功,请重新登录')
switchMode('login')
} else {
Toast.error(response.msg || '重置失败')
try {
loading = true
const res = await resetPasswordApi({
account: forgotForm.account,
code: forgotForm.code,
newPassword: forgotForm.newPassword
})
if (res.success) {
Toast.success('密码重置成功,请重新登录')
switchMode('login')
} else {
Toast.error(res.msg || '重置失败')
}
} catch (error) {
Toast.error(error || '重置密码失败,请重试')
} finally {
loading = false
}
})
}
@@ -335,28 +369,28 @@ onBeforeUnmount(() => {
<!-- Tab切换 -->
<div class="center gap-8 mb-6">
<div
class="center cp transition-colors"
:class="loginType === 'code' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'code'"
class="center cp transition-colors"
:class="loginType === 'code' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'code'"
>
<div>
<span>验证码登录</span>
<div
v-opacity="loginType === 'code'"
class="mt-1 h-0.5 bg-blue-600"
v-opacity="loginType === 'code'"
class="mt-1 h-0.5 bg-blue-600"
></div>
</div>
</div>
<div
class="center cp transition-colors"
:class="loginType === 'password' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'password'"
class="center cp transition-colors"
:class="loginType === 'password' ? 'link font-medium' : 'text-gray-600'"
@click="loginType = 'password'"
>
<div>
<span>密码登录</span>
<div
v-opacity="loginType === 'password'"
class="mt-1 h-0.5 bg-blue-600"
v-opacity="loginType === 'password'"
class="mt-1 h-0.5 bg-blue-600"
></div>
</div>
</div>
@@ -364,10 +398,10 @@ onBeforeUnmount(() => {
<!-- 验证码登录表单 -->
<Form
v-if="loginType === 'code'"
ref="phoneLoginFormRef"
:rules="phoneLoginFormRules"
:model="phoneLoginForm">
v-if="loginType === 'code'"
ref="phoneLoginFormRef"
:rules="phoneLoginFormRules"
:model="phoneLoginForm">
<FormItem prop="phone">
<BaseInput v-model="phoneLoginForm.phone"
type="tel"
@@ -378,18 +412,18 @@ onBeforeUnmount(() => {
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="phoneLoginForm.code"
type="text"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
v-model="phoneLoginForm.code"
type="text"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
/>
<BaseButton
@click="sendVerificationCode(phoneLoginForm.phone, 'login','phone')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
style="border: 1px solid var(--color-input-border)"
@click="sendVerificationCode(phoneLoginForm.phone, CodeType.Login,'phone')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
style="border: 1px solid var(--color-input-border)"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '发送验证码') }}
</BaseButton>
@@ -399,10 +433,10 @@ onBeforeUnmount(() => {
<!-- 密码登录表单 -->
<Form
v-else
ref="loginForm2Ref"
:rules="loginForm2Rules"
:model="loginForm2">
v-else
ref="loginForm2Ref"
:rules="loginForm2Rules"
:model="loginForm2">
<FormItem prop="account">
<BaseInput v-model="loginForm2.account"
type="text"
@@ -413,10 +447,10 @@ onBeforeUnmount(() => {
<FormItem prop="password">
<div class="flex gap-2">
<BaseInput
v-model="loginForm2.password"
type="password"
size="large"
placeholder="请输入密码"
v-model="loginForm2.password"
type="password"
size="large"
placeholder="请输入密码"
/>
</div>
</FormItem>
@@ -427,10 +461,10 @@ onBeforeUnmount(() => {
</Notice>
<BaseButton
class="w-full"
size="large"
:loading="authStore.isLoading"
@click="handleLogin"
class="w-full"
size="large"
:loading="loading"
@click="handleLogin"
>
登录
</BaseButton>
@@ -446,32 +480,32 @@ onBeforeUnmount(() => {
<div v-else-if="currentMode === 'register'">
<div class="mb-6 text-xl font-bold text-center">注册新账号</div>
<Form
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
<FormItem prop="phone">
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
<FormItem prop="account">
<BaseInput
v-model="registerForm.phone"
type="tel"
size="large"
placeholder="请输入手机号"
v-model="registerForm.account"
type="tel"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="registerForm.code"
type="text"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="registerForm.code"
type="text"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<BaseButton
@click="sendVerificationCode(registerForm.phone, 'register','phone')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
style="border: 1px solid var(--color-input-border)"
@click="sendVerificationCode(registerForm.account, CodeType.Register,'phone')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
style="border: 1px solid var(--color-input-border)"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '获取验证码') }}
</BaseButton>
@@ -479,18 +513,18 @@ onBeforeUnmount(() => {
</FormItem>
<FormItem prop="password">
<BaseInput
v-model="registerForm.password"
type="password"
size="large"
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
v-model="registerForm.password"
type="password"
size="large"
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="registerForm.confirmPassword"
type="password"
size="large"
placeholder="请再次输入密码"
v-model="registerForm.confirmPassword"
type="password"
size="large"
placeholder="请再次输入密码"
/>
</FormItem>
</Form>
@@ -498,10 +532,10 @@ onBeforeUnmount(() => {
<Notice/>
<BaseButton
class="w-full"
size="large"
:loading="authStore.isLoading"
@click="handleRegister"
class="w-full"
size="large"
:loading="loading"
@click="handleRegister"
>
注册
</BaseButton>
@@ -516,32 +550,32 @@ onBeforeUnmount(() => {
<div v-else-if="currentMode === 'forgot'">
<div class="mb-6 text-xl font-bold text-center">重置密码</div>
<Form
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
<FormItem prop="account">
<BaseInput
v-model="forgotForm.account"
type="tel"
size="large"
placeholder="请输入手机号/邮箱地址"
v-model="forgotForm.account"
type="tel"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="forgotForm.code"
type="text"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="forgotForm.code"
type="text"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<BaseButton
@click="sendVerificationCode(forgotForm.account, 'reset_password','account')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
style="border: 1px solid var(--color-input-border)"
@click="sendVerificationCode(forgotForm.account, CodeType.ResetPwd,'account')"
:disabled="isSendingCode || codeCountdown > 0"
type="info"
size="large"
style="border: 1px solid var(--color-input-border)"
>
{{ codeCountdown > 0 ? `${codeCountdown}s` : (isSendingCode ? '发送中' : '获取验证码') }}
</BaseButton>
@@ -549,27 +583,27 @@ onBeforeUnmount(() => {
</FormItem>
<FormItem prop="newPassword">
<BaseInput
v-model="forgotForm.newPassword"
type="password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
v-model="forgotForm.newPassword"
type="password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="forgotForm.confirmPassword"
type="password"
size="large"
placeholder="请再次输入新密码"
v-model="forgotForm.confirmPassword"
type="password"
size="large"
placeholder="请再次输入新密码"
/>
</FormItem>
</Form>
<BaseButton
class="w-full mt-2"
size="large"
:loading="authStore.isLoading"
@click="handleForgotPassword"
class="w-full mt-2"
size="large"
:loading="loading"
@click="handleForgotPassword"
>
重置密码
</BaseButton>
@@ -585,16 +619,16 @@ onBeforeUnmount(() => {
<div v-if="currentMode === 'login'" class="center flex-col bg-gray-100 rounded-xl px-12">
<div class="relative w-40 h-40 bg-white rounded-xl overflow-hidden shadow-xl">
<img
v-if="showWechatQR"
:src="wechatQRUrl"
alt="微信登录二维码"
class="w-full h-full"
:class="{ 'opacity-30': qrStatus === 'expired' }"
v-if="showWechatQR"
:src="wechatQRUrl"
alt="微信登录二维码"
class="w-full h-full"
:class="{ 'opacity-30': qrStatus === 'expired' }"
/>
<!-- 扫描成功蒙层 -->
<div
v-if="qrStatus === 'scanned'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
v-if="qrStatus === 'scanned'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
>
<IconFluentCheckmarkCircle20Filled class="color-green text-4xl"/>
<div class="text-base text-gray-700 font-medium">扫描成功</div>
@@ -602,8 +636,8 @@ onBeforeUnmount(() => {
</div>
<!-- 取消登录蒙层 -->
<div
v-if="qrStatus === 'cancelled'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
v-if="qrStatus === 'cancelled'"
class="absolute left-0 top-0 w-full h-full center flex-col gap-space bg-white"
>
<IconFluentErrorCircle20Regular class="color-red text-4xl"/>
<div class="text-base text-gray-700 font-medium">你已取消此次登录</div>
@@ -612,12 +646,12 @@ onBeforeUnmount(() => {
</div>
<!-- 过期蒙层 -->
<div
v-if=" qrStatus === 'expired'"
class="absolute top-0 left-0 right-0 bottom-0 bg-opacity-95 center backdrop-blur-sm"
v-if=" qrStatus === 'expired'"
class="absolute top-0 left-0 right-0 bottom-0 bg-opacity-95 center backdrop-blur-sm"
>
<IconFluentArrowClockwise20Regular
@click="refreshQRCode"
class="cp text-4xl"/>
@click="refreshQRCode"
class="cp text-4xl"/>
</div>
</div>
<p class="mt-4 center gap-space">

View File

@@ -11,8 +11,8 @@ 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 { useAuthStore } from "@/stores/auth.ts";
import User from "@/pages/user/User.vue";
// import { useAuthStore } from "@/stores/auth.ts";
export const routes: RouteRecordRaw[] = [
{
@@ -61,26 +61,26 @@ const router = VueRouter.createRouter({
router.beforeEach(async (to: any, from: any) => {
return true
const authStore = useAuthStore()
// 公共路由,不需要登录验证
const publicRoutes = ['/login', '/wechat/callback', '/user-agreement', '/privacy-policy']
// 如果目标路由是公共路由,直接放行
if (publicRoutes.includes(to.path)) {
return true
}
// 如果用户未登录,跳转到登录页
if (!authStore.isLoggedIn) {
// 尝试初始化认证状态
const isInitialized = await authStore.initAuth()
if (!isInitialized) {
return {path: '/login', query: {redirect: to.fullPath}}
}
}
return true
// const authStore = useAuthStore()
//
// // 公共路由,不需要登录验证
// const publicRoutes = ['/login', '/wechat/callback', '/user-agreement', '/privacy-policy']
//
// // 如果目标路由是公共路由,直接放行
// if (publicRoutes.includes(to.path)) {
// return true
// }
//
// // 如果用户未登录,跳转到登录页
// if (!authStore.isLoggedIn) {
// // 尝试初始化认证状态
// const isInitialized = await authStore.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()

View File

@@ -1,16 +1,9 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import {
loginApi,
registerApi,
logoutApi,
getUserInfo,
resetPasswordApi,
type LoginParams,
type RegisterParams
} from '@/apis/user.ts'
import {defineStore} from 'pinia'
import {computed, ref} from 'vue'
import {getUserInfo} from '@/apis/user.ts'
import Toast from '@/components/base/toast/Toast.ts'
import router from '@/router.ts'
import {AppEnv} from "@/config/env.ts";
export interface User {
id: string
@@ -21,21 +14,19 @@ export interface User {
}
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(localStorage.getItem('token') || '')
const user = ref<User | null>(null)
const isLoading = ref(false)
const isLoggedIn = computed(() => !!token.value)
const isLogin = computed(() => AppEnv.IS_LOGIN)
// 设置token
const setToken = (newToken: string) => {
token.value = newToken
AppEnv.TOKEN = newToken
localStorage.setItem('token', newToken)
}
// 清除token
const clearToken = () => {
token.value = ''
AppEnv.IS_LOGIN = AppEnv.CAN_REQUEST = false
AppEnv.TOKEN = ''
localStorage.removeItem('token')
user.value = null
}
@@ -45,50 +36,20 @@ export const useAuthStore = defineStore('auth', () => {
user.value = userInfo
}
// 登录
const login = async (params: LoginParams) => {
try {
isLoading.value = true
const response = await loginApi(params)
if (response.success) {
setToken(response.data.token)
setUser(response.data.user)
Toast.success('登录成功')
// 跳转到首页或用户中心
router.push('/')
return true
} else {
Toast.error(response.msg || '登录失败')
return false
}
} catch (error) {
console.error('Login error:', error)
Toast.error('登录失败,请重试')
return false
} finally {
isLoading.value = false
}
}
// 登出
const logout = async () => {
try {
await logoutApi()
} catch (error) {
console.error('Logout error:', error)
} finally {
clearToken()
Toast.success('已退出登录')
router.push('/')
}
clearToken()
Toast.success('已退出登录')
//这行会引起hrm失效
// router.push('/')
}
// 获取用户信息
const fetchUserInfo = async () => {
try {
const response = await getUserInfo()
if (response.success) {
setUser(response.data)
const res = await getUserInfo()
if (res.success) {
setUser(res.data)
return true
}
return false
@@ -98,78 +59,25 @@ export const useAuthStore = defineStore('auth', () => {
}
}
// 注册
const register = async (params: RegisterParams) => {
try {
isLoading.value = true
const response = await registerApi(params)
if (response.success && response.data) {
setToken(response.data.token)
setUser(response.data.user)
Toast.success('注册成功')
// 跳转到首页或用户中心
router.push('/')
return true
} else {
Toast.error(response.msg || '注册失败')
return false
}
} catch (error) {
console.error('Register error:', error)
Toast.error('注册失败,请重试')
return false
} finally {
isLoading.value = false
}
}
// 重置密码
const resetPassword = async (params: { email?: string; phone: string; code: string; newPassword: string }) => {
try {
isLoading.value = true
const response = await resetPasswordApi(params)
if (response.success) {
Toast.success('密码重置成功')
return {success: true, msg: '密码重置成功'}
} else {
return {success: false, msg: response.msg || '重置失败'}
}
} catch (error) {
console.error('Reset password error:', error)
return {success: false, msg: '重置密码失败,请重试'}
} finally {
isLoading.value = false
}
}
// 初始化用户状态
const initAuth = async () => {
if (token.value) {
const init = async () => {
if (AppEnv.CAN_REQUEST) {
const success = await fetchUserInfo()
if (!success) {
clearToken()
}
return success
}
return false
}
return {
token,
user,
isLoading,
isLoggedIn,
isLogin,
setToken,
clearToken,
setUser,
login,
register,
resetPassword,
logout,
fetchUserInfo,
initAuth
init
}
})

View File

@@ -1,11 +1,11 @@
import { defineStore } from 'pinia'
import { Dict, DictId, Word } from "../types/types.ts"
import { _getStudyProgress, checkAndUpgradeSaveDict, shakeCommonDict } from "@/utils";
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 { add2MyDict, dictListVersion, myDictList } from "@/apis";
import {defineStore} from 'pinia'
import {Dict, DictId, Word} from "../types/types.ts"
import {_getStudyProgress, checkAndUpgradeSaveDict, shakeCommonDict} from "@/utils";
import {shallowReactive} from "vue";
import {getDefaultDict} from "@/types/func.ts";
import {get, set} from 'idb-keyval'
import {CAN_REQUEST, IS_OFFICIAL, SAVE_DICT_KEY} from "@/config/env.ts";
import {add2MyDict, dictListVersion, myDictList} from "@/apis";
import Toast from "@/components/base/toast/Toast.ts";
export interface BaseState {

View File

@@ -229,4 +229,10 @@ export enum WordPracticeType {
Identify,
Listen,
Dictation
}
}
export enum CodeType {
Login = 0,
Register = 1,
ResetPwd = 2,
}

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