大修改

This commit is contained in:
Yeomai committed 2026-09-30 03:38:10 +08:00
1 parent a1cbc9404a
commit e81481623c
47 files changed
+1453 -285

No files matched your search

+10 -3
View File
@@ -15,7 +15,7 @@ App({
/**
* 启动流程:配置再静默登录,最后拉已购课程。
*
* 每一步失败都不阻断启动:配置缺失时用兜底文案,登录失败时按未登录渲染。
* 每一步失败都不阻断启动:配置缺失时页面按空值渲染,登录失败时按未登录渲染。
* 旧版本在页面 data 里同步读缓存、而缓存由异步写入,首次安装必然报错,这里不再重蹈。
*/
bootstrap() {
@@ -27,8 +27,15 @@ App({
api
.login()
.then(() => api.getEntitlements())
.then((list) => store.setEntitlements(list))
.then((res) => {
// 静默登录只建立身份。用户从没主动登录过、或者已经退出,就不去拉已购课程,
// 否则知识页会在「未登录」状态下又冒出课程列表。
if (!res || !res.user || res.user.isLogin !== 1) {
store.setEntitlements([]);
return null;
}
return api.getEntitlements().then((list) => store.setEntitlements(list));
})
.catch((err) => console.warn('[app] 登录或权益加载失败', err)),
];
return Promise.all(tasks);
+1
View File
@@ -6,6 +6,7 @@
"pages/LoginOrOutDetails/LoginOrOutDetails",
"pages/WebPageDetails/WebPageDetails",
"pages/VideoDetails/VideoDetails",
"pages/buy/buy",
"pages/VideoDetailsPage/VideoDetailsPage",
"pages/IndexNewCourseDetails/IndexNewCourseDetails",
"pages/IndexFreeCourseDetails/IndexFreeCourseDetails",
@@ -28,7 +28,7 @@ Page({
this.setData({
username: user.nickname || '',
HeadiconID: user.headiconId || 0,
list: parseInt(store.cfg('profile.headicon_count', '50'), 10) || 50,
list: parseInt(store.cfg('profile.headicon_count'), 10) || 0,
});
},
@@ -1,5 +1,8 @@
// pages/IndexNewCourseDetails/IndexNewCourseDetails.js
const api = require('../../utils/api');
const store = require('../../utils/store');
const app = getApp();
Page({
data: {
@@ -7,6 +10,8 @@ Page({
courseCode: '',
courseTitle: '',
priceYuan: '',
buyText: '',
contactText: '',
},
onLoad(options) {
@@ -27,9 +32,21 @@ Page({
.catch((err) => console.error('[IndexNewCourseDetails] 加载失败', err));
},
/** 复用付费课页面完成购买:未拥有时那里会展示价格与购买按钮。 */
onShow() {
app.ready().then(() => this.applyConfig());
},
/** 按钮文案取自后台配置,后台改完小程序下拉刷新即可生效。 */
applyConfig() {
this.setData({
buyText: store.cfg('course.button.buy'),
contactText: store.cfg('course.button.contact'),
});
},
/** 购买统一走商业化页面。 */
toBuy() {
if (!this.data.courseCode) return;
wx.navigateTo({ url: '/pages/VideoDetails/VideoDetails?code=' + this.data.courseCode });
wx.navigateTo({ url: '/pages/buy/buy?code=' + this.data.courseCode });
},
});
@@ -4,10 +4,10 @@
<view class="fixed-btn-wrapper">
<view class="btn-group">
<button wx:if="{{courseCode}}" class="DianJiXiangQing1" bind:tap="toBuy">
<text class="DianJiXiangQing2">立即购买</text>
<text class="DianJiXiangQing2">{{buyText}}</text>
</button>
<button class="DianJiXiangQing1" open-type="contact" bindcontact="handleContact">
<text class="DianJiXiangQing2">咨询客服</text>
<text class="DianJiXiangQing2">{{contactText}}</text>
</button>
</view>
</view>
@@ -30,7 +30,8 @@ Page({
refresh() {
const user = store.getUser();
const registered = !!(user && user.nickname);
// 「已登录」看的是用户主动登录过没有(服务端记着这一位),不是有没有 token
const registered = !!(user && user.isLogin === 1);
this.setData({
loginStatue: registered,
LoginTag: registered ? '退出登录' : '一键登录',
@@ -49,13 +50,16 @@ Page({
* 会把老用户在服务端的真实昵称覆盖掉。正确做法是先取服务端资料:
* - 已有昵称 → 老用户,原样保留,不提交任何修改
* - 没有昵称 → 首次注册,生成一个随机昵称
*
* activeLogin 负责把数据库里的「已登录」标记置 1——只有用户亲自点这里才会置 1,
* 静默登录不碰它。它带鉴权,未登录时会自动完成静默登录,无需手动判断 token。
*/
onLoginBtnClick() {
wx.showLoading({ title: '请稍候', mask: true });
// getMe 在未登录时会自动完成静默登录,无需手动判断 token
api
.getMe()
.activeLogin()
.then(() => api.getMe())
.then((user) => {
store.setUser(user);
if (user.nickname) return user;
+18 -58
View File
@@ -3,19 +3,18 @@ const api = require('../../utils/api');
const store = require('../../utils/store');
const format = require('../../utils/format');
const POLL_INTERVAL = 1500;
const POLL_MAX = 8;
const app = getApp();
Page({
data: {
code: '',
owned: false,
loading: true,
buying: false,
buyText: '',
contactText: '',
VideoTitle: '',
VideoDetails: '',
coverUrl: '',
priceYuan: '',
playdata: [],
VideoPlayerSrc: '',
HiddenVideoControl: true,
@@ -29,11 +28,20 @@ Page({
return;
}
this.setData({ code: code });
this.load(code);
},
/** 首次进入和从购买页返回都会走这里:买完回来要立刻变成已拥有。 */
onShow() {
if (this.data.code && this.data.owned) this.load(this.data.code);
app.ready().then(() => this.applyConfig());
if (this.data.code) this.load(this.data.code);
},
/** 按钮文案取自后台配置,后台改完小程序下拉刷新即可生效。 */
applyConfig() {
this.setData({
buyText: store.cfg('course.button.buy'),
contactText: store.cfg('course.button.contact'),
});
},
load(code) {
@@ -46,7 +54,6 @@ Page({
VideoTitle: course.title || '',
VideoDetails: course.intro || '',
coverUrl: course.coverUrl || '',
priceYuan: format.fenToYuan(course.priceFen),
});
if (course.owned) this.loadLessons(code);
})
@@ -96,57 +103,10 @@ Page({
});
},
/** 购买:下单 → 拉起支付 → 轮询订单状态确认发货。 */
buyNow() {
if (this.data.buying) return;
if (typeof wx.requestVirtualPayment !== 'function') {
wx.showToast({ title: '当前微信版本不支持支付,请升级微信', icon: 'none' });
return;
}
this.setData({ buying: true });
wx.showLoading({ title: '发起支付', mask: true });
api
.createOrder(this.data.code)
.then((order) => {
wx.hideLoading();
return api.requestVirtualPayment(order.payData).then(() => order.outTradeNo);
})
.then((outTradeNo) => this.waitForDelivery(outTradeNo))
.catch((err) => {
wx.hideLoading();
this.setData({ buying: false });
// 用户主动取消不算错误
if (err && err.errMsg && err.errMsg.indexOf('cancel') >= 0) return;
wx.showToast({ title: (err && err.message) || '支付失败', icon: 'none' });
});
},
/**
* 支付成功后权益由服务端发货推送发放,存在秒级延迟,这里轮询确认。
* 即便轮询全部超时也不会丢单:服务端有定时查单兜底。
*/
waitForDelivery(outTradeNo) {
let attempts = 0;
const poll = () => {
attempts += 1;
return api.getOrder(outTradeNo).then((order) => {
if (order.status === 2) {
this.setData({ buying: false, owned: true });
wx.showToast({ title: '购买成功', icon: 'success', mask: true });
this.load(this.data.code);
return;
}
if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) {
this.setData({ buying: false });
wx.showToast({ title: '支付已提交,开通可能稍有延迟', icon: 'none', duration: 3000 });
return;
}
return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)).then(poll);
});
};
return poll();
/** 购买入口:详情页不做交易,统一跳商业化页面。 */
goBuy() {
if (!this.data.code) return;
wx.navigateTo({ url: '/pages/buy/buy?code=' + this.data.code });
},
onShareAppMessage() {
@@ -27,7 +27,7 @@
</scroll-view>
</block>
<!-- 未拥有:展示介绍与购买入口 -->
<!-- 购买入口 -->
<block wx:else>
<scroll-view class="scrollarea" scroll-y type="list">
<view class="jieshao">
@@ -41,11 +41,11 @@
<view class="fixed-btn-wrapper">
<view class="btn-group">
<button class="DianJiXiangQing1" bind:tap="buyNow" disabled="{{buying}}">
<text class="DianJiXiangQing2">{{buying ? '处理中…' : '立即购买 ¥' + priceYuan}}</text>
<button class="DianJiXiangQing1" bind:tap="goBuy">
<text class="DianJiXiangQing2">{{buyText}}</text>
</button>
<button class="DianJiXiangQing1" open-type="contact">
<text class="DianJiXiangQing2">咨询客服</text>
<text class="DianJiXiangQing2">{{contactText}}</text>
</button>
</view>
</view>
+143
View File
@@ -0,0 +1,143 @@
// pages/buy/buy.js
//
// 商业化页面:所有购买入口都落到这里。
// 职责是「确认订单 → 拉起微信支付 → 确认发货」,课程详情不在这里展示。
// UI 待设计,wxml 现在只有一个占位块,支付链路已经就位。
const api = require('../../utils/api');
const format = require('../../utils/format');
const store = require('../../utils/store');
const POLL_INTERVAL = 1500;
const POLL_MAX = 8;
Page({
data: {
code: '',
loading: true,
buying: false,
owned: false,
title: '',
coverUrl: '',
priceYuan: '',
},
onLoad(options) {
const code = options && options.code;
// 没带课程号说明是误入(比如直接输路径打开),不该停在这个页面
if (!code) {
this.rejectOut('缺少课程参数');
return;
}
this.setData({ code: code });
this.load(code);
},
load(code) {
api
.getCourse(code)
.then((course) => {
this.setData({
loading: false,
owned: course.owned,
title: course.title || '',
coverUrl: course.coverUrl || '',
priceYuan: format.fenToYuan(course.priceFen),
});
// 已经拥有就不用再买了,避免重复下单
if (course.owned) this.rejectOut('你已经拥有这门课程');
})
.catch((err) => {
this.setData({ loading: false });
// 课程被下架或课程号拼错,后端会返回错误,这里按同样的方式退出
this.rejectOut((err && err.message) || '课程不存在');
});
},
/** 进错页面(没带课程、课程不存在、已经买过):提示一句然后退出去。 */
rejectOut(message) {
wx.showToast({ title: message, icon: 'none' });
setTimeout(() => this.back(), 1500);
},
/** 退回上一页;没有上一页(直接打开)就回知识页,避免停在死页面。 */
back() {
if (getCurrentPages().length > 1) {
wx.navigateBack();
return;
}
wx.switchTab({ url: '/pages/kecheng/kecheng' });
},
/** 购买:下单 → 拉起支付 → 轮询订单状态确认发货。 */
buyNow() {
if (this.data.buying || !this.data.code) return;
// 购买的前置条件:用户主动使用过登录功能。静默登录不算。
if (!store.isLoggedIn()) {
wx.showModal({
title: '请先登录',
content: '使用微信登录后才能购买课程',
confirmText: '去登录',
success: (res) => {
if (res.confirm) {
wx.navigateTo({ url: '/pages/LoginOrOutDetails/LoginOrOutDetails' });
}
},
});
return;
}
if (typeof wx.requestVirtualPayment !== 'function') {
wx.showToast({ title: '当前微信版本不支持支付,请升级微信', icon: 'none' });
return;
}
this.setData({ buying: true });
wx.showLoading({ title: '发起支付', mask: true });
api
.createOrder(this.data.code)
.then((order) => {
wx.hideLoading();
return api.requestVirtualPayment(order.payData).then(() => order.outTradeNo);
})
.then((outTradeNo) => this.waitForDelivery(outTradeNo))
.catch((err) => {
wx.hideLoading();
this.setData({ buying: false });
// 用户主动取消不算错误
if (err && err.errMsg && err.errMsg.indexOf('cancel') >= 0) return;
wx.showToast({ title: (err && err.message) || '支付失败', icon: 'none' });
});
},
/**
* 支付成功后权益由服务端发货推送发放,存在秒级延迟,这里轮询确认。
* 即便轮询全部超时也不会丢单:服务端有定时查单兜底。
*/
waitForDelivery(outTradeNo) {
let attempts = 0;
const poll = () => {
attempts += 1;
return api.getOrder(outTradeNo).then((order) => {
if (order.status === 2) {
this.setData({ buying: false, owned: true });
wx.showToast({ title: '购买成功', icon: 'success', mask: true });
setTimeout(() => this.back(), 1500);
return;
}
if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) {
this.setData({ buying: false });
wx.showToast({ title: '支付已提交,开通可能稍有延迟', icon: 'none', duration: 3000 });
return;
}
return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)).then(poll);
});
};
return poll();
},
onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' };
},
});
+5
View File
@@ -0,0 +1,5 @@
{
"usingComponents": {
}
}
+7
View File
@@ -0,0 +1,7 @@
<!--pages/buy/buy.wxml-->
<!-- UI 待设计。支付链路(buyNow / waitForDelivery)已经在 buy.js 里就位,
设计稿定了直接替换下面这个占位块即可。 -->
<view class="buy-page">
<text class="buy-placeholder">购买页(待设计)</text>
</view>
+14
View File
@@ -0,0 +1,14 @@
/* pages/buy/buy.wxss */
/* 占位样式,UI 定稿后连同 wxml 一起替换 */
.buy-page {
display: flex;
align-items: center;
justify-content: center;
min-height: 70vh;
}
.buy-placeholder {
color: #999;
font-size: 28rpx;
}
+4 -4
View File
@@ -33,10 +33,10 @@ Page({
applyConfig() {
this.setData({
title1: store.cfg('home.title.paid', '付费课程'),
title2: store.cfg('home.title.trial', '试听课程'),
title3: store.cfg('home.title.public', '公开课程'),
footerText: store.cfg('home.footer_text', '我是有底线的!'),
title1: store.cfg('home.title.paid'),
title2: store.cfg('home.title.trial'),
title3: store.cfg('home.title.public'),
footerText: store.cfg('home.footer_text'),
});
},
+21 -9
View File
@@ -46,13 +46,19 @@ page {
display: flex;
flex-wrap: wrap;/* 允许内容换行 */
justify-content:space-between;/* 水平方向上两端对齐 */
/* 左右各留 20rpx,和卡片之间的 20rpx 间距相同,视觉上三边等宽 */
width: 750rpx;
box-sizing: border-box;
padding: 0 20rpx;
}
.shouyetupianlistcontent {
/* display: inline; */
padding: 0;
margin: 20rpx;
/* 水平间距交给容器 padding + space-between,这里只留出上下行距 */
margin: 0 0 40rpx 0;
height: 204rpx;
width: 334rpx; /* 每行两个图片,设置宽度为 48% 可以有一定的间距 */
/* (750 - 左右各 20 - 中间 20) / 2 = 345 */
width: 345rpx;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
@@ -60,9 +66,10 @@ page {
.shouyetupianlistcontent01 {
position: relative;
padding: 0;
margin: 20rpx;
margin: 0 0 40rpx 0;
height: 260rpx;
width: 730rpx;
/* 容器内容区宽度,正好铺满 */
width: 710rpx;
overflow: hidden;
border-radius: 20rpx;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.5);
@@ -82,10 +89,11 @@ page {
.biaoti01 {
display: flex;
width: 750rpx;
width: 100%;
height: 60rpx;
align-items: center;
padding-left: 20rpx;
/* 左侧对齐交给容器 padding,去掉后标题和小图/卡片左边缘对齐 */
padding-left: 0;
}
.biaoti01img {
width: 40rpx;
@@ -125,13 +133,17 @@ page {
display: inline;
position: absolute;
left: 300rpx;
top: 35px;
/* 原为 35px:再看它与标题的关系(下面那行 top:-15px),两者会重叠。
标题框实际占到 40px,这里退到 90rpx(45px)之后才真正错开。 */
top: 90rpx;
align-items: center;
padding-left: 20rpx;
}
.indeximagelistmiaosu0102 {
font-size: 20rpx;
position: relative;
top: -15px;
/* 原为 -15px:这一步把简介往上提了 15px,正好压到标题上。
容器已经让开了位置,这里不再需要负偏移。 */
top: 0;
width: 370rpx;
}
}
+2 -14
View File
@@ -10,7 +10,6 @@ Page({
staticBase: config.STATIC_BASE_URL,
hasCourse: false,
ownedCourses: [],
otherCourses: [],
tipsHtml: '',
},
@@ -21,6 +20,8 @@ Page({
onShow() {
if (store.isLoggedIn()) this.refresh();
// 未登录(含刚退出登录)不能留着一份别人的课程列表
else this.setData({ ownedCourses: [], hasCourse: false });
},
refresh() {
@@ -33,13 +34,6 @@ Page({
this.setData({ ownedCourses: list || [], hasCourse: (list || []).length > 0 });
})
.catch((err) => console.warn('[kecheng] 已购课程加载失败', err));
api
.getCourses()
.then((list) => {
this.setData({ otherCourses: (list || []).filter((c) => !c.owned) });
})
.catch((err) => console.warn('[kecheng] 课程列表加载失败', err));
},
loadTips() {
@@ -56,12 +50,6 @@ Page({
});
},
toStoreCourse(e) {
wx.navigateTo({
url: '/pages/VideoDetails/VideoDetails?code=' + e.currentTarget.dataset.code,
});
},
onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' };
},
-13
View File
@@ -20,18 +20,5 @@
</view>
</block>
<block wx:if="{{otherCourses.length > 0}}">
<view wx:for="{{otherCourses}}" wx:for-item="item" wx:for-index="index" wx:key="code"
class="kecheng" bind:tap="toStoreCourse" data-code="{{item.code}}">
<view class="kecheng_video_up">
<image class="kecheng_img" src="{{item.coverUrl}}" mode="aspectFit" />
</view>
<view class="kecheng_video_right">
<text class="kecheng_title">{{item.title}}</text>
<text class="kecheng_details">{{item.intro}}</text>
</view>
<text style="position: absolute; right: 24rpx; bottom: 24rpx; font-size: 24rpx; color: #ff6b00;">未开通</text>
</view>
</block>
</scroll-view>
+15 -10
View File
@@ -34,27 +34,32 @@ Page({
},
applyConfig() {
const qrcodeName = store.cfg('profile.qrcode_image', '');
const qrcodeName = store.cfg('profile.qrcode_image');
this.setData({
version: store.cfg('site.version', 'V0.0.9'),
text2: store.cfg('profile.menu.change_info', '修改资料'),
text3: store.cfg('profile.menu.share', '好友分享'),
text4: store.cfg('profile.menu.contact', '在线沟通'),
text5: store.cfg('profile.menu.wechat', '添加微信:叶雷讲数学'),
text6: store.cfg('profile.qrcode_caption', '长按识别二维码'),
version: store.cfg('site.version'),
text2: store.cfg('profile.menu.change_info'),
text3: store.cfg('profile.menu.share'),
text4: store.cfg('profile.menu.contact'),
text5: store.cfg('profile.menu.wechat'),
text6: store.cfg('profile.qrcode_caption'),
qrcodeUrl: qrcodeName ? config.STATIC_BASE_URL + '/Images/WXCode/' + qrcodeName + '.png' : '',
});
},
applyUser() {
const user = store.getUser();
// 已登录 = 有 token 且设置过昵称;未设置昵称的仍是游客,与旧版语义一致
const registered = !!(user && user.nickname);
// 「已登录」看的是用户主动登录过没有(服务端记着这一位),不是有没有 token
const registered = !!(user && user.isLogin === 1);
this.setData({
isLoggedIn: registered,
userNameText: registered ? user.nickname : '未登录',
loginBtnText: registered ? '退出登录' : '登录注册',
headiconUrl: user && user.headiconUrl ? user.headiconUrl : config.STATIC_BASE_URL + config.PLACEHOLDER.USER,
// 未登录一律用默认头像:静默登录仍会带回上次设置的头像,
// 不挡住的话退出登录后还会显示用户自己的头像。
headiconUrl:
registered && user.headiconUrl
? user.headiconUrl
: config.STATIC_BASE_URL + config.PLACEHOLDER.USER,
});
},
+1 -1
View File
@@ -1,7 +1,7 @@
<!--pages/wode/wode.wxml-->
<van-popup show="{{ showCode }}" bind:close="onCodeClose">
<view class="code-container">
<text class="tip-text">{{text6}}</text>
<text wx:if="{{text6}}" class="tip-text">{{text6}}</text>
<van-image
show-menu-by-longpress="true"
use-loading-slot
+11
View File
@@ -145,12 +145,23 @@ function logout() {
});
}
/**
* 用户主动登录:把数据库里的「已登录」标记置 1。
*
* 静默登录(login)不会碰这个标记——它只负责建立身份。只有用户亲自点了登录,
* 才算「主动使用过登录功能」,购买资格也以这个标记为准,而不是有没有 token。
*/
function activeLogin() {
return request({ url: '/v1/auth/login', method: 'POST' });
}
// ── 首页内容 ──────────────────────────────────────────────────────────────
const api = {
staticUrl,
login,
logout,
activeLogin,
getConfig: () => request({ url: '/v1/config', auth: false }),
getBanners: () => request({ url: '/v1/banners', auth: false }),
+13 -4
View File
@@ -49,7 +49,16 @@ const store = {
wx.removeStorageSync(config.STORAGE.REFRESH_TOKEN);
},
isLoggedIn: () => !!read(config.STORAGE.TOKEN, ''),
/**
* 是否「已登录」。
*
* 指用户主动使用过登录功能(标记由服务端返回、跟着 user 一起存),
* 不是「本地有没有 token」——静默登录每次都会拿到 token,那不算登录。
*/
isLoggedIn: () => {
const user = store.getUser();
return !!(user && user.isLogin === 1);
},
// ── 用户 ─────────────────────────────────────────────────────────────
getUser() {
@@ -77,10 +86,10 @@ const store = {
notify();
},
/** 取配置项,带兜底值,避免配置缺失时页面直接崩掉。 */
cfg(key, fallback) {
/** 取配置项。数据库里配什么就是什么,没配返回空字符串,由页面自己决定要不要渲染。 */
cfg(key) {
const value = store.getSiteConfig()[key];
return value === undefined || value === '' ? (fallback === undefined ? '' : fallback) : value;
return value === undefined ? '' : value;
},
// ── 已购课程 ─────────────────────────────────────────────────────────