468 lines
14 KiB
TypeScript
468 lines
14 KiB
TypeScript
import { MOCK_SEED_COUNTS } from './mockLikes';
|
||
import {
|
||
MATH_WORKSHEET_DEFINITIONS,
|
||
CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
|
||
FOCUS_WORKSHEET_DEFINITIONS,
|
||
LETTER_TRACING_WORKSHEET_DEFINITIONS,
|
||
PINYIN_DICTATION_WORKSHEET_DEFINITIONS,
|
||
PEN_CONTROL_WORKSHEET_DEFINITIONS,
|
||
WORD_COLORING_WORKSHEET_DEFINITIONS,
|
||
type WorksheetDefinition,
|
||
} from '../../config/worksheets';
|
||
|
||
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 || {};
|
||
}
|
||
|
||
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 ConfigSource = {
|
||
label: string;
|
||
data: ReadonlyArray<WorksheetDefinition>;
|
||
};
|
||
|
||
const CONFIG_SOURCES: ConfigSource[] = [
|
||
{
|
||
label: 'math',
|
||
data: MATH_WORKSHEET_DEFINITIONS,
|
||
},
|
||
{
|
||
label: 'focus',
|
||
data: FOCUS_WORKSHEET_DEFINITIONS,
|
||
},
|
||
{
|
||
label: 'letterTracing',
|
||
data: LETTER_TRACING_WORKSHEET_DEFINITIONS,
|
||
},
|
||
{
|
||
label: 'pinyin',
|
||
data: PINYIN_DICTATION_WORKSHEET_DEFINITIONS,
|
||
},
|
||
{
|
||
label: 'penControl',
|
||
data: PEN_CONTROL_WORKSHEET_DEFINITIONS,
|
||
},
|
||
{
|
||
label: 'wordColoring',
|
||
data: WORD_COLORING_WORKSHEET_DEFINITIONS,
|
||
},
|
||
{
|
||
label: 'clockConnect',
|
||
data: CLOCK_CONNECT_WORKSHEET_DEFINITIONS,
|
||
},
|
||
];
|
||
|
||
function isWorksheetDefinition(value: unknown): value is WorksheetDefinition {
|
||
const row = value as Partial<WorksheetDefinition>;
|
||
return (
|
||
!!row &&
|
||
typeof row.id === 'string' &&
|
||
typeof row.ageMin === 'number' &&
|
||
typeof row.ageMax === 'number' &&
|
||
Array.isArray(row.tags)
|
||
);
|
||
}
|
||
|
||
async function loadConfigSource(
|
||
source: ConfigSource,
|
||
): Promise<WorksheetDefinition[]> {
|
||
const rows = source.data;
|
||
|
||
if (!Array.isArray(rows) || !rows.every(isWorksheetDefinition)) {
|
||
throw new Error(`${source.label} 配置格式不正确`);
|
||
}
|
||
|
||
return [...rows];
|
||
}
|
||
|
||
async function collectAll(): Promise<WorksheetDefinition[]> {
|
||
const groups = await Promise.all(CONFIG_SOURCES.map(loadConfigSource));
|
||
return groups.flat();
|
||
}
|
||
|
||
function assertUniqueIds(rows: WorksheetDefinition[]) {
|
||
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,
|
||
loadingSeed: false,
|
||
loadingResetStats: 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 });
|
||
}
|
||
},
|
||
|
||
async onSyncSeed() {
|
||
if (
|
||
this.data.loadingAges ||
|
||
this.data.loadingTags ||
|
||
this.data.loadingRebuild ||
|
||
this.data.loadingSeed
|
||
)
|
||
return;
|
||
|
||
this.setData({ loadingSeed: true });
|
||
wx.showLoading({ title: '同步 Seed...' });
|
||
const startedAt = Date.now();
|
||
|
||
try {
|
||
const patches = Object.entries(MOCK_SEED_COUNTS).map(
|
||
([id, counts]) => ({
|
||
id,
|
||
likes_seed: counts.likes,
|
||
downloads_seed: counts.downloads,
|
||
}),
|
||
);
|
||
|
||
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
|
||
patches,
|
||
dryRun: false,
|
||
});
|
||
|
||
if (!patchRes.success) {
|
||
throw new Error(patchRes.message || '同步 Seed 失败');
|
||
}
|
||
|
||
const elapsed = Math.round((Date.now() - startedAt) / 1000);
|
||
|
||
const text = [
|
||
summarizeResult(
|
||
'同步 Seed(likes_seed / downloads_seed)',
|
||
patchRes,
|
||
),
|
||
`共 ${patches.length} 条 Seed 数据`,
|
||
`耗时:${elapsed}s`,
|
||
].join('\n');
|
||
|
||
this.setData({ statusText: text });
|
||
wx.showToast({ title: '已完成', icon: 'success' });
|
||
} catch (error) {
|
||
const msg =
|
||
error instanceof Error ? error.message : '同步 Seed 失败';
|
||
this.setData({ statusText: msg });
|
||
wx.showToast({ title: msg, icon: 'none' });
|
||
} finally {
|
||
wx.hideLoading();
|
||
this.setData({ loadingSeed: false });
|
||
}
|
||
},
|
||
|
||
async onResetStats() {
|
||
if (
|
||
this.data.loadingAges ||
|
||
this.data.loadingTags ||
|
||
this.data.loadingRebuild ||
|
||
this.data.loadingSeed ||
|
||
this.data.loadingResetStats
|
||
)
|
||
return;
|
||
|
||
const { confirm } = await wx.showModal({
|
||
title: '确认初始化',
|
||
content:
|
||
'将所有 worksheet 的 likes 和 downloads 重置为 0,此操作不可撤销。确定继续?',
|
||
confirmText: '确定重置',
|
||
confirmColor: '#e53935',
|
||
});
|
||
if (!confirm) return;
|
||
|
||
this.setData({ loadingResetStats: true });
|
||
wx.showLoading({ title: '重置中...' });
|
||
const startedAt = Date.now();
|
||
|
||
try {
|
||
const queryRes = await callCloudFunction<Array<{ _id: string }>>(
|
||
'worksheetsQuery',
|
||
{},
|
||
);
|
||
|
||
if (!queryRes.success || !queryRes.data) {
|
||
throw new Error(queryRes.message || '查询 worksheet 列表失败');
|
||
}
|
||
|
||
const patches = (queryRes.data as Array<{ _id: string }>).map(
|
||
(ws) => ({
|
||
id: ws._id,
|
||
likes: 0,
|
||
downloads: 0,
|
||
}),
|
||
);
|
||
|
||
if (patches.length === 0) {
|
||
this.setData({ statusText: '没有需要重置的 worksheet' });
|
||
wx.showToast({ title: '无数据', icon: 'none' });
|
||
return;
|
||
}
|
||
|
||
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(
|
||
'初始化收藏和下载数据(likes / downloads → 0)',
|
||
patchRes,
|
||
),
|
||
`共 ${patches.length} 条 worksheet`,
|
||
`耗时:${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();
|
||
this.setData({ loadingResetStats: false });
|
||
}
|
||
},
|
||
});
|