This commit is contained in:
Zyronon
2025-11-19 20:01:03 +08:00
committed by GitHub
parent d4e508faf1
commit f9c3da2f78
16 changed files with 556 additions and 390 deletions

View File

@@ -49,7 +49,9 @@
"@iconify-json/qlementine-icons": "^1.2.11",
"@iconify-json/ri": "^1.2.5",
"@iconify-json/simple-icons": "^1.2.48",
"@iconify-json/streamline": "^1.2.5",
"@iconify-json/system-uicons": "^1.2.4",
"@iconify-json/uiw": "^1.2.3",
"@types/file-saver": "^2.0.7",
"@types/lodash-es": "^4.17.12",
"@types/md5": "^2.1.33",

View File

@@ -25,6 +25,45 @@ export type LevelBenefits = {
}[]
}
export type CouponInfo = {
"id": number,
"code": string,
"name": string,
"type": string,
"value"?: string,
"min_amount"?: string,
"max_discount"?: string,
"applicable_levels": {
code: string,
name: string,
level: string,
}[]
"usage_limit": number,
"total_usage": number,
"start_date": string
"end_date": string
"is_active": number,
"created_at": string
"updated_at": string
"is_valid": boolean,
}
export function levelBenefits(params) {
return http<LevelBenefits>('member/levelBenefits', null, params, 'get')
}
export function orderCreate(params) {
return http<{ orderNo: string }>('/member/orderCreate', params, null, 'post')
}
export function orderStatus(params) {
return http('/member/orderStatus', null, params, 'get')
}
export function couponInfo(params) {
return http<CouponInfo>('/member/couponInfo', null, params, 'get')
}
export function setAutoRenewApi(params) {
return http('/member/setAutoRenew', params, null, 'post')
}

View File

@@ -24,6 +24,7 @@ export interface User {
endDate: number,
autoRenew: boolean,
plan: string,
planDesc: string,
}
}

View File

@@ -73,6 +73,8 @@
--color-label-bg: whitesmoke;
--color-link: #2563EB;
--color-card-bg: white;
}
.footer {
@@ -124,6 +126,8 @@ html.dark {
--color-label-bg: rgb(10, 10, 10);
--color-card-bg: rgb(30, 31, 34);
.footer {
&.hide {
--color-progress-bar: var(--color-third) !important;
@@ -403,6 +407,11 @@ a {
background: var(--color-second);
}
.card-white {
@extend .card;
background: var(--color-card-bg);
}
.inline-center {
@apply inline-flex justify-center items-center;
}

View File

@@ -11,9 +11,21 @@ export default {
},
props: {
title: {
type: String,
type: [String, Array],
default() {
return ''
},
validator(value) {
// Validate that array items have the correct structure
if (Array.isArray(value)) {
return value.every(item =>
typeof item === 'object' &&
item !== null &&
typeof item.text === 'string' &&
['normal', 'bold', 'red', 'redBold'].includes(item.type)
)
}
return typeof value === 'string'
}
},
disabled: {
@@ -23,6 +35,17 @@ export default {
}
}
},
computed: {
titleItems() {
if (typeof this.title === 'string') {
return [{ text: this.title, type: 'normal' }]
}
if (Array.isArray(this.title)) {
return this.title
}
return []
}
},
data() {
return {
show: false
@@ -37,6 +60,27 @@ export default {
})
},
methods: {
getTextStyle(type) {
const styles = {
normal: {
fontWeight: 'normal',
color: 'inherit'
},
bold: {
fontWeight: 'bold',
color: 'inherit'
},
red: {
fontWeight: 'normal',
color: 'red'
},
redBold: {
fontWeight: 'bold',
color: 'red'
}
}
return styles[type] || styles.normal
},
showPop(e) {
if (this.disabled) return this.$emit('confirm')
e?.stopPropagation()
@@ -68,8 +112,16 @@ export default {
{
this.show && (
<div ref="tip" class="pop-confirm-content shadow-2xl">
<div class="w-50">
{this.title}
<div class="w-52 title-content">
{this.titleItems.map((item, index) => (
<div
key={index}
style={this.getTextStyle(item.type)}
class="title-item"
>
{item.text}
</div>
))}
</div>
<div class="options">
<BaseButton type="info" size="small" onClick={() => this.show = false}>取消</BaseButton>
@@ -95,6 +147,15 @@ export default {
transform: translate(-50%, calc(-100% - .6rem));
z-index: 999;
.title-content {
.title-item {
margin-bottom: 0.25rem;
&:last-child {
margin-bottom: 0;
}
}
}
.options {
margin-top: .9rem;

View File

@@ -24,6 +24,7 @@ export let AppEnv = {
AppEnv.IS_LOGIN = !!AppEnv.TOKEN
AppEnv.CAN_REQUEST = AppEnv.IS_LOGIN && AppEnv.IS_OFFICIAL
// console.log('AppEnv.CAN_REQUEST',AppEnv.CAN_REQUEST)
export const RESOURCE_PATH = ENV.API + 'static'

View File

@@ -437,15 +437,15 @@ function onContextMenu(e: MouseEvent, sentence: Sentence, i, j, w) {
onClick: () => {
let word = props.article.sections[i][j].words[w]
let text = word.word
// let doc = nlp(text)
// // 优先判断是不是动词
// if (doc.verbs().found) {
// text = doc.verbs().toInfinitive().text()
// }
// // 如果是名词(复数 → 单数)
// if (doc.nouns().found) {
// text = doc.nouns().toSingular().text()
// }
let doc = nlp(text)
// 优先判断是不是动词
if (doc.verbs().found) {
text = doc.verbs().toInfinitive().text()
}
// 如果是名词(复数 → 单数)
if (doc.nouns().found) {
text = doc.nouns().toSingular().text()
}
if (!text.length) text = word.word
console.log('text', text)
toggleWordCollect(getDefaultWord({word: text, id: nanoid()}))

View File

@@ -1,217 +0,0 @@
<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

@@ -246,13 +246,17 @@ function subscribe() {
}
function onFileChange(e) {
console.log('e', e)
}
</script>
<template>
<BasePage>
<!-- Unauthenticated View -->
<div v-if="!userStore.isLogin" class="center h-screen">
<div class="card bg-white shadow-lg text-center flex-col gap-6 w-110">
<div class="card-white text-center flex-col gap-6 w-110">
<div class="w-20 h-20 bg-blue-100 rounded-full center mx-auto">
<IconFluentPerson20Regular class="text-3xl text-blue-600"/>
</div>
@@ -279,8 +283,7 @@ function subscribe() {
<!-- Authenticated View -->
<div v-else class="w-full flex gap-4">
<!-- Main Account Settings -->
<!-- todo 夜间背景色-->
<div class="card bg-reverse-white shadow-lg flex-1 flex flex-col gap-2 px-6">
<div class="card-white flex-1 flex flex-col gap-2 px-6">
<h1 class="text-2xl font-bold mt-0">帐户</h1>
<!-- 用户名-->
@@ -458,14 +461,14 @@ function subscribe() {
<!-- Password Section -->
<div class="item cp" @click="showChangePwdForm">
<div class="item">
<div class="flex-1">
<div class="mb-2">设置密码</div>
<div class="text-xs">在此输入密码</div>
</div>
<IconFluentChevronLeft28Filled
class="transition-transform"
:class="['rotate-270','rotate-180'][showChangePwd?0:1]"/>
<BaseIcon @click="showChangePwdForm">
<IconFluentTextEditStyle20Regular/>
</BaseIcon>
</div>
<div v-if="showChangePwd">
<Form
@@ -523,6 +526,20 @@ function subscribe() {
</div>
<!-- <div class="line"></div>-->
<!-- 同步进度-->
<div class="item cp relative">
<div class="flex-1">
<div class="">同步进度</div>
<!-- <div class="text-xs mt-2">在此输入密码</div>-->
</div>
<IconFluentChevronLeft28Filled class="rotate-180"/>
<input type="file" accept=".json,.zip,application/json,application/zip"
@change="onFileChange"
class="absolute left-0 top-0 w-full h-full bg-red cp opacity-0"/>
</div>
<div class="line"></div>
<!-- 去github issue-->
<div class="item cp"
@click="goIssues">
<div class="flex-1">
@@ -551,8 +568,7 @@ function subscribe() {
</div>
<!-- Subscription Information -->
<!-- todo 夜间背景色-->
<div class="card bg-reverse-white shadow-lg w-80">
<div class="card-white w-80">
<div class="flex items-center gap-3 mb-4">
<IconFluentCrown20Regular class="text-2xl text-yellow-500"/>
<div class="text-lg font-bold">订阅信息</div>
@@ -563,13 +579,13 @@ function subscribe() {
<template v-if="userStore.user?.member">
<div>
<div class="mb-1">当前计划</div>
<div class="text-base font-bold">{{ member?.levelDesc }}</div>
<div class="text-base font-bold">{{ member?.planDesc }}</div>
</div>
<div>
<div class="mb-1">状态</div>
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full" :class="member?.active ?'bg-green-500':'bg-red-500'"></div>
<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>
@@ -609,6 +625,7 @@ function subscribe() {
</div>
</BasePage>
</template>
<style scoped lang="scss">
.item {
@apply flex items-center justify-between min-h-14;

View File

@@ -1,17 +1,29 @@
<script setup lang="ts">
import BasePage from '@/components/BasePage.vue'
import BaseButton from '@/components/BaseButton.vue'
import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/auth.ts'
import { User } from "@/apis/user.ts";
import { computed, onMounted, ref } from "vue";
import {useRouter} from 'vue-router'
import {useUserStore} from '@/stores/auth.ts'
import {User} from "@/apis/user.ts";
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
import Header from "@/components/Header.vue";
import { LevelBenefits, levelBenefits } from "@/apis/member.ts";
import {
CouponInfo,
couponInfo,
LevelBenefits,
levelBenefits,
orderCreate,
orderStatus,
setAutoRenewApi
} from "@/apis/member.ts";
import Radio from "@/components/base/radio/Radio.vue";
import RadioGroup from "@/components/base/radio/RadioGroup.vue";
import { APP_NAME } from "@/config/env.ts";
import {APP_NAME} from "@/config/env.ts";
import Toast from "@/components/base/toast/Toast.ts";
import { _dateFormat, _nextTick } from "@/utils";
import {_dateFormat, _nextTick} from "@/utils";
import InputNumber from "@/components/base/InputNumber.vue";
import dayjs from "dayjs";
import BaseInput from "@/components/base/BaseInput.vue";
import PopConfirm from "@/components/PopConfirm.vue";
const router = useRouter()
const userStore = useUserStore()
@@ -25,9 +37,10 @@ interface Plan {
autoRenew?: boolean
}
let loading = $ref(false);
let selectedPaymentMethod = $ref('wechat')
let selectedSubscribePlan = $ref(undefined)
let selectedPlanId = $ref('')
let duration = $ref(1)
const member = $computed<User['member']>(() => userStore.user?.member ?? {} as any)
const memberEndDate = $computed(() => {
@@ -40,13 +53,13 @@ const plans: Plan[] = $computed(() => {
let list = []
if (data?.level) {
list.push({
id: 'monthly',
id: 'month',
name: '月付',
price: data.level.price,
unit: '月',
},)
list.push({
id: 'monthly-auto',
id: 'month_auto',
name: '连续包月',
price: data.level.price_auto,
unit: '月',
@@ -64,41 +77,6 @@ const plans: Plan[] = $computed(() => {
return list
})
const currentPlan = $computed(() => {
return plans.find(v => v.id === selectedSubscribePlan) ?? null
})
onMounted(async () => {
let res = await levelBenefits({levelCode: 'basic'})
if (res.success) {
data = res.data
}
})
// Toggle auto-renewal
function toggleAutoRenew() {
// TODO: Implement API call to toggle auto-renewal
console.log('Toggle auto-renewal:', !member.autoRenew)
}
// Get button text based on current plan
function getPlanButtonText(plan: Plan) {
if (plan.id === selectedSubscribePlan) return '当前计划'
if (!member?.active) return '选择'
}
function goPurchase(plan: Plan) {
if (!userStore.isLogin) {
router.push({path: '/login', query: {redirect: '/vip'}})
return
}
selectedSubscribePlan = plan.id
_nextTick(() => {
let el = document.getElementById('pay')
el.scrollIntoView({behavior: "smooth"})
})
}
// Payment methods - WeChat and Alipay
const paymentMethods = [
{
@@ -113,9 +91,188 @@ const paymentMethods = [
}
]
const currentPlan = $computed(() => {
return plans.find(v => v.id === member?.plan) ?? null
})
function handlePayment() {
console.log('Processing payment with:', selectedPaymentMethod)
const selectPlan = $computed(() => {
return plans.find(v => v.id === selectedPlanId) ?? null
})
// Calculate original price based on plan type
const originalPrice = $computed(() => {
return selectPlan?.id === 'month_auto' ? Number(selectPlan?.price) : Number(duration) * Number(selectPlan?.price)
})
// check Is it enough for a discount
const enoughDiscount = $computed(() => {
if (coupon.is_valid) {
if (coupon.min_amount) {
const minAmount = Number(coupon.min_amount)
return originalPrice > minAmount
}
return true
}
return false
})
const endPrice = $computed(() => {
if (!coupon.is_valid) {
return Number(originalPrice.toFixed(2))
}
if (coupon.type === 'free_trial') return 0
if (!enoughDiscount) {
return Number(originalPrice.toFixed(2))
}
let discountAmount = 0
if (coupon.type === 'discount') {
// Discount coupon: e.g., 0.8 means 20% off
const discountRate = Number(coupon.value)
discountAmount = originalPrice * (1 - discountRate)
// Apply max_discount limit if available
if (coupon.max_discount) {
const maxDiscount = Number(coupon.max_discount)
discountAmount = Math.min(discountAmount, maxDiscount)
}
} else if (coupon.type === 'amount') {
// Amount coupon: fixed amount off
discountAmount = Number(coupon.value)
}
const finalPrice = Math.max(originalPrice - discountAmount, 0)
return finalPrice.toFixed(2)
}
)
const startDate = $computed(() => {
if (member?.active) {
return member.endDate
} else {
return _dateFormat(Date.now())
}
})
onMounted(async () => {
let res = await levelBenefits({levelCode: 'basic'})
if (res.success) {
data = res.data
}
})
let loading2 = $ref(false);
async function toggleAutoRenew() {
if (loading2) return
loading2 = true
let res = await setAutoRenewApi({autoRenew: false})
if (res.success) {
Toast.success('取消成功')
userStore.init()
} else {
Toast.error(res.msg || '取消失败')
}
loading2 = false
}
// Get button text based on current plan
function getPlanButtonText(plan: Plan) {
if (plan.id === selectedPlanId) return '已选中'
if (plan.id === currentPlan?.id) return '当前计划'
return '选择'
}
function goPurchase(plan: Plan) {
if (!userStore.isLogin) {
router.push({path: '/login', query: {redirect: '/vip'}})
return
}
selectedPlanId = plan.id
_nextTick(() => {
let el = document.getElementById('pay')
el.scrollIntoView({behavior: "smooth"})
})
}
let startLoop = $ref(false)
let orderNo = $ref('')
let timer: number = $ref()
let showCouponInput = $ref(false)
let coupon = $ref<CouponInfo>({code: ''} as CouponInfo)
watch(() => startLoop, (n) => {
if (n) {
clearInterval(timer)
timer = setInterval(() => {
orderStatus({orderNo}).then(res => {
if (res?.success) {
if (res.data?.payment_status === 'paid') {
Toast.success('付款成功')
userStore.init()
startLoop = false
selectedPlanId = undefined
}
} else {
startLoop = false
Toast.error(res.msg || '付款失败')
}
})
}, 1000)
} else {
clearInterval(timer)
}
})
onUnmounted(() => {
startLoop = false
clearInterval(timer)
})
async function handlePayment() {
if (loading) return
loading = true
let data = {
plan: selectedPlanId,
duration: Number(duration),
payment_method: selectedPaymentMethod,
couponCode: coupon.is_valid ? coupon.code : undefined
}
let res = await orderCreate(data)
if (res.success) {
orderNo = res.data.orderNo
startLoop = true
} else {
Toast.error(res.msg || '付款失败')
}
loading = false
}
let couponLoading = $ref(false)
async function getCouponInfo() {
if (showCouponInput) {
if (!coupon.code) return
if (couponLoading) return
couponLoading = true
let res = await couponInfo(coupon)
if (res.success) {
if (res.data.is_valid) {
coupon = res.data
} else {
coupon = {code: coupon.code} as CouponInfo
Toast.info('优惠券已失效')
}
} else {
coupon = {code: coupon.code} as CouponInfo
Toast.error(res.msg || '优惠券无效')
}
couponLoading = false
} else {
showCouponInput = true
}
}
</script>
@@ -123,16 +280,42 @@ function handlePayment() {
<template>
<BasePage>
<div class="space-y-6">
<div>
<div class="card-white">
<Header title="会员介绍"></Header>
<div class="center">
<div>
<div class="text-lg flex items-center" v-for="f in data.benefits" :key="f.name">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<span>
<span>{{ f.name }}</span>
<span v-if="f.value !== 'true'">{{ `(${f.value}${f.unit ?? ''})`}}</span>
</span>
<div class="grid grid-cols-3 grid-rows-3 gap-3">
<div class="text-lg items-center" v-for="f in data.benefits" :key="f.name">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<span>
<span>{{ f.name }}</span>
<span v-if="f.value !== 'true'">{{ `(${f.value}${f.unit ?? ''})` }}</span>
</span>
</div>
</div>
</div>
<div v-if="member?.active" class="card-white bg-green-50 dark:bg-item border border-green-200 mt-3 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<div>
<div class="font-semibold text-green-800">当前计划{{ currentPlan?.name }}</div>
<div class="text-sm text-green-600">
到期时间{{ memberEndDate }}
</div>
</div>
</div>
<div class="text-align-end space-y-2">
<div v-if="member.autoRenew" class="flex items-center gap-space">
<div class="flex items-center text-sm text-gray-600">
<IconFluentArrowRepeatAll20Regular class="mr-1"/>
<span>自动续费已开启</span>
</div>
<PopConfirm
title="确认取消?"
@confirm="toggleAutoRenew"
>
<BaseButton size="small" type="info" :loading="loading2">关闭</BaseButton>
</PopConfirm>
</div>
</div>
</div>
@@ -145,8 +328,8 @@ function handlePayment() {
<div class="plans">
<div v-for="p in plans" :key="p.id"
class="card bg-reverse-white shadow-lg p-0 shadow-lg overflow-hidden flex flex-col">
<div class="plan-name">{{ p.name }}</div>
class="card-white p-0 overflow-hidden flex flex-col">
<div class="text-2xl font-bold bg-gray-300 dark:bg-third px-6 py-4">{{ p.name }}</div>
<div class="p-6 flex flex-col justify-between flex-1">
<div class="plan-head">
<div class="price">
@@ -159,76 +342,79 @@ function handlePayment() {
<IconFluentArrowRepeatAll20Regular class="mr-2"/>
开启自动续费可随时关闭
</div>
<BaseButton
class="w-full mt-4"
size="large"
:type="p.id === selectedSubscribePlan ? 'primary' : 'info'"
@click="goPurchase(p)">
<BaseButton class="w-full mt-4" size="large"
:type="(p.id === currentPlan?.id || p.id === selectedPlanId) ? 'primary' : 'info'"
:disabled="p.id === currentPlan?.id" @click="goPurchase(p)">
{{ getPlanButtonText(p) }}
</BaseButton>
</div>
</div>
</div>
<!-- Membership Status Display -->
<div v-if="member?.active" class="card bg-green-50 border border-green-200 mt-3 mb-6 shadow-lg">
<div class="flex items-center justify-between">
<div class="flex items-center">
<IconFluentCheckmarkCircle20Regular class="mr-2 text-green-600"/>
<div>
<div class="font-semibold text-green-800">当前计划{{ member?.levelDesc }}</div>
<div class="text-sm text-green-600">
到期时间{{ memberEndDate }}
</div>
</div>
</div>
<div class="text-align-end space-y-2">
<div v-if="member.autoRenew" class="flex items-center gap-space">
<div class="flex items-center text-sm text-gray-600">
<IconFluentArrowRepeatAll20Regular class="mr-1"/>
<span>自动续费已开启</span>
</div>
<BaseButton
size="small"
type="info"
@click="toggleAutoRenew">
关闭
</BaseButton>
</div>
</div>
</div>
</div>
</div>
<div id="pay" class="mb-50" v-if="member?.plan !== selectedSubscribePlan">
<div id="pay" class="mb-50" v-if="selectedPlanId">
<!-- Page Header -->
<div class="text-center mb-6">
<h1 class="text-xl font-semibold mb-2">安全支付</h1>
<p class="">选择支付方式完成订单</p>
</div>
<div class="center">
<div class="card-white w-7/10">
<div class="flex items-center justify-between gap-6 ">
<div class="center gap-2" v-if="!showCouponInput">
<IconStreamlineDiscountPercentCoupon/>
<span>有优惠券</span>
</div>
<BaseInput v-else v-model="coupon.code"
placeholder="请输入优惠券"
autofocus
@enter="getCouponInfo"
/>
<BaseButton size="large"
:loading="couponLoading"
@click="getCouponInfo">{{ showCouponInput ? '确定' : '在此兑换!' }}
</BaseButton>
</div>
<div class="bg-green-50 border border-green-200 rounded-lg px-4 py-3 mt-4"
v-if="coupon.is_valid">
<div class="font-medium">优惠券: {{ coupon.name }}</div>
<div class="flex justify-between w-full mt-2">
<span v-if="coupon.type === 'discount'">折扣券{{ (Number(coupon.value) * 10).toFixed(1) }}</span>
<span v-else-if="coupon.type === 'amount'">立减券{{ Number(coupon.value).toFixed(2) }}</span>
<span v-else-if="coupon.type === 'free_trial'">折扣: -100%</span>
<!-- Coupon restrictions -->
<div v-if="coupon.min_amount || coupon.max_discount">
<span v-if="coupon.min_amount">{{ Number(coupon.min_amount).toFixed(2) }}元可用</span>
<span v-if="coupon.max_discount && coupon.type === 'discount'">
· 最高减{{ Number(coupon.max_discount).toFixed(2) }}
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Main Content -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Left Card: Payment Method Selection -->
<div class="card bg-white shadow-lg">
<div class="card-white">
<div class="text-lg font-medium mb-4">选择支付方式</div>
<RadioGroup v-model="selectedPaymentMethod">
<div class="space-y-3 w-full">
<div
v-for="method in paymentMethods"
:key="method.id"
@click=" selectedPaymentMethod = method.id"
class="flex p-4 border rounded-lg cp transition-all duration-200"
:class="[
selectedPaymentMethod === method.id
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300 hover:bg-gray-50'
]"
>
<div class="flex items-center flex-1 gap-2">
<IconSimpleIconsWechat class="text-xl"/>
<div v-for="method in paymentMethods" :key="method.id"
@click=" selectedPaymentMethod = method.id"
class="flex p-4 border rounded-lg cp transition-all duration-200 hover:bg-item"
:class="selectedPaymentMethod === method.id && 'bg-item'">
<div class="flex items-center flex-1 gap-4">
<IconSimpleIconsWechat class="text-xl color-green-500" v-if="method.id === 'wechat'"/>
<IconUiwAlipay class="text-xl color-blue" v-else/>
<div>
<div class="font-medium">{{ method.name }}</div>
<div class="font-medium color-main">{{ method.name }}</div>
<div class="text-sm text-gray-500">{{ method.description }}</div>
</div>
</div>
@@ -239,31 +425,57 @@ function handlePayment() {
</div>
<!-- Right Card: Order Summary -->
<div class="card bg-white shadow-lg">
<div class="card-white">
<div class="text-lg font-semibold mb-4">订单概要</div>
<!-- Plan Info -->
<div class="mb-4">
<div class="text-purple-600 text-sm mb-2">付费方案{{ currentPlan.name }}订阅</div>
<div class="text-gray-900 mb-4">
{{ _dateFormat(Date.now()) }} 开始:
<div class="text-purple-600 text-sm mb-2">付费方案{{ selectPlan?.name }}订阅</div>
<div class="mb-4"> {{ startDate }} 开始:</div>
</div>
<div class="flex justify-between items-center mb-4">
<!-- Price -->
<div class="flex items-baseline">
<span class="font-semibold"
:class="selectPlan?.id === 'month_auto' ? 'text-3xl' : 'text-xl'">
{{ selectPlan?.price }}
</span>
<span class="ml-2">/ {{ selectPlan?.unit }}</span>
</div>
<div v-if="selectPlan?.id !== 'month_auto'">
<InputNumber :min="1" v-model="duration"/>
</div>
</div>
<!-- Price -->
<div v-if="coupon.is_valid" class="mb-4">
<div class="flex items-baseline text-gray-500 line-through" v-if="enoughDiscount">
<span class="text-lg">原价{{ Number(originalPrice).toFixed(2) }}</span>
<span class="ml-2">/ {{ selectPlan?.unit }}</span>
</div>
<div class="text-sm">
<div v-if="enoughDiscount" class="text-green-600 flex items-center">
<IconStreamlineDiscountPercentCoupon class="mr-2"/>
<span>已优惠{{ (Number(originalPrice) - Number(endPrice)).toFixed(2) }}</span>
</div>
<span v-else>优惠券不可用未满足条件</span>
</div>
</div>
<!-- Final Price -->
<div class="flex items-baseline mb-4">
<span class="text-3xl font-semibold text-gray-900">{{ currentPlan.price }}</span>
<span class="text-gray-600 ml-2">/ {{ currentPlan.unit }}</span>
<span class="text-2xl font-semibold">总计</span>
<span class="text-3xl font-semibold">{{ endPrice }}</span>
</div>
<div class="bg-second text-sm px-4 py-3 rounded-lg mb-4 text-gray-600">
会员属于虚拟服务一经购买激活后不支持退款请在购买前仔细阅读权益说明确认符合您的需求再进行支付
</div>
<!-- Payment Button -->
<BaseButton
class="w-full"
size="large"
:type="!!selectedPaymentMethod ? 'primary' : 'info'"
:disabled="!selectedPaymentMethod"
@click="handlePayment"
>
<BaseButton class="w-full" size="large" :loading="loading || startLoop"
:type="!!selectedPaymentMethod ? 'primary' : 'info'" :disabled="!selectedPaymentMethod"
@click="handlePayment">
付款
</BaseButton>
</div>
@@ -273,7 +485,6 @@ function handlePayment() {
</template>
<style scoped lang="scss">
.plans {
display: grid;
gap: 3rem;
@@ -284,9 +495,6 @@ function handlePayment() {
@apply flex flex-col gap-2;
}
.plan-name {
@apply text-2xl font-bold bg-gray-300 px-6 py-4;
}
.price {
@apply flex items-end gap-1;
@@ -308,4 +516,3 @@ function handlePayment() {
@apply text-xs bg-yellow-100 text-yellow-700 px-2 py-1 rounded w-fit;
}
</style>

View File

@@ -5,7 +5,7 @@ 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 {loginApi, LoginParams, registerApi, resetPasswordApi} from "@/apis/user.ts";
import {accountRules, codeRules, passwordRules, phoneRules} from "@/utils/validation.ts";
import Toast from "@/components/base/toast/Toast.ts";
import FormItem from "@/components/base/form/FormItem.vue";
@@ -15,9 +15,9 @@ 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 {isNewUser, useNav} from "@/utils";
import Header from "@/components/Header.vue";
import PopConfirm from "@/components/PopConfirm.vue";
// 状态管理
const userStore = useUserStore()
@@ -28,13 +28,14 @@ const router = useNav()
let currentMode = $ref<'login' | 'register' | 'forgot'>('login')
let loginType = $ref<'code' | 'password'>('code') // 默认验证码登录
let loading = $ref(false)
let codeCountdown = $ref(0)
let showWechatQR = $ref(true)
let wechatQRUrl = $ref('https://open.weixin.qq.com/connect/qrcode/041GmMJM2wfM0w3D')
// 微信二维码状态idle-正常/等待扫码scanned-已扫码待确认expired-已过期cancelled-已取消
let qrStatus = $ref<'idle' | 'scanned' | 'expired' | 'cancelled'>('idle')
let qrExpireTimer: ReturnType<typeof setTimeout> | null = null
let qrCheckInterval: ReturnType<typeof setInterval> | null = null
let waitForImportConfirmation = $ref(true)
let isImporting = $ref(true)
const QR_EXPIRE_TIME = 5 * 60 * 1000 // 5分钟过期
@@ -153,7 +154,7 @@ async function handleRegister() {
let res = await registerApi(registerForm)
if (res.success) {
userStore.setToken(res.data.token)
userStore.setUser(res.data.user)
userStore.setUser(res.data.user as any)
Toast.success('注册成功')
// 跳转到首页或用户中心
router.push('/')
@@ -284,7 +285,7 @@ onBeforeUnmount(() => {
<template>
<div class="center min-h-screen">
<div class="rounded-2xl p-2 bg-white shadow-lg">
<div class="card-white p-2" v-if="!waitForImportConfirmation">
<!-- 登录区域容器 - 弹框形式 -->
<div class="flex gap-2">
<!-- 左侧登录区域 -->
@@ -581,6 +582,44 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<div v-else class="card-white p-6 w-100">
<div class="title">同步数据确认</div>
<div class="flex flex-col justify-between h-60">
<div v-if="!isImporting">
<h2>检测到您本地存在使用记录</h2>
<h3>是否需要同步到账户中?</h3>
</div>
<div>
<h3 class="text-align-center">正在导入中</h3>
<ol class="pl-4">
<li>
您的用户数据已自动下载到您的电脑中
</li>
<li>
随后将开始数据同步
</li>
<li>
如果您的数据量很大,这将是一个耗时操作
</li>
<li class="color-red-5 font-bold">
请耐心等待,请勿关闭此页面
</li>
</ol>
</div>
<div class="flex gap-space justify-end">
<PopConfirm :title="[
{text:'您的用户数据将以压缩包自动下载到您的电脑中',type:'normal'},
{text:'随后用户数据将被移除',type:'redBold'},
{text:'是否确认继续?',type:'normal'},
]">
<BaseButton type="info">放弃数据</BaseButton>
</PopConfirm>
<BaseButton>确认同步</BaseButton>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,5 +1,5 @@
import * as VueRouter from 'vue-router'
import { RouteRecordRaw } from 'vue-router'
import {RouteRecordRaw} from 'vue-router'
import WordsPage from "@/pages/word/WordsPage.vue";
import Layout from "@/pages/layout.vue";
import ArticlesPage from "@/pages/article/ArticlesPage.vue";
@@ -13,7 +13,6 @@ 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[] = [
@@ -39,7 +38,6 @@ 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

@@ -1,7 +1,7 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { getUserInfo, User } from '@/apis/user.ts'
import { AppEnv } from "@/config/env.ts";
import {defineStore} from 'pinia'
import {ref} from 'vue'
import {getUserInfo, User} from '@/apis/user.ts'
import {AppEnv} from "@/config/env.ts";
import Toast from "@/components/base/toast/Toast.ts";
export const useUserStore = defineStore('user', () => {
@@ -42,6 +42,7 @@ export const useUserStore = defineStore('user', () => {
// 获取用户信息
async function fetchUserInfo() {
if (!AppEnv.CAN_REQUEST) return false
try {
const res = await getUserInfo()
if (res.success) {
@@ -58,11 +59,9 @@ export const useUserStore = defineStore('user', () => {
// 初始化用户状态
async function init() {
if (AppEnv.CAN_REQUEST) {
const success = await fetchUserInfo()
if (!success) {
clearToken()
}
const success = await fetchUserInfo()
if (!success) {
clearToken()
}
}

View File

@@ -22,7 +22,7 @@ export interface BaseState {
dictListVersion: number
}
export const DefaultBaseState = (): BaseState => ({
export const getDefaultBaseState = (): BaseState => ({
simpleWords: [
'a', 'an',
'i', 'my', 'me', 'you', 'your', 'he', 'his', 'she', 'her', 'it',
@@ -51,7 +51,7 @@ export const DefaultBaseState = (): BaseState => ({
export const useBaseStore = defineStore('base', {
state: (): BaseState => {
return DefaultBaseState()
return getDefaultBaseState()
},
getters: {
collectWord(): Dict {

View File

@@ -1,4 +1,4 @@
import {BaseState, DefaultBaseState, useBaseStore} from "@/stores/base.ts";
import {BaseState, getDefaultBaseState, useBaseStore} from "@/stores/base.ts";
import {getDefaultSettingState, SettingState} from "@/stores/setting.ts";
import {Dict, DictId, DictResource, DictType} from "@/types/types.ts";
import {useRouter} from "vue-router";
@@ -28,7 +28,7 @@ export function checkAndUpgradeSaveDict(val: any) {
// console.log(configStr)
// console.log('s', new Blob([val]).size)
// val = ''
let defaultState = DefaultBaseState()
let defaultState = getDefaultBaseState()
if (val) {
try {
let data: any
@@ -450,3 +450,12 @@ export function resourceWrap(resource: string, version?: number) {
}
return resource;
}
// check if it is a new user
export async function isNewUser() {
let isNew = false
let base = useBaseStore()
console.log(JSON.stringify(base.$state))
console.log(JSON.stringify(getDefaultBaseState()))
return JSON.stringify(base.$state) === JSON.stringify({...getDefaultBaseState(), ...{load: true}})
}

View File

@@ -6,6 +6,7 @@ export default defineConfig({
'bg-primary': 'bg-[var(--color-primary)]',
'bg-second': 'bg-[var(--color-second)]',
'bg-third': 'bg-[var(--color-third)]',
'bg-fourth': 'bg-[var(--color-fourth)]',
'bg-card-active': 'bg-[var(--color-card-active)]',
'bg-item': 'bg-[var(--color-item-bg)]',
'bg-reverse-white': 'bg-[var(--color-reverse-white)]',