feat: 我的、设置、打印指南页面开发完成

This commit is contained in:
R524809
2026-05-07 16:32:49 +08:00
parent 0737c6165e
commit c732111e12
43 changed files with 1702 additions and 837 deletions
@@ -18,6 +18,8 @@ export type CategoryItem = {
downloads: number;
/** 列表底部展示的日期(静态数据用 id 派生稳定值) */
date: string;
/** 最后更新时间(云端数据用于排序) */
updatedAt?: string;
};
export type CategoryGroup = {
+99 -27
View File
@@ -17,6 +17,13 @@ type CategoryDataset = {
}>;
};
const DIFFICULTY_LABELS: Record<number, string> = {
1: '入门',
2: '基础',
3: '进阶',
4: '挑战',
};
const SIDEBAR_TABS: CategoryTab[] = CATEGORY_LIST_WITH_ALL.map(
({ id, name, icon }) => ({
id,
@@ -33,9 +40,74 @@ const TAB_BAR_PATHS = new Set([
'/pages/profile/profile',
]);
// Page-level mutable state (shared across methods, set once on load)
// Page-level mutable state (shared across methods)
let _categoryData: CategoryDataset | null = null;
/** 将云端 worksheet 原始数据转换为 CategoryItem */
function toDisplayItem(raw: Record<string, any>): CategoryItem {
const difficulty = (Number(raw.difficulty) || 2) as 1 | 2 | 3 | 4;
const ageMin = Number(raw.ageMin) || 0;
const ageMax = Number(raw.ageMax) || 0;
return {
id: String(raw._id || ''),
title: String(raw.title || ''),
subtitle: String(raw.subtitle || ''),
previewImg: String(raw.previewImg || ''),
ageBand: `${ageMin}-${ageMax}`,
ageMin,
ageMax,
difficulty,
difficultyLabel: DIFFICULTY_LABELS[difficulty] || '基础',
path: String(raw.path || ''),
available: true,
likes: Number(raw.likes) || 0,
downloads: Number(raw.downloads) || 0,
date: raw.updatedAt
? new Date(raw.updatedAt).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10),
updatedAt: raw.updatedAt ? String(raw.updatedAt) : '',
};
}
/** 排序:updatedAt 最新的 2 个置顶,其余按 downloads 降序 */
function sortWorksheets(items: CategoryItem[]): CategoryItem[] {
const byTime = [...items].sort(
(a, b) =>
new Date(b.updatedAt || '').getTime() -
new Date(a.updatedAt || '').getTime(),
);
const recent = byTime.slice(0, 2);
const recentIds = new Set(recent.map((w) => w.id));
const rest = byTime
.filter((w) => !recentIds.has(w.id))
.sort((a, b) => (b.downloads || 0) - (a.downloads || 0));
return [...recent, ...rest];
}
/** 将云端 worksheet 数组按分类分组,构建 CategoryDataset */
function buildCategoryDataFromCloud(
rawList: Record<string, any>[],
): CategoryDataset {
const itemsByCategory: Record<string, CategoryItem[]> = {};
for (const raw of rawList) {
const item = toDisplayItem(raw);
const cat = String(raw.category || '');
if (!itemsByCategory[cat]) itemsByCategory[cat] = [];
itemsByCategory[cat].push(item);
}
const categories = CATEGORY_LIST_WITH_ALL.filter((c) => c.id !== 'all').map(
(cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
items: sortWorksheets(itemsByCategory[cat.id] || []),
}),
);
return { searchPlaceholder: '搜索练习纸...', categories };
}
function buildAllItems(): CategoryItem[] {
if (!_categoryData) return [];
const items: CategoryItem[] = [];
@@ -44,7 +116,7 @@ function buildAllItems(): CategoryItem[] {
items.push({ ...item });
}
}
return items;
return sortWorksheets(items);
}
function getItemsByCategory(categoryId: string): CategoryItem[] {
@@ -69,7 +141,7 @@ function filterByKeyword(
}
// Skeleton placeholder items for loading state
const SKELETON_ITEMS = Array.from({ length: 4 }, (_, i) => ({
const SKELETON_ITEMS = Array.from({ length: 6 }, (_, i) => ({
id: `skeleton-${i}`,
title: '\u00A0',
subtitle: '\u00A0',
@@ -87,45 +159,45 @@ Page({
loading: true,
},
_defaultCategoryId: 'all',
onLoad(options: Record<string, string>) {
void this.initData(options);
if (options.id && options.id !== 'all') {
this._defaultCategoryId = options.id;
this.setData({ activeCategoryId: options.id });
}
},
async initData(options: Record<string, string>) {
wx.showLoading({ title: '加载中', mask: false });
onShow() {
_categoryData = null;
this.setData({ displayItems: SKELETON_ITEMS, loading: true });
void this.loadWorksheets();
},
const app = getApp<IAppOption>();
const config = await app.getPageConfig();
async loadWorksheets() {
try {
const res = await wx.cloud.callFunction({
name: 'worksheetsQuery',
data: { status: 'active' },
});
const result = (res?.result as any) || {};
if (!result.success) throw new Error(result.message || '查询失败');
// Use cloud config if available, otherwise fallback to local static data
if (config?.category?.categories?.length) {
_categoryData = {
searchPlaceholder:
config.category.searchPlaceholder || '搜索练习纸...',
categories: config.category.categories.map((cat) => ({
id: cat.id,
name: cat.name,
icon: cat.icon,
items: (cat.items || []).map(
(raw) => raw as unknown as CategoryItem,
),
})),
};
} else {
_categoryData = buildCategoryDataFromCloud(result.data || []);
} catch {
_categoryData = CATEGORY_DATA;
}
const activeId = options.id && options.id !== 'all' ? options.id : 'all';
const activeId = this.data.activeCategoryId || this._defaultCategoryId;
const items = getItemsByCategory(activeId);
const filtered = filterByKeyword(items, this.data.searchKeyword);
this.setData({
loading: false,
searchPlaceholder: _categoryData.searchPlaceholder,
activeCategoryId: activeId,
displayItems: items,
displayItems: filtered,
});
wx.hideLoading();
},
onTapCategory(e: WechatMiniprogram.TouchEvent) {
+2 -2
View File
@@ -243,7 +243,7 @@
.fav-cta__btn {
margin-top: 32rpx;
padding: 22rpx 64rpx;
padding: 24rpx 52rpx;
border-radius: 999rpx;
font-size: 26rpx;
font-weight: 700;
@@ -425,7 +425,7 @@
.fav-empty__sub {
font-size: 26rpx;
color: @text-secondary;
margin-bottom: 40rpx;
margin-bottom: 32rpx;
text-align: center;
}
+14 -9
View File
@@ -31,11 +31,15 @@
aria-hidden="true"></text>
</view>
<view wx:elif="{{favoriteList.length === 0}}" class="fav-empty">
<text class="fav-empty__emoji">🦆</text>
<text class="fav-empty__msg">鸭丫还没找到收藏呢</text>
<toy-icon
name="mood-empty"
size="100rpx"
color="#b3ac9f"
class="fav-empty__icon" />
<text class="fav-empty__msg">还没找到收藏呢</text>
<text class="fav-empty__sub">去发现好玩的练习纸吧~</text>
<button class="fav-empty__btn" bindtap="onDiscoverTap">
去发现
<button class="fav-cta__btn" bindtap="onDiscoverTap">
去发现页看看
</button>
</view>
<view wx:elif="{{favoriteList.length > 0}}" class="fav-list">
@@ -112,14 +116,15 @@
<view
wx:elif="{{downloadSections.length === 0}}"
class="fav-empty">
<van-icon
name="clock"
size="120rpx"
<toy-icon
name="mood-empty"
size="100rpx"
color="#b3ac9f"
class="fav-empty__icon" />
<text class="fav-empty__msg">暂无下载记录</text>
<button class="fav-empty__btn" bindtap="onDiscoverTap">
去发现
<text class="fav-empty__sub">去发现好玩的练习纸吧~</text>
<button class="fav-cta__btn" bindtap="onDiscoverTap">
去发现页看看
</button>
</view>
<view wx:elif="{{downloadSections.length > 0}}" class="fav-dl">
+3 -4
View File
@@ -24,8 +24,8 @@
<swiper
class="home-hero-swiper"
previous-margin="100rpx"
next-margin="100rpx"
previous-margin="80rpx"
next-margin="80rpx"
circular="{{featuredItems.length > 1 && !loading}}"
autoplay="{{!loading}}"
interval="{{4000}}"
@@ -200,8 +200,7 @@
path="{{item.path}}"
data-title="{{item.title}}"
data-path="{{item.path}}"
bindtap="onTapCard"
/>
bindtap="onTapCard" />
</view>
</scroll-view>
</view>
+3 -1
View File
@@ -6,6 +6,8 @@
"backgroundColor": "#F8F0E0",
"usingComponents": {
"van-icon": "@vant/weapp/icon/index",
"nav-bar": "../../components3.0/nav-bar/nav-bar"
"van-popup": "@vant/weapp/popup/index",
"nav-bar": "../../components3.0/nav-bar/nav-bar",
"toy-icon": "../../toy/icon/icon"
}
}
+144 -4
View File
@@ -57,8 +57,8 @@ page {
}
.profile-hero__avatar {
width: 256rpx;
height: 256rpx;
width: 224rpx;
height: 224rpx;
border-radius: 50%;
border: 8rpx solid @bg-white;
background: @bg-gray;
@@ -98,7 +98,7 @@ page {
.profile-hero__name {
margin-top: 32rpx;
font-size: 40rpx;
font-size: 36rpx;
font-weight: 700;
color: @text-title;
text-align: center;
@@ -116,7 +116,7 @@ page {
.profile-hero__stat {
flex: 1;
background: @bg-header;
border-radius: @radius;
border-radius: 48rpx;
border: 1rpx solid rgba(124, 118, 106, 0.12);
padding: 32rpx 24rpx;
text-align: center;
@@ -321,4 +321,144 @@ page {
color: @text-secondary;
letter-spacing: 0.2em;
text-transform: uppercase;
}
.profile-editor-popup {
border-radius: 40rpx 40rpx 0 0;
overflow: hidden;
}
.profile-editor {
overflow-y: auto;
padding: 32rpx 40rpx;
padding-bottom: env(safe-area-inset-bottom);
background: @bg-white;
box-sizing: border-box;
}
.profile-editor__header {
margin-bottom: 24rpx;
}
.profile-editor__title {
display: block;
font-size: 36rpx;
font-weight: 700;
color: @text-title;
}
.profile-editor__desc {
display: block;
margin-top: 12rpx;
font-size: 24rpx;
line-height: 1.5;
color: @text-secondary;
}
.profile-editor__avatar-section {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-bottom: 28rpx;
}
.profile-editor__avatar-button {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
background: transparent;
border: 0;
line-height: 1;
}
.profile-editor__avatar-button::after {
border: 0;
}
.profile-editor__avatar {
width: 180rpx;
height: 180rpx;
border-radius: 50%;
background: @bg-gray;
border: 2rpx dashed fade(@text-secondary, 30%);
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
}
.profile-editor__avatar-placeholder {
width: 120rpx;
font-size: 24rpx;
line-height: 1.4;
color: @text-secondary;
text-align: center;
}
.profile-editor__avatar-img {
width: 100%;
height: 100%;
}
.profile-editor__field {
margin-bottom: 28rpx;
}
.profile-editor__label {
display: block;
margin-bottom: 16rpx;
font-size: 26rpx;
font-weight: 600;
color: @text-title;
}
.profile-editor__input {
width: 100%;
height: 96rpx;
padding: 0 28rpx;
border-radius: 24rpx;
background: @bg-header;
border: 1rpx solid rgba(124, 118, 106, 0.12);
box-sizing: border-box;
font-size: 30rpx;
color: @text-title;
}
.profile-editor__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 24rpx;
padding-top: 20rpx;
}
.profile-editor__action {
height: 92rpx;
border-radius: 24rpx;
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
font-size: 30rpx;
font-weight: 700;
}
.profile-editor__action--ghost {
background: @bg-header;
color: @text-title;
border: 1rpx solid rgba(124, 118, 106, 0.12);
}
.profile-editor__action--primary {
background: @brand;
color: @text-selected-btn;
box-shadow: @shadow;
}
.profile-editor__action--disabled {
opacity: 0.6;
}
+137 -14
View File
@@ -1,31 +1,154 @@
import { getTodayDownloadCount } from '../../utils/downloadPrint';
import { getDownloadLogCount } from '../../utils/downloadLogs';
import { getFavoriteCount } from '../../utils/favorites';
import {
getCachedUser,
getUser,
type UserInfo,
updateUserProfile,
} from '../../utils/auth';
Page({
DEFAULT_AVATAR_URL: '/assets/imgs/doodle-head.png',
data: {
userName: '微信用户',
avatarUrl: '',
printCount: 0,
favoriteCount: 0,
version: '',
isDevEnv: false,
version: '3.0.0',
showProfileEditor: false,
draftNickName: '',
draftAvatarUrl: '/assets/imgs/doodle-head.png',
isSavingProfile: false,
},
onLoad() {
const info = wx.getAccountInfoSync();
const version = info.miniProgram.version || '开发版';
const isDevEnv = info.miniProgram.envVersion === 'develop';
this.setData({ version, isDevEnv });
this.syncUserInfoFromApp();
},
onShow() {
this.refreshStats();
this.syncUserInfoFromApp();
void this.refreshStats();
},
refreshStats() {
const printCount = getTodayDownloadCount();
// 收藏数待与收藏页数据源打通后接入
const favoriteCount = 0;
this.setData({ printCount, favoriteCount });
async syncUserInfoFromApp() {
const app = getApp<IAppOption>();
const cachedUser = app.globalData.user || getCachedUser();
if (cachedUser) {
this.applyUserInfo(cachedUser);
}
try {
const latestUser = await getUser();
this.applyUserInfo(latestUser);
} catch (error) {
console.error('获取用户信息失败', error);
}
},
applyUserInfo(user: UserInfo) {
const userName = user.nickName?.trim() || '微信用户';
const avatarUrl =
user.avatarUrl || (this as any).DEFAULT_AVATAR_URL || '';
this.setData({
userName,
avatarUrl,
draftNickName: userName === '微信用户' ? '' : userName,
draftAvatarUrl: avatarUrl,
});
},
async refreshStats() {
try {
const [printCount, favoriteCount] = await Promise.all([
getDownloadLogCount(),
getFavoriteCount(),
]);
this.setData({ printCount, favoriteCount });
} catch (e) {
console.error('refreshStats failed', e);
}
},
onOpenProfileEditor() {
this.setData({
showProfileEditor: true,
draftNickName:
this.data.userName === '微信用户' ? '' : this.data.userName,
draftAvatarUrl: this.data.avatarUrl,
});
},
onCloseProfileEditor() {
if (this.data.isSavingProfile) return;
this.setData({ showProfileEditor: false });
},
onChooseAvatar(e: WechatMiniprogram.CustomEvent<{ avatarUrl?: string }>) {
const avatarUrl = e.detail?.avatarUrl || '';
if (!avatarUrl) return;
this.setData({ draftAvatarUrl: avatarUrl });
},
onNicknameInput(e: WechatMiniprogram.CustomEvent<{ value: string }>) {
const nickName = (e.detail?.value || '').trim();
this.setData({ draftNickName: nickName });
},
async onSubmitProfile() {
if (this.data.isSavingProfile) return;
const nickName = this.data.draftNickName.trim();
if (!nickName) {
wx.showToast({ title: '请输入昵称', icon: 'none' });
return;
}
if (!this.data.draftAvatarUrl) {
wx.showToast({ title: '请选择头像', icon: 'none' });
return;
}
this.setData({ isSavingProfile: true });
try {
const avatarUrl = await this.persistAvatarIfNeeded(
this.data.draftAvatarUrl,
);
const user = await updateUserProfile({
nickName,
avatarUrl,
});
this.applyUserInfo(user);
this.setData({ showProfileEditor: false });
wx.showToast({ title: '资料已更新', icon: 'success' });
} catch (error) {
console.error('更新用户资料失败', error);
wx.showToast({ title: '更新失败,请稍后重试', icon: 'none' });
} finally {
this.setData({ isSavingProfile: false });
}
},
async persistAvatarIfNeeded(avatarUrl: string): Promise<string> {
if (!avatarUrl) return avatarUrl;
if (
avatarUrl.startsWith('cloud://') ||
avatarUrl.startsWith('http://') ||
avatarUrl.startsWith('https://')
) {
return avatarUrl;
}
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;
},
onGoToDownloads() {
@@ -47,7 +170,7 @@ Page({
},
onGoToSettings() {
wx.showToast({ title: '功能开发中', icon: 'none' });
wx.navigateTo({ url: '/supportPages/settings/settings' });
},
onGoToDebug() {
+95 -20
View File
@@ -8,19 +8,14 @@
<view class="profile-hero__tint"></view>
<view class="profile-hero__avatar-wrap">
<view class="profile-hero__avatar">
<text
class="profile-hero__avatar-emoji"
wx:if="{{!avatarUrl}}"
>🦆</text
>
<image
wx:else
class="profile-hero__avatar-img"
src="{{avatarUrl}}"
mode="aspectFill" />
</view>
<view
class="profile-hero__edit-badge"
bindtap="onOpenProfileEditor"
aria-role="button"
aria-label="编辑头像">
<van-icon name="edit" size="28rpx" color="#6c5a00" />
@@ -73,9 +68,13 @@
</view>
<text class="profile-row__label">打印指南</text>
</view>
<text class="profile-row__chevron"></text>
<toy-icon
class="profile-row__chevron"
name="arrow-right"
size="40rpx"
color="#9a958b" />
</view>
<view class="profile-row" bindtap="onGoToFeedback">
<!-- <view class="profile-row" bindtap="onGoToFeedback">
<view class="profile-row__left">
<view class="profile-row__icon-bg">
<van-icon
@@ -85,8 +84,12 @@
</view>
<text class="profile-row__label">提交反馈</text>
</view>
<text class="profile-row__chevron"></text>
</view>
<toy-icon
class="profile-row__chevron"
name="arrow-right"
size="40rpx"
color="#9a958b" />
</view> -->
<view class="profile-row" bindtap="onGoToSettings">
<view class="profile-row__left">
<view class="profile-row__icon-bg">
@@ -97,7 +100,11 @@
</view>
<text class="profile-row__label">设置</text>
</view>
<text class="profile-row__chevron"></text>
<toy-icon
class="profile-row__chevron"
name="arrow-right"
size="40rpx"
color="#9a958b" />
</view>
<view
wx:if="{{isDevEnv}}"
@@ -109,9 +116,13 @@
</view>
<text class="profile-row__label">Debug 工具</text>
</view>
<text class="profile-row__chevron"></text>
<toy-icon
class="profile-row__chevron"
name="arrow-right"
size="40rpx"
color="#9a958b" />
</view>
<view
<!-- <view
class="profile-row profile-row--last"
bindtap="onGoToAbout">
<view class="profile-row__left">
@@ -123,12 +134,16 @@
</view>
<text class="profile-row__label">关于涂鸦丫</text>
</view>
<text class="profile-row__chevron"></text>
</view>
<toy-icon
class="profile-row__chevron"
name="arrow-right"
size="40rpx"
color="#9a958b" />
</view> -->
</view>
<!-- 专业版推广(Stitch 深色卡) -->
<view class="profile-upsell" bindtap="onGoToPremium">
<!-- <view class="profile-upsell" bindtap="onGoToPremium">
<van-icon
name="gem-o"
class="profile-upsell__deco"
@@ -141,13 +156,73 @@
>
<view class="profile-upsell__btn">立即升级</view>
</view>
</view>
</view> -->
<view class="profile-version">
<text class="profile-version__text"
>Doodle Mini v{{version}}</text
>
<text class="profile-version__text">涂鸦丫 v{{version}}</text>
</view>
</view>
</view>
<van-popup
show="{{showProfileEditor}}"
position="bottom"
round
safe-area-inset-bottom="{{false}}"
custom-class="profile-editor-popup"
bind:close="onCloseProfileEditor">
<view class="profile-editor">
<view class="profile-editor__header">
<text class="profile-editor__title">完善资料</text>
<text class="profile-editor__desc"
>选择头像并填写昵称后保存</text
>
</view>
<view class="profile-editor__avatar-section">
<button
class="profile-editor__avatar-button"
open-type="chooseAvatar"
bindchooseavatar="onChooseAvatar">
<view class="profile-editor__avatar">
<text
class="profile-editor__avatar-placeholder"
wx:if="{{!draftAvatarUrl}}"
>选择头像</text
>
<image
wx:else
class="profile-editor__avatar-img"
src="{{draftAvatarUrl}}"
mode="aspectFill" />
</view>
</button>
</view>
<view class="profile-editor__field">
<text class="profile-editor__label">昵称</text>
<input
class="profile-editor__input"
type="nickname"
placeholder="请输入昵称"
maxlength="20"
value="{{draftNickName}}"
bindblur="onNicknameInput"
bindinput="onNicknameInput" />
</view>
<view class="profile-editor__actions">
<view
class="profile-editor__action profile-editor__action--ghost"
bindtap="onCloseProfileEditor">
取消
</view>
<view
class="profile-editor__action profile-editor__action--primary {{isSavingProfile ? 'profile-editor__action--disabled' : ''}}"
bindtap="onSubmitProfile">
{{isSavingProfile ? '保存中...' : '保存'}}
</view>
</view>
</view>
</van-popup>
</view>