save
This commit is contained in:
30
src/App.vue
30
src/App.vue
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
12
src/main.ts
12
src/main.ts
@@ -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) {
|
||||
|
||||
@@ -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
310
src/pages/user/User.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -229,4 +229,10 @@ export enum WordPracticeType {
|
||||
Identify,
|
||||
Listen,
|
||||
Dictation
|
||||
}
|
||||
}
|
||||
|
||||
export enum CodeType {
|
||||
Login = 0,
|
||||
Register = 1,
|
||||
ResetPwd = 2,
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user