feat: 完成分龄页开发
This commit is contained in:
@@ -14,6 +14,13 @@ const DEBUG_ENTRIES: DebugEntry[] = [
|
||||
icon: '🗂️',
|
||||
path: '/supportPages/categoryManage/categoryManage',
|
||||
},
|
||||
{
|
||||
id: 'worksheet-sync',
|
||||
title: 'Worksheet 同步',
|
||||
subtitle: '按本地配置批量同步年龄/标签,并重建 home+category 配置。',
|
||||
icon: '🔄',
|
||||
path: '/supportPages/worksheetSync/worksheetSync',
|
||||
},
|
||||
{
|
||||
id: 'category-content',
|
||||
title: '分类页内容管理',
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"navigationBarTitleText": "Worksheet 同步",
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarBackgroundColor": "#F8F0E0",
|
||||
"backgroundColor": "#F8F0E0",
|
||||
"enablePullDownRefresh": true,
|
||||
"usingComponents": {
|
||||
"toy-button": "/toy/button-v2/button"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
@import '../../style/theme.less';
|
||||
|
||||
page {
|
||||
min-height: 100%;
|
||||
background: @bg-header;
|
||||
}
|
||||
|
||||
.ws-sync-page {
|
||||
min-height: 100vh;
|
||||
padding: 24rpx;
|
||||
padding-bottom: 320rpx;
|
||||
background: @bg-header;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ws-sync-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.ws-sync-summary__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ws-sync-summary__title {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.ws-sync-summary__desc {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
line-height: 1.5;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
.ws-sync-summary__badge {
|
||||
flex-shrink: 0;
|
||||
margin-left: 20rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: 999rpx;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #605b50;
|
||||
background: fade(#8a8478, 15%);
|
||||
}
|
||||
|
||||
.ws-sync-summary__badge--ready {
|
||||
color: #4a7a12;
|
||||
background: fade(#93d333, 18%);
|
||||
}
|
||||
|
||||
.ws-sync-page__stats {
|
||||
display: flex;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
|
||||
.ws-sync-stat {
|
||||
flex: 1;
|
||||
padding: 24rpx 16rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
text-align: center;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.ws-sync-stat + .ws-sync-stat {
|
||||
margin-left: 16rpx;
|
||||
}
|
||||
|
||||
.ws-sync-stat__num {
|
||||
display: block;
|
||||
font-size: 32rpx;
|
||||
font-weight: 800;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.ws-sync-stat__label {
|
||||
display: block;
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: @text-secondary;
|
||||
}
|
||||
|
||||
.ws-sync-page__notice {
|
||||
margin-top: 24rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: @text-secondary;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.ws-sync-page__notice--error {
|
||||
color: #8a6d00;
|
||||
background: fade(#ffb703, 18%);
|
||||
}
|
||||
|
||||
.ws-sync-result {
|
||||
margin-top: 24rpx;
|
||||
padding: 28rpx;
|
||||
background: @bg-white;
|
||||
border-radius: @radius-xl;
|
||||
box-shadow: @shadow;
|
||||
}
|
||||
|
||||
.ws-sync-result__title {
|
||||
display: block;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: @text-title;
|
||||
}
|
||||
|
||||
.ws-sync-result__body {
|
||||
display: block;
|
||||
margin-top: 12rpx;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
color: @text-secondary;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.ws-sync-page__build {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
padding: 24rpx 40rpx;
|
||||
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
|
||||
background: @bg-header;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(50, 46, 37, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
type CloudFunctionResult<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
dryRun?: boolean;
|
||||
total?: number;
|
||||
failed?: number;
|
||||
results?: Array<{ id: string | null; ok: boolean; error?: string }>;
|
||||
};
|
||||
|
||||
async function callCloudFunction<T>(
|
||||
name: string,
|
||||
data?: Record<string, unknown>,
|
||||
): Promise<CloudFunctionResult<T>> {
|
||||
const response = (await wx.cloud.callFunction({
|
||||
name,
|
||||
data: data || {},
|
||||
})) as { result?: CloudFunctionResult<T> };
|
||||
return response.result || {};
|
||||
}
|
||||
|
||||
type LocalDef = {
|
||||
id: string;
|
||||
ageMin: number;
|
||||
ageMax: number;
|
||||
tags: readonly string[];
|
||||
};
|
||||
|
||||
function inferGradeFromAge(ageMin: number, ageMax: number): number {
|
||||
const centerAge = Math.round((ageMin + ageMax) / 2);
|
||||
const ageGradeMap: Record<number, number> = {
|
||||
2: -4,
|
||||
3: -3,
|
||||
4: -2,
|
||||
5: -1,
|
||||
6: 0,
|
||||
7: 1,
|
||||
8: 2,
|
||||
9: 3,
|
||||
10: 4,
|
||||
11: 5,
|
||||
12: 6,
|
||||
};
|
||||
|
||||
return ageGradeMap[centerAge] ?? 0;
|
||||
}
|
||||
|
||||
type ConfigModule = Record<string, readonly LocalDef[] | unknown>;
|
||||
|
||||
type ConfigSource = {
|
||||
label: string;
|
||||
modulePath: string;
|
||||
exportName: string;
|
||||
};
|
||||
|
||||
const CONFIG_SOURCES: ConfigSource[] = [
|
||||
{
|
||||
label: 'math',
|
||||
modulePath: '../../mathPages/mathDraw/mathDraw.config',
|
||||
exportName: 'MATH_WORKSHEET_DEFINITIONS',
|
||||
},
|
||||
{
|
||||
label: 'focus',
|
||||
modulePath: '../../focusPages/focusDraw/focusDraw.config',
|
||||
exportName: 'FOCUS_WORKSHEET_DEFINITIONS',
|
||||
},
|
||||
{
|
||||
label: 'letterTracing',
|
||||
modulePath: '../../englishPages/letterTracing/letterTracing.config',
|
||||
exportName: 'LETTER_TRACING_WORKSHEET_DEFINITIONS',
|
||||
},
|
||||
];
|
||||
|
||||
function isLocalDef(value: unknown): value is LocalDef {
|
||||
const row = value as Partial<LocalDef>;
|
||||
return (
|
||||
!!row &&
|
||||
typeof row.id === 'string' &&
|
||||
typeof row.ageMin === 'number' &&
|
||||
typeof row.ageMax === 'number' &&
|
||||
Array.isArray(row.tags)
|
||||
);
|
||||
}
|
||||
|
||||
async function loadConfigSource(source: ConfigSource): Promise<LocalDef[]> {
|
||||
const mod = (await require.async(source.modulePath)) as ConfigModule;
|
||||
const rows = mod[source.exportName];
|
||||
|
||||
if (!Array.isArray(rows) || !rows.every(isLocalDef)) {
|
||||
throw new Error(`${source.label} 配置格式不正确`);
|
||||
}
|
||||
|
||||
return [...rows];
|
||||
}
|
||||
|
||||
async function collectAll(): Promise<LocalDef[]> {
|
||||
const groups = await Promise.all(CONFIG_SOURCES.map(loadConfigSource));
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
function assertUniqueIds(rows: LocalDef[]) {
|
||||
const set = new Set<string>();
|
||||
for (const r of rows) {
|
||||
if (set.has(r.id)) {
|
||||
throw new Error(`本地配置存在重复 id:${r.id}`);
|
||||
}
|
||||
set.add(r.id);
|
||||
}
|
||||
}
|
||||
|
||||
function summarizeResult(
|
||||
title: string,
|
||||
result: CloudFunctionResult<unknown>,
|
||||
): string {
|
||||
if (!result.success) {
|
||||
return `${title}失败:${result.message || '未知错误'}`;
|
||||
}
|
||||
const total = result.total ?? 0;
|
||||
const failed = result.failed ?? 0;
|
||||
const ok = total - failed;
|
||||
const firstError = result.results?.find((r) => !r.ok)?.error;
|
||||
return `${title}完成:成功 ${ok}/${total}${
|
||||
failed ? `,失败 ${failed}(如:${firstError || '未知原因'})` : ''
|
||||
}`;
|
||||
}
|
||||
|
||||
Page({
|
||||
data: {
|
||||
configLoading: true,
|
||||
configReady: false,
|
||||
configError: '',
|
||||
configTotal: 0,
|
||||
configSourceCount: CONFIG_SOURCES.length,
|
||||
loadingAges: false,
|
||||
loadingTags: false,
|
||||
loadingRebuild: false,
|
||||
statusText: '配置加载中,请稍候',
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
void this.loadConfigs();
|
||||
},
|
||||
|
||||
async onPullDownRefresh() {
|
||||
await this.loadConfigs();
|
||||
wx.stopPullDownRefresh();
|
||||
},
|
||||
|
||||
async loadConfigs() {
|
||||
this.setData({
|
||||
configLoading: true,
|
||||
configReady: false,
|
||||
configError: '',
|
||||
statusText: '配置加载中,请稍候',
|
||||
});
|
||||
|
||||
try {
|
||||
const rows = await collectAll();
|
||||
assertUniqueIds(rows);
|
||||
this.setData({
|
||||
configLoading: false,
|
||||
configReady: true,
|
||||
configTotal: rows.length,
|
||||
statusText: `配置已加载:${rows.length} 个 worksheet,可执行同步`,
|
||||
});
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '配置加载失败';
|
||||
this.setData({
|
||||
configLoading: false,
|
||||
configReady: false,
|
||||
configError: msg,
|
||||
statusText: msg,
|
||||
});
|
||||
wx.showToast({ title: msg, icon: 'none' });
|
||||
}
|
||||
},
|
||||
|
||||
async sync(mode: 'ages' | 'tags') {
|
||||
if (!this.data.configReady) {
|
||||
wx.showToast({ title: '配置未加载完成', icon: 'none' });
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = await collectAll();
|
||||
assertUniqueIds(rows);
|
||||
|
||||
const patches =
|
||||
mode === 'ages'
|
||||
? rows.map((r) => ({
|
||||
id: r.id,
|
||||
ageMin: r.ageMin,
|
||||
ageMax: r.ageMax,
|
||||
grade: inferGradeFromAge(r.ageMin, r.ageMax),
|
||||
}))
|
||||
: rows.map((r) => ({
|
||||
id: r.id,
|
||||
tags: [...r.tags],
|
||||
}));
|
||||
|
||||
wx.showLoading({ title: '同步中...' });
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
|
||||
patches,
|
||||
dryRun: false,
|
||||
});
|
||||
|
||||
if (!patchRes.success) {
|
||||
throw new Error(patchRes.message || '同步失败');
|
||||
}
|
||||
|
||||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||
|
||||
const text = [
|
||||
summarizeResult(
|
||||
mode === 'ages'
|
||||
? '同步年龄(worksheetsBatchPatch)'
|
||||
: '同步标签(worksheetsBatchPatch)',
|
||||
patchRes,
|
||||
),
|
||||
'page-config 未自动重建,如需刷新前台展示请点击“重建配置”。',
|
||||
`耗时:${elapsed}s`,
|
||||
].join('\n');
|
||||
|
||||
this.setData({ statusText: text });
|
||||
wx.showToast({ title: '已完成', icon: 'success' });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '同步失败';
|
||||
this.setData({ statusText: msg });
|
||||
wx.showToast({ title: msg, icon: 'none' });
|
||||
} finally {
|
||||
wx.hideLoading();
|
||||
}
|
||||
},
|
||||
|
||||
async rebuildPageConfig() {
|
||||
wx.showLoading({ title: '重建中...' });
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const rebuildRes = await callCloudFunction('pageContentBuild', {
|
||||
page: 'all',
|
||||
});
|
||||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||||
|
||||
const text = [
|
||||
summarizeResult(
|
||||
'重建 page-config(pageContentBuild: all)',
|
||||
rebuildRes,
|
||||
),
|
||||
`耗时:${elapsed}s`,
|
||||
].join('\n');
|
||||
|
||||
this.setData({ statusText: text });
|
||||
if (!rebuildRes.success) {
|
||||
throw new Error(rebuildRes.message || '重建失败');
|
||||
}
|
||||
wx.showToast({ title: '已重建', icon: 'success' });
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message : '重建失败';
|
||||
this.setData({ statusText: msg });
|
||||
wx.showToast({ title: msg, icon: 'none' });
|
||||
} finally {
|
||||
wx.hideLoading();
|
||||
}
|
||||
},
|
||||
|
||||
async onSyncAges() {
|
||||
if (
|
||||
!this.data.configReady ||
|
||||
this.data.loadingAges ||
|
||||
this.data.loadingTags ||
|
||||
this.data.loadingRebuild
|
||||
)
|
||||
return;
|
||||
this.setData({ loadingAges: true });
|
||||
try {
|
||||
await this.sync('ages');
|
||||
} finally {
|
||||
this.setData({ loadingAges: false });
|
||||
}
|
||||
},
|
||||
|
||||
async onSyncTags() {
|
||||
if (
|
||||
!this.data.configReady ||
|
||||
this.data.loadingAges ||
|
||||
this.data.loadingTags ||
|
||||
this.data.loadingRebuild
|
||||
)
|
||||
return;
|
||||
this.setData({ loadingTags: true });
|
||||
try {
|
||||
await this.sync('tags');
|
||||
} finally {
|
||||
this.setData({ loadingTags: false });
|
||||
}
|
||||
},
|
||||
|
||||
async onRebuildConfig() {
|
||||
if (
|
||||
this.data.loadingAges ||
|
||||
this.data.loadingTags ||
|
||||
this.data.loadingRebuild
|
||||
)
|
||||
return;
|
||||
this.setData({ loadingRebuild: true });
|
||||
try {
|
||||
await this.rebuildPageConfig();
|
||||
} finally {
|
||||
this.setData({ loadingRebuild: false });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
<view class="ws-sync-page">
|
||||
<view class="ws-sync-summary">
|
||||
<view class="ws-sync-summary__info">
|
||||
<text class="ws-sync-summary__title">Worksheet 批量同步</text>
|
||||
<text class="ws-sync-summary__desc">
|
||||
同步本地配置到 worksheets,必要时单独重建 page-config 数据。
|
||||
</text>
|
||||
</view>
|
||||
<view
|
||||
class="ws-sync-summary__badge {{configReady ? 'ws-sync-summary__badge--ready' : ''}}">
|
||||
<text
|
||||
>{{configReady ? '可同步' : configLoading ? '加载中' :
|
||||
'需处理'}}</text
|
||||
>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="ws-sync-page__stats">
|
||||
<view class="ws-sync-stat">
|
||||
<text class="ws-sync-stat__num">{{configTotal}}</text>
|
||||
<text class="ws-sync-stat__label">配置项</text>
|
||||
</view>
|
||||
<view class="ws-sync-stat">
|
||||
<text class="ws-sync-stat__num">{{configSourceCount}}</text>
|
||||
<text class="ws-sync-stat__label">分包</text>
|
||||
</view>
|
||||
<view class="ws-sync-stat">
|
||||
<text class="ws-sync-stat__num"
|
||||
>{{configReady ? 'Ready' : '--'}}</text
|
||||
>
|
||||
<text class="ws-sync-stat__label">状态</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{configLoading}}" class="ws-sync-page__notice">
|
||||
正在异步加载 math / focus / letterTracing 配置...
|
||||
</view>
|
||||
|
||||
<view
|
||||
wx:elif="{{configError}}"
|
||||
class="ws-sync-page__notice ws-sync-page__notice--error">
|
||||
{{configError}}
|
||||
</view>
|
||||
|
||||
<view class="ws-sync-result">
|
||||
<text class="ws-sync-result__title">最近一次结果</text>
|
||||
<text class="ws-sync-result__body">{{statusText}}</text>
|
||||
</view>
|
||||
|
||||
<view class="ws-sync-page__build">
|
||||
<toy-button
|
||||
type="primary"
|
||||
width="100%"
|
||||
loading="{{loadingAges}}"
|
||||
disabled="{{!configReady || loadingAges || loadingTags || loadingRebuild}}"
|
||||
bindtap="onSyncAges">
|
||||
{{loadingAges ? '同步中...' : '同步年龄'}}
|
||||
</toy-button>
|
||||
<toy-button
|
||||
width="100%"
|
||||
loading="{{loadingTags}}"
|
||||
disabled="{{!configReady || loadingAges || loadingTags || loadingRebuild}}"
|
||||
bindtap="onSyncTags">
|
||||
{{loadingTags ? '同步中...' : '同步标签'}}
|
||||
</toy-button>
|
||||
<toy-button
|
||||
width="100%"
|
||||
loading="{{loadingRebuild}}"
|
||||
disabled="{{loadingAges || loadingTags || loadingRebuild}}"
|
||||
bindtap="onRebuildConfig">
|
||||
{{loadingRebuild ? '重建中...' : '重建配置'}}
|
||||
</toy-button>
|
||||
</view>
|
||||
</view>
|
||||
Reference in New Issue
Block a user