文案配置化 + 支付开关

小程序
- 页面固定文案全部改读后台配置(购买、登录、资料、首页、分享、介绍页),代码里不再硬编码
- 已拥有课程时在当前页提示,不再跳购买页再提示
- 介绍页正文由代码常量改为读 settings.page.formula.content

服务端
- 课程相关报错文案(不存在 / 已下架 / 课时不存在 / 未购买 / 已过期 / 重复购买 / 未配置支付商品)改读配置,代码保留默认值兜底
- 新增支付总开关 pay.blocked:打开即拦截下单并下发配置里的提示;开关存在数据库里,开发库与生产库各一份(开发库已开,生产库未设)

后台
- 系统配置页按页面分组折叠,默认收起;认不出来的配置归入「其他」

配套数据(不在代码里):对应的 settings 行已写入生产库与开发库
This commit is contained in:
Yeomai committed 2026-10-01 11:28:29 +08:00
1 parent c45025fc86
commit 879e74ebda
21 files changed
+308 -86

No files matched your search

@@ -13,6 +13,11 @@ Page({
username: '', username: '',
HeadiconID: 0, HeadiconID: 0,
list: 50, list: 50,
// 表单上的固定文案,全部来自后台配置
labelName: '',
placeholderName: '',
labelAvatar: '',
textSubmit: '',
}, },
onLoad() { onLoad() {
@@ -29,6 +34,10 @@ Page({
username: user.nickname || '', username: user.nickname || '',
HeadiconID: user.headiconId || 0, HeadiconID: user.headiconId || 0,
list: parseInt(store.cfg('profile.headicon_count'), 10) || 0, list: parseInt(store.cfg('profile.headicon_count'), 10) || 0,
labelName: store.cfg('profile.form.name'),
placeholderName: store.cfg('profile.form.namePlaceholder'),
labelAvatar: store.cfg('profile.form.avatar'),
textSubmit: store.cfg('profile.form.submit'),
}); });
}, },
@@ -58,8 +67,8 @@ Page({
} }
wx.showModal({ wx.showModal({
title: '提示', title: store.cfg('common.dialog.title'),
content: '确定修改个人资料吗?', content: store.cfg('profile.confirm.save'),
confirmColor: '#ff0000', confirmColor: '#ff0000',
success: (res) => { success: (res) => {
if (!res.confirm) return; if (!res.confirm) return;
@@ -1,15 +1,15 @@
<van-cell-group> <van-cell-group>
<van-field <van-field
label="用户名" label="{{labelName}}"
value="{{username}}" value="{{username}}"
placeholder="请输入用户名" placeholder="{{placeholderName}}"
border="true" border="true"
bind:change="onChange" bind:change="onChange"
/> />
</van-cell-group> </van-cell-group>
<van-collapse value="{{ activeNames }}" bind:change="onChange2"> <van-collapse value="{{ activeNames }}" bind:change="onChange2">
<van-collapse-item title="头像" name="1"> <van-collapse-item title="{{labelAvatar}}" name="1">
<view style="display: flex; justify-content: center; padding: 20rpx 0;"> <view style="display: flex; justify-content: center; padding: 20rpx 0;">
<van-image width="120rpx" height="120rpx" src="{{BaseURL}}/Images/Headicons/Headicon{{HeadiconID}}.png" /> <van-image width="120rpx" height="120rpx" src="{{BaseURL}}/Images/Headicons/Headicon{{HeadiconID}}.png" />
</view> </view>
@@ -22,5 +22,5 @@
</van-collapse> </van-collapse>
<view class="button-container" style="text-align: center;padding: 20rpx;"> <view class="button-container" style="text-align: center;padding: 20rpx;">
<van-button type="primary" bind:click="onConfirm">确认修改</van-button> <van-button type="primary" bind:click="onConfirm">{{textSubmit}}</van-button>
</view> </view>
@@ -1,6 +1,7 @@
// pages/IndexFreeCourseDetails/IndexFreeCourseDetails.js // pages/IndexFreeCourseDetails/IndexFreeCourseDetails.js
const api = require('../../utils/api'); const api = require('../../utils/api');
const format = require('../../utils/format'); const format = require('../../utils/format');
const store = require('../../utils/store');
Page({ Page({
data: { data: {
@@ -9,6 +10,8 @@ Page({
VideoPlayerSrc: '', VideoPlayerSrc: '',
HiddenVideoControl: true, HiddenVideoControl: true,
top: 0, top: 0,
/** 列表上方的更新期数文案,整句来自后台配置 */
updatedText: '',
}, },
onLoad(options) { onLoad(options) {
@@ -32,6 +35,7 @@ Page({
this.setData({ this.setData({
playdata: rows, playdata: rows,
VideoTitle: rows.length ? rows[0].videoTitle || '' : '', VideoTitle: rows.length ? rows[0].videoTitle || '' : '',
updatedText: store.cfg('course.lesson.updated').replace('{n}', rows.length),
}); });
}) })
.catch((err) => { .catch((err) => {
@@ -43,14 +47,14 @@ Page({
onTapLesson(e) { onTapLesson(e) {
const item = this.data.playdata[e.currentTarget.dataset.index]; const item = this.data.playdata[e.currentTarget.dataset.index];
if (!item || !item.videoUrl) { if (!item || !item.videoUrl) {
wx.showToast({ title: '该课时暂无视频', icon: 'none' }); wx.showToast({ title: store.cfg('course.tip.lessonNoVideo'), icon: 'none' });
return; return;
} }
this.setData({ VideoPlayerSrc: item.videoUrl, HiddenVideoControl: false, top: 420 }); this.setData({ VideoPlayerSrc: item.videoUrl, HiddenVideoControl: false, top: 420 });
}, },
onShareAppMessage() { onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' }; return { title: store.cfg('share.title'), path: '/pages/index/index' };
}, },
}); });
@@ -2,7 +2,7 @@
<scroll-view class="scrollarea" scroll-y type="list" style="top:{{top}}rpx;"> <scroll-view class="scrollarea" scroll-y type="list" style="top:{{top}}rpx;">
<view class="jieshao"> <view class="jieshao">
<text decode class="video_biaoti">&nbsp;&nbsp;{{VideoTitle}}</text> <text decode class="video_biaoti">&nbsp;&nbsp;{{VideoTitle}}</text>
<text decode class="video_info">&nbsp;&nbsp;已更新{{playdata.length}}期</text> <text decode class="video_info">&nbsp;&nbsp;{{updatedText}}</text>
</view> </view>
<view class="videolistcontainer" bind:tap="onTapLesson" <view class="videolistcontainer" bind:tap="onTapLesson"
@@ -12,6 +12,8 @@ Page({
priceYuan: '', priceYuan: '',
buyText: '', buyText: '',
contactText: '', contactText: '',
/** 是否已拥有这门课:已拥有时按钮就在本页提示,不再跳购买页 */
owned: false,
}, },
onLoad(options) { onLoad(options) {
@@ -28,12 +30,27 @@ Page({
courseCode: item.courseCode || '', courseCode: item.courseCode || '',
courseTitle: item.courseTitle || '', courseTitle: item.courseTitle || '',
}); });
this.refreshOwned();
}) })
.catch((err) => console.error('[IndexNewCourseDetails] 加载失败', err)); .catch((err) => console.error('[IndexNewCourseDetails] 加载失败', err));
}, },
onShow() { onShow() {
app.ready().then(() => this.applyConfig()); app.ready().then(() => {
this.applyConfig();
// 买完回来要立刻变成「已拥有」,所以每次显示都重新问一次
this.refreshOwned();
});
},
/** 查这门课的拥有状态。拿不到就按未拥有处理,不影响正常购买。 */
refreshOwned() {
const code = this.data.courseCode;
if (!code) return;
api
.getCourse(code)
.then((course) => this.setData({ owned: !!course.owned }))
.catch((err) => console.warn('[IndexNewCourseDetails] 拥有状态加载失败', err));
}, },
/** 按钮文案取自后台配置,后台改完小程序下拉刷新即可生效。 */ /** 按钮文案取自后台配置,后台改完小程序下拉刷新即可生效。 */
@@ -47,6 +64,11 @@ Page({
/** 购买统一走商业化页面。 */ /** 购买统一走商业化页面。 */
toBuy() { toBuy() {
if (!this.data.courseCode) return; if (!this.data.courseCode) return;
// 已经拥有就不必再跳过去了:原地提示一句
if (this.data.owned) {
wx.showToast({ title: store.cfg('course.tip.owned'), icon: 'none' });
return;
}
wx.navigateTo({ url: '/pages/buy/buy?code=' + this.data.courseCode }); wx.navigateTo({ url: '/pages/buy/buy?code=' + this.data.courseCode });
}, },
}); });
@@ -17,7 +17,11 @@ Page({
data: { data: {
userInfo: { avatarUrl: '', nickName: '' }, userInfo: { avatarUrl: '', nickName: '' },
loginStatue: false, loginStatue: false,
LoginTag: '一键登录', LoginTag: '',
// 页面固定文案,全部来自后台配置
pageTitle: '',
pageSubtitle: '',
logoutText: '',
}, },
onLoad() { onLoad() {
@@ -34,7 +38,10 @@ Page({
const registered = !!(user && user.isLogin === 1); const registered = !!(user && user.isLogin === 1);
this.setData({ this.setData({
loginStatue: registered, loginStatue: registered,
LoginTag: registered ? '退出登录' : '一键登录', LoginTag: registered ? store.cfg('login.button.logout') : store.cfg('login.button.login'),
logoutText: store.cfg('login.button.logout'),
pageTitle: store.cfg('login.title'),
pageSubtitle: store.cfg('login.subtitle'),
userInfo: { userInfo: {
avatarUrl: config.STATIC_BASE_URL + config.PLACEHOLDER.WECHAT, avatarUrl: config.STATIC_BASE_URL + config.PLACEHOLDER.WECHAT,
nickName: registered ? user.nickname : '', nickName: registered ? user.nickname : '',
@@ -82,8 +89,8 @@ Page({
onlogout() { onlogout() {
if (!this.data.loginStatue) return; if (!this.data.loginStatue) return;
wx.showModal({ wx.showModal({
title: '提示', title: store.cfg('common.dialog.title'),
content: '确定退出登录吗?', content: store.cfg('login.confirm.logout'),
confirmColor: '#ff0000', confirmColor: '#ff0000',
success: (res) => { success: (res) => {
if (!res.confirm) return; if (!res.confirm) return;
@@ -4,10 +4,10 @@
<image class="touxiang_img" src="{{userInfo.avatarUrl}}" mode="" /> <image class="touxiang_img" src="{{userInfo.avatarUrl}}" mode="" />
</view> </view>
<view class="imgview2"> <view class="imgview2">
<text class="imgtext2">叶雷讲数学</text> <text class="imgtext2">{{pageTitle}}</text>
</view> </view>
<view class="imgview3"> <view class="imgview3">
<text class="imgtext3">让数学不再难学</text> <text class="imgtext3">{{pageSubtitle}}</text>
</view> </view>
<view wx:if="{{!loginStatue}}" class="btn_wrapper"> <view wx:if="{{!loginStatue}}" class="btn_wrapper">
@@ -17,7 +17,7 @@
</view> </view>
<view wx:else="{{loginStatue}}" class="btn_wrapper"> <view wx:else="{{loginStatue}}" class="btn_wrapper">
<button plain class="btn2" bind:tap="onlogout"> <button plain class="btn2" bind:tap="onlogout">
<text class="btn_text">退出登录</text> <text class="btn_text">{{logoutText}}</text>
</button> </button>
</view> </view>
+15 -4
View File
@@ -16,6 +16,8 @@ Page({
VideoDetails: '', VideoDetails: '',
coverUrl: '', coverUrl: '',
playdata: [], playdata: [],
/** 列表上方的更新期数文案,整句来自后台配置 */
updatedText: '',
VideoPlayerSrc: '', VideoPlayerSrc: '',
HiddenVideoControl: true, HiddenVideoControl: true,
top: 0, top: 0,
@@ -59,7 +61,7 @@ Page({
}) })
.catch((err) => { .catch((err) => {
this.setData({ loading: false }); this.setData({ loading: false });
wx.showToast({ title: err.message || '课程加载失败', icon: 'none' }); wx.showToast({ title: err.message || store.cfg('course.tip.loadFailed'), icon: 'none' });
setTimeout(() => wx.switchTab({ url: '/pages/kecheng/kecheng' }), 1500); setTimeout(() => wx.switchTab({ url: '/pages/kecheng/kecheng' }), 1500);
}); });
}, },
@@ -77,7 +79,11 @@ Page({
publishedAt: format.toDateString(item.publishedAt), publishedAt: format.toDateString(item.publishedAt),
coverUrl: item.coverUrl, coverUrl: item.coverUrl,
})); }));
this.setData({ playdata: rows }); // 期数文案整句来自后台配置,{n} 在这里替换成实际条数
this.setData({
playdata: rows,
updatedText: store.cfg('course.lesson.updated').replace('{n}', rows.length),
});
}) })
.catch((err) => { .catch((err) => {
this.setData({ playdata: [] }); this.setData({ playdata: [] });
@@ -99,18 +105,23 @@ Page({
}) })
.catch((err) => { .catch((err) => {
wx.hideLoading(); wx.hideLoading();
wx.showToast({ title: err.message || '无法播放', icon: 'none' }); wx.showToast({ title: err.message || store.cfg('course.tip.playFailed'), icon: 'none' });
}); });
}, },
/** 购买入口:详情页不做交易,统一跳商业化页面。 */ /** 购买入口:详情页不做交易,统一跳商业化页面。 */
goBuy() { goBuy() {
if (!this.data.code) return; if (!this.data.code) return;
// 已经拥有就不必再进购买页:原地提示一句
if (this.data.owned) {
wx.showToast({ title: store.cfg('course.tip.owned'), icon: 'none' });
return;
}
wx.navigateTo({ url: '/pages/buy/buy?code=' + this.data.code }); wx.navigateTo({ url: '/pages/buy/buy?code=' + this.data.code });
}, },
onShareAppMessage() { onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' }; return { title: store.cfg('share.title'), path: '/pages/index/index' };
}, },
}); });
@@ -7,7 +7,7 @@
<view class="jieshao"> <view class="jieshao">
<text decode class="video_biaoti">&nbsp;&nbsp;{{VideoTitle}}</text> <text decode class="video_biaoti">&nbsp;&nbsp;{{VideoTitle}}</text>
<text decode class="video_miaoshu">&nbsp;&nbsp;{{VideoDetails}}</text> <text decode class="video_miaoshu">&nbsp;&nbsp;{{VideoDetails}}</text>
<text decode class="video_info">&nbsp;&nbsp;已更新{{playdata.length}}期</text> <text decode class="video_info">&nbsp;&nbsp;{{updatedText}}</text>
</view> </view>
<view class="videolistcontainer" bind:tap="onTapLesson" <view class="videolistcontainer" bind:tap="onTapLesson"
@@ -1,15 +1,8 @@
// pages/VideoDetailsPage/VideoDetailsPage.js // pages/VideoDetailsPage/VideoDetailsPage.js
const htmlSnip = // 正文 HTML 放在后台配置里(settings.page.formula.content),这里只负责取出来交给 Parser 渲染。
`<p> const store = require('../../utils/store');
在初中数学的考试当中,解所有题型都需要用到公式,公式的重要性不言而喻!为了更好的帮助孩子们记住初中数学常用的公式、定理,在此老师给大家总结了初中三年所有的公式及定理!这份资料十分珍贵,每次考试都能用的上!
</p> const app = getApp();
<p>
<img src="https://api.yeomai.site/WebPages/2/1.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/2.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/3.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/4.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/5.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/6.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/7.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/8.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/9.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/10.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/11.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/12.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/13.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/14.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/15.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/16.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/17.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/18.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/19.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/20.jpg"/><br/><img src="https://api.yeomai.site/WebPages/2/21.jpg"/>
</p>
<p>
<br/>
</p>
`
Page({ Page({
@@ -18,14 +11,15 @@ Page({
* 页面的初始数据 * 页面的初始数据
*/ */
data: { data: {
htmlSnip, htmlSnip: '',
}, },
/** /**
* 生命周期函数--监听页面加载 * 生命周期函数--监听页面加载
*/ */
onLoad(options) { onLoad(options) {
// 页面可能被直接打开(小程序码、搜一搜),等初始化把站点配置读回来
app.ready().then(() => this.setData({ htmlSnip: store.cfg('page.formula.content') }));
}, },
/** /**
@@ -1,6 +1,7 @@
// pages/WebPageDetails/WebPageDetails.js // pages/WebPageDetails/WebPageDetails.js
const api = require('../../utils/api'); const api = require('../../utils/api');
const format = require('../../utils/format'); const format = require('../../utils/format');
const store = require('../../utils/store');
Page({ Page({
data: { data: {
@@ -29,11 +30,11 @@ Page({
}, },
onShareAppMessage() { onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' }; return { title: store.cfg('share.title'), path: '/pages/index/index' };
}, },
onShareTimeline() { onShareTimeline() {
return { title: '叶雷讲数学' }; return { title: store.cfg('share.title') };
}, },
}); });
+32 -18
View File
@@ -10,8 +10,10 @@ const api = require('../../utils/api');
const format = require('../../utils/format'); const format = require('../../utils/format');
const store = require('../../utils/store'); const store = require('../../utils/store');
const app = getApp();
const POLL_INTERVAL = 1500; const POLL_INTERVAL = 1500;
// 微信的发货推送实测要 13~17 秒才到,原来只等 12 秒,用户多半看到「可能稍有延迟」而不是「购买成功」 // 微信的发货推送实测要 13~17 秒才到,原来只等 12 秒,用户多半先拿到兜底提示而不是成功提示
const POLL_MAX = 20; const POLL_MAX = 20;
/** iPhone 上跑 Apple 支付要求微信客户端 ≥ 这个版本 */ /** iPhone 上跑 Apple 支付要求微信客户端 ≥ 这个版本 */
@@ -50,9 +52,17 @@ Page({
coverUrl: '', coverUrl: '',
priceYuan: '', priceYuan: '',
list: [], list: [],
// 页面上的固定文案全部来自后台配置,这里只放容器
textBuy: '',
textBuyList: '',
textPaying: '',
textOwned: '',
textEmpty: '',
}, },
onLoad(options) { onLoad(options) {
// 页面可能被直接打开(分享、扫码),先等初始化把站点配置读回来
app.ready().then(() => this.applyTexts());
const code = options && options.code; const code = options && options.code;
if (code) { if (code) {
this.setData({ code: code }); this.setData({ code: code });
@@ -62,6 +72,16 @@ Page({
this.loadList(); this.loadList();
}, },
applyTexts() {
this.setData({
textBuy: store.cfg('course.buy.button'),
textBuyList: store.cfg('course.buy.buttonList'),
textPaying: store.cfg('course.buy.paying'),
textOwned: store.cfg('course.buy.owned'),
textEmpty: store.cfg('course.buy.empty'),
});
},
load(code) { load(code) {
api api
.getCourse(code) .getCourse(code)
@@ -74,12 +94,12 @@ Page({
priceYuan: format.fenToYuan(course.priceFen), priceYuan: format.fenToYuan(course.priceFen),
}); });
// 已经拥有就不用再买了,避免重复下单 // 已经拥有就不用再买了,避免重复下单
if (course.owned) this.rejectOut('你已经拥有这门课程'); if (course.owned) this.rejectOut(store.cfg('course.tip.owned'));
}) })
.catch((err) => { .catch((err) => {
this.setData({ loading: false }); this.setData({ loading: false });
// 课程被下架或课程号拼错,后端会返回错误,这里按同样的方式退出 // 课程被下架或课程号拼错,后端会返回错误,这里按同样的方式退出
this.rejectOut((err && err.message) || '课程不存在'); this.rejectOut((err && err.message) || store.cfg('course.tip.notFound'));
}); });
}, },
@@ -100,7 +120,7 @@ Page({
}) })
.catch((err) => { .catch((err) => {
this.setData({ loading: false }); this.setData({ loading: false });
wx.showToast({ title: (err && err.message) || '课程加载失败', icon: 'none' }); wx.showToast({ title: (err && err.message) || store.cfg('course.tip.loadFailed'), icon: 'none' });
}); });
}, },
@@ -131,9 +151,9 @@ Page({
// 购买的前置条件:用户主动使用过登录功能。静默登录不算。 // 购买的前置条件:用户主动使用过登录功能。静默登录不算。
if (!store.isLoggedIn()) { if (!store.isLoggedIn()) {
wx.showModal({ wx.showModal({
title: '请先登录', title: store.cfg('course.login.title'),
content: '使用微信登录后才能购买课程', content: store.cfg('course.login.desc'),
confirmText: '去登录', confirmText: store.cfg('course.login.confirm'),
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
wx.navigateTo({ url: '/pages/LoginOrOutDetails/LoginOrOutDetails' }); wx.navigateTo({ url: '/pages/LoginOrOutDetails/LoginOrOutDetails' });
@@ -152,7 +172,7 @@ Page({
if (!this.checkIosVersion()) return; if (!this.checkIosVersion()) return;
this.setData({ buying: true }); this.setData({ buying: true });
wx.showLoading({ title: '发起支付', mask: true }); wx.showLoading({ title: store.cfg('course.loading.paying'), mask: true });
api api
.createOrder(code) .createOrder(code)
@@ -188,13 +208,7 @@ Page({
if (!version || compareVersion(version, IOS_MIN_WECHAT) >= 0) return true; if (!version || compareVersion(version, IOS_MIN_WECHAT) >= 0) return true;
wx.showModal({ wx.showModal({
title: '请先升级微信', content: '请先升级微信',
content:
'iPhone 上购买课程需要微信 ' +
IOS_MIN_WECHAT +
' 及以上版本,你当前是 ' +
version +
'。请到 App Store 更新微信后重试。',
showCancel: false, showCancel: false,
confirmText: '知道了', confirmText: '知道了',
}); });
@@ -212,7 +226,7 @@ Page({
return api.getOrder(outTradeNo).then((order) => { return api.getOrder(outTradeNo).then((order) => {
if (order.status === 2) { if (order.status === 2) {
this.setData({ buying: false, owned: true }); this.setData({ buying: false, owned: true });
wx.showToast({ title: '购买成功', icon: 'success', mask: true }); wx.showToast({ title: store.cfg('course.tip.buySuccess'), icon: 'success', mask: true });
// 单课程模式从详情页进来,买完退回去;列表模式留在原地刷新 // 单课程模式从详情页进来,买完退回去;列表模式留在原地刷新
if (this.data.code) { if (this.data.code) {
setTimeout(() => this.back(), 1500); setTimeout(() => this.back(), 1500);
@@ -223,7 +237,7 @@ Page({
} }
if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) { if (attempts >= POLL_MAX || order.status === 3 || order.status === 4) {
this.setData({ buying: false }); this.setData({ buying: false });
wx.showToast({ title: '支付已提交,开通可能稍有延迟', icon: 'none', duration: 3000 }); wx.showToast({ title: store.cfg('course.tip.payPending'), icon: 'none', duration: 3000 });
return; return;
} }
return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)).then(poll); return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL)).then(poll);
@@ -233,6 +247,6 @@ Page({
}, },
onShareAppMessage() { onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' }; return { title: store.cfg('share.title'), path: '/pages/index/index' };
}, },
}); });
+3 -3
View File
@@ -10,7 +10,7 @@
class="buy-btn" class="buy-btn"
disabled="{{buying || owned}}" disabled="{{buying || owned}}"
bindtap="buyNow" bindtap="buyNow"
>{{owned ? '已拥有' : (buying ? '支付中…' : '立即购买')}}</button> >{{owned ? textOwned : (buying ? textPaying : textBuy)}}</button>
</view> </view>
<view class="buy-list" wx:else> <view class="buy-list" wx:else>
@@ -25,10 +25,10 @@
disabled="{{buying || item.owned}}" disabled="{{buying || item.owned}}"
data-code="{{item.code}}" data-code="{{item.code}}"
bindtap="buyNow" bindtap="buyNow"
>{{item.owned ? '已拥有' : '购买'}}</button> >{{item.owned ? textOwned : textBuyList}}</button>
</view> </view>
<view class="buy-empty" wx:if="{{!loading && list.length === 0}}"> <view class="buy-empty" wx:if="{{!loading && list.length === 0}}">
<text>暂无可购买的课程</text> <text>{{textEmpty}}</text>
</view> </view>
</view> </view>
</view> </view>
+4 -4
View File
@@ -111,9 +111,9 @@ Page({
updateManager.onCheckForUpdate(() => {}); updateManager.onCheckForUpdate(() => {});
updateManager.onUpdateReady(() => { updateManager.onUpdateReady(() => {
wx.showModal({ wx.showModal({
title: '更新提示', title: store.cfg('app.update.title'),
showCancel: false, showCancel: false,
content: '新版本已准备好,即将重启应用', content: store.cfg('app.update.content'),
success: (res) => { success: (res) => {
if (res.confirm) updateManager.applyUpdate(); if (res.confirm) updateManager.applyUpdate();
}, },
@@ -174,10 +174,10 @@ Page({
}, },
onShareAppMessage() { onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' }; return { title: store.cfg('share.title'), path: '/pages/index/index' };
}, },
onShareTimeline() { onShareTimeline() {
return { title: '叶雷讲数学' }; return { title: store.cfg('share.title') };
}, },
}); });
+1 -1
View File
@@ -69,7 +69,7 @@ Page({
}, },
onShareAppMessage() { onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' }; return { title: store.cfg('share.title'), path: '/pages/index/index' };
}, },
}); });
+7 -7
View File
@@ -10,8 +10,9 @@ Page({
showCode: false, showCode: false,
headiconUrl: '', headiconUrl: '',
isLoggedIn: false, isLoggedIn: false,
userNameText: '未登录', // 文字全部来自后台配置
loginBtnText: '登录注册', userNameText: '',
loginBtnText: '',
version: '', version: '',
qrcodeUrl: '', qrcodeUrl: '',
text2: '', text2: '',
@@ -52,8 +53,8 @@ Page({
const registered = !!(user && user.isLogin === 1); const registered = !!(user && user.isLogin === 1);
this.setData({ this.setData({
isLoggedIn: registered, isLoggedIn: registered,
userNameText: registered ? user.nickname : '未登录', userNameText: registered ? user.nickname : store.cfg('profile.user.guest'),
loginBtnText: registered ? '退出登录' : '登录注册', loginBtnText: registered ? store.cfg('login.button.logout') : store.cfg('profile.button.login'),
// 未登录一律用默认头像:静默登录仍会带回上次设置的头像, // 未登录一律用默认头像:静默登录仍会带回上次设置的头像,
// 不挡住的话退出登录后还会显示用户自己的头像。 // 不挡住的话退出登录后还会显示用户自己的头像。
headiconUrl: headiconUrl:
@@ -69,7 +70,7 @@ Page({
toChangeUserInfo() { toChangeUserInfo() {
if (!this.data.isLoggedIn) { if (!this.data.isLoggedIn) {
wx.showToast({ mask: true, icon: 'error', title: '请先登录' }); wx.showToast({ mask: true, icon: 'error', title: store.cfg('profile.tip.needLogin') });
return; return;
} }
wx.navigateTo({ url: '/pages/ChangeUserInfo/ChangeUserInfo' }); wx.navigateTo({ url: '/pages/ChangeUserInfo/ChangeUserInfo' });
@@ -84,7 +85,6 @@ Page({
}, },
onShareAppMessage() { onShareAppMessage() {
return { title: '叶雷讲数学', path: '/pages/index/index' }; return { title: store.cfg('share.title'), path: '/pages/index/index' };
}, },
}); });
@@ -23,6 +23,58 @@ const SWITCHES = [
label: '使用开发库时,显示已购买课程', label: '使用开发库时,显示已购买课程',
hint: '关(默认):小程序读开发库时,课程不显示「已购」状态,也不返回已购课程列表。开:按实际权益正常显示。生产库不受这个开关影响。', hint: '关(默认):小程序读开发库时,课程不显示「已购」状态,也不返回已购课程列表。开:按实际权益正常显示。生产库不受这个开关影响。',
}, },
{
key: 'pay.blocked',
label: '暂停支付(拦截下单)',
hint: '开:所有下单直接返回「支付暂不可用」,不产生订单——临时停售 / 维护时用。关(默认):正常收款。注意:开关存在你当前查看的数据库里,而小程序读哪个库就受哪个库的开关控制,所以开发库和生产库是各设各的(改之前先确认左下角看的是哪个库)。',
},
];
/** 服务端返回、小程序原样显示的报错文案。 */
const SERVER_TIP_KEYS = new Set([
'course.tip.notFoundOrOffline',
'course.tip.notFound',
'course.tip.offline',
'course.tip.lessonNotFound',
'course.tip.noEntitlement',
'course.tip.expired',
'course.tip.alreadyOwned',
'course.tip.noProduct',
]);
/**
* 配置项分组。数组顺序就是页面上的展示顺序,每组默认折叠。
*
* 用「前缀 + 例外」来归类:认不出来的会落到最后的「其他」,不会丢。
*/
const GROUP_RULES: Array<{ title: string; hint?: string; match: (key: string) => boolean }> = [
{ title: '首页', match: (k) => k.startsWith('home.') },
{ title: '登录页', match: (k) => k.startsWith('login.') },
{
title: '我的页',
match: (k) =>
k.startsWith('profile.') && !k.startsWith('profile.form.') && !k.startsWith('profile.confirm.'),
},
{
title: '修改资料页',
match: (k) => k.startsWith('profile.form.') || k.startsWith('profile.confirm.'),
},
{
title: '服务端提示',
hint: '服务端返回的报错文案,小程序收到后原样显示。改完立即生效,不用发版。',
match: (k) => SERVER_TIP_KEYS.has(k),
},
{ title: '课程与购买页', match: (k) => k.startsWith('course.') },
{ title: '账号状态', match: (k) => k.startsWith('account.') },
{ title: '应用更新', match: (k) => k.startsWith('app.') },
{ title: '分享', match: (k) => k === 'share.title' },
{ title: '介绍页', match: (k) => k.startsWith('page.') },
{ title: '通用文案', match: (k) => k.startsWith('common.') },
{
title: '系统',
hint: 'system.active_datasource 是小程序当前用的数据源,别在这里改——用左下角「线上小程序数据源」的切换按钮。',
match: (k) => k.startsWith('site.') || k.startsWith('system.') || k.startsWith('develop.'),
},
]; ];
export default function SettingsPage() { export default function SettingsPage() {
@@ -85,6 +137,17 @@ export default function SettingsPage() {
// 开关项已经在上面单独渲染,别再以文本框的形式出现一次 // 开关项已经在上面单独渲染,别再以文本框的形式出现一次
const textRows = rows.filter((r) => !SWITCHES.some((s) => s.key === r.key)); const textRows = rows.filter((r) => !SWITCHES.some((s) => s.key === r.key));
// 按分组归类:只认第一个匹配的规则(否则 course.tip.notFound 会同时出现在两个分组里)。
// 认不出来的落到「其他」,保证不会有配置项看不见。
const groupOf = (key: string) => GROUP_RULES.find((r) => r.match(key))?.title ?? '其他';
const groups = GROUP_RULES.map((rule) => ({
title: rule.title,
hint: rule.hint,
rows: textRows.filter((r) => groupOf(r.key) === rule.title),
})).filter((g) => g.rows.length > 0);
const rest = textRows.filter((r) => groupOf(r.key) === '其他');
if (rest.length > 0) groups.push({ title: '其他', hint: undefined, rows: rest });
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<h1 className="text-lg font-semibold text-gray-900">系统配置</h1> <h1 className="text-lg font-semibold text-gray-900">系统配置</h1>
@@ -93,7 +156,8 @@ export default function SettingsPage() {
<Card title="站点文案与开关"> <Card title="站点文案与开关">
<p className="mb-4 text-xs text-gray-500"> <p className="mb-4 text-xs text-gray-500">
小程序首屏显示的标题、我的页面菜单文案、版本号等都在这里。修改后小程序下拉刷新即可生效。 小程序里的文案、以及服务端返回给用户的提示都在这里,按页面分组。点标题展开,改完点「保存」。
小程序端改完下拉刷新即可生效;服务端提示改完立即生效。
</p> </p>
<div className="mb-5 space-y-4 border-b border-gray-100 pb-5"> <div className="mb-5 space-y-4 border-b border-gray-100 pb-5">
@@ -132,8 +196,16 @@ export default function SettingsPage() {
})} })}
</div> </div>
<div className="space-y-3"> <div className="space-y-2">
{textRows.map((row) => ( {groups.map((group) => (
<details key={group.title} className="rounded-md border border-gray-200 bg-white">
<summary className="cursor-pointer select-none px-3 py-2 text-sm font-medium text-gray-800 hover:bg-gray-50">
{group.title}
<span className="ml-2 text-xs font-normal text-gray-400">{group.rows.length} 项</span>
</summary>
<div className="space-y-3 border-t border-gray-100 px-3 py-3">
{group.hint && <p className="text-xs text-gray-500">{group.hint}</p>}
{group.rows.map((row) => (
<div key={row.key} className="flex items-center gap-3"> <div key={row.key} className="flex items-center gap-3">
<div className="w-56 shrink-0"> <div className="w-56 shrink-0">
<div className="text-sm text-gray-700">{row.remark || row.key}</div> <div className="text-sm text-gray-700">{row.remark || row.key}</div>
@@ -150,7 +222,10 @@ export default function SettingsPage() {
</Button> </Button>
</div> </div>
))} ))}
{textRows.length === 0 && ( </div>
</details>
))}
{groups.length === 0 && (
<div className="py-6 text-center text-sm text-gray-400">暂无配置</div> <div className="py-6 text-center text-sm text-gray-400">暂无配置</div>
)} )}
</div> </div>
+1 -1
View File
@@ -1 +1 @@
{"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated {"fileNames":["./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es5.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.dom.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.core.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.collection.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.generator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.iterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.proxy.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.reflect.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2015.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.array.include.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2016.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.arraybuffer.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2017.typedarrays.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asyncgenerator.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.asynciterable.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2018.regexp.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.object.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.symbol.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2019.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.bigint.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.date.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.sharedmemory.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.symbol.wellknown.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2020.number.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.promise.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.string.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.weakref.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2021.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.array.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.error.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.intl.d.ts","./node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/lib/lib.es2022.object.d.ts",".Line truncated
+50
View File
@@ -9,16 +9,66 @@ import type { PrismaService } from '../prisma/prisma.service';
export const SITE_FLAG_KEYS = { export const SITE_FLAG_KEYS = {
/** 使用开发库时是否显示已购课程。开发库里带着老数据,容易让人误以为自己买过课。 */ /** 使用开发库时是否显示已购课程。开发库里带着老数据,容易让人误以为自己买过课。 */
showOwnedOnDevelop: 'develop.show_owned_courses', showOwnedOnDevelop: 'develop.show_owned_courses',
/**
* 是否暂停支付(拦截下单)。
*
* 打开后所有下单直接返回「支付暂不可用」,不产生任何订单——临时停售或维护时用。
* 默认关(等于正常收款)。开关存在数据库里,所以每个库一份:
* 开发库通常是开的(防止误下单),生产库通常是关的。
*/
payBlocked: 'pay.blocked',
} as const; } as const;
export type SiteFlagKey = (typeof SITE_FLAG_KEYS)[keyof typeof SITE_FLAG_KEYS]; export type SiteFlagKey = (typeof SITE_FLAG_KEYS)[keyof typeof SITE_FLAG_KEYS];
/**
* 课程相关的报错文案 key(都存在 settings 表,后台可改)。
*
* 代码里同时保留默认值:配置被删或改空时仍然给用户一句能看懂的话。
*/
export const COURSE_TIP_KEYS = {
/** 课程不存在或已下架(详情页 / 下单) */
gone: 'course.tip.notFoundOrOffline',
/** 课程不存在(播放鉴权);小程序购买页也用它做兜底 */
notFound: 'course.tip.notFound',
/** 课程已下架 */
offline: 'course.tip.offline',
/** 课时不存在 */
lessonNotFound: 'course.tip.lessonNotFound',
/** 尚未拥有该课程 */
noEntitlement: 'course.tip.noEntitlement',
/** 课程已过期 */
expired: 'course.tip.expired',
/** 你已拥有该课程(下单时的兜底,小程序一般先在本地拦掉) */
alreadyOwned: 'course.tip.alreadyOwned',
/** 该课程尚未配置支付商品 */
noProduct: 'course.tip.noProduct',
/** 小程序切到开发库时不收钱,下单直接给这句 */
payUnavailable: 'course.tip.payUnavailable',
} as const;
/** 读一个布尔开关,缺省为 false。 */ /** 读一个布尔开关,缺省为 false。 */
export async function readFlag(prisma: PrismaService, key: SiteFlagKey): Promise<boolean> { export async function readFlag(prisma: PrismaService, key: SiteFlagKey): Promise<boolean> {
const row = await prisma.active.setting.findUnique({ where: { key } }); const row = await prisma.active.setting.findUnique({ where: { key } });
return row?.value === '1'; return row?.value === '1';
} }
/**
* 读一条文本配置。
*
* 只在报错路径上调用(比如课程被下架),所以不做缓存。
* 没有这一行或值为空时用兜底值——报错文案不能是空的。
*/
export async function readText(
prisma: PrismaService,
key: string,
fallback: string,
): Promise<string> {
const row = await prisma.active.setting.findUnique({ where: { key } });
const value = (row?.value ?? '').trim();
return value || fallback;
}
/** /**
* 「已购课程」现在该不该对外显示。 * 「已购课程」现在该不该对外显示。
* *
@@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../../config/config'; import { APP_CONFIG, type AppConfig } from '../../config/config';
import { AppError } from '../../common/errors'; import { AppError } from '../../common/errors';
import { showOwnedCourses } from '../../common/site-flags'; import { COURSE_TIP_KEYS, readText, showOwnedCourses } from '../../common/site-flags';
import { staticUrls } from '../../common/urls'; import { staticUrls } from '../../common/urls';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { MediaService } from '../media/media.service'; import { MediaService } from '../media/media.service';
@@ -53,7 +53,10 @@ export class CoursesService {
async detail(userId: number, code: string) { async detail(userId: number, code: string) {
const course = await this.prisma.active.course.findUnique({ where: { code } }); const course = await this.prisma.active.course.findUnique({ where: { code } });
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架'); if (!course || course.status !== 1) {
// 文案放后台配置里,改提示不用发版
throw AppError.notFound(await readText(this.prisma, COURSE_TIP_KEYS.gone, '课程不存在或已下架'));
}
const owned = const owned =
(await showOwnedCourses(this.prisma)) && (await showOwnedCourses(this.prisma)) &&
@@ -100,7 +103,9 @@ export class CoursesService {
const lesson = await this.prisma.active.lesson.findFirst({ const lesson = await this.prisma.active.lesson.findFirst({
where: { course_id: course.id, chapter_id: chapterId, play_id: playId }, where: { course_id: course.id, chapter_id: chapterId, play_id: playId },
}); });
if (!lesson) throw AppError.notFound('课时不存在'); if (!lesson) {
throw AppError.notFound(await readText(this.prisma, COURSE_TIP_KEYS.lessonNotFound, '课时不存在'));
}
const relative = `Course/${course.code}/${lesson.chapter_id}/${lesson.play_id}/playlist.m3u8`; const relative = `Course/${course.code}/${lesson.chapter_id}/${lesson.play_id}/playlist.m3u8`;
return { return {
@@ -134,15 +139,23 @@ export class CoursesService {
/** 取课程并校验权益,是唯一的鉴权入口。 */ /** 取课程并校验权益,是唯一的鉴权入口。 */
private async requireEntitlement(userId: number, code: string) { private async requireEntitlement(userId: number, code: string) {
const course = await this.prisma.active.course.findUnique({ where: { code } }); const course = await this.prisma.active.course.findUnique({ where: { code } });
if (!course) throw AppError.notFound('课程不存在'); if (!course) {
if (course.status !== 1) throw AppError.offline(); throw AppError.notFound(await readText(this.prisma, COURSE_TIP_KEYS.notFound, '课程不存在'));
}
if (course.status !== 1) {
throw AppError.offline(await readText(this.prisma, COURSE_TIP_KEYS.offline, '课程已下架'));
}
const owned = await this.prisma.active.entitlement.findUnique({ const owned = await this.prisma.active.entitlement.findUnique({
where: { user_id_course_id: { user_id: userId, course_id: course.id } }, where: { user_id_course_id: { user_id: userId, course_id: course.id } },
}); });
if (!owned) throw AppError.noEntitlement(); if (!owned) {
throw AppError.noEntitlement(
await readText(this.prisma, COURSE_TIP_KEYS.noEntitlement, '尚未拥有该课程'),
);
}
if (owned.expire_at && owned.expire_at.getTime() < Date.now()) { if (owned.expire_at && owned.expire_at.getTime() < Date.now()) {
throw AppError.noEntitlement('课程已过期'); throw AppError.noEntitlement(await readText(this.prisma, COURSE_TIP_KEYS.expired, '课程已过期'));
} }
return course; return course;
@@ -1,6 +1,7 @@
import { randomInt } from 'node:crypto'; import { randomInt } from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AppError, ErrorCode } from '../../common/errors'; import { AppError, ErrorCode } from '../../common/errors';
import { COURSE_TIP_KEYS, readFlag, readText, SITE_FLAG_KEYS } from '../../common/site-flags';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { OrderStatus, PaymentsService } from '../payments/payments.service'; import { OrderStatus, PaymentsService } from '../payments/payments.service';
import { WxPayService } from '../payments/wxpay.service'; import { WxPayService } from '../payments/wxpay.service';
@@ -34,9 +35,21 @@ export class OrdersService {
quantity = 1, quantity = 1,
terminal: string | null = null, terminal: string | null = null,
): Promise<{ outTradeNo: string; payData: PayData }> { ): Promise<{ outTradeNo: string; payData: PayData }> {
// 支付配置跟着「小程序当前数据源」走:切到开发库就用开发那套(默认沙箱),不会误收真钱 // 支付配置跟着「小程序当前数据源」走:生产库用现网那套
const source = this.prisma.activeEnv; const source = this.prisma.activeEnv;
const pay = this.wxpay.configFor(source); const pay = this.wxpay.configFor(source);
// 支付总开关:打开就拦下单(临时停售、维护、或开发库防止误下单)。
// 开关存在数据库里,小程序读哪个库就受哪个库的开关控制,所以开发库和生产库可以各设各的。
// 放在「支付是否配置」之前判断,保证暂停期间永远是同一句提示。
if (await readFlag(this.prisma, SITE_FLAG_KEYS.payBlocked)) {
throw new AppError(
ErrorCode.PAY_NOT_ENABLED,
await readText(this.prisma, COURSE_TIP_KEYS.payUnavailable, '支付暂不可用'),
503,
);
}
if (!this.wxpay.enabledFor(source)) { if (!this.wxpay.enabledFor(source)) {
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503); throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
} }
@@ -55,17 +68,26 @@ export class OrdersService {
} }
const course = await this.prisma.active.course.findUnique({ where: { code: courseCode } }); const course = await this.prisma.active.course.findUnique({ where: { code: courseCode } });
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架'); if (!course || course.status !== 1) {
// 与课程详情用同一条配置,两处提示保持一致
throw AppError.notFound(await readText(this.prisma, COURSE_TIP_KEYS.gone, '课程不存在或已下架'));
}
const owned = await this.prisma.active.entitlement.findUnique({ const owned = await this.prisma.active.entitlement.findUnique({
where: { user_id_course_id: { user_id: userId, course_id: course.id } }, where: { user_id_course_id: { user_id: userId, course_id: course.id } },
}); });
if (owned) throw AppError.conflict('你已拥有该课程'); if (owned) {
throw AppError.conflict(await readText(this.prisma, COURSE_TIP_KEYS.alreadyOwned, '你已拥有该课程'));
}
const product = await this.prisma.active.product.findFirst({ const product = await this.prisma.active.product.findFirst({
where: { course_id: course.id, status: 1 }, where: { course_id: course.id, status: 1 },
}); });
if (!product) throw AppError.notFound('该课程尚未配置支付商品'); if (!product) {
throw AppError.notFound(
await readText(this.prisma, COURSE_TIP_KEYS.noProduct, '该课程尚未配置支付商品'),
);
}
const outTradeNo = this.newOutTradeNo(); const outTradeNo = this.newOutTradeNo();
const amountFen = product.price_fen * quantity; const amountFen = product.price_fen * quantity;