文案配置化 + 支付开关

小程序
- 页面固定文案全部改读后台配置(购买、登录、资料、首页、分享、介绍页),代码里不再硬编码
- 已拥有课程时在当前页提示,不再跳购买页再提示
- 介绍页正文由代码常量改为读 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
+325 -103

No files matched your search

@@ -23,6 +23,58 @@ const SWITCHES = [
label: '使用开发库时,显示已购买课程',
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() {
@@ -85,6 +137,17 @@ export default function SettingsPage() {
// 开关项已经在上面单独渲染,别再以文本框的形式出现一次
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 (
<div className="space-y-4">
<h1 className="text-lg font-semibold text-gray-900">系统配置</h1>
@@ -93,7 +156,8 @@ export default function SettingsPage() {
<Card title="站点文案与开关">
<p className="mb-4 text-xs text-gray-500">
小程序首屏显示的标题、我的页面菜单文案、版本号等都在这里。修改后小程序下拉刷新即可生效。
小程序里的文案、以及服务端返回给用户的提示都在这里,按页面分组。点标题展开,改完点「保存」。
小程序端改完下拉刷新即可生效;服务端提示改完立即生效。
</p>
<div className="mb-5 space-y-4 border-b border-gray-100 pb-5">
@@ -132,25 +196,36 @@ export default function SettingsPage() {
})}
</div>
<div className="space-y-3">
{textRows.map((row) => (
<div key={row.key} className="flex items-center gap-3">
<div className="w-56 shrink-0">
<div className="text-sm text-gray-700">{row.remark || row.key}</div>
<div className="font-mono text-xs text-gray-400">{row.key}</div>
<div className="space-y-2">
{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 className="w-56 shrink-0">
<div className="text-sm text-gray-700">{row.remark || row.key}</div>
<div className="font-mono text-xs text-gray-400">{row.key}</div>
</div>
<div className="flex-1">
<Input
value={draft[row.key] ?? ''}
onChange={(v) => setDraft({ ...draft, [row.key]: v })}
/>
</div>
<Button variant="ghost" onClick={() => save(row)} disabled={savingKey === row.key}>
{savingKey === row.key ? '保存中…' : '保存'}
</Button>
</div>
))}
</div>
<div className="flex-1">
<Input
value={draft[row.key] ?? ''}
onChange={(v) => setDraft({ ...draft, [row.key]: v })}
/>
</div>
<Button variant="ghost" onClick={() => save(row)} disabled={savingKey === row.key}>
{savingKey === row.key ? '保存中…' : '保存'}
</Button>
</div>
</details>
))}
{textRows.length === 0 && (
{groups.length === 0 && (
<div className="py-6 text-center text-sm text-gray-400">暂无配置</div>
)}
</div>