feat: 完成全部接口替换为新后端接口

This commit is contained in:
R524809
2026-10-19 13:19:07 +08:00
parent 8de06ce66b
commit 997042789d
40 changed files with 1946 additions and 785 deletions
+54 -22
View File
@@ -1,7 +1,11 @@
import { fetchPageConfig } from './services/site-api';
import { normalizePreviewImages } from './config/assets';
import { defaultPrintConfig } from './config/config';
import { getUser } from './utils/auth';
import { generateUUID, getAppUUID, setAppUUID } from './utils/uuid';
let _refreshGeneration = 0;
const STORAGE_KEY_PAGE_CONFIG = 'pageConfig';
// Pending resolvers for getPageConfig() callers waiting on config
@@ -18,11 +22,12 @@ App<IAppOption>({
pageConfigReady: false,
},
onLaunch() {
wx.cloud.init({
env: 'cloud1-9gifs7a2756e2c87',
traceUser: true,
});
try {
wx.removeStorageSync('user_info');
wx.removeStorageSync('user_info_expire');
} catch {
/* 旧云登录缓存清理失败不影响新凭证命名空间。 */
}
const accountInfo = wx.getAccountInfoSync();
const env = accountInfo.miniProgram.envVersion || 'release';
this.globalData.env = env;
@@ -41,19 +46,22 @@ App<IAppOption>({
this.globalData.printConfig = printConfig;
}
// 静默登录(不阻塞页面渲染)
getUser()
.then((user) => {
this.globalData.user = user;
})
.catch((err) => {
console.error('静默登录失败', err);
});
// 启动与回到前台共用登录 Promise,不阻塞公开页面渲染。
this.warmAuthentication();
void this.loadPageConfig();
},
onShow() {},
onShow() {
this.warmAuthentication();
if (this.globalData.pageConfigReady) void this.refreshPageConfig();
},
warmAuthentication() {
void getUser().catch(() => {
console.warn('静默登录暂未完成,公开浏览仍可使用');
});
},
onHide() {},
@@ -100,20 +108,19 @@ App<IAppOption>({
}
}
// L3: 云函数兜底
// L3: 新后端动态兜底;微信预拉取机制和地址保持不变。
const hadCachedConfig = !!config;
if (!config) {
try {
const res = (await wx.cloud.callFunction({
name: 'pageConfigFetch',
})) as { result?: { success?: boolean; data?: any } };
if (res.result?.success && res.result.data) {
config = res.result.data;
}
config = await fetchPageConfig();
} catch {
// 云函数也失败,config 保持 null
/* 新后端不可用时继续展示空状态,本地生成仍可使用。 */
}
}
// 三种数据入口都先转换预览图,旧缓存会随成功加载写回新地址。
config = normalizePreviewImages(config);
// 写入 globalData
if (config) {
this.globalData.pageConfig = config;
@@ -131,6 +138,31 @@ App<IAppOption>({
resolve(config);
}
_configResolvers = [];
// 预拉取/缓存先渲染,再用新后端刷新,避免旧静态配置长期覆盖管理修改。
if (hadCachedConfig) void this.refreshPageConfig();
},
async refreshPageConfig() {
const generation = ++_refreshGeneration;
try {
const config = normalizePreviewImages(await fetchPageConfig());
if (generation !== _refreshGeneration) return;
this.globalData.pageConfig = config;
try {
wx.setStorageSync(STORAGE_KEY_PAGE_CONFIG, config);
} catch {
/* 缓存失败不影响当前数据。 */
}
for (const page of getCurrentPages()) {
if (
page.route === 'pages/home/home' &&
typeof page.initData === 'function'
)
void page.initData(config);
}
} catch {
/* 保留已显示的数据,网络错误不清空预拉取首屏。 */
}
},
getPageConfig(): Promise<PageConfigData | null> {
-6
View File
@@ -1,6 +0,0 @@
/**
* 桥接文件 - 保持向后兼容
* 实际实现已迁移到 platform/cloud-adapter.ts
*/
import { callCloud } from '../platform/cloud-adapter';
export default callCloud;
+25 -34
View File
@@ -1,3 +1,5 @@
import { uploadAsset } from '../services/member-api';
import { apiRequest } from '../services/api-request';
import { PAPER_SIZE } from '../constants/colors';
import { downloadPrint, tryUnlockWithRewardedAd } from '../utils/downloadPrint';
import { shouldShowShareGuide, shouldUnlockWithAd } from '../utils/shareGuide';
@@ -5,7 +7,6 @@ import { BaseDrawService } from '../core/draw/baseDraw';
import tracker from '../utils/tracker';
import { defaultShareConfig } from '../config/config';
import {
buildWorksheetPreviewCloudPath,
isDebugPublishEnabled,
type DebugPublishConfirmDetail,
type DebugPublishMeta,
@@ -117,8 +118,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
const normalized = route.startsWith('/') ? route : `/${route}`;
const data = page?.data || {};
const functionId = (data.functionId || data.worksheetId) as
| string
| undefined;
string | undefined;
const currentMode = data.currentMode as string | undefined;
if (!functionId) {
@@ -126,9 +126,7 @@ export function buildPageSharePath(page?: SharePathPage): string {
}
const modeQuery =
currentMode && currentMode !== functionId
? `&mode=${currentMode}`
: '';
currentMode && currentMode !== functionId ? `&mode=${currentMode}` : '';
return `${normalized}?id=${functionId}${modeQuery}`;
}
@@ -509,34 +507,28 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
settings: detail.settings,
});
const cloudPath = buildWorksheetPreviewCloudPath(
publishMeta.category,
publishMeta.id,
const asset = await uploadAsset(
processed.tempFilePath,
'preview',
);
const uploadRes = await wx.cloud.uploadFile({
cloudPath,
filePath: processed.tempFilePath,
});
const cloudCall = (await wx.cloud.callFunction({
name: 'worksheetsPublish',
const {
category,
previewImg: _oldPreview,
...metadata
} = publishMeta;
await apiRequest('/admin/worksheets/publish', {
method: 'POST',
data: {
...publishMeta,
previewImg: uploadRes.fileID,
...metadata,
categoryId: category,
previewAssetId: asset.id,
},
})) as {
result?: { success?: boolean; message?: string };
};
if (!cloudCall.result?.success) {
throw new Error(
cloudCall.result?.message || '云端写入失败',
);
}
});
this.setData({
debugPublishVisible: false,
debugPublishMeta: {
...publishMeta,
previewImg: uploadRes.fileID,
previewImg: asset.url,
},
});
@@ -566,8 +558,7 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
initPageInfo(
this: PageCanvasInstance,
functionIdOrOptions:
| string
| { title: string; desc?: string; functionId: string },
string | { title: string; desc?: string; functionId: string },
defaultTitle?: string,
) {
let functionId: string;
@@ -605,7 +596,9 @@ export function getPageCommonMethods(config: PageCommonMethodsConfig = {}) {
tracker.reportPreviewView(this.route || '', {
worksheet_id: functionId,
worksheet_title: title,
...(this.data.currentMode ? { mode: this.data.currentMode } : {}),
...(this.data.currentMode
? { mode: this.data.currentMode }
: {}),
});
},
};
@@ -670,8 +663,7 @@ export function applyPageMixin(
try {
const result = await originalDrawCanvas.apply(this, args);
const startedAt = (this as any)[generationStartKey] as
| number
| undefined;
number | undefined;
if (startedAt) {
delete (this as any)[generationStartKey];
const worksheetId =
@@ -693,8 +685,7 @@ export function applyPageMixin(
return result;
} catch (error) {
const startedAt = (this as any)[generationStartKey] as
| number
| undefined;
number | undefined;
if (startedAt) {
delete (this as any)[generationStartKey];
tracker.reportGenerateResult(this.route || '', false, {
+65
View File
@@ -0,0 +1,65 @@
/** 与后端及 asset-sync 的公开资源配置保持一致;此处不包含上传凭据。 */
export const assetConfig = {
publicDomain: 'https://cdn.joeyone.cn',
keyPrefix: 'doodle-assets/',
};
/** 固定字体和页面预览使用相对路径;接口返回的资源 URL 直接使用。 */
export function publicAssetUrl(relativePath: string): string {
const parts = relativePath.split('/');
if (
relativePath.includes('\\') ||
[...relativePath].some(
(char) => char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127,
) ||
parts.some((part) => !part || part === '.' || part === '..')
)
throw new Error('资源相对路径不正确');
const domain = assetConfig.publicDomain.replace(/\/+$/, '');
const prefix = assetConfig.keyPrefix.replace(/\/+$/, '');
return `${domain}/${[...(prefix ? prefix.split('/') : []), ...parts].map(encodeURIComponent).join('/')}`;
}
const LEGACY_PREVIEW_PREFIX =
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
const LEGACY_PREVIEW_HTTPS =
/^https?:\/\/636c-cloud1-9gifs7a2756e2c87-1351593184\.tcb\.qcloud\.la\/([^?#]+)(?:[?#].*)?$/;
/** 旧预拉取、缓存和云函数中的预览图指向已迁移的公开目录;其他资源不作替换。 */
export function resolvePreviewUrl(value: unknown): string {
if (typeof value !== 'string') return '';
const raw = value.trim();
let path: string;
if (raw.startsWith(LEGACY_PREVIEW_PREFIX)) {
path = raw.slice(LEGACY_PREVIEW_PREFIX.length);
if (/[?#]/.test(path)) return '';
} else {
const match = raw.match(LEGACY_PREVIEW_HTTPS);
if (!match) return raw;
path = match[1];
}
try {
path = path.split('/').map(decodeURIComponent).join('/');
if (!path.startsWith('assets/previews/')) return raw;
return publicAssetUrl(path);
} catch {
// 畸形路径不继续请求旧云存储,也不生成越界的 CDN 地址。
return '';
}
}
/** 统一处理预拉取/缓存中的嵌套 previewImg;不修改原数据、头像和其他云文件引用。 */
export function normalizePreviewImages<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => normalizePreviewImages(item)) as T;
}
if (!value || typeof value !== 'object') return value;
const result = { ...value } as Record<string, unknown>;
for (const key of Object.keys(result)) {
result[key] =
key === 'previewImg'
? resolvePreviewUrl(result[key])
: normalizePreviewImages(result[key]);
}
return result as T;
}
+5 -15
View File
@@ -3,36 +3,26 @@
*
* 使用 wx.loadFontFace 将字体注册到 Canvas native 渲染管线,
* 按 URL 去重,同一字体只加载一次。
* 支持 HTTP/HTTPS 直链和 cloud:// 云存储文件 ID。
* 仅使用已配置的 HTTPS 字体地址。
*/
/** 字体加载所需的最小描述 */
export interface FontFace {
/** wx.loadFontFace 的 family 参数 */
name: string;
/** 字体文件 URL(HTTP/HTTPS)或 cloud:// 文件 ID */
/** 字体文件 HTTPS URL */
url: string;
}
const _loadedIds = new Set<string>();
/**
* 将 cloud:// 文件 ID 解析为临时 HTTPS URL。
* 非 cloud:// 的 URL 原样返回。
*/
async function resolveUrl(raw: string): Promise<string> {
if (!raw.startsWith('cloud://')) return raw;
const res = await wx.cloud.getTempFileURL({ fileList: [raw] });
const file = res.fileList?.[0];
if (file?.tempFileURL) return file.tempFileURL;
throw new Error(`cloud file resolve failed: ${raw}`);
}
/** 加载字体,同一 url 只加载一次 */
export async function loadFontFace(font: FontFace): Promise<void> {
if (_loadedIds.has(font.url)) return;
const resolved = await resolveUrl(font.url);
if (!font.url.startsWith('https://') || /["\r\n]/.test(font.url))
throw new Error('字体地址须为 HTTPS');
const resolved = font.url;
return new Promise((resolve, reject) => {
wx.loadFontFace({
+4 -8
View File
@@ -1,3 +1,5 @@
import { publicAssetUrl } from '../../config/assets';
/**
* 字体渲染配置(Font Profile)
*
@@ -12,10 +14,7 @@
/** 字母分类 */
export type LetterCategory =
| 'uppercase'
| 'tallLower'
| 'descender'
| 'lowercase';
'uppercase' | 'tallLower' | 'descender' | 'lowercase';
/** 单个分类的渲染参数 */
export interface CategoryMetrics {
@@ -48,9 +47,6 @@ export function fontFamilyOf(profile: FontProfile): string {
return `${profile.name}, Roboto, sans-serif`;
}
const CLOUD_PREFIX =
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/';
// ── 字母分类集合 ──
const TALL_LOWERCASE = new Set(['b', 'd', 'h', 'k', 'l']);
const DESCENDERS = new Set(['g', 'p', 'q', 'y']);
@@ -213,7 +209,7 @@ export const PRINT_CLEARLY_DASHED: FontProfile = {
/** ToneOZ Pinyin 拼音字体(含声调字母、ü 变体等专用字形) */
export const TONEOZ_PINYIN: FontProfile = {
name: 'ToneOZPinyin',
url: `${CLOUD_PREFIX}assets/fonts/ToneOZ-Pinyin-Regular.ttf`,
url: publicAssetUrl('assets/fonts/ToneOZ-Pinyin-Regular.ttf'),
categories: {
uppercase: { scale: 1, baselineOffset: 0 },
tallLower: { scale: 1, baselineOffset: 0 },
+9 -15
View File
@@ -1,3 +1,5 @@
import { fetchAgeRecommendations } from '../../services/site-api';
import { resolvePreviewUrl } from '../../config/assets';
import { appSharePageMethods } from '../../base/pageMixin';
import { AGE_BANDS, AgeBandKey } from '../../core/data/difficulty';
import { parseMiniProgramUrl } from '../../utils/index';
@@ -91,24 +93,17 @@ async function fetchAgeRecommended(
ageKey: AgeBandKey,
): Promise<RecommendedItemView[]> {
try {
const res = await wx.cloud.callFunction({
name: 'ageRecommendedQuery',
data: { ageKey },
});
const payload = (res && (res.result as any)) || null;
if (!payload?.success) return [];
const items = (payload.data?.items || []) as any[];
const response = await fetchAgeRecommendations(ageKey);
const items = response.items;
return items
.map((it) => ({
title: String(it.title || ''),
image: String(it.previewImg || ''),
image: resolvePreviewUrl(it.previewImg),
subtitle: String(it.subtitle || ''),
ageBand: String(it.ageBand || it.ageRange || ''),
difficulty: toDifficultyLabel(
it.difficultyLabel || it.difficulty || '',
),
ageBand: `${it.ageMin}-${it.ageMax}岁`,
difficulty: toDifficultyLabel(it.difficulty || ''),
path: String(it.path || ''),
worksheetId: String(it.worksheetId || ''),
worksheetId: String(it.id || ''),
}))
.filter((it) => it.title);
} catch (e) {
@@ -174,8 +169,7 @@ Page({
onTapCard(e: WechatMiniprogram.CustomEvent) {
const detail = (e.detail || {}) as { title?: string; path?: string };
const path =
detail.path ||
(e.currentTarget.dataset.path as string | undefined);
detail.path || (e.currentTarget.dataset.path as string | undefined);
const title =
detail.title ||
(e.currentTarget.dataset.title as string | undefined);
+2 -1
View File
@@ -1,3 +1,4 @@
import { resolvePreviewUrl } from '../../config/assets';
import type { ApiCategory, ApiWorksheet } from '../../services/content-api';
export interface CategoryTab {
@@ -95,7 +96,7 @@ export function buildCategoryDataset(
title: row.title,
subtitle: row.subtitle,
icon: icons.get(row.category) || '📄',
previewImg: row.previewImg || '',
previewImg: resolvePreviewUrl(row.previewImg),
ageBand: `${row.ageMin}-${row.ageMax}岁`,
difficultyLabel: DIFFICULTY_LABELS[row.difficulty - 1] || '基础',
path: row.path,
+6
View File
@@ -222,6 +222,9 @@ Page({
this.setData({
favoriteList: records.map((r) => mapFavoriteToItem(r)),
});
} catch {
this.setData({ favoriteList: [] });
wx.showToast({ title: '收藏加载失败,请重试', icon: 'none' });
} finally {
this.setData({ favLoading: false });
}
@@ -232,6 +235,9 @@ Page({
try {
const records = await getDownloadLogs(1, DOWNLOAD_HISTORY_MAX);
this.setData({ downloadSections: groupDownloadsByDate(records) });
} catch {
this.setData({ downloadSections: [] });
wx.showToast({ title: '下载记录加载失败,请重试', icon: 'none' });
} finally {
this.setData({ dlLoading: false });
}
+8 -6
View File
@@ -1,3 +1,4 @@
import { publicAssetUrl } from '../../config/assets';
import { CATEGORY_LIST_WITH_ALL } from '../../core/data/categories';
import { AGE_BANDS } from '../../core/data/difficulty';
import { CategoryId } from '../../core/models/category';
@@ -91,8 +92,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '5-8岁',
difficulty: '基础',
icon: '📝',
previewImg:
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/chinese/hanzi-daily-checkin.jpg',
previewImg: publicAssetUrl(
'assets/previews/chinese/hanzi-daily-checkin.jpg',
),
path: '/chinesePages/handwritingSheet/handwritingSheet?id=hanzi-daily-checkin',
available: true,
},
@@ -104,8 +106,7 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '6-8岁',
difficulty: '基础',
icon: '🔤',
previewImg:
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/pinyin/pinyin-daily.jpg',
previewImg: publicAssetUrl('assets/previews/pinyin/pinyin-daily.jpg'),
path: '/pinyinPages/pinyinDictation/pinyinDictation?id=pinyin-daily',
available: true,
},
@@ -117,8 +118,9 @@ export const HOME_DAILY_CHECKIN_ITEMS: HomeDisplayItem[] = [
ageBand: '6-8岁',
difficulty: '基础',
icon: '🔠',
previewImg:
'cloud://cloud1-9gifs7a2756e2c87.636c-cloud1-9gifs7a2756e2c87-1351593184/assets/previews/english/letter-tracing-daily-checkin.jpg',
previewImg: publicAssetUrl(
'assets/previews/english/letter-tracing-daily-checkin.jpg',
),
path: '/englishPages/letterTracing/letterTracing?id=letter-tracing-daily-checkin',
available: true,
},
+20 -5
View File
@@ -1,3 +1,4 @@
import { resolvePreviewUrl } from '../../config/assets';
import { appSharePageMethods } from '../../base/pageMixin';
import { NAV_INNER_PX } from '../../utils/navMetrics';
import {
@@ -52,6 +53,7 @@ const TAB_BAR_PATHS = new Set([
function toItemView(item: HomeDisplayItem): HomeDisplayItemView {
return {
...item,
previewImg: resolvePreviewUrl(item.previewImg),
};
}
@@ -218,11 +220,11 @@ Page({
void this.initData();
},
async initData() {
wx.showLoading({ title: '加载中', mask: false });
async initData(override?: PageConfigData) {
if (!override) wx.showLoading({ title: '加载中', mask: false });
const app = getApp<IAppOption>();
const config = await app.getPageConfig();
const config = override || (await app.getPageConfig());
let homeData: HomeDisplayDataset;
@@ -249,13 +251,26 @@ Page({
searchPlaceholder: homeData.searchPlaceholder,
tabs,
ageBands,
activeAgeBand: homeData.ageBands[0]?.key || '3-4',
activeAgeBand:
override &&
homeData.ageBands.some(
(band) => band.key === this.data.activeAgeBand,
)
? this.data.activeAgeBand
: homeData.ageBands[0]?.key || '3-4',
activeTab: tabs.some((tab) => tab.id === this.data.activeTab)
? this.data.activeTab
: 'all',
heroSwiperCurrent: Math.min(
this.data.heroSwiperCurrent,
Math.max(0, homeData.featured.length - 1),
),
featuredItems: homeData.featured.map(toItemView),
hotRecommends: homeData.hot.slice(0, 3).map(toItemView),
sections,
});
wx.hideLoading();
if (!override) wx.hideLoading();
},
// ── Tab & Navigation ──
+15 -3
View File
@@ -123,7 +123,7 @@ page {
box-sizing: border-box;
}
.profile-hero__stat+.profile-hero__stat {
.profile-hero__stat + .profile-hero__stat {
margin-left: 32rpx;
}
@@ -164,7 +164,7 @@ page {
box-sizing: border-box;
}
.profile-grid__cell+.profile-grid__cell {
.profile-grid__cell + .profile-grid__cell {
margin-left: 32rpx;
}
@@ -461,4 +461,16 @@ page {
.profile-editor__action--disabled {
opacity: 0.6;
}
}
/* 仅在账户恢复需要用户主动确认时展示,正常静默登录不出现。 */
.profile-account-restart {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16rpx;
padding: 24rpx;
margin-bottom: @section-gap-xs;
border-radius: @radius-xl;
background: @bg-white;
font-size: 26rpx;
}
+52 -30
View File
@@ -1,7 +1,7 @@
import { uploadAsset, memberStats } from '../../services/member-api';
import { getUser as getServerUser, startAccount } from '../../utils/auth';
import { disablePageShareMenu } from '../../base/pageMixin';
import { APP_VERSION } from '../../config/config';
import { getDownloadLogCount } from '../../utils/downloadLogs';
import { getFavoriteCount } from '../../utils/favorites';
import {
getCachedUser,
getUser,
@@ -22,6 +22,8 @@ Page({
draftNickName: '',
draftAvatarUrl: '/assets/imgs/doodle-head.png',
isSavingProfile: false,
serverAccountActionRequired: false,
isStartingAccount: false,
},
onLoad() {
@@ -35,17 +37,52 @@ Page({
},
onShow() {
void this.checkServerAccount();
this.syncUserInfoFromApp();
void this.refreshStats();
},
/** 正常登录始终静默;账户不存在时才展示用户主动重新开始的入口。 */
async checkServerAccount() {
try {
await getServerUser();
this.setData({ serverAccountActionRequired: false });
} catch (error) {
const reason = (error as { reason?: string }).reason;
this.setData({
serverAccountActionRequired:
reason === 'ACCOUNT_NOT_FOUND' ||
reason === 'ACCOUNT_RESTART_REQUIRED',
});
}
},
async onStartServerAccount() {
if (this.data.isStartingAccount) return;
this.setData({ isStartingAccount: true });
try {
await startAccount();
this.setData({ serverAccountActionRequired: false });
wx.showToast({ title: '账户已就绪', icon: 'success' });
} catch {
wx.showToast({ title: '账户暂不可用,请稍后重试', icon: 'none' });
} finally {
this.setData({ isStartingAccount: false });
}
},
async syncUserInfoFromApp() {
const app = getApp<IAppOption>();
const cachedUser = app.globalData.user || getCachedUser();
if (cachedUser) {
this.applyUserInfo(cachedUser);
}
if (cachedUser) this.applyUserInfo(cachedUser);
else
this.setData({
userName: '微信用户',
avatarUrl: this.DEFAULT_AVATAR_URL,
printCount: 0,
favoriteCount: 0,
});
try {
const latestUser = await getUser();
@@ -69,13 +106,11 @@ Page({
async refreshStats() {
try {
const [printCount, favoriteCount] = await Promise.all([
getDownloadLogCount(),
getFavoriteCount(),
]);
const stats = await memberStats();
const { downloadCount: printCount, favoriteCount } = stats;
this.setData({ printCount, favoriteCount });
} catch (e) {
console.error('refreshStats failed', e);
} catch {
this.setData({ printCount: 0, favoriteCount: 0 });
}
},
@@ -121,12 +156,12 @@ Page({
this.setData({ isSavingProfile: true });
try {
const avatarUrl = await this.persistAvatarIfNeeded(
const avatarAssetId = await this.persistAvatarIfNeeded(
this.data.draftAvatarUrl,
);
const user = await updateUserProfile({
nickName,
avatarUrl,
avatarAssetId,
});
this.applyUserInfo(user);
@@ -140,24 +175,11 @@ Page({
}
},
async persistAvatarIfNeeded(avatarUrl: string): Promise<string> {
if (!avatarUrl) return avatarUrl;
if (
avatarUrl.startsWith('cloud://') ||
avatarUrl.startsWith('http://') ||
avatarUrl.startsWith('https://')
) {
return avatarUrl;
}
async persistAvatarIfNeeded(avatarUrl: string): Promise<string | null> {
const user = await getUser();
const cloudPath = `user-avatars/${user._id}-${Date.now()}.png`;
const uploadRes = await wx.cloud.uploadFile({
cloudPath,
filePath: avatarUrl,
});
return uploadRes.fileID;
if (!avatarUrl || avatarUrl === this.DEFAULT_AVATAR_URL) return null;
if (avatarUrl === user.avatarUrl) return user.avatarAssetId || null;
return (await uploadAsset(avatarUrl, 'avatar')).id;
},
onGoToDownloads() {
+13
View File
@@ -38,6 +38,19 @@
</view>
</view>
<view
wx:if="{{serverAccountActionRequired}}"
class="profile-account-restart">
<text>账户需要重新开始使用</text>
<button
size="mini"
loading="{{isStartingAccount}}"
disabled="{{isStartingAccount}}"
bindtap="onStartServerAccount">
开始使用账户
</button>
</view>
<!-- 双格入口 -->
<view class="profile-grid">
<view class="profile-grid__cell" bindtap="onGoToFavorites">
-70
View File
@@ -1,70 +0,0 @@
/**
* 云服务平台适配器
* 封装微信云开发 API
*/
export interface CloudResult {
code?: string | number;
message?: string;
info?: string;
data?: any;
[key: string]: any;
}
/**
* 调用云函数
*/
export async function callCloud(
action: string,
options?: Record<string, any>,
): Promise<CloudResult> {
try {
const name = 'doodle';
const { errMsg, result } = await wx.cloud.callFunction({
name,
data: {
action,
...options,
},
});
if (!/ok/.test(errMsg)) {
wx.showToast({
title: '服务器错误',
icon: 'none',
duration: 2000,
});
return {
code: '500',
info: '服务器繁忙',
message: errMsg,
};
}
return result as CloudResult;
} catch (e: any) {
return { code: '500', info: e.message || '服务器繁忙' };
}
}
/**
* 查询云数据库集合
*/
export async function queryCollection(
collection: string,
where?: Record<string, any>,
limit: number = 20,
orderBy?: { field: string; order: 'asc' | 'desc' },
): Promise<any[]> {
try {
const db = wx.cloud.database();
let query = db.collection(collection).where(where || {}).limit(limit);
if (orderBy) {
query = query.orderBy(orderBy.field, orderBy.order);
}
const { data } = await query.get();
return data;
} catch (e) {
console.error('queryCollection failed:', collection, e);
return [];
}
}
+170
View File
@@ -0,0 +1,170 @@
import { apiRequest } from './api-request';
import type { ApiWorksheet } from './content-api';
export type AdminAction =
| 'categories.list'
| 'categories.create'
| 'categories.update'
| 'worksheets.list'
| 'worksheets.status'
| 'worksheets.batch'
| 'site.config'
| 'site.rebuild'
| 'site.timer';
interface AdminWorksheet extends ApiWorksheet {
status: 'draft' | 'active' | 'hidden';
likesDisplay: string;
downloadsDisplay: string;
likesSeed: string;
downloadsSeed: string;
}
interface AdminPage {
items: AdminWorksheet[];
total: number;
hasNext: boolean;
page: number;
pageSize: number;
}
/** 管理页迁移适配:只调用具名 REST 接口,旧页面展示字段在客户端映射,不传旧用户身份。 */
export async function managementRequest<T>(
action: AdminAction,
payload: Record<string, unknown> = {},
): Promise<{
success: boolean;
data?: T;
stats?: { total: number; active: number; draft: number; hidden: number };
message?: string;
}> {
let data: unknown;
let stats;
switch (action) {
case 'categories.list': {
const rows =
await apiRequest<Array<{ id: string }>>('/admin/categories');
data = rows.map((row) => ({ ...row, _id: row.id }));
break;
}
case 'categories.create':
data = await apiRequest('/admin/categories', {
method: 'POST',
data: payload,
});
break;
case 'categories.update': {
const { id, ...fields } = payload;
data = await apiRequest(
`/admin/categories/${encodeURIComponent(String(id))}`,
{ method: 'PUT', data: fields },
);
break;
}
case 'worksheets.list': {
const rows: AdminWorksheet[] = [];
let expected: number | undefined;
for (let page = 1; ; page++) {
const response = await apiRequest<AdminPage>(
'/admin/catalog/worksheets',
{
data: {
page,
pageSize: 100,
...(payload.category
? { categoryId: payload.category }
: {}),
...(payload.status
? { status: payload.status }
: {}),
},
},
);
if (
response.page !== page ||
response.pageSize !== 100 ||
!Array.isArray(response.items) ||
!Number.isSafeInteger(response.total) ||
(expected !== undefined && response.total !== expected) ||
response.hasNext !== page * 100 < response.total
)
throw new Error('管理目录发生变化,请重试');
expected = response.total;
rows.push(...response.items);
if (!response.hasNext) break;
}
if (
rows.length !== expected ||
new Set(rows.map((row) => row.id)).size !== rows.length
)
throw new Error('管理目录发生变化,请重试');
data = rows.map((row) => ({
...row,
_id: row.id,
likes: payload.rawStats ? row.likes : row.likesDisplay,
downloads: payload.rawStats
? row.downloads
: row.downloadsDisplay,
}));
stats = {
total: rows.length,
active: rows.filter((row) => row.status === 'active').length,
draft: rows.filter((row) => row.status === 'draft').length,
hidden: rows.filter((row) => row.status === 'hidden').length,
};
break;
}
case 'worksheets.status':
data = await apiRequest(
`/admin/worksheets/${encodeURIComponent(String(payload.id))}/status`,
{ method: 'PUT', data: { status: payload.status } },
);
break;
case 'worksheets.batch': {
const patches = (
payload.patches as Array<Record<string, unknown>>
).map(({ likes_seed, downloads_seed, ...row }) => ({
...row,
...(likes_seed === undefined ? {} : { likesSeed: likes_seed }),
...(downloads_seed === undefined
? {}
: { downloadsSeed: downloads_seed }),
}));
data = await apiRequest('/admin/worksheets/batch', {
method: 'POST',
timeout: 60000,
data: { patches, dryRun: payload.dryRun === true },
});
break;
}
case 'site.config':
data = await apiRequest('/admin/site/config');
break;
case 'site.rebuild':
data = await apiRequest('/admin/site/rebuild', {
method: 'POST',
timeout: 60000,
data: payload,
});
break;
case 'site.timer':
data = await apiRequest('/admin/site/timer', {
method: 'PUT',
data: { enabled: payload.enabled },
});
break;
default:
throw new Error('不支持的管理操作');
}
if (
[
'categories.create',
'categories.update',
'worksheets.status',
'worksheets.batch',
'site.rebuild',
].includes(action)
) {
const app = getApp<IAppOption>();
if (typeof app.refreshPageConfig === 'function')
await app.refreshPageConfig();
}
return { success: true, data: data as T, ...(stats ? { stats } : {}) };
}
+93 -65
View File
@@ -1,74 +1,102 @@
import { getApiConfig } from '../config/api';
import {
ApiRequestError,
requestApi,
type RequestOptions,
} from './api-transport';
import {
cachedServerUser,
clearSession,
ensureSession,
getAuthContext,
recoverSession,
rejectSession,
} from './auth-session';
export { ApiRequestError } from './api-transport';
interface ApiEnvelope<T> {
code: number;
message: string;
data: T;
requestId?: string;
}
const recoverable = new Set([
'AUTH_REQUIRED',
'AUTH_INVALID',
'AUTH_EXPIRED',
'AUTH_REVOKED',
]);
const blocked = new Set([
'ACCOUNT_NOT_FOUND',
'ACCOUNT_DISABLED',
'WECHAT_LOGIN_BLOCKED',
]);
/** 保留有限的状态用于页面反馈,不把服务器原始响应或凭证输出到界面。 */
export class ApiRequestError extends Error {
constructor(
message: string,
readonly statusCode: number = 0,
) {
super(message);
this.name = 'ApiRequestError';
}
}
/** 新后端统一请求层:校验 HTTP 与业务状态,旧云函数/CDN 请求保持独立。 */
/** 公开接口显式 auth=none;其余接口合并登录,最多恢复并重发一次,不跨账户补发。 */
export async function apiRequest<T>(
path: string,
options: {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: Record<string, unknown>;
header?: Record<string, string>;
} = {},
options: RequestOptions & { auth?: 'required' | 'none' } = {},
): Promise<T> {
const config = getApiConfig();
if (!path.startsWith('/') || path.startsWith('//'))
throw new Error('接口路径不正确');
return new Promise((resolve, reject) => {
wx.request<ApiEnvelope<T>>({
url: `${config.baseUrl}${path}`,
method: options.method || 'GET',
data: options.data,
header: { 'Content-Type': 'application/json', ...options.header },
timeout: config.timeout,
success(res) {
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(
new ApiRequestError(
res.statusCode === 404
? '内容暂不可用'
: '接口请求失败,请稍后重试',
res.statusCode,
),
);
return;
}
if (
!res.data ||
res.data.code !== 0 ||
!Object.prototype.hasOwnProperty.call(res.data, 'data')
) {
reject(
new ApiRequestError(
'接口返回异常,请稍后重试',
res.statusCode,
),
);
return;
}
resolve(res.data.data);
// Authorization 由会话层统一提供;公开请求不附带调用方误传的凭证。
const header = Object.fromEntries(
Object.entries(options.header || {}).filter(
([key]) => key.toLowerCase() !== 'authorization',
),
);
if (options.auth === 'none')
return requestApi(path, { ...options, header }, getApiConfig());
const context = getAuthContext();
const previousUserId = cachedServerUser()?.id;
const initial = await ensureSession(context);
if (previousUserId && initial.user.id !== previousUserId) {
clearSession(false, context);
throw new ApiRequestError(
'账户已变化,请重新操作',
401,
'ACCOUNT_CHANGED',
);
}
const send = async (accessToken: string) => {
if (getAuthContext().key !== context.key)
throw new ApiRequestError('接口环境已变化,请重新操作');
const result = await requestApi<T>(
path,
{
...options,
header: { ...header, Authorization: `Bearer ${accessToken}` },
},
fail() {
reject(
new ApiRequestError('连接失败,请确认后端已启动及网络配置'),
context,
);
if (
getAuthContext().key !== context.key ||
cachedServerUser()?.id !== initial.user.id
)
throw new ApiRequestError('账户状态已变化,请重新操作');
return result;
};
try {
return await send(initial.accessToken);
} catch (error) {
if (!(error instanceof ApiRequestError) || error.statusCode !== 401)
throw error;
if (!recoverable.has(error.reason)) {
if (blocked.has(error.reason))
rejectSession(context, initial.accessToken, true);
throw error;
}
const renewed = await recoverSession(context, initial);
if (renewed.user.id !== initial.user.id) {
clearSession(false, context);
throw new ApiRequestError(
'账户已变化,请重新操作',
401,
'ACCOUNT_CHANGED',
);
}
try {
return await send(renewed.accessToken);
} catch (second) {
if (second instanceof ApiRequestError && second.statusCode === 401)
rejectSession(
context,
renewed.accessToken,
blocked.has(second.reason),
);
},
});
});
throw second;
}
}
}
+100
View File
@@ -0,0 +1,100 @@
import { getApiConfig } from '../config/api';
export class ApiRequestError extends Error {
constructor(
message: string,
readonly statusCode = 0,
readonly reason = '',
) {
super(message);
this.name = 'ApiRequestError';
}
}
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
data?: Record<string, unknown>;
header?: Record<string, string>;
timeout?: number;
upload?: { filePath: string; uploadId: string };
}
/** 底层只发请求,不触发登录;登录接口与鉴权包装共用,避免循环依赖。 */
export function requestApi<T>(
path: string,
options: RequestOptions = {},
config = getApiConfig(),
): Promise<T> {
if (!path.startsWith('/') || path.startsWith('//'))
throw new Error('接口路径不正确');
return new Promise((resolve, reject) => {
const success = (res: { statusCode: number; data: unknown }) => {
const body = res.data as {
code?: number;
data?: T;
reason?: string;
} | null;
if (res.statusCode < 200 || res.statusCode >= 300) {
reject(
new ApiRequestError(
res.statusCode === 404
? '内容暂不可用'
: '接口请求失败,请稍后重试',
res.statusCode,
typeof body?.reason === 'string' &&
/^[A-Z_]{1,64}$/.test(body.reason)
? body.reason
: '',
),
);
} else if (
!body ||
body.code !== 0 ||
!Object.prototype.hasOwnProperty.call(body, 'data')
) {
reject(
new ApiRequestError(
'接口返回异常,请稍后重试',
res.statusCode,
),
);
} else resolve(body.data as T);
};
const fail = () =>
reject(new ApiRequestError('连接失败,请确认后端已启动及网络配置'));
if (options.upload) {
// wx.uploadFile 由运行时生成 multipart boundary,不能自行设置 JSON Content-Type。
wx.uploadFile({
url: `${config.baseUrl}${path}`,
filePath: options.upload.filePath,
name: 'file',
formData: { uploadId: options.upload.uploadId },
header: options.header,
timeout: 60000,
success(res) {
let data: unknown;
try {
data = JSON.parse(res.data);
} catch {
reject(new ApiRequestError('上传响应格式异常'));
return;
}
success({ statusCode: res.statusCode, data });
},
fail,
});
} else
wx.request({
url: `${config.baseUrl}${path}`,
method: options.method || 'GET',
data: options.data,
header: {
'Content-Type': 'application/json',
...options.header,
},
timeout: options.timeout ?? config.timeout,
success,
fail,
});
});
}
+301
View File
@@ -0,0 +1,301 @@
import { getApiConfig } from '../config/api';
import { ApiRequestError, requestApi } from './api-transport';
export interface ServerUser {
id: string;
nickName: string | null;
avatarUrl: string | null;
avatarAssetId?: string | null;
avatarUrlExpiresAt: string | null;
createdAt: string;
}
export interface AuthSession {
accessToken: string;
expiresAt: number;
user: ServerUser;
}
type Context = ReturnType<typeof getAuthContext>;
type History = 'new' | 'used' | 'blocked';
interface SessionState {
session: AuthSession | null;
history: History;
generation: number;
flight?: Promise<AuthSession>;
}
const states = new Map<string, SessionState>();
const EARLY_RENEW_MS = 60000;
/** 环境、完整接口入口和 AppID 隔离;正式版不会读取开发版凭证。 */
export function getAuthContext() {
const config = getApiConfig();
const appId = wx.getAccountInfoSync().miniProgram.appId;
if (!appId) throw new ApiRequestError('无法识别小程序身份');
return {
...config,
key: `doodle_auth_v1:${encodeURIComponent(appId)}:${encodeURIComponent(config.baseUrl)}`,
};
}
/** 用户资料白名单只供展示,不缓存角色、微信身份或服务端内部字段。 */
export function parseServerUser(value: unknown): ServerUser {
const row = value as ServerUser | null;
if (
!row ||
typeof row.id !== 'string' ||
!row.id ||
(row.nickName !== null && typeof row.nickName !== 'string') ||
(row.avatarUrl !== null && typeof row.avatarUrl !== 'string') ||
(row.avatarUrlExpiresAt !== null &&
typeof row.avatarUrlExpiresAt !== 'string') ||
typeof row.createdAt !== 'string' ||
!Number.isFinite(Date.parse(row.createdAt))
)
throw new ApiRequestError('登录资料格式异常');
return {
id: row.id,
nickName: row.nickName,
avatarUrl: row.avatarUrl,
avatarAssetId:
typeof row.avatarAssetId === 'string' ? row.avatarAssetId : null,
avatarUrlExpiresAt: row.avatarUrlExpiresAt,
createdAt: row.createdAt,
};
}
function readState(context: Context): SessionState {
const existing = states.get(context.key);
if (existing) return existing;
const state: SessionState = {
session: null,
history: 'new',
generation: 0,
};
try {
const stored = wx.getStorageSync(context.key);
if (stored) {
state.history = 'used';
if (
stored.version === 1 &&
['new', 'used', 'blocked'].includes(stored.history)
) {
state.history = stored.history;
const session = stored.session;
if (
state.history === 'used' &&
session &&
typeof session.accessToken === 'string' &&
session.accessToken &&
!/\s/.test(session.accessToken) &&
Number.isFinite(session.expiresAt)
)
state.session = {
accessToken: session.accessToken,
expiresAt: session.expiresAt,
user: parseServerUser(session.user),
};
}
}
} catch {
/* 损坏资料不作为有效凭证;保留已使用账户标记。 */
}
states.set(context.key, state);
return state;
}
function syncGlobal(context: Context, user: ServerUser | null) {
try {
if (getAuthContext().key === context.key)
getApp<IAppOption>().globalData.user = user;
} catch {
/* App 未初始化时不影响认证流程。 */
}
}
function persist(context: Context, state: SessionState) {
try {
wx.setStorageSync(context.key, {
version: 1,
history: state.history,
session: state.session,
});
} catch {
/* Storage 不可写时仍可使用本次内存会话,不记录任何凭据。 */
}
syncGlobal(context, state.session?.user || null);
}
function usable(session: AuthSession | null): session is AuthSession {
return !!session && session.expiresAt > Date.now() + EARLY_RENEW_MS;
}
function wechatCode(): Promise<string> {
return new Promise((resolve, reject) =>
wx.login({
timeout: 10000,
success(res) {
res.code
? resolve(res.code)
: reject(new ApiRequestError('微信登录失败'));
},
fail() {
reject(new ApiRequestError('微信登录失败'));
},
}),
);
}
/** 合并启动、续期及 401 恢复;每次尝试取新 code,明确无效 code 最多补试一次。 */
export async function ensureSession(
context = getAuthContext(),
force = false,
explicitStart = false,
): Promise<AuthSession> {
if (getAuthContext().key !== context.key)
throw new ApiRequestError('接口环境已变化,请重新操作');
const state = readState(context);
if (state.history === 'blocked' && !explicitStart)
throw new ApiRequestError(
'账户需要重新确认后才能使用',
401,
'ACCOUNT_RESTART_REQUIRED',
);
if (!force && usable(state.session)) {
syncGlobal(context, state.session.user);
return state.session;
}
if (state.flight) return state.flight;
const generation = state.generation;
const mode = explicitStart || state.history === 'new' ? 'start' : 'resume';
const flight = (async () => {
try {
let result:
| {
accessToken: string;
tokenType: string;
expiresIn: number;
expiresAt: string;
user: ServerUser;
}
| undefined;
for (let attempt = 0; attempt < 2; attempt++) {
try {
result = await requestApi(
'/auth/wechat/login',
{
method: 'POST',
data: { code: await wechatCode(), mode },
},
context,
);
break;
} catch (error) {
if (!(
error instanceof ApiRequestError &&
error.reason === 'WECHAT_CODE_INVALID' &&
attempt === 0
))
throw error;
}
}
if (
!result ||
result.tokenType !== 'Bearer' ||
typeof result.accessToken !== 'string' ||
!result.accessToken ||
/\s/.test(result.accessToken) ||
!Number.isSafeInteger(result.expiresIn) ||
result.expiresIn < 60 ||
result.expiresIn > 604800 ||
!Number.isFinite(Date.parse(result.expiresAt))
)
throw new ApiRequestError('登录响应格式异常');
const session = {
accessToken: result.accessToken,
expiresAt: Date.now() + result.expiresIn * 1000,
user: parseServerUser(result.user),
};
// 清理、切换环境期间迟到的登录响应不得恢复会话或写入当前界面。
if (
state.generation !== generation ||
getAuthContext().key !== context.key
)
throw new ApiRequestError('登录状态已变化,请重新操作');
state.session = session;
state.history = 'used';
persist(context, state);
return session;
} catch (error) {
if (
state.generation === generation &&
error instanceof ApiRequestError &&
[
'ACCOUNT_NOT_FOUND',
'ACCOUNT_DISABLED',
'WECHAT_LOGIN_BLOCKED',
].includes(error.reason)
) {
state.session = null;
state.history = 'blocked';
persist(context, state);
}
throw error;
}
})();
state.flight = flight;
try {
return await flight;
} finally {
if (state.flight === flight) state.flight = undefined;
}
}
/** 同一原请求只恢复一次;迟到的旧 401 直接复用较新凭证。 */
export async function recoverSession(context: Context, failed: AuthSession) {
const state = readState(context);
if (
usable(state.session) &&
state.session.accessToken !== failed.accessToken
)
return state.session;
// 已获得过凭证就只恢复账户,绝不在恢复失败后自动切回 start 建号。
state.session = null;
state.history = state.history === 'blocked' ? 'blocked' : 'used';
persist(context, state);
return ensureSession(context, true);
}
/** 清理保留账户历史;注销/账号消失后只能由用户主动调用 startAccount 再建号。 */
export function clearSession(blocked = false, context = getAuthContext()) {
const state = readState(context);
state.generation++;
state.flight = undefined;
state.session = null;
state.history = blocked
? 'blocked'
: state.history === 'new'
? 'new'
: 'used';
persist(context, state);
}
export function cachedServerUser(): ServerUser | null {
const context = getAuthContext();
return readState(context).session?.user || null;
}
/** GET /me 结果只更新同一环境、同一用户的现有会话,不混入旧云业务资料。 */
export function updateSessionUser(
value: unknown,
context = getAuthContext(),
): ServerUser {
const user = parseServerUser(value);
const state = readState(context);
if (!state.session || state.session.user.id !== user.id)
throw new ApiRequestError('账户已变化,请重新操作');
state.session = { ...state.session, user };
persist(context, state);
return user;
}
/** 只清理本次请求使用的凭证;旧请求迟到的错误不能撤销新登录。 */
export function rejectSession(
context: Context,
token: string,
blocked: boolean,
) {
if (readState(context).session?.accessToken === token)
clearSession(blocked, context);
}
+5 -1
View File
@@ -36,7 +36,9 @@ interface WorksheetPage {
/** 分类直接由服务端发布状态决定,不用本地目录补齐或恢复下架分类。 */
export async function listCategories(): Promise<ApiCategory[]> {
const rows = await apiRequest<ApiCategory[]>('/categories');
const rows = await apiRequest<ApiCategory[]>('/categories', {
auth: 'none',
});
if (
!Array.isArray(rows) ||
rows.some(
@@ -87,6 +89,7 @@ export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
let expectedTotal: number | undefined;
for (let page = 1; ; page++) {
const result = await apiRequest<WorksheetPage>('/worksheets', {
auth: 'none',
data: { page, pageSize },
});
if (
@@ -121,6 +124,7 @@ export async function listAllWorksheets(): Promise<ApiWorksheet[]> {
export async function getWorksheet(id: string): Promise<ApiWorksheet> {
const row = await apiRequest<ApiWorksheet>(
`/worksheets/${encodeURIComponent(id)}`,
{ auth: 'none' },
);
validateWorksheet(row);
if (row.id !== id) throw new Error('题型数据异常,请稍后重试');
+53
View File
@@ -0,0 +1,53 @@
import { apiRequest } from './api-request';
import { generateUUID } from '../utils/uuid';
import type { ApiWorksheet } from './content-api';
export interface UserStats {
favoriteCount: number;
downloadCount: number;
totalDownloads: string;
}
export interface MemberRecord {
id: string;
worksheetId: string;
exportId?: string | null;
createdAt: string;
worksheet: ApiWorksheet | null;
}
export interface MemberPage {
items: MemberRecord[];
total: number;
page: number;
pageSize: number;
hasNext: boolean;
}
export const memberStats = () => apiRequest<UserStats>('/me/stats');
/** uploadId 在一次操作中固定;401 重发沿用同一 ID,网络失败不自动重复上传。 */
export async function uploadAsset(
filePath: string,
purpose: 'avatar' | 'preview',
) {
return apiRequest<{
id: string;
url: string;
mimeType: string;
sizeBytes: number;
}>(
purpose === 'avatar'
? '/me/avatar-upload'
: '/admin/assets/preview-upload',
{ method: 'POST', upload: { filePath, uploadId: generateUUID() } },
);
}
export function legacyWorksheet(row: ApiWorksheet | null) {
return row
? {
...row,
_id: row.id,
subcategory:
(row as ApiWorksheet & { subcategory?: string | null })
.subcategory || '',
tags: (row as ApiWorksheet & { tags?: string[] }).tags || [],
previewImg: row.previewImg || '',
}
: null;
}
+12
View File
@@ -0,0 +1,12 @@
import { apiRequest } from './api-request';
import type { ApiWorksheet } from './content-api';
/** 保留微信预拉取,动态兜底与后台刷新改由新后端提供。 */
export function fetchPageConfig() {
return apiRequest<PageConfigData>('/page-config', { auth: 'none' });
}
export function fetchAgeRecommendations(ageBand: string) {
return apiRequest<{ ageBand: string; items: ApiWorksheet[] }>(
'/recommendations/age',
{ auth: 'none', data: { ageBand } },
);
}
@@ -1,3 +1,6 @@
import { compareCountDescending } from '../../pages/category/category.model';
import { managementRequest } from '../../services/admin-api';
import { resolvePreviewUrl } from '../../config/assets';
import { CATEGORY_LIST } from '../../core/data/categories';
type WorksheetItem = {
@@ -13,8 +16,8 @@ type WorksheetItem = {
tagsText: string;
status: 'draft' | 'active' | 'hidden';
sortOrder: number;
downloads: number;
likes: number;
downloads: string;
likes: string;
updatedAt: string;
// display helpers
ageBand: string;
@@ -32,13 +35,6 @@ type Stats = {
hidden: number;
};
type CloudFunctionResult<T> = {
success?: boolean;
message?: string;
data?: T;
stats?: Stats;
};
const DIFFICULTY_LABELS: Record<number, string> = {
1: '入门',
2: '基础',
@@ -82,7 +78,7 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
title: String(raw.title || ''),
subtitle: String(raw.subtitle || ''),
category: String(raw.category || ''),
previewImg: String(raw.previewImg || ''),
previewImg: resolvePreviewUrl(raw.previewImg),
ageMin,
ageMax,
difficulty,
@@ -92,8 +88,8 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
: '',
status: status as WorksheetItem['status'],
sortOrder: Number(raw.sortOrder) || 0,
downloads: Number(raw.downloads) || 0,
likes: Number(raw.likes) || 0,
downloads: String(raw.downloads || '0'),
likes: String(raw.likes || '0'),
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
ageBand: `${ageMin}-${ageMax}岁`,
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
@@ -104,17 +100,6 @@ function formatWorksheet(raw: Record<string, unknown>): WorksheetItem {
};
}
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 || {};
}
Page({
data: {
categories: CATEGORY_LIST,
@@ -139,8 +124,8 @@ Page({
this.setData({ loading: true });
try {
const result = await callCloudFunction<WorksheetItem[]>(
'worksheetsQuery',
const result = await managementRequest<WorksheetItem[]>(
'worksheets.list',
{
category: this.data.activeCategoryId,
rawStats: true,
@@ -155,7 +140,9 @@ Page({
.map((raw) =>
formatWorksheet(raw as unknown as Record<string, unknown>),
)
.sort((a, b) => b.downloads - a.downloads);
.sort((a, b) =>
compareCountDescending(a.downloads, b.downloads),
);
this.setData({
loading: false,
@@ -204,7 +191,7 @@ Page({
this.setData({ updatingId: id });
try {
const result = await callCloudFunction('worksheetsUpdateStatus', {
const result = await managementRequest('worksheets.status', {
id,
status: target,
});
@@ -1,3 +1,4 @@
import { managementRequest } from '../../services/admin-api';
import { CATEGORY_LIST } from '../../core/data/categories';
type CloudCategory = {
@@ -23,12 +24,6 @@ type CompareItem = {
cloudCategory: CloudCategory | null;
};
type CloudFunctionResult<T> = {
success?: boolean;
message?: string;
data?: T;
};
const DEFAULT_CATEGORY_COLORS = [
'#F7EE47',
'#93D333',
@@ -133,18 +128,6 @@ function buildCompareItems(cloudCategories: CloudCategory[]): {
};
}
async function callCategoryFunction<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 || {};
}
Page({
data: {
loading: false,
@@ -170,7 +153,7 @@ Page({
try {
const result =
await callCategoryFunction<CloudCategory[]>('categoriesQuery');
await managementRequest<CloudCategory[]>('categories.list');
if (!result.success) {
throw new Error(result.message || '获取分类失败');
@@ -204,7 +187,7 @@ Page({
const confirmed = await new Promise<boolean>((resolve) => {
wx.showModal({
title: `${item.actionText}分类`,
content: `确认将「${item.name}」同步到云端吗?`,
content: `确认将「${item.name}」同步到新后端吗?`,
success: (res) => resolve(!!res.confirm),
fail: () => resolve(false),
});
@@ -228,9 +211,9 @@ Page({
try {
const functionName =
item.status === 'missing'
? 'categoriesCreate'
: 'categoriesUpdate';
const result = await callCategoryFunction<CloudCategory>(
? 'categories.create'
: 'categories.update';
const result = await managementRequest<CloudCategory>(
functionName,
payload,
);
@@ -1,3 +1,7 @@
import { compareCountDescending } from '../../pages/category/category.model';
import { apiRequest } from '../../services/api-request';
import { managementRequest } from '../../services/admin-api';
import { resolvePreviewUrl } from '../../config/assets';
import { CATEGORY_LIST } from '../../core/data/categories';
type SimpleWorksheet = {
@@ -13,8 +17,8 @@ type SimpleWorksheet = {
isHot: boolean;
path: string;
ageBand: string;
downloads: number;
likes: number;
downloads: string;
likes: string;
};
type SectionConfig = {
@@ -43,12 +47,6 @@ type PageConfig = {
[key: string]: unknown;
};
type CloudFunctionResult<T> = {
success?: boolean;
message?: string;
data?: T;
};
function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
const ageMin = Number(raw.ageMin) || 0;
const ageMax = Number(raw.ageMax) || 0;
@@ -57,7 +55,7 @@ function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
title: String(raw.title || ''),
subtitle: String(raw.subtitle || ''),
category: String(raw.category || ''),
previewImg: String(raw.previewImg || ''),
previewImg: resolvePreviewUrl(raw.previewImg),
ageMin,
ageMax,
difficulty: Number(raw.difficulty) || 2,
@@ -65,22 +63,11 @@ function formatSimple(raw: Record<string, unknown>): SimpleWorksheet {
isHot: !!raw.isHot,
path: String(raw.path || ''),
ageBand: `${ageMin}-${ageMax}岁`,
downloads: Number(raw.downloads) || 0,
likes: Number(raw.likes) || 0,
downloads: String(raw.downloads || '0'),
likes: String(raw.likes || '0'),
};
}
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 || {};
}
Page({
data: {
featured: [] as SimpleWorksheet[],
@@ -122,15 +109,15 @@ Page({
async loadTimerStatus() {
try {
const db = wx.cloud.database();
const { data } = await db
.collection('settings')
.doc('homeAutoRefresh')
.get();
this.setData({ timerEnabled: (data as { enabled?: boolean }).enabled !== false });
const data = await apiRequest<{ enabled: boolean }>(
'/admin/site/timer',
);
this.setData({
timerEnabled: (data as { enabled?: boolean }).enabled !== false,
});
} catch {
// Document doesn't exist — default enabled
this.setData({ timerEnabled: true });
// 权限或网络失败不能被当成已启用。
this.setData({ timerEnabled: false });
}
},
@@ -139,13 +126,14 @@ Page({
try {
// Fetch in parallel: active worksheets + saved page config
const [wsResult, configResult] = await Promise.all([
callCloudFunction<SimpleWorksheet[]>('worksheetsQuery', {
managementRequest<SimpleWorksheet[]>('worksheets.list', {
status: 'active',
}),
callCloudFunction<PageConfig>('pageConfigFetch'),
managementRequest<PageConfig>('site.config'),
]);
if (!wsResult.success) throw new Error(wsResult.message || '查询失败');
if (!wsResult.success)
throw new Error(wsResult.message || '查询失败');
const allActive = (wsResult.data || []).map((raw) =>
formatSimple(raw as unknown as Record<string, unknown>),
@@ -156,16 +144,21 @@ Page({
// featured: always top-5 by downloads; hot: always top-5 by likes
const featured: SimpleWorksheet[] = [...allActive]
.sort((a, b) => (b.downloads ?? 0) - (a.downloads ?? 0))
.sort((a, b) =>
compareCountDescending(a.downloads, b.downloads),
)
.slice(0, 5);
const hot: SimpleWorksheet[] = [...allActive]
.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0))
.sort((a, b) => compareCountDescending(a.likes, b.likes))
.slice(0, 5);
// Restore saved sections from config
const savedHome = (configResult.data as PageConfig | undefined)?.home;
const savedHome = (configResult.data as PageConfig | undefined)
?.home;
const savedSections: HomeConfigItem[] = savedHome?.sections || [];
const savedSectionMap = new Map(savedSections.map((s) => [s.id, s]));
const savedSectionMap = new Map(
savedSections.map((s) => [s.id, s]),
);
const sections: SectionConfig[] = CATEGORY_LIST.map((c) => {
const saved = savedSectionMap.get(c.id);
@@ -184,7 +177,13 @@ Page({
};
});
this.setData({ loading: false, allActive, featured, hot, sections });
this.setData({
loading: false,
allActive,
featured,
hot,
sections,
});
} catch (error) {
this.setData({ loading: false });
wx.showToast({
@@ -202,7 +201,7 @@ Page({
this.setData({ timerLoading: true });
try {
const result = await callCloudFunction('homeTimerControl', {
const result = await managementRequest('site.timer', {
enabled: newEnabled,
});
if (!result.success) {
@@ -335,9 +334,9 @@ Page({
this.setData({ building: true });
try {
const result = await callCloudFunction<{
const result = await managementRequest<{
version: number;
}>('pageContentBuild', {
}>('site.rebuild', {
page: 'home',
featuredIds: this.data.featured.map((w) => w._id),
hotIds: this.data.hot.map((w) => w._id),
+6 -19
View File
@@ -1,5 +1,6 @@
import { apiRequest } from '../../services/api-request';
import { disablePageShareMenu } from '../../base/pageMixin';
import { clearUser } from '../../utils/auth';
import { clearUser, refreshUser } from '../../utils/auth';
Page({
onLoad() {
@@ -22,15 +23,8 @@ Page({
async doClearProfile() {
wx.showLoading({ title: '处理中...', mask: true });
try {
const res = await wx.cloud.callFunction({
name: 'userSettings',
data: { action: 'clearProfile' },
});
const result = (res?.result as any) || {};
if (result.code !== 0) {
throw new Error(result.message || '操作失败');
}
clearUser();
await apiRequest('/me/profile', { method: 'DELETE' });
await refreshUser();
wx.hideLoading();
wx.showToast({ title: '已删除', icon: 'success' });
} catch (e) {
@@ -58,15 +52,8 @@ Page({
async doDeleteAccount() {
wx.showLoading({ title: '处理中...', mask: true });
try {
const res = await wx.cloud.callFunction({
name: 'userSettings',
data: { action: 'deleteAccount' },
});
const result = (res?.result as any) || {};
if (result.code !== 0) {
throw new Error(result.message || '操作失败');
}
clearUser();
await apiRequest('/me', { method: 'DELETE' });
clearUser(true);
wx.hideLoading();
wx.reLaunch({ url: '/pages/home/home' });
@@ -1,3 +1,4 @@
import { managementRequest } from '../../services/admin-api';
import { MOCK_SEED_COUNTS } from './mockLikes';
import {
MATH_WORKSHEET_DEFINITIONS,
@@ -21,17 +22,6 @@ type CloudFunctionResult<T> = {
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> = {
@@ -224,7 +214,7 @@ Page({
const startedAt = Date.now();
try {
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
const patchRes = await managementRequest('worksheets.batch', {
patches,
dryRun: false,
});
@@ -262,7 +252,7 @@ Page({
const startedAt = Date.now();
try {
const rebuildRes = await callCloudFunction('pageContentBuild', {
const rebuildRes = await managementRequest('site.rebuild', {
page: 'all',
});
const elapsed = Math.round((Date.now() - startedAt) / 1000);
@@ -358,7 +348,7 @@ Page({
}),
);
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
const patchRes = await managementRequest('worksheets.batch', {
patches,
dryRun: false,
});
@@ -415,8 +405,8 @@ Page({
const startedAt = Date.now();
try {
const queryRes = await callCloudFunction<Array<{ _id: string }>>(
'worksheetsQuery',
const queryRes = await managementRequest<Array<{ _id: string }>>(
'worksheets.list',
{},
);
@@ -438,7 +428,7 @@ Page({
return;
}
const patchRes = await callCloudFunction('worksheetsBatchPatch', {
const patchRes = await managementRequest('worksheets.batch', {
patches,
dryRun: false,
});
+39 -148
View File
@@ -1,165 +1,56 @@
interface UserInfo {
_id: string;
openid: string;
unionid: string | null;
nickName: string | null;
avatarUrl: string | null;
totalDownloads: number;
createdAt: string;
lastActiveAt: string;
}
import { apiRequest } from '../services/api-request';
import {
cachedServerUser,
clearSession,
ensureSession,
getAuthContext,
updateSessionUser,
type ServerUser,
} from '../services/auth-session';
const STORAGE_KEY = 'user_info';
const STORAGE_EXPIRE_KEY = 'user_info_expire';
const EXPIRE_DURATION = 7 * 24 * 60 * 60 * 1000; // 7 天
export type UserInfo = ServerUser;
export { ensureSession as ensureAuthenticated } from '../services/auth-session';
/** 内存中的用户数据(App 生命周期内有效) */
let currentUser: UserInfo | null = null;
/** 登录进行中的 Promise(防止并发重复调用) */
let loginPromise: Promise<UserInfo> | null = null;
function syncAppGlobalUser(user: UserInfo | null): void {
try {
const app = getApp<IAppOption>();
if (app?.globalData) {
app.globalData.user = user;
}
} catch {
// App 尚未初始化时忽略
}
}
function syncUserState(user: UserInfo, persist = true): UserInfo {
currentUser = user;
if (persist) {
saveToStorage(user);
}
syncAppGlobalUser(user);
return user;
}
/**
* 获取当前用户,优先内存 → Storage → 云函数登录
*/
/** 用户摘要与新后端认证绑定,所有远端用户业务只使用本站用户 ID。 */
export async function getUser(): Promise<UserInfo> {
// 1. 内存中有,直接返回
if (currentUser) {
syncAppGlobalUser(currentUser);
return currentUser;
}
// 2. 尝试从 Storage 读取
const cached = loadFromStorage();
if (cached) {
return syncUserState(cached, false);
}
// 3. 静默登录(防止并发)
if (!loginPromise) {
loginPromise = silentLogin().finally(() => {
loginPromise = null;
});
}
return loginPromise;
return (await ensureSession()).user;
}
/**
* 静默登录:调用云函数
*/
async function silentLogin(): Promise<UserInfo> {
const res = await wx.cloud.callFunction({
name: 'userLogin',
});
const result = (res?.result as any) || {};
if (result.code !== 0) {
throw new Error('登录失败');
}
const user = result.data as UserInfo;
return syncUserState(user);
}
/** 缓存只供展示,不能证明服务端仍认可当前凭证。 */
export function getCachedUser(): UserInfo | null {
if (currentUser) {
syncAppGlobalUser(currentUser);
return currentUser;
}
const cached = loadFromStorage();
if (!cached) return null;
return syncUserState(cached, false);
}
/**
* 从 Storage 读取用户数据(检查过期)
*/
function loadFromStorage(): UserInfo | null {
try {
const expire = wx.getStorageSync(STORAGE_EXPIRE_KEY);
if (!expire || Date.now() > expire) {
wx.removeStorageSync(STORAGE_KEY);
wx.removeStorageSync(STORAGE_EXPIRE_KEY);
return null;
}
const user = wx.getStorageSync(STORAGE_KEY);
return user || null;
return cachedServerUser();
} catch {
return null;
}
}
/**
* 写入 Storage
*/
function saveToStorage(user: UserInfo): void {
try {
wx.setStorageSync(STORAGE_KEY, user);
wx.setStorageSync(STORAGE_EXPIRE_KEY, Date.now() + EXPIRE_DURATION);
} catch {
// Storage 写入失败不影响主流程
}
}
/**
* 清除登录状态
*/
export function clearUser(): void {
currentUser = null;
syncAppGlobalUser(null);
wx.removeStorageSync(STORAGE_KEY);
wx.removeStorageSync(STORAGE_EXPIRE_KEY);
}
/**
* 强制刷新用户数据(跳过缓存)
*/
/** 读取本人资料;GET /me 会按统一规则完成过期与 401 恢复。 */
export async function refreshUser(): Promise<UserInfo> {
clearUser();
return await silentLogin();
const context = getAuthContext();
const user = await apiRequest<UserInfo>('/me');
if (getAuthContext().key !== context.key)
throw new Error('接口环境已变化,请重新操作');
return updateSessionUser(user, context);
}
/** 暂停账户自动恢复时保留标记,不能通过清缓存自动注册。 */
export function clearUser(blocked = false): void {
clearSession(blocked);
}
/** 仅供用户主动确认“重新开始使用账户”的动作调用,禁止在失败回调自动调用。 */
export async function startAccount(): Promise<UserInfo> {
return (await ensureSession(getAuthContext(), true, true)).user;
}
/** 本人资料更新只提交头像资产 ID,不允许传任意外链或用户身份字段。 */
export async function updateUserProfile(payload: {
nickName?: string | null;
avatarUrl?: string | null;
nickName?: string;
avatarAssetId?: string | null;
}): Promise<UserInfo> {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userLogin',
data: {
action: 'updateProfile',
...payload,
},
const context = getAuthContext();
const user = await apiRequest<UserInfo>('/me/profile', {
method: 'PUT',
data: payload,
});
const result = (res?.result as any) || {};
if (result.code !== 0 || !result.data) {
throw new Error(result.message || '更新用户资料失败');
}
return syncUserState(result.data as UserInfo);
if (getAuthContext().key !== context.key)
throw new Error('接口环境已变化,请重新操作');
return updateSessionUser(user, context);
}
export type { UserInfo };
+5 -7
View File
@@ -1,7 +1,5 @@
import type {
WorksheetCloudCategory,
WorksheetStatus,
} from '../core/models/worksheet';
import type { CategoryId } from '../core/models/category';
import type { WorksheetConfig } from '../core/models/worksheet';
/**
* 调试发布时的裁剪模式。
@@ -19,7 +17,7 @@ export interface DebugPublishMeta {
id: string;
title: string;
subtitle: string;
category: WorksheetCloudCategory;
category: CategoryId;
subcategory?: string;
path: string;
ageMin: number;
@@ -31,7 +29,7 @@ export interface DebugPublishMeta {
isNew: boolean;
isHot: boolean;
sortOrder?: number;
status: WorksheetStatus;
status: WorksheetConfig['status'];
}
/** 发布弹窗里可调的图片处理参数。 */
@@ -118,7 +116,7 @@ export function getCropRatios(mode: DebugCropMode): {
/** 统一构造预览图在云存储中的路径。 */
export function buildWorksheetPreviewCloudPath(
category: WorksheetCloudCategory,
category: CategoryId,
id: string,
): string {
return `assets/previews/${category}/${id}.jpg`;
+27 -48
View File
@@ -1,10 +1,16 @@
import { getUser } from './auth';
import { apiRequest } from '../services/api-request';
import {
legacyWorksheet,
memberStats,
type MemberPage,
} from '../services/member-api';
import { generateUUID } from './uuid';
export interface DownloadLogRecord {
_id: string;
userId: string;
worksheetId: string;
exportId?: string;
exportId?: string | null;
createdAt: string;
worksheet?: {
_id: string;
@@ -20,66 +26,39 @@ export interface DownloadLogRecord {
} | null;
}
/**
* 记录下载日志
*/
/** 一次成功保存沿用同一个 exportId,服务端保证记录及下载计数幂等。 */
export async function addDownloadLog(
worksheetId: string,
exportId?: string,
exportId = generateUUID(),
expectedUserId?: string,
): Promise<boolean> {
if (!worksheetId) return false;
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userDownloadLogs',
data: { action: 'add', worksheetId, exportId },
const user = await getUser();
if (expectedUserId && user.id !== expectedUserId) return false;
await apiRequest('/me/downloads', {
method: 'POST',
data: { worksheetId, exportId },
});
const result = (res?.result as any) || {};
return result.code === 0;
} catch (e) {
console.error('addDownloadLog failed', e);
return true;
} catch {
return false;
}
}
/** 收藏页下载历史展示条数上限 */
export const DOWNLOAD_HISTORY_MAX = 30;
/**
* 获取下载历史(默认最近 30 条)
*/
export async function getDownloadLogs(
page = 1,
pageSize = DOWNLOAD_HISTORY_MAX,
): Promise<DownloadLogRecord[]> {
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userDownloadLogs',
data: { action: 'list', page, pageSize },
});
const result = (res?.result as any) || {};
return result.code === 0 ? result.data : [];
} catch (e) {
console.error('getDownloadLogs failed', e);
return [];
}
const result = await apiRequest<MemberPage>('/me/downloads', {
data: { page, pageSize },
});
return result.items.map((row) => ({
...row,
_id: row.id,
worksheet: legacyWorksheet(row.worksheet),
}));
}
/**
* 获取当前用户下载(打印)总数
*/
export async function getDownloadLogCount(): Promise<number> {
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userDownloadLogs',
data: { action: 'count' },
});
const result = (res?.result as any) || {};
return result.code === 0 ? Number(result.data?.count || 0) : 0;
} catch (e) {
console.error('getDownloadLogCount failed', e);
return 0;
}
return (await memberStats()).downloadCount;
}
+10 -2
View File
@@ -1,3 +1,4 @@
import { getCachedUser } from './auth';
/**
* 下载打印通用工具
* 支持下载次数限制和激励广告
@@ -67,7 +68,9 @@ function showVideoAd(): Promise<boolean> {
mask: true,
});
const onClose: WechatMiniprogram.RewardedVideoAdOnCloseCallback = (res) => {
const onClose: WechatMiniprogram.RewardedVideoAdOnCloseCallback = (
res,
) => {
videoAd.offClose(onClose);
wx.hideLoading();
resolve(!!(res && res.isEnded));
@@ -137,6 +140,7 @@ function doDownload(
},
): Promise<boolean> {
const exportId = generateUUID();
const expectedUserId = getCachedUser()?.id;
const trackerPage = options.trackerPage || options.trackerName || '';
const trackerParams = {
export_id: exportId,
@@ -154,7 +158,11 @@ function doDownload(
recordDownloadSuccess();
tracker.reportExportResult(trackerPage, true, trackerParams);
if (options.worksheetId) {
await addDownloadLog(options.worksheetId, exportId);
await addDownloadLog(
options.worksheetId,
exportId,
expectedUserId,
);
}
} else {
tracker.reportExportResult(trackerPage, false, trackerParams);
+38 -102
View File
@@ -1,14 +1,13 @@
import { getUser } from './auth';
import { apiRequest } from '../services/api-request';
import {
legacyWorksheet,
memberStats,
type MemberPage,
} from '../services/member-api';
import tracker from './tracker';
function getCurrentPageRoute(): string {
const pages = getCurrentPages();
return pages[pages.length - 1]?.route || '';
}
export interface FavoriteRecord {
_id: string;
userId: string;
worksheetId: string;
createdAt: string;
worksheet?: {
@@ -26,122 +25,59 @@ export interface FavoriteRecord {
} | null;
}
/**
* 添加收藏
*/
/** 收藏操作使用新后端当前账户;调用失败不能向其他用户补发。 */
export async function addFavorite(worksheetId: string): Promise<boolean> {
try {
await getUser(); // 确保已登录
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'add', worksheetId },
await apiRequest(`/me/favorites/${encodeURIComponent(worksheetId)}`, {
method: 'PUT',
});
const result = (res?.result as any) || {};
const success = result.code === 0;
if (success) tracker.reportFavorite(getCurrentPageRoute(), worksheetId, true);
return success;
} catch (e) {
console.error('addFavorite failed', e);
tracker.reportFavorite(currentRoute(), worksheetId, true);
return true;
} catch {
return false;
}
}
/**
* 取消收藏
*/
export async function removeFavorite(worksheetId: string): Promise<boolean> {
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'remove', worksheetId },
await apiRequest(`/me/favorites/${encodeURIComponent(worksheetId)}`, {
method: 'DELETE',
});
const result = (res?.result as any) || {};
const success = result.code === 0;
if (success) {
tracker.reportFavorite(getCurrentPageRoute(), worksheetId, false);
}
return success;
} catch (e) {
console.error('removeFavorite failed', e);
tracker.reportFavorite(currentRoute(), worksheetId, false);
return true;
} catch {
return false;
}
}
/**
* 获取收藏列表
*/
export async function getFavoriteList(
page = 1,
pageSize = 20,
): Promise<FavoriteRecord[]> {
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'list', page, pageSize },
});
const result = (res?.result as any) || {};
return result.code === 0 ? result.data || [] : [];
} catch (e) {
console.error('getFavoriteList failed', e);
return [];
}
const result = await apiRequest<MemberPage>('/me/favorites', {
data: { page, pageSize },
});
return result.items.map((row) => ({
...row,
_id: row.id,
worksheet: legacyWorksheet(row.worksheet),
}));
}
/**
* 获取当前用户收藏总数
*/
export async function getFavoriteCount(): Promise<number> {
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'count' },
});
const result = (res?.result as any) || {};
return result.code === 0 ? Number(result.data?.count || 0) : 0;
} catch (e) {
console.error('getFavoriteCount failed', e);
return 0;
}
return (await memberStats()).favoriteCount;
}
/**
* 检查是否已收藏
*/
export async function checkFavorited(worksheetId: string): Promise<boolean> {
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'check', worksheetId },
});
const result = (res?.result as any) || {};
return result.code === 0 && result.data?.favorited;
} catch (e) {
console.error('checkFavorited failed', e);
return false;
}
}
/**
* 批量检查是否已收藏,返回 { worksheetId: true } 的 map
*/
export async function batchCheckFavorited(
worksheetIds: string[],
): Promise<Record<string, boolean>> {
if (!worksheetIds.length) return {};
try {
await getUser();
const res = await wx.cloud.callFunction({
name: 'userFavorites',
data: { action: 'batchCheck', worksheetIds },
});
const result = (res?.result as any) || {};
return result.code === 0 ? result.data || {} : {};
} catch (e) {
console.error('batchCheckFavorited failed', e);
return {};
}
return apiRequest('/me/favorites/check', {
method: 'POST',
data: { worksheetIds },
});
}
export async function checkFavorited(worksheetId: string): Promise<boolean> {
return (await batchCheckFavorited([worksheetId]))[worksheetId] === true;
}
function currentRoute() {
const pages = getCurrentPages();
return pages[pages.length - 1]?.route || '';
}
-31
View File
@@ -1,31 +0,0 @@
type WorksheetStatsField = 'likes' | 'downloads';
export async function updateWorksheetStats(
id: string,
field: WorksheetStatsField,
): Promise<boolean> {
const worksheetId = String(id || '').trim();
if (!worksheetId) return false;
try {
const { result } = await wx.cloud.callFunction({
name: 'worksheetsStatsUpdate',
data: {
id: worksheetId,
field,
},
});
return !!(result as { success?: boolean } | undefined)?.success;
} catch (error) {
console.error('update worksheet stats failed', field, worksheetId, error);
return false;
}
}
export function incrementWorksheetLikes(id: string): Promise<boolean> {
return updateWorksheetStats(id, 'likes');
}
export function incrementWorksheetDownloads(id: string): Promise<boolean> {
return updateWorksheetStats(id, 'downloads');
}