This commit is contained in:
Zyronon
2025-11-14 10:57:28 +00:00
parent 27d4f19237
commit c08a8faa68
8 changed files with 622 additions and 324 deletions

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

@@ -0,0 +1,30 @@
import http from '@/utils/http.ts'
export type LevelBenefits = {
"level": {
"id": number,
"name": string,
"code": string,
"level": number,
"price": string,
"price_auto": string,
"yearly_price": string,
"description": string,
"color": string,
"icon": string,
"is_active": number,
"created_at": string,
"updated_at": string
},
"benefits": {
"code": string,
"name": string,
"type": boolean,
"unit": null,
"value": string
}[]
}
export function levelBenefits(params) {
return http<LevelBenefits>('member/levelBenefits', null, params, 'get')
}

View File

@@ -18,13 +18,11 @@ export interface User {
avatar?: string,
hasPwd?: boolean,
member: {
level: number,
levelDesc: string,
status: string,
active: boolean,
endTime: number,
endDate: number,
autoRenew: boolean,
payMethod: number,
payMethodDesc: string,
}
}
@@ -67,7 +65,7 @@ export interface WechatLoginParams {
}
export function loginApi(params: LoginParams) {
return http<User>('user/login', params, null, 'post')
return http<{ token:string }>('user/login', params, null, 'post')
}
export function registerApi(params: RegisterParams) {

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

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

View File

@@ -1,23 +1,23 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useUserStore } from '@/stores/auth.ts'
import { useRouter } from 'vue-router'
import {onMounted} from 'vue'
import {useUserStore} from '@/stores/auth.ts'
import {useRouter} from 'vue-router'
import BaseInput from '@/components/base/BaseInput.vue'
import BasePage from "@/components/BasePage.vue";
import { APP_NAME, EMAIL, GITHUB } from "@/config/env.ts";
import {APP_NAME, EMAIL, GITHUB} from "@/config/env.ts";
import BaseButton from "@/components/BaseButton.vue";
import { PASSWORD_CONFIG, PHONE_CONFIG } from "@/config/auth.ts";
import { changeEmailApi, changePhoneApi, setPassword, updateUserInfoApi } from "@/apis/user.ts";
import {PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
import {changeEmailApi, changePhoneApi, setPassword, updateUserInfoApi, User} from "@/apis/user.ts";
import BaseIcon from "@/components/BaseIcon.vue";
import { CodeType } from "@/types/types.ts";
import {CodeType} from "@/types/types.ts";
import FormItem from "@/components/base/form/FormItem.vue";
import Form from "@/components/base/form/Form.vue";
import { FormInstance } from "@/components/base/form/types.ts";
import { codeRules, emailRules, passwordRules, phoneRules } from "@/utils/validation.ts";
import { _dateFormat, cloneDeep } from "@/utils";
import {FormInstance} from "@/components/base/form/types.ts";
import {codeRules, emailRules, passwordRules, phoneRules} from "@/utils/validation.ts";
import {_dateFormat, cloneDeep} from "@/utils";
import Toast from "@/components/base/toast/Toast.ts";
import Code from "@/pages/user/Code.vue";
import { MessageBox } from "@/utils/MessageBox.tsx";
import {MessageBox} from "@/utils/MessageBox.tsx";
const userStore = useUserStore()
const router = useRouter()
@@ -234,14 +234,11 @@ function changePwd() {
})
}
const member = $computed<User['member']>(() => userStore.user?.member ?? {} as any)
// 订阅相关
const memberEndtime = $computed(() => {
if (userStore.user?.member) {
if (userStore.user?.member?.endTime === -1) return '永久'
else return _dateFormat(userStore.user?.member?.endTime)
}
return ''
const memberEndDate = $computed(() => {
if (member?.endDate === null) return '永久'
return member?.endDate
})
function subscribe() {
@@ -266,9 +263,9 @@ function subscribe() {
<p class="">登录开启您的学习之旅</p>
<div>保存进度同步数据解锁个性化内容</div>
<BaseButton
@click="router.push('/login')"
size="large"
class="w-full mt-4"
@click="router.push('/login')"
size="large"
class="w-full mt-4"
>
登录
</BaseButton>
@@ -302,16 +299,16 @@ function subscribe() {
</div>
<div v-if="showChangeUsername">
<Form
ref="changeUsernameFormRef"
:rules="changeUsernameFormRules"
:model="changeUsernameForm">
ref="changeUsernameFormRef"
:rules="changeUsernameFormRules"
:model="changeUsernameForm">
<FormItem prop="username">
<BaseInput
v-model="changeUsernameForm.username"
type="text"
size="large"
placeholder="请输入用户名"
autofocus
v-model="changeUsernameForm.username"
type="text"
size="large"
placeholder="请输入用户名"
autofocus
>
<template #preIcon>
<IconFluentPerson20Regular class="text-base"/>
@@ -342,17 +339,17 @@ function subscribe() {
</div>
<div v-if="showChangePhone">
<Form
ref="changePhoneFormRef"
:rules="changePhoneFormRules"
:model="changePhoneForm">
ref="changePhoneFormRef"
:rules="changePhoneFormRules"
:model="changePhoneForm">
<FormItem prop="oldCode" v-if="userStore.user?.phone">
<div class="flex gap-2">
<BaseInput
v-model="changePhoneForm.oldCode"
type="code"
autofocus
placeholder="请输入原手机号验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="changePhoneForm.oldCode"
type="code"
autofocus
placeholder="请输入原手机号验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => true"
:type="CodeType.ChangePhoneOld"
@@ -361,19 +358,19 @@ function subscribe() {
</FormItem>
<FormItem prop="phone">
<BaseInput
v-model="changePhoneForm.phone"
type="tel"
size="large"
placeholder="请输入新手机号"
v-model="changePhoneForm.phone"
type="tel"
size="large"
placeholder="请输入新手机号"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="changePhoneForm.code"
type="code"
placeholder="请输入新手机号验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="changePhoneForm.code"
type="code"
placeholder="请输入新手机号验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => changePhoneFormRef.validateField('phone')"
:type="CodeType.ChangePhoneNew"
@@ -382,10 +379,10 @@ function subscribe() {
</FormItem>
<FormItem prop="pwd" v-if="!userStore.user?.phone">
<BaseInput
v-model="changePhoneForm.pwd"
type="password"
size="large"
placeholder="请输入原密码"
v-model="changePhoneForm.pwd"
type="password"
size="large"
placeholder="请输入原密码"
/>
</FormItem>
</Form>
@@ -418,25 +415,25 @@ function subscribe() {
</div>
<div v-if="showChangeEmail">
<Form
ref="changeEmailFormRef"
:rules="changeEmailFormRules"
:model="changeEmailForm">
ref="changeEmailFormRef"
:rules="changeEmailFormRules"
:model="changeEmailForm">
<FormItem prop="email">
<BaseInput
v-model="changeEmailForm.email"
type="email"
size="large"
placeholder="请输入邮箱地址"
autofocus
v-model="changeEmailForm.email"
type="email"
size="large"
placeholder="请输入邮箱地址"
autofocus
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="changeEmailForm.code"
type="code"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="changeEmailForm.code"
type="code"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => changeEmailFormRef.validateField('email')"
:type="CodeType.ChangeEmail"
@@ -445,10 +442,10 @@ function subscribe() {
</FormItem>
<FormItem prop="pwd" v-if="userStore.user?.hasPwd">
<BaseInput
v-model="changePwdForm.pwd"
type="password"
size="large"
placeholder="请输入密码"
v-model="changePwdForm.pwd"
type="password"
size="large"
placeholder="请输入密码"
/>
</FormItem>
</Form>
@@ -467,42 +464,42 @@ function subscribe() {
<div class="text-xs">在此输入密码</div>
</div>
<IconFluentChevronLeft28Filled
class="transition-transform"
:class="['rotate-270','rotate-180'][showChangePwd?0:1]"/>
class="transition-transform"
:class="['rotate-270','rotate-180'][showChangePwd?0:1]"/>
</div>
<div v-if="showChangePwd">
<Form
ref="changePwdFormRef"
:rules="changePwdFormRules"
:model="changePwdForm">
ref="changePwdFormRef"
:rules="changePwdFormRules"
:model="changePwdForm">
<FormItem prop="oldPwd" v-if="userStore.user.hasPwd">
<BaseInput
v-model="changePwdForm.oldPwd"
placeholder="旧密码"
type="password"
size="large"
autofocus
v-model="changePwdForm.oldPwd"
placeholder="旧密码"
type="password"
size="large"
autofocus
/>
</FormItem>
<FormItem prop="newPwd">
<BaseInput
v-model="changePwdForm.newPwd"
type="password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength}位)`"
:min="PASSWORD_CONFIG.minLength"
:max="PASSWORD_CONFIG.maxLength"
v-model="changePwdForm.newPwd"
type="password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength}位)`"
:min="PASSWORD_CONFIG.minLength"
:max="PASSWORD_CONFIG.maxLength"
/>
</FormItem>
<FormItem prop="confirmPwd">
<BaseInput
v-model="changePwdForm.confirmPwd"
type="password"
size="large"
placeholder="请再次输入新密码"
:min="PASSWORD_CONFIG.minLength"
:max="PASSWORD_CONFIG.maxLength"
v-model="changePwdForm.confirmPwd"
type="password"
size="large"
placeholder="请再次输入新密码"
:min="PASSWORD_CONFIG.minLength"
:max="PASSWORD_CONFIG.maxLength"
/>
</FormItem>
</Form>
@@ -537,9 +534,9 @@ function subscribe() {
<!-- Logout Button -->
<div class="center w-full mt-4">
<BaseButton
@click="handleLogout"
size="large"
class="w-[80%]"
@click="handleLogout"
size="large"
class="w-[80%]"
>
登出
</BaseButton>
@@ -565,15 +562,15 @@ function subscribe() {
<template v-if="userStore.user?.member">
<div>
<div class="mb-1">当前计划</div>
<div class="text-base font-bold">{{ userStore.user?.member?.levelDesc }}</div>
<div class="text-base font-bold">{{ member?.levelDesc }}</div>
</div>
<div>
<div class="mb-1">状态</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 bg-green-500 rounded-full"></div>
<span class="text-base font-medium text-green-700">
{{ userStore.user?.member?.active ? '使用中' : '已过期' }}
<div class="w-2 h-2 rounded-full" :class="member?.active ?'bg-green-500':'bg-red-500'"></div>
<span class="text-base font-medium" :class="member?.active ?'text-green-700':'text-red-700'">
{{ member?.status }}
</span>
</div>
</div>
@@ -582,7 +579,7 @@ function subscribe() {
<div class="mb-1">到期时间</div>
<div class="flex items-center gap-2">
<IconFluentCalendarDate20Regular class="text-lg"/>
<span class="text-base font-medium">{{ memberEndtime }}</span>
<span class="text-base font-medium">{{ memberEndDate }}</span>
</div>
</div>
@@ -590,27 +587,21 @@ function subscribe() {
<div class="mb-1">自动续费</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full"
:class="userStore.user?.member?.autoRenew ? 'bg-blue-500' : 'bg-gray-400'"
:class="member?.autoRenew ? 'bg-blue-500' : 'bg-gray-400'"
></div>
<span class="text-base font-medium"
:class="userStore.user?.member?.autoRenew ? 'text-blue-700' : 'text-gray-600'">
{{ userStore.user?.member?.autoRenew ? '已开启' : '已关闭' }}
:class="member?.autoRenew ? 'text-blue-700' : 'text-gray-600'">
{{ member?.autoRenew ? '已开启' : '已关闭' }}
</span>
</div>
</div>
<div>
<div class="mb-1">付款方式</div>
<div class="flex items-center gap-2">
<IconFluentPayment20Regular class="text-lg"/>
<span class="text-base font-medium">{{ userStore.user?.member?.payMethodDesc }}</span>
</div>
</div>
</template>
<div class="text-base" v-else>当前无订阅</div>
<BaseButton class="w-full" size="large" @click="subscribe">{{ userStore.user?.member ? '管理订阅' : '会员介绍' }}
<BaseButton class="w-full" size="large" @click="subscribe">{{
userStore.user?.member ? '管理订阅' : '会员介绍'
}}
</BaseButton>
</div>
</div>

View File

@@ -1,8 +1,12 @@
<script setup lang="ts">
import BasePage from '@/components/BasePage.vue'
import BaseButton from '@/components/BaseButton.vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/auth.ts'
import {useRouter} from 'vue-router'
import {useUserStore} from '@/stores/auth.ts'
import {User} from "@/apis/user.ts";
import {onMounted} from "vue";
import Header from "@/components/Header.vue";
import {LevelBenefits, levelBenefits} from "@/apis/member.ts";
const router = useRouter()
const userStore = useUserStore()
@@ -18,119 +22,185 @@ interface Plan {
features: string[]
}
const plans: Plan[] = [
{
id: 'monthly',
name: '月付',
price: 10,
unit: '月',
desc: '',
features: [
'同步自定义词典/书籍',
'练习功能与进度统计',
'词库与文章每日更新',
'优先客服支持'
]
},
{
id: 'monthly-auto',
name: '连续包月',
price: 7,
unit: '月',
desc: '',
highlight: '性价比更高',
autoRenew: true,
features: [
]
},
{
id: 'yearly',
name: '年度会员',
price: 100,
unit: '年',
desc: '',
highlight: '年度优惠',
features: [
]
}
]
const member = $computed<User['member']>(() => userStore.user?.member ?? {} as any)
const memberEndDate = $computed(() => {
if (member?.endDate === null) return '永久'
return member?.endDate
})
// Get current plan info
const currentPlan = $computed(() => {
if (!member?.active) return null
return plans.find(p => p.id === member.planId) || null
})
// Toggle auto-renewal
function toggleAutoRenew() {
// TODO: Implement API call to toggle auto-renewal
console.log('Toggle auto-renewal:', !member.autoRenew)
}
// Get button text based on current plan
function getPlanButtonText(plan: Plan) {
if (!member?.active) return '选择'
if (plan.id === currentPlan?.id) return '当前计划'
// Compare prices to determine upgrade/downgrade
if (plan.price > (currentPlan?.price || 0)) return '升级'
return '降级'
}
function goPurchase(plan: Plan) {
return router.push('/pay')
if (!userStore.isLogin) {
router.push({path: '/login', query: {redirect: '/vip'}})
return
}
if (plan.id === currentPlan?.id) return
router.push('/user')
}
let data = $ref<LevelBenefits>({} as any)
onMounted(async () => {
let res = await levelBenefits({levelCode: 'basic'})
if (res.success) {
data = res.data
}
})
const plans: Plan[] = $computed(() => {
let list = []
if (data?.level) {
list.push({
id: 'monthly',
name: '月付',
price: data.level.price,
unit: '月',
desc: '',
},)
list.push({
id: 'monthly-auto',
name: '连续包月',
price: data.level.price_auto,
unit: '月',
desc: '',
highlight: '性价比更高',
autoRenew: true,
},)
list.push({
id: 'monthly',
name: '年度会员',
price: data.level.yearly_price,
unit: '年',
highlight: '年度优惠',
},)
}
return list
})
</script>
<template>
<BasePage>
<div class="vip-intro">
<div class="my-5 flex justify-between">
<div class="flex items-center text-2xl">
<IconFluentCrown20Regular class="mr-2 text-yellow-500"/>
<span>会员介绍</span>
<div class="space-y-6">
<div>
<Header title="会员介绍"></Header>
<div class="center">
<div>
<div class="text-lg flex items-center" v-for="f in data.benefits" :key="f.name">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<span>{{ f.name }}</span>
</div>
</div>
</div>
</div>
<div class="flex justify-between">
<div class="title">选择会员级别并立即开始试用您可以随时升级降级或取消</div>
<div class="subtitle">三种方案按需选择</div>
</div>
<div class="plans">
<div v-for="p in plans" :key="p.id" class="card bg-reverse-white shadow-lg plan">
<div>
<div class="plan-head">
<div class="plan-name">{{ p.name }}</div>
<div class="price">
<span class="amount">¥{{ p.price }}</span>
<span class="unit">/{{ p.unit }}</span>
</div>
<div class="desc">{{ p.desc }}</div>
<div v-if="p.highlight" class="tag">{{ p.highlight }}</div>
</div>
<div class="features">
<div class="feature" v-for="f in p.features" :key="f">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<span>{{ f }}</span>
</div>
<div v-if="p.autoRenew" class="notice">
<IconFluentArrowRepeatAll20Regular class="mr-2"/>
开启自动续费可在账户页随时关闭
</div>
</div>
</div>
<div v-for="p in plans" :key="p.id"
class="card bg-reverse-white shadow-lg p-0 shadow-lg overflow-hidden flex flex-col">
<div class="plan-name">{{ p.name }}</div>
<div class="p-6 flex flex-col justify-between flex-1">
<div class="plan-head">
<div class="price">
<span class="amount">¥{{ p.price }}</span>
<span class="unit">/ {{ p.unit }}</span>
</div>
<div class="desc">{{ p.desc }}</div>
<div v-if="p.highlight" class="tag">{{ p.highlight }}</div>
</div>
<div v-if="p.autoRenew" class="text-sm flex items-center mt-4">
<IconFluentArrowRepeatAll20Regular class="mr-2"/>
开启自动续费可随时关闭
</div>
<BaseButton
class="w-full mt-4"
size="large"
:type="p.id === currentPlan?.id ? 'primary' : 'info'"
:disabled="p.id === currentPlan?.id"
@click="goPurchase(p)">
{{ getPlanButtonText(p) }}
</BaseButton>
</div>
</div>
</div>
<BaseButton class="w-full mt-4" size="large" type="info" @click="goPurchase(p)">选择</BaseButton>
<!-- Membership Status Display -->
<div v-if="member?.active" class="card bg-green-50 border border-green-200 mt-3 mb-6 shadow-lg">
<div class="flex items-center justify-between">
<div class="flex items-center">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<div>
<div class="font-semibold text-green-800">当前计划{{ member?.levelDesc }}</div>
<div class="text-sm text-green-600">
到期时间{{ memberEndDate }}
</div>
</div>
</div>
<div class="text-align-end space-y-2">
<div v-if="member.autoRenew" class="flex items-center gap-space">
<div class="flex items-center text-sm text-gray-600">
<IconFluentArrowRepeatAll20Regular class="mr-1"/>
<span>自动续费已开启</span>
</div>
<BaseButton
size="small"
type="info"
@click="toggleAutoRenew">
关闭
</BaseButton>
</div>
</div>
</div>
</div>
</div>
</BasePage>
</template>
<style scoped lang="scss">
.vip-intro {
display: flex;
flex-direction: column;
gap: 1rem;
}
.plans {
display: grid;
gap: 1rem;
gap: 3rem;
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.plan {
@apply flex flex-col gap-3 px-6 py-5 justify-between;
}
.plan-head {
@apply flex flex-col gap-2;
}
.plan-name {
@apply text-lg font-bold;
@apply text-2xl font-bold bg-gray-300 px-6 py-4;
}
.price {
@@ -138,11 +208,11 @@ function goPurchase(plan: Plan) {
}
.amount {
@apply text-2xl font-500;
@apply text-4xl font-500;
}
.unit {
@apply text-sm text-gray-500;
@apply text-base text-gray-500;
}
.desc {
@@ -152,18 +222,5 @@ function goPurchase(plan: Plan) {
.tag {
@apply text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded w-fit;
}
.features {
@apply flex flex-col gap-2 mt-2;
}
.feature {
@apply flex items-center;
}
.notice {
@apply text-xs text-gray-600 flex items-center;
}
</style>

View File

@@ -1,22 +1,22 @@
<script setup lang="tsx">
import { onBeforeUnmount, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import {onBeforeUnmount, onMounted} from 'vue'
import {useRoute} from 'vue-router'
import BaseInput from "@/components/base/BaseInput.vue";
import BaseButton from "@/components/BaseButton.vue";
import { APP_NAME } from "@/config/env.ts";
import { useUserStore } from "@/stores/auth.ts";
import { loginApi, LoginParams, registerApi, resetPasswordApi, sendCode } from "@/apis/user.ts";
import { accountRules, codeRules, passwordRules, phoneRules } from "@/utils/validation.ts";
import {APP_NAME} from "@/config/env.ts";
import {useUserStore} from "@/stores/auth.ts";
import {loginApi, LoginParams, registerApi, resetPasswordApi, sendCode} from "@/apis/user.ts";
import {accountRules, codeRules, passwordRules, phoneRules} from "@/utils/validation.ts";
import Toast from "@/components/base/toast/Toast.ts";
import FormItem from "@/components/base/form/FormItem.vue";
import Form from "@/components/base/form/Form.vue";
import Notice from "@/pages/user/Notice.vue";
import { FormInstance } from "@/components/base/form/types.ts";
import { PASSWORD_CONFIG, PHONE_CONFIG } from "@/config/auth.ts";
import { CodeType } from "@/types/types.ts";
import {FormInstance} from "@/components/base/form/types.ts";
import {PASSWORD_CONFIG, PHONE_CONFIG} from "@/config/auth.ts";
import {CodeType} from "@/types/types.ts";
import Code from "@/pages/user/Code.vue";
import BackIcon from "@/components/BackIcon.vue";
import { useNav } from "@/utils";
import {useNav} from "@/utils";
import Header from "@/components/Header.vue";
// 状态管理
@@ -128,12 +128,13 @@ async function handleLogin() {
let res = await loginApi(data as LoginParams)
if (res.success) {
userStore.setToken(res.data.token)
userStore.setUser(res.data.user)
Toast.success('登录成功')
// 跳转到首页或用户中心
router.push('/')
router.back()
} else {
Toast.error(res.msg || '登录失败')
if (res.code === 499) {
loginType = 'code'
}
}
} catch (error) {
Toast.error('登录失败,请重试')
@@ -295,28 +296,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>
@@ -324,10 +325,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"
@@ -340,11 +341,11 @@ onBeforeUnmount(() => {
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="phoneLoginForm.code"
type="code"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
v-model="phoneLoginForm.code"
type="code"
size="large"
:max-length="PHONE_CONFIG.codeLength"
placeholder="请输入验证码"
/>
<Code :validate-field="() => phoneLoginFormRef.validateField('phone')"
:type="CodeType.Login"
@@ -355,10 +356,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="email"
@@ -371,12 +372,12 @@ onBeforeUnmount(() => {
<FormItem prop="password">
<div class="flex gap-2">
<BaseInput
v-model="loginForm2.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
placeholder="请输入密码"
v-model="loginForm2.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
placeholder="请输入密码"
/>
</div>
</FormItem>
@@ -387,10 +388,10 @@ onBeforeUnmount(() => {
</Notice>
<BaseButton
class="w-full"
size="large"
:loading="loading"
@click="handleLogin"
class="w-full"
size="large"
:loading="loading"
@click="handleLogin"
>
登录
</BaseButton>
@@ -407,27 +408,27 @@ onBeforeUnmount(() => {
<Header @click="switchMode('login')" title="注册新账号"/>
<Form
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
ref="registerFormRef"
:rules="registerFormRules"
:model="registerForm">
<FormItem prop="account">
<BaseInput
v-model="registerForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
v-model="registerForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="registerForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="registerForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => registerFormRef.validateField('account')"
:type="CodeType.Register"
@@ -436,22 +437,22 @@ onBeforeUnmount(() => {
</FormItem>
<FormItem prop="password">
<BaseInput
v-model="registerForm.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
v-model="registerForm.password"
type="password"
name="password"
autocomplete="current-password"
size="large"
:placeholder="`请设置密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="registerForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入密码"
v-model="registerForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入密码"
/>
</FormItem>
</Form>
@@ -459,10 +460,10 @@ onBeforeUnmount(() => {
<Notice/>
<BaseButton
class="w-full"
size="large"
:loading="loading"
@click="handleRegister"
class="w-full"
size="large"
:loading="loading"
@click="handleRegister"
>
注册
</BaseButton>
@@ -474,27 +475,27 @@ onBeforeUnmount(() => {
<Header @click="switchMode('login')" title="重置密码"/>
<Form
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
ref="forgotFormRef"
:rules="forgotFormRules"
:model="forgotForm">
<FormItem prop="account">
<BaseInput
v-model="forgotForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
v-model="forgotForm.account"
type="tel"
name="username"
autocomplete="username"
size="large"
placeholder="请输入手机号/邮箱地址"
/>
</FormItem>
<FormItem prop="code">
<div class="flex gap-2">
<BaseInput
v-model="forgotForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
v-model="forgotForm.code"
type="code"
size="large"
placeholder="请输入验证码"
:max-length="PHONE_CONFIG.codeLength"
/>
<Code :validate-field="() => forgotFormRef.validateField('account')"
:type="CodeType.ResetPwd"
@@ -503,31 +504,31 @@ onBeforeUnmount(() => {
</FormItem>
<FormItem prop="newPassword">
<BaseInput
v-model="forgotForm.newPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
v-model="forgotForm.newPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
:placeholder="`请输入新密码(${PASSWORD_CONFIG.minLength}-${PASSWORD_CONFIG.maxLength} 位)`"
/>
</FormItem>
<FormItem prop="confirmPassword">
<BaseInput
v-model="forgotForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入新密码"
v-model="forgotForm.confirmPassword"
type="password"
name="password"
autocomplete="new-password"
size="large"
placeholder="请再次输入新密码"
/>
</FormItem>
</Form>
<BaseButton
class="w-full mt-2"
size="large"
:loading="loading"
@click="handleForgotPassword"
class="w-full mt-2"
size="large"
:loading="loading"
@click="handleForgotPassword"
>
重置密码
</BaseButton>
@@ -538,16 +539,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>
@@ -555,8 +556,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>
@@ -565,12 +566,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

@@ -13,6 +13,7 @@ import Setting from "@/pages/setting/Setting.vue";
import Login from "@/pages/user/login.vue";
import User from "@/pages/user/User.vue";
import VipIntro from "@/pages/user/VipIntro.vue";
import Pay from "@/pages/user/Pay.vue";
// import { useAuthStore } from "@/stores/auth.ts";
export const routes: RouteRecordRaw[] = [
@@ -38,6 +39,7 @@ export const routes: RouteRecordRaw[] = [
{path: 'login', component: Login},
{path: 'user', component: User},
{path: 'vip', component: VipIntro},
{path: 'pay', component: Pay},
]
},
{path: '/batch-edit-article', component: () => import("@/pages/article/BatchEditArticlePage.vue")},

View File

@@ -13,6 +13,8 @@ export const useUserStore = defineStore('user', () => {
// 设置token
const setToken = (newToken: string) => {
AppEnv.TOKEN = newToken
AppEnv.IS_LOGIN = !!AppEnv.TOKEN
AppEnv.CAN_REQUEST = AppEnv.IS_LOGIN && AppEnv.IS_OFFICIAL
localStorage.setItem('token', newToken)
}