修复:开发测试问题修改

This commit is contained in:
2025-11-27 18:18:47 +08:00
parent 7062e675f6
commit 435a23f995
16 changed files with 99 additions and 1400 deletions

View File

@@ -1,394 +0,0 @@
<template>
<div class="ali-player-wrapper" :style="{ background: '#000' }">
<div v-if="showError" class="player-error">{{ errorText }}</div>
<div ref="playerContainer" class="player-container" :style="{ width: '100%', height: playerHeight }"></div>
<!-- 倒计时覆盖可选父层也可以自行实现 -->
<div v-if="showCountDown" class="countdown-overlay">
<div class="countdown-text">{{ countDownSeconds }} 秒后播放下一个视频</div>
<button class="btn-cancel" @click="cancelNext">取消下一个</button>
</div>
<!-- 控制按钮示例父层应该控制 UI我仅提供常用API按钮用于调试 -->
<div class="player-controls" style="display:none;">
<button @click="play()">播放</button>
<button @click="pause()">暂停</button>
<button @click="replay()">重播</button>
<button @click="enterFullscreen()">全屏</button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, onMounted, onBeforeUnmount, watch, PropType, nextTick } from 'vue';
type Platform = 'web' | 'app-ios' | 'app-android';
export default defineComponent({
name: 'AliyunPlayer',
props: {
// videoData: should include fields similar to original: type, m3u8Url, videoUrl, videoId, playAuth, firstTime, id...
videoData: { type: Object as PropType<Record<string, any>>, required: true },
// platform hint: affects screen lock behavior; default web
platform: { type: String as PropType<Platform>, default: 'web' },
// height for player area
height: { type: String, default: '200px' },
// auto start playback
autoplay: { type: Boolean, default: true },
// how often to auto-save progress in seconds (default 60)
autoSaveInterval: { type: Number, default: 60 },
// localStorage key prefix for resume data
storageKeyPrefix: { type: String, default: 'videoOssList' },
// flag: in APP environment should use WebView (true) or try to run player directly in page (false)
useWebViewForApp: { type: Boolean, default: false },
// urls for loading Aliplayer (allow overriding if needed)
playerScriptUrl: { type: String, default: 'https://g.alicdn.com/apsara-media-box/imp-web-player/2.20.3/aliplayer-min.js' },
playerComponentsUrl: { type: String, default: 'https://player.alicdn.com/aliplayer/presentation/js/aliplayercomponents.min.js' },
playerCssUrl: { type: String, default: 'https://g.alicdn.com/apsara-media-box/imp-web-player/2.20.3/skins/default/aliplayer-min.css' },
},
emits: [
'ready',
'play',
'pause',
'timeupdate',
'progress-save', // payload: { videoId, position }
'ended',
'error',
'request-playauth', // in case parent wants to fetch playAuth separately
'change-screen',
'load-next' // when ended and parent should load next
],
setup(props, { emit, expose }) {
const playerContainer = ref<HTMLElement | null>(null);
const playerInstance = ref<any | null>(null);
const scriptLoaded = ref(false);
const timerDiff = ref(0);
const currentSeconds = ref(0);
const pauseTime = ref(0);
const saveCounter = ref(0);
const autoSaveIntervalId = ref<number | null>(null);
const showCountDown = ref(false);
const countDownSeconds = ref(5);
const countdownTimerId = ref<number | null>(null);
const showError = ref(false);
const errorText = ref('');
const playerHeight = props.height;
// helper: localStorage save/load (simple array of {id, time})
function loadResumeList(): Array<any> {
try {
const raw = localStorage.getItem(props.storageKeyPrefix);
return raw ? JSON.parse(raw) : [];
} catch (e) {
return [];
}
}
function saveResumeItem(videoId: any, time: number) {
try {
const list = loadResumeList();
const idx = list.findIndex((i: any) => i.id === videoId);
if (idx >= 0) list[idx].time = time;
else list.push({ id: videoId, time });
localStorage.setItem(props.storageKeyPrefix, JSON.stringify(list));
} catch (e) { /* ignore */ }
}
// dynamic load aliplayer script + css
function loadAliplayer(): Promise<void> {
if ((window as any).Aliplayer) {
scriptLoaded.value = true;
return Promise.resolve();
}
return new Promise((resolve, reject) => {
// css
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = props.playerCssUrl;
document.head.appendChild(link);
// main script
const s = document.createElement('script');
s.src = props.playerScriptUrl;
s.onload = () => {
// components script
const s2 = document.createElement('script');
s2.src = props.playerComponentsUrl;
s2.onload = () => {
scriptLoaded.value = true;
resolve();
};
s2.onerror = () => reject(new Error('aliplayer components load failed'));
document.body.appendChild(s2);
};
s.onerror = () => reject(new Error('aliplayer load failed'));
document.body.appendChild(s);
});
}
// initialize player with videoData
async function initPlayer() {
showError.value = false;
if (props.useWebViewForApp && props.platform !== 'web') {
// In-app recommended to use WebView. Emit event so parent can take over.
emit('error', { message: 'App environment required WebView. Set useWebViewForApp=false to attempt in-page' });
showError.value = true;
errorText.value = 'App environment recommended to use WebView for Aliplayer';
return;
}
await loadAliplayer();
// choose options
const v = props.videoData || {};
let options: Record<string, any> = {
id: (playerContainer.value as HTMLElement).id || 'ali-player-' + Math.random().toString(36).slice(2),
width: '100%',
height: '100%',
autoplay: props.autoplay,
isLive: false,
rePlay: false,
playsinline: true,
controlBarVisibility: 'hover',
useH5Prism: true,
// skinLayout can be extended if needed
skinLayout: [
{ name: 'bigPlayButton', align: 'cc' },
{ name: 'H5Loading', align: 'cc' },
{ name: 'errorDisplay', align: 'tlabs' },
{ name: 'controlBar', align: 'blabs', children: [
{ name: 'progress', align: 'blabs' },
{ name: 'playButton', align: 'tl' },
{ name: 'timeDisplay', align: 'tl' },
{ name: 'prism-speed-selector', align: 'tr' },
{ name: 'volume', align: 'tr' }
] }
]
};
// decide source mode
if (v.type === 1) {
if (!v.m3u8Url) {
// private encrypted: require vid+playAuth
if (!v.videoId || !v.playAuth) {
// parent might need to request playAuth
emit('request-playauth', v);
showError.value = true;
errorText.value = '播放凭证缺失';
return;
}
options = {
...options,
vid: v.videoId,
playauth: v.playAuth,
encryptType: 1,
playConfig: { EncryptType: 'AliyunVoDEncryption' }
};
} else {
options = { ...options, source: v.m3u8Url };
}
} else {
// not encrypted
options = { ...options, source: v.videoUrl };
}
// add rate component by default
options.components = [
{ name: 'RateComponent', type: (window as any).AliPlayerComponent?.RateComponent }
];
// create player
try {
// ensure container has an id
if (playerContainer.value && !(playerContainer.value as HTMLElement).id) {
(playerContainer.value as HTMLElement).id = 'ali-player-' + Math.random().toString(36).slice(2);
}
const player = new (window as any).Aliplayer(options, function (p: any) {
// ready
});
playerInstance.value = player;
// event binds
player.on('ready', () => {
emit('ready');
if (props.autoplay) player.play();
});
player.on('play', () => {
emit('play');
});
player.on('pause', () => {
pauseTime.value = Math.floor(player.getCurrentTime() || 0);
emit('pause');
});
player.on('timeupdate', () => {
const t = Math.floor(player.getCurrentTime() || 0);
if (currentSeconds.value !== t) {
currentSeconds.value = t;
emit('timeupdate', { time: t, status: player.getStatus?.() });
saveCounter.value++;
// every autoSaveInterval seconds -> emit progress-save
if (saveCounter.value >= props.autoSaveInterval) {
saveCounter.value = 0;
emit('progress-save', { videoId: props.videoData.id, position: currentSeconds.value });
// also local save
saveResumeItem(props.videoData.id, currentSeconds.value);
}
}
});
player.on('ended', () => {
emit('ended', { videoId: props.videoData.id });
// default behavior: start countdown then emit load-next
startNextCountdown();
});
player.on('error', (e: any) => {
showError.value = true;
errorText.value = '播放出错';
emit('error', e);
});
// seek to resume pos if present
nextTick(() => {
const list = loadResumeList();
const idx = list.findIndex(item => item.id === props.videoData.id);
const resumeTime = idx >= 0 ? list[idx].time : (props.videoData.firstTime || 0);
const dur = player.getDuration ? Math.floor(player.getDuration() || 0) : 0;
if (resumeTime && dur && resumeTime < dur) {
player.seek(resumeTime);
} else if (resumeTime && !dur) {
// if duration unknown yet, attempt seek once canplay
player.one && player.one('canplay', () => {
const d2 = Math.floor(player.getDuration() || 0);
if (resumeTime < d2) player.seek(resumeTime);
});
}
});
// periodic autosave fallback (in case events miss)
if (autoSaveIntervalId.value) window.clearInterval(autoSaveIntervalId.value);
autoSaveIntervalId.value = window.setInterval(() => {
if (currentSeconds.value > 0) {
emit('progress-save', { videoId: props.videoData.id, position: currentSeconds.value });
saveResumeItem(props.videoData.id, currentSeconds.value);
}
}, props.autoSaveInterval * 1000);
} catch (err) {
showError.value = true;
errorText.value = '播放器初始化失败';
emit('error', err);
}
}
// start next countdown
function startNextCountdown(seconds = 5) {
showCountDown.value = true;
countDownSeconds.value = seconds;
if (countdownTimerId.value) window.clearInterval(countdownTimerId.value);
countdownTimerId.value = window.setInterval(() => {
countDownSeconds.value -= 1;
if (countDownSeconds.value <= 0) {
// trigger parent to load next
window.clearInterval(countdownTimerId.value!);
showCountDown.value = false;
emit('load-next', { videoId: props.videoData.id });
}
}, 1000);
}
function cancelNext() {
showCountDown.value = false;
if (countdownTimerId.value) window.clearInterval(countdownTimerId.value);
emit('change-screen', { action: 'cancel-next' });
}
// control API exposed
function play() {
playerInstance.value && playerInstance.value.play && playerInstance.value.play();
}
function pause() {
playerInstance.value && playerInstance.value.pause && playerInstance.value.pause();
}
function seek(sec: number) {
playerInstance.value && playerInstance.value.seek && playerInstance.value.seek(sec);
}
function replay() {
if (playerInstance.value) {
playerInstance.value.seek(0);
playerInstance.value.play();
}
}
function enterFullscreen() {
if (!playerInstance.value) return;
const status = playerInstance.value.fullscreenService.getIsFullScreen && playerInstance.value.fullscreenService.getIsFullScreen();
if (status) {
playerInstance.value.fullscreenService.cancelFullScreen && playerInstance.value.fullscreenService.cancelFullScreen();
emit('change-screen', { status: false });
// example: lock portrait if in app (needs plus.* or native)
} else {
playerInstance.value.fullscreenService.requestFullScreen && playerInstance.value.fullscreenService.requestFullScreen();
emit('change-screen', { status: true });
}
}
// watch videoData changes
watch(() => props.videoData, async (nv) => {
// dispose old player
if (playerInstance.value && playerInstance.value.dispose) {
try { playerInstance.value.dispose(); } catch (e) { console.warn(e); }
playerInstance.value = null;
}
// reset states
currentSeconds.value = 0;
pauseTime.value = 0;
showError.value = false;
// init new
await initPlayer();
}, { immediate: true, deep: true });
onMounted(() => {
// ensure container has unique id for Aliplayer
if (playerContainer.value && !(playerContainer.value as HTMLElement).id) {
(playerContainer.value as HTMLElement).id = 'ali-player-' + Math.random().toString(36).slice(2);
}
});
onBeforeUnmount(() => {
if (playerInstance.value && playerInstance.value.dispose) {
try { playerInstance.value.dispose(); } catch (e) { /* ignore */ }
}
if (autoSaveIntervalId.value) window.clearInterval(autoSaveIntervalId.value);
if (countdownTimerId.value) window.clearInterval(countdownTimerId.value);
});
// expose methods to parent via ref
expose({
play, pause, seek, replay, startNextCountdown
});
return {
playerContainer,
playerHeight,
play, pause, seek, replay,
showCountDown, countDownSeconds, cancelNext,
showError, errorText
};
}
});
</script>
<style scoped>
.ali-player-wrapper { position: relative; width: 100%; }
.player-container { background: #000; }
.countdown-overlay {
position: absolute; top: 0; right: 10px; z-index: 50;
background: rgba(0,0,0,0.6); color: #fff; padding: 10px; border-radius: 6px;
}
.btn-cancel { margin-top: 8px; background: #fff; color: #000; border: none; padding:6px 12px; border-radius:4px; }
.player-error { color: #fff; text-align:center; padding: 20px; }
</style>

View File

@@ -143,9 +143,9 @@ interface Props {
const props = withDefaults(defineProps<Props>(), {
goodsList: () => [],
userInfo: () => ({}),
allowPointPay: true,
orderType: 'order',
backStep: 1
allowPointPay: () => false,
orderType: () => '',
backStep: () => 1
})
// 订单备注

View File

@@ -18,7 +18,8 @@
"loginExpired": "Login expired. Please log in again.",
"requestException": "Request exception",
"coin": "Coin",
"days": "Days"
"days": "Days",
"and": "and"
},
"tabar.course": "COURSE",
"tabar.book": "EBOOK",
@@ -79,7 +80,7 @@
"getCode": "Get Code",
"passwordStrengthStrong": "Strong password strength.",
"passwordStrengthMedium": "Medium password strength.",
"passwordStrengthWeak": "please use a password consisting of at least two types: uppercase and lowercase letters, numbers, and symbols, with a length of 8 characters.",
"passwordStrengthWeak": "please use a password consisting of at least two types: uppercase and lowercase letters, numbers, and symbols, with a length of 8-20 characters.",
"passwordChanged": "Password changed successfully"
},
"common": {
@@ -391,7 +392,9 @@
"courseInfo": "Course",
"chapterInfo": "Chapter",
"videoLoadFailed": "Video load failed",
"vipBenefit": "VIP benefit active"
"vipBenefit": "VIP benefit active",
"audio": "Audio",
"video": "Video"
},
"courseOrder": {
"orderTitle": "Order Confirmation",

View File

@@ -18,7 +18,8 @@
"loginExpired": "登录失效,请重新登录。",
"requestException": "请求异常",
"coin": "天医币",
"days": "天"
"days": "天",
"and": "和"
},
"tabar.course": "课程",
"tabar.book": "图书",
@@ -80,7 +81,7 @@
"getCode": "获取验证码",
"passwordStrengthStrong": "密码强度强",
"passwordStrengthMedium": "密码强度中等",
"passwordStrengthWeak": "请使用至少包含大小写字母、数字、符号中的两种类型长度为8个字符的密码",
"passwordStrengthWeak": "请使用至少包含大小写字母、数字、符号中的两种类型长度为8-20个字符的密码",
"passwordChanged": "密码修改成功"
},
"common": {
@@ -391,7 +392,9 @@
"courseInfo": "课程",
"chapterInfo": "章节",
"videoLoadFailed": "视频加载失败",
"vipBenefit": "VIP畅学权益生效中"
"vipBenefit": "VIP畅学权益生效中",
"audio": "音频",
"video": "视频"
},
"courseOrder": {
"orderTitle": "确认订单",
@@ -455,7 +458,7 @@
"customerService": "客服",
"paymentTipTitle": "说明",
"paymentTip1": "1. 1积分=1天医币",
"paymentTip2-1": "2. 若有疑问请加客服微信:{ customerServiceWechat } { customerServiceEmail }",
"paymentTip2-1": "2. 若有疑问请加客服微信:",
"paymentTip2-2": "或邮箱联系",
"ensureBalance": "确保您的天医币足够支付",
"vipLabel": "VIP优惠",

View File

@@ -1,7 +1,7 @@
{
"name" : "太湖国际",
"name" : "吴门国际",
"appid" : "__UNI__1250B39",
"description" : "太湖国际",
"description" : "吴门国际",
"versionName" : "1.0.4",
"versionCode" : 104,
"transformPx" : false,

View File

@@ -127,12 +127,6 @@
"navigationStyle": "custom",
"navigationBarTitleText": "%listen.title%"
}
}, {
"path": "pages/book/order",
"style": {
"navigationStyle": "custom",
"navigationBarTitleText": "%order.orderTitle%"
}
}, {
"path": "pages/course/search",
"style": {

View File

@@ -1,721 +0,0 @@
<template>
<view class="order-page">
<!-- 导航栏 -->
<nav-bar :title="$t('bookOrder.orderTitle')"></nav-bar>
<!-- 图书信息区域 -->
<view class="order-block">
<view class="order-info">
<image :src="bookInfo.images" class="order-img" mode="aspectFill"></image>
<text class="order-name">{{ bookInfo.name }}</text>
</view>
<view class="order-price">
<text class="order-title">{{ $t('bookOrder.amount') }}</text>
<view v-if="paymentMethod === '5'" class="price-display">
<image src="/static/icon/currency.png" class="coin-img"></image>
<text class="coin-text">{{ displayPrice }} NZD</text>
</view>
<view v-if="paymentMethod === '4'" class="price-display">
<image src="/static/icon/coin.png" class="coin-img"></image>
<text class="coin-text">{{ displayPrice }}</text>
</view>
</view>
</view>
<!-- 支付方式选择区域 -->
<view class="order-type">
<text class="order-title">{{ $t('bookOrder.paymentMethod') }}</text>
<radio-group @change="handlePaymentChange" class="radio-group">
<label
v-for="(item, index) in paymentOptions"
:key="index"
class="type-label"
>
<view class="type-view">
<radio
:value="item.value"
:checked="paymentMethod === item.value"
color="#54a966"
></radio>
<text>
{{ item.name }}
<text v-if="item.value === '4'" class="balance-text">
{{ $t('bookOrder.balance') }}{{ userCoinBalance }}
</text>
</text>
</view>
</label>
</radio-group>
</view>
<!-- 底部确认按钮 -->
<view class="order-btn">
<view class="btn-spacer"></view>
<button
class="confirm-btn"
:disabled="isSubmitting"
@click="handleConfirmOrder"
>
{{ $t('bookOrder.confirm') }}
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import { storeToRefs } from 'pinia'
import { orderApi } from '@/api/modules/order'
import { bookApi } from '@/api/modules/book'
import type { IBookDetail } from '@/types/book'
import type { IPaymentOption } from '@/types/order'
const { t } = useI18n()
const userStore = useUserStore()
const { userInfo } = storeToRefs(userStore)
// 页面参数
const bookId = ref(0)
// 图书信息
const bookInfo = ref<IBookDetail>({
id: 0,
name: '',
images: '',
author: { authorName: '', introduction: '' },
priceData: { dictType: '', dictValue: '' },
abroadPrice: 0,
isBuy: false,
freeChapterCount: 0
})
// 支付相关
const paymentMethod = ref<'4' | '5'>('5') // 默认 Google Pay
const paymentOptions = ref<IPaymentOption[]>([])
const userCoinBalance = ref(0)
const orderSn = ref('')
const isSubmitting = ref(false)
// Google Pay 相关
const googlePayConnected = ref(false)
let googlePayPlugin: any = null
// 计算显示价格
const displayPrice = computed(() => {
if (paymentMethod.value === '4') {
// 虚拟币价格
return (bookInfo.value.abroadPrice || 0) * 10
} else {
// Google Pay 价格
return bookInfo.value.priceData?.dictValue || '0'
}
})
/**
* 页面加载
*/
onLoad((options: any) => {
if (options.id) {
bookId.value = Number(options.id)
}
// 初始化支付方式
initPaymentOptions()
// 初始化 Google Pay
initGooglePay()
// 加载图书信息
loadBookInfo()
})
/**
* 页面显示
*/
onShow(() => {
// 刷新用户信息
loadUserInfo()
})
/**
* 页面卸载
*/
onUnload(() => {
// 清理资源
uni.hideLoading()
})
/**
* 初始化支付方式列表
*/
function initPaymentOptions() {
const platform = uni.getSystemInfoSync().platform
if (platform === 'android') {
// Android 平台显示 Google Pay
paymentOptions.value = [
{ value: '5', name: t('bookOrder.googlePay') }
]
paymentMethod.value = '5'
} else {
// iOS 平台暂不支持支付
paymentOptions.value = []
paymentMethod.value = '5'
}
}
/**
* 初始化 Google Pay 插件
*/
function initGooglePay() {
// #ifdef APP-PLUS
try {
googlePayPlugin = uni.requireNativePlugin('sn-googlepay5')
if (googlePayPlugin) {
googlePayPlugin.init({}, (result: any) => {
console.log('[Google Pay] Init result:', result)
if (result.code === 0) {
googlePayConnected.value = true
console.log('[Google Pay] Connected successfully')
} else {
googlePayConnected.value = false
console.log('[Google Pay] Connection failed')
}
})
}
} catch (error) {
console.error('[Google Pay] Init error:', error)
googlePayConnected.value = false
}
// #endif
}
/**
* 加载图书信息
*/
async function loadBookInfo() {
try {
uni.showLoading({ title: t('common.loading') })
const res = await bookApi.getBookInfo(bookId.value)
uni.hideLoading()
if (res.bookInfo) {
bookInfo.value = res.bookInfo
}
} catch (error) {
uni.hideLoading()
console.error('[Order] Load book info error:', error)
uni.showToast({
title: t('common.networkError'),
icon: 'none'
})
}
}
/**
* 加载用户信息
*/
async function loadUserInfo() {
if (!userInfo.value.id) return
try {
const res = await orderApi.getUserInfo()
if (res.result) {
userCoinBalance.value = res.result.peanutCoin || 0
}
} catch (error) {
console.error('[Order] Load user info error:', error)
}
}
/**
* 切换支付方式
*/
function handlePaymentChange(e: any) {
paymentMethod.value = e.detail.value
}
/**
* 确认下单
*/
async function handleConfirmOrder() {
// 防止重复提交
if (isSubmitting.value) {
uni.showToast({
title: t('bookOrder.doNotRepeat'),
icon: 'none'
})
return
}
// 验证订单
if (!validateOrder()) {
return
}
isSubmitting.value = true
try {
// 创建订单
await createOrder()
} catch (error) {
isSubmitting.value = false
console.error('[Order] Confirm order error:', error)
}
}
/**
* 验证订单
*/
function validateOrder(): boolean {
// 检查是否登录
if (!userInfo.value.id) {
uni.showToast({
title: t('login.agreeFirst'),
icon: 'none'
})
return false
}
// 虚拟币支付时检查余额
if (paymentMethod.value === '4') {
const coinPrice = (bookInfo.value.abroadPrice || 0) * 10
if (userCoinBalance.value < coinPrice) {
uni.showToast({
title: t('bookOrder.insufficientBalance'),
icon: 'none'
})
return false
}
}
return true
}
/**
* 创建订单
*/
async function createOrder() {
try {
uni.showLoading({ title: t('bookOrder.creating') })
const res = await orderApi.createOrder({
paymentMethod: paymentMethod.value,
orderMoney: displayPrice.value,
abroadBookId: bookId.value,
orderType: 'abroadBook'
})
uni.hideLoading()
if (res.code === 0 && res.orderSn) {
orderSn.value = res.orderSn
// 根据支付方式执行相应流程
if (paymentMethod.value === '4') {
// 虚拟币支付
await processVirtualCoinPayment()
} else if (paymentMethod.value === '5') {
// Google Pay 支付
await processGooglePayment()
}
} else {
throw new Error(res.msg || t('bookOrder.orderCreateFailed'))
}
} catch (error: any) {
uni.hideLoading()
isSubmitting.value = false
uni.showToast({
title: error.message || t('bookOrder.orderCreateFailed'),
icon: 'none'
})
}
}
/**
* 处理虚拟币支付
*/
async function processVirtualCoinPayment() {
try {
// 虚拟币支付在订单创建时已完成
// 刷新用户信息
await refreshUserInfo()
// 显示支付成功
uni.showToast({
title: t('bookOrder.paymentSuccess'),
icon: 'success'
})
// 延迟跳转
setTimeout(() => {
navigateToBookDetail()
}, 1000)
} catch (error) {
isSubmitting.value = false
console.error('[Order] Virtual coin payment error:', error)
uni.showToast({
title: t('bookOrder.paymentFailed'),
icon: 'none'
})
}
}
/**
* 处理 Google Pay 支付
*/
async function processGooglePayment() {
try {
// 检查 Google Pay 连接状态
if (!googlePayConnected.value) {
throw new Error(t('bookOrder.googlePayNotAvailable'))
}
uni.showLoading({ title: t('bookOrder.processing') })
// 1. 查询商品 SKU
const skuList = await queryGooglePaySku()
if (skuList.length === 0) {
throw new Error(t('bookOrder.productNotFound'))
}
// 2. 调起 Google Pay 支付
const paymentResult = await initiateGooglePayment()
// 3. 消费购买凭证
await consumeGooglePayPurchase(paymentResult.purchaseToken)
// 4. 后端验证支付
await verifyGooglePayment(paymentResult)
// 5. 支付成功处理
await handlePaymentSuccess()
} catch (error: any) {
uni.hideLoading()
isSubmitting.value = false
console.error('[Order] Google Pay payment error:', error)
uni.showToast({
title: error.message || t('bookOrder.paymentFailed'),
icon: 'none',
duration: 2000
})
}
}
/**
* 查询 Google Pay SKU
*/
function queryGooglePaySku(): Promise<any[]> {
return new Promise((resolve, reject) => {
if (!googlePayPlugin) {
reject(new Error(t('bookOrder.googlePayNotAvailable')))
return
}
const productId = bookInfo.value.priceData?.dictType
if (!productId) {
reject(new Error(t('bookOrder.productNotFound')))
return
}
googlePayPlugin.querySku(
{ inapp: [productId] },
(result: any) => {
console.log('[Google Pay] Query SKU result:', result)
if (result.code === 0 && result.list && result.list.length > 0) {
resolve(result.list)
} else {
reject(new Error(t('bookOrder.productNotFound')))
}
}
)
})
}
/**
* 调起 Google Pay 支付
*/
function initiateGooglePayment(): Promise<{ purchaseToken: string; productId: string }> {
return new Promise((resolve, reject) => {
if (!googlePayPlugin) {
reject(new Error(t('bookOrder.googlePayNotAvailable')))
return
}
const productId = bookInfo.value.priceData?.dictType
googlePayPlugin.payAll(
{
accountId: orderSn.value,
productId: productId
},
(result: any) => {
console.log('[Google Pay] Payment result:', result)
if (result.code === 0 && result.data && result.data.length > 0) {
const purchaseToken = result.data[0].original.purchaseToken
resolve({ purchaseToken, productId })
} else {
// 支付失败或取消
reject(new Error(t('bookOrder.paymentCancelled')))
}
}
)
})
}
/**
* 消费 Google Pay 购买凭证
*/
function consumeGooglePayPurchase(purchaseToken: string): Promise<void> {
return new Promise((resolve, reject) => {
if (!googlePayPlugin) {
reject(new Error(t('bookOrder.googlePayNotAvailable')))
return
}
googlePayPlugin.consume(
{ purchaseToken },
(result: any) => {
console.log('[Google Pay] Consume result:', result)
if (result.code === 0) {
resolve()
} else {
reject(new Error(t('bookOrder.verificationFailed')))
}
}
)
})
}
/**
* 验证 Google Pay 支付
*/
async function verifyGooglePayment(paymentResult: { purchaseToken: string; productId: string }) {
try {
const res = await orderApi.verifyGooglePay({
purchaseToken: paymentResult.purchaseToken,
orderSn: orderSn.value,
productId: paymentResult.productId
})
if (res.code !== 0) {
throw new Error(res.msg || t('bookOrder.verificationFailed'))
}
} catch (error: any) {
throw new Error(error.message || t('bookOrder.verificationFailed'))
}
}
/**
* 支付成功处理
*/
async function handlePaymentSuccess() {
try {
// 刷新用户信息
await refreshUserInfo()
uni.hideLoading()
// 显示支付成功
uni.showToast({
title: t('bookOrder.paymentSuccess'),
icon: 'success'
})
// 延迟跳转
setTimeout(() => {
navigateToBookDetail()
}, 1000)
} catch (error) {
console.error('[Order] Payment success handler error:', error)
// 即使刷新用户信息失败,也跳转到详情页
setTimeout(() => {
navigateToBookDetail()
}, 1000)
}
}
/**
* 刷新用户信息
*/
async function refreshUserInfo() {
if (!userInfo.value.id) return
try {
const res = await orderApi.refreshUserInfo(userInfo.value.id)
if (res.code === 0 && res.user) {
userStore.setUserInfo(res.user)
}
} catch (error) {
console.error('[Order] Refresh user info error:', error)
}
}
/**
* 跳转到图书详情页
*/
function navigateToBookDetail() {
uni.navigateTo({
url: `/pages/book/detail?id=${bookId.value}&page=order`
})
}
</script>
<style lang="scss" scoped>
.order-page {
min-height: 100vh;
background: #f7faf9;
}
.order-block {
margin: 20rpx;
padding: 30rpx;
background: #fff;
border-radius: 15rpx;
}
.order-info {
margin-top: 20rpx;
display: flex;
align-items: center;
.order-img {
width: 180rpx;
height: 240rpx;
border-radius: 10rpx;
flex-shrink: 0;
}
.order-name {
flex: 1;
color: #333;
font-size: 36rpx;
padding-left: 40rpx;
line-height: 44rpx;
max-height: 88rpx;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
}
.order-price {
padding-top: 40rpx;
display: flex;
align-items: center;
justify-content: space-between;
.price-display {
display: flex;
align-items: center;
}
.coin-img {
width: 40rpx;
height: 40rpx;
margin-right: 10rpx;
}
.coin-text {
font-size: 38rpx;
color: #ff4703;
font-weight: bold;
}
}
.order-type {
background: #fff;
border-radius: 15rpx;
margin: 20rpx;
padding: 30rpx;
.radio-group {
margin-top: 30rpx;
padding-bottom: 10rpx;
}
.type-label {
display: flex;
align-items: center;
margin-top: 10rpx;
.type-view {
width: 100%;
display: flex;
align-items: center;
line-height: 30rpx;
radio {
transform: scale(0.95);
}
text {
font-size: 30rpx;
padding-left: 5rpx;
color: #333;
.balance-text {
padding-left: 10rpx;
color: #54a966;
font-weight: normal;
}
}
}
}
}
.order-title {
font-size: 34rpx;
line-height: 50rpx;
padding-bottom: 10rpx;
color: #333;
}
.order-btn {
width: 100%;
height: 110rpx;
background: #fff;
position: fixed;
bottom: 0;
left: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 20rpx;
box-shadow: 0 -2rpx 10rpx rgba(0, 0, 0, 0.05);
.btn-spacer {
flex: 1;
}
.confirm-btn {
margin: 20rpx 0;
padding: 0 35rpx;
height: 70rpx;
line-height: 70rpx;
background-color: #ff4703;
color: #fff;
font-size: 30rpx;
border-radius: 10rpx;
border: none;
&[disabled] {
opacity: 0.6;
}
}
}
</style>

View File

@@ -4,7 +4,7 @@
<nav-bar :title="$t('courseDetails.chapter')" />
<!-- 页面内容 -->
<view class="page-content" :style="{ height: contentHeight }">
<view class="page-content">
<!-- 视频播放器 -->
<view v-if="videoList.length > 0" class="video-section">
<VideoPlayer
@@ -13,19 +13,13 @@
:video-list="videoList"
:countdown-seconds="5"
/>
<!-- <AliyunPlayer
ref="videoPlayerRef"
:currentVideo="videoList[currentVideoIndex]"
:currentVideoList="videoList"
@unlockChangeVideo="changeVideoLock = false"
/> -->
</view>
<!-- 课程和章节信息 -->
<view class="info-section">
<view class="info-item">
<text class="label">{{ $t('courseDetails.courseInfo') }}</text>
<text class="value">{{ navTitle }}</text>
<text class="value">{{ courseTitle }}</text>
</view>
<view class="info-item">
<text class="label">{{ $t('courseDetails.chapterInfo') }}</text>
@@ -36,126 +30,74 @@
<!-- 视频列表 -->
<view v-if="videoList.length > 0" class="video-list-section">
<view class="section-title">{{ $t('courseDetails.videoTeaching') }}</view>
<view class="video-list">
<view
v-for="(video, index) in videoList"
:key="video.id"
:class="['video-item', currentVideoIndex === index ? 'active' : '']"
@click="selectVideo(index)"
>
<view class="video-info">
<text class="video-title">{{ video.type == "2" ? "音频" : "视频" }}{{ index + 1 }}</text>
</view>
</view>
</view>
<wd-radio-group v-model="currentVideoIndex" shape="button" >
<wd-radio v-for="(video, index) in videoList" :key="video.id" :value="index">
{{ video.type == "2" ? $t('courseDetails.audio') : $t('courseDetails.video') }}{{ index + 1 }}
</wd-radio>
</wd-radio-group>
</view>
<!-- 选项卡 -->
<view v-if="tabList.length > 0" class="tabs-section">
<view class="tabs">
<view
v-for="(tab, index) in tabList"
:key="tab.id"
:class="['tab-item', currentTab === index ? 'active' : '']"
@click="switchTab(index)"
>
<text>{{ tab.name }}</text>
</view>
</view>
</view>
<!-- 选项卡内容 -->
<view class="tab-content">
<wd-tabs v-model="currentTab" class="tabs-section" lineWidth="30">
<!-- 章节介绍 -->
<view v-show="currentTab === 0" class="intro-content">
<view class="section-title">{{ $t('courseDetails.chapterIntro') }}</view>
<view class="intro-wrapper">
<!-- 章节封面 -->
<image
v-if="chapterDetail?.imgUrl"
:src="chapterDetail.imgUrl"
mode="widthFix"
class="chapter-image"
@click="previewImage(chapterDetail.imgUrl)"
/>
<!-- 章节内容 -->
<view v-if="chapterDetail?.content" class="chapter-content" v-html="chapterDetail.content"></view>
</view>
<wd-tab name="chapterIntro" :title="$t('courseDetails.chapterIntro')">
<!-- 章节封面 -->
<image
v-if="chapterDetail?.imgUrl"
:src="chapterDetail.imgUrl"
mode="widthFix"
class="chapter-image"
@click="previewImage(chapterDetail.imgUrl)"
/>
<!-- 章节内容 -->
<view v-if="chapterDetail?.content" v-html="chapterDetail.content"></view>
<view class="copyright">
<text>{{ $t('courseDetails.copyright') }}</text>
</view>
</view>
</wd-tab>
<!-- 思考题 -->
<view v-show="currentTab === 1" class="question-content">
<view class="section-title">{{ $t('courseDetails.thinkingQuestion') }}</view>
<view v-if="chapterDetail?.questions" class="question-wrapper">
<view class="question-html" v-html="chapterDetail.questions"></view>
</view>
<view v-else class="no-question">
<wd-tab v-if="chapterDetail?.questions" name="thinkingQuestion" :title="$t('courseDetails.thinkingQuestion')">
<view v-html="chapterDetail.questions"></view>
<!-- <view v-else class="no-question">
<wd-divider>{{ $t('courseDetails.noQuestion') }}</wd-divider>
</view>
</view>
</view>
</view> -->
</wd-tab>
</wd-tabs>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad, onShow, onHide } from '@dcloudio/uni-app'
import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { courseApi } from '@/api/modules/course'
import VideoPlayer from '@/components/video-player/index.vue'
import type { IChapterDetail, IVideo } from '@/types/course'
// 页面参数
const chapterId = ref<number>(0)
const courseId = ref<number>(0)
const navTitle = ref('')
const courseTitle = ref('')
const chapterTitle = ref('')
const noRecored = ref(false)
// 页面数据
const chapterDetail = ref<IChapterDetail | null>(null)
const videoList = ref<IVideo[]>([])
const currentVideoIndex = ref(0)
const activeVideoIndex = ref(0)
const currentTab = ref(0)
const isFullScreen = ref(false)
const currentTab = ref('chapterIntro')
// 视频播放器引用
const videoPlayerRef = ref<any>(null)
// 选项卡列表
const tabList = computed(() => {
const tabs = [
{ id: '0', name: '章节介绍' }
]
// 如果有思考题,添加思考题选项卡
if (chapterDetail.value?.questions) {
tabs.push({ id: '1', name: '思考题' })
}
return tabs
})
// 内容高度(全屏时调整)
const contentHeight = computed(() => {
return isFullScreen.value ? '100vh' : 'auto'
})
/**
* 页面加载
*/
onLoad((options: any) => {
chapterId.value = parseInt(options.id)
courseId.value = parseInt(options.courseId)
navTitle.value = options.navTitle || ''
courseTitle.value = options.courseTitle || ''
chapterTitle.value = options.title || ''
noRecored.value = options.noRecored === 'true'
loadChapterDetail()
})
@@ -171,7 +113,7 @@ const loadChapterDetail = async () => {
// 如果有历史播放记录,定位到对应视频
if (res.data.current) {
const index = videoList.value.findIndex(v => v.id === res.data.current)
const index = videoList.value.findIndex((v:any) => v.id === res.data.current)
if (index !== -1) {
currentVideoIndex.value = index
activeVideoIndex.value = index
@@ -188,13 +130,6 @@ const selectVideo = async (index: number) => {
currentVideoIndex.value = index
}
/**
* 切换选项卡
*/
const switchTab = (index: number) => {
currentTab.value = index
}
/**
* 预览图片
*/
@@ -212,10 +147,6 @@ const previewImage = (url: string) => {
background-color: #f5f5f5;
}
.page-content {
padding-bottom: 100rpx;
}
.video-section {
background-color: #000;
}
@@ -254,135 +185,45 @@ const previewImage = (url: string) => {
color: #2979ff;
margin-bottom: 20rpx;
}
.video-list {
display: flex;
flex-wrap: wrap;
justify-content: flex-start;
align-items: center;
gap: 10rpx;
.video-item {
padding: 18rpx;
margin-bottom: 10rpx;
background-color: #f7f8f9;
border-radius: 8rpx;
border: 2rpx solid transparent;
transition: all 0.3s;
&.active {
background-color: #e8f4ff;
border-color: #258feb;
}
.video-info {
display: flex;
justify-content: space-between;
align-items: center;
.video-title {
flex: 1;
font-size: 28rpx;
color: #333;
}
}
}
}
}
.tabs-section {
background-color: #fff;
margin-top: 20rpx;
border-bottom: 2rpx solid #2979ff;
.tabs {
display: flex;
.tab-item {
flex: 1;
text-align: center;
padding: 25rpx 0;
font-size: 30rpx;
color: #666;
position: relative;
transition: all 0.3s;
&.active {
color: #2979ff;
font-weight: 500;
&::after {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 60rpx;
height: 4rpx;
background-color: #2979ff;
border-radius: 2rpx;
}
}
}
:deep(.wd-tabs__nav) {
border-bottom: 1px solid #2979ff;
}
:deep(.wd-tab__body) {
padding: 30rpx;
font-size: 28rpx;
line-height: 1.8;
color: #666;
word-break: break-all;
}
:deep(.wd-tabs__line) {
bottom: 0;
}
}
.tab-content {
background-color: #fff;
padding: 20rpx;
.section-title {
font-size: 32rpx;
font-weight: 500;
color: #333;
margin-bottom: 20rpx;
}
.intro-content {
.intro-wrapper {
.chapter-image {
width: 100%;
display: block;
margin-bottom: 20rpx;
border-radius: 8rpx;
}
.chapter-content {
font-size: 28rpx;
line-height: 1.8;
color: #666;
text-align: justify;
word-break: break-all;
}
}
.copyright {
margin-top: 40rpx;
padding-top: 20rpx;
border-top: 1px solid #f0f0f0;
text-align: center;
text {
font-size: 24rpx;
color: #ff4444;
}
}
}
.question-content {
.question-wrapper {
.question-html {
font-size: 28rpx;
line-height: 1.8;
color: #666;
word-break: break-all;
}
}
.no-question {
padding: 80rpx 0;
text-align: center;
}
}
.chapter-image {
width: 100%;
display: block;
margin-bottom: 20rpx;
border-radius: 8rpx;
}
.copyright {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1px solid #f0f0f0;
text-align: center;
font-size: 24rpx;
color: #ff4444;
}
.no-question {
padding: 80rpx 0;
text-align: center;
}
</style>

View File

@@ -339,7 +339,7 @@ const handleChapterClick = (chapter: IChapter) => {
const noRecored = chapter.isAudition === 1 && currentCatalogue.value?.isBuy === 0 && !userVip.value
uni.navigateTo({
url: `/pages/course/details/chapter?id=${chapter.id}&courseId=${courseId.value}&navTitle=${courseDetail.value?.title}&title=${chapter.title}&noRecored=${noRecored}`
url: `/pages/course/details/chapter?id=${chapter.id}&courseId=${courseId.value}&courseTitle=${courseDetail.value?.title}&title=${chapter.title}&noRecored=${noRecored}`
})
}

View File

@@ -1,6 +1,6 @@
<template>
<view class="page">
<view class="title">{{ $t('forget.title') }}</view>
<view class="title" :style="{ 'margin-top': getNotchHeight() + 'px' }">{{ $t('forget.title') }}</view>
<!-- 邮箱输入 -->
<view class="input-box">
@@ -53,7 +53,8 @@
<text class="input-tit">{{ $t('forget.passwordAgain') }}</text>
<input
class="input-text"
type="password"
type="password"
minlength="8"
maxlength="20"
v-model="confirmPassword"
:placeholder="$t('forget.passwordAgainPlaceholder')"
@@ -73,6 +74,7 @@ import { useI18n } from 'vue-i18n'
import { commonApi } from '@/api/modules/common'
import { resetPassword } from '@/api/modules/auth'
import { validateEmail, checkPasswordStrength } from '@/utils/validator'
import { getNotchHeight } from '@/utils/system'
const { t } = useI18n()

View File

@@ -2,7 +2,7 @@
<view class="login-page">
<!-- Logo 背景区域 -->
<view class="logo-bg">
<text class="welcome-text">Hello! Welcome to<br>Amazing Limited</text>
<text class="welcome-text">Hello! Welcome to<br>太湖国际</text>
<image src="@/static/icon/login_icon.png" mode="aspectFit" class="icon-hua-1"></image>
<image src="@/static/icon/login_icon.png" mode="aspectFit" class="icon-hua-2"></image>
</view>
@@ -87,7 +87,7 @@
<view class="protocol-text">
{{ $t('login.agree') }}
<text class="highlight" @click="yhxy">{{ $t('login.userAgreement') }}</text>
and
{{ $t('global.and') }}
<text class="highlight" @click="yszc">{{ $t('login.privacyPolicy') }}</text>
</view>
</view>
@@ -424,11 +424,11 @@ const getAgreements = async (id: number) => {
}
const loadAgreements = async () => {
// 获取用户协议
const yhxyRes = await getAgreements(111)
const yhxyRes = await getAgreements(116)
yhxyText.value = yhxyRes
// 获取隐私政策
const yszcRes = await getAgreements(112)
const yszcRes = await getAgreements(117)
yszcText.value = yszcRes
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 208 KiB

View File

@@ -9,7 +9,6 @@
"Courier New", monospace;
--color-red-500: oklch(63.7% 0.237 25.331);
--spacing: 0.25rem;
--font-weight-bold: 700;
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--default-transition-duration: 150ms;
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
@@ -205,18 +204,9 @@
max-width: 96rem;
}
}
.mr-1 {
margin-right: calc(var(--spacing) * 1);
}
.ml-1 {
margin-left: calc(var(--spacing) * 1);
}
.ml-1\! {
margin-left: calc(var(--spacing) * 1) !important;
}
.ml-2 {
margin-left: calc(var(--spacing) * 2);
}
.ml-2\.5\! {
margin-left: calc(var(--spacing) * 2.5) !important;
}
@@ -253,9 +243,6 @@
.flex-shrink {
flex-shrink: 1;
}
.border-collapse {
border-collapse: collapse;
}
.transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
}
@@ -284,12 +271,6 @@
.pt-10 {
padding-top: calc(var(--spacing) * 10);
}
.pt-\[40px\] {
padding-top: 40px;
}
.pb-0 {
padding-bottom: calc(var(--spacing) * 0);
}
.pb-0\! {
padding-bottom: calc(var(--spacing) * 0) !important;
}
@@ -299,10 +280,6 @@
.text-right {
text-align: right;
}
.font-bold {
--tw-font-weight: var(--font-weight-bold);
font-weight: var(--font-weight-bold);
}
.text-\[\#000\] {
color: #000;
}
@@ -325,9 +302,6 @@
--tw-ordinal: ordinal;
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
}
.underline {
text-decoration-line: underline;
}
.ring {
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
@@ -397,10 +371,6 @@
inherits: false;
initial-value: solid;
}
@property --tw-font-weight {
syntax: "*";
inherits: false;
}
@property --tw-ordinal {
syntax: "*";
inherits: false;
@@ -593,7 +563,6 @@
--tw-skew-x: initial;
--tw-skew-y: initial;
--tw-border-style: solid;
--tw-font-weight: initial;
--tw-ordinal: initial;
--tw-slashed-zero: initial;
--tw-numeric-figure: initial;

View File

@@ -114,8 +114,13 @@ uni-textarea {
.wd-overlay {
z-index: 9998 !important;
}
.wd-popup-wrapper .wd-popup {
border-radius: 15px 15px 0 0 !important;
.wd-popup-wrapper {
.wd-popup {
border-radius: 15px !important;
}
.wd-popup--bottom {
border-radius: 15px 15px 0 0 !important;
}
}
// uni-ui form

View File

@@ -2,7 +2,6 @@ export default function() {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
plus.runtime.getProperty(plus.runtime.appid, function(widgetInfo) {
console.log('哈哈哈哈', widgetInfo)
let data = {
action: 'checkVersion',
appid: plus.runtime.appid,
@@ -13,7 +12,6 @@ export default function() {
name: 'uni-upgrade-center',
data,
success: (e) => {
console.log("e: ", e);
resolve(e)
},
fail: (error) => {

View File

@@ -7,7 +7,6 @@ export default function() {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
callCheckVersion().then(async (e) => {
console.log('hhhhhhhhhhhh', e)
if (!e.result) return;
const {
code,